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: String?) {
println("Executing: $sql")
}
override fun afterQuery(event: QueryEvent) {
println("${event.name()} completed in ${event.elapsed().toMillis()}ms")
}
override fun failedQuery(event: QueryEvent) {
System.err.println("${event.name()} failed after ${event.elapsed().toMillis()}ms: ${event.error()?.message}")
}
}

Attaching to a Strategy

Attach a listener to a Strategy so all operations through that transactor are observed:

val strategy: Strategy =
Transactor.defaultStrategy()
.replaceListener(logger)

Per-Operation Listeners

You can also attach a listener to a specific operation:

operation.withListener(myListener).transact(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: Operation<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() transfers a timeout to the database via setQueryTimeout():

Fragment.of("SELECT * FROM large_table")
.query(codec.all())
.timeout(Duration.ofSeconds(10))
.transact(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: String?) {}
override fun afterQuery(event: QueryEvent) {
println(event.interpolatedSql())
}
override fun failedQuery(event: QueryEvent) {
System.err.println("Failed: ${event.interpolatedSql()}")
}
}

For strategy merging and per-transaction overrides, see Transactors — Strategies.

Patterns

Slow Query Detection

fun slowQueryDetector(threshold: Duration): QueryListener =
object : QueryListener {
override fun beforeQuery(sql: String, name: 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: 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())
}
}