Files
lancedb/python/tests/test_otel.py
T
Will Jones 285add40dd feat: expose Lance metrics via OpenTelemetry in Python and Node (#3609)
Bridges Lance's internal `metrics`-crate instrumentation (object store
request counts, bytes, latency, errors, and throttles) into
OpenTelemetry, in both the Python and Node bindings, with a shared
adapter in the Rust core. This is the LanceDB counterpart to
lance-format/lance#7537.

## Rust core (`rust/lancedb`)
Two new, **off-by-default** features:
- `metrics` — re-exports the [`metrics`](https://docs.rs/metrics) crate
as `lancedb::metrics` and turns on Lance's object-store instrumentation.
Install any `metrics`-compatible recorder to collect them.
- `metrics-otel` — adds `lancedb::metrics_otel`, a pull-based adapter
that installs a process-global recorder aggregating into lock-free
cumulative storage and exposes a snapshot/catalog API
(`register_metrics_recorder`, `metrics_catalog`, `snapshot_metrics`,
`MetricPoint`/`MetricValue`/`MetricKind`/`MetricDescription`). Both
bindings build on this.

## Python
`lancedb.otel.instrument_lancedb_metrics()` registers each metric as an
OpenTelemetry observable instrument on the given (or global)
`MeterProvider`. Available via the `otel` extra (`pip install
lancedb[otel]`), which pulls in only `opentelemetry-api` — the
application supplies and configures the SDK.

## Node
`instrumentLanceDbMetrics()` provides the equivalent wiring against
`@opentelemetry/api`. This is the only public entry point; the
underlying recorder/catalog/snapshot functions stay internal.

Because OpenTelemetry has no asynchronous histogram instrument,
histograms are exported Prometheus-style as `<name>_bucket` (with an
`le` attribute), `<name>_count`, and `<name>_sum`. Only `_sum` carries
the histogram's unit; `_bucket` and `_count` observe cumulative counts
and are unitless. The adapter is enabled by default in the Python and
Node builds, and off by default in the Rust crate.

## Notes
- Requires Lance ≥ `v9.0.0-beta.19`, which ships the object-store
metrics APIs (upstream lance-format/lance#7537, now merged). `main` is
already on beta.19, so this is a single feature commit with no
dependency bump.
- Tests: 8 Rust unit tests, 3 Python tests, 2 Node tests, all covering
the end-to-end object-store-metrics → OpenTelemetry path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:36:03 -07:00

102 lines
4.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import lancedb
import pyarrow as pa
import pytest
# The metrics recorder is process-global and installed once, so the whole
# bridge is exercised in a single test to avoid cross-test global-state coupling.
def _metrics_by_name(reader):
data = reader.get_metrics_data()
result = {}
for resource_metrics in data.resource_metrics:
for scope_metrics in resource_metrics.scope_metrics:
for metric in scope_metrics.metrics:
result[metric.name] = metric
return result
def test_instrument_lancedb_metrics_exports_object_store_metrics(tmp_path):
pytest.importorskip("opentelemetry.sdk.metrics")
from lancedb.otel import instrument_lancedb_metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
reader = InMemoryMetricReader()
provider = MeterProvider(metric_readers=[reader])
assert instrument_lancedb_metrics(provider)
# The catalog is populated once the recorder is installed.
from lancedb._lancedb import lancedb_metrics_catalog
catalog = {desc.name: desc for desc in lancedb_metrics_catalog()}
# Every metric kind emitted by the object store must be described so it is
# surfaced by the bridge (counter, histogram, and gauge).
assert catalog["lance_object_store_requests_total"].kind == "counter"
assert catalog["lance_object_store_request_duration_seconds"].kind == "histogram"
assert catalog["lance_object_store_in_flight_requests"].kind == "gauge"
assert catalog["lance_object_store_retryable_responses_total"].kind == "counter"
# Generate object store activity on the local filesystem (scheme "file").
db = lancedb.connect(str(tmp_path))
table = db.create_table("t", pa.table({"id": pa.array(range(256))}))
assert table.count_rows() == 256
assert table.to_arrow().num_rows == 256
metrics = _metrics_by_name(reader)
requests = metrics["lance_object_store_requests_total"]
points = list(requests.data.data_points)
assert points, "expected at least one request data point"
# Object store metrics are labelled by `operation` and `base` (the store
# scheme, e.g. "file", by default).
assert all("base" in p.attributes and "operation" in p.attributes for p in points)
assert sum(p.value for p in points) > 0
# Histograms are decomposed into bucket / count / sum observable counters.
bucket = metrics["lance_object_store_request_duration_seconds_bucket"]
bucket_points = list(bucket.data.data_points)
assert bucket_points
assert all("le" in p.attributes for p in bucket_points)
# The implicit +Inf bucket must be present and is the cumulative maximum.
assert any(p.attributes["le"] == "+Inf" for p in bucket_points)
count = metrics["lance_object_store_request_duration_seconds_count"]
assert sum(p.value for p in count.data.data_points) > 0
# The `_sum` instrument must also be wired and report positive latency.
duration_sum = metrics["lance_object_store_request_duration_seconds_sum"]
assert sum(p.value for p in duration_sum.data.data_points) > 0
# Unit handling: only `_sum` keeps the histogram's unit (seconds); `_bucket`
# and `_count` observe cumulative counts and are unitless.
assert duration_sum.unit == "s"
assert bucket.unit == ""
assert count.unit == ""
def test_snapshot_empty_before_install_is_safe():
# snapshot is callable regardless of installation state and never raises.
from lancedb._lancedb import snapshot_lancedb_metrics
assert isinstance(snapshot_lancedb_metrics(), list)
def test_instrument_warns_when_recorder_unavailable(monkeypatch):
# A foreign `metrics` recorder already installed -> register returns False;
# instrument_lancedb_metrics must warn and return False without instrumenting.
pytest.importorskip("opentelemetry.sdk.metrics")
import lancedb.otel as otel
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
monkeypatch.setattr(otel, "register_lancedb_metrics_recorder", lambda: False)
reader = InMemoryMetricReader()
provider = MeterProvider(metric_readers=[reader])
with pytest.warns(UserWarning, match="recorder"):
assert otel.instrument_lancedb_metrics(provider) is False