Core Principle
OpenTelemetry counter metrics can be reported in two equivalent representations, delta and cumulative, and the backend’s query math must match the representation used at emission. A mismatch between stored temporality and assumed temporality produces silently wrong (often empty) results, not errors.
- Cumulative: each data point is the running total since process start.
rate()= differentiate consecutive points, then divide by elapsed time. - Delta: each data point is the change since the last report.
rate()= divide the value directly by the reporting interval.
Both encode the same information. Neither is inherently better — the fit depends on the producer’s lifecycle. Long-lived services (HTTP servers, daemons) naturally suit cumulative; short-lived invocations (serverless, CLI commands, batch jobs) naturally suit delta.
Why This Matters
- The SDK sets temporality per exporter preference (
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative|delta), and each data point carries a temporality flag. The backend is expected to honor it. - Backends that don’t store the flag per-sample, or that cache a single “canonical” temporality per metric name, break as soon as an app changes its preference — either via an env var flip or via an SDK default change across app versions.
- The failure mode is silent: wrong formula over technically-valid samples → empty aggregation result or nonsense numbers. No exception, no log line. You only notice when a dashboard goes blank.
- The OTel spec permits producers to change temporality, so this is not “misuse” — it’s an ongoing backend correctness requirement.
Evidence/Examples
SigNoz stale-metadata bug (April 2026). Claude Code emitted delta metrics through v2.1.112, then cumulative from v2.1.117 after we set the env var preference. SigNoz stores samples with per-row temporality correctly (samples_v4 shows Cumulative for recent rows, Delta for old), but the metric catalog (signoz_metrics.metadata) has rows for every (version, temporality) combination ever seen, and the API layer picks one with ClickHouse’s anyLast(temporality) GROUP BY metric_name. anyLast is non-deterministic — it returned a stale Delta row. The query planner then applied delta-formula rate math to cumulative samples. 1h queries returned empty; 7d happened to work because its scalar reduction path skipped the temporality lookup. Upstream: SigNoz #8961 (cache-aside TTL design, open, unassigned) and SigNoz #9708 (closed; declared the “keep all rows, pick latest” policy but implemented it with a non-deterministic picker).
Prometheus’s cumulative-only origin. Prometheus historically only understood cumulative counters. OTel-to-Prom conversion requires a cumulativetodelta or deltatocumulative processor in the Collector. Same principle: the reader assumes one form, the writer uses the other, so a translation layer has to exist somewhere.
AWS CloudWatch. Takes cumulative internally. Delta-emitting OTLP exporters routed to CloudWatch need a conversion processor or get zero-rate series.
Implications
- Pick temporality deliberately and early, document it in the project’s observability config, and change it only with eyes open. Each change is a soft breaking event for backends that cache.
- Prefer cumulative for long-lived processes targeting Prometheus/ClickHouse/CloudWatch; prefer delta only when the backend explicitly supports it (OTLP-native stores, Honeycomb, some configurations of New Relic).
- When a producer version bump changes temporality, expect backend breakage. Include a catalog reset / metadata purge step in the upgrade procedure.
- Silent emptiness is the diagnostic tell. When a metric query returns no rows despite the underlying table clearly having data, the first hypothesis should be “temporality mismatch between catalog and samples,” not “data is missing.”
- Dashboards and alerts should override temporality explicitly when the backend supports it, defense-in-depth against catalog drift.
Related Ideas
- SigNoz ClickHouse TTL Overflow Post-Mortem — same class of SigNoz failure: silent data loss from a catalog/storage-layer invariant (DateTime UInt32 overflow there, temporality cache here). Both have the shape “data looks fine going in, disappears or misaggregates coming out, no error anywhere.”
Questions
- Does the OTel spec mandate backend behavior when a producer changes temporality mid-stream? Spec requires readers to honor per-point temporality, but backend catalog/cache behavior is implementation-defined.
- Is there a standard “temporality-stable fingerprint” across backends, or does every backend reinvent its own cache keying?
- What does Grafana Tempo do with temporality changes in span metrics? Worth checking next time that surface comes up.