mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 03:58:26 +00:00
285add40dd
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>
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import {
|
|
MeterProvider,
|
|
type MetricData,
|
|
MetricReader,
|
|
} from "@opentelemetry/sdk-metrics";
|
|
import * as tmp from "tmp";
|
|
import { connect, instrumentLanceDbMetrics } from "../lancedb";
|
|
// snapshotLancedbMetrics is internal plumbing (not part of the public API), so
|
|
// it is imported from the native module rather than the package entry point.
|
|
import { snapshotLancedbMetrics } from "../lancedb/native";
|
|
|
|
// 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.
|
|
|
|
// A minimal pull-based reader whose `collect()` we drive directly, invoking the
|
|
// observable-instrument callbacks. `@opentelemetry/sdk-metrics` ships no
|
|
// in-memory reader, so we subclass the abstract base.
|
|
class TestMetricReader extends MetricReader {
|
|
protected async onForceFlush(): Promise<void> {
|
|
// no-op: collection is driven directly via collect()
|
|
}
|
|
protected async onShutdown(): Promise<void> {
|
|
// no-op: nothing to release
|
|
}
|
|
}
|
|
|
|
async function metricsByName(
|
|
reader: TestMetricReader,
|
|
): Promise<Map<string, MetricData>> {
|
|
const collected = await reader.collect();
|
|
const result = new Map<string, MetricData>();
|
|
for (const scope of collected.resourceMetrics.scopeMetrics) {
|
|
for (const metric of scope.metrics) {
|
|
result.set(metric.descriptor.name, metric);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe("OpenTelemetry metrics bridge", () => {
|
|
let tmpDir: tmp.DirResult;
|
|
beforeEach(() => {
|
|
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
|
});
|
|
afterEach(() => tmpDir.removeCallback());
|
|
|
|
it("snapshot is safe to call regardless of install state", () => {
|
|
expect(Array.isArray(snapshotLancedbMetrics())).toBe(true);
|
|
});
|
|
|
|
it("exports object store metrics via observable instruments", async () => {
|
|
const reader = new TestMetricReader();
|
|
const provider = new MeterProvider({ readers: [reader] });
|
|
expect(instrumentLanceDbMetrics(provider)).toBe(true);
|
|
|
|
// Generate object store activity on the local filesystem (scheme "file").
|
|
const db = await connect(tmpDir.name);
|
|
const data = Array.from({ length: 256 }, (_, i) => ({ id: i }));
|
|
const table = await db.createTable("t", data);
|
|
expect(await table.countRows()).toBe(256);
|
|
|
|
const metrics = await metricsByName(reader);
|
|
|
|
const requests = metrics.get("lance_object_store_requests_total");
|
|
expect(requests).toBeDefined();
|
|
// biome-ignore lint/suspicious/noExplicitAny: SDK point shape
|
|
const requestPoints = (requests!.dataPoints as any[]) ?? [];
|
|
expect(requestPoints.length).toBeGreaterThan(0);
|
|
for (const p of requestPoints) {
|
|
// Labelled by `operation` and `base` (the store scheme by default).
|
|
expect(p.attributes).toHaveProperty("base");
|
|
expect(p.attributes).toHaveProperty("operation");
|
|
}
|
|
const totalRequests = requestPoints.reduce((acc, p) => acc + p.value, 0);
|
|
expect(totalRequests).toBeGreaterThan(0);
|
|
|
|
// Histograms are decomposed into bucket / count / sum observable counters.
|
|
const bucket = metrics.get(
|
|
"lance_object_store_request_duration_seconds_bucket",
|
|
);
|
|
expect(bucket).toBeDefined();
|
|
// biome-ignore lint/suspicious/noExplicitAny: SDK point shape
|
|
const bucketPoints = (bucket!.dataPoints as any[]) ?? [];
|
|
expect(bucketPoints.length).toBeGreaterThan(0);
|
|
expect(bucketPoints.every((p) => "le" in p.attributes)).toBe(true);
|
|
// The implicit +Inf bucket must be present.
|
|
expect(bucketPoints.some((p) => p.attributes.le === "+Inf")).toBe(true);
|
|
|
|
const count = metrics.get(
|
|
"lance_object_store_request_duration_seconds_count",
|
|
);
|
|
expect(count).toBeDefined();
|
|
// biome-ignore lint/suspicious/noExplicitAny: SDK point shape
|
|
const countPoints = (count!.dataPoints as any[]) ?? [];
|
|
expect(countPoints.reduce((acc, p) => acc + p.value, 0)).toBeGreaterThan(0);
|
|
|
|
const sum = metrics.get("lance_object_store_request_duration_seconds_sum");
|
|
expect(sum).toBeDefined();
|
|
// biome-ignore lint/suspicious/noExplicitAny: SDK point shape
|
|
const sumPoints = (sum!.dataPoints as any[]) ?? [];
|
|
expect(sumPoints.reduce((acc, p) => acc + p.value, 0)).toBeGreaterThan(0);
|
|
|
|
// Unit handling: only `_sum` keeps the histogram's unit (seconds); `_bucket`
|
|
// and `_count` observe cumulative counts and are unitless.
|
|
expect(sum!.descriptor.unit).toBe("s");
|
|
expect(bucket!.descriptor.unit).toBe("");
|
|
expect(count!.descriptor.unit).toBe("");
|
|
|
|
await provider.shutdown();
|
|
});
|
|
});
|