feat(core-shared): OtelMetrics impl using @opentelemetry/api metrics

This commit is contained in:
2026-05-11 11:59:04 +02:00
parent e11fd7c897
commit 9835752c21
2 changed files with 190 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import { OtelMetrics } from "./otel-metrics";
function setupMeterProvider(): {
exporter: InMemoryMetricExporter;
provider: MeterProvider;
} {
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
return { exporter, provider };
}
describe("OtelMetrics", () => {
let exporter: InMemoryMetricExporter;
let provider: MeterProvider;
beforeEach(() => {
({ exporter, provider } = setupMeterProvider());
});
afterEach(async () => {
await provider.shutdown();
metrics.disable();
});
it("counter() records a counter measurement", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("http.requests", 1, { method: "GET" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "http.requests");
expect(counterMetric).toBeDefined();
expect(counterMetric!.dataPoints).toHaveLength(1);
expect(counterMetric!.dataPoints[0]!.value).toBe(1);
});
it("counter() defaults value to 1 when omitted", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("events.processed");
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "events.processed");
expect(counterMetric).toBeDefined();
expect(counterMetric!.dataPoints[0]!.value).toBe(1);
});
it("histogram() records a histogram measurement", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.histogram("http.duration", 250, { route: "/api/me" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const histogramMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "http.duration");
expect(histogramMetric).toBeDefined();
expect(histogramMetric!.dataPoints).toHaveLength(1);
// Histogram data points carry a Histogram aggregate value with sum/count/buckets.
const dp = histogramMetric!.dataPoints[0] as {
value: { sum?: number; count: number };
};
expect(dp.value.sum).toBe(250);
expect(dp.value.count).toBe(1);
});
it("gauge() records via UpDownCounter", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.gauge("queue.depth", 5, { queue: "emails" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const gaugeMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "queue.depth");
expect(gaugeMetric).toBeDefined();
expect(gaugeMetric!.dataPoints).toHaveLength(1);
});
it("lazily caches instrument instances — same counter object reused across calls", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("reuse.test", 1);
otelMetrics.counter("reuse.test", 2);
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "reuse.test");
// Cumulative: should accumulate both adds (1+2=3)
expect(counterMetric!.dataPoints[0]!.value).toBe(3);
});
});

View File

@@ -0,0 +1,70 @@
import { metrics } from "@opentelemetry/api";
import type { Counter, Histogram, UpDownCounter } from "@opentelemetry/api";
import type { IMetrics, MetricAttributeValue } from "../metrics.interface";
/**
* OTel-backed IMetrics implementation.
*
* - counter → OTel Counter (monotonic, add-only)
* - histogram → OTel Histogram
* - gauge → OTel UpDownCounter (synchronous emit). Known limitation: this
* accumulates deltas, not point-in-time values. True "set to
* absolute" semantics require ObservableGauge with a callback;
* deferred to a v2 interface.
*
* Instrument instances are lazily created and cached per name so repeated
* calls to the same metric name reuse the same OTel instrument.
*/
export class OtelMetrics implements IMetrics {
private readonly meter = metrics.getMeter("@repo/core-shared", "1.0.0");
private readonly counters = new Map<string, Counter>();
private readonly histograms = new Map<string, Histogram>();
private readonly gauges = new Map<string, UpDownCounter>();
counter(
name: string,
value = 1,
attributes?: Record<string, MetricAttributeValue>,
): void {
let counter = this.counters.get(name);
if (!counter) {
counter = this.meter.createCounter(name);
this.counters.set(name, counter);
}
counter.add(value, attributes);
}
histogram(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void {
let histogram = this.histograms.get(name);
if (!histogram) {
histogram = this.meter.createHistogram(name);
this.histograms.set(name, histogram);
}
histogram.record(value, attributes);
}
/**
* Records a gauge value via UpDownCounter.
*
* Note: UpDownCounter accumulates a running delta — each call adds to the
* previous value rather than replacing it. This is a synchronous approximation
* of gauge semantics. For true "set to absolute value" behaviour, use an
* ObservableGauge with a periodic callback instead (a future v2 addition).
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void {
let gauge = this.gauges.get(name);
if (!gauge) {
gauge = this.meter.createUpDownCounter(name);
this.gauges.set(name, gauge);
}
gauge.add(value, attributes);
}
}