DevOps

Observability vs Monitoring: The Complete DevOps Guide for 2026

2026-08-02·13 min read
#observability#monitoring#devops#opentelemetry#sre

Observability vs Monitoring: The Complete DevOps Guide for 2026

If you've been in the DevOps or SRE world for any meaningful stretch of time, you've heard the debate: "Monitoring is dead, observability is the future." But that framing is reductive and, frankly, unhelpful. Monitoring isn't dead — it's a subset of observability. And understanding the distinction is the difference between catching incidents before users notice them and drowning in a sea of alerts at 3 AM.

This guide breaks down what observability actually means in 2026, how it differs from traditional monitoring, and gives you concrete, production-ready implementations using OpenTelemetry, Grafana, Prometheus, and friends. No fluff. Just signal.


Table of Contents

  1. Monitoring vs Observability — The Real Difference
  2. The Three Pillars (Plus One)
  3. Why Monitoring Alone Fails in Microservices
  4. OpenTelemetry Deep Dive
  5. Building Your Observability Stack
  6. SRE Practices: SLIs, SLOs, SLAs, and Error Budgets
  7. Distributed Tracing in Practice
  8. Structured Logging Best Practices
  9. Cost Optimization: Taming Cardinality Explosion
  10. Practical Implementation: Instrumenting a Real Service

Monitoring vs Observability — The Real Difference

Let's settle this once and for all.

Monitoring is about watching specific, predefined signals. You decide in advance what matters — CPU usage, memory, HTTP 500 rates, disk space — and you set up dashboards and alerts for those things. Monitoring answers questions you already knew to ask.

Observability is a system property. A system is observable if you can understand its internal state by examining its external outputs — without deploying new code to answer a new question. Observability lets you answer questions you didn't know you needed to ask.

Here's the canonical analogy:

| Aspect | Monitoring | Observability | |--------|-----------|---------------| | Mindset | "I know what could go wrong" | "I don't know what I don't know" | | Approach | Predefined checks & thresholds | Exploratory investigation | | Data | Aggregated metrics, fixed dashboards | High-cardinality, high-dimensional data | | Failure Mode | Unknown unknowns blindspot | Adapts to novel failure modes | | Question Type | "Is X within threshold?" | "Why is X behaving this way?" | | Tooling | Nagios, Datadog (classic), Zabbix | OpenTelemetry, Honeycomb, Grafana stack |

The "Unknown Unknowns" Problem

Monitoring assumes you can predict failure modes. In a monolith with 5 dependencies, that's feasible. In a microservices architecture with 200 services, each with its own database, cache, message queue, and third-party API calls? Not a chance.

Consider this real-world scenario: A payment service starts timing out intermittently. Your monitoring shows the service is up and responding to health checks. CPU is normal. Memory is fine. But users can't check out.

With monitoring alone, you're stuck. You see the symptom (timeouts) but can't trace the cause. You start guessing. You SSH into boxes. You tail logs. You lose time.

With observability, you trace a single failed request and see that it hit Service A → Service B → Database C, where the query took 4.7 seconds instead of the usual 50ms. The database's connection pool was exhausted because a deploy 20 minutes ago introduced a connection leak. Total time to root cause: 90 seconds.

That's the difference.


The Three Pillars (Plus One)

Observability is traditionally built on three pillars. In 2026, we add a fourth that's become mainstream.

1. Metrics

Metrics are numeric measurements aggregated over time. They're cheap to store, fast to query, and ideal for dashboards and alerting.

# Prometheus metric types
- Counter:     monotonically increasing (e.g., http_requests_total)
- Gauge:       goes up and down (e.g., active_connections)
- Histogram:   distribution of values (e.g., request latency buckets)
- Summary:     pre-computed quantiles (e.g., 0.99 quantile latency)

Example — exposing metrics in Prometheus format:

from prometheus_client import Counter, Histogram, generate_latest

http_requests = Counter(
    'http_requests_total',
    'Total HTTP requests',
    ['method', 'endpoint', 'status']
)

request_latency = Histogram(
    'http_request_duration_seconds',
    'HTTP request latency',
    ['method', 'endpoint'],
    buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
)

@app.route('/api/orders')
def get_orders():
    with request_latency.labels('GET', '/api/orders').time():
        result = fetch_orders()
        http_requests.labels('GET', '/api/orders', '200').inc()
        return result

2. Logs

Logs are discrete, timestamped events. They're the most detailed signal but also the most expensive to store and search. In 2026, structured logging is non-negotiable — plain text logs are a liability.

{
  "timestamp": "2026-08-02T14:23:01.234Z",
  "level": "ERROR",
  "service": "payment-service",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "message": "Payment gateway timeout",
  "user_id": "usr_8f3a2b",
  "amount": 49.99,
  "currency": "USD",
  "gateway": "stripe",
  "duration_ms": 30234
}

3. Traces

Traces follow a single request across service boundaries. They're the backbone of debugging in distributed systems. A trace consists of spans — each span representing a unit of work (an HTTP call, a database query, a cache lookup).

[Trace: 4bf92f3577b34da6a3ce929d0e0e4736]
├── Span: GET /api/checkout (2.3s) ← gateway-service
│   ├── Span: POST /api/orders (1.8s) ← order-service
│   │   ├── Span: INSERT orders (45ms) ← postgres
│   │   └── Span: POST /api/payment (1.7s) ← payment-service
│   │       └── Span: stripe.charge (1.6s) ← external API
│   └── Span: GET /api/inventory (120ms) ← inventory-service
│       └── Span: redis.GET (2ms) ← redis

4. Profiles (The Fourth Pillar)

Continuous profiling has gone mainstream. Tools like Pyroscope, Parca, and Grafana Phlare capture CPU, memory, and goroutine/thread profiles from production services with minimal overhead. This lets you answer questions like "which function is burning 40% of our CPU?" without attaching a debugger.

# Pyroscope agent configuration
profiling:
  enabled: true
  sampling_rate: 100Hz
  profiles:
    - cpu
    - memory_alloc
    - memory_inuse
  labels:
    service: payment-service
    env: production

Why Monitoring Alone Fails in Microservices

Let's be concrete. Here's what goes wrong when you rely on traditional monitoring in a distributed system.

Problem 1: Cascading Failures Are Invisible

A single failed database query in Service D can cascade through Services C, B, and A. Your monitoring shows alerts firing across all four services simultaneously. Which one is the root cause? You can't tell from metrics alone — they only show that something is wrong, not why.

Problem 2: Alert Fatigue

When you monitor individual service health (CPU, memory, latency p95), every deploy triggers a cascade of threshold-based alerts. Engineers develop alert blindness. Real incidents get buried in noise.

Problem 3: The N+1 Query Problem Across Services

Service A calls Service B 47 times per request because someone forgot to implement batching. Each individual call is fast (8ms), so latency alerts don't fire. But the aggregate request takes 376ms — slow enough to cause user-visible degradation but not slow enough to trip any single threshold.

Problem 4: Black-Box Failure Modes

A misconfigured service mesh policy causes 0.3% of requests to Service F to be rate-limited. This is below your error rate threshold of 1%. But those 0.3% of requests all belong to enterprise-tier users on a specific API endpoint. Monitoring sees green dashboards; your biggest customers are experiencing failures.

The Solution: High-Cardinality, Cross-Sectional Data

Observability tooling lets you slice data by any dimension — user_id, tenant_id, endpoint, region, version. When something goes wrong, you group by different dimensions until the pattern emerges. This is fundamentally impossible with pre-aggregated monitoring metrics.


OpenTelemetry Deep Dive

OpenTelemetry (OTel) is the CNCF project that has consolidated the observability instrumentation landscape. In 2026, it's the de facto standard — Datadog, New Relic, Honeycomb, Grafana Cloud, and AWS X-Ray all support it natively.

Architecture Overview

┌─────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ Application │────▶│  OTel Collector   │────▶│ Backend (Tempo, │
│ (Instrumented│    │  (Processing,     │     │ Loki, Prom,     │
│  with SDK)  │     │   Batching,       │     │ Jaeger, etc.)   │
└─────────────┘     │   Exporting)      │     └─────────────────┘
                     └──────────────────┘

Instrumentation

OpenTelemetry provides SDKs for 11+ languages. Here's the general pattern:

Auto-instrumentation (Node.js):

# Install dependencies
npm install @opentelemetry/sdk-node \
            @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-http \
            @opentelemetry/exporter-metrics-otlp-http

# Run your app with OTel auto-instrumentation
node --require ./tracing.js app.js

tracing.js — Node.js OpenTelemetry setup:

const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'checkout-service',
    [ATTR_SERVICE_VERSION]: '2.4.1',
    deployment_environment: 'production',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4318/v1/traces',
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: 'http://otel-collector:4318/v1/metrics',
    }),
    exportIntervalMillis: 10000,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Python auto-instrumentation:

pip install opentelemetry-distro opentelemetry-exporter-otlp

# Auto-instrument common libraries (Flask, requests, psycopg2, etc.)
opentelemetry-bootstrap -a install

# Run with the OTel agent
opentelemetry-instrument \
  --service_name payment-service \
  --exporter_otlp_endpoint http://otel-collector:4317 \
  --exporter_otlp_protocol grpc \
  python app.py

The OTel Collector

The collector is the heart of a production OTel deployment. It decouples your applications from backends and provides processing pipelines.

otel-collector-config.yaml:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  # Batch spans for efficient export
  batch:
    timeout: 5s
    send_batch_size: 1024

  # Add environment attributes
  resource:
    attributes:
      - key: deployment.environment
        value: production
        action: upsert

  # Tail-based sampling — keep 100% of errors, sample the rest
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: slow_requests
        type: latency
        latency:
          threshold_ms: 1000
      - name: baseline_sample
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

exporters:
  # Traces → Tempo
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

  # Metrics → Prometheus (via OTLP)
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write

  # Logs → Loki
  loki:
    endpoint: http://loki:3100/loki/api/v1/push

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [resource, tail_sampling, batch]
      exporters: [otlp/tempo]

    metrics:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [prometheusremotewrite]

    logs:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [loki]

Run the collector with Docker:

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.110.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol/config.yaml
    ports:
      - "4317:4317"  # OTLP gRPC
      - "4318:4318"  # OTLP HTTP
      - "8888:8888"  # Metrics
    depends_on:
      - tempo
      - prometheus
      - loki

Building Your Observability Stack

The Grafana stack (LGTM — Loki, Grafana, Tempo, Mimir/Prometheus) is the most popular open-source observability platform in 2026. Here's a production-grade deployment.

Architecture

                    ┌──────────┐
                    │ Grafana  │ ← Dashboards & Visualization
                    └────┬─────┘
           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
    ┌────────────┐ ┌──────────┐ ┌───────────┐
    │ Prometheus │ │  Tempo   │ │   Loki    │
    │ (Metrics)  │ │ (Traces) │ │  (Logs)   │
    └──────┬─────┘ └────┬─────┘ └─────┬─────┘
           │             │             │
           └─────────────┼─────────────┘
                         ▼
                ┌────────────────┐
                │ OTel Collector │ ← Processing & Sampling
                └────────┬───────┘
                         │
         ┌───────────────┼───────────────┐
         ▼               ▼               ▼
   ┌───────────┐  ┌───────────┐  ┌───────────┐
   │ Service A  │  │ Service B  │  │ Service C  │
   │ (Node.js)  │  │ (Python)   │  │  (Go)      │
   └───────────┘  └───────────┘  └───────────┘

Docker Compose Deployment

# docker-compose.observability.yml
version: "3.8"

services:
  # ── Metrics ────────────────────────────────
  prometheus:
    image: prom/prometheus:v2.55.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    command:
      - '--storage.tsdb.retention.time=30d'
      - '--storage.tsdb.retention.size=50GB'
      - '--enable-feature=exemplar-storage'
      - '--enable-feature=otlp-write-receiver'
    ports:
      - "9090:9090"

  # ── Logs ───────────────────────────────────
  loki:
    image: grafana/loki:3.2.0
    volumes:
      - ./loki-config.yaml:/etc/loki/local-config.yaml
      - loki-data:/loki
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - "3100:3100"

  # ── Traces ─────────────────────────────────
  tempo:
    image: grafana/tempo:2.6.0
    volumes:
      - ./tempo-config.yaml:/etc/tempo/tempo.yaml
      - tempo-data:/var/tempo
    command: ["-config.file=/etc/tempo/tempo.yaml"]
    ports:
      - "4317"       # OTLP gRPC (internal)
      - "3200:3200"  # Tempo API

  # ── Visualization ──────────────────────────
  grafana:
    image: grafana/grafana:11.3.0
    volumes:
      - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
      - ./grafana-dashboards:/etc/grafana/provisioning/dashboards
      - grafana-data:/var/lib/grafana
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "false"
      GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_PASSWORD:-admin}"
      GF_INSTALL_PLUGINS: "grafana-pyroscope-app"
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
      - loki
      - tempo

  # ── OTel Collector ─────────────────────────
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.110.0
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol/config.yaml
    command: ["--config=/etc/otelcol/config.yaml"]
    ports:
      - "4317:4317"
      - "4318:4318"
    depends_on:
      - tempo
      - prometheus
      - loki

volumes:
  prometheus-data:
  loki-data:
  tempo-data:
  grafana-data:

Grafana Datasource Provisioning

# grafana-datasources.yaml
apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    jsonData:
      exemplarTraceIdDestinations:
        - name: trace_id
          datasourceUid: tempo

  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      tracesToLogs:
        datasourceUid: loki
        tags: ['service.name', 'trace_id']
      serviceMap:
        datasourceUid: prometheus

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    jsonData:
      derivedFields:
        - name: trace_id
          matcherRegex: '"trace_id":"(\w+)"'
          url: '$${__value.raw}'
          datasourceUid: tempo

This configuration enables correlation across signals: click a metric exemplar → jump to the trace → jump to the logs for that trace. This is the observability superpower.


SRE Practices: SLIs, SLOs, SLAs, and Error Budgets

Observability without SRE practices is just expensive dashboards. Here's how to make it actionable.

Definitions

  • SLI (Service Level Indicator): A quantitative measure of service health. Example: "ratio of successful HTTP requests to total requests."
  • SLO (Service Level Objective): A target for your SLI. Example: "99.9% of requests succeed over a 28-day window."
  • SLA (Service Level Agreement): A contractual consequence of missing an SLO. Example: "If we miss 99.9% for two consecutive months, we issue service credits."
  • Error Budget: The allowed failure amount before your SLO is breached. For 99.9% over 30 days: 0.1% × 43,200 minutes = 43.2 minutes of allowed downtime.

Implementing SLOs with Prometheus

# slo-rules.yaml — Prometheus recording & alerting rules
groups:
  - name: slo-checkout-service
    interval: 30s
    rules:
      # SLI: Success rate over 5m window
      - record: slo:checkout_service:success_rate_5m
        expr: |
          sum(rate(http_requests_total{
            service="checkout-service",
            status!~"5.."
          }[5m]))
          /
          sum(rate(http_requests_total{
            service="checkout-service"
          }[5m]))

      # SLO: 99.9% success rate over 28 days
      - record: slo:checkout_service:success_rate_28d
        expr: |
          sum(rate(http_requests_total{
            service="checkout-service",
            status!~"5.."
          }[28d]))
          /
          sum(rate(http_requests_total{
            service="checkout-service"
          }[28d]))

      # Error budget burn rate (fast burn — 2h window)
      - alert: SLOHighErrorRate
        expr: |
          (1 - slo:checkout_service:success_rate_5m) > (1 - 0.999) * 14.4
        for: 2m
        labels:
          severity: critical
          service: checkout-service
        annotations:
          summary: "Checkout service burning error budget 14.4x faster than allowed"
          description: "5m error rate exceeds 14.4x the SLO threshold. Immediate action required."

      # Error budget burn rate (slow burn — 6h window)
      - alert: SLOSlowErrorBudgetBurn
        expr: |
          (1 - slo:checkout_service:success_rate_28d) > (1 - 0.999) * 3
        for: 1h
        labels:
          severity: warning
          service: checkout-service
        annotations:
          summary: "Checkout service burning error budget 3x over 28d window"

The Multi-Window Multi-Burn-Rate Strategy

Google's SRE book recommends alerting based on burn rate — how fast you're consuming your error budget — using two windows simultaneously:

| Window | Burn Rate Threshold | Purpose | |--------|-------------------|---------| | 1 hour | 14.4x | Fast detection of acute incidents | | 5 minutes | 14.4x | Reduces false positives (both must fire) | | 6 hours | 3x | Detection of sustained degradation | | 1 hour | 3x | Reduces false positives |

This approach reduces alert fatigue dramatically compared to static threshold alerting.


Distributed Tracing in Practice

Tracing is where observability delivers its highest ROI. Here's how to use it effectively.

Trace Context Propagation

OpenTelemetry uses W3C Trace Context headers (traceparent and tracestate) for propagation:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                  │                │
             │  │                                  │                └── trace-flags (01 = sampled)
             │  │                                  └── parent-span-id
             │  └── trace-id (32 hex chars)
             └── version

Adding Custom Spans

from opentelemetry import trace

tracer = trace.get_tracer(__name__)

@app.route('/api/checkout')
def checkout():
    with tracer.start_as_current_span('checkout_processing') as span:
        span.set_attribute('checkout.items_count', len(request.json['items']))
        span.set_attribute('checkout.total', calculate_total(request.json['items']))

        # Validate cart
        with tracer.start_as_current_span('validate_cart'):
            cart = validate_cart(request.json['items'])
            span.set_attribute('cart.valid', True)

        # Process payment
        with tracer.start_as_current_span('process_payment') as payment_span:
            payment_span.set_attribute('payment.gateway', 'stripe')
            payment_span.set_attribute('payment.amount', cart.total)
            result = process_payment(cart)

        # Record an event (not a span)
        span.add_event('checkout_completed', {
            'order_id': result.order_id,
            'processing_time_ms': 234
        })

        return result

Span Attributes vs. Events vs. Logs

| Mechanism | When to Use | Example | |-----------|------------|---------| | Span Attributes | Static or computed values for a span | payment.amount = 49.99 | | Span Events | Timestamped annotations within a span | checkout_completed with metadata | | Span Links | Connect spans across traces | Fan-out/fan-in patterns | | Logs (via OTel) | Detailed debug output | Stack traces, variable dumps |

Trace-Based Testing

Tools like Tracetest let you write assertions against traces — effectively integration testing your distributed system's behavior:

# tracetest-test.yaml
spec:
  trigger:
    type: http
    httpRequest:
      url: http://checkout-service/api/checkout
      method: POST
      body: '{"items": [{"id": "sku_123", "qty": 2}]}'
  specs:
    - name: "Payment processed within 2s"
      selector: span[tracetest.span.type="general" name="process_payment"]
      assertions:
        - attr:tracetest.span.duration < 2s

    - name: "Database query under 100ms"
      selector: span[tracetest.span.type="database"]
      assertions:
        - attr:tracetest.span.duration < 100ms

    - name: "Trace contains no errors"
      selector: span
      assertions:
        - attr:otel.status_code != "ERROR"

Structured Logging Best Practices

Good logging is the foundation of observability. Here are the rules in 2026.

Rule 1: Always Use Structured Format

❌ Bad:

logger.info(f"User {user_id} purchased {item_count} items for ${total}")

✅ Good:

import structlog

logger = structlog.get_logger()

logger.info(
    "purchase_completed",
    user_id=user_id,
    item_count=item_count,
    total=total,
    currency="USD",
    payment_method="credit_card",
)

Rule 2: Inject Trace Context

Your logs must include trace_id and span_id so they can be correlated with traces:

// Node.js with Winston + OpenTelemetry
const { trace, context } = require('@opentelemetry/api');
const winston = require('winston');

const traceInjectFormat = winston.format((info) => {
  const span = trace.getSpan(context.active());
  if (span) {
    const spanContext = span.spanContext();
    info.trace_id = spanContext.traceId;
    info.span_id = spanContext.spanId;
  }
  return info;
});

const logger = winston.createLogger({
  format: winston.format.combine(
    traceInjectFormat(),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()],
});

Rule 3: Log Levels Mean Something

| Level | When to Use | Action Expected | |-------|------------|----------------| | ERROR | Something is broken; user impact | Page someone | | WARN | Something unexpected but handled | Investigate during business hours | | INFO | Significant business events | Dashboard and analyze | | DEBUG | Diagnostic detail | Off in production unless debugging | | TRACE | Very verbose flow tracking | Never in production |

Rule 4: Never Log Secrets

# ❌ Never do this
logger.info("Authenticating user", password=user_password, api_key=STRIPE_KEY)

# ✅ Redact sensitive fields
import structlog

def redact_sensitive(logger, method_name, event_dict):
    sensitive_keys = {'password', 'api_key', 'token', 'ssn', 'credit_card'}
    for key in list(event_dict.keys()):
        if key.lower() in sensitive_keys:
            event_dict[key] = '[REDACTED]'
    return event_dict

structlog.configure(processors=[redact_sensitive, structlog.processors.JSONRenderer()])

Rule 5: Use Consistent Field Names

Adopt a naming convention and stick to it. This makes log queries dramatically more effective:

// Consistent field names across all services
{
  "timestamp": "2026-08-02T14:23:01.234Z",
  "level": "INFO",
  "service.name": "checkout-service",
  "service.version": "2.4.1",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "event": "order_created",
  "order.id": "ord_7f3a2b",
  "user.id": "usr_8f3a2b",
  "order.total": 49.99,
  "order.currency": "USD"
}

Cost Optimization: Taming Cardinality Explosion

Observability data is expensive. The #1 cost driver is cardinality — the number of unique label combinations your metrics and indexes produce.

Understanding Cardinality

Every unique combination of label values creates a new time series in Prometheus:

# Low cardinality (good) — ~10 series
http_requests_total.labels(method='GET', status='200')        # 4 methods × ~5 statuses

# High cardinality (expensive) — potentially millions of series
http_requests_total.labels(user_id=user_id, request_id=req_id) # every request = new series!

The math: If you have 1M users and track http_requests_total with a user_id label, that's potentially 1M × N_endpoints × N_statuses time series. At typical cloud pricing (~$0.50 per 1000 series/month for managed Prometheus), that's thousands of dollars wasted.

Strategies to Control Costs

1. Never put high-cardinality values in metric labels.

# ❌ This creates unbounded cardinality
http_requests.labels(user_id=user_id).inc()

# ✅ Use traces or logs for high-cardinality data
# Metrics should have < 100 unique label combinations
http_requests.labels(endpoint='/api/checkout', status='200').inc()

2. Use exemplars instead of labels for correlation.

# Prometheus exemplars let you link metrics to traces
# without adding high-cardinality labels
from prometheus_client import Counter

http_requests = Counter(
    'http_requests_total',
    'Total HTTP requests',
    ['method', 'endpoint', 'status']
)

# Exemplar carries trace_id without affecting cardinality
http_requests.labels('GET', '/api/checkout', '200').inc(
    exemplar={'trace_id': trace_id, 'user_id': user_id}
)

3. Implement tail-based sampling at the collector.

Not all traces are equally valuable. Sample aggressively for healthy requests, keep 100% of errors and slow requests:

# Already shown above — tail_sampling processor
# Typical savings: 70-90% reduction in trace volume
# while retaining 100% of "interesting" traces

4. Use log retention tiers.

# Loki retention configuration
limits_config:
  retention_period: 30d

compactor:
  retention_enabled: true
  retention_delete_delay: 2h
  delete_request_store: filesystem

# Structured log tiers via rules
ruler:
  rules:
    - name: retention
      rules:
        - record: logs:retain:7d
          expr: '{severity=~"DEBUG|TRACE"}'
        - record: logs:retain:30d
          expr: '{severity=~"ERROR|WARN|INFO"}'

5. Pre-aggregate metrics where possible.

Use recording rules to pre-compute common queries:

# Pre-aggregate into low-cardinality series
groups:
  - name: preaggregation
    rules:
      - record: job:http_request_rate:5m
        expr: sum by (job, status) (rate(http_requests_total[5m]))

      - record: job:http_error_rate:5m
        expr: |
          sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum by (job) (rate(http_requests_total[5m]))

Cost Monitoring Dashboard

Track your own observability costs:

# Active time series count
prometheus_tsdb_head_series

# Ingestion rate
rate(prometheus_tsdb_head_samples_appended_total[5m])

# Estimated monthly cost (adjust rate for your provider)
prometheus_tsdb_head_series * 0.0005

Practical Implementation: Instrumenting a Real Service

Let's put it all together. Here's a complete instrumented Node.js Express service.

Project Structure

checkout-service/
├── package.json
├── tracing.js          # OpenTelemetry setup
├── app.js              # Express app
├── logger.js           # Structured logger
└── Dockerfile

package.json

{
  "name": "checkout-service",
  "version": "2.4.1",
  "type": "commonjs",
  "scripts": {
    "start": "node --require ./tracing.js app.js",
    "dev": "nodemon --require ./tracing.js app.js"
  },
  "dependencies": {
    "express": "^4.21.0",
    "@opentelemetry/api": "^1.9.0",
    "@opentelemetry/sdk-node": "^0.110.0",
    "@opentelemetry/auto-instrumentations-node": "^0.55.0",
    "@opentelemetry/exporter-trace-otlp-http": "^0.110.0",
    "@opentelemetry/exporter-metrics-otlp-http": "^0.110.0",
    "@opentelemetry/semantic-conventions": "^1.27.0",
    "prom-client": "^15.1.3",
    "winston": "^3.15.0",
    "pg": "^8.13.0"
  }
}

tracing.js — OpenTelemetry Initialization

const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { PeriodicExportingMetricReader, ConsoleMetricExporter } = require('@opentelemetry/sdk-metrics');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
  ATTR_DEPLOYMENT_ENVIRONMENT,
} = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'checkout-service',
    [ATTR_SERVICE_VERSION]: '2.4.1',
    [ATTR_DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || 'development',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces' ||
      'http://localhost:4318/v1/traces',
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/metrics' ||
        'http://localhost:4318/v1/metrics',
    }),
    exportIntervalMillis: 10000,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

sdk.start();

// Graceful shutdown
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('OpenTelemetry SDK shut down'))
    .catch((err) => console.error('Error shutting down OpenTelemetry', err))
    .finally(() => process.exit(0));
});

logger.js — Structured Logger with Trace Context

const { trace, context } = require('@opentelemetry/api');
const winston = require('winston');

const traceContext = winston.format((info) => {
  const span = trace.getSpan(context.active());
  if (span) {
    const ctx = span.spanContext();
    info.trace_id = ctx.traceId;
    info.span_id = ctx.spanId;
    info.trace_flags = ctx.traceFlags;
  }
  return info;
});

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp({ format: 'iso8601' }),
    traceContext(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: {
    service: 'checkout-service',
    version: '2.4.1',
    env: process.env.NODE_ENV || 'development',
  },
  transports: [
    new winston.transports.Console({
      format: winston.format.json(),
    }),
  ],
});

module.exports = logger;

app.js — Express Application with Full Instrumentation

const express = require('express');
const { trace } = require('@opentelemetry/api');
const promClient = require('prom-client');
const logger = require('./logger');
const { Pool } = require('pg');

const app = express();
app.use(express.json());

// ── Prometheus Metrics ──────────────────────────
const register = new promClient.Registry();
promClient.collectDefaultMetrics({ register });

const httpDuration = new promClient.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
  registers: [register],
});

const checkoutTotal = new promClient.Counter({
  name: 'checkout_completed_total',
  help: 'Total completed checkouts',
  labelNames: ['currency', 'payment_method'],
  registers: [register],
});

const activeCheckouts = new promClient.Gauge({
  name: 'checkout_active_count',
  help: 'Currently active checkouts',
  registers: [register],
});

// ── Metrics Middleware ──────────────────────────
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = (Date.now() - start) / 1000;
    httpDuration
      .labels(req.method, req.route?.path || req.path, res.statusCode)
      .observe(duration);
  });
  next();
});

// ── Database ────────────────────────────────────
const db = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
});

// ── Health & Metrics Endpoints ──────────────────
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', version: '2.4.1' });
});

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

// ── Checkout Endpoint ───────────────────────────
app.post('/api/checkout', async (req, res) => {
  const tracer = trace.getTracer('checkout-service');
  const activeSpan = trace.getActiveSpan();

  activeSpan?.setAttribute('checkout.items_count', req.body.items?.length || 0);

  activeCheckouts.inc();
  logger.info('checkout_started', {
    items: req.body.items,
    user_id: req.body.user_id,
  });

  try {
    // Validate cart
    const cart = await tracer.startActiveSpan('validate_cart', async (span) => {
      try {
        const result = validateCart(req.body.items);
        span.setAttribute('cart.item_count', result.items.length);
        span.setAttribute('cart.total', result.total);
        span.setStatus({ code: 1 }); // OK
        return result;
      } catch (err) {
        span.recordException(err);
        span.setStatus({ code: 2, message: err.message }); // ERROR
        throw err;
      } finally {
        span.end();
      }
    });

    // Process payment
    const payment = await tracer.startActiveSpan('process_payment', async (span) => {
      span.setAttributes({
        'payment.gateway': 'stripe',
        'payment.amount': cart.total,
        'payment.currency': cart.currency,
      });

      try {
        const result = await chargeStripe(cart);
        span.setAttribute('payment.transaction_id', result.id);
        span.setStatus({ code: 1 });
        return result;
      } catch (err) {
        span.recordException(err);
        span.setStatus({ code: 2, message: err.message });
        logger.error('payment_failed', {
          error: err.message,
          user_id: req.body.user_id,
          amount: cart.total,
        });
        throw err;
      } finally {
        span.end();
      }
    });

    // Save order
    const order = await tracer.startActiveSpan('save_order', async (span) => {
      span.setAttribute('db.operation', 'INSERT');
      try {
        const result = await db.query(
          'INSERT INTO orders (user_id, total, currency, payment_id, status) VALUES ($1, $2, $3, $4, $5) RETURNING *',
          [req.body.user_id, cart.total, cart.currency, payment.id, 'completed']
        );
        span.setAttribute('order.id', result.rows[0].id);
        span.setStatus({ code: 1 });
        return result.rows[0];
      } catch (err) {
        span.recordException(err);
        span.setStatus({ code: 2, message: err.message });
        throw err;
      } finally {
        span.end();
      }
    });

    checkoutTotal.labels(cart.currency, 'credit_card').inc();
    activeSpan?.addEvent('checkout_completed', {
      'order.id': order.id,
      'order.total': order.total,
    });

    logger.info('checkout_completed', {
      order_id: order.id,
      user_id: req.body.user_id,
      total: order.total,
      currency: order.currency,
    });

    res.json({ success: true, order_id: order.id });

  } catch (error) {
    activeSpan?.recordException(error);
    activeSpan?.setStatus({ code: 2, message: error.message });

    logger.error('checkout_failed', {
      error: error.message,
      stack: error.stack,
      user_id: req.body.user_id,
    });

    if (error.message.includes('payment')) {
      res.status(402).json({ error: 'Payment failed' });
    } else {
      res.status(500).json({ error: 'Checkout failed' });
    }
  } finally {
    activeCheckouts.dec();
  }
});

// ── Helpers ─────────────────────────────────────
function validateCart(items) {
  if (!items || items.length === 0) throw new Error('Empty cart');
  const total = items.reduce((sum, i) => sum + (i.price * i.quantity), 0);
  return { items, total, currency: 'USD' };
}

async function chargeStripe(cart) {
  // Simulate Stripe API call
  await new Promise(r => setTimeout(r, 200 + Math.random() * 300));
  if (Math.random() < 0.02) throw new Error('Stripe declined');
  return { id: 'pi_' + Math.random().toString(36).substring(2, 15) };
}

// ── Start ───────────────────────────────────────
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  logger.info('server_started', { port: PORT, version: '2.4.1' });
});

What This Gives You

When this service runs with the full stack from earlier, you get:

  1. Metrics in Prometheus — request rates, latency histograms, active checkout gauges, business counters.
  2. Traces in Tempo — every checkout creates a trace with spans for cart validation, payment processing, and database persistence.
  3. Logs in Loki — every log line carries trace_id and span_id, so you can click from a trace span to the exact log lines.
  4. Correlated dashboards in Grafana — metrics exemplars link to traces, traces link to logs, all in one UI.

Conclusion

The distinction between monitoring and observability isn't academic — it's the difference between knowing your system is broken and understanding why. In 2026's world of distributed microservices, serverless functions, and multi-cloud deployments, you cannot operate reliably with monitoring alone.

Here's the summary:

| Practice | Do This | Not This | |----------|---------|----------| | Metrics | Low-cardinality labels, pre-aggregated | user_id or request_id as labels | | Logs | Structured JSON with trace context | Unstructured plain text | | Traces | Tail-sampled with 100% error retention | Head-based uniform sampling | | Alerts | SLO-based burn rate alerts | Static threshold per-service | | Sampling | At the collector, configurable policies | At the application level | | Cost | Monitor active series, use retention tiers | Store everything forever |

Start small. Instrument one critical service with OpenTelemetry. Set up the LGTM stack. Define one SLO. Experience the power of clicking from a Grafana metric → trace → log. Once you've felt it, there's no going back to grep-ing through log files.

The tools are open source. The standards are settled. The only question is whether you'll invest the time to do it right — before the next 3 AM incident makes you wish you had.


Further Reading:

Have questions about implementing observability in your stack? Drop a comment below or reach out — happy to help you avoid the mistakes we made on our journey.