Recording Counters and Histograms with OpenTelemetry

The day-to-day work of metrics instrumentation is deciding which instrument fits a measurement and calling it with the right attributes: a Counter for events you tally, a Histogram for values whose distribution matters, and an observable gauge for state you sample. This guide is part of the Python Metrics and Instrumentation guide and builds on the OpenTelemetry Metrics SDK guide, which sets up the MeterProvider this page assumes is already running.

Instrument selection A Counter accumulates additive events, a Histogram buckets recorded values into a distribution, and an ObservableGauge samples a current value through a callback on each collection. Counter add(1, attrs) monotonic total events, errors Histogram record(v, attrs) bucketed dist. latency, size ObservableGauge callback yields current sample pool, memory collected each interval
Match the instrument to the measurement: tally, distribution, or sampled state.

Prerequisites

You need a configured MeterProvider; see the OpenTelemetry Metrics SDK guide for the full bootstrap. Install the SDK with a pinned range.

pip install "opentelemetry-sdk>=1.30.0,<2.0.0"
export OTEL_METRIC_EXPORT_INTERVAL="15000"   # how often instruments are collected

Implementation

Step 1 — Get a Meter. The Meter mints every instrument and stamps the instrumentation scope on exported data.

from opentelemetry import metrics

meter = metrics.get_meter(__name__, "1.0.0")

Step 2 — Create and increment a Counter. A Counter is monotonic; pass only non-negative deltas. Attributes split the total into series, so keep them bounded.

request_counter = meter.create_counter(
    "http.server.request.count",
    unit="{request}",
    description="Total HTTP requests handled",
)

# On each handled request:
request_counter.add(1, {"http.route": "/checkout", "http.status_code": 200})

For values that rise and fall, use an UpDownCounter, which accepts negatives:

inflight = meter.create_up_down_counter("http.server.active_requests", unit="{request}")
inflight.add(1, {"http.route": "/checkout"})   # request started
inflight.add(-1, {"http.route": "/checkout"})  # request finished

Step 3 — Create and record a Histogram. A Histogram captures the distribution of a value, most often latency. Record the observed value once per event.

latency = meter.create_histogram(
    "http.server.duration",
    unit="ms",
    description="HTTP server request duration",
)

# After timing a request:
latency.record(42.5, {"http.route": "/checkout", "http.status_code": 200})

Step 4 — Shape histogram buckets with a View. Default buckets rarely match a real latency profile. Attach a View at provider construction with an explicit bucket aggregation. Because the View is bound when the provider is built, set it before the first record.

from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation

latency_view = View(
    instrument_name="http.server.duration",
    aggregation=ExplicitBucketHistogramAggregation(
        boundaries=[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]
    ),
)
# Pass views=[latency_view] when constructing the MeterProvider.

Reach for an ObservableCounter instead of a synchronous Counter only when the total already lives somewhere you can read, such as a kernel byte counter or a library's internal tally; in that case sampling it on each interval is correct and you avoid double counting. For discrete events your own code generates, the synchronous Counter is always the right tool because it cannot miss an event that occurs between two collections.

Step 5 — Add an observable gauge for sampled state. For a value you read rather than increment, register a callback that yields one Observation per series. The reader invokes it on every collection. A gauge holds a last-value aggregation, so only the most recent sample per attribute set survives into the export; this is exactly what you want for instantaneous readings like pool depth or resident memory, where an average across the interval would hide spikes.

from opentelemetry.metrics import CallbackOptions, Observation

def read_pool(options: CallbackOptions):
    # Keep this fast and side-effect free; it runs on the reader thread.
    in_use = current_pool_usage()  # your own cheap accessor
    yield Observation(in_use, {"pool": "primary"})

meter.create_observable_gauge(
    "db.pool.in_use",
    callbacks=[read_pool],
    unit="{connection}",
    description="Connections currently checked out",
)

Choosing attributes

Attributes are what turn one instrument into many time series, and they are the single largest driver of metrics cost and query performance. Every distinct combination of attribute values on an instrument creates a separate series the backend must store and index. Attach attributes that you will group or filter by in dashboards and alerts, such as route, method, and status class, and never attach values that are effectively unique per event, such as user IDs, full URLs with query strings, or trace IDs. A useful test is to ask whether a value belongs on a chart axis or in a log line; chart-axis values are attributes, log-line values are not. When you cannot trust upstream code to stay disciplined, enforce the boundary centrally with a View that allow-lists attribute keys, so even if an instrument is called with a noisy attribute the SDK drops it before aggregation. Bucketing status codes into classes (2xx, 4xx, 5xx) rather than recording every numeric code is a common way to keep a useful dimension while bounding its cardinality.

Configuration options

Instrument Method Accepts Use for
create_counter add(amount, attrs) non-negative request totals, error counts
create_up_down_counter add(±amount, attrs) signed active requests, queue depth
create_histogram record(value, attrs) any value latency, payload size
create_observable_gauge callback yield Observation sampled value pool usage, memory, temperature
create_observable_counter callback yield Observation cumulative total bytes sent read from a system counter
View(aggregation=ExplicitBucketHistogramAggregation(...)) construction bucket list tune histogram boundaries
View(attribute_keys={...}) construction allowed keys bound series cardinality

Verification

Record on each instrument once, force a flush, and inspect the exported payload. The histogram reflects the custom boundaries and the counter carries the attribute series.

Expected Output:

{
  "metrics": [
    {
      "name": "http.server.request.count",
      "sum": {
        "isMonotonic": true,
        "dataPoints": [{
          "asInt": "1",
          "attributes": [
            {"key": "http.route", "value": {"stringValue": "/checkout"}},
            {"key": "http.status_code", "value": {"intValue": "200"}}
          ]
        }]
      }
    },
    {
      "name": "http.server.duration",
      "histogram": {
        "dataPoints": [{
          "count": "1",
          "sum": 42.5,
          "min": 42.5,
          "max": 42.5,
          "bucketCounts": ["0", "0", "0", "0", "1", "0", "0", "0", "0", "0", "0"],
          "explicitBounds": [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]
        }]
      }
    },
    {
      "name": "db.pool.in_use",
      "gauge": {
        "dataPoints": [{"asInt": "7", "attributes": [{"key": "pool", "value": {"stringValue": "primary"}}]}]
      }
    }
  ]
}

Common mistakes

Counter.add with a negative value is dropped. A monotonic Counter rejects negatives and logs a warning, leaving the series unchanged. Root cause: using a Counter for a value that decreases. Remediation: switch to an UpDownCounter, which accepts signed deltas.

Histogram falls back to default buckets. The View boundaries never applied because its instrument_name did not match the histogram, or it was added after the first record. Remediation: match the name exactly and register the View when constructing the MeterProvider, before any measurement.

Observable callback does blocking work. A callback that queries a database or holds a lock stalls the reader thread and delays collection for every instrument. Remediation: read a cached or in-memory value inside the callback and refresh it elsewhere.

Frequently Asked Questions

What is the difference between a Counter and an UpDownCounter?

A Counter is monotonic and only accepts non-negative add values, suited to totals like requests served. An UpDownCounter accepts negative values too, so it tracks quantities that rise and fall, such as active connections or queue depth.

How do I choose histogram bucket boundaries?

Pick boundaries that bracket the latencies or sizes you care about and that align with the quantiles your alerts use. For HTTP latency in milliseconds a geometric spread such as 5, 10, 25, 50, 100, 250, 500, 1000 captures both fast and slow tails without excessive buckets.

Why is a synchronous Counter better than an observable one for request counts?

A synchronous Counter increments exactly when the event happens, so no events are lost between collections. An observable counter only samples its callback on the export interval, which is right for cumulative values you can read but wrong for discrete events you must not miss.