OpenTelemetry Delta Temporality and Datadog

When you ship OpenTelemetry metrics to Datadog, the choice between cumulative and delta temporality changes how your dashboards behave. This post covers the practical differences, what Datadog actually stores, and how to query each instrument type correctly.

Temporality

OpenTelemetry supports two temporalities for counters and histograms:

  • Cumulative — each export sends the running total since process start. The value only goes up (for monotonic counters).
  • Delta — each export sends the change since the last export. The value resets after each push.

Configure delta temporality with a TemporalitySelector on the OTLP exporter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func deltaSelector(kind sdkmetric.InstrumentKind) metricdata.Temporality {
    switch kind {
    case sdkmetric.InstrumentKindCounter,
        sdkmetric.InstrumentKindHistogram,
        sdkmetric.InstrumentKindObservableCounter:
        return metricdata.DeltaTemporality
    default:
        return metricdata.CumulativeTemporality
    }
}

metricExp, _ := otlpmetrichttp.New(ctx,
    otlpmetrichttp.WithTemporalitySelector(deltaSelector),
)

Gauges always use cumulative temporality — they represent point-in-time state, not accumulations.

What Datadog Actually Stores

In my experience, Datadog appears to store counter data as deltas internally regardless of the temporality you send. When I switched from cumulative to delta, the queried values looked the same — the metric was always represented as a delta, even before the change. This suggests Datadog diffs consecutive cumulative points on ingest to derive the delta.

The advantage of sending delta is avoiding counter-reset artifacts. When a container restarts, a cumulative counter drops from a large value to zero. Datadog must detect and discard the negative diff, which can produce spurious spikes or dips. With delta, each data point is self-contained and container restarts are invisible.

Instrument Types at Export Time

Counter

Each export sends the delta (count since last export), not a running total. In Datadog, use the bare query (no modifier) to see the average count per export interval — this is the primary throughput view. Use .as_count() to see the total count over the rollup bucket — this is useful for spotting bursts where more events landed in a specific bucket, even when the average rate looks smooth.

Be careful with .as_rate() — it computes a per-second rate by dividing the count by the export interval. If your PeriodicReader interval is long (e.g., 60 seconds), you only get one data point per minute, and the per-second rate is a coarse estimate. With a shorter export interval (e.g., 10 seconds), .as_rate() becomes more meaningful. Match your expectations to your export frequency.

Histogram

Every .Record() call increments a bucket count and adds to an exact sum/count. Export sends the bucket counts, sum, count, min, and max. Datadog computes percentiles (p50/p95/p99) per interval from the buckets, and exact averages from sum/count. Individual values are not preserved — you see distributions, not data points.

Do not use .as_rate() or .as_count() on histograms — those are counter-only modifiers. Use the aggregation prefix (avg:, p50:, p95:, max:) to control the view.

Gauge

Only the last .Record() value survives to export. Use for point-in-time state (e.g., buffer depth, queue length), not per-event measurements.

Querying Counters in Datadog

With delta temporality, there is no cumulative reset artifact when a container restarts. However, there is a subtlety with aggregation during container rotation: when a container is replaced, the old container may emit a final low-value data point while the new container ramps up. The default avg aggregation averages these together, which halves the apparent rate.

For timeseries graphs, use max aggregation to avoid this:

1
max:my.service.requests{$env}

This takes the highest value across containers at each time bucket, which reflects the active container’s actual throughput. Datadog automatically buckets the data points based on the selected timeframe (e.g., 5-minute buckets for a 4-hour view, 20-second buckets for a 1-hour view) and averages values within each bucket. Regardless of which bucket size is in effect, the metric always shows the average count per minute — the bucket size only affects granularity (smoother vs. more detailed), not the unit.

For KPI widgets (query_value), use the last aggregator to display the most recent data point:

1
max:my.service.requests{$env}  (aggregator: last)

Querying Histograms in Datadog

Use the bare query with an aggregation prefix — no modifiers needed:

1
2
3
4
avg:my.service.latency_ms{*}     — typical latency
p95:my.service.latency_ms{*}     — worst-case latency (for alerting)
avg:my.service.batch_size{*}     — average batch size
max:my.service.batch_size{*}     — largest batch in the bucket

.as_rate() on a histogram divides the sum by time. For a latency metric, this produces “milliseconds per second” — meaningless. For a batch size metric, it produces “rows per second” — technically valid but misleading; use a counter for throughput instead.

.as_count() on a histogram gives the number of observations (how many events were recorded), not the recorded values.

Summary

Instrument Temporality Datadog query What you see
Counter Delta max:metric{*} Average count per export interval
Counter Delta max:metric{*}.as_count() Total count per rollup bucket
Counter Delta max:metric{*}.as_rate() Per-second rate (only useful with short export intervals)
Histogram Delta avg:metric{*} Average value per bucket
Histogram Delta p95:metric{*} 95th percentile per bucket
Histogram Delta .as_rate() / .as_count() Do not use
Gauge Cumulative avg:metric{*} Point-in-time value