Collecting db.client.connection Metrics with the OpenTelemetry Collector

This guide is part of OpenTelemetry Connection Pool Tracing. Services emit connection pool metrics over OTLP; dashboards and alerts read them from Prometheus. The OpenTelemetry Collector sits between, and most of the problems with pool dashboards come from what happens there rather than in the services. A fleet with instrumentation of different ages emits two naming generations for the same measurement, some SDKs send delta temporality that Prometheus cannot ingest as-is, units differ between milliseconds and seconds, and pod-level resource attributes multiply the series count. The first sign is usually a dashboard that shows half the fleet:

# Prometheus, same service, two deployments of different ages
db_client_connections_usage{pool_name="orders-primary",state="used",service_name="orders-api"}          14
db_client_connection_count{db_client_connection_pool_name="orders-primary",db_client_connection_state="used",service_name="orders-api"} 11

The objective is a Collector pipeline that delivers one consistent set of pool series in seconds, with cumulative temporality, bounded cardinality and exemplars that link back to traces.

Rapid incident diagnosis

List what is actually arriving. The Collector’s debug exporter prints metric names, types and temporality. Enable it briefly on a staging Collector with a filter for pool metrics:

processors:
  filter/pool-only:
    metrics:
      include:
        match_type: regexp
        metric_names: ["db\\.client\\.connections?\\..*"]
exporters:
  debug:
    verbosity: detailed
service:
  pipelines:
    metrics/debug:
      receivers: [otlp]
      processors: [filter/pool-only]
      exporters: [debug]

In the output, check for each metric: the name generation (db.client.connections.* or db.client.connection.*), the unit (ms or s), the aggregation temporality (Delta or Cumulative) and the attributes.

Check Prometheus for gaps and duplicates.

count by (__name__) ({__name__=~"db_client_connections?_.*"})
count by (service_name) (db_client_connection_max) unless count by (service_name) (db_client_connections_max)

The first query shows both naming generations if they coexist; the second finds services that only emit the new names.

Common findings.

  • Two name generations. Older instrumentation emits db.client.connections.usage with state and pool.name; newer emits db.client.connection.count with db.client.connection.state and db.client.connection.pool.name.
  • Delta temporality. Some SDKs, notably when configured for backends that prefer delta, send counters and histograms as deltas. The Prometheus exporter drops or mishandles them, which shows as flat zeros or missing _bucket series.
  • Unit mismatch. Older histograms such as db.client.connections.wait_time use milliseconds; the newer ones use seconds. Mixing them in one histogram_quantile gives values off by a factor of a thousand.
  • Cardinality. Resource attributes such as k8s.pod.name and host.name promoted to labels create a new series set for every pod restart.
Normalising pool metric names in the Collector Older instrumentation sends db.client.connections.wait_time in milliseconds with pool.name. Newer instrumentation sends db.client.connection.wait_time in seconds with db.client.connection.pool.name. A Collector transform renames and rescales the old form so Prometheus receives only db_client_connection_wait_time_seconds. Older instrumentation db.client.connections.wait_time unit ms, attr pool.name Newer instrumentation db.client.connection.wait_time unit s, attr ...pool.name Collector transform processor rename, rescale ms to s rename attributes Prometheus one name wait_time_seconds
The same measurement can arrive under two names and two units. Normalising in the Collector means dashboards and alerts are written once, against one name.

Mathematical sizing or parameter formula

Pool metrics are cheap per pool, but histograms multiply by bucket count and the fleet multiplies by pod count. Estimate active series before promoting attributes to labels:

series_per_pool = gauges × states + counters + histograms × (buckets + 2)
active_series   = pods × pools_per_pod × series_per_pool × label_variants

With the current instrument set — count split into two states, max, idle.min, idle.max and pending_requests as gauges (6 series), timeouts as a counter (1), and three histograms with 15 buckets each plus _sum and _count (51) — one pool produces 58 series. A deployment of 40 pods with 2 pools each produces 40 × 2 × 58 = 4,640 active series. That is modest. The multiplier to watch is label_variants: promoting k8s.pod.name is already included in pods, but adding a label that varies per request or per restart — a container ID, a process start time — makes series churn on every deploy, and the total over a retention window grows with deploy count.

Temporality affects correctness, not volume. Prometheus needs cumulative values. A delta counter converted to cumulative in the Collector must see every delta from a given stream, in order, on the same Collector instance; with several Collector replicas behind a load balancer, delta streams must be routed consistently or converted in the SDK instead.

Histogram buckets should be chosen for the instrument, not inherited from request latency defaults. For wait_time, include resolution near zero and a bucket at the acquisition timeout:

wait_time buckets (s):   0, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30
use_time buckets (s):    0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300

Buckets are set by views in the SDK. The Collector can drop buckets but cannot add resolution the SDK did not record.

Active series from pool metrics per pool and across the fleet One pool produces 6 gauge series, 1 counter series and 51 histogram series for 58 in total. Forty pods with two pools each produce 4,640 active series. Series per pool gauges 6 counter 1 histograms 51 (3 × 17) = 58 Fleet: 40 pods × 2 pools × 58 = 4,640 a label that changes per restart turns this into a new 4,640 on every deploy
Histograms dominate the per-pool series count. The fleet total stays small unless a label that changes on every restart multiplies it over time.

Exact remediation & configuration

A Collector configuration that normalises names and units, converts temporality, trims resource labels and exports with exemplars:

receivers:
  otlp:
    protocols: { grpc: {}, http: {} }

processors:
  memory_limiter: { check_interval: 1s, limit_percentage: 80, spike_limit_percentage: 20 }

  # Delta streams from some SDKs → cumulative, as Prometheus expects
  deltatocumulative:
    max_stale: 10m

  # Bridge the older instrument names and units to the current conventions
  transform/pool-names:
    error_mode: ignore
    metric_statements:
      - context: metric
        statements:
          - set(name, "db.client.connection.count") where name == "db.client.connections.usage"
          - set(name, "db.client.connection.max") where name == "db.client.connections.max"
          - set(name, "db.client.connection.pending_requests") where name == "db.client.connections.pending_requests"
          - set(name, "db.client.connection.timeouts") where name == "db.client.connections.timeouts"
      - context: datapoint
        statements:
          - set(attributes["db.client.connection.pool.name"], attributes["pool.name"]) where attributes["pool.name"] != nil
          - set(attributes["db.client.connection.state"], attributes["state"]) where attributes["state"] != nil
          - delete_key(attributes, "pool.name")
          - delete_key(attributes, "state")

  # Keep only the resource attributes dashboards group by
  resource/trim:
    attributes:
      - { key: process.pid, action: delete }
      - { key: process.command_args, action: delete }
      - { key: container.id, action: delete }

  batch: { timeout: 5s, send_batch_size: 2048 }

exporters:
  prometheus:
    endpoint: 0.0.0.0:8889
    enable_open_metrics: true               # exemplars require OpenMetrics
    resource_to_telemetry_conversion: { enabled: true }
    metric_expiration: 10m

service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, deltatocumulative, transform/pool-names, resource/trim, batch]
      exporters: [prometheus]

The old millisecond histograms (db.client.connections.wait_time, use_time, create_time) cannot be rescaled into second-based buckets reliably, because bucket boundaries do not convert cleanly. Rename them with an explicit unit suffix so they never mix with the new ones, and migrate those services to current instrumentation:

          - set(name, "db.client.connections.wait_time_ms") where name == "db.client.connections.wait_time"

Prometheus must accept exemplars. Scrape the Collector with OpenMetrics negotiation and enable exemplar storage:

# prometheus.yml
scrape_configs:
  - job_name: otel-collector
    scrape_protocols: [OpenMetricsText1.0.0, PrometheusText0.0.4]
    static_configs:
      - targets: ["otel-collector:8889"]
prometheus --enable-feature=exemplar-storage --config.file=prometheus.yml

Recording rules keep dashboard queries cheap and give alerts stable names:

groups:
  - name: pool-recording
    interval: 30s
    rules:
      - record: pool:wait_time_seconds:p99_5m
        expr: histogram_quantile(0.99, sum by (le, service_name, db_client_connection_pool_name)
              (rate(db_client_connection_wait_time_seconds_bucket[5m])))
      - record: pool:utilisation:ratio
        expr: sum by (service_name, db_client_connection_pool_name) (db_client_connection_count{db_client_connection_state="used"})
              / sum by (service_name, db_client_connection_pool_name) (db_client_connection_max)
      - record: pool:timeouts:rate5m
        expr: sum by (service_name, db_client_connection_pool_name) (rate(db_client_connection_timeouts_total[5m]))
Collector metrics pipeline for pool telemetry OTLP receiver, then memory limiter, delta to cumulative conversion, transform to normalise names and attributes, resource trimming and batching, ending at a Prometheus exporter with OpenMetrics and exemplars enabled. OTLP receiver memory limiter delta to cumulative transform names, attrs trim + batch Prometheus OpenMetrics exemplars One metrics pipeline, applied to every service's pool telemetry order matters: temporality before names, trimming before export
Processors run in order. Temporality is fixed before names are normalised, and resource attributes are trimmed before the exporter turns them into labels.

Validation & verification

One name per measurement. After deploying the Collector change, the query below must return only current names for every service:

count by (__name__) ({__name__=~"db_client_connections_(usage|max|pending_requests|timeouts).*"})

It should return nothing once the transform is active (the millisecond histograms, renamed with _ms, are the deliberate exception).

Cumulative behaviour. db_client_connection_timeouts_total must be monotonic per series between restarts. A resets() count much higher than the number of pod restarts means delta streams are still arriving unconverted or are split across Collector replicas:

resets(db_client_connection_timeouts_total[1h])

Units. Induce a known wait in staging — a pool of 1 and two concurrent requests that hold connections for 500 ms — and check that pool:wait_time_seconds:p99_5m reports roughly 0.5, not 500.

Exemplars. In Grafana, enable exemplars on the p99 wait panel for the induced-wait window. Points must appear, and each must open a trace containing the acquisition span described in Tracing Connection Acquisition Latency with OpenTelemetry Spans.

Series count. Check the Collector’s exported series against the estimate:

count({__name__=~"db_client_connection_.*"})

A value far above pods × pools × 58 points to a label that should have been trimmed.

Alerting from Collector-exported pool metrics

With names and units normalised, alert rules can be written once for every runtime in the fleet. The rules below use the recording rules from the configuration section and group by service and pool name, so each alert identifies exactly which pool is in trouble.

groups:
  - name: pool-alerts
    rules:
      - alert: PoolAcquisitionTimeouts
        expr: pool:timeouts:rate5m > 0
        for: 2m
        labels: { severity: critical }
        annotations:
          summary: "{{ $labels.service_name }} pool {{ $labels.db_client_connection_pool_name }}: requests timing out waiting for connections"

      - alert: PoolWaitHigh
        expr: pool:wait_time_seconds:p99_5m > 0.25
        for: 10m
        labels: { severity: warning }
        annotations:
          summary: "{{ $labels.service_name }} pool {{ $labels.db_client_connection_pool_name }}: p99 acquisition wait above 250 ms"

      - alert: PoolConnectionChurn
        expr: sum by (service_name, db_client_connection_pool_name) (rate(db_client_connection_create_time_seconds_count[10m])) > 1
        for: 30m
        labels: { severity: info }
        annotations:
          summary: "{{ $labels.service_name }} pool {{ $labels.db_client_connection_pool_name }}: more than one new connection per second"

      - alert: PoolTelemetryMissing
        expr: absent_over_time(db_client_connection_max{service_name="orders-api"}[15m])
        labels: { severity: warning }
        annotations:
          summary: "orders-api has stopped reporting pool metrics"

The last rule matters more than it looks. A pool that stops exporting metrics — after an instrumentation upgrade renames instruments, or a Collector pipeline change drops them — makes every other alert silently stop working. Add one absence rule per critical service.

Choose the wait threshold from the acquisition timeout rather than a round number. A p99 wait above 10 % of the timeout means some requests are spending a noticeable share of their allowance in the pool; above 50 %, timeouts are imminent:

Acquisition timeout Warning at p99 wait Critical signal
1 s 100 ms any timeouts
3 s 300 ms any timeouts
10 s 1 s any timeouts, or p99 above 5 s

Route alerts by service_name to the owning team, and include the pool name in the notification. For shared databases, add a second route by database so the database owner sees saturation across every service that connects to it, which is often the first indication that the problem is capacity rather than one service’s configuration.

Frequently Asked Questions

Should services push to Prometheus directly with OTLP instead of going through the Collector?
Prometheus can ingest OTLP directly, which removes a hop. The Collector remains useful for normalising mixed instrumentation, converting temporality and trimming attributes centrally; without it, each service must be configured correctly on its own.
Can the transform processor fix everything the instrumentation got wrong?
It can rename metrics and attributes, drop data points and adjust some values, but it cannot recover information the SDK never recorded — histogram resolution, a missing pool name, or observations lost to a long collection interval. Fix those in the SDK configuration.
Do I need the deltatocumulative processor if all SDKs send cumulative?
No, and leaving it out avoids per-stream state in the Collector. Set OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative in services where the SDK supports it, and keep the processor only for sources you cannot change.
How should alerts use these series?
Alert on pool:timeouts:rate5m > 0 for pages and on sustained pool:wait_time_seconds:p99_5m above a fraction of the acquisition timeout for warnings. The thresholds and routing are covered in Alerting on Connection Pool Saturation.
Why use absent_over_time rather than absent?
absent fires as soon as a single evaluation finds no series, which happens briefly during Collector restarts. absent_over_time over 15 minutes fires only when the series has been missing for the whole window, which is the failure worth paging on.