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. 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.