Skip to content

Commit 8170907

Browse files
committed
OpenTelemetry metrics support via vendor-neutral ydb.observability
Add client-side metrics on the ydb.observability layer, symmetric with tracing, with the metric machinery kept out of the query-service code. - ydb/observability/metrics.py: the vendor-neutral MetricsProvider Protocol (record / add / observe_gauge — one method per instrument kind), a Noop default, and the facades that hide all bookkeeping — SessionMetrics, QuerySessionPoolMetrics (with conditional timing context managers), and the observe_retry_metrics decorator. The asynchronous-gauge state (open sessions per pool, pool size) is owned here, so backends only implement three generic methods. Enabling metrics adds a ydb-sdk-metrics/0.1.0 token via the sdk_build_info_tokens aggregator. - ydb/observability/tracing.py: create_ydb_span returns a span-compatible composite that drives tracing and metrics for one operation (zero overhead when both off). - ydb/opentelemetry/metrics_plugin.py: OtelMetricsProvider installed through the observability layer; ydb.opentelemetry.enable_metrics(meter_provider=None) convenience and a lazily-exposed OtelMetricsProvider. The SDK core never imports opentelemetry. - Pool/session/retry code carries only clean facade calls — no time.monotonic, attribute dicts, or record_* scattered inline. QuerySessionPool gains an optional name for the pool metric label. - Consolidate observability tests under tests/observability/; document metrics and a custom-backend example on the observability + opentelemetry pages; extend the OTel example. Fix a concurrent patch.object leak in the async tracing tests and allow the example scripts into the Docker build context (.dockerignore).
1 parent 72c6109 commit 8170907

30 files changed

Lines changed: 2976 additions & 129 deletions

.dockerignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@
44
!README.md
55
!requirements.txt
66
!pyproject.toml
7-
!setup.py
7+
!setup.py
8+
!examples/opentelemetry/requirements.txt
9+
!examples/opentelemetry/otel_example.py
10+
!examples/opentelemetry/load_tank.py

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
* 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
22
* 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+
* Add client-side metrics through the same vendor-neutral `ydb.observability` layer: `enable_metrics(provider)` / `disable_metrics()`, with `ydb.opentelemetry.enable_metrics(meter_provider=None)` as the OpenTelemetry convenience. Instruments cover client operation duration/failures, retry duration/attempts, and query session pool state. `QuerySessionPool` gains an optional `name` argument for the pool metric label. When metrics are enabled the SDK appends a `ydb-sdk-metrics/0.1.0` token to the `x-ydb-sdk-build-info` header
34

45
## 3.30.0 ##
56
* 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

docs/observability.rst

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ driver initialization, and retries — are turned into spans and handed to which
88
built-in backend (see :doc:`opentelemetry`), but any tracing system can be plugged in
99
by implementing the interface described below.
1010

11-
Tracing is **zero-cost when disabled**: until you install a provider every span is a
12-
no-op stub, and the SDK never imports ``opentelemetry`` — or any other backend — on its
13-
own. The dependency is pulled in only when you explicitly opt into a concrete provider.
11+
The same layer also exposes **client-side metrics** (operation latency and failures,
12+
retry cost, query session pool state) through :func:`ydb.observability.enable_metrics`.
13+
Tracing and metrics are independent — enable either, both, or neither.
14+
15+
Observability is **zero-cost when disabled**: until you install a backend every span is a
16+
no-op stub and every metric is dropped by a no-op provider, and the SDK never imports
17+
``opentelemetry`` — or any other backend — on its own. The dependency is pulled in only
18+
when you explicitly opt into a concrete backend.
1419

1520

1621
The Tracing Interface
@@ -247,3 +252,163 @@ A custom ``attach_context`` must therefore honour this three-way contract on blo
247252

248253
The ``LoggingSpan`` above already implements exactly this. Getting it wrong leaks spans
249254
(never ended) or ends them too early (streaming spans that miss the actual read time).
255+
256+
257+
Metrics
258+
-------
259+
260+
Client-side metrics are enabled independently of tracing:
261+
262+
.. code-block:: python
263+
264+
from ydb.observability import enable_metrics, disable_metrics
265+
266+
enable_metrics(provider) # install a metrics backend
267+
disable_metrics() # turn metrics off — back to the no-op default
268+
269+
For OpenTelemetry the built-in convenience is ``ydb.opentelemetry.enable_metrics``,
270+
which builds an OTel-backed provider from a meter provider and installs it here — see
271+
the :doc:`opentelemetry` page for the meter-provider and exporter setup.
272+
273+
The Metrics Interface
274+
~~~~~~~~~~~~~~~~~~~~~~
275+
276+
A metrics backend implements :class:`~ydb.observability.MetricsProvider` — three
277+
methods, one per instrument kind:
278+
279+
.. code-block:: python
280+
281+
class MetricsProvider(Protocol):
282+
def record(self, name, value, attributes=None): ... # value distribution (histogram)
283+
def add(self, name, value, attributes=None): ... # additive counter (may go negative)
284+
def observe_gauge(self, name, callback): ... # asynchronous gauge
285+
286+
``record`` and ``add`` are pushed at the moment of the event. ``observe_gauge`` is
287+
different: the SDK owns the accumulated state (open sessions per pool, configured pool
288+
size) and hands the backend a ``callback`` that returns the current
289+
``(value, attributes)`` observations; the backend reads it whenever it collects. This
290+
keeps the pool bookkeeping vendor-neutral instead of pushing it into every backend.
291+
292+
It is a :class:`typing.Protocol`, so a backend does **not** need to subclass anything —
293+
any object with these three methods works.
294+
295+
Instruments
296+
~~~~~~~~~~~
297+
298+
The SDK records the following instruments regardless of backend (the OpenTelemetry
299+
adapter maps them to instruments on the ``"ydb.sdk"`` meter):
300+
301+
.. list-table::
302+
:header-rows: 1
303+
:widths: 32 22 46
304+
305+
* - Metric
306+
- Kind
307+
- Description
308+
* - ``db.client.operation.duration``
309+
- Histogram (``s``)
310+
- Latency of user-visible YDB client operations.
311+
* - ``ydb.client.operation.failed``
312+
- Counter
313+
- Failed user-visible YDB client operations.
314+
* - ``ydb.query.session.create_time``
315+
- Histogram (``s``)
316+
- Time spent creating a query session.
317+
* - ``ydb.query.session.pending_requests``
318+
- UpDownCounter
319+
- Requests currently waiting for a session from the pool.
320+
* - ``ydb.query.session.timeouts``
321+
- Counter
322+
- Session acquisition timeouts.
323+
* - ``ydb.query.session.count``
324+
- ObservableUpDownCounter
325+
- Current number of open query sessions by pool and ``ydb.query.session.state`` (``idle`` / ``used``).
326+
* - ``ydb.query.session.max``
327+
- ObservableUpDownCounter
328+
- Maximum configured number of sessions for a query session pool.
329+
* - ``ydb.query.session.min``
330+
- ObservableUpDownCounter
331+
- Minimum configured pool size. The SDK sets no minimum, so this is always ``0``.
332+
* - ``ydb.client.retry.duration``
333+
- Histogram (``s``)
334+
- Total duration of a logical retried operation, including attempts and backoff.
335+
* - ``ydb.client.retry.attempts``
336+
- Histogram
337+
- Number of attempts performed for one logical retried operation.
338+
339+
Attributes
340+
~~~~~~~~~~
341+
342+
Operation metrics (``db.client.operation.*``) carry stable labels only:
343+
344+
.. list-table::
345+
:header-rows: 1
346+
:widths: 35 65
347+
348+
* - Attribute
349+
- Description
350+
* - ``database``
351+
- Database path.
352+
* - ``endpoint``
353+
- Configured endpoint in ``host:port`` form.
354+
* - ``operation.name``
355+
- SDK operation name without the ``ydb.`` prefix, e.g. ``"ExecuteQuery"``.
356+
* - ``status_code``
357+
- Added only to ``ydb.client.operation.failed``.
358+
359+
Operation metrics are recorded for ``ExecuteQuery``, ``Commit``, ``Rollback``,
360+
``CreateSession`` and ``BeginTransaction``. Query session metrics use
361+
``ydb.query.session.pool.name``: when ``name`` is not passed to ``QuerySessionPool``
362+
the SDK falls back to the driver connection string ``<endpoint><database>`` (e.g.
363+
``grpc://localhost:2136/local``) so the pool is identifiable out of the box. Pass
364+
``QuerySessionPool(..., name="main-pool")`` (sync or async) when several pools share a
365+
connection string. Retry metrics are recorded without attributes.
366+
367+
Writing a Custom Metrics Backend
368+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
369+
370+
To send metrics somewhere OpenTelemetry does not cover — a home-grown collector, a log
371+
line, an in-memory recorder for tests — implement the three methods. Nothing to
372+
subclass; just match the signatures:
373+
374+
.. code-block:: python
375+
376+
from ydb.observability import enable_metrics, disable_metrics
377+
378+
379+
class LoggingMetrics:
380+
def __init__(self):
381+
self._gauges = {}
382+
383+
def record(self, name, value, attributes=None):
384+
# A value distribution (histogram): operation/create/retry durations, attempts.
385+
print(f"[metric] {name} = {value} {attributes or {}}")
386+
387+
def add(self, name, value, attributes=None):
388+
# An additive counter; value may be negative (pending_requests goes up and down).
389+
print(f"[metric] {name} += {value} {attributes or {}}")
390+
391+
def observe_gauge(self, name, callback):
392+
# Asynchronous gauge: keep the callback and poll it whenever you collect.
393+
# callback() -> iterable of (value, attributes).
394+
self._gauges[name] = callback
395+
396+
def collect(self):
397+
for name, callback in self._gauges.items():
398+
for value, attributes in callback():
399+
print(f"[gauge] {name} = {value} {attributes}")
400+
401+
402+
enable_metrics(LoggingMetrics())
403+
# ... use the SDK; record()/add() fire as operations happen ...
404+
disable_metrics()
405+
406+
The instrument kind for each metric is implied by which method the SDK calls: names in
407+
the *Histogram* rows above arrive through ``record``, *Counter* / *UpDownCounter* names
408+
through ``add``, and the three *ObservableUpDownCounter* gauges
409+
(``ydb.query.session.count`` / ``max`` / ``min``) through ``observe_gauge``. A backend
410+
that has no notion of asynchronous gauges can simply store the callbacks (or ignore
411+
them); the SDK never pushes those values, so nothing is lost elsewhere.
412+
413+
``disable_metrics()`` clears the SDK-side gauge state and reverts to the no-op default,
414+
so recording calls become cheap no-ops again.

docs/opentelemetry.rst

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ OpenTelemetry
22
=============
33

44
`OpenTelemetry <https://opentelemetry.io/>`_ is the built-in backend for the SDK's
5-
vendor-neutral tracing interface (see :doc:`observability`). Enabling it turns key YDB
6-
operations — session creation, query execution, transaction commit/rollback, driver
7-
initialization, and retries — into OpenTelemetry spans, and propagates trace context to
8-
the YDB server through gRPC metadata using the
9-
`W3C Trace Context <https://www.w3.org/TR/trace-context/>`_ standard.
5+
vendor-neutral observability interface (see :doc:`observability`). Enabling tracing turns
6+
key YDB operations — session creation, query execution, transaction commit/rollback,
7+
driver initialization, and retries — into OpenTelemetry spans, and propagates trace
8+
context to the YDB server through gRPC metadata using the
9+
`W3C Trace Context <https://www.w3.org/TR/trace-context/>`_ standard. Enabling metrics
10+
records client-side OpenTelemetry instruments for the same operations, retries, and
11+
query session pools.
1012

1113
Like every backend, it is **zero-cost when disabled**: the SDK uses no-op stubs by
12-
default and does not import ``opentelemetry`` until you call ``enable_tracing()``.
14+
default and does not import ``opentelemetry`` until you call ``enable_tracing()`` or
15+
``enable_metrics()``.
1316

1417

1518
Installation
@@ -85,6 +88,52 @@ vendor-neutral entrypoint — this is exactly what the convenience wrapper above
8588
enable_tracing(OtelTracingProvider()) # or OtelTracingProvider(my_tracer)
8689
8790
91+
Enabling Metrics
92+
----------------
93+
94+
Metrics are independent from tracing — enable either or both. Call ``enable_metrics()``
95+
once, after configuring your OpenTelemetry meter provider and before creating drivers or
96+
query session pools:
97+
98+
.. code-block:: python
99+
100+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
101+
from opentelemetry.sdk.metrics import MeterProvider
102+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
103+
from opentelemetry.sdk.resources import Resource
104+
105+
import ydb
106+
from ydb.opentelemetry import enable_metrics
107+
108+
# 1. Set up OpenTelemetry
109+
resource = Resource(attributes={"service.name": "my-service"})
110+
metric_reader = PeriodicExportingMetricReader(
111+
OTLPMetricExporter(endpoint="http://localhost:4317"),
112+
export_interval_millis=1000,
113+
)
114+
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
115+
116+
# 2. Enable YDB metrics
117+
enable_metrics(meter_provider)
118+
119+
# 3. Use the SDK as usual — metrics are recorded automatically
120+
with ydb.Driver(endpoint="grpc://localhost:2136", database="/local") as driver:
121+
driver.wait(timeout=5)
122+
with ydb.QuerySessionPool(driver, name="main-pool") as pool:
123+
pool.execute_with_retries("SELECT 1")
124+
125+
meter_provider.shutdown()
126+
127+
``enable_metrics()`` accepts an optional ``meter_provider`` argument. If omitted, the SDK
128+
obtains a meter named ``"ydb.sdk"`` from the global meter provider. The call is
129+
idempotent: repeated ``enable_metrics()`` calls do nothing until you call
130+
``disable_metrics()``, which clears the in-memory observable metric values and restores
131+
the no-op provider so metric recording stays a cheap no-op.
132+
133+
The full list of instruments and their attributes is catalogued on the
134+
:doc:`observability` page.
135+
136+
88137
Instrumented Spans and Attributes
89138
---------------------------------
90139

examples/opentelemetry/Dockerfile

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
# Isolated image for the OpenTelemetry demo. Build context is the repository root.
1+
# Isolated image for the OpenTelemetry demo scripts. Build context is the repository root.
22
#
3-
# docker compose -f examples/opentelemetry/compose-e2e.yaml build otel-example
3+
# docker compose -f examples/opentelemetry/compose-e2e.yaml build
44
#
55
# A separate ``.dockerignore`` at the repo root keeps the context small.
66

77
FROM python:3.11-slim
88

9+
ENV PYTHONUNBUFFERED=1
10+
911
WORKDIR /app
1012

1113
# Dependency layer: copy only what setup.py needs so changes to the demo script do
@@ -15,7 +17,6 @@ COPY ydb ./ydb
1517
COPY examples/opentelemetry/requirements.txt ./examples/opentelemetry/requirements.txt
1618
RUN pip install --no-cache-dir -e '.[opentelemetry]' -r examples/opentelemetry/requirements.txt
1719

18-
# Demo script.
20+
# Demo scripts.
1921
COPY examples/opentelemetry/otel_example.py ./examples/opentelemetry/otel_example.py
20-
21-
CMD ["python", "examples/opentelemetry/otel_example.py"]
22+
COPY examples/opentelemetry/load_tank.py ./examples/opentelemetry/load_tank.py

0 commit comments

Comments
 (0)