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/docs/exporter/zipkin/zipkin.rst b/docs/exporter/zipkin/zipkin.rst index a33b7f5de1f..b9841f474d4 100644 --- a/docs/exporter/zipkin/zipkin.rst +++ b/docs/exporter/zipkin/zipkin.rst @@ -6,11 +6,31 @@ OpenTelemetry Zipkin Exporters :undoc-members: :show-inheritance: +.. automodule:: opentelemetry.exporter.zipkin.encoder + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: opentelemetry.exporter.zipkin.node_endpoint + :members: + :undoc-members: + :show-inheritance: + .. automodule:: opentelemetry.exporter.zipkin.json :members: :undoc-members: :show-inheritance: +.. automodule:: opentelemetry.exporter.zipkin.json.v1 + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: opentelemetry.exporter.zipkin.json.v2 + :members: + :undoc-members: + :show-inheritance: + .. automodule:: opentelemetry.exporter.zipkin.proto.http :members: :undoc-members: diff --git a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/encoder/__init__.py b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/encoder/__init__.py index a6283a7ca4f..3b20885f7c8 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/encoder/__init__.py +++ b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/encoder/__init__.py @@ -56,24 +56,60 @@ class Encoder(abc.ABC): """ def __init__(self, max_tag_value_length: int = DEFAULT_MAX_TAG_VALUE_LENGTH): + """Initialize the encoder. + + The parameters are documented on the class. + """ self.max_tag_value_length = max_tag_value_length @staticmethod @abc.abstractmethod def content_type() -> str: - pass + """Return the media type of the payload this encoder produces. + + The exporter sends the value as the ``Content-Type`` header of every + request to the Zipkin collector, so it has to name the format the + collector should parse the body as. + + Returns: + The media type, for example ``"application/json"``. + """ @abc.abstractmethod def serialize(self, spans: Sequence[Span], local_endpoint: NodeEndpoint) -> str: - pass + """Encode a batch of spans into a single request body. + + Args: + spans: The spans to encode. An empty sequence still produces a + valid body, which encodes to an empty list of spans. + local_endpoint: The network context to attach to the encoded + spans. Where it ends up in the payload depends on the Zipkin + API version: v2 writes it once per span, v1 repeats it inside + every annotation and every binary annotation. + + Returns: + The request body to send to the Zipkin collector. + """ @abc.abstractmethod def _encode_span(self, span: Span, encoded_local_endpoint: EncodedLocalEndpointT) -> Any: - """ + """Encode one span. + Per spec Zipkin fields that can be absent SHOULD be omitted from the payload when they are empty in the OpenTelemetry Span. https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/sdk_exporters/zipkin.md#request-payload + + Args: + span: The span to encode. It must have ended, because the encoding + reads ``end_time`` to compute the duration. + encoded_local_endpoint: The already encoded local endpoint, as + returned by ``_encode_local_endpoint``. It is encoded once per + batch and passed in here so that it is not rebuilt per span. + + Returns: + The encoded span, in whatever representation the concrete encoder + serializes, which for the JSON encoders is a ``dict``. """ @staticmethod @@ -81,24 +117,86 @@ def _encode_span(self, span: Span, encoded_local_endpoint: EncodedLocalEndpointT def _encode_local_endpoint( local_endpoint: NodeEndpoint, ) -> EncodedLocalEndpointT: - pass + """Encode the local endpoint once, for reuse across a whole batch. + + Args: + local_endpoint: The network context of the node that recorded the + spans. + + Returns: + The encoded endpoint, to be handed to ``_encode_span`` for every + span of the batch. + """ @staticmethod def _encode_debug(span_context) -> Any: + """Report whether the span should carry the Zipkin ``debug`` flag. + + The sampled flag of the span context is used as the debug flag. + OpenTelemetry has no separate debug flag to map onto it. + + Args: + span_context: The span context to read the trace flags from. + + Returns: + True when the context is sampled, which makes the caller emit a + ``debug`` field. The encoders omit the field when this is false. + """ return span_context.trace_flags.sampled @staticmethod @abc.abstractmethod def _encode_span_id(span_id: int) -> Any: - pass + """Encode a span id into the representation the payload uses. + + Args: + span_id: The span id, as the 64 bit integer the SDK holds. + + Returns: + The encoded span id, which for the JSON encoders is the 16 + character lowercase hexadecimal string Zipkin expects. + """ @staticmethod @abc.abstractmethod def _encode_trace_id(trace_id: int) -> Any: - pass + """Encode a trace id into the representation the payload uses. + + Args: + trace_id: The trace id, as the 128 bit integer the SDK holds. + + Returns: + The encoded trace id, which for the JSON encoders is the 32 + character lowercase hexadecimal string Zipkin expects. + """ @staticmethod def _get_parent_id(span_context) -> int | None: + """Extract the id of the parent span from whatever ``span.parent`` holds. + + ``span.parent`` is a ``SpanContext`` for an ordinary child span and + ``None`` for a root span, and this also accepts a ``Span``, in which + case the id is read from the parent of that span. + + Args: + span_context: The value of ``span.parent``, or any other value, + since anything that is neither a ``Span`` nor a + ``SpanContext`` is treated as absent. + + Returns: + The parent span id, or ``None`` when the argument is neither a + ``Span`` nor a ``SpanContext``, which is what ``span.parent`` + holds for a root span. The encoders omit the ``parentId`` field + when this is ``None``. + + Raises: + AttributeError: If the argument is a ``Span`` whose ``parent`` is + ``None``, or one with no ``parent`` attribute at all, such + as ``NonRecordingSpan``, since that branch reads + ``span.parent.span_id`` without guarding. Both encoders pass + ``span.parent`` rather than the span, so they never reach + this. + """ if isinstance(span_context, Span): parent_id = span_context.parent.span_id elif isinstance(span_context, SpanContext): @@ -108,6 +206,28 @@ def _get_parent_id(span_context) -> int | None: return parent_id def _extract_tags_from_dict(self, tags_dict: dict | None) -> dict[str, str]: + """Convert a mapping of attributes into Zipkin tags. + + Zipkin tag values are strings, so every value is stringified: a bool + becomes ``"true"`` or ``"false"``, a number becomes its ``str``, and a + sequence becomes a compact JSON array. A value of any other type, and + a sequence when ``max_tag_value_length`` is 1, is dropped with a + warning on the ``opentelemetry.exporter.zipkin.encoder`` logger rather + than failing the export. A sequence whose elements do not fit the + limit is kept as the empty array ``"[]"``. + + Values are truncated to ``max_tag_value_length`` characters when that + limit is set to a positive number. Truncation applies to the rendered + string, so a truncated JSON array is not valid JSON on its own. + + Args: + tags_dict: The attributes to convert, or ``None``. ``None`` and an + empty mapping both yield an empty result. + + Returns: + The tags, keyed by attribute name. Keys whose values could not be + serialized are absent. + """ tags = {} if not tags_dict: return tags @@ -131,6 +251,37 @@ def _extract_tags_from_dict(self, tags_dict: dict | None) -> dict[str, str]: return tags def _extract_tag_value_string_from_sequence(self, sequence: Sequence): + """Render a sequence attribute as a compact JSON array string. + + The length limit is honored at the element boundary rather than by + cutting the finished string, so the result stays valid JSON. Elements + are appended while the running length of the rendered array, counting + the brackets, the quotes and the separating commas, stays within + ``max_tag_value_length``. The first element that would exceed it ends + the array, and the remaining elements are dropped silently. + + That running length measures each element before ``json.dumps`` + escapes it, so an element holding any character that JSON escapes, + which is a non-ASCII character, a double quote, a backslash or a + control character such as a newline, renders longer than it was + counted as, and the array can come back longer than + ``max_tag_value_length``. The caller then truncates it, which is the + one case in which a sequence tag ends up as invalid JSON. + + An element that is not a bool, a number, a string or ``None`` is + skipped without ending the array, and without counting towards the + length. + + Args: + sequence: The attribute value to render. + + Returns: + The JSON array, or ``None`` when ``max_tag_value_length`` is 1, + since one character cannot hold even an empty array. The caller + treats ``None`` as a failure to serialize and drops the tag with a + warning. A sequence whose elements all exceed the limit still + returns ``"[]"``, which the caller keeps. + """ if self.max_tag_value_length and self.max_tag_value_length == 1: return None @@ -167,6 +318,36 @@ def _extract_tag_value_string_from_sequence(self, sequence: Sequence): return json.dumps(tag_value_elements, separators=(",", ":")) def _extract_tags_from_span(self, span: Span) -> dict[str, str]: + """Collect every Zipkin tag a span contributes. + + Four groups are merged, later groups overwriting earlier ones on a key + collision: the attributes of the span, the attributes of its resource, + the name and version of its instrumentation scope, and the status and + dropped counts. + + The scope name and version are written under both ``otel.scope.name`` + and ``otel.scope.version`` and the older ``otel.library.name`` and + ``otel.library.version``. These four bypass the stringification and + the truncation that the attribute groups go through, so a scope name + or version longer than ``max_tag_value_length`` reaches the v2 + payload at its full length while every attribute value was cut to + that limit. The v1 encoder cuts them to the limit of its own accord + when it builds the binary annotations. + + ``otel.status_code`` is written only when the status is not UNSET, and + ``error`` is added alongside it when the status is ERROR, holding the + status description or an empty string when there is none. Each of + ``otel.dropped_attributes_count``, ``otel.dropped_events_count`` and + ``otel.dropped_links_count`` appears only when that count is non-zero. + + Args: + span: The span to read. It is expected to be an SDK span, since + the resource, the instrumentation scope and the dropped counts + are SDK attributes that the API span has no counterpart for. + + Returns: + The merged tags. + """ tags = self._extract_tags_from_dict(span.attributes) if span.resource: tags.update(self._extract_tags_from_dict(span.resource.attributes)) @@ -196,6 +377,31 @@ def _extract_tags_from_span(self, span: Span) -> dict[str, str]: return tags def _extract_annotations_from_events(self, events: list[Event] | None) -> list[dict] | None: + """Convert span events into Zipkin annotations. + + Zipkin gives an annotation one timestamp and one string value, so the + name of the event and all of its attributes are packed into that + single value as a JSON object of the form ``{name: attributes}``, with + keys sorted so that the output is stable. String attribute values are + truncated to ``max_tag_value_length`` when that limit is positive; + values of other types are left alone. + + Args: + events: The events of the span, or ``None``. + + Returns: + One annotation per event, in the order the events were recorded, + or ``None`` when there are no events. The encoders omit the + ``annotations`` field when this is ``None``. + + Raises: + AttributeError: If an event has ``attributes`` of ``None``, since + the attributes are read with ``.items()`` without guarding. + An event recorded through ``Span.add_event`` carries an empty + mapping instead, so the SDK never produces one, while an + ``opentelemetry.sdk.trace.Event`` built directly without + attributes does. + """ if not events: return None @@ -221,16 +427,59 @@ def _nsec_to_usec_round(nsec: int) -> int: Timestamp in zipkin spans is int of microseconds. See: https://zipkin.io/pages/instrumenting.html + + Args: + nsec: The value in nanoseconds. It is a wall clock timestamp for + the start of a span or an event, and a difference of two such + timestamps for a duration. + + Returns: + The value in microseconds, rounded half up, so 1500 nanoseconds + gives 2 microseconds. """ return (nsec + 500) // 10**3 class JsonEncoder(Encoder): + """Everything the JSON encoders share, independent of the API version. + + This fills in the parts of ``Encoder`` that the v1 and v2 JSON formats + agree on: the media type, the shape of the request body, the encoding of + ids as hexadecimal strings, and the encoding of the local endpoint. The + per-span encoding differs between the two versions, so ``_encode_span`` is + still abstract here and is supplied by ``JsonV1Encoder`` and + ``JsonV2Encoder``. + + Args: + max_tag_value_length: maximum length of an exported tag value. Values + will be truncated to conform. Since values are serialized to a JSON + list string, max_tag_value_length is honored at the element boundary. + """ + @staticmethod def content_type(): + """Return the media type of a JSON Zipkin payload. + + Returns: + Always ``"application/json"``, for both API versions. + """ return "application/json" def serialize(self, spans: Sequence[Span], local_endpoint: NodeEndpoint) -> str: + """Encode a batch of spans as a JSON array. + + The local endpoint is encoded once and reused for every span of the + batch. + + Args: + spans: The ended spans to encode. An empty sequence gives ``"[]"``. + local_endpoint: The network context of the node that recorded the + spans. + + Returns: + The JSON array of encoded spans, ready to be sent as the request + body. + """ encoded_local_endpoint = self._encode_local_endpoint(local_endpoint) encoded_spans = [] for span in spans: @@ -239,6 +488,17 @@ def serialize(self, spans: Sequence[Span], local_endpoint: NodeEndpoint) -> str: @staticmethod def _encode_local_endpoint(local_endpoint: NodeEndpoint) -> dict: + """Encode the local endpoint as a Zipkin endpoint object. + + Args: + local_endpoint: The network context of the node that recorded the + spans. + + Returns: + A mapping always carrying ``serviceName``, and carrying ``ipv4``, + ``ipv6`` and ``port`` only for those that the endpoint knows. The + addresses are written in their textual form. + """ encoded_local_endpoint = {"serviceName": local_endpoint.service_name} if local_endpoint.ipv4 is not None: encoded_local_endpoint["ipv4"] = str(local_endpoint.ipv4) @@ -250,8 +510,24 @@ def _encode_local_endpoint(local_endpoint: NodeEndpoint) -> dict: @staticmethod def _encode_span_id(span_id: int) -> str: + """Encode a span id as Zipkin JSON spells it. + + Args: + span_id: The span id, as the 64 bit integer the SDK holds. + + Returns: + The id as 16 lowercase hexadecimal characters, zero padded. + """ return format_span_id(span_id) @staticmethod def _encode_trace_id(trace_id: int) -> str: + """Encode a trace id as Zipkin JSON spells it. + + Args: + trace_id: The trace id, as the 128 bit integer the SDK holds. + + Returns: + The id as 32 lowercase hexadecimal characters, zero padded. + """ return format_trace_id(trace_id) 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..5cf414c9969 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,11 +81,87 @@ 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: + """Read the export timeout from the environment. + + An unset, empty or whitespace-only value is treated as absent rather than + as an error, so the default applies silently. A value that is set but does + not parse as an integer is reported at warning level before the default + applies, because that one is a mistake worth telling the operator about. + + Returns: + The number of seconds from ``OTEL_EXPORTER_ZIPKIN_TIMEOUT``, or + ``_DEFAULT_TIMEOUT`` when the variable is absent or unparsable. + """ + 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): + """Span exporter that sends JSON over HTTP to a Zipkin collector. + + Each call to ``export`` encodes the batch it is given and sends it as one + POST request, which makes export synchronous and blocking. Pair the + exporter with a ``BatchSpanProcessor`` to keep that work off the thread + that ends the spans. + + The tuple (local_node_ipv4, local_node_ipv6, local_node_port) is used to + represent the network context of a node in the service graph. + + Args: + version: The protocol version to be used. + endpoint: The endpoint of the Zipkin collector. When ``None``, the + value of the :envvar:`OTEL_EXPORTER_ZIPKIN_ENDPOINT` environment + variable is used, falling back to + ``http://localhost:9411/api/v2/spans`` when that variable is + unset or empty. The endpoint has to match the chosen version, + since the default path names the v2 API. + local_node_ipv4: Primary IPv4 address associated with this connection. + local_node_ipv6: Primary IPv6 address associated with this connection. + local_node_port: Depending on context, this could be a listen port or the + client-side of a socket. + max_tag_value_length: Max length string attribute values can have. + Under ``Protocol.V2``, ``None`` leaves tag values untruncated. + Under ``Protocol.V1``, ``None`` makes ``export`` raise + ``TypeError``, so a length has to be given to use that version. + timeout: Maximum time the Zipkin exporter will wait for each batch export. + The default value is 10s. When ``None`` or zero, the value of the + :envvar:`OTEL_EXPORTER_ZIPKIN_TIMEOUT` environment variable is + used, falling back to 10 seconds when that variable is unset. + The variable has to hold a decimal integer, since its value is + passed to ``int``. + session: Connection session to the Zipkin collector endpoint. When + ``None``, the exporter creates its own ``requests.Session`` and + closes it on shutdown. A session passed in here is closed on + shutdown too, so do not share one with code that outlives the + exporter. + + Raises: + ValueError: If ``local_node_ipv4`` or ``local_node_ipv6`` does not + parse as an address of the matching family, or if ``timeout`` is + ``None`` or zero and :envvar:`OTEL_EXPORTER_ZIPKIN_TIMEOUT` is + set to a value that is not a decimal integer. + KeyError: If the resource of the globally registered tracer provider + carries no ``service.name`` attribute, which ``NodeEndpoint`` + reads with a plain subscript. + """ + def __init__( self, version: Protocol = Protocol.V2, @@ -97,22 +173,9 @@ def __init__( timeout: int | None = None, session: requests.Session | None = None, ): - """Zipkin exporter. + """Initialize the exporter. - Args: - version: The protocol version to be used. - endpoint: The endpoint of the Zipkin collector. - local_node_ipv4: Primary IPv4 address associated with this connection. - local_node_ipv6: Primary IPv6 address associated with this connection. - local_node_port: Depending on context, this could be a listen port or the - client-side of a socket. - max_tag_value_length: Max length string attribute values can have. - timeout: Maximum time the Zipkin exporter will wait for each batch export. - The default value is 10s. - session: Connection session to the Zipkin collector endpoint. - - The tuple (local_node_ipv4, local_node_ipv6, local_node_port) is used to represent - the network context of a node in the service graph. + The parameters are documented on the class. """ self.local_node = NodeEndpoint(local_node_ipv4, local_node_ipv6, local_node_port) @@ -128,9 +191,41 @@ 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: + """Encode a batch of spans and POST it to the Zipkin collector. + + The call blocks until the collector answers or the timeout expires. + The service name of the local node is replaced, at the start of every + non-empty export, with the one on the resource of the first span of + that batch, whenever that span carries one. Otherwise the value held + from before is kept, which for the first export is the one sampled + when the exporter was built. Reading only the first span rests on the + assumption that a span processor serves a single tracer provider and + so a batch cannot mix resources. + + Failures are reported through the return value and logged on the + ``opentelemetry.exporter.zipkin.json`` logger. A transport failure is + not caught, so the exception raised by ``requests`` propagates to the + caller, which for a ``BatchSpanProcessor`` means the batch is lost. + + Args: + spans: The ended spans to export. + + Returns: + ``SpanExportResult.SUCCESS`` when the collector answered 200 or + 202, and ``SpanExportResult.FAILURE`` for any other status code or + when the exporter has already been shut down. + + Raises: + TypeError: If the exporter was built with ``Protocol.V1`` and + ``max_tag_value_length`` left unset, and the batch holds a + span that contributes at least one tag whose value is a + string, which in practice is every span. Give + ``max_tag_value_length`` a length to use + ``Protocol.V1``. + """ # After the call to Shutdown subsequent calls to Export are # not allowed and should return a Failure result if self._closed: @@ -162,6 +257,13 @@ def export(self, spans: Sequence[Span]) -> SpanExportResult: return SpanExportResult.SUCCESS def shutdown(self) -> None: + """Close the HTTP session and refuse any further export. + + Every later call to ``export`` returns + ``SpanExportResult.FAILURE`` without contacting the collector. Calling + this a second time does nothing beyond logging a warning on the + ``opentelemetry.exporter.zipkin.json`` logger. + """ if self._closed: logger.warning("Exporter already shutdown, ignoring call") return @@ -169,4 +271,16 @@ def shutdown(self) -> None: self._closed = True def force_flush(self, timeout_millis: int = 30000) -> bool: + """Report that there is nothing buffered to flush. + + The exporter sends each batch during ``export`` and holds no queue of + its own, so there is no work for this to do. + + Args: + timeout_millis: Ignored, since the call returns at once. It is + accepted so that the signature matches ``SpanExporter``. + + Returns: + Always True. + """ return True diff --git a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v1/__init__.py b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v1/__init__.py index 7b7f194fba0..db0022fedca 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v1/__init__.py +++ b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v1/__init__.py @@ -9,7 +9,51 @@ # pylint: disable=W0223 class V1Encoder(Encoder): + """The part of the v1 encoding that does not depend on the wire format. + + The v1 Zipkin API has no ``tags`` field. Span attributes are carried as + binary annotations instead, and this supplies the conversion. Everything + else is left to the concrete encoder, which is why this class is still + abstract. + + Args: + max_tag_value_length: maximum length of an exported tag value. Values + will be truncated to conform. Since values are serialized to a JSON + list string, max_tag_value_length is honored at the element boundary. + """ + def _extract_binary_annotations(self, span: Span, encoded_local_endpoint: dict) -> list[dict]: + """Convert the tags of a span into v1 binary annotations. + + Each tag becomes one annotation carrying the key, the value and a copy + of the local endpoint, since v1 attaches the endpoint per annotation + rather than once per span. + + String values are truncated to ``max_tag_value_length`` when that + limit is set to a positive number. The tags that came from the + attributes of the span and of its resource were already truncated + when they were extracted, so truncating them again changes nothing. + The scope, status and dropped count tags bypass that earlier + truncation, so this is the only one they get, and the v1 payload can + therefore carry a shorter value than the v2 payload for the same span. + + Args: + span: The span whose tags to convert. + encoded_local_endpoint: The already encoded local endpoint, placed + in every annotation produced. + + Returns: + One binary annotation per tag. Empty when the span contributes no + tags. + + Raises: + TypeError: If ``max_tag_value_length`` is ``None`` and the span + contributes at least one tag whose value is a string, because + the comparison against zero has no meaning for ``None``. A + span that contributes no such tag never reaches the + comparison. ``ZipkinExporter`` passes ``None`` whenever its + own ``max_tag_value_length`` argument is left unset. + """ binary_annotations = [] for tag_key, tag_value in self._extract_tags_from_span(span).items(): if isinstance(tag_value, str) and self.max_tag_value_length > 0: @@ -28,9 +72,36 @@ class JsonV1Encoder(JsonEncoder, V1Encoder): """Zipkin Export Encoder for JSON v1 API API spec: https://github.com/openzipkin/zipkin-api/blob/master/zipkin-api.yaml + + Args: + max_tag_value_length: maximum length of an exported tag value. Values + will be truncated to conform. Since values are serialized to a JSON + list string, max_tag_value_length is honored at the element boundary. """ def _encode_span(self, span: Span, encoded_local_endpoint: dict) -> dict: + """Encode one span in the v1 JSON format. + + ``traceId``, ``id``, ``name``, ``timestamp`` and ``duration`` are + always written. ``annotations``, ``binaryAnnotations``, ``debug`` and + ``parentId`` are written only when the span has something to put in + them, which is what the specification asks for. + + The local endpoint is repeated inside every annotation and every + binary annotation, because v1 has no per-span endpoint field. + + Args: + span: The ended span to encode. + encoded_local_endpoint: The already encoded local endpoint. + + Returns: + The encoded span. + + Raises: + TypeError: If ``max_tag_value_length`` is ``None`` and the span + contributes at least one tag whose value is a string. See + ``_extract_binary_annotations``. + """ context = span.get_span_context() encoded_span = { diff --git a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v2/__init__.py b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v2/__init__.py index d4369396571..bcfc2c421e8 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v2/__init__.py +++ b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/v2/__init__.py @@ -11,6 +11,15 @@ class JsonV2Encoder(JsonEncoder): """Zipkin Export Encoder for JSON v2 API API spec: https://github.com/openzipkin/zipkin-api/blob/master/zipkin2-api.yaml + + ``SPAN_KIND_MAP`` translates an OpenTelemetry ``SpanKind`` into the Zipkin + span kind of the same name. INTERNAL has no Zipkin counterpart and maps to + ``None``, which Zipkin reads as a local span. + + Args: + max_tag_value_length: maximum length of an exported tag value. Values + will be truncated to conform. Since values are serialized to a JSON + list string, max_tag_value_length is honored at the element boundary. """ SPAN_KIND_MAP = { @@ -22,6 +31,22 @@ class JsonV2Encoder(JsonEncoder): } def _encode_span(self, span: Span, encoded_local_endpoint: dict) -> dict: + """Encode one span in the v2 JSON format. + + ``traceId``, ``id``, ``name``, ``timestamp``, ``duration``, + ``localEndpoint`` and ``kind`` are always written, with ``kind`` set + to ``None`` for an INTERNAL span. ``tags``, ``annotations``, ``debug`` + and ``parentId`` are written only when the span has something to put + in them, which is what the specification asks for. + + Args: + span: The ended span to encode. + encoded_local_endpoint: The already encoded local endpoint, + written once as ``localEndpoint``. + + Returns: + The encoded span. + """ context = span.get_span_context() encoded_span = { "traceId": self._encode_trace_id(context.trace_id), diff --git a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/version/__init__.py b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/version/__init__.py index 6e653a1c7ce..556d23c115e 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/version/__init__.py +++ b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/json/version/__init__.py @@ -1,4 +1,10 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +"""Version of the ``opentelemetry-exporter-zipkin-json`` distribution. + +The value is kept in step with the rest of the repository by the release +tooling, so edit it only through that tooling. +""" + __version__ = "1.46.0.dev" diff --git a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/node_endpoint.py b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/node_endpoint.py index 39e6e77f03a..82bbbc93568 100644 --- a/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/node_endpoint.py +++ b/exporter/opentelemetry-exporter-zipkin-json/src/opentelemetry/exporter/zipkin/node_endpoint.py @@ -1,7 +1,16 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -"""Zipkin Exporter Endpoints""" +"""Zipkin Exporter Endpoints + +A Zipkin span carries the network context of the node that recorded it. This +module holds the representation of that context, ``NodeEndpoint``, which the +encoders in ``opentelemetry.exporter.zipkin.encoder`` serialize into the +payload. Where it lands depends on the Zipkin API version: the v2 encoder +writes it once per span as ``localEndpoint``, and the v1 encoder repeats it +under the key ``endpoint`` inside every annotation and binary annotation, +because v1 has no per-span endpoint field. +""" import ipaddress @@ -9,16 +18,45 @@ from opentelemetry.sdk.resources import SERVICE_NAME, Resource IpInput = str | int | None +"""Accepted spellings of an IP address passed to ``NodeEndpoint``. + +An address may be given as a string such as ``"192.168.0.1"``, as the integer +the address packs into, or as ``None`` when the address is unknown. + +``ipaddress.ip_address`` reads an integer by its magnitude rather than by the +attribute it is assigned to, so every integer from 0 up to ``2**32 - 1`` +parses as IPv4. +An IPv6 address whose integer falls below that, such as ``::1``, which packs +into 1, is rejected when it is given to ``ipv6``, and has to be given as a +string instead. +""" class NodeEndpoint: """The network context of a node in the service graph. + The service name is not a parameter. It is read once, at construction + time, from the resource of the globally registered tracer provider, or + from a freshly created ``Resource`` when that provider carries none. The + exporter then overwrites it at the start of every non-empty export, with + the service name taken from the resource of the first span of that batch, + whenever that span carries one. The value sampled here is therefore used + only until the first batch whose first span carries a service name, and + every batch after that is sent under the most recent such name, including + an empty batch and a batch whose first span carries none. + Args: ipv4: Primary IPv4 address associated with this connection. ipv6: Primary IPv6 address associated with this connection. port: Depending on context, this could be a listen port or the client-side of a socket. None if unknown. + + Raises: + ValueError: If ``ipv4`` does not parse as an IPv4 address, or if + ``ipv6`` does not parse as an IPv6 address. + KeyError: If the resource of the globally registered tracer provider + carries no ``service.name`` attribute, since the service name is + read from that resource with a plain subscript. """ def __init__( @@ -27,6 +65,10 @@ def __init__( ipv6: IpInput = None, port: int | None = None, ): + """Initialize the endpoint. + + The parameters are documented on the class. + """ self.ipv4 = ipv4 self.ipv6 = ipv6 self.port = port @@ -42,10 +84,31 @@ def __init__( @property def ipv4(self) -> ipaddress.IPv4Address | None: + """The parsed form of the ``ipv4`` argument given to the constructor. + + Returns: + The address as an ``ipaddress.IPv4Address``, whatever spelling it + was assigned in, or ``None`` when no IPv4 address was given. The + encoders omit the ``ipv4`` field of the Zipkin endpoint when this + is ``None``. + """ return self._ipv4 @ipv4.setter def ipv4(self, address: IpInput) -> None: + """Parse and store an IPv4 address. + + Args: + address: The address, as a string, as the integer it packs into, + or ``None`` to record that no IPv4 address is known. + + Raises: + ValueError: If ``address`` parses as an IP address of a family + other than IPv4, which in practice means an IPv6 address was + assigned to the IPv4 attribute. ``ipaddress.ip_address`` + raises its own ``ValueError`` first when ``address`` is not an + IP address at all. + """ if address is None: self._ipv4 = None else: @@ -56,10 +119,33 @@ def ipv4(self, address: IpInput) -> None: @property def ipv6(self) -> ipaddress.IPv6Address | None: + """The parsed form of the ``ipv6`` argument given to the constructor. + + Returns: + The address as an ``ipaddress.IPv6Address``, whatever spelling it + was assigned in, or ``None`` when no IPv6 address was given. The + encoders omit the ``ipv6`` field of the Zipkin endpoint when this + is ``None``. + """ return self._ipv6 @ipv6.setter def ipv6(self, address: IpInput) -> None: + """Parse and store an IPv6 address. + + Args: + address: The address, as a string, as the integer it packs into + when that integer is ``2**32`` or greater, or ``None`` to + record that no IPv6 address is known. + + Raises: + ValueError: If ``address`` parses as an IP address of a family + other than IPv6, which happens for an IPv4 address and for an + IPv6 address given as an integer below ``2**32``, since + ``ipaddress.ip_address`` reads such an integer as IPv4. + ``ipaddress.ip_address`` raises its own ``ValueError`` first + when ``address`` is not an IP address at all. + """ if address is None: self._ipv6 = None else: 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