From 67a06b44c5ca5e10e310ed829c7f537aef186273 Mon Sep 17 00:00:00 2001 From: KirillKurdyukov Date: Fri, 10 Jul 2026 13:17:03 +0300 Subject: [PATCH 1/2] Split tracing into vendor-neutral observability + OTel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all non-OTel tracing plumbing (Span/TracingProvider protocols, Noop implementation, SpanName, registry, YDB attribute helpers) into a new ydb.observability package. The OTel bridge in ydb.opentelemetry becomes a concrete provider (OtelTracingProvider) that plugs into the same interface. Users can now enable tracing without any OpenTelemetry dependency by supplying a custom TracingProvider to ydb.observability.enable_tracing. A second enable call always replaces the previously installed provider. ydb.opentelemetry.tracing is kept as a thin re-export shim for backward compatibility. All SDK internal imports (retries, connection, pool, session, transaction — sync + aio) now go through ydb.observability.tracing. Added tests/tracing/test_observability_enable.py covering: default Noop state, custom provider wiring, reset semantics on double enable, disable reverting to Noop, OTel enable replacing a custom provider, and duck-typed providers satisfying the protocol. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 + docs/opentelemetry.rst | 42 +++- tests/tracing/test_observability_enable.py | 276 +++++++++++++++++++++ tests/tracing/test_tracing_async.py | 4 +- tests/tracing/test_tracing_sync.py | 7 +- ydb/aio/connection.py | 2 +- ydb/aio/pool.py | 2 +- ydb/aio/query/session.py | 2 +- ydb/aio/query/transaction.py | 2 +- ydb/connection.py | 2 +- ydb/observability/__init__.py | 60 +++++ ydb/observability/tracing.py | 228 +++++++++++++++++ ydb/opentelemetry/__init__.py | 13 +- ydb/opentelemetry/plugin.py | 83 +++---- ydb/opentelemetry/tracing.py | 192 ++------------ ydb/pool.py | 2 +- ydb/query/session.py | 2 +- ydb/query/transaction.py | 2 +- ydb/retries.py | 2 +- 19 files changed, 697 insertions(+), 228 deletions(-) create mode 100644 tests/tracing/test_observability_enable.py create mode 100644 ydb/observability/__init__.py create mode 100644 ydb/observability/tracing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 77717472c..0e841b3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +* Introduce `ydb.observability` — vendor-neutral tracing entrypoint with a `TracingProvider` interface; `enable_tracing(provider)` accepts any implementation (custom or OpenTelemetry) and replaces the previously installed one. The SDK core no longer imports `opentelemetry`, so tracing can be enabled without the OpenTelemetry packages by supplying a custom provider + ## 3.30.0 ## * Query session attach stream handles `NodeShutdown` and `SessionShutdown` session hints: on `NodeShutdown` the session's node connection is pessimized and the session is retired, on `SessionShutdown` the session is retired without touching the node * Honor the `max_bytes` parameter in topic reader `receive_batch`/`receive_batch_with_tx` (previously accepted but silently ignored); add `max_bytes` to the async reader as well diff --git a/docs/opentelemetry.rst b/docs/opentelemetry.rst index c4eb810e8..060a0e193 100644 --- a/docs/opentelemetry.rst +++ b/docs/opentelemetry.rst @@ -69,8 +69,46 @@ and **before** creating a ``Driver``: ``enable_tracing()`` accepts an optional ``tracer`` argument. If omitted, the SDK obtains a tracer named ``"ydb.sdk"`` from the global tracer provider. -Repeated calls to ``enable_tracing()`` do nothing until you call ``disable_tracing()``, -which removes hooks so you can reconfigure or turn instrumentation off. +Repeated calls to ``enable_tracing()`` replace the previously installed provider, so +it is safe to reconfigure at any time. Call ``disable_tracing()`` to remove the hooks +entirely and return the SDK to its no-op default. + + +Custom Providers (Vendor-Neutral API) +------------------------------------- + +OpenTelemetry is only one possible backend. The SDK exposes a small tracing +interface in :mod:`ydb.observability` that any custom implementation can satisfy. +This is how ``ydb.opentelemetry.enable_tracing`` is implemented — it constructs an +:class:`~ydb.opentelemetry.plugin.OtelTracingProvider` and hands it to +:func:`ydb.observability.enable_tracing`. + +.. code-block:: python + + from ydb.observability import ( + NoopSpan, + Span, + TracingProvider, + enable_tracing, + disable_tracing, + ) + + class MyProvider: + def create_span(self, name, attributes=None, kind=None) -> Span: + # return your own Span implementation + return NoopSpan() + + def get_trace_metadata(self): + # (key, value) pairs to attach to outgoing gRPC calls + return () + + enable_tracing(MyProvider()) + # ... use the SDK ... + disable_tracing() + +``enable_tracing(provider)`` replaces whichever provider was installed before +(OpenTelemetry, another custom one, or the built-in Noop). Passing ``None`` is +equivalent to ``disable_tracing()``. What Is Instrumented diff --git a/tests/tracing/test_observability_enable.py b/tests/tracing/test_observability_enable.py new file mode 100644 index 000000000..ef6d1d41e --- /dev/null +++ b/tests/tracing/test_observability_enable.py @@ -0,0 +1,276 @@ +"""Unit tests for the vendor-neutral ``ydb.observability`` tracing entrypoints. + +These tests use a hand-rolled TracingProvider (no OpenTelemetry involvement) +to verify that: + +* ``enable_tracing(provider)`` accepts an arbitrary implementation of the + ``TracingProvider`` protocol, +* a second ``enable_tracing`` call replaces (resets) the previous provider, +* ``disable_tracing()`` returns the SDK to the Noop provider, +* the SDK produces spans through whichever provider is currently installed, +* ``get_trace_metadata`` is empty until a provider is enabled. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from ydb.observability import ( + NoopSpan, + NoopTracingProvider, + Span, + SpanName, + TracingProvider, + disable_tracing, + enable_tracing, + get_active_provider, +) +from ydb.observability.tracing import ( + _registry, + create_span, + create_ydb_span, + get_trace_metadata, +) + +from .conftest import FakeDriverConfig + + +class RecordingSpan: + """Minimal Span implementation that records every call.""" + + def __init__(self, name: str, attributes: Optional[dict], kind: Optional[str], sink: List[Dict[str, Any]]): + self.name = name + self.attributes: Dict[str, Any] = dict(attributes or {}) + self.kind = kind + self.ended = False + self.errors: List[BaseException] = [] + self._sink = sink + + def set_error(self, exception): + self.errors.append(exception) + + def set_attribute(self, key, value): + self.attributes[key] = value + + def end(self): + self.ended = True + self._sink.append( + { + "name": self.name, + "attributes": dict(self.attributes), + "kind": self.kind, + "ended": True, + "errors": list(self.errors), + } + ) + + def attach_context(self, end_on_exit: bool = True): + span = self + + class _Ctx: + def __enter__(self_inner): + return span + + def __exit__(self_inner, exc_type, exc_val, exc_tb): + if exc_val is not None: + span.set_error(exc_val) + span.end() + elif end_on_exit: + span.end() + return False + + return _Ctx() + + +class RecordingProvider: + """Custom TracingProvider used across the tests.""" + + def __init__(self, metadata: Optional[List[Tuple[str, str]]] = None): + self.spans: List[RecordingSpan] = [] + self.finished: List[Dict[str, Any]] = [] + self._metadata = metadata or [] + + def create_span(self, name, attributes=None, kind=None) -> Span: + span = RecordingSpan(name, attributes, kind, self.finished) + self.spans.append(span) + return span + + def get_trace_metadata(self): + return list(self._metadata) + + +@pytest.fixture(autouse=True) +def _reset_registry(): + """Guarantee a clean Noop state around every test in this module.""" + disable_tracing() + yield + disable_tracing() + + +class TestDefaultsAreNoop: + def test_registry_starts_inactive(self): + assert _registry.is_active() is False + assert get_active_provider() is None + + def test_create_ydb_span_is_noop_when_disabled(self): + span = create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()) + assert isinstance(span, NoopSpan) + # NoopSpan methods must be safely callable + span.set_attribute("x", 1) + span.set_error(RuntimeError("boom")) + span.end() + + def test_get_trace_metadata_is_empty_when_disabled(self): + assert get_trace_metadata() == [] + + +class TestEnableTracingWithCustomProvider: + def test_custom_provider_receives_spans(self): + provider = RecordingProvider() + enable_tracing(provider) + + assert _registry.is_active() is True + assert get_active_provider() is provider + + with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context() as span: + span.set_attribute("custom.attr", "hello") + + assert len(provider.spans) == 1 + recorded = provider.spans[0] + assert recorded.name == SpanName.EXECUTE_QUERY + assert recorded.attributes["db.system.name"] == "ydb" + assert recorded.attributes["custom.attr"] == "hello" + assert recorded.ended is True + + def test_custom_provider_metadata_is_returned(self): + provider = RecordingProvider(metadata=[("traceparent", "abc")]) + enable_tracing(provider) + + assert get_trace_metadata() == [("traceparent", "abc")] + + def test_internal_span_helper_uses_active_provider(self): + provider = RecordingProvider() + enable_tracing(provider) + + with create_span(SpanName.RUN_WITH_RETRY): + pass + + assert len(provider.spans) == 1 + assert provider.spans[0].name == SpanName.RUN_WITH_RETRY + assert provider.spans[0].kind == "internal" + + +class TestEnableTracingResetsPrevious: + """Second ``enable_tracing`` must replace the previous provider.""" + + def test_double_enable_swaps_provider(self): + first = RecordingProvider() + second = RecordingProvider() + + enable_tracing(first) + with create_ydb_span(SpanName.CREATE_SESSION, FakeDriverConfig()).attach_context(): + pass + + enable_tracing(second) # reset + assert get_active_provider() is second + + with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context(): + pass + + # The second provider only sees spans emitted after enable() + assert [s.name for s in first.spans] == [SpanName.CREATE_SESSION] + assert [s.name for s in second.spans] == [SpanName.EXECUTE_QUERY] + + def test_enable_none_is_disable(self): + provider = RecordingProvider() + enable_tracing(provider) + assert _registry.is_active() + + enable_tracing(None) # type: ignore[arg-type] + assert _registry.is_active() is False + assert get_active_provider() is None + + def test_disable_tracing_reverts_to_noop(self): + provider = RecordingProvider() + enable_tracing(provider) + disable_tracing() + + assert _registry.is_active() is False + with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context(): + pass + + # No spans reached the provider after disable + assert provider.spans == [] + + +class TestNoopProviderExplicit: + def test_enabling_noop_provider_still_marks_registry_active(self): + """A user explicitly picking NoopTracingProvider is a valid choice.""" + enable_tracing(NoopTracingProvider()) + assert _registry.is_active() is True + # But the produced span is still a NoopSpan — no attributes recorded. + span = create_ydb_span(SpanName.CREATE_SESSION, FakeDriverConfig()) + assert isinstance(span, NoopSpan) + + +class TestOtelEnableAlsoResets: + """The OTel entrypoint must reset any previously enabled provider too.""" + + def test_otel_enable_replaces_custom_provider(self): + pytest.importorskip("opentelemetry") + + from ydb.opentelemetry import disable_tracing as otel_disable + from ydb.opentelemetry import enable_tracing as otel_enable + from ydb.opentelemetry.plugin import OtelTracingProvider + + custom = RecordingProvider() + enable_tracing(custom) + assert get_active_provider() is custom + + otel_enable() + active = get_active_provider() + assert isinstance(active, OtelTracingProvider) + + otel_disable() + assert _registry.is_active() is False + + def test_second_otel_enable_swaps_the_tracer(self): + pytest.importorskip("opentelemetry") + + from opentelemetry import trace + + from ydb.opentelemetry import disable_tracing as otel_disable + from ydb.opentelemetry import enable_tracing as otel_enable + + try: + otel_enable() + first = get_active_provider() + otel_enable(trace.get_tracer("ydb.sdk.custom")) + second = get_active_provider() + assert first is not second + finally: + otel_disable() + + +class TestProtocolContract: + def test_protocol_can_be_satisfied_by_duck_typed_object(self): + """A user-written provider does not need to inherit anything.""" + + class MyProvider: + def __init__(self): + self.calls = 0 + + def create_span(self, name, attributes=None, kind=None): + self.calls += 1 + return NoopSpan() + + def get_trace_metadata(self): + return () + + provider: TracingProvider = MyProvider() # runtime duck check + enable_tracing(provider) + + with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context(): + pass + + assert provider.calls == 1 diff --git a/tests/tracing/test_tracing_async.py b/tests/tracing/test_tracing_async.py index 6b4e96ad1..2c08d3dc7 100644 --- a/tests/tracing/test_tracing_async.py +++ b/tests/tracing/test_tracing_async.py @@ -4,7 +4,7 @@ """ from opentelemetry.trace import StatusCode, SpanKind -from ydb.opentelemetry.tracing import SpanName +from ydb.observability.tracing import SpanName from ydb.query.transaction import QueryTxStateEnum from .conftest import FakeDriverConfig from unittest.mock import AsyncMock, MagicMock, patch @@ -100,7 +100,7 @@ def test_async_connection_peer_attributes_are_resolved(self, otel_setup): from ydb.aio.connection import Connection from ydb.connection import EndpointOptions - from ydb.opentelemetry.tracing import create_ydb_span + from ydb.observability.tracing import create_ydb_span from ydb.query.session import _resolve_peer cfg = FakeDriverConfig() diff --git a/tests/tracing/test_tracing_sync.py b/tests/tracing/test_tracing_sync.py index 9f8bbc421..d605b174d 100644 --- a/tests/tracing/test_tracing_sync.py +++ b/tests/tracing/test_tracing_sync.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch from opentelemetry import trace from opentelemetry.trace import StatusCode, SpanKind -from ydb.opentelemetry.tracing import SpanName, _registry, create_ydb_span +from ydb.observability.tracing import SpanName, _registry, create_ydb_span from ydb.query.transaction import QueryTxStateEnum from .conftest import FakeDriverConfig @@ -316,8 +316,7 @@ def test_no_spans_without_enable_tracing(self): from tests.tracing.conftest import _exporter - _registry.set_create_span(None) - _registry.set_metadata_hook(None) + _registry.set_provider(None) _exporter.clear() with create_ydb_span(SpanName.CREATE_SESSION, FakeDriverConfig()).attach_context(): @@ -347,7 +346,7 @@ def test_sdk_span_is_child_of_user_span(self, otel_setup): class TestTraceMetadataInjection: def test_get_trace_metadata_returns_traceparent(self, otel_setup): - from ydb.opentelemetry.tracing import get_trace_metadata + from ydb.observability.tracing import get_trace_metadata tracer = trace.get_tracer("test.tracer") diff --git a/ydb/aio/connection.py b/ydb/aio/connection.py index dd1342d3f..6865e583f 100644 --- a/ydb/aio/connection.py +++ b/ydb/aio/connection.py @@ -26,7 +26,7 @@ from ydb.driver import DriverConfig from ydb.settings import BaseRequestSettings from ydb import issues -from ydb.opentelemetry.tracing import get_trace_metadata +from ydb.observability.tracing import get_trace_metadata # Workaround for good IDE and universal for runtime if TYPE_CHECKING: diff --git a/ydb/aio/pool.py b/ydb/aio/pool.py index 8d5b0b227..150274368 100644 --- a/ydb/aio/pool.py +++ b/ydb/aio/pool.py @@ -6,7 +6,7 @@ from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING, cast from ydb import issues -from ydb.opentelemetry.tracing import SpanName, create_ydb_span +from ydb.observability.tracing import SpanName, create_ydb_span from ydb.pool import ConnectionsCache as _ConnectionsCache, IConnectionPool from .connection import Connection, EndpointKey diff --git a/ydb/aio/query/session.py b/ydb/aio/query/session.py index 1c0db50ad..645c936cd 100644 --- a/ydb/aio/query/session.py +++ b/ydb/aio/query/session.py @@ -18,7 +18,7 @@ from ...query import base from ...query.session import BaseQuerySession -from ...opentelemetry.tracing import SpanName, create_ydb_span, set_peer_attributes, span_finish_callback +from ...observability.tracing import SpanName, create_ydb_span, set_peer_attributes, span_finish_callback from ..._constants import DEFAULT_INITIAL_RESPONSE_TIMEOUT diff --git a/ydb/aio/query/transaction.py b/ydb/aio/query/transaction.py index a05d91f23..4592f5892 100644 --- a/ydb/aio/query/transaction.py +++ b/ydb/aio/query/transaction.py @@ -12,7 +12,7 @@ BaseQueryTxContext, QueryTxStateEnum, ) -from ...opentelemetry.tracing import SpanName, create_ydb_span, span_finish_callback +from ...observability.tracing import SpanName, create_ydb_span, span_finish_callback if TYPE_CHECKING: from .session import QuerySession diff --git a/ydb/connection.py b/ydb/connection.py index 7e5ad3675..ee9fb0cb1 100644 --- a/ydb/connection.py +++ b/ydb/connection.py @@ -24,7 +24,7 @@ import grpc from . import issues, _apis, _utilities from . import default_pem -from .opentelemetry.tracing import get_trace_metadata +from .observability.tracing import get_trace_metadata _stubs_list = ( _apis.TableService.Stub, diff --git a/ydb/observability/__init__.py b/ydb/observability/__init__.py new file mode 100644 index 000000000..a7203d272 --- /dev/null +++ b/ydb/observability/__init__.py @@ -0,0 +1,60 @@ +"""Vendor-neutral observability entrypoints for the YDB SDK. + +Users pick a tracing backend and register it here: + +.. code-block:: python + + from ydb.observability import enable_tracing + from ydb.opentelemetry import OtelTracingProvider + + enable_tracing(OtelTracingProvider()) # or any custom TracingProvider + +The SDK itself never imports ``opentelemetry`` — until a provider is enabled, +every span is a :class:`~ydb.observability.tracing.NoopSpan`. +""" + +from typing import Optional + +from ydb.observability.tracing import ( + NoopSpan, + NoopTracingProvider, + Span, + SpanName, + TracingProvider, + _registry, +) + + +def enable_tracing(provider: TracingProvider) -> None: + """Install *provider* as the active tracing backend. + + Calling this a second time replaces the previous provider — the old one + stops receiving spans immediately. Passing ``None`` is equivalent to + :func:`disable_tracing`. + """ + _registry.set_provider(provider) + + +def disable_tracing() -> None: + """Reset the tracing backend to Noop. + + After this, :func:`enable_tracing` can be called again with any provider. + """ + _registry.set_provider(None) + + +def get_active_provider() -> Optional[TracingProvider]: + """Return the currently installed provider, or ``None`` if tracing is disabled.""" + return _registry.get_provider() if _registry.is_active() else None + + +__all__ = [ + "NoopSpan", + "NoopTracingProvider", + "Span", + "SpanName", + "TracingProvider", + "disable_tracing", + "enable_tracing", + "get_active_provider", +] diff --git a/ydb/observability/tracing.py b/ydb/observability/tracing.py new file mode 100644 index 000000000..932ff6681 --- /dev/null +++ b/ydb/observability/tracing.py @@ -0,0 +1,228 @@ +"""Vendor-neutral tracing interfaces, Noop implementation and SDK helpers. + +The YDB SDK talks to a small :class:`TracingProvider` interface here. Concrete +providers (OpenTelemetry in :mod:`ydb.opentelemetry`, or any custom one written +by the user) plug in via :func:`ydb.observability.enable_tracing`. Until a +provider is installed everything is a no-op — the SDK never depends on +``opentelemetry`` being importable. +""" + +import enum +from typing import Any, Callable, Iterable, List, Optional, Protocol, Tuple + + +class SpanName(str, enum.Enum): + """Canonical span names used across the YDB SDK.""" + + CREATE_SESSION = "ydb.CreateSession" + EXECUTE_QUERY = "ydb.ExecuteQuery" + BEGIN_TRANSACTION = "ydb.BeginTransaction" + COMMIT = "ydb.Commit" + ROLLBACK = "ydb.Rollback" + DRIVER_INITIALIZE = "ydb.Driver.Initialize" + RUN_WITH_RETRY = "ydb.RunWithRetry" + TRY = "ydb.Try" + + +class Span(Protocol): + """Minimal span surface the SDK relies on. + + Any custom provider must return objects that satisfy this interface. + """ + + def set_error(self, exception: BaseException) -> None: ... + + def set_attribute(self, key: str, value: Any) -> None: ... + + def end(self) -> None: ... + + def attach_context(self, end_on_exit: bool = True) -> Any: + """Return a context manager that makes this span active for its block. + + With ``end_on_exit=True`` (default) the span is ended on exit — used for + single-shot RPCs. With ``end_on_exit=False`` the span is only ended on + exception — used for streaming RPCs where the result iterator owns + ``end()``. + """ + ... + + +class TracingProvider(Protocol): + """Pluggable tracing backend. + + Implement this to wire the SDK into any tracing system. Only two methods + are required: creating spans, and returning propagation metadata that + should ride along on outgoing gRPC calls. + """ + + def create_span( + self, + name: str, + attributes: Optional[dict] = None, + kind: Optional[str] = None, + ) -> Span: ... + + def get_trace_metadata(self) -> Iterable[Tuple[str, str]]: + """Return ``(key, value)`` pairs to inject into outgoing RPC metadata.""" + ... + + +class _NoopCtx: + __slots__ = ("_span",) + + def __init__(self, span): + self._span = span + + def __enter__(self): + return self._span + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + +class NoopSpan: + """Span implementation used while no provider is enabled.""" + + def set_error(self, exception): + pass + + def set_attribute(self, key, value): + pass + + def end(self): + pass + + def attach_context(self, end_on_exit=True): + return _NoopCtx(self) + + +class NoopTracingProvider: + """Default provider — every span is a :class:`NoopSpan`, no metadata.""" + + _SPAN = NoopSpan() + + def create_span(self, name, attributes=None, kind=None): + return self._SPAN + + def get_trace_metadata(self): + return () + + +_NOOP_PROVIDER = NoopTracingProvider() + + +class _TracingRegistry: + """Holds the currently active :class:`TracingProvider`. + + A single instance (:data:`_registry`) is shared across the SDK. Swapping + the provider via :meth:`set_provider` is atomic from the caller's point + of view: the next span will use the new provider. + """ + + def __init__(self) -> None: + self._provider: TracingProvider = _NOOP_PROVIDER + + def is_active(self) -> bool: + return self._provider is not _NOOP_PROVIDER + + def set_provider(self, provider: Optional[TracingProvider]) -> None: + self._provider = provider if provider is not None else _NOOP_PROVIDER + + def get_provider(self) -> TracingProvider: + return self._provider + + def create_span(self, name, attributes=None, kind=None) -> Span: + return self._provider.create_span(name, attributes, kind=kind) + + def get_trace_metadata(self) -> Iterable[Tuple[str, str]]: + return self._provider.get_trace_metadata() + + +_registry = _TracingRegistry() + + +def get_trace_metadata() -> List[Tuple[str, str]]: + """Return tracing metadata for gRPC calls (empty list when no provider).""" + return list(_registry.get_trace_metadata()) + + +def _split_endpoint(endpoint: Optional[str]) -> Tuple[str, int]: + ep = endpoint or "" + if ep.startswith("grpcs://"): + ep = ep[len("grpcs://") :] + elif ep.startswith("grpc://"): + ep = ep[len("grpc://") :] + + if ep.startswith("["): + close = ep.find("]") + if close != -1 and len(ep) > close + 1 and ep[close + 1] == ":": + host = ep[: close + 1] + port_s = ep[close + 2 :] + return host, int(port_s) if port_s.isdigit() else 0 + + host, sep, port_s = ep.rpartition(":") + if not sep: + return ep, 0 + return host, int(port_s) if port_s.isdigit() else 0 + + +def _build_ydb_attrs(driver_config, node_id=None, peer=None): + host, port = _split_endpoint(getattr(driver_config, "endpoint", None)) + attrs = { + "db.system.name": "ydb", + "db.namespace": getattr(driver_config, "database", None) or "", + "server.address": host, + "server.port": port, + } + if peer is not None: + address, port_, location = peer + if address is not None: + attrs["network.peer.address"] = address + if port_ is not None: + attrs["network.peer.port"] = int(port_) + if location: + attrs["ydb.node.dc"] = location + if node_id is not None: + attrs["ydb.node.id"] = node_id + return attrs + + +def create_span(name, attributes=None, kind="internal"): + """Create a span with no YDB-specific attributes (used for SDK-internal operations).""" + return _registry.create_span(name, attributes=attributes, kind=kind).attach_context() + + +def create_ydb_span(name, driver_config, node_id=None, kind=None, peer=None) -> Span: + """Create a span pre-filled with standard YDB attributes. + + When no provider is active a :class:`NoopSpan` is returned so callers can + call ``.attach_context()``, ``.set_attribute(...)`` etc. unconditionally. + """ + if not _registry.is_active(): + return NoopTracingProvider._SPAN + attrs = _build_ydb_attrs(driver_config, node_id, peer) + return _registry.create_span(name, attributes=attrs, kind=kind) + + +def set_peer_attributes(span: Span, peer) -> None: + """Fill in network.peer.* and ydb.node.dc on an existing span once the peer is known.""" + if peer is None: + return + address, port, location = peer + if address is not None: + span.set_attribute("network.peer.address", address) + if port is not None: + span.set_attribute("network.peer.port", int(port)) + if location: + span.set_attribute("ydb.node.dc", location) + + +def span_finish_callback(span: Span) -> Callable[..., None]: + """Return an on_finish callable that ends *span* when a streaming result iterator completes.""" + + def _finish(exception=None): + if exception is not None: + span.set_error(exception) + span.end() + + return _finish diff --git a/ydb/opentelemetry/__init__.py b/ydb/opentelemetry/__init__.py index fc058d0d8..dc35e1aaf 100644 --- a/ydb/opentelemetry/__init__.py +++ b/ydb/opentelemetry/__init__.py @@ -1,12 +1,17 @@ -"""Public OpenTelemetry entrypoints for YDB.""" +"""Public OpenTelemetry entrypoints for YDB. + +For vendor-neutral tracing (custom providers, Noop, interfaces) see +:mod:`ydb.observability`. This module is a convenience wrapper that constructs +an OpenTelemetry-backed provider and installs it via +:func:`ydb.observability.enable_tracing`. +""" def enable_tracing(tracer=None): """Enable OpenTelemetry trace context propagation and span creation for all YDB gRPC calls. - This call is **idempotent**: if tracing is already enabled, later calls do nothing - (including passing a different ``tracer``). Call :func:`disable_tracing` first to - reconfigure or turn instrumentation off. + Any previously installed tracing provider (custom or OTel) is replaced — + calling this a second time is safe and simply swaps the tracer. Args: tracer: Optional OTel tracer to use. If not provided, the default tracer named diff --git a/ydb/opentelemetry/plugin.py b/ydb/opentelemetry/plugin.py index 76942789f..82218bf15 100644 --- a/ydb/opentelemetry/plugin.py +++ b/ydb/opentelemetry/plugin.py @@ -1,4 +1,11 @@ -"""OpenTelemetry bridge for YDB.""" +"""OpenTelemetry adapter for the YDB observability interface. + +This module implements :class:`ydb.observability.TracingProvider` on top of the +``opentelemetry`` packages. The SDK core does not import it — the OTel +dependency is only pulled in when a user calls :func:`ydb.opentelemetry.enable_tracing`. +""" + +from typing import Iterable, Optional, Tuple from opentelemetry import context as otel_context from opentelemetry import trace @@ -7,7 +14,8 @@ from ydb import issues from ydb.issues import StatusCode as YdbStatusCode -from ydb.opentelemetry.tracing import _registry +from ydb.observability import enable_tracing as _observability_enable +from ydb.observability import disable_tracing as _observability_disable # YDB client transport StatusCode values (401xxx band) -> OTel error.type transport_error. _TRANSPORT_STATUSES = frozenset( @@ -20,22 +28,12 @@ } ) -_tracer = None -_enabled = False - _KIND_MAP = { "client": trace.SpanKind.CLIENT, "internal": trace.SpanKind.INTERNAL, } -def _otel_metadata_hook(): - """Inject W3C Trace Context into outgoing gRPC metadata using the active OTel context.""" - headers = {} - inject(headers) - return list(headers.items()) - - def _set_error_on_span(span, exception): if isinstance(exception, issues.Error) and exception.status is not None: span.set_attribute("db.response.status_code", exception.status.name) @@ -49,7 +47,7 @@ def _set_error_on_span(span, exception): class _AttachContext: - """Make a span the active OTel context for a ``with`` block. + """Make an OTel span the active context for a ``with`` block. When ``end_on_exit=True`` (default) the span is ended on exit — used for single-shot RPCs. When ``end_on_exit=False`` the span is only ended on @@ -79,13 +77,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): class TracingSpan: - """Wrapper around an OTel span. - - Use :meth:`attach_context` as a context manager around any RPC call. - The default (``end_on_exit=True``) is for single-shot operations; pass - ``end_on_exit=False`` for streaming RPCs where the result iterator owns - ``end()``. - """ + """OpenTelemetry-backed :class:`ydb.observability.Span` implementation.""" def __init__(self, span): self._span = span @@ -103,32 +95,39 @@ def attach_context(self, end_on_exit=True): return _AttachContext(self, end_on_exit) -def _create_span(name, attributes=None, kind=None): - span = _tracer.start_span( - name, - kind=_KIND_MAP.get(kind, trace.SpanKind.CLIENT), - attributes=attributes or {}, - ) - return TracingSpan(span) +class OtelTracingProvider: + """Bridges the YDB observability interface to an OpenTelemetry tracer. + + Args: + tracer: An OTel tracer. If not provided, the global provider's + ``ydb.sdk`` tracer is used. + """ + def __init__(self, tracer=None): + self._tracer = tracer if tracer is not None else trace.get_tracer("ydb.sdk") -def _enable_tracing(tracer=None): - global _enabled, _tracer + def create_span(self, name, attributes=None, kind=None): + span = self._tracer.start_span( + name, + kind=_KIND_MAP.get(kind, trace.SpanKind.CLIENT), + attributes=attributes or {}, + ) + return TracingSpan(span) - if _enabled: - return + def get_trace_metadata(self) -> Iterable[Tuple[str, str]]: + headers: dict = {} + inject(headers) + return tuple(headers.items()) - _tracer = tracer if tracer is not None else trace.get_tracer("ydb.sdk") - _enabled = True - _registry.set_metadata_hook(_otel_metadata_hook) - _registry.set_create_span(_create_span) +def _enable_tracing(tracer: Optional[object] = None) -> None: + """Install an :class:`OtelTracingProvider` (idempotent replace). + + Called by :func:`ydb.opentelemetry.enable_tracing`. Any previously + registered provider — OTel or custom — is replaced. + """ + _observability_enable(OtelTracingProvider(tracer)) -def _disable_tracing(): - """Clear hooks and tracer; after this, :func:`~ydb.opentelemetry.enable_tracing` may be called again.""" - global _enabled, _tracer - _registry.set_create_span(None) - _registry.set_metadata_hook(None) - _enabled = False - _tracer = None +def _disable_tracing() -> None: + _observability_disable() diff --git a/ydb/opentelemetry/tracing.py b/ydb/opentelemetry/tracing.py index 1d0995df8..9b6525df5 100644 --- a/ydb/opentelemetry/tracing.py +++ b/ydb/opentelemetry/tracing.py @@ -1,165 +1,27 @@ -"""Internal SDK tracing helpers and registry.""" - -import enum -from typing import Optional, Tuple - - -class SpanName(str, enum.Enum): - """Canonical span names used across the YDB SDK.""" - - CREATE_SESSION = "ydb.CreateSession" - EXECUTE_QUERY = "ydb.ExecuteQuery" - BEGIN_TRANSACTION = "ydb.BeginTransaction" - COMMIT = "ydb.Commit" - ROLLBACK = "ydb.Rollback" - DRIVER_INITIALIZE = "ydb.Driver.Initialize" - RUN_WITH_RETRY = "ydb.RunWithRetry" - TRY = "ydb.Try" - - -class _NoopCtx: - __slots__ = ("_span",) - - def __init__(self, span): - self._span = span - - def __enter__(self): - return self._span - - def __exit__(self, exc_type, exc_val, exc_tb): - return False - - -class _NoopSpan: - """Returned by create_ydb_span when tracing is disabled.""" - - def set_error(self, exception): - pass - - def set_attribute(self, key, value): - pass - - def end(self): - pass - - def attach_context(self, end_on_exit=True): - return _NoopCtx(self) - - -_NOOP_SPAN = _NoopSpan() - - -class OtelTracingRegistry: - """Singleton registry for OpenTelemetry tracing. - - By default everything is no-op until :func:`~ydb.opentelemetry.enable_tracing` is called. - """ - - def __init__(self): - self._metadata_hook = None - self._create_span_func = None - - def is_active(self) -> bool: - return self._create_span_func is not None - - def create_span(self, name, attributes=None, kind=None): - if self._create_span_func is None: - return _NOOP_SPAN - return self._create_span_func(name, attributes, kind=kind) - - def get_trace_metadata(self): - if self._metadata_hook is not None: - return self._metadata_hook() - return [] - - def set_metadata_hook(self, hook): - self._metadata_hook = hook - - def set_create_span(self, func): - self._create_span_func = func - - -_registry = OtelTracingRegistry() - - -def get_trace_metadata(): - """Return tracing metadata for gRPC calls.""" - return _registry.get_trace_metadata() - - -def _split_endpoint(endpoint: Optional[str]) -> Tuple[str, int]: - ep = endpoint or "" - if ep.startswith("grpcs://"): - ep = ep[len("grpcs://") :] - elif ep.startswith("grpc://"): - ep = ep[len("grpc://") :] - - if ep.startswith("["): - close = ep.find("]") - if close != -1 and len(ep) > close + 1 and ep[close + 1] == ":": - host = ep[: close + 1] - port_s = ep[close + 2 :] - return host, int(port_s) if port_s.isdigit() else 0 - - host, sep, port_s = ep.rpartition(":") - if not sep: - return ep, 0 - return host, int(port_s) if port_s.isdigit() else 0 - - -def _build_ydb_attrs(driver_config, node_id=None, peer=None): - host, port = _split_endpoint(getattr(driver_config, "endpoint", None)) - attrs = { - "db.system.name": "ydb", - "db.namespace": getattr(driver_config, "database", None) or "", - "server.address": host, - "server.port": port, - } - if peer is not None: - address, port_, location = peer - if address is not None: - attrs["network.peer.address"] = address - if port_ is not None: - attrs["network.peer.port"] = int(port_) - if location: - attrs["ydb.node.dc"] = location - if node_id is not None: - attrs["ydb.node.id"] = node_id - return attrs - - -def create_span(name, attributes=None, kind="internal"): - """Create a span with no YDB-specific attributes (used for SDK-internal operations).""" - return _registry.create_span(name, attributes=attributes, kind=kind).attach_context() - - -def create_ydb_span(name, driver_config, node_id=None, kind=None, peer=None): - """Create a span pre-filled with standard YDB attributes.""" - if not _registry.is_active(): - return _NOOP_SPAN - attrs = _build_ydb_attrs(driver_config, node_id, peer) - return _registry.create_span(name, attributes=attrs, kind=kind) - - -def set_peer_attributes(span, peer): - """Fill in network.peer.* and ydb.node.dc on an existing span once the peer is known.""" - if peer is None: - return - address, port, location = peer - if address is not None: - span.set_attribute("network.peer.address", address) - if port is not None: - span.set_attribute("network.peer.port", int(port)) - if location: - span.set_attribute("ydb.node.dc", location) - - -def span_finish_callback(span): - """Return an on_finish callable that ends *span* when a streaming result iterator completes.""" - - def _finish(exception=None): - if exception is not None: - span.set_error(exception) - span.end() - - return _finish +"""Backward-compatible re-exports. + +The tracing interfaces, Noop implementation and SDK helpers now live in +:mod:`ydb.observability.tracing`. This module is preserved so existing imports +like ``from ydb.opentelemetry.tracing import SpanName, create_ydb_span`` +keep working, but new code should import from :mod:`ydb.observability`. +""" + +from ydb.observability.tracing import ( # noqa: F401 + NoopSpan, + NoopTracingProvider, + Span, + SpanName, + TracingProvider, + _NoopCtx, + _build_ydb_attrs, + _registry, + _split_endpoint, + create_span, + create_ydb_span, + get_trace_metadata, + set_peer_attributes, + span_finish_callback, +) + +_NoopSpan = NoopSpan +_NOOP_SPAN = NoopTracingProvider._SPAN diff --git a/ydb/pool.py b/ydb/pool.py index a77359c23..2ba1c33ab 100644 --- a/ydb/pool.py +++ b/ydb/pool.py @@ -10,7 +10,7 @@ from typing import Any, Callable, ContextManager, List, Optional, Set, Tuple, TYPE_CHECKING from . import connection as connection_impl, issues, resolver, _utilities, tracing -from .opentelemetry.tracing import SpanName, create_ydb_span +from .observability.tracing import SpanName, create_ydb_span from abc import abstractmethod from .connection import Connection, EndpointKey diff --git a/ydb/query/session.py b/ydb/query/session.py index 661e24011..3d72d168f 100644 --- a/ydb/query/session.py +++ b/ydb/query/session.py @@ -18,7 +18,7 @@ from .base import QueryExplainResultFormat from .. import _apis, issues, _utilities -from ..opentelemetry.tracing import SpanName, create_ydb_span, set_peer_attributes, span_finish_callback +from ..observability.tracing import SpanName, create_ydb_span, set_peer_attributes, span_finish_callback from ..settings import BaseRequestSettings from ..connection import _RpcState as RpcState, EndpointKey from .._grpc.grpcwrapper import common_utils diff --git a/ydb/query/transaction.py b/ydb/query/transaction.py index 1d278ac2f..e2cbb34ba 100644 --- a/ydb/query/transaction.py +++ b/ydb/query/transaction.py @@ -17,7 +17,7 @@ _apis, issues, ) -from ..opentelemetry.tracing import SpanName, create_ydb_span, span_finish_callback +from ..observability.tracing import SpanName, create_ydb_span, span_finish_callback from .._grpc.grpcwrapper import ydb_topic as _ydb_topic from .._grpc.grpcwrapper import ydb_query as _ydb_query from ..connection import _RpcState as RpcState diff --git a/ydb/retries.py b/ydb/retries.py index 4b7c137f3..68b60b43a 100644 --- a/ydb/retries.py +++ b/ydb/retries.py @@ -7,7 +7,7 @@ from . import issues from ._errors import check_retriable_error -from .opentelemetry.tracing import SpanName, create_span as _create_span +from .observability.tracing import SpanName, create_span as _create_span def _try_span_attrs(backoff_ms: Optional[int]): From d6aed82d0f08f4102aab5b416e37e07e1e61bad2 Mon Sep 17 00:00:00 2001 From: KirillKurdyukov Date: Fri, 10 Jul 2026 15:59:33 +0300 Subject: [PATCH 2/2] added to tests/tracing/test_observability_enable.py: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestSplitEndpoint — parametric test covering every branch of _split_endpoint (grpcs://, grpc://, bare host:port, valid/malformed IPv6, missing colon, non-numeric port, None). - TestBuildYdbAttrsPeerOptionals — peer tuples with only address / only port / only location / empty location, hitting the three if x is not None branches in _build_ydb_attrs. - TestSetPeerAttributes — direct unit test for the set_peer_attributes helper (previously only reached through session flows that never had a non-None peer). - TestSpanFinishCallback — success and exception paths of the streaming _finish callback. - TestOpentelemetryTracingShimReexports — proves the ydb.opentelemetry.tracing back-compat shim re-exports the exact same objects as ydb.observability.tracing. - TestOtelEntrypointHandlesMissingPackage — uses sys.modules["ydb.opentelemetry.plugin"] = None to force the ImportError branches in ydb.opentelemetry.__init__.enable_tracing / disable_tracing. - TestOtelTracingSpanBridge — direct tests for TracingSpan.set_attribute and the defensive "exit before enter" branch in _AttachContext. - TestNoopContextManager — smoke tests for the Noop context manager and NoopTracingProvider returning the shared span + empty metadata. --- tests/tracing/test_observability_enable.py | 252 +++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/tests/tracing/test_observability_enable.py b/tests/tracing/test_observability_enable.py index ef6d1d41e..183048803 100644 --- a/tests/tracing/test_observability_enable.py +++ b/tests/tracing/test_observability_enable.py @@ -26,10 +26,14 @@ get_active_provider, ) from ydb.observability.tracing import ( + _build_ydb_attrs, _registry, + _split_endpoint, create_span, create_ydb_span, get_trace_metadata, + set_peer_attributes, + span_finish_callback, ) from .conftest import FakeDriverConfig @@ -274,3 +278,251 @@ def get_trace_metadata(self): pass assert provider.calls == 1 + + +class TestSplitEndpoint: + """Cover every branch of the endpoint parser.""" + + @pytest.mark.parametrize( + "endpoint,expected", + [ + # ``grpcs://`` scheme is stripped before parsing. + ("grpcs://secure.example.com:2136", ("secure.example.com", 2136)), + # ``grpc://`` scheme is stripped before parsing. + ("grpc://plain.example.com:2136", ("plain.example.com", 2136)), + # Bare host:port with no scheme. + ("host.example.com:2136", ("host.example.com", 2136)), + # Bracketed IPv6 with port — the fast path. + ("[::1]:2136", ("[::1]", 2136)), + ("[fe80::1]:8080", ("[fe80::1]", 8080)), + # A leading ``[`` with no closing ``]:`` falls through to rpartition. + ("[malformed", ("[malformed", 0)), + # No colon at all → whole string is host, port is 0. + ("no_colon_at_all", ("no_colon_at_all", 0)), + # Non-numeric port defaults to 0. + ("host:not_a_port", ("host", 0)), + # ``None`` is normalized to an empty string. + (None, ("", 0)), + ], + ) + def test_parses(self, endpoint, expected): + assert _split_endpoint(endpoint) == expected + + +class TestBuildYdbAttrsPeerOptionals: + """Peer tuple may carry ``None`` in any position — each field is optional.""" + + def test_peer_with_only_address(self): + cfg = FakeDriverConfig() + attrs = _build_ydb_attrs(cfg, peer=("only.host", None, None)) + assert attrs["network.peer.address"] == "only.host" + assert "network.peer.port" not in attrs + assert "ydb.node.dc" not in attrs + + def test_peer_with_only_port(self): + cfg = FakeDriverConfig() + attrs = _build_ydb_attrs(cfg, peer=(None, 2137, None)) + assert "network.peer.address" not in attrs + assert attrs["network.peer.port"] == 2137 + assert "ydb.node.dc" not in attrs + + def test_peer_with_only_location(self): + cfg = FakeDriverConfig() + attrs = _build_ydb_attrs(cfg, peer=(None, None, "dc-north")) + assert "network.peer.address" not in attrs + assert "network.peer.port" not in attrs + assert attrs["ydb.node.dc"] == "dc-north" + + def test_peer_with_empty_location(self): + cfg = FakeDriverConfig() + attrs = _build_ydb_attrs(cfg, peer=("h", 1, "")) + assert "ydb.node.dc" not in attrs + + +class TestSetPeerAttributes: + """Direct unit tests for ``set_peer_attributes``.""" + + def _recording_span(self): + recorded: Dict[str, Any] = {} + + class _Span: + def set_attribute(self, key, value): + recorded[key] = value + + return _Span(), recorded + + def test_peer_none_is_noop(self): + span, recorded = self._recording_span() + set_peer_attributes(span, None) + assert recorded == {} + + def test_full_peer_records_all_three(self): + span, recorded = self._recording_span() + set_peer_attributes(span, ("node-1.example", 2136, "dc-a")) + assert recorded == { + "network.peer.address": "node-1.example", + "network.peer.port": 2136, + "ydb.node.dc": "dc-a", + } + + def test_partial_peer_skips_missing_fields(self): + span, recorded = self._recording_span() + set_peer_attributes(span, (None, None, "")) + assert recorded == {} + + def test_port_coerced_to_int(self): + span, recorded = self._recording_span() + set_peer_attributes(span, ("h", "2137", "dc")) + assert recorded["network.peer.port"] == 2137 + + +class TestSpanFinishCallback: + """``span_finish_callback`` wires stream completion into span lifecycle.""" + + def test_finish_ends_span_on_success(self): + calls: List[str] = [] + + class _Span: + def set_error(self, exc): + calls.append(f"error:{exc}") + + def end(self): + calls.append("end") + + span_finish_callback(_Span())() + assert calls == ["end"] + + def test_finish_records_error_then_ends_span(self): + calls: List[str] = [] + exc = RuntimeError("stream broke") + + class _Span: + def set_error(self, e): + calls.append(f"error:{e}") + + def end(self): + calls.append("end") + + span_finish_callback(_Span())(exc) + assert calls == [f"error:{exc}", "end"] + + +class TestOpentelemetryTracingShimReexports: + """``ydb.opentelemetry.tracing`` must expose the same public surface as before the split.""" + + def test_shim_reexports_are_identity(self): + from ydb import observability + from ydb.observability import tracing as obs_tracing + from ydb.opentelemetry import tracing as otel_shim + + assert otel_shim.NoopSpan is observability.NoopSpan + assert otel_shim.NoopTracingProvider is observability.NoopTracingProvider + assert otel_shim.Span is observability.Span + assert otel_shim.SpanName is observability.SpanName + assert otel_shim.TracingProvider is observability.TracingProvider + assert otel_shim.create_span is obs_tracing.create_span + assert otel_shim.create_ydb_span is obs_tracing.create_ydb_span + assert otel_shim.get_trace_metadata is obs_tracing.get_trace_metadata + assert otel_shim.set_peer_attributes is obs_tracing.set_peer_attributes + assert otel_shim.span_finish_callback is obs_tracing.span_finish_callback + assert otel_shim._registry is obs_tracing._registry + assert otel_shim._build_ydb_attrs is obs_tracing._build_ydb_attrs + assert otel_shim._split_endpoint is obs_tracing._split_endpoint + assert otel_shim._NoopCtx is obs_tracing._NoopCtx + # Legacy private aliases used by very old callers. + assert otel_shim._NoopSpan is observability.NoopSpan + assert otel_shim._NOOP_SPAN is observability.NoopTracingProvider._SPAN + + +class TestOtelEntrypointHandlesMissingPackage: + """``ydb.opentelemetry.enable_tracing`` must raise a clear error if OTel isn't importable.""" + + def test_enable_raises_when_plugin_import_fails(self, monkeypatch): + import sys + + # Block the plugin import — this is how missing OTel would manifest. + monkeypatch.setitem(sys.modules, "ydb.opentelemetry.plugin", None) + + from ydb.opentelemetry import enable_tracing as otel_enable + + with pytest.raises(ImportError, match="OpenTelemetry"): + otel_enable() + + def test_disable_is_noop_when_plugin_import_fails(self, monkeypatch): + import sys + + monkeypatch.setitem(sys.modules, "ydb.opentelemetry.plugin", None) + + from ydb.opentelemetry import disable_tracing as otel_disable + + # Must not raise — silent fallback is documented behavior. + otel_disable() + + +class TestOtelTracingSpanBridge: + """Cover the ``TracingSpan`` wrapper directly (no SDK glue).""" + + def test_set_attribute_forwards_to_underlying_otel_span(self): + pytest.importorskip("opentelemetry") + + from ydb.opentelemetry.plugin import TracingSpan + + recorded: Dict[str, Any] = {} + + class _FakeOtelSpan: + def set_attribute(self, key, value): + recorded[key] = value + + def end(self): + recorded["ended"] = True + + wrapped = TracingSpan(_FakeOtelSpan()) + wrapped.set_attribute("db.system.name", "ydb") + wrapped.end() + + assert recorded == {"db.system.name": "ydb", "ended": True} + + def test_attach_context_exit_without_enter_is_safe(self): + """Defensive path: ``__exit__`` before a successful ``__enter__``. + + This can happen if ``__enter__`` raises before ``_token`` is assigned; + we must not detach a ``None`` token, but we should still end the span. + """ + pytest.importorskip("opentelemetry") + + from ydb.opentelemetry.plugin import TracingSpan, _AttachContext + + ended = {"n": 0} + + class _FakeOtelSpan: + def end(self): + ended["n"] += 1 + + def set_attribute(self, k, v): + pass + + ctx = _AttachContext(TracingSpan(_FakeOtelSpan()), end_on_exit=True) + # ``_token`` stays ``None`` — mimics the "__enter__ never ran" case. + assert ctx._token is None + ctx.__exit__(None, None, None) + assert ended["n"] == 1 + + +class TestNoopContextManager: + """The Noop provider's ``attach_context`` must be a well-formed context manager.""" + + def test_noop_ctx_yields_span_and_swallows_no_exceptions(self): + span = NoopSpan() + ctx = span.attach_context() + with ctx as inner: + assert inner is span + # A second entry after exit must still work — noop stays idempotent. + with span.attach_context() as inner: + inner.set_attribute("k", "v") + inner.set_error(RuntimeError("x")) + inner.end() + + def test_noop_provider_returns_shared_span_and_empty_metadata(self): + provider = NoopTracingProvider() + assert provider.create_span("x") is NoopTracingProvider._SPAN + assert tuple(provider.get_trace_metadata()) == ()