OpenTelemetry
The foundations-jdbc-otel module wires OpenTelemetry into the existing QueryListener mechanism, producing spans and metrics for every query. When no OTel SDK is configured, the listener does nothing.
Setup
Add the foundations-jdbc-otel module to your project alongside the core library. Then create an OtelQueryListener and attach it to your transactor:
- Kotlin
- Java
- Scala
val config =
PgConfig.builder("localhost", 5432, "mydb", "user", "pass").build()
val telemetryConfig = TelemetryConfig.builder(config).build()
val otelListener: QueryListener =
OtelQueryListener.create(GlobalOpenTelemetry.get(), telemetryConfig)
val tx = Transactor.create(config).withListener(otelListener)
PgConfig config = PgConfig.builder("localhost", 5432, "mydb", "user", "pass").build();
TelemetryConfig telemetryConfig = TelemetryConfig.builder(config).build();
QueryListener otelListener = OtelQueryListener.create(GlobalOpenTelemetry.get(), telemetryConfig);
Transactor tx = Transactor.create(config).withListener(otelListener);
val config =
PgConfig
.builder("localhost", 5432, "mydb", "user", "pass")
.build()
val telemetryConfig = TelemetryConfig.builder(config).build()
val otelListener: QueryListener =
OtelQueryListener.create(GlobalOpenTelemetry.get(), telemetryConfig)
val tx = Transactor.create(config).withListener(otelListener)
TelemetryConfig.builder(config) extracts the database system, host, port, and database name from the DatabaseConfig. You can also build it manually:
var telemetryConfig = TelemetryConfig.builder(DatabaseSystem.POSTGRESQL)
.serverAddress("db.example.com")
.serverPort(5432)
.dbNamespace("orders")
.build();
Span attributes
Each query produces a span with attributes following OTel Semantic Conventions v1.39.0:
| Attribute | Source |
|---|---|
db.system.name | From TelemetryConfig |
db.query.text | Raw SQL text (when recordQueryText is enabled) |
server.address / server.port | From config |
db.namespace | Database name from config |
error.type | Exception class on failure |
The span name is the operation's .named() value if set, otherwise "DB query".
TelemetryConfig options
| Option | Default | Description |
|---|---|---|
recordQueryText | true | Include db.query.text attribute |
No ThreadLocal
The listener uses backdated spans rather than ThreadLocal state. beforeQuery is a no-op. afterQuery and failedQuery each create a complete span by computing Instant.now() - elapsed. This is safe for virtual threads, nested queries, and composed listeners.
Pool metrics
If you use HikariCP, register connection pool metrics:
- Kotlin
- Java
- Scala
val poolMetrics: PoolMetrics =
PoolMetrics.register(GlobalOpenTelemetry.get(), dataSource, Optional.of("main-pool"))
fun onShutdown() {
poolMetrics.close()
}
PoolMetrics poolMetrics =
PoolMetrics.register(GlobalOpenTelemetry.get(), dataSource, Optional.of("main-pool"));
void onShutdown() {
poolMetrics.close();
}
val poolMetrics: PoolMetrics =
PoolMetrics.register(GlobalOpenTelemetry.get(), dataSource, Optional.of("main-pool"))
def onShutdown(): Unit =
poolMetrics.close()
Metrics follow the OTel database pool semantic conventions:
| Metric | Description |
|---|---|
db.client.connection.count | Active and idle connections (by state) |
db.client.connection.pending_requests | Threads waiting for a connection |
db.client.connection.max | Maximum pool size |
Grafana dashboard
A pre-built Grafana dashboard is included at foundations-jdbc-otel/grafana/foundations-jdbc-dashboard.json. Import it into Grafana and configure:
- Tempo / Jaeger datasource for trace panels (latency percentiles, slow queries)
- Prometheus datasource for metric panels (throughput, pool status)
Panels include:
- Query latency p50/p95/p99 by operation
- Error rate
- Throughput by database system
- Connection pool active/idle/pending
- Pool usage percentage gauge
- Slow queries table