From 67a06b44c5ca5e10e310ed829c7f537aef186273 Mon Sep 17 00:00:00 2001 From: KirillKurdyukov Date: Fri, 10 Jul 2026 13:17:03 +0300 Subject: [PATCH 1/5] 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/5] 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()) == () From 3adcd6b082e263b7d385d5d023e9ef895ccacc2e Mon Sep 17 00:00:00 2001 From: Oleg Ovcharuk Date: Thu, 16 Jul 2026 10:08:05 +0300 Subject: [PATCH 3/5] Address review: public OtelTracingProvider, required provider, observability docs - Expose OtelTracingProvider from ydb.opentelemetry via lazy __getattr__ so the documented example imports without pulling opentelemetry at load time. - enable_tracing requires a provider (drop the None-as-disable overload); disable_tracing stays the single off switch. - Type fixes: headers as Dict[str, str], Span.attach_context -> ContextManager. - Docs: promote Observability to the umbrella page (interface, custom providers, the streaming end_on_exit contract); OpenTelemetry becomes a backend section. --- docs/index.rst | 12 +- docs/observability.rst | 249 +++++++++++++++++++++ docs/opentelemetry.rst | 146 +++--------- tests/tracing/test_observability_enable.py | 39 +++- ydb/observability/__init__.py | 2 +- ydb/observability/tracing.py | 4 +- ydb/opentelemetry/__init__.py | 18 +- ydb/opentelemetry/plugin.py | 4 +- 8 files changed, 334 insertions(+), 140 deletions(-) create mode 100644 docs/observability.rst diff --git a/docs/index.rst b/docs/index.rst index 4a3810ff3..94c7c6d69 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ Python client for `YDB `_ — a fault-tolerant distributed SQ :hidden: :caption: Observability + observability opentelemetry .. toctree:: @@ -121,10 +122,13 @@ incidents. Observability ------------- -The :doc:`opentelemetry` page explains how to add distributed tracing to your -application using OpenTelemetry. One call to ``enable_tracing()`` instruments -query sessions, transactions, and connection pool operations — so you can -visualize request flow in Jaeger, Grafana, or any OpenTelemetry-compatible backend. +The :doc:`observability` page describes the SDK's vendor-neutral tracing interface. +Key operations — query sessions, transactions, and connection pool activity — are +turned into spans and handed to whichever backend you install. `OpenTelemetry +`_ is the built-in backend (:doc:`opentelemetry`), so one +call to ``enable_tracing()`` lets you visualize request flow in Jaeger, Grafana, or any +OpenTelemetry-compatible system — while the same interface lets you plug in a custom +provider when you need one. API Reference diff --git a/docs/observability.rst b/docs/observability.rst new file mode 100644 index 000000000..1058ee7e5 --- /dev/null +++ b/docs/observability.rst @@ -0,0 +1,249 @@ +Observability +============= + +The SDK exposes a small, vendor-neutral tracing interface in :mod:`ydb.observability`. +Key YDB operations — session creation, query execution, transaction commit/rollback, +driver initialization, and retries — are turned into spans and handed to whichever +*tracing provider* you install. `OpenTelemetry `_ is the +built-in backend (see :doc:`opentelemetry`), but any tracing system can be plugged in +by implementing the interface described below. + +Tracing is **zero-cost when disabled**: until you install a provider every span is a +no-op stub, and the SDK never imports ``opentelemetry`` — or any other backend — on its +own. The dependency is pulled in only when you explicitly opt into a concrete provider. + + +The Tracing Interface +--------------------- + +Two small protocols make up the whole contract. A backend implements +:class:`~ydb.observability.TracingProvider`; the SDK calls it to create spans and to +collect metadata to propagate to the server: + +.. code-block:: python + + class TracingProvider(Protocol): + def create_span(self, name, attributes=None, kind=None) -> Span: ... + def get_trace_metadata(self) -> Iterable[Tuple[str, str]]: ... + +Every span it returns must satisfy :class:`~ydb.observability.Span`: + +.. code-block:: python + + class Span(Protocol): + def set_attribute(self, key, value): ... + def set_error(self, exception): ... + def end(self): ... + def attach_context(self, end_on_exit=True) -> ContextManager["Span"]: ... + +Both are :class:`typing.Protocol` types, so a provider does **not** need to subclass +anything — any object with matching methods works (duck typing). + + +Enabling and Disabling +---------------------- + +.. code-block:: python + + from ydb.observability import enable_tracing, disable_tracing + + enable_tracing(provider) # install a backend + disable_tracing() # turn tracing off — back to the no-op default + +``enable_tracing`` requires a provider. Calling it again replaces the previous one — the +old provider stops receiving spans immediately — so it is safe to reconfigure at any +time. ``disable_tracing()`` is the single way to switch tracing off; it restores the +built-in no-op default. + + +What Gets Traced +---------------- + +The SDK produces the following spans regardless of which backend is installed: + +.. list-table:: + :header-rows: 1 + :widths: 35 20 45 + + * - Span Name + - Kind + - Description + * - ``ydb.Driver.Initialize`` + - INTERNAL + - Driver wait / endpoint discovery. + * - ``ydb.CreateSession`` + - CLIENT + - Creating a new query session. + * - ``ydb.ExecuteQuery`` + - CLIENT + - Executing a query (including ``execute_with_retries``). + * - ``ydb.BeginTransaction`` + - CLIENT + - Explicitly beginning a transaction via ``.begin()``. + * - ``ydb.Commit`` + - CLIENT + - Committing an explicit transaction. + * - ``ydb.Rollback`` + - CLIENT + - Rolling back a transaction. + * - ``ydb.RunWithRetry`` + - INTERNAL + - Umbrella span wrapping the whole retryable block (``retry_operation_*`` / ``retry_tx_*`` / ``execute_with_retries``). + * - ``ydb.Try`` + - INTERNAL + - A single retry attempt. From the **second** attempt onward carries + ``ydb.retry.backoff_ms`` — how long the retrier slept before starting this + attempt (``0`` on the skip-yield retry path: ``Aborted`` / ``BadSession`` / + ``NotFound`` / ``InternalError``, where the protocol prescribes immediate + retry without backoff). The very first ``ydb.Try`` omits the attribute entirely. + +Every RPC (CLIENT-kind) span is created with these standard attributes already filled +in, so they reach your provider through the ``attributes`` argument of +``create_span``: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Attribute + - Description + * - ``db.system.name`` + - Always ``"ydb"``. + * - ``db.namespace`` + - Database path (e.g. ``"/local"``). + * - ``server.address`` + - Host from the connection string. + * - ``server.port`` + - Port from the connection string. + * - ``network.peer.address`` + - Actual node host from the discovery endpoint map (set once the session is attached to a node). + * - ``network.peer.port`` + - Actual node port from the discovery endpoint map. + * - ``ydb.node.dc`` + - Data-center / location reported by discovery for the node (e.g. ``"vla"``, ``"sas"``). + * - ``ydb.node.id`` + - YDB node that handled the request (when known). + +How **errors** are recorded is up to the provider: the SDK calls ``span.set_error(exc)`` +and the provider decides what to store. The built-in OpenTelemetry backend, for example, +adds ``error.type`` and ``db.response.status_code`` and marks the span status — see +:doc:`opentelemetry`. + + +Choosing a Backend +------------------ + +OpenTelemetry (built-in) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The recommended backend for most users. Enabling it is a single call: + +.. code-block:: python + + from ydb.opentelemetry import enable_tracing + + enable_tracing() + +Under the hood this constructs an +:class:`~ydb.opentelemetry.plugin.OtelTracingProvider` and installs it via +``ydb.observability.enable_tracing``. Installation, exporters, trace-context +propagation, and a full runnable example live on the :doc:`opentelemetry` page. + + +Writing a Custom Provider +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To send traces somewhere OpenTelemetry does not cover — a home-grown collector, a log +line, an in-memory recorder for tests — implement the two protocols yourself. Nothing to +subclass; just match the method signatures: + +.. code-block:: python + + import contextlib + + from ydb.observability import enable_tracing, disable_tracing + + + class LoggingSpan: + def __init__(self, name, attributes): + self.name = name + self.attributes = dict(attributes or {}) + self.error = None + + def set_attribute(self, key, value): + self.attributes[key] = value + + def set_error(self, exception): + self.error = exception + + def end(self): + status = f"ERROR {self.error!r}" if self.error else "OK" + print(f"[trace] {self.name} {status} {self.attributes}") + + @contextlib.contextmanager + def attach_context(self, end_on_exit=True): + # Make this span the "current" one for your backend here, if it has a + # notion of an active span (optional). + try: + yield self + except BaseException as exc: + # Setup failed: end the span here — nobody else will. + self.set_error(exc) + self.end() + raise + else: + if end_on_exit: + self.end() + # If end_on_exit is False the span is left open on purpose — see below. + + + class LoggingProvider: + def create_span(self, name, attributes=None, kind=None): + return LoggingSpan(name, attributes) + + def get_trace_metadata(self): + # (key, value) header pairs to inject into every outgoing gRPC call so the + # server can join the trace. Return () if you do not propagate context. + return () + + + enable_tracing(LoggingProvider()) + # ... use the SDK; spans are printed as they finish ... + disable_tracing() + +``kind`` is the semantic span kind the SDK requests — ``"client"`` for RPC spans and +``"internal"`` for umbrella/retry spans; map it to your backend or ignore it. + + +The Streaming Subtlety (``end_on_exit``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Most SDK spans are **single-shot**: the span is entered, the RPC runs inside the ``with`` +block, and the span ends when the block exits — that is ``end_on_exit=True``, the default. + +**Streaming** RPCs (such as query execution, which returns a result-set iterator) are +different. The span must stay open while the *caller* consumes the stream, which happens +**after** the setup ``with`` block has already returned. For those the SDK calls +``attach_context(end_on_exit=False)``: the span's context is attached only while the call +is being initiated, and ownership of ``end()`` is handed to the result iterator, which +ends the span once the stream is fully consumed (or fails). If setup itself raises inside +the ``with`` block, the span is ended right there instead — the caller never received an +iterator to drain. + +A custom ``attach_context`` must therefore honour this three-way contract on block exit: + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Situation + - Action + * - An exception is raised inside the block + - ``set_error(exc)`` then ``end()`` — always, regardless of ``end_on_exit``. + * - Clean exit, ``end_on_exit=True`` + - ``end()``. + * - Clean exit, ``end_on_exit=False`` + - Do **nothing** — the result iterator owns ``end()`` and will call it later. + +The ``LoggingSpan`` above already implements exactly this. Getting it wrong leaks spans +(never ended) or ends them too early (streaming spans that miss the actual read time). diff --git a/docs/opentelemetry.rst b/docs/opentelemetry.rst index 060a0e193..33e3fa1a6 100644 --- a/docs/opentelemetry.rst +++ b/docs/opentelemetry.rst @@ -1,14 +1,15 @@ -OpenTelemetry Tracing -===================== - -The SDK provides built-in distributed tracing via `OpenTelemetry `_. -When enabled, key YDB operations — such as session creation, query execution, transaction -commit/rollback, and driver initialization — produce OpenTelemetry spans. Trace -context is automatically propagated to the YDB server through gRPC metadata using the +OpenTelemetry +============= + +`OpenTelemetry `_ is the built-in backend for the SDK's +vendor-neutral tracing interface (see :doc:`observability`). Enabling it turns key YDB +operations — session creation, query execution, transaction commit/rollback, driver +initialization, and retries — into OpenTelemetry spans, and propagates trace context to +the YDB server through gRPC metadata using the `W3C Trace Context `_ standard. -Tracing is **zero-cost when disabled**: the SDK uses no-op stubs by default, so there is -no overhead unless you explicitly opt in. +Like every backend, it is **zero-cost when disabled**: the SDK uses no-op stubs by +default and does not import ``opentelemetry`` until you call ``enable_tracing()``. Installation @@ -73,88 +74,24 @@ Repeated calls to ``enable_tracing()`` replace the previously installed provider 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`. +Advanced users can build the provider explicitly and install it through the +vendor-neutral entrypoint — this is exactly what the convenience wrapper above does: .. 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() + from ydb.observability import enable_tracing + from ydb.opentelemetry import OtelTracingProvider - def get_trace_metadata(self): - # (key, value) pairs to attach to outgoing gRPC calls - return () + enable_tracing(OtelTracingProvider()) # or OtelTracingProvider(my_tracer) - 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()``. +Instrumented Spans and Attributes +--------------------------------- - -What Is Instrumented --------------------- - -The following operations produce spans: - -.. list-table:: - :header-rows: 1 - :widths: 35 20 45 - - * - Span Name - - Kind - - Description - * - ``ydb.Driver.Initialize`` - - INTERNAL - - Driver wait / endpoint discovery. - * - ``ydb.CreateSession`` - - CLIENT - - Creating a new query session. - * - ``ydb.ExecuteQuery`` - - CLIENT - - Executing a query (including ``execute_with_retries``). - * - ``ydb.BeginTransaction`` - - CLIENT - - Explicitly beginning a transaction via ``.begin()``. - * - ``ydb.Commit`` - - CLIENT - - Committing an explicit transaction. - * - ``ydb.Rollback`` - - CLIENT - - Rolling back a transaction. - * - ``ydb.RunWithRetry`` - - INTERNAL - - Umbrella span wrapping the whole retryable block (``retry_operation_*`` / ``retry_tx_*`` / ``execute_with_retries``). - * - ``ydb.Try`` - - INTERNAL - - A single retry attempt. From the **second** attempt onward carries - ``ydb.retry.backoff_ms`` — how long the retrier slept before starting this - attempt (``0`` on the skip-yield retry path: ``Aborted`` / ``BadSession`` / - ``NotFound`` / ``InternalError``, where the protocol prescribes immediate - retry without backoff). The very first ``ydb.Try`` omits the attribute - entirely because nothing preceded it. - -All spans are nested under the currently active span, so wrapping your application -logic in a parent span produces a complete trace tree: +The set of spans and the standard attributes attached to them are the same for every +backend and are catalogued on the :doc:`observability` page. All spans are nested under +the currently active OpenTelemetry span, so wrapping your application logic in a parent +span produces a complete trace tree: .. code-block:: python @@ -166,47 +103,15 @@ logic in a parent span produces a complete trace tree: # ↳ ydb.ExecuteQuery -Span Attributes +Error Recording --------------- -Every YDB RPC (CLIENT-kind) span carries these semantic attributes: - -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Attribute - - Description - * - ``db.system.name`` - - Always ``"ydb"``. - * - ``db.namespace`` - - Database path (e.g. ``"/local"``). - * - ``server.address`` - - Host from the connection string. - * - ``server.port`` - - Port from the connection string. - * - ``network.peer.address`` - - Actual node host from the discovery endpoint map (set once the session is attached to a node). - * - ``network.peer.port`` - - Actual node port from the discovery endpoint map. - * - ``ydb.node.dc`` - - Data-center / location reported by discovery for the node (e.g. ``"vla"``, ``"sas"``). - -Additional attributes are set when available: - -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Attribute - - Description - * - ``ydb.node.id`` - - YDB node that handled the request. - -On errors, the span also records: +On failures the OpenTelemetry backend enriches the span before ending it: - ``error.type`` — ``"ydb_error"``, ``"transport_error"``, or the Python exception class name. - ``db.response.status_code`` — the YDB status code name (e.g. ``"SCHEME_ERROR"``). +- the span status is set to ``ERROR`` and the exception is recorded via + ``record_exception``. Trace Context Propagation @@ -250,7 +155,6 @@ startup: asyncio.run(main()) - Using a Custom Tracer --------------------- diff --git a/tests/tracing/test_observability_enable.py b/tests/tracing/test_observability_enable.py index 183048803..23b6da2e3 100644 --- a/tests/tracing/test_observability_enable.py +++ b/tests/tracing/test_observability_enable.py @@ -185,15 +185,6 @@ def test_double_enable_swaps_provider(self): 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) @@ -256,6 +247,36 @@ def test_second_otel_enable_swaps_the_tracer(self): otel_disable() +class TestPublicOtelProviderExport: + """``OtelTracingProvider`` is part of the public ``ydb.opentelemetry`` namespace.""" + + def test_provider_is_exported_from_package_root(self): + pytest.importorskip("opentelemetry") + + import ydb.opentelemetry as otel + from ydb.opentelemetry.plugin import OtelTracingProvider + + assert otel.OtelTracingProvider is OtelTracingProvider + assert "OtelTracingProvider" in otel.__all__ + + def test_exported_provider_plugs_into_vendor_neutral_enable(self): + pytest.importorskip("opentelemetry") + + from ydb.opentelemetry import OtelTracingProvider + + try: + enable_tracing(OtelTracingProvider()) + assert isinstance(get_active_provider(), OtelTracingProvider) + finally: + disable_tracing() + + def test_unknown_attribute_still_raises(self): + import ydb.opentelemetry as otel + + with pytest.raises(AttributeError): + otel.does_not_exist + + class TestProtocolContract: def test_protocol_can_be_satisfied_by_duck_typed_object(self): """A user-written provider does not need to inherit anything.""" diff --git a/ydb/observability/__init__.py b/ydb/observability/__init__.py index a7203d272..6a2826d27 100644 --- a/ydb/observability/__init__.py +++ b/ydb/observability/__init__.py @@ -29,7 +29,7 @@ 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 + stops receiving spans immediately. To turn tracing off again call :func:`disable_tracing`. """ _registry.set_provider(provider) diff --git a/ydb/observability/tracing.py b/ydb/observability/tracing.py index 932ff6681..47aad481f 100644 --- a/ydb/observability/tracing.py +++ b/ydb/observability/tracing.py @@ -8,7 +8,7 @@ """ import enum -from typing import Any, Callable, Iterable, List, Optional, Protocol, Tuple +from typing import Any, Callable, ContextManager, Iterable, List, Optional, Protocol, Tuple class SpanName(str, enum.Enum): @@ -36,7 +36,7 @@ def set_attribute(self, key: str, value: Any) -> None: ... def end(self) -> None: ... - def attach_context(self, end_on_exit: bool = True) -> Any: + def attach_context(self, end_on_exit: bool = True) -> ContextManager["Span"]: """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 diff --git a/ydb/opentelemetry/__init__.py b/ydb/opentelemetry/__init__.py index dc35e1aaf..37d91f03b 100644 --- a/ydb/opentelemetry/__init__.py +++ b/ydb/opentelemetry/__init__.py @@ -38,4 +38,20 @@ def disable_tracing(): _disable_tracing() -__all__ = ["disable_tracing", "enable_tracing"] +def __getattr__(name): + # Lazily expose the OTel provider so ``from ydb.opentelemetry import + # OtelTracingProvider`` works without importing ``opentelemetry`` at + # module load time. + if name == "OtelTracingProvider": + try: + from ydb.opentelemetry.plugin import OtelTracingProvider + except ImportError: + raise ImportError( + "OpenTelemetry packages are required for tracing support. " + "Install them with: pip install ydb[opentelemetry]" + ) from None + return OtelTracingProvider + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["OtelTracingProvider", "disable_tracing", "enable_tracing"] diff --git a/ydb/opentelemetry/plugin.py b/ydb/opentelemetry/plugin.py index 82218bf15..ccdee9961 100644 --- a/ydb/opentelemetry/plugin.py +++ b/ydb/opentelemetry/plugin.py @@ -5,7 +5,7 @@ dependency is only pulled in when a user calls :func:`ydb.opentelemetry.enable_tracing`. """ -from typing import Iterable, Optional, Tuple +from typing import Dict, Iterable, Optional, Tuple from opentelemetry import context as otel_context from opentelemetry import trace @@ -115,7 +115,7 @@ def create_span(self, name, attributes=None, kind=None): return TracingSpan(span) def get_trace_metadata(self) -> Iterable[Tuple[str, str]]: - headers: dict = {} + headers: Dict[str, str] = {} inject(headers) return tuple(headers.items()) From 2ed85cb874edcf7c6f1e04c862c79cd0418ef4a6 Mon Sep 17 00:00:00 2001 From: Oleg Ovcharuk Date: Thu, 16 Jul 2026 10:55:50 +0300 Subject: [PATCH 4/5] Advertise ydb-sdk-tracing token in x-ydb-sdk-build-info when tracing is on While a tracing provider is installed the SDK appends ydb-sdk-tracing/0.1.0 to the x-ydb-sdk-build-info header (native SDK, then SDK-owned feature tokens, then custom driver headers). The token list is aggregated by ydb.observability.sdk_build_info_tokens so future features (e.g. metrics) plug in with a single line. --- CHANGELOG.md | 1 + tests/tracing/test_observability_enable.py | 56 ++++++++++++++++++++++ ydb/aio/connection.py | 4 +- ydb/connection.py | 4 +- ydb/observability/__init__.py | 16 ++++++- ydb/observability/tracing.py | 13 +++++ 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e841b3de..061c2fe70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +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 +* When tracing is enabled the SDK appends a `ydb-sdk-tracing/0.1.0` token to the `x-ydb-sdk-build-info` header, so the server can distinguish requests from tracing-enabled clients ## 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 diff --git a/tests/tracing/test_observability_enable.py b/tests/tracing/test_observability_enable.py index 23b6da2e3..0e828915d 100644 --- a/tests/tracing/test_observability_enable.py +++ b/tests/tracing/test_observability_enable.py @@ -36,6 +36,8 @@ span_finish_callback, ) +from ydb.ydb_version import VERSION + from .conftest import FakeDriverConfig @@ -301,6 +303,60 @@ def get_trace_metadata(self): assert provider.calls == 1 +class _BuildInfoCfg: + database = None + credentials = None + + +class TestSdkBuildInfoHeader: + """Enabling tracing advertises a ``ydb-sdk-tracing`` token in x-ydb-sdk-build-info.""" + + def test_tokens_reflect_active_provider(self): + from ydb.observability import sdk_build_info_tokens + + assert sdk_build_info_tokens() == [] + enable_tracing(RecordingProvider()) + assert sdk_build_info_tokens() == ["ydb-sdk-tracing/0.1.0"] + disable_tracing() + assert sdk_build_info_tokens() == [] + + def test_header_gains_tracing_token_when_enabled(self): + from ydb.connection import _construct_metadata + + def build_info(): + return dict(_construct_metadata(_BuildInfoCfg(), None))["x-ydb-sdk-build-info"] + + assert "ydb-sdk-tracing" not in build_info() + + enable_tracing(RecordingProvider()) + header = build_info() + disable_tracing() + + assert header == f"ydb-python-sdk/{VERSION};ydb-sdk-tracing/0.1.0" + + def test_tracing_token_precedes_driver_additional_headers(self): + from ydb.connection import _construct_metadata + + class _Cfg(_BuildInfoCfg): + _additional_sdk_headers = ("lib1/1.0",) + + enable_tracing(RecordingProvider()) + header = dict(_construct_metadata(_Cfg(), None))["x-ydb-sdk-build-info"] + disable_tracing() + + # native SDK, then SDK-owned tracing token, then custom driver headers + assert header == f"ydb-python-sdk/{VERSION};ydb-sdk-tracing/0.1.0;lib1/1.0" + + async def test_header_async_parity(self): + from ydb.aio.connection import _construct_metadata + + enable_tracing(RecordingProvider()) + header = dict(await _construct_metadata(_BuildInfoCfg(), None))["x-ydb-sdk-build-info"] + disable_tracing() + + assert header == f"ydb-python-sdk/{VERSION};ydb-sdk-tracing/0.1.0" + + class TestSplitEndpoint: """Cover every branch of the endpoint parser.""" diff --git a/ydb/aio/connection.py b/ydb/aio/connection.py index 6865e583f..13e300617 100644 --- a/ydb/aio/connection.py +++ b/ydb/aio/connection.py @@ -26,6 +26,7 @@ from ydb.driver import DriverConfig from ydb.settings import BaseRequestSettings from ydb import issues +from ydb.observability import sdk_build_info_tokens from ydb.observability.tracing import get_trace_metadata # Workaround for good IDE and universal for runtime @@ -71,7 +72,8 @@ async def _construct_metadata( if settings.request_type is not None: metadata.append((YDB_REQUEST_TYPE_HEADER, settings.request_type)) - metadata.append(_utilities.x_ydb_sdk_build_info_header(getattr(driver_config, "_additional_sdk_headers", ()))) + additional_sdk_headers = (*sdk_build_info_tokens(), *getattr(driver_config, "_additional_sdk_headers", ())) + metadata.append(_utilities.x_ydb_sdk_build_info_header(additional_sdk_headers)) metadata.extend(get_trace_metadata()) diff --git a/ydb/connection.py b/ydb/connection.py index ee9fb0cb1..052b563ef 100644 --- a/ydb/connection.py +++ b/ydb/connection.py @@ -24,6 +24,7 @@ import grpc from . import issues, _apis, _utilities from . import default_pem +from .observability import sdk_build_info_tokens from .observability.tracing import get_trace_metadata _stubs_list = ( @@ -179,7 +180,8 @@ def _construct_metadata(driver_config, settings): metadata.append((YDB_REQUEST_TYPE_HEADER, settings.request_type)) metadata.extend(getattr(settings, "headers", [])) - metadata.append(_utilities.x_ydb_sdk_build_info_header(getattr(driver_config, "_additional_sdk_headers", ()))) + additional_sdk_headers = (*sdk_build_info_tokens(), *getattr(driver_config, "_additional_sdk_headers", ())) + metadata.append(_utilities.x_ydb_sdk_build_info_header(additional_sdk_headers)) metadata.extend(get_trace_metadata()) diff --git a/ydb/observability/__init__.py b/ydb/observability/__init__.py index 6a2826d27..b1df1af04 100644 --- a/ydb/observability/__init__.py +++ b/ydb/observability/__init__.py @@ -13,7 +13,7 @@ every span is a :class:`~ydb.observability.tracing.NoopSpan`. """ -from typing import Optional +from typing import List, Optional from ydb.observability.tracing import ( NoopSpan, @@ -22,6 +22,7 @@ SpanName, TracingProvider, _registry, + _tracing_build_info_tokens, ) @@ -48,6 +49,19 @@ def get_active_provider() -> Optional[TracingProvider]: return _registry.get_provider() if _registry.is_active() else None +def sdk_build_info_tokens() -> List[str]: + """All ``x-ydb-sdk-build-info`` feature tokens contributed by observability. + + Aggregated across observability features so the SDK build-info header advertises + every capability the client has turned on. Tracing contributes + ``ydb-sdk-tracing/`` while active; future features (e.g. metrics via + ``enable_metrics``) append their own tokens here. + """ + tokens: List[str] = [] + tokens.extend(_tracing_build_info_tokens()) + return tokens + + __all__ = [ "NoopSpan", "NoopTracingProvider", diff --git a/ydb/observability/tracing.py b/ydb/observability/tracing.py index 47aad481f..0512f2c78 100644 --- a/ydb/observability/tracing.py +++ b/ydb/observability/tracing.py @@ -146,6 +146,19 @@ def get_trace_metadata() -> List[Tuple[str, str]]: return list(_registry.get_trace_metadata()) +TRACING_SDK_BUILD_INFO = "ydb-sdk-tracing/0.1.0" + + +def _tracing_build_info_tokens() -> List[str]: + """Tracing's contribution to the ``x-ydb-sdk-build-info`` header. + + Returns ``["ydb-sdk-tracing/0.1.0"]`` once a provider is installed, otherwise an + empty list. Aggregated with other features by + :func:`ydb.observability.sdk_build_info_tokens`. + """ + return [TRACING_SDK_BUILD_INFO] if _registry.is_active() else [] + + def _split_endpoint(endpoint: Optional[str]) -> Tuple[str, int]: ep = endpoint or "" if ep.startswith("grpcs://"): From 988e0eb7ba097307c68814704d04810496f6885d Mon Sep 17 00:00:00 2001 From: Oleg Ovcharuk Date: Thu, 16 Jul 2026 12:11:05 +0300 Subject: [PATCH 5/5] Raise AttributeError (not ImportError) from ydb.opentelemetry.__getattr__ Module __getattr__ raising ImportError broke hasattr()/getattr(default) introspection of OtelTracingProvider when OpenTelemetry is absent. Raise AttributeError instead, keeping the install hint in the message; enable_tracing() still surfaces its own ImportError guidance. --- tests/tracing/test_observability_enable.py | 16 ++++++++++++++++ ydb/opentelemetry/__init__.py | 11 +++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/tracing/test_observability_enable.py b/tests/tracing/test_observability_enable.py index 0e828915d..88e02a02b 100644 --- a/tests/tracing/test_observability_enable.py +++ b/tests/tracing/test_observability_enable.py @@ -535,6 +535,22 @@ def test_disable_is_noop_when_plugin_import_fails(self, monkeypatch): # Must not raise — silent fallback is documented behavior. otel_disable() + def test_provider_access_raises_attributeerror_when_plugin_import_fails(self, monkeypatch): + import sys + + monkeypatch.setitem(sys.modules, "ydb.opentelemetry.plugin", None) + + import ydb.opentelemetry as otel + + # AttributeError (not ImportError) keeps introspection sane... + assert hasattr(otel, "OtelTracingProvider") is False + sentinel = object() + assert getattr(otel, "OtelTracingProvider", sentinel) is sentinel + + # ...while the install hint still rides along on direct access. + with pytest.raises(AttributeError, match="OpenTelemetry"): + otel.OtelTracingProvider + class TestOtelTracingSpanBridge: """Cover the ``TracingSpan`` wrapper directly (no SDK glue).""" diff --git a/ydb/opentelemetry/__init__.py b/ydb/opentelemetry/__init__.py index 37d91f03b..b9a636183 100644 --- a/ydb/opentelemetry/__init__.py +++ b/ydb/opentelemetry/__init__.py @@ -40,14 +40,17 @@ def disable_tracing(): def __getattr__(name): # Lazily expose the OTel provider so ``from ydb.opentelemetry import - # OtelTracingProvider`` works without importing ``opentelemetry`` at - # module load time. + # OtelTracingProvider`` works without importing ``opentelemetry`` at module + # load time. When OTel is missing, raise AttributeError (not ImportError) so + # hasattr()/getattr(..., default) introspection behaves normally; the install + # hint rides along in the message, and enable_tracing() keeps its own + # ImportError guidance for the primary entrypoint. if name == "OtelTracingProvider": try: from ydb.opentelemetry.plugin import OtelTracingProvider except ImportError: - raise ImportError( - "OpenTelemetry packages are required for tracing support. " + raise AttributeError( + "OtelTracingProvider requires the OpenTelemetry packages. " "Install them with: pip install ydb[opentelemetry]" ) from None return OtelTracingProvider