diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77717472c..061c2fe70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+* 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
* 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/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 c4eb810e8..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
@@ -69,54 +70,28 @@ 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.
+Advanced users can build the provider explicitly and install it through the
+vendor-neutral entrypoint — this is exactly what the convenience wrapper above does:
-What Is Instrumented
---------------------
+.. code-block:: python
+
+ from ydb.observability import enable_tracing
+ from ydb.opentelemetry import OtelTracingProvider
-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:
+ enable_tracing(OtelTracingProvider()) # or OtelTracingProvider(my_tracer)
+
+
+Instrumented Spans and Attributes
+---------------------------------
+
+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
@@ -128,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
@@ -212,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
new file mode 100644
index 000000000..88e02a02b
--- /dev/null
+++ b/tests/tracing/test_observability_enable.py
@@ -0,0 +1,621 @@
+"""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 (
+ _build_ydb_attrs,
+ _registry,
+ _split_endpoint,
+ create_span,
+ create_ydb_span,
+ get_trace_metadata,
+ set_peer_attributes,
+ span_finish_callback,
+)
+
+from ydb.ydb_version import VERSION
+
+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_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 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."""
+
+ 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
+
+
+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."""
+
+ @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()
+
+ 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)."""
+
+ 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()) == ()
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..13e300617 100644
--- a/ydb/aio/connection.py
+++ b/ydb/aio/connection.py
@@ -26,7 +26,8 @@
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 import sdk_build_info_tokens
+from ydb.observability.tracing import get_trace_metadata
# Workaround for good IDE and universal for runtime
if TYPE_CHECKING:
@@ -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/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..052b563ef 100644
--- a/ydb/connection.py
+++ b/ydb/connection.py
@@ -24,7 +24,8 @@
import grpc
from . import issues, _apis, _utilities
from . import default_pem
-from .opentelemetry.tracing import get_trace_metadata
+from .observability import sdk_build_info_tokens
+from .observability.tracing import get_trace_metadata
_stubs_list = (
_apis.TableService.Stub,
@@ -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
new file mode 100644
index 000000000..b1df1af04
--- /dev/null
+++ b/ydb/observability/__init__.py
@@ -0,0 +1,74 @@
+"""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 List, Optional
+
+from ydb.observability.tracing import (
+ NoopSpan,
+ NoopTracingProvider,
+ Span,
+ SpanName,
+ TracingProvider,
+ _registry,
+ _tracing_build_info_tokens,
+)
+
+
+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. To turn tracing off again call
+ :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
+
+
+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",
+ "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..0512f2c78
--- /dev/null
+++ b/ydb/observability/tracing.py
@@ -0,0 +1,241 @@
+"""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, ContextManager, 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) -> 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
+ 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())
+
+
+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://"):
+ 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..b9a636183 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
@@ -33,4 +38,23 @@ 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. 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 AttributeError(
+ "OtelTracingProvider requires the OpenTelemetry packages. "
+ "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 76942789f..ccdee9961 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 Dict, 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[str, str] = {}
+ 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]):