Observability
The SDK exposes a small, vendor-neutral tracing interface in 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 OpenTelemetry), but any tracing system can be plugged in
by implementing the interface described below.
The same layer also exposes client-side metrics (operation latency and failures,
retry cost, query session pool state) through ydb.observability.enable_metrics().
Tracing and metrics are independent — enable either, both, or neither.
Observability is zero-cost when disabled: until you install a backend every span is a
no-op stub and every metric is dropped by a no-op provider, 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 backend.
The Tracing Interface
Two small protocols make up the whole contract. A backend implements
TracingProvider; the SDK calls it to create spans and to
collect metadata to propagate to the server:
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 Span:
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 typing.Protocol types, so a provider does not need to subclass
anything — any object with matching methods works (duck typing).
Enabling and Disabling
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:
Span Name |
Kind |
Description |
|---|---|---|
|
INTERNAL |
Driver wait / endpoint discovery. |
|
CLIENT |
Creating a new query session. |
|
CLIENT |
Executing a query (including |
|
CLIENT |
Explicitly beginning a transaction via |
|
CLIENT |
Committing an explicit transaction. |
|
CLIENT |
Rolling back a transaction. |
|
INTERNAL |
Umbrella span wrapping the whole retryable block ( |
|
INTERNAL |
A single retry attempt. From the second attempt onward carries
|
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:
Attribute |
Description |
|---|---|
|
Always |
|
Database path (e.g. |
|
Host from the connection string. |
|
Port from the connection string. |
|
Actual node host from the discovery endpoint map (set once the session is attached to a node). |
|
Actual node port from the discovery endpoint map. |
|
Data-center / location reported by discovery for the node (e.g. |
|
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
OpenTelemetry.
Choosing a Backend
OpenTelemetry (built-in)
The recommended backend for most users. Enabling it is a single call:
from ydb.opentelemetry import enable_tracing
enable_tracing()
Under the hood this constructs an
OtelTracingProvider and installs it via
ydb.observability.enable_tracing. Installation, exporters, trace-context
propagation, and a full runnable example live on the 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:
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:
Situation |
Action |
|---|---|
An exception is raised inside the block |
|
Clean exit, |
|
Clean exit, |
Do nothing — the result iterator owns |
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).
Metrics
Client-side metrics are enabled independently of tracing:
from ydb.observability import enable_metrics, disable_metrics
enable_metrics(provider) # install a metrics backend
disable_metrics() # turn metrics off — back to the no-op default
For OpenTelemetry the built-in convenience is ydb.opentelemetry.enable_metrics,
which builds an OTel-backed provider from a meter provider and installs it here — see
the OpenTelemetry page for the meter-provider and exporter setup.
The Metrics Interface
A metrics backend implements MetricsProvider — three
methods, one per instrument kind:
class MetricsProvider(Protocol):
def record(self, name, value, attributes=None): ... # value distribution (histogram)
def add(self, name, value, attributes=None): ... # additive counter (may go negative)
def observe_gauge(self, name, callback): ... # asynchronous gauge
record and add are pushed at the moment of the event. observe_gauge is
different: the SDK owns the accumulated state (open sessions per pool, configured pool
size) and hands the backend a callback that returns the current
(value, attributes) observations; the backend reads it whenever it collects. This
keeps the pool bookkeeping vendor-neutral instead of pushing it into every backend.
It is a typing.Protocol, so a backend does not need to subclass anything —
any object with these three methods works.
Instruments
The SDK records the following instruments regardless of backend (the OpenTelemetry
adapter maps them to instruments on the "ydb.sdk" meter):
Metric |
Kind |
Description |
|---|---|---|
|
Histogram ( |
Latency of user-visible YDB client operations. |
|
Counter |
Failed user-visible YDB client operations. |
|
Histogram ( |
Time spent creating a query session. |
|
UpDownCounter |
Requests currently waiting for a session from the pool. |
|
Counter |
Session acquisition timeouts. |
|
ObservableUpDownCounter |
Current number of open query sessions by pool and |
|
ObservableUpDownCounter |
Maximum configured number of sessions for a query session pool. |
|
ObservableUpDownCounter |
Minimum configured pool size. The SDK sets no minimum, so this is always |
|
Histogram ( |
Total duration of a logical retried operation, including attempts and backoff. |
|
Histogram |
Number of attempts performed for one logical retried operation. |
Attributes
Operation metrics (db.client.operation.*) carry stable labels only:
Attribute |
Description |
|---|---|
|
Database path. |
|
Configured endpoint in |
|
SDK operation name without the |
|
Added only to |
Operation metrics are recorded for ExecuteQuery, Commit, Rollback,
CreateSession and BeginTransaction. Query session metrics use
ydb.query.session.pool.name: when name is not passed to QuerySessionPool
the SDK falls back to the driver connection string <endpoint><database> (e.g.
grpc://localhost:2136/local) so the pool is identifiable out of the box. Pass
QuerySessionPool(..., name="main-pool") (sync or async) when several pools share a
connection string. Retry metrics are recorded without attributes.
Writing a Custom Metrics Backend
To send metrics somewhere OpenTelemetry does not cover — a home-grown collector, a log line, an in-memory recorder for tests — implement the three methods. Nothing to subclass; just match the signatures:
from ydb.observability import enable_metrics, disable_metrics
class LoggingMetrics:
def __init__(self):
self._gauges = {}
def record(self, name, value, attributes=None):
# A value distribution (histogram): operation/create/retry durations, attempts.
print(f"[metric] {name} = {value} {attributes or {}}")
def add(self, name, value, attributes=None):
# An additive counter; value may be negative (pending_requests goes up and down).
print(f"[metric] {name} += {value} {attributes or {}}")
def observe_gauge(self, name, callback):
# Asynchronous gauge: keep the callback and poll it whenever you collect.
# callback() -> iterable of (value, attributes).
self._gauges[name] = callback
def collect(self):
for name, callback in self._gauges.items():
for value, attributes in callback():
print(f"[gauge] {name} = {value} {attributes}")
enable_metrics(LoggingMetrics())
# ... use the SDK; record()/add() fire as operations happen ...
disable_metrics()
The instrument kind for each metric is implied by which method the SDK calls: names in
the Histogram rows above arrive through record, Counter / UpDownCounter names
through add, and the three ObservableUpDownCounter gauges
(ydb.query.session.count / max / min) through observe_gauge. A backend
that has no notion of asynchronous gauges can simply store the callbacks (or ignore
them); the SDK never pushes those values, so nothing is lost elsewhere.
disable_metrics() clears the SDK-side gauge state and reverts to the no-op default,
so recording calls become cheap no-ops again.