Skip to main content

Observability

foundations-jdbc provides lightweight observability hooks: query listeners, named operations, timeouts, and interpolated SQL for debugging. Zero overhead when no listener is configured.

Query listeners

A QueryListener receives callbacks before and after every query. Implement the interface to add logging, metrics, or alerting:

object logger : QueryListener {
override fun beforeQuery(sql: String, name: java.util.Optional<String>) {
println("Executing: $sql")
}
override fun afterQuery(event: QueryEvent) {
println("${event.name().orElse("unnamed")} completed in ${event.elapsed().toMillis()}ms")
}
override fun failedQuery(event: QueryEvent) {
System.err.println("${event.name().orElse("unnamed")} failed after ${event.elapsed().toMillis()}ms: ${event.error().map { it.message }.orElse("unknown")}")
}
}

Attaching to a transactor

Attach a listener to a Transactor so all operations are observed:

val tx: Transactor = Transactor.create(config)
val txWithLogging: Transactor = tx.mergeListener(logger)

Per-operation listeners

You can also attach a listener to a specific operation:

operation.withListener(myListener).transactRead(tx);

Named operations

Give operations a name with .named(). The name appears as a SQL comment prefix (/* name */) visible in pg_stat_activity, slow query logs, and listener callbacks:

val users: OperationRead<List<String>> =
sql { "SELECT name FROM users" }
.query(RowCodec.of(PgTypes.text).all())
.named("load-users")
.timeout(Duration.ofSeconds(5))

For composite operations (e.g. a.combine(b).named("dashboard")), each leaf query gets a unique suffix: dashboard#1, dashboard#2, etc. Single queries get no suffix.

Query timeouts

.timeout() sets a query timeout on the database via setQueryTimeout():

Fragment.of("SELECT * FROM large_table")
.query(codec.all())
.timeout(Duration.ofSeconds(10))
.transactRead(tx);

Interpolated SQL

QueryEvent.interpolatedSql() inlines parameter values into the SQL string for debugging. String values are quoted, numeric values are bare, and nulls render as NULL:

object debugListener : QueryListener {
override fun beforeQuery(sql: String, name: java.util.Optional<String>) {}
override fun afterQuery(event: QueryEvent) {
println(event.interpolatedSql())
}
override fun failedQuery(event: QueryEvent) {
System.err.println("Failed: ${event.interpolatedSql()}")
}
}

For listener configuration, see Transactors.

Patterns

Slow query detection

fun slowQueryDetector(threshold: Duration): QueryListener =
object : QueryListener {
override fun beforeQuery(sql: String, name: java.util.Optional<String>) {}
override fun afterQuery(event: QueryEvent) {
if (event.elapsed() > threshold) {
System.err.println("SLOW QUERY [${event.elapsed().toMillis()}ms]: ${event.interpolatedSql()}")
}
}
override fun failedQuery(event: QueryEvent) {}
}

Micrometer integration

fun metricsListener(/* registry: MeterRegistry */): QueryListener =
object : QueryListener {
override fun beforeQuery(sql: String, name: java.util.Optional<String>) {}
override fun afterQuery(event: QueryEvent) {
// registry.timer("db.query",
// "name", event.name(),
// "outcome", "success"
// ).record(event.elapsed())
}
override fun failedQuery(event: QueryEvent) {
// registry.timer("db.query",
// "name", event.name(),
// "outcome", "failure"
// ).record(event.elapsed())
}
}

OpenTelemetry

For tracing and metrics, see the OpenTelemetry module. It provides OtelQueryListener (automatic span creation with semantic conventions), PoolMetrics (HikariCP gauges), and a Grafana dashboard template.