Instrumenting HikariCP with the OpenTelemetry Java Agent

This guide is part of OpenTelemetry Connection Pool Tracing. The OpenTelemetry Java agent instruments HikariCP without touching the application: attach it to the JVM, point it at a Collector, and pool metrics and JDBC spans appear. The gap between that description and a working dashboard is usually four small things — a pool with a generated name, metrics that never leave the JVM because only tracing was configured, an export interval too long to see bursts, and semantic convention names that changed between agent versions:

# metrics arriving, but unusable
db_client_connection_count{pool_name="HikariPool-1", service_name="orders-api"}  12
db_client_connection_count{pool_name="HikariPool-2", service_name="orders-api"}   4
# after a restart, HikariPool-1 and HikariPool-2 swap meanings

The objective is an agent configuration that produces stable, named pool series, spans that show where connections are acquired, and an explicit choice of convention version so dashboards survive an upgrade.

Rapid incident diagnosis

Confirm the agent is attached and what it enabled:

jcmd $(pgrep -f orders-api) VM.command_line | tr ' ' '\n' | grep -i "javaagent\|otel"

Check that metrics — not just traces — are configured. A common outcome is a service with beautiful traces and no pool metrics, because only the traces exporter was set:

env | grep -E "OTEL_(TRACES|METRICS|LOGS)_EXPORTER|OTEL_EXPORTER_OTLP"

OTEL_METRICS_EXPORTER=none — sometimes set to reduce noise — silently removes every pool instrument.

Verify the instrumentation is active. The agent logs the instrumentations it applies at debug level; a targeted check is cheaper:

curl -s http://otel-collector:8889/metrics | grep -c "db_client_connection"

Zero means either no metrics pipeline or no pool instrumentation.

Look at the pool names. Generated names are the difference between a dashboard and a guessing game:

count by (pool_name) (db_client_connection_max{service_name="orders-api"})

HikariPool-1 and HikariPool-2 are assigned in construction order, which is not stable across restarts when pools are created concurrently.

Check which convention version is in use. Older agents emit db.client.connections.usage with a state attribute; newer ones emit db.client.connection.count with db.client.connection.state:

curl -s http://otel-collector:8889/metrics | grep -o "db_client_connections\?_[a-z_]*" | sort -u
Agent configuration that produces usable pool telemetry The metrics exporter must be enabled, the export interval short enough to see bursts, the pool named explicitly in the application, and the semantic convention pinned so names do not change on upgrade. OTEL_METRICS_EXPORTER=otlp — without it, no pool instruments at all OTEL_METRIC_EXPORT_INTERVAL=15000 — 60 s hides short bursts spring.datasource.hikari.pool-name — stable series across restarts OTEL_SEMCONV_STABILITY_OPT_IN=database — pin the metric names The four that matter
Four settings decide whether the agent produces a usable dashboard: the metrics exporter, the export interval, the pool name and the convention version.

Mathematical sizing or parameter formula

Export interval versus burst visibility. Pool gauges are sampled at the export interval, so a saturation shorter than the interval is invisible in the gauge:

p(burst observed) ≈ min(1, burst_duration ÷ export_interval)

An 8-second burst with a 60-second interval is seen about 13 % of the time; at 15 seconds, about 53 %. The histograms and counters — wait_time, timeouts — record every event regardless, which is why alerts should use them and dashboards use the gauges for context.

Series count. The agent’s pool instruments are few, so the cost is modest:

series_per_pool ≈ states(2) + max + idle.min + idle.max + pending + timeouts + 3 histograms × (buckets + 2)

With 15 buckets, about 58 series per pool. A fleet of 40 JVMs with two pools each is roughly 4,600 series.

Span volume. JDBC statement spans are one per statement, which is the largest part of the agent’s output:

spans_per_second ≈ statements_per_second + (1 per acquisition if data-source instrumentation is on)

A service issuing 3,000 statements per second produces 3,000 spans per second before sampling — the reason head sampling, or tail sampling in the Collector, matters more than the pool instrumentation does.

Overhead. The agent’s cost is dominated by span creation rather than by metric collection. Typical figures are a few percent of CPU for a database-heavy service, and it is worth measuring on a canary rather than assuming:

measure: p50 and p99 latency, CPU, and allocation rate, with and without the agent
Burst visibility by export interval An eight second saturation burst is seen in about thirteen percent of sixty second collection intervals and about fifty three percent of fifteen second intervals, while the histogram records every delayed acquisition regardless. Probability an 8 s burst appears in a gauge sample 60 s interval 13 % 15 s interval 53 % histogram every delayed acquisition is recorded, whatever the interval
Gauges sample at the export interval, so short bursts are missed; the counters and histograms record every event. Alerts belong on the latter.

Exact remediation & configuration

A complete agent configuration for a Spring Boot service:

JAVA_TOOL_OPTIONS="-javaagent:/otel/opentelemetry-javaagent.jar"

OTEL_SERVICE_NAME=orders-api
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=commerce
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc

OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=none

OTEL_METRIC_EXPORT_INTERVAL=15000
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

OTEL_SEMCONV_STABILITY_OPT_IN=database
OTEL_INSTRUMENTATION_JDBC_DATASOURCE_ENABLED=true      # span around getConnection()
OTEL_INSTRUMENTATION_COMMON_DB_STATEMENT_SANITIZER_ENABLED=true

Name every pool. This is the single change that makes the metrics usable:

spring:
  datasource:
    hikari:
      pool-name: orders-primary
      maximum-pool-size: 16
      minimum-idle: 4

With several data sources, name each one after the database and role it serves — orders-primary, orders-replica — so a dashboard row is self-explanatory.

Decide about acquisition spans deliberately. OTEL_INSTRUMENTATION_JDBC_DATASOURCE_ENABLED=true adds a span per getConnection(). It is what makes pool wait visible in a trace, and it roughly doubles span volume on database-heavy services. Enable it where contention is being investigated, or permanently with tail sampling that keeps slow traces:

# Collector: keep slow and errored traces, sample the rest
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: slow
        type: latency
        latency: { threshold_ms: 500 }
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

Suppress what you do not need. The agent instruments a great deal; disabling the noisiest parts reduces volume without losing the database picture:

OTEL_INSTRUMENTATION_COMMON_DEFAULT_ENABLED=true
OTEL_INSTRUMENTATION_LOGBACK_APPENDER_ENABLED=false
OTEL_INSTRUMENTATION_SPRING_SCHEDULING_ENABLED=false

Keep Micrometer if it is already wired. Running both produces two series sets for the same pool; either disable the agent’s JDBC metrics or stop publishing the Micrometer ones, and choose one for alerting. The Micrometer route is described in Exposing HikariCP Metrics with Micrometer and Prometheus.

Add resource attributes that make grouping possible. deployment.environment and service.namespace cost nothing and let one dashboard serve every environment.

What the agent cannot see

The agent is powerful precisely because it needs no code changes, and that boundary is also its limitation.

Application intent. A span shows that getConnection() took 1.8 seconds; it cannot show which business operation was waiting or why that operation holds connections longer than others. A custom attribute on the request span — the endpoint, the tenant tier, the job type — is what makes the trace actionable, and it requires code:

Span.current().setAttribute("app.operation", "checkout.finalise");

Pool internals beyond the standard instruments. HikariCP’s getTotalConnections, creation timing and housekeeping are exposed through its own metrics interface; the agent publishes the conventional subset. Where a specific internal matters, Micrometer or a JMX exporter fills the gap.

Connections used outside JDBC. A native client, a driver the agent does not instrument, or a pool inside a library are invisible. The database’s own view — sessions by application_name — is the cross-check that catches them.

Work that holds a connection without querying. A span for getConnection() and spans for statements leave the gap between them unexplained; that gap is where an external call inside a transaction hides. Reading it requires looking at the waterfall rather than at any single span.

Question Agent alone Needs code or another source
Is the pool saturated? yes, from the instruments —
Which requests waited? yes, with data-source spans —
Which business operation was it? no span attributes
What held the connection between statements? visible as a gap reading the waterfall
Are there non-JDBC connections? no pg_stat_activity
Pool-specific internals partially Micrometer or JMX
What the agent provides and what it does not The agent supplies pool instruments, JDBC spans and optional acquisition spans without code changes, while business context, non-JDBC connections and pool internals require application code or another source. From the agent pool instruments JDBC spans acquisition spans Needs more business attributes non-JDBC connections pool internals A clear boundary adding two or three span attributes closes most of the gap on the right
The agent answers the operational questions without code. The business context that makes an incident actionable still has to be added by the application.

Running the agent alongside existing instrumentation

Most services that adopt the agent already have something: Micrometer metrics, a vendor APM agent, or hand-written timers. Running two systems over the same pool is the usual source of confusion afterwards.

Two agents on one JVM rarely work. A vendor APM agent and the OpenTelemetry agent both instrument the same classes, and the result ranges from duplicate spans to startup failures. Where both are required, use the vendor’s OpenTelemetry-compatible mode or its support for receiving OTLP, rather than attaching both.

Micrometer and the agent both publish pool metrics. The series have different names — hikaricp_connections_active against db_client_connection_count — so nothing breaks, but dashboards and alerts must pick one. Two practical rules: alert on one source only, and record which one in the runbook, because an incident is a bad time to discover that the alert watches the series nobody is looking at.

# If the agent is the source of truth, stop publishing the Micrometer pool metrics
management:
  metrics:
    enable:
      hikaricp: false

Traces and logs should share the context. With the agent attached, adding the trace and span ids to the log pattern makes a log line and a trace mutually reachable:

<pattern>%d{ISO8601} %-5level [%X{trace_id},%X{span_id}] %logger{36} - %msg%n</pattern>

The agent populates those MDC keys automatically for the supported logging frameworks, which costs nothing and is the single most useful addition to a log format in a traced service.

Keep the rollout reversible. The agent is attached by a JVM flag, so removing it is a deployment rather than a code change — which makes it reasonable to canary on one instance, compare latency and CPU, and expand or revert based on the measurement rather than on expectation.

Existing tooling With the agent
Vendor APM agent Use one; prefer the vendor’s OTLP support over two agents
Micrometer pool metrics Keep one source for alerts; disable the other
Hand-written timers Usually redundant; the agent’s spans replace them
Log correlation Add trace_id and span_id to the pattern

Validation & verification

Metrics arrive with stable names. After a restart, the pool series must keep the same pool_name:

count by (pool_name) (db_client_connection_max{service_name="orders-api"})

Bursts are visible. Induce a short saturation in staging and confirm the histogram records it even when the gauges do not:

increase(db_client_connection_wait_time_seconds_count{pool_name="orders-primary"}[5m])

Acquisition spans appear when enabled. Find a slow trace and confirm a getConnection span with a duration matching the wait.

Overhead is acceptable. Compare a canary with and without the agent under identical load: p99 latency, CPU and allocation rate.

Convention names are pinned. Upgrade the agent in staging and confirm the metric names are unchanged; if they change, the opt-in is not set or the version moved past it.

Frequently Asked Questions

Does the agent work with pools other than HikariCP?
Yes — it instruments Apache DBCP, c3p0, Oracle UCP, Tomcat JDBC and Vibur as well, publishing the same conventional instruments. That is a large part of its value in a mixed estate.
Can I use the agent and the OpenTelemetry SDK together?
Yes. The agent installs a global SDK, and application code can obtain the tracer and meter from it to add custom spans and attributes. Avoid configuring a second SDK in the application, which produces duplicate telemetry.
Does sampling affect the metrics?
No. Metrics are aggregated in the SDK and exported regardless of trace sampling, which is why alerting on metrics and sampling traces aggressively is a sound combination.
What if my service runs on a JVM where an agent is not allowed?
The OpenTelemetry Spring Boot starter provides much of the same instrumentation without a Java agent, including JDBC and pool metrics, at the cost of a dependency and a rebuild.