Skip to content
ExponaBLOG
PROMETHEUS, GRAFANA & MONITORING · DAY 080 / 100

Instrumenting with OpenTelemetry metrics for production

One API for any backend.

Expona Admin7 min read

Introduction

By day 80 of a monitoring transformation, your team knows the power of observability—but scattered instrumentation strategies create blind spots and vendor lock-in. Instrumenting with OpenTelemetry metrics for production gives you one unified API that works across any backend, whether that's Prometheus scraping your endpoints or OTLP exporters pushing to distributed tracing platforms. We'll walk through building production-grade metrics that scale from a single microservice to a complex platform.

Why OpenTelemetry Metrics Matter Now

Before OpenTelemetry, teams often embedded Prometheus client libraries directly into code. That couples your application to a specific exporter format and makes switching backends expensive. OpenTelemetry decouples instrumentation from export: you write once, export anywhere. This separation of concerns is especially critical in production where you might want to ship metrics to Prometheus today, OpenTelemetry Protocol (OTLP) to a vendor tomorrow, and a custom backend next year without rewriting instrumentation.

The spec is now mature enough that major frameworks ship auto-instrumentation out of the box, meaning much of your work is already done if you configure it correctly.

Meters, Counters and Histograms

OpenTelemetry metrics are built around three core instruments. Understanding the differences is crucial for choosing the right one in production.

Counters: Monotonic Growth

Counters measure monotonically increasing values—they never decrease. Use them for request counts, errors, exceptions, or any cumulative metric. In Prometheus, a counter is scraped at intervals and the rate of change is calculated by the backend.

javascript

const meter = meterProvider.getMeter('payment-service');
const requestCounter = meter.createCounter('http_requests_total', {
  description: 'Total HTTP requests processed',
});

// In your request handler
requestCounter.add(1, {
  'http.method': 'POST',
  'http.status_code': 200,
  'service.name': 'payment-api',
});

Counters are cheap to compute and reliable. Every increment is additive, so gaps in scraping don't lose data—the total is always correct when you resume.

Histograms: Distribution and Latency

Histograms capture the distribution of values across buckets. They're essential for latency tracking, payload sizes, or database response times. OpenTelemetry histograms automatically aggregate into percentiles without additional code.

javascript

const latencyHistogram = meter.createHistogram('http_request_duration_ms', {
  description: 'HTTP request latency in milliseconds',
});

// In middleware
const start = Date.now();
// ... handle request ...
const duration = Date.now() - start;
latencyHistogram.record(duration, {
  'http.method': req.method,
  'http.status_code': res.statusCode,
  'http.route': req.route,
});

Histograms let you calculate p50, p95, and p99 latencies without polling every request. In production, set explicit bucket boundaries matching your SLOs: if your SLO is 500ms response time, include a 500ms bucket so you can easily track compliance.

Gauges: Point-in-Time Snapshots

Gauges measure values that go up and down—memory usage, active connections, or queue depth. Use them sparingly; they're harder to aggregate across instances than counters.

javascript

const activeConnections = meter.createObservableGauge(
  'db_active_connections',
  {
    description: 'Number of active database connections',
  },
  (observableResult) => {
    observableResult.observe(pool.activeCount(), {
      'db.system': 'postgresql',
      'db.pool': 'primary',
    });
  }
);

Observable gauges are registered once and called during metric collection, so they're lightweight for polling internal state.

Export to Prometheus or OTLP

OpenTelemetry provides exporters for both Prometheus pull-based scraping and OTLP push-based protocols. The choice depends on your infrastructure.

Prometheus Exporter: Pull Model

If you're already running Prometheus, the Prometheus exporter exposes metrics on an HTTP endpoint that Prometheus scrapes at regular intervals (typically 15-30 seconds).

javascript

const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
const { MeterProvider } = require('@opentelemetry/sdk-metrics');

const exporter = new PrometheusExporter({
  port: 9464,
  endpoint: '/metrics',
}, () => {
  console.log('Prometheus metrics exposed on http://localhost:9464/metrics');
});

const meterProvider = new MeterProvider({
  readers: [exporter],
});

This endpoint exposes metrics in Prometheus text format. Your Prometheus config scrapes it like any other target. No external dependencies or network reliability issues—just HTTP polling on localhost.

OTLP Exporter: Push Model

OTLP (OpenTelemetry Protocol) pushes metrics to a collector endpoint. This is useful for managed backends like Datadog, Honeycomb, or a self-hosted OTEL Collector.

javascript

const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { MeterProvider, PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');

const exporter = new OTLPMetricExporter({
  url: 'http://otel-collector:4318/v1/metrics',
});

const meterProvider = new MeterProvider({
  readers: [new PeriodicExportingMetricReader({
    exporter,
    intervalMillis: 5000,
  })],
});

OTLP is protocol-agnostic and vendor-neutral, making it ideal for multi-backend setups or future flexibility. Trade-off: you need a collector running and network reliability between your app and the collector.

Running Multiple Exporters

In production, you often want both—Prometheus for internal dashboards and OTLP for a vendor backend. The MeterProvider accepts multiple readers.

javascript

const meterProvider = new MeterProvider({
  readers: [
    exporter,  // Prometheus exporter
    new PeriodicExportingMetricReader({ exporter: otlpExporter }),
  ],
});

Auto-instrumentation for Frameworks

Writing every metric manually is tedious and error-prone. OpenTelemetry provides auto-instrumentation packages for Express, Fastify, PostgreSQL, Redis, HTTP clients, and more. These inject metrics automatically without code changes.

Express Auto-Instrumentation

The Express instrumentation captures HTTP metrics automatically.

javascript

const { registerInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

registerInstrumentations({
  instrumentationConfig: {
    '@opentelemetry/instrumentation-express': {
      enabled: true,
    },
    '@opentelemetry/instrumentation-http': {
      enabled: true,
    },
  },
});

This single setup auto-captures request count, duration, status codes, and route names—no manual middleware needed. The instrumentation respects the OpenTelemetry spec, so metrics are consistent across teams and services.

Database Instrumentation

PostgreSQL, MySQL, and MongoDB instrumentations track query count, duration, and errors.

javascript

const { PostgreSQLInstrumentation } = require('@opentelemetry/instrumentation-pg');

new PostgreSQLInstrumentation().enable();

// Your code runs unchanged
const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
// Metrics captured automatically: query duration, error status, etc.

Auto-instrumentation reduces boilerplate and ensures consistency. In production, enable only what you need to avoid overhead; a busy service with all instrumentations enabled can add 5-15% CPU.

Consistent Attribute Names

Attributes (tags in Prometheus parlance) are key-value pairs attached to metrics. Inconsistent naming creates cardinality explosions and makes dashboards brittle. OpenTelemetry defines semantic conventions for standard attributes.

Semantic Conventions

Use standard attribute names from the OpenTelemetry spec. For HTTP endpoints, use http.method, http.status_code, http.route. For databases, use db.system, db.operation, db.statement.

javascript

// Good: semantic conventions
requestCounter.add(1, {
  'http.method': 'POST',
  'http.status_code': 200,
  'http.route': '/api/payments',
  'service.name': 'payment-api',
  'service.version': '1.2.3',
});

// Bad: inconsistent custom names
requestCounter.add(1, {
  'METHOD': 'POST',
  'statusCode': 200,
  'endpoint': '/api/payments',
});

Semantic conventions ensure your metrics are queryable across the entire org. A standard set of attribute names lets teams write consistent PromQL queries and Grafana dashboards that work across services.

High-Cardinality Traps

Attributes like user IDs, transaction IDs, or customer IDs should never be tags. They create unbounded cardinality and crash Prometheus.

javascript

// Dangerous: user_id is high-cardinality
paymentCounter.add(1, {
  'user_id': userId,  // 10 million unique values
  'status': 'success',
});

// Better: only use bounded attributes
paymentCounter.add(1, {
  'payment_status': 'success',
  'payment_method': 'card',
});
// Store the user_id in traces, not metrics

Limit attributes to bounded values: HTTP methods (6), status codes (20), regions (10), environment (3). This keeps cardinality manageable and your Prometheus database size reasonable.

Common Mistakes

Mistake 1: High-Cardinality Attributes

The easiest way to break Prometheus is attaching user IDs, request IDs, or session tokens to metrics. One endpoint with 10 million users generates 10 million metric series. Use filters in traces instead; metrics should have bounded dimensions.

Mistake 2: Not Setting Histogram Buckets

Default histogram buckets are generic. If your SLO is 200ms but default buckets go up to 1000ms, you can't easily track compliance. Always set explicit buckets matching your SLOs.

Mistake 3: Mixing Manual and Auto-Instrumentation

Enabling auto-instrumentation and then adding manual metrics for the same thing (e.g., auto HTTP metrics plus manual request counters) doubles your cardinality and confuses alerts. Use auto-instrumentation as the base and add manual metrics only for business logic.

Mistake 4: Forgetting Resource Attributes

Every metric should include service.name, service.version, and deployment.environment. Without these, you can't correlate metrics across services or deployments.

javascript

const meterProvider = new MeterProvider({
  resource: Resource.default().merge(new Resource({
    'service.name': 'payment-api',
    'service.version': process.env.APP_VERSION,
    'deployment.environment': process.env.NODE_ENV,
  })),
  readers: [exporter],
});

Key Takeaways

  • Counters measure monotonic growth (request count, errors); histograms capture distributions (latency, payload size); gauges track point-in-time values (active connections).
  • Export to Prometheus for pull-based scraping or OTLP for push-based collection—or both simultaneously for flexibility.
  • Auto-instrumentation packages handle framework and dependency metrics automatically; enable only what you need to avoid overhead.
  • Use OpenTelemetry semantic conventions for attribute names to ensure consistency across your platform.
  • Keep attributes low-cardinality: use bounded values like HTTP status codes and regions, never user IDs or transaction IDs.
  • Set histogram buckets explicitly to match your SLOs; generic buckets hide performance problems.
  • Include resource attributes (service.name, service.version, deployment.environment) on every metric for observability across your platform.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles