Skip to content

Metrics ​

Distributed tracing gives you end-to-end visibility into individual operations. But traces are sampled — you cannot trace every event in a high-throughput system without significant cost. Metrics fill the gap: aggregated counts, distributions, and current values that show you the health of the system at a glance.

OTel metric types ​

OpenTelemetry defines four metric instruments, each suited to a different pattern:

InstrumentBehaviorUse case
CounterCumulative, monotonically increasingEvents produced, events consumed, errors
UpDown CounterCan increase or decreaseActive connections, queue depth, in-flight requests
HistogramDistribution of valuesProcessing latency, message size, batch duration
GaugeCurrent value at a point in timeMemory usage, CPU load, goroutine count

Counter ​

A counter sums values over time. The total only ever goes up (or resets to zero on restart). Use it for throughput metrics:

events_produced_total{event_type="order.created"} 42

Query: rate(events_produced_total[1m]) gives you events per second.

UpDown counter ​

Like a counter but supports decrement. Use it for pool sizes and queue lengths where value goes both up and down.

Histogram ​

A histogram records measurements into configurable buckets and tracks count, sum, and min/max. Use it for latency:

event_processing_duration_ms count=42 sum=1560

Query: histogram_quantile(0.99, rate(event_processing_duration_ms_bucket[1m])) gives you the 99th percentile latency.

Gauge ​

A gauge snapshots a value. Use it for instantaneous measurements that do not accumulate over time.

Meter setup ​

Metrics are created through a meter, which is analogous to a tracer but for metrics. The initMeter function configures an OTLP gRPC exporter and a periodic reader that pushes metrics to the OTel Collector:

Dependencies ​

go get go.opentelemetry.io/otel/metric \
  go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc \
  go.opentelemetry.io/otel/sdk/metric \
  go.opentelemetry.io/otel/sdk/resource

initMeter function ​

go
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/sdk/resource"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func initMeter() (*metric.MeterProvider, error) {
    ctx := context.Background()

    exporter, err := otlpmetricgrpc.New(ctx,
        otlpmetricgrpc.WithEndpoint("localhost:4317"),
        otlpmetricgrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceNameKey.String("event-processor"),
        ),
    )
    if err != nil {
        return nil, err
    }

    mp := metric.NewMeterProvider(
        metric.WithReader(metric.NewPeriodicReader(exporter)),
        metric.WithResource(res),
    )
    otel.SetMeterProvider(mp)
    return mp, nil
}

The periodic reader pushes metrics to the OTLP exporter at a configurable interval (default 60 seconds). For development, you can shorten this:

go
metric.WithReader(metric.NewPeriodicReader(exporter,
    metric.WithInterval(10*time.Second),
))

Shutdown ​

At program exit, flush remaining metric data:

go
func shutdownMeter(ctx context.Context, mp *metric.MeterProvider) {
    _ = mp.Shutdown(ctx)
}

Counters for event throughput ​

With the meter provider configured, create counters that track how many events are produced, consumed, and how many errors occur:

go
meter := otel.Meter("event-processor")

eventsProduced, _ := meter.Int64Counter("events.produced",
    metric.WithDescription("Total events produced"),
)
eventsConsumed, _ := meter.Int64Counter("events.consumed",
    metric.WithDescription("Total events consumed"),
)
errorsTotal, _ := meter.Int64Counter("events.errors",
    metric.WithDescription("Total processing errors"),
)

Recording counter values ​

In the producer, increment the produced counter after a successful write:

go
func produceEvents(ctx context.Context, writer *kafka.Writer, events []OrderEvent) {
    meter := otel.Meter("event-processor")
    eventsProduced, _ := meter.Int64Counter("events.produced")

    for _, event := range events {
        data, _ := json.Marshal(event)
        msg := kafka.Message{
            Key:   []byte(event.UserID),
            Value: data,
            Headers: []kafka.Header{
                {Key: "event_type", Value: []byte("order.created")},
            },
        }

        if err := writer.WriteMessages(ctx, msg); err != nil {
            slog.Error("produce failed", "event_id", event.ID, "error", err)
            continue
        }

        eventsProduced.Add(ctx, 1,
            metric.WithAttributes(
                attribute.String("event_type", "order.created"),
            ),
        )
    }
}

In the consumer, increment consumed and error counters:

go
func consumeEvents(ctx context.Context, r *kafka.Reader) {
    meter := otel.Meter("event-processor")
    eventsConsumed, _ := meter.Int64Counter("events.consumed")
    errorsTotal, _ := meter.Int64Counter("events.errors")

    for {
        msg, err := r.ReadMessage(ctx)
        if err != nil {
            if ctx.Err() != nil {
                return
            }
            errorsTotal.Add(ctx, 1,
                metric.WithAttributes(
                    attribute.String("error_type", "read_failed"),
                ),
            )
            continue
        }

        var event OrderEvent
        if err := json.Unmarshal(msg.Value, &event); err != nil {
            errorsTotal.Add(ctx, 1,
                metric.WithAttributes(
                    attribute.String("error_type", "decode_error"),
                    attribute.Int("partition", msg.Partition),
                    attribute.Int64("offset", msg.Offset),
                ),
            )
            continue
        }

        eventsConsumed.Add(ctx, 1,
            metric.WithAttributes(
                attribute.String("event_type", "order.created"),
                attribute.String("event.id", event.ID),
            ),
        )
    }
}

Attributes on counter increments ​

Each Add call can carry attributes that create separate time series. The event_type attribute lets you query:

# All produce events
rate(events.produced_total[1m])

# Filtered by type
rate(events.produced_total{event_type="order.created"}[1m])

# Error breakdown
rate(events.errors_total{error_type="decode_error"}[1m])

Keep attribute cardinality bounded — never put a unique value like the full event ID into a metric attribute unless you are certain the number of distinct values is small. The event ID example above is shown for illustration; in production, use it sparingly and prefer logging for per-event details.

Histogram for processing latency ​

A histogram records the distribution of processing durations. This is the most important metric for understanding whether your system is fast, slow, or degrading:

go
processingDuration, _ := meter.Float64Histogram("event.processing.duration",
    metric.WithDescription("Event processing duration"),
    metric.WithUnit("ms"),
)

Record the duration around the processing logic:

go
func handleEvent(ctx context.Context, event OrderEvent) error {
    meter := otel.Meter("event-processor")
    processingDuration, _ := meter.Float64Histogram("event.processing.duration")

    start := time.Now()
    defer func() {
        processingDuration.Record(ctx,
            float64(time.Since(start).Milliseconds()),
            metric.WithAttributes(
                attribute.String("event_type", "order.created"),
                attribute.String("event.id", event.ID),
            ),
        )
    }()

    // Simulate processing work
    time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)

    return nil
}

Prometheus queries with histograms ​

The histogram exports _bucket, _count, _sum, and _total time series. In PromQL:

# Average processing duration (last 5 minutes)
rate(event.processing.duration_sum[5m]) / rate(event.processing.duration_count[5m])

# 99th percentile
histogram_quantile(0.99, rate(event.processing.duration_bucket[5m]))

# Processing rate
rate(event.processing.duration_count[5m])

A rising p99 or p999 is the earliest signal that something is wrong — often before error counters even move.

Prometheus scrape endpoint ​

If you use a Prometheus-compatible reader instead of (or alongside) the OTLP push model, add the Prometheus exporter:

go
import "go.opentelemetry.io/otel/exporters/prometheus"

func initPrometheusReader() (*metric.MeterProvider, error) {
    exporter, err := prometheus.New()
    if err != nil {
        return nil, err
    }

    mp := metric.NewMeterProvider(
        metric.WithReader(exporter),
    )
    otel.SetMeterProvider(mp)
    return mp, nil
}

The Prometheus reader exposes a /metrics endpoint that you serve alongside your application's HTTP server. This is the pull model — Prometheus scrapes your service periodically instead of your service pushing metrics to a collector.

Updated docker-compose.yml ​

Add Prometheus to scrape the OTel Collector's metrics endpoint:

yaml
version: "3.8"
services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.6
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181

  kafka:
    image: confluentinc/cp-kafka:7.6
    ports:
      - "9092:9092"
    depends_on: [zookeeper]
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    ports:
      - "4317:4317"
      - "8888:8888"
    depends_on: [jaeger]
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml
    command: ["--config=/etc/otel-collector-config.yml"]

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"
      - "14250:14250"

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    depends_on: [otel-collector]

Prometheus config ​

Create prometheus.yml:

yaml
scrape_configs:
  - job_name: "otel-collector"
    scrape_interval: 10s
    static_configs:
      - targets: ["otel-collector:8888"]

The OTel Collector exposes its own metrics on port 8888, including the metrics it has ingested from your application. Prometheus scrapes this endpoint every 10 seconds.

Running the checkpoint ​

With the full stack running:

bash
# Start everything
docker compose up -d

# Run the instrumented event processor
go run main.go

# Open Prometheus
open http://localhost:9090

In the Prometheus UI:

  1. Go to Graph
  2. Query rate(events.produced_total[1m]) — you should see a spike when your producer runs
  3. Query rate(event.processing.duration_count[1m]) — the consumer processing rate
  4. Query histogram_quantile(0.99, rate(event.processing.duration_bucket[1m])) — the 99th percentile latency

Key dashboards ​

Build these PromQL queries into a Grafana dashboard (covered in page 09):

PanelQueryPurpose
Produce raterate(events.produced_total[1m])Incoming event throughput
Consume raterate(events.consumed_total[1m])Outgoing event throughput
Error raterate(events.errors_total[1m])Failure rate by error type
Latency p99histogram_quantile(0.99, rate(event.processing.duration_bucket[1m]))Tail latency
Latency avgrate(event.processing.duration_sum[1m]) / rate(event.processing.duration_count[1m])Average latency

Metric naming conventions ​

OTel and Prometheus share naming conventions. Follow these for consistency:

  • Use dots as separators: events.produced, event.processing.duration
  • Suffix counters with a unit when the unit is ambiguous: events.produced.total
  • Suffix histograms with the unit: event.processing.duration_ms
  • Use lowercase and underscores for attribute keys
  • Include the error type in error counter attributes, not in the metric name — events.errors{error_type="decode"} not events.decode.errors

What you learned ​

  • OpenTelemetry provides four metric instruments: Counter, UpDown Counter, Histogram, and Gauge
  • initMeter() configures an OTLP gRPC exporter with a periodic reader that pushes metrics to the OTel Collector
  • Counters track cumulative totals for produced events, consumed events, and errors
  • Histograms record processing latency distributions, enabling p99/p999 queries
  • Prometheus scrapes the OTel Collector's metrics endpoint and stores the time series for querying
  • rate(), histogram_quantile(), and division of _sum / _count are the core PromQL patterns for understanding throughput and latency
  • Metric attributes create segmented time series but must have bounded cardinality

Your service now emits three observability signals: structured logs (page 05), distributed traces (page 06), and Prometheus metrics (this page). The next page adds graceful shutdown so in-flight traces and metrics are flushed before the process exits.

Next → Graceful shutdown