From 56834a147cecc9cad763ee3e19ec205e273752ec Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 23 Oct 2026 16:23:34 -0700 Subject: [PATCH 1/9] Add experimental span type to Tracer API and SDK --- .changelog/5819.added | 1 + .../src/opentelemetry/trace/__init__.py | 106 ++++++++- .../tests/trace/test_tracer_compat.py | 223 ++++++++++++++++++ .../src/opentelemetry/sdk/trace/__init__.py | 119 +++++++++- opentelemetry-sdk/tests/trace/test_trace.py | 115 +++++++++ 5 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 .changelog/5819.added create mode 100644 opentelemetry-api/tests/trace/test_tracer_compat.py diff --git a/.changelog/5819.added b/.changelog/5819.added new file mode 100644 index 00000000000..7ceff22dde6 --- /dev/null +++ b/.changelog/5819.added @@ -0,0 +1 @@ +`opentelemetry-api`, `opentelemetry-sdk`: Add experimental `Tracer._start_typed_span` and `Tracer._start_as_current_typed_span` to create spans with a span type; the SDK validates it and exposes it on `ReadableSpan._span_type` diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index cb8a5400601..c135d0e66ac 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -67,7 +67,7 @@ from collections.abc import Iterator, Sequence from enum import Enum from logging import getLogger -from typing import cast +from typing import Any, cast from typing_extensions import deprecated @@ -400,6 +400,102 @@ def function(): ... The newly-created span. """ + # Experimental. Becomes public (start_typed_span) once span type is stable. + def _start_typed_span( + self, + name: str, + *, + span_type: str | None = None, + kind: SpanKind = SpanKind.INTERNAL, + context: Context | None = None, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + **kwargs: Any, + ) -> "Span": + """Experimental: starts a span with a span type. + + Same as :meth:`start_span`, plus ``span_type``. All arguments after + ``name`` are keyword-only. + + Args: + span_type: Identifies the class of spans this span belongs to, as + defined by semantic conventions, for example + ``"http.client.request"``. Immutable after creation. Should be + a case-sensitive ASCII string of at most 255 characters that + starts with a letter, followed by letters, digits, ``_``, + ``.``, ``-`` or ``/``. Not validated by the API. + kwargs: Reserved for future span creation inputs. Implementations + ignore the ones they don't know. ``record_exception`` and + ``set_status_on_exception`` are recognized here, with the same + defaults as in :meth:`start_span`. + + The default implementation calls :meth:`start_span` and drops + ``span_type``, so existing ``Tracer`` implementations keep working. + It passes ``record_exception`` and ``set_status_on_exception`` only if + the caller set them, so when they're not set the implementation's own + :meth:`start_span` defaults apply. + Implementations that support span type override this method. + """ + return self.start_span( + name, + **_non_default_start_args(context, kind, attributes, links, start_time, kwargs), + ) + + # Experimental. Becomes public (start_as_current_typed_span) once span type is stable. + @_agnosticcontextmanager + def _start_as_current_typed_span( + self, + name: str, + *, + span_type: str | None = None, + kind: SpanKind = SpanKind.INTERNAL, + context: Context | None = None, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + end_on_exit: bool = True, + **kwargs: Any, + ) -> Iterator["Span"]: + """Experimental: same as :meth:`start_as_current_span`, with a span type. + + See :meth:`_start_typed_span` for ``span_type`` and ``kwargs``. The + default implementation calls :meth:`start_as_current_span` and drops + ``span_type``. + """ + args = _non_default_start_args(context, kind, attributes, links, start_time, kwargs) + if not end_on_exit: + args["end_on_exit"] = end_on_exit + with self.start_as_current_span(name, **args) as span: + yield span + + +def _non_default_start_args( + context: Context | None, + kind: SpanKind, + attributes: types.Attributes, + links: _Links, + start_time: int | None, + kwargs: dict[str, Any], +) -> dict[str, Any]: + # Only forward what the caller set, so implementations that override + # start_span with a subset of the parameters keep working. + args: dict[str, Any] = {} + if context is not None: + args["context"] = context + if kind is not SpanKind.INTERNAL: + args["kind"] = kind + if attributes is not None: + args["attributes"] = attributes + if links is not None: + args["links"] = links + if start_time is not None: + args["start_time"] = start_time + for key in ("record_exception", "set_status_on_exception"): + if key in kwargs: + args[key] = kwargs[key] + return args + class ProxyTracer(Tracer): # pylint: disable=W0222,signature-differs @@ -440,6 +536,14 @@ def start_as_current_span(self, *args, **kwargs) -> Iterator[Span]: with self._tracer.start_as_current_span(*args, **kwargs) as span: # type: ignore yield span + def _start_typed_span(self, *args, **kwargs) -> Span: # type: ignore + return self._tracer._start_typed_span(*args, **kwargs) # type: ignore # pylint: disable=protected-access + + @_agnosticcontextmanager # type: ignore + def _start_as_current_typed_span(self, *args, **kwargs) -> Iterator[Span]: + with self._tracer._start_as_current_typed_span(*args, **kwargs) as span: # type: ignore # pylint: disable=protected-access + yield span + class NoOpTracer(Tracer): """The default Tracer, used when no Tracer implementation is available. diff --git a/opentelemetry-api/tests/trace/test_tracer_compat.py b/opentelemetry-api/tests/trace/test_tracer_compat.py new file mode 100644 index 00000000000..99bb9cf0319 --- /dev/null +++ b/opentelemetry-api/tests/trace/test_tracer_compat.py @@ -0,0 +1,223 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access + +# Tracer implementations written against the current API, the way third parties +# write them. They must keep working, and keep type-checking, when the API grows. + +from collections.abc import Iterator +from typing import Any + +from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.test.globals_test import TraceGlobalsTest +from opentelemetry.trace import ( + INVALID_SPAN, + NoOpTracer, + NoOpTracerProvider, + Span, + SpanKind, + Tracer, + _Links, +) +from opentelemetry.util import types +from opentelemetry.util._decorator import _agnosticcontextmanager + + +class ExplicitTracer(Tracer): + """Overrides both methods with the full signature, no **kwargs.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def start_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + ) -> Span: + self.calls.append(("start_span", {"name": name, "kind": kind, "attributes": attributes})) + return INVALID_SPAN + + @_agnosticcontextmanager + def start_as_current_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + end_on_exit: bool = True, + ) -> Iterator[Span]: + self.calls.append(("start_as_current_span", {"name": name, "kind": kind, "attributes": attributes})) + yield INVALID_SPAN + + +class ShortSignatureTracer(Tracer): + """Overrides with a subset of parameters, as minimal implementations do.""" + + def start_span( # type: ignore[override] # pylint: disable=arguments-differ + self, name: str, context: Context | None = None, kind: SpanKind = SpanKind.INTERNAL + ) -> Span: + return INVALID_SPAN + + @_agnosticcontextmanager + def start_as_current_span( # type: ignore[override] # pylint: disable=arguments-differ + self, name: str, context: Context | None = None, kind: SpanKind = SpanKind.INTERNAL + ) -> Iterator[Span]: + yield INVALID_SPAN + + +class DelegatingTracer(NoOpTracer): + """Wraps another tracer and forwards positionally via super().""" + + def __init__(self) -> None: + self.started: list[str] = [] + + def start_span( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + ) -> Span: + self.started.append(name) + return super().start_span( + name, + context, + kind, + attributes, + links, + start_time, + record_exception, + set_status_on_exception, + ) + + +class _Provider(NoOpTracerProvider): + def __init__(self, tracer: Tracer) -> None: + self._tracer = tracer + + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + schema_url: str | None = None, + attributes: types.Attributes = None, + ) -> Tracer: + return self._tracer + + +class TestLegacyTracerImplementations(TraceGlobalsTest): + def test_explicit_tracer_direct(self): + tracer = ExplicitTracer() + tracer.start_span("a", None, SpanKind.CLIENT, {"k": "v"}) + with tracer.start_as_current_span("b", kind=SpanKind.SERVER): + pass + + @tracer.start_as_current_span("c") + def decorated() -> int: + return 1 + + self.assertEqual(decorated(), 1) + self.assertEqual( + tracer.calls, + [ + ("start_span", {"name": "a", "kind": SpanKind.CLIENT, "attributes": {"k": "v"}}), + ("start_as_current_span", {"name": "b", "kind": SpanKind.SERVER, "attributes": None}), + ("start_as_current_span", {"name": "c", "kind": SpanKind.INTERNAL, "attributes": None}), + ], + ) + + def test_explicit_tracer_through_proxy(self): + proxy = trace.get_tracer("instr") + tracer = ExplicitTracer() + trace.set_tracer_provider(_Provider(tracer)) + + proxy.start_span("a", kind=SpanKind.CLIENT) + with proxy.start_as_current_span("b"): + pass + + self.assertEqual([c[1]["name"] for c in tracer.calls], ["a", "b"]) + + def test_short_signature_tracer(self): + trace.set_tracer_provider(_Provider(ShortSignatureTracer())) + tracer = trace.get_tracer("instr") + self.assertIs(tracer.start_span("a", kind=SpanKind.CLIENT), INVALID_SPAN) + with tracer.start_as_current_span("b") as span: + self.assertIs(span, INVALID_SPAN) + + def test_typed_span_with_short_signature_tracer(self): + trace.set_tracer_provider(_Provider(ShortSignatureTracer())) + tracer = trace.get_tracer("instr") + span = tracer._start_typed_span("a", kind=SpanKind.CLIENT, span_type="http.client.request") + self.assertIs(span, INVALID_SPAN) + with tracer._start_as_current_typed_span("b", span_type="http.client.request") as span: + self.assertIs(span, INVALID_SPAN) + + def test_delegating_tracer(self): + tracer = DelegatingTracer() + tracer.start_span("a", None, SpanKind.CLIENT) + with tracer.start_as_current_span("b"): + pass + self.assertEqual(tracer.started, ["a", "b"]) + + def test_typed_span_with_explicit_tracer(self): + explicit = ExplicitTracer() + trace.set_tracer_provider(_Provider(explicit)) + tracer = trace.get_tracer("instr") + tracer._start_typed_span("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, span_type="http.client.request") + with tracer._start_as_current_typed_span("b", span_type="http.client.request", future_arg=1): + pass + tracer._start_typed_span("c", record_exception=False) + + self.assertEqual( + explicit.calls, + [ + ("start_span", {"name": "a", "kind": SpanKind.CLIENT, "attributes": {"k": "v"}}), + ("start_as_current_span", {"name": "b", "kind": SpanKind.INTERNAL, "attributes": None}), + ("start_span", {"name": "c", "kind": SpanKind.INTERNAL, "attributes": None}), + ], + ) + + def test_typed_span_with_delegating_tracer(self): + tracer = DelegatingTracer() + tracer._start_typed_span("a", span_type="http.client.request") + with tracer._start_as_current_typed_span("b", span_type="http.client.request"): + pass + self.assertEqual(tracer.started, ["a", "b"]) + + def test_typed_span_with_noop_tracer(self): + with trace.get_tracer("instr")._start_as_current_typed_span("a", span_type="not valid!") as span: + self.assertFalse(span.is_recording()) + + def test_typed_span_through_proxy_created_before_provider(self): + proxy = trace.get_tracer("instr") + self.assertIsInstance(proxy, trace.ProxyTracer) + explicit = ExplicitTracer() + trace.set_tracer_provider(_Provider(explicit)) + + proxy._start_typed_span("a", span_type="http.client.request", kind=SpanKind.CLIENT) + with proxy._start_as_current_typed_span("b", span_type="http.client.request"): + pass + + self.assertEqual( + explicit.calls, + [ + ("start_span", {"name": "a", "kind": SpanKind.CLIENT, "attributes": None}), + ("start_as_current_span", {"name": "b", "kind": SpanKind.INTERNAL, "attributes": None}), + ], + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 6f47f213bff..dd8a6ad602b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -21,6 +21,7 @@ ) from dataclasses import dataclass from os import environ +from string import ascii_letters, digits from time import time_ns from types import MappingProxyType, TracebackType from typing import ( @@ -76,6 +77,27 @@ logger = logging.getLogger(__name__) +_SPAN_TYPE_ERROR_MESSAGE = ( + "Dropping span type: expected ASCII string of maximum length 255 characters starting with a letter but got %s" +) + +_SPAN_TYPE_MAX_LENGTH = 255 +_SPAN_TYPE_CHARS = frozenset(ascii_letters + digits + "_./-") + + +def _is_valid_span_type(span_type: str) -> bool: + """Checks the span type for compliance with the span type syntax.""" + return ( + 0 < len(span_type) <= _SPAN_TYPE_MAX_LENGTH + and span_type[0] in ascii_letters + and all(char in _SPAN_TYPE_CHARS for char in span_type) + ) + + +def _bool_kwarg(kwargs: Mapping[str, Any], key: str, default: bool) -> bool: + value = kwargs.get(key, default) + return value if isinstance(value, bool) else default + _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT = 128 @@ -429,10 +451,13 @@ 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_value = _span_type self._instrumentation_info = instrumentation_info self._instrumentation_scope = instrumentation_scope self._parent = parent @@ -480,6 +505,11 @@ def context(self): def kind(self) -> trace_api.SpanKind: return self._kind + @property + def _span_type(self) -> str | None: + """Experimental: the span type, if set. Becomes public once span type is stable.""" + return self._span_type_value + @property def parent(self) -> trace_api.SpanContext | None: return self._parent @@ -801,6 +831,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({}) @@ -809,6 +840,7 @@ def __init__( context=context, parent=parent, kind=kind, + _span_type=_span_type, resource=resource, instrumentation_info=instrumentation_info, instrumentation_scope=instrumentation_scope, @@ -950,6 +982,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, @@ -1188,7 +1221,7 @@ def start_as_current_span( ) as span: yield span - def start_span( # pylint: disable=too-many-locals + def start_span( self, name: str, context: context_api.Context | None = None, @@ -1198,6 +1231,89 @@ def start_span( # pylint: disable=too-many-locals start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True, + ) -> trace_api.Span: + return self._new_span( + name, + context, + kind, + attributes, + links, + start_time, + record_exception, + set_status_on_exception, + None, + ) + + def _start_typed_span( + self, + name: str, + *, + span_type: str | None = None, + kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, + context: context_api.Context | None = None, + attributes: types.Attributes = None, + links: Sequence[trace_api.Link] | None = (), + start_time: int | None = None, + **kwargs: Any, + ) -> trace_api.Span: + if span_type is not None and not _is_valid_span_type(span_type): + logger.error(_SPAN_TYPE_ERROR_MESSAGE, span_type) + span_type = None + return self._new_span( + name, + context, + kind, + attributes, + links, + start_time, + _bool_kwarg(kwargs, "record_exception", True), + _bool_kwarg(kwargs, "set_status_on_exception", True), + span_type, + ) + + @_agnosticcontextmanager # pylint: disable=protected-access + def _start_as_current_typed_span( + self, + name: str, + *, + span_type: str | None = None, + kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, + context: context_api.Context | None = None, + attributes: types.Attributes = None, + links: Sequence[trace_api.Link] | None = (), + start_time: int | None = None, + end_on_exit: bool = True, + **kwargs: Any, + ) -> Iterator[trace_api.Span]: + span = self._start_typed_span( + name, + span_type=span_type, + kind=kind, + context=context, + attributes=attributes, + links=links, + start_time=start_time, + **kwargs, + ) + with trace_api.use_span( + span, + end_on_exit=end_on_exit, + record_exception=_bool_kwarg(kwargs, "record_exception", True), + set_status_on_exception=_bool_kwarg(kwargs, "set_status_on_exception", True), + ) as span: + yield span + + def _new_span( # pylint: disable=too-many-locals + self, + name: str, + context: context_api.Context | None, + kind: trace_api.SpanKind, + attributes: types.Attributes, + links: Sequence[trace_api.Link] | None, + start_time: int | None, + record_exception: bool, + set_status_on_exception: bool, + span_type: str | None, ) -> trace_api.Span: links = links or () parent_span_context = trace_api.get_current_span(context).get_span_context() @@ -1259,6 +1375,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/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index c93300c73cd..1e3711a7e17 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-lines # pylint: disable=no-member +import asyncio import copy import dataclasses import json @@ -44,6 +45,8 @@ _RuleBasedTracerConfigurator, _TracerConfig, ) +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, @@ -796,6 +799,118 @@ def test_surplus_span_attributes(self): self.assertEqual(len(root.attributes), max_attrs) +class TestSpanType(unittest.TestCase): + # pylint: disable=protected-access + + def test_span_type(self): + tracer = new_tracer() + span = tracer._start_typed_span("s", span_type="http.client.request") + self.assertEqual(span._span_type, "http.client.request") + span.end() + readable = span._readable_span() + self.assertEqual(readable._span_type, "http.client.request") + + with tracer._start_as_current_typed_span("s", span_type="db.client.operation") as span: + self.assertEqual(span._span_type, "db.client.operation") + self.assertIs(trace_api.get_current_span(), span) + + def test_span_type_through_proxy_created_before_provider(self): + with mock.patch.object(trace_api, "_TRACER_PROVIDER", None): + proxy = trace_api.get_tracer("instr") + self.assertIsInstance(proxy, trace_api.ProxyTracer) + with mock.patch.object(trace_api, "_TRACER_PROVIDER", trace.TracerProvider()): + span = proxy._start_typed_span("a", span_type="http.client.request") + with proxy._start_as_current_typed_span("b", span_type="db.client.operation") as current: + self.assertEqual(current._span_type, "db.client.operation") + self.assertEqual(span._span_type, "http.client.request") + + def test_span_type_exported(self): + exporter = InMemorySpanExporter() + tracer_provider = trace.TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = tracer_provider.get_tracer("instr") + + tracer._start_typed_span("a", span_type="http.client.request").end() + with tracer._start_as_current_typed_span("b", span_type="db.client.operation"): + pass + tracer.start_span("c").end() + + self.assertEqual( + [(s.name, s._span_type) for s in exporter.get_finished_spans()], + [("a", "http.client.request"), ("b", "db.client.operation"), ("c", None)], + ) + + def test_start_as_current_typed_span_decorator(self): + tracer = new_tracer() + + @tracer._start_as_current_typed_span("sync", span_type="a.sync") + def sync_func() -> str | None: + return trace_api.get_current_span()._span_type + + @tracer._start_as_current_typed_span("async", span_type="a.async") + async def async_func() -> str | None: + return trace_api.get_current_span()._span_type + + self.assertEqual(sync_func(), "a.sync") + self.assertEqual(asyncio.run(async_func()), "a.async") + + def test_start_as_current_typed_span_end_on_exit(self): + tracer = new_tracer() + with tracer._start_as_current_typed_span("a", span_type="x", end_on_exit=False) as span: + pass + self.assertTrue(span.is_recording()) + span.end() + with tracer._start_as_current_typed_span("b", span_type="x") as span: + pass + self.assertFalse(span.is_recording()) + + def test_typed_span_exception_handling_defaults(self): + tracer = new_tracer() + with self.assertRaises(DummyError): + with tracer._start_as_current_typed_span("s", span_type="a") as span: + raise DummyError() + self.assertIs(span.status.status_code, StatusCode.ERROR) + self.assertEqual(span.events[0].name, "exception") + + def test_typed_span_exception_flags_ignore_non_bool(self): + tracer = new_tracer() + with self.assertRaises(DummyError): + with tracer._start_as_current_typed_span("s", record_exception="no", set_status_on_exception=0) as span: + raise DummyError() + self.assertIs(span.status.status_code, StatusCode.ERROR) + self.assertEqual(span.events[0].name, "exception") + + def test_typed_span_exception_handling_from_kwargs(self): + tracer = new_tracer() + with self.assertRaises(DummyError): + with tracer._start_as_current_typed_span( + "s", span_type="a", record_exception=False, set_status_on_exception=False + ) as span: + raise DummyError() + self.assertIs(span.status.status_code, StatusCode.UNSET) + self.assertEqual(len(span.events), 0) + + def test_span_type_unset(self): + span = new_tracer().start_span("s") + self.assertIsNone(span._span_type) + self.assertIsNone(new_tracer()._start_typed_span("s")._span_type) + + def test_span_type_valid_syntax(self): + tracer = new_tracer() + for span_type in ("a", "A_b.c-d/e9", "a" * 255): + with self.subTest(span_type=span_type): + self.assertEqual(tracer._start_typed_span("s", span_type=span_type)._span_type, span_type) + + def test_span_type_invalid_syntax(self): + tracer = new_tracer() + for span_type in ("", "1abc", "_abc", "a b", "a!", "h\u00e9llo", "a" * 256): + with self.subTest(span_type=span_type): + with self.assertLogs(level=ERROR): + span = tracer._start_typed_span("s", span_type=span_type) + self.assertTrue(span.is_recording()) + self.assertIsNone(span._span_type) + + class TestReadableSpan(unittest.TestCase): def test_links(self): span = trace.ReadableSpan("test") From d5ea2a3a0b2ce7af611917ee67f2b99cd71d41e1 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 23 Oct 2026 16:37:46 -0700 Subject: [PATCH 2/9] Drop non-string span types instead of raising --- .../src/opentelemetry/sdk/trace/__init__.py | 5 +++-- opentelemetry-sdk/tests/trace/test_trace.py | 13 ++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index dd8a6ad602b..57480d5d3b9 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -85,10 +85,11 @@ _SPAN_TYPE_CHARS = frozenset(ascii_letters + digits + "_./-") -def _is_valid_span_type(span_type: str) -> bool: +def _is_valid_span_type(span_type: object) -> bool: """Checks the span type for compliance with the span type syntax.""" return ( - 0 < len(span_type) <= _SPAN_TYPE_MAX_LENGTH + isinstance(span_type, str) + and 0 < len(span_type) <= _SPAN_TYPE_MAX_LENGTH and span_type[0] in ascii_letters and all(char in _SPAN_TYPE_CHARS for char in span_type) ) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 1e3711a7e17..cd343fa2c74 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -903,7 +903,18 @@ def test_span_type_valid_syntax(self): def test_span_type_invalid_syntax(self): tracer = new_tracer() - for span_type in ("", "1abc", "_abc", "a b", "a!", "h\u00e9llo", "a" * 256): + for span_type in ( + "", + "1abc", + "_abc", + "a b", + "a!", + "h\u00e9llo", + "a" * 256, + 123, + b"http.client.request", + ["a", "b"], + ): with self.subTest(span_type=span_type): with self.assertLogs(level=ERROR): span = tracer._start_typed_span("s", span_type=span_type) From 2bdf5cf43820bb3bb103909d121d09fc179f4330 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Mon, 26 Oct 2026 15:03:38 -0700 Subject: [PATCH 3/9] Type span options, warn on unknown ones, speed up span type validation - Type typed-span kwargs as Unpack[_SpanOptions] so type checkers catch typos. - SDK logs a warning for unknown span options. - SDK skips reading kwargs when none are passed and reads them once otherwise. - Validate span type with a compiled regex instead of a per-character check. - Add typed span and span type validation benchmarks with allocation tracking. Assisted-by: Antigravity --- .../src/opentelemetry/trace/__init__.py | 29 +++-- .../tests/trace/test_tracer_compat.py | 4 +- .../benchmarks/trace/test_benchmark_trace.py | 100 ++++++++++++++++++ .../src/opentelemetry/sdk/trace/__init__.py | 79 ++++++++------ opentelemetry-sdk/tests/trace/test_trace.py | 21 ++++ 5 files changed, 190 insertions(+), 43 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index c135d0e66ac..667cbf21c21 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -69,7 +69,7 @@ from logging import getLogger from typing import Any, cast -from typing_extensions import deprecated +from typing_extensions import TypedDict, Unpack, deprecated from opentelemetry import context as context_api from opentelemetry.attributes import BoundedAttributes @@ -148,6 +148,13 @@ def dropped_attributes(self) -> int: _Links = Sequence[Link] | None +# Experimental. Options for the typed span methods. New span start options are +# added here as keys, so older implementations keep accepting newer callers. +class _SpanOptions(TypedDict, total=False): + record_exception: bool + set_status_on_exception: bool + + class SpanKind(Enum): """Specifies additional details on how this span relates to its parent span. @@ -411,7 +418,7 @@ def _start_typed_span( attributes: types.Attributes = None, links: _Links = None, start_time: int | None = None, - **kwargs: Any, + **kwargs: Unpack[_SpanOptions], ) -> "Span": """Experimental: starts a span with a span type. @@ -425,10 +432,9 @@ def _start_typed_span( a case-sensitive ASCII string of at most 255 characters that starts with a letter, followed by letters, digits, ``_``, ``.``, ``-`` or ``/``. Not validated by the API. - kwargs: Reserved for future span creation inputs. Implementations - ignore the ones they don't know. ``record_exception`` and - ``set_status_on_exception`` are recognized here, with the same - defaults as in :meth:`start_span`. + kwargs: Options from :class:`_SpanOptions`, with the same defaults + as in :meth:`start_span`. Future span creation inputs are + added there. Implementations ignore the ones they don't know. The default implementation calls :meth:`start_span` and drops ``span_type``, so existing ``Tracer`` implementations keep working. @@ -455,7 +461,7 @@ def _start_as_current_typed_span( links: _Links = None, start_time: int | None = None, end_on_exit: bool = True, - **kwargs: Any, + **kwargs: Unpack[_SpanOptions], ) -> Iterator["Span"]: """Experimental: same as :meth:`start_as_current_span`, with a span type. @@ -476,7 +482,7 @@ def _non_default_start_args( attributes: types.Attributes, links: _Links, start_time: int | None, - kwargs: dict[str, Any], + kwargs: _SpanOptions, ) -> dict[str, Any]: # Only forward what the caller set, so implementations that override # start_span with a subset of the parameters keep working. @@ -491,9 +497,10 @@ def _non_default_start_args( args["links"] = links if start_time is not None: args["start_time"] = start_time - for key in ("record_exception", "set_status_on_exception"): - if key in kwargs: - args[key] = kwargs[key] + if "record_exception" in kwargs: + args["record_exception"] = kwargs["record_exception"] + if "set_status_on_exception" in kwargs: + args["set_status_on_exception"] = kwargs["set_status_on_exception"] return args diff --git a/opentelemetry-api/tests/trace/test_tracer_compat.py b/opentelemetry-api/tests/trace/test_tracer_compat.py index 99bb9cf0319..4b62c37c728 100644 --- a/opentelemetry-api/tests/trace/test_tracer_compat.py +++ b/opentelemetry-api/tests/trace/test_tracer_compat.py @@ -180,7 +180,9 @@ def test_typed_span_with_explicit_tracer(self): trace.set_tracer_provider(_Provider(explicit)) tracer = trace.get_tracer("instr") tracer._start_typed_span("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, span_type="http.client.request") - with tracer._start_as_current_typed_span("b", span_type="http.client.request", future_arg=1): + # simulates a caller built against a newer API with options this one doesn't know + future_options: dict[str, Any] = {"future_arg": 1} + with tracer._start_as_current_typed_span("b", span_type="http.client.request", **future_options): pass tracer._start_typed_span("c", record_exception=False) diff --git a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py index f17e6534215..0ac8bc0e6bc 100644 --- a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py +++ b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py @@ -14,6 +14,7 @@ SpanProcessor, TracerProvider, _default_tracer_configurator, + _is_valid_span_type, _RuleBasedTracerConfigurator, _TracerConfig, sampling, @@ -148,6 +149,105 @@ def benchmark_start_as_current_span(): benchmark(benchmark_start_as_current_span) +def _record_mean_alloc_bytes(benchmark, func, *args): + func(*args) # warm-up so one-time caches aren't counted + peaks = [] + for _ in range(200): + tracemalloc.start() + func(*args) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + peaks.append(peak) + benchmark.extra_info["mean_alloc_bytes"] = sum(peaks) / len(peaks) + + +def _start_typed_span_no_type(): + span = tracer._start_typed_span("benchmarkedSpan", attributes={"long.attribute": -10000000001000000000}) + span.add_event("benchmarkEvent") + span.end() + + +def _start_typed_span_type(): + span = tracer._start_typed_span( + "benchmarkedSpan", + span_type="http.client.request", + attributes={"long.attribute": -10000000001000000000}, + ) + span.add_event("benchmarkEvent") + span.end() + + +def _start_typed_span_type_with_option(): + span = tracer._start_typed_span( + "benchmarkedSpan", + span_type="http.client.request", + attributes={"long.attribute": -10000000001000000000}, + record_exception=False, + ) + span.add_event("benchmarkEvent") + span.end() + + +def _start_as_current_typed_span_no_type(): + with tracer._start_as_current_typed_span( + "benchmarkedSpan", attributes={"long.attribute": -10000000001000000000} + ) as span: + span.add_event("benchmarkEvent") + + +def _start_as_current_typed_span_type(): + with tracer._start_as_current_typed_span( + "benchmarkedSpan", + span_type="http.client.request", + attributes={"long.attribute": -10000000001000000000}, + ) as span: + span.add_event("benchmarkEvent") + + +def _start_as_current_typed_span_type_with_option(): + with tracer._start_as_current_typed_span( + "benchmarkedSpan", + span_type="http.client.request", + attributes={"long.attribute": -10000000001000000000}, + record_exception=False, + ) as span: + span.add_event("benchmarkEvent") + + +@pytest.mark.parametrize( + "func", + [_start_typed_span_no_type, _start_typed_span_type, _start_typed_span_type_with_option], + ids=["no_type", "type", "type_with_option"], +) +def test_simple_start_typed_span(benchmark, func): + _record_mean_alloc_bytes(benchmark, func) + benchmark(func) + + +@pytest.mark.parametrize( + "func", + [ + _start_as_current_typed_span_no_type, + _start_as_current_typed_span_type, + _start_as_current_typed_span_type_with_option, + ], + ids=["no_type", "type", "type_with_option"], +) +def test_simple_start_as_current_typed_span(benchmark, func): + _record_mean_alloc_bytes(benchmark, func) + benchmark(func) + + +@pytest.mark.parametrize( + "span_type", + ["http.client.request", "gen_ai.client.inference.operation", "a" * 255, "invalid type"], + ids=["19_chars", "33_chars", "255_chars", "invalid"], +) +def test_is_valid_span_type(benchmark, span_type): + _record_mean_alloc_bytes(benchmark, _is_valid_span_type, span_type) + benchmark(_is_valid_span_type, span_type) + + class _EventsReadingProcessor(SpanProcessor): def on_end(self, span: ReadableSpan) -> None: _ = span.events diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 57480d5d3b9..a0b99c1fb24 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -8,6 +8,7 @@ import json import logging import os +import re import threading import traceback import typing @@ -21,7 +22,6 @@ ) from dataclasses import dataclass from os import environ -from string import ascii_letters, digits from time import time_ns from types import MappingProxyType, TracebackType from typing import ( @@ -29,7 +29,7 @@ ) from warnings import filterwarnings -from typing_extensions import deprecated +from typing_extensions import Unpack, deprecated from opentelemetry import context as context_api from opentelemetry import metrics as metrics_api @@ -70,7 +70,7 @@ EXCEPTION_STACKTRACE, EXCEPTION_TYPE, ) -from opentelemetry.trace import NoOpTracer, SpanContext +from opentelemetry.trace import NoOpTracer, SpanContext, _SpanOptions from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util import types from opentelemetry.util._decorator import _agnosticcontextmanager @@ -81,23 +81,36 @@ "Dropping span type: expected ASCII string of maximum length 255 characters starting with a letter but got %s" ) -_SPAN_TYPE_MAX_LENGTH = 255 -_SPAN_TYPE_CHARS = frozenset(ascii_letters + digits + "_./-") +_SPAN_TYPE_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_./-]{0,254}") + +_KNOWN_SPAN_OPTIONS = frozenset(("record_exception", "set_status_on_exception")) +# (record_exception, set_status_on_exception) when no options are passed +_DEFAULT_SPAN_OPTIONS = (True, True) def _is_valid_span_type(span_type: object) -> bool: """Checks the span type for compliance with the span type syntax.""" - return ( - isinstance(span_type, str) - and 0 < len(span_type) <= _SPAN_TYPE_MAX_LENGTH - and span_type[0] in ascii_letters - and all(char in _SPAN_TYPE_CHARS for char in span_type) - ) + return isinstance(span_type, str) and _SPAN_TYPE_PATTERN.fullmatch(span_type) is not None + +def _checked_span_type(span_type: str) -> str | None: + if _is_valid_span_type(span_type): + return span_type + logger.error(_SPAN_TYPE_ERROR_MESSAGE, span_type) + return None -def _bool_kwarg(kwargs: Mapping[str, Any], key: str, default: bool) -> bool: - value = kwargs.get(key, default) - return value if isinstance(value, bool) else default + +def _read_span_options(kwargs: Mapping[str, Any]) -> tuple[bool, bool]: + if not _KNOWN_SPAN_OPTIONS.issuperset(kwargs): + logger.warning( + "Ignoring unknown span start options: %s", + sorted(kwargs.keys() - _KNOWN_SPAN_OPTIONS), + ) + # both default to True; only an explicit False turns them off, other non-bool values are ignored + return ( + kwargs.get("record_exception") is not False, + kwargs.get("set_status_on_exception") is not False, + ) _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 @@ -1255,11 +1268,11 @@ def _start_typed_span( attributes: types.Attributes = None, links: Sequence[trace_api.Link] | None = (), start_time: int | None = None, - **kwargs: Any, + **kwargs: Unpack[_SpanOptions], ) -> trace_api.Span: - if span_type is not None and not _is_valid_span_type(span_type): - logger.error(_SPAN_TYPE_ERROR_MESSAGE, span_type) - span_type = None + if span_type is not None: + span_type = _checked_span_type(span_type) + record_exception, set_status_on_exception = _read_span_options(kwargs) if kwargs else _DEFAULT_SPAN_OPTIONS return self._new_span( name, context, @@ -1267,8 +1280,8 @@ def _start_typed_span( attributes, links, start_time, - _bool_kwarg(kwargs, "record_exception", True), - _bool_kwarg(kwargs, "set_status_on_exception", True), + record_exception, + set_status_on_exception, span_type, ) @@ -1284,23 +1297,27 @@ def _start_as_current_typed_span( links: Sequence[trace_api.Link] | None = (), start_time: int | None = None, end_on_exit: bool = True, - **kwargs: Any, + **kwargs: Unpack[_SpanOptions], ) -> Iterator[trace_api.Span]: - span = self._start_typed_span( + if span_type is not None: + span_type = _checked_span_type(span_type) + record_exception, set_status_on_exception = _read_span_options(kwargs) if kwargs else _DEFAULT_SPAN_OPTIONS + span = self._new_span( name, - span_type=span_type, - kind=kind, - context=context, - attributes=attributes, - links=links, - start_time=start_time, - **kwargs, + context, + kind, + attributes, + links, + start_time, + record_exception, + set_status_on_exception, + span_type, ) with trace_api.use_span( span, end_on_exit=end_on_exit, - record_exception=_bool_kwarg(kwargs, "record_exception", True), - set_status_on_exception=_bool_kwarg(kwargs, "set_status_on_exception", True), + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, ) as span: yield span diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index cd343fa2c74..588f5d5f1d9 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -890,6 +890,26 @@ def test_typed_span_exception_handling_from_kwargs(self): self.assertIs(span.status.status_code, StatusCode.UNSET) self.assertEqual(len(span.events), 0) + def test_typed_span_unknown_options_warn(self): + tracer = new_tracer() + with self.assertLogs(level=WARNING) as logs: + span = tracer._start_typed_span("s", span_type="a", kindd=trace_api.SpanKind.CLIENT) + self.assertIn("kindd", logs.output[0]) + self.assertIs(span.kind, trace_api.SpanKind.INTERNAL) + + with self.assertLogs(level=WARNING) as logs: + with tracer._start_as_current_typed_span("s", span_type="a", foo=1, record_exception=False): + pass + self.assertEqual(len(logs.output), 1) + self.assertIn("foo", logs.output[0]) + + def test_typed_span_known_options_dont_warn(self): + tracer = new_tracer() + with self.assertNoLogs(level=WARNING): + tracer._start_typed_span("s", span_type="a", record_exception=False, set_status_on_exception=False) + with tracer._start_as_current_typed_span("s", span_type="a"): + pass + def test_span_type_unset(self): span = new_tracer().start_span("s") self.assertIsNone(span._span_type) @@ -909,6 +929,7 @@ def test_span_type_invalid_syntax(self): "_abc", "a b", "a!", + "abc\n", "h\u00e9llo", "a" * 256, 123, From 1ab397957f529e9f17bd0aac44ac9515c5d39122 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Mon, 26 Oct 2026 15:22:15 -0700 Subject: [PATCH 4/9] Rename span_type parameter to type_ in typed span methods Assisted-by: Antigravity --- .../src/opentelemetry/trace/__init__.py | 14 +++---- .../tests/trace/test_tracer_compat.py | 18 ++++----- .../benchmarks/trace/test_benchmark_trace.py | 8 ++-- .../src/opentelemetry/sdk/trace/__init__.py | 16 ++++---- opentelemetry-sdk/tests/trace/test_trace.py | 40 +++++++++---------- 5 files changed, 48 insertions(+), 48 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 667cbf21c21..c85fe5403e6 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -412,7 +412,7 @@ def _start_typed_span( self, name: str, *, - span_type: str | None = None, + type_: str | None = None, kind: SpanKind = SpanKind.INTERNAL, context: Context | None = None, attributes: types.Attributes = None, @@ -422,11 +422,11 @@ def _start_typed_span( ) -> "Span": """Experimental: starts a span with a span type. - Same as :meth:`start_span`, plus ``span_type``. All arguments after + Same as :meth:`start_span`, plus ``type_``. All arguments after ``name`` are keyword-only. Args: - span_type: Identifies the class of spans this span belongs to, as + type_: Identifies the class of spans this span belongs to, as defined by semantic conventions, for example ``"http.client.request"``. Immutable after creation. Should be a case-sensitive ASCII string of at most 255 characters that @@ -437,7 +437,7 @@ def _start_typed_span( added there. Implementations ignore the ones they don't know. The default implementation calls :meth:`start_span` and drops - ``span_type``, so existing ``Tracer`` implementations keep working. + ``type_``, so existing ``Tracer`` implementations keep working. It passes ``record_exception`` and ``set_status_on_exception`` only if the caller set them, so when they're not set the implementation's own :meth:`start_span` defaults apply. @@ -454,7 +454,7 @@ def _start_as_current_typed_span( self, name: str, *, - span_type: str | None = None, + type_: str | None = None, kind: SpanKind = SpanKind.INTERNAL, context: Context | None = None, attributes: types.Attributes = None, @@ -465,9 +465,9 @@ def _start_as_current_typed_span( ) -> Iterator["Span"]: """Experimental: same as :meth:`start_as_current_span`, with a span type. - See :meth:`_start_typed_span` for ``span_type`` and ``kwargs``. The + See :meth:`_start_typed_span` for ``type_`` and ``kwargs``. The default implementation calls :meth:`start_as_current_span` and drops - ``span_type``. + ``type_``. """ args = _non_default_start_args(context, kind, attributes, links, start_time, kwargs) if not end_on_exit: diff --git a/opentelemetry-api/tests/trace/test_tracer_compat.py b/opentelemetry-api/tests/trace/test_tracer_compat.py index 4b62c37c728..1339d906ed8 100644 --- a/opentelemetry-api/tests/trace/test_tracer_compat.py +++ b/opentelemetry-api/tests/trace/test_tracer_compat.py @@ -163,9 +163,9 @@ def test_short_signature_tracer(self): def test_typed_span_with_short_signature_tracer(self): trace.set_tracer_provider(_Provider(ShortSignatureTracer())) tracer = trace.get_tracer("instr") - span = tracer._start_typed_span("a", kind=SpanKind.CLIENT, span_type="http.client.request") + span = tracer._start_typed_span("a", kind=SpanKind.CLIENT, type_="http.client.request") self.assertIs(span, INVALID_SPAN) - with tracer._start_as_current_typed_span("b", span_type="http.client.request") as span: + with tracer._start_as_current_typed_span("b", type_="http.client.request") as span: self.assertIs(span, INVALID_SPAN) def test_delegating_tracer(self): @@ -179,10 +179,10 @@ def test_typed_span_with_explicit_tracer(self): explicit = ExplicitTracer() trace.set_tracer_provider(_Provider(explicit)) tracer = trace.get_tracer("instr") - tracer._start_typed_span("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, span_type="http.client.request") + tracer._start_typed_span("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, type_="http.client.request") # simulates a caller built against a newer API with options this one doesn't know future_options: dict[str, Any] = {"future_arg": 1} - with tracer._start_as_current_typed_span("b", span_type="http.client.request", **future_options): + with tracer._start_as_current_typed_span("b", type_="http.client.request", **future_options): pass tracer._start_typed_span("c", record_exception=False) @@ -197,13 +197,13 @@ def test_typed_span_with_explicit_tracer(self): def test_typed_span_with_delegating_tracer(self): tracer = DelegatingTracer() - tracer._start_typed_span("a", span_type="http.client.request") - with tracer._start_as_current_typed_span("b", span_type="http.client.request"): + tracer._start_typed_span("a", type_="http.client.request") + with tracer._start_as_current_typed_span("b", type_="http.client.request"): pass self.assertEqual(tracer.started, ["a", "b"]) def test_typed_span_with_noop_tracer(self): - with trace.get_tracer("instr")._start_as_current_typed_span("a", span_type="not valid!") as span: + with trace.get_tracer("instr")._start_as_current_typed_span("a", type_="not valid!") as span: self.assertFalse(span.is_recording()) def test_typed_span_through_proxy_created_before_provider(self): @@ -212,8 +212,8 @@ def test_typed_span_through_proxy_created_before_provider(self): explicit = ExplicitTracer() trace.set_tracer_provider(_Provider(explicit)) - proxy._start_typed_span("a", span_type="http.client.request", kind=SpanKind.CLIENT) - with proxy._start_as_current_typed_span("b", span_type="http.client.request"): + proxy._start_typed_span("a", type_="http.client.request", kind=SpanKind.CLIENT) + with proxy._start_as_current_typed_span("b", type_="http.client.request"): pass self.assertEqual( diff --git a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py index 0ac8bc0e6bc..aa5b6aafc33 100644 --- a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py +++ b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py @@ -170,7 +170,7 @@ def _start_typed_span_no_type(): def _start_typed_span_type(): span = tracer._start_typed_span( "benchmarkedSpan", - span_type="http.client.request", + type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, ) span.add_event("benchmarkEvent") @@ -180,7 +180,7 @@ def _start_typed_span_type(): def _start_typed_span_type_with_option(): span = tracer._start_typed_span( "benchmarkedSpan", - span_type="http.client.request", + type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, record_exception=False, ) @@ -198,7 +198,7 @@ def _start_as_current_typed_span_no_type(): def _start_as_current_typed_span_type(): with tracer._start_as_current_typed_span( "benchmarkedSpan", - span_type="http.client.request", + type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, ) as span: span.add_event("benchmarkEvent") @@ -207,7 +207,7 @@ def _start_as_current_typed_span_type(): def _start_as_current_typed_span_type_with_option(): with tracer._start_as_current_typed_span( "benchmarkedSpan", - span_type="http.client.request", + type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, record_exception=False, ) as span: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index a0b99c1fb24..10649886bdf 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1262,7 +1262,7 @@ def _start_typed_span( self, name: str, *, - span_type: str | None = None, + type_: str | None = None, kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, context: context_api.Context | None = None, attributes: types.Attributes = None, @@ -1270,8 +1270,8 @@ def _start_typed_span( start_time: int | None = None, **kwargs: Unpack[_SpanOptions], ) -> trace_api.Span: - if span_type is not None: - span_type = _checked_span_type(span_type) + if type_ is not None: + type_ = _checked_span_type(type_) record_exception, set_status_on_exception = _read_span_options(kwargs) if kwargs else _DEFAULT_SPAN_OPTIONS return self._new_span( name, @@ -1282,7 +1282,7 @@ def _start_typed_span( start_time, record_exception, set_status_on_exception, - span_type, + type_, ) @_agnosticcontextmanager # pylint: disable=protected-access @@ -1290,7 +1290,7 @@ def _start_as_current_typed_span( self, name: str, *, - span_type: str | None = None, + type_: str | None = None, kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, context: context_api.Context | None = None, attributes: types.Attributes = None, @@ -1299,8 +1299,8 @@ def _start_as_current_typed_span( end_on_exit: bool = True, **kwargs: Unpack[_SpanOptions], ) -> Iterator[trace_api.Span]: - if span_type is not None: - span_type = _checked_span_type(span_type) + if type_ is not None: + type_ = _checked_span_type(type_) record_exception, set_status_on_exception = _read_span_options(kwargs) if kwargs else _DEFAULT_SPAN_OPTIONS span = self._new_span( name, @@ -1311,7 +1311,7 @@ def _start_as_current_typed_span( start_time, record_exception, set_status_on_exception, - span_type, + type_, ) with trace_api.use_span( span, diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 588f5d5f1d9..0e7e41c1069 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -804,13 +804,13 @@ class TestSpanType(unittest.TestCase): def test_span_type(self): tracer = new_tracer() - span = tracer._start_typed_span("s", span_type="http.client.request") + span = tracer._start_typed_span("s", type_="http.client.request") self.assertEqual(span._span_type, "http.client.request") span.end() readable = span._readable_span() self.assertEqual(readable._span_type, "http.client.request") - with tracer._start_as_current_typed_span("s", span_type="db.client.operation") as span: + with tracer._start_as_current_typed_span("s", type_="db.client.operation") as span: self.assertEqual(span._span_type, "db.client.operation") self.assertIs(trace_api.get_current_span(), span) @@ -819,8 +819,8 @@ def test_span_type_through_proxy_created_before_provider(self): proxy = trace_api.get_tracer("instr") self.assertIsInstance(proxy, trace_api.ProxyTracer) with mock.patch.object(trace_api, "_TRACER_PROVIDER", trace.TracerProvider()): - span = proxy._start_typed_span("a", span_type="http.client.request") - with proxy._start_as_current_typed_span("b", span_type="db.client.operation") as current: + span = proxy._start_typed_span("a", type_="http.client.request") + with proxy._start_as_current_typed_span("b", type_="db.client.operation") as current: self.assertEqual(current._span_type, "db.client.operation") self.assertEqual(span._span_type, "http.client.request") @@ -830,8 +830,8 @@ def test_span_type_exported(self): tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) tracer = tracer_provider.get_tracer("instr") - tracer._start_typed_span("a", span_type="http.client.request").end() - with tracer._start_as_current_typed_span("b", span_type="db.client.operation"): + tracer._start_typed_span("a", type_="http.client.request").end() + with tracer._start_as_current_typed_span("b", type_="db.client.operation"): pass tracer.start_span("c").end() @@ -843,11 +843,11 @@ def test_span_type_exported(self): def test_start_as_current_typed_span_decorator(self): tracer = new_tracer() - @tracer._start_as_current_typed_span("sync", span_type="a.sync") + @tracer._start_as_current_typed_span("sync", type_="a.sync") def sync_func() -> str | None: return trace_api.get_current_span()._span_type - @tracer._start_as_current_typed_span("async", span_type="a.async") + @tracer._start_as_current_typed_span("async", type_="a.async") async def async_func() -> str | None: return trace_api.get_current_span()._span_type @@ -856,18 +856,18 @@ async def async_func() -> str | None: def test_start_as_current_typed_span_end_on_exit(self): tracer = new_tracer() - with tracer._start_as_current_typed_span("a", span_type="x", end_on_exit=False) as span: + with tracer._start_as_current_typed_span("a", type_="x", end_on_exit=False) as span: pass self.assertTrue(span.is_recording()) span.end() - with tracer._start_as_current_typed_span("b", span_type="x") as span: + with tracer._start_as_current_typed_span("b", type_="x") as span: pass self.assertFalse(span.is_recording()) def test_typed_span_exception_handling_defaults(self): tracer = new_tracer() with self.assertRaises(DummyError): - with tracer._start_as_current_typed_span("s", span_type="a") as span: + with tracer._start_as_current_typed_span("s", type_="a") as span: raise DummyError() self.assertIs(span.status.status_code, StatusCode.ERROR) self.assertEqual(span.events[0].name, "exception") @@ -884,7 +884,7 @@ def test_typed_span_exception_handling_from_kwargs(self): tracer = new_tracer() with self.assertRaises(DummyError): with tracer._start_as_current_typed_span( - "s", span_type="a", record_exception=False, set_status_on_exception=False + "s", type_="a", record_exception=False, set_status_on_exception=False ) as span: raise DummyError() self.assertIs(span.status.status_code, StatusCode.UNSET) @@ -893,12 +893,12 @@ def test_typed_span_exception_handling_from_kwargs(self): def test_typed_span_unknown_options_warn(self): tracer = new_tracer() with self.assertLogs(level=WARNING) as logs: - span = tracer._start_typed_span("s", span_type="a", kindd=trace_api.SpanKind.CLIENT) + span = tracer._start_typed_span("s", type_="a", kindd=trace_api.SpanKind.CLIENT) self.assertIn("kindd", logs.output[0]) self.assertIs(span.kind, trace_api.SpanKind.INTERNAL) with self.assertLogs(level=WARNING) as logs: - with tracer._start_as_current_typed_span("s", span_type="a", foo=1, record_exception=False): + with tracer._start_as_current_typed_span("s", type_="a", foo=1, record_exception=False): pass self.assertEqual(len(logs.output), 1) self.assertIn("foo", logs.output[0]) @@ -906,8 +906,8 @@ def test_typed_span_unknown_options_warn(self): def test_typed_span_known_options_dont_warn(self): tracer = new_tracer() with self.assertNoLogs(level=WARNING): - tracer._start_typed_span("s", span_type="a", record_exception=False, set_status_on_exception=False) - with tracer._start_as_current_typed_span("s", span_type="a"): + tracer._start_typed_span("s", type_="a", record_exception=False, set_status_on_exception=False) + with tracer._start_as_current_typed_span("s", type_="a"): pass def test_span_type_unset(self): @@ -918,8 +918,8 @@ def test_span_type_unset(self): def test_span_type_valid_syntax(self): tracer = new_tracer() for span_type in ("a", "A_b.c-d/e9", "a" * 255): - with self.subTest(span_type=span_type): - self.assertEqual(tracer._start_typed_span("s", span_type=span_type)._span_type, span_type) + with self.subTest(type_=span_type): + self.assertEqual(tracer._start_typed_span("s", type_=span_type)._span_type, span_type) def test_span_type_invalid_syntax(self): tracer = new_tracer() @@ -936,9 +936,9 @@ def test_span_type_invalid_syntax(self): b"http.client.request", ["a", "b"], ): - with self.subTest(span_type=span_type): + with self.subTest(type_=span_type): with self.assertLogs(level=ERROR): - span = tracer._start_typed_span("s", span_type=span_type) + span = tracer._start_typed_span("s", type_=span_type) self.assertTrue(span.is_recording()) self.assertIsNone(span._span_type) From 2a8a6922b149451fae18df1f63b2b1b40c7a5ec4 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Mon, 26 Oct 2026 15:27:34 -0700 Subject: [PATCH 5/9] Tighten typed span docstrings and comments Assisted-by: Antigravity --- .../src/opentelemetry/trace/__init__.py | 29 ++++++++----------- .../tests/trace/test_tracer_compat.py | 8 ++--- .../src/opentelemetry/sdk/trace/__init__.py | 2 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index c85fe5403e6..6ee0a968fd2 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -148,8 +148,8 @@ def dropped_attributes(self) -> int: _Links = Sequence[Link] | None -# Experimental. Options for the typed span methods. New span start options are -# added here as keys, so older implementations keep accepting newer callers. +# Experimental. New span start options go here, so older implementations +# still accept calls from newer callers. class _SpanOptions(TypedDict, total=False): record_exception: bool set_status_on_exception: bool @@ -407,7 +407,7 @@ def function(): ... The newly-created span. """ - # Experimental. Becomes public (start_typed_span) once span type is stable. + # Experimental. Becomes public as start_typed_span once span type is stable. def _start_typed_span( self, name: str, @@ -422,8 +422,7 @@ def _start_typed_span( ) -> "Span": """Experimental: starts a span with a span type. - Same as :meth:`start_span`, plus ``type_``. All arguments after - ``name`` are keyword-only. + Arguments after ``name`` are keyword-only. Args: type_: Identifies the class of spans this span belongs to, as @@ -433,22 +432,18 @@ def _start_typed_span( starts with a letter, followed by letters, digits, ``_``, ``.``, ``-`` or ``/``. Not validated by the API. kwargs: Options from :class:`_SpanOptions`, with the same defaults - as in :meth:`start_span`. Future span creation inputs are - added there. Implementations ignore the ones they don't know. - - The default implementation calls :meth:`start_span` and drops - ``type_``, so existing ``Tracer`` implementations keep working. - It passes ``record_exception`` and ``set_status_on_exception`` only if - the caller set them, so when they're not set the implementation's own - :meth:`start_span` defaults apply. - Implementations that support span type override this method. + as :meth:`start_span`. Implementations ignore options they + don't know. + + The default implementation calls :meth:`start_span` without ``type_``. + Implementations that support span type should override this method. """ return self.start_span( name, **_non_default_start_args(context, kind, attributes, links, start_time, kwargs), ) - # Experimental. Becomes public (start_as_current_typed_span) once span type is stable. + # Experimental. Becomes public as start_as_current_typed_span once span type is stable. @_agnosticcontextmanager def _start_as_current_typed_span( self, @@ -484,8 +479,8 @@ def _non_default_start_args( start_time: int | None, kwargs: _SpanOptions, ) -> dict[str, Any]: - # Only forward what the caller set, so implementations that override - # start_span with a subset of the parameters keep working. + # Forward only what the caller set. Overrides of start_span may not + # accept every parameter. args: dict[str, Any] = {} if context is not None: args["context"] = context diff --git a/opentelemetry-api/tests/trace/test_tracer_compat.py b/opentelemetry-api/tests/trace/test_tracer_compat.py index 1339d906ed8..a7ac4a8e295 100644 --- a/opentelemetry-api/tests/trace/test_tracer_compat.py +++ b/opentelemetry-api/tests/trace/test_tracer_compat.py @@ -3,8 +3,8 @@ # pylint: disable=protected-access -# Tracer implementations written against the current API, the way third parties -# write them. They must keep working, and keep type-checking, when the API grows. +# Tracers written against today's API, like third-party ones. They must keep +# working and type-checking as the API grows. from collections.abc import Iterator from typing import Any @@ -63,7 +63,7 @@ def start_as_current_span( class ShortSignatureTracer(Tracer): - """Overrides with a subset of parameters, as minimal implementations do.""" + """Overrides with a subset of parameters.""" def start_span( # type: ignore[override] # pylint: disable=arguments-differ self, name: str, context: Context | None = None, kind: SpanKind = SpanKind.INTERNAL @@ -180,7 +180,7 @@ def test_typed_span_with_explicit_tracer(self): trace.set_tracer_provider(_Provider(explicit)) tracer = trace.get_tracer("instr") tracer._start_typed_span("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, type_="http.client.request") - # simulates a caller built against a newer API with options this one doesn't know + # a caller built against a newer API, passing an option this one doesn't have future_options: dict[str, Any] = {"future_arg": 1} with tracer._start_as_current_typed_span("b", type_="http.client.request", **future_options): pass diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 10649886bdf..ee07a532863 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -106,7 +106,7 @@ def _read_span_options(kwargs: Mapping[str, Any]) -> tuple[bool, bool]: "Ignoring unknown span start options: %s", sorted(kwargs.keys() - _KNOWN_SPAN_OPTIONS), ) - # both default to True; only an explicit False turns them off, other non-bool values are ignored + # Both default to True. Only an explicit False turns them off, other values are ignored. return ( kwargs.get("record_exception") is not False, kwargs.get("set_status_on_exception") is not False, From e36ab49aa2b1f7919fa3bbb920a1cbe3e02fc1ba Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Mon, 26 Oct 2026 15:35:16 -0700 Subject: [PATCH 6/9] Rename ReadableSpan._span_type to _type Assisted-by: Antigravity --- .changelog/5819.added | 2 +- .../src/opentelemetry/sdk/trace/__init__.py | 20 ++++++++-------- opentelemetry-sdk/tests/trace/test_trace.py | 24 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.changelog/5819.added b/.changelog/5819.added index 7ceff22dde6..9f5e5c82703 100644 --- a/.changelog/5819.added +++ b/.changelog/5819.added @@ -1 +1 @@ -`opentelemetry-api`, `opentelemetry-sdk`: Add experimental `Tracer._start_typed_span` and `Tracer._start_as_current_typed_span` to create spans with a span type; the SDK validates it and exposes it on `ReadableSpan._span_type` +`opentelemetry-api`, `opentelemetry-sdk`: Add experimental `Tracer._start_typed_span` and `Tracer._start_as_current_typed_span` to create spans with a span type; the SDK validates it and exposes it on `ReadableSpan._type` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index ee07a532863..8e2a23835c2 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -466,12 +466,12 @@ def __init__( end_time: int | None = None, instrumentation_scope: InstrumentationScope | None = None, *, - _span_type: str | None = None, + _type: str | None = None, ) -> None: self._name = name self._context = context self._kind = kind - self._span_type_value = _span_type + self._type_value = _type self._instrumentation_info = instrumentation_info self._instrumentation_scope = instrumentation_scope self._parent = parent @@ -520,9 +520,9 @@ def kind(self) -> trace_api.SpanKind: return self._kind @property - def _span_type(self) -> str | None: - """Experimental: the span type, if set. Becomes public once span type is stable.""" - return self._span_type_value + def _type(self) -> str | None: + """Experimental: the span type, if set. Becomes public as ``type`` once span type is stable.""" + return self._type_value @property def parent(self) -> trace_api.SpanContext | None: @@ -845,7 +845,7 @@ def __init__( instrumentation_scope: InstrumentationScope | None = None, *, record_end_metrics: Callable[[], None] | None = None, - _span_type: str | None = None, + _type: str | None = None, ) -> None: if resource is None: resource = Resource.create({}) @@ -854,7 +854,7 @@ def __init__( context=context, parent=parent, kind=kind, - _span_type=_span_type, + _type=_type, resource=resource, instrumentation_info=instrumentation_info, instrumentation_scope=instrumentation_scope, @@ -996,7 +996,7 @@ def _readable_span(self) -> ReadableSpan: events=self._events, links=self._links, kind=self.kind, - _span_type=self._span_type, + _type=self._type, status=self._status, start_time=self._start_time, end_time=self._end_time, @@ -1331,7 +1331,7 @@ def _new_span( # pylint: disable=too-many-locals start_time: int | None, record_exception: bool, set_status_on_exception: bool, - span_type: str | None, + type_: str | None, ) -> trace_api.Span: links = links or () parent_span_context = trace_api.get_current_span(context).get_span_context() @@ -1393,7 +1393,7 @@ def _new_span( # pylint: disable=too-many-locals attributes=sampling_result.attributes, span_processor=self.span_processor, kind=kind, - _span_type=span_type, + _type=type_, links=links, instrumentation_info=self.instrumentation_info, record_exception=record_exception, diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 0e7e41c1069..d9a58130839 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -805,13 +805,13 @@ class TestSpanType(unittest.TestCase): def test_span_type(self): tracer = new_tracer() span = tracer._start_typed_span("s", type_="http.client.request") - self.assertEqual(span._span_type, "http.client.request") + self.assertEqual(span._type, "http.client.request") span.end() readable = span._readable_span() - self.assertEqual(readable._span_type, "http.client.request") + self.assertEqual(readable._type, "http.client.request") with tracer._start_as_current_typed_span("s", type_="db.client.operation") as span: - self.assertEqual(span._span_type, "db.client.operation") + self.assertEqual(span._type, "db.client.operation") self.assertIs(trace_api.get_current_span(), span) def test_span_type_through_proxy_created_before_provider(self): @@ -821,8 +821,8 @@ def test_span_type_through_proxy_created_before_provider(self): with mock.patch.object(trace_api, "_TRACER_PROVIDER", trace.TracerProvider()): span = proxy._start_typed_span("a", type_="http.client.request") with proxy._start_as_current_typed_span("b", type_="db.client.operation") as current: - self.assertEqual(current._span_type, "db.client.operation") - self.assertEqual(span._span_type, "http.client.request") + self.assertEqual(current._type, "db.client.operation") + self.assertEqual(span._type, "http.client.request") def test_span_type_exported(self): exporter = InMemorySpanExporter() @@ -836,7 +836,7 @@ def test_span_type_exported(self): tracer.start_span("c").end() self.assertEqual( - [(s.name, s._span_type) for s in exporter.get_finished_spans()], + [(s.name, s._type) for s in exporter.get_finished_spans()], [("a", "http.client.request"), ("b", "db.client.operation"), ("c", None)], ) @@ -845,11 +845,11 @@ def test_start_as_current_typed_span_decorator(self): @tracer._start_as_current_typed_span("sync", type_="a.sync") def sync_func() -> str | None: - return trace_api.get_current_span()._span_type + return trace_api.get_current_span()._type @tracer._start_as_current_typed_span("async", type_="a.async") async def async_func() -> str | None: - return trace_api.get_current_span()._span_type + return trace_api.get_current_span()._type self.assertEqual(sync_func(), "a.sync") self.assertEqual(asyncio.run(async_func()), "a.async") @@ -912,14 +912,14 @@ def test_typed_span_known_options_dont_warn(self): def test_span_type_unset(self): span = new_tracer().start_span("s") - self.assertIsNone(span._span_type) - self.assertIsNone(new_tracer()._start_typed_span("s")._span_type) + self.assertIsNone(span._type) + self.assertIsNone(new_tracer()._start_typed_span("s")._type) def test_span_type_valid_syntax(self): tracer = new_tracer() for span_type in ("a", "A_b.c-d/e9", "a" * 255): with self.subTest(type_=span_type): - self.assertEqual(tracer._start_typed_span("s", type_=span_type)._span_type, span_type) + self.assertEqual(tracer._start_typed_span("s", type_=span_type)._type, span_type) def test_span_type_invalid_syntax(self): tracer = new_tracer() @@ -940,7 +940,7 @@ def test_span_type_invalid_syntax(self): with self.assertLogs(level=ERROR): span = tracer._start_typed_span("s", type_=span_type) self.assertTrue(span.is_recording()) - self.assertIsNone(span._span_type) + self.assertIsNone(span._type) class TestReadableSpan(unittest.TestCase): From 81d35f229cddb3cc78a818cf5b293c5ffb32067b Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Wed, 28 Oct 2026 17:46:28 -0700 Subject: [PATCH 7/9] Match typed span signatures to start_span Typed span methods now take the same (positional) arguments as start_span and start_as_current_span, with type_ as a trailing keyword-only argument. record_exception and set_status_on_exception are regular arguments again; _SpanOptions is reserved for future options. --- .../src/opentelemetry/trace/__init__.py | 59 +++++++++++-------- .../src/opentelemetry/sdk/trace/__init__.py | 39 +++++------- opentelemetry-sdk/tests/trace/test_trace.py | 36 ++++++++--- 3 files changed, 77 insertions(+), 57 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 6ee0a968fd2..05c08ac7a8a 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -148,11 +148,11 @@ def dropped_attributes(self) -> int: _Links = Sequence[Link] | None -# Experimental. New span start options go here, so older implementations -# still accept calls from newer callers. -class _SpanOptions(TypedDict, total=False): - record_exception: bool - set_status_on_exception: bool +# Experimental. Span start options added after span type go here as +# NotRequired fields, so older implementations still accept calls from newer +# callers. +class _SpanOptions(TypedDict): + pass class SpanKind(Enum): @@ -408,21 +408,22 @@ def function(): ... """ # Experimental. Becomes public as start_typed_span once span type is stable. + # TODO: fold type_ into start_span in the next major version. def _start_typed_span( self, name: str, - *, - type_: str | None = None, - kind: SpanKind = SpanKind.INTERNAL, context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, attributes: types.Attributes = None, links: _Links = None, start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + *, + type_: str | None = None, **kwargs: Unpack[_SpanOptions], ) -> "Span": - """Experimental: starts a span with a span type. - - Arguments after ``name`` are keyword-only. + """Experimental: same as :meth:`start_span`, with a span type. Args: type_: Identifies the class of spans this span belongs to, as @@ -431,31 +432,36 @@ def _start_typed_span( a case-sensitive ASCII string of at most 255 characters that starts with a letter, followed by letters, digits, ``_``, ``.``, ``-`` or ``/``. Not validated by the API. - kwargs: Options from :class:`_SpanOptions`, with the same defaults - as :meth:`start_span`. Implementations ignore options they - don't know. + kwargs: Options from :class:`_SpanOptions`. Implementations + ignore options they don't know. - The default implementation calls :meth:`start_span` without ``type_``. + See :meth:`start_span` for the other arguments. The default + implementation calls :meth:`start_span` without ``type_``. Implementations that support span type should override this method. """ return self.start_span( name, - **_non_default_start_args(context, kind, attributes, links, start_time, kwargs), + **_non_default_start_args( + context, kind, attributes, links, start_time, record_exception, set_status_on_exception + ), ) # Experimental. Becomes public as start_as_current_typed_span once span type is stable. + # TODO: fold type_ into start_as_current_span in the next major version. @_agnosticcontextmanager def _start_as_current_typed_span( self, name: str, - *, - type_: str | None = None, - kind: SpanKind = SpanKind.INTERNAL, context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, attributes: types.Attributes = None, links: _Links = None, start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, end_on_exit: bool = True, + *, + type_: str | None = None, **kwargs: Unpack[_SpanOptions], ) -> Iterator["Span"]: """Experimental: same as :meth:`start_as_current_span`, with a span type. @@ -464,7 +470,9 @@ def _start_as_current_typed_span( default implementation calls :meth:`start_as_current_span` and drops ``type_``. """ - args = _non_default_start_args(context, kind, attributes, links, start_time, kwargs) + args = _non_default_start_args( + context, kind, attributes, links, start_time, record_exception, set_status_on_exception + ) if not end_on_exit: args["end_on_exit"] = end_on_exit with self.start_as_current_span(name, **args) as span: @@ -477,7 +485,8 @@ def _non_default_start_args( attributes: types.Attributes, links: _Links, start_time: int | None, - kwargs: _SpanOptions, + record_exception: bool, + set_status_on_exception: bool, ) -> dict[str, Any]: # Forward only what the caller set. Overrides of start_span may not # accept every parameter. @@ -492,10 +501,10 @@ def _non_default_start_args( args["links"] = links if start_time is not None: args["start_time"] = start_time - if "record_exception" in kwargs: - args["record_exception"] = kwargs["record_exception"] - if "set_status_on_exception" in kwargs: - args["set_status_on_exception"] = kwargs["set_status_on_exception"] + if record_exception is not True: + args["record_exception"] = record_exception + if set_status_on_exception is not True: + args["set_status_on_exception"] = set_status_on_exception return args diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 8e2a23835c2..1f6d7c3a16b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -83,10 +83,6 @@ _SPAN_TYPE_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_./-]{0,254}") -_KNOWN_SPAN_OPTIONS = frozenset(("record_exception", "set_status_on_exception")) -# (record_exception, set_status_on_exception) when no options are passed -_DEFAULT_SPAN_OPTIONS = (True, True) - def _is_valid_span_type(span_type: object) -> bool: """Checks the span type for compliance with the span type syntax.""" @@ -100,17 +96,8 @@ def _checked_span_type(span_type: str) -> str | None: return None -def _read_span_options(kwargs: Mapping[str, Any]) -> tuple[bool, bool]: - if not _KNOWN_SPAN_OPTIONS.issuperset(kwargs): - logger.warning( - "Ignoring unknown span start options: %s", - sorted(kwargs.keys() - _KNOWN_SPAN_OPTIONS), - ) - # Both default to True. Only an explicit False turns them off, other values are ignored. - return ( - kwargs.get("record_exception") is not False, - kwargs.get("set_status_on_exception") is not False, - ) +def _warn_unknown_span_options(kwargs: Mapping[str, Any]) -> None: + logger.warning("Ignoring unknown span start options: %s", sorted(kwargs)) _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 @@ -1261,18 +1248,21 @@ def start_span( def _start_typed_span( self, name: str, - *, - type_: str | None = None, - kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, context: context_api.Context | None = None, + kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, attributes: types.Attributes = None, links: Sequence[trace_api.Link] | None = (), start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + *, + type_: str | None = None, **kwargs: Unpack[_SpanOptions], ) -> trace_api.Span: if type_ is not None: type_ = _checked_span_type(type_) - record_exception, set_status_on_exception = _read_span_options(kwargs) if kwargs else _DEFAULT_SPAN_OPTIONS + if kwargs: + _warn_unknown_span_options(kwargs) return self._new_span( name, context, @@ -1289,19 +1279,22 @@ def _start_typed_span( def _start_as_current_typed_span( self, name: str, - *, - type_: str | None = None, - kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, context: context_api.Context | None = None, + kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, attributes: types.Attributes = None, links: Sequence[trace_api.Link] | None = (), start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, end_on_exit: bool = True, + *, + type_: str | None = None, **kwargs: Unpack[_SpanOptions], ) -> Iterator[trace_api.Span]: if type_ is not None: type_ = _checked_span_type(type_) - record_exception, set_status_on_exception = _read_span_options(kwargs) if kwargs else _DEFAULT_SPAN_OPTIONS + if kwargs: + _warn_unknown_span_options(kwargs) span = self._new_span( name, context, diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index d9a58130839..718aa1f441c 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -872,23 +872,41 @@ def test_typed_span_exception_handling_defaults(self): self.assertIs(span.status.status_code, StatusCode.ERROR) self.assertEqual(span.events[0].name, "exception") - def test_typed_span_exception_flags_ignore_non_bool(self): + def test_typed_span_positional_args_match_start_span(self): tracer = new_tracer() - with self.assertRaises(DummyError): - with tracer._start_as_current_typed_span("s", record_exception="no", set_status_on_exception=0) as span: - raise DummyError() - self.assertIs(span.status.status_code, StatusCode.ERROR) - self.assertEqual(span.events[0].name, "exception") + span = tracer._start_typed_span("s", None, trace_api.SpanKind.CLIENT, {"k": "v"}, type_="a") + self.assertIs(span.kind, trace_api.SpanKind.CLIENT) + self.assertEqual(span.attributes, {"k": "v"}) + self.assertEqual(span._type, "a") - def test_typed_span_exception_handling_from_kwargs(self): - tracer = new_tracer() with self.assertRaises(DummyError): with tracer._start_as_current_typed_span( - "s", type_="a", record_exception=False, set_status_on_exception=False + "s", None, trace_api.SpanKind.SERVER, None, None, None, False, False, type_="b" ) as span: raise DummyError() + self.assertIs(span.kind, trace_api.SpanKind.SERVER) self.assertIs(span.status.status_code, StatusCode.UNSET) self.assertEqual(len(span.events), 0) + self.assertEqual(span._type, "b") + + def test_typed_span_exception_handling(self): + tracer = new_tracer() + for record_exception in (True, False): + for set_status_on_exception in (True, False): + with self.subTest(record_exception=record_exception, set_status_on_exception=set_status_on_exception): + with self.assertRaises(DummyError): + with tracer._start_as_current_typed_span( + "s", + type_="a", + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) as span: + raise DummyError() + self.assertIs( + span.status.status_code, + StatusCode.ERROR if set_status_on_exception else StatusCode.UNSET, + ) + self.assertEqual(len(span.events), 1 if record_exception else 0) def test_typed_span_unknown_options_warn(self): tracer = new_tracer() From 90b6d2994f24f4c4b13995fb687b327f61f5af3e Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sun, 1 Nov 2026 14:30:16 -0800 Subject: [PATCH 8/9] Use extensible options objects for experimental span creation Keep span type as a top-level keyword argument and preserve fallback behavior for existing tracer implementations. Assisted-by: ChatGPT --- .changelog/5819.added | 2 +- .../src/opentelemetry/trace/__init__.py | 122 +++++++++++++----- .../tests/trace/test_tracer_compat.py | 28 ++-- .../benchmarks/trace/test_benchmark_trace.py | 36 +++--- .../src/opentelemetry/sdk/trace/__init__.py | 18 +-- opentelemetry-sdk/tests/trace/test_trace.py | 98 +++++++++----- 6 files changed, 194 insertions(+), 110 deletions(-) diff --git a/.changelog/5819.added b/.changelog/5819.added index 9f5e5c82703..e7c05e7e42c 100644 --- a/.changelog/5819.added +++ b/.changelog/5819.added @@ -1 +1 @@ -`opentelemetry-api`, `opentelemetry-sdk`: Add experimental `Tracer._start_typed_span` and `Tracer._start_as_current_typed_span` to create spans with a span type; the SDK validates it and exposes it on `ReadableSpan._type` +`opentelemetry-api`, `opentelemetry-sdk`: Add experimental span creation methods with `_SpanOptions`, preserving existing tracer implementations; the SDK validates span type and exposes it on `ReadableSpan._type` diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 05c08ac7a8a..e4c5396f034 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -65,11 +65,12 @@ import os from abc import ABC, abstractmethod from collections.abc import Iterator, Sequence +from dataclasses import dataclass from enum import Enum from logging import getLogger from typing import Any, cast -from typing_extensions import TypedDict, Unpack, deprecated +from typing_extensions import deprecated from opentelemetry import context as context_api from opentelemetry.attributes import BoundedAttributes @@ -148,11 +149,13 @@ def dropped_attributes(self) -> int: _Links = Sequence[Link] | None -# Experimental. Span start options added after span type go here as -# NotRequired fields, so older implementations still accept calls from newer -# callers. -class _SpanOptions(TypedDict): - pass +@dataclass(frozen=True, kw_only=True) +class _SpanOptions: + """Experimental options for span creation. + + New options must have defaults. Implementations ignore fields they do not + support, allowing the options to grow without changing method signatures. + """ class SpanKind(Enum): @@ -407,9 +410,8 @@ def function(): ... The newly-created span. """ - # Experimental. Becomes public as start_typed_span once span type is stable. - # TODO: fold type_ into start_span in the next major version. - def _start_typed_span( + # Experimental. Becomes public as start_span_with_options once span type is stable. + def _start_span_with_options( self, name: str, context: Context | None = None, @@ -421,23 +423,26 @@ def _start_typed_span( set_status_on_exception: bool = True, *, type_: str | None = None, - **kwargs: Unpack[_SpanOptions], + options: _SpanOptions | None = None, ) -> "Span": - """Experimental: same as :meth:`start_span`, with a span type. + """Experimental: create a span with extensible options. Args: - type_: Identifies the class of spans this span belongs to, as - defined by semantic conventions, for example - ``"http.client.request"``. Immutable after creation. Should be - a case-sensitive ASCII string of at most 255 characters that - starts with a letter, followed by letters, digits, ``_``, - ``.``, ``-`` or ``/``. Not validated by the API. - kwargs: Options from :class:`_SpanOptions`. Implementations - ignore options they don't know. + type_: Identifies the class of spans, for example ``"http.client.request"``. + Immutable after creation. Must be a case-sensitive ASCII string of + at most 255 characters starting with a letter, followed by letters, + digits, ``_``, ``.``, ``-`` or ``/``. Validated by the SDK. + options: Additional span creation options. Unsupported fields are + ignored. Omitting options preserves :meth:`start_span` behavior. See :meth:`start_span` for the other arguments. The default - implementation calls :meth:`start_span` without ``type_``. - Implementations that support span type should override this method. + implementation calls :meth:`start_span` and ignores ``type_`` and ``options``. + Implementations supporting these options should override this method. + + Example:: + + with tracer._start_span_with_options("GET", type_="http.client.request"): + ... """ return self.start_span( name, @@ -446,10 +451,9 @@ def _start_typed_span( ), ) - # Experimental. Becomes public as start_as_current_typed_span once span type is stable. - # TODO: fold type_ into start_as_current_span in the next major version. + # Experimental. Becomes public as start_as_current_span_with_options once span type is stable. @_agnosticcontextmanager - def _start_as_current_typed_span( + def _start_as_current_span_with_options( self, name: str, context: Context | None = None, @@ -462,13 +466,13 @@ def _start_as_current_typed_span( end_on_exit: bool = True, *, type_: str | None = None, - **kwargs: Unpack[_SpanOptions], + options: _SpanOptions | None = None, ) -> Iterator["Span"]: - """Experimental: same as :meth:`start_as_current_span`, with a span type. + """Experimental: create a current span with extensible options. - See :meth:`_start_typed_span` for ``type_`` and ``kwargs``. The - default implementation calls :meth:`start_as_current_span` and drops - ``type_``. + See :meth:`_start_span_with_options` for ``type_`` and ``options``. The default + implementation calls :meth:`start_as_current_span` and ignores + ``type_`` and ``options``. """ args = _non_default_start_args( context, kind, attributes, links, start_time, record_exception, set_status_on_exception @@ -547,12 +551,62 @@ def start_as_current_span(self, *args, **kwargs) -> Iterator[Span]: with self._tracer.start_as_current_span(*args, **kwargs) as span: # type: ignore yield span - def _start_typed_span(self, *args, **kwargs) -> Span: # type: ignore - return self._tracer._start_typed_span(*args, **kwargs) # type: ignore # pylint: disable=protected-access + def _start_span_with_options( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + *, + type_: str | None = None, + options: _SpanOptions | None = None, + ) -> Span: + return self._tracer._start_span_with_options( + name, + context, + kind, + attributes, + links, + start_time, + record_exception, + set_status_on_exception, + type_=type_, + options=options, + ) - @_agnosticcontextmanager # type: ignore - def _start_as_current_typed_span(self, *args, **kwargs) -> Iterator[Span]: - with self._tracer._start_as_current_typed_span(*args, **kwargs) as span: # type: ignore # pylint: disable=protected-access + @_agnosticcontextmanager + def _start_as_current_span_with_options( + self, + name: str, + context: Context | None = None, + kind: SpanKind = SpanKind.INTERNAL, + attributes: types.Attributes = None, + links: _Links = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + end_on_exit: bool = True, + *, + type_: str | None = None, + options: _SpanOptions | None = None, + ) -> Iterator[Span]: + with self._tracer._start_as_current_span_with_options( + name, + context, + kind, + attributes, + links, + start_time, + record_exception, + set_status_on_exception, + end_on_exit, + type_=type_, + options=options, + ) as span: yield span diff --git a/opentelemetry-api/tests/trace/test_tracer_compat.py b/opentelemetry-api/tests/trace/test_tracer_compat.py index a7ac4a8e295..72d61bf4d0c 100644 --- a/opentelemetry-api/tests/trace/test_tracer_compat.py +++ b/opentelemetry-api/tests/trace/test_tracer_compat.py @@ -7,6 +7,7 @@ # working and type-checking as the API grows. from collections.abc import Iterator +from dataclasses import dataclass from typing import Any from opentelemetry import trace @@ -20,6 +21,7 @@ SpanKind, Tracer, _Links, + _SpanOptions, ) from opentelemetry.util import types from opentelemetry.util._decorator import _agnosticcontextmanager @@ -163,9 +165,9 @@ def test_short_signature_tracer(self): def test_typed_span_with_short_signature_tracer(self): trace.set_tracer_provider(_Provider(ShortSignatureTracer())) tracer = trace.get_tracer("instr") - span = tracer._start_typed_span("a", kind=SpanKind.CLIENT, type_="http.client.request") + span = tracer._start_span_with_options("a", kind=SpanKind.CLIENT, type_="http.client.request") self.assertIs(span, INVALID_SPAN) - with tracer._start_as_current_typed_span("b", type_="http.client.request") as span: + with tracer._start_as_current_span_with_options("b", type_="http.client.request") as span: self.assertIs(span, INVALID_SPAN) def test_delegating_tracer(self): @@ -179,12 +181,16 @@ def test_typed_span_with_explicit_tracer(self): explicit = ExplicitTracer() trace.set_tracer_provider(_Provider(explicit)) tracer = trace.get_tracer("instr") - tracer._start_typed_span("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, type_="http.client.request") + tracer._start_span_with_options("a", kind=SpanKind.CLIENT, attributes={"k": "v"}, type_="http.client.request") + # a caller built against a newer API, passing an option this one doesn't have - future_options: dict[str, Any] = {"future_arg": 1} - with tracer._start_as_current_typed_span("b", type_="http.client.request", **future_options): + @dataclass(frozen=True, kw_only=True) + class FutureOptions(_SpanOptions): + future_arg: int = 1 + + with tracer._start_as_current_span_with_options("b", type_="http.client.request", options=FutureOptions()): pass - tracer._start_typed_span("c", record_exception=False) + tracer._start_span_with_options("c", record_exception=False) self.assertEqual( explicit.calls, @@ -197,13 +203,13 @@ def test_typed_span_with_explicit_tracer(self): def test_typed_span_with_delegating_tracer(self): tracer = DelegatingTracer() - tracer._start_typed_span("a", type_="http.client.request") - with tracer._start_as_current_typed_span("b", type_="http.client.request"): + tracer._start_span_with_options("a", type_="http.client.request") + with tracer._start_as_current_span_with_options("b", type_="http.client.request"): pass self.assertEqual(tracer.started, ["a", "b"]) def test_typed_span_with_noop_tracer(self): - with trace.get_tracer("instr")._start_as_current_typed_span("a", type_="not valid!") as span: + with trace.get_tracer("instr")._start_as_current_span_with_options("a", type_="not valid!") as span: self.assertFalse(span.is_recording()) def test_typed_span_through_proxy_created_before_provider(self): @@ -212,8 +218,8 @@ def test_typed_span_through_proxy_created_before_provider(self): explicit = ExplicitTracer() trace.set_tracer_provider(_Provider(explicit)) - proxy._start_typed_span("a", type_="http.client.request", kind=SpanKind.CLIENT) - with proxy._start_as_current_typed_span("b", type_="http.client.request"): + proxy._start_span_with_options("a", type_="http.client.request", kind=SpanKind.CLIENT) + with proxy._start_as_current_span_with_options("b", type_="http.client.request"): pass self.assertEqual( diff --git a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py index aa5b6aafc33..6a730a8bd38 100644 --- a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py +++ b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py @@ -161,14 +161,14 @@ def _record_mean_alloc_bytes(benchmark, func, *args): benchmark.extra_info["mean_alloc_bytes"] = sum(peaks) / len(peaks) -def _start_typed_span_no_type(): - span = tracer._start_typed_span("benchmarkedSpan", attributes={"long.attribute": -10000000001000000000}) +def _start_span_with_options_no_type(): + span = tracer._start_span_with_options("benchmarkedSpan", attributes={"long.attribute": -10000000001000000000}) span.add_event("benchmarkEvent") span.end() -def _start_typed_span_type(): - span = tracer._start_typed_span( +def _start_span_with_options_type(): + span = tracer._start_span_with_options( "benchmarkedSpan", type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, @@ -177,8 +177,8 @@ def _start_typed_span_type(): span.end() -def _start_typed_span_type_with_option(): - span = tracer._start_typed_span( +def _start_span_with_options_type_with_option(): + span = tracer._start_span_with_options( "benchmarkedSpan", type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, @@ -188,15 +188,15 @@ def _start_typed_span_type_with_option(): span.end() -def _start_as_current_typed_span_no_type(): - with tracer._start_as_current_typed_span( +def _start_as_current_span_with_options_no_type(): + with tracer._start_as_current_span_with_options( "benchmarkedSpan", attributes={"long.attribute": -10000000001000000000} ) as span: span.add_event("benchmarkEvent") -def _start_as_current_typed_span_type(): - with tracer._start_as_current_typed_span( +def _start_as_current_span_with_options_type(): + with tracer._start_as_current_span_with_options( "benchmarkedSpan", type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, @@ -204,8 +204,8 @@ def _start_as_current_typed_span_type(): span.add_event("benchmarkEvent") -def _start_as_current_typed_span_type_with_option(): - with tracer._start_as_current_typed_span( +def _start_as_current_span_with_options_type_with_option(): + with tracer._start_as_current_span_with_options( "benchmarkedSpan", type_="http.client.request", attributes={"long.attribute": -10000000001000000000}, @@ -216,10 +216,10 @@ def _start_as_current_typed_span_type_with_option(): @pytest.mark.parametrize( "func", - [_start_typed_span_no_type, _start_typed_span_type, _start_typed_span_type_with_option], + [_start_span_with_options_no_type, _start_span_with_options_type, _start_span_with_options_type_with_option], ids=["no_type", "type", "type_with_option"], ) -def test_simple_start_typed_span(benchmark, func): +def test_simple_start_span_with_options(benchmark, func): _record_mean_alloc_bytes(benchmark, func) benchmark(func) @@ -227,13 +227,13 @@ def test_simple_start_typed_span(benchmark, func): @pytest.mark.parametrize( "func", [ - _start_as_current_typed_span_no_type, - _start_as_current_typed_span_type, - _start_as_current_typed_span_type_with_option, + _start_as_current_span_with_options_no_type, + _start_as_current_span_with_options_type, + _start_as_current_span_with_options_type_with_option, ], ids=["no_type", "type", "type_with_option"], ) -def test_simple_start_as_current_typed_span(benchmark, func): +def test_simple_start_as_current_span_with_options(benchmark, func): _record_mean_alloc_bytes(benchmark, func) benchmark(func) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 1f6d7c3a16b..28766ce1a01 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -29,7 +29,7 @@ ) from warnings import filterwarnings -from typing_extensions import Unpack, deprecated +from typing_extensions import deprecated from opentelemetry import context as context_api from opentelemetry import metrics as metrics_api @@ -96,10 +96,6 @@ def _checked_span_type(span_type: str) -> str | None: return None -def _warn_unknown_span_options(kwargs: Mapping[str, Any]) -> None: - logger.warning("Ignoring unknown span start options: %s", sorted(kwargs)) - - _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT = 128 _DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT = 128 @@ -1245,7 +1241,7 @@ def start_span( None, ) - def _start_typed_span( + def _start_span_with_options( self, name: str, context: context_api.Context | None = None, @@ -1257,12 +1253,10 @@ def _start_typed_span( set_status_on_exception: bool = True, *, type_: str | None = None, - **kwargs: Unpack[_SpanOptions], + options: _SpanOptions | None = None, ) -> trace_api.Span: if type_ is not None: type_ = _checked_span_type(type_) - if kwargs: - _warn_unknown_span_options(kwargs) return self._new_span( name, context, @@ -1276,7 +1270,7 @@ def _start_typed_span( ) @_agnosticcontextmanager # pylint: disable=protected-access - def _start_as_current_typed_span( + def _start_as_current_span_with_options( self, name: str, context: context_api.Context | None = None, @@ -1289,12 +1283,10 @@ def _start_as_current_typed_span( end_on_exit: bool = True, *, type_: str | None = None, - **kwargs: Unpack[_SpanOptions], + options: _SpanOptions | None = None, ) -> Iterator[trace_api.Span]: if type_ is not None: type_ = _checked_span_type(type_) - if kwargs: - _warn_unknown_span_options(kwargs) span = self._new_span( name, context, diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 718aa1f441c..eec8dd872cd 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -804,13 +804,13 @@ class TestSpanType(unittest.TestCase): def test_span_type(self): tracer = new_tracer() - span = tracer._start_typed_span("s", type_="http.client.request") + span = tracer._start_span_with_options("s", type_="http.client.request") self.assertEqual(span._type, "http.client.request") span.end() readable = span._readable_span() self.assertEqual(readable._type, "http.client.request") - with tracer._start_as_current_typed_span("s", type_="db.client.operation") as span: + with tracer._start_as_current_span_with_options("s", type_="db.client.operation") as span: self.assertEqual(span._type, "db.client.operation") self.assertIs(trace_api.get_current_span(), span) @@ -819,8 +819,8 @@ def test_span_type_through_proxy_created_before_provider(self): proxy = trace_api.get_tracer("instr") self.assertIsInstance(proxy, trace_api.ProxyTracer) with mock.patch.object(trace_api, "_TRACER_PROVIDER", trace.TracerProvider()): - span = proxy._start_typed_span("a", type_="http.client.request") - with proxy._start_as_current_typed_span("b", type_="db.client.operation") as current: + span = proxy._start_span_with_options("a", type_="http.client.request") + with proxy._start_as_current_span_with_options("b", type_="db.client.operation") as current: self.assertEqual(current._type, "db.client.operation") self.assertEqual(span._type, "http.client.request") @@ -830,8 +830,8 @@ def test_span_type_exported(self): tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) tracer = tracer_provider.get_tracer("instr") - tracer._start_typed_span("a", type_="http.client.request").end() - with tracer._start_as_current_typed_span("b", type_="db.client.operation"): + tracer._start_span_with_options("a", type_="http.client.request").end() + with tracer._start_as_current_span_with_options("b", type_="db.client.operation"): pass tracer.start_span("c").end() @@ -840,48 +840,56 @@ def test_span_type_exported(self): [("a", "http.client.request"), ("b", "db.client.operation"), ("c", None)], ) - def test_start_as_current_typed_span_decorator(self): + def test_start_as_current_span_with_options_decorator(self): tracer = new_tracer() - @tracer._start_as_current_typed_span("sync", type_="a.sync") + @tracer._start_as_current_span_with_options("sync", type_="a.sync") def sync_func() -> str | None: return trace_api.get_current_span()._type - @tracer._start_as_current_typed_span("async", type_="a.async") + @tracer._start_as_current_span_with_options("async", type_="a.async") async def async_func() -> str | None: return trace_api.get_current_span()._type self.assertEqual(sync_func(), "a.sync") self.assertEqual(asyncio.run(async_func()), "a.async") - def test_start_as_current_typed_span_end_on_exit(self): + def test_start_as_current_span_with_options_end_on_exit(self): tracer = new_tracer() - with tracer._start_as_current_typed_span("a", type_="x", end_on_exit=False) as span: + with tracer._start_as_current_span_with_options("a", type_="x", end_on_exit=False) as span: pass self.assertTrue(span.is_recording()) span.end() - with tracer._start_as_current_typed_span("b", type_="x") as span: + with tracer._start_as_current_span_with_options("b", type_="x") as span: pass self.assertFalse(span.is_recording()) def test_typed_span_exception_handling_defaults(self): tracer = new_tracer() with self.assertRaises(DummyError): - with tracer._start_as_current_typed_span("s", type_="a") as span: + with tracer._start_as_current_span_with_options("s", type_="a") as span: raise DummyError() self.assertIs(span.status.status_code, StatusCode.ERROR) self.assertEqual(span.events[0].name, "exception") def test_typed_span_positional_args_match_start_span(self): tracer = new_tracer() - span = tracer._start_typed_span("s", None, trace_api.SpanKind.CLIENT, {"k": "v"}, type_="a") + span = tracer._start_span_with_options("s", None, trace_api.SpanKind.CLIENT, {"k": "v"}, type_="a") self.assertIs(span.kind, trace_api.SpanKind.CLIENT) self.assertEqual(span.attributes, {"k": "v"}) self.assertEqual(span._type, "a") with self.assertRaises(DummyError): - with tracer._start_as_current_typed_span( - "s", None, trace_api.SpanKind.SERVER, None, None, None, False, False, type_="b" + with tracer._start_as_current_span_with_options( + "s", + None, + trace_api.SpanKind.SERVER, + None, + None, + None, + False, + False, + type_="b", ) as span: raise DummyError() self.assertIs(span.kind, trace_api.SpanKind.SERVER) @@ -895,7 +903,7 @@ def test_typed_span_exception_handling(self): for set_status_on_exception in (True, False): with self.subTest(record_exception=record_exception, set_status_on_exception=set_status_on_exception): with self.assertRaises(DummyError): - with tracer._start_as_current_typed_span( + with tracer._start_as_current_span_with_options( "s", type_="a", record_exception=record_exception, @@ -908,36 +916,60 @@ def test_typed_span_exception_handling(self): ) self.assertEqual(len(span.events), 1 if record_exception else 0) - def test_typed_span_unknown_options_warn(self): - tracer = new_tracer() - with self.assertLogs(level=WARNING) as logs: - span = tracer._start_typed_span("s", type_="a", kindd=trace_api.SpanKind.CLIENT) - self.assertIn("kindd", logs.output[0]) - self.assertIs(span.kind, trace_api.SpanKind.INTERNAL) + def test_future_options_are_ignored(self): + @dataclasses.dataclass(frozen=True, kw_only=True) + class FutureOptions(trace_api._SpanOptions): + future_arg: int = 1 - with self.assertLogs(level=WARNING) as logs: - with tracer._start_as_current_typed_span("s", type_="a", foo=1, record_exception=False): - pass - self.assertEqual(len(logs.output), 1) - self.assertIn("foo", logs.output[0]) + options = FutureOptions() + tracer = new_tracer() + with self.assertNoLogs(level=WARNING): + span = tracer._start_span_with_options("s", type_="a", options=options) + self.assertEqual(span._type, "a") + span.end() + with tracer._start_as_current_span_with_options("s", type_="a", options=options) as current: + self.assertEqual(current._type, "a") def test_typed_span_known_options_dont_warn(self): tracer = new_tracer() with self.assertNoLogs(level=WARNING): - tracer._start_typed_span("s", type_="a", record_exception=False, set_status_on_exception=False) - with tracer._start_as_current_typed_span("s", type_="a"): + tracer._start_span_with_options("s", type_="a", record_exception=False, set_status_on_exception=False) + with tracer._start_as_current_span_with_options("s", type_="a"): pass def test_span_type_unset(self): span = new_tracer().start_span("s") self.assertIsNone(span._type) - self.assertIsNone(new_tracer()._start_typed_span("s")._type) + self.assertIsNone(new_tracer()._start_span_with_options("s")._type) + + def test_options_can_be_reused(self): + options = trace_api._SpanOptions() + tracer = new_tracer() + with tracer._start_span_with_options("a", type_="http.client.request", options=options) as first: + with tracer._start_as_current_span_with_options( + "b", type_="db.client.operation", options=options + ) as second: + self.assertEqual(first._type, "http.client.request") + self.assertEqual(second._type, "db.client.operation") + self.assertIsNot(first, second) + + def test_empty_options(self): + tracer = new_tracer() + options = trace_api._SpanOptions() + with tracer._start_span_with_options("a", options=options) as span: + self.assertIsNone(span._type) + with tracer._start_as_current_span_with_options("b", options=options) as span: + self.assertIsNone(span._type) + self.assertIs(trace_api.get_current_span(), span) def test_span_type_valid_syntax(self): tracer = new_tracer() for span_type in ("a", "A_b.c-d/e9", "a" * 255): with self.subTest(type_=span_type): - self.assertEqual(tracer._start_typed_span("s", type_=span_type)._type, span_type) + self.assertEqual( + tracer._start_span_with_options("s", type_=span_type)._type, + span_type, + ) def test_span_type_invalid_syntax(self): tracer = new_tracer() @@ -956,7 +988,7 @@ def test_span_type_invalid_syntax(self): ): with self.subTest(type_=span_type): with self.assertLogs(level=ERROR): - span = tracer._start_typed_span("s", type_=span_type) + span = tracer._start_span_with_options("s", type_=span_type) self.assertTrue(span.is_recording()) self.assertIsNone(span._type) From b3cda671e849b84193088faa09a4468a6288b0e4 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sun, 1 Nov 2026 15:02:21 -0800 Subject: [PATCH 9/9] Fix lint for experimental span API proxy forwarding Assisted-by: ChatGPT --- opentelemetry-api/src/opentelemetry/trace/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index e4c5396f034..ef2a1943d97 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -565,7 +565,7 @@ def _start_span_with_options( type_: str | None = None, options: _SpanOptions | None = None, ) -> Span: - return self._tracer._start_span_with_options( + return self._tracer._start_span_with_options( # pylint: disable=protected-access name, context, kind, @@ -594,7 +594,7 @@ def _start_as_current_span_with_options( type_: str | None = None, options: _SpanOptions | None = None, ) -> Iterator[Span]: - with self._tracer._start_as_current_span_with_options( + with self._tracer._start_as_current_span_with_options( # pylint: disable=protected-access name, context, kind,