Skip to content

Commit 67a06b4

Browse files
Split tracing into vendor-neutral observability + OTel adapter
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) <noreply@anthropic.com>
1 parent a7bf659 commit 67a06b4

19 files changed

Lines changed: 697 additions & 228 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
* 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
2+
13
## 3.30.0 ##
24
* 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
35
* 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

docs/opentelemetry.rst

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,46 @@ and **before** creating a ``Driver``:
6969
``enable_tracing()`` accepts an optional ``tracer`` argument. If omitted, the SDK
7070
obtains a tracer named ``"ydb.sdk"`` from the global tracer provider.
7171

72-
Repeated calls to ``enable_tracing()`` do nothing until you call ``disable_tracing()``,
73-
which removes hooks so you can reconfigure or turn instrumentation off.
72+
Repeated calls to ``enable_tracing()`` replace the previously installed provider, so
73+
it is safe to reconfigure at any time. Call ``disable_tracing()`` to remove the hooks
74+
entirely and return the SDK to its no-op default.
75+
76+
77+
Custom Providers (Vendor-Neutral API)
78+
-------------------------------------
79+
80+
OpenTelemetry is only one possible backend. The SDK exposes a small tracing
81+
interface in :mod:`ydb.observability` that any custom implementation can satisfy.
82+
This is how ``ydb.opentelemetry.enable_tracing`` is implemented — it constructs an
83+
:class:`~ydb.opentelemetry.plugin.OtelTracingProvider` and hands it to
84+
:func:`ydb.observability.enable_tracing`.
85+
86+
.. code-block:: python
87+
88+
from ydb.observability import (
89+
NoopSpan,
90+
Span,
91+
TracingProvider,
92+
enable_tracing,
93+
disable_tracing,
94+
)
95+
96+
class MyProvider:
97+
def create_span(self, name, attributes=None, kind=None) -> Span:
98+
# return your own Span implementation
99+
return NoopSpan()
100+
101+
def get_trace_metadata(self):
102+
# (key, value) pairs to attach to outgoing gRPC calls
103+
return ()
104+
105+
enable_tracing(MyProvider())
106+
# ... use the SDK ...
107+
disable_tracing()
108+
109+
``enable_tracing(provider)`` replaces whichever provider was installed before
110+
(OpenTelemetry, another custom one, or the built-in Noop). Passing ``None`` is
111+
equivalent to ``disable_tracing()``.
74112

75113

76114
What Is Instrumented
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Unit tests for the vendor-neutral ``ydb.observability`` tracing entrypoints.
2+
3+
These tests use a hand-rolled TracingProvider (no OpenTelemetry involvement)
4+
to verify that:
5+
6+
* ``enable_tracing(provider)`` accepts an arbitrary implementation of the
7+
``TracingProvider`` protocol,
8+
* a second ``enable_tracing`` call replaces (resets) the previous provider,
9+
* ``disable_tracing()`` returns the SDK to the Noop provider,
10+
* the SDK produces spans through whichever provider is currently installed,
11+
* ``get_trace_metadata`` is empty until a provider is enabled.
12+
"""
13+
14+
from typing import Any, Dict, List, Optional, Tuple
15+
16+
import pytest
17+
18+
from ydb.observability import (
19+
NoopSpan,
20+
NoopTracingProvider,
21+
Span,
22+
SpanName,
23+
TracingProvider,
24+
disable_tracing,
25+
enable_tracing,
26+
get_active_provider,
27+
)
28+
from ydb.observability.tracing import (
29+
_registry,
30+
create_span,
31+
create_ydb_span,
32+
get_trace_metadata,
33+
)
34+
35+
from .conftest import FakeDriverConfig
36+
37+
38+
class RecordingSpan:
39+
"""Minimal Span implementation that records every call."""
40+
41+
def __init__(self, name: str, attributes: Optional[dict], kind: Optional[str], sink: List[Dict[str, Any]]):
42+
self.name = name
43+
self.attributes: Dict[str, Any] = dict(attributes or {})
44+
self.kind = kind
45+
self.ended = False
46+
self.errors: List[BaseException] = []
47+
self._sink = sink
48+
49+
def set_error(self, exception):
50+
self.errors.append(exception)
51+
52+
def set_attribute(self, key, value):
53+
self.attributes[key] = value
54+
55+
def end(self):
56+
self.ended = True
57+
self._sink.append(
58+
{
59+
"name": self.name,
60+
"attributes": dict(self.attributes),
61+
"kind": self.kind,
62+
"ended": True,
63+
"errors": list(self.errors),
64+
}
65+
)
66+
67+
def attach_context(self, end_on_exit: bool = True):
68+
span = self
69+
70+
class _Ctx:
71+
def __enter__(self_inner):
72+
return span
73+
74+
def __exit__(self_inner, exc_type, exc_val, exc_tb):
75+
if exc_val is not None:
76+
span.set_error(exc_val)
77+
span.end()
78+
elif end_on_exit:
79+
span.end()
80+
return False
81+
82+
return _Ctx()
83+
84+
85+
class RecordingProvider:
86+
"""Custom TracingProvider used across the tests."""
87+
88+
def __init__(self, metadata: Optional[List[Tuple[str, str]]] = None):
89+
self.spans: List[RecordingSpan] = []
90+
self.finished: List[Dict[str, Any]] = []
91+
self._metadata = metadata or []
92+
93+
def create_span(self, name, attributes=None, kind=None) -> Span:
94+
span = RecordingSpan(name, attributes, kind, self.finished)
95+
self.spans.append(span)
96+
return span
97+
98+
def get_trace_metadata(self):
99+
return list(self._metadata)
100+
101+
102+
@pytest.fixture(autouse=True)
103+
def _reset_registry():
104+
"""Guarantee a clean Noop state around every test in this module."""
105+
disable_tracing()
106+
yield
107+
disable_tracing()
108+
109+
110+
class TestDefaultsAreNoop:
111+
def test_registry_starts_inactive(self):
112+
assert _registry.is_active() is False
113+
assert get_active_provider() is None
114+
115+
def test_create_ydb_span_is_noop_when_disabled(self):
116+
span = create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig())
117+
assert isinstance(span, NoopSpan)
118+
# NoopSpan methods must be safely callable
119+
span.set_attribute("x", 1)
120+
span.set_error(RuntimeError("boom"))
121+
span.end()
122+
123+
def test_get_trace_metadata_is_empty_when_disabled(self):
124+
assert get_trace_metadata() == []
125+
126+
127+
class TestEnableTracingWithCustomProvider:
128+
def test_custom_provider_receives_spans(self):
129+
provider = RecordingProvider()
130+
enable_tracing(provider)
131+
132+
assert _registry.is_active() is True
133+
assert get_active_provider() is provider
134+
135+
with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context() as span:
136+
span.set_attribute("custom.attr", "hello")
137+
138+
assert len(provider.spans) == 1
139+
recorded = provider.spans[0]
140+
assert recorded.name == SpanName.EXECUTE_QUERY
141+
assert recorded.attributes["db.system.name"] == "ydb"
142+
assert recorded.attributes["custom.attr"] == "hello"
143+
assert recorded.ended is True
144+
145+
def test_custom_provider_metadata_is_returned(self):
146+
provider = RecordingProvider(metadata=[("traceparent", "abc")])
147+
enable_tracing(provider)
148+
149+
assert get_trace_metadata() == [("traceparent", "abc")]
150+
151+
def test_internal_span_helper_uses_active_provider(self):
152+
provider = RecordingProvider()
153+
enable_tracing(provider)
154+
155+
with create_span(SpanName.RUN_WITH_RETRY):
156+
pass
157+
158+
assert len(provider.spans) == 1
159+
assert provider.spans[0].name == SpanName.RUN_WITH_RETRY
160+
assert provider.spans[0].kind == "internal"
161+
162+
163+
class TestEnableTracingResetsPrevious:
164+
"""Second ``enable_tracing`` must replace the previous provider."""
165+
166+
def test_double_enable_swaps_provider(self):
167+
first = RecordingProvider()
168+
second = RecordingProvider()
169+
170+
enable_tracing(first)
171+
with create_ydb_span(SpanName.CREATE_SESSION, FakeDriverConfig()).attach_context():
172+
pass
173+
174+
enable_tracing(second) # reset
175+
assert get_active_provider() is second
176+
177+
with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context():
178+
pass
179+
180+
# The second provider only sees spans emitted after enable()
181+
assert [s.name for s in first.spans] == [SpanName.CREATE_SESSION]
182+
assert [s.name for s in second.spans] == [SpanName.EXECUTE_QUERY]
183+
184+
def test_enable_none_is_disable(self):
185+
provider = RecordingProvider()
186+
enable_tracing(provider)
187+
assert _registry.is_active()
188+
189+
enable_tracing(None) # type: ignore[arg-type]
190+
assert _registry.is_active() is False
191+
assert get_active_provider() is None
192+
193+
def test_disable_tracing_reverts_to_noop(self):
194+
provider = RecordingProvider()
195+
enable_tracing(provider)
196+
disable_tracing()
197+
198+
assert _registry.is_active() is False
199+
with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context():
200+
pass
201+
202+
# No spans reached the provider after disable
203+
assert provider.spans == []
204+
205+
206+
class TestNoopProviderExplicit:
207+
def test_enabling_noop_provider_still_marks_registry_active(self):
208+
"""A user explicitly picking NoopTracingProvider is a valid choice."""
209+
enable_tracing(NoopTracingProvider())
210+
assert _registry.is_active() is True
211+
# But the produced span is still a NoopSpan — no attributes recorded.
212+
span = create_ydb_span(SpanName.CREATE_SESSION, FakeDriverConfig())
213+
assert isinstance(span, NoopSpan)
214+
215+
216+
class TestOtelEnableAlsoResets:
217+
"""The OTel entrypoint must reset any previously enabled provider too."""
218+
219+
def test_otel_enable_replaces_custom_provider(self):
220+
pytest.importorskip("opentelemetry")
221+
222+
from ydb.opentelemetry import disable_tracing as otel_disable
223+
from ydb.opentelemetry import enable_tracing as otel_enable
224+
from ydb.opentelemetry.plugin import OtelTracingProvider
225+
226+
custom = RecordingProvider()
227+
enable_tracing(custom)
228+
assert get_active_provider() is custom
229+
230+
otel_enable()
231+
active = get_active_provider()
232+
assert isinstance(active, OtelTracingProvider)
233+
234+
otel_disable()
235+
assert _registry.is_active() is False
236+
237+
def test_second_otel_enable_swaps_the_tracer(self):
238+
pytest.importorskip("opentelemetry")
239+
240+
from opentelemetry import trace
241+
242+
from ydb.opentelemetry import disable_tracing as otel_disable
243+
from ydb.opentelemetry import enable_tracing as otel_enable
244+
245+
try:
246+
otel_enable()
247+
first = get_active_provider()
248+
otel_enable(trace.get_tracer("ydb.sdk.custom"))
249+
second = get_active_provider()
250+
assert first is not second
251+
finally:
252+
otel_disable()
253+
254+
255+
class TestProtocolContract:
256+
def test_protocol_can_be_satisfied_by_duck_typed_object(self):
257+
"""A user-written provider does not need to inherit anything."""
258+
259+
class MyProvider:
260+
def __init__(self):
261+
self.calls = 0
262+
263+
def create_span(self, name, attributes=None, kind=None):
264+
self.calls += 1
265+
return NoopSpan()
266+
267+
def get_trace_metadata(self):
268+
return ()
269+
270+
provider: TracingProvider = MyProvider() # runtime duck check
271+
enable_tracing(provider)
272+
273+
with create_ydb_span(SpanName.EXECUTE_QUERY, FakeDriverConfig()).attach_context():
274+
pass
275+
276+
assert provider.calls == 1

tests/tracing/test_tracing_async.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from opentelemetry.trace import StatusCode, SpanKind
7-
from ydb.opentelemetry.tracing import SpanName
7+
from ydb.observability.tracing import SpanName
88
from ydb.query.transaction import QueryTxStateEnum
99
from .conftest import FakeDriverConfig
1010
from unittest.mock import AsyncMock, MagicMock, patch
@@ -100,7 +100,7 @@ def test_async_connection_peer_attributes_are_resolved(self, otel_setup):
100100

101101
from ydb.aio.connection import Connection
102102
from ydb.connection import EndpointOptions
103-
from ydb.opentelemetry.tracing import create_ydb_span
103+
from ydb.observability.tracing import create_ydb_span
104104
from ydb.query.session import _resolve_peer
105105

106106
cfg = FakeDriverConfig()

tests/tracing/test_tracing_sync.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from unittest.mock import MagicMock, patch
99
from opentelemetry import trace
1010
from opentelemetry.trace import StatusCode, SpanKind
11-
from ydb.opentelemetry.tracing import SpanName, _registry, create_ydb_span
11+
from ydb.observability.tracing import SpanName, _registry, create_ydb_span
1212
from ydb.query.transaction import QueryTxStateEnum
1313
from .conftest import FakeDriverConfig
1414

@@ -316,8 +316,7 @@ def test_no_spans_without_enable_tracing(self):
316316

317317
from tests.tracing.conftest import _exporter
318318

319-
_registry.set_create_span(None)
320-
_registry.set_metadata_hook(None)
319+
_registry.set_provider(None)
321320
_exporter.clear()
322321

323322
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):
347346

348347
class TestTraceMetadataInjection:
349348
def test_get_trace_metadata_returns_traceparent(self, otel_setup):
350-
from ydb.opentelemetry.tracing import get_trace_metadata
349+
from ydb.observability.tracing import get_trace_metadata
351350

352351
tracer = trace.get_tracer("test.tracer")
353352

ydb/aio/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ydb.driver import DriverConfig
2727
from ydb.settings import BaseRequestSettings
2828
from ydb import issues
29-
from ydb.opentelemetry.tracing import get_trace_metadata
29+
from ydb.observability.tracing import get_trace_metadata
3030

3131
# Workaround for good IDE and universal for runtime
3232
if TYPE_CHECKING:

ydb/aio/pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING, cast
77

88
from ydb import issues
9-
from ydb.opentelemetry.tracing import SpanName, create_ydb_span
9+
from ydb.observability.tracing import SpanName, create_ydb_span
1010
from ydb.pool import ConnectionsCache as _ConnectionsCache, IConnectionPool
1111

1212
from .connection import Connection, EndpointKey

0 commit comments

Comments
 (0)