Metrics Aggregation: Collect, Tag
How to collect, tag, and aggregate business metrics for observability. Covers Prometheus, OpenTelemetry, custom metrics, histograms, and dashboarding.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Metrics aggregation is the practice of collecting numeric measurements from your application, tagging them with contextual labels, and aggregating them for querying and alerting. Unlike logs (which record individual events), metrics are pre-aggregated numbers — request count, error rate, latency percentile, active connections. This makes them cheap to store and fast to query. The pattern covers four metric types: counters (monotonically increasing), gauges (can go up or down), histograms (distribution of values), and summaries (quantiles of values).
When to Use
- Any production application that needs observability beyond logs
- Alerting on SLOs (error rate, latency percentiles, throughput)
- Capacity planning (tracking resource usage trends)
- Business dashboards (orders per minute, active users, revenue)
- Performance monitoring (request latency, queue depth, cache hit rate)
When NOT to Use
- Individual event tracking — use logs or event streams instead
- Debugging specific issues — logs and traces are more useful
- Applications with very low traffic where metrics are not statistically meaningful
- When the overhead of a metrics backend is not justified
Solution
Prometheus client (Python)
# Python — prometheus_client for metrics
from prometheus_client import Counter, Gauge, Histogram, Summary, start_http_server
import time
import random
# Counter — monotonically increasing (request count, error count)
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status'],
)
# Gauge — can go up or down (active connections, queue depth)
ACTIVE_CONNECTIONS = Gauge(
'active_connections',
'Currently active connections',
)
QUEUE_DEPTH = Gauge(
'message_queue_depth',
'Messages waiting in queue',
['queue_name'],
)
# Histogram — distribution of values (request latency)
REQUEST_LATENCY = Histogram(
'http_request_duration_seconds',
'HTTP request duration in seconds',
['method', 'endpoint'],
buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
)
# Summary — quantiles of values
RESPONSE_SIZE = Summary(
'http_response_size_bytes',
'HTTP response size in bytes',
['endpoint'],
)
# Usage
def handle_request(method, endpoint):
start_time = time.time()
# Simulate processing
time.sleep(random.uniform(0.01, 0.2))
status = random.choice([200, 200, 200, 200, 404, 500])
# Record metrics
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=str(status)).inc()
REQUEST_LATENCY.labels(method=method, endpoint=endpoint).observe(time.time() - start_time)
RESPONSE_SIZE.labels(endpoint=endpoint).observe(random.randint(100, 5000))
return status
# Start metrics server on port 9090
start_http_server(9090)
# Update gauges
ACTIVE_CONNECTIONS.set(42)
QUEUE_DEPTH.labels(queue_name='orders').set(150)
FastAPI middleware with metrics
# Python — FastAPI middleware for automatic request metrics
from fastapi import FastAPI, Request
from prometheus_client import Counter, Histogram, start_http_server
import time
app = FastAPI()
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status'],
)
REQUEST_LATENCY = Histogram(
'http_request_duration_seconds',
'HTTP request duration',
['method', 'endpoint'],
)
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
endpoint = request.url.path
method = request.method
status = str(response.status_code)
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=status).inc()
REQUEST_LATENCY.labels(method=method, endpoint=endpoint).observe(duration)
return response
# Start metrics server
start_http_server(9090)
OpenTelemetry metrics (Python)
# Python — OpenTelemetry metrics API
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk.resources import Resource
# Setup
resource = Resource.create({"service.name": "order-service"})
reader = PrometheusMetricReader()
provider = MeterProvider(resource=resource, metric_readers=[reader])
metrics.set_meter_provider(provider)
meter = metrics.get_meter("order-service")
# Create instruments
request_counter = meter.create_counter(
"http_requests_total",
description="Total HTTP requests",
unit="1",
)
request_duration = meter.create_histogram(
"http_request_duration_seconds",
description="HTTP request duration",
unit="s",
)
active_connections = meter.create_up_down_counter(
"active_connections",
description="Active connections",
unit="1",
)
# Usage
def handle_order(order_id):
attrs = {"endpoint": "/api/orders", "method": "POST"}
request_counter.add(1, attrs)
active_connections.add(1, attrs)
start = time.time()
# Process order
duration = time.time() - start
request_duration.record(duration, attrs)
active_connections.add(-1, attrs)
Node.js with prom-client
// JavaScript — prom-client for Prometheus metrics
const promClient = require('prom-client');
// Create a Registry
const register = new promClient.Registry();
// Default metrics (CPU, memory, GC)
promClient.collectDefaultMetrics({ register });
// Custom metrics
const httpRequestTotal = new promClient.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'endpoint', 'status'],
registers: [register],
});
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'endpoint'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
registers: [register],
});
const activeConnections = new promClient.Gauge({
name: 'active_connections',
help: 'Currently active connections',
registers: [register],
});
const queueDepth = new promClient.Gauge({
name: 'message_queue_depth',
help: 'Messages waiting in queue',
labelNames: ['queue_name'],
registers: [register],
});
// Express middleware
const express = require('express');
const app = express();
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
httpRequestTotal.labels(req.method, req.path, String(res.statusCode)).inc();
httpRequestDuration.labels(req.method, req.path).observe(duration);
});
next();
});
// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
// Update gauges
activeConnections.set(42);
queueDepth.labels({ queue_name: 'orders' }).set(150);
Business metrics with tagging
# Python — business metrics with dimensional tags
from prometheus_client import Counter, Gauge, Histogram
# Order metrics with rich tags
ORDERS_PROCESSED = Counter(
'orders_processed_total',
'Total orders processed',
['product_category', 'payment_method', 'region', 'status'],
)
ORDER_VALUE = Histogram(
'order_value_usd',
'Order value in USD',
['product_category', 'region'],
buckets=[1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000],
)
ACTIVE_SUBSCRIPTIONS = Gauge(
'active_subscriptions',
'Currently active subscriptions',
['plan', 'region'],
)
# Usage
def process_order(order):
ORDER_VALUE.labels(
product_category=order.category,
region=order.region,
).observe(order.total)
ORDER_COUNT.labels(
product_category=order.category,
payment_method=order.payment_method,
region=order.region,
status='success' if order.success else 'failed',
).inc()
Java with Micrometer
// Java — Micrometer metrics
import io.micrometer.core.instrument.*;
import io.micrometer.prometheus.PrometheusConfig;
import io.micrometer.prometheus.PrometheusMeterRegistry;
PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
// Counter
Counter orderCounter = Counter.builder("orders_processed")
.description("Total orders processed")
.tag("product_category", "electronics")
.tag("region", "us-east")
.register(registry);
orderCounter.increment();
// Timer (equivalent to histogram for duration)
Timer orderTimer = Timer.builder("order_processing_duration")
.description("Order processing duration")
.tag("endpoint", "/api/orders")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
orderTimer.record(() -> processOrder(order));
// Gauge
Gauge activeConnections = Gauge.builder("active_connections",
() -> getActiveConnectionCount())
.description("Currently active connections")
.register(registry);
// Distribution summary (histogram for arbitrary values)
DistributionSummary orderValue = DistributionSummary.builder("order_value_usd")
.description("Order value in USD")
.tag("region", "us-east")
.register(registry);
orderValue.record(order.getTotal());
// Expose metrics endpoint
// Spring Boot auto-configures /actuator/prometheus
Prometheus scrape configuration
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'order-service'
metrics_path: /metrics
static_configs:
- targets: ['order-service:9090']
labels:
service: 'order-service'
env: 'production'
- job_name: 'payment-service'
metrics_path: /metrics
static_configs:
- targets: ['payment-service:9090']
labels:
service: 'payment-service'
env: 'production'
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
Grafana dashboard queries
# PromQL queries for common dashboards
# Request rate (requests per second)
rate(http_requests_total[5m])
# Error rate (percentage of 5xx responses)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) * 100
# 95th percentile latency
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket[5m]))
# Average latency by endpoint
rate(http_request_duration_seconds_sum[5m])
/ rate(http_request_duration_seconds_count[5m])
# Active connections over time
active_connections
# Orders per minute by category
sum(rate(orders_processed_total[1m])) by (product_category)
# Top 5 slowest endpoints
topk(5,
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket[5m])) by (endpoint))
Alerting rules
# Prometheus alerting rules
groups:
- name: order-service
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 0.05
for: 10m
labels:
severity: critical
annotations:
summary: "Error rate above 5% for 10 minutes"
description: "Current error rate: {{ $value }}"
- alert: HighLatency
expr: |
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket[5m])) > 2
for: 5m
labels:
severity: warning
annotations:
summary: "95th percentile latency above 2 seconds"
- alert: ServiceDown
expr: up{job="order-service"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Order service is down"
Variants
Push-based metrics with StatsD
# Python — StatsD push-based metrics
import statsd
client = statsd.StatsClient('localhost', 8125, prefix='order-service')
# Counter
client.incr('orders.created')
client.incr('orders.failed', count=1)
# Timing
with client.timer('order_processing_duration'):
process_order(order)
# Gauge
client.gauge('active_connections', 42)
# Sets (unique count)
client.set('unique_users', user_id)
Custom business metrics dashboard
// JavaScript — custom business metrics
const businessMetrics = {
// Revenue tracking
revenueToday: new promClient.Gauge({
name: 'revenue_today_usd',
help: 'Total revenue today in USD',
registers: [register],
}),
// Conversion funnel
funnelStage: new promClient.Counter({
name: 'funnel_stage_total',
help: 'Users reaching each funnel stage',
labelNames: ['stage'],
registers: [register],
}),
// Feature usage
featureUsage: new promClient.Counter({
name: 'feature_usage_total',
help: 'Feature usage count',
labelNames: ['feature', 'user_tier'],
registers: [register],
}),
};
// Track funnel stages
function trackFunnel(userId, stage) {
businessMetrics.funnelStage.labels({ stage }).inc();
}
trackFunnel(userId, 'page_view');
trackFunnel(userId, 'add_to_cart');
trackFunnel(userId, 'checkout');
trackFunnel(userId, 'purchase');
Best Practices
-
For a deeper guide, see Observability — Metrics, Logs, and Traces Complete Guide.
-
Use the right metric type — counters for totals, gauges for current state, histograms for distributions
-
Tag with dimensions — endpoint, method, status, region. Tags enable slicing and dicing in queries.
-
Keep cardinality bounded — avoid tagging with user IDs or request IDs (unlimited values)
-
Use standard buckets for histograms — Prometheus default buckets cover most latency ranges
-
Expose a /metrics endpoint — let Prometheus scrape it rather than pushing
-
Track business metrics, not just technical — orders, revenue, conversion, not just CPU and memory
-
Set up alerting on SLOs — error rate, latency percentiles, not just “is it up”
-
Use histograms over summaries for aggregatable percentiles — histograms can be aggregated across instances
Common Mistakes
- Unbounded cardinality: tagging with user IDs or request IDs creates one time series per value. This causes memory explosion in Prometheus.
- Using gauges for counters: gauges can go down, so
rate()doesn’t work. Use counters for monotonically increasing values. - Not tagging: a single
http_requests_totalwithout method/endpoint tags can’t be sliced. Always include relevant dimensions. - Too many buckets: 50 histogram buckets wastes memory. 10-15 well-chosen buckets are sufficient.
- Not handling counter resets: when a process restarts, counters reset to 0. Use
rate()orincrease()which handle resets.
FAQ
What are the four metric types?
Counters (monotonically increasing, like request count), gauges (can go up or down, like active connections), histograms (distribution of values, like request latency), and summaries (pre-computed quantiles, like 95th percentile).
Should I use histograms or summaries?
Use histograms for latency — they can be aggregated across instances and allow calculating any percentile at query time. Use summaries only when you need pre-computed quantiles and don’t need to aggregate across instances.
What is cardinality and why does it matter?
Cardinality is the number of unique label combinations. High cardinality (tagging with user IDs) creates too many time series, causing memory and performance issues in Prometheus. Keep cardinality bounded.
How often should I update metrics?
Counters and histograms are updated when events happen. Gauges should be updated when the value changes. Prometheus scrapes every 15 seconds by default, so don’t push — let it pull.
What is the difference between push and pull metrics?
Pull (Prometheus): the metrics system scrapes your /metrics endpoint. Push (StatsD, InfluxDB): your application sends metrics to a collector. Pull is simpler and more common in cloud-native environments.
Related Resources
Structured Logging: Emit JSON Logs with Consistent Fields
How to emit structured JSON logs with consistent fields for searchability. Covers Python structlog, Winston, Serilog, log levels, and log aggregation.
PatternDistributed Tracing: Propagate Trace Context Across Services
How to propagate trace context across service boundaries with OpenTelemetry. Covers span creation, context propagation, sampling, and trace analysis.
PatternHealth Check Pattern: Expose Liveness and Readiness Probes
How to implement liveness and readiness probes for container orchestration. Covers Kubernetes probes, dependency checks, graceful degradation, and probe endpoints.
PatternCircuit Breaker with Monitoring
How to expose circuit breaker state as metrics for observability. Covers Prometheus integration, alerting rules, dashboards, and state transitions.