OpenTelemetry metrics are built from a small set of instruments, each shaped for a different kind of measurement. Picking the right one determines what questions your data can answer later.

The instrument types

InstrumentBehaviorExample
CounterMonotonically increasing value; only goes up.Total requests served
UpDownCounterCan increase or decrease.Active connections, queue length
HistogramRecords a distribution of values into buckets.Request duration, response size
GaugeRecords the current value at a point in time; not summed.CPU temperature, room occupancy
Observable variantsAsync versions of the above, read via a callback instead of pushed inline.Memory usage sampled every collection cycle

Synchronous vs. asynchronous

Synchronous instruments (Counter, UpDownCounter, Histogram) are recorded inline, at the moment the event happens — e.g. incrementing a request counter right after handling a request. Asynchronous (Observable) instruments are instead registered with a callback function that the SDK invokes once per collection cycle to read the current value — ideal for things you can't "increment," only sample, like current memory usage or open file descriptors.

// Synchronous — recorded at the event
const requestCounter = meter.createCounter('http.server.request.count');
requestCounter.add(1, { 'http.route': '/checkout' });

// Asynchronous — sampled on demand
meter.createObservableGauge('process.memory.usage').addCallback((result) => {
  result.observe(process.memoryUsage().heapUsed);
});

Aggregation and temporality

Raw measurements are aggregated by the SDK before export — you don't ship every single increment over the wire, you ship periodic aggregates. Temporality determines whether an exported data point represents the delta since the last export (Delta temporality) or the cumulative total since the instrument was created (Cumulative temporality). Prometheus expects cumulative counters by convention; OTLP defaults to cumulative temporality for most instruments to match this, though delta is available and used by some backends (e.g. certain SaaS vendors that prefer delta ingestion).

💡
Why this matters in practice

If you ever see counters that look "reset to zero" unexpectedly in your backend, check whether the exporter's temporality matches what your backend expects — a cumulative counter read as delta (or vice versa) produces nonsensical graphs.

Views: shaping data on the way out

A View lets you change how an instrument's data is aggregated or exported without touching instrumentation code — for example, customizing histogram bucket boundaries, renaming a metric, dropping high-cardinality attributes, or filtering out an instrument entirely.

const view = new View({
  instrumentName: 'http.server.duration',
  aggregation: new ExplicitBucketHistogramAggregation([0, 5, 10, 25, 50, 100, 250, 500, 1000, 2500]),
});
const meterProvider = new MeterProvider({ views: [view] });

Views are the standard tool for controlling cardinality — dropping a high-cardinality attribute like user.id from a metric before export prevents your time-series database from being flooded with a unique series per user.

🚨
Cardinality is the #1 metrics cost driver

Every unique combination of attribute values creates a new time series. Attributes like user ID, request ID, or raw URLs (instead of route templates) can explode a metric from a few series into millions, spiking storage cost and query latency. Reserve high-cardinality data for traces and logs, not metrics.

Choosing the right instrument

  • Counting things that only go up (requests, errors, bytes sent) → Counter.
  • Tracking something that goes up and down (active connections, in-flight requests) → UpDownCounter.
  • Measuring a distribution you'll want percentiles from (latency, payload size) → Histogram.
  • A point-in-time reading you can't accumulate (temperature, current queue depth read externally) → Gauge (usually Observable).

Example

const meter = metrics.getMeter('checkout-service');

const orderCounter = meter.createCounter('orders.processed', {
  description: 'Number of orders processed',
});
const orderDuration = meter.createHistogram('orders.duration', {
  description: 'Time to process an order',
  unit: 'ms',
});

const start = Date.now();
await processOrder(order);
orderDuration.record(Date.now() - start, { 'order.type': order.type });
orderCounter.add(1, { 'order.status': 'success' });
Takeaway

Pick the instrument that matches the shape of your data (monotonic, bidirectional, distribution, or point-in-time) — the instrument type constrains what your backend can compute from it later, and it can't be changed retroactively without losing history.