From 279df49112619bbb60377b610f9f58b3e59b3340 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Mon, 27 Jul 2026 13:51:58 -0700 Subject: [PATCH] prototype showing span type OTEP --- docs/examples/span_type/README.rst | 47 ++++++ .../examples/span_type/registry/manifest.yaml | 2 + .../examples/span_type/registry/registry.yaml | 40 +++++ docs/examples/span_type/span_type.py | 51 +++++++ docs/examples/span_type/test_span_type.py | 144 ++++++++++++++++++ docs/examples/span_type/weaver.toml | 4 + .../_internal/trace_encoder/__init__.py | 14 +- .../src/opentelemetry/trace/__init__.py | 16 ++ .../src/opentelemetry/sdk/trace/__init__.py | 26 +++- .../trace/_sampling_experimental/_sampler.py | 2 + .../src/opentelemetry/sdk/trace/sampling.py | 9 ++ 11 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 docs/examples/span_type/README.rst create mode 100644 docs/examples/span_type/registry/manifest.yaml create mode 100644 docs/examples/span_type/registry/registry.yaml create mode 100644 docs/examples/span_type/span_type.py create mode 100644 docs/examples/span_type/test_span_type.py create mode 100644 docs/examples/span_type/weaver.toml diff --git a/docs/examples/span_type/README.rst b/docs/examples/span_type/README.rst new file mode 100644 index 00000000000..c6806622585 --- /dev/null +++ b/docs/examples/span_type/README.rst @@ -0,0 +1,47 @@ +Span type +========= + +Prototype for the `Span type OTEP +`_: +a span property that identifies the semantic convention definition the span +follows. + +* API: ``span_type`` keyword argument on ``Tracer.start_span`` and + ``Tracer.start_as_current_span``. Immutable, no setter. +* SDK: ``ReadableSpan.span_type``, plus ``span_type`` passed to + ``Sampler.should_sample``. +* OTLP: emitted as the ``otel.span.type`` attribute until the protocol gains a + top-level ``Span.type`` field. + +Run +--- + +.. code-block:: sh + + pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc + python span_type.py + +A collector on ``localhost:4317`` is optional -- the example prints the encoded +OTLP payload either way. + +Live check +---------- + +``test_span_type.py`` checks the example's span against +``weaver registry live-check`` using the schema v2 registry in ``registry/``, +which defines the ``gen_ai.client.inference`` span with one required and one +recommended attribute. ``weaver.toml`` filters out findings for the default SDK +resource attributes, which the registry deliberately does not define. + +.. code-block:: sh + + pip install opentelemetry-test-utils pytest # weaver binary also required + pytest test_span_type.py + +Live-check resolves the span by its type -- not by its name -- and then checks +it against that definition: the missing recommended ``gen_ai.request.model`` is +an improvement, a missing required attribute or an attribute the registry does +not define fails the check. + +Requires a weaver build with span type support, and ``--v2`` -- without it +weaver downconverts the registry to v1, where spans have no type. diff --git a/docs/examples/span_type/registry/manifest.yaml b/docs/examples/span_type/registry/manifest.yaml new file mode 100644 index 00000000000..774752d002e --- /dev/null +++ b/docs/examples/span_type/registry/manifest.yaml @@ -0,0 +1,2 @@ +description: Tiny registry defining the span the span_type example emits. +schema_url: https://example.com/schemas/0.1.0 diff --git a/docs/examples/span_type/registry/registry.yaml b/docs/examples/span_type/registry/registry.yaml new file mode 100644 index 00000000000..ea6fb753736 --- /dev/null +++ b/docs/examples/span_type/registry/registry.yaml @@ -0,0 +1,40 @@ +# Semantic conventions schema v2 registry with a single span definition. +# The span is identified by `type`, which is what the example passes to +# `start_as_current_span(span_type=...)`. +file_format: definition/2 + +attributes: + - key: otel.span.type + type: string + stability: development + brief: The semantic convention definition this span follows. + note: > + Only needed while span type travels as an attribute. Declared here so + live-check does not report it as an unknown attribute; once span type is a + top-level OTLP field no registry should define it. + examples: ["gen_ai.client.inference"] + + - key: gen_ai.operation.name + type: string + stability: development + brief: The name of the GenAI operation being performed. + examples: ["chat"] + - key: gen_ai.request.model + type: string + stability: development + brief: The name of the GenAI model the request is made to. + examples: ["gpt-4o-mini"] + +spans: + - type: gen_ai.client.inference + kind: client + stability: development + requirement_level: recommended + brief: Describes a GenAI inference call. + name: + note: "{gen_ai.operation.name} {gen_ai.request.model}" + attributes: + - ref: gen_ai.operation.name + requirement_level: required + - ref: gen_ai.request.model + requirement_level: recommended diff --git a/docs/examples/span_type/span_type.py b/docs/examples/span_type/span_type.py new file mode 100644 index 00000000000..466847d1e7e --- /dev/null +++ b/docs/examples/span_type/span_type.py @@ -0,0 +1,51 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Prototype of the "Span type" OTEP. + +Sets a span type at span creation and shows how it reaches the OTLP gRPC +exporter. Until OTLP has a top-level ``Span.type`` field, the exporter emits it +as the ``otel.span.type`` attribute. + +Run with a collector on localhost:4317, or without one to just see the encoded +payload. +""" + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.common._internal.trace_encoder import ( + encode_spans, +) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor + +captured: list = [] + + +class _Capture(SimpleSpanProcessor): + def on_end(self, span): + captured.append(span) + super().on_end(span) + + +provider = TracerProvider() +provider.add_span_processor(_Capture(OTLPSpanExporter(insecure=True))) +trace.set_tracer_provider(provider) + +tracer = trace.get_tracer(__name__) + +with tracer.start_as_current_span( + "chat gpt-4o-mini", + kind=trace.SpanKind.CLIENT, + span_type="gen_ai.client.inference", + attributes={"gen_ai.operation.name": "chat"}, +) as span: + # span type is immutable: readable from the SDK span, no setter exists + print("span_type on the SDK span:", span.span_type) + +provider.force_flush() + +print("\nOTLP payload:") +print(encode_spans(captured)) diff --git a/docs/examples/span_type/test_span_type.py b/docs/examples/span_type/test_span_type.py new file mode 100644 index 00000000000..00ec0bfcc5c --- /dev/null +++ b/docs/examples/span_type/test_span_type.py @@ -0,0 +1,144 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Checks the span the span_type.py example emits against +``weaver registry live-check``. + +The registry in ``registry/`` is a semantic conventions schema v2 registry +defining a single span, ``gen_ai.client.inference``, with one required and one +recommended attribute -- the same type the example passes to +``start_as_current_span(span_type=...)``. + +Requires the ``weaver`` binary on PATH: + https://github.com/open-telemetry/weaver/releases +""" + +import os +import shutil +import unittest + +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.test.weaver_live_check import ( + LiveCheckError, + WeaverLiveCheck, +) +from opentelemetry.trace import SpanKind + +_DIR = os.path.dirname(os.path.abspath(__file__)) +_REGISTRY_DIR = os.path.join(_DIR, "registry") + +SPAN_TYPE = "gen_ai.client.inference" + +# gen_ai.operation.name is required by the span definition, +# gen_ai.request.model is only recommended. +CONFORMANT_ATTRIBUTES = {"gen_ai.operation.name": "chat"} + + +def _emit_and_collect(attributes: dict) -> dict: + """Emit the example's span into live-check, return the span sample.""" + with WeaverLiveCheck( + registry=_REGISTRY_DIR, + # --v2 is required for the registry to load as schema v2; without it + # weaver downconverts to v1, where spans have no type. + extra_args=["--v2", "--config", os.path.join(_DIR, "weaver.toml")], + ) as weaver: + provider = TracerProvider() + provider.add_span_processor( + BatchSpanProcessor( + OTLPSpanExporter(endpoint=weaver.otlp_endpoint, insecure=True) + ) + ) + with provider.get_tracer(__name__).start_as_current_span( + "chat gpt-4o-mini", + kind=SpanKind.CLIENT, + span_type=SPAN_TYPE, + attributes=attributes, + ): + pass + provider.force_flush() + # validates the span against the registry and returns the report + # with details + # it'll fail if anny violations are found + report = weaver.end_and_check() + + spans = [s["span"] for s in report.get("samples", []) if "span" in s] + assert len(spans) == 1, f"expected one span, got {len(spans)}" + return {"span": spans[0], "report": report} + + +@unittest.skipUnless( + shutil.which("weaver") is not None, + "weaver binary not found on PATH — install from https://github.com/open-telemetry/weaver/releases", +) +class TestSpanTypeExample(unittest.TestCase): + def test_span_type_reaches_weaver(self): + """The span type set at creation arrives at live-check. + + Until OTLP has a top-level ``Span.type`` field the exporter carries it + as the ``otel.span.type`` attribute, so that is what weaver sees. + """ + result = _emit_and_collect(CONFORMANT_ATTRIBUTES) + attributes = { + a["name"]: a["value"] for a in result["span"]["attributes"] + } + self.assertEqual(attributes.get("otel.span.type"), SPAN_TYPE) + + def test_live_check_resolves_span_by_type(self): + """What span type is for: live-check resolves the span to its + definition and checks it against that definition. + + The example omits ``gen_ai.request.model``, which the registry marks + recommended, so live-check advises on it. Nothing about the span name or + its attributes is used to find the definition -- only the type. + """ + result = _emit_and_collect(CONFORMANT_ATTRIBUTES) + advice = result["span"]["live_check_result"]["all_advice"] + + # improvement, not a violation, so end_and_check() above did not raise: + # Recommended attribute 'gen_ai.request.model' is not present. + self.assertEqual(len(advice), 1, advice) + self.assertEqual(advice[0]["id"], "recommended_attribute_not_present") + self.assertEqual( + advice[0]["context"]["attribute_key"], "gen_ai.request.model" + ) + + def test_missing_required_attribute_fails(self): + """Dropping a required attribute fails the live check. + + Only possible because the span resolved to its definition -- without a + span type weaver has nothing to compare the attributes against. + """ + with self.assertRaises(LiveCheckError) as ctx: + _emit_and_collect({}) + + # Semconv violations found: + # - [required_attribute_not_present] Required attribute + # 'gen_ai.operation.name' is not present. (1 occurrence(s) on span + # 'gen_ai.client.inference') + violations = ctx.exception.report.violations + self.assertEqual(len(violations), 1, violations) + self.assertEqual(violations[0]["id"], "required_attribute_not_present") + self.assertEqual( + violations[0]["context"]["attribute_key"], "gen_ai.operation.name" + ) + + def test_unknown_attribute_fails(self): + """An attribute the registry does not define fails the live check.""" + with self.assertRaises(LiveCheckError) as ctx: + _emit_and_collect( + {**CONFORMANT_ATTRIBUTES, "gen_ai.made.up": "value"} + ) + + # Semconv violations found: + # - [missing_attribute] Attribute 'gen_ai.made.up' does not exist in + # the registry. (1 occurrence(s) on span 'gen_ai.client.inference') + violations = ctx.exception.report.violations + self.assertEqual(len(violations), 1, violations) + self.assertEqual(violations[0]["id"], "missing_attribute") + self.assertEqual( + violations[0]["context"]["attribute_key"], "gen_ai.made.up" + ) diff --git a/docs/examples/span_type/weaver.toml b/docs/examples/span_type/weaver.toml new file mode 100644 index 00000000000..b1a52ad9a7e --- /dev/null +++ b/docs/examples/span_type/weaver.toml @@ -0,0 +1,4 @@ +# The registry only defines the span under test, so drop findings for the +# resource attributes the SDK sets by default. +[[live-check.finding_filters]] +exclude_samples = ["service.*", "telemetry.sdk.*"] diff --git a/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/trace_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/trace_encoder/__init__.py index 96276a383e8..467e49c41d9 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/trace_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/trace_encoder/__init__.py @@ -101,8 +101,20 @@ def _span_flags(parent_span_context: SpanContext | None) -> int: return flags +# Until OTLP gains a top-level Span.type field, span type travels as an +# attribute in this prototype. +# See OTEP "Span type" - TODO add link +_SPAN_TYPE_ATTRIBUTE_KEY = "otel.span.type" + + def _encode_span(sdk_span: ReadableSpan) -> PB2SPan: span_context = sdk_span.get_span_context() + attributes = sdk_span.attributes + if sdk_span.span_type: + attributes = { + **(attributes or {}), + _SPAN_TYPE_ATTRIBUTE_KEY: sdk_span.span_type, + } return PB2SPan( trace_id=_encode_trace_id(span_context.trace_id), span_id=_encode_span_id(span_context.span_id), @@ -112,7 +124,7 @@ def _encode_span(sdk_span: ReadableSpan) -> PB2SPan: kind=_SPAN_KIND_MAP[sdk_span.kind], start_time_unix_nano=sdk_span.start_time, end_time_unix_nano=sdk_span.end_time, - attributes=_encode_attributes(sdk_span.attributes), + attributes=_encode_attributes(attributes), events=_encode_events(sdk_span.events), links=_encode_links(sdk_span.links), status=_encode_status(sdk_span.status), diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 996576c3ee0..f58a8340825 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -284,6 +284,8 @@ def start_span( start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True, + *, + span_type: str | None = None, ) -> "Span": """Starts a span. @@ -322,6 +324,10 @@ def start_span( be automatically set to ERROR when an uncaught exception is raised in the span with block. The span status won't be set by this mechanism if it was previously set manually. + span_type: Identifies the semantic convention definition this span follows + (within schema_url specified for this tracer), for example + ``"http.server.request"``. Low-cardinality and immutable after + creation. Not validated by the API. Returns: The newly-created span. @@ -340,6 +346,8 @@ def start_as_current_span( record_exception: bool = True, set_status_on_exception: bool = True, end_on_exit: bool = True, + *, + span_type: str | None = None, ) -> Iterator["Span"]: """Context manager for creating a new span and set it as the current span in this tracer's context. @@ -397,6 +405,9 @@ def function(): this mechanism if it was previously set manually. end_on_exit: Whether to end the span automatically when leaving the context manager. + span_type: Identifies the semantic convention definition this span + follows, for example ``"http.server.request"``. Low-cardinality + and immutable after creation. Not validated by the API. Yields: The newly-created span. @@ -459,6 +470,8 @@ def start_span( start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True, + *, + span_type: str | None = None, ) -> "Span": current_span = get_current_span(context) if isinstance(current_span, NonRecordingSpan): @@ -488,6 +501,8 @@ def start_as_current_span( record_exception: bool = True, set_status_on_exception: bool = True, end_on_exit: bool = True, + *, + span_type: str | None = None, ) -> Iterator["Span"]: span = self.start_span( name=name, @@ -498,6 +513,7 @@ def start_as_current_span( start_time=start_time, record_exception=record_exception, set_status_on_exception=set_status_on_exception, + span_type=span_type, ) with use_span( span, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index fe800c3695a..165556f883f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -427,10 +427,12 @@ def __init__( start_time: int | None = None, end_time: int | None = None, instrumentation_scope: InstrumentationScope | None = None, + span_type: str | None = None, ) -> None: self._name = name self._context = context self._kind = kind + self._span_type = span_type self._instrumentation_info = instrumentation_info self._instrumentation_scope = instrumentation_scope self._parent = parent @@ -478,6 +480,11 @@ def context(self): def kind(self) -> trace_api.SpanKind: return self._kind + @property + def span_type(self) -> str | None: + """Semantic convention definition this span follows, if any.""" + return self._span_type + @property def parent(self) -> trace_api.SpanContext | None: return self._parent @@ -555,6 +562,8 @@ def to_json(self, indent: int | None = 4): "links": self._format_links(self._links), "resource": json.loads(self.resource.to_json()), } + if self._span_type is not None: + f_span["span_type"] = self._span_type return json.dumps(f_span, indent=indent) @@ -821,6 +830,7 @@ def __init__( instrumentation_scope: InstrumentationScope | None = None, *, record_end_metrics: Callable[[], None] | None = None, + span_type: str | None = None, ) -> None: if resource is None: resource = Resource.create({}) @@ -829,6 +839,7 @@ def __init__( context=context, parent=parent, kind=kind, + span_type=span_type, resource=resource, instrumentation_info=instrumentation_info, instrumentation_scope=instrumentation_scope, @@ -961,6 +972,7 @@ def _readable_span(self) -> ReadableSpan: events=self._events, links=self._links, kind=self.kind, + span_type=self._span_type, status=self._status, start_time=self._start_time, end_time=self._end_time, @@ -1163,6 +1175,8 @@ def start_as_current_span( record_exception: bool = True, set_status_on_exception: bool = True, end_on_exit: bool = True, + *, + span_type: str | None = None, ) -> Iterator[trace_api.Span]: span = self.start_span( name=name, @@ -1173,6 +1187,7 @@ def start_as_current_span( start_time=start_time, record_exception=record_exception, set_status_on_exception=set_status_on_exception, + span_type=span_type, ) with trace_api.use_span( span, @@ -1192,6 +1207,8 @@ def start_span( # pylint: disable=too-many-locals start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True, + *, + span_type: str | None = None, ) -> trace_api.Span: links = links or () parent_span_context = trace_api.get_current_span( @@ -1222,7 +1239,13 @@ def start_span( # pylint: disable=too-many-locals # to include information about the sampling result. # The sampler may also modify the parent span context's tracestate sampling_result = self.sampler.should_sample( - context, trace_id, name, kind, attributes, links + context, + trace_id, + name, + kind, + attributes, + links, + span_type=span_type, ) trace_flags = ( @@ -1265,6 +1288,7 @@ def start_span( # pylint: disable=too-many-locals attributes=sampling_result.attributes, span_processor=self.span_processor, kind=kind, + span_type=span_type, links=links, instrumentation_info=self.instrumentation_info, record_exception=record_exception, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py index 7a9ec416848..9d15d44a469 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py @@ -28,6 +28,8 @@ def should_sample( attributes: Attributes | None = None, links: Sequence[Link] | None = None, trace_state: TraceState | None = None, + *, + span_type: str | None = None, ) -> SamplingResult: ot_trace_state = OtelTraceState.parse(trace_state) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py index 1652220dfc9..9918d8b71a0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py @@ -198,6 +198,8 @@ def should_sample( attributes: Attributes = None, links: Sequence[Link] | None = None, trace_state: TraceState | None = None, + *, + span_type: str | None = None, ) -> SamplingResult: pass @@ -221,6 +223,8 @@ def should_sample( attributes: Attributes = None, links: Sequence[Link] | None = None, trace_state: TraceState | None = None, + *, + span_type: str | None = None, ) -> SamplingResult: if self._decision is Decision.DROP: attributes = None @@ -282,6 +286,8 @@ def should_sample( attributes: Attributes = None, links: Sequence[Link] | None = None, trace_state: TraceState | None = None, + *, + span_type: str | None = None, ) -> SamplingResult: decision = Decision.DROP if trace_id & self.TRACE_ID_LIMIT < self.bound: @@ -337,6 +343,8 @@ def should_sample( attributes: Attributes = None, links: Sequence[Link] | None = None, trace_state: TraceState | None = None, + *, + span_type: str | None = None, ) -> SamplingResult: parent_span_context = get_current_span( parent_context @@ -363,6 +371,7 @@ def should_sample( kind=kind, attributes=attributes, links=links, + span_type=span_type, ) def get_description(self):