feat(core-shared): OtelMetrics impl using @opentelemetry/api metrics
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user