Observability vs Monitoring: The Complete DevOps Guide for 2026
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
- Monitoring vs Observability — The Real Difference
- The Three Pillars (Plus One)
- Why Monitoring Alone Fails in Microservices
- OpenTelemetry Deep Dive
- Building Your Observability Stack
- SRE Practices: SLIs, SLOs, SLAs, and Error Budgets
- Distributed Tracing in Practice
- Structured Logging Best Practices
- Cost Optimization: Taming Cardinality Explosion
- 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:
- Metrics in Prometheus — request rates, latency histograms, active checkout gauges, business counters.
- Traces in Tempo — every checkout creates a trace with spans for cart validation, payment processing, and database persistence.
- Logs in Loki — every log line carries
trace_idandspan_id, so you can click from a trace span to the exact log lines. - 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.