diff --git a/.changelog/5704.fixed b/.changelog/5704.fixed new file mode 100644 index 00000000000..aef00dd8750 --- /dev/null +++ b/.changelog/5704.fixed @@ -0,0 +1 @@ +`opentelemetry-exporter-zipkin-json`: warn and fall back to the default instead of raising `ValueError` when `OTEL_EXPORTER_ZIPKIN_TIMEOUT` holds a non-integer value; the same fix is applied to `opentelemetry-exporter-zipkin-proto-http`. diff --git a/.pylintrc b/.pylintrc index bda4148beb5..6ee96abff4d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -36,10 +36,6 @@ persistent=yes # Specify a configuration file. #rcfile= -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no diff --git a/dev-requirements.txt b/dev-requirements.txt index caa46ac0c4a..c96dbd33898 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,4 @@ -pylint==3.3.4 +pylint==4.0.9 griffe==1.15.0 httpretty==1.1.4 inline-snapshot==0.34.2 diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py index e57cf4aba95..8991f3a52d4 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py @@ -1,2 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 + +"""Encoders shared by the OTLP JSON exporters. + +This package converts the telemetry that the OpenTelemetry SDK holds in memory +into the dataclasses of ``opentelemetry.proto_json``, whose ``to_dict`` methods +produce the OTLP JSON wire representation. It performs no input and output of +its own. ``opentelemetry-exporter-otlp-json-http`` and +``opentelemetry-exporter-otlp-json-file`` depend on this package, which is what +makes the two of them encode the three signals identically. + +The three entry points are ``encode_spans`` in +``opentelemetry.exporter.otlp.json.common.trace_encoder``, ``encode_metrics`` +in ``opentelemetry.exporter.otlp.json.common.metrics_encoder`` and +``encode_logs`` in ``opentelemetry.exporter.otlp.json.common._log_encoder``. +Everything under ``opentelemetry.exporter.otlp.json.common._internal`` is +private and can change in any release. +""" diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py index 017c8aa3a15..32df02497ac 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py @@ -3,6 +3,22 @@ # pyright: reportCallIssue=false +"""Encoding helpers shared by the trace, metrics and log encoders. + +What the three signals have in common is what this module covers: the resource +and the instrumentation scope that every exported batch is grouped by, the +attribute lists that hang off nearly every OTLP message, and the fixed-width +byte form of trace and span identifiers that ``to_dict`` on the enclosing +message renders as hexadecimal. + +Nothing here is public, and nothing outside this distribution imports these +names. The trace, metrics and log encoder packages under +``opentelemetry.exporter.otlp.json.common._internal`` each import the ones they +need, which is not the same set for all three. ``_encode_key_value`` is +imported by none of them: it is reached only from ``_encode_value`` and +``_encode_attributes`` in this module. +""" + from __future__ import annotations import logging @@ -33,6 +49,20 @@ def _encode_instrumentation_scope( instrumentation_scope: InstrumentationScope | None, ) -> JSONInstrumentationScope: + """Encode an SDK instrumentation scope as its OTLP JSON message. + + Args: + instrumentation_scope: the scope recorded on the telemetry being + exported, or ``None`` when the telemetry carries no scope. + + Returns: + A ``JSONInstrumentationScope`` carrying the name, the version and the + attributes of the scope. The schema URL of the scope is not encoded + here: it belongs to the enclosing ``ScopeSpans``, ``ScopeMetrics`` or + ``ScopeLogs`` message, and each signal encoder sets it there. When + ``instrumentation_scope`` is ``None`` the result is a message left at + its field defaults, which serialises to an empty JSON object. + """ return ( JSONInstrumentationScope( name=instrumentation_scope.name, @@ -45,11 +75,45 @@ def _encode_instrumentation_scope( def _encode_resource(resource: Resource) -> JSONResource: + """Encode an SDK resource as its OTLP JSON message. + + Args: + resource: the resource the telemetry being exported was produced + under. + + Returns: + A ``JSONResource`` carrying the attributes of the resource. The schema + URL of the resource is not encoded here: it belongs to the enclosing + ``ResourceSpans``, ``ResourceMetrics`` or ``ResourceLogs`` message, and + each signal encoder sets it there. + """ return JSONResource(attributes=_encode_attributes(resource.attributes)) # pylint: disable-next=too-many-return-statements def _encode_value(value: Any) -> JSONAnyValue: + """Encode one attribute value or log body as an OTLP JSON ``AnyValue``. + + A mapping or a sequence is encoded recursively, so a nested value becomes a + tree of ``AnyValue`` messages. Mapping keys are passed through ``str``, + because OTLP has no representation for a non-string key. The order in which + the types are tested is load bearing: ``bool`` is tested before ``int`` + because ``bool`` is a subclass of ``int``, and ``str`` and ``bytes`` are + tested before ``Sequence`` because both register as sequences. + + Args: + value: the value to encode. + + Returns: + The encoded ``JSONAnyValue``. For a ``value`` of ``None`` that is a + message with no field set, which serialises to an empty JSON object + rather than to a JSON ``null``. + + Raises: + TypeError: if ``value`` is of a type OTLP has no representation for. + ``_encode_attributes`` catches this for an attribute, but a log + body of such a type reaches the caller. + """ if value is None: return JSONAnyValue() if isinstance(value, bool): @@ -72,20 +136,78 @@ def _encode_value(value: Any) -> JSONAnyValue: def _encode_key_value(key: str, value: Any) -> JSONKeyValue: + """Encode one named value as an OTLP JSON ``KeyValue``. + + Args: + key: the name, used unchanged. + value: the value, encoded by ``_encode_value``. + + Returns: + The encoded ``JSONKeyValue``. + + Raises: + TypeError: if ``_encode_value`` has no representation for ``value``. + """ return JSONKeyValue(key=key, value=_encode_value(value)) def _encode_span_id(span_id: int) -> bytes: + """Encode a span identifier as the eight bytes OTLP gives it. + + Args: + span_id: the identifier as the SDK holds it, an unsigned integer of at + most 64 bits. + + Returns: + The identifier as eight big-endian bytes, which ``to_dict`` on the + enclosing message then renders as a sixteen-character lowercase + hexadecimal string. + + Raises: + OverflowError: if ``span_id`` is negative or needs more than eight + bytes. + """ return span_id.to_bytes(length=8, byteorder="big", signed=False) def _encode_trace_id(trace_id: int) -> bytes: + """Encode a trace identifier as the sixteen bytes OTLP gives it. + + Args: + trace_id: the identifier as the SDK holds it, an unsigned integer of at + most 128 bits. + + Returns: + The identifier as sixteen big-endian bytes, which ``to_dict`` on the + enclosing message then renders as a thirty-two-character lowercase + hexadecimal string. + + Raises: + OverflowError: if ``trace_id`` is negative or needs more than sixteen + bytes. + """ return trace_id.to_bytes(length=16, byteorder="big", signed=False) def _encode_attributes( attributes: Attributes, ) -> list[JSONKeyValue]: + """Encode an attribute mapping as a list of OTLP JSON ``KeyValue`` messages. + + An attribute whose value ``_encode_value`` has no representation for does + not fail the export. The exception is logged together with the key, that + one attribute is left out, and the remaining attributes are still encoded, + so one bad value does not cost the caller the whole batch. + + Args: + attributes: the attributes to encode, or ``None`` when the telemetry + carries none. + + Returns: + One ``JSONKeyValue`` per attribute that encoded, in the iteration order + of ``attributes``. ``None`` and an empty mapping both give an empty + list. + """ if not attributes: return [] json_attributes = [] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py index 1dee399db77..e51e807d18a 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py @@ -3,6 +3,20 @@ # pyright: reportCallIssue=false +"""Encoding of SDK log records into OTLP JSON log messages. + +``encode_logs`` is the entry point and is re-exported from +``opentelemetry.exporter.otlp.json.common._log_encoder``, which is where a log +record exporter should import it from. The rest of this module is private. + +A log record exporter hands over a flat collection of records, while OTLP wants +them grouped first by resource and then by instrumentation scope. Rebuilding +that grouping is most of the work here, and it relies on both +``opentelemetry.sdk.resources.Resource`` and +``opentelemetry.sdk.util.instrumentation.InstrumentationScope`` being hashable, +so that each can be used as a dictionary key. +""" + from __future__ import annotations from collections import defaultdict @@ -34,10 +48,49 @@ def encode_logs( batch: Collection[ReadableLogRecord], ) -> JSONExportLogsServiceRequest: + """Encode a batch of SDK log records as an OTLP export request. + + Args: + batch: the log records to export, in any order and from any number of + resources and instrumentation scopes. + + Returns: + An ``ExportLogsServiceRequest`` holding every record in ``batch``, + regrouped by resource and then by instrumentation scope. An empty + ``batch`` gives a request with no resource logs. + + Raises: + TypeError: if the body of a record, or any value nested inside a + mapping or a sequence body, is of a type OTLP has no + representation for. Unlike an attribute of such a type, which is + dropped with a log message, a body of such a type fails the whole + batch. + """ return JSONExportLogsServiceRequest(resource_logs=_encode_resource_logs(batch)) def _encode_log(readable_log_record: ReadableLogRecord) -> JSONLogRecord: + """Encode one SDK log record as an OTLP JSON ``LogRecord``. + + A trace or span identifier of zero is treated as absent and left out of the + result, since zero is the identifier the SDK uses for a record that was + emitted outside any span. + + Args: + readable_log_record: the record to encode, together with the + instrumentation scope and the resource it was emitted under. + + Returns: + The encoded ``LogRecord``. ``severity_number`` is the numeric value of + the severity, or ``None`` when the record carries no severity. + ``dropped_attributes_count`` is what the SDK log record limits dropped, + and is zero when the attributes were never bounded. + + Raises: + TypeError: if the body of the record, or any value nested inside a + mapping or a sequence body, is of a type OTLP has no + representation for. + """ return JSONLogRecord( time_unix_nano=readable_log_record.log_record.timestamp, observed_time_unix_nano=readable_log_record.log_record.observed_timestamp, @@ -60,6 +113,28 @@ def _encode_log(readable_log_record: ReadableLogRecord) -> JSONLogRecord: def _encode_resource_logs( batch: Collection[ReadableLogRecord], ) -> list[JSONResourceLogs]: + """Group a flat collection of SDK log records into OTLP ``ResourceLogs``. + + Records sharing a resource end up in one ``ResourceLogs``, and within it + records sharing an instrumentation scope end up in one ``ScopeLogs``. The + schema URL of the resource is written on the ``ResourceLogs`` and the + schema URL of the scope on the ``ScopeLogs``. + + Args: + batch: the log records to group, in any order. + + Returns: + One ``ResourceLogs`` per distinct resource, in the order each resource + was first seen in ``batch``. A record whose instrumentation scope is + absent or falsy is grouped under a scope of ``None``, which encodes as + an empty scope message and leaves the schema URL of the ``ScopeLogs`` + unset. + + Raises: + TypeError: if the body of a record, or any value nested inside a + mapping or a sequence body, is of a type OTLP has no + representation for. + """ sdk_resource_logs = defaultdict(lambda: defaultdict(list)) for readable_log in batch: diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py index ff1389a4adb..7b14e3a8d42 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py @@ -4,6 +4,25 @@ # type: ignore[reportArgumentType] # pyright: reportCallIssue=false +"""Encoding of SDK metrics into OTLP JSON metrics messages. + +``encode_metrics`` is the entry point and is re-exported from +``opentelemetry.exporter.otlp.json.common.metrics_encoder``, which is where a +metric exporter should import it from. The rest of this module is private. + +The grouping by resource and by instrumentation scope that the trace and log +encoders have to rebuild is already present in ``MetricsData``, so the encoding +here is a direct walk of that structure. The extra work is in +``split_metrics_data``, which cuts an encoded request into smaller requests +when a receiver limits how many data points one request may carry. + +``_METRIC_DATA_FIELDS`` names the five mutually exclusive fields of an OTLP +``Metric`` that hold data points. ``encode_metrics`` produces four of them: a +summary is a legacy type that the SDK does not aggregate to, so nothing here +writes one, but ``split_metrics_data`` still handles it for a request built +elsewhere. +""" + from __future__ import annotations import logging @@ -84,6 +103,23 @@ def encode_metrics(data: MetricsData) -> JSONExportMetricsServiceRequest: + """Encode collected SDK metrics as an OTLP export request. + + Args: + data: the metrics a metric reader collected, already grouped by + resource and by instrumentation scope. + + Returns: + An ``ExportMetricsServiceRequest`` mirroring the grouping of ``data``, + one ``ResourceMetrics`` per entry and in the same order. A metric whose + aggregated data is of a type this encoder does not recognise is logged + as a warning and encoded with its name, description and unit but no + data points. + + Raises: + ValueError: if an exemplar carries a value that is neither an ``int`` + nor a ``float``. + """ return JSONExportMetricsServiceRequest( resource_metrics=[_encode_resource_metrics(rm) for rm in data.resource_metrics] ) @@ -96,6 +132,37 @@ def split_metrics_data( ) -> Iterable[JSONExportMetricsServiceRequest]: """Split an ExportMetricsServiceRequest into batches of at most max_export_batch_size data points, preserving resource/scope hierarchy. + + That limit holds only for a ``max_export_batch_size`` above zero. ``None`` + hands the request back unsplit, whatever it holds, and a value of zero or + less puts one data point in each batch. + + Every batch repeats the resource and the instrumentation scope that its + data points belong to, so each one is a request a receiver can accept on + its own. A metric whose data points span a batch boundary appears in both + batches, each time carrying only the data points of that batch. + + When ``max_export_batch_size`` is not ``None``, a metric that has none of + the five data fields set, and a metric whose data field holds no data + points, are both logged as a warning and left out of every batch. Neither + check runs on the unlimited path, which hands ``metrics_data`` back + untouched. + + Args: + metrics_data: the encoded request to split. It is read but never + modified, and the batches reuse its messages rather than copying + their contents. + max_export_batch_size: the largest number of data points a batch may + carry. ``None`` means no limit. A value of zero or less puts one + data point in each batch. + + Yields: + One ``ExportMetricsServiceRequest`` per batch. With + ``max_export_batch_size`` of ``None`` that is ``metrics_data`` itself, + unchanged and yielded once, even when it holds no data points at all. + For a ``max_export_batch_size`` above zero, every batch but the last + carries exactly that many data points, and a ``metrics_data`` holding + no data points at all yields nothing. """ if max_export_batch_size is None: yield metrics_data @@ -148,6 +215,17 @@ def split_metrics_data( def _get_metric_data_field_name(metric: JSONMetric) -> str | None: + """Find which of the five data fields of an OTLP metric is set. + + Args: + metric: the encoded metric to inspect. + + Returns: + The attribute name of the first field in ``_METRIC_DATA_FIELDS`` that + is not ``None``, or ``None`` when every one of them is unset. The five + fields are mutually exclusive in a well-formed metric, so at most one + can match. + """ return next( (f for f in _METRIC_DATA_FIELDS if getattr(metric, f) is not None), None, @@ -157,6 +235,23 @@ def _get_metric_data_field_name(metric: JSONMetric) -> str | None: def _iter_metric_data_points( metrics_data: JSONExportMetricsServiceRequest, ) -> Iterable[tuple[JSONResourceMetrics, JSONScopeMetrics, JSONMetric, str, list]]: + """Walk an encoded request down to the data points of each metric. + + Args: + metrics_data: the encoded request to walk. It is read but never + modified. + + Yields: + One tuple per metric that has data points, holding the + ``ResourceMetrics`` and the ``ScopeMetrics`` it sits under, the metric + itself, the attribute name of the data field that is set on it, and the + list of data points held by that field. Everything in the tuple is the + object from ``metrics_data`` rather than a copy. + + A metric with no data field set is skipped with a warning, and so is a + metric whose data field holds an empty list of data points, since + neither can be split into batches. + """ for resource_metrics in metrics_data.resource_metrics: for scope_metrics in resource_metrics.scope_metrics: for metric in scope_metrics.metrics: @@ -182,6 +277,24 @@ def _build_metric_with_data_points( field_name: str, data_points: list, ) -> JSONMetric: + """Copy a metric, replacing its data points with another list. + + The copy shares the list object passed in rather than its contents, so + appending to ``data_points`` afterwards changes the metric that comes back. + ``split_metrics_data`` relies on that: it attaches an empty list to the + metric of a batch and fills it as it walks the data points. + + Args: + metric: the metric to copy. It is left as it was. + field_name: the attribute name of the data field that is set on + ``metric``, as ``_get_metric_data_field_name`` returns it. + data_points: the list of data points the copy should carry. + + Returns: + A new ``Metric`` with the name, description, unit and aggregation + settings of ``metric`` and with ``data_points`` in place of its data + points. + """ new_data = replace(getattr(metric, field_name), data_points=data_points) return replace(metric, **{field_name: new_data}) @@ -192,6 +305,30 @@ def _build_empty_metric_batches( metric: JSONMetric, field_name: str, ) -> tuple[list[JSONResourceMetrics], list[JSONScopeMetrics], list[JSONMetric], list]: + """Start the next batch, repeating the hierarchy the current metric sits in. + + ``split_metrics_data`` calls this straight after yielding a full batch. + When the batch boundary falls part way through the data points of a metric, + the data points that are left need somewhere to go, and OTLP requires them + to be reachable through a resource and a scope, so the enclosing messages + are repeated in the new batch. When the boundary instead falls on the last + data point of a metric, ``split_metrics_data`` discards what this returns + and starts the next batch from the next metric. + + Args: + resource_metrics: the ``ResourceMetrics`` the current metric sits + under. + scope_metrics: the ``ScopeMetrics`` the current metric sits under. + metric: the metric whose data points the new batch continues with. + field_name: the attribute name of the data field that is set on + ``metric``. + + Returns: + The four lists that make up a batch, nested into one another and all + empty of data points: the resource metrics list that becomes the + request, the scope metrics list inside it, the metrics list inside + that, and the data points list the caller appends to. + """ data_points_batch = [] metrics_batch = [_build_metric_with_data_points(metric, field_name, data_points_batch)] scope_metrics_batch = [replace(scope_metrics, metrics=metrics_batch)] @@ -207,6 +344,16 @@ def _build_empty_metric_batches( def _encode_resource_metrics( rm: ResourceMetrics, ) -> JSONResourceMetrics: + """Encode the metrics collected under one resource. + + Args: + rm: the resource and the per-scope metrics collected under it. + + Returns: + A ``ResourceMetrics`` carrying the attributes and the schema URL of the + resource and one ``ScopeMetrics`` per instrumentation scope, in the + order ``rm`` holds them. + """ return JSONResourceMetrics( resource=JSONResource(attributes=_encode_attributes(rm.resource.attributes)), scope_metrics=[_encode_scope_metrics(sm) for sm in rm.scope_metrics], @@ -217,6 +364,15 @@ def _encode_resource_metrics( def _encode_scope_metrics( sm: ScopeMetrics, ) -> JSONScopeMetrics: + """Encode the metrics collected under one instrumentation scope. + + Args: + sm: the instrumentation scope and the metrics collected under it. + + Returns: + A ``ScopeMetrics`` carrying the encoded scope, the schema URL of the + scope and one ``Metric`` per metric, in the order ``sm`` holds them. + """ return JSONScopeMetrics( scope=_encode_instrumentation_scope(sm.scope), schema_url=sm.scope.schema_url, @@ -225,6 +381,34 @@ def _encode_scope_metrics( def _encode_metric(metric: Metric) -> JSONMetric: + """Encode one metric, choosing the data field from its aggregated type. + + A gauge, a histogram, a sum and an exponential histogram each map to the + OTLP field of that name. Anything else is logged as a warning and produces + a metric with no data field set. Exported unsplit, such a metric reaches a + receiver as a metric with no data points rather than as an error. Passed to + ``split_metrics_data`` with a ``max_export_batch_size`` that is not + ``None``, it is warned about a second time and left out of every batch, so + it reaches no receiver at all. + + Args: + metric: the metric to encode, carrying its aggregated data. + + Returns: + A ``Metric`` with the name, the description and the unit of ``metric``. + When the aggregated data is a gauge, a histogram, a sum or an + exponential histogram, the matching one of the five data fields holds + the encoded data points and the other four are left unset. Any other + aggregated type leaves all five unset. The fifth field, ``summary``, + is never written here, so only four of the five can ever carry data + points. For a sum and for both kinds of histogram the aggregation + temporality is copied across, and for a sum so is whether it is + monotonic. + + Raises: + ValueError: if an exemplar on one of the data points carries a value + that is neither an ``int`` nor a ``float``. + """ json_metric = JSONMetric( name=metric.name, description=metric.description, @@ -261,6 +445,23 @@ def _encode_metric(metric: Metric) -> JSONMetric: def _encode_gauge_data_point( data_point: NumberDataPoint, ) -> JSONNumberDataPoint: + """Encode one data point of a gauge. + + The start time of the data point is left out, since a gauge reports the + value at an instant rather than over an interval. + + Args: + data_point: the data point to encode. + + Returns: + A ``NumberDataPoint`` whose value is in ``as_int`` for an ``int`` and + in ``as_double`` for anything else, which in practice means a + ``float``. + + Raises: + ValueError: if an exemplar on the data point carries a value that is + neither an ``int`` nor a ``float``. + """ pt = JSONNumberDataPoint( attributes=_encode_attributes(data_point.attributes), time_unix_nano=data_point.time_unix_nano, @@ -276,6 +477,26 @@ def _encode_gauge_data_point( def _encode_sum_data_point( data_point: NumberDataPoint, ) -> JSONNumberDataPoint: + """Encode one data point of a sum. + + The start time of the data point is carried across, which is what lets a + receiver detect a reset in a cumulative series and bound the interval a + delta value covers. Whether the sum is cumulative or delta is carried by + ``aggregation_temporality`` on the enclosing ``Sum``, not by the start + time, which both kinds have. + + Args: + data_point: the data point to encode. + + Returns: + A ``NumberDataPoint`` whose value is in ``as_int`` for an ``int`` and + in ``as_double`` for anything else, which in practice means a + ``float``. + + Raises: + ValueError: if an exemplar on the data point carries a value that is + neither an ``int`` nor a ``float``. + """ pt = JSONNumberDataPoint( attributes=_encode_attributes(data_point.attributes), start_time_unix_nano=data_point.start_time_unix_nano, @@ -292,6 +513,22 @@ def _encode_sum_data_point( def _encode_histogram_data_point( data_point: HistogramDataPoint, ) -> JSONHistogramDataPoint: + """Encode one data point of an explicit bucket histogram. + + Args: + data_point: the data point to encode. + + Returns: + A ``HistogramDataPoint`` carrying the count, the sum, the bucket counts + and the explicit bounds, plus the minimum and the maximum, which are + ``None`` when the aggregation was configured not to record them. The + bucket counts and the explicit bounds are copied into new lists, so a + later change to the SDK data point does not reach the encoded one. + + Raises: + ValueError: if an exemplar on the data point carries a value that is + neither an ``int`` nor a ``float``. + """ return JSONHistogramDataPoint( attributes=_encode_attributes(data_point.attributes), time_unix_nano=data_point.time_unix_nano, @@ -309,6 +546,25 @@ def _encode_histogram_data_point( def _encode_exponential_histogram_data_point( data_point: ExponentialHistogramDataPoint, ) -> JSONExponentialHistogramDataPoint: + """Encode one data point of an exponential bucket histogram. + + Args: + data_point: the data point to encode. + + Returns: + An ``ExponentialHistogramDataPoint`` carrying the count, the sum, the + scale, the count of values that fell in the zero bucket and the flags, + plus the minimum and the maximum, which are ``None`` when the + aggregation was configured not to record them. The positive and the + negative bucket sets are each left unset only when the list of bucket + counts is empty, which a data point the SDK produced never has: a fresh + set of buckets already holds the single count zero, so a histogram of + only positive values still encodes a negative bucket set. + + Raises: + ValueError: if an exemplar on the data point carries a value that is + neither an ``int`` nor a ``float``. + """ return JSONExponentialHistogramDataPoint( attributes=_encode_attributes(data_point.attributes), time_unix_nano=data_point.time_unix_nano, @@ -343,6 +599,26 @@ def _encode_exponential_histogram_data_point( def _encode_exemplars( sdk_exemplars: Collection[Exemplar], ) -> list[JSONExemplar]: + """Encode the exemplars attached to one metric data point. + + An exemplar links a recorded measurement back to the trace that was active + when it was taken. The trace and span identifiers are encoded only when the + exemplar carries both, so an exemplar recorded outside any span is still + exported, with its timestamp, its value and its filtered attributes but no + trace link. + + Args: + sdk_exemplars: the exemplars of one data point, in the order the + aggregation holds them. + + Returns: + One ``Exemplar`` per exemplar, with the value in ``as_double`` for a + ``float`` and in ``as_int`` for an ``int``. + + Raises: + ValueError: if an exemplar carries a value that is neither an ``int`` + nor a ``float``. + """ json_exemplars = [] for sdk_exemplar in sdk_exemplars: if sdk_exemplar.span_id is not None and sdk_exemplar.trace_id is not None: diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py index 3655839bf63..408183e44df 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py @@ -3,6 +3,20 @@ # pyright: reportCallIssue=false +"""Encoding of SDK spans into OTLP JSON trace messages. + +``encode_spans`` is the entry point and is re-exported from +``opentelemetry.exporter.otlp.json.common.trace_encoder``, which is where a +span exporter should import it from. The rest of this module is private. + +A span exporter hands over a flat collection of spans, while OTLP wants them +grouped first by resource and then by instrumentation scope. Rebuilding that +grouping is most of the work here, and it relies on both +``opentelemetry.sdk.resources.Resource`` and +``opentelemetry.sdk.util.instrumentation.InstrumentationScope`` being hashable, +so that each can be used as a dictionary key. +""" + from __future__ import annotations import logging @@ -49,12 +63,49 @@ def encode_spans( sdk_spans: Collection[ReadableSpan], ) -> JSONExportTraceServiceRequest: + """Encode a batch of SDK spans as an OTLP export request. + + Args: + sdk_spans: the spans to export, in any order and from any number of + resources and instrumentation scopes. + + Returns: + An ``ExportTraceServiceRequest`` holding every span in ``sdk_spans``, + regrouped by resource and then by instrumentation scope. An empty + ``sdk_spans`` gives a request with no resource spans. + + Raises: + KeyError: if the kind of one of the spans is not one of the five + members of ``opentelemetry.trace.SpanKind``. One such span fails + the export of the whole batch. + """ return JSONExportTraceServiceRequest(resource_spans=_encode_resource_spans(sdk_spans)) def _encode_resource_spans( sdk_spans: Collection[ReadableSpan], ) -> list[JSONResourceSpans]: + """Group a flat collection of SDK spans into OTLP ``ResourceSpans``. + + Spans sharing a resource end up in one ``ResourceSpans``, and within it + spans sharing an instrumentation scope end up in one ``ScopeSpans``. The + schema URL of the resource is written on the ``ResourceSpans`` and the + schema URL of the scope on the ``ScopeSpans``. + + Args: + sdk_spans: the spans to group, in any order. + + Returns: + One ``ResourceSpans`` per distinct resource, in the order each resource + was first seen in ``sdk_spans``. A span whose instrumentation scope is + absent or falsy is grouped under a scope of ``None``, which encodes as + an empty scope message and leaves the schema URL of the ``ScopeSpans`` + unset. + + Raises: + KeyError: if the kind of one of the spans is not one of the five + members of ``opentelemetry.trace.SpanKind``. + """ sdk_resource_spans = defaultdict(lambda: defaultdict(list)) for sdk_span in sdk_spans: @@ -85,6 +136,32 @@ def _encode_resource_spans( def _span_flags(parent_span_context: SpanContext | None) -> int: + """Build the OTLP ``flags`` field recording whether a span context was remote. + + ``SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK`` is always set, which tells a + receiver that the ``is_remote`` bit beside it carries a real answer rather + than meaning "unknown". ``SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK`` is set on top + of it only for a context that came from a remote caller. The eight low bits + that OTLP reserves for the W3C trace flags are left at zero. + + Both the ``flags`` of a span and the ``flags`` of a link are built here, so + which span the answer is about depends on the caller. On a span it is the + parent of that span, and on a link it is the span the link points at. + + Args: + parent_span_context: the span context being asked about, which is the + parent context of a span or the context a link points at. ``None`` + stands for a root span, and only a span reaches that case, since a + link always carries a context. + + Returns: + ``SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK`` on its own for a context that + is not remote, and that mask combined with + ``SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK`` for one that is. A + ``parent_span_context`` of ``None`` and one with ``is_remote`` false + give the same value, since both state that the context is known not to + be remote. + """ flags = JSONSpanFlags.SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK if parent_span_context and parent_span_context.is_remote: flags |= JSONSpanFlags.SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK @@ -92,6 +169,22 @@ def _span_flags(parent_span_context: SpanContext | None) -> int: def _encode_span(sdk_span: ReadableSpan) -> JSONSpan: + """Encode one SDK span as an OTLP JSON ``Span``. + + Args: + sdk_span: the span to encode. It is expected to have ended, since + ``end_time`` is copied straight across and is ``None`` until then. + + Returns: + The encoded ``Span``, including its events, its links, its status and + the counts of attributes, events and links the SDK limits dropped. A + span with no recorded span context encodes its trace and span + identifiers as all-zero bytes. + + Raises: + KeyError: if ``sdk_span.kind`` is not one of the five members of + ``opentelemetry.trace.SpanKind``. + """ span_context = sdk_span.get_span_context() return JSONSpan( trace_id=_encode_trace_id(span_context.trace_id if span_context else 0), @@ -116,6 +209,16 @@ def _encode_span(sdk_span: ReadableSpan) -> JSONSpan: def _encode_events( events: Collection[Event], ) -> list[JSONSpan.Event]: + """Encode the events recorded on a span. + + Args: + events: the events of one span, in the order the SDK recorded them. + + Returns: + One ``Span.Event`` per event, carrying the name, the timestamp, the + attributes and the count of attributes the SDK limits dropped from that + event. + """ return [ JSONSpan.Event( name=event.name, @@ -128,6 +231,20 @@ def _encode_events( def _encode_links(links: Collection[Link]) -> list[JSONSpan.Link]: + """Encode the links recorded on a span. + + The trace state of a linked span context is not encoded, even though OTLP + has a field for it. + + Args: + links: the links of one span, in the order the SDK recorded them. + + Returns: + One ``Span.Link`` per link, carrying the trace and span identifiers of + the linked context, the attributes of the link, the count of attributes + the SDK limits dropped from it, and the flags that record whether the + linked context was remote. + """ return [ JSONSpan.Link( trace_id=_encode_trace_id(link.context.trace_id), @@ -141,6 +258,17 @@ def _encode_links(links: Collection[Link]) -> list[JSONSpan.Link]: def _encode_status(status: Status | None) -> JSONStatus | None: + """Encode the status of a span. + + Args: + status: the status the span ended with, or ``None`` when the span + carries none. + + Returns: + A ``Status`` whose code is the numeric value of the SDK status code and + whose message is the description of the status, or ``None`` when + ``status`` is ``None``, which leaves the field out of the encoded span. + """ return ( JSONStatus( code=JSONStatus.StatusCode(status.status_code.value), @@ -152,8 +280,30 @@ def _encode_status(status: Status | None) -> JSONStatus | None: def _encode_trace_state(trace_state: TraceState | None) -> str | None: + """Encode a trace state as the single header string OTLP carries it in. + + Args: + trace_state: the trace state of a span context, or ``None``. + + Returns: + The entries rendered as ``key=value`` and joined with commas, in the + order the trace state holds them, which W3C Trace Context defines as + most recently updated first. An empty trace state gives an empty + string, and a ``trace_state`` of ``None`` gives ``None``. + """ return ",".join([f"{key}={value}" for key, value in (trace_state.items())]) if trace_state is not None else None def _encode_context_span_id(context: SpanContext | None) -> bytes | None: + """Encode the span identifier held by a parent span context. + + Args: + context: the span context of the parent of a span, or ``None`` for a + root span. + + Returns: + The eight-byte form of the span identifier, or ``None`` for a root + span, which leaves ``parent_span_id`` out of the encoded span and so + marks it as a root to the receiver. + """ return _encode_span_id(context.span_id) if context else None diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py index ebe6f660de0..fa46e4a2469 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py @@ -1,6 +1,17 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Access to the OTLP JSON log record encoder. + +``encode_logs`` is implemented in +``opentelemetry.exporter.otlp.json.common._internal._log_encoder`` and +re-exported here. A log record exporter should import it from this module +rather than from the implementation module, whose contents can change in any +release. The leading underscore in the name of this module mirrors the one on +``opentelemetry.sdk._logs``, the logs part of the SDK being unstable, and is +not an instruction to import from somewhere else. +""" + from opentelemetry.exporter.otlp.json.common._internal._log_encoder import ( encode_logs, ) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py index 1247e717be5..c7bcbf894f6 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py @@ -1,6 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Public access to the OTLP JSON metrics encoder. + +``encode_metrics`` is implemented in +``opentelemetry.exporter.otlp.json.common._internal.metrics_encoder`` and +re-exported here. A metric exporter should import it from this module, because +the implementation module is private and its contents can change in any +release. + +``split_metrics_data``, which cuts an encoded request into smaller ones, is +deliberately left out of this module. An exporter that needs it has to accept +that it is reaching into private code. +""" + from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( encode_metrics, ) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py index 7f17e8cc593..198d12552fb 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py @@ -1,6 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Public access to the OTLP JSON span encoder. + +``encode_spans`` is implemented in +``opentelemetry.exporter.otlp.json.common._internal.trace_encoder`` and +re-exported here. A span exporter should import it from this module, because +the implementation module is private and its contents can change in any +release. +""" + from opentelemetry.exporter.otlp.json.common._internal.trace_encoder import ( encode_spans, ) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py index b83363231d3..aef21a9d1c0 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py @@ -1,4 +1,13 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Version of the ``opentelemetry-exporter-otlp-json-common`` distribution. + +``[tool.hatch.version]`` in the ``pyproject.toml`` of the distribution names +this module, so the ``__version__`` string below is the version that hatchling +writes into a built wheel or sdist. ``opentelemetry-exporter-otlp-json-http`` +and ``opentelemetry-exporter-otlp-json-file`` require this distribution at an +exact version, so a change here has to be made in those two pins as well. +""" + __version__ = "0.67b0.dev" diff --git a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/__init__.py b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/__init__.py index e976a07b387..426230615bc 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/__init__.py +++ b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/__init__.py @@ -81,10 +81,27 @@ DEFAULT_ENDPOINT = "http://localhost:9411/api/v2/spans" REQUESTS_SUCCESS_STATUS_CODES = (200, 202) +_DEFAULT_TIMEOUT = 10 logger = logging.getLogger(__name__) +def _timeout_from_env() -> int: + value = environ.get(OTEL_EXPORTER_ZIPKIN_TIMEOUT) + if value is None or not value.strip(): + return _DEFAULT_TIMEOUT + try: + return int(value) + except ValueError: + logger.warning( + "Invalid value %r for %s, using default of %s seconds", + value, + OTEL_EXPORTER_ZIPKIN_TIMEOUT, + _DEFAULT_TIMEOUT, + ) + return _DEFAULT_TIMEOUT + + class ZipkinExporter(SpanExporter): def __init__( self, @@ -128,7 +145,7 @@ def __init__( self.session = session or requests.Session() self.session.headers.update({"Content-Type": self.encoder.content_type()}) self._closed = False - self.timeout = timeout or int(environ.get(OTEL_EXPORTER_ZIPKIN_TIMEOUT, 10)) + self.timeout = timeout or _timeout_from_env() def export(self, spans: Sequence[Span]) -> SpanExportResult: # After the call to Shutdown subsequent calls to Export are diff --git a/exporter/opentelemetry-exporter-zipkin-json/tests/test_zipkin_exporter.py b/exporter/opentelemetry-exporter-zipkin-json/tests/test_zipkin_exporter.py index ced0bd27d81..0e49d1d81f5 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/tests/test_zipkin_exporter.py +++ b/exporter/opentelemetry-exporter-zipkin-json/tests/test_zipkin_exporter.py @@ -63,6 +63,52 @@ def test_constructor_env_vars(self): self.assertEqual(exporter.local_node.port, None) self.assertEqual(exporter.timeout, 15) + def test_constructor_invalid_timeout_env_var_falls_back(self): + with patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: "10s"}): + with self.assertLogs("opentelemetry.exporter.zipkin.json", level="WARNING") as cm: + exporter = ZipkinExporter() + + self.assertEqual(exporter.timeout, 10) + self.assertEqual(len(cm.records), 1) + self.assertTrue( + any("Invalid value" in message for message in cm.output), + cm.output, + ) + + def test_constructor_empty_timeout_env_var_falls_back(self): + for value in ("", " ", "\t\n"): + with self.subTest(value=value), patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: value}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.json", level="WARNING"): + exporter = ZipkinExporter() + self.assertEqual(exporter.timeout, 10) + + def test_constructor_unset_timeout_env_var_uses_default(self): + with patch.dict(os.environ): + os.environ.pop(OTEL_EXPORTER_ZIPKIN_TIMEOUT, None) + with self.assertNoLogs("opentelemetry.exporter.zipkin.json", level="WARNING"): + exporter = ZipkinExporter() + self.assertEqual(exporter.timeout, 10) + + def test_constructor_numeric_timeout_env_var_preserves_values(self): + for value, expected in (("15", 15), (" 15 ", 15), ("0", 0), ("-1", -1)): + with self.subTest(value=value), patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: value}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.json", level="WARNING"): + exporter = ZipkinExporter() + self.assertEqual(exporter.timeout, expected) + + def test_constructor_explicit_timeout_ignores_invalid_env_var(self): + for value in ("10s", "", "\t\n"): + with self.subTest(value=value), patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: value}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.json", level="WARNING"): + exporter = ZipkinExporter(timeout=7) + self.assertEqual(exporter.timeout, 7) + + def test_constructor_explicit_timeout_preserves_truthiness(self): + with patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: "15"}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.json", level="WARNING"): + self.assertEqual(ZipkinExporter(timeout=0).timeout, 15) + self.assertEqual(ZipkinExporter(timeout=-1).timeout, -1) + def test_constructor_protocol_endpoint(self): """Test the constructor for the common usage of providing the protocol and endpoint arguments.""" diff --git a/exporter/opentelemetry-exporter-zipkin-proto-http/src/opentelemetry/exporter/zipkin/proto/http/__init__.py b/exporter/opentelemetry-exporter-zipkin-proto-http/src/opentelemetry/exporter/zipkin/proto/http/__init__.py index e664f20624b..28dc8f2c85c 100644 --- a/exporter/opentelemetry-exporter-zipkin-proto-http/src/opentelemetry/exporter/zipkin/proto/http/__init__.py +++ b/exporter/opentelemetry-exporter-zipkin-proto-http/src/opentelemetry/exporter/zipkin/proto/http/__init__.py @@ -78,10 +78,27 @@ DEFAULT_ENDPOINT = "http://localhost:9411/api/v2/spans" REQUESTS_SUCCESS_STATUS_CODES = (200, 202) +_DEFAULT_TIMEOUT = 10 logger = logging.getLogger(__name__) +def _timeout_from_env() -> int: + value = environ.get(OTEL_EXPORTER_ZIPKIN_TIMEOUT) + if value is None or not value.strip(): + return _DEFAULT_TIMEOUT + try: + return int(value) + except ValueError: + logger.warning( + "Invalid value %r for %s, using default of %s seconds", + value, + OTEL_EXPORTER_ZIPKIN_TIMEOUT, + _DEFAULT_TIMEOUT, + ) + return _DEFAULT_TIMEOUT + + class ZipkinExporter(SpanExporter): def __init__( self, @@ -121,7 +138,7 @@ def __init__( self.session = session or requests.Session() self.session.headers.update({"Content-Type": self.encoder.content_type()}) self._closed = False - self.timeout = timeout or int(environ.get(OTEL_EXPORTER_ZIPKIN_TIMEOUT, 10)) + self.timeout = timeout or _timeout_from_env() def export(self, spans: Sequence[Span]) -> SpanExportResult: # After the call to Shutdown subsequent calls to Export are diff --git a/exporter/opentelemetry-exporter-zipkin-proto-http/tests/test_zipkin_exporter.py b/exporter/opentelemetry-exporter-zipkin-proto-http/tests/test_zipkin_exporter.py index 78e30b0b310..522d4fb43cb 100644 --- a/exporter/opentelemetry-exporter-zipkin-proto-http/tests/test_zipkin_exporter.py +++ b/exporter/opentelemetry-exporter-zipkin-proto-http/tests/test_zipkin_exporter.py @@ -65,6 +65,52 @@ def test_constructor_env_vars(self): self.assertEqual(exporter.local_node.port, None) self.assertEqual(exporter.timeout, 15) + def test_constructor_invalid_timeout_env_var_falls_back(self): + with patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: "10s"}): + with self.assertLogs("opentelemetry.exporter.zipkin.proto.http", level="WARNING") as cm: + exporter = ZipkinExporter() + + self.assertEqual(exporter.timeout, 10) + self.assertEqual(len(cm.records), 1) + self.assertTrue( + any("Invalid value" in message for message in cm.output), + cm.output, + ) + + def test_constructor_empty_timeout_env_var_falls_back(self): + for value in ("", " ", "\t\n"): + with self.subTest(value=value), patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: value}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.proto.http", level="WARNING"): + exporter = ZipkinExporter() + self.assertEqual(exporter.timeout, 10) + + def test_constructor_unset_timeout_env_var_uses_default(self): + with patch.dict(os.environ): + os.environ.pop(OTEL_EXPORTER_ZIPKIN_TIMEOUT, None) + with self.assertNoLogs("opentelemetry.exporter.zipkin.proto.http", level="WARNING"): + exporter = ZipkinExporter() + self.assertEqual(exporter.timeout, 10) + + def test_constructor_numeric_timeout_env_var_preserves_values(self): + for value, expected in (("15", 15), (" 15 ", 15), ("0", 0), ("-1", -1)): + with self.subTest(value=value), patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: value}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.proto.http", level="WARNING"): + exporter = ZipkinExporter() + self.assertEqual(exporter.timeout, expected) + + def test_constructor_explicit_timeout_ignores_invalid_env_var(self): + for value in ("10s", "", "\t\n"): + with self.subTest(value=value), patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: value}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.proto.http", level="WARNING"): + exporter = ZipkinExporter(timeout=7) + self.assertEqual(exporter.timeout, 7) + + def test_constructor_explicit_timeout_preserves_truthiness(self): + with patch.dict(os.environ, {OTEL_EXPORTER_ZIPKIN_TIMEOUT: "15"}): + with self.assertNoLogs("opentelemetry.exporter.zipkin.proto.http", level="WARNING"): + self.assertEqual(ZipkinExporter(timeout=0).timeout, 15) + self.assertEqual(ZipkinExporter(timeout=-1).timeout, -1) + def test_constructor_protocol_endpoint(self): """Test the constructor for the common usage of providing the protocol and endpoint arguments.""" diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py b/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py index e5ceb0f59f5..dca1e14c805 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py @@ -14,7 +14,7 @@ from collections.abc import Mapping from dataclasses import MISSING, fields, is_dataclass from enum import Enum -from types import UnionType +from types import NoneType, UnionType from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints _T = TypeVar("_T") @@ -27,7 +27,7 @@ def _unwrap_optional(type_hint: Any) -> Any: """ origin = get_origin(type_hint) if origin is UnionType or origin is Union: - non_none = [t for t in get_args(type_hint) if t is not type(None)] + non_none = [t for t in get_args(type_hint) if t is not NoneType] if len(non_none) == 1: return non_none[0] return type_hint diff --git a/opentelemetry-semantic-conventions/.pylintrc b/opentelemetry-semantic-conventions/.pylintrc index b034d8c75ad..621216e432e 100644 --- a/opentelemetry-semantic-conventions/.pylintrc +++ b/opentelemetry-semantic-conventions/.pylintrc @@ -36,10 +36,6 @@ persistent=yes # Specify a configuration file. #rcfile= -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no