OpenTelemetry
Django
Metrics

Metrics

The OTel auto-instrumentation captures default request and outbound-HTTP metrics automatically: http.server.duration, http.server.active_requests and http.client.duration. The stable names http.server.request.duration / http.client.request.duration only appear if you set OTEL_SEMCONV_STABILITY_OPT_IN=http. The Python DB instrumentations produce spans only, so there is no db.client.operation.duration metric. To track custom application metrics, use the OTel Metrics API.

Metrics are exported every 60 seconds by default, so a new instrument can take up to a minute to show up in the dashboard. Set OTEL_METRIC_EXPORT_INTERVAL=10000 (milliseconds) while you are testing.

Getting a Meter

Call metrics.get_meter(__name__) once per module. Meters are cheap and cached:

from opentelemetry import metrics
 
meter = metrics.get_meter(__name__)

Instruments created from the same meter + same name return the same instance, so it's safe to call meter.create_counter("orders.created") from multiple places.

Supported Instruments

MethodUse case
meter.create_counter(name, unit, description)Monotonic counter (only goes up)
meter.create_up_down_counter(...)Counter that can go up or down
meter.create_observable_counter(...)Counter whose value is read via callback at export time
meter.create_observable_up_down_counter(...)Up/down counter read via callback
meter.create_gauge(...)Synchronous gauge. Call .set(value) with a point-in-time value (it is .set(), not .record())
meter.create_observable_gauge(...)Gauge read via callback at export time
meter.create_histogram(name, unit, description)Distribution of values (durations, sizes)

Counter

Counters track cumulative values that only go up (total orders, requests processed):

from opentelemetry import metrics
 
meter = metrics.get_meter(__name__)
order_counter = meter.create_counter(
    "orders.created",
    unit="orders",
    description="Total orders created",
)
 
# Increment by 1
order_counter.add(1)
 
# Increment with attributes
order_counter.add(1, {"plan": "pro", "region": "eu"})

Histogram

Histograms track distributions of values (response times, payload sizes):

import time
from opentelemetry import metrics
 
meter = metrics.get_meter(__name__)
duration_histogram = meter.create_histogram(
    "order.processing_ms",
    unit="ms",
    description="Order processing time",
)
 
 
def process_order(order):
    started = time.perf_counter()
    _process(order)
    duration_ms = (time.perf_counter() - started) * 1000
    duration_histogram.record(duration_ms, {"plan": order.plan})

TracePath converts histograms into average and count metrics (see OTel metrics).

Gauge

Gauges track point-in-time values that can go up or down (queue depth, active connections):

# Synchronous: set a value when you know it
cache_size_gauge = meter.create_gauge(
    "cache.size_mb",
    unit="MB",
    description="Cache memory usage",
)
cache_size_gauge.set(42.3)
 
 
# Observable: value is read by a callback at export time
from opentelemetry.metrics import Observation
 
 
def queue_depth_callback(options):
    return [Observation(get_queue_size("default"))]
 
 
meter.create_observable_gauge(
    "queue.depth",
    callbacks=[queue_depth_callback],
    unit="items",
    description="Pending jobs in the default queue",
)

The synchronous gauge is the one people get wrong. .record() belongs to Histogram. A gauge only has .set(), and calling .record() on one raises AttributeError: '_Gauge' object has no attribute 'record', which in a Django view means a 500.

Sharing One Expensive Reading Between Instruments

OpenTelemetry Python has no batch-callback API (unlike the Go and Java SDKs). Each instrument's callbacks are invoked separately at collection time, so registering one callback against two instruments calls it twice, not once. Cache the reading yourself:

import time
 
from opentelemetry.metrics import Observation
 
_cache = {"at": 0.0, "usage": 0.0, "pressure": 0.0}
 
 
def _refresh():
    now = time.monotonic()
    if now - _cache["at"] > 5:
        _cache["usage"], _cache["pressure"] = expensive_system_call()
        _cache["at"] = now
 
 
def usage_callback(options):
    _refresh()
    return [Observation(_cache["usage"], {"source": "system"})]
 
 
def pressure_callback(options):
    _refresh()
    return [Observation(_cache["pressure"], {"source": "system"})]
 
 
meter.create_observable_counter("usage", callbacks=[usage_callback], description="count of items used")
meter.create_observable_gauge("pressure", callbacks=[pressure_callback], description="force per unit area")

One more rule that makes the naive version silently wrong: multiple Observations returned by a single callback must differ in their attribute set. Return Observation(10, {"source": "system"}) and Observation(20, {"source": "system"}) from the same callback and they are aggregated into one data point (a counter reports 30, a gauge reports 20). Give each observation its own attributes, or use one callback per instrument as above.

Use Cases

Business Metrics

from opentelemetry import metrics
 
meter = metrics.get_meter(__name__)
revenue_counter = meter.create_counter("payments.revenue", unit="usd", description="Total revenue")
signups_counter = meter.create_counter("users.signups", unit="users", description="User signups")
 
 
class PaymentService:
    def process_payment(self, order):
        self.gateway.charge(order.total)
        revenue_counter.add(order.total, {"plan": order.plan})
 
 
def register(request):
    user = create_user(request.POST)
    signups_counter.add(1)
    return JsonResponse({"id": user.pk}, status=201)

Performance Metrics

import time
import requests
from opentelemetry import metrics
 
meter = metrics.get_meter(__name__)
external_latency = meter.create_histogram(
    "external_api.latency_ms",
    unit="ms",
    description="External API call duration",
)
 
 
def fetch_external_data(endpoint: str) -> dict:
    started = time.perf_counter()
    try:
        return requests.get(endpoint, timeout=5).json()
    finally:
        duration_ms = (time.perf_counter() - started) * 1000
        external_latency.record(duration_ms, {"endpoint": endpoint})

Resource Metrics

import redis
from opentelemetry import metrics
from opentelemetry.metrics import Observation
 
meter = metrics.get_meter(__name__)
redis_client = redis.Redis()
 
 
def redis_clients_callback(options):
    info = redis_client.info("clients")
    return [Observation(int(info.get("connected_clients", 0)))]
 
 
meter.create_observable_gauge(
    "redis.connected_clients",
    callbacks=[redis_clients_callback],
    unit="connections",
    description="Active Redis connections",
)

Temporality

The OTLP exporter supports a preferred temporality (DELTA vs CUMULATIVE) for exported metrics. Set it via env:

OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=DELTA

Leave it unset to use the SDK default.

Metric Naming Conventions

Use dot-separated names for organization:

# Good
meter.create_counter("orders.created")
meter.create_histogram("db.query_ms")
meter.create_counter("cache.hits")
 
# Bad
meter.create_counter("created")     # too vague
meter.create_counter("orderCount")  # inconsistent style