diff --git a/.changelog/5869.added b/.changelog/5869.added new file mode 100644 index 00000000000..2c7542437d3 --- /dev/null +++ b/.changelog/5869.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: document every hand-written module, class and callable diff --git a/docs/conf.py b/docs/conf.py index d411c975b3c..a3ab8fc2d22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -117,6 +117,34 @@ "py:class", "opentelemetry.sdk.metrics._internal.instrument._Asynchronous", ), + ( + "py:class", + "opentelemetry.sdk.trace._TracerConfig", + ), + ( + "py:class", + "opentelemetry.sdk.metrics._internal._MeterConfig", + ), + ( + "py:class", + "opentelemetry.sdk.metrics._internal._ProxyMeterConfig", + ), + ( + "py:class", + "opentelemetry.sdk.metrics._internal.measurement_consumer.MeasurementConsumer", + ), + ( + "py:class", + "opentelemetry.sdk._logs._internal._LoggerConfig", + ), + ( + "py:class", + "opentelemetry.sdk._logs._internal._logger_metrics.LoggerMetricsT", + ), + ( + "py:class", + "opentelemetry.semconv._incubating.attributes.otel_attributes.OtelComponentTypeValues", + ), # Even if wrapt is added to intersphinx_mapping, sphinx keeps failing # with "class reference target not found: ObjectProxy". ("py:class", "ObjectProxy"), diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 2516e14146a..f6bd3b7bef6 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -2,8 +2,27 @@ # SPDX-License-Identifier: Apache-2.0 # +# pylint: disable=too-many-lines """ OpenTelemetry SDK Configurator for Easy Instrumentation with Distros + +This module builds and installs the three global providers from environment +variables alone, so that an application needs no setup code of its own. It is +what the ``opentelemetry-instrument`` command runs, through the +``opentelemetry_configurator`` entry point, and what a distribution subclasses +when it wants different defaults. + +Each component is named in an environment variable and resolved through an +entry point group: an exporter through ``opentelemetry_traces_exporter`` and +its metric and log counterparts, a sampler through +``opentelemetry_traces_sampler``, an id generator through +``opentelemetry_id_generator``. A name that no entry point provides is a +``RuntimeError``, except where a function below says it logs a warning and +falls back instead. + +Everything here is private to the SDK. A distribution should subclass +``_OTelSDKConfigurator`` rather than call these functions, and the names may +change without a deprecation period. """ from __future__ import annotations @@ -103,14 +122,67 @@ class _ConfigurationExporterSpanProcessorT(Protocol): - def __call__(self, span_exporter: SpanExporter, *args, **kwargs) -> SpanProcessor: ... + """What ``_init_tracing`` accepts in place of ``BatchSpanProcessor``. + + A distribution passes something matching this to wrap each configured + span exporter in a processor of its own choosing. + """ + + def __call__(self, span_exporter: SpanExporter, *args, **kwargs) -> SpanProcessor: + """Wrap one span exporter in a span processor. + + Args: + span_exporter: The exporter to wrap, already constructed. + *args: Extra positional arguments. ``_init_tracing`` passes none. + **kwargs: Extra keyword arguments. ``_init_tracing`` passes none. + + Returns: + The processor to register on the tracer provider. + """ + ... # pylint: disable=unnecessary-ellipsis class _ConfigurationExporterLogRecordProcessorT(Protocol): - def __call__(self, exporter: LogRecordExporter, *args, **kwargs) -> LogRecordProcessor: ... + """What ``_init_logging`` accepts in place of ``BatchLogRecordProcessor``. + + A distribution passes something matching this to wrap each configured log + record exporter in a processor of its own choosing. + """ + + def __call__(self, exporter: LogRecordExporter, *args, **kwargs) -> LogRecordProcessor: + """Wrap one log record exporter in a log record processor. + + Args: + exporter: The exporter to wrap, already constructed. + *args: Extra positional arguments. ``_init_logging`` passes none. + **kwargs: Extra keyword arguments. ``_init_logging`` passes none. + + Returns: + The processor to register on the logger provider. + """ + ... # pylint: disable=unnecessary-ellipsis def _import_config_components(selected_components: Sequence[str], entry_point_name: str) -> list[tuple[str, type]]: + """Load each named component from one entry point group. + + Args: + selected_components: The entry point names to load, in order. Each + one is looked up as it is given, so a caller that accepts + whitespace around a name has to strip it first. + entry_point_name: The entry point group to look in, for example + ``opentelemetry_traces_exporter``. + + Returns: + One pair per requested name, in the order requested, holding the name + and the object the entry point loaded. The object is not checked + against any interface here; the caller does that. + + Raises: + RuntimeError: If the group does not exist, or if a requested name is + not in it. A name that resolves to several entry points takes the + first. + """ component_implementations = [] for selected_component in selected_components: @@ -133,26 +205,84 @@ def _import_config_components(selected_components: Sequence[str], entry_point_na def _get_sampler() -> str | None: + """Read the configured sampler name. + + Returns: + The value of ``OTEL_TRACES_SAMPLER``, or ``None`` when it is unset, + which leaves the tracer provider on its own default sampler. + """ return environ.get(OTEL_TRACES_SAMPLER, None) def _get_id_generator() -> str: + """Read the configured id generator name. + + Returns: + The value of ``OTEL_PYTHON_ID_GENERATOR``, or ``"random"`` when it is + unset. + """ return environ.get(OTEL_PYTHON_ID_GENERATOR, _DEFAULT_ID_GENERATOR) def _get_tracer_configurator() -> str | None: + """Read the configured tracer configurator name. + + Returns: + The value of ``OTEL_PYTHON_TRACER_CONFIGURATOR``, or ``None`` when it + is unset, which gives every tracer the same configuration. + """ return environ.get(OTEL_PYTHON_TRACER_CONFIGURATOR, None) def _get_meter_configurator() -> str | None: + """Read the configured meter configurator name. + + Returns: + The value of ``OTEL_PYTHON_METER_CONFIGURATOR``, or ``None`` when it + is unset, which gives every meter the same configuration. + """ return environ.get(OTEL_PYTHON_METER_CONFIGURATOR, None) def _get_logger_configurator() -> str | None: + """Read the configured logger configurator name. + + Returns: + The value of ``OTEL_PYTHON_LOGGER_CONFIGURATOR``, or ``None`` when it + is unset, which gives every logger the same configuration. + """ return environ.get(OTEL_PYTHON_LOGGER_CONFIGURATOR, None) def _get_exporter_entry_point(exporter_name: str, signal_type: Literal["traces", "metrics", "logs"]): + """Resolve an OTLP exporter name to the entry point of one transport. + + The name ``otlp`` does not name an entry point on its own, because the + OTLP exporter ships one implementation per transport. The transport is + taken from the signal-specific protocol variable when it is set, and from + ``OTEL_EXPORTER_OTLP_PROTOCOL`` otherwise. With neither set, ``otlp`` + becomes the gRPC implementation. + + A name that already states the transport, ``otlp_proto_grpc`` or + ``otlp_proto_http``, keeps it. When a protocol variable asks for the other + transport, the name wins and the disagreement is logged at ``WARNING`` + level. + + Args: + exporter_name: The exporter name as configured, already stripped. + signal_type: Which signal this exporter is for, which selects the + signal-specific protocol variable to read. + + Returns: + The entry point name to load. A name that is not one of the three + OTLP spellings is returned unchanged, so this function is safe to + call on every configured exporter. + + Raises: + RuntimeError: If ``exporter_name`` is ``otlp`` and the protocol + variable holds something other than ``grpc`` or + ``http/protobuf``. + """ if exporter_name not in ( _EXPORTER_OTLP, _EXPORTER_OTLP_PROTO_GRPC, @@ -193,6 +323,24 @@ def _get_exporter_entry_point(exporter_name: str, signal_type: Literal["traces", def _get_exporter_names( signal_type: Literal["traces", "metrics", "logs"], ) -> list[str]: + """Read the exporters configured for one signal. + + Args: + signal_type: Which signal to read, which selects between + ``OTEL_TRACES_EXPORTER``, ``OTEL_METRICS_EXPORTER`` and + ``OTEL_LOGS_EXPORTER``. + + Returns: + The entry point names to load, in the order given, with each OTLP + name already resolved to a transport. The list is empty when the + variable is unset, when it is empty, and when it holds the sentinel + ``none`` in any case with surrounding whitespace, which is how an + operator turns a signal off. + + Raises: + RuntimeError: If an ``otlp`` entry is configured and the protocol + variable holds an unsupported value. + """ names = environ.get(_EXPORTER_ENV_BY_SIGNAL_TYPE.get(signal_type, "")) if not names or names.lower().strip() == "none": @@ -211,6 +359,33 @@ def _init_tracing( export_span_processor: _ConfigurationExporterSpanProcessorT | None = None, tracer_configurator: _TracerConfiguratorT | None = None, ): + """Build a tracer provider, install it globally and attach the exporters. + + The provider is set as the global one before the processors are added, so + a tracer taken during this call already belongs to it. The provider is + installed even when there are no exporters, which is what makes spans + record without being sent anywhere. + + Args: + exporters: The span exporter classes to attach, keyed by the entry + point name they were loaded under. The key is not used; the + classes are what matter, and each is constructed here. + id_generator: The generator of trace and span ids, or ``None`` to let + the provider use its own default. + sampler: The sampler, or ``None`` to let the provider use its own + default. + resource: The resource every span is attributed to, or ``None`` to + let the provider build one. + exporter_args_map: Keyword arguments to construct a given exporter + class with, keyed by the class. A class that is absent is built + with no arguments. Pass ``None`` for no arguments at all. + span_processors: Processors to register before the exporting ones, so + they see a span first. Pass ``None`` for none. + export_span_processor: What to wrap each exporter in, or ``None`` to + use ``BatchSpanProcessor``. + tracer_configurator: The per-scope tracer configuration, or ``None`` + to configure every tracer alike. + """ provider = TracerProvider( id_generator=id_generator, sampler=sampler, @@ -237,6 +412,25 @@ def _init_metrics( exporter_args_map: ExporterArgsMap | None = None, meter_configurator: _MeterConfiguratorT | None = None, ): + """Build a meter provider from the configured exporters and install it. + + The metrics entry point group holds two kinds of thing. A class that is a + ``MetricReader`` is used as it is, which is how a pull-based exporter + such as the Prometheus one works. Anything else is treated as a + ``MetricExporter`` and wrapped in a ``PeriodicExportingMetricReader``, + which is what makes a push-based exporter run on a timer. + + Args: + exporters_or_readers: The classes to attach, keyed by the entry point + name they were loaded under. The key is not used. + resource: The resource every metric is attributed to, or ``None`` to + let the provider build one. + exporter_args_map: Keyword arguments to construct a given class with, + keyed by the class. A class that is absent is built with no + arguments. Pass ``None`` for no arguments at all. + meter_configurator: The per-scope meter configuration, or ``None`` to + configure every meter alike. + """ metric_readers = [] exporter_args_map = exporter_args_map or {} @@ -265,6 +459,36 @@ def _init_logging( export_log_record_processor: _ConfigurationExporterLogRecordProcessorT | None = None, logger_configurator: _LoggerConfiguratorT | None = None, ): + """Build a logger provider, install it globally and attach the exporters. + + When ``setup_logging_handler`` is true, a ``LoggingHandler`` is added to + the root logger of the standard library, which is what sends ordinary + ``logging`` calls through OpenTelemetry. That path is deprecated in + favour of the ``opentelemetry-instrumentation-logging`` package, so a + ``DeprecationWarning`` is raised when it is taken. + + Taking that path also replaces ``logging.config.fileConfig``, + ``logging.config.dictConfig`` and ``logging.basicConfig`` at module level + with wrappers that preserve the handler, since each of those would + otherwise remove it. + + Args: + exporters: The log record exporter classes to attach, keyed by the + entry point name they were loaded under. The key is not used. + resource: The resource every log record is attributed to, or ``None`` + to let the provider build one. + setup_logging_handler: Whether to attach the handler to the root + logger, as described above. + exporter_args_map: Keyword arguments to construct a given exporter + class with, keyed by the class. A class that is absent is built + with no arguments. Pass ``None`` for no arguments at all. + log_record_processors: Processors to register before the exporting + ones, so they see a record first. Pass ``None`` for none. + export_log_record_processor: What to wrap each exporter in, or + ``None`` to use ``BatchLogRecordProcessor``. + logger_configurator: The per-scope logger configuration, or ``None`` + to configure every logger alike. + """ provider = LoggerProvider(resource=resource, _logger_configurator=logger_configurator) set_logger_provider(provider) @@ -294,10 +518,45 @@ def _init_logging( def _overwrite_logging_config_fns(handler: LoggingHandler) -> None: + """Keep ``handler`` on the root logger across a logging reconfiguration. + + ``logging.config.fileConfig``, ``logging.config.dictConfig`` and + ``logging.basicConfig`` each replace or clear the handlers of the root + logger, which would silently stop the OpenTelemetry handler from + receiving anything. All three are replaced here by wrappers that take the + handler off before the call and put it back afterwards. + + The replacement is global and permanent for the process, and calling this + twice wraps the wrappers. + + Args: + handler: The handler to preserve. It is captured by the wrappers, so + it stays alive for as long as the ``logging`` module does. + """ root = logging.getLogger() def wrapper(config_fn: Callable) -> Callable: + """Wrap one logging configuration function. + + Args: + config_fn: The function to wrap, one of the three named above. + + Returns: + A function with the same signature that preserves the handler. + """ + def overwritten_config_fn(*args, **kwargs): + """Run the wrapped function with the handler detached. + + The handler is put back in a ``finally`` block, so it survives a + configuration function that raises. It is only put back when it + was actually present beforehand, so this does not re-add a + handler the application had already removed on purpose. + + Args: + *args: Passed through to the wrapped function. + **kwargs: Passed through to the wrapped function. + """ removed_handler = False # We don't want the OTLP handler to be modified or deleted by the logging config functions. # So we remove it and then add it back after the function call. @@ -321,6 +580,20 @@ def overwritten_config_fn(*args, **kwargs): def _import_logger_configurator( logger_configurator_name: str | None, ) -> _LoggerConfiguratorT | None: + """Load the per-scope logger configuration named by ``OTEL_PYTHON_LOGGER_CONFIGURATOR``. + + Args: + logger_configurator_name: The entry point name in the + ``_opentelemetry_logger_configurator`` group, or ``None`` or the + empty string to ask for no configurator. Surrounding whitespace + is stripped. + + Returns: + The loaded configurator, or ``None`` when none was asked for and when + the named one could not be loaded. A failure is logged at ``WARNING`` + level and is never raised, so a broken configurator leaves the SDK + running with every logger configured alike. + """ if not logger_configurator_name: return None @@ -342,6 +615,20 @@ def _import_logger_configurator( def _import_tracer_configurator( tracer_configurator_name: str | None, ) -> _TracerConfiguratorT | None: + """Load the per-scope tracer configuration named by ``OTEL_PYTHON_TRACER_CONFIGURATOR``. + + Args: + tracer_configurator_name: The entry point name in the + ``_opentelemetry_tracer_configurator`` group, or ``None`` or the + empty string to ask for no configurator. Surrounding whitespace + is stripped. + + Returns: + The loaded configurator, or ``None`` when none was asked for and when + the named one could not be loaded. A failure is logged at ``WARNING`` + level and is never raised, so a broken configurator leaves the SDK + running with every tracer configured alike. + """ if not tracer_configurator_name: return None @@ -363,6 +650,20 @@ def _import_tracer_configurator( def _import_meter_configurator( meter_configurator_name: str | None, ) -> _MeterConfiguratorT | None: + """Load the per-scope meter configuration named by ``OTEL_PYTHON_METER_CONFIGURATOR``. + + Args: + meter_configurator_name: The entry point name in the + ``_opentelemetry_meter_configurator`` group, or ``None`` or the + empty string to ask for no configurator. Surrounding whitespace + is stripped. + + Returns: + The loaded configurator, or ``None`` when none was asked for and when + the named one could not be loaded. A failure is logged at ``WARNING`` + level and is never raised, so a broken configurator leaves the SDK + running with every meter configured alike. + """ if not meter_configurator_name: return None @@ -390,6 +691,29 @@ def _import_exporters( dict[str, type[MetricExporter | MetricReader]], dict[str, type[LogRecordExporter]], ]: + """Load the exporter classes of all three signals and check their types. + + Nothing is constructed here; the classes are returned for the + ``_init_*`` functions to build. + + Args: + trace_exporter_names: Entry point names in the + ``opentelemetry_traces_exporter`` group. + metric_exporter_names: Entry point names in the + ``opentelemetry_metrics_exporter`` group. An entry there may be a + ``MetricExporter`` or a ``MetricReader``, since a pull-based + exporter is registered as a reader. + log_exporter_names: Entry point names in the + ``opentelemetry_logs_exporter`` group. + + Returns: + Three mappings from entry point name to class, for traces, metrics + and logs in that order. + + Raises: + RuntimeError: If a name is not in its group, or if what it loads is + not of the type that group is supposed to hold. + """ trace_exporters = {} metric_exporters = {} log_exporters = {} @@ -429,11 +753,47 @@ def _import_exporters( def _import_sampler_factory( sampler_name: str, ) -> Callable[[float | str | None], Sampler]: + """Load the factory registered for one sampler name. + + The entry point holds a factory rather than a sampler class, because a + sampler is built from the single argument ``OTEL_TRACES_SAMPLER_ARG`` + carries and each sampler reads that argument differently. + + Args: + sampler_name: The entry point name in the + ``opentelemetry_traces_sampler`` group. Surrounding whitespace is + stripped. + + Returns: + The factory, which takes the sampler argument and returns a sampler. + + Raises: + RuntimeError: If the name is not in the group. + """ _, sampler_impl = _import_config_components([sampler_name.strip()], _OTEL_SAMPLER_ENTRY_POINT_GROUP)[0] return sampler_impl def _import_sampler(sampler_name: str | None) -> Sampler | None: + """Build the sampler named by ``OTEL_TRACES_SAMPLER``. + + For ``traceidratio`` and ``parentbased_traceidratio`` the argument is + parsed as a float; anything that does not parse, including an unset + variable, is logged at ``WARNING`` level and becomes 1.0, which samples + every trace. For every other sampler the argument is passed through as a + string, or as ``None`` when the variable is unset. + + Args: + sampler_name: The entry point name, or ``None`` or the empty string + to ask for no sampler. + + Returns: + The sampler, or ``None`` when none was asked for and when building + one failed, which leaves the tracer provider on its own default. + Every failure is logged at ``WARNING`` level and swallowed: an + unknown name, a factory that raises, and a factory that returns + something which is not a ``Sampler``. + """ if not sampler_name: return None try: @@ -465,6 +825,23 @@ def _import_sampler(sampler_name: str | None) -> Sampler | None: def _import_id_generator(id_generator_name: str) -> IdGenerator: + """Build the id generator named by ``OTEL_PYTHON_ID_GENERATOR``. + + Unlike the sampler and the configurators, a failure here is raised rather + than logged, so a misconfigured id generator stops the SDK from starting. + + Args: + id_generator_name: The entry point name in the + ``opentelemetry_id_generator`` group. Surrounding whitespace is + stripped. + + Returns: + A new instance, built with no arguments. + + Raises: + RuntimeError: If the name is not in the group, or if what it loads is + not an ``IdGenerator`` subclass. + """ id_generator_name, id_generator_impl = _import_config_components( [id_generator_name.strip()], "opentelemetry_id_generator" )[0] @@ -484,6 +861,22 @@ def _import_opamp( the resource and instantiate an OpAMP agent. Since configuration is not specified every implementer may have its own. Refer to the opentelemetry-opamp-client package on how to setup the OpAMP agent. + + Args: + name: Which of the two hooks to load from the + ``_opentelemetry_opamp`` entry point group. + ``pre_sdk_init_function`` runs before any provider is built, so it + can block until a remote configuration arrives. + ``post_sdk_init_function`` runs once all three providers are + installed. + + Returns: + The callable, which takes the resource, or ``None`` when no entry + point is registered under that name, which is the usual case, and + when loading one raised ``AttributeError``. The first is logged at + ``DEBUG`` level and the second at ``WARNING`` level. Any other + exception from loading the entry point, ``ImportError`` in + particular, is left to propagate. """ entry_point = None try: @@ -519,6 +912,62 @@ def _initialize_components( meter_configurator: _MeterConfiguratorT | None = None, logger_configurator: _LoggerConfiguratorT | None = None, ): + """Build and install the tracer, meter and logger providers. + + This is the whole environment-driven startup. Every parameter exists so + that a distribution can override one decision while leaving the rest to + the environment: a parameter that is left ``None`` is read from the + environment instead, and the exporter name lists are added to what the + environment asks for rather than replacing it. + + The resource is built once and shared by all three providers. + + Args: + auto_instrumentation_version: The version of the auto instrumentation + that started the process, recorded as the + ``telemetry.auto.version`` resource attribute. Pass ``None`` when + the SDK was started by hand. + trace_exporter_names: Span exporter entry point names to add to those + in ``OTEL_TRACES_EXPORTER``. + metric_exporter_names: Metric exporter entry point names to add to + those in ``OTEL_METRICS_EXPORTER``. + log_exporter_names: Log exporter entry point names to add to those in + ``OTEL_LOGS_EXPORTER``. + sampler: The sampler to use, or ``None`` to read + ``OTEL_TRACES_SAMPLER``. + resource_attributes: Attributes to put on the resource, merged with + the detected ones and with ``OTEL_RESOURCE_ATTRIBUTES``. The + mapping is written into, so a caller that reuses it sees + ``telemetry.auto.version`` appear in it. + id_generator: The id generator to use, or ``None`` to read + ``OTEL_PYTHON_ID_GENERATOR``. + setup_logging_handler: Whether to route the standard library + ``logging`` through OpenTelemetry, or ``None`` to read + ``OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED``, which is + off unless it holds ``true``. + exporter_args_map: Keyword arguments to construct a given exporter + class with, keyed by the class. It is shared by all three + signals. + span_processors: Span processors to register before the exporting + ones. + export_span_processor: What to wrap each span exporter in, or + ``None`` for ``BatchSpanProcessor``. + log_record_processors: Log record processors to register before the + exporting ones. + export_log_record_processor: What to wrap each log record exporter + in, or ``None`` for ``BatchLogRecordProcessor``. + tracer_configurator: Per-scope tracer configuration, or ``None`` to + read ``OTEL_PYTHON_TRACER_CONFIGURATOR``. + meter_configurator: Per-scope meter configuration, or ``None`` to + read ``OTEL_PYTHON_METER_CONFIGURATOR``. + logger_configurator: Per-scope logger configuration, or ``None`` to + read ``OTEL_PYTHON_LOGGER_CONFIGURATOR``. + + Raises: + RuntimeError: If a configured exporter or id generator name cannot be + resolved. A sampler or a configurator that cannot be resolved is + logged and skipped instead. + """ # pylint: disable=too-many-locals,too-many-branches if resource_attributes is None: resource_attributes = {} @@ -616,6 +1065,23 @@ class _BaseConfigurator(ABC): _is_instrumented = False def __new__(cls, *args, **kwargs): + """Return the one instance of ``cls``, creating it on the first call. + + The instance is cached as the ``_instance`` attribute of the class it + was first built for, and that attribute is looked up through the + inheritance chain, so a subclass of a class that has already been + instantiated receives that class's instance rather than one of its + own. Arguments are accepted and ignored, which means a second + construction with different arguments silently returns the first + instance. + + Args: + *args: Ignored. + **kwargs: Ignored. + + Returns: + The single instance of ``cls``. + """ if cls._instance is None: cls._instance = super().__new__(cls) @@ -623,10 +1089,25 @@ def __new__(cls, *args, **kwargs): @abstractmethod def _configure(self, **kwargs): - """Configure the SDK""" + """Configure the SDK + + This is what a subclass implements. ``configure`` is its only + caller, and it holds no guard, so calling ``configure`` twice runs + this twice. + + Args: + **kwargs: Whatever the caller of ``configure`` passed. The + meaning is up to the implementation; + ``_OTelSDKConfigurator`` forwards them to + ``_initialize_components``. + """ def configure(self, **kwargs): - """Configure the SDK""" + """Configure the SDK + + Args: + **kwargs: Forwarded unchanged to ``_configure``. + """ self._configure(**kwargs) @@ -643,6 +1124,28 @@ class _OTelSDKConfigurator(_BaseConfigurator): """ def _configure(self, **kwargs): + """Start the SDK, from a configuration file when one is named. + + Setting ``OTEL_CONFIG_FILE`` switches to declarative configuration, + which reads the whole SDK out of that file instead of out of the + environment variables this module otherwise consults. The file is + not sealed off from the environment: the loader resolves a + ``${VAR}`` reference in any value it reads, so a configured value + may still come from an environment variable. The file is handled by + the separate + ``opentelemetry-configuration`` distribution, imported only on this + path so that the SDK does not depend on it at runtime. Any ``kwargs`` + are then ignored, with a warning naming them, since the file is the + only source. + + Args: + **kwargs: Forwarded to ``_initialize_components`` when no + configuration file is set, and ignored when one is. + + Raises: + RuntimeError: If ``OTEL_CONFIG_FILE`` is set and + ``opentelemetry-configuration`` is not installed. + """ if config_file := environ.get(OTEL_CONFIG_FILE): # Declarative configuration lives in the separate # ``opentelemetry-configuration`` package. Import lazily so the diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py index 36f218e1fb6..8d3fdeb8979 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py @@ -1,5 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Public names of the OpenTelemetry logs SDK. + +This module re-exports the two log record wrappers, the attribute limits, the +log record processor interface with its two multiplexing implementations, the +``logging`` module bridge and ``LoggerProvider``. The implementation of all of +them lives in ``opentelemetry.sdk._logs._internal``. Import from here instead: +the layout of the private package may change between releases, while the names +listed in ``__all__`` are the ones the SDK supports. + +``LogDroppedAttributesWarning`` and ``LogLimits`` are deprecated aliases kept +for callers written against the older spelling. +""" from opentelemetry.sdk._logs._internal import ( ConcurrentMultiLogRecordProcessor, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 9a0aa4cbd9e..709474a0c23 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -1,5 +1,22 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Implementation of the OpenTelemetry logs SDK. + +Holds the two log record wrappers, the attribute limits applied to them, the +log record processor interface with its sequential and parallel multiplexers, +the bridge from the standard ``logging`` module, and ``LoggerProvider``. + +A record travels through these in one direction. A caller reaches +``Logger.emit``, either directly or through ``LoggingHandler``, which wraps the +record in a ``ReadWriteLogRecord``, applies the limits to its attributes, and +hands it to the provider's multiplexer. The multiplexer forwards it to each +registered processor, and a processor narrows it to a ``ReadableLogRecord`` for +its exporter. + +Everything meant for use outside the SDK is re-exported from +``opentelemetry.sdk._logs``; import from there rather than from this module. +""" + from __future__ import annotations import abc @@ -90,7 +107,29 @@ class BytesEncoder(json.JSONEncoder): + """JSON encoder that renders ``bytes`` as base64 text. + + An attribute value or a log record body may be ``bytes``, and + ``json.dumps`` has no representation for it, so + ``ReadableLogRecord.to_json`` passes this encoder. The encoding is applied + wherever ``bytes`` appears, including inside a nested sequence or mapping. + Nothing records that the value was encoded, so a reader cannot tell the + base64 text apart from a string that happened to look like base64. + """ + def default(self, o): + """Encode one value the standard JSON encoder has no rule for. + + Args: + o: The value ``json.dumps`` could not serialise on its own. + + Returns: + The base64 text of ``o`` when it is ``bytes``. + + Raises: + TypeError: For any other type, raised by the base implementation + this defers to. + """ if isinstance(o, bytes): return base64.b64encode(o).decode() return super().default(o) @@ -112,7 +151,14 @@ class LogRecordDroppedAttributesWarning(UserWarning): "Use LogRecordDroppedAttributesWarning. Since logs are not stable yet this WILL be removed in future releases." ) class LogDroppedAttributesWarning(LogRecordDroppedAttributesWarning): - pass + """Deprecated alias of ``LogRecordDroppedAttributesWarning``. + + The SDK raises the parent class and never this one. A warning filter + registered against this name therefore matches nothing, because a filter + matches a category and its subclasses, not its base classes. Code that + filters on dropped attributes has to name + ``LogRecordDroppedAttributesWarning``. + """ class LogRecordLimits: @@ -149,6 +195,32 @@ class LogRecordLimits: max_log_record_attribute_length: Maximum length a log record attribute value can have. Environment variable: ``OTEL_LOGRECORD_ATTRIBUTE_VALUE_LENGTH_LIMIT`` Falls back to ``max_attribute_length`` when unset. + + Only the two log record specific values are ever applied to a record. + ``ReadWriteLogRecord.__post_init__`` reads + ``max_log_record_attributes`` and ``max_log_record_attribute_length``; + ``max_attributes`` and ``max_attribute_length`` exist to supply their + fallbacks and are not consulted again afterwards. The four resolved values + are plain instance attributes, and changing one after construction has no + effect on a record already built. + + Reaching the count limit drops attributes: once a record holds + ``max_log_record_attributes`` keys, a further new key is discarded, while + assigning to a key the record already has still works. Reaching the length + limit truncates instead: a string value longer than + ``max_log_record_attribute_length`` is cut to that many characters and the + attribute is kept. Only the drops are counted, by + ``ReadWriteLogRecord.dropped_attributes``. A truncation is reported through + the logger of ``opentelemetry.attributes`` and leaves no mark on the record + itself, so a consumer cannot tell a truncated value from a short one. + + A limit of ``None`` means unlimited. A count limit of zero drops every + attribute, and a length limit of zero truncates every string value to the + empty string. + + Raises: + ValueError: If a limit is negative, or if an environment variable that + had to be consulted does not parse as an integer. """ UNSET = -1 @@ -160,6 +232,17 @@ def __init__( max_log_record_attributes: int | None = None, max_log_record_attribute_length: int | None = None, ): + """Resolve and store the four limits. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. The resolution happens here, once, so + changing an environment variable afterwards has no effect on an + instance already built. + + Raises: + ValueError: If a limit is negative, or if an environment variable + that had to be consulted does not parse as an integer. + """ # global attribute count global_max_attributes = self._from_env_if_absent(max_attributes, OTEL_ATTRIBUTE_COUNT_LIMIT) self.max_attributes = ( @@ -185,6 +268,15 @@ def __init__( ) def __repr__(self): + """Render the four limits as they were resolved. + + Returns: + Text naming the class and the four values the instance will + enforce, after the environment variables and the defaults have been + applied. It shows what is in effect rather than what the caller + passed, so an argument of ``None`` shows up as the value it + resolved to. A limit that resolved to unlimited shows as ``None``. + """ return ( f"{type(self).__name__}(" f"max_attributes={self.max_attributes}, " @@ -195,6 +287,29 @@ def __repr__(self): @classmethod def _from_env_if_absent(cls, value: int | None, env_var: str, default: int | None = None) -> int | None: + """Resolve one limit from the argument, the environment or a default. + + Args: + value: The limit as the caller passed it. ``None`` means the caller + said nothing, so the environment is consulted. + ``LogRecordLimits.UNSET``, which is ``-1``, means the caller + asked for no limit, and the environment is not consulted. + env_var: Name of the environment variable to read when ``value`` is + ``None``. A value there that is empty or only whitespace means + no limit. The name is also what the error messages quote, even + when the offending number came from ``value`` rather than from + the environment. + default: What to return when ``value`` is ``None`` and ``env_var`` + is absent from the environment. ``None`` here means no limit. + + Returns: + The resolved limit as a non-negative integer, or ``None`` when the + limit is not to be enforced. + + Raises: + ValueError: If the environment variable is set to something that is + not an integer, or if the resolved limit is negative. + """ if value == cls.UNSET: return None @@ -222,12 +337,38 @@ def _from_env_if_absent(cls, value: int | None, env_var: str, default: int | Non @deprecated("Use LogRecordLimits. Since logs are not stable yet this WILL be removed in future releases.") class LogLimits(LogRecordLimits): - pass + """Deprecated alias of ``LogRecordLimits``. + + A subclass with no changes, so it reads the same environment variables and + is accepted anywhere a ``LogRecordLimits`` is expected. + """ @dataclass(frozen=True, slots=True) class ReadableLogRecord: - """Readable LogRecord should be kept exactly in-sync with ReadWriteLogRecord, only difference is the frozen=True param.""" + """Readable LogRecord should be kept exactly in-sync with ReadWriteLogRecord, only difference is the frozen=True param. + + This is the shape a ``LogRecordExporter`` receives. A processor builds one + from the ``ReadWriteLogRecord`` it was handed, so the limits have already + been applied by the time an exporter sees it; nothing here applies them + again. + + Being frozen prevents reassigning the four fields. It does not make the + record immutable: ``log_record`` points at a live ``LogRecord`` whose + fields and attribute mapping can still be changed, and an exporter that + does so changes what the next exporter on the same provider sees. + + Args: + log_record: The API record carrying the timestamps, the severity, the + body, the attributes and the trace correlation. + resource: The resource of the ``LoggerProvider`` the record came from. + instrumentation_scope: The scope of the ``Logger`` that emitted the + record, or ``None`` when none was supplied. + limits: The limits that were applied when the record was built, kept so + that a consumer can tell what shaped the attributes. ``None`` means + no limits travelled with the record, which does not mean none were + applied. + """ log_record: LogRecord resource: Resource @@ -236,11 +377,45 @@ class ReadableLogRecord: @property def dropped_attributes(self) -> int: + """Count the attributes the limits refused to keep. + + Returns: + How many attributes were discarded while the record was being + built: keys refused because the record already held + ``max_log_record_attributes`` of them, and keys refused for being + empty or not a string. A value truncated for exceeding + ``max_log_record_attribute_length`` is not counted, because the + attribute itself was kept. Zero when the attribute mapping is not a + ``BoundedAttributes``, which is the case for a record built by hand + rather than by ``ReadWriteLogRecord``, and which means the count is + unknown rather than known to be zero. + """ if isinstance(self.log_record.attributes, BoundedAttributes): return self.log_record.attributes.dropped return 0 def to_json(self, indent: int | None = 4) -> str: + """Serialise the record to the JSON form the console exporter writes. + + Timestamps become ISO 8601 text, the trace id and the span id become + ``0x`` prefixed hex, and ``bytes`` anywhere in the body or the + attributes become base64 through ``BytesEncoder``. A missing trace id, + span id or event name comes out as the empty string, while a missing + timestamp, severity number or attribute mapping comes out as ``null``. + + The instrumentation scope and the limits are left out entirely, and + ``dropped_attributes`` is included, so the output says how many + attributes were lost without saying what the limit was. + + Args: + indent: Spaces per nesting level. ``None`` produces the compact + form, which puts the whole record on one line and is what a + line-oriented consumer wants. + + Returns: + The JSON text, with no trailing newline. The console exporter adds + the line separator itself. + """ return json.dumps( { "body": self.log_record.body, @@ -275,6 +450,27 @@ class ReadWriteLogRecord: ReadWriteLogRecord instances are created and emitted via `Logger` every time something is logged. They contain all the information pertinent to the event being logged. + + This is the shape a ``LogRecordProcessor`` receives. Constructing one + applies ``limits`` to the attributes of ``log_record``, in place, which is + the only point in the pipeline where that happens. + + Args: + log_record: The API record carrying the timestamps, the severity, the + body, the attributes and the trace correlation. It is stored by + reference and its ``attributes`` is replaced during construction, + so the caller's record is changed too. + resource: The resource to attach. The default is + ``Resource.create({})``, evaluated once at class definition time + and shared by every instance that does not pass one. It is not an + empty resource: the default detectors run, so it carries the + ``telemetry.sdk.*``, ``service.name`` and ``service.instance.id`` + attributes. + instrumentation_scope: The scope of the ``Logger`` that emitted the + record, or ``None`` when none was supplied. + limits: The limits to apply to the attributes. The default is a fresh + ``LogRecordLimits``, built per instance, which reads the + environment at that moment. """ log_record: LogRecord @@ -283,6 +479,20 @@ class ReadWriteLogRecord: limits: LogRecordLimits = field(default_factory=LogRecordLimits) def __post_init__(self): + """Apply the limits to the attributes of the wrapped record. + + Replaces ``log_record.attributes`` with a mutable + ``BoundedAttributes`` holding at most + ``limits.max_log_record_attributes`` entries, each string value cut to + ``limits.max_log_record_attribute_length`` characters. Once the count + is reached a further new key is discarded, while an existing key can + still be assigned to. A record that had no attributes gets an empty + bounded mapping rather than ``None``. + + Issues a ``LogRecordDroppedAttributesWarning`` when anything was + dropped. A module-level filter set at import time reduces that to one + warning per process, so a second record losing attributes says nothing. + """ self.log_record.attributes = BoundedAttributes( maxlen=self.limits.max_log_record_attributes, attributes=self.log_record.attributes if self.log_record.attributes else None, @@ -298,6 +508,28 @@ def __post_init__(self): ) def __eq__(self, other: object) -> bool: + """Compare all four fields. + + Written by hand rather than left to the ``dataclass`` decorator, which + would accept only an exact instance of this class. This one accepts a + subclass as well. + + Args: + other: The object to compare against. + + Returns: + ``True`` when ``other`` is a ``ReadWriteLogRecord`` whose record, + resource, instrumentation scope and limits all compare equal. + ``False`` when it is one and they do not. ``NotImplemented`` for + any other type, which lets Python fall back to the reflected + comparison and then to identity. + + Neither ``LogRecord`` nor ``LogRecordLimits`` defines ``__eq__``, + so those two fields are compared by identity. Two records built + separately from equal contents are therefore unequal. ``Resource`` + and ``InstrumentationScope`` do define ``__eq__`` and are compared + by value. + """ if not isinstance(other, ReadWriteLogRecord): return NotImplemented return ( @@ -309,6 +541,21 @@ def __eq__(self, other: object) -> bool: @property def dropped_attributes(self) -> int: + """Count the attributes the limits refused to keep. + + Recomputed on each read, so a processor that adds attributes past the + count limit sees the number grow. + + Returns: + How many attributes were discarded, counting keys refused because + the record already held ``limits.max_log_record_attributes`` of + them and keys refused for being empty or not a string. A value + truncated for exceeding + ``limits.max_log_record_attribute_length`` is not counted, + because the attribute itself was kept. Zero when the + attribute mapping is not a ``BoundedAttributes``, which happens + only if something replaced the mapping after construction. + """ if isinstance(self.log_record.attributes, BoundedAttributes): return self.log_record.attributes.dropped return 0 @@ -322,6 +569,26 @@ def _from_api_log_record( instrumentation_scope: InstrumentationScope | None = None, limits: LogRecordLimits | None = None, ) -> ReadWriteLogRecord: + """Wrap an API log record so it can enter the processor pipeline. + + Exists so that ``limits`` can be left out entirely when the caller has + none, which is what makes the field fall back to its default factory + instead of being set to ``None``. + + Args: + record: The record to wrap. It is not copied, and construction + replaces its ``attributes`` in place. + resource: The resource to attach. + instrumentation_scope: The scope to attach, or ``None`` when the + emitting logger has none. + limits: The limits to apply. ``None`` means the field keeps its + default, a freshly built ``LogRecordLimits`` that reads the + environment; it does not mean no limits are applied. + + Returns: + The wrapping record, with the limits already applied to the + attributes of ``record``. + """ return cls( log_record=record, resource=resource, @@ -372,6 +639,16 @@ def on_emit(self, log_record: ReadWriteLogRecord) -> None: Implementers should handle any exceptions raised during log processing to prevent application crashes. See the class docstring for details on error handling expectations. + + Called on the thread that emitted the record, with the record every + other registered processor also receives. An implementation that + changes the record changes what the processors registered after it + see, so copy before editing. + + Args: + log_record: The record to process. Its attributes already have the + limits applied, and its ``resource`` may be ``None`` for a + record not built by ``Logger.emit``. """ def enabled( # pylint: disable=no-self-use,unused-argument @@ -386,6 +663,27 @@ def enabled( # pylint: disable=no-self-use,unused-argument Processors that support filtering MAY override this method. The default implementation returns ``True``. + + ``Logger.emit`` calls this before it builds the record, so an + implementation that answers ``False`` saves the cost of building it. + The answer has to hold for the whole call: a processor that says + ``False`` here and is then given the record anyway must still handle + it. + + Args: + context: The context the record would carry, or ``None`` when the + caller has none and the current context would be used. + instrumentation_scope: The scope of the emitting ``Logger``, or + ``None`` when it has none. + severity_number: The severity the record would carry, or ``None`` + when it is unset. Filtering by level is the usual reason to + override this method. + event_name: The event name the record would carry, or ``None`` for + an unnamed record. + + Returns: + ``True`` when the record is worth building. The base implementation + ignores all four arguments and always answers ``True``. """ return True @@ -420,15 +718,40 @@ class SynchronousMultiLogRecordProcessor(LogRecordProcessor): def __init__(self) -> None: # use a tuple to avoid race conditions when adding a new log and # iterating through it on "emit". + """Build the processor with no registered processors. + + Takes no arguments: add the processors afterwards with + ``add_log_record_processor``. + """ self._log_record_processors = () # type: tuple[LogRecordProcessor, ...] self._lock = threading.Lock() def add_log_record_processor(self, log_record_processor: LogRecordProcessor) -> None: - """Adds a Logprocessor to the list of log processors handled by this instance""" + """Adds a Logprocessor to the list of log processors handled by this instance + + The stored tuple is replaced rather than appended to, so a concurrent + ``on_emit`` keeps iterating the tuple it started with and neither sees + the new processor nor fails. There is no way to remove one afterwards, + and adding the same processor twice makes it receive each record twice. + + Args: + log_record_processor: The processor to add. It takes its place at + the end, and processors run in the order they were added. + """ with self._lock: self._log_record_processors += (log_record_processor,) def on_emit(self, log_record: ReadWriteLogRecord) -> None: + """Hand the record to each processor in turn, on the calling thread. + + The processors share one record, so a processor that edits it changes + what the ones after it receive. An exception from any processor + propagates to the caller and stops the remaining ones from being + called at all. + + Args: + log_record: The record to forward. + """ for lp in self._log_record_processors: lp.on_emit(log_record) @@ -440,6 +763,21 @@ def enabled( severity_number: SeverityNumber | None = None, event_name: str | None = None, ) -> bool: + """Ask each processor in turn whether it wants such a record. + + Args: + context: The context the record would carry, or ``None``. + instrumentation_scope: The scope of the emitting ``Logger``, or + ``None``. + severity_number: The severity the record would carry, or ``None``. + event_name: The event name the record would carry, or ``None``. + + Returns: + ``True`` as soon as one processor answers ``True``, which leaves + the rest unasked. ``False`` when every processor declines, and also + ``False`` when no processor has been registered, so a provider with + an empty processor list discards everything emitted through it. + """ for lp in self._log_record_processors: if lp.enabled( context=context, @@ -451,7 +789,12 @@ def enabled( return False def shutdown(self) -> None: - """Shutdown the log processors one by one""" + """Shutdown the log processors one by one + + An exception from one processor stops the ones after it from being + shut down. There is no guard against repetition here, so each call + reaches every processor again. + """ for lp in self._log_record_processors: lp.shutdown() @@ -496,11 +839,27 @@ class ConcurrentMultiLogRecordProcessor(LogRecordProcessor): def __init__(self, max_workers: int = 2) -> None: # use a tuple to avoid race conditions when adding a new log and # iterating through it on "emit". + """Build the processor. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._log_record_processors = () # type: tuple[LogRecordProcessor, ...] self._lock = threading.Lock() self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) def add_log_record_processor(self, log_record_processor: LogRecordProcessor) -> None: + """Add a processor to the set this instance drives. + + The stored tuple is replaced rather than appended to, so a concurrent + forwarding call keeps iterating the tuple it started with. There is no + way to remove a processor afterwards. Adding more processors than the + executor has threads does not fail; the extra ones simply wait their + turn, which costs the parallelism the class exists for. + + Args: + log_record_processor: The processor to add. + """ with self._lock: self._log_record_processors += (log_record_processor,) @@ -510,6 +869,24 @@ def _submit_and_wait( *args: Any, **kwargs: Any, ): + """Run one bound method of every processor in parallel and wait. + + Every future is collected before any result is read, so the work really + does overlap. There is no timeout, so one processor that never returns + blocks the call for good. + + Args: + func: Picks the method to call, given a processor. Callers pass a + lambda such as ``lambda lp: lp.on_emit``. + args: Positional arguments passed on to each picked method. + kwargs: Keyword arguments passed on to each picked method. + + Raises: + Exception: Whatever the earliest-added processor that failed + raised. Results are read in the order the processors were + added, so a later processor's exception is never read and + never surfaces. + """ futures = [] for lp in self._log_record_processors: future = self._executor.submit(func(lp), *args, **kwargs) @@ -518,6 +895,19 @@ def _submit_and_wait( future.result() def on_emit(self, log_record: ReadWriteLogRecord) -> None: + """Hand the record to every processor at once and wait for all of them. + + The call returns only once every processor has finished, so the + emitting thread still pays the slowest one. What it saves is the sum: + the processors overlap instead of queueing behind each other. + + Every processor gets the same record object and they run at the same + time, so a processor that edits the record races the others. Copy + before editing. + + Args: + log_record: The record to forward. + """ self._submit_and_wait(lambda lp: lp.on_emit, log_record) def enabled( @@ -528,6 +918,25 @@ def enabled( severity_number: SeverityNumber | None = None, event_name: str | None = None, ) -> bool: + """Ask each processor in turn whether it wants such a record. + + Asked sequentially on the calling thread, unlike the other forwarding + methods of this class, since the answer is cheap and the loop stops + early. + + Args: + context: The context the record would carry, or ``None``. + instrumentation_scope: The scope of the emitting ``Logger``, or + ``None``. + severity_number: The severity the record would carry, or ``None``. + event_name: The event name the record would carry, or ``None``. + + Returns: + ``True`` as soon as one processor answers ``True``, which leaves + the rest unasked. ``False`` when every processor declines, and also + ``False`` when no processor has been registered, so a provider with + an empty processor list discards everything emitted through it. + """ for lp in self._log_record_processors: if lp.enabled( context=context, @@ -539,6 +948,12 @@ def enabled( return False def shutdown(self) -> None: + """Shut every processor down at once and wait for all of them. + + The thread pool itself is left running, so the executor's threads + survive this call. There is no guard against repetition, so each call + shuts every processor down again. + """ self._submit_and_wait(lambda lp: lp.shutdown) def force_flush(self, timeout_millis: int = 30000) -> bool: @@ -605,6 +1020,52 @@ class LoggingHandler(logging.Handler): """A handler class which writes logging records, in OTLP format, to a network destination or file. Supports signals from the `logging` module. https://docs.python.org/3/library/logging.html + + Deprecated in favour of the handler in + ``opentelemetry-instrumentation-logging``. Constructing one issues a + ``DeprecationWarning``. + + A ``logging.LogRecord`` becomes an OpenTelemetry log record like this: + + - ``record.created``, which is seconds as a float, becomes ``timestamp`` in + nanoseconds. The conversion keeps only the resolution the float had, so + the last few digits are not meaningful. + - ``record.levelno`` becomes ``severity_number``. ``DEBUG`` maps to + ``DEBUG``, ``INFO`` to ``INFO``, ``WARNING`` to ``WARN``, ``ERROR`` to + ``ERROR`` and ``CRITICAL`` to ``FATAL``. A custom level between two + standard ones maps to a numbered variant of the lower one, a level below + 10 maps to ``UNSPECIFIED``, and a level above 53 maps to ``FATAL4``. + - ``record.levelname`` becomes ``severity_text`` unchanged, except that + ``WARNING`` is rewritten to ``WARN`` and ``CRITICAL`` to ``FATAL`` to + match the specification. + - The message becomes ``body``, after formatting. See ``_translate`` for + when the unformatted object is used instead. + - Everything passed through ``extra=`` becomes an attribute, because it + lands on the record as an instance attribute and the handler copies every + instance attribute that is not one of the standard ones. A key in + ``extra`` that collides with a standard attribute name never reaches the + handler at all: ``logging`` raises ``KeyError`` at the call site instead + of letting it overwrite the standard field. + - ``record.pathname``, ``record.funcName`` and ``record.lineno`` become the + ``code.*`` attributes. ``record.name``, ``record.module``, + ``record.thread`` and the other standard fields are dropped. + - ``record.exc_info``, set by ``logger.exception`` or + ``logger.error(..., exc_info=True)``, becomes the ``exception.*`` + attributes. The exception itself is not attached to the record, so the + SDK's own exception handling does not run on it. + - The current context supplies the trace correlation, so a record logged + inside a span carries that span's ids. + + ``event_name`` is never set. + + Args: + level: The lowest level this handler accepts, as a ``logging`` level + number. ``logging.NOTSET``, the default, accepts whatever the + logger it is attached to passed on. + logger_provider: The provider to obtain a ``Logger`` from, one per + ``record.name``. ``None`` reads the globally configured provider + once, at construction, so configuring the global provider + afterwards does not reach a handler already built. """ def __init__( @@ -612,6 +1073,11 @@ def __init__( level: int = logging.NOTSET, logger_provider: APILoggerProvider | None = None, ) -> None: + """Build the handler and resolve the logger provider. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. Issues a ``DeprecationWarning``. + """ super().__init__(level=level) self._logger_provider = logger_provider or get_logger_provider() @@ -623,6 +1089,30 @@ def __init__( @staticmethod def _get_attributes(record: logging.LogRecord) -> Attributes: + """Collect the attributes of one ``logging.LogRecord``. + + Starts from every instance attribute of the record whose name is not in + the reserved set, which is how anything passed as ``extra=`` arrives, + then adds ``code.file.path``, ``code.function.name`` and + ``code.line.number``, which overwrite any key of the same name that + came from the record. + + When the record carries ``exc_info``, each of the three parts is added + only if it is present: ``exception.type`` from the class name, which is + unqualified and so does not distinguish two exception classes of the + same name in different modules; ``exception.message`` from the first + argument of the exception, which is left out for an exception raised + with no arguments; and ``exception.stacktrace`` from the formatted + traceback. + + Args: + record: The record to read. It is not modified. + + Returns: + A plain ``dict``. The values are whatever the record held, with no + limits applied yet, since the limits are applied later when the + record is wrapped. + """ attributes = {k: v for k, v in vars(record).items() if k not in _RESERVED_ATTRS} # Add standard code attributes for logs. @@ -644,6 +1134,29 @@ def _get_attributes(record: logging.LogRecord) -> Attributes: return attributes def _translate(self, record: logging.LogRecord) -> LogRecord: + """Convert one ``logging.LogRecord`` into an OpenTelemetry record. + + The body is chosen in three ways. With a formatter set on the handler, + it is the formatted text. With no formatter and no ``args``, and a + ``msg`` that is not a string, the object itself is used when it is one + of the types an attribute value may hold, and ``str`` of it otherwise; + this is what lets a dictionary logged as the message survive as a + structured body. In every other case it is ``record.getMessage()``, + which interpolates ``args`` into ``msg``. + + The timestamp comes from ``record.created``, the observed timestamp + from the clock at this moment, and the context from the current + context, which is ``None`` when there is none. + + Args: + record: The record to convert. It is not modified. + + Returns: + A new API log record. Its ``event_name`` and ``exception`` are + unset: an exception reaches the output as the ``exception.*`` + attributes rather than as the ``exception`` field, so the SDK does + not derive those attributes a second time. + """ timestamp = int(record.created * 1e9) observered_timestamp = time_ns() attributes = self._get_attributes(record) @@ -710,6 +1223,14 @@ def emit(self, record: logging.LogRecord) -> None: Emit a record. Skip emitting if logger is NoOp. The record is translated to OTel format, and then sent across the pipeline. + + A ``Logger`` is obtained per call, named after ``record.name``, so + records from different Python loggers arrive under different + instrumentation scopes. Translation happens only after the no-op check, + so a disabled SDK costs the lookup and nothing more. + + Args: + record: The ``logging.LogRecord`` to emit. It is not modified. """ logger = get_logger(record.name, logger_provider=self._logger_provider) if not isinstance(logger, NoOpLogger): @@ -730,14 +1251,59 @@ def flush(self) -> None: @dataclass class _LoggerConfig: + """Per-scope switch a ``LoggerProvider`` hands to each ``Logger``. + + A configurator function returns one of these for an instrumentation scope, + which is how a provider turns logging off for one library without touching + the rest. The object is replaced wholesale when the configurator changes; + the field is never edited on an instance a ``Logger`` already holds. + + Args: + is_enabled: Whether the ``Logger`` emits at all. ``False`` makes + ``Logger.emit`` return before counting the record or consulting any + processor, and makes ``Logger.enabled`` answer ``False``. + """ + is_enabled: bool = True @classmethod def default(cls) -> _LoggerConfig: + """Build the configuration a ``Logger`` gets when nothing says otherwise. + + Returns: + A new instance with logging enabled. A new one each call, so a + caller that edits the result does not affect any other + ``Logger``. + """ return _LoggerConfig() class Logger(APILogger): + """SDK implementation of the logs API ``Logger``. + + Obtain one from ``LoggerProvider.get_logger`` rather than constructing it: + the provider is what supplies the resource, the processor pipeline and the + self-observability counters, and what keeps the logger up to date when the + resource or the configuration changes after a fork or a reconfiguration. + + Args: + resource: The resource attached to every record this logger emits. It + is replaced in place when the provider's resource changes, for + instance after a fork. + multi_log_record_processor: The provider's multiplexer, shared with + every other logger of the same provider. Emitting goes through it, + never to a processor directly. + instrumentation_scope: The scope identifying the library that logs + through this logger. It is what a configurator matches on. + logger_metrics: Where the count of created records is reported. + log_record_limits: The limits applied to the attributes of every record + this logger builds. ``None`` builds a fresh ``LogRecordLimits``, + which reads the environment at that moment. + _logger_config: The enabled switch for this scope, produced by the + provider's configurator. Replaced by the provider when the + configurator is changed. + """ + def __init__( self, resource: Resource, @@ -748,6 +1314,11 @@ def __init__( log_record_limits: LogRecordLimits | None = None, _logger_config: _LoggerConfig, ): + """Build the logger. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__( instrumentation_scope.name, instrumentation_scope.version, @@ -768,6 +1339,27 @@ def enabled( severity_number: SeverityNumber | None = None, event_name: str | None = None, ) -> bool: + """Report whether such a record would reach any processor. + + Call this to skip building an expensive body or attribute set. It is + the same question ``emit`` asks itself, so a ``False`` here means a + call to ``emit`` with the same arguments would discard the record. + + Args: + context: The context the record would carry, or ``None`` when the + current context would be used. + severity_number: The severity the record would carry, or ``None``. + event_name: The event name the record would carry, or ``None``. + + Returns: + ``False`` when a configurator disabled this logger's scope, or when + every registered processor declines, which includes the case of no + processor being registered at all. ``True`` otherwise. + + The instrumentation scope passed to the processors is this logger's + own, which differs from what ``emit`` asks when it is handed a + ``ReadWriteLogRecord`` that carries a scope of its own. + """ if not self._logger_config.is_enabled: return False return self._multi_log_record_processor.enabled( @@ -778,17 +1370,51 @@ def enabled( ) def _set_logger_config(self, logger_config: _LoggerConfig) -> None: + """Replace this logger's enabled switch. + + Called by the provider when a new configurator is installed. Takes + effect on the next ``emit``; a record already on its way through the + processors is unaffected. + + Args: + logger_config: The configuration produced by the configurator for + this logger's instrumentation scope. + """ self._logger_config = logger_config def _set_resource(self, resource: Resource) -> None: + """Replace the resource attached to records emitted from now on. + + Called by the provider when its own resource changes, which happens + after a fork so that the child's records carry the child's process + identity. Records already emitted keep the old resource. + + Args: + resource: The provider's current resource, already merged. + """ self._resource = resource @property def instrumentation_scope(self): + """The scope this logger was created for. + + Returns: + The ``InstrumentationScope`` the provider built from the name, + version, schema URL and attributes passed to ``get_logger``. It is + fixed for the life of the logger, and it is what a configurator + matches on. + """ return self._instrumentation_scope @property def resource(self): + """The resource attached to records this logger emits. + + Returns: + The provider's resource as of now. Reading it again after a fork + returns a different object, because the provider merges the child + process's identity in and pushes the result here. + """ return self._resource # pylint: disable=arguments-differ @@ -808,6 +1434,54 @@ def emit( ) -> None: """Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope and forwarding to the processor. + + There are two ways to call this, and they do not mix. Either pass + ``record``, or pass the individual keyword arguments and leave + ``record`` out. When ``record`` is given, every other argument is + ignored, silently: ``context``, ``severity_number`` and ``event_name`` + are overwritten from the record before the processors are consulted, + and ``timestamp``, ``observed_timestamp``, ``severity_text``, ``body``, + ``attributes`` and ``exception`` are never read. + + A record is discarded, and nothing reaches any processor, when a + configurator disabled this logger's scope, or when every registered + processor declines it. The first check happens before the record is + counted, the second after, so the count of created records includes + records that every processor declined. + + What reaches the processors depends on what was passed. A + ``ReadWriteLogRecord`` is forwarded as it stands, keeping its own + resource, limits and instrumentation scope, with its exception + rendered into its attributes in place. A plain ``LogRecord`` carrying + an exception is copied first, so the caller's record keeps its own + attributes; one without an exception is wrapped without being copied, + and wrapping replaces its attribute mapping. Keyword arguments are + built into a new record. In the latter two cases the record gets this + logger's resource, limits and instrumentation scope. + + Args: + record: A ready-made record, either an API ``LogRecord`` or a + ``ReadWriteLogRecord``. ``None`` selects the keyword argument + form. + timestamp: When the event happened, in nanoseconds since the Unix + epoch. ``None`` leaves it unset. + observed_timestamp: When the SDK saw the event, in nanoseconds + since the Unix epoch. ``None`` fills in the current time. + context: The context supplying the trace correlation. ``None`` uses + the current context. + severity_number: The severity, or ``None`` to leave it unset. + severity_text: The severity label of the originating logging + system, or ``None``. + body: The payload of the event. Any attribute value type is + accepted, including a mapping or a sequence. + attributes: Attributes to attach, before the limits are applied. A + key given here survives even when it collides with one of the + derived ``exception.*`` attributes. + event_name: The name identifying the class of event, or ``None`` + for an unnamed record. + exception: An exception to render into ``exception.type``, + ``exception.message`` and ``exception.stacktrace``. ``None`` + adds none of them. """ if not self._logger_config.is_enabled: return @@ -878,16 +1552,88 @@ def emit( def _default_logger_configurator( _logger_scope: InstrumentationScope, ) -> _LoggerConfig: + """Enable every scope. + + What a ``LoggerProvider`` uses when the caller installed no configurator of + its own. + + Args: + _logger_scope: The scope being configured. Ignored, which is what makes + the answer the same for every logger. + + Returns: + A configuration with logging enabled. + """ return _LoggerConfig.default() def _disable_logger_configurator( _logger_scope: InstrumentationScope, ) -> _LoggerConfig: + """Disable every scope. + + Install this to silence a whole ``LoggerProvider`` without unregistering + its processors, or use it as the fallback of a rule-based configurator so + that a scope matching no rule stays quiet. + + Args: + _logger_scope: The scope being configured. Ignored, which is what makes + the answer the same for every logger. + + Returns: + A configuration with logging disabled. + """ return _LoggerConfig(is_enabled=False) class LoggerProvider(APILoggerProvider): + """SDK implementation of the logs API ``LoggerProvider``. + + Owns the resource, the processor pipeline and the limits that every + ``Logger`` it hands out shares. Build one, register processors on it, then + either set it as the global provider or pass it where a provider is + wanted. + + Loggers are cached by name, version and schema URL, so repeated + ``get_logger`` calls with the same three return one object, and that cache + keeps them alive for the life of the provider. Every logger handed out, + cached or not, is additionally held in a weak set, which is what lets the + provider push a new resource or a new configuration into loggers already in + use. + + When ``os.register_at_fork`` exists, the provider re-creates its locks in a + forked child and merges the child's process identity into the resource, so + records emitted after the fork carry the child's process id. The handler + is registered through a weak reference, so a provider that is garbage + collected does not keep being called at fork. + + Args: + resource: The resource attached to every record. ``None`` builds an + empty one, which still picks up the default resource detectors. + shutdown_on_exit: Whether to register ``shutdown`` with ``atexit``, so + that a program that ends without shutting down still flushes. + ``False`` leaves flushing entirely to the caller. + multi_log_record_processor: The multiplexer that holds the registered + processors. ``None`` builds a ``SynchronousMultiLogRecordProcessor``, + which runs them one after another. Pass a + ``ConcurrentMultiLogRecordProcessor`` to run them in parallel. + meter_provider: Provider for the SDK's own counters about logging. + ``None`` uses the globally configured meter provider. Nothing is + recorded at all unless + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` is set to true. + log_record_limits: The attribute limits applied to every record. + ``None`` builds a fresh ``LogRecordLimits``, which reads the + environment at that moment. + _logger_configurator: Decides, per instrumentation scope, whether its + logger emits. ``None`` enables every scope. It is called once per + logger created and again for every outstanding logger whenever a + new configurator is installed, so it has to return quickly. + + Setting ``OTEL_SDK_DISABLED`` to true makes ``get_logger`` return a no-op + logger for every scope. The flag is read once, here, and the processors are + still constructed and registered. + """ + def __init__( self, resource: Resource | None = None, @@ -900,6 +1646,11 @@ def __init__( log_record_limits: LogRecordLimits | None = None, _logger_configurator: _LoggerConfiguratorT | None = None, ): + """Build the provider and register its exit and fork handlers. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ if resource is None: self._resource = Resource.create({}) else: @@ -924,21 +1675,57 @@ def __init__( weak_at_fork = WeakMethod(self._handle_fork) def _after_in_child() -> None: + """Run the provider's fork handler in the freshly forked child. + + ``os.register_at_fork`` keeps a strong reference to whatever is handed + to it, so this closure holds the provider only weakly. A provider that + has been garbage collected leaves the weak reference empty and the + handler does nothing. + """ if at_fork := weak_at_fork(): at_fork() os.register_at_fork(after_in_child=_after_in_child) def _handle_fork(self) -> None: + """Repair the provider inside a freshly forked child process. + + Both locks are replaced, because a lock held by a thread that did not + survive the fork would stay held for good. The logger cache itself is + kept, so a logger obtained before the fork is still returned after it. + The process-dependent resource attributes, such as the process id, are + then merged in so that the child's records identify the child. + + Registered as an ``after_in_child`` fork handler, through a weak + reference, so it stops being called once the provider is collected. + """ self._logger_cache_lock = Lock() self._active_loggers_lock = Lock() self._update_resource(_get_process_dependent_resource()) @property def resource(self): + """The resource attached to every record from this provider. + + Returns: + The current resource. It is replaced, not edited, when the process + identity is merged in after a fork, so a reference taken earlier + keeps the older contents. + """ return self._resource def _update_resource(self, resource: Resource) -> None: + """Merge attributes into the resource and push the result to loggers. + + Every logger handed out and not yet collected is updated, so the change + reaches code holding a logger it obtained earlier. A logger created + after this picks the new resource up from the provider directly. + + Args: + resource: The attributes to merge in. They win over the ones the + provider already had, which is what lets the forked child's + process id replace the parent's. + """ with self._active_loggers_lock: self._resource = self._resource.merge(resource) for logger in list(self._active_loggers): @@ -952,6 +1739,25 @@ def _get_logger_no_cache( schema_url: str | None = None, attributes: Attributes = None, ) -> Logger: + """Build a new ``Logger`` without consulting or filling the cache. + + The configurator runs here, once, so the logger's enabled state is + decided at creation rather than at each emit. + + Args: + name: Name of the instrumented library, which becomes the + instrumentation scope name. + version: Version of the instrumented library, or ``None``. + schema_url: URL of the schema the emitted records follow, or + ``None``. + attributes: Attributes describing the scope itself, as opposed to + the records. ``None`` means none. + + Returns: + A new ``Logger`` sharing the provider's resource, multiplexer, + counters and limits. Two calls with the same arguments return two + distinct loggers. + """ scope = InstrumentationScope(name, version, schema_url, attributes) return Logger( @@ -969,6 +1775,25 @@ def _get_logger_cached( version: str | None = None, schema_url: str | None = None, ) -> Logger: + """Return the cached ``Logger`` for a scope, creating it if needed. + + The cache is keyed on the three arguments together and is never + evicted, so a program that invents logger names at runtime grows it + without bound. It survives a fork, so a child keeps returning the + loggers the parent cached. + + Args: + name: Name of the instrumented library. + version: Version of the instrumented library, or ``None``. It is + part of the key, so two versions get two loggers. + schema_url: URL of the schema the emitted records follow, or + ``None``. Also part of the key. + + Returns: + The one ``Logger`` for that key. Scope attributes cannot be given + here, which is why a caller that passes them is sent down the + uncached path instead. + """ with self._logger_cache_lock: key = (name, version, schema_url) if key in self._logger_cache: @@ -984,6 +1809,26 @@ def get_logger( schema_url: str | None = None, attributes: Attributes = None, ) -> APILogger: + """Obtain a ``Logger`` for one instrumented library. + + Args: + name: Name of the instrumented library. Convention is the module or + package doing the logging. + version: Version of the instrumented library, or ``None``. + schema_url: URL of the schema the emitted records follow, or + ``None``. + attributes: Attributes describing the scope itself. Passing + anything other than ``None``, including an empty mapping, skips + the cache and returns a fresh logger on every call. + + Returns: + A ``NoOpLogger`` when ``OTEL_SDK_DISABLED`` was true at the time + this provider was built, in which case nothing emitted through it + reaches a processor. Otherwise an SDK ``Logger``, the cached one + when ``attributes`` is ``None`` and a new one when it is not. + Either way the logger is tracked weakly, so later resource and + configuration changes reach it. + """ if self._disabled: return NoOpLogger( name, @@ -1004,6 +1849,14 @@ def add_log_record_processor(self, log_record_processor: LogRecordProcessor): """Registers a new :class:`LogRecordProcessor` for this `LoggerProvider` instance. The log processors are invoked in the same order they are registered. + + Registering takes effect at once, including for loggers already handed + out, because every logger goes through the one multiplexer rather than + holding processors of its own. There is no way to unregister one. + + Args: + log_record_processor: The processor to register. Registering the + same one twice makes it receive each record twice. """ self._multi_log_record_processor.add_log_record_processor(log_record_processor) @@ -1013,6 +1866,16 @@ def _set_logger_configurator(self, *, logger_configurator: _LoggerConfiguratorT) Setting a new LoggerConfigurator will result in the configurator being called for each outstanding Logger and for any newly created loggers thereafter. Therefore, it is important that the provided function returns quickly. + + Loggers are reconfigured one at a time while the active-logger lock is + held, so a logger reached early in the pass is already on the new + configuration while a later one is still on the old. + + Args: + logger_configurator: Maps an instrumentation scope to its + configuration. An exception from it is caught per logger and + that logger falls back to the default configuration, so one bad + scope does not disturb the rest. """ self._logger_configurator = logger_configurator with self._active_loggers_lock: @@ -1021,6 +1884,22 @@ def _set_logger_configurator(self, *, logger_configurator: _LoggerConfiguratorT) logger._set_logger_config(self._apply_logger_configurator(logger.instrumentation_scope)) def _apply_logger_configurator(self, instrumentation_scope: InstrumentationScope) -> _LoggerConfig: + """Run the configurator for one scope, surviving a failure in it. + + A configurator comes from outside the SDK, so it is not trusted to + return without raising. Letting it break ``get_logger`` would turn a + configuration mistake into an application crash. + + Args: + instrumentation_scope: The scope to configure. + + Returns: + Whatever the configurator returned. When it raised, the exception + and its traceback are logged and the default configuration, with + logging enabled, is returned instead. A scope the author meant to + silence is therefore left noisy rather than silent if the + configurator fails on it. + """ try: return self._logger_configurator(instrumentation_scope) # pylint: disable-next=broad-exception-caught @@ -1032,7 +1911,21 @@ def _apply_logger_configurator(self, instrumentation_scope: InstrumentationScope return _LoggerConfig.default() def shutdown(self) -> None: - """Shuts down the log processors.""" + """Shuts down the log processors. + + Each registered processor is shut down, which for a batching processor + means draining its queue and shutting its exporter down. Records + emitted afterwards are dropped by the processors rather than refused + here, so emitting still succeeds and still costs the work of building + the record. + + The ``atexit`` registration is removed, so a shutdown done by hand is + not repeated at interpreter exit. That part is idempotent. The rest is + not: a second call reaches every processor again, and the processors in + this SDK differ in how they take that. A ``BatchLogRecordProcessor`` + returns at once, while a ``SimpleLogRecordProcessor`` shuts its + exporter down a second time. + """ self._multi_log_record_processor.shutdown() if self._at_exit_handler is not None: atexit.unregister(self._at_exit_handler) @@ -1043,11 +1936,27 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: Args: timeout_millis: The maximum amount of time to wait for logs to be - exported. + exported. The budget is shared: with a + ``SynchronousMultiLogRecordProcessor`` each processor is given + what the ones before it left, and once it is gone the remaining + processors are not flushed at all. A + ``ConcurrentMultiLogRecordProcessor`` gives the whole budget to + every processor at once. Either way the value is a request, + since ``BatchLogRecordProcessor`` ignores it and blocks until + its queue is empty. Returns: True if all the log processors flushes the logs within timeout, False otherwise. + + ``False`` says a flush did not complete. It does not say which + processor, nor whether the records it held were exported, dropped + or are still queued. The three causes are a processor answering + ``False``, a processor not finishing in time, and a processor that + was already shut down, which is what a ``BatchLogRecordProcessor`` + reports after ``shutdown``. ``True`` only means every processor + claimed to have handed its records to its exporter; an export that + failed is logged and still counts as a successful flush. """ return self._multi_log_record_processor.force_flush(timeout_millis) @@ -1104,6 +2013,24 @@ def std_to_otel(levelno: int) -> SeverityNumber: """ Map python log levelno as defined in https://docs.python.org/3/library/logging.html#logging-levels to OTel log severity number as defined here: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#field-severitynumber + + The five standard Python levels land on the base of each OpenTelemetry + range: 10 on ``DEBUG``, 20 on ``INFO``, 30 on ``WARN``, 40 on ``ERROR`` and + 50 on ``FATAL``. A custom level in between is mapped within the same range, + using the numbered variants, so 21 gives ``INFO2`` and 22 gives ``INFO3``. + Each range has only four variants while Python leaves ten numbers between + its levels, so the fourth variant absorbs the rest: every level from 23 to + 29 gives ``INFO4``. + + Args: + levelno: A ``logging`` level number. Any integer is accepted, including + a custom level that no ``logging`` constant names. + + Returns: + The matching severity number. ``SeverityNumber.UNSPECIFIED`` for + anything below 10, which covers ``logging.NOTSET``, and + ``SeverityNumber.FATAL4`` for anything above 53, since there is no + range beyond fatal to grow into. """ if levelno < 10: return SeverityNumber.UNSPECIFIED diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py index bd4a9538d00..1cf81c3d6f4 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py @@ -1,5 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Turning an exception attached to a log record into attributes. + +``LogRecord`` carries an ``exception`` field holding a live ``BaseException``. +Nothing downstream can serialise that, so before a record reaches a processor +the SDK derives the three ``exception.*`` semantic convention attributes from +it. The helpers here do that derivation in the three shapes ``Logger.emit`` +needs: build a fresh record, copy an existing one, or edit one in place. + +An attribute the caller set by hand always wins over the derived value, in +every one of those shapes. +""" + from __future__ import annotations import traceback @@ -13,6 +25,23 @@ def _get_exception_attributes( exception: BaseException, ) -> dict[str, AnyValue]: + """Derive the three ``exception.*`` attributes from an exception. + + The type is qualified with the defining module, so a ``ValueError`` comes + out as ``ValueError`` while a custom class comes out as + ``mypackage.mymodule.MyError``. Builtins are left unqualified. + + Args: + exception: The exception to describe. Its ``__traceback__`` supplies + the stack trace, and an exception that was never raised has none, + which yields a one-line trace holding only the exception itself. + + Returns: + A mapping with ``exception.type``, ``exception.message`` and + ``exception.stacktrace``. The message is ``str(exception)``, which is + the empty string for an exception constructed with no arguments. All + three keys are always present. + """ stacktrace = "".join(traceback.format_exception(type(exception), value=exception, tb=exception.__traceback__)) module = type(exception).__module__ qualname = type(exception).__qualname__ @@ -28,6 +57,29 @@ def _get_attributes_with_exception( attributes: Attributes, exception: BaseException | None, ) -> Attributes: + """Merge the derived exception attributes under the caller's attributes. + + A key the caller already set is never overwritten, so passing + ``exception.message`` explicitly keeps that text and discards the one + derived from the exception. + + Args: + attributes: The attributes already on the record. ``None`` is treated + as an empty mapping. + exception: The exception to describe, or ``None`` to leave + ``attributes`` untouched. + + Returns: + ``attributes`` itself, unchanged and possibly ``None``, when + ``exception`` is ``None``. A new ``BoundedAttributes`` carrying the + same ``maxlen`` and ``max_value_len`` when ``attributes`` was one, + which means the exception attributes are themselves subject to those + limits and can be dropped or truncated. Its ``dropped`` count starts + from the input's and is then raised by however many of the three + exception attributes the count limit refused, so it is at least the + input's count rather than equal to it. A plain ``dict`` otherwise. + The input mapping is never modified. + """ if exception is None: return attributes @@ -58,6 +110,21 @@ def _copy_log_record( record: LogRecord, attributes: Attributes, ) -> LogRecord: + """Shallow-copy a log record, swapping in a different attribute mapping. + + Every other field is carried over by reference. ``trace_id``, ``span_id`` + and ``trace_flags`` are reassigned after construction because the + ``LogRecord`` constructor otherwise re-derives them from ``context``, which + would lose the values a caller set by hand. + + Args: + record: The record to copy. It is not modified. + attributes: The attribute mapping the copy gets. Passing the mapping of + ``record`` makes the two records share it. + + Returns: + The new record. + """ copied_record = LogRecord( timestamp=record.timestamp, observed_timestamp=record.observed_timestamp, @@ -76,6 +143,21 @@ def _copy_log_record( def _copy_log_record_with_exception(record: LogRecord) -> LogRecord: + """Copy a log record with its exception rendered into attributes. + + ``Logger.emit`` uses this when the caller hands it a record it does not + own, so that the caller's record keeps the attribute mapping it had. + + Args: + record: The record to copy. It is not modified, including its + attributes. + + Returns: + A new record whose attributes carry ``exception.type``, + ``exception.message`` and ``exception.stacktrace``. When + ``record.exception`` is ``None`` the copy shares the original attribute + mapping rather than getting one of its own. + """ return _copy_log_record( record, _get_attributes_with_exception(record.attributes, record.exception), @@ -83,6 +165,17 @@ def _copy_log_record_with_exception(record: LogRecord) -> LogRecord: def _set_log_record_exception_attributes(record: LogRecord) -> None: + """Render a log record's exception into its attributes, in place. + + ``Logger.emit`` uses this on a ``ReadWriteLogRecord``, which the SDK built + itself and is free to edit. The attribute mapping is replaced rather than + added to, so a ``BoundedAttributes`` mapping is swapped for a new one that + inherits its limits and its dropped count. + + Args: + record: The record to edit. Nothing happens when its ``exception`` is + ``None``. + """ record.attributes = _get_attributes_with_exception( record.attributes, record.exception, @@ -101,6 +194,37 @@ def _create_log_record_with_exception( event_name: str | None = None, exception: BaseException | None = None, ) -> LogRecord: + """Build a log record from loose arguments, folding in an exception. + + ``Logger.emit`` uses this on the path where the caller passed individual + keyword arguments instead of a ready-made record. + + Args: + timestamp: When the event happened, in nanoseconds since the Unix + epoch. ``None`` leaves the field unset, and a consumer is then + expected to fall back to ``observed_timestamp``. + observed_timestamp: When the SDK saw the event, in nanoseconds since + the Unix epoch. ``None`` makes ``LogRecord`` fill it with the + current time. + context: The context to take the trace id, span id and trace flags + from. ``None`` makes ``LogRecord`` use the current context. + severity_number: The severity as an ``opentelemetry._logs`` + ``SeverityNumber``, or ``None`` to leave it unset. + severity_text: The original severity label of the logging system the + record came from, or ``None``. + body: The payload of the event. Any attribute value type is accepted, + including a mapping or a sequence. + attributes: Attributes to attach. A key given here survives even when + the derived exception attributes use the same key. + event_name: The name identifying the class of event, or ``None`` for an + unnamed log record. + exception: The exception to describe in ``exception.*`` attributes. It + is also stored on the returned record, so the field still holds the + live exception object afterwards. + + Returns: + The new record. + """ return LogRecord( timestamp=timestamp, observed_timestamp=observed_timestamp, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_logger_metrics.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_logger_metrics.py index 1b068c537a0..ff0e73b5732 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_logger_metrics.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_logger_metrics.py @@ -1,5 +1,12 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Self-observability counters for the logs SDK. + +Holds the one metric the ``Logger`` records about itself, the count of log +records it has created, and a do-nothing stand-in used when self-observability +is switched off. ``Logger.emit`` calls ``emit_log`` once per record, so the +stand-in exists to keep that call cheap in the default configuration. +""" from typing import Protocol @@ -10,20 +17,57 @@ class LoggerMetricsT(Protocol): - def emit_log(self) -> None: ... + """What ``Logger`` requires of the object it reports its own counts to.""" + + def emit_log(self) -> None: + """Record that one log record has been created.""" class NoOpLoggerMetrics: + """Implementation of ``LoggerMetricsT`` that records nothing. + + Used when ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` is not set to true, + which is the default. No instrument is created, so no meter provider is + touched. + """ + def emit_log(self) -> None: - pass + """Do nothing. + + Present so that ``Logger`` can call it unconditionally instead of + testing whether self-observability is switched on. + """ class LoggerMetrics: + """Implementation of ``LoggerMetricsT`` backed by a real counter. + + Creates the ``otel.sdk.log.created`` counter on a meter named + ``opentelemetry-sdk`` when it is built, so the instrument exists even + before the first log record is emitted. + + Args: + meter_provider: The provider to obtain the meter from. Pointing it at + the same provider that exports application metrics is what makes + these counts show up alongside them. + """ + def __init__(self, meter_provider: metrics_api.MeterProvider) -> None: + """Build the instance and create the counter. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ meter = meter_provider.get_meter("opentelemetry-sdk") self._created_logs = create_otel_sdk_log_created(meter) def emit_log(self) -> None: + """Add one to the ``otel.sdk.log.created`` counter. + + Counts records the ``Logger`` accepted for processing. A record the + processors later drop, because the queue was full or the processor was + already shut down, has still been counted here. + """ self._created_logs.add(1) @@ -31,6 +75,20 @@ def create_logger_metrics( meter_provider: metrics_api.MeterProvider, enabled: bool, ) -> LoggerMetricsT: + """Pick the recording or the do-nothing implementation. + + Args: + meter_provider: The provider the counter is created on. Ignored when + ``enabled`` is false, so passing a provider costs nothing in the + default configuration. + enabled: Whether to record at all. ``LoggerProvider`` passes the parsed + value of ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED``. + + Returns: + A ``LoggerMetrics`` when ``enabled`` is true, otherwise a + ``NoOpLoggerMetrics``. The choice is made once, at ``LoggerProvider`` + construction, and cannot be changed afterwards. + """ if not enabled: return NoOpLoggerMetrics() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/__init__.py index f8c466ddf5a..a3b857fb0e6 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/__init__.py @@ -1,5 +1,20 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Sending log records out of the SDK. + +Holds the ``LogRecordExporter`` interface that an exporter implements, the +console exporter the SDK ships, and the two processors that stand between +``Logger`` and an exporter: ``SimpleLogRecordProcessor``, which exports each +record on the emitting thread, and ``BatchLogRecordProcessor``, which queues +records and exports them from a background thread. + +The module-level logger used for reporting export failures carries a +``DuplicateFilter``, which suppresses a message identical to the one it just +let through until the clock reaches the next twenty-second boundary. An export +that keeps failing the same way is therefore reported every twenty seconds +rather than once per batch. +""" + from __future__ import annotations import abc @@ -63,12 +78,27 @@ class LogRecordExportResult(enum.Enum): + """Outcome an exporter reports for one batch. + + ``FAILURE`` covers everything that went wrong, with no distinction between + a failure worth retrying and a permanent one. Neither processor in this + module retries or reports the value further, so an exporter that wants a + retry has to do it inside ``export``. + """ + SUCCESS = 0 FAILURE = 1 @deprecated("Use LogRecordExportResult. Since logs are not stable yet this WILL be removed in future releases.") class LogExportResult(enum.Enum): + """Deprecated alias of ``LogRecordExportResult``. + + It is a separate enum rather than a subclass, so its members do not compare + equal to the members of ``LogRecordExportResult``. Code that checks an + exporter's return value against this enum will not match a modern exporter. + """ + SUCCESS = 0 FAILURE = 1 @@ -133,7 +163,11 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: @deprecated("Use LogRecordExporter. Since logs are not stable yet this WILL be removed in future releases.") class LogExporter(LogRecordExporter): - pass + """Deprecated alias of ``LogRecordExporter``. + + A subclass, so an exporter written against this name is still accepted + everywhere a ``LogRecordExporter`` is expected. + """ class ConsoleLogRecordExporter(LogRecordExporter): @@ -142,6 +176,21 @@ class ConsoleLogRecordExporter(LogRecordExporter): This class can be used for diagnostic purposes. It prints the exported log records to the console STDOUT. + + Both attributes are plain instance attributes with no validation, so + reassigning ``out`` or ``formatter`` after construction takes effect on the + next export. + + Args: + out: The stream to write to. The default is bound when this module is + imported rather than when an exporter is built, so replacing + ``sys.stdout`` at any point after that import does not redirect an + exporter that was built with the default. Pass the stream + explicitly to write anywhere else. + formatter: Turns one record into the text written for it. The default + emits the indented JSON of ``ReadableLogRecord.to_json`` followed + by the platform line separator. A formatter that returns text + without a trailing separator runs its records together. """ def __init__( @@ -149,25 +198,56 @@ def __init__( out: IO = sys.stdout, formatter: Callable[[ReadableLogRecord], str] = lambda record: record.to_json() + linesep, ): + """Build the exporter. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self.out = out self.formatter = formatter def export(self, batch: Sequence[ReadableLogRecord]): + """Write each record to the stream and flush it. + + Args: + batch: The records to write, in the order they are to appear. + + Returns: + ``LogRecordExportResult.SUCCESS``, always. A write that fails + raises instead, and the calling processor is the one that has to + catch it. + """ for log_record in batch: self.out.write(self.formatter(log_record)) self.out.flush() return LogRecordExportResult.SUCCESS def shutdown(self): - pass + """Do nothing. + + The stream is not closed, since the exporter does not own it, and + writes after this still reach it. + """ def force_flush(self, timeout_millis: int = 30000) -> bool: + """Do nothing, since ``export`` already flushed the stream. + + Args: + timeout_millis: Ignored. Accepted so that the signature matches + ``LogRecordExporter``. + + Returns: + ``True``, always. + """ return True @deprecated("Use ConsoleLogRecordExporter. Since logs are not stable yet this WILL be removed in future releases.") class ConsoleLogExporter(ConsoleLogRecordExporter): - pass + """Deprecated alias of ``ConsoleLogRecordExporter``. + + Same constructor, same behaviour. + """ class SimpleLogRecordProcessor(LogRecordProcessor): @@ -180,6 +260,20 @@ class SimpleLogRecordProcessor(LogRecordProcessor): demonstrating proper error handling. Note how the ``on_emit`` method wraps the exporter call in a try/except block to prevent exceptions from propagating to the application. + + Exporting happens on whatever thread called ``Logger.emit``, so the cost of + the export, including any network round trip, is paid by the application + code that logged. Each record is exported on its own, in a batch of one. + ``BatchLogRecordProcessor`` is the alternative for anything other than + tests and short-lived programs. + + Args: + exporter: Where records are sent. ``shutdown`` is forwarded to it. + meter_provider: Provider for the processor's own self-observability + counters, which record how many records were submitted to the + exporter and how many were dropped. ``None`` uses the globally + configured meter provider. Nothing is recorded at all unless + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` is set to true. """ def __init__( @@ -188,6 +282,11 @@ def __init__( *, meter_provider: MeterProvider | None = None, ): + """Build the processor. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._exporter = exporter self._shutdown = False self._metrics = create_processor_metrics( @@ -198,6 +297,30 @@ def __init__( ) def on_emit(self, log_record: ReadWriteLogRecord): + """Export one record immediately, on the calling thread. + + The record is narrowed to a ``ReadableLogRecord`` and handed to the + exporter as a one-element batch. Instrumentation is suppressed for the + duration, so telemetry the exporter itself produces is not re-emitted. + + A record is dropped, and the drop counted, in two cases. The first is + reaching a recursion depth above three, which happens when exporting a + record causes another record to be emitted; the warning about it is + written to a logger with propagation switched off, so it does not feed + the loop it is reporting. The second is the processor having been shut + down. + + An exception from the exporter is caught and logged rather than + allowed to reach the code that called ``Logger.emit``. + + Args: + log_record: The record to export. Its ``resource`` is used as is, + and an unset one is replaced with ``Resource.create({})`` + rather than treated as an error. That replacement is not + empty: the default detectors run, so it carries the + ``telemetry.sdk.*``, ``service.name`` and + ``service.instance.id`` attributes. + """ # Prevent entering a recursive loop. cnt = get_value(_ON_EMIT_RECURSION_COUNT_KEY) or 0 # Recursive depth of 3 is sort of arbitrary. It's possible that an Exporter.export call @@ -240,10 +363,28 @@ def on_emit(self, log_record: ReadWriteLogRecord): detach(token) def shutdown(self): + """Stop accepting records and shut the exporter down. + + Nothing is buffered, so there is nothing to flush first. Later calls to + ``on_emit`` log a warning and count a drop. + + The call is not guarded against repetition: calling it twice calls + ``shutdown`` on the exporter twice, which matters for an exporter whose + own ``shutdown`` is not idempotent. + """ self._shutdown = True self._exporter.shutdown() def force_flush(self, timeout_millis: int = 30000) -> bool: # pylint: disable=no-self-use + """Do nothing, since every record was exported as it was emitted. + + Args: + timeout_millis: Ignored. Accepted so that the signature matches + ``LogRecordProcessor``. + + Returns: + ``True``, always, including after ``shutdown``. + """ return True @@ -260,6 +401,47 @@ class BatchLogRecordProcessor(LogRecordProcessor): - :envvar:`OTEL_BLRP_EXPORT_TIMEOUT` All the logic for emitting logs, shutting down etc. resides in the BatchProcessor class. + + A background daemon thread drains the queue. It wakes on the schedule + delay, and early whenever the queue reaches ``max_export_batch_size`` or + the schedule delay is zero. Records are exported oldest first. + + When the queue is already at ``max_queue_size``, the new record is still + added and the oldest queued record is discarded to make room; the drop is + counted and a warning logged. After a fork, the child clears whatever the + queue held at the moment of the fork and starts a fresh worker thread, so + records queued in the parent and not yet exported are lost in the child. + The parent is unaffected. + + Args: + exporter: Where batches are sent. The worker thread calls its + ``export``, and an exception from it is caught and logged. + schedule_delay_millis: How long the worker waits between drains, in + milliseconds. ``None`` reads ``OTEL_BLRP_SCHEDULE_DELAY``, + falling back to 1000. Zero means export on every emit, which + removes the batching. + max_export_batch_size: Largest number of records handed to ``export`` + in one call. ``None`` reads + ``OTEL_BLRP_MAX_EXPORT_BATCH_SIZE``, falling back to 512. + export_timeout_millis: Not used. No way currently exists to pass a + timeout to ``export``, so the value is validated and then ignored. + ``None`` reads ``OTEL_BLRP_EXPORT_TIMEOUT``, falling back to + 30000. + max_queue_size: How many records may wait to be exported. ``None`` + reads ``OTEL_BLRP_MAX_QUEUE_SIZE``, falling back to 2048. + meter_provider: Provider for the processor's own self-observability + instruments, which report the queue length and its capacity and + count submitted and dropped records. ``None`` uses the globally + configured meter provider. Nothing is recorded at all unless + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` is set to true. + + Raises: + ValueError: If ``max_queue_size`` or ``max_export_batch_size`` is not + positive, if ``schedule_delay_millis`` is negative, if + ``export_timeout_millis`` is not positive, or if + ``max_export_batch_size`` exceeds ``max_queue_size``. A value that + came from an environment variable and did not parse as an integer + does not raise: the default is used and the failure is logged. """ def __init__( @@ -272,6 +454,15 @@ def __init__( *, meter_provider: MeterProvider | None = None, ): + """Build the processor and start its worker thread. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: On an invalid combination of sizes and delays. The + class docstring lists each case. + """ if max_queue_size is None: max_queue_size = BatchLogRecordProcessor._default_max_queue_size() @@ -308,6 +499,28 @@ def __init__( ) def on_emit(self, log_record: ReadWriteLogRecord) -> None: + """Put one record on the queue, to be exported by the worker thread. + + The API log record is shallow-copied and the copy's context replaced + with an empty one, so that a queued record does not keep a whole + ``Context`` alive while it waits. The record the caller passed keeps + its context, which is what the other processors registered on the same + provider still see. The trace id, span id and trace flags were already + resolved onto the record, so dropping the context does not lose the + correlation. + + The record is discarded, and the drop counted, when the processor has + already been shut down. When the queue is full the oldest queued record + is discarded instead and this one is kept. + + Args: + log_record: The record to queue. Its ``resource`` is used as is, + and an unset one is replaced with ``Resource.create({})`` + rather than treated as an error. That replacement is not + empty: the default detectors run, so it carries the + ``telemetry.sdk.*``, ``service.name`` and + ``service.instance.id`` attributes. + """ # Convert ReadWriteLogRecord to ReadableLogRecord before passing to BatchProcessor # Note: resource should not be None at this point as it's set during Logger.emit() resource = log_record.resource if log_record.resource is not None else Resource.create({}) @@ -325,13 +538,44 @@ def on_emit(self, log_record: ReadWriteLogRecord) -> None: return self._batch_processor.emit(readable_log_record) def shutdown(self): + """Drain the queue, stop the worker thread and shut the exporter down. + + The worker is woken, given up to thirty seconds to finish, and then + prevented from starting another export even if records remain queued. + The exporter's ``shutdown`` is then called with whatever is left of + those thirty seconds, when its signature accepts a timeout. Later calls + to ``on_emit`` are ignored and counted as drops. + + The call is idempotent: a second call returns at once, exports nothing + and does not touch the exporter again. + """ return self._batch_processor.shutdown() def force_flush(self, timeout_millis: int = 30000) -> bool: + """Export everything queued, blocking until the queue is empty. + + Args: + timeout_millis: Ignored. The flush runs to completion however long + it takes, so a slow exporter blocks the caller past this value. + + Returns: + ``False`` when the processor has already been shut down, in which + case nothing is exported. ``True`` otherwise, once the queue has + been drained. ``True`` does not mean the exporter accepted the + records: a failing export is logged and still reports success here. + """ return self._batch_processor.force_flush(timeout_millis) @staticmethod def _default_max_queue_size(): + """Read the queue capacity from the environment. + + Returns: + The integer value of ``OTEL_BLRP_MAX_QUEUE_SIZE``, or 2048 when + that variable is unset or does not parse as an integer. A value + that does not parse is reported through the module logger and then + ignored. + """ try: return int(environ.get(OTEL_BLRP_MAX_QUEUE_SIZE, _DEFAULT_MAX_QUEUE_SIZE)) except ValueError: @@ -344,6 +588,14 @@ def _default_max_queue_size(): @staticmethod def _default_schedule_delay_millis(): + """Read the delay between drains from the environment. + + Returns: + The integer value of ``OTEL_BLRP_SCHEDULE_DELAY`` in milliseconds, + or 1000 when that variable is unset or does not parse as an + integer. A value that does not parse is reported through the module + logger and then ignored. + """ try: return int(environ.get(OTEL_BLRP_SCHEDULE_DELAY, _DEFAULT_SCHEDULE_DELAY_MILLIS)) except ValueError: @@ -356,6 +608,14 @@ def _default_schedule_delay_millis(): @staticmethod def _default_max_export_batch_size(): + """Read the largest batch size from the environment. + + Returns: + The integer value of ``OTEL_BLRP_MAX_EXPORT_BATCH_SIZE``, or 512 + when that variable is unset or does not parse as an integer. A + value that does not parse is reported through the module logger and + then ignored. + """ try: return int( environ.get( @@ -373,6 +633,17 @@ def _default_max_export_batch_size(): @staticmethod def _default_export_timeout_millis(): + """Read the export timeout from the environment. + + The value is validated and stored but never applied, since the exporter + interface has no way to receive a per-export timeout. + + Returns: + The integer value of ``OTEL_BLRP_EXPORT_TIMEOUT`` in milliseconds, + or 30000 when that variable is unset or does not parse as an + integer. A value that does not parse is reported through the module + logger and then ignored. + """ try: return int(environ.get(OTEL_BLRP_EXPORT_TIMEOUT, _DEFAULT_EXPORT_TIMEOUT_MILLIS)) except ValueError: @@ -390,6 +661,29 @@ def _validate_arguments( max_export_batch_size, export_timeout_millis=None, ): + """Reject a combination of sizes and delays the processor cannot use. + + Called once from the constructor, after the environment variables and + the defaults have been resolved, so it sees the values that will + actually be used rather than what the caller passed. + + Args: + max_queue_size: Queue capacity in records. + schedule_delay_millis: Delay between drains in milliseconds. Zero + is allowed and turns batching off. + max_export_batch_size: Largest batch handed to the exporter. + export_timeout_millis: Export timeout in milliseconds, or ``None`` + to skip the check on it. The constructor always passes a + number, so the ``None`` case only arises when this is called + directly. + + Raises: + ValueError: If ``max_queue_size`` is not positive, if + ``schedule_delay_millis`` is negative, if + ``max_export_batch_size`` is not positive or exceeds + ``max_queue_size``, or if ``export_timeout_millis`` is given + and is not positive. + """ if max_queue_size <= 0: raise ValueError("max_queue_size must be a positive integer.") diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/in_memory_log_exporter.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/in_memory_log_exporter.py index 580fd605098..23002c5288d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/in_memory_log_exporter.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/in_memory_log_exporter.py @@ -1,5 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Log record exporter that keeps everything in a list. + +Written for tests: register the exporter on a processor, run the code under +test, then read back the records it produced. + +It sits in a module of its own rather than in +``opentelemetry.sdk._logs._internal.export`` because it imports the public +``opentelemetry.sdk._logs.export``, which imports that private module. +``opentelemetry.sdk._logs.export`` therefore re-exports it from here directly. +""" import collections.abc import threading @@ -19,22 +29,54 @@ class InMemoryLogRecordExporter(LogRecordExporter): This class can be used for testing purposes. It stores the exported logs in a list in memory that can be retrieved using the :meth:`.get_finished_logs` method. + + Records accumulate until ``clear`` is called, so a long-running test leaks + memory in proportion to how much it logs. The stored records are the ones + the processor handed over, not copies, so mutating one afterwards changes + what a later read returns. """ def __init__(self) -> None: + """Build the exporter with an empty, not yet shut down, store.""" self._logs = [] self._lock = threading.Lock() self._stopped = False def clear(self) -> None: + """Discard every record collected so far. + + Useful between phases of one test. It does not reset the shutdown flag, + so an exporter that was shut down keeps rejecting exports after this. + """ with self._lock: self._logs.clear() def get_finished_logs(self) -> tuple[ReadableLogRecord, ...]: + """Read back the records collected so far. + + Call ``force_flush`` on the processor first when a + ``BatchLogRecordProcessor`` is in use, since a record still sitting in + its queue has not reached this exporter yet. + + Returns: + The records in the order they were exported, oldest first. The + tuple is a snapshot, so a later export does not grow it, but the + records inside it are shared with the exporter. + """ with self._lock: return tuple(self._logs) def export(self, batch: collections.abc.Sequence[ReadableLogRecord]) -> LogRecordExportResult: + """Append a batch of records to the store. + + Args: + batch: The records to store. They are appended in the order given. + + Returns: + ``LogRecordExportResult.FAILURE`` when ``shutdown`` has been + called, in which case the batch is discarded. + ``LogRecordExportResult.SUCCESS`` otherwise. + """ if self._stopped: return LogRecordExportResult.FAILURE with self._lock: @@ -42,12 +84,31 @@ def export(self, batch: collections.abc.Sequence[ReadableLogRecord]) -> LogRecor return LogRecordExportResult.SUCCESS def shutdown(self) -> None: + """Make every later export fail. + + Records already collected stay readable through + ``get_finished_logs``, which is what lets a test assert on them after + shutting the SDK down. The call is idempotent. + """ self._stopped = True def force_flush(self, timeout_millis: int = 30000) -> bool: + """Do nothing, since export is already synchronous. + + Args: + timeout_millis: Ignored. Accepted so that the signature matches + ``LogRecordExporter``. + + Returns: + ``True``, always. + """ return True @deprecated("Use InMemoryLogRecordExporter. Since logs are not stable yet this WILL be removed in future releases.") class InMemoryLogExporter(InMemoryLogRecordExporter): - pass + """Deprecated alias of ``InMemoryLogRecordExporter``. + + Behaves identically. Instantiating it emits a ``DeprecationWarning``, and + type checkers report its use. + """ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/export/__init__.py index 310a19425ab..600d6e4865a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/export/__init__.py @@ -1,5 +1,20 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Public names of the log record export pipeline. + +This module re-exports the exporter interface, its result enum, the console and +in-memory exporters, and the simple and batching log record processors. The +implementation lives in ``opentelemetry.sdk._logs._internal.export``, except +for ``InMemoryLogRecordExporter``, which lives in +``opentelemetry.sdk._logs._internal.export.in_memory_log_exporter`` and is +re-exported from there directly, because it imports this very module and so +cannot be imported by the private export package. Import from here instead of +from either private module. + +``ConsoleLogExporter``, ``LogExporter``, ``LogExportResult`` and +``InMemoryLogExporter`` are deprecated aliases kept for callers written against +the older spelling. +""" from opentelemetry.sdk._logs._internal.export import ( BatchLogRecordProcessor, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py index 567bf41c347..4c5c1b89c58 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py @@ -1,6 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The batching machinery shared by the span and the log record processors. + +``BatchSpanProcessor`` and ``BatchLogRecordProcessor`` both hold a +``BatchProcessor``, which owns the bounded queue, the background worker thread +and the export loop. Keeping one implementation here is what makes the two +signals behave alike on queue overflow, on shutdown and after a fork. + +Everything in this module is private to the SDK and may change without a +deprecation period. +""" + from __future__ import annotations import collections @@ -37,6 +48,27 @@ class DuplicateFilter(logging.Filter): in cases where logging itself is failing.""" def filter(self, record): + """Drop a record that repeats the previous one within the same window. + + A record is treated as a repeat when its module, its level and its + unformatted message all match the last record that passed, and both + fall in the same 20 second window. The windows are fixed to the Unix + epoch grid rather than measured from the last record that passed, so + a repeat passes again as soon as the grid moves on. An export failing + every 10 seconds, the default timeout of the OTLP log exporter, keeps + logging at one line per window for as long as it keeps failing, which + bounds the rate instead of stopping it. + + The state is a single previous record, so two failures alternating + with each other both pass every time. + + Args: + record: The ``logging.LogRecord`` about to be emitted. + + Returns: + ``True`` to let the ``logging`` module go on handling the record, + ``False`` to discard it. + """ current_log = ( record.module, record.levelno, @@ -54,6 +86,15 @@ def filter(self, record): class BatchExportStrategy(enum.Enum): + """How many batches one pass of the export loop should send. + + ``EXPORT_ALL`` drains the queue, and is what a force flush and a shutdown + use. ``EXPORT_WHILE_BATCH_EXCEEDS_THRESHOLD`` sends only while the queue + still holds a full batch, which is the case when the worker was woken + early by a burst. ``EXPORT_AT_LEAST_ONE_BATCH`` sends one batch and stops, + which is the case when the worker woke on its own schedule. + """ + EXPORT_ALL = 0 EXPORT_WHILE_BATCH_EXCEEDS_THRESHOLD = 1 EXPORT_AT_LEAST_ONE_BATCH = 2 @@ -63,12 +104,39 @@ class BatchExportStrategy(enum.Enum): class Exporter(Protocol[Telemetry]): + """The part of an exporter that ``BatchProcessor`` needs. + + A span exporter and a log record exporter both satisfy this, which is + what lets one batch processor drive either of them. + """ + @abstractmethod def export(self, batch: list[Telemetry], /): + """Send one batch of telemetry. + + Args: + batch: The items to send, oldest first, at most + ``max_export_batch_size`` of them. The parameter is + positional only. + + Raises: + NotImplementedError: Always, on this base. An implementation is + expected to catch its own transport failures rather than let + them out, since ``BatchProcessor`` only logs what escapes. + """ raise NotImplementedError @abstractmethod def shutdown(self): + """Release whatever the exporter holds and refuse further exports. + + An implementation may accept a ``timeout_millis`` keyword argument. + ``BatchProcessor.shutdown`` inspects the signature and passes the + remaining budget only when that argument is there. + + Raises: + NotImplementedError: Always, on this base. + """ raise NotImplementedError @@ -79,7 +147,37 @@ def shutdown(self): class BatchProcessor(Generic[Telemetry]): """This class can be used with exporter's that implement the above Exporter interface to buffer and send telemetry in batch through - the exporter.""" + the exporter. + + The worker thread starts during construction and runs as a daemon, so it + does not keep the interpreter alive. It wakes either when the schedule + delay elapses or when ``emit`` has queued enough items for a full batch. + + The queue is bounded. Once it is full, the oldest item is discarded to + make room for the newest, and the drop is counted through the metrics + object rather than raised. + + Args: + exporter: The exporter to hand each batch to. Its ``export`` is called + with instrumentation suppressed, so telemetry produced by the + export itself is not fed back into the queue. + schedule_delay_millis: How long the worker waits between passes, in + milliseconds. Pass 0 to remove the timeout, so that the worker + waits for an ``emit`` and then exports at once. + max_export_batch_size: The largest number of items handed to + ``export`` at once. Reaching this many queued items also wakes + the worker early. + export_timeout_millis: Stored and not used, because there is no way + to pass a timeout into ``export``. See + https://github.com/open-telemetry/opentelemetry-python/issues/4555 + max_queue_size: The capacity of the queue, in items. + exporting: The word naming the telemetry being exported, for example + ``"span"``. It goes into the worker thread name and into the log + messages about dropped and failed items. + metrics: The object the processor reports its queue size, its + finished items and its dropped items to. Pass a + ``NoOpProcessorMetrics`` to report nothing. + """ def __init__( self, @@ -91,6 +189,14 @@ def __init__( exporting: str, metrics: ProcessorMetricsT, ): + """Build the processor and start its worker thread. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. On a platform that provides + ``os.register_at_fork``, a hook is registered so that a forked child + rebuilds the lock, the event and the thread rather than inheriting + them. + """ self._bsp_reset_once = Once() self._exporter = exporter self._max_queue_size = max_queue_size @@ -118,6 +224,13 @@ def __init__( weak_reinit = weakref.WeakMethod(self._at_fork_reinit) def _after_in_child() -> None: + """Rebuild the worker state in a freshly forked child. + + The bound method is held weakly, so the hook does nothing once + the processor itself has been collected. That keeps a + discarded processor from being kept alive by the fork hook, + which ``os.register_at_fork`` never unregisters. + """ if reinit := weak_reinit(): reinit() @@ -128,6 +241,23 @@ def _after_in_child() -> None: self._metrics = metrics def _should_export_batch(self, batch_strategy: BatchExportStrategy, num_iterations: int) -> bool: + """Decide whether the export loop should send one more batch. + + It is called inside the export lock, so that a decision taken by the + worker is not invalidated by a concurrent force flush. + + Args: + batch_strategy: The strategy chosen for this pass of the loop. + num_iterations: How many batches this pass has already sent. + + Returns: + ``False`` when the queue is empty or the shutdown timeout has + already been exceeded, whatever the strategy. Otherwise ``True`` + while a full batch is still queued, and after that according to + the strategy: always for ``EXPORT_ALL``, only on the first + iteration for ``EXPORT_AT_LEAST_ONE_BATCH``, and never for + ``EXPORT_WHILE_BATCH_EXCEEDS_THRESHOLD``. + """ if not self._queue or self._shutdown_timeout_exceeded: return False # Always continue to export while queue length exceeds max batch size. @@ -140,6 +270,14 @@ def _should_export_batch(self, batch_strategy: BatchExportStrategy, num_iteratio return False def _at_fork_reinit(self): + """Rebuild the lock, the event, the queue and the worker thread. + + A fork copies the memory of the parent but only the calling thread, + so the child inherits a worker thread that does not run and a lock + that may be held by nobody reachable. Everything queued in the parent + is discarded here rather than exported twice, so telemetry recorded + before the fork is lost in the child. + """ self._export_lock = threading.Lock() self._worker_awaken = threading.Event() self._queue.clear() @@ -152,6 +290,19 @@ def _at_fork_reinit(self): self._pid = os.getpid() def worker(self): + """Run the export loop until shutdown, then drain the queue once. + + Each pass waits for the schedule delay or for ``emit`` to signal that + a full batch is queued, and picks the strategy from which of the two + happened. A schedule delay of 0 removes the timeout rather than the + wait: the worker blocks until an ``emit`` or a shutdown wakes it, and + then drains the queue, so telemetry is exported as soon as it is + recorded. + + The final pass after the loop exits runs with ``EXPORT_ALL``, which + is how telemetry queued just before a shutdown still reaches the + exporter, as long as the shutdown timeout has not elapsed. + """ while not self._shutdown: # Lots of strategies in the spec for setting next timeout. # https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#batching-processor. @@ -173,6 +324,22 @@ def worker(self): self._export(BatchExportStrategy.EXPORT_ALL) def _export(self, batch_strategy: BatchExportStrategy) -> None: + """Send batches to the exporter until the strategy says to stop. + + The whole loop runs under the export lock, so a force flush and the + worker never hand overlapping batches to the exporter. Instrumentation + is suppressed around each ``export`` call, which stops the export path + from recording telemetry about itself. + + An exception raised by the exporter is caught and logged, and the loop + goes on with the next batch. The items in the failed batch have + already been removed from the queue and already counted as finished, + so they are not retried. + + Args: + batch_strategy: How many batches this call should send, as + interpreted by ``_should_export_batch``. + """ with self._export_lock: iteration = 0 # We could see concurrent export calls from worker and force_flush. We call _should_export_batch @@ -195,6 +362,22 @@ def _export(self, batch_strategy: BatchExportStrategy) -> None: detach(token) def emit(self, data: Telemetry) -> None: + """Queue one item for export. + + The call does not block on the exporter. After a shutdown the item is + discarded and counted with the ``already_shutdown`` error type. When + the queue is already at capacity the oldest item is discarded to make + room and counted with the ``queue_full`` error type, so a slow + exporter costs the oldest telemetry rather than the newest. + + When the process id has changed since construction, the worker state + is rebuilt first, which discards whatever the parent had queued. + + Args: + data: The span or log record to queue. It is stored as it is, + without copying, so a later mutation of a still-queued item + is visible to the exporter. + """ if self._shutdown: _logger.info("Shutdown called, ignoring %s.", self._exporting) self._metrics.drop_items(1, "already_shutdown") @@ -212,6 +395,23 @@ def emit(self, data: Telemetry) -> None: self._worker_awaken.set() def shutdown(self, timeout_millis: int = 30000): + """Stop accepting telemetry, drain what is queued, close the exporter. + + A second call returns at once and does nothing. After the first call + ``emit`` drops everything it is given and ``force_flush`` returns + ``False``. + + The worker is woken, then joined for up to the whole timeout. Whatever + is left of the budget after the join is passed to the exporter, but + only when its ``shutdown`` declares a ``timeout_millis`` argument. + The method can return while one export is still in flight, since it + does not wait for the worker a second time. + + Args: + timeout_millis: The budget for the join and the exporter shutdown + together, in milliseconds. Telemetry still queued when it runs + out is discarded without being exported. + """ if self._shutdown: return shutdown_should_end = time.time() + (timeout_millis / 1000) @@ -237,6 +437,21 @@ def shutdown(self, timeout_millis: int = 30000): # TODO: Fix force flush so the timeout is used https://github.com/open-telemetry/opentelemetry-python/issues/4568. def force_flush(self, timeout_millis: int | None = None) -> bool: + """Export everything queued, blocking until the queue is empty. + + The call takes the export lock, so it waits for a batch the worker is + already sending before starting its own. + + Args: + timeout_millis: Ignored. The call blocks until the queue is + drained however long that takes. See + https://github.com/open-telemetry/opentelemetry-python/issues/4568 + + Returns: + ``True`` once the queue has been drained. ``False`` when the + processor has been shut down, in which case nothing was + exported. + """ if self._shutdown: return False # Blocking call to export. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/_processor_metrics.py b/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/_processor_metrics.py index aae2b14d967..cc4e6e113ef 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/_processor_metrics.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/_processor_metrics.py @@ -1,6 +1,18 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Self-observability metrics for the batch span and log record processors. + +A processor reports how many items it finished, how many it dropped and why, +how large its queue is and what its capacity is. The metric names and the +attribute keys come from the incubating ``otel`` semantic conventions, so a +backend can chart the health of the SDK itself alongside the application. + +Reporting is off by default. When it is off, ``create_processor_metrics`` +returns a ``NoOpProcessorMetrics``, so each report costs one method call that +does nothing and no instrument is ever created. +""" + from __future__ import annotations from collections import Counter @@ -27,25 +39,91 @@ class ProcessorMetricsT(Protocol): - def register_queue_size(self, get_queue_size: Callable[[], int]) -> None: ... + """What a batch processor calls to report on itself. + + ``ProcessorMetrics`` and ``NoOpProcessorMetrics`` both satisfy this, so + the processor never has to test whether reporting is on. + """ - def drop_items(self, count: int, error_type: str = "queue_full") -> None: ... + def register_queue_size(self, get_queue_size: Callable[[], int]) -> None: + """Arrange for the queue size to be observed at each collection. + + Args: + get_queue_size: A function returning the current number of queued + items. It is called during metric collection, from whichever + thread drives the metric reader, so it has to be cheap and + safe to call concurrently with the processor. + """ - def finish_items(self, count: int) -> None: ... + def drop_items(self, count: int, error_type: str = "queue_full") -> None: + """Report items the processor discarded without exporting them. + + Args: + count: How many items were discarded. + error_type: Why they were discarded, recorded as the + ``error.type`` attribute. The values the processors use are + ``"queue_full"``, ``"already_shutdown"`` and ``"recursion"``. + """ + + def finish_items(self, count: int) -> None: + """Report items the processor handed to the exporter. + + The count is recorded when the batch is submitted, not when the + export comes back, so an export that fails is still counted here. + + Args: + count: How many items were submitted. + """ class NoOpProcessorMetrics: + """Discards every report, for a processor with reporting turned off.""" + def register_queue_size(self, get_queue_size: Callable[[], int]) -> None: - pass + """Ignore the queue size callback. + + Args: + get_queue_size: The callback, which is never stored and never + called. + """ def drop_items(self, count: int, error_type: str = "queue_full") -> None: - pass + """Ignore a report of discarded items. + + Args: + count: How many items were discarded. + error_type: Why they were discarded. + """ def finish_items(self, count: int) -> None: - pass + """Ignore a report of submitted items. + + Args: + count: How many items were submitted. + """ class ProcessorMetrics: + """Records the self-observability metrics of one processor instance. + + Every instance is given its own ``otel.component.name``, built from the + component type and a count of how many instances of that type have been + created in this process, so two batch processors of the same kind can be + told apart. + + Args: + signal: Which telemetry the processor handles. It selects the span or + the log variant of each instrument. + component_type: The semantic convention value naming the kind of + processor, recorded as the ``otel.component.type`` attribute. + meter_provider: The provider the instruments are created from. Pass + the one the SDK uses for its own telemetry, not necessarily the + global one. + capacity: The queue capacity to record once, in items. Pass ``None`` + to skip the capacity instrument altogether, which is what a + processor without a bounded queue does. + """ + def __init__( self, signal: Literal["traces", "logs"], @@ -54,6 +132,11 @@ def __init__( *, capacity: int | None = None, ) -> None: + """Create the instruments and record the queue capacity. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. ``capacity`` is keyword only. + """ self._signal = signal meter = meter_provider.get_meter("opentelemetry-sdk") self._meter = meter @@ -95,9 +178,31 @@ def __init__( self._queue_capacity.add(capacity, self._standard_attrs) def register_queue_size(self, get_queue_size: Callable[[], int]) -> None: + """Create the observable up down counter for the queue size. + + The instrument is named ``otel.sdk.processor.span.queue.size`` for + traces and ``otel.sdk.processor.log.queue.size`` for logs. Nothing is + recorded here; the callback is invoked once per metric collection. + + Args: + get_queue_size: A function returning the current number of queued + items. + """ + def record_queue_size( _options: CallbackOptions, ) -> tuple[Observation]: + """Observe the queue size once, for one metric collection. + + Args: + _options: The timeout and other collection options the metric + reader passes in. They are ignored, since reading the + queue size cannot block. + + Returns: + A single observation of the current queue size, carrying the + component type and component name attributes. + """ return (Observation(get_queue_size(), self._standard_attrs),) if self._signal == "traces": @@ -117,6 +222,18 @@ def record_queue_size( ) def drop_items(self, count: int, error_type: str = "queue_full") -> None: + """Add to the processed counter with an ``error.type`` attribute. + + Dropped and finished items share one counter, told apart by whether + ``error.type`` is present, so the drop rate is a ratio over the same + instrument. + + Args: + count: How many items were discarded. + error_type: Why they were discarded. ``"already_shutdown"`` and + ``"recursion"`` are recorded as themselves. Any other value, + including the default, is recorded as ``"queue_full"``. + """ if error_type == "already_shutdown": self._processed.add(count, self._already_shutdown_attrs) elif error_type == "recursion": @@ -125,6 +242,13 @@ def drop_items(self, count: int, error_type: str = "queue_full") -> None: self._processed.add(count, self._dropped_attrs) def finish_items(self, count: int) -> None: + """Add to the processed counter with no ``error.type`` attribute. + + Args: + count: How many items were handed to the exporter. They are + counted at submission, so an export that then fails is still + counted as finished. + """ self._processed.add(count, self._standard_attrs) @@ -136,6 +260,23 @@ def create_processor_metrics( capacity: int | None = None, enabled: bool, ) -> ProcessorMetricsT: + """Build the metrics object a processor should report through. + + Args: + signal: Which telemetry the processor handles. + component_type: The semantic convention value naming the kind of + processor. + meter_provider: The provider the instruments are created from. It is + not touched when ``enabled`` is false. + capacity: The queue capacity to record once, in items, or ``None`` to + record no capacity. + enabled: Whether to record anything at all. It is keyword only and + has no default, so a caller has to state its choice. + + Returns: + A ``ProcessorMetrics`` when ``enabled`` is true, which creates its + instruments right away. A ``NoOpProcessorMetrics`` otherwise. + """ if not enabled: return NoOpProcessorMetrics() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 303f62924bc..dcf56d3f54b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -1,6 +1,20 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The names of the environment variables the SDK reads. + +Each constant here holds the name of a variable, not its value, and carries a +docstring with an ``.. envvar::`` directive so that the variable appears in +the documentation and can be cross-referenced from elsewhere. Importing a +constant from this module rather than writing the string gives one spelling +that the whole code base shares. + +Reading a variable happens in the code that needs it, mostly in +``opentelemetry.sdk._configuration`` and in the processors and exporters, so +changing one of these constants changes the name that is read but not when it +is read. +""" + OTEL_SDK_DISABLED = "OTEL_SDK_DISABLED" """ .. envvar:: OTEL_SDK_DISABLED diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/_internal.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/_internal.py index 0f0fa930a62..5bc0c68a763 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/_internal.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/_internal.py @@ -1,6 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Reading of the environment variables that carry a boolean. + +The specification writes a boolean environment variable as the literal +``true`` or ``false``, and asks an SDK to warn and fall back to the default +rather than fail when it reads anything else. This module holds the one +function that applies that rule, so every boolean variable the SDK reads +behaves the same way. +""" + from logging import getLogger from os import environ @@ -8,6 +17,23 @@ def parse_boolean_environment_variable(environment_variable: str, default: bool = False) -> bool: + """Read one environment variable as a boolean. + + Surrounding whitespace is stripped and the comparison ignores case, so + ``" TRUE "`` reads as true. The variable is read at each call rather than + cached, so a change made after import is picked up. + + Args: + environment_variable: The name of the variable to read. + default: What to return when the variable is unset, and when it holds + something other than ``true`` or ``false``. + + Returns: + ``True`` for ``true``, ``False`` for ``false``, and ``default`` + otherwise. An unparsable value is logged at ``WARNING`` level and + nothing is raised, so a typo in the environment leaves the SDK + running on its default. + """ value = environ.get(environment_variable) if value is None: return default diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/error_handler/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/error_handler/__init__.py index c98952ce6ee..e6407ecfa29 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/error_handler/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/error_handler/__init__.py @@ -57,10 +57,30 @@ def _handle(self, error: Exception, *args, **kwargs): class ErrorHandler(ABC): + """Base class every registered error handler inherits from. + + A handler also inherits from each exception class it wants to handle, and + ``GlobalErrorHandler`` selects it by testing that inheritance against the + exception that was raised. A handler that inherits only from this class + therefore handles nothing. + """ + @abstractmethod def _handle(self, error: Exception, *args, **kwargs): """ Handle an exception + + A handler is built with no arguments and this method is called once + per matching handler. Returning normally counts as having handled the + exception, which stops the default handler from also logging it. + + Args: + error: The exception that was raised inside the + ``GlobalErrorHandler`` block. + *args: Accepted so that a handler can take extra positional + arguments. ``GlobalErrorHandler`` passes none. + **kwargs: Accepted so that a handler can take extra keyword + arguments. ``GlobalErrorHandler`` passes none. """ @@ -73,6 +93,19 @@ class _DefaultErrorHandler(ErrorHandler): # pylint: disable=useless-return def _handle(self, error: Exception, *args, **kwargs): + """Log the exception and its traceback, then swallow it. + + The message is written at ``ERROR`` level to the logger of this + module, so it reaches whatever handlers the application configured. + The exception is not re-raised. + + Args: + error: The exception that was raised. It is not formatted into + the message; ``logger.exception`` takes the traceback from + the exception being handled instead. + *args: Ignored. + **kwargs: Ignored. + """ logger.exception("Error handled by default error handler: ") @@ -88,16 +121,56 @@ class GlobalErrorHandler: _instance = None def __new__(cls) -> "GlobalErrorHandler": + """Return the one instance, creating it on the first call. + + Returns: + The single instance, so that every ``with GlobalErrorHandler():`` + in the process shares one object. The class carries no state, so + sharing it costs nothing and nesting two blocks is safe. + """ if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __enter__(self): - pass + """Enter the block, doing nothing. + + Returns: + ``None``. There is nothing useful to bind with ``as``. + """ # pylint: disable=no-self-use def __exit__(self, exc_type, exc_value, traceback): + """Dispatch an escaping exception to the registered handlers. + + Every entry point in the ``opentelemetry_error_handler`` group is + loaded on each exception, and a handler class is selected when it is a + subclass of the class of the raised exception, which is what the + module docstring means by a handler inheriting from the exceptions it + handles. A handler for a base class is therefore not selected for a + derived exception. Several handlers can match one exception and all + of them run. An exception raised by a handler is logged and the + remaining handlers still run, but that handler does not count as + having handled anything. + + When no handler matched, or every handler that matched raised, the + default handler logs the exception. + + Args: + exc_type: The class of the escaping exception, or ``None`` when + the block finished normally. It is not used; the dispatch + reads the class off ``exc_value``. + exc_value: The escaping exception, or ``None`` when the block + finished normally. + traceback: The traceback of the escaping exception. It is not + used. + + Returns: + ``None`` when the block finished normally. ``True`` when an + exception escaped, which suppresses it, so an exception raised + inside the block never reaches the surrounding code. + """ if exc_value is None: return None diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py index 27a577ff68f..3093063c171 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py @@ -1,6 +1,26 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Entry point of the metrics SDK. + +This package re-exports the names that make up the public metrics SDK: +``MeterProvider`` and ``Meter``; the instrument classes ``Counter``, +``UpDownCounter``, ``Histogram``, ``ObservableCounter``, +``ObservableUpDownCounter``, ``ObservableGauge`` and ``_Gauge``; the exemplar +filters ``ExemplarFilter``, ``AlwaysOnExemplarFilter``, +``AlwaysOffExemplarFilter`` and ``TraceBasedExemplarFilter``; the exemplar +types ``Exemplar``, ``ExemplarReservoir``, +``SimpleFixedSizeExemplarReservoir`` and +``AlignedHistogramBucketExemplarReservoir``; and ``MetricsTimeoutError``. The +``export`` and ``view`` subpackages are imported here as well, so that +``opentelemetry.sdk.metrics.export`` and ``opentelemetry.sdk.metrics.view`` +resolve after importing this package alone. + +Every one of those names is implemented under +``opentelemetry.sdk.metrics._internal``. Import them from this package rather +than from the ``_internal`` package, whose layout carries no stability +guarantee. +""" from opentelemetry.sdk.metrics import export, view from opentelemetry.sdk.metrics._internal import Meter, MeterProvider diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py index 2a43dc299c7..870f4dd4552 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py @@ -1,6 +1,32 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +# pylint: disable=too-many-lines +"""Implementation of ``MeterProvider`` and ``Meter``. + +``MeterProvider`` is the root object of the metrics SDK: it owns the +resource, the views, the exemplar filter and the metric readers, and it +hands out ``Meter`` instances, one per instrumentation scope. ``Meter`` +creates instruments and caches them by identity. Both are re-exported from +``opentelemetry.sdk.metrics``, which is where they should be imported from. + +Two environment variables are read here, both when a ``MeterProvider`` is +constructed and never again. ``OTEL_SDK_DISABLED`` set to ``true``, compared +after stripping whitespace and lower-casing, makes every ``get_meter`` call +on that provider return a no-op meter. ``OTEL_METRICS_EXEMPLAR_FILTER`` +names the exemplar filter, out of ``trace_based``, ``always_on`` and +``always_off``; it is consulted only when the ``exemplar_filter`` argument +was not given, it defaults to ``trace_based``, and any other value raises +``ValueError`` out of the constructor. + +This module also holds the meter configuration machinery. A +``_MeterConfig`` carries the settings of one meter, currently just whether +it is enabled. A ``_ProxyMeterConfig`` is the indirection that lets those +settings be replaced after instruments have already been created. A meter +configurator is a callable taking an ``InstrumentationScope`` and returning +a ``_MeterConfig``, which the provider runs once per scope. +""" + import os import weakref from atexit import register, unregister @@ -70,27 +96,103 @@ @dataclass class _MeterConfig: + """The settings a ``MeterProvider`` applies to a single meter. + + A meter configurator produces one of these per instrumentation scope, + and it reaches the meter and its instruments through a + ``_ProxyMeterConfig``. + + Args: + is_enabled: Whether the meter records anything. When it is ``False`` + the synchronous instruments of the meter discard every + measurement and the asynchronous ones report nothing, while + creating instruments still succeeds and still returns real + instruments. + """ + is_enabled: bool = True @classmethod def default(cls) -> "_MeterConfig": + """Build the configuration a meter gets when nothing else applies. + + Returns: + A new instance with ``is_enabled`` set to ``True``. A separate + object is built on every call, so two meters never share one. + """ return _MeterConfig() class _ProxyMeterConfig: + """Mutable holder that keeps a meter's configuration replaceable. + + A ``Meter`` and every instrument it creates keep a reference to this + object rather than to the ``_MeterConfig`` itself, and read through it on + every measurement. Replacing the configuration with ``update`` therefore + reaches instruments that already exist, which is what lets + ``MeterProvider._set_meter_configurator`` take effect on a meter handed + out earlier. + + Args: + config: The configuration to hold to begin with. + """ + def __init__(self, config: _MeterConfig): + """Store the initial configuration. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._config = config @property def is_enabled(self) -> bool: + """Whether the meter this configuration belongs to is enabled. + + Returns: + The ``is_enabled`` flag of whichever ``_MeterConfig`` is held at + the moment of the call, so the answer changes as soon as + ``update`` replaces it. + """ return self._config.is_enabled def update(self, config: _MeterConfig) -> None: + """Replace the configuration held. + + The meter and all of its instruments see the change on their next + measurement, since every one of them reads through this object. The + replacement is a plain attribute assignment with no lock, so a + measurement running concurrently may still read the old + configuration. + + Args: + config: The configuration to hold from now on. + """ self._config = config class Meter(APIMeter): - """See `opentelemetry.metrics.Meter`.""" + """See `opentelemetry.metrics.Meter`. + + Instruments are cached by identity, which is the instrument name + stripped and lower-cased, the instrument kind, the unit and the + description taken together. Asking twice for the same identity returns + the first instrument, and whatever the second call passed outside those + four values (callbacks, or a histogram bucket advisory) is discarded. + + Args: + instrumentation_scope: Identifies the library this meter was created + for. Every instrument created here carries it, and a ``View`` + matches its ``meter_name``, ``meter_version`` and + ``meter_schema_url`` criteria against it. + measurement_consumer: Handed to every instrument created here, and + told about each asynchronous instrument so that collection can + call back into it. + _meter_config: The configuration this meter starts with. ``None`` + means ``_MeterConfig.default()``, which is enabled. It is + wrapped in a ``_ProxyMeterConfig`` so that + ``MeterProvider._set_meter_configurator`` can replace it later. + """ def __init__( self, @@ -99,6 +201,11 @@ def __init__( *, _meter_config: _MeterConfig | None = None, ): + """Build the meter and its empty instrument cache. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__( name=instrumentation_scope.name, version=instrumentation_scope.version, @@ -111,12 +218,47 @@ def __init__( self._meter_config = _ProxyMeterConfig(_meter_config or _MeterConfig.default()) def _is_enabled(self) -> bool: + """Report whether this meter currently records anything. + + Returns: + The ``is_enabled`` flag of the configuration held right now. The + instruments of this meter consult their own copy of the same + proxy rather than calling this. + """ return self._meter_config.is_enabled def _set_meter_config(self, meter_config: _MeterConfig) -> None: + """Replace the configuration of this meter and of its instruments. + + The instruments created by this meter share the one + ``_ProxyMeterConfig`` updated here, so they pick the new + configuration up on their next measurement without being visited. + + Args: + meter_config: The configuration to apply from now on. + """ self._meter_config.update(meter_config) def create_counter(self, name, unit="", description="") -> APICounter: + """Create or look up a counter on this meter. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. It is stripped and + lower-cased for the identity check and lower-cased on the + instrument itself. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity, so the + same name with a different unit is a different instrument. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + + Returns: + A ``Counter``. The same object comes back on every later call + with the same name, unit and description, with no warning and + without a second instrument being built. + """ with self._instrument_registration_lock: status = self._register_instrument(name, _Counter, unit, description) if not status.already_registered: @@ -144,6 +286,24 @@ def create_counter(self, name, unit="", description="") -> APICounter: return instrument def create_up_down_counter(self, name, unit="", description="") -> APIUpDownCounter: + """Create or look up an up-down counter on this meter. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. It is stripped and + lower-cased for the identity check and lower-cased on the + instrument itself. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + + Returns: + An ``UpDownCounter``. The same object comes back on every later + call with the same name, unit and description, with no warning + and without a second instrument being built. + """ with self._instrument_registration_lock: status = self._register_instrument(name, _UpDownCounter, unit, description) if not status.already_registered: @@ -177,6 +337,31 @@ def create_observable_counter( unit="", description="", ) -> APIObservableCounter: + """Create or look up an observable counter on this meter. + + On first creation the instrument is registered with the measurement + consumer, which is what makes its callbacks run at collection time. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. + callbacks: The callbacks the SDK invokes on every collection, + each a callable or a generator yielding + ``opentelemetry.metrics.Observation`` values. ``None``, the + default, means the instrument reports nothing. Callbacks do + not take part in the instrument identity, so the ones passed + to a repeated call are discarded with the rest of that call. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + + Returns: + An ``ObservableCounter``. The same object comes back on every + later call with the same name, unit and description, keeping the + callbacks it was first given. + """ with self._instrument_registration_lock: status = self._register_instrument(name, _ObservableCounter, unit, description) if not status.already_registered: @@ -215,6 +400,33 @@ def create_histogram( *, explicit_bucket_boundaries_advisory: Sequence[float] | None = None, ) -> APIHistogram: + """Create or look up a histogram on this meter. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + explicit_bucket_boundaries_advisory: The bucket boundaries this + histogram suggests to whatever aggregates it, which a + ``View`` naming an explicit aggregation overrides. ``None``, + the default, means no suggestion. Anything that is not a + sequence of numbers is replaced by ``None`` and logged at + warning level, so a bad advisory does not stop the + instrument from being created. The advisory is not part of + the instrument identity. + + Returns: + A ``Histogram``. The same object comes back on every later call + with the same name, unit and description, keeping the advisory + it was first given. A repeated call whose advisory differs from + the first logs a warning naming the advisory already in force, + which makes a histogram the only instrument kind where a + duplicate registration is reported at all. + """ if explicit_bucket_boundaries_advisory is not None: invalid_advisory = False if isinstance(explicit_bucket_boundaries_advisory, Sequence): @@ -265,6 +477,22 @@ def create_histogram( return instrument def create_gauge(self, name, unit="", description="") -> APIGauge: + """Create or look up a synchronous gauge on this meter. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + + Returns: + A ``Gauge``. The same object comes back on every later call with + the same name, unit and description, with no warning and without + a second instrument being built. + """ with self._instrument_registration_lock: status = self._register_instrument(name, _Gauge, unit, description) if not status.already_registered: @@ -292,6 +520,31 @@ def create_gauge(self, name, unit="", description="") -> APIGauge: return instrument def create_observable_gauge(self, name, callbacks=None, unit="", description="") -> APIObservableGauge: + """Create or look up an observable gauge on this meter. + + On first creation the instrument is registered with the measurement + consumer, which is what makes its callbacks run at collection time. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. + callbacks: The callbacks the SDK invokes on every collection, + each a callable or a generator yielding + ``opentelemetry.metrics.Observation`` values. ``None``, the + default, means the instrument reports nothing. Callbacks do + not take part in the instrument identity, so the ones passed + to a repeated call are discarded with the rest of that call. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + + Returns: + An ``ObservableGauge``. The same object comes back on every + later call with the same name, unit and description, keeping the + callbacks it was first given. + """ with self._instrument_registration_lock: status = self._register_instrument(name, _ObservableGauge, unit, description) if not status.already_registered: @@ -325,6 +578,31 @@ def create_observable_gauge(self, name, callbacks=None, unit="", description="") def create_observable_up_down_counter( self, name, callbacks=None, unit="", description="" ) -> APIObservableUpDownCounter: + """Create or look up an observable up-down counter on this meter. + + On first creation the instrument is registered with the measurement + consumer, which is what makes its callbacks run at collection time. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, and the constructor of + the instrument raises if it does not. + callbacks: The callbacks the SDK invokes on every collection, + each a callable or a generator yielding + ``opentelemetry.metrics.Observation`` values. ``None``, the + default, means the instrument reports nothing. Callbacks do + not take part in the instrument identity, so the ones passed + to a repeated call are discarded with the rest of that call. + unit: The unit of measure, an ASCII string of at most 63 + characters. It is part of the instrument identity. + description: Free-form text describing the instrument. It is + part of the instrument identity as well. + + Returns: + An ``ObservableUpDownCounter``. The same object comes back on + every later call with the same name, unit and description, + keeping the callbacks it was first given. + """ with self._instrument_registration_lock: status = self._register_instrument(name, _ObservableUpDownCounter, unit, description) if not status.already_registered: @@ -357,6 +635,22 @@ def create_observable_up_down_counter( def _get_exemplar_filter(exemplar_filter: str) -> ExemplarFilter: + """Build the exemplar filter a configuration value names. + + Args: + exemplar_filter: One of ``"trace_based"``, ``"always_on"`` or + ``"always_off"``. ``MeterProvider`` passes the value of + ``OTEL_METRICS_EXEMPLAR_FILTER`` here, unmodified, so the + comparison is exact and surrounding whitespace or a capital + letter makes it fail. + + Returns: + A new instance of the matching filter class. + + Raises: + ValueError: If ``exemplar_filter`` is none of the three names. The + message quotes the rejected value. + """ if exemplar_filter == "trace_based": return TraceBasedExemplarFilter() if exemplar_filter == "always_on": @@ -374,12 +668,41 @@ def _get_exemplar_filter(exemplar_filter: str) -> ExemplarFilter: def _default_meter_configurator( _meter_scope: InstrumentationScope, ) -> _MeterConfig: + """Give every meter the default configuration. + + A ``MeterProvider`` built without a ``_meter_configurator`` uses this + one, which is why a provider enables every meter out of the box. + + Args: + _meter_scope: The instrumentation scope of the meter being + configured. It is accepted to satisfy the configurator + signature and then ignored, since the answer is the same for + every scope. + + Returns: + A new ``_MeterConfig`` with ``is_enabled`` set to ``True``. + """ return _MeterConfig.default() def _disable_meter_configurator( _meter_scope: InstrumentationScope, ) -> _MeterConfig: + """Turn off every meter it is applied to. + + Pass this as the ``_meter_configurator`` of a ``MeterProvider``, or hand + it to ``MeterProvider._set_meter_configurator``, to silence the whole + provider while instrument creation keeps working. + + Args: + _meter_scope: The instrumentation scope of the meter being + configured. It is accepted to satisfy the configurator + signature and then ignored, since the answer is the same for + every scope. + + Returns: + A new ``_MeterConfig`` with ``is_enabled`` set to ``False``. + """ return _MeterConfig(is_enabled=False) @@ -393,9 +716,26 @@ class MeterProvider(APIMeterProvider): metrics. For push-based export, use :class:`opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader`. resource: The resource representing what the metrics emitted from the SDK pertain to. + If ``None``, ``Resource.create({})`` is used, which gathers the + detectors configured through the environment. The resource is + not frozen: a fork merges the process dependent attributes of + the child into it. + exemplar_filter: Decides which measurements are eligible to become + exemplars. If ``None``, the value of + ``OTEL_METRICS_EXEMPLAR_FILTER`` is read instead, defaulting to + ``trace_based``. That is the only moment the variable is + consulted. shutdown_on_exit: If true, registers an `atexit` handler to call `MeterProvider.shutdown` - views: The views to configure the metric output the SDK + views: The views to configure the metric output the SDK. + An instrument that matches no view is aggregated with the + default aggregation for its kind, and an instrument that matches + several of them feeds one metric stream per matching view. + _meter_configurator: Called with one ``InstrumentationScope`` and + returns the ``_MeterConfig`` for the meter of that scope, which + is how individual meters are turned off. If ``None``, + ``_default_meter_configurator`` is used and every meter is + enabled. Keep it fast: it runs while the meter lock is held. .. code-block:: python :caption: Push-based export with PeriodicExportingMetricReader @@ -440,6 +780,26 @@ def __init__( *, _meter_configurator: _MeterConfiguratorT | None = None, ): + """Wire the provider to its readers, views and resource. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Each metric reader is claimed here for this provider alone, by way of + a set shared by every ``MeterProvider`` in the process. The readers + are given a collect callback and a reference back to this provider + before the constructor returns, so a reader starts working as soon as + the provider exists. + + Raises: + ValueError: If ``exemplar_filter`` was not given and + ``OTEL_METRICS_EXEMPLAR_FILTER`` holds a name that is not + one of ``trace_based``, ``always_on`` and ``always_off``. + Exception: If any reader in ``metric_readers`` is already + registered with another ``MeterProvider``. The readers + before it in the sequence have already been claimed by then, + so they cannot be given to a different provider afterwards. + """ self._lock = Lock() self._meter_lock = Lock() self._atexit_handler = None @@ -485,12 +845,33 @@ def __init__( weak_at_fork = weakref.WeakMethod(self._handle_fork) def _after_in_child() -> None: + """Run the provider's fork handler in the freshly forked child. + + ``os.register_at_fork`` keeps a strong reference to whatever is handed + to it, so this closure holds the provider only weakly. A provider that + has been garbage collected leaves the weak reference empty and the + handler does nothing. + """ if at_fork := weak_at_fork(): at_fork() os.register_at_fork(after_in_child=_after_in_child) def _handle_fork(self) -> None: + """Repair this provider inside a process that has just forked. + + ``os.register_at_fork`` runs this in the child, through a weak + method, so a provider that has been garbage collected is neither + resurrected nor called. The three locks are replaced because a lock + another thread held at the instant of the fork would stay held + forever in the child, which has only the forking thread. Replacing + ``_all_metric_readers_lock`` assigns to the class of this provider, + so every provider of that class and of its subclasses gets the new + lock, while a sibling class keeps the one it inherited. + The resource is then merged with the process dependent + attributes of the new process, which is what corrects the recorded + process id. + """ self._lock = Lock() self._meter_lock = Lock() type(self)._all_metric_readers_lock = Lock() @@ -502,6 +883,18 @@ def _set_meter_configurator(self, *, meter_configurator: _MeterConfiguratorT): Setting a new MeterConfigurator will result in the configurator being called for each outstanding Meter and for any newly created meters thereafter. Therefore, it is important that the provided function returns quickly. + + The meters are revisited under the meter lock, and each one is + updated through the ``_ProxyMeterConfig`` it shares with its + instruments, so instruments created before this call also pick the + new configuration up. + + Args: + meter_configurator: Called with one ``InstrumentationScope`` and + returns the ``_MeterConfig`` for the meter of that scope. An + exception it raises is logged with a traceback and the + default configuration is used for that scope instead, so one + failing scope does not stop the rest from being updated. """ with self._meter_lock: self._meter_configurator = meter_configurator @@ -510,10 +903,34 @@ def _set_meter_configurator(self, *, meter_configurator: _MeterConfiguratorT): meter._set_meter_config(self._apply_meter_configurator(instrumentation_scope)) def _update_resource(self, resource: Resource) -> None: + """Merge further attributes into the resource of this provider. + + On a key both resources carry, the incoming value wins, which is the + order ``Resource.merge`` defines. The metric readers reach the + resource through the same ``SdkConfiguration`` object, so the next + collection sees the merged resource with nothing else being + notified. + + Args: + resource: The resource whose attributes are merged in. + """ with self._meter_lock: self._sdk_config.resource = self._sdk_config.resource.merge(resource) def _apply_meter_configurator(self, instrumentation_scope: InstrumentationScope) -> _MeterConfig: + """Run the meter configurator for one scope, surviving a failure. + + Args: + instrumentation_scope: The scope of the meter being configured, + passed to the configurator unchanged. + + Returns: + Whatever the configurator returned, or ``_MeterConfig.default()`` + when it raised. An exception is logged with a traceback and the + name of the scope, and is not propagated, so a configurator that + is broken leaves every meter enabled rather than breaking + ``get_meter``. + """ try: return self._meter_configurator(instrumentation_scope) # pylint: disable-next=broad-exception-caught @@ -525,6 +942,33 @@ def _apply_meter_configurator(self, instrumentation_scope: InstrumentationScope) return _MeterConfig.default() def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Make every metric reader export what it is holding right now. + + The readers are flushed one after another against a single deadline + computed when the call starts, so a slow reader leaves less time for + the ones behind it, and a reader reached after the deadline is + counted as failed without being flushed. One reader failing does not + stop the others: every failure is gathered and reported together at + the end. + + The call may be repeated as often as wanted; nothing guards against + it, and nothing here checks whether the provider has been shut down. + Only the readers passed to the constructor are flushed, so a reader + attached later with ``add_metric_reader`` is skipped. + + Args: + timeout_millis: Upper bound on the whole operation, in + milliseconds, shared by all the readers. + + Returns: + ``True``. There is no return value meaning failure, since a + failure is raised instead. + + Raises: + Exception: If any reader raised, or was reached after the + deadline. The message lists the class name and the ``repr`` + of the error for each one. + """ deadline_ns = time_ns() + timeout_millis * 10**6 metric_reader_error = {} @@ -557,9 +1001,41 @@ def force_flush(self, timeout_millis: float = 10_000) -> bool: return True def shutdown(self, timeout_millis: float = 30_000): + """Shut the metric readers down and stop handing out real meters. + + The call is idempotent. The second and every later call logs a + warning, shuts nothing down and returns at once, which is also what + makes the ``atexit`` handler harmless after a manual shutdown. The + handler is unregistered here in any case. + + The readers share one deadline computed when the call starts, the + same way ``force_flush`` works, and a reader reached after it is + counted as failed without being shut down. One reader failing does + not stop the others. + + Afterwards ``get_meter`` returns no-op meters. Meters already handed + out keep working and keep feeding the measurement consumer, and only + the readers passed to the constructor are shut down, so a reader + attached later with ``add_metric_reader`` is left running. + + Args: + timeout_millis: Upper bound on the whole operation, in + milliseconds, shared by all the readers. + + Raises: + Exception: If any reader raised, or was reached after the + deadline. Every reader is attempted first and the failures + are reported together, listing the class name and the + ``repr`` of the error for each one. + """ deadline_ns = time_ns() + timeout_millis * 10**6 def _shutdown(): + """Mark the provider as shut down. + + It runs through ``Once``, so only the first ``shutdown`` call on + this provider reaches it. + """ self._shutdown = True did_shutdown = self._shutdown_once.do_once(_shutdown) @@ -608,6 +1084,35 @@ def get_meter( schema_url: str | None = None, attributes: Attributes = None, ) -> APIMeter: + """Return the meter of an instrumentation scope, creating it once. + + Meters are cached per scope, and the identity of a scope is its + name, version, schema URL and attributes taken together, so changing + any of the four yields a separate meter. The configurator runs once + per scope, at the moment the meter is created, and not again on a + later call for the same scope. + + Args: + name: The name of the instrumenting library. An empty name, or + ``None``, is refused with a warning and gets a no-op meter. + version: The version of the instrumenting library. ``None`` + means unversioned, and two versions of one library get two + meters. + schema_url: The schema URL of the telemetry this meter emits. + ``None`` means none was declared. + attributes: Further attributes describing the scope. ``None`` + means none. They take part in the identity of the scope, + although not in its hash, so two scopes differing only in + attributes collide in the cache and are then told apart by + comparison. + + Returns: + The ``Meter`` already held for this exact scope, or a new one + registered under it. A ``NoOpMeter`` comes back instead when + ``OTEL_SDK_DISABLED`` was true at construction, when this + provider has been shut down, or when ``name`` is empty. The last + two are logged at warning level; the disabled case is silent. + """ if self._disabled: return NoOpMeter(name, version=version, schema_url=schema_url) @@ -632,6 +1137,24 @@ def get_meter( return self._meters[instrumentation_scope] def add_metric_reader(self, metric_reader: "opentelemetry.sdk.metrics.export.MetricReader") -> None: + """Attach a metric reader to this provider after construction. + + The reader receives measurements from the next collection onwards. + Readers are claimed process-wide, so one already attached to any + ``MeterProvider`` is refused here with a warning and nothing else + happens; the constructor raises in the same situation. + + A reader attached this way differs in two ways from one passed to + the constructor. ``MeterProvider.force_flush`` and + ``MeterProvider.shutdown`` walk only the sequence the constructor + was given, so neither reaches it. It is also not given a reference + back to this provider, so it goes on measuring itself into the + instruments its own constructor built on a ``NoOpMeterProvider``, + and none of its self-metrics is ever exported. + + Args: + metric_reader: The reader to attach. + """ with self._all_metric_readers_lock: if metric_reader in self._all_metric_readers: _logger.warning( @@ -648,6 +1171,23 @@ def remove_metric_reader( self, metric_reader: "opentelemetry.sdk.metrics.export.MetricReader", ) -> None: + """Detach a metric reader from this provider and shut it down. + + The reader stops receiving measurements, its collect callback is + cleared, and ``shutdown`` is called on it with the reader's own + default timeout rather than one chosen here, so the reader is spent + afterwards. It is released from the process-wide claim all the same, + which means another provider would accept the shut down reader. A + reader attached to no ``MeterProvider`` at all is refused with a + warning and nothing happens. + + A reader that was passed to the constructor stays in the sequence + ``MeterProvider.shutdown`` walks, so shutting the provider down + later shuts that reader down a second time. + + Args: + metric_reader: The reader to detach. + """ with self._all_metric_readers_lock: if metric_reader not in self._all_metric_readers: _logger.warning("MetricReader '%s' has not been registered!", metric_reader) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py index e9ac5eecce5..8471fa4a27f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py @@ -1,6 +1,21 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""One metric stream: a view applied to an instrument. + +``MetricReaderStorage`` builds a ``_ViewInstrumentMatch`` for every pair of a +view and an instrument the view selects, and one more against a catch-all view +for an instrument no view selects. The object owns the aggregations of that +stream, one per distinct attribute set, and turns them into data points when +the reader collects. + +The module also holds ``_hash_attributes``, which turns an attribute set into +the hashable key those aggregations are stored under, and the cardinality +limit constants: ``_DEFAULT_CARDINALITY_LIMIT``, and the attribute set +``otel.metric.overflow=true`` that measurements past the limit are folded +into. +""" + from collections.abc import Mapping, Sequence from logging import getLogger from threading import Lock @@ -43,6 +58,26 @@ # pylint: disable=inconsistent-return-statements def _hash_attributes(value: Attributes | AnyValue) -> _HashedAttributes: + """Turn an attribute set into the key its aggregation is stored under. + + The result is hashable and compares equal only to the result of another + value the OpenTelemetry data model treats as the same, which plain + equality on the value would not give: ``True``, ``1`` and ``1.0`` compare + equal in Python, so each scalar is tagged with the name of its type. A + sequence and a mapping are tagged in the same way and converted to tuples + element by element, and the entries of a mapping are sorted by key so that + two mappings written in a different order give the same key. + + Args: + value: The whole attribute set, which is a mapping, or one value + inside it reached by recursion. The caller has already cleaned and + validated it while building the ``Measurement``. + + Returns: + A tuple of the type tag and the converted value. Nothing is returned + for a value of a type outside the OpenTelemetry data model, which a + validated attribute set cannot hold. + """ # Attributes have been cleaned and validated when Measurement was instantiated, # so value is guaranteed to match one of the branches below at runtime. if isinstance(value, (NoneType, str, int, float, bool, bytes)): @@ -73,6 +108,43 @@ def _hash_attributes(value: Attributes | AnyValue) -> _HashedAttributes: class _ViewInstrumentMatch: + """The aggregated state of one instrument as seen through one view. + + The name and the description of the stream are fixed here: the view's own + name and description win where it gives a non-empty one, and the + instrument's are used otherwise. An empty string therefore counts as + unset, so a view cannot blank out the description of an instrument. The + unit is never overridden by a view and is read from the instrument at + collection time. + + One aggregation is held per distinct attribute set, created on first use + and keyed by ``_hash_attributes``. The construction also builds a single + aggregation with no attributes, which is never fed a measurement: it is + kept so that the storage can read the aggregation class off this object to + choose the data point type, and so that ``conflicts`` can compare streams + before either has seen a measurement. + + Args: + view: The view whose name, description, attribute keys, aggregation + and exemplar reservoir factory shape the stream. The storage + passes a catch-all view here for an instrument that no configured + view selects. + instrument: The instrument being aggregated. Its name, description and + unit are the fallbacks for the stream, and its class selects the + default aggregation. + instrument_class_aggregation: The reader's mapping of instrument class + to aggregation, consulted whenever the view asks for + ``DefaultAggregation``. It is read at every aggregation creation + rather than copied, so a later change to the mapping is picked up. + cardinality_limit: How many distinct attribute sets this stream keeps + before folding the rest into the overflow attribute set. + reset_on_collect: Whether a delta collection discards the aggregations + held here. The storage sets this for a synchronous instrument on a + reader configured for delta temporality, and leaves it false for + an asynchronous instrument, whose delta conversion needs the + previous state. + """ + def __init__( self, view: View, @@ -81,6 +153,11 @@ def __init__( cardinality_limit: int = _DEFAULT_CARDINALITY_LIMIT, reset_on_collect: bool = False, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._view = view self._cardinality_limit = cardinality_limit self._reset_on_collect = reset_on_collect @@ -108,6 +185,27 @@ def __init__( ) def conflicts(self, other: "_ViewInstrumentMatch") -> bool: + """Report whether the two streams carry the same metric identity. + + The comparison is made on the stream name, the unit of the underlying + instrument and the class of the aggregation, and it stops at the first + of those that differs. When both aggregations are sums, the comparison + continues on the monotonicity of the instrument and on the aggregation + temporality the instrument was given. The description is not compared, + and neither are the attribute keys nor the cardinality limit. + + ``MetricReaderStorage`` calls this on every pair of an existing stream + and a stream it is about to add, and logs a warning when the answer is + true, since two streams agreeing on all of that cannot be told apart + once exported. + + Args: + other: The stream to compare against. + + Returns: + ``True`` when every compared property agrees, ``False`` as soon as + one of them differs. + """ # pylint: disable=protected-access result = ( @@ -135,6 +233,30 @@ def conflicts(self, other: "_ViewInstrumentMatch") -> bool: # pylint: disable=protected-access def consume_measurement(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: + """Fold one measurement into the aggregation for its attribute set. + + The attributes are shallow copied before anything else, so a caller + that mutates the dict it recorded with afterwards does not change the + key this measurement was filed under. A mutable value inside that dict + is still shared, since copying it would cost more than it saves. + + When the view names a set of attribute keys, every attribute whose key + is outside that set is dropped before the key is computed, which is + how several attribute sets end up aggregated together. A view naming + an empty set drops all of them, leaving one aggregation under the + empty attribute set. + + A measurement whose attribute set is new while the stream already + holds as many as its cardinality limit allows is aggregated into the + overflow attribute set instead of its own. + + Args: + measurement: The measurement to aggregate. Only its attributes are + read here, the rest is passed on to the aggregation. + should_sample_exemplar: Whether the aggregation may keep this + measurement as an exemplar. The measurement consumer has + already applied the configured exemplar filter to decide this. + """ attributes = {} if measurement.attributes: # Make a shallow copy since the user can mutate the dict after the fact. @@ -161,7 +283,37 @@ def consume_measurement(self, measurement: Measurement, should_sample_exemplar: aggregation.aggregate(measurement, should_sample_exemplar) def _get_or_create_aggregation(self, aggr_key: _HashedAttributes, attributes: Attributes) -> _Aggregation: - """Must be called with ``self._lock`` held.""" + """Return the aggregation for an attribute set, creating it if needed. + + Must be called with ``self._lock`` held. + + A set that is already known is returned straight away. A set that is + not costs one slot of the cardinality limit, and once the limit is + reached every further set is redirected to the single overflow + aggregation, whose attributes are ``otel.metric.overflow=true``. That + aggregation takes a slot of its own, so a stream exports at most one + more point than its limit. + + A newly created aggregation starts at the current time, except on a + stream that both resets on collection and has already been collected + at least once, where it starts at the beginning of the current cycle + so that a delta point for an attribute set first seen mid cycle still + covers the whole cycle. Only a synchronous instrument collected under + delta temporality resets, so under the default preferences, which are + cumulative, the start time is always the current time. + + Args: + aggr_key: The key of the attribute set, as returned by + ``_hash_attributes``. + attributes: The attribute set itself, handed to the new + aggregation so that it can label its data points. Ignored when + the limit has been reached, where the overflow attributes are + used instead. + + Returns: + The aggregation the measurement should be folded into, which is + the overflow aggregation when the limit has been reached. + """ aggregation = self._attributes_aggregation.get(aggr_key) if aggregation is not None: return aggregation @@ -199,6 +351,31 @@ def collect( collection_aggregation_temporality: AggregationTemporality, collection_start_nanos: int, ) -> Sequence[DataPointT] | None: + """Turn the aggregations held here into data points. + + When this stream was built with ``reset_on_collect`` and the reader + asks for delta temporality, every aggregation is dropped as part of + this call and the next measurement starts a fresh one. An attribute + set that is not recorded again is therefore reported once and then + never again, and the cardinality limit is spent per collection cycle + rather than over the life of the process. In any other case the + aggregations are kept and the attribute sets accumulate. + + Args: + collection_aggregation_temporality: The temporality the reader + asks for, which each aggregation uses to decide whether it + reports a delta or a running total. + collection_start_nanos: The time the whole collection started, in + nanoseconds since the epoch. One value is shared by every + stream collected in the same pass so that their data points + carry the same end time. + + Returns: + One data point per attribute set that produced one, or ``None`` + when none did. An aggregation that has seen no measurement since + the last collection may produce nothing, so an empty result is not + the same as having no attribute sets. + """ data_points: list[DataPointT] = [] with self._lock: aggregations = self._attributes_aggregation diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py index 5477ef664fb..adf2b62110d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py @@ -3,6 +3,30 @@ # pylint: disable=too-many-lines +"""Aggregations: how recorded measurements become exported data points. + +There are two families of classes in this module and they are easy to confuse. + +The public ones, ``Aggregation`` and its subclasses (``SumAggregation``, +``LastValueAggregation``, ``ExplicitBucketHistogramAggregation``, +``ExponentialBucketHistogramAggregation``, ``DropAggregation`` and +``DefaultAggregation``), are factories. A ``View`` holds one of them, it holds +no measurement state, and the same instance is shared by every stream the view +produces. Its ``_create_aggregation`` method builds one object of the second +family. + +The private ones, ``_Aggregation`` and its subclasses, hold the state. One of +them exists per attribute set per stream. ``aggregate`` folds a measurement +into that state and ``collect`` turns the state into a data point. + +Two temporalities are involved in a collection and they are independent. The +instrument aggregation temporality is fixed by the instrument kind: a +synchronous instrument reports increments, so it is ``DELTA``, while an +asynchronous instrument reports running totals, so it is ``CUMULATIVE``. The +collection aggregation temporality is chosen by the metric reader and passed +to ``collect``, which converts between the two when they differ. +""" + import math from abc import ABC, abstractmethod from bisect import bisect_left @@ -69,6 +93,20 @@ class AggregationTemporality(IntEnum): The temporality to use when aggregating data. Can be one of the following values: + + ``UNSPECIFIED`` is the zero value. A factory assigns it when the + instrument is neither synchronous nor asynchronous, which no instrument + shipped with the SDK is. + + ``DELTA`` means a point covers only what happened since the previous + collection, so the start time of each point is the time of the previous + collection. + + ``CUMULATIVE`` means a point covers everything since the stream started, + so the start time of each point is the time the aggregation was created. + + The integer values match the ``AggregationTemporality`` enumeration of the + OpenTelemetry protocol, and that is what ``to_json`` emits. """ UNSPECIFIED = 0 @@ -77,11 +115,39 @@ class AggregationTemporality(IntEnum): class _Aggregation(ABC, Generic[_DataPointVarT]): + """Holds the aggregated state of one attribute set of one stream. + + An instance is created by the ``_create_aggregation`` method of an + ``Aggregation`` factory, the first time a measurement carrying this + attribute set is seen. From then on it receives every further measurement + with those attributes through ``aggregate``, and every collection cycle + asks it for a point through ``collect``. + + Subclasses guard their mutable state with ``self._lock``, because + ``aggregate`` is called from application threads while ``collect`` is + called from the thread of a metric reader. + + Args: + attributes: The attribute set this instance aggregates, after the + view has dropped whatever it filters out. It is copied into every + point ``collect`` returns, and it is also what + ``_collect_exemplars`` subtracts from the exemplar attributes. + reservoir_builder: Called once, here, with no positional arguments, to + build the exemplar reservoir. The factories bind the + aggregation-specific keyword arguments (bucket boundaries, size) + into it beforehand with ``partial``. + """ + def __init__( self, attributes: Attributes, reservoir_builder: ExemplarReservoirBuilder, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._lock = Lock() self._attributes = attributes self._reservoir = reservoir_builder() @@ -102,13 +168,39 @@ def collect( collection_aggregation_temporality: AggregationTemporality, collection_start_nano: int, ) -> _DataPointVarT | None: - pass + """Turn the state accumulated so far into a data point. + + Implementations take ``self._lock`` while they read and reset their + state, so a measurement recorded concurrently lands either wholly in + this point or wholly in the next one. + + Args: + collection_aggregation_temporality: The temporality the metric + reader asked for. When it differs from the temporality of the + instrument, the implementation converts, which is what the + ``_previous_*`` attributes of the subclasses are kept for. + collection_start_nano: Nanoseconds since the Unix epoch at which + this collection cycle started. Every point produced by one + cycle carries the same value as its ``time_unix_nano``. + + Returns: + The point, or ``None`` when there is nothing to report, which + happens when no measurement arrived since the previous collection + or when the requested conversion is not supported. + """ def _collect_exemplars(self) -> Sequence[Exemplar]: """Returns the collected exemplars. + Calling this drains the reservoir and resets it for the next sampling + period, so it is called exactly once per point that ``collect`` + returns. A ``collect`` that returns ``None`` leaves the reservoir + untouched, and those exemplars then appear in the next point. + Returns: - The exemplars collected by the reservoir + The exemplars collected by the reservoir, with the attributes of + this aggregation removed from each of them, so only the attributes + the view dropped remain. Empty when nothing was sampled. """ return self._reservoir.collect(self._attributes) @@ -131,18 +223,67 @@ def _sample_exemplar(self, measurement: Measurement, should_sample_exemplar: boo class _DropAggregation(_Aggregation): + """Keeps no state and produces no point. + + This is what a stream gets when its view selects ``DropAggregation``. The + measurements still reach it, so the instrument and the view keep working, + but nothing is stored and nothing is ever exported for the stream. The + constructor is the one inherited from ``_Aggregation``, and the exemplar + reservoir it builds is never offered anything. + """ + def aggregate(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: - pass + """Discard the measurement. + + Args: + measurement: Ignored. + should_sample_exemplar: Ignored. No measurement is offered to the + exemplar reservoir, whatever the exemplar filter decided. + """ def collect( self, collection_aggregation_temporality: AggregationTemporality, collection_start_nano: int, ) -> _DataPointVarT | None: - pass + """Report nothing. + + Args: + collection_aggregation_temporality: Ignored. + collection_start_nano: Ignored. + + Returns: + ``None``, always, so the stream contributes no data point to any + collection. + """ class _SumAggregation(_Aggregation[Sum]): + """Adds up the measurements of one attribute set. + + The running value starts at ``None`` rather than at zero, which is how an + interval that received no measurement is told apart from one that received + measurements adding up to zero. A negative measurement is added like any + other: this class never rejects one, and an instrument that is declared + monotonic but is handed a negative value will report a decreasing sum. + + Args: + attributes: The attribute set this instance aggregates. + instrument_is_monotonic: Whether the instrument can only increase. It + changes nothing about how the value is computed, and is only read + when two streams are checked for a naming conflict. The + ``is_monotonic`` field of the exported ``Sum`` is derived from the + instrument type instead, by the metric reader storage. + instrument_aggregation_temporality: ``DELTA`` for a synchronous + instrument, whose measurements are increments, and ``CUMULATIVE`` + for an asynchronous one, whose measurements are running totals. + start_time_unix_nano: Nanoseconds since the Unix epoch at which this + stream started. It is the start time of every cumulative point, + and the start time of the first delta point. + reservoir_builder: Builds the exemplar reservoir, as on + ``_Aggregation``. + """ + def __init__( self, attributes: Attributes, @@ -151,6 +292,11 @@ def __init__( start_time_unix_nano: int, reservoir_builder: ExemplarReservoirBuilder, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(attributes, reservoir_builder) self._start_time_unix_nano = start_time_unix_nano @@ -163,6 +309,22 @@ def __init__( self._previous_value = 0 def aggregate(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: + """Add the measured value to the running value. + + For a synchronous instrument the value is an increment and this + accumulates it. For an asynchronous one the value is a running total + reported by a callback, and since a callback runs once per collection + the addition happens once per cycle, so the running value ends up + being the reported total. + + Args: + measurement: The measurement to fold in. Its value may be + negative, and is then subtracted from the running value. + should_sample_exemplar: Whether the measurement may be offered to + the exemplar reservoir. It comes from the exemplar filter and + is passed on unchanged. Offering happens outside the lock, + after the value has been updated. + """ with self._lock: if self._value is None: self._value = 0 @@ -294,6 +456,29 @@ def collect( returned value (delta) |----| + + The running value is reset to ``None`` on every call, whichever + branch is taken. + + Args: + collection_aggregation_temporality: The temporality the metric + reader asked for, which selects one of the four branches + described above. + collection_start_nano: Nanoseconds since the Unix epoch at which + this collection cycle started. It becomes the + ``time_unix_nano`` of the returned point, and it is also + remembered as the start of the next delta interval. + + Returns: + A ``NumberDataPoint``, or ``None``. ``None`` is returned when a + delta point is asked for and no measurement arrived since the + previous collection, and also when the instrument is asynchronous + and its callback produced nothing. A cumulative point is returned + even for an empty interval, since the total is still meaningful. + The start time of a delta point is the time of the previous + collection, falling back to ``start_time_unix_nano`` for the first + one; the start time of a cumulative point is always + ``start_time_unix_nano``. """ with self._lock: @@ -365,17 +550,49 @@ def collect( class _LastValueAggregation(_Aggregation[GaugePoint]): + """Keeps only the most recent measurement of one attribute set. + + Nothing is summed and no history is kept, so a measurement is lost as soon + as the next one arrives. The points this produces carry no start time, + because a gauge reading describes an instant rather than an interval. + + Args: + attributes: The attribute set this instance aggregates. + reservoir_builder: Builds the exemplar reservoir, as on + ``_Aggregation``. + instrument_is_synchronous: Whether the instrument is a synchronous + gauge, as opposed to an observable gauge. It decides whether the + stored value survives a cumulative collection, which is what lets + a synchronous gauge keep reporting its last reading when no new + measurement was recorded. + """ + def __init__( self, attributes: Attributes, reservoir_builder: ExemplarReservoirBuilder, instrument_is_synchronous: bool, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(attributes, reservoir_builder) self._instrument_is_synchronous = instrument_is_synchronous self._value = None def aggregate(self, measurement: Measurement, should_sample_exemplar: bool = True): + """Replace the stored value with the measured one. + + Args: + measurement: The measurement to store. Its timestamp is not kept: + the point produced later is stamped with the time of the + collection instead. + should_sample_exemplar: Whether the measurement may be offered to + the exemplar reservoir. It comes from the exemplar filter and + is passed on unchanged. + """ with self._lock: self._value = measurement.value @@ -388,6 +605,25 @@ def collect( ) -> _DataPointVarT | None: """ Atomically return a point for the current value of the metric. + + The stored value is cleared afterwards, so a later collection that + saw no new measurement reports nothing. There is one exception: a + synchronous gauge collected cumulatively keeps its value, and + therefore repeats the same reading in every cycle until a new + measurement replaces it. + + Args: + collection_aggregation_temporality: The temporality the metric + reader asked for. It does not change the value reported, only + whether the value is kept for the next cycle. + collection_start_nano: Nanoseconds since the Unix epoch at which + this collection cycle started. It becomes the + ``time_unix_nano`` of the returned point. + + Returns: + A ``NumberDataPoint`` whose ``start_time_unix_nano`` is ``None``, + or ``None`` when no measurement has been recorded since the value + was last cleared. """ with self._lock: if self._value is None: @@ -430,6 +666,43 @@ def collect( class _ExplicitBucketHistogramAggregation(_Aggregation[HistogramPoint]): + """Counts measurements into buckets whose boundaries are fixed up front. + + The number of buckets is ``len(boundaries) + 1`` and never changes. Bucket + ``i`` counts the values greater than ``boundaries[i - 1]`` and less than or + equal to ``boundaries[i]``, so a value equal to a boundary falls in the + lower of the two buckets that boundary separates. The last bucket counts + everything above the last boundary, and the first counts everything at or + below the first one, negative values included. + + A negative measurement is accepted. It lands in the first bucket (with the + default boundaries, whose first entry is ``0.0``) and it is subtracted + from the running sum, which is why the specification advises against a + histogram over an instrument that records negative values. + + Args: + attributes: The attribute set this instance aggregates. + instrument_aggregation_temporality: ``DELTA`` for a synchronous + instrument and ``CUMULATIVE`` for an asynchronous one. Only + ``DELTA`` produces points: see ``collect``. + start_time_unix_nano: Nanoseconds since the Unix epoch at which this + stream started. It is the start time of every cumulative point. + reservoir_builder: Builds the exemplar reservoir. The boundaries are + bound into it, so the default reservoir keeps one exemplar per + bucket. + boundaries: Strictly increasing, finite bucket boundaries. ``None`` + selects the default set, which runs from ``0.0`` to ``10000.0``. + An empty sequence is accepted and gives a single bucket holding + every value. + record_min_max: Whether to track the smallest and largest measurement. + When ``False``, the points report ``None`` for both, and the + running extremes are not updated. + + Raises: + ValueError: If a boundary is infinite or ``NaN``, or if the + boundaries are not strictly increasing. + """ + def __init__( self, attributes: Attributes, @@ -439,6 +712,15 @@ def __init__( boundaries: Sequence[float] | None = None, record_min_max: bool = True, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If a boundary is infinite or ``NaN``, or if the + boundaries are not strictly increasing. + """ if boundaries is None: boundaries = _DEFAULT_EXPLICIT_BUCKET_HISTOGRAM_AGGREGATION_BOUNDARIES if boundaries: @@ -470,9 +752,28 @@ def __init__( self._previous_collection_start_nano = self._start_time_unix_nano def _get_empty_bucket_counts(self) -> list[int]: + """Build a fresh counter list sized for the configured boundaries. + + Returns: + A new list of ``len(self._boundaries) + 1`` zeros. It is a new + list on every call, so the caller may store it and mutate it. + """ return [0] * (len(self._boundaries) + 1) def aggregate(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: + """Count the measurement into its bucket. + + The bucket is found by a binary search over the boundaries, the sum is + updated, and the extremes are updated when ``record_min_max`` is + ``True``. Every value is counted, however large or small, since the + first and last buckets are unbounded. + + Args: + measurement: The measurement to count. + should_sample_exemplar: Whether the measurement may be offered to + the exemplar reservoir. It comes from the exemplar filter and + is passed on unchanged. + """ with self._lock: if self._value is None: self._value = self._get_empty_bucket_counts() @@ -496,6 +797,32 @@ def collect( ) -> _DataPointVarT | None: """ Atomically return a point for the current value of the metric. + + The bucket counts, the sum and the extremes are read and reset under + the lock. When a cumulative point is asked for, the counts just read + are added element by element into the running cumulative counts, the + sums are added and the extremes are widened, and the running totals + are what gets reported. + + Args: + collection_aggregation_temporality: The temporality the metric + reader asked for. + collection_start_nano: Nanoseconds since the Unix epoch at which + this collection cycle started. It becomes the + ``time_unix_nano`` of the returned point, and the start of the + next delta interval. + + Returns: + A ``HistogramDataPoint``, or ``None``. ``None`` is returned when a + delta point is asked for and no measurement arrived since the + previous collection, and also whenever the instrument temporality + is ``CUMULATIVE``, which means an asynchronous instrument with + this aggregation never reports anything. The start time of a delta + point is the time of the previous collection, falling back to + ``start_time_unix_nano`` for the first one; the start time of a + cumulative point is always ``start_time_unix_nano``. A cumulative + point produced before any measurement reports ``inf`` as its + minimum and ``-inf`` as its maximum. """ with self._lock: @@ -567,6 +894,51 @@ def collect( # pylint: disable=protected-access class _ExponentialBucketHistogramAggregation(_Aggregation[HistogramPoint]): + """Counts measurements into buckets whose boundaries follow a power law. + + Bucket ``i`` covers the values from ``base ** i`` excluded to + ``base ** (i + 1)`` included, where ``base`` is ``2 ** (2 ** -scale)``. + The scale is therefore a resolution: raising it by one halves the width of + every bucket. The index is signed, so values below one get negative + indexes. Positive and negative measurements are counted in two separate + sets of buckets, the negative ones by their absolute value, and a + measurement of exactly zero is counted in neither and tallied in a + dedicated zero count. + + Unlike the explicit bucket histogram, the buckets here are chosen from the + data. The aggregation starts at ``max_scale`` and lowers the scale + whenever a new measurement would need a span of more than ``max_size`` + buckets: the existing buckets are collapsed in pairs, each halving of the + resolution costing one unit of scale, and the mapping is rebuilt at the + new scale. The scale thus only ever decreases while one instance lives, + and the number of buckets on either side never exceeds ``max_size``. The + scale reported for a stream is not monotonic, because a synchronous + instrument collected with ``DELTA`` temporality discards this object on + every collection and the next one starts again at ``max_scale``. + + Args: + attributes: The attribute set this instance aggregates. + reservoir_builder: Builds the exemplar reservoir. A size of + ``min(20, max_size)`` is bound into it. + instrument_aggregation_temporality: ``DELTA`` for a synchronous + instrument and ``CUMULATIVE`` for an asynchronous one. Only + ``DELTA`` produces points: see ``collect``. + start_time_unix_nano: Nanoseconds since the Unix epoch at which this + stream started. It is the start time of every cumulative point. + max_size: Largest number of buckets allowed on each side, positive and + negative, not counting the zero count. Lowering it makes + downscaling happen sooner and so costs resolution. The default of + 160 comes from the specification. + max_scale: Scale the aggregation starts at, and therefore the finest + resolution it will ever use. A value above 20 is accepted with a + warning, since at a higher scale the mapping loses accuracy. + record_min_max: Whether to track the smallest and largest measurement. + When ``False``, the points report ``None`` for both. + + Raises: + ValueError: If ``max_size`` is below 2 or above 16384. + """ + # _min_max_size and _max_max_size are the smallest and largest values # the max_size parameter may have, respectively. @@ -592,6 +964,14 @@ def __init__( max_scale: int = 20, record_min_max: bool = True, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``max_size`` is below 2 or above 16384. + """ # max_size is the maximum capacity of the positive and negative # buckets. # _sum is the sum of all the values aggregated by this aggregator. @@ -653,6 +1033,33 @@ def __init__( self._mapping = self._new_mapping(self._max_scale) def aggregate(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: + """Count the measurement, rescaling the buckets if it does not fit. + + The sum, the count and, when ``record_min_max`` is ``True``, the + extremes are updated first, for every measurement including zero. A + measurement of exactly zero then increments the zero count and + returns, touching no bucket; while every measurement so far has been + zero the scale is reported as 0, since no mapping has been exercised. + + A non-zero measurement is mapped to a bucket index at the current + scale. If that index would stretch the used range of the side it + belongs to, positive or negative, to more than ``max_size`` buckets, + the scale is lowered by as much as it takes for the range to fit, + both sides are collapsed together so that one scale keeps describing + the whole histogram, the mapping is rebuilt and the value is mapped + again at the new scale. The bucket array is + then grown if needed, to a power of two capped at ``max_size``, and + the bucket is incremented. + + Args: + measurement: The measurement to count. A negative value is + counted in the negative buckets by its absolute value, and is + still subtracted from the running sum. + should_sample_exemplar: Whether the measurement may be offered to + the exemplar reservoir. It comes from the exemplar filter and + is passed on unchanged. A zero measurement returns before the + reservoir is offered anything. + """ # pylint: disable=too-many-branches,too-many-statements, too-many-locals with self._lock: @@ -763,6 +1170,34 @@ def collect( ) -> _DataPointVarT | None: """ Atomically return a point for the current value of the metric. + + Producing a cumulative point is more involved than for the explicit + bucket histogram, because the buckets just collected and the running + cumulative buckets may be at different scales. Both are first brought + down to a common scale, lowering it further if the merged range would + not fit in ``max_size`` buckets, and only then are the counts added + into the cumulative buckets. The scale reported is that common scale, + so it can be lower than the scale of either side on its own. + + Args: + collection_aggregation_temporality: The temporality the metric + reader asked for. + collection_start_nano: Nanoseconds since the Unix epoch at which + this collection cycle started. It becomes the + ``time_unix_nano`` of the returned point, and the start of the + next delta interval. + + Returns: + An ``ExponentialHistogramDataPoint``, or ``None``. ``None`` is + returned when a delta point is asked for and no measurement + arrived since the previous collection, and also whenever the + instrument temporality is ``CUMULATIVE``, which means an + asynchronous instrument with this aggregation never reports + anything. The start time of a delta point is the time of the + previous collection, falling back to ``start_time_unix_nano`` for + the first one; the start time of a cumulative point is always + ``start_time_unix_nano``. The ``flags`` field of the point is + always 0. """ # pylint: disable=too-many-statements, too-many-locals @@ -969,6 +1404,25 @@ def _get_low_high_previous_current( current_scale, min_scale, ): + """Find the index range that the two sets of buckets span together. + + Both ranges are expressed at ``min_scale`` before being combined, so + that they can be compared. A set that is empty, which + ``_get_low_high`` reports as the inverted range ``(0, -1)``, is left + out of the combination, and if both are empty the inverted range is + returned unchanged. + + Args: + previous_point_buckets: The running cumulative buckets. They are + read at ``self._previous_scale``. + current_point_buckets: The buckets just collected. + current_scale: Scale that ``current_point_buckets`` are at. + min_scale: Scale both ranges are shifted down to. + + Returns: + The lowest and the highest bucket index of the union, as a pair, + both at ``min_scale``. + """ (previous_point_low, previous_point_high) = self._get_low_high( previous_point_buckets, self._previous_scale, min_scale ) @@ -990,6 +1444,22 @@ def _get_low_high_previous_current( @staticmethod def _get_low_high(buckets, scale, min_scale): + """Express the index range of one set of buckets at a lower scale. + + Lowering the scale by one halves the number of buckets, so an index + is shifted right by the difference between the two scales. + + Args: + buckets: The buckets whose range is wanted. + scale: Scale the buckets are at. + min_scale: Scale the range is wanted at. It is never above + ``scale``, so the shift is never negative. + + Returns: + The lowest and the highest bucket index, as a pair, at + ``min_scale``. A set of buckets that holds nothing is reported as + ``(0, -1)``, an inverted range, which callers read as empty. + """ if buckets.counts == [0]: return 0, -1 @@ -999,11 +1469,37 @@ def _get_low_high(buckets, scale, min_scale): @staticmethod def _new_mapping(scale: int) -> Mapping: + """Build the object that maps a value to a bucket index at ``scale``. + + Args: + scale: The scale the mapping works at. + + Returns: + A ``LogarithmMapping`` for a positive scale, where buckets are + narrower than a power of two and a logarithm is needed, and an + ``ExponentMapping`` for a scale of zero or below, where a bucket + covers one or more binary exponents and the index can be read off + the floating point representation. + """ if scale <= 0: return ExponentMapping(scale) return LogarithmMapping(scale) def _get_scale_change(self, low, high): + """Count how far the scale must drop for a range to fit. + + The range is halved repeatedly until it spans no more than + ``max_size`` buckets, and the number of halvings is the answer, since + one halving of the bucket count is one unit of scale. + + Args: + low: Lowest bucket index of the range. + high: Highest bucket index of the range. + + Returns: + The number of units the scale has to be lowered by, which is 0 + when the range already fits. + """ change = 0 while high - low >= self._max_size: @@ -1016,6 +1512,23 @@ def _get_scale_change(self, low, high): @staticmethod def _downscale(change: int, positive, negative): + """Collapse both sides of the histogram by ``change`` units of scale. + + Each unit merges neighbouring buckets in pairs, so the counts are + preserved and the resolution drops. Both sides are always collapsed + together, so that one scale keeps describing the whole histogram. + + Args: + change: How many units to lower the scale by. A change of 0 does + nothing and returns immediately. + positive: Buckets holding the positive measurements. + negative: Buckets holding the negative measurements. + + Raises: + Exception: If ``change`` is negative, which would mean raising the + scale. The buckets carry no information to split, so this is + a programming error rather than a recoverable condition. + """ if change == 0: return @@ -1034,6 +1547,30 @@ def _merge( min_scale, aggregation_temporality, ): + """Add the counts of one set of buckets into another, in place. + + Each non-empty bucket of ``current_buckets`` is mapped down to + ``min_scale``, ``previous_buckets`` is extended if the index falls + outside the range it already covers, and the count is added there. + This is the exponential counterpart of adding two fixed bucket count + lists element by element. + + Args: + previous_buckets: The running cumulative buckets. They are + modified in place and may be grown. + current_buckets: The buckets just collected. They are only read. + current_scale: Scale that ``current_buckets`` are at. + min_scale: Scale ``previous_buckets`` are at, and the scale the + indexes of ``current_buckets`` are shifted down to. + aggregation_temporality: When it is ``DELTA``, each count is + negated before being added, which subtracts the current + buckets from the previous ones instead of adding them. + + Raises: + Exception: If an index would stretch ``previous_buckets`` to more + than ``max_size`` buckets, meaning the common scale was + computed wrongly by the caller. + """ current_change = current_scale - min_scale for current_bucket_index, current_bucket in enumerate(current_buckets.counts): @@ -1088,6 +1625,13 @@ def _merge( class Aggregation(ABC): """ Base class for all aggregation types. + + A subclass is a factory and holds no measurement state. A ``View`` is + given one instance, and that instance is asked for a new ``_Aggregation`` + every time the stream sees an attribute set it has not seen before. The + subclasses that take parameters (``ExplicitBucketHistogramAggregation``, + ``ExponentialBucketHistogramAggregation``) hold them and pass them to + every object they build, which is why one instance can be shared. """ @abstractmethod @@ -1098,7 +1642,22 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: - """Creates an aggregation""" + """Creates an aggregation + + Args: + instrument: The instrument the stream was created from. Most + implementations read its kind, to decide whether measurements + are increments or running totals. + attributes: The attribute set the new object will aggregate. + reservoir_factory: Called with the class of the ``_Aggregation`` + about to be built, and returns the builder for the exemplar + reservoir that suits it. + start_time_unix_nano: Nanoseconds since the Unix epoch to record + as the start of the stream. + + Returns: + A new ``_Aggregation`` holding no measurements yet. + """ class DefaultAggregation(Aggregation): @@ -1127,6 +1686,34 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: + """Build the aggregation that suits the instrument kind. + + The instrument type is tested against the API classes in the order of + the table above, and that choice also fixes the instrument + temporality: ``DELTA`` for the synchronous instruments, ``CUMULATIVE`` + for the observable ones. A histogram takes its bucket boundaries from + the advisory the instrument was created with. + + Args: + instrument: The instrument the stream was created from. + attributes: The attribute set the new object will aggregate. + reservoir_factory: Builds the exemplar reservoir builder for the + aggregation class that was chosen. + start_time_unix_nano: Nanoseconds since the Unix epoch to record + as the start of the stream. + + Returns: + A ``_SumAggregation``, a ``_ExplicitBucketHistogramAggregation`` + or a ``_LastValueAggregation``, according to the table above. A + synchronous gauge, which the table omits, also gets a + ``_LastValueAggregation``, built with + ``instrument_is_synchronous`` set to ``True``. + + Raises: + Exception: If the instrument is of none of the known kinds, which + can only happen with an instrument implemented outside this + SDK. + """ # pylint: disable=too-many-return-statements if isinstance(instrument, Counter): return _SumAggregation( @@ -1211,6 +1798,13 @@ def __init__( max_scale: int = 20, record_min_max: bool = True, ): + """Build the factory. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. Nothing is validated here: a + ``max_size`` outside the allowed range is only rejected when the + first stream is created. + """ self._max_size = max_size self._max_scale = max_scale self._record_min_max = record_min_max @@ -1222,6 +1816,24 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: + """Build an exponential bucket histogram for one attribute set. + + The three parameters held by this factory are passed on unchanged, so + every stream the view produces shares the same limits. + + Args: + instrument: The instrument the stream was created from. A + synchronous one gives an instrument temporality of ``DELTA``, + an asynchronous one ``CUMULATIVE``, and anything else + ``UNSPECIFIED``, which produces no points at all. + attributes: The attribute set the new object will aggregate. + reservoir_factory: Builds the exemplar reservoir builder. + start_time_unix_nano: Nanoseconds since the Unix epoch to record + as the start of the stream. + + Returns: + A new ``_ExponentialBucketHistogramAggregation``. + """ instrument_aggregation_temporality = AggregationTemporality.UNSPECIFIED if isinstance(instrument, Synchronous): instrument_aggregation_temporality = AggregationTemporality.DELTA @@ -1258,6 +1870,13 @@ def __init__( boundaries: Sequence[float] | None = None, record_min_max: bool = True, ) -> None: + """Build the factory. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. The boundaries are not validated + here: a malformed sequence is only rejected when the first stream is + created. + """ self._boundaries = boundaries self._record_min_max = record_min_max @@ -1268,6 +1887,30 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: + """Build an explicit bucket histogram for one attribute set. + + The boundaries given to this factory win. When it was given ``None``, + the boundaries come from the advisory of the instrument, and when the + instrument carries no advisory they stay ``None``, which makes the + aggregation fall back to its own default set. + + Args: + instrument: The instrument the stream was created from. A + synchronous one gives an instrument temporality of ``DELTA``, + an asynchronous one ``CUMULATIVE``, and anything else + ``UNSPECIFIED``, which produces no points at all. + attributes: The attribute set the new object will aggregate. + reservoir_factory: Builds the exemplar reservoir builder. + start_time_unix_nano: Nanoseconds since the Unix epoch to record + as the start of the stream. + + Returns: + A new ``_ExplicitBucketHistogramAggregation``. + + Raises: + ValueError: If the boundaries that end up being used are not + finite and strictly increasing. + """ instrument_aggregation_temporality = AggregationTemporality.UNSPECIFIED if isinstance(instrument, Synchronous): instrument_aggregation_temporality = AggregationTemporality.DELTA @@ -1304,6 +1947,25 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: + """Build a sum aggregation for one attribute set. + + Selecting this aggregation in a view is how a histogram instrument + can be reduced to a plain sum. + + Args: + instrument: The instrument the stream was created from. A counter + or an observable counter is reported as monotonic, anything + else as non-monotonic. A synchronous instrument gives an + instrument temporality of ``DELTA``, an asynchronous one + ``CUMULATIVE``, and anything else ``UNSPECIFIED``. + attributes: The attribute set the new object will aggregate. + reservoir_factory: Builds the exemplar reservoir builder. + start_time_unix_nano: Nanoseconds since the Unix epoch to record + as the start of the stream. + + Returns: + A new ``_SumAggregation``. + """ instrument_aggregation_temporality = AggregationTemporality.UNSPECIFIED if isinstance(instrument, Synchronous): instrument_aggregation_temporality = AggregationTemporality.DELTA @@ -1334,6 +1996,20 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: + """Build a last value aggregation for one attribute set. + + Args: + instrument: The instrument the stream was created from. Only + whether it is synchronous matters here, and it decides + whether the stored reading survives a cumulative collection. + attributes: The attribute set the new object will aggregate. + reservoir_factory: Builds the exemplar reservoir builder. + start_time_unix_nano: Ignored. The points this aggregation + produces carry no start time. + + Returns: + A new ``_LastValueAggregation``. + """ return _LastValueAggregation( attributes, reservoir_builder=reservoir_factory(_LastValueAggregation), @@ -1351,4 +2027,18 @@ def _create_aggregation( reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder], start_time_unix_nano: int, ) -> _Aggregation: + """Build the aggregation that discards everything. + + Args: + instrument: Ignored. The behaviour does not depend on the + instrument kind. + attributes: The attribute set the new object would aggregate. It + is stored and never used, since no point is produced. + reservoir_factory: Builds the exemplar reservoir builder. The + reservoir is created and never offered anything. + start_time_unix_nano: Ignored. + + Returns: + A new ``_DropAggregation``. + """ return _DropAggregation(attributes, reservoir_factory(_DropAggregation)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exceptions.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exceptions.py index 5ae8519eb9a..776d4060cb0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exceptions.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exceptions.py @@ -1,6 +1,20 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Exceptions raised by the metrics SDK. + +``MetricsTimeoutError`` is re-exported as +``opentelemetry.sdk.metrics.MetricsTimeoutError``, which is the name +application code should catch. It is raised in two places. The first is +``MeasurementConsumer.collect``, when an asynchronous callback overruns the +deadline, and it propagates out of ``MetricReader.collect`` and +``MetricReader.force_flush`` from there. The second is +``MeterProvider.force_flush``, which raises it and then catches it two lines +later and re-raises a plain ``Exception``, so it never escapes that method. +No shutdown path raises it at all: ``MeterProvider.shutdown`` reports a +deadline overrun as a plain ``Exception``. +""" + class MetricsTimeoutError(Exception): """Raised when a metrics function times out""" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/__init__.py index 8068406a280..93126f17ab1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/__init__.py @@ -1,6 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Exemplar filters and reservoirs. + +An exemplar is one measurement kept alongside an aggregated metric point, +together with the trace and span it was recorded in, so that a reader can +get from an aggregate back to a single recorded event. A filter decides +which measurements are eligible, and a reservoir decides which of the +eligible ones are kept and hands them over when a metric reader collects. +""" + from .exemplar import Exemplar from .exemplar_filter import ( AlwaysOffExemplarFilter, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar.py index e864529238c..4c51ff95250 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar.py @@ -1,6 +1,12 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The exemplar record that a reservoir hands to an exporter. + +``Exemplar`` is frozen, so a reservoir builds one at collection time and +never edits it afterwards. +""" + import dataclasses from opentelemetry.util.types import Attributes diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_filter.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_filter.py index 2117382a0ea..2fcefab2846 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_filter.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_filter.py @@ -1,6 +1,14 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The filters that decide which measurements may become exemplars. + +A filter is consulted before a reservoir is, and answers from the +measurement alone without keeping any state. Three are provided here, and +``opentelemetry.sdk.metrics._internal.MeterProvider`` picks one from the +``OTEL_METRICS_EXEMPLAR_FILTER`` environment variable. +""" + from abc import ABC, abstractmethod from opentelemetry import trace @@ -32,9 +40,19 @@ def should_sample( Args: value: The value of the measurement - timestamp: A timestamp that best represents when the measurement was taken + time_unix_nano: A timestamp that best represents when the + measurement was taken attributes: The complete set of measurement attributes context: The Context of the measurement + + Returns: + ``True`` when the measurement may be offered to a reservoir. + An answer of ``True`` does not mean the measurement will be + kept: the reservoir still decides that. + + Raises: + NotImplementedError: Always, since this is the interface. A + subclass overrides it. """ raise NotImplementedError("ExemplarFilter.should_sample is not implemented") @@ -57,9 +75,13 @@ def should_sample( Args: value: The value of the measurement - timestamp: A timestamp that best represents when the measurement was taken + time_unix_nano: A timestamp that best represents when the + measurement was taken attributes: The complete set of measurement attributes context: The Context of the measurement + + Returns: + ``True``, whatever the arguments are. No argument is read. """ return True @@ -84,9 +106,14 @@ def should_sample( Args: value: The value of the measurement - timestamp: A timestamp that best represents when the measurement was taken + time_unix_nano: A timestamp that best represents when the + measurement was taken attributes: The complete set of measurement attributes context: The Context of the measurement + + Returns: + ``False``, whatever the arguments are. No argument is read and + no reservoir is ever offered a measurement. """ return False @@ -110,9 +137,20 @@ def should_sample( Args: value: The value of the measurement - timestamp: A timestamp that best represents when the measurement was taken + time_unix_nano: A timestamp that best represents when the + measurement was taken attributes: The complete set of measurement attributes - context: The Context of the measurement + context: The Context of the measurement. This is the only + argument read; the other three are ignored. + + Returns: + ``True`` when a span is current in ``context`` and the + ``sampled`` bit of its trace flags is set. ``False`` when no + span is current, which ``get_current_span`` reports by handing + back ``INVALID_SPAN``, and ``False`` when the current span is + not sampled. The decision is the span's own sampling decision, + so a measurement recorded outside any span never becomes an + exemplar under this filter. """ span = trace.get_current_span(context) if span == INVALID_SPAN: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_reservoir.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_reservoir.py index 8bc92896585..ef9dc558ca0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_reservoir.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exemplar/exemplar_reservoir.py @@ -1,6 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The reservoirs that hold exemplars between collections. + +A reservoir receives the measurements an exemplar filter let through and +keeps a bounded number of them until a metric reader collects. Each +aggregation owns one, built by the factory in +``opentelemetry.sdk.metrics._internal.view``. Two implementations are +provided: ``SimpleFixedSizeExemplarReservoir``, which samples uniformly, +and ``AlignedHistogramBucketExemplarReservoir``, which keeps one +measurement per explicit histogram bucket. +""" + from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Callable, Mapping, Sequence @@ -70,7 +81,20 @@ def collect(self, point_attributes: Attributes) -> list[Exemplar]: class ExemplarBucket: + """One cell of a reservoir, holding at most one measurement. + + A cell keeps the last measurement offered to it. The span id and trace + id are read out of the context only when a span is current, and are + never cleared, so after an offer made outside any span the cell still + carries the ids of the last offer that was made inside one. The + reservoir decides which cell a measurement goes to, so overwriting is + how a cell is reused rather than a loss to be avoided. ``collect`` + turns what the cell holds into an ``Exemplar`` and empties the cell, so + a cell offered nothing during a collection cycle contributes nothing. + """ + def __init__(self) -> None: + """Build an empty cell, which collects nothing until it is offered.""" self.__value: int | float = 0 self.__attributes: Attributes = None self.__time_unix_nano: int = 0 @@ -105,7 +129,24 @@ def offer( self.__offered = True def collect(self, point_attributes: Attributes) -> Exemplar | None: - """May return an Exemplar and resets the bucket for the next sampling period.""" + """May return an Exemplar and resets the bucket for the next sampling period. + + Args: + point_attributes: The attributes of the metric point the + exemplar will hang off. Any attribute of the measurement + that also appears here is left out of the exemplar, since + the point already carries it. + + Returns: + An ``Exemplar`` holding the value, the timestamp, the + attributes that survived the filtering above and the span id + and trace id captured by ``offer``, or ``None`` when nothing + has been offered since the last collection. Those two are the + ids of the most recent offer that was made inside a span since + the last collection, which is not necessarily the offer whose + value is reported. They are ``None`` only when no offer since + the last collection had a span current. + """ if not self.__offered: return None @@ -141,9 +182,32 @@ class BucketIndexError(ValueError): class FixedSizeExemplarReservoirABC(ExemplarReservoir): - """Abstract class for a reservoir with fixed size.""" + """Abstract class for a reservoir with fixed size. + + The reservoir holds at most ``size`` cells, each an + ``ExemplarBucket``, created on first use by a ``defaultdict``. + ``offer`` asks ``_find_bucket_index`` which cell a measurement belongs + in and a subclass answers that; a subclass that wants to drop the + measurement raises ``BucketIndexError``, which ``offer`` swallows. One + lock guards both ``offer`` and ``collect``, since measurements arrive + on application threads while a metric reader collects on its own. + + Args: + size: How many cells the reservoir has, which is the largest + number of exemplars one collection can produce. + kwargs: Further aggregation parameters. They are handed to the + parent class, which defines no ``__init__``, so a parameter + that no class in the chain consumes ends up at + ``object.__init__`` and raises ``TypeError``. The reservoirs + in this module are built with none. + """ def __init__(self, size: int, **kwargs) -> None: + """Build the reservoir. + + Every parameter is documented on the class itself, since that is + the form the API reference renders. + """ super().__init__(**kwargs) self._size: int = size self._reservoir_storage: Mapping[int, ExemplarBucket] = defaultdict(ExemplarBucket) @@ -220,7 +284,13 @@ def _find_bucket_index( """ def _reset(self) -> None: - """Reset the reservoir by resetting any stateful logic after a collection cycle.""" + """Reset the reservoir by resetting any stateful logic after a collection cycle. + + This implementation does nothing, because the cells have already + been emptied by ``collect`` before it is called. A subclass that + keeps state of its own beyond the cells overrides it. ``collect`` + calls it while holding the lock. + """ class SimpleFixedSizeExemplarReservoir(FixedSizeExemplarReservoirABC): @@ -228,15 +298,38 @@ class SimpleFixedSizeExemplarReservoir(FixedSizeExemplarReservoirABC): of samples the reservoir has seen so far to determine if the offered measurements should be sampled. + The count of measurements seen is kept per collection cycle and + cleared by ``_reset``, so the sampling starts over after each + collection rather than letting early measurements of the process + crowd out later ones. + + Args: + size: How many cells the reservoir has, which is the largest + number of exemplars one collection can produce. One cell by + default, which is what the specification asks for on a + non-histogram aggregation. + kwargs: Further aggregation parameters, handed on to + ``FixedSizeExemplarReservoirABC``. + Reference: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#simplefixedsizeexemplarreservoir """ def __init__(self, size: int = 1, **kwargs) -> None: + """Build the reservoir. + + Every parameter is documented on the class itself, since that is + the form the API reference renders. + """ super().__init__(size, **kwargs) self._measurements_seen: int = 0 def _reset(self) -> None: + """Clear the measurement count along with the cell contents. + + ``collect`` calls this once it has emptied the cells, so the next + collection cycle samples from scratch. + """ super()._reset() self._measurements_seen = 0 @@ -247,6 +340,31 @@ def _find_bucket_index( attributes: Attributes, context: Context, ) -> int: + """Pick the cell for a measurement, giving each one equal odds. + + The first ``size`` measurements of a collection cycle fill the + cells in order. Every measurement after that is placed in a cell + drawn uniformly from the range ``0`` to ``m - 1``, where ``m`` + counts every measurement the cycle has seen including this one, and + is dropped when the draw lands outside the cells. The ``m``-th + measurement is therefore kept with probability ``size / m``, which + leaves every measurement of the cycle with the same chance of being + one of the kept ones. + + Args: + value: Measured value. The choice does not depend on it. + time_unix_nano: Measurement instant, unused here. + attributes: Measurement attributes, unused here. + context: Measurement context, unused here. + + Returns: + The index of the cell to store the measurement in, between 0 + and ``size - 1``. + + Raises: + BucketIndexError: If the draw landed outside the cells, which + means this measurement is dropped. ``offer`` catches it. + """ if self._measurements_seen < self._size: self._measurements_seen += 1 return self._measurements_seen - 1 @@ -264,11 +382,29 @@ class AlignedHistogramBucketExemplarReservoir(FixedSizeExemplarReservoirABC): configuration of a Histogram. This implementation keeps the last seen measurement that falls within a histogram bucket. + There is one cell per histogram bucket, so the reservoir size is one + more than the number of boundaries. Nothing is ever dropped: every + measurement that reaches the reservoir overwrites the cell of its + bucket. + + Args: + boundaries: The explicit bucket boundaries of the histogram, in + ascending order. They come from the aggregation, which binds + them to the reservoir class with ``functools.partial`` before + the reservoir is built. + kwargs: Further aggregation parameters, handed on to + ``FixedSizeExemplarReservoirABC``. + Reference: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#alignedhistogrambucketexemplarreservoir """ def __init__(self, boundaries: Sequence[float], **kwargs) -> None: + """Build the reservoir. + + Every parameter is documented on the class itself, since that is + the form the API reference renders. + """ super().__init__(len(boundaries) + 1, **kwargs) self._boundaries: Sequence[float] = boundaries @@ -279,6 +415,27 @@ def _find_bucket_index( attributes: Attributes, context: Context, ) -> int: + """Pick the cell that matches the histogram bucket of the value. + + The boundaries are scanned in ascending order and the first one + that ``value`` does not exceed decides the cell, which lands on + the same bucket the explicit bucket histogram aggregation counts + the measurement in. Since the cell is overwritten on every offer, + what survives a collection cycle is the last measurement that fell + in each bucket. + + Args: + value: Measured value, compared against the boundaries. + time_unix_nano: Measurement instant, unused here. + attributes: Measurement attributes, unused here. + context: Measurement context, unused here. + + Returns: + The position of the first boundary that is at least ``value``, + or the number of boundaries when ``value`` is above all of + them. ``BucketIndexError`` is never raised here, so no + measurement is dropped. + """ for index, boundary in enumerate(self._boundaries): if value <= boundary: return index diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/__init__.py index e69de29bb2d..7b5b68a0911 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/__init__.py @@ -0,0 +1,10 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Bucket storage and index mappings for the exponential histogram. + +``buckets`` holds the counts of one data point, and the ``mapping`` +subpackage turns a measured value into the bucket index those counts are +addressed by. The aggregation that drives both lives in +``opentelemetry.sdk.metrics._internal.aggregation``. +""" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/buckets.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/buckets.py index a8749700b00..d11234c838e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/buckets.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/buckets.py @@ -1,14 +1,45 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Storage for the bucket counts of an exponential histogram. + +One instance holds the counts of a single sign of a data point, so an +aggregation keeps two of them: one for the positive measurements and one +for the negative ones. The scale that turns a measured value into a bucket +index lives in the mapping classes under +``opentelemetry.sdk.metrics._internal.exponential_histogram.mapping``. +""" + from math import ceil, log2 class Buckets: - # No method of this class is protected by locks because instances of this - # class are only used in methods that are protected by locks themselves. + """Counts of the buckets of one sign of an exponential histogram. + + The counts are held in a plain list that is read as a ring: position 0 + of the list holds the count of the bucket whose index is + ``index_base``, and positions wrap around when the end of the list is + reached. The rotation lets a measurement below the range recorded so + far be written without moving the counts already stored. A caller that + wants the counts in index order calls ``get_offset_counts``. + + An index identifies a bucket by its boundaries: the bucket with index + ``i`` covers the values in ``(base ** i, base ** (i + 1)]``, where + ``base`` comes from the scale of the mapping in use. Indexes are + signed, so a value below 1 gets a negative index. + + No method here takes a lock. Every caller in + ``opentelemetry.sdk.metrics._internal.aggregation`` already holds one. + """ def __init__(self): + """Build an instance covering the single bucket with index 0. + + The backing list starts with one entry and ``index_base``, + ``index_start`` and ``index_end`` all start at 0. ``__len__`` + reports 0 for that state, so an instance that has been offered + nothing is indistinguishable from an empty histogram. + """ self._counts = [0] # The term index refers to the number of the exponential histogram bucket @@ -35,37 +66,127 @@ def __init__(self): @property def index_start(self) -> int: + """Return the smallest bucket index the counts cover. + + Returns: + The index of the first bucket of the exported histogram. The + aggregation lowers it when a measurement falls below the range + recorded so far. + """ return self.__index_start @index_start.setter def index_start(self, value: int) -> None: + """Move the lower end of the covered index range. + + The backing list is not resized here, so a caller that widens the + range calls ``grow`` first when the range no longer fits. + + Args: + value: The new smallest bucket index. + """ self.__index_start = value @property def index_end(self) -> int: + """Return the largest bucket index the counts cover. + + Returns: + The index of the last bucket of the exported histogram. The + aggregation raises it when a measurement falls above the range + recorded so far. + """ return self.__index_end @index_end.setter def index_end(self, value: int) -> None: + """Move the upper end of the covered index range. + + The backing list is not resized here, so a caller that widens the + range calls ``grow`` first when the range no longer fits. + + Args: + value: The new largest bucket index. + """ self.__index_end = value @property def index_base(self) -> int: + """Return the bucket index whose count sits at position 0. + + Returns: + The index that position 0 of the backing list belongs to. + ``downscale`` sets it equal to ``index_start``, which undoes + the rotation. + """ return self.__index_base @index_base.setter def index_base(self, value: int) -> None: + """Set the bucket index that position 0 of the backing list holds. + + Assigning here reinterprets the counts already stored rather than + moving them, so the aggregation only assigns while the histogram + is still empty. + + Args: + value: The bucket index to give to position 0. + """ self.__index_base = value @property def counts(self): + """Return the backing list of counts, by reference and uncopied. + + The order is the ring order described on the class, which is not + the order the histogram is exported in. The aggregation reads the + length of this list to decide whether a new index still fits, and + calls ``get_offset_counts`` when it wants index order. + + Returns: + The list holding the counts. Mutating it mutates the instance. + """ return self._counts def get_offset_counts(self): + """Return the counts in index order, starting at ``index_start``. + + The ring rotation is undone, so position 0 of the result is the + count of the bucket with index ``index_start``. This is the form + an exporter writes into the ``bucket_counts`` field of a data + point. + + Returns: + A new list holding every entry of the backing list, rotated so + that ``index_start`` comes first. Entries for indexes past + ``index_end`` are included and are 0. + """ bias = self.__index_base - self.__index_start return self._counts[-bias:] + self._counts[:-bias] def grow(self, needed: int, max_size: int) -> None: + """Enlarge the backing list, keeping the ring order intact. + + The new length is the smallest power of two that is at least + ``needed``, held down to ``max_size`` when that power of two + exceeds it. The entries are copied so that the zero padding lands + between the counts of the high indexes and the counts of the low + indexes, which is where the indexes that forced the growth will be + written. + + ``index_start``, ``index_end`` and ``index_base`` are left as they + were, so the caller widens the covered range itself afterwards. + + Args: + needed: How many buckets have to fit once the call returns, + counted across the whole range from ``index_start`` to + ``index_end``. + max_size: Upper bound on the length of the backing list, which + comes from the ``max_size`` of the aggregation. When + ``needed`` exceeds it the list is held at this length and + the counts no longer span the requested range, so the + caller downscales before reaching that point. + """ size = len(self._counts) bias = self.__index_base - self.__index_start old_positive_limit = size - bias @@ -91,9 +212,27 @@ def grow(self, needed: int, max_size: int) -> None: @property def offset(self) -> int: + """Return the index of the first bucket of the exported histogram. + + This is the same value as ``index_start``, under the name the + OpenTelemetry data model gives the field. + + Returns: + The bucket index that position 0 of ``get_offset_counts`` + belongs to. + """ return self.__index_start def __len__(self) -> int: + """Return how many buckets the covered index range spans. + + Returns: + ``index_end - index_start + 1``. The result is 0 when the + backing list is empty, and 0 when the range is a single bucket + whose count is still 0, which is the state a fresh instance + starts in. The length of the backing list is usually larger, + since it is sized in powers of two. + """ if len(self._counts) == 0: return 0 @@ -103,6 +242,21 @@ def __len__(self) -> int: return self.__index_end - self.__index_start + 1 def __getitem__(self, key: int) -> int: + """Return the count of one bucket, addressed in index order. + + Args: + key: Position counted from ``index_start``, so 0 is the bucket + with index ``index_start``. The ring rotation is undone + here, so this is the same position ``get_offset_counts`` + uses. + + Returns: + The count of that bucket. + + Raises: + IndexError: If ``key`` is large enough that the position it + translates to falls past the end of the backing list. + """ bias = self.__index_base - self.__index_start if key < bias: @@ -115,6 +269,25 @@ def __getitem__(self, key: int) -> int: def downscale(self, amount: int) -> None: """ Rotates, then collapses 2 ** amount to 1 buckets. + + The rotation puts the count of ``index_start`` back at position 0 + of the backing list, which makes ``index_base`` equal to + ``index_start`` again. Each run of ``2 ** amount`` neighbouring + buckets is then summed into the leftmost position of the run and + the positions it was read from are set to 0. Runs are aligned on + multiples of ``2 ** amount`` in index space, which is what keeps + the result equal to what a mapping of the lower scale would have + produced from the same measurements. + + ``index_start`` and ``index_end`` are right shifted by ``amount`` + afterwards, and ``index_base`` is set to ``index_start``. The + backing list keeps its length, with the positions above the new + range left at 0. + + Args: + amount: How many steps the scale drops by. Each step merges + two neighbouring buckets into one, so an ``amount`` of 0 + rotates and changes nothing else. """ bias = self.__index_base - self.__index_start @@ -160,9 +333,35 @@ def downscale(self, amount: int) -> None: self.__index_base = self.__index_start def increment_bucket(self, bucket_index: int, increment: int = 1) -> None: + """Add to the count held at one position of the backing list. + + The position is used as given. Unlike ``__getitem__`` this does + not translate from index order, so the caller subtracts + ``index_base`` from the bucket index and wraps the result into the + length of the backing list first. + + Args: + bucket_index: Position in the backing list, which is the + bucket index minus ``index_base``, wrapped around the + length of the list. + increment: How much to add. Recording a measurement adds 1; + merging a previous data point into the current one passes + the count being merged. + """ self._counts[bucket_index] += increment def copy_empty(self) -> "Buckets": + """Return a new instance with this index range and no counts. + + The three index attributes are copied and the backing list of the + copy has the same length as this one, with every entry 0. Delta + temporality uses this to start a collection cycle already laid out + the way the previous one ended, so that adding the copy to the + current counts leaves them unchanged. + + Returns: + A new ``Buckets`` that shares no list with this one. + """ copy = Buckets() # pylint: disable=no-member diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py index a62a2e6b0df..98d4b2b4382 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py @@ -1,6 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The interface that turns a measured value into a bucket index. + +``Mapping`` declares that interface and holds the per-scale instance +cache. Two implementations complete it: ``ExponentMapping`` for scales of +0 and below, where a bucket spans whole powers of two, and +``LogarithmMapping`` for scales of 1 and above, where a power of two is +split across several buckets. +""" + from abc import ABC, abstractmethod from threading import Lock @@ -8,6 +17,20 @@ class Mapping(ABC): """ Parent class for `LogarithmMapping` and `ExponentialMapping`. + + A mapping is immutable once built and carries no per-measurement + state, so one instance per scale is shared by every histogram using + that scale. ``__new__`` keeps that cache. Each concrete subclass + declares its own ``_mappings`` dictionary and ``_mappings_lock``, so + the two subclasses never share an entry even for a scale both could + serve. + + Args: + scale: Resolution of the mapping. A larger scale gives smaller + buckets, since bucket boundaries sit at integer powers of + ``2 ** (2 ** -scale)``. The accepted range is whatever + ``_get_min_scale`` and ``_get_max_scale`` of the subclass + report. """ _mappings: dict[int, "Mapping"] @@ -18,6 +41,18 @@ class Mapping(ABC): # pylint: disable=no-member def __new__(cls, scale: int): + """Return the one instance of ``cls`` that serves ``scale``. + + Every parameter is documented on the class itself, since that is + the form the API reference renders. The instance is built and + initialized the first time a scale is asked for, and handed back + unchanged afterwards. + + Raises: + Exception: If ``scale`` is outside the range the subclass + supports. ``_init`` raises it, so only the first request + for a given scale can. + """ with cls._mappings_lock: # cls._mappings and cls._mappings_lock are implemented in each of # the child classes as a dictionary and a lock, respectively. They @@ -32,6 +67,22 @@ def __new__(cls, scale: int): @abstractmethod def _init(self, scale: int) -> None: + """Check ``scale`` against the subclass range and store it. + + ``__new__`` calls this once, on the instance it has just built for + a scale. A subclass overrides it to precompute whatever it derives + from the scale, and calls this implementation first so that the + range check happens before anything is derived. + + Args: + scale: Resolution of the mapping to store on the instance. + + Raises: + Exception: If ``scale`` is above ``_get_max_scale()`` or below + ``_get_min_scale()``. A plain ``Exception`` is raised, so + a caller cannot tell the two cases apart except by the + message. + """ # pylint: disable=attribute-defined-outside-init if scale > self._get_max_scale(): @@ -55,12 +106,21 @@ def _init(self, scale: int) -> None: def _get_min_scale(self) -> int: """ Return the smallest possible value for the mapping scale + + Returns: + The lowest scale the subclass can represent. ``_init`` rejects + anything below it, which is what sends the remaining scales to + the other subclass. """ @abstractmethod def _get_max_scale(self) -> int: """ Return the largest possible value for the mapping scale + + Returns: + The highest scale the subclass can represent. ``_init`` + rejects anything above it. """ @abstractmethod @@ -69,6 +129,16 @@ def map_to_index(self, value: float) -> int: Maps positive floating point values to indexes corresponding to `Mapping.scale`. Implementations are not expected to handle zeros, +inf, NaN, or negative values. + + Args: + value: A finite, positive measurement. One of the excluded + values gives a meaningless index rather than an error, so + the caller screens them out first. + + Returns: + The index of the bucket holding ``value``, that is, the only + ``index`` for which ``get_lower_boundary(index) < value`` and + ``value <= get_lower_boundary(index + 1)``. """ @abstractmethod @@ -78,11 +148,25 @@ def get_lower_boundary(self, index: int) -> float: expected to map onto a range that is at least partially inside the range of normal floating point values. If the corresponding bucket's upper boundary is less than or equal to 2 ** -1022, - :class:`~opentelemetry.sdk.metrics.MappingUnderflowError` - will be raised. If the corresponding bucket's lower boundary is greater - than ``sys.float_info.max``, - :class:`~opentelemetry.sdk.metrics.MappingOverflowError` - will be raised. + ``MappingUnderflowError`` will be raised. If the corresponding + bucket's lower boundary is greater than ``sys.float_info.max``, + ``MappingOverflowError`` will be raised. Both classes are defined in + the sibling module + ``opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.errors`` + and are not re-exported from this one. + + Args: + index: The bucket index to get the lower boundary of. + + Returns: + The lower boundary of that bucket, which the bucket excludes: + the bucket holds values above this and up to the lower + boundary of ``index + 1``. + + Raises: + MappingUnderflowError: If the whole bucket sits below the + normal floating point range. + MappingOverflowError: If the whole bucket sits above it. """ @property @@ -90,5 +174,10 @@ def scale(self) -> int: """ Returns the parameter that controls the resolution of this mapping. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/datamodel.md#exponential-scale + + Returns: + The scale the instance was built for. It never changes, since + ``__new__`` caches one instance per scale, so the aggregation + builds a different mapping when it needs a different scale. """ return self._scale diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py index 8bf64b28464..764c21c9186 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py @@ -1,6 +1,13 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Errors raised when a bucket boundary is not a representable float. + +Both errors come out of ``get_lower_boundary``, never out of +``map_to_index``, so they report a bucket index that no float can +describe rather than a measurement the mapping cannot handle. +""" + class MappingUnderflowError(Exception): """ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py index d80000334ae..e189300975e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py @@ -1,6 +1,8 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The mapping used for exponential histogram scales of 0 and below.""" + from math import ldexp from threading import Lock @@ -22,6 +24,23 @@ class ExponentMapping(Mapping): + """Maps a value to a bucket index by reading its IEEE 754 exponent. + + This mapping covers scales -10 through 0. At scale 0 a bucket holds + exactly one power of two, so the index is the exponent of the value, + and each step down merges two neighbouring buckets, which is a right + shift of the exponent by ``-scale``. No logarithm is computed. + + Scales of 1 and above belong to ``LogarithmMapping``, where a power of + two is split across several buckets and the exponent alone no longer + determines the index. + + Args: + scale: Resolution of the mapping, from -10 to 0. + ``Mapping.__new__`` returns the single cached instance for + that scale. + """ + # Reference implementation here: # https://github.com/open-telemetry/opentelemetry-go/blob/0e6f9c29c10d6078e8131418e1d1d166c7195d61/sdk/metric/aggregator/exponential/mapping/exponent/exponent.go @@ -32,17 +51,48 @@ class ExponentMapping(Mapping): _max_scale = 0 def _get_min_scale(self): + """Return the lowest scale this mapping serves. + + Returns: + -10, below which the mapping stops being useful for 64-bit + floats: at scale -10 every normal value already falls into one + of the two buckets with index -1 or 0, and + ``get_lower_boundary(1)`` raises ``MappingOverflowError``. + """ # _min_scale defines the point at which the exponential mapping # function becomes useless for 64-bit floats. With scale -10, ignoring # subnormal values, bucket indices range from -1 to 1. return -10 def _get_max_scale(self): + """Return the highest scale this mapping serves. + + Returns: + 0. A positive scale splits one power of two across several + buckets, which reading the exponent cannot express, so those + scales go to ``LogarithmMapping``. + """ # _max_scale is the largest scale supported by exponential mapping. Use # a logarithm mapping for larger scales. return 0 def _init(self, scale: int): + """Precompute the lowest and highest usable bucket indexes. + + ``Mapping._init`` runs first and rejects a scale outside -10 to 0. + ``_min_normal_lower_boundary_index`` ends up as the index of the + bucket that contains the smallest normal float, and + ``_max_normal_lower_boundary_index`` as the index of the bucket + containing the largest one. At scales -1 and 0 the smallest normal + float is itself a bucket boundary, so the index is lowered by one + to keep that value inside the bucket rather than at its edge. + + Args: + scale: Resolution of the mapping, from -10 to 0. + + Raises: + Exception: If ``scale`` is outside that range. + """ # pylint: disable=attribute-defined-outside-init super()._init(scale) @@ -74,6 +124,24 @@ def _init(self, scale: int): self._max_normal_lower_boundary_index = MAX_NORMAL_EXPONENT >> -self._scale def map_to_index(self, value: float) -> int: + """Return the index of the bucket that holds ``value``. + + The IEEE 754 exponent of ``value`` is read directly, then a + correction of -1 is applied when ``value`` is an exact power of + two, so that a boundary belongs to the bucket below it. The result + is right shifted by ``-scale``, which merges ``2 ** -scale`` + neighbouring powers of two into one bucket. + + Args: + value: A finite, positive measurement. Anything below the + smallest normal float, zero and the subnormal values + included, returns ``_min_normal_lower_boundary_index``. + +inf and NaN get past that guard and produce a + meaningless index, so the caller screens them out. + + Returns: + The index of the bucket holding ``value``. + """ if value < MIN_NORMAL_VALUE: return self._min_normal_lower_boundary_index @@ -132,6 +200,21 @@ def map_to_index(self, value: float) -> int: return (exponent + correction) >> -self._scale def get_lower_boundary(self, index: int) -> float: + """Return the lower boundary of the bucket with this index. + + Args: + index: The bucket index to get the lower boundary of. + + Returns: + ``2 ** (index << -scale)``, built with ``ldexp`` so that the + result is exact and no rounding enters. + + Raises: + MappingUnderflowError: If ``index`` is below + ``_min_normal_lower_boundary_index``. + MappingOverflowError: If ``index`` is above + ``_max_normal_lower_boundary_index``. + """ if index < self._min_normal_lower_boundary_index: raise MappingUnderflowError() @@ -142,4 +225,11 @@ def get_lower_boundary(self, index: int) -> float: @property def scale(self) -> int: + """Return the scale this instance was built for. + + Returns: + A value between -10 and 0, fixed when the instance was + created. An aggregation that needs a coarser mapping asks for + another instance rather than changing this one. + """ return self._scale diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py index b1815c42561..df204b88cce 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py @@ -1,6 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Reading the parts of an IEEE 754 binary64 float. + +A Python float is one of these: a sign bit, ``EXPONENT_WIDTH`` exponent +bits and ``MANTISSA_WIDTH`` mantissa bits. The constants here name the +widths, the masks that isolate each field, and the ends of the normal +value range. ``get_ieee_754_exponent`` and ``get_ieee_754_mantissa`` pull +two of the fields out, which is how ``ExponentMapping`` finds a bucket +index without computing a logarithm and how ``LogarithmMapping`` detects +an exact power of two. +""" + from ctypes import c_double, c_uint64 from sys import float_info @@ -52,6 +63,17 @@ def get_ieee_754_exponent(value: float) -> int: """ Gets the exponent of the IEEE 754 representation of a float. + + Args: + value: The float to read. The sign bit is masked away, so a + negative value gives the same answer as its magnitude. + + Returns: + The exponent with the bias already subtracted. A normal value + gives a result from -1022 to 1023. Zero and the subnormal values + give -1023, since their exponent bits are all 0, and +inf and NaN + give 1024, since theirs are all 1. Neither -1023 nor 1024 can come + from a normal value. """ return ( @@ -97,6 +119,19 @@ def get_ieee_754_exponent(value: float) -> int: def get_ieee_754_mantissa(value: float) -> int: + """Get the mantissa bits of the IEEE 754 representation of a float. + + Args: + value: The float to read. The sign and exponent bits are masked + away. + + Returns: + The 52 mantissa bits as an integer, without the leading 1 that a + normal value carries implicitly. A normal value gives 0 here + exactly when it is an integer power of two, and that is the test + both mappings use to decide whether a measurement sits on a bucket + boundary. + """ return ( c_uint64.from_buffer(c_double(value)).value # This step isolates the mantissa bits. There is no need to do any diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py index a0641d751bd..199716066e1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py @@ -1,6 +1,8 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The mapping used for exponential histogram scales of 1 and above.""" + from math import exp, floor, ldexp, log from threading import Lock @@ -21,6 +23,23 @@ class LogarithmMapping(Mapping): + """Maps a value to a bucket index by taking its logarithm. + + This mapping covers scales 1 through 20, where each power of two is + split into ``2 ** scale`` buckets, so the index cannot be read out of + the IEEE 754 exponent alone. ``map_to_index`` computes + ``floor(log(value) * _scale_factor)``, where ``_scale_factor`` is + precomputed so that a multiplication stands in for a division by + ``log(base)``. + + Scales of 0 and below belong to ``ExponentMapping``, which needs no + logarithm. + + Args: + scale: Resolution of the mapping, from 1 to 20. ``Mapping.__new__`` + returns the single cached instance for that scale. + """ + # Reference implementation here: # https://github.com/open-telemetry/opentelemetry-go/blob/0e6f9c29c10d6078e8131418e1d1d166c7195d61/sdk/metric/aggregator/exponential/mapping/logarithm/logarithm.go @@ -31,11 +50,24 @@ class LogarithmMapping(Mapping): _max_scale = 20 def _get_min_scale(self): + """Return the lowest scale this mapping serves. + + Returns: + ``_min_scale``, which is 1. Scale 0 and the negative scales go + to ``ExponentMapping`` instead. + """ # _min_scale ensures that ExponentMapping is used for zero and negative # scale values. return self._min_scale def _get_max_scale(self): + """Return the highest scale this mapping serves. + + Returns: + ``_max_scale``, which is 20. At scale 21 the largest bucket + index reaches the top of the signed 32-bit range that the + OpenTelemetry specification requires indexes to fit in. + """ # _max_scale is 20. The OpenTelemetry specification requires that # bucket indices fit within a signed 32-bit integer. At scale 20, # the maximum bucket index is ((MAX_NORMAL_EXPONENT + 1) << 20) - 1, @@ -47,6 +79,20 @@ def _get_max_scale(self): return self._max_scale def _init(self, scale: int): + """Precompute the scale factor and the two boundary indexes. + + ``Mapping._init`` runs first and rejects a scale outside 1 to 20. + What is derived here is ``_scale_factor``, the multiplier that + replaces a division by ``log(base)`` in ``map_to_index``, and the + indexes of the lowest and highest buckets whose lower boundary is + a representable float. + + Args: + scale: Resolution of the mapping, from 1 to 20. + + Raises: + Exception: If ``scale`` is outside that range. + """ # pylint: disable=attribute-defined-outside-init super()._init(scale) @@ -87,6 +133,23 @@ def _init(self, scale: int): def map_to_index(self, value: float) -> int: """ Maps positive floating point values to indexes corresponding to scale. + + Three cases are separated. A value at or below the smallest normal + float goes into the single bucket below + ``_min_normal_lower_boundary_index``, which collects every + subnormal value and zero. An exact power of two is given the index + of the bucket whose upper boundary it is, so that a boundary + belongs to the bucket below it, which a floating point logarithm + would not place reliably. Every other value is the floor of its + logarithm times ``_scale_factor``, held down to + ``_max_normal_lower_boundary_index``. + + Args: + value: A finite, positive measurement. Negative values, +inf + and NaN are not handled here. + + Returns: + The index of the bucket holding ``value``. """ # value is subnormal @@ -104,6 +167,28 @@ def map_to_index(self, value: float) -> int: ) def get_lower_boundary(self, index: int) -> float: + """Return the lower boundary of the bucket with this index. + + Args: + index: The bucket index to get the lower boundary of. + + Returns: + ``exp(index / _scale_factor)`` for an index strictly inside + the normal range. The three indexes at the ends of that range + are computed differently so that the expression above does not + overflow or underflow: + ``_min_normal_lower_boundary_index`` returns + ``sys.float_info.min`` exactly, the index one below it halves + the boundary computed one power of two higher, and + ``_max_normal_lower_boundary_index`` doubles the boundary + computed one power of two lower. + + Raises: + MappingOverflowError: If ``index`` is above + ``_max_normal_lower_boundary_index``. + MappingUnderflowError: If ``index`` is more than one below + ``_min_normal_lower_boundary_index``. + """ if index >= self._max_normal_lower_boundary_index: if index == self._max_normal_lower_boundary_index: return 2 * exp((index - (1 << self._scale)) / self._scale_factor) @@ -120,4 +205,11 @@ def get_lower_boundary(self, index: int) -> float: @property def scale(self) -> int: + """Return the scale this instance was built for. + + Returns: + A value between 1 and 20, fixed when the instance was created. + An aggregation that needs a coarser mapping asks for another + instance rather than changing this one. + """ return self._scale diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py index 63656979972..b12f8fe3528 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/__init__.py @@ -1,5 +1,23 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=too-many-lines +"""Getting metrics out of the SDK. + +Two roles meet here. A metric reader drives collection: it asks the SDK for +the current state of every metric stream and decides when that happens. A +metric exporter turns a collected batch into whatever an endpoint wants and +sends it. ``PeriodicExportingMetricReader`` is the pairing of the two that +most applications run, collecting on a timer and handing each batch to the +exporter it was built with. The reader owns the aggregation settings as +well: the temporality, the aggregation and the cardinality limit, each per +instrument class. An exporter states its preferences for the first two, and +``PeriodicExportingMetricReader`` adopts them. + +The classes here are re-exported from ``opentelemetry.sdk.metrics.export``, +which is where they should be imported from. +""" + from __future__ import annotations import math @@ -75,7 +93,11 @@ class MetricExportResult(Enum): """Result of exporting a metric - Can be any of the following values:""" + Can be any of the following values: + + ``SUCCESS`` means the batch reached the endpoint and ``FAILURE`` that it + did not and has been lost. Nothing in the SDK retries on ``FAILURE``, and + ``PeriodicExportingMetricReader`` does not read the value at all.""" SUCCESS = 0 FAILURE = 1 @@ -87,6 +109,10 @@ class MetricExporter(ABC): Interface to be implemented by services that want to export metrics received in their own format. + A subclass implements ``export``, ``force_flush`` and ``shutdown``. The two + preferences below are read by a reader at construction and never again, so + changing them on an exporter already paired with a reader does nothing. + Args: preferred_temporality: Used by `opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader` to configure exporter level preferred temporality. See `opentelemetry.sdk.metrics.export.MetricReader` for @@ -101,6 +127,11 @@ def __init__( preferred_temporality: dict[type, AggregationTemporality] | None = None, preferred_aggregation: dict[type, opentelemetry.sdk.metrics.view.Aggregation] | None = None, ) -> None: + """Build the exporter. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._preferred_temporality = preferred_temporality self._preferred_aggregation = preferred_aggregation @@ -113,8 +144,17 @@ def export( ) -> MetricExportResult: """Exports a batch of telemetry data. + ``PeriodicExportingMetricReader`` holds a lock across this call, so an + implementation never sees two exports at once from that reader, and a + slow export delays the next collection. + Args: - metrics: The list of `opentelemetry.sdk.metrics.export.Metric` objects to be exported + metrics_data: The collected batch, holding one ``ResourceMetrics`` + per resource, each holding ``ScopeMetrics`` and ``Metric``. + timeout_millis: How long the export may take, in milliseconds. + Enforcing it is left to the implementation. + **kwargs: Ignored by the exporters in this package, and there for + a caller to pass something an individual exporter understands. Returns: The result of the export @@ -125,13 +165,27 @@ def force_flush(self, timeout_millis: float = 10_000) -> bool: """ Ensure that export of any metrics currently received by the exporter are completed as soon as possible. + + An exporter that sends on the calling thread has nothing to flush and + returns ``True`` at once. + + Args: + timeout_millis: How long the flush may take, in milliseconds. + + Returns: + Whether everything held was exported within the timeout. """ @abstractmethod def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: """Shuts down the exporter. - Called when the SDK is shut down. + Called when the SDK is shut down. An implementation is expected to + tolerate a second call rather than raise. + + Args: + timeout_millis: How long the shutdown may take, in milliseconds. + **kwargs: Accepted and ignored by the exporters in this package. """ @@ -141,6 +195,28 @@ class ConsoleMetricExporter(MetricExporter): This class can be used for diagnostic purposes. It prints the exported metrics to the console STDOUT. + + Nothing is buffered beyond the write itself, so there is nothing to flush + and nothing to release on shutdown. + + Args: + out: Where to write. The default is bound when this module is imported + rather than when an exporter is built, so replacing ``sys.stdout`` + at any point after that import does not redirect an exporter built + with the default. Pass the stream explicitly to write anywhere + else. + formatter: Turns one batch into the text to write. The default emits + the indented JSON of ``MetricsData.to_json``, which spans several + lines, followed by a line separator. Pass a formatter calling + ``to_json(indent=None)`` for the single-line form a line-delimited + consumer wants. The text is written as given, so a formatter that + appends no separator runs batches together. + preferred_temporality: The temporality per instrument class a reader + built with this exporter adopts. ``None`` leaves that reader on + its own defaults. + preferred_aggregation: The aggregation per instrument class a reader + built with this exporter adopts. ``None`` leaves that reader on + its own defaults. """ def __init__( @@ -150,6 +226,11 @@ def __init__( preferred_temporality: dict[type, AggregationTemporality] | None = None, preferred_aggregation: dict[type, opentelemetry.sdk.metrics.view.Aggregation] | None = None, ): + """Build the exporter. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__( preferred_temporality=preferred_temporality, preferred_aggregation=preferred_aggregation, @@ -163,14 +244,42 @@ def export( timeout_millis: float = 10_000, **kwargs, ) -> MetricExportResult: + """Format the batch and write it to the stream. + + The stream is flushed before returning, so the batch is visible even + when the process is killed straight afterwards. + + Args: + metrics_data: The batch to write. + timeout_millis: Ignored. The write is not bounded by a timeout. + **kwargs: Ignored. + + Returns: + Always ``MetricExportResult.SUCCESS``. An error raised by the + stream or by the formatter propagates instead of being turned into + a failure result. + """ self.out.write(self.formatter(metrics_data)) self.out.flush() return MetricExportResult.SUCCESS def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: - pass + """Do nothing, leaving the stream open since it was not opened here. + + Args: + timeout_millis: Ignored. + **kwargs: Ignored. + """ def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Do nothing, since ``export`` already flushed every batch. + + Args: + timeout_millis: Ignored. + + Returns: + Always ``True``. + """ return True @@ -198,7 +307,25 @@ def force_flush(self, timeout_millis: float = 10_000) -> bool: def _validate_cardinality_limit(typ: type, limit: int) -> type: - """Return the SDK instrument class for ``typ`` after validating ``limit``.""" + """Return the SDK instrument class for ``typ`` after validating ``limit``. + + A reader is configured with the API instrument classes, while the limits + it stores are keyed by the SDK subclasses the meter creates, which is why + a limit given for ``Counter`` has to be translated here. + + Args: + typ: One of the seven API instrument classes. Anything else, including + an SDK subclass, is rejected. + limit: The number of distinct attribute sets to keep. ``True`` is + rejected even though Python treats it as the integer 1. + + Returns: + The SDK instrument class corresponding to ``typ``. + + Raises: + ValueError: If ``typ`` is not one of the seven API instrument classes, + or if ``limit`` is not an integer of at least 1. + """ if typ not in _CARDINALITY_INSTRUMENT_CLASSES: raise ValueError(f"Invalid instrument class found {typ}") if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: @@ -237,6 +364,13 @@ class MetricReader(ABC): """ Base class for all metric readers + A subclass implements ``_receive_metrics``, which is handed each collected + batch, and ``shutdown``. It decides for itself when collection happens by + calling ``collect``, which is final and cannot be overridden. Each of the + three mappings below is filled in for all seven instrument classes at + construction and then updated from what the caller passed, so a class left + out of an argument keeps its default rather than going missing. + Args: preferred_temporality: A mapping between instrument classes and aggregation temporality. By default uses CUMULATIVE for all instrument @@ -266,11 +400,19 @@ class MetricReader(ABC): the SDK's own metrics whenever this reader collects. cardinality_limits: A dictionary mapping instrument classes to the maximum number of distinct attribute sets a metric stream of that - instrument class aggregates per collection cycle. Instrument - classes not included use a limit of 2000. A limit defined by a view - takes precedence. Measurements with further attribute sets are - aggregated into one additional point identified by the attribute - ``otel.metric.overflow=true``. + instrument class holds at once. They are released on collection + only for a synchronous instrument collected with delta + temporality, so under the default cumulative preference the limit + is spent over the life of the stream rather than per collection + cycle. Instrument classes not included use a limit of 2000. A + limit defined by a view takes precedence. Measurements with + further attribute sets are aggregated into one additional point + identified by the attribute ``otel.metric.overflow=true``. + otel_component_type: The value reported as ``otel.component.type`` by + the metrics the SDK emits about this reader. Defaults to the + qualified name of the reader class, which is what a reader outside + this package gets unless it names a value from the semantic + conventions. .. document protected _receive_metrics which is a intended to be overridden by subclass .. automethod:: _receive_metrics @@ -285,6 +427,21 @@ def __init__( metric_producers: Sequence[MetricProducer] = (), otel_component_type: OtelComponentTypeValues | None = None, ) -> None: + """Build the reader. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + Exception: If ``preferred_temporality`` holds a value that is + neither ``CUMULATIVE`` nor ``DELTA``, or if + ``preferred_temporality`` or ``preferred_aggregation`` is + keyed by something other than the seven API instrument + classes. + ValueError: If ``cardinality_limits`` is keyed by something other + than the seven API instrument classes, or holds a value that + is not an integer of at least 1. + """ self._metric_producers: tuple[MetricProducer, ...] = tuple(metric_producers) self._collect: ( Callable[ @@ -390,6 +547,15 @@ def collect(self, timeout_millis: float = 10_000) -> None: """Collects the metrics from the internal SDK state and invokes the `_receive_metrics` with the collection. + Until the reader is registered on a ``MeterProvider`` there is nothing + to collect from, and the call logs a warning and returns without + raising, before the timing starts, so nothing is recorded on the SDK's + own metrics for it. Once the reader is registered the duration is + recorded whether or not a batch came back. A collection that yields + nothing does not reach ``_receive_metrics``, so an exporter sees no + batch at all rather than an empty one, except for the producer case + described on ``_receive_metrics``. + Args: timeout_millis: Amount of time in milliseconds before this function raises a timeout error. @@ -426,7 +592,15 @@ def _set_collect_callback( ] | None, ) -> None: - """This function is internal to the SDK. It should not be called or overridden by users""" + """This function is internal to the SDK. It should not be called or overridden by users + + A ``MeterProvider`` passes its measurement consumer's ``collect`` here + on registration, and ``None`` on detachment, which leaves ``collect`` + warning and doing nothing again. + + Args: + func: What ``collect`` delegates to, or ``None`` to detach. + """ self._collect = func @abstractmethod @@ -436,9 +610,34 @@ def _receive_metrics( timeout_millis: float = 10_000, **kwargs, ) -> None: - """Called by `MetricReader.collect` when it receives a batch of metrics""" + """Called by `MetricReader.collect` when it receives a batch of metrics + + This is where a subclass does whatever it exists to do with a batch. + It runs on the thread that called ``collect``, so a reader that blocks + here blocks its own collection loop. + + Args: + metrics_data: The collected batch, which is never ``None``. It + holds at least one metric whenever the SDK's own state + produced the batch, but a ``MetricProducer`` returning a + ``ScopeMetrics`` whose ``metrics`` list is empty produces a + batch carrying no metric at all. + timeout_millis: Whatever budget the caller of ``collect`` gave, in + milliseconds, not reduced by the collection just paid for. + **kwargs: Nothing is passed here by ``collect``. + """ def _set_meter_provider(self, meter_provider: MeterProvider) -> None: + """Point the SDK's own metrics about this reader at a real provider. + + A reader measures itself on a ``NoOpMeterProvider`` until this is + called, which discards those measurements. The replacement builds a new + instrument and, while those metrics are switched on, takes the next + instance number for the reader's component type. + + Args: + meter_provider: The provider the reader was just registered on. + """ self._metrics = create_metric_reader_metrics( self._otel_component_type, meter_provider, @@ -446,6 +645,18 @@ def _set_meter_provider(self, meter_provider: MeterProvider) -> None: ) def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Collect once, right now, instead of waiting for the next cycle. + + A subclass that owns an exporter is expected to override this and + flush the exporter as well, since this base version only collects. + + Args: + timeout_millis: The budget for the collection, in milliseconds. + + Returns: + Always ``True``, including when the reader is not registered on a + ``MeterProvider`` and nothing was collected. + """ self.collect(timeout_millis=timeout_millis) return True @@ -460,6 +671,11 @@ def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: :class:`~opentelemetry.sdk.metrics.MeterProvider`, :meth:`~opentelemetry.sdk.metrics.MeterProvider.shutdown` will invoke this automatically. + + Args: + timeout_millis: The budget for the whole shutdown, in + milliseconds, final collection and thread join included. + **kwargs: Accepted and ignored by the readers in this package. """ @@ -467,6 +683,19 @@ class InMemoryMetricReader(MetricReader): """Implementation of `MetricReader` that returns its metrics from :func:`get_metrics_data`. This is useful for e.g. unit tests. + + Nothing collects on its own here: a batch exists only because + ``get_metrics_data`` was called. There is no exporter and no thread. + + Args: + preferred_temporality: The temporality per instrument class, as on + ``MetricReader``. + preferred_aggregation: The aggregation per instrument class, as on + ``MetricReader``. + cardinality_limits: The attribute set limit per instrument class, as + on ``MetricReader``. + metric_producers: Producers whose metrics are collected together with + the SDK's own on every ``get_metrics_data`` call. """ def __init__( @@ -477,6 +706,11 @@ def __init__( *, metric_producers: Sequence[MetricProducer] = (), ) -> None: + """Build the reader. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__( preferred_temporality=preferred_temporality, preferred_aggregation=preferred_aggregation, @@ -489,7 +723,19 @@ def __init__( def get_metrics_data( self, ) -> MetricsData | None: - """Reads and returns current metrics from the SDK""" + """Reads and returns current metrics from the SDK + + The call collects first, so it reports what has been recorded up to + this moment rather than the result of an earlier collection. The held + batch is then cleared, which means two calls in a row with nothing + recorded in between return a batch and then ``None`` on a reader + configured for delta temporality, while a cumulative reader keeps + reporting the running totals. + + Returns: + The batch just collected, or ``None`` when it yielded nothing, + which covers a reader not registered on a ``MeterProvider``. + """ with self._lock: self.collect() metrics_data = self._metrics_data @@ -502,11 +748,29 @@ def _receive_metrics( timeout_millis: float = 10_000, **kwargs, ) -> None: + """Hold on to the batch for ``get_metrics_data`` to pick up. + + A batch that is still held is overwritten, so collecting twice without + reading in between loses the first batch. + + Args: + metrics_data: The batch just collected. + timeout_millis: Ignored, since storing a reference takes no time. + **kwargs: Ignored. + """ with self._lock: self._metrics_data = metrics_data def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: - pass + """Do nothing. + + There is no thread to stop and no connection to close, and the reader + is not marked as spent, so ``get_metrics_data`` keeps collecting. + + Args: + timeout_millis: Ignored. + **kwargs: Ignored. + """ class PeriodicExportingMetricReader(MetricReader): @@ -517,6 +781,33 @@ class PeriodicExportingMetricReader(MetricReader): The configured exporter's :py:meth:`~MetricExporter.export` method will not be called concurrently. + + The interval is a delay between cycles and not a period: the reader waits + the full interval, collects, exports, and only then starts waiting again, + so a cycle longer than the interval pushes the following ones back rather + than overlapping with them. An exception raised by the exporter is logged + and swallowed, so a failing endpoint stops nothing and the batch is lost. + + Args: + exporter: Where each collected batch is sent. Its preferred + temporality and preferred aggregation become this reader's. + export_interval_millis: How long to wait between cycles, in + milliseconds. ``None`` reads ``OTEL_METRIC_EXPORT_INTERVAL`` and + falls back to 60000 when that variable is unset or unparsable. + ``math.inf`` starts no thread at all, which leaves + ``force_flush`` as the only thing that collects, since + ``shutdown`` collects nothing of its own. + export_timeout_millis: The budget for one scheduled collection and its + export, in milliseconds. ``None`` reads + ``OTEL_METRIC_EXPORT_TIMEOUT`` and falls back to 30000 when that + variable is unset or unparsable. A collection that exceeds it is + abandoned with a warning. It bounds only the collections the + interval triggers: the one final collection the thread runs after + ``shutdown`` is given ``export_interval_millis`` instead. + cardinality_limits: The attribute set limit per instrument class, as + on ``MetricReader``. + metric_producers: Producers whose metrics are collected together with + the SDK's own on every cycle. """ def __init__( @@ -528,6 +819,17 @@ def __init__( *, metric_producers: Sequence[MetricProducer] = (), ) -> None: + """Build the reader and start its thread. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. The thread started here is a daemon, + so it does not keep the process alive, and it is started again in the + child after a fork where ``os.register_at_fork`` exists. + + Raises: + ValueError: If the interval or the timeout, whether passed here or + read from the environment, is zero or negative. + """ # PeriodicExportingMetricReader defers to exporter for configuration super().__init__( preferred_temporality=exporter._preferred_temporality, @@ -581,12 +883,25 @@ def __init__( weak_at_fork = weakref.WeakMethod(self._at_fork_reinit) def _after_in_child() -> None: + """Restart the exporting thread in a forked child. + + The reader is held weakly, so a reader collected before + the fork leaves this doing nothing rather than keeping the + reader alive for the life of the process. + """ if at_fork := weak_at_fork(): at_fork() os.register_at_fork(after_in_child=_after_in_child) def _at_fork_reinit(self): + """Start a fresh exporting thread in a child process after a fork. + + Only the forking thread survives a fork, so the child inherits a reader + whose thread object is there but dead, and a new one is started over + the old reference. The aggregated state is inherited as it was, so the + first batch the child exports covers what the parent recorded. + """ self._daemon_thread = Thread( name="OtelPeriodicExportingMetricReader", target=self._ticker, @@ -595,6 +910,20 @@ def _at_fork_reinit(self): self._daemon_thread.start() def _ticker(self) -> None: + """Run the collection loop until shutdown, then collect once more. + + Each pass waits for the interval to elapse or for the shutdown event, + whichever comes first, and collects when it was the interval. A + collection that times out is logged at warning level and the loop + carries on, so one slow cycle costs one batch rather than the thread. + Any other exception leaves the loop and ends the thread, which stops + the reader exporting for the rest of the process. One final collection + runs after ``shutdown`` has set the event, so whatever was recorded + since the last cycle is still exported. That final collection is given + ``export_interval_millis`` as its budget rather than + ``export_timeout_millis``, so a reader configured with a long interval + and a short timeout lets it run for the length of the interval. + """ interval_secs = self._export_interval_millis / 1e3 while not self._shutdown_event.wait(interval_secs): try: @@ -620,6 +949,22 @@ def _receive_metrics( timeout_millis: float = 10_000, **kwargs, ) -> None: + """Export one batch, swallowing whatever the exporter raises. + + Instrumentation is suppressed for the duration of the call, so an + exporter built on an instrumented HTTP client does not generate + telemetry about its own export. A lock held across the call keeps two + exports from overlapping, as the specification requires. An exception + from the exporter is logged with its traceback and the batch is + dropped, with no retry and no change to the schedule. + + Args: + metrics_data: The batch to export. + timeout_millis: Passed to the exporter unchanged, and not reduced + by the time the collection itself took. The caller is + ``collect``, which forwards whatever budget it was given. + **kwargs: Ignored. + """ token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) # pylint: disable=broad-exception-caught,invalid-name try: @@ -630,9 +975,31 @@ def _receive_metrics( detach(token) def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + """Stop the exporting thread and shut the exporter down. + + The exporting thread wakes up, runs one last collection and exports + it, and ends. This call waits for it, then shuts the exporter down. + Nothing is collected here when the reader was built with an infinite + export interval, because there is no thread to run that collection. + + The timeout bounds the wait for the thread and nothing else. The + remaining budget is passed to the exporter under the keyword + ``timeout``, while ``MetricExporter.shutdown`` declares the parameter + as ``timeout_millis``, so the value lands in ``**kwargs`` and the + exporter shuts down on its own default instead. + + A second call logs a warning and returns at once, leaving the first + call to finish on its own. + + Args: + timeout_millis: The budget for the wait on the exporting thread, + in milliseconds, counted from entry. + **kwargs: Ignored. + """ deadline_ns = time_ns() + timeout_millis * 10**6 def _shutdown(): + """Mark the reader as shut down, under the ``Once`` guard.""" self._shutdown = True did_set = self._shutdown_once.do_once(_shutdown) @@ -646,6 +1013,18 @@ def _shutdown(): self._exporter.shutdown(timeout=(deadline_ns - time_ns()) / 10**6) def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Collect and export right now, then flush the exporter. + + The timer is left alone, so the next scheduled cycle still happens at + its usual time rather than an interval after this call. + + Args: + timeout_millis: Given in full to the collection and then in full + again to the exporter's flush, so this can take twice as long. + + Returns: + Always ``True``. What the exporter's flush reported is discarded. + """ super().force_flush(timeout_millis=timeout_millis) self._exporter.force_flush(timeout_millis=timeout_millis) return True diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/_metric_reader_metrics.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/_metric_reader_metrics.py index 019f1cc1725..6e816fcb08d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/_metric_reader_metrics.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/export/_metric_reader_metrics.py @@ -1,6 +1,24 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The metrics a metric reader emits about its own collections. + +One instrument is created here, the histogram named +``otel.sdk.metric_reader.collection.duration``, declared in +``opentelemetry.semconv._incubating.metrics.otel_metrics`` and recorded in +seconds. Every recording carries ``otel.component.type``, the kind of reader, +and ``otel.component.name``, that kind followed by a slash and the number of +``MetricReaderMetrics`` objects of that kind built before this one. One reader +builds two of them, one in ``MetricReader.__init__`` and one when it is +registered on a ``MeterProvider``, and only the second ever records anything, +so a lone periodic reader emits ``periodic_metric_reader/1``. + +``MetricReader`` holds one of these objects and calls ``record_collection`` +once per collection. The object is a ``NoOpMetricReaderMetrics`` unless +``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` is set to ``true``, which keeps +the SDK from measuring itself by default. +""" + from collections import Counter from typing import Protocol @@ -17,16 +35,60 @@ class MetricReaderMetricsT(Protocol): - def record_collection(self, duration: float) -> None: ... + """What a metric reader expects of the object it reports collections to. + + Both ``MetricReaderMetrics`` and ``NoOpMetricReaderMetrics`` satisfy this + protocol, which lets a reader hold either one without testing which. + """ + + def record_collection(self, duration: float) -> None: + """Report that one collection took ``duration``. + + Args: + duration: How long the collection took, in seconds. + """ class NoOpMetricReaderMetrics: + """The stand-in used while the SDK's own metrics are switched off. + + Every recording is discarded, so no instrument is created and no meter is + requested from the meter provider. + """ + def record_collection(self, duration: float) -> None: - pass + """Discard the recording. + + Args: + duration: How long the collection took, in seconds. Unused. + """ class MetricReaderMetrics: + """Records the duration of each collection of one metric reader. + + The instance number that goes into ``otel.component.name`` is taken from a + module-level counter keyed by component type, which is never reset, so + building and discarding readers over the life of the process keeps raising + the number rather than reusing a freed one. + + Args: + component_type: The value of ``otel.component.type`` for this reader, + either a value from the semantic conventions such as + ``periodic_metric_reader`` or the qualified name of the reader + class when the reader declares none. + meter_provider: The provider the histogram is created on. Passing a + ``NoOpMeterProvider`` yields a meter whose instruments discard + everything, which is what a reader holds until it is registered on + a real ``MeterProvider``. + """ + def __init__(self, component_type: str, meter_provider: MeterProvider) -> None: + """Build the instance and create the histogram. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ meter = meter_provider.get_meter("opentelemetry-sdk") count = _component_counter[component_type] @@ -40,6 +102,15 @@ def __init__(self, component_type: str, meter_provider: MeterProvider) -> None: self._collection_duration = create_otel_sdk_metric_reader_collection_duration(meter) def record_collection(self, duration: float) -> None: + """Record one collection on the histogram. + + The caller times the collection whether it succeeded or failed, and + no ``error.type`` attribute is added here, so a failed collection is + recorded under the same attributes as a successful one. + + Args: + duration: How long the collection took, in seconds. + """ self._collection_duration.record(duration, self._standard_attrs) @@ -48,6 +119,22 @@ def create_metric_reader_metrics( meter_provider: MeterProvider, enabled: bool, ) -> MetricReaderMetricsT: + """Build the object a metric reader reports its collections to. + + Args: + component_type: The value of ``otel.component.type`` for the reader. + Ignored when ``enabled`` is false. + meter_provider: The provider the histogram is created on. Ignored when + ``enabled`` is false, so no meter is requested in that case. + enabled: Whether the SDK emits metrics about itself, read from + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` by the caller. + + Returns: + A ``MetricReaderMetrics`` when ``enabled`` is true, otherwise a + ``NoOpMetricReaderMetrics``. The call also consumes the next instance + number for ``component_type`` when ``enabled`` is true, and consumes + none when it is false. + """ if not enabled: return NoOpMetricReaderMetrics() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/instrument.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/instrument.py index 872715b8955..ea161e93369 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/instrument.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/instrument.py @@ -2,6 +2,40 @@ # SPDX-License-Identifier: Apache-2.0 # pylint: disable=too-many-ancestors, unused-import +"""SDK implementations of the metric instruments. + +An instrument is the object instrumentation code calls to report a +measurement. The classes here implement the abstract instruments of +``opentelemetry.metrics`` on top of a ``MeasurementConsumer``, which hands +every measurement to the views and metric readers of the ``MeterProvider`` +the instrument came from. + +Instruments come in two kinds. A synchronous instrument (``Counter``, +``UpDownCounter``, ``Histogram``, ``Gauge``) is called by the application at +the moment the measurement happens, and reports it immediately. An +asynchronous instrument (``ObservableCounter``, ``ObservableUpDownCounter``, +``ObservableGauge``) is given callbacks instead, and the SDK invokes them +during collection. + +None of these classes is meant to be constructed directly. Each one rejects +direct instantiation in ``__new__``, and a ``Meter`` creates instruments by +instantiating the ``_``-prefixed subclass of each, declared in this module +for exactly that purpose. + +Every constructor validates the instrument name and unit. A name has to +match ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``: it starts with an ASCII letter, +runs to at most 255 characters, and otherwise holds only ASCII letters, +digits, ``-``, ``_``, ``.`` and ``/``. A unit has to be ASCII and at most 63 +characters. Either one failing raises a bare ``Exception`` from the +constructor, so an invalid name or unit is a hard failure and not something +the SDK logs and works around. The accepted name is then stored lower-cased, +which is what makes instrument names case-insensitive to a ``View``. + +Measurement values are checked at recording time instead, and a value that +fails the check is dropped with a warning on this module's logger rather +than raised. +""" + from __future__ import annotations import math @@ -53,6 +87,20 @@ @runtime_checkable class _Instrument(Protocol): + """Structural type for the four attributes view matching reads. + + ``View._match`` and ``_ViewInstrumentMatch`` are handed instruments typed + as this protocol, so they depend on the attributes they read and not on + any concrete instrument class. The protocol is ``runtime_checkable``, so + an ``isinstance`` check against it tests only that the four attributes + exist. + + ``name`` is the lower-cased instrument name, ``unit`` the unit of + measure, ``description`` the free-form description (the empty string when + none was given), and ``instrumentation_scope`` the scope of the ``Meter`` + that created the instrument. + """ + name: str unit: str description: str @@ -60,6 +108,38 @@ class _Instrument(Protocol): class _Synchronous(_Instrument, Synchronous): + """Base of the instruments the application records into directly. + + The constructor validates the instrument identity and keeps the + measurement consumer that ``Counter.add``, ``UpDownCounter.add``, + ``Histogram.record`` and ``Gauge.set`` hand their measurements to. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, which bounds it to 255 + characters and to ASCII letters, digits, ``-``, ``_``, ``.`` + and ``/``, starting with a letter. It is stored lower-cased, so + two names differing only in case produce one instrument. + instrumentation_scope: The scope of the ``Meter`` that created this + instrument. A ``View`` matches its ``meter_name``, + ``meter_version`` and ``meter_schema_url`` criteria against it, + and it identifies the scope the resulting metrics are grouped + under on export. + measurement_consumer: Receives every measurement recorded on this + instrument and passes it to the views and metric readers of the + ``MeterProvider``. + unit: The unit of measure, as an ASCII string of at most 63 + characters. ``None`` is accepted and replaced by the empty + string. + description: Free-form text saying what the instrument measures. + ``None`` is accepted and replaced by the empty string. + _meter_config: The configuration object shared with the creating + ``Meter``. ``None`` means the instrument is permanently enabled. + When it is given, the instrument reads it on every recording + call, so disabling the meter afterwards also silences + instruments that were created before. + """ + def __init__( self, name: str, @@ -70,6 +150,18 @@ def __init__( *, _meter_config: _ProxyMeterConfig | None = None, ): + """Validate the instrument identity and store what recording needs. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + Exception: If ``name`` does not match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, or if ``unit`` is not an + ASCII string of at most 63 characters. A bare ``Exception`` + is raised in both cases rather than a ``ValueError``, and the + instrument is not created. + """ # pylint: disable=no-member result = self._check_name_unit_description(name, unit, description) @@ -94,10 +186,60 @@ def __init__( super().__init__(name, unit=unit, description=description) def _is_enabled(self) -> bool: + """Report whether recording on this instrument is currently on. + + Returns: + ``True`` when the instrument was built without a meter config, or + when the shared config reports the meter as enabled. The value is + read on every call, so a later change to the meter config takes + effect on the next measurement. + """ return self._meter_config is None or self._meter_config.is_enabled class _Asynchronous(_Instrument, Asynchronous): + """Base of the instruments the SDK reads from during collection. + + The constructor validates the instrument identity the same way + ``_Synchronous`` does, then normalizes every callback into a plain + callable so that ``callback`` can treat them all alike. + + Args: + name: The instrument name. It has to match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, which bounds it to 255 + characters and to ASCII letters, digits, ``-``, ``_``, ``.`` + and ``/``, starting with a letter. It is stored lower-cased, so + two names differing only in case produce one instrument. + instrumentation_scope: The scope of the ``Meter`` that created this + instrument. A ``View`` matches its ``meter_name``, + ``meter_version`` and ``meter_schema_url`` criteria against it, + and it identifies the scope the resulting metrics are grouped + under on export. + measurement_consumer: Stored for symmetry with ``_Synchronous``. An + asynchronous instrument never reads it, because the measurement + consumer drives the exchange in the other direction: it registers + the instrument and calls ``callback`` on it at collection time. + callbacks: The callbacks the SDK invokes on every collection. + ``None``, which is the default, means the instrument reports + nothing. Each entry is either a callable taking a + ``CallbackOptions`` and returning an iterable of + ``opentelemetry.metrics.Observation``, or a generator that + yields such an iterable and receives a ``CallbackOptions`` back + from each ``yield``. A generator is advanced to its first + ``yield`` here, during construction, and whatever it yields there + is discarded. + unit: The unit of measure, as an ASCII string of at most 63 + characters. ``None`` is accepted and replaced by the empty + string. + description: Free-form text saying what the instrument measures. + ``None`` is accepted and replaced by the empty string. + _meter_config: The configuration object shared with the creating + ``Meter``. ``None`` means the instrument is permanently enabled. + When it is given, the instrument reads it at the start of every + collection, so disabling the meter afterwards also silences + instruments that were created before. + """ + def __init__( self, name: str, @@ -109,6 +251,21 @@ def __init__( *, _meter_config: _ProxyMeterConfig | None = None, ): + """Validate the instrument identity and normalize the callbacks. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + Exception: If ``name`` does not match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, or if ``unit`` is not an + ASCII string of at most 63 characters. A bare ``Exception`` + is raised in both cases rather than a ``ValueError``, and the + instrument is not created. + StopIteration: If an entry of ``callbacks`` is a generator that + is already exhausted, since advancing it to its first + ``yield`` is what the constructor does with it. + """ # pylint: disable=no-member result = self._check_name_unit_description(name, unit, description) @@ -144,6 +301,20 @@ def inner( options: CallbackOptions, callback=callback, ) -> Iterable[Measurement]: + """Resume the generator callback and return what it yields next. + + ``callback`` is bound as a default argument so that each iteration of + the enclosing loop captures its own generator rather than the last one. + + Args: + options: The collection options to send into the generator. + callback: The generator to resume. Bound at definition time; a + caller never passes it. + + Returns: + The measurements the generator yields, or an empty list once the + generator is exhausted. + """ try: return callback.send(options) except StopIteration: @@ -154,9 +325,43 @@ def inner( self._callbacks.append(callback) def _is_enabled(self) -> bool: + """Report whether this instrument is currently observed. + + Returns: + ``True`` when the instrument was built without a meter config, or + when the shared config reports the meter as enabled. ``callback`` + consults this once per collection, so a later change to the meter + config takes effect on the next collection. + """ return self._meter_config is None or self._meter_config.is_enabled def callback(self, callback_options: CallbackOptions) -> Iterable[Measurement]: + """Run the callbacks and convert what they report. + + This is a generator, so the callbacks only run as the caller consumes + the result. The measurement consumer calls it once per collection. + + A callback that raises is logged with a traceback and abandoned; the + callbacks after it still run, and the measurements the failing + callback had already produced are kept. A reported value that is not + finite (``nan``, ``inf`` or ``-inf``) is logged at warning level and + skipped, while the rest of the same callback's observations are + still used. Neither case propagates an exception to the caller. + + Args: + callback_options: Passed to each callback unchanged. It carries + the timeout the callback is expected to respect; nothing here + enforces it. + + Yields: + One ``Measurement`` per finite observation, timestamped with the + moment it was converted rather than the moment the callback ran. + The context of the observation is used when it holds anything. An + observation carrying ``None``, and one carrying an empty + ``Context``, both get the context current at the moment of the + conversion, since the two cases are indistinguishable here. + Nothing is yielded at all when the meter is disabled. + """ if not self._is_enabled(): return for callback in self._callbacks: @@ -181,7 +386,29 @@ def callback(self, callback_options: CallbackOptions) -> Iterable[Measurement]: class Counter(_Synchronous, APICounter): + """Synchronous instrument for a quantity that only ever increases. + + Obtain one from ``Meter.create_counter`` rather than by calling this + class, which refuses direct instantiation. The constructor parameters are + documented on ``_Synchronous``. + """ + def __new__(cls, *args, **kwargs): + """Refuse construction of ``Counter`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_Counter``. + + Raises: + TypeError: If ``Counter`` is instantiated directly instead of + through ``Meter.create_counter``. + """ if cls is Counter: raise TypeError("Counter must be instantiated via a meter.") return super().__new__(cls) @@ -192,6 +419,22 @@ def add( attributes: Attributes = None, context: Context | None = None, ): + """Add a non-negative increment to the counter. + + The measurement is dropped, with a warning naming the instrument, if + ``amount`` is not finite or is negative. Nothing is raised in either + case. When the creating meter is disabled the call does nothing at + all, and the value is not even checked. + + Args: + amount: How much to add. It has to be finite and non-negative. + attributes: Attributes to attach to the measurement, which decide + which time series within the metric stream it lands in. + ``None`` means no attributes. + context: The context to associate with the measurement, which is + what a trace-based exemplar filter inspects. ``None`` means + the context current at the moment of the call. + """ if not self._is_enabled(): super().add(amount, attributes=attributes, context=context) return @@ -219,7 +462,29 @@ def add( class UpDownCounter(_Synchronous, APIUpDownCounter): + """Synchronous instrument for a quantity that rises and falls. + + Obtain one from ``Meter.create_up_down_counter`` rather than by calling + this class, which refuses direct instantiation. The constructor + parameters are documented on ``_Synchronous``. + """ + def __new__(cls, *args, **kwargs): + """Refuse construction of ``UpDownCounter`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_UpDownCounter``. + + Raises: + TypeError: If ``UpDownCounter`` is instantiated directly instead + of through ``Meter.create_up_down_counter``. + """ if cls is UpDownCounter: raise TypeError("UpDownCounter must be instantiated via a meter.") return super().__new__(cls) @@ -230,6 +495,24 @@ def add( attributes: Attributes = None, context: Context | None = None, ): + """Add a signed delta to the counter. + + A negative ``amount`` is accepted here, which is the one thing that + separates this from ``Counter.add``. A value that is not finite is + dropped with a warning naming the instrument, and nothing is raised. + When the creating meter is disabled the call does nothing at all, and + the value is not even checked. + + Args: + amount: How much to add. It may be negative, and has to be + finite. + attributes: Attributes to attach to the measurement, which decide + which time series within the metric stream it lands in. + ``None`` means no attributes. + context: The context to associate with the measurement, which is + what a trace-based exemplar filter inspects. ``None`` means + the context current at the moment of the call. + """ if not self._is_enabled(): super().add(amount, attributes=attributes, context=context) return @@ -254,20 +537,102 @@ def add( class ObservableCounter(_Asynchronous, APIObservableCounter): + """Asynchronous instrument whose callbacks report a running total. + + The callbacks report the cumulative value reached so far, not the + increment since the previous collection, and the SDK derives the + increment from consecutive observations. Obtain one from + ``Meter.create_observable_counter`` rather than by calling this class, + which refuses direct instantiation. The constructor parameters are + documented on ``_Asynchronous``. + """ + def __new__(cls, *args, **kwargs): + """Refuse construction of ``ObservableCounter`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_ObservableCounter``. + + Raises: + TypeError: If ``ObservableCounter`` is instantiated directly + instead of through ``Meter.create_observable_counter``. + """ if cls is ObservableCounter: raise TypeError("ObservableCounter must be instantiated via a meter.") return super().__new__(cls) class ObservableUpDownCounter(_Asynchronous, APIObservableUpDownCounter): + """Asynchronous instrument whose callbacks report an additive total. + + The callbacks report the current value, which may be lower than the one + reported at the previous collection. Obtain one from + ``Meter.create_observable_up_down_counter`` rather than by calling this + class, which refuses direct instantiation. The constructor parameters are + documented on ``_Asynchronous``. + """ + def __new__(cls, *args, **kwargs): + """Refuse construction of ``ObservableUpDownCounter`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_ObservableUpDownCounter``. + + Raises: + TypeError: If ``ObservableUpDownCounter`` is instantiated + directly instead of through + ``Meter.create_observable_up_down_counter``. + """ if cls is ObservableUpDownCounter: raise TypeError("ObservableUpDownCounter must be instantiated via a meter.") return super().__new__(cls) class Histogram(_Synchronous, APIHistogram): + """Synchronous instrument for the distribution of recorded values. + + This is the one instrument with a constructor of its own, because it + carries an advisory about the bucket boundaries it would prefer. The + advisory is a hint: a ``View`` that names an explicit aggregation + overrides it. + + Obtain one from ``Meter.create_histogram`` rather than by calling this + class, which refuses direct instantiation. + + Args: + name: The instrument name, validated and lower-cased exactly as + ``_Synchronous`` describes. + instrumentation_scope: The scope of the ``Meter`` that created this + instrument. + measurement_consumer: Receives every recorded measurement and passes + it to the views and metric readers of the ``MeterProvider``. + unit: The unit of measure, as an ASCII string of at most 63 + characters. + description: Free-form text saying what the instrument measures. + explicit_bucket_boundaries_advisory: The bucket boundaries this + histogram suggests to whatever aggregates it. ``None``, the + default, means no suggestion, and the aggregation then picks its + own boundaries. ``Meter.create_histogram`` replaces a value that + is not a sequence of numbers with ``None`` and logs a warning, so + an instrument built through a meter never holds an invalid + advisory; constructing this class directly performs no such + check. + _meter_config: The configuration object shared with the creating + ``Meter``. ``None`` means the instrument is permanently enabled. + """ + def __init__( self, name: str, @@ -279,6 +644,16 @@ def __init__( *, _meter_config: _ProxyMeterConfig | None = None, ): + """Build the instrument and wrap its bucket boundary advisory. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + Exception: If ``name`` does not match + ``[a-zA-Z][-_./a-zA-Z0-9]{0,254}``, or if ``unit`` is not an + ASCII string of at most 63 characters. + """ super().__init__( name, unit=unit, @@ -290,6 +665,21 @@ def __init__( self._advisory = _MetricsHistogramAdvisory(explicit_bucket_boundaries=explicit_bucket_boundaries_advisory) def __new__(cls, *args, **kwargs): + """Refuse construction of ``Histogram`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_Histogram``. + + Raises: + TypeError: If ``Histogram`` is instantiated directly instead of + through ``Meter.create_histogram``. + """ if cls is Histogram: raise TypeError("Histogram must be instantiated via a meter.") return super().__new__(cls) @@ -300,6 +690,23 @@ def record( attributes: Attributes = None, context: Context | None = None, ): + """Record one value into the distribution. + + The measurement is dropped, with a warning naming the instrument, if + ``amount`` is not finite or is negative. Nothing is raised in either + case. When the creating meter is disabled the call does nothing at + all, and the value is not even checked. + + Args: + amount: The value to record. It has to be finite and + non-negative. + attributes: Attributes to attach to the measurement, which decide + which time series within the metric stream it lands in. + ``None`` means no attributes. + context: The context to associate with the measurement, which is + what a trace-based exemplar filter inspects. ``None`` means + the context current at the moment of the call. + """ if not self._is_enabled(): super().record(amount, attributes=attributes, context=context) return @@ -330,7 +737,31 @@ def record( class Gauge(_Synchronous, APIGauge): + """Synchronous instrument for the latest value of something sampled. + + Only the last value recorded before a collection is reported; earlier + ones in the same collection cycle are replaced. Obtain one from + ``Meter.create_gauge`` rather than by calling this class, which refuses + direct instantiation. The constructor parameters are documented on + ``_Synchronous``. + """ + def __new__(cls, *args, **kwargs): + """Refuse construction of ``Gauge`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_Gauge``. + + Raises: + TypeError: If ``Gauge`` is instantiated directly instead of + through ``Meter.create_gauge``. + """ if cls is Gauge: raise TypeError("Gauge must be instantiated via a meter.") return super().__new__(cls) @@ -341,6 +772,23 @@ def set( attributes: Attributes = None, context: Context | None = None, ): + """Record the current value of the gauge. + + A negative ``amount`` is fine. A value that is not finite is dropped + with a warning naming the instrument, and nothing is raised, which + leaves the previously recorded value in place. When the creating + meter is disabled the call does nothing at all, and the value is not + even checked. + + Args: + amount: The value the gauge now holds. It has to be finite. + attributes: Attributes to attach to the measurement, which decide + which time series within the metric stream it lands in. + ``None`` means no attributes. + context: The context to associate with the measurement, which is + what a trace-based exemplar filter inspects. ``None`` means + the context current at the moment of the call. + """ if not self._is_enabled(): super().set(amount, attributes=attributes, context=context) return @@ -365,7 +813,30 @@ def set( class ObservableGauge(_Asynchronous, APIObservableGauge): + """Asynchronous instrument whose callbacks report a current value. + + Each observation stands on its own and is not combined with the one + before it. Obtain one from ``Meter.create_observable_gauge`` rather than + by calling this class, which refuses direct instantiation. The + constructor parameters are documented on ``_Asynchronous``. + """ + def __new__(cls, *args, **kwargs): + """Refuse construction of ``ObservableGauge`` itself. + + Args: + *args: Forwarded to ``object.__new__`` by way of ``super``, which + ignores them. + **kwargs: Ignored, for the same reason. + + Returns: + A new instance, but only when ``cls`` is a subclass such as + ``_ObservableGauge``. + + Raises: + TypeError: If ``ObservableGauge`` is instantiated directly + instead of through ``Meter.create_observable_gauge``. + """ if cls is ObservableGauge: raise TypeError("ObservableGauge must be instantiated via a meter.") return super().__new__(cls) @@ -373,28 +844,61 @@ def __new__(cls, *args, **kwargs): # Below classes exist to prevent the direct instantiation class _Counter(Counter): - pass + """The concrete counter a ``Meter`` instantiates. + + It adds nothing to ``Counter``. It exists so that the ``__new__`` guard + on ``Counter`` can reject every other caller while this subclass passes. + """ class _UpDownCounter(UpDownCounter): - pass + """The concrete up-down counter a ``Meter`` instantiates. + + It adds nothing to ``UpDownCounter``. It exists so that the ``__new__`` + guard on ``UpDownCounter`` can reject every other caller while this + subclass passes. + """ class _ObservableCounter(ObservableCounter): - pass + """The concrete observable counter a ``Meter`` instantiates. + + It adds nothing to ``ObservableCounter``. It exists so that the + ``__new__`` guard on ``ObservableCounter`` can reject every other caller + while this subclass passes. + """ class _ObservableUpDownCounter(ObservableUpDownCounter): - pass + """The concrete observable up-down counter a ``Meter`` instantiates. + + It adds nothing to ``ObservableUpDownCounter``. It exists so that the + ``__new__`` guard on ``ObservableUpDownCounter`` can reject every other + caller while this subclass passes. + """ class _Histogram(Histogram): - pass + """The concrete histogram a ``Meter`` instantiates. + + It adds nothing to ``Histogram``. It exists so that the ``__new__`` + guard on ``Histogram`` can reject every other caller while this subclass + passes. + """ class _Gauge(Gauge): - pass + """The concrete gauge a ``Meter`` instantiates. + + It adds nothing to ``Gauge``. It exists so that the ``__new__`` guard on + ``Gauge`` can reject every other caller while this subclass passes. + """ class _ObservableGauge(ObservableGauge): - pass + """The concrete observable gauge a ``Meter`` instantiates. + + It adds nothing to ``ObservableGauge``. It exists so that the + ``__new__`` guard on ``ObservableGauge`` can reject every other caller + while this subclass passes. + """ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py index 4e9d5eea603..7d6e46a6eae 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py @@ -1,6 +1,14 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The value an instrument hands to the SDK when it records something. + +Every ``add``, ``record`` or asynchronous callback result is turned into one +``Measurement`` by the instrument, then passed to the measurement consumer, +the views and finally an aggregation. Nothing downstream mutates it: the +dataclass is frozen. +""" + from __future__ import annotations from collections.abc import Mapping @@ -23,13 +31,20 @@ class Measurement: """ Represents a data point reported via the metrics API to the SDK. - Attributes: - value: Measured value - time_unix_nano: The time the API call was made to record the Measurement + Args: + value: Measured value. + time_unix_nano: The time the API call was made to record the + Measurement, in nanoseconds since the Unix epoch. instrument: The instrument that produced this `Measurement`. context: The active Context of the Measurement at API call time. - attributes: Measurement attributes. Mutating mutable attribute values (lists or dicts) - outside of the Measurement object can lead to undefined and bad behavior. + The exemplar machinery reads the current span out of it, so an + exemplar only carries a trace id and a span id when this Context + held a recording span. + attributes: Measurement attributes. Mutating mutable attribute values + (lists or dicts) outside of the Measurement object can lead to + undefined and bad behavior. ``None`` means the measurement was + recorded without attributes, which is also what a value of the + wrong type is replaced with. """ value: int | float @@ -39,6 +54,17 @@ class Measurement: attributes: Attributes = None def __post_init__(self) -> None: + """Normalise ``attributes`` right after the dataclass is built. + + A mapping is copied and cleaned. An empty key is dropped. A key that + is not a string is converted with ``str`` when its type carries a + usable one, and dropped when it does not. Values that are not valid + attribute values are cast to ``str`` or replaced with ``None``. Anything that is not a mapping + and not ``None`` is discarded altogether, ``attributes`` becomes + ``None`` and a warning is logged. The frozen dataclass is written + through ``object.__setattr__``, so this is the only point at which the + field changes. + """ if self.attributes is not None: if isinstance(self.attributes, Mapping): object.__setattr__( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement_consumer.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement_consumer.py index adfda28f84c..be33d5e6184 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement_consumer.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement_consumer.py @@ -1,6 +1,20 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Where a measurement goes once an instrument has produced it. + +A ``MeterProvider`` owns one ``SynchronousMeasurementConsumer``, which holds a +``MetricReaderStorage`` per registered reader. Every measurement recorded on a +synchronous instrument is offered to all of them, so each reader aggregates it +on its own terms. Collection runs the other way round: a single reader asks +for its own storage, and the asynchronous instruments registered on the +provider are polled at that moment. + +"Synchronous" in ``SynchronousMeasurementConsumer`` describes the consumer and +not the instruments: it aggregates on the thread that recorded the +measurement, with no queue in between. +""" + # pylint: disable=unused-import from abc import ABC, abstractmethod @@ -29,16 +43,40 @@ class MeasurementConsumer(ABC): + """What a ``MeterProvider`` routes its measurements to. + + An implementation accepts measurements from synchronous instruments, keeps + a register of the asynchronous instruments so they can be polled, and + produces a batch for one reader on demand. + ``SynchronousMeasurementConsumer`` is the only implementation in the SDK. + """ + @abstractmethod def consume_measurement(self, measurement: Measurement) -> None: - pass + """Take one measurement recorded on a synchronous instrument. + + Called on the thread that recorded it, so an implementation that + blocks here blocks the instrumented application. + + Args: + measurement: The measurement, which names the instrument it came + from. + """ @abstractmethod def register_asynchronous_instrument( self, instrument: ("opentelemetry.sdk.metrics._internal.instrument._Asynchronous"), ): - pass + """Record that an asynchronous instrument exists. + + The instrument records nothing by itself. Registering it is what gets + its callbacks run at collection time. + + Args: + instrument: The observable counter, observable up down counter or + observable gauge to poll from now on. + """ @abstractmethod def collect( @@ -46,15 +84,53 @@ def collect( metric_reader: "opentelemetry.sdk.metrics.export.MetricReader", timeout_millis: float = 10_000, ) -> MetricsData | None: - pass + """Produce the batch one reader should export. + + Args: + metric_reader: The reader asking. Only the state held for this + reader is collected, and the other readers are untouched. + timeout_millis: How long the whole collection may take, in + milliseconds, including the callbacks of every asynchronous + instrument. + + Returns: + The batch, or ``None`` when nothing was aggregated for this reader + since the last collection. + """ class SynchronousMeasurementConsumer(MeasurementConsumer): + """Aggregates each measurement on the thread that recorded it. + + One ``MetricReaderStorage`` is held per reader, built from the + temporality, aggregation and cardinality limit mappings the reader was + configured with. A measurement is offered to every storage in turn, which + is why two readers can report the same instrument differently. + + The mapping of readers to storages is replaced wholesale rather than + mutated whenever a reader is added or removed, so recording a measurement + takes no lock and never sees a half updated mapping. Collection and + registration do take the lock. + + Args: + sdk_config: The provider's configuration, passed on to every storage + and consulted for the exemplar filter that decides whether a + measurement may be kept as an exemplar. + metric_readers: The readers to build storages for. Iterated once, at + construction. Readers registered later arrive through + ``add_metric_reader``. + """ + def __init__( self, sdk_config: "opentelemetry.sdk.metrics._internal.sdk_configuration.SdkConfiguration", metric_readers: Iterable["opentelemetry.sdk.metrics.MetricReader"], ) -> None: + """Build the instance and one storage per reader. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._lock = Lock() self._sdk_config = sdk_config self._reader_storages: Mapping[opentelemetry.sdk.metrics.export.MetricReader, MetricReaderStorage] = { @@ -69,6 +145,18 @@ def __init__( self._async_instruments: list[opentelemetry.sdk.metrics._internal.instrument._Asynchronous] = [] def consume_measurement(self, measurement: Measurement) -> None: + """Offer one measurement to the storage of every registered reader. + + The exemplar filter is applied once here and its verdict is passed to + all of the storages, so every reader agrees on whether this particular + measurement is exemplar material. + + With no reader registered the measurement is dropped, since there is + nothing to aggregate it into. + + Args: + measurement: The measurement to aggregate. + """ should_sample_exemplar = self._sdk_config.exemplar_filter.should_sample( measurement.value, measurement.time_unix_nano, @@ -85,6 +173,14 @@ def register_asynchronous_instrument( self, instrument: ("opentelemetry.sdk.metrics._internal.instrument._Asynchronous"), ) -> None: + """Add an asynchronous instrument to the list polled at collection. + + Nothing checks for a duplicate, so registering the same instrument + twice has its callbacks run twice per collection. + + Args: + instrument: The instrument to poll from the next collection on. + """ with self._lock: self._async_instruments.append(instrument) @@ -93,6 +189,46 @@ def collect( metric_reader: "opentelemetry.sdk.metrics.export.MetricReader", timeout_millis: float = 10_000, ) -> MetricsData | None: + """Poll the asynchronous instruments, then collect for one reader. + + The callback of every registered asynchronous instrument is run, and + the measurements they yield are aggregated into the storage of this + reader alone, so a reader that never collects never sees them. A + callback is told how long it has left only once the remaining budget + has fallen below ten seconds, and nothing interrupts a callback that + runs past the deadline: the deadline is tested after each instrument + has been fully iterated. + + The metrics of the reader's metric producers are collected afterwards + and merged into the batch, under the resource of the SDK's own + ``ResourceMetrics`` when there is one and under the provider's + resource otherwise. + + The whole call holds the consumer's lock, so two readers collecting at + the same time run one after the other, and a reader cannot be added or + removed while a collection is in progress. + + Args: + metric_reader: The reader to collect for. It has to be registered + on this consumer. + timeout_millis: The budget for the whole call, in milliseconds, + shared by the asynchronous callbacks and the metric producers. + + Returns: + The batch for this reader, or ``None`` when neither the reader's + storage nor its metric producers yielded anything. + + Raises: + MetricsTimeoutError: If the budget is spent after an asynchronous + callback has run. The measurements that callback produced were + already aggregated into the reader's storage and stay there, + since raising skips the ``collect`` call that would have + drained it. They are not reported by this collection and are + aggregated again by the next one, which for an asynchronous + counter reports a total larger than anything the callback + observed. + KeyError: If the reader is not registered on this consumer. + """ with self._lock: metric_reader_storage = self._reader_storages[metric_reader] # for now, just use the defaults @@ -141,6 +277,21 @@ def _collect_from_producers( Called while holding ``self._lock`` so ``produce()`` calls are serialized. A producer that fails or times out is isolated so metrics collected by the SDK are not dropped. + + Each producer is given whatever is left of the deadline. Once nothing + is left, the remaining producers are skipped with a single warning, + and the producers already polled keep their metrics. + + Args: + metric_reader: The reader whose registered producers are polled, + in the order they were passed to it. + deadline_ns: When the whole collection has to be finished, as a + time in nanoseconds since the epoch. + + Returns: + The metrics of every producer that answered in time, concatenated. + Empty when the reader has no producers, when all of them failed, + or when the deadline had already passed. """ producer_scope_metrics: list[ScopeMetrics] = [] # pylint: disable-next=protected-access @@ -170,6 +321,25 @@ def _merge_producer_metrics( producer_scope_metrics: list[ScopeMetrics], resource: Resource, ) -> MetricsData: + """Put the producers' metrics into the batch the SDK collected. + + The producers' scopes are appended to the first ``ResourceMetrics`` of + the batch, which means they are reported under the resource the SDK + collected rather than one of their own. Any further + ``ResourceMetrics`` is carried over untouched. + + Args: + result: The batch the reader's storage produced, which is ``None`` + when it had nothing to report. + producer_scope_metrics: The scopes gathered from the producers. + resource: The provider's resource, used only when ``result`` holds + no ``ResourceMetrics`` to attach the scopes to. + + Returns: + A batch holding both sets of metrics. A fresh one built around + ``resource`` when ``result`` had nothing, otherwise a copy of + ``result`` whose first ``ResourceMetrics`` has grown. + """ if result is not None and result.resource_metrics: sdk_resource_metrics = result.resource_metrics[0] merged = ResourceMetrics( @@ -193,7 +363,17 @@ def _merge_producer_metrics( ) def add_metric_reader(self, metric_reader: "opentelemetry.sdk.metrics.MetricReader") -> None: - """Registers a new metric reader.""" + """Registers a new metric reader. + + A storage is built for the reader from the temporality, aggregation + and cardinality limit mappings it carries, which means the reader + starts empty and reports nothing recorded before this call. Adding a + reader that is already registered replaces its storage and discards + everything aggregated in it so far. + + Args: + metric_reader: The reader to start aggregating for. + """ # Build a new mapping and swap it in atomically so that # a concurrent consume_measurement never iterates a mapping # that is being mutated in place. @@ -211,7 +391,17 @@ def add_metric_reader(self, metric_reader: "opentelemetry.sdk.metrics.MetricRead self._reader_storages = new_reader_storages def remove_metric_reader(self, metric_reader: "opentelemetry.sdk.metrics.MetricReader") -> None: - """Unregisters the given metric reader.""" + """Unregisters the given metric reader. + + The reader's storage is dropped along with everything aggregated in + it, so measurements recorded but not yet collected are lost. + + Args: + metric_reader: The reader to stop aggregating for. + + Raises: + KeyError: If the reader is not registered on this consumer. + """ # Mutate using copy-on-write: see add_metric_reader. with self._lock: new_reader_storages = dict(self._reader_storages) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py index ea84bf25a0d..2ea29a4b327 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py @@ -1,6 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The per-reader half of the SDK's metric state. + +Each metric reader registered on a ``MeterProvider`` gets one +``MetricReaderStorage``, since two readers may ask for different temporalities +and different aggregations of the very same instrument. The storage decides +which views apply to an instrument the first time a measurement from it +arrives, keeps a ``_ViewInstrumentMatch`` per applicable view, and assembles +the ``MetricsData`` the reader exports. + +The module also holds ``_DEFAULT_VIEW``, the view used for an instrument that +no configured view selects. +""" + from logging import getLogger from threading import RLock from time import time_ns @@ -52,7 +65,32 @@ class MetricReaderStorage: - """The SDK's storage for a given reader""" + """The SDK's storage for a given reader + + The three mappings passed here come from the reader and are consulted by + instrument class, so one reader can collect a counter as a delta while + another collects the same counter cumulatively. The mappings are kept by + reference rather than copied. + + Args: + sdk_config: The provider's configuration, read for the views to match + instruments against and for the resource stamped on the collected + ``MetricsData``. + instrument_class_temporality: Maps each SDK instrument class to the + temporality the reader wants for it. An instrument whose class is + absent raises ``KeyError`` when it is first collected, and the + readers that ship with the SDK fill in every class. + instrument_class_aggregation: Maps each SDK instrument class to the + aggregation to use when the matched view asks for the default one. + instrument_class_cardinality_limit: Maps each SDK instrument class to + the number of distinct attribute sets a stream of that class holds + at once. They are released on collection only for a synchronous + instrument collected with delta temporality, so under the default + cumulative preference the limit is spent over the life of the + stream. A class that is absent, and the value ``None`` for the + whole mapping, fall back to 2000. A limit set on the matched view + wins over this. + """ def __init__( self, @@ -61,6 +99,11 @@ def __init__( instrument_class_aggregation: dict[type, Aggregation], instrument_class_cardinality_limit: dict[type, int] | None = None, ) -> None: + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._lock = RLock() self._sdk_config = sdk_config self._instrument_view_instrument_matches: dict[_Instrument, list[_ViewInstrumentMatch]] = {} @@ -69,6 +112,23 @@ def __init__( self._instrument_class_cardinality_limit = instrument_class_cardinality_limit or {} def _new_view_instrument_match(self, view: View, instrument: _Instrument) -> _ViewInstrumentMatch: + """Build the stream for one pair of a view and an instrument. + + The cardinality limit is resolved here: the limit set on the view wins, + then the reader's limit for the instrument's class, then 2000. + + Args: + view: The view to apply. ``_DEFAULT_VIEW`` is passed for an + instrument that no configured view selects. + instrument: The instrument the stream reads from. + + Returns: + A stream that discards its aggregations on every delta collection + when the instrument is synchronous and the reader asks for delta + temporality, and keeps them otherwise. An asynchronous instrument + always keeps them, because the running total it reports is what + the delta is computed from. + """ # pylint: disable=protected-access cardinality_limit = view._aggregation_cardinality_limit if cardinality_limit is None: @@ -93,6 +153,25 @@ def _new_view_instrument_match(self, view: View, instrument: _Instrument) -> _Vi ) def _get_or_init_view_instrument_match(self, instrument: _Instrument) -> list[_ViewInstrumentMatch]: + """Return the streams of an instrument, matching views on first sight. + + The matching runs once per instrument and the result is then fixed, so + a view added to the provider afterwards has no effect on an instrument + that has already recorded something. The lookup outside the lock is + the common case; the lock is taken only to decide the matching, and + the check is repeated inside it because another thread may have + decided it in the meantime. + + Args: + instrument: The instrument a measurement just arrived from. + + Returns: + One stream per view that selects the instrument and is compatible + with it, in the order the views were configured. A single stream + built from ``_DEFAULT_VIEW`` when no view selects it, so an + instrument is never left without one. The same list object is + returned on every later call. + """ # Optimistically get the relevant views for the given instrument. Once set for a given # instrument, the mapping will never change @@ -117,10 +196,43 @@ def _get_or_init_view_instrument_match(self, instrument: _Instrument) -> list[_V return view_instrument_matches def consume_measurement(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: + """Hand one measurement to every stream of its instrument. + + A measurement from an instrument seen for the first time triggers the + view matching, so the first recording on an instrument is the one that + pays for it. + + Args: + measurement: The measurement, which names the instrument it came + from. + should_sample_exemplar: Whether the aggregations may keep this + measurement as an exemplar. Passed through unchanged. + """ for view_instrument_match in self._get_or_init_view_instrument_match(measurement.instrument): view_instrument_match.consume_measurement(measurement, should_sample_exemplar) def collect(self) -> MetricsData | None: + """Gather every stream of every instrument into one batch. + + The shape of the data of a metric follows the class of the stream's + aggregation: a sum becomes ``Sum``, marked monotonic when the + instrument is a counter or an observable counter, a last value becomes + ``Gauge``, and the two histogram aggregations become ``Histogram`` and + ``ExponentialHistogram``. A stream aggregated with the drop + aggregation contributes no metric at all. + + Metrics are grouped into one ``ScopeMetrics`` per instrumentation + scope, so two instruments created by the same meter are reported + together. The name and the description are the stream's, which a view + may have overridden, while the unit is always the instrument's. + + One timestamp is taken before anything is collected and given to every + aggregation, so the data points of a single batch share an end time. + + Returns: + The batch, holding a single ``ResourceMetrics`` for the provider's + resource, or ``None`` when no stream produced a data point. + """ # Use a list instead of yielding to prevent a slow reader from holding # SDK locks @@ -235,6 +347,27 @@ def _handle_view_instrument_match( instrument: _Instrument, view_instrument_matches: list["_ViewInstrumentMatch"], ) -> None: + """Append one stream per view that selects the instrument. + + The views are walked in the order they were configured. A view that + does not select the instrument is skipped silently, and one that + selects it but cannot be applied to it is skipped with a warning + logged by the compatibility check. + + Each new stream is compared against every stream already stored for an + instrument matched earlier, and a warning naming both views is logged + for each pair that would carry the same metric identity. The warning + is all that happens: the stream is appended either way, and the + exporter receives both. Streams built in this same call are not + compared against each other, so two views that collide on the very + same instrument go unreported. + + Args: + instrument: The instrument being matched. + view_instrument_matches: The list the new streams are appended to. + The caller owns it and reads the result from it, since nothing + is returned. + """ for view in self._sdk_config.views: # pylint: disable=protected-access if not view._match(instrument): @@ -263,6 +396,21 @@ def _check_view_instrument_compatibility(view: View, instrument: _Instrument) -> Returns `true` if they are compatible and a `_ViewInstrumentMatch` object should be created, `false` otherwise. + + One pairing is rejected: an asynchronous instrument aggregated by + either of the two histogram aggregations, which would bucket a running + total instead of the individual values. Rejecting it logs a warning + and leaves the instrument to the other views, or to the default view + when no other view selects it. + + Args: + view: The view that selected the instrument. + instrument: The instrument the view selected. + + Returns: + ``False`` for the rejected pairing described above, ``True`` for + everything else, including a view whose aggregation makes no sense + for the instrument in a way this check does not look for. """ result = True diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py index eeb84b6f8f1..8d6b3cdb06e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py @@ -1,6 +1,22 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The immutable data classes a metric exporter receives. + +A metric reader hands an exporter a single ``MetricsData``, which nests +``ResourceMetrics``, ``ScopeMetrics``, ``Metric``, one of the four data types +(``Sum``, ``Gauge``, ``Histogram``, ``ExponentialHistogram``) and finally the +data points. The nesting mirrors the OpenTelemetry protocol message of the +same name, so an OTLP exporter walks this structure field by field. + +Every class here is a frozen dataclass and every one of them, except +``Buckets``, has a ``to_json`` method. ``ConsoleMetricExporter`` uses +``MetricsData.to_json`` as its default formatter. + +Time fields are named ``*_unix_nano`` and are integers counting nanoseconds +since the Unix epoch. +""" + # pylint: disable=unused-import from collections.abc import Sequence @@ -19,6 +35,24 @@ class NumberDataPoint: """Single data point in a timeseries that describes the time-varying scalar value of a metric. + + Produced by the sum and last value aggregations. + + Args: + attributes: The attribute set this point was aggregated under. Two + measurements of the same instrument land in the same point only + when their attributes are equal after view filtering. + start_time_unix_nano: Nanoseconds since the Unix epoch at which the + interval covered by ``value`` begins. The last value aggregation + leaves it as ``None``, since a gauge reading covers no interval. + time_unix_nano: Nanoseconds since the Unix epoch at which the + collection that produced this point started. + value: The aggregated value. A delta point carries the change since + ``start_time_unix_nano``, a cumulative point the running total. + exemplars: Sample measurements kept by the exemplar reservoir during + the interval, each one carrying the trace id and span id that were + active when it was recorded. Empty when no measurement passed the + exemplar filter. """ attributes: Attributes @@ -28,6 +62,19 @@ class NumberDataPoint: exemplars: Sequence[Exemplar] = field(default_factory=list) def to_json(self, indent: int | None = 4) -> str: + """Serialise this data point to OTLP-shaped JSON. + + Every field is emitted under its own name, so the result matches the + ``NumberDataPoint`` message of the OpenTelemetry protocol. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with the attributes, the two timestamps, the value + and the exemplars of this point. + """ return dumps(asdict(self), indent=indent) @@ -35,6 +82,32 @@ def to_json(self, indent: int | None = 4) -> str: class HistogramDataPoint: """Single data point in a timeseries that describes the time-varying scalar value of a metric. + + Produced by the explicit bucket histogram aggregation. + + Args: + attributes: The attribute set this point was aggregated under. + start_time_unix_nano: Nanoseconds since the Unix epoch at which the + interval covered by this point begins. + time_unix_nano: Nanoseconds since the Unix epoch at which the + collection that produced this point started. + count: Number of measurements that fell into the interval, equal to + the sum of ``bucket_counts``. + sum: Arithmetic sum of those measurements. + bucket_counts: One count per bucket, in ascending bucket order. It + holds exactly ``len(explicit_bounds) + 1`` entries: one for every + bound plus a final one for everything above the last bound. + explicit_bounds: The strictly increasing bucket boundaries. Bucket + ``i`` covers the values greater than ``explicit_bounds[i - 1]`` and + less than or equal to ``explicit_bounds[i]``, so a measurement + equal to a boundary is counted in the lower of the two buckets + that boundary separates. + min: Smallest measurement in the interval, or ``None`` when the + aggregation was built with ``record_min_max`` set to ``False``. + max: Largest measurement in the interval, or ``None`` under the same + condition. + exemplars: Sample measurements kept by the exemplar reservoir during + the interval. """ attributes: Attributes @@ -49,11 +122,40 @@ class HistogramDataPoint: exemplars: Sequence[Exemplar] = field(default_factory=list) def to_json(self, indent: int | None = 4) -> str: + """Serialise this data point to OTLP-shaped JSON. + + Every field is emitted under its own name, so the result matches the + ``HistogramDataPoint`` message of the OpenTelemetry protocol. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with the attributes, the timestamps, the count, the + sum, the buckets, the bounds, the minimum, the maximum and the + exemplars of this point. + """ return dumps(asdict(self), indent=indent) @dataclass(frozen=True) class Buckets: + """One side of an exponential histogram, either positive or negative. + + Args: + offset: Index of the bucket that ``bucket_counts[0]`` counts. Bucket + index ``i`` covers the values from ``base ** i`` excluded to + ``base ** (i + 1)`` included, where ``base`` is + ``2 ** (2 ** -scale)`` and ``scale`` is read from the data point + that holds these buckets. The index is signed, since values below + one map to negative indexes. + bucket_counts: Counts of the consecutive buckets starting at + ``offset``, in ascending index order. A side that received no + measurement is reported as a single bucket holding a count of + zero, at offset zero. + """ + offset: int bucket_counts: Sequence[int] @@ -63,6 +165,39 @@ class ExponentialHistogramDataPoint: """Single data point in a timeseries whose boundaries are defined by an exponential function. This timeseries describes the time-varying scalar value of a metric. + + Args: + attributes: The attribute set this point was aggregated under. + start_time_unix_nano: Nanoseconds since the Unix epoch at which the + interval covered by this point begins. + time_unix_nano: Nanoseconds since the Unix epoch at which the + collection that produced this point started. + count: Number of measurements that fell into the interval, zeros + included. + sum: Arithmetic sum of those measurements. + scale: Resolution of the buckets. The bucket base is + ``2 ** (2 ** -scale)``, so a larger scale means narrower buckets. + The aggregation lowers it whenever the recorded values stop + fitting in ``max_size`` buckets, and never raises it, so the scale + only ever drops while one aggregation object lives. It is not + monotonic across the points of a stream: a synchronous instrument + collected with ``DELTA`` temporality discards its aggregation on + every collection, so the next point starts again at ``max_scale`` + and the reported scale can rise. + zero_count: Number of measurements that were exactly zero. They are + counted here and in ``count``, and appear in neither ``positive`` + nor ``negative``. + positive: Buckets holding the measurements greater than zero. + negative: Buckets holding the measurements less than zero, indexed by + their absolute value. + flags: Data point flags defined by the OpenTelemetry protocol. The SDK + always emits 0. + min: Smallest measurement in the interval, or ``None`` when the + aggregation was built with ``record_min_max`` set to ``False``. + max: Largest measurement in the interval, or ``None`` under the same + condition. + exemplars: Sample measurements kept by the exemplar reservoir during + the interval. """ attributes: Attributes @@ -80,6 +215,20 @@ class ExponentialHistogramDataPoint: exemplars: Sequence[Exemplar] = field(default_factory=list) def to_json(self, indent: int | None = 4) -> str: + """Serialise this data point to OTLP-shaped JSON. + + Every field is emitted under its own name, so the result matches the + ``ExponentialHistogramDataPoint`` message of the OpenTelemetry + protocol. The two ``Buckets`` are inlined as nested objects. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with every field of this point, including the + positive and negative buckets. + """ return dumps(asdict(self), indent=indent) @@ -87,12 +236,33 @@ def to_json(self, indent: int | None = 4) -> str: class ExponentialHistogram: """Represents the type of a metric that is calculated by aggregating as an ExponentialHistogram of all reported measurements over a time interval. + + Args: + data_points: One point per attribute set that received a measurement, + in no particular order. + aggregation_temporality: Whether each point covers only the interval + since the previous collection (``DELTA``) or everything since the + start of the stream (``CUMULATIVE``). The metric reader picks it + per instrument kind. """ data_points: Sequence[ExponentialHistogramDataPoint] aggregation_temporality: "opentelemetry.sdk.metrics.export.AggregationTemporality" def to_json(self, indent: int | None = 4) -> str: + """Serialise this data type to OTLP-shaped JSON. + + Each data point is serialised and parsed back, so the result nests + real JSON objects rather than strings. The temporality is emitted as + its integer value. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with ``data_points`` and ``aggregation_temporality``. + """ return dumps( { "data_points": [loads(data_point.to_json(indent=indent)) for data_point in self.data_points], @@ -105,13 +275,38 @@ def to_json(self, indent: int | None = 4) -> str: @dataclass(frozen=True) class Sum: """Represents the type of a scalar metric that is calculated as a sum of - all reported measurements over a time interval.""" + all reported measurements over a time interval. + + Args: + data_points: One point per attribute set that received a measurement, + in no particular order. + aggregation_temporality: Whether each point covers only the interval + since the previous collection (``DELTA``) or everything since the + start of the stream (``CUMULATIVE``). + is_monotonic: ``True`` when the instrument behind the stream can only + increase, which is the case for a counter and an observable + counter. A backend may rely on this to detect resets. + """ data_points: Sequence[NumberDataPoint] aggregation_temporality: "opentelemetry.sdk.metrics.export.AggregationTemporality" is_monotonic: bool def to_json(self, indent: int | None = 4) -> str: + """Serialise this data type to OTLP-shaped JSON. + + Each data point is serialised and parsed back, so the result nests + real JSON objects rather than strings. The temporality is emitted as + its integer value. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with ``data_points``, ``aggregation_temporality`` + and ``is_monotonic``. + """ return dumps( { "data_points": [loads(data_point.to_json(indent=indent)) for data_point in self.data_points], @@ -126,11 +321,30 @@ def to_json(self, indent: int | None = 4) -> str: class Gauge: """Represents the type of a scalar metric that always exports the current value for every data point. It should be used for an unknown - aggregation.""" + aggregation. + + Args: + data_points: One point per attribute set that received a measurement, + in no particular order. A gauge point covers no interval, so it + carries no temporality and its ``start_time_unix_nano`` is + ``None``. + """ data_points: Sequence[NumberDataPoint] def to_json(self, indent: int | None = 4) -> str: + """Serialise this data type to OTLP-shaped JSON. + + Each data point is serialised and parsed back, so the result nests + real JSON objects rather than strings. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object whose only key is ``data_points``. + """ return dumps( { "data_points": [loads(data_point.to_json(indent=indent)) for data_point in self.data_points], @@ -142,12 +356,35 @@ def to_json(self, indent: int | None = 4) -> str: @dataclass(frozen=True) class Histogram: """Represents the type of a metric that is calculated by aggregating as a - histogram of all reported measurements over a time interval.""" + histogram of all reported measurements over a time interval. + + Args: + data_points: One point per attribute set that received a measurement, + in no particular order. All of them share the same bucket + boundaries, since the boundaries come from the view or the + instrument advisory rather than from the data. + aggregation_temporality: Whether each point covers only the interval + since the previous collection (``DELTA``) or everything since the + start of the stream (``CUMULATIVE``). + """ data_points: Sequence[HistogramDataPoint] aggregation_temporality: "opentelemetry.sdk.metrics.export.AggregationTemporality" def to_json(self, indent: int | None = 4) -> str: + """Serialise this data type to OTLP-shaped JSON. + + Each data point is serialised and parsed back, so the result nests + real JSON objects rather than strings. The temporality is emitted as + its integer value. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with ``data_points`` and ``aggregation_temporality``. + """ return dumps( { "data_points": [loads(data_point.to_json(indent=indent)) for data_point in self.data_points], @@ -165,7 +402,21 @@ def to_json(self, indent: int | None = 4) -> str: @dataclass(frozen=True) class Metric: """Represents a metric point in the OpenTelemetry data model to be - exported.""" + exported. + + Args: + name: Stream name. It is the instrument name unless a view renamed + it. Nothing stops two streams of one scope from carrying the same + name: the reader storage logs a warning about conflicting metric + identities and exports both. + description: Free text taken from the instrument or the view. ``None`` + is serialised as an empty string. + unit: Unit of the measured values, as given to the instrument. + ``None`` is serialised as an empty string. + data: The aggregated data and the points themselves. Which of the four + data types appears here follows from the aggregation the view + selected. + """ name: str description: str | None @@ -173,6 +424,19 @@ class Metric: data: DataT def to_json(self, indent: int | None = 4) -> str: + """Serialise this metric to OTLP-shaped JSON. + + A ``description`` or a ``unit`` of ``None`` becomes an empty string, + which is what the protocol uses for an absent value. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with ``name``, ``description``, ``unit`` and + ``data``. + """ return dumps( { "name": self.name, @@ -186,13 +450,30 @@ def to_json(self, indent: int | None = 4) -> str: @dataclass(frozen=True) class ScopeMetrics: - """A collection of Metrics produced by a scope""" + """A collection of Metrics produced by a scope. + + Args: + scope: The instrumentation scope, meaning the name, version and + attributes of the meter the instruments were created from. + metrics: The metrics that meter produced during this collection. + schema_url: URL of the schema the scope declares, as passed to + ``get_meter``. Empty when the meter declared none. + """ scope: InstrumentationScope metrics: Sequence[Metric] schema_url: str def to_json(self, indent: int | None = 4) -> str: + """Serialise this scope and its metrics to OTLP-shaped JSON. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with ``scope``, ``metrics`` and ``schema_url``. + """ return dumps( { "scope": loads(self.scope.to_json(indent=indent)), @@ -205,13 +486,32 @@ def to_json(self, indent: int | None = 4) -> str: @dataclass(frozen=True) class ResourceMetrics: - """A collection of ScopeMetrics from a Resource""" + """A collection of ScopeMetrics from a Resource. + + Args: + resource: The resource of the meter provider that produced these + metrics. Every metric below it is attributed to it. + scope_metrics: One entry per instrumentation scope that produced + metrics during this collection. + schema_url: URL of the schema the resource declares. Empty when the + resource declared none. + """ resource: Resource scope_metrics: Sequence[ScopeMetrics] schema_url: str def to_json(self, indent: int | None = 4) -> str: + """Serialise this resource and its scopes to OTLP-shaped JSON. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line. + + Returns: + A JSON object with ``resource``, ``scope_metrics`` and + ``schema_url``. + """ return dumps( { "resource": loads(self.resource.to_json(indent=indent)), @@ -224,11 +524,33 @@ def to_json(self, indent: int | None = 4) -> str: @dataclass(frozen=True) class MetricsData: - """An array of ResourceMetrics""" + """An array of ResourceMetrics. + + This is what a metric reader hands to an exporter, and the outermost + message of the OpenTelemetry protocol payload. + + Args: + resource_metrics: One entry per resource. An SDK meter provider has a + single resource, so a reader of that provider produces exactly one + entry, or none when no instrument recorded anything. + """ resource_metrics: Sequence[ResourceMetrics] def to_json(self, indent: int | None = 4) -> str: + """Serialise the whole collection to OTLP-shaped JSON. + + ``ConsoleMetricExporter`` calls this with the default indent and + writes one such object per export. + + Args: + indent: Number of spaces per nesting level. ``None`` produces the + most compact form, on a single line, which is what a + line-delimited output wants. + + Returns: + A JSON object whose only key is ``resource_metrics``. + """ return dumps( { "resource_metrics": [ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/sdk_configuration.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/sdk_configuration.py index a1430261cb5..3c3c9254a44 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/sdk_configuration.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/sdk_configuration.py @@ -1,6 +1,13 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The bundle of meter provider settings shared with every metric reader. + +``MeterProvider`` builds a single ``SdkConfiguration`` at construction time and +hands the same instance to every ``MetricReaderStorage``, so the readers all +see the same resource, the same views and the same exemplar filter. +""" + # pylint: disable=unused-import from collections.abc import Sequence @@ -13,6 +20,20 @@ @dataclass class SdkConfiguration: + """Settings a ``MeterProvider`` passes down to its metric readers. + + Args: + exemplar_filter: Decides which measurements may become exemplars. It + is consulted once per recorded measurement, before the value + reaches the exemplar reservoir. + resource: The resource that every metric produced by this meter + provider is attributed to. + views: The views registered on the meter provider, in registration + order. A single instrument may match several of them, in which + case it produces one stream per match. An empty sequence means + every instrument keeps its default aggregation. + """ + exemplar_filter: "opentelemetry.sdk.metrics.ExemplarFilter" resource: "opentelemetry.sdk.resources.Resource" views: Sequence["opentelemetry.sdk.metrics.view.View"] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py index d989d2c3097..c16eff09d8b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py @@ -1,6 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Instrument selection and metric stream customization. + +This module holds ``View``, the object a ``MeterProvider`` is given to decide +which instruments it processes and how the metric streams they feed are +named, described, aggregated and limited in attribute cardinality. It also +holds ``_default_reservoir_factory``, which picks the exemplar reservoir a +view uses when the caller names none. + +``View`` is re-exported from ``opentelemetry.sdk.metrics.view``, which is +where it should be imported from. +""" from collections.abc import Callable from fnmatch import fnmatchcase @@ -27,7 +38,21 @@ def _default_reservoir_factory( aggregation_type: type[_Aggregation], ) -> ExemplarReservoirBuilder: - """Default reservoir factory per aggregation.""" + """Default reservoir factory per aggregation. + + Args: + aggregation_type: The aggregation class the reservoir will be + attached to. Only an explicit bucket histogram is treated + specially; everything else, including the exponential bucket + histogram, falls through to the same answer. + + Returns: + ``AlignedHistogramBucketExemplarReservoir`` for a subclass of + ``_ExplicitBucketHistogramAggregation``, which keeps one exemplar per + declared bucket, and ``SimpleFixedSizeExemplarReservoir`` for every + other aggregation. The class itself is returned, not an instance, so + the caller still constructs it. + """ if issubclass(aggregation_type, _ExplicitBucketHistogramAggregation): return AlignedHistogramBucketExemplarReservoir if issubclass(aggregation_type, _ExponentialBucketHistogramAggregation): @@ -48,23 +73,44 @@ class View: The attributes documented next serve one of the previous two purposes. + At least one instrument matching attribute has to be given, otherwise the + view would select every instrument in the process. Every matching + attribute that is given has to hold for an instrument to match, and one + left as ``None`` is skipped during matching. + Args: instrument_type: This is an instrument matching attribute: the class the - instrument must be to match the view. + instrument must be to match the view. The test is + ``isinstance``, so a base class such as ``Counter`` also selects + the ``_Counter`` a meter actually creates. ``None`` means the + class of the instrument is not considered. instrument_name: This is an instrument matching attribute: the name the instrument must have to match the view. Wild card characters are supported. Wild card characters should not be used with this attribute if the view has also a - ``name`` defined. + ``name`` defined. Matching goes through ``fnmatch.fnmatchcase``, + so ``*`` stands for any run of characters, ``?`` for exactly one, + and ``[seq]`` for one character out of ``seq`` (with ``[!seq]`` + for one character outside it). The comparison is + case-insensitive, because the SDK stores instrument names + lower-cased and this pattern is lower-cased before it is applied. + ``None`` means the name of the instrument is not considered. meter_name: This is an instrument matching attribute: the name the - instrument meter must have to match the view. + instrument meter must have to match the view. This one is + compared for equality, with no wild card handling and no case + folding. ``None`` means the name of the meter is not considered. meter_version: This is an instrument matching attribute: the version - the instrument meter must have to match the view. + the instrument meter must have to match the view. Compared for + equality, like ``meter_name``. ``None`` means the version of the + meter is not considered, so it cannot be used to select only the + meters that were created without a version. meter_schema_url: This is an instrument matching attribute: the schema - URL the instrument meter must have to match the view. + URL the instrument meter must have to match the view. Compared + for equality, like ``meter_name``. ``None`` means the schema URL + of the meter is not considered. name: This is a metric stream customizing attribute: the name of the metric stream. If `None`, the name of the instrument will be used. @@ -78,25 +124,44 @@ class View: attribute_keys: This is a metric stream customizing attribute: this is a set of attribute keys. If not `None` then only the measurement attributes that are in ``attribute_keys`` will be used to identify the metric stream. + Keys outside the set are dropped from the measurement, which + merges the time series that differed only in those keys and is + the usual way of cutting cardinality. An empty set keeps no key + at all, so every measurement collapses into a single point with + no attributes. ``None`` keeps every attribute the measurement + carries. aggregation: This is a metric stream customizing attribute: the aggregation instance to use when data is aggregated for the corresponding metrics stream. If `None` an instance of - `DefaultAggregation` will be used. + `DefaultAggregation` will be used. A ``DropAggregation`` here + discards the stream entirely, which is how an instrument is + turned off through a view. exemplar_reservoir_factory: This is a metric stream customizing attribute: - the exemplar reservoir factory + the exemplar reservoir factory. It is called with the aggregation + class of the stream and returns the reservoir class to build for + it. ``None`` selects ``_default_reservoir_factory``, which gives + an explicit bucket histogram one exemplar per bucket and + everything else a fixed size reservoir. instrument_unit: This is an instrument matching attribute: the unit the - instrument must have to match the view. + instrument must have to match the view. Matching goes through + ``fnmatch.fnmatchcase``, so the same wild cards as + ``instrument_name`` apply, but the comparison is case-sensitive + because units are. ``None`` means the unit of the instrument is + not considered. aggregation_cardinality_limit: This is a metric stream customizing attribute: the maximum number of distinct attribute sets the - stream aggregates per collection cycle. Measurements with further - attribute sets are aggregated into one additional point identified - by the attribute ``otel.metric.overflow=true``. If `None` the - cardinality limit of the metric reader is used, which defaults to - 2000. Must be a positive integer. + stream holds at once. Measurements with further attribute sets are + aggregated into one additional point identified by the attribute + ``otel.metric.overflow=true``. The attribute sets are released on + collection only for a synchronous instrument collected with delta + temporality, so under the default cumulative preference the limit + is spent over the life of the stream rather than per collection + cycle. If `None` the cardinality limit of the metric reader is + used, which defaults to 2000. Must be a positive integer. This class is not intended to be subclassed by the user. """ @@ -118,6 +183,26 @@ def __init__( instrument_unit: str | None = None, aggregation_cardinality_limit: int | None = None, ): + """Validate the criteria and store them for later matching. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``aggregation_cardinality_limit`` is given but is + not an integer of at least 1. A ``bool`` is rejected too, + even though ``True`` is an integer equal to 1. + Exception: If ``instrument_type``, ``instrument_name``, + ``instrument_unit``, ``meter_name``, ``meter_version`` and + ``meter_schema_url`` are all ``None``, which would leave the + view with no way to select anything. A bare ``Exception`` is + raised, not a ``ValueError``. + Exception: If ``name`` is given while ``instrument_name`` is + ``None``, since renaming a stream only makes sense when one + instrument feeds it, or if ``name`` is given and + ``instrument_name`` contains any of ``*``, ``?`` or ``[``, + for the same reason. + """ if aggregation_cardinality_limit is not None and ( isinstance(aggregation_cardinality_limit, bool) or not isinstance(aggregation_cardinality_limit, int) @@ -169,6 +254,29 @@ def __init__( # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches def _match(self, instrument: _Instrument) -> bool: + """Decide whether this view selects ``instrument``. + + Every matching criterion the view was given has to hold, and a + criterion left as ``None`` is skipped. The constructor guarantees at + least one of them was given, so this never matches everything by + accident. The first criterion that fails ends the test. + + ``instrument_name`` and ``instrument_unit`` are wild card patterns + applied with ``fnmatch.fnmatchcase``. The name comparison is + case-insensitive, since the instrument name is already lower-cased by + the SDK and the pattern is lower-cased here; the unit comparison is + case-sensitive. ``meter_name``, ``meter_version`` and + ``meter_schema_url`` are compared for equality against the + instrumentation scope of the instrument. + + Args: + instrument: The instrument to test. Its class, ``name``, ``unit`` + and ``instrumentation_scope`` are read. + + Returns: + ``True`` when every criterion this view declares holds for + ``instrument``, ``False`` otherwise. + """ if self._instrument_type is not None: if not isinstance(instrument, self._instrument_type): return False diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py index c1950cd1629..5ddbba7624b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py @@ -1,6 +1,27 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Metric readers, metric exporters and the data they carry. + +This package re-exports the reader classes ``MetricReader``, +``PeriodicExportingMetricReader`` and ``InMemoryMetricReader``; the exporter +side ``MetricExporter``, ``MetricExportResult`` and ``ConsoleMetricExporter``; +``MetricProducer`` for feeding a reader from a source outside the SDK; the +cardinality limit types ``CardinalityLimits`` and ``InstrumentCardinalityKey``; +``AggregationTemporality``; and the classes a collection produces: +``MetricsData``, ``ResourceMetrics``, ``ScopeMetrics``, ``Metric``, ``Sum``, +``Gauge``, ``Histogram``, ``ExponentialHistogram``, ``Buckets``, +``NumberDataPoint``, ``HistogramDataPoint``, ``ExponentialHistogramDataPoint`` +and the type aliases ``DataT`` and ``DataPointT``. + +The readers and the exporters are implemented in +``opentelemetry.sdk.metrics._internal.export``, the data classes in +``opentelemetry.sdk.metrics._internal.point``, and +``AggregationTemporality`` in +``opentelemetry.sdk.metrics._internal.aggregation``. Import them from this +package rather than from the ``_internal`` package, whose layout carries no +stability guarantee. +""" from opentelemetry.sdk.metrics._internal.aggregation import ( AggregationTemporality, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/view/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/view/__init__.py index 3aeb6642043..ee00ca2fe32 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/view/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/view/__init__.py @@ -1,6 +1,21 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""View and aggregation configuration for the metrics SDK. + +This package re-exports ``View``, which selects instruments and customizes the +metric streams they feed, together with the aggregations a view can be given: +the ``Aggregation`` base class and its subclasses ``DefaultAggregation``, +``DropAggregation``, ``ExplicitBucketHistogramAggregation``, +``ExponentialBucketHistogramAggregation``, ``LastValueAggregation`` and +``SumAggregation``. + +``View`` is implemented in ``opentelemetry.sdk.metrics._internal.view`` and the +aggregations in ``opentelemetry.sdk.metrics._internal.aggregation``. Import +them from this package rather than from the ``_internal`` package, whose +layout carries no stability guarantee. +""" + from opentelemetry.sdk.metrics._internal.aggregation import ( Aggregation, DefaultAggregation, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index 4c2a484b004..8777e01b096 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -1,6 +1,7 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +# pylint: disable=too-many-lines """ This package implements `OpenTelemetry Resources `_: @@ -171,6 +172,20 @@ class Resource: _schema_url_conflict: bool def __init__(self, attributes: Attributes, schema_url: str | None = None): + """Build a resource directly, without running any detector. + + Only a `ResourceDetector` should call this. Everything else goes + through `Resource.create`, which also applies the detectors and the + default ``service.name``. + + Args: + attributes: The attributes of the entity. They are copied into an + immutable ``BoundedAttributes``, so the mapping passed in can + be changed afterwards without affecting the resource. + schema_url: The url of the semantic convention schema the + attributes follow. ``None`` is stored as the empty string, so + ``schema_url`` never reads back as ``None``. + """ # Immutable set to true so attributes cannot be added or removed after creation. self._attributes = BoundedAttributes(attributes=attributes, immutable=True) if schema_url is None: @@ -187,6 +202,20 @@ def create( `ResourceDetector` instances should not call this method. + Three sources are merged, in increasing order of priority: the + default resource holding the ``telemetry.sdk.*`` attributes, then + every detector returned by ``_build_resource_detectors`` in order, + then ``attributes``. ``OTEL_RESOURCE_ATTRIBUTES`` and + ``OTEL_SERVICE_NAME`` are read by ``OTELResourceDetector``, which runs + last unless ``OTEL_EXPERIMENTAL_RESOURCE_DETECTORS`` names ``otel`` + at an earlier position, so by default they beat the other detectors + and lose to ``attributes``. + + When nothing has supplied a non-empty ``service.name``, one is + invented: ``unknown_service``, with the base name of + ``sys.executable`` appended after a colon when the interpreter + reports one. + Args: attributes: Optional zero or more key-value pairs. schema_url: Optional URL pointing to the schema @@ -211,14 +240,34 @@ def create( @staticmethod def get_empty() -> "Resource": + """Return the shared resource that carries no attributes. + + Returns: + One module-level instance with no attributes and an empty schema + url, reused by every caller. It is immutable, so sharing it is + safe, and merging it into another resource changes nothing. + """ return _EMPTY_RESOURCE @property def attributes(self) -> Attributes: + """The attributes describing the entity. + + Returns: + The immutable ``BoundedAttributes`` built at construction. + Assigning into it raises ``TypeError``, so a caller that wants to + add an attribute builds another resource and merges it. + """ return self._attributes @property def schema_url(self) -> str: + """The semantic convention schema url of the attributes. + + Returns: + The url, or the empty string when none was given and when a merge + found two resources claiming different schema urls. + """ return self._schema_url def merge(self, other: "Resource") -> "Resource": @@ -268,6 +317,20 @@ def merge(self, other: "Resource") -> "Resource": return merged_resource def __eq__(self, other: object) -> bool: + """Compare the attributes, the schema url and the conflict flag. + + The conflict flag takes part, so a resource whose schema url was + cleared by a merge is not equal to one built with an empty schema url + even though both report ``""``. + + Args: + other: The object to compare against. Anything that is not a + `Resource` compares unequal rather than yielding + ``NotImplemented``. + + Returns: + ``True`` when all three agree. + """ if not isinstance(other, Resource): return False return ( @@ -277,12 +340,35 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: + """Hash the JSON rendering of the attributes with the schema url. + + The attributes are serialised with sorted keys, so two resources + holding the same pairs hash alike whatever order they were built in. + A value that JSON cannot encode goes through ``_json_default``, which + renders ``bytes`` as hexadecimal, so a bytes value and the matching + hexadecimal string collide here while still comparing unequal. + + Returns: + A hash over the attributes, the schema url and the conflict flag. + """ return hash( f"{dumps(self._attributes.copy(), sort_keys=True, default=_json_default)}" f"|{self._schema_url}|{self._schema_url_conflict}" ) def to_json(self, indent: int | None = 4) -> str: + """Serialise the resource to JSON, for a console exporter or a log line. + + Args: + indent: The number of spaces to indent each nesting level by, as + ``json.dumps`` takes it. Pass ``None`` for the compact + single-line form. + + Returns: + A JSON object with ``attributes`` and ``schema_url``. The + conflict flag is left out, so a resource whose schema url was + cleared by a merge renders the same as one that never had any. + """ return dumps( { "attributes": dict(self.attributes), @@ -300,6 +386,15 @@ def _json_default(value: object) -> str: as hex keeps `to_json` readable and keeps `__hash__` working; `__eq__` still distinguishes `b"\x01"` from the string `"01"`, so the resulting hash collision is harmless. + + Args: + value: The attribute value ``json`` refused to encode. It reaches + here only through the ``default`` argument of ``json.dumps``, so + an encodable value never does. + + Returns: + The hexadecimal rendering for ``bytes`` and ``bytearray``, and the + ``str`` of the object for anything else. """ if isinstance(value, (bytes, bytearray)): return value.hex() @@ -323,12 +418,44 @@ def _json_default(value: object) -> str: class ResourceDetector(abc.ABC): + """Source of resource attributes read from the surrounding environment. + + A detector is registered under the ``opentelemetry_resource_detector`` + entry point group and named in ``OTEL_EXPERIMENTAL_RESOURCE_DETECTORS`` to + be used. ``get_aggregated_resources`` runs each one on a worker thread and + merges what they return, later detectors overriding earlier ones. + + Args: + raise_on_error: What to do when ``detect`` raises or runs past the + per-detector timeout. ``False``, the default, logs a warning and + contributes no attributes. ``True`` lets the exception out of + ``get_aggregated_resources``, which fails the construction of the + whole resource. + """ + def __init__(self, raise_on_error: bool = False) -> None: + """Build the detector. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self.raise_on_error = raise_on_error @abc.abstractmethod def detect(self) -> "Resource": - """Don't call `Resource.create` here to avoid an infinite loop, instead instantiate `Resource` directly""" + """Don't call `Resource.create` here to avoid an infinite loop, instead instantiate `Resource` directly + + The method runs on a worker thread of ``get_aggregated_resources`` + and is given a few seconds, so an implementation that reads a file or + runs a command has to bound its own waiting. + + Returns: + A resource holding whatever this detector found. Return an empty + resource rather than raising when there is nothing to report. + + Raises: + NotImplementedError: Always, on this base. + """ raise NotImplementedError() # pylint: disable-next=no-self-use @@ -348,8 +475,35 @@ def is_process_dependent(self) -> bool: class OTELResourceDetector(ResourceDetector): + """Reads the resource attributes the operator put in the environment. + + This detector runs last, so what the operator set beats what the other + detectors found. Only ``Resource.create`` arguments beat it in turn. That + holds while ``OTEL_EXPERIMENTAL_RESOURCE_DETECTORS`` does not name + ``otel`` itself: ``_build_resource_detectors`` appends ``otel`` at the end + only when the list does not already contain it, so naming it earlier puts + the detectors listed after it in a position to override the environment. + """ + # pylint: disable=no-self-use def detect(self) -> "Resource": + """Read ``OTEL_RESOURCE_ATTRIBUTES`` and ``OTEL_SERVICE_NAME``. + + ``OTEL_RESOURCE_ATTRIBUTES`` is a comma separated list of ``key=value`` + pairs. Whitespace around a key and around a value is stripped, the + value is percent decoded so that a comma or an equals sign can be + carried in it, and an entry with no equals sign is logged at + ``WARNING`` level and skipped rather than failing the whole variable. + An entry whose value itself contains an equals sign keeps everything + after the first one as the value. + + ``OTEL_SERVICE_NAME`` is applied after the list, so it wins over a + ``service.name`` set there. An empty ``OTEL_SERVICE_NAME`` is ignored. + + Returns: + A resource holding every pair that parsed. It is empty when + neither variable is set. + """ env_resources_items = environ.get(OTEL_RESOURCE_ATTRIBUTES) env_resource_map: dict[str, AnyValue] = {} @@ -389,14 +543,44 @@ def __init__( *, include_command_args: bool = False, ) -> None: + """Build the detector. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(raise_on_error=raise_on_error) self._include_command_args = include_command_args # pylint: disable=no-self-use def is_process_dependent(self) -> bool: + """Report that these attributes go stale after a fork. + + Returns: + ``True`` always. ``process.pid`` and ``process.parent_pid`` + describe the process that ran the detector, so a provider re-runs + this detector in the child after a fork. + """ return True def detect(self) -> "Resource": + """Collect the runtime, the process ids and the command. + + The command is taken from ``sys.orig_argv`` rather than ``sys.argv``, + which is what lets ``process.command_line`` and + ``process.command_args`` show ``python -m module`` as it was typed. + Only element 0 is reported unless ``include_command_args`` was set, + since the remaining arguments often carry secrets, and element 0 of + ``sys.orig_argv`` is the interpreter path, so ``process.command`` + alone names the interpreter and not the module. + + ``process.parent_pid`` is omitted where ``os.getppid`` is absent, + which is the case on PyPy. ``process.owner`` is omitted when + ``psutil`` is not installed. + + Returns: + A resource with the ``process.*`` attributes that this + interpreter could report. + """ _runtime_version = ".".join( map( str, @@ -504,6 +688,12 @@ def detect(self) -> "Resource": >>> platform.version() '11.4.0.15.0' + Returns: + A resource with ``os.type`` lower cased, and ``os.version`` taken + from ``platform.release`` on most systems but from + ``platform.version`` on Windows and on Solaris, where the release + string is not the version number a reader expects. ``SunOS`` is + reported as ``solaris`` to match the semantic conventions. """ os_type = platform.system().lower() @@ -539,6 +729,18 @@ def detect(self) -> "Resource": def _read_machine_id_file(path: str) -> str | None: + """Read a machine id out of a file, tolerating its absence. + + Args: + path: The file to read. It is expected to hold the id on one line, + possibly with trailing whitespace. + + Returns: + The stripped contents, or ``None`` when the file does not exist, + cannot be read or is empty. The failure is logged at ``DEBUG`` level, + because a missing file is the normal case on a system that keeps its + machine id somewhere else. + """ try: with open(path, encoding="utf8") as machine_id_file: return machine_id_file.read().strip() or None @@ -552,6 +754,21 @@ def _run_command(command: tuple[str, ...]) -> str: A non-zero exit or a missing binary means this host has no machine id to offer. + + Args: + command: The program and its arguments, already split. It is run + without a shell and is given ``_COMMAND_TIMEOUT_SECONDS``, which + is deliberately shorter than the per-detector timeout of + ``get_aggregated_resources``. + + Returns: + Whatever the command wrote to standard output, as text, or the empty + string when it exited non-zero or could not be found. Both failures + are logged at ``DEBUG`` level. + + Raises: + subprocess.TimeoutExpired: If the command outlives the timeout. It is + not caught here, so the caller decides. """ try: completed = subprocess.run( @@ -568,6 +785,13 @@ def _run_command(command: tuple[str, ...]) -> str: def _get_linux_machine_id() -> str | None: + """Read the machine id from the usual Linux locations. + + Returns: + The contents of the first readable and non-empty file among + ``/etc/machine-id`` and ``/var/lib/dbus/machine-id``, or ``None`` + when neither can be read. + """ for path in _LINUX_MACHINE_ID_PATHS: machine_id = _read_machine_id_file(path) if machine_id: @@ -576,10 +800,23 @@ def _get_linux_machine_id() -> str | None: def _get_bsd_machine_id() -> str | None: + """Read the machine id from ``/etc/hostid``, falling back to ``kenv``. + + Returns: + The host id file contents when it holds something, otherwise the + SMBIOS system uuid that ``kenv`` reports, otherwise ``None``. + """ return _read_machine_id_file(_BSD_HOSTID_PATH) or _run_command(_BSD_KENV_COMMAND).strip() or None def _get_macos_machine_id() -> str | None: + """Read the platform uuid out of the ``ioreg`` output. + + Returns: + The value of the first ``IOPlatformUUID`` line, with its surrounding + quotes removed, or ``None`` when ``ioreg`` could not be run or + printed no such line. + """ for line in _run_command(_MACOS_IOREG_COMMAND).splitlines(): # The line looks like: ` "IOPlatformUUID" = "AAAAAAAA-BBBB-..."` key, separator, value = line.partition("=") @@ -593,6 +830,20 @@ def _get_macos_machine_id() -> str | None: def _get_windows_machine_id() -> str | None: + """Read ``MachineGuid`` from the registry. + + The 64-bit view of the key is requested explicitly, so a 32-bit + interpreter on a 64-bit system reads the same value as a 64-bit one + rather than being redirected to the WOW6432Node copy. + + Returns: + The machine guid as a string, or ``None`` when ``winreg`` is absent, + which is the case off Windows, and when the value is empty. + + Raises: + OSError: If the key or the value is missing, or cannot be read. The + caller catches it. + """ if winreg is None: logger.debug("winreg is unavailable, cannot detect %s", HOST_ID) return None @@ -606,6 +857,17 @@ def _get_windows_machine_id() -> str | None: def _get_host_id() -> str | None: + """Read the non-privileged machine id of this host. + + The source depends on the operating system: a file on Linux, ``ioreg`` on + macOS, the registry on Windows, and ``/etc/hostid`` or ``kenv`` on BSD + and DragonFly. + + Returns: + The machine id, or ``None`` when the operating system is not one of + those, which is logged at ``DEBUG`` level, and when the lookup for a + known operating system found nothing. + """ system = platform.system() if system == "Linux": return _get_linux_machine_id() @@ -632,6 +894,22 @@ class _HostResourceDetector(ResourceDetector): # type: ignore[reportUnusedClass """ def detect(self) -> "Resource": + """Collect the host name, the architecture and the machine id. + + The machine id lookup is guarded here rather than relying on + ``get_aggregated_resources``, because this method is also called + directly, and a failed lookup must not cost the caller the other two + attributes. + + Returns: + A resource with ``host.name`` and ``host.arch``, plus ``host.id`` + when the machine id could be read. + + Raises: + Exception: Whatever the machine id lookup raised, but only when + this detector was built with ``raise_on_error=True``. The + failure is logged at ``WARNING`` level either way. + """ resource_info: dict[str, AnyValue] = { HOST_NAME: socket.gethostname(), HOST_ARCH: platform.machine(), @@ -670,9 +948,26 @@ class ServiceInstanceIdResourceDetector(ResourceDetector): """ def is_process_dependent(self) -> bool: + """Report that the instance id has to be redrawn after a fork. + + Returns: + ``True`` always. Two processes sharing one ``service.instance.id`` + would be indistinguishable to a backend, so a provider re-runs + this detector in the child. + """ return True def detect(self) -> "Resource": + """Return the instance id of this process, drawing one if needed. + + The id is held in a module-level variable alongside the process id it + was drawn for, under a lock, so every detector instance in one + process reports the same value and a fork causes exactly one redraw. + + Returns: + A resource holding ``service.instance.id`` as the string form of + a version 4 UUID. + """ # pylint: disable-next=global-statement global _service_instance_id, _service_instance_id_pid with _service_instance_id_lock: @@ -695,6 +990,17 @@ def _build_resource_detectors() -> list["ResourceDetector"]: that OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME take highest merge priority, but an explicit position in OTEL_EXPERIMENTAL_RESOURCE_DETECTORS is respected for either name. + + A name of ``*`` in OTEL_EXPERIMENTAL_RESOURCE_DETECTORS expands in place + to every registered detector name that was not already listed, sorted, + with ``otel`` left out so that it keeps its position at the end. A name + that matches no entry point, or whose entry point fails to load or to + construct, is logged with its traceback and skipped, so one broken + detector package does not stop the resource from being built. + + Returns: + The detector instances to run, in merge order, each already + constructed with no arguments. """ configured_detector_names = [ name.strip() for name in environ.get(OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, "").split(",") if name.strip() @@ -743,6 +1049,16 @@ def _build_resource_detectors() -> list["ResourceDetector"]: def _get_process_dependent_resource() -> Resource: # pyright: ignore[reportUnusedFunction] + """Re-run only the detectors whose attributes a fork invalidates. + + A provider calls this in the child after a fork and merges the result on + top of the resource it already holds. + + Returns: + A resource holding the attributes of the process-dependent detectors + alone, starting from the empty resource rather than the default one, + so that nothing else is re-asserted. + """ return get_aggregated_resources( [detector for detector in _build_resource_detectors() if detector.is_process_dependent()], Resource.get_empty(), @@ -756,10 +1072,33 @@ def get_aggregated_resources( ) -> "Resource": """Retrieves resources from detectors in the order that they were passed - :param detectors: List of resources in order of priority - :param initial_resource: Static resource. This has highest priority - :param timeout: Number of seconds to wait for each detector to return - :return: + Every detector runs on a thread pool of four workers, but the results are + merged in the order the detectors were given, so a later detector + overrides an earlier one whatever order they finished in. + + A detector that exceeds ``timeout`` is skipped and logged at ``WARNING`` + level, unless it was built with ``raise_on_error=True``, in which case + the error is raised and the detectors that have not started yet are + cancelled. The pool is always joined before this function returns, so a + detector that blocks forever blocks this call forever. + + Args: + detectors: The detectors to run, in increasing order of priority. + initial_resource: The resource every detected resource is merged on + top of, which gives it the lowest priority of all. Pass ``None`` + to start from ``Resource.create()``, which runs the whole + detection over again. + timeout: How long to wait for each detector, in seconds. The budget + is per detector rather than for the whole call. + + Returns: + One resource holding the merged attributes. + + Raises: + concurrent.futures.TimeoutError: If a detector built with + ``raise_on_error=True`` exceeded ``timeout``. + Exception: Whatever a detector built with ``raise_on_error=True`` + raised. """ detectors_merged_resource = initial_resource or Resource.create() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 6f47f213bff..57a91b60a64 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -2,6 +2,27 @@ # SPDX-License-Identifier: Apache-2.0 # pylint: disable=too-many-lines +"""Tracing implementation of the OpenTelemetry SDK. + +This module holds the concrete types behind the tracing API. A +``TracerProvider`` builds and caches ``Tracer`` instances. A ``Tracer`` asks +its sampler whether a new span should record, and hands the recording ones to +the span processors registered on the provider. A ``Span`` accumulates the +attributes, events, links and status that an exporter later reads back through +``ReadableSpan``. + +``SpanLimits`` collects the caps applied while a span records: how many +attributes, events and links it keeps, and how long a string attribute value +may be. Anything over a cap is dropped or truncated, and the counts show up on +the finished span as ``dropped_attributes``, ``dropped_events`` and +``dropped_links``. + +Nothing here exports a span. A ``SpanProcessor`` registered with +``TracerProvider.add_span_processor`` is called when a span starts and when it +ends, and the processors in ``opentelemetry.sdk.trace.export`` are the ones +that hand spans to an exporter. +""" + import abc import atexit import concurrent.futures @@ -162,13 +183,27 @@ class SynchronousMultiSpanProcessor(SpanProcessor): _span_processors: tuple[SpanProcessor, ...] def __init__(self): + """Build the instance with no span processors registered. + + The constructor takes no arguments. Processors are added afterwards + with ``add_span_processor``. + """ # use a tuple to avoid race conditions when adding a new span and # iterating through it on "on_start" and "on_end". self._span_processors = () self._lock = threading.Lock() def add_span_processor(self, span_processor: SpanProcessor) -> None: - """Adds a SpanProcessor to the list handled by this instance.""" + """Adds a SpanProcessor to the list handled by this instance. + + The processor goes at the end, so it is called after every processor + already registered. Registering the same processor twice makes it run + twice for every span. + + Args: + span_processor: The processor to append to the list this instance + forwards to. + """ with self._lock: self._span_processors += (span_processor,) @@ -177,10 +212,30 @@ def on_start( span: "Span", parent_context: context_api.Context | None = None, ) -> None: + """Forward the start of ``span`` to each processor in turn. + + An exception raised by one processor propagates to the thread that + started the span, and the processors after it are not called. + + Args: + span: The span that just started. + parent_context: The context the span was started in, passed on + unchanged. ``None`` means the current context was used. + """ for sp in self._span_processors: sp.on_start(span, parent_context=parent_context) def _on_ending(self, span: "Span") -> None: + """Forward the ending of ``span`` to each processor in turn. + + The span is still mutable on this thread while these callbacks run, so + a processor may add attributes to it. An exception raised by one + processor is logged with its traceback and swallowed, and the + processors after it still run. + + Args: + span: The span whose ``end`` call is in progress. + """ for sp in self._span_processors: try: # pylint: disable=protected-access @@ -189,11 +244,24 @@ def _on_ending(self, span: "Span") -> None: logger.exception("Exception while calling _on_ending on %s.", sp) def on_end(self, span: "ReadableSpan") -> None: + """Forward the ended ``span`` to each processor in turn. + + An exception raised by one processor propagates to the thread that + ended the span, and the processors after it are not called. + + Args: + span: The read-only snapshot taken when the span ended. Every + processor receives the same object. + """ for sp in self._span_processors: sp.on_end(span) def shutdown(self) -> None: - """Sequentially shuts down all underlying span processors.""" + """Sequentially shuts down all underlying span processors. + + An exception raised by one processor propagates to the caller and the + processors after it are never shut down. + """ for sp in self._span_processors: sp.shutdown() @@ -201,6 +269,12 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: """Sequentially calls force_flush on all underlying :class:`SpanProcessor` + A deadline is computed once, and each processor is given whatever is + left of it, rounded down to whole milliseconds, so a processor reached + with less than a millisecond to spare is asked to flush in zero. Once + the deadline has passed the call returns ``False`` at once, without + calling the processors that are left. + Args: timeout_millis: The maximum amount of time over all span processors to wait for spans to be exported. In case the first n span @@ -234,6 +308,10 @@ class ConcurrentMultiSpanProcessor(SpanProcessor): is called synchronously on the thread ending the span, since that is the only thread allowed to mutate the span while it is ending. + The pool is rebuilt in a child process after a fork, because only the + forking thread survives and the worker threads inherited with the pool + would never run anything. + Args: num_threads: The number of threads managed by the thread pool executor and thus defining how many span processors can work in parallel. @@ -242,6 +320,11 @@ class ConcurrentMultiSpanProcessor(SpanProcessor): _span_processors: tuple[SpanProcessor, ...] def __init__(self, num_threads: int = 2): + """Build the instance and the thread pool it submits work to. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ # use a tuple to avoid race conditions when adding a new span and # iterating through it on "on_start" and "on_end". self._span_processors = () @@ -253,6 +336,15 @@ def __init__(self, num_threads: int = 2): weak_reinit = weakref.WeakMethod(self._init_executor) def _after_in_child() -> None: + """Rebuild the thread pool in the freshly forked child. + + A fork keeps only the calling thread, so the inherited pool has + no workers. ``os.register_at_fork`` keeps a strong reference to + whatever is handed to it, so this closure holds + ``_init_executor`` of this multi span processor only weakly. A + multi span processor that has been garbage collected leaves the + weak reference empty and the handler does nothing. + """ reinit = weak_reinit() if reinit is not None: reinit(num_threads) @@ -260,10 +352,28 @@ def _after_in_child() -> None: os.register_at_fork(after_in_child=_after_in_child) def _init_executor(self, num_threads: int) -> None: + """Replace the thread pool with a fresh one of the requested size. + + Called once from the constructor, and again in a child process after a + fork. The pool being replaced is dropped without being shut down. + + Args: + num_threads: Number of worker threads the new pool runs. + """ self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) def add_span_processor(self, span_processor: SpanProcessor) -> None: - """Adds a SpanProcessor to the list handled by this instance.""" + """Adds a SpanProcessor to the list handled by this instance. + + Registering the same processor twice makes it run twice for every span. + Work is submitted to the pool in registration order, but the processors + run in parallel, so that order says nothing about which one finishes + first. + + Args: + span_processor: The processor to append to the list this instance + forwards to. + """ with self._lock: self._span_processors += (span_processor,) @@ -273,6 +383,20 @@ def _submit_and_await( *args: Any, **kwargs: Any, ): + """Call one bound method on every processor and wait for all of them. + + Every call is submitted to the thread pool before any result is + collected, so the processors run concurrently. The first exception + raised by a processor surfaces here when its result is collected, and + the results not yet collected are abandoned, though the calls behind + them still run to completion. + + Args: + func: Takes a processor and returns the bound method to call on it, + for example ``lambda sp: sp.on_end``. + *args: Positional arguments passed to each bound method. + **kwargs: Keyword arguments passed to each bound method. + """ futures = [] for sp in self._span_processors: future = self._executor.submit(func(sp), *args, **kwargs) @@ -285,9 +409,30 @@ def on_start( span: "Span", parent_context: context_api.Context | None = None, ) -> None: + """Forward the start of ``span`` to every processor at once. + + The call blocks until every processor has returned, so the thread that + started the span still waits for the slowest of them. + + Args: + span: The span that just started. + parent_context: The context the span was started in, passed on + unchanged. ``None`` means the current context was used. + """ self._submit_and_await(lambda sp: sp.on_start, span, parent_context=parent_context) def _on_ending(self, span: "Span") -> None: + """Forward the ending of ``span`` to each processor in turn. + + This is the one callback that does not go through the thread pool. The + span is still mutable while it ends, and only the thread ending it may + mutate it, so a processor that wants to add an attribute has to run + there. An exception raised by one processor is logged with its + traceback and swallowed, and the processors after it still run. + + Args: + span: The span whose ``end`` call is in progress. + """ # Unlike the other callbacks, _on_ending runs synchronously on the # thread that ends the span: that is the only thread allowed to # mutate the span while it is ending. @@ -299,15 +444,34 @@ def _on_ending(self, span: "Span") -> None: logger.exception("Exception while calling _on_ending on %s.", sp) def on_end(self, span: "ReadableSpan") -> None: + """Forward the ended ``span`` to every processor at once. + + The call blocks until every processor has returned, so the thread that + ended the span still waits for the slowest of them. + + Args: + span: The read-only snapshot taken when the span ended. Every + processor receives the same object. + """ self._submit_and_await(lambda sp: sp.on_end, span) def shutdown(self) -> None: - """Shuts down all underlying span processors in parallel.""" + """Shuts down all underlying span processors in parallel. + + The call blocks until every processor has returned. There is no + timeout: each processor applies its own. + """ self._submit_and_await(lambda sp: sp.shutdown) def force_flush(self, timeout_millis: int = 30000) -> bool: """Calls force_flush on all underlying span processors in parallel. + Unlike ``SynchronousMultiSpanProcessor``, this class gives the whole + budget to every processor rather than dividing it, and waits that long + for all of them together. A processor that has not finished by then + makes the call return ``False`` while its flush keeps running in the + thread pool, unobserved. + Args: timeout_millis: The maximum amount of time to wait for spans to be exported. @@ -334,7 +498,24 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: class EventBase(abc.ABC): + """Base class for a named, timestamped annotation on a span. + + The name and the timestamp are stored here and never change afterwards. + Subclasses decide where the attributes come from by implementing + ``attributes``. ``Event`` is the only implementation in this package. + + Args: + name: Name of the event. + timestamp: Time of the event in nanoseconds since the Unix epoch. + ``None`` reads the clock as the instance is built. + """ + def __init__(self, name: str, timestamp: int | None = None) -> None: + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._name = name if timestamp is None: self._timestamp = time_ns() @@ -343,16 +524,33 @@ def __init__(self, name: str, timestamp: int | None = None) -> None: @property def name(self) -> str: + """Name the event was created with. + + Returns: + The name, exactly as it was passed to the constructor. Nothing in + this package renames an event after the fact. + """ return self._name @property def timestamp(self) -> int: + """Time of the event in nanoseconds since the Unix epoch. + + Returns: + The timestamp given to the constructor, or the clock reading taken + when the instance was built. + """ return self._timestamp @property @abc.abstractmethod def attributes(self) -> types.Attributes: - pass + """Attributes recorded on the event. + + Returns: + The attributes, or ``None`` when the event carries none. Callers + are expected to read the result and not write to it. + """ class Event(EventBase): @@ -364,6 +562,10 @@ class Event(EventBase): attributes: Attributes of the event. timestamp: Timestamp of the event. If `None` it will filled automatically. + limit: Accepted and ignored, kept so that older callers keep working. + No cap is applied here. The attribute limit is applied by whoever + builds the attributes passed in, which for a span event is + ``Span.add_event``. """ def __init__( @@ -373,22 +575,81 @@ def __init__( timestamp: int | None = None, limit: int | None = _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, ) -> None: + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(name, timestamp) self._attributes = attributes @property def attributes(self) -> types.Attributes: + """Attributes recorded on the event, exactly as they were passed in. + + ``Span.add_event`` passes an immutable, bounded mapping, so an event + that reached a span through it is already capped and frozen. An event + built directly keeps whatever mapping was given, uncapped. + + Returns: + The attributes, or ``None`` when none were given. + """ return self._attributes @property def dropped_attributes(self) -> int: + """Number of attributes the attribute limit kept out of this event. + + The count covers keys rejected because the mapping was already full, + and keys that were neither non-empty strings nor convertible to one. + Truncating a long string value is not a drop, and neither is replacing + a value of an unusable type with ``None``, so neither of those shows up + here. + + Returns: + The number of dropped attributes, or ``0`` when the attributes + were not stored in a bounded mapping and therefore nothing could + have been dropped. + """ if isinstance(self._attributes, BoundedAttributes): return self._attributes.dropped return 0 def _check_span_ended(func): + """Wrap a span mutator so that it is skipped once the span has ended. + + The wrapper takes the span lock, calls ``func`` only while the span is + still mutable, and otherwise logs a warning naming ``func`` and leaves the + span untouched. Whatever ``func`` returns is discarded, so this suits only + mutators that return ``None``. + + The wrapper does not copy the name, signature or docstring of ``func``, so + what the API reference shows for a decorated method is the wrapper. + + Args: + func: The unbound method to wrap. It is called as + ``func(self, *args, **kwargs)`` with the span lock held. + + Returns: + The function to use as the body of the decorated method. + """ + def wrapper(self, *args, **kwargs): + """Apply this mutation to the span while it is still recording. + + ``_check_span_ended`` gives this body to ``Span.set_status``, + ``Span.update_name``, ``Span._add_event`` and ``Span._add_link``, so + the API reference shows this text, and the ``*args`` and ``**kwargs`` + below, in place of the signature and the docstring each of those four + methods carries in the source. The call is made while the span lock is + held. On a span that has already ended nothing is applied, and a + warning naming the method is logged once the lock is released. + + Args: + *args: The positional arguments of the method being called. + **kwargs: The keyword arguments of the method being called. + """ already_ended = False with self._lock: # pylint: disable=protected-access if self._is_mutable(): # pylint: disable=protected-access @@ -403,6 +664,21 @@ def wrapper(self, *args, **kwargs): def _is_valid_link(context: SpanContext, attributes: types.Attributes) -> bool: + """Decide whether a link with this context and attributes is kept. + + A link whose context is valid is always kept. A link whose context is + invalid is kept only when it carries something else of value, meaning + attributes or a non-empty trace state. Everything else, a missing context + included, is rejected, and the caller drops the link without logging. + + Args: + context: The span context the link points at. A falsy value rejects the + link outright. + attributes: Attributes recorded on the link, or ``None``. + + Returns: + ``True`` when the link should be recorded. + """ return bool(context and (context.is_valid or (attributes or context.trace_state))) @@ -412,6 +688,33 @@ class ReadableSpan: Users should NOT be creating these objects directly. `ReadableSpan`s are created as a direct result from using the tracing pipeline via the `Tracer`. + One of these is built by ``Span._readable_span`` when a span ends, and is + what the ``on_end`` callbacks and the exporters read. It shares the + containers holding the attributes, the events and the links with the span + it was taken from rather than copying them, which is safe because the span + freezes its attributes before handing the snapshot over. + + Args: + name: Name of the span. + context: Span context carrying the trace id, the span id, the flags and + the trace state. ``None`` for a span that was never given one. + parent: Span context of the parent, or ``None`` for a root span. + resource: Resource describing the entity that produced the span. + ``None`` builds one from the configured resource detectors. + attributes: Attributes recorded on the span, or ``None``. + events: Events recorded on the span, oldest first. + links: Links recorded on the span. + kind: Relationship between this span and its parent. + instrumentation_info: Deprecated description of the instrumenting + library. Pass ``instrumentation_scope`` instead. + status: Status of the operation the span represents. Defaults to a + status whose code is ``UNSET``. + start_time: Start of the span in nanoseconds since the Unix epoch, or + ``None`` for a span that was never started. + end_time: End of the span in nanoseconds since the Unix epoch, or + ``None`` for a span that has not ended. + instrumentation_scope: Name, version, schema URL and attributes of the + instrumenting library the span came from. """ def __init__( @@ -430,6 +733,11 @@ def __init__( end_time: int | None = None, instrumentation_scope: InstrumentationScope | None = None, ) -> None: + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._name = name self._context = context self._kind = kind @@ -449,79 +757,242 @@ def __init__( @property def dropped_attributes(self) -> int: + """Number of attributes the attribute limit kept out of this span. + + The count covers keys rejected because the span already held as many + attributes as the limit allows, and keys that were neither non-empty + strings nor convertible to one. Truncating a long string value is not a + drop, and neither is replacing a value of an unusable type with + ``None``, so neither of those shows up here. + + Returns: + The number of dropped attributes, or ``0`` when the attributes + were not stored in a bounded mapping, which is the case for a + ``ReadableSpan`` built directly. + """ if isinstance(self._attributes, BoundedAttributes): return self._attributes.dropped return 0 @property def dropped_events(self) -> int: + """Number of events the event limit kept out of this span. + + Appending to a full event list discards the oldest event rather than + the new one, so a span that overflowed keeps its most recent events and + this count says how many earlier ones were lost. + + Returns: + The number of dropped events, or ``0`` when the events were not + stored in a bounded list. + """ if isinstance(self._events, BoundedList): return self._events.dropped return 0 @property def dropped_links(self) -> int: + """Number of links the link limit kept out of this span. + + As with events, the oldest link is discarded when a full list is + appended to. Links refused at creation for having neither a valid + context, attributes nor a trace state were never counted here. + + Returns: + The number of dropped links, or ``0`` when the links were not + stored in a bounded list. + """ if isinstance(self._links, BoundedList): return self._links.dropped return 0 @property def name(self) -> str: + """Name of the span. + + Returns: + The name the span was created with, or the one last given to + ``Span.update_name`` before it ended. + """ return self._name def get_span_context(self) -> trace_api.SpanContext | None: + """Return the span context, the method form the tracing API declares. + + Returns: + The same object the ``context`` property returns: the span context + carrying the trace id, the span id, the flags and the trace state, + or ``None`` when the span was built without one. + """ return self._context @property def context(self): + """Span context of this span, also returned by ``get_span_context``. + + Returns: + The span context, or ``None`` when the span was built without one. + """ return self._context @property def kind(self) -> trace_api.SpanKind: + """Relationship between this span and its parent. + + Returns: + The kind asked for when the span was created, which is + ``SpanKind.INTERNAL`` unless the caller said otherwise. It cannot + be changed afterwards. + """ return self._kind @property def parent(self) -> trace_api.SpanContext | None: + """Span context of the span that started this one. + + Returns: + The parent span context, or ``None`` for a root span. + ``Tracer.start_span`` records ``None`` both when there was no + current span and when the current span context was invalid, so + ``None`` does not mean the caller failed to propagate anything. + """ return self._parent @property def start_time(self) -> int | None: + """Time the span started, in nanoseconds since the Unix epoch. + + Returns: + The start timestamp, or ``None`` for a span on which ``start`` was + never called. + """ return self._start_time @property def end_time(self) -> int | None: + """Time the span ended, in nanoseconds since the Unix epoch. + + The value is set at the beginning of ``Span.end``, before the + ``_on_ending`` callbacks run, so a processor reading it from one of + those callbacks already sees it. + + Returns: + The end timestamp, or ``None`` while the span has not ended. + """ return self._end_time @property def status(self) -> trace_api.Status: + """Status of the operation the span represents. + + A span starts out ``UNSET``. ``Span.set_status`` raises it to ``ERROR`` + or to ``OK`` and refuses to lower it again. + + Returns: + The status, carrying a code and an optional description. + """ return self._status @property def attributes(self) -> types.Attributes: + """Attributes recorded on the span, behind a read-only view. + + The view wraps the container the span writes into rather than copying + it, so reading it again after the span records another attribute shows + the new value. That holds only for a span that already carried at + least one attribute when the view was taken: an empty container is + falsy, so a view taken from a span with no attributes wraps a + throwaway empty mapping and never changes. Reading the property again + always reports the current attributes either way. An ended span + records nothing further, which is what makes the view of an ended + span stable. + + Returns: + A mapping proxy over the attributes, empty when the span has none. + Writing through it raises ``TypeError``. + """ return MappingProxyType(self._attributes or {}) @property def events(self) -> Sequence[Event]: + """Events recorded on the span, oldest first. + + A new tuple is built on every access, so what you get is a snapshot + and a later event never appears in a tuple already taken. The ``Event`` + objects inside it are shared, not copied. + + Returns: + A tuple of the events the span still holds, which excludes any the + event limit dropped. + """ return tuple(self._events) @property def links(self) -> Sequence[trace_api.Link]: + """Links recorded on the span, oldest first. + + A new tuple is built on every access, so what you get is a snapshot. + Links refused at creation never appear here. + + Returns: + A tuple of the links the span still holds, which excludes any the + link limit dropped. + """ return tuple(self._links) @property def resource(self) -> Resource: + """Resource describing the entity that produced the span. + + A span takes this from its tracer as it is created and keeps that same + object afterwards, so replacing the resource of the provider does not + change the resource of a span that already exists. + + Returns: + The resource. Never ``None``: a span built without one gets + whatever the configured resource detectors produce. + """ return self._resource @property @deprecated("You should use instrumentation_scope. Deprecated since version 1.11.1.") def instrumentation_info(self) -> InstrumentationInfo | None: + """Deprecated description of the instrumenting library. + + Returns: + The instrumentation info given at creation, or ``None``. Read + ``instrumentation_scope`` instead, which carries the same name, + version and schema URL plus the attributes of the scope. + """ return self._instrumentation_info @property def instrumentation_scope(self) -> InstrumentationScope | None: + """Scope of the instrumenting library the span came from. + + Returns: + The scope the tracer was obtained for, carrying its name, version, + schema URL and attributes, or ``None`` for a span built without + one. + """ return self._instrumentation_scope def to_json(self, indent: int | None = 4): + """Render the span as a JSON document. + + The document holds the name, the context, the kind, the parent span id, + the start and end times as ISO 8601 strings, the status, the + attributes, the events, the links and the resource. The instrumentation + scope and the dropped counts are left out, which makes this a readable + dump and not a wire format. + + Args: + indent: Number of spaces to indent each nested level by. ``None`` + produces the compact single-line form. + + Returns: + The JSON document as a string. + """ parent_id = None if self.parent is not None: parent_id = f"0x{trace_api.format_span_id(self.parent.span_id)}" @@ -558,6 +1029,19 @@ def to_json(self, indent: int | None = 4): @staticmethod def _format_context(context: SpanContext) -> dict[str, str]: + """Render one span context for ``to_json``. + + The trace id and the span id come out as lowercase hexadecimal behind + an ``0x`` prefix, padded to their full width, and the trace state comes + out as its ``repr``. + + Args: + context: The span context to render. + + Returns: + A dict holding the ``trace_id``, ``span_id`` and ``trace_state`` + keys. + """ return { "trace_id": f"0x{trace_api.format_trace_id(context.trace_id)}", "span_id": f"0x{trace_api.format_span_id(context.span_id)}", @@ -568,12 +1052,37 @@ def _format_context(context: SpanContext) -> dict[str, str]: def _format_attributes( attributes: types.Attributes, ) -> dict[str, Any] | None: + """Coerce an attribute mapping into something ``json.dumps`` accepts. + + A bounded mapping is copied into a plain dict. A plain dict and + ``None`` are handed back untouched, so the result is sometimes the very + object that was passed in. + + Args: + attributes: The attributes to render, or ``None``. + + Returns: + A plain dict of the attributes, or ``None`` when there were none. + """ if attributes is not None and not isinstance(attributes, dict): return dict(attributes) return attributes @staticmethod def _format_events(events: Sequence[Event]) -> list[dict[str, Any]]: + """Render events for ``to_json``. + + Each timestamp becomes an ISO 8601 string and each attribute mapping is + flattened into a plain dict. The dropped attribute count of an event is + left out. + + Args: + events: The events to render, in the order they should appear. + + Returns: + A list of dicts holding the ``name``, ``timestamp`` and + ``attributes`` keys. + """ return [ { "name": event.name, @@ -587,6 +1096,17 @@ def _format_events(events: Sequence[Event]) -> list[dict[str, Any]]: @staticmethod def _format_links(links: Sequence[trace_api.Link]) -> list[dict[str, Any]]: + """Render links for ``to_json``. + + Each link contributes the span context it points at and its attributes. + The dropped attribute count of a link is left out. + + Args: + links: The links to render, in the order they should appear. + + Returns: + A list of dicts holding the ``context`` and ``attributes`` keys. + """ return [ { "context": Span._format_context( # pylint: disable=protected-access @@ -620,27 +1140,50 @@ class SpanLimits: - Else if the model specific limit has a default value, the default value will be used. - Else if the global limit has a default value, the default value will be used. + Passing ``SpanLimits.UNSET`` for a limit removes it, so that nothing is + capped. An environment variable set to the empty string, or to whitespace + alone, has the same effect. A negative number raises ``ValueError`` out of + the constructor instead of being ignored, and so does an environment + variable whose value does not parse as an integer. A limit argument is + never parsed and never type checked, so one that cannot be compared with + an integer, a string for instance, raises ``TypeError`` from that + comparison, and a float is accepted and stored as it is. + + ``max_attributes`` is the exception to both of those. When it resolves to + no limit, whether because ``UNSET`` was passed or because the environment + variable was empty, 128 is stored instead, so the global attribute limit + can never be removed. The three specific attribute limits fall back on 128 + in the same case, so removing the global limit does not remove them either. + A span applies the three specific limits, never this one: ``max_attributes`` + only ever acts as their default. + Args: max_attributes: Maximum number of attributes that can be added to a span, event, and link. Environment variable: OTEL_ATTRIBUTE_COUNT_LIMIT - Default: {_DEFAULT_ATTRIBUTE_COUNT_LIMIT} + Default: 128 max_events: Maximum number of events that can be added to a Span. Environment variable: OTEL_SPAN_EVENT_COUNT_LIMIT - Default: {_DEFAULT_SPAN_EVENT_COUNT_LIMIT} + Default: 128 max_links: Maximum number of links that can be added to a Span. Environment variable: OTEL_SPAN_LINK_COUNT_LIMIT - Default: {_DEFAULT_SPAN_LINK_COUNT_LIMIT} + Default: 128 max_span_attributes: Maximum number of attributes that can be added to a Span. Environment variable: OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT - Default: {_DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT} + Default: the global attribute limit, 128 when that is unset too. max_event_attributes: Maximum number of attributes that can be added to an Event. - Default: {_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT} + Environment variable: OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT + Default: the global attribute limit, 128 when that is unset too. max_link_attributes: Maximum number of attributes that can be added to a Link. - Default: {_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT} + Environment variable: OTEL_LINK_ATTRIBUTE_COUNT_LIMIT + Default: the global attribute limit, 128 when that is unset too. max_attribute_length: Maximum length an attribute value can have. Values longer than the specified length will be truncated. + Environment variable: OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT + Default: no limit, so values are never truncated. max_span_attribute_length: Maximum length a span attribute value can have. Values longer than the specified length will be truncated. + Environment variable: OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT + Default: whatever ``max_attribute_length`` resolved to. """ UNSET = -1 @@ -656,6 +1199,22 @@ def __init__( max_attribute_length: int | None = None, max_span_attribute_length: int | None = None, ): + """Resolve each limit from the arguments, the environment and defaults. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. The resolution happens once, here, so + changing an environment variable afterwards has no effect on an + instance that already exists. + + Raises: + ValueError: If an argument or the environment variable backing it + is negative, or if the environment variable does not parse as + an integer. The message names the environment variable even + when the offending value came from the argument. + TypeError: If an argument cannot be compared with an integer, a + string for instance. Arguments are never parsed, so one of the + wrong type reaches that comparison unconverted. + """ # span events and links count self.max_events = self._from_env_if_absent( max_events, @@ -703,10 +1262,49 @@ def __init__( ) def __repr__(self): + """Render every resolved limit on one line. + + ``max_span_attribute_length`` is the one limit left out. + + Returns: + A string naming the class and the limits it resolved to, where + ``None`` means that limit caps nothing. + """ return f"{type(self).__name__}(max_span_attributes={self.max_span_attributes}, max_events_attributes={self.max_event_attributes}, max_link_attributes={self.max_link_attributes}, max_attributes={self.max_attributes}, max_events={self.max_events}, max_links={self.max_links}, max_attribute_length={self.max_attribute_length})" @classmethod def _from_env_if_absent(cls, value: int | None, env_var: str, default: int | None = None) -> int | None: + """Resolve one limit from the argument, the environment or the default. + + An argument equal to ``UNSET`` wins over everything and removes the + limit. Any other argument is used as given. Only when no argument was + passed is ``env_var`` read, and only when it is absent from the + environment is ``default`` used. An environment variable present but + empty, or whitespace alone, also removes the limit, which is not the + same as being absent. + + Args: + value: The limit the caller asked for, ``None`` when the caller + said nothing, or ``UNSET`` to remove the limit. + env_var: Name of the environment variable to read when ``value`` + is ``None``. Its value is stripped and lowercased before being + parsed. + default: What to return when ``value`` is ``None`` and ``env_var`` + is absent from the environment. ``None`` means no limit. + + Returns: + The resolved limit, or ``None`` when nothing should be capped. + + Raises: + ValueError: If the resolved value is negative, or if the + environment variable does not parse as an integer. The message + names ``env_var`` in both cases, including when the offending + value came from ``value``. + TypeError: If ``value`` is neither ``None`` nor ``UNSET`` and + cannot be compared with an integer, a string for instance. + Only the environment variable is ever parsed, so a ``value`` + of the wrong type reaches the comparison unconverted. + """ if value == cls.UNSET: return None @@ -757,6 +1355,12 @@ class Span(trace_api.Span, ReadableSpan): Users should create `Span` objects via the `Tracer` instead of this constructor. + The attributes, the events and the links given here are copied into bounded + containers as the span is built, so the limits apply to them as they do to + anything recorded later. The attributes of each event and of each link are + replaced in place with an immutable copy, which changes the ``Event`` and + ``Link`` objects the caller passed in. + Args: name: The name of the operation this span represents context: The immutable span context @@ -773,9 +1377,43 @@ class Span(trace_api.Span, ReadableSpan): span_processor: `SpanProcessor` to invoke when starting and ending this `Span`. limits: `SpanLimits` instance that was passed to the `TracerProvider` + kind: Relationship between this span and its parent + instrumentation_info: Deprecated description of the instrumenting + library. Pass ``instrumentation_scope`` instead + record_exception: Whether leaving a ``with`` block over this span by + raising records the exception as an event on it. Only consulted by + ``__exit__``, so it does nothing for a span that is ended by hand + set_status_on_exception: Whether leaving a ``with`` block over this + span by raising sets its status to ``ERROR``. Only consulted by + ``__exit__`` + instrumentation_scope: Name, version, schema URL and attributes of the + instrumenting library this span came from + record_end_metrics: Called once, with no arguments, at the start of + ``end``, to decrement the count of live spans. A tracer always + passes a callable, one that does nothing when the internal SDK + metrics are switched off. ``None`` counts nothing at all and is + what a span built outside a tracer gets """ def __new__(cls, *args, **kwargs): + """Build an instance of a subclass and refuse to build a bare ``Span``. + + ``Tracer.start_span`` creates ``_Span``, the only subclass this package + defines. Creating a ``Span`` directly is the mistake this guards + against. + + Args: + *args: Accepted so that the constructor arguments may be passed + positionally. They are discarded here. + **kwargs: Accepted so that the constructor arguments may be passed + by keyword. They are discarded here. + + Returns: + The new instance, before ``__init__`` has run on it. + + Raises: + TypeError: If the class being instantiated is ``Span`` itself. + """ if cls is Span: raise TypeError("Span must be instantiated via a tracer.") return super().__new__(cls) @@ -802,6 +1440,13 @@ def __init__( *, record_end_metrics: Callable[[], None] | None = None, ) -> None: + """Build the span, without starting it. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. The span has no start time until + ``start`` is called, and ``end`` on a span that was never started + raises. + """ if resource is None: resource = Resource.create({}) super().__init__( @@ -847,12 +1492,43 @@ def __init__( self._record_end_metrics = record_end_metrics def __repr__(self): + """Render the span as its type, its name and its span context. + + The attributes, events, links, status and timings are left out, so two + spans that differ only in what they recorded print the same. + + Returns: + A string of the form ``_Span(name="...", context=...)``, naming the + concrete subclass rather than ``Span``. + """ return f'{type(self).__name__}(name="{self._name}", context={self._context})' def _new_events(self): + """Build the empty container the span appends events to. + + Returns: + A bounded list capped at ``max_events``. Appending to it once it is + full discards the oldest event and raises the dropped count. + """ return BoundedList(self._limits.max_events) def _new_links(self, links: Sequence[trace_api.Link]): + """Build the link container, seeded with the links worth keeping. + + A link that is falsy, or that has neither a valid context, attributes + nor a trace state, is left out without a word. The attributes of each + link that survives are replaced in place with an immutable, bounded + copy, which mutates the ``Link`` object the caller passed in. + + Args: + links: Links given to the constructor. An empty sequence, which is + the usual case, skips the filtering and produces an empty + container. + + Returns: + A bounded list capped at ``max_links``. When more links survive the + filtering than the cap allows, the oldest are dropped. + """ if not links: return BoundedList(self._limits.max_links) @@ -872,9 +1548,32 @@ def _new_links(self, links: Sequence[trace_api.Link]): return BoundedList.from_seq(self._limits.max_links, valid_links) def get_span_context(self) -> trace_api.SpanContext: + """Return the span context, which a span built by a tracer always has. + + Returns: + The span context the tracer assigned at creation. The cast only + narrows the type the base class declares to one that cannot be + ``None``; no check is performed at runtime. + """ return typing.cast(trace_api.SpanContext, self._context) def set_attributes(self, attributes: Mapping[str, types.AnyValue]) -> None: + """Record several attributes in one pass. + + A key that is already present is overwritten and moves to the end of + the insertion order, so it does not count against the attribute limit a + second time. Once the span holds as many attributes as the limit + allows, further new keys are dropped and counted in + ``dropped_attributes``, and a warning is logged for the first drop + only. On a span that has ended the call logs a warning and records + nothing. + + Args: + attributes: Mapping of attribute name to value. A string value + longer than the configured limit is truncated, and a value of a + type that cannot be used as an attribute is replaced with + ``None`` rather than dropped. + """ with self._lock: if not self._is_mutable(): logger.warning("Setting attribute on ended span.") @@ -883,6 +1582,20 @@ def set_attributes(self, attributes: Mapping[str, types.AnyValue]) -> None: self._attributes._set_items(attributes) # pylint: disable=protected-access def set_attribute(self, key: str, value: types.AnyValue) -> None: + """Record a single attribute. + + A key that is already present is overwritten and moves to the end of + the insertion order. Once the span holds as many attributes as the + limit allows, a new key is dropped and counted in + ``dropped_attributes``. On a span that has ended the call logs a + warning and records nothing. + + Args: + key: Attribute name. Anything that is not a non-empty string is + rejected with a warning and counted as dropped. + value: Attribute value. A string longer than the configured limit + is truncated, which is not counted as a drop. + """ with self._lock: if not self._is_mutable(): logger.warning("Setting attribute on ended span.") @@ -892,6 +1605,16 @@ def set_attribute(self, key: str, value: types.AnyValue) -> None: @_check_span_ended def _add_event(self, event: EventBase) -> None: + """Append an already built event to the span. + + The decorator takes the span lock and skips the append, with a warning, + once the span has ended. The event is stored as it arrives: no limit is + applied to its attributes here, so the caller has to have applied one. + + Args: + event: The event to append. Appending to a full event list discards + the oldest event and raises ``dropped_events``. + """ self._events.append(event) def add_event( @@ -900,6 +1623,22 @@ def add_event( attributes: types.Attributes = None, timestamp: int | None = None, ) -> None: + """Record a timestamped annotation on the span. + + The attributes are capped at ``max_event_attributes`` and frozen before + the event is built, so nothing can change them afterwards. The value + length applied to them is the global ``max_attribute_length`` and not + the span specific one. On a span that has ended the call logs a warning + and records nothing. + + Args: + name: Name of the event. It is recorded as given, with no length + limit applied. + attributes: Attributes of the event, or ``None`` for none. + timestamp: Time of the event in nanoseconds since the Unix epoch. + ``None`` reads the clock as the ``Event`` is built, which is + after the attributes have been capped and frozen. + """ # Immutable set to true so attributes cannot be added or removed after creation. attributes = BoundedAttributes( self._limits.max_event_attributes, @@ -917,6 +1656,16 @@ def add_event( @_check_span_ended def _add_link(self, link: trace_api.Link) -> None: + """Append an already built link to the span. + + The decorator takes the span lock and skips the append, with a warning, + once the span has ended. No validation and no attribute limit are + applied here, so the caller has to have applied both. + + Args: + link: The link to append. Appending to a full link list discards + the oldest link and raises ``dropped_links``. + """ self._links.append(link) def add_link( @@ -924,6 +1673,20 @@ def add_link( context: SpanContext, attributes: types.Attributes = None, ) -> None: + """Record a link from this span to another one. + + A link whose context is invalid is kept only when it carries + attributes or a trace state. Otherwise the call returns having recorded + nothing and logged nothing, which makes a mistyped context hard to + notice. The attributes are capped at ``max_link_attributes`` and + frozen. On a span that has ended the call logs a warning and records + nothing. + + Args: + context: Span context of the span being linked to. + attributes: Attributes of the link, or ``None`` for none. They + count towards whether an invalid context is kept. + """ if not _is_valid_link(context, attributes): return # Immutable set to true so attributes cannot be added or removed after creation. @@ -941,6 +1704,17 @@ def add_link( ) def _readable_span(self) -> ReadableSpan: + """Take the read-only snapshot handed to the ``on_end`` callbacks. + + The containers holding the attributes, the events and the links are + shared with this span rather than copied, which is safe because ``end`` + freezes the attributes before calling this. Nothing is read under the + span lock. + + Returns: + A ``ReadableSpan`` carrying the current name, context, parent, + resource, kind, status, timings and instrumentation scope. + """ return ReadableSpan( name=self._name, context=self._context, @@ -962,6 +1736,20 @@ def start( start_time: int | None = None, parent_context: context_api.Context | None = None, ) -> None: + """Record the start time and run the ``on_start`` callbacks. + + A second call logs a warning and does nothing, so neither the start + time nor the callbacks are repeated. The callbacks run on the calling + thread with the span lock released, and an exception from one of them + propagates to the caller, leaving the span started and the remaining + callbacks uncalled. + + Args: + start_time: Start of the span in nanoseconds since the Unix epoch. + ``None`` reads the clock now. + parent_context: Context the span was started in, handed to the + processors unchanged. ``None`` means the current context. + """ with self._lock: if self._start_time is not None: logger.warning("Calling start() on a started span.") @@ -971,6 +1759,28 @@ def start( self._span_processor.on_start(self, parent_context=parent_context) def end(self, end_time: int | None = None) -> None: + """Record the end time of the span and run the end callbacks. + + The end time is taken first. The metrics callback and then the + ``_on_ending`` callbacks run next, and throughout those the span stays + mutable for this one thread, so a processor can still add an attribute + to it. The attributes are frozen once they return, whether or not they + raised, and only then is the read-only snapshot handed to the + ``on_end`` callbacks. An exception out of the metrics callback or out + of ``_on_ending`` therefore leaves a span that is ended and frozen but + was never delivered to ``on_end``. + + A second call logs a warning and changes nothing, so the end time and + the callbacks are not repeated. + + Args: + end_time: End of the span in nanoseconds since the Unix epoch. + ``None`` reads the clock now. No check is made that it is later + than the start time. + + Raises: + RuntimeError: If ``start`` was never called on this span. + """ with self._lock: if self._start_time is None: raise RuntimeError("Calling end() on a not started span.") @@ -996,12 +1806,41 @@ def end(self, end_time: int | None = None) -> None: @_check_span_ended def update_name(self, name: str) -> None: + """Replace the name of the span. + + On a span that has ended the call logs a warning and leaves the name + alone. A span processor that renames spans has to do it from + ``_on_ending``, since by ``on_end`` the span is no longer mutable. + + Args: + name: The new name, recorded as given with no length limit + applied. + """ self._name = name def _is_mutable(self) -> bool: + """Say whether the span still accepts changes. + + A span is mutable until its end time is recorded, and then stays + mutable for the single thread that is inside ``end`` running the + ``_on_ending`` callbacks. Callers are expected to hold the span lock, + since the answer can change under them otherwise. + + Returns: + ``True`` while the span still accepts changes. + """ return self._end_time is None or self._ending_thread == threading.get_ident() def is_recording(self) -> bool: + """Say whether anything recorded on the span would still be kept. + + The answer is read without the span lock, so it can be stale by the + time the caller acts on it: another thread may end the span in between. + + Returns: + ``True`` while the span accepts changes, which lasts until ``end`` + has run its ``_on_ending`` callbacks. + """ # Unlike the mutators, this check is deliberately not taken under # self._lock: it does not modify the span, and the answer can become # stale as soon as it is returned anyway, since another thread may @@ -1014,6 +1853,20 @@ def set_status( status: Status | StatusCode, description: str | None = None, ) -> None: + """Set the status of the operation the span represents. + + The new status is recorded only when it outranks the one already there, + which ``_accepts_status`` decides. An argument that is neither a + ``Status`` nor a ``StatusCode`` is ignored without a word. On a span + that has ended the call logs a warning and changes nothing. + + Args: + status: Either a ready made ``Status``, or a ``StatusCode`` to be + paired with ``description``. + description: Message to attach. Used only when ``status`` is a + ``StatusCode``; passing it alongside a ``Status`` logs a + warning and drops the message. + """ if isinstance(status, Status): if description is not None: logger.warning( @@ -1037,6 +1890,13 @@ def _accepts_status(self, new_status: Status) -> bool: differing code always outranks the one recorded. A repeat of the same code gets through only when it fills in a description that is still missing, and an empty description counts as missing. + + Args: + new_status: The status ``set_status`` was asked to record. + + Returns: + ``True`` when the recorded status should be replaced by + ``new_status``. """ if new_status.status_code is StatusCode.UNSET: return False @@ -1058,7 +1918,32 @@ def __exit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: - """Ends context manager and calls `end` on the `Span`.""" + """Ends context manager and calls `end` on the `Span`. + + This runs when the span itself is used as a context manager, as in + ``with tracer.start_span("name") as span:``. It is not what + ``start_as_current_span`` goes through: that one uses + ``opentelemetry.trace.use_span``, which does its own recording and + then ends the span directly. + + When the block is left by raising and the span is still recording, the + exception is recorded as an event with ``exception.escaped`` set to + ``True``, unless the span was built with ``record_exception`` false, + and the status is set to ``ERROR`` with the type and message of the + exception as its description, unless the span was built with + ``set_status_on_exception`` false. Any ``BaseException`` counts here, + including ``KeyboardInterrupt`` and ``GeneratorExit``, which is wider + than what ``use_span`` records. The span is ended either way and the + exception is left to propagate. + + Args: + exc_type: Type of the exception leaving the block, or ``None``. + Unused. + exc_val: The exception leaving the block, or ``None`` when the + block finished normally. This is the value the method keys off. + exc_tb: Traceback of the exception, or ``None``. Unused: the + traceback is read off the exception itself. + """ if exc_val is not None and self.is_recording(): # Record the exception as an event # pylint:disable=protected-access @@ -1083,7 +1968,28 @@ def record_exception( timestamp: int | None = None, escaped: bool = False, ) -> None: - """Records an exception as a span event.""" + """Records an exception as a span event. + + The event is named ``exception`` and carries the type, the message and + the formatted traceback of the exception. The type is qualified with + its module unless the module is ``builtins``. The status of the span is + left alone, so recording an exception does not by itself mark the span + as failed. The event goes through ``add_event``, so the event limit + applies to it and a span that has ended records nothing and logs a + warning. + + Args: + exception: The exception to record. The traceback is read off the + exception, and one that was never raised has none, which + yields a one-line trace holding only the type and the message. + attributes: Extra attributes for the event, or ``None``. A key that + collides with one of the four exception attributes replaces it. + timestamp: Time of the event in nanoseconds since the Unix epoch. + ``None`` reads the clock now. + escaped: Recorded as the ``exception.escaped`` attribute, as the + string ``"True"`` or ``"False"``. ``True`` says the exception + ended the span by escaping its scope. + """ stacktrace = "".join(traceback.format_exception(exception)) module = type(exception).__module__ qualname = type(exception).__qualname__ @@ -1109,15 +2015,57 @@ class _Span(Span): @dataclass class _TracerConfig: + """Settings a ``TracerProvider`` applies to each ``Tracer`` it hands out. + + A provider computes one of these per instrumentation scope when it creates + a tracer, and replaces the one a tracer holds whenever its configurator + changes. + + Args: + is_enabled: Whether the tracer records anything. A disabled tracer + returns a non-recording span from every ``start_span`` call and + never consults its sampler. + """ + is_enabled: bool @classmethod def default(cls): + """Build the configuration a tracer gets when no rule matches it. + + Returns: + A configuration that leaves the tracer enabled. + """ return cls(is_enabled=True) class Tracer(trace_api.Tracer): - """See `opentelemetry.trace.Tracer`.""" + """See `opentelemetry.trace.Tracer`. + + Obtain one from ``TracerProvider.get_tracer`` rather than building it + directly. A tracer holds everything ``start_span`` needs: the sampler that + decides whether a span records, the processors that are told when it starts + and ends, and the limits applied while it records. + + Args: + sampler: Decides, for each new span, whether it records and whether it + is sampled. It may also add attributes and replace the trace state. + resource: Resource attached to every span this tracer creates. + span_processor: Told when a span of this tracer starts and ends. This + is the multi processor held by the provider, so processors + registered after the tracer was created are picked up as well. + id_generator: Produces the trace ids and span ids of new spans. + instrumentation_info: Deprecated description of the instrumenting + library, recorded on spans for backwards compatibility. + span_limits: Caps applied to the attributes, events and links of every + span this tracer creates. + instrumentation_scope: Scope recorded on every span this tracer + creates. + meter_provider: Provider for the internal SDK metrics. ``None`` reads + the global meter provider as the tracer is built. + _tracer_config: Configuration to start with. ``None`` leaves the tracer + enabled. The provider replaces it when its configurator changes. + """ def __init__( self, @@ -1132,6 +2080,13 @@ def __init__( meter_provider: metrics_api.MeterProvider | None = None, _tracer_config: _TracerConfig | None = None, ) -> None: + """Build the tracer and its internal metric instruments. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. Whether the internal instruments + record anything is read once here, from + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED``. + """ self.sampler = sampler self.resource = resource self.span_processor = span_processor @@ -1148,13 +2103,36 @@ def __init__( ) def _set_tracer_config(self, tracer_config: _TracerConfig): + """Replace the configuration this tracer consults. + + Called by ``TracerProvider._set_tracer_configurator`` for every tracer + it has already handed out, which is how disabling reaches tracers that + callers are still holding. Spans already started are unaffected. + + Args: + tracer_config: The configuration to consult from now on. + """ self._tracer_config = tracer_config def _set_resource(self, resource: Resource) -> None: + """Replace the resource attached to spans created from now on. + + Called by ``TracerProvider._update_resource``, which is how the new + process attributes reach later spans after a fork. Spans that already + exist keep the resource they were given. + + Args: + resource: The resource to attach from now on. + """ self.resource = resource def _is_enabled(self) -> bool: - """If the tracer is not enabled, start_span will create a NonRecordingSpan""" + """If the tracer is not enabled, start_span will create a NonRecordingSpan + + Returns: + ``True`` while the configuration the provider last applied leaves + this tracer enabled. + """ return self._tracer_config.is_enabled @_agnosticcontextmanager # pylint: disable=protected-access @@ -1170,6 +2148,42 @@ def start_as_current_span( set_status_on_exception: bool = True, end_on_exit: bool = True, ) -> Iterator[trace_api.Span]: + """Start a span and make it current for the duration of the block. + + The span is started before the block runs and, with ``end_on_exit`` + left true, ended when the block is left, however it is left. An + exception escaping the block is recorded on the span and sets its + status to ``ERROR``, both subject to the flags below, and is then + re-raised. Only exceptions deriving from ``Exception`` are treated that + way: a ``BaseException`` such as ``GeneratorExit`` passes through + without being recorded. + + The returned object also works as a function decorator. An async + function decorated with it is awaited inside the span, so the span + covers the whole coroutine and not only its first suspension. + + Args: + name: Name of the span. + context: Context to take the parent span from. ``None`` uses the + current context. + kind: Relationship between this span and its parent. + attributes: Attributes to record on the span at creation. They are + also handed to the sampler. + links: Links to record on the span. ``None`` is treated as empty. + start_time: Start of the span in nanoseconds since the Unix epoch. + ``None`` reads the clock now. + record_exception: Whether an exception escaping the block is + recorded on the span as an event. + set_status_on_exception: Whether an exception escaping the block + sets the status of the span to ``ERROR``. A status recorded by + hand beforehand survives, unless it was an ``ERROR`` with no + description, which the one built here fills in. + end_on_exit: Whether leaving the block ends the span. Pass + ``False`` to end it yourself later. + + Yields: + The span, already started and already current. + """ span = self.start_span( name=name, context=context, @@ -1199,6 +2213,46 @@ def start_span( # pylint: disable=too-many-locals record_exception: bool = True, set_status_on_exception: bool = True, ) -> trace_api.Span: + """Create a span and start it, leaving the current context untouched. + + The parent is the span context of the current span of ``context``. The + sampler is then asked about the new span, with that parent, the trace + id, the name, the kind, the attributes and the links. A decision that + does not record produces a non-recording span, which carries the span + context onwards for propagation and records nothing. A decision that + records produces a span that is started here, so its ``on_start`` + callbacks have already run by the time this returns. + + A disabled tracer skips all of that and returns a non-recording span + wrapping the parent span context, which may be ``None``. + + The new span takes the trace id of a valid parent and a fresh one + otherwise. Attributes the sampler added and the trace state it returned + both end up on the new span. + + Args: + name: Name of the span. + context: Context to take the parent span from. ``None`` uses the + current context. + kind: Relationship between this span and its parent. + attributes: Attributes to record on the span at creation. They are + handed to the sampler, and what reaches the span is what the + sampler returned, which may differ. + links: Links to record on the span. ``None`` is treated as empty. + start_time: Start of the span in nanoseconds since the Unix epoch. + ``None`` reads the clock now. + record_exception: Whether an exception escaping a ``with`` block + over the returned span is recorded on it as an event. + set_status_on_exception: Whether an exception escaping a ``with`` + block over the returned span sets its status to ``ERROR``. + + Returns: + The new span, already started. Ending it is left to the caller. + + Raises: + TypeError: If the current span of ``context`` returns something + that is neither a span context nor ``None``. + """ links = links or () parent_span_context = trace_api.get_current_span(context).get_span_context() @@ -1284,7 +2338,16 @@ def _default_tracer_configurator( In order to update Tracers configs you need to call TracerProvider._set_tracer_configurator with a function - implementing this interface returning a Tracer Config.""" + implementing this interface returning a Tracer Config. + + Args: + tracer_scope: Scope of the tracer being configured. It is matched + against an empty list of rules here, so it has no effect on the + result. + + Returns: + The default configuration, which leaves the tracer enabled. + """ return _RuleBasedTracerConfigurator( rules=[], default_config=_TracerConfig.default(), @@ -1294,6 +2357,20 @@ def _default_tracer_configurator( def _disable_tracer_configurator( tracer_scope: InstrumentationScope, ) -> _TracerConfig: + """Configure every tracer as disabled, whatever its scope. + + Handing this to ``TracerProvider._set_tracer_configurator`` switches + tracing off for the tracers already handed out as well as for the ones + created later. + + Args: + tracer_scope: Scope of the tracer being configured. It is matched + against an empty list of rules here, so it has no effect on the + result. + + Returns: + A configuration whose ``is_enabled`` is ``False``. + """ return _RuleBasedTracerConfigurator( rules=[], default_config=_TracerConfig(is_enabled=False), @@ -1301,7 +2378,40 @@ def _disable_tracer_configurator( class TracerProvider(trace_api.TracerProvider): - """See `opentelemetry.trace.TracerProvider`.""" + """See `opentelemetry.trace.TracerProvider`. + + One provider owns the sampler, the resource, the id generator, the span + limits and the registered span processors, and hands all of them to every + tracer it creates. Tracers are cached per instrumentation scope, so asking + twice for the same name, version, schema URL and attributes gives back the + same tracer. + + Setting ``OTEL_SDK_DISABLED`` to ``true`` makes ``get_tracer`` return the + no-op tracer of the API, and then none of what the provider holds is ever + used. The variable is read once, as the provider is built. + + Args: + sampler: Decides which spans record and which are sampled. ``None`` + reads the sampler out of the environment. + resource: Resource attached to every span. ``None`` builds one from + the configured resource detectors. + shutdown_on_exit: Whether to register ``shutdown`` with ``atexit``, so + that the processors get a chance to flush as the interpreter exits. + active_span_processor: The multi processor that registered processors + are added to. ``None`` builds a ``SynchronousMultiSpanProcessor``. + Pass a ``ConcurrentMultiSpanProcessor`` to have the processors run + in parallel instead. + id_generator: Produces trace ids and span ids. ``None`` builds a + ``RandomIdGenerator``. + span_limits: Caps applied while a span records. ``None`` builds limits + from the environment and the defaults. + meter_provider: Provider for the internal SDK metrics, passed to every + tracer. ``None`` makes each tracer read the global meter provider + as it is created. + _tracer_configurator: Maps an instrumentation scope to the + configuration its tracer starts with. ``None`` leaves every tracer + enabled. + """ def __init__( self, @@ -1315,6 +2425,16 @@ def __init__( meter_provider: metrics_api.MeterProvider | None = None, _tracer_configurator: _TracerConfiguratorT | None = None, ) -> None: + """Build the provider and register its fork and exit hooks. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. The hook registered with + ``os.register_at_fork`` holds only a weak reference to this provider, + so the hook does not keep this provider alive. ``os.register_at_fork`` + has no matching unregister call, so the hook stays registered for the + life of the process and does nothing once the provider has been + garbage collected. + """ self._active_span_processor = active_span_processor or SynchronousMultiSpanProcessor() if id_generator is None: self.id_generator = RandomIdGenerator() @@ -1343,12 +2463,26 @@ def __init__( weak_at_fork = weakref.WeakMethod(self._handle_fork) def _after_in_child() -> None: + """Run the provider's fork handler in the freshly forked child. + + ``os.register_at_fork`` keeps a strong reference to whatever is handed + to it, so this closure holds the provider only weakly. A provider that + has been garbage collected leaves the weak reference empty and the + handler does nothing. + """ if at_fork := weak_at_fork(): at_fork() os.register_at_fork(after_in_child=_after_in_child) def _handle_fork(self) -> None: + """Repair the provider in a child process after a fork. + + The tracers lock is replaced, since a lock inherited while another + thread held it would never be released in the child. The attributes + that depend on the process, its id among them, are then merged into the + resource of the provider and pushed to the tracers already created. + """ self._tracers_lock = threading.Lock() self._update_resource(_get_process_dependent_resource()) @@ -1357,6 +2491,16 @@ def _set_tracer_configurator(self, *, tracer_configurator: _TracerConfiguratorT) Setting a new TracerConfigurator for a TracerProvider will update the TracerConfig of all Tracers create by this TracerProvider. + + A configurator that raises while being applied to one scope is logged + and that tracer keeps the default configuration, which leaves it + enabled. + + Args: + tracer_configurator: Maps an instrumentation scope to the + configuration its tracer should use. It is called once for + every tracer that already exists, and once for each tracer + created afterwards. """ self._tracer_configurator = tracer_configurator with self._tracers_lock: @@ -1367,15 +2511,44 @@ def _set_tracer_configurator(self, *, tracer_configurator: _TracerConfiguratorT) @property def resource(self) -> Resource: + """Resource attached to the spans of every tracer from this provider. + + Returns: + The resource. It is replaced by a merged one after a fork, so a + caller holding the result from before the fork holds a resource + that is now out of date. + """ return self._resource def _update_resource(self, resource: Resource) -> None: + """Merge ``resource`` into the provider resource and tell the tracers. + + The attributes of ``resource`` win where the two disagree. Tracers + created later pick the merged resource up on their own, and spans that + already exist keep the resource object they were given. + + Args: + resource: Resource whose attributes take precedence over the ones + already held. + """ with self._tracers_lock: self._resource = self._resource.merge(resource) for tracer in self._tracers.values(): tracer._set_resource(self._resource) # pylint: disable=protected-access def _apply_tracer_configurator(self, instrumentation_scope: InstrumentationScope): + """Ask the configurator for the configuration of one scope. + + A configurator that raises is logged with its traceback and does not + stop the tracer from being created or reconfigured. + + Args: + instrumentation_scope: Scope of the tracer being configured. + + Returns: + What the configurator returned, or the default configuration when + it raised. + """ try: return self._tracer_configurator(instrumentation_scope) except Exception: # pylint: disable=broad-exception-caught @@ -1392,6 +2565,28 @@ def get_tracer( schema_url: str | None = None, attributes: types.Attributes = None, ) -> "trace_api.Tracer": + """Return the tracer for an instrumentation scope, building it once. + + Tracers are cached on the four values that make up the scope, so + repeated calls with the same arguments give back the same object and + the configurator runs only the first time. A module name that is empty + or missing is logged as an error and replaced with the empty string, + which means every caller making that mistake shares one tracer. + + Args: + instrumenting_module_name: Name of the instrumenting library, for + example ``opentelemetry.instrumentation.requests``. + instrumenting_library_version: Version of the instrumenting + library. ``None`` is stored as the empty string. + schema_url: URL of the semantic conventions schema the scope emits + against, or ``None``. + attributes: Attributes of the scope, recorded next to its name and + version. They are part of the cache key. + + Returns: + A tracer for that scope, or the no-op tracer of the API when + ``OTEL_SDK_DISABLED`` was ``true`` as this provider was built. + """ if self._disabled: return NoOpTracer() if not instrumenting_module_name: # Reject empty strings too. @@ -1443,7 +2638,15 @@ def get_tracer( def add_span_processor(self, span_processor: SpanProcessor) -> None: """Registers a new :class:`SpanProcessor` for this `TracerProvider`. - The span processors are invoked in the same order they are registered. + The span processors are invoked in the same order they are registered, + unless the provider was built with a ``ConcurrentMultiSpanProcessor``, + which runs them in parallel. A processor registered after a tracer was + created still sees the spans of that tracer, since the tracer holds the + multi processor rather than a list of its own. + + Args: + span_processor: The processor to register. Registering the same + one twice makes it run twice for every span. """ # no lock here because add_span_processor is thread safe for both @@ -1451,7 +2654,15 @@ def add_span_processor(self, span_processor: SpanProcessor) -> None: self._active_span_processor.add_span_processor(span_processor) def shutdown(self) -> None: - """Shut down the span processors added to the tracer provider.""" + """Shut down the span processors added to the tracer provider. + + There is no timeout to hand out here: each processor applies whatever + bound it has of its own. The ``atexit`` hook is unregistered + afterwards, so calling this twice reaches the processors twice and only + the first call unregisters. Spans started after this are still created + and still ended; the processors are simply no longer in a position to + export them. + """ self._active_span_processor.shutdown() if self._atexit_handler is not None: atexit.unregister(self._atexit_handler) @@ -1468,6 +2679,11 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: initialize the tracer provider with an instance of `ConcurrentMultiSpanProcessor` at the cost of using multiple threads. + A ``False`` result says that at least one processor did not finish + within the budget, or that the budget ran out before a later processor + was reached at all. It does not say which spans were exported and which + were not, and the processors that were skipped are not flushed. + Args: timeout_millis: The maximum amount of time to wait for spans to be processed. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py index e838448490a..7f9f223178c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py @@ -1,6 +1,31 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Experimental consistent probability samplers. + +This subpackage is private and experimental. The leading underscore in its +name is the warning: names exported here may change or disappear in any +release, including a patch release, and nothing in it is covered by the +compatibility guarantees that apply to the rest of +``opentelemetry.sdk.trace``. + +It implements the sampling scheme described by +https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/ +in which a sampling probability is encoded as a 56-bit threshold carried in +the ``th`` field of the ``ot`` trace state entry, and the decision is made by +comparing that threshold against a 56-bit randomness value, taken from the +``rv`` field of the same entry or from the low 56 bits of the trace id. Every +service in a trace that runs the same scheme therefore reaches the same +decision, and a backend can recover an unbiased span count from the threshold +each sampled span carries. + +Samplers here implement ``ComposableSampler``, which reports a threshold +rather than a decision. That makes them nestable: ``composable_rule_based`` +chooses among them by predicate and ``composable_parent_threshold`` falls back +to one for root spans. Wrap the outermost one in ``composite_sampler`` to get +an ordinary SDK ``Sampler`` that a tracer provider accepts. +""" + __all__ = [ "ComposableSampler", "SamplingIntent", diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py index 7c5c4e10d0b..e5c846805b3 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py @@ -1,6 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The composable sampler that drops every span. + +It reports no threshold at all, ``INVALID_THRESHOLD``, rather than the +``MAX_THRESHOLD`` that encodes a probability of zero. The composite sampler +then skips the randomness comparison altogether instead of performing one +whose outcome is already known. + +Both the intent and the sampler are built once at import time and shared. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Sequence @@ -16,6 +29,12 @@ class _ComposableAlwaysOffSampler(ComposableSampler): + """Composable sampler that reports no threshold for any span. + + It takes no construction arguments and keeps no state. Use the + ``_always_off`` instance in this module rather than building another. + """ + def sampling_intent( self, parent_ctx: Context | None, @@ -25,9 +44,33 @@ def sampling_intent( links: Sequence[Link] | None, trace_state: TraceState | None = None, ) -> SamplingIntent: + """Return the fixed intent, whatever the span looks like. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: Ignored. + links: Ignored. + trace_state: Ignored. The parent's ``th`` is not read, so a + sampled parent does not keep the span alive. + + Returns: + The shared intent holding ``INVALID_THRESHOLD`` and an unreliable + threshold, which makes the composite sampler drop the span and + leave ``th`` out of the ``ot`` entry it writes. A parent entry + that held nothing but ``th`` is passed on unchanged instead, + because the composite sampler only writes an entry that + serializes to a non-empty string. + """ return _intent def get_description(self) -> str: + """Describe the sampler. + + Returns: + The constant ``"ComposableAlwaysOff"``. + """ return "ComposableAlwaysOff" @@ -40,5 +83,8 @@ def composable_always_off() -> ComposableSampler: - Always returns a SamplingIntent with no threshold, indicating all spans should be dropped - Sets threshold_reliable to false - Does not add any attributes + + Returns: + A shared, stateless sampler. Every call hands back the same instance. """ return _always_off diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py index fd2dbdb4b7c..e509e84f313 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py @@ -1,6 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The composable sampler that samples every span. + +Both the intent and the sampler are built once at import time and shared, so +calling the factory repeatedly costs nothing. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Sequence @@ -16,6 +25,12 @@ class _ComposableAlwaysOnSampler(ComposableSampler): + """Composable sampler whose threshold admits every randomness value. + + It takes no construction arguments and keeps no state. Use the + ``_always_on`` instance in this module rather than building another. + """ + def sampling_intent( self, parent_ctx: Context | None, @@ -25,9 +40,30 @@ def sampling_intent( links: Sequence[Link] | None, trace_state: TraceState | None = None, ) -> SamplingIntent: + """Return the fixed intent, whatever the span looks like. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: Ignored. + links: Ignored. + trace_state: Ignored. In particular the parent's ``th`` is not + read, so this sampler overrides an upstream decision to drop. + + Returns: + The shared intent holding ``MIN_THRESHOLD`` and a reliable + threshold, which makes the composite sampler sample the span and + write ``th:0`` into the outgoing ``ot`` entry. + """ return _intent def get_description(self) -> str: + """Describe the sampler. + + Returns: + The constant ``"ComposableAlwaysOn"``. + """ return "ComposableAlwaysOn" @@ -40,5 +76,8 @@ def composable_always_on() -> ComposableSampler: - Always returns a SamplingIntent with threshold set to sample all spans (threshold = 0) - Sets threshold_reliable to true - Does not add any attributes + + Returns: + A shared, stateless sampler. Every call hands back the same instance. """ return _always_on diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py index 6bedc8d9afa..f07801a843c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py @@ -1,6 +1,21 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The interface every composable sampler in this subpackage implements. + +A composable sampler answers one question about a span that is about to be +created: which sampling threshold applies to it. It never decides whether the +span is sampled. That comparison belongs to the composite sampler built by +``composite_sampler``, which reads the randomness value, compares it against +the threshold and produces the ``SamplingResult``. Splitting the work this way +is what lets samplers be nested, as ``composable_rule_based`` and +``composable_parent_threshold`` do, without any of them having to agree on how +randomness is obtained. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Callable, Sequence @@ -14,23 +29,63 @@ @dataclass(frozen=True) class SamplingIntent: - """Information to make a consistent sampling decision.""" + """Information to make a consistent sampling decision. + + The instance is frozen. Several samplers in this subpackage build one + intent at import or construction time and hand back the same object for + every span, while ``_ComposableParentThreshold`` builds a fresh one per + span. Treat an intent handed back by ``sampling_intent`` as shared either + way, and never hold a reference expecting it to track later spans. + + Each field is documented on the field itself below. + """ threshold: int - """The sampling threshold value. A lower threshold increases the likelihood of sampling.""" + """The sampling threshold value. A lower threshold increases the likelihood of sampling. + + ``MIN_THRESHOLD`` samples every span. Any value that + ``is_valid_threshold`` rejects, ``INVALID_THRESHOLD`` included, makes the + composite sampler drop the span without comparing anything. + """ threshold_reliable: bool = field(default=True) - """Indicates whether the threshold is reliable for Span-to-Metrics estimation.""" + """Indicates whether the threshold is reliable for Span-to-Metrics estimation. + + The decision is taken from the threshold either way. What changes is the + outgoing trace state: when this is ``False`` the composite sampler leaves + ``th`` out of the ``ot`` entry it writes, so no downstream consumer + derives an adjusted count from a threshold that would not support one. + The entry is written only when it serializes to a non-empty string, so a + parent entry holding nothing but ``th`` is passed on unchanged. + """ attributes: Attributes = field(default=None) - """Any attributes to be added to a sampled span.""" + """Any attributes to be added to a sampled span. + + ``None`` adds nothing. The composite sampler copies the value into the + ``SamplingResult`` without inspecting it. + """ update_trace_state: Callable[[TraceState], TraceState] = field(default=lambda ts: ts) - """Any updates to be made to trace state.""" + """Any updates to be made to trace state. + + The default returns the trace state it is given, unchanged. The callable + receives the trace state inherited from the parent and returns the + replacement; the composite sampler then writes the ``ot`` entry on top of + whatever comes back. It is called only when the parent trace state is + present and non-empty, so a root span starting with no trace state never + reaches it. + """ class ComposableSampler(Protocol): - """A sampler that can be composed to make a final sampling decision.""" + """A sampler that can be composed to make a final sampling decision. + + Implementations are expected to be stateless and safe to call from several + threads, since one sampler instance serves every span of a tracer + provider. Wrap an implementation in ``composite_sampler`` to obtain an + ordinary ``Sampler`` that a tracer provider accepts. + """ def sampling_intent( self, @@ -41,9 +96,38 @@ def sampling_intent( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> SamplingIntent: - """Returns information to make a sampling decision.""" + """Returns information to make a sampling decision. + + The call happens before the span exists, so the arguments are the ones + that would be passed to the span constructor. An implementation is not + told the trace id and cannot compute the outcome itself. + + Args: + parent_ctx: Context holding the parent span, or ``None`` to mean + the current context. A parent span context that is not valid + marks the span as a root. + name: The name the span will be created with. + span_kind: The kind the span will be created with, or ``None`` + when the caller supplied none. + attributes: The attributes known at creation time, or ``None``. + Attributes set after the span starts are not visible here. + links: The links the span will be created with, or ``None``. + trace_state: The trace state inherited from the parent, or + ``None``. This is where the ``ot`` entry is read from. + + Returns: + The intent carrying the threshold, whether that threshold is + reliable, attributes to add, and a trace state update. + """ ... # pylint: disable=unnecessary-ellipsis def get_description(self) -> str: - """Returns a description of the sampler.""" + """Returns a description of the sampler. + + Returns: + A string naming the sampler and the parameters it was built with. + ``composite_sampler`` reports this verbatim as its own + description, and a sampler that wraps others is expected to embed + their descriptions in its own. + """ ... # pylint: disable=unnecessary-ellipsis diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py index 88af9a5f677..f7ac6b0a733 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py @@ -1,6 +1,18 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The composable sampler that inherits the parent span's threshold. + +Once a root span has been sampled at some probability, every descendant reuses +the same threshold, and since every span of a trace is compared against the +same randomness value, they all reach the same decision. A span with no parent +has nothing to inherit, so it is handed to a separate root sampler chosen at +construction time. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Sequence @@ -19,7 +31,24 @@ class _ComposableParentThreshold(ComposableSampler): + """Composable sampler that reuses the threshold found on the parent. + + The description string is built once in the constructor, which means the + root sampler's description is captured at that moment and a root sampler + that later changes how it describes itself will not be reflected. + + Args: + root_sampler: Sampler consulted for a span whose parent span context + is not valid, which is what makes the span a root. Its + description is embedded in this sampler's description. + """ + def __init__(self, root_sampler: ComposableSampler): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._root_sampler = root_sampler self._description = f"ComposableParentThreshold{{root={root_sampler.get_description()}}}" @@ -32,6 +61,37 @@ def sampling_intent( links: Sequence[Link] | None, trace_state: TraceState | None = None, ) -> SamplingIntent: + """Derive the intent from the parent span, or from the root sampler. + + Three cases, in the order they are tried. A span whose parent span + context is not valid is a root, and every argument is forwarded to the + root sampler. Otherwise, if the parent's ``ot`` entry carries a usable + ``th``, that threshold is reused and marked reliable. Otherwise only + the sampled flag of the parent's trace flags is left to go on, which + gives ``MIN_THRESHOLD`` when it is set and ``INVALID_THRESHOLD`` when + it is not, marked unreliable either way because a flag says nothing + about the probability behind it. + + Args: + parent_ctx: Context holding the parent span, or ``None`` to mean + the current context. + name: The name the span will be created with. Used only when + forwarding to the root sampler. + span_kind: The kind the span will be created with, or ``None``. + Used only when forwarding to the root sampler. + attributes: The attributes known at creation time, or ``None``. + Used only when forwarding to the root sampler. + links: The links the span will be created with, or ``None``. Used + only when forwarding to the root sampler. + trace_state: The trace state inherited from the parent, or + ``None``. The ``ot`` entry is read from here. ``None`` on a + non-root span falls through to the trace flags. + + Returns: + The root sampler's intent for a root span, otherwise an intent + built here. Attributes are never added and the trace state is + never updated by this sampler. + """ parent_span = get_current_span(parent_ctx) parent_span_ctx = parent_span.get_span_context() is_root = not parent_span_ctx.is_valid @@ -50,6 +110,13 @@ def sampling_intent( return SamplingIntent(threshold=threshold, threshold_reliable=False) def get_description(self) -> str: + """Describe the sampler. + + Returns: + The string built in the constructor, of the form + ``ComposableParentThreshold{root=...}`` with the root sampler's + description inside the braces. + """ return self._description @@ -68,5 +135,9 @@ def composable_parent_threshold( Args: root_sampler: The root sampler to use for spans without a parent context. + + Returns: + A new sampler wrapping ``root_sampler``. Each call builds a fresh + instance, since the description depends on the argument. """ return _ComposableParentThreshold(root_sampler) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py index 7b55d03a2df..ffe37050e38 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py @@ -1,6 +1,22 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Routing a span to one of several composable samplers by predicate. + +A rule is a pair of a predicate and a composable sampler. The rules are tried +in order and the first predicate that returns ``True`` wins, so ordering +carries meaning and a broad rule placed early hides the narrower rules after +it. A span that matches no rule is dropped. + +The predicates in this module look at one aspect of the span each: an +attribute value, the span kind, or the relationship to the parent. Combine +them with ``AllPredicate``, and end the rule list with ``AlwaysMatchPredicate`` +when a catch-all is wanted instead of the default drop. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations import logging @@ -22,6 +38,13 @@ class PredicateT(Protocol): + """The test a rule applies to decide whether its sampler is used. + + Implementations take the same arguments as ``sampling_intent`` and answer + with a plain boolean. They are called once per span, in rule order, until + one matches, so keep them cheap and free of side effects. + """ + def __call__( self, parent_ctx: Context | None, @@ -30,15 +53,60 @@ def __call__( attributes: Attributes, links: Sequence[Link] | None, trace_state: TraceState | None, - ) -> bool: ... + ) -> bool: + """Report whether this predicate matches the span about to be created. + + ``_ComposableRuleBased`` passes all six arguments by keyword, so an + implementation must keep these parameter names. + + Args: + parent_ctx: Context holding the parent span, or ``None`` to mean + the current context. + name: The name the span will be created with. + span_kind: The kind the span will be created with, or ``None``. + attributes: The attributes known at creation time, or ``None``. + links: The links the span will be created with, or ``None``. + trace_state: The trace state inherited from the parent, or + ``None``. + + Returns: + ``True`` when the rule holding this predicate should be used. + """ + ... # pylint: disable=unnecessary-ellipsis + + def __str__(self) -> str: + """Render the predicate for the rule-based sampler's description. - def __str__(self) -> str: ... + Returns: + A short form of the test, with no surrounding punctuation. The + rule-based sampler embeds it in its own description. + """ + ... # pylint: disable=unnecessary-ellipsis class AttributePredicate: - """An exact match of an attribute value""" + """An exact match of an attribute value + + Superseded by ``AttributeValuesPredicate``, which accepts several values + and looks inside sequence-valued attributes. Constructing this class logs + a deprecation warning on the root logger, once per construction. + + The comparison is ``==`` on the raw attribute value, with no string + conversion, so an integer attribute does not match a string value. Both + ``key`` and ``value`` are public and read on every call, so changing them + after construction changes what the predicate matches. + + Args: + key: The attribute name to look up. + value: The value the attribute has to equal. + """ def __init__(self, key: str, value: AnyValue): + """Build the instance and log the deprecation warning. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ logging.warning("This is deprecated, use AttributeValuesPredicate instead") self.key = key self.value = value @@ -52,15 +120,45 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Report whether the attribute equals the configured value. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: The attributes known at creation time. ``None`` or an + empty mapping never matches, even when the configured value + is itself ``None``. + links: Ignored. + trace_state: Ignored. + + Returns: + ``True`` when the attribute compares equal. A missing key reads as + ``None``, so a predicate built with a value of ``None`` matches + any span that carries at least one attribute. + """ if not attributes: return False return attributes.get(self.key) == self.value def __str__(self): + """Render the predicate as ``key=value``. + + Returns: + The key and the value joined by ``=``, with the value formatted + by ``str``. + """ return f"{self.key}={self.value}" class AlwaysMatchPredicate: + """A predicate that matches every span. + + Put a rule holding this predicate last in the rule list to give the + rule-based sampler a fallback. Without one, a span matching no rule is + dropped. + """ + def __call__( self, parent_ctx: Context | None, @@ -70,14 +168,52 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Match unconditionally. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: Ignored. + links: Ignored. + trace_state: Ignored. + + Returns: + Always ``True``. + """ return True def __str__(self) -> str: + """Render the predicate. + + Returns: + The constant ``"AlwaysMatch"``. + """ return "AlwaysMatch" class AllPredicate: + """The conjunction of several predicates. + + The sequence is copied into a tuple at construction, so changing the + sequence afterwards has no effect. Evaluation short circuits on the first + predicate that fails, which means ordering within the sequence is a + performance choice and not a semantic one. + + An empty sequence matches everything, following the usual reading of a + conjunction over nothing. + + Args: + predicates: The predicates that all have to match. Each receives the + same six arguments this predicate was called with. + """ + def __init__(self, predicates: Sequence[PredicateT]): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._predicates = tuple(predicates) def __call__( @@ -89,6 +225,19 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Report whether every configured predicate matches. + + Args: + parent_ctx: Forwarded to each predicate. + name: Forwarded to each predicate. + span_kind: Forwarded to each predicate. + attributes: Forwarded to each predicate. + links: Forwarded to each predicate. + trace_state: Forwarded to each predicate. + + Returns: + ``True`` when all of them match, and ``True`` when there are none. + """ return all( predicate( parent_ctx, @@ -102,11 +251,40 @@ def __call__( ) def __str__(self) -> str: + """Render the conjunction. + + Returns: + The rendering of each predicate joined by `` && ``, in the order + they were given. An empty sequence renders as the empty string. + """ return " && ".join(str(predicate) for predicate in self._predicates) class AttributeValuesPredicate: + """A match of an attribute against a set of allowed string values. + + Comparison runs on ``str`` of the attribute value, so an attribute holding + the integer ``8080`` matches the value ``"8080"``. The allowed values are + taken as given and are not converted, so pass them as strings. + + An attribute holding a sequence matches when any of its elements matches. + A string, ``bytes`` or ``bytearray`` attribute counts as a single value + rather than a sequence of characters. + + Args: + key: The attribute name to look up. A span without this key never + matches. + values: The allowed values, copied into a frozen set, so order and + duplicates do not matter and later edits to the sequence have no + effect. + """ + def __init__(self, key: str, values: Sequence[str]): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._key = key self._values = frozenset(values) @@ -119,11 +297,35 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Report whether the attribute holds one of the allowed values. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: The attributes known at creation time. ``None``, an + empty mapping, or a mapping without the configured key all + fail to match. + links: Ignored. + trace_state: Ignored. + + Returns: + ``True`` when the attribute, or any element of it when it is a + sequence, stringifies to one of the allowed values. An empty set + of allowed values never matches. + """ if not attributes or self._key not in attributes: return False return any(str(value) in self._values for value in _attribute_values(attributes[self._key])) def __str__(self) -> str: + """Render the predicate as ``key in [a,b,c]``. + + Returns: + The key, the word ``in``, and the allowed values sorted and + comma separated inside square brackets. Sorting keeps the + rendering stable across runs, which a frozen set would not. + """ values = ",".join(sorted(self._values)) return f"{self._key} in [{values}]" @@ -132,6 +334,28 @@ class AttributePatternsPredicate: """Match attribute values using a callable receiving a value and a pattern. By default, patterns use case-sensitive ``fnmatchcase`` semantics. + + A value is accepted when it matches at least one included pattern and no + excluded pattern, with exclusion winning over inclusion. Leaving + ``included`` empty means every value is included, which turns the + predicate into a pure exclusion list. Leaving both empty makes it match + any span that carries the key, unless the attribute holds an empty + sequence, which matches nothing because there is no element to accept. + + An attribute holding a sequence matches when any of its elements is + accepted. Values are stringified before matching. + + Args: + key: The attribute name to look up. A span without this key never + matches. + included: Patterns at least one of which has to match, or ``None`` + for no inclusion requirement. Copied into a tuple. + excluded: Patterns none of which may match, or ``None`` for no + exclusions. Copied into a tuple. + matcher: Keyword-only callable taking the value first and the pattern + second, returning whether they match. The default is + ``fnmatchcase``, so matching is case sensitive. Pass something + else to use regular expressions or prefix matching instead. """ def __init__( @@ -142,6 +366,11 @@ def __init__( *, matcher: Callable[[str, str], bool] = fnmatchcase, ): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._matcher = matcher self._key = key self._included = tuple(included or ()) @@ -156,21 +385,73 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Report whether the attribute holds an accepted value. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: The attributes known at creation time. ``None``, an + empty mapping, or a mapping without the configured key all + fail to match. + links: Ignored. + trace_state: Ignored. + + Returns: + ``True`` when the attribute, or any element of it when it is a + sequence, passes the included and excluded pattern lists. + """ if not attributes or self._key not in attributes: return False return any(self._matches_value(str(value)) for value in _attribute_values(attributes[self._key])) def _matches_value(self, value: str) -> bool: + """Apply the included and excluded pattern lists to one value. + + Args: + value: The attribute value, already converted with ``str``. + + Returns: + ``True`` when the value matches an included pattern, or there are + no included patterns, and matches no excluded pattern. Exclusion + takes precedence, so a value matching both lists is rejected. + """ included = not self._included or any(self._matcher(value, pattern) for pattern in self._included) excluded = any(self._matcher(value, pattern) for pattern in self._excluded) return included and not excluded def __str__(self) -> str: + """Render the predicate as ``key matches``. + + The patterns themselves do not appear, so two predicates on the same + key with different patterns render identically. + + Returns: + The key followed by the word ``matches``. + """ return f"{self._key} matches" class SpanKindPredicate: + """A match on the kind of the span. + + This is the filter that lets a rule apply to, say, server and consumer + spans while leaving internal spans to a later rule. The comparison is set + membership on the ``SpanKind`` enum, with no widening, so a span created + without a kind never matches: ``None`` is outside the declared type of + ``span_kinds``. + + Args: + span_kinds: The kinds that match, copied into a frozen set. An empty + sequence matches nothing. + """ + def __init__(self, span_kinds: Sequence[SpanKind]): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._span_kinds = frozenset(span_kinds) def __call__( @@ -182,15 +463,57 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Report whether the span kind is one of the configured kinds. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: The kind the span will be created with, or ``None`` + when the caller supplied none, in which case nothing matches. + attributes: Ignored. + links: Ignored. + trace_state: Ignored. + + Returns: + ``True`` when the kind is in the configured set. + """ return span_kind in self._span_kinds def __str__(self) -> str: + """Render the predicate as ``span_kind in [server,client]``. + + Returns: + The kind names lowercased and comma separated inside square + brackets. The order follows the frozen set, so it is not + guaranteed to match the order the kinds were given in. + + Raises: + AttributeError: If ``None`` was included in the configured kinds, + since ``None`` has no ``name``. + """ kinds = ",".join(kind.name.lower() for kind in self._span_kinds) return f"span_kind in [{kinds}]" class ParentPredicate: + """A match on how the span relates to its parent. + + The parent is classified into exactly one of three names: ``"none"`` when + the parent span context is not valid, which is a root span, ``"remote"`` + when the parent arrived over the wire, and ``"local"`` otherwise. + + Args: + parents: The names that match, copied into a frozen set. Only + ``"none"``, ``"remote"`` and ``"local"`` can ever match; any other + string is accepted without complaint and simply never matches. + """ + def __init__(self, parents: Sequence[str]): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._parents = frozenset(parents) def __call__( @@ -202,6 +525,22 @@ def __call__( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> bool: + """Report whether the parent falls into one of the configured classes. + + Args: + parent_ctx: Context holding the parent span. ``None`` means the + current context, so this is not the same as having no parent: + an ambient active span is still found and classified. + name: Ignored. + span_kind: Ignored. + attributes: Ignored. + links: Ignored. + trace_state: Ignored. + + Returns: + ``True`` when the class of the parent, one of ``"none"``, + ``"remote"`` or ``"local"``, is in the configured set. + """ parent_span_context = get_current_span(parent_ctx).get_span_context() if not parent_span_context.is_valid: parent = "none" @@ -212,11 +551,32 @@ def __call__( return parent in self._parents def __str__(self) -> str: + """Render the predicate as ``parent in [remote,local]``. + + Returns: + The configured names comma separated inside square brackets. The + order follows the frozen set, so it varies and does not reflect + the order the names were given in. + """ parents = ",".join(self._parents) return f"parent in [{parents}]" def _attribute_values(value): + """Normalise one attribute value into an iterable of values to test. + + An attribute may hold a scalar or a sequence, and the predicates in this + module treat both the same way by iterating. Text and byte values are + sequences in Python but are meant as single values here, so they are + wrapped rather than iterated character by character. + + Args: + value: The raw attribute value. + + Returns: + The value itself when it is a sequence that is not ``str``, ``bytes`` + or ``bytearray``, and a one-element tuple holding it otherwise. + """ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return value return (value,) @@ -228,7 +588,25 @@ def _attribute_values(value): class _ComposableRuleBased(ComposableSampler): + """Composable sampler that picks a delegate by the first matching rule. + + Rules are tried in the order given. The first predicate that returns + ``True`` decides which sampler is asked for the intent, and no later rule + is consulted. A span matching no rule gets ``_non_sampling_intent``, which + drops it. + + Args: + rules: Pairs of predicate and composable sampler. The sequence is + copied at construction, so adding or removing rules afterwards + has no effect on an already built sampler. + """ + def __init__(self, rules: RulesT): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ # work on an internal copy of the rules self._rules = list(rules) @@ -241,6 +619,27 @@ def sampling_intent( links: Sequence[Link] | None, trace_state: TraceState | None = None, ) -> SamplingIntent: + """Ask the first matching rule's sampler for the intent. + + Predicates and the chosen sampler are both called with these six + arguments passed by keyword, so a custom predicate or sampler has to + keep the parameter names. + + Args: + parent_ctx: Forwarded to each predicate and to the chosen sampler. + name: Forwarded to each predicate and to the chosen sampler. + span_kind: Forwarded to each predicate and to the chosen sampler. + attributes: Forwarded to each predicate and to the chosen sampler. + links: Forwarded to each predicate and to the chosen sampler. + trace_state: Forwarded to each predicate and to the chosen + sampler. Defaults to ``None``, which the protocol does not. + + Returns: + The intent from the sampler of the first rule whose predicate + matched. When no rule matches, a shared intent with + ``INVALID_THRESHOLD`` and an unreliable threshold, which drops the + span, so an empty rule list drops everything. + """ for predicate, sampler in self._rules: if predicate( parent_ctx=parent_ctx, @@ -261,6 +660,16 @@ def sampling_intent( return _non_sampling_intent def get_description(self) -> str: + """Describe the sampler and every rule it holds. + + The descriptions of the delegate samplers are read on each call, so a + delegate whose description depends on mutable state is re-read here. + + Returns: + A string of the form ``ComposableRuleBased{[(pred:desc),...]}``, + with one parenthesised pair per rule in rule order, the predicate + rendered by ``str`` and the sampler by its own description. + """ rules_str = ",".join(f"({predicate}:{sampler.get_description()})" for predicate, sampler in self._rules) return f"ComposableRuleBased{{[{rules_str}]}}" @@ -274,6 +683,12 @@ def composable_rule_based( - If no rules match, returns a non-sampling intent Args: - rules: A list of (Predicate, ComposableSampler) pairs, where Predicate is a function that evaluates whether a rule applies + rules: A list of (Predicate, ComposableSampler) pairs, where Predicate + is a function that evaluates whether a rule applies + + Returns: + A new sampler holding a copy of ``rules``. Each call builds a fresh + instance. Order the rules from most specific to least specific, since + the first match wins and the remaining rules are not consulted. """ return _ComposableRuleBased(rules) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py index 7d74a3fb77f..7b06edffdac 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py @@ -1,6 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The bridge from a composable sampler to the SDK ``Sampler`` interface. + +A composable sampler produces a threshold. This module turns that threshold +into a decision by comparing it against a 56-bit randomness value, and then +writes the result back into the ``ot`` trace state entry so that children of +the span inherit it. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Sequence @@ -16,7 +27,22 @@ class _CompositeSampler(Sampler): + """A ``Sampler`` that takes its threshold from a composable sampler. + + The instance holds no per-span state, so one is enough for a whole tracer + provider. + + Args: + delegate: The composable sampler asked for a threshold on every span. + Its description is also reported as this sampler's description. + """ + def __init__(self, delegate: ComposableSampler): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._delegate = delegate def should_sample( @@ -29,6 +55,46 @@ def should_sample( links: Sequence[Link] | None = None, trace_state: TraceState | None = None, ) -> SamplingResult: + """Decide whether a span about to be created is sampled. + + The threshold comes from the delegate. The randomness it is compared + against is the ``rv`` field of the incoming ``ot`` trace state entry + when that field is valid, and otherwise the low 56 bits of + ``trace_id``. The trace flags are not consulted, so the low bits of + the trace id are used whether or not the parent advertised the trace + id as random. The span is sampled when the threshold is less than or + equal to the randomness, which is why a lower threshold samples more. + + An invalid threshold short circuits the comparison and drops the span. + + The outgoing ``ot`` entry repeats the threshold only when the span was + sampled and the delegate marked the threshold reliable. Otherwise + ``th`` is left out of the entry the composite sampler writes. That + entry is written only when it serializes to a non-empty string, so a + parent ``ot`` entry that held nothing but ``th`` is passed on to the + child unchanged and the child does inherit that threshold. + + Args: + parent_context: Context holding the parent span, or ``None`` to + mean the current context. Passed to the delegate unchanged. + trace_id: The trace id of the span being created. Only its low 56 + bits are read, and only when the ``ot`` entry carries no + usable ``rv``. + name: The name the span will be created with. + kind: The kind the span will be created with, or ``None``. + attributes: The attributes known at creation time, or ``None``. + The result carries the delegate's attributes, not these. + links: The links the span will be created with, or ``None``. + trace_state: The trace state inherited from the parent, or + ``None``. + + Returns: + A result whose decision is ``Decision.RECORD_AND_SAMPLE`` or + ``Decision.DROP``, whose attributes are the ones the delegate + asked for, and whose trace state carries the updated ``ot`` entry. + The trace state is ``None`` when there was none to start with and + nothing needed writing. + """ ot_trace_state = OtelTraceState.parse(trace_state) intent = self._delegate.sampling_intent(parent_context, name, kind, attributes, links, trace_state) @@ -59,6 +125,13 @@ def should_sample( ) def get_description(self) -> str: + """Describe the sampler. + + Returns: + The delegate's own description, unwrapped. Nothing identifies the + composite sampler itself, so two tracer providers wrapping the + same composable sampler report the same string. + """ return self._delegate.get_description() @@ -67,6 +140,23 @@ def _update_trace_state( ot_trace_state: OtelTraceState, intent: SamplingIntent, ) -> TraceState | None: + """Build the trace state the new span will carry. + + The ``ot`` entry is written last, so it overrides any ``ot`` the intent's + own update produced. The intent's update is skipped entirely when there is + no incoming trace state. + + Args: + trace_state: The trace state inherited from the parent, or ``None``. + ot_trace_state: The parsed ``ot`` entry, already carrying the + threshold the decision was made with. + intent: The intent returned by the composable sampler, consulted only + for its ``update_trace_state`` callable. + + Returns: + A new trace state, or ``None`` when there was no incoming trace state + and the ``ot`` entry serialized to the empty string. + """ otts = ot_trace_state.serialize() if not trace_state: if otts: @@ -84,5 +174,9 @@ def composite_sampler(delegate: ComposableSampler) -> Sampler: Args: delegate: The composable sampler to use for making sampling decisions. + + Returns: + A ``Sampler`` a tracer provider accepts. It holds no state of its own, + so one instance serves every span. """ return _CompositeSampler(delegate) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py index 8133454e4f5..242c12cd5d0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py @@ -1,6 +1,28 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Reading and writing the ``ot`` entry of the W3C trace state. + +The entry value is a list of ``key:value`` members separated by ``;``. Two +keys are understood here: ``th`` holds the sampling threshold and ``rv`` holds +the 56-bit randomness value. Every other member is carried through untouched +so that a vendor sharing the entry does not lose its own data. + +``th`` is hexadecimal, lowercase, at most 14 digits, with trailing zeros +removed, so ``th:8`` and ``th:80000000000000`` denote the same threshold of +one half. ``rv`` is hexadecimal and always exactly 14 digits. + +Nothing in this module raises on malformed input. A member that cannot be read +leaves its field set to ``INVALID_THRESHOLD`` or ``INVALID_RANDOM_VALUE`` and +is then simply absent from anything written back out. + +The encoding is defined by +https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/ + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Sequence @@ -27,6 +49,19 @@ class OtelTraceState: """Marshals OpenTelemetry tracestate for sampling parameters. https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/ + + The dataclass is mutable on purpose: the composite sampler parses an + incoming entry, overwrites ``threshold`` with the one it decided on, and + serializes the same instance back out. + + Args: + random_value: The 56-bit value read from the ``rv`` member, or + ``INVALID_RANDOM_VALUE`` when the member was absent or unreadable. + threshold: The threshold read from the ``th`` member with its trailing + zeros restored, or ``INVALID_THRESHOLD`` when the member was + absent or unreadable. + rest: The members that were neither ``th`` nor ``rv``, in the order + they appeared, each still in its original ``key:value`` form. """ random_value: int @@ -35,10 +70,37 @@ class OtelTraceState: @staticmethod def invalid() -> OtelTraceState: + """Build the state that carries no usable sampling information. + + Returns: + An instance whose two fields hold their invalid sentinels and + whose ``rest`` is empty, which ``serialize`` turns into the empty + string. + """ return OtelTraceState(INVALID_RANDOM_VALUE, INVALID_THRESHOLD, ()) @staticmethod def parse(trace_state: TraceState | None) -> OtelTraceState: + """Read the ``ot`` entry out of a trace state. + + Members are split on ``;``. A duplicate ``th:`` or ``rv:`` member + overwrites the earlier one, so the last occurrence wins, and a later + unreadable duplicate discards a readable earlier value. A member whose + value does not parse leaves its field at the invalid sentinel; no + exception is raised and the remaining members are still read. + + The entry as a whole is rejected when it is empty or longer than 256 + characters, which takes its readable members down with it. + + Args: + trace_state: The trace state inherited from the parent, or + ``None``. ``None``, an empty trace state and a trace state + with no ``ot`` entry are all treated the same way. + + Returns: + The parsed state, or the result of ``invalid`` when there was + nothing usable to read. + """ if not trace_state: return OtelTraceState.invalid() @@ -67,6 +129,24 @@ def parse(trace_state: TraceState | None) -> OtelTraceState: return OtelTraceState(random_value=random_value, threshold=threshold, rest=rest or ()) def serialize(self) -> str: + """Render the state back into an ``ot`` entry value. + + ``th`` is emitted only when the threshold is valid and is not + ``MAX_THRESHOLD``, the threshold that samples nothing. ``th`` always + comes first and ``rv`` second. ``rv`` is emitted whenever the + randomness value is valid, so a value that arrived in the parent entry + is passed on and one that was never there is not invented. The members + in ``rest`` follow, in the order they were parsed. + + When the result exceeds 256 characters, trailing members are dropped + one at a time until it fits. Once no ``;`` is left, the remaining + single member is returned over the limit rather than dropped. + + Returns: + The entry value, or the empty string when neither field is valid + and ``rest`` is empty. The caller is expected to omit the ``ot`` + entry entirely in that case. + """ if not is_valid_threshold(self.threshold) and not is_valid_random_value(self.random_value) and not self.rest: return "" @@ -87,6 +167,26 @@ def serialize(self) -> str: def _parse_th(value: str, default: int) -> int: + """Decode the value of a ``th`` member into a threshold. + + The encoding drops trailing zeros, so the digits are left-aligned within + 14 hexadecimal digits and the missing ones are restored by shifting four + bits per absent digit. Because 14 digits is 56 bits, the result satisfies + ``is_valid_threshold`` for any text made of hexadecimal digits alone. + ``int`` also accepts a leading sign and an underscore separator, and + either of those can produce a value that is neither valid nor equal to + the default, so test a parsed threshold with ``is_valid_threshold`` + rather than by comparing it against ``INVALID_THRESHOLD``. + + Args: + value: The text after ``th:``, without the key or the colon. + default: Returned when the text is empty, is longer than 14 + characters, or is not hexadecimal. Callers pass + ``INVALID_THRESHOLD``. + + Returns: + The decoded threshold, or ``default`` when the text is unusable. + """ if not value or len(value) > _MAX_VALUE_LENGTH: return default @@ -102,6 +202,21 @@ def _parse_th(value: str, default: int) -> int: def _parse_rv(value: str, default: int) -> int: + """Decode the value of an ``rv`` member into a randomness value. + + Unlike ``th``, this encoding has no trailing-zero compression, so the + length check is exact rather than an upper bound: 13 digits are rejected + instead of being padded. + + Args: + value: The text after ``rv:``, without the key or the colon. + default: Returned when the text is not exactly 14 characters or is + not hexadecimal. Callers pass ``INVALID_RANDOM_VALUE``. + + Returns: + The decoded randomness value, or ``default`` when the text is + unusable. + """ if not value or len(value) != _MAX_VALUE_LENGTH: return default @@ -112,10 +227,39 @@ def _parse_rv(value: str, default: int) -> int: def serialize_th(threshold: int) -> str: + """Encode a threshold as the value of a ``th`` member. + + The threshold is written as 14 lowercase hexadecimal digits and the + trailing zeros are then stripped, which is the compression the ``ot`` + entry defines. ``MIN_THRESHOLD`` would strip to nothing, so it is special + cased to ``"0"``. + + ``ComposableTraceIDRatioBased`` also calls this to build its description + string, which is why the function is not underscore prefixed. + + Args: + threshold: A threshold below ``MAX_THRESHOLD``. ``MAX_THRESHOLD`` + itself needs 15 digits and is never passed here, because neither + caller emits a ``th`` for it. + + Returns: + The compressed hexadecimal text, without the ``th:`` key. + """ if not threshold: return "0" return f"{threshold:014x}".rstrip("0") def _serialize_rv(random_value: int) -> str: + """Encode a randomness value as the value of an ``rv`` member. + + No compression applies, so the result is always 14 lowercase hexadecimal + digits, zero padded on the left. + + Args: + random_value: A value that ``is_valid_random_value`` accepts. + + Returns: + The hexadecimal text, without the ``rv:`` key. + """ return f"{random_value:014x}" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py index 2a6488f9cbf..453b1e23368 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py @@ -1,6 +1,18 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The composable sampler that samples a fixed fraction of spans. + +The fraction is turned into a threshold once, at construction, so sampling a +span costs one attribute lookup. Which spans end up sampled is decided by the +composite sampler comparing that threshold against the span's randomness +value, which is why two services configured with the same ratio sample the +same traces rather than independently chosen ones. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + from __future__ import annotations from collections.abc import Sequence @@ -15,10 +27,35 @@ class ComposableTraceIDRatioBased(ComposableSampler): + """Composable sampler reporting one threshold derived from a ratio. + + A ratio that rounds to ``MAX_THRESHOLD``, which includes ``0.0``, cannot + sample anything. The constructor stores ``INVALID_THRESHOLD`` and marks it + unreliable in that case, so the sampler behaves exactly like + ``composable_always_off`` while still describing itself with the ratio it + was given. + + The constructor does not validate ``ratio``. Building this class directly + with a ratio outside ``0.0`` to ``1.0`` can produce a threshold that + ``is_valid_threshold`` rejects, in which case the composite sampler drops + every span and reports nothing. Go through + ``composable_traceid_ratio_based`` to get the range check. + + Args: + ratio: Fraction of spans to sample, from ``0.0`` to ``1.0``. It is + read once, to compute the threshold and to fill in the + description, and is not retained as an attribute. + """ + _threshold: int _description: str def __init__(self, ratio: float): + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ threshold = calculate_threshold(ratio) if threshold == MAX_THRESHOLD: threshold_str = "max" @@ -40,9 +77,34 @@ def sampling_intent( links: Sequence[Link] | None, trace_state: TraceState | None, ) -> SamplingIntent: + """Return the intent computed at construction, for any span. + + Args: + parent_ctx: Ignored. + name: Ignored. + span_kind: Ignored. + attributes: Ignored. + links: Ignored. + trace_state: Ignored, so the parent's ``th`` has no effect here. + Combine this sampler with ``composable_parent_threshold`` when + the ratio should apply to root spans only. + + Returns: + The same intent object on every call, holding the threshold for + the configured ratio, or an invalid threshold when the ratio + samples nothing. + """ return self._intent def get_description(self) -> str: + """Describe the sampler. + + Returns: + The string built in the constructor, of the form + ``ComposableTraceIDRatioBased{threshold=..., ratio=...}``. The + threshold appears as compressed hexadecimal, or as ``max`` when + the ratio samples nothing. + """ return self._description @@ -60,6 +122,14 @@ def composable_traceid_ratio_based( Args: ratio: The sampling ratio to use (between 0.0 and 1.0). + + Returns: + A new sampler holding the threshold for ``ratio``. Each call builds a + fresh instance. + + Raises: + ValueError: If ``ratio`` is outside the closed interval from ``0.0`` + to ``1.0``, or is a NaN, which fails both comparisons. """ if not 0.0 <= ratio <= 1.0: raise ValueError("Sampling ratio must be between 0.0 and 1.0") diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py index dc8c10ae40b..2851d9e8854 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py @@ -1,6 +1,23 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Threshold and randomness arithmetic for probability sampling. + +A sampling probability is encoded as a 56-bit rejection threshold. A span is +sampled when its 56-bit randomness value is greater than or equal to the +threshold, so ``MIN_THRESHOLD`` (zero) samples everything, and +``MAX_THRESHOLD`` (two raised to the power of 56) samples nothing because no +randomness value can reach it. ``MAX_RANDOM_VALUE`` is one less than +``MAX_THRESHOLD``. + +``INVALID_THRESHOLD`` and ``INVALID_RANDOM_VALUE`` are both ``-1``. They mark +the absence of a value rather than an encodable one, and the two validity +checks below reject them. + +Private to the ``_sampling_experimental`` subpackage, with no compatibility +guarantee. +""" + RANDOM_VALUE_BITS = 56 MAX_THRESHOLD = 1 << RANDOM_VALUE_BITS # 0% sampling MIN_THRESHOLD = 0 # 100% sampling @@ -12,12 +29,56 @@ def calculate_threshold(sampling_probability: float) -> int: + """Convert a sampling probability into a 56-bit rejection threshold. + + The probability is scaled by two raised to the power of 56, rounded to the + nearest integer and subtracted from ``MAX_THRESHOLD``. Probability ``1.0`` + therefore maps to ``MIN_THRESHOLD`` and probability ``0.0`` maps to + ``MAX_THRESHOLD``. Rounding is what makes a probability too small to move + the scaled value away from zero collapse onto ``MAX_THRESHOLD``, which + samples nothing. + + The argument is not range checked here. A probability outside ``0.0`` to + ``1.0`` yields a threshold that ``is_valid_threshold`` rejects. + + Args: + sampling_probability: Fraction of spans to sample, expected in the + closed interval from ``0.0`` to ``1.0``. + + Returns: + The threshold that encodes that probability. + """ return MAX_THRESHOLD - round(sampling_probability * _probability_threshold_scale) def is_valid_threshold(threshold: int) -> bool: + """Report whether an integer can be used as a sampling threshold. + + The range is inclusive at both ends, so ``MAX_THRESHOLD`` counts as valid + even though a span carrying it is never sampled. ``INVALID_THRESHOLD`` and + anything else below ``MIN_THRESHOLD`` is rejected. + + Args: + threshold: The integer to check. + + Returns: + ``True`` when the integer lies between ``MIN_THRESHOLD`` and + ``MAX_THRESHOLD`` inclusive. + """ return MIN_THRESHOLD <= threshold <= MAX_THRESHOLD def is_valid_random_value(random_value: int) -> bool: + """Report whether an integer can be used as a 56-bit randomness value. + + The upper bound is ``MAX_RANDOM_VALUE``, one less than ``MAX_THRESHOLD``. + ``INVALID_RANDOM_VALUE`` is rejected, which is how an ``ot`` trace state + entry with no usable ``rv`` field is detected. + + Args: + random_value: The integer to check. + + Returns: + ``True`` when the integer is non-negative and fits in 56 bits. + """ return 0 <= random_value <= MAX_RANDOM_VALUE diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_tracer_metrics.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_tracer_metrics.py index 480d7b59678..994762c70a2 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_tracer_metrics.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_tracer_metrics.py @@ -1,6 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Self-observability metrics the SDK records about its own span creation. + +Two instruments are recorded here, both created from the semantic conventions +and both reported by a meter named ``opentelemetry-sdk``. ``otel.sdk.span.started`` +counts every span the SDK creates. ``otel.sdk.span.live`` tracks how many +recording spans have been created and not yet ended. + +``Tracer`` builds one of these objects through ``create_tracer_metrics`` and +calls ``start_span`` on it for each span. Whether real instruments or no-op +ones are used is decided by the ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED`` +environment variable, which the caller parses and passes in as ``enabled``. +""" + from __future__ import annotations from collections.abc import Callable @@ -21,25 +34,74 @@ class TracerMetricsT(Protocol): + """Interface ``Tracer`` uses to report span creation to itself. + + Both ``TracerMetrics`` and ``NoOpTracerMetrics`` satisfy this protocol, so + the caller treats an enabled and a disabled build the same way. + """ + def start_span( self, parent_span_context: SpanContext | None, sampling_decision: Decision, - ) -> Callable[[], None]: ... + ) -> Callable[[], None]: + """Record that a span has been created. + + Args: + parent_span_context: Span context of the parent, or ``None`` for a + root span. + sampling_decision: Decision the sampler returned for this span. + + Returns: + A callable the caller must invoke when the span ends. + """ + ... # pylint: disable=unnecessary-ellipsis # pylint: disable=no-self-use class NoOpTracerMetrics: + """Implementation that records nothing. + + Used when self-observability metrics are turned off, so that ``Tracer`` + pays only an attribute lookup and a call per span. + """ + def start_span( self, parent_span_context: SpanContext | None, sampling_decision: Decision, ) -> Callable[[], None]: + """Ignore the span. + + Args: + parent_span_context: Ignored. + sampling_decision: Ignored. + + Returns: + ``noop``, which does nothing when the span ends. + """ return noop class TracerMetrics: + """Implementation that records ``otel.sdk.span.started`` and ``otel.sdk.span.live``. + + One instance is built per ``Tracer``, and each one asks a meter named + ``opentelemetry-sdk`` for the two instruments. The meter creates them on + the first request and returns the same pair to every later one, so all + the tracers reaching one meter provider record into a single pair of + instruments. + + Args: + meter_provider: Provider the two instruments are created from. + """ + def __init__(self, meter_provider: metrics_api.MeterProvider) -> None: + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ meter = meter_provider.get_meter("opentelemetry-sdk") self._started_spans = create_otel_sdk_span_started(meter) @@ -50,6 +112,26 @@ def start_span( parent_span_context: SpanContext | None, sampling_decision: Decision, ) -> Callable[[], None]: + """Add one to ``otel.sdk.span.started`` and, when recording, to ``otel.sdk.span.live``. + + ``otel.sdk.span.started`` is incremented for every span, including + dropped ones, and carries ``otel.span.parent.origin`` and + ``otel.span.sampling_result``. ``otel.sdk.span.live`` is only touched + when the decision is recording, and carries + ``otel.span.sampling_result`` alone. + + Args: + parent_span_context: Span context of the parent, or ``None``. + It decides the value of ``otel.span.parent.origin``. + sampling_decision: Decision the sampler returned. It decides the + value of ``otel.span.sampling_result`` and whether the live + count is touched at all. + + Returns: + A callable that subtracts one from ``otel.sdk.span.live`` when the + span ends, or ``noop`` when the decision is not recording and + nothing was added to that instrument. + """ sampling_result_value = sampling_result(sampling_decision) self._started_spans.add( 1, @@ -68,6 +150,12 @@ def start_span( self._live_spans.add(1, live_span_attrs) def end_span() -> None: + """Subtract one from ``otel.sdk.span.live``. + + The same attributes used when the span was counted as live are + reused, so the addition and the subtraction land on one time + series. + """ self._live_spans.add(-1, live_span_attrs) return end_span @@ -77,6 +165,18 @@ def create_tracer_metrics( meter_provider: metrics_api.MeterProvider, enabled: bool, ) -> TracerMetricsT: + """Build the metrics recorder a ``Tracer`` should use. + + Args: + meter_provider: Provider the instruments are created from. Only read + when ``enabled`` is ``True``. + enabled: Whether self-observability metrics are turned on, as parsed + from ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED``. + + Returns: + A ``TracerMetrics`` when ``enabled`` is ``True``, otherwise a + ``NoOpTracerMetrics`` that creates no instruments. + """ if not enabled: return NoOpTracerMetrics() @@ -84,10 +184,25 @@ def create_tracer_metrics( def noop() -> None: - pass + """Do nothing. + + Handed out by ``NoOpTracerMetrics.start_span``, and by + ``TracerMetrics.start_span`` for a span that is not recording, so that the + caller always has a callable to invoke when the span ends. + """ def parent_origin(span_ctx: SpanContext | None) -> str: + """Classify the parent of a span for the ``otel.span.parent.origin`` attribute. + + Args: + span_ctx: Span context of the parent, or ``None`` for a root span. + + Returns: + ``"none"`` when ``span_ctx`` is ``None``, ``"remote"`` when the parent + was extracted from an incoming request, and ``"local"`` when the + parent was created in this process. + """ if span_ctx is None: return "none" if span_ctx.is_remote: @@ -96,6 +211,16 @@ def parent_origin(span_ctx: SpanContext | None) -> str: def sampling_result(decision: Decision) -> str: + """Map a sampling decision to the ``otel.span.sampling_result`` attribute value. + + Args: + decision: Decision the sampler returned for the span. + + Returns: + The semantic convention string for the decision. Anything other than + ``Decision.RECORD_AND_SAMPLE`` and ``Decision.RECORD_ONLY`` maps to + the value for a dropped span. + """ if decision == Decision.RECORD_AND_SAMPLE: return OtelSpanSamplingResultValues.RECORD_AND_SAMPLE.value if decision == Decision.RECORD_ONLY: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py index 8aad6bbfee3..eafb0fd4f2b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py @@ -1,5 +1,29 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Span processors and span exporters. + +A ``SpanExporter`` turns ended spans into whatever shape a backend accepts and +sends them. A ``SpanProcessor`` decides when an exporter is called. Two +processors are provided here. + +``SimpleSpanProcessor`` exports each sampled span on the thread that ended it, +one span per export call. Nothing is buffered, so no span is lost on a crash, +and the ending thread pays the cost of the export. + +``BatchSpanProcessor`` puts sampled spans on a bounded queue and lets a +background thread export them in batches. The queue holds 2048 spans, the +thread wakes every 5000 milliseconds, and at most 512 spans go out per export +call. Each of those is overridable through a constructor argument or through +``OTEL_BSP_MAX_QUEUE_SIZE``, ``OTEL_BSP_SCHEDULE_DELAY`` and +``OTEL_BSP_MAX_EXPORT_BATCH_SIZE``. An emit onto a queue that is already full +drops the oldest span in it and logs a warning. + +``ConsoleSpanExporter`` writes spans to a stream as JSON, and is useful while +developing. ``InMemorySpanExporter``, in the sibling module +``opentelemetry.sdk.trace.export.in_memory_span_exporter``, keeps them in a +deque for tests. +""" + from __future__ import annotations import collections.abc @@ -48,6 +72,13 @@ class SpanExportResult(Enum): + """Outcome of one call to ``SpanExporter.export``. + + ``SUCCESS`` means the batch was handed over. ``FAILURE`` means it was not, + and since neither processor in this module retries, the spans in that + batch are gone. + """ + SUCCESS = 0 FAILURE = 1 @@ -82,6 +113,16 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: # pyright: ignore[r """Hint to ensure that the export of any spans the exporter has received prior to the call to ForceFlush SHOULD be completed as soon as possible, preferably before returning from this method. + + The base implementation does nothing and returns ``None``. An exporter + that holds spans of its own should override it. + + Args: + timeout_millis: Upper bound on the flush, in milliseconds. + + Returns: + ``True`` when everything held was flushed within the timeout, + ``False`` otherwise. """ @@ -90,6 +131,17 @@ class SimpleSpanProcessor(SpanProcessor): SimpleSpanProcessor is an implementation of `SpanProcessor` that passes ended spans directly to the configured `SpanExporter`. + + The export happens on the thread that ended the span and blocks it until + the exporter returns, one span per export call. Spans whose sampled flag + is not set are discarded without reaching the exporter. + + Args: + span_exporter: Exporter each sampled span is handed to. + meter_provider: Provider used for the self-observability metrics of + this processor. ``None`` means the globally configured provider. + Whether those metrics are recorded at all is decided by + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED``. """ def __init__( @@ -98,6 +150,11 @@ def __init__( *, meter_provider: MeterProvider | None = None, ): + """Build the processor. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self.span_exporter = span_exporter self._shutdown = False self._metrics = create_processor_metrics( @@ -108,12 +165,42 @@ def __init__( ) def on_start(self, span: Span, parent_context: Context | None = None) -> None: - pass + """Ignore the start of a span. + + This processor acts only when a span ends, so nothing happens here. + + Args: + span: The span that just started. Ignored. + parent_context: Context the span was started in, or ``None`` for + the current context. Ignored. + """ def _on_ending(self, span: Span) -> None: - pass + """Ignore the moment just before a span ends. + + This hook exists so a processor can still mutate a span while it is + writable. This processor does not, so nothing happens here. + + Args: + span: The span about to end. Ignored. + """ def on_end(self, span: ReadableSpan) -> None: + """Export the span immediately, on the calling thread. + + A span whose sampled flag is not set returns without reaching the + exporter. After ``shutdown``, the span is counted as dropped with + error type ``already_shutdown`` and a warning is logged. An exception + raised by the exporter is caught and logged, so it never reaches the + code that ended the span. + + Instrumentation is suppressed for the duration of the export, which + keeps an exporter that is itself instrumented from producing spans + about its own export. + + Args: + span: The span that just ended. + """ if not (span.context and span.context.trace_flags.sampled): return if self._shutdown: @@ -132,10 +219,24 @@ def on_end(self, span: ReadableSpan) -> None: detach(token) def shutdown(self) -> None: + """Stop accepting spans and shut the exporter down. + + Nothing is queued here, so there is nothing left to flush. Every span + that ends afterwards is dropped with a warning. + """ self._shutdown = True self.span_exporter.shutdown() def force_flush(self, timeout_millis: int = 30000) -> bool: + """Do nothing, since every span was already exported as it ended. + + Args: + timeout_millis: Ignored. Accepted so that this processor matches + the ``SpanProcessor`` interface. + + Returns: + ``True`` always, including after ``shutdown``. + """ # pylint: disable=unused-argument return True @@ -155,6 +256,38 @@ class BatchSpanProcessor(SpanProcessor): - :envvar:`OTEL_BSP_EXPORT_TIMEOUT` All the logic for emitting spans, shutting down etc. resides in the `BatchProcessor` class. + + Ended spans whose sampled flag is set are put on a bounded queue, and a + daemon thread drains it. The thread exports as soon as the queue holds at + least ``max_export_batch_size`` spans, and otherwise once every + ``schedule_delay_millis``. A ``schedule_delay_millis`` of zero turns that + into an export on every emit. + + When the queue is full, the span that has waited longest is discarded to + make room for the new one, a warning is logged, and the discarded span is + counted with error type ``queue_full``. + + Args: + span_exporter: Exporter each batch is handed to. + max_queue_size: Number of spans the queue holds. ``None`` reads + ``OTEL_BSP_MAX_QUEUE_SIZE``, falling back to 2048 when that + variable is unset or does not parse as an integer. Must be + positive. + schedule_delay_millis: Time the worker thread waits between exports, + in milliseconds. ``None`` reads ``OTEL_BSP_SCHEDULE_DELAY``, + falling back to 5000. Must not be negative. + max_export_batch_size: Largest number of spans passed to the exporter + in one call. ``None`` reads ``OTEL_BSP_MAX_EXPORT_BATCH_SIZE``, + falling back to 512. Must be positive and no larger than + ``max_queue_size``. + export_timeout_millis: Budget for one export call, in milliseconds. + ``None`` reads ``OTEL_BSP_EXPORT_TIMEOUT``, falling back to 30000. + It is validated and stored, and the export path has no way to + apply it, so it currently bounds nothing. + meter_provider: Provider used for the self-observability metrics of + this processor. ``None`` means the globally configured provider. + Whether those metrics are recorded at all is decided by + ``OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED``. """ def __init__( @@ -167,6 +300,17 @@ def __init__( *, meter_provider: MeterProvider | None = None, ): + """Build the processor and start its worker thread. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``max_queue_size`` or ``max_export_batch_size`` is + not positive, if ``schedule_delay_millis`` is negative, if + ``max_export_batch_size`` exceeds ``max_queue_size``, or if + ``export_timeout_millis`` is not positive. + """ if max_queue_size is None: max_queue_size = BatchSpanProcessor._default_max_queue_size() @@ -206,27 +350,90 @@ def __init__( # Added for backward compatibility. Not recommended to directly access/use underlying exporter. @property def span_exporter(self): + """The exporter passed to the constructor. + + Kept for callers written against an earlier version that stored the + exporter as an attribute. Reaching through it to call ``export`` or + ``shutdown`` directly bypasses the queue and the worker thread. + + Returns: + The exporter held by the underlying batch processor. + """ return self._batch_processor._exporter # pylint: disable=protected-access def on_start(self, span: Span, parent_context: Context | None = None) -> None: - pass + """Ignore the start of a span. + + This processor acts only when a span ends, so nothing happens here. + + Args: + span: The span that just started. Ignored. + parent_context: Context the span was started in, or ``None`` for + the current context. Ignored. + """ def _on_ending(self, span: Span) -> None: - pass + """Ignore the moment just before a span ends. + + This hook exists so a processor can still mutate a span while it is + writable. This processor does not, so nothing happens here. + + Args: + span: The span about to end. Ignored. + """ def on_end(self, span: ReadableSpan) -> None: + """Put the span on the queue for the worker thread. + + A span whose sampled flag is not set returns without being queued. + After ``shutdown``, the span is discarded and counted with error type + ``already_shutdown``. On a full queue, the oldest queued span is + discarded instead. The call does not wait for an export. + + Args: + span: The span that just ended. + """ if not (span.context and span.context.trace_flags.sampled): return self._batch_processor.emit(span) def shutdown(self): + """Stop accepting spans, drain the queue and shut the exporter down. + + The worker thread is woken and exports what is still queued, then is + joined with a 30 second budget. Whatever remains queued when that + budget runs out is never exported. The exporter is shut down last, + with the part of the budget the join left over when it accepts a + ``timeout_millis`` argument. A second call returns at once. + """ return self._batch_processor.shutdown() def force_flush(self, timeout_millis: int | None = None) -> bool: + """Export everything queued, blocking until the queue is empty. + + The export runs on the calling thread, in batches of at most + ``max_export_batch_size``, and shares a lock with the worker thread. + + Args: + timeout_millis: Accepted and currently ignored, so the call + blocks for as long as draining the queue takes. ``None`` + requests no particular bound. + + Returns: + ``False`` if the processor has been shut down, in which case + nothing was exported, ``True`` otherwise. + """ return self._batch_processor.force_flush(timeout_millis) @staticmethod def _default_max_queue_size(): + """Read the queue capacity from the environment. + + Returns: + ``OTEL_BSP_MAX_QUEUE_SIZE`` as an integer, or 2048 when the + variable is unset or does not parse, in which case the parse + failure is logged with its traceback. + """ try: return int(environ.get(OTEL_BSP_MAX_QUEUE_SIZE, _DEFAULT_MAX_QUEUE_SIZE)) except ValueError: @@ -239,6 +446,13 @@ def _default_max_queue_size(): @staticmethod def _default_schedule_delay_millis(): + """Read the delay between scheduled exports from the environment. + + Returns: + ``OTEL_BSP_SCHEDULE_DELAY`` as an integer number of milliseconds, + or 5000 when the variable is unset or does not parse, in which + case the parse failure is logged with its traceback. + """ try: return int(environ.get(OTEL_BSP_SCHEDULE_DELAY, _DEFAULT_SCHEDULE_DELAY_MILLIS)) except ValueError: @@ -251,6 +465,13 @@ def _default_schedule_delay_millis(): @staticmethod def _default_max_export_batch_size(): + """Read the largest export batch size from the environment. + + Returns: + ``OTEL_BSP_MAX_EXPORT_BATCH_SIZE`` as an integer, or 512 when the + variable is unset or does not parse, in which case the parse + failure is logged with its traceback. + """ try: return int( environ.get( @@ -268,6 +489,13 @@ def _default_max_export_batch_size(): @staticmethod def _default_export_timeout_millis(): + """Read the export timeout from the environment. + + Returns: + ``OTEL_BSP_EXPORT_TIMEOUT`` as an integer number of milliseconds, + or 30000 when the variable is unset or does not parse, in which + case the parse failure is logged with its traceback. + """ try: return int(environ.get(OTEL_BSP_EXPORT_TIMEOUT, _DEFAULT_EXPORT_TIMEOUT_MILLIS)) except ValueError: @@ -285,6 +513,25 @@ def _validate_arguments( max_export_batch_size, export_timeout_millis=None, ): + """Reject a combination of settings the batch processor cannot honour. + + Returns ``None`` when every value is acceptable. + + Args: + max_queue_size: Number of spans the queue would hold. + schedule_delay_millis: Delay between scheduled exports, in + milliseconds. Zero is allowed and means export on every emit. + max_export_batch_size: Largest number of spans per export call. + export_timeout_millis: Budget for one export call, in + milliseconds. ``None`` skips the check on it. + + Raises: + ValueError: If ``max_queue_size`` is not positive, if + ``schedule_delay_millis`` is negative, if + ``max_export_batch_size`` is not positive or exceeds + ``max_queue_size``, or if ``export_timeout_millis`` is given + and is not positive. + """ if max_queue_size <= 0: raise ValueError("max_queue_size must be a positive integer.") @@ -307,6 +554,23 @@ class ConsoleSpanExporter(SpanExporter): This class can be used for diagnostic purposes. It prints the exported spans to the console STDOUT. + + Args: + service_name: Stored on the instance and read by nothing else here. + The service name that reaches a backend comes from the resource + of the tracer provider, so setting this does not change the + printed output. + out: Stream the formatted spans are written to. The default is bound + when this module is imported rather than when an exporter is + built, so replacing ``sys.stdout`` at any point after that import + does not redirect an exporter that was built with the default. + Pass the stream explicitly to write anywhere else. + formatter: Turns one span into the text written for it. The default + emits the indented JSON of ``ReadableSpan.to_json``, which spans + several lines, followed by the platform line separator. Pass a + formatter calling ``to_json(indent=None)`` to get one span per + line. A formatter that returns text without a trailing separator + runs the spans together. """ def __init__( @@ -315,15 +579,38 @@ def __init__( out: typing.IO = sys.stdout, formatter: collections.abc.Callable[[ReadableSpan], str] = lambda span: span.to_json() + linesep, ): + """Build the exporter. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self.out = out self.formatter = formatter self.service_name = service_name def export(self, spans: collections.abc.Sequence[ReadableSpan]) -> SpanExportResult: + """Write each span through the formatter and flush the stream. + + Args: + spans: The spans to write, in the order given. + + Returns: + ``SpanExportResult.SUCCESS`` always. A stream that raises on + write propagates the exception instead of reporting failure. + """ for span in spans: self.out.write(self.formatter(span)) self.out.flush() return SpanExportResult.SUCCESS def force_flush(self, timeout_millis: int = 30000) -> bool: + """Do nothing, since ``export`` already flushed the stream. + + Args: + timeout_millis: Ignored. Accepted so that this exporter matches + the ``SpanExporter`` interface. + + Returns: + ``True`` always. + """ return True diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/in_memory_span_exporter.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/in_memory_span_exporter.py index dbe0da104d2..eb99cbcbf90 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/in_memory_span_exporter.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/in_memory_span_exporter.py @@ -1,6 +1,13 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Span exporter that keeps the spans it receives in a deque. + +Nothing is sent anywhere, so this exporter is meant for tests: register it on +a span processor, run the code under test, then read the spans back with +``get_finished_spans``. +""" + import collections.abc import threading from collections import deque @@ -23,6 +30,11 @@ class InMemorySpanExporter(SpanExporter): """ def __init__(self, max_spans: int | None = None) -> None: + """Build the exporter. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._finished_spans: deque[ReadableSpan] = deque(maxlen=max_spans) self._stopped = False self._lock = threading.Lock() @@ -33,12 +45,28 @@ def clear(self) -> None: self._finished_spans.clear() def get_finished_spans(self) -> tuple[ReadableSpan, ...]: - """Get list of collected spans.""" + """Get list of collected spans. + + Returns: + A snapshot of the stored spans, oldest first, copied out of the + deque under the lock. Later exports do not change the returned + tuple. + """ with self._lock: return tuple(self._finished_spans) def export(self, spans: collections.abc.Sequence[ReadableSpan]) -> SpanExportResult: - """Stores a list of spans in memory.""" + """Stores a list of spans in memory. + + Args: + spans: The spans to append to the stored ones. When appending + takes the deque past ``max_spans``, the oldest stored spans + are discarded to make room. + + Returns: + ``SpanExportResult.FAILURE`` once ``shutdown`` has been called and + nothing is stored, ``SpanExportResult.SUCCESS`` otherwise. + """ if self._stopped: return SpanExportResult.FAILURE with self._lock: @@ -53,4 +81,13 @@ def shutdown(self) -> None: self._stopped = True def force_flush(self, timeout_millis: int = 30000) -> bool: + """Do nothing, since ``export`` has already stored every span. + + Args: + timeout_millis: Ignored. Accepted so that this exporter matches + the ``SpanExporter`` interface. + + Returns: + ``True`` always. + """ return True diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py index f23e3338b2c..1afa980d21c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/id_generator.py @@ -1,6 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Generation of the trace and span identifiers the SDK puts on new spans. + +``TracerProvider`` holds one ``IdGenerator`` and asks it for a trace id +whenever a root span is created, and for a span id on every span. Replacing +the generator is the way to make the SDK produce ids that an external system +has already chosen, for example ids carried in from another tracing +implementation. +""" + import abc import random @@ -8,6 +17,15 @@ class IdGenerator(abc.ABC): + """Source of the trace ids and span ids used for new spans. + + Subclasses must implement both abstract methods. Neither of them may + return the invalid all-zero id. Nothing in the SDK enforces that: a span + whose context reports itself invalid is still handed to the processors + and exported with those ids, and it is the backend that is left to reject + it. + """ + @abc.abstractmethod def generate_span_id(self) -> int: """Get a new span ID. @@ -57,16 +75,42 @@ class RandomIdGenerator(IdGenerator): """ def generate_span_id(self) -> int: + """Draw 64 random bits for a span id. + + The draw is repeated while it yields the invalid all-zero span id, so + the returned value is always a valid span id. ``random`` is seeded by + the interpreter and is not cryptographically strong, so the ids are + unsuitable as secrets. + + Returns: + A 64-bit int for use as a span ID. + """ span_id = random.getrandbits(64) while span_id == trace.INVALID_SPAN_ID: span_id = random.getrandbits(64) return span_id def generate_trace_id(self) -> int: + """Draw 128 random bits for a trace id. + + Every bit is random, not only the 56 least significant ones the + specification requires, which is why ``is_trace_id_random`` reports + ``True``. The draw is repeated while it yields the invalid all-zero + trace id. + + Returns: + A 128-bit int for use as a trace ID. + """ trace_id = random.getrandbits(128) while trace_id == trace.INVALID_TRACE_ID: trace_id = random.getrandbits(128) return trace_id def is_trace_id_random(self) -> bool: + """Report that the trace ids of this generator are random. + + Returns: + ``True`` always, which makes the SDK set the random trace id flag + on the trace flags of spans whose trace id came from here. + """ return True diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py index 1ba48e0124c..722cece70ca 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py @@ -66,6 +66,15 @@ Sampling probability can be set with ``OTEL_TRACES_SAMPLER_ARG`` if the sampler is traceidratio or parentbased_traceidratio. Rate must be in the range [0.0,1.0]. When not provided rate will be set to 1.0 (maximum rate possible). +A value of ``OTEL_TRACES_SAMPLER`` that is none of the six names above and matches no entry point +is logged as a warning and replaced by ``parentbased_always_on``. The four samplers that take no +rate ignore ``OTEL_TRACES_SAMPLER_ARG``: ``_get_from_env_or_default`` does not read the variable +for them at all, while the auto-configuration path does read it and hands the string to the +entry-point factory, which discards it. For ``traceidratio`` and +``parentbased_traceidratio`` it is parsed as a float. A value that does not parse, and a value +that parses but is outside [0.0, 1.0] or is NaN, are both logged as a warning and replaced by 1.0, +so neither one stops the SDK from starting. + Prev example but with environment variables. Please make sure to set the env ``OTEL_TRACES_SAMPLER=traceidratio`` and ``OTEL_TRACES_SAMPLER_ARG=0.001``. .. code:: python @@ -146,6 +155,20 @@ def get_sampler(sampler_argument): class Decision(enum.Enum): + """What a sampler decided about one span. + + The decision controls two independent things: whether the SDK builds a + recording span at all, and whether the sampled flag is set on the span + context that is propagated to other processes. + + ``DROP`` produces a non-recording span, so its attributes, events and + links are discarded and no processor sees it. ``RECORD_ONLY`` produces a + recording span that processors see but whose sampled flag is clear, which + both span processors in ``opentelemetry.sdk.trace.export`` use as a reason + to skip it. ``RECORD_AND_SAMPLE`` produces a recording span with the + sampled flag set. + """ + # IsRecording() == false, span will not be recorded and all events and attributes will be dropped. DROP = 0 # IsRecording() == true, but Sampled flag MUST NOT be set. @@ -154,15 +177,35 @@ class Decision(enum.Enum): RECORD_AND_SAMPLE = 2 def is_recording(self): + """Report whether the SDK should build a recording span. + + Returns: + ``True`` for ``RECORD_ONLY`` and ``RECORD_AND_SAMPLE``, ``False`` + for ``DROP``. + """ return self in (Decision.RECORD_ONLY, Decision.RECORD_AND_SAMPLE) def is_sampled(self): + """Report whether the sampled flag should be set on the span context. + + Returns: + ``True`` only for ``RECORD_AND_SAMPLE``. ``RECORD_ONLY`` answers + ``False`` even though the span is recorded. + """ return self is Decision.RECORD_AND_SAMPLE class SamplingResult: """A sampling result as applied to a newly-created Span. + ``attributes`` is wrapped in a read-only mapping when the instance is + built, so nothing reached through ``SamplingResult.attributes`` can write + to it, and ``None`` becomes an empty mapping rather than staying ``None``. + The wrapper is a view and not a copy, so a sampler that keeps a reference + to the dictionary it passed can still change what the result reports. The samplers in this module pass ``None`` whenever the decision + is ``Decision.DROP``, which is why a dropped span never carries the + attributes the caller asked for. + Args: decision: A sampling decision based off of whether the span is recorded and the sampled flag in trace flags in the span context. @@ -172,6 +215,14 @@ class SamplingResult: """ def __repr__(self) -> str: + """Render the class name, the decision and the attributes. + + The trace state is left out, so two results that differ only in trace + state have the same representation. + + Returns: + A string such as ``SamplingResult(Decision.DROP, attributes={})``. + """ return f"{type(self).__name__}({self.decision!s}, attributes={self.attributes!s})" def __init__( @@ -180,6 +231,11 @@ def __init__( attributes: Attributes = None, trace_state: TraceState | None = None, ) -> None: + """Build the result. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self.decision = decision if attributes is None: self.attributes = MappingProxyType({}) @@ -189,6 +245,13 @@ def __init__( class Sampler(abc.ABC): + """Base class every sampler derives from. + + A subclass must implement both abstract methods. ``should_sample`` is + called once per span, before the span object exists, so it can only see + what is passed to it. + """ + @abc.abstractmethod def should_sample( self, @@ -200,17 +263,61 @@ def should_sample( links: Sequence[Link] | None = None, trace_state: TraceState | None = None, ) -> SamplingResult: - pass + """Decide what should happen to a span that is about to be created. + + Args: + parent_context: Context the span is being created in. ``None`` + means the currently active context, which is what makes a + span with no explicit parent still pick one up. + trace_id: Trace id the span will carry. For a root span this is a + freshly generated id, for a child it is the trace id of the + parent. + name: Name the span is being created with. A sampler that reads + it sees the name at creation time, and a later rename does + not re-run sampling. + kind: Span kind, or ``None`` when the caller did not give one. + attributes: Attributes the caller passed at creation time. A + sampler returns them, possibly changed, in the result. + links: Links the caller passed at creation time, or ``None``. + trace_state: Trace state the caller passed, or ``None``. + Implementations are expected to take the trace state from + ``parent_context`` instead and put it in the result. + + Returns: + The decision, the attributes the span should start with, and the + trace state the span context should carry. + """ @abc.abstractmethod def get_description(self) -> str: - pass + """Describe the sampler and its settings. + + The string identifies the configuration, not the instance, so two + samplers built with the same arguments describe themselves the same + way. It is what the SDK reports as the sampler in use. + + Returns: + A description such as ``TraceIdRatioBased{0.001}``. + """ class StaticSampler(Sampler): - """Sampler that always returns the same decision.""" + """Sampler that always returns the same decision. + + Nothing about the span is examined, so the trace id, the name, the kind + and the parent make no difference. The two instances anyone normally + needs already exist as ``ALWAYS_ON`` and ``ALWAYS_OFF``. + + Args: + decision: Decision every call returns. + """ def __init__(self, decision: Decision) -> None: + """Build the sampler. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._decision = decision def should_sample( @@ -223,6 +330,25 @@ def should_sample( links: Sequence[Link] | None = None, trace_state: TraceState | None = None, ) -> SamplingResult: + """Return the fixed decision. + + Args: + parent_context: Context the span is being created in. Read only + to copy the trace state of the parent into the result. + trace_id: Ignored. + name: Ignored. + kind: Ignored. + attributes: Returned as given, except under ``Decision.DROP``, + where they are replaced by ``None`` and therefore discarded. + links: Ignored. + trace_state: Ignored. The trace state in the result comes from + ``parent_context``. + + Returns: + A result holding the fixed decision, the attributes, and the + trace state of the parent, or ``None`` when there is no valid + parent. + """ if self._decision is Decision.DROP: attributes = None return SamplingResult( @@ -232,6 +358,14 @@ def should_sample( ) def get_description(self) -> str: + """Describe the sampler by the decision it was built with. + + Returns: + ``"AlwaysOffSampler"`` under ``Decision.DROP``, and + ``"AlwaysOnSampler"`` for both other decisions, so a sampler + built with ``Decision.RECORD_ONLY`` describes itself as if it + sampled. + """ if self._decision is Decision.DROP: return "AlwaysOffSampler" return "AlwaysOnSampler" @@ -248,11 +382,37 @@ class TraceIdRatioBased(Sampler): """ Sampler that makes sampling decisions probabilistically based on `rate`. + The decision is a function of the trace id alone, with no randomness of + its own, so every span of one trace gets the same answer and two SDKs + configured with the same rate agree with each other. The 64 low-order + bits of the trace id are compared against a bound computed once in the + constructor as ``round(rate * 2 ** 64)``: the span is sampled when those + bits are strictly less than the bound. + + A rate of 0.0 gives a bound of 0, and no trace id is below it, so nothing + is sampled. A rate of 1.0 gives a bound of ``2 ** 64``, which every + 64-bit value is below, so everything is sampled. The accuracy of any rate + in between depends on the 64 low-order bits of the trace id being + uniformly distributed, which is what ``RandomIdGenerator`` provides. + + The parent is not consulted, so a child span can be sampled while its + parent was dropped only if they carry different trace ids, which does not + happen within one trace. Wrap this sampler in ``ParentBased`` to apply + the rate to root spans alone. + Args: rate: Probability (between 0 and 1) that a span will be sampled """ def __init__(self, rate: float): + """Build the sampler and compute its bound. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``rate`` is NaN or falls outside [0.0, 1.0]. + """ if math.isnan(rate) or rate < 0.0 or rate > 1.0: raise ValueError("Probability must be in range [0.0, 1.0].") self._rate = rate @@ -264,14 +424,37 @@ def __init__(self, rate: float): @classmethod def get_bound_for_rate(cls, rate: float) -> int: + """Convert a rate into the threshold the trace id is compared against. + + Args: + rate: Probability in [0.0, 1.0]. The range is not checked here, + so a value outside it yields a bound that no 64-bit trace id + can reach, or one that every trace id is below. + + Returns: + ``round(rate * 2 ** 64)``, the value a trace id must be strictly + below to be sampled. + """ return round(rate * (cls.TRACE_ID_LIMIT + 1)) @property def rate(self) -> float: + """Probability this sampler was built with. + + Returns: + The rate passed to the constructor, unchanged. It cannot be + reassigned, so the bound can never fall out of step with it. + """ return self._rate @property def bound(self) -> int: + """Threshold the 64 low-order bits of a trace id are compared against. + + Returns: + The value computed from the rate in the constructor. A trace id + whose 64 low-order bits are strictly below it is sampled. + """ return self._bound def should_sample( @@ -284,6 +467,27 @@ def should_sample( links: Sequence[Link] | None = None, trace_state: TraceState | None = None, ) -> SamplingResult: + """Compare the 64 low-order bits of the trace id against the bound. + + Args: + parent_context: Context the span is being created in. Read only + to copy the trace state of the parent into the result. The + sampling decision of the parent is not consulted. + trace_id: Trace id the span will carry. Only its 64 low-order + bits are used, so the 64 high-order ones make no difference. + name: Ignored. + kind: Ignored. + attributes: Returned as given when the span is sampled, and + replaced by ``None`` when it is dropped. + links: Ignored. + trace_state: Ignored. The trace state in the result comes from + ``parent_context``. + + Returns: + A result holding ``Decision.RECORD_AND_SAMPLE`` when the trace id + is below the bound and ``Decision.DROP`` otherwise, plus the + attributes and the trace state of the parent. + """ decision = Decision.DROP if trace_id & self.TRACE_ID_LIMIT < self.bound: decision = Decision.RECORD_AND_SAMPLE @@ -296,6 +500,12 @@ def should_sample( ) def get_description(self) -> str: + """Describe the sampler by its rate. + + Returns: + A string such as ``TraceIdRatioBased{0.001}``, built from the + rate given to the constructor rather than from the bound. + """ return f"TraceIdRatioBased{{{self._rate}}}" @@ -305,6 +515,21 @@ class ParentBased(Sampler): Otherwise, uses the root provided at initialization to make a decision. + Exactly one of the five samplers handles any given span. ``root`` is used + when the parent span context is missing or invalid, which is what a root + span looks like. Otherwise two properties of the parent span context pick + the delegate: whether it is remote, meaning it arrived in an incoming + request rather than being created in this process, and whether its + sampled flag is set. Remote and sampled selects + ``remote_parent_sampled``, remote and not sampled selects + ``remote_parent_not_sampled``, local and sampled selects + ``local_parent_sampled``, and local and not sampled selects + ``local_parent_not_sampled``. + + With the four defaults left alone, the sampler copies the decision of the + parent and applies ``root`` only to root spans, which is the behaviour of + ``DEFAULT_ON`` and ``DEFAULT_OFF``. + Args: root: Sampler called for spans with no parent (root spans). remote_parent_sampled: Sampler called for a remote sampled parent. @@ -323,6 +548,11 @@ def __init__( local_parent_sampled: Sampler = ALWAYS_ON, local_parent_not_sampled: Sampler = ALWAYS_OFF, ): + """Build the sampler. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._root = root self._remote_parent_sampled = remote_parent_sampled self._remote_parent_not_sampled = remote_parent_not_sampled @@ -339,6 +569,26 @@ def should_sample( links: Sequence[Link] | None = None, trace_state: TraceState | None = None, ) -> SamplingResult: + """Pick a delegate from the parent span context and call it. + + Args: + parent_context: Context the span is being created in. ``None`` + means the currently active context. The span context taken + from it is what picks the delegate. + trace_id: Trace id the span will carry. Passed to the delegate + unchanged. + name: Span name. Passed to the delegate unchanged. + kind: Span kind, or ``None``. Passed to the delegate unchanged. + attributes: Attributes given at creation time. Passed to the + delegate unchanged. + links: Links given at creation time, or ``None``. Passed to the + delegate unchanged. + trace_state: Accepted and not forwarded to the delegate, which + therefore always sees ``None`` for it. + + Returns: + Whatever the chosen delegate returns, unexamined and unchanged. + """ parent_span_context = get_current_span(parent_context).get_span_context() # default to the root sampler sampler = self._root @@ -365,6 +615,14 @@ def should_sample( ) def get_description(self): + """Describe the sampler by the descriptions of all five delegates. + + Returns: + A string such as + ``ParentBased{root:AlwaysOnSampler,remoteParentSampled:...}``, + naming each delegate in the order root, remote sampled, remote + not sampled, local sampled, local not sampled. + """ return f"ParentBased{{root:{self._root.get_description()},remoteParentSampled:{self._remote_parent_sampled.get_description()},remoteParentNotSampled:{self._remote_parent_not_sampled.get_description()},localParentSampled:{self._local_parent_sampled.get_description()},localParentNotSampled:{self._local_parent_not_sampled.get_description()}}}" @@ -380,9 +638,21 @@ class AlwaysRecordSampler(Sampler): processor without having an impact on the sampling rate. This may be desirable if a user wishes to count or otherwise measure all spans produced in a service, without incurring the cost of 100% sampling. + + Args: + root: Sampler consulted for every span. Its result is passed through + untouched unless it decided ``Decision.DROP``. """ def __init__(self, root: Sampler): + """Build the sampler. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``root`` is ``None``. + """ if root is None: raise ValueError("root must not be None") self._root = root @@ -397,6 +667,30 @@ def should_sample( links: Sequence[Link] | None = None, trace_state: TraceState | None = None, ) -> SamplingResult: + """Delegate to the root sampler and turn a drop into a record. + + Every argument is forwarded to the root sampler unchanged, including + ``trace_state``, so the meaning of each one is whatever that sampler + makes of it. + + Args: + parent_context: Context the span is being created in, forwarded + to the root sampler. + trace_id: Trace id the span will carry, forwarded. + name: Span name, forwarded. + kind: Span kind, or ``None``, forwarded. + attributes: Attributes given at creation time, forwarded. + links: Links given at creation time, or ``None``, forwarded. + trace_state: Trace state given at creation time, or ``None``, + forwarded. + + Returns: + The result of the root sampler when its decision is not + ``Decision.DROP``. Otherwise a new result carrying + ``Decision.RECORD_ONLY`` with the attributes and trace state the + root sampler produced, which for the samplers in this module + means an empty attribute mapping. + """ result: SamplingResult = self._root.should_sample( parent_context, trace_id, @@ -412,6 +706,11 @@ def should_sample( return result def get_description(self): + """Describe the sampler by the description of its root sampler. + + Returns: + A string such as ``AlwaysRecordSampler{AlwaysOnSampler}``. + """ return f"AlwaysRecordSampler{{{self._root.get_description()}}}" @@ -426,30 +725,105 @@ class ParentBasedTraceIdRatio(ParentBased): """ Sampler that respects its parent span's sampling decision, but otherwise samples probabilistically based on `rate`. + + This is ``ParentBased`` with a ``TraceIdRatioBased`` root and the four + parent delegates left at their defaults, so the rate applies to root + spans alone and every child copies the decision of its parent. + + Args: + rate: Probability in [0.0, 1.0] that a root span will be sampled. """ def __init__(self, rate: float): + """Build the root sampler from the rate and wrap it. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``rate`` is NaN or falls outside [0.0, 1.0]. The + check happens in ``TraceIdRatioBased``. + """ root = TraceIdRatioBased(rate=rate) super().__init__(root=root) class _AlwaysOff(StaticSampler): + """Factory shape for the ``always_off`` value of ``OTEL_TRACES_SAMPLER``. + + ``_KNOWN_SAMPLERS`` maps that name to the ``ALWAYS_OFF`` instance rather + than to this class, so nothing in this module constructs it. It exists so + that code building a sampler from a name and an argument string has a + callable with the same shape as ``TraceIdRatioBased``. + + Args: + _: The sampler argument string. Accepted and discarded, since a + static sampler has nothing to configure. + """ + def __init__(self, _): + """Build an always-off static sampler. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(Decision.DROP) class _AlwaysOn(StaticSampler): + """Factory shape for the ``always_on`` value of ``OTEL_TRACES_SAMPLER``. + + ``_KNOWN_SAMPLERS`` maps that name to the ``ALWAYS_ON`` instance rather + than to this class, so nothing in this module constructs it. + + Args: + _: The sampler argument string. Accepted and discarded. + """ + def __init__(self, _): + """Build an always-on static sampler. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(Decision.RECORD_AND_SAMPLE) class _ParentBasedAlwaysOff(ParentBased): + """Factory shape for the ``parentbased_always_off`` value of ``OTEL_TRACES_SAMPLER``. + + ``_KNOWN_SAMPLERS`` maps that name to the ``DEFAULT_OFF`` instance rather + than to this class, so nothing in this module constructs it. + + Args: + _: The sampler argument string. Accepted and discarded. + """ + def __init__(self, _): + """Build a parent-based sampler whose root never samples. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(ALWAYS_OFF) class _ParentBasedAlwaysOn(ParentBased): + """Factory shape for the ``parentbased_always_on`` value of ``OTEL_TRACES_SAMPLER``. + + ``_KNOWN_SAMPLERS`` maps that name to the ``DEFAULT_ON`` instance rather + than to this class, so nothing in this module constructs it. + + Args: + _: The sampler argument string. Accepted and discarded. + """ + def __init__(self, _): + """Build a parent-based sampler whose root always samples. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ super().__init__(ALWAYS_ON) @@ -464,6 +838,26 @@ def __init__(self, _): def _get_from_env_or_default() -> Sampler: + """Build the sampler named by ``OTEL_TRACES_SAMPLER``. + + The variable is lowercased and looked up in ``_KNOWN_SAMPLERS``. A name + that is not there is logged as a warning and replaced by + ``parentbased_always_on``, so a typo makes the SDK start with the default + sampler instead of failing. + + ``OTEL_TRACES_SAMPLER_ARG`` is read only for ``traceidratio`` and + ``parentbased_traceidratio``, and is parsed as a float. An unset or + unparsable value and a value the sampler constructor rejects, meaning + NaN or anything outside [0.0, 1.0], are handled the same way: a warning + is logged and 1.0 is used. Entry-point samplers are resolved elsewhere, + so a name registered under ``opentelemetry_traces_sampler`` does not + reach this function. + + Returns: + For the four names that take no rate, the shared module-level + instance, meaning every caller gets the same object. For the two rate + samplers, a new instance built with the parsed rate. + """ trace_sampler = os.getenv(OTEL_TRACES_SAMPLER, "parentbased_always_on").lower() if trace_sampler not in _KNOWN_SAMPLERS: _logger.warning("Couldn't recognize sampler %s.", trace_sampler) @@ -483,6 +877,19 @@ def _get_from_env_or_default() -> Sampler: def _get_parent_trace_state( parent_context: Context | None, ) -> TraceState | None: + """Pull the trace state of the parent out of a context. + + Used by the samplers in this module to fill the trace state of their + result, so that vendor entries set upstream survive into the new span. + + Args: + parent_context: Context the span is being created in. ``None`` means + the currently active context. + + Returns: + The trace state of the parent span context, or ``None`` when that + context is missing or invalid, which is the case for a root span. + """ parent_span_context = get_current_span(parent_context).get_span_context() if parent_span_context is None or not parent_span_context.is_valid: return None diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index d689f4dc5f3..5f83b1b424f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -1,6 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Size-limited containers and small helpers shared across the SDK. + +``BoundedList`` is the sequence a span stores its events and links in. It +keeps a fixed number of the most recent items and counts the rest in +``dropped`` rather than raising, so that instrumentation which records far +more than the configured limit degrades the telemetry and leaves the +application running. + +``BoundedDict`` is the mapping equivalent and is deprecated. New code stores +attributes in ``opentelemetry.attributes.BoundedAttributes``, which also +validates and truncates the values. +""" + import copy import datetime import threading @@ -11,13 +24,37 @@ def ns_to_iso_str(nanoseconds): - """Get an ISO 8601 string from time_ns value.""" + """Get an ISO 8601 string from time_ns value. + + The nanosecond count is divided by a float before being handed to + ``datetime``, so the result keeps microsecond resolution at best and the + last three digits of the input are lost. + + Args: + nanoseconds: A timestamp as a count of nanoseconds since the Unix + epoch, of the kind ``time.time_ns`` returns. + + Returns: + The same instant in UTC, formatted as + ``YYYY-MM-DDTHH:MM:SS.ffffffZ``. + """ ts = datetime.datetime.fromtimestamp(nanoseconds / 1e9, tz=datetime.timezone.utc) return ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ") def get_dict_as_key(labels): - """Converts a dict to be used as a unique key""" + """Converts a dict to be used as a unique key. + + Args: + labels: The mapping to convert. A value that is a ``list`` is turned + into a ``tuple`` so that it hashes; any other value is stored as + it is and has to be hashable already. + + Returns: + A ``tuple`` of key and value pairs, sorted by key, so that two + mappings holding the same pairs produce equal keys whatever order + they were built in. + """ return tuple( sorted( map( @@ -33,14 +70,41 @@ class BoundedList(Sequence): Calls to `append` and `extend` will drop the oldest elements if there is not enough room. + + The count of elements that were dropped is kept in the ``dropped`` + attribute, which a span exports as its dropped events or dropped links + count. Reading and iterating are not themselves bounded: the sequence + behaves like the ``deque`` it wraps. + + Args: + maxlen: The number of elements to keep. Pass ``None`` to keep every + element, in which case ``dropped`` stays at zero. """ def __init__(self, maxlen: int | None): + """Build an empty list. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self.dropped = 0 self._dq = deque(maxlen=maxlen) # type: deque self._lock = threading.Lock() def __deepcopy__(self, memo): + """Copy the list and its elements, holding the lock while doing so. + + Args: + memo: The mapping of already copied objects that + ``copy.deepcopy`` threads through a copy operation. The new + list is registered in it before its elements are copied, so + that a cycle terminates. + + Returns: + A new ``BoundedList`` holding deep copies of the elements, with + the same ``dropped`` count and the same maximum length as this + one, since the copied ``deque`` carries that length with it. + """ copy_ = BoundedList(0) memo[id(self)] = copy_ with self._lock: @@ -49,25 +113,74 @@ def __deepcopy__(self, memo): return copy_ def __repr__(self): + """Render the kept elements and the maximum length. + + Returns: + A string of the form ``BoundedList([...], maxlen=...)``. The + ``dropped`` count is not part of it. + """ return f"{type(self).__name__}({list(self._dq)}, maxlen={self._dq.maxlen})" def __getitem__(self, index): + """Return the element at ``index`` among the kept ones. + + Args: + index: A position or a slice, counted over the elements still + held. A dropped element shifts every later position, so an + index is not stable across an ``append`` that drops. + + Returns: + The element, or the list of elements when ``index`` is a slice. + + Raises: + IndexError: If ``index`` is out of range. + """ return self._dq[index] def __len__(self): + """Return how many elements are still held. + + Returns: + The number of kept elements, which never exceeds the maximum + length. Dropped elements are not counted; ``dropped`` holds those. + """ return len(self._dq) def __iter__(self): + """Iterate over a snapshot of the kept elements. + + The elements are copied into a list while the lock is held, so an + append from another thread during iteration neither blocks nor + changes what is yielded. + + Returns: + An iterator over the elements held when the call was made. + """ with self._lock: return iter(list(self._dq)) def append(self, item): + """Add one element, dropping the oldest when the list is full. + + Args: + item: The element to add. It is stored as it is, without + validation or copying. + """ with self._lock: if self._dq.maxlen is not None and len(self._dq) == self._dq.maxlen: self.dropped += 1 self._dq.append(item) def extend(self, seq): + """Add several elements, dropping the oldest ones that no longer fit. + + Args: + seq: The elements to add, in order. On a bounded list it has to + support ``len``, so a one-shot iterator is accepted only when + the list was built with ``maxlen=None``. When the whole + sequence is longer than the maximum length, the earlier + elements of ``seq`` itself are the ones dropped. + """ with self._lock: if self._dq.maxlen is not None: to_drop = len(seq) + len(self._dq) - self._dq.maxlen @@ -77,6 +190,20 @@ def extend(self, seq): @classmethod def from_seq(cls, maxlen, seq): + """Build a bounded list already holding ``seq``. + + Args: + maxlen: The number of elements to keep, or ``None`` to keep every + element. + seq: The initial elements, in order. Any iterable is accepted; it + is materialised as a ``tuple`` first, so a generator works + here even though ``extend`` alone would reject it on a + bounded list. + + Returns: + A new instance holding the last ``maxlen`` elements of ``seq``, + with ``dropped`` set to the number that did not fit. + """ seq = tuple(seq) bounded_list = cls(maxlen) bounded_list.extend(seq) @@ -89,9 +216,31 @@ class BoundedDict(MutableMapping): Oldest elements are dropped when the dict is full and a new element is added. + + Deprecated since version 1.4.0. The SDK stores attributes in + ``opentelemetry.attributes.BoundedAttributes`` instead, which validates + and truncates values as well as bounding their number. + + Assigning to a key that is already present removes it and inserts it + again, which moves it last. Eviction always takes the first key of that + order, so the entry dropped is the one written longest ago, and rewriting + a key protects it. Rewriting never drops anything itself. + + Args: + maxlen: The number of entries to keep. Pass ``None`` to keep every + entry. Pass 0 to drop every entry, counting each one in + ``dropped``. """ def __init__(self, maxlen: int | None): + """Build an empty mapping. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + + Raises: + ValueError: If ``maxlen`` is not an ``int``, or is negative. + """ if maxlen is not None: if not isinstance(maxlen, int): raise ValueError @@ -103,12 +252,41 @@ def __init__(self, maxlen: int | None): self._lock = threading.Lock() # type: threading.Lock def __repr__(self): + """Render the kept entries and the maximum length. + + Returns: + A string of the form ``BoundedDict({...}, maxlen=...)``. The + ``dropped`` count is not part of it. + """ return f"{type(self).__name__}({dict(self._dict)}, maxlen={self.maxlen})" def __getitem__(self, key): + """Return the value stored under ``key``. + + Args: + key: The key to look up. + + Returns: + The stored value. + + Raises: + KeyError: If ``key`` is absent, which includes the case where it + was evicted to make room for a later key. + """ return self._dict[key] def __setitem__(self, key, value): + """Store ``value`` under ``key``, evicting the oldest entry if needed. + + Nothing is raised when the mapping is full. The oldest entry is + removed and ``dropped`` is incremented. When the maximum length is 0 + the new entry is counted in ``dropped`` and discarded at once. + + Args: + key: The key to store under. It has to be hashable. + value: The value to store. It is kept as it is, without + validation. + """ with self._lock: if self.maxlen is not None and self.maxlen == 0: self.dropped += 1 @@ -122,17 +300,57 @@ def __setitem__(self, key, value): self._dict[key] = value def __delitem__(self, key): + """Remove the entry stored under ``key``. + + The lock is not taken, unlike for the other mutations, so a removal + racing a write from another thread is not serialised. + + Args: + key: The key to remove. + + Raises: + KeyError: If ``key`` is absent. + """ del self._dict[key] def __iter__(self): + """Iterate over a snapshot of the keys. + + Returns: + An iterator over the keys held when the call was made, in + insertion order. The snapshot is taken under the lock, so a write + from another thread during iteration does not disturb it. + """ with self._lock: return iter(self._dict.copy()) def __len__(self): + """Return how many entries are still held. + + Returns: + The number of kept entries, which never exceeds the maximum + length. Dropped entries are not counted; ``dropped`` holds those. + """ return len(self._dict) @classmethod def from_map(cls, maxlen, mapping): + """Build a bounded mapping already holding the entries of ``mapping``. + + Args: + maxlen: The number of entries to keep, or ``None`` to keep every + entry. + mapping: The initial entries. They are inserted one at a time in + iteration order, so when there are more of them than + ``maxlen`` the earliest ones are evicted and counted in + ``dropped``. + + Returns: + A new instance holding the last ``maxlen`` entries of ``mapping``. + + Raises: + ValueError: If ``maxlen`` is not an ``int``, or is negative. + """ mapping = dict(mapping) bounded_dict = cls(maxlen) for key, value in mapping.items(): diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py index d4314955889..dbd721f935a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py @@ -1,5 +1,13 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Selection of a per-scope configuration by the first matching rule. + +The SDK lets a tracer, a meter or a logger be configured differently depending +on which instrumentation scope asked for it. ``RuleBasedConfigurator`` holds +the ordered rules that make that choice and is called once per scope, when the +tracer, the meter or the logger for that scope is first created. +""" + from collections.abc import Sequence from typing import Generic, TypeVar @@ -13,11 +21,41 @@ class RuleBasedConfigurator(Generic[ConfigT]): + """Maps an instrumentation scope onto a configuration value. + + The rules are tried in order and the first predicate that accepts the + scope decides, so a narrower rule has to be placed before a broader one. + + Args: + rules: The ordered pairs of a predicate over an + ``InstrumentationScope`` and the configuration to use when that + predicate accepts. An empty sequence means every scope gets + ``default_config``. + default_config: The configuration returned when no predicate + accepts. + """ + def __init__(self, *, rules: ConfiguratorRulesT, default_config: ConfigT): + """Build the configurator. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. Both are keyword only. + """ self._rules = rules self._default_config = default_config def __call__(self, scope: InstrumentationScope) -> ConfigT: + """Return the configuration that applies to ``scope``. + + Args: + scope: The instrumentation scope to configure. + + Returns: + The configuration of the first rule whose predicate accepts + ``scope``, or the default configuration when none does. The value + is returned as it is rather than copied, so several scopes can + share one configuration object. + """ for predicate, config in self._rules: if predicate(scope): return config @@ -25,4 +63,13 @@ def __call__(self, scope: InstrumentationScope) -> ConfigT: return self._default_config def update_rules(self, rules: ConfiguratorRulesT) -> None: + """Replace the whole rule sequence. + + A configuration already handed to a scope is not revisited, so the + new rules only affect calls made after this one. + + Args: + rules: The new ordered pairs of predicate and configuration. The + default configuration is left as it was. + """ self._rules = rules diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/instrumentation.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/instrumentation.py index e41ca76dba5..1ae421c8eca 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/instrumentation.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/instrumentation.py @@ -1,5 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Identity of the code that emitted a piece of telemetry. + +Every tracer, meter and logger the SDK hands out carries an +``InstrumentationScope`` built from the name, version, schema url and +attributes passed to ``get_tracer``, ``get_meter`` or ``get_logger``. The SDK +groups spans, metrics and log records by that scope when it exports them, and +a view or a processor can select on it. + +``InstrumentationInfo`` is the earlier form of the same idea, without the +attributes. It is deprecated and kept so that older exporters keep importing. +""" + from collections.abc import Callable from fnmatch import fnmatchcase from json import dumps @@ -18,6 +30,18 @@ class InstrumentationInfo: See `opentelemetry.trace.TracerProvider.get_tracer` for the meaning of these properties. + + Deprecated since version 1.11.1. Use ``InstrumentationScope``, which also + carries attributes. + + Args: + name: The name the instrumentation passed to ``get_tracer``, usually + the import name of the instrumenting module. + version: The version of the instrumenting module, or ``None`` when it + did not give one. + schema_url: The url of the semantic convention schema the emitted + telemetry follows. ``None`` is stored as the empty string, so + ``schema_url`` never reads back as ``None``. """ __slots__ = ("_name", "_version", "_schema_url") @@ -29,6 +53,11 @@ def __init__( version: str | None = None, schema_url: str | None = None, ): + """Build the instance and warn that the class is deprecated. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._name = name self._version = version if schema_url is None: @@ -36,12 +65,35 @@ def __init__( self._schema_url = schema_url def __repr__(self): + """Render the name, the version and the schema url. + + Returns: + A string of the form ``InstrumentationInfo(name, version, + schema_url)``. The fields are not quoted, so the result is not + valid Python. + """ return f"{type(self).__name__}({self._name}, {self._version}, {self._schema_url})" def __hash__(self): + """Hash the name, the version and the schema url together. + + Returns: + A hash consistent with ``__eq__``, so two instances built from + the same three values land in the same bucket of a dict or a set. + """ return hash((self._name, self._version, self._schema_url)) def __eq__(self, value): + """Compare the name, the version and the schema url. + + Args: + value: The object to compare against. Anything whose type is not + exactly this class compares unequal, including a subclass + instance and an ``InstrumentationScope``. + + Returns: + ``True`` when the two carry the same three values. + """ return type(value) is type(self) and ( self._name, self._version, @@ -49,6 +101,23 @@ def __eq__(self, value): ) == (value._name, value._version, value._schema_url) def __lt__(self, value): + """Order by name, then version, then schema url. + + The ordering exists so that a list of scopes can be sorted into a + stable export order. Tuple comparison stops at the first element that + differs, so a ``None`` version is only reached when the two names are + equal, and it raises ``TypeError`` then because ``None`` and a string + cannot be compared. Instances with distinct names sort whether or not + they carry a version. + + Args: + value: The object to compare against. + + Returns: + ``True`` when this instance sorts first. ``NotImplemented`` when + ``value`` is not of exactly this type, which makes Python fall + back to the reflected operation and then raise ``TypeError``. + """ if type(value) is not type(self): return NotImplemented return (self._name, self._version, self._schema_url) < ( @@ -59,14 +128,32 @@ def __lt__(self, value): @property def schema_url(self) -> str | None: + """The schema url passed to the constructor. + + Returns: + The url, or the empty string when the constructor was given + ``None``. + """ return self._schema_url @property def version(self) -> str | None: + """The version passed to the constructor. + + Returns: + The version string, or ``None`` when the instrumentation did not + report one. + """ return self._version @property def name(self) -> str: + """The name passed to the constructor. + + Returns: + The name, unchanged. It is not validated, so an empty name is + possible. + """ return self._name @@ -76,6 +163,23 @@ class InstrumentationScope: See `opentelemetry.trace.TracerProvider.get_tracer` for the meaning of these properties. + + An instance is used as a dict key by the exporters, which group the spans, + metrics and log records they receive by the scope that produced them. + + Args: + name: The name the instrumentation passed to ``get_tracer``, + ``get_meter`` or ``get_logger``, usually the import name of the + instrumenting module. + version: The version of the instrumenting module, or ``None`` when it + did not give one. + schema_url: The url of the semantic convention schema the emitted + telemetry follows. ``None`` is stored as the empty string, so + ``schema_url`` never reads back as ``None``. + attributes: Scope attributes, stored in an immutable + ``BoundedAttributes``, so they cannot be added to or removed + after construction. ``None`` gives an empty mapping rather than + ``None``. """ __slots__ = ( @@ -92,6 +196,11 @@ def __init__( schema_url: str | None = None, attributes: Attributes = None, ) -> None: + """Build the instance. + + Every parameter is documented on the class itself, since that is the + form the API reference renders. + """ self._name = name self._version = version if schema_url is None: @@ -101,12 +210,40 @@ def __init__( self._attributes = BoundedAttributes(attributes=attributes, immutable=True) def __repr__(self) -> str: + """Render the name, the version, the schema url and the attributes. + + Returns: + A string of the form ``InstrumentationScope(name, version, + schema_url, attributes)``. The fields are not quoted, so the + result is not valid Python. + """ return f"{type(self).__name__}({self._name}, {self._version}, {self._schema_url}, {self._attributes})" def __hash__(self) -> int: + """Hash the name, the version and the schema url together. + + The attributes are left out, because the mapping holding them does + not hash. Two scopes that differ only in their attributes therefore + share a hash while comparing unequal, which a dict handles as an + ordinary collision. + + Returns: + A hash over the three string fields. + """ return hash((self._name, self._version, self._schema_url)) def __eq__(self, value: object) -> bool: + """Compare the name, the version, the schema url and the attributes. + + Args: + value: The object to compare against. Anything that is not an + ``InstrumentationScope`` yields ``NotImplemented``, which + makes Python fall back to the reflected comparison and then + to identity. + + Returns: + ``True`` when all four fields are equal. + """ if not isinstance(value, InstrumentationScope): return NotImplemented return ( @@ -122,6 +259,23 @@ def __eq__(self, value: object) -> bool: ) def __lt__(self, value: object) -> bool: + """Order by name, then version, then schema url, then attributes. + + The ordering exists so that a list of scopes can be sorted into a + stable export order. Tuple comparison stops at the first element that + differs, so the attributes are only reached by two scopes that agree + on the three string fields, and they raise ``TypeError`` only when + they are themselves unequal, since comparing two + ``BoundedAttributes`` with ``<`` is not supported. Two scopes that + are equal throughout sort without raising. + + Args: + value: The object to compare against. + + Returns: + ``True`` when this instance sorts first. ``NotImplemented`` when + ``value`` is not an ``InstrumentationScope``. + """ if not isinstance(value, InstrumentationScope): return NotImplemented return ( @@ -138,21 +292,58 @@ def __lt__(self, value: object) -> bool: @property def schema_url(self) -> str | None: + """The schema url passed to the constructor. + + Returns: + The url, or the empty string when the constructor was given + ``None``. + """ return self._schema_url @property def version(self) -> str | None: + """The version passed to the constructor. + + Returns: + The version string, or ``None`` when the instrumentation did not + report one. + """ return self._version @property def name(self) -> str: + """The name passed to the constructor. + + Returns: + The name, unchanged. It is not validated, so an empty name is + possible. + """ return self._name @property def attributes(self) -> Attributes: + """The scope attributes. + + Returns: + The immutable ``BoundedAttributes`` built at construction. + Assigning into it raises ``TypeError``. It is empty rather than + ``None`` when no attributes were given. + """ return self._attributes def to_json(self, indent: int | None = 4) -> str: + """Serialise the scope to JSON, for a console exporter or a log line. + + Args: + indent: The number of spaces to indent each nesting level by, as + ``json.dumps`` takes it. Pass ``None`` for the compact + single-line form. + + Returns: + A JSON object with ``name``, ``version``, ``schema_url`` and + ``attributes``. The ``attributes`` member is ``null`` rather than + an empty object when the scope carries none. + """ return dumps( { "name": self._name, @@ -170,7 +361,30 @@ def to_json(self, indent: int | None = 4) -> str: def _scope_name_matches_glob( glob_pattern: str, ) -> _InstrumentationScopePredicateT: + """Build a predicate that tests a scope name against a glob pattern. + + Args: + glob_pattern: A pattern in the syntax ``fnmatch`` accepts, so ``*`` + matches any run of characters, ``?`` matches one, and ``[seq]`` + matches one character of the set. A pattern with no wildcard in + it matches only the exact name. + + Returns: + A function that takes an ``InstrumentationScope`` and reports whether + its name matches, comparing case sensitively on every platform. + """ + def inner(scope: InstrumentationScope) -> bool: + """Report whether the name of ``scope`` matches the captured pattern. + + Args: + scope: The scope whose name is tested. Only the name is looked + at; the version, the schema url and the attributes are + ignored. + + Returns: + ``True`` when the name matches. + """ # Scope name matching is case-sensitive. fnmatchcase is used instead of # fnmatch so it does not rely on the host platform's filename case # sensitivity (normcase), which lower-cases both operands on Windows. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/version/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/version/__init__.py index 6e653a1c7ce..9fbf0822ab5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/version/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/version/__init__.py @@ -1,4 +1,12 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""The version of the ``opentelemetry-sdk`` distribution. + +``__version__`` is the single place the number is written. Hatch reads it from +here at build time, as declared at ``opentelemetry-sdk/pyproject.toml:87``, +and ``Resource.create`` reports it as the ``telemetry.sdk.version`` attribute +of every default resource. +""" + __version__ = "1.46.0.dev"