Testing OpenTelemetry Metrics and Alerting: Validate Before Production
Your OTEL metrics instrumentation is only as good as your confidence that it measures what it claims to measure.
Your OTEL metrics instrumentation is only as good as your confidence that it measures what it claims to measure. A checkout success counter that double-counts, a latency histogram with wrong boundaries, or an alert rule that fires too late (or never) — these are observability bugs that cause real production incidents.
Testing metrics and alerting is different from testing traces. Traces are about individual requests; metrics are about aggregated behavior over time. The testing approach needs to match.
What Can Go Wrong With OTEL Metrics
Counter semantics errors: A checkout.success counter increments once per item instead of once per order. Your checkout success rate dashboard shows 5x the actual rate.
Histogram bucket misconfiguration: Your API latency histogram has buckets at [10ms, 50ms, 100ms, 500ms, 1s]. Your P99 latency is 800ms. The histogram can only tell you it's somewhere between 500ms and 1s — not useful for SLO compliance.
Gauge staleness: A gauge metric that should update on every request stops updating after a configuration change. Your dashboards show stale values. Alerting based on the gauge stops working.
Missing dimensions: You add a service.region attribute to new metrics but forget to add it to existing metrics. Cross-region comparisons on dashboards break.
Alert query bugs: Your alert fires count(errors) > 100. Your error counter resets every deployment. The alert fires after every deploy regardless of error rate.
Unit mismatches: Your latency metric records in milliseconds. Your alert threshold is set to > 1 assuming seconds. Alert never fires.
Setting Up Metrics Testing
The OTEL SDK provides in-memory metric exporters that work for unit testing:
// test-utils/metrics-collector.js
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { InMemoryMetricExporter } from '@opentelemetry/sdk-metrics';
import { AggregationTemporality } from '@opentelemetry/sdk-metrics';
export class MetricsTestCollector {
constructor() {
this.exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
this.meterProvider = new MeterProvider();
this.meterProvider.addMetricReader(
new PeriodicExportingMetricReader({
exporter: this.exporter,
exportIntervalMillis: 100, // Short interval for tests
})
);
}
getMeter(name) {
return this.meterProvider.getMeter(name);
}
async flush() {
await this.meterProvider.forceFlush();
}
getMetrics() {
return this.exporter.getMetrics();
}
getMetricByName(name) {
return this.getMetrics()
.flatMap(rm => rm.scopeMetrics)
.flatMap(sm => sm.metrics)
.find(m => m.descriptor.name === name);
}
reset() {
this.exporter.reset();
}
async shutdown() {
await this.meterProvider.shutdown();
}
}Testing Counter Semantics
// tests/observability/metrics.test.js
import { MetricsTestCollector } from '../test-utils/metrics-collector';
import { CheckoutService } from '../../services/checkout';
describe('checkout metrics', () => {
let collector;
let checkout;
beforeAll(() => {
collector = new MetricsTestCollector();
checkout = new CheckoutService(collector.getMeter('checkout'));
});
afterAll(() => collector.shutdown());
afterEach(() => collector.reset());
it('increments success counter once per order', async () => {
// Order with 3 items should count as 1 successful checkout
await checkout.process({ items: ['a', 'b', 'c'] });
await collector.flush();
const metric = collector.getMetricByName('checkout.success.total');
expect(metric).toBeDefined();
// Should be 1, not 3
const dataPoint = metric.dataPoints[0];
expect(dataPoint.value).toBe(1);
});
it('records correct attributes on success counter', async () => {
await checkout.process({ items: ['a'], region: 'us-east-1' });
await collector.flush();
const metric = collector.getMetricByName('checkout.success.total');
const dataPoint = metric.dataPoints[0];
expect(dataPoint.attributes['checkout.region']).toBe('us-east-1');
expect(dataPoint.attributes['checkout.item_count']).toBeDefined();
});
it('increments error counter on failure', async () => {
await expect(
checkout.process({ items: [] }) // empty cart
).rejects.toThrow();
await collector.flush();
const errorMetric = collector.getMetricByName('checkout.error.total');
expect(errorMetric?.dataPoints[0]?.value).toBeGreaterThan(0);
// Success counter should NOT increment on failure
const successMetric = collector.getMetricByName('checkout.success.total');
expect(successMetric?.dataPoints[0]?.value ?? 0).toBe(0);
});
});Testing Histogram Bucket Coverage
Histogram bucket configuration is easy to get wrong — and when it's wrong, you can't compute accurate percentiles:
describe('latency histogram configuration', () => {
it('has appropriate buckets for API latency', () => {
const histogram = meter.createHistogram('api.latency', {
description: 'API request latency',
unit: 'ms',
// Boundaries should cover your typical latency range with sufficient resolution
advice: { explicitBucketBoundaries: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] }
});
// Record some synthetic latencies
const testLatencies = [7, 15, 45, 80, 200, 450, 750, 1200];
testLatencies.forEach(lat => histogram.record(lat));
await collector.flush();
const metric = collector.getMetricByName('api.latency');
const { dataPoints } = metric;
// Verify that our P99 case (1200ms) falls in a meaningful bucket
// Not in "infinity bucket" that tells us nothing
const buckets = dataPoints[0].value.buckets;
const bucket1500 = buckets.boundaries.indexOf(1000); // closest upper bound
// The 1200ms value should be captured before the "5000ms" bucket
// If it's in the last bucket, our resolution is too coarse for SLO tracking
expect(bucket1500).not.toBe(buckets.boundaries.length - 1);
});
it('records latency in milliseconds not seconds', async () => {
// This tests for a common unit confusion bug
const startTime = Date.now();
await apiCall();
const duration = Date.now() - startTime;
await collector.flush();
const metric = collector.getMetricByName('api.latency');
const recorded = metric.dataPoints[0].value.sum;
// Recorded value should be in milliseconds (close to actual duration)
// If it's in seconds, it would be ~1000x smaller
expect(recorded).toBeGreaterThan(duration * 0.5);
expect(recorded).toBeLessThan(duration * 2);
});
});Testing Alert Rules
Alert rules written in PromQL, ClickHouse SQL, or your backend's query language should be tested separately from instrumentation:
// tests/observability/alerts.test.js
import { AlertRule } from '../test-utils/alert-tester';
describe('error rate alert', () => {
it('fires when error rate exceeds 5% over 5 minutes', async () => {
const alert = new AlertRule(`
rate(http_server_errors_total[5m])
/
rate(http_server_requests_total[5m])
> 0.05
`);
// Simulate 1000 requests with 60 errors (6% error rate)
const series = generateTimeSeries({
errors: 60,
total: 1000,
windowMinutes: 5
});
const result = await alert.evaluate(series);
expect(result.firing).toBe(true);
});
it('does not fire for transient spike within tolerance', async () => {
const alert = new AlertRule(`
rate(http_server_errors_total[5m])
/
rate(http_server_requests_total[5m])
> 0.05
FOR 2m // Must be sustained for 2 minutes
`);
// 30 second spike of 10% errors, then returns to 1%
const series = generateTransientSpike({
spikeRate: 0.10,
spikeDurationSeconds: 30,
baselineRate: 0.01,
windowMinutes: 5
});
const result = await alert.evaluate(series);
expect(result.firing).toBe(false); // Transient, not sustained
});
it('recovers correctly after error rate drops', async () => {
const alert = new AlertRule(`...`);
// Fire state
const firingResult = await alert.evaluate(highErrorSeries);
expect(firingResult.firing).toBe(true);
// Recovery state
const recoveredResult = await alert.evaluate(normalSeries);
expect(recoveredResult.firing).toBe(false);
expect(recoveredResult.resolved).toBe(true);
});
});Integration: Testing Metrics End-to-End
Unit testing metrics tells you the instrumentation code is correct. An integration test tells you the full pipeline works:
// tests/integration/metrics-pipeline.test.js
describe('metrics pipeline integration', () => {
it('metrics emitted by app are queryable in Prometheus', async () => {
// Hit the endpoint 10 times
for (let i = 0; i < 10; i++) {
await request(app).get('/api/products').expect(200);
}
// Wait for metrics to be scraped
await wait(15000); // Prometheus scrape interval
// Query Prometheus
const result = await prometheusQuery(
'increase(http_server_requests_total{path="/api/products"}[1m])'
);
// Should show approximately 10 requests
expect(parseFloat(result.value)).toBeGreaterThan(8);
expect(parseFloat(result.value)).toBeLessThan(12);
});
});Metric Cardinality Governance
High-cardinality metrics are a common production incident. Add cardinality tests:
describe('metric cardinality', () => {
it('http_server_requests_total does not include user ID as label', async () => {
// Make requests as different users
await request(app).get('/api').set('Authorization', 'Bearer user1-token');
await request(app).get('/api').set('Authorization', 'Bearer user2-token');
await request(app).get('/api').set('Authorization', 'Bearer user3-token');
await collector.flush();
const metric = collector.getMetricByName('http_server_requests_total');
const labelKeys = Object.keys(metric.dataPoints[0].attributes);
// user_id must not be a label — it would create unbounded cardinality
expect(labelKeys).not.toContain('user_id');
expect(labelKeys).not.toContain('user.id');
});
it('error metrics use code not full error message', async () => {
// Different error messages shouldn't create different label values
await triggerError('Database connection timeout after 30s');
await triggerError('Database connection timeout after 31s');
await collector.flush();
const metric = collector.getMetricByName('application.errors.total');
// Should use error.type not error.message
const labelKeys = Object.keys(metric.dataPoints[0].attributes);
expect(labelKeys).toContain('error.type');
expect(labelKeys).not.toContain('error.message');
});
});Summary
Testing OpenTelemetry metrics and alerting prevents a specific class of production failure: the outage where your monitoring system doesn't alert because the metrics are wrong.
The essential tests:
- Counter semantics — verify counters count what they claim (units, per-event vs per-item)
- Histogram bucket coverage — verify your latency buckets support your SLO resolution
- Attribute correctness — verify required dimensions are present, cardinality dimensions are absent
- Alert query accuracy — verify alert rules fire and recover correctly
- End-to-end pipeline — verify emitted metrics reach your backend
Write these tests when you write the instrumentation. A metric that's never tested is a metric that can silently mislead you about your production system.