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:
- Kotlin
- Java
- Scala
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")}")
}
}
QueryListener logger =
new QueryListener() {
@Override
public void beforeQuery(String sql, java.util.Optional<String> name) {
System.out.println("Executing: " + sql);
}
@Override
public void afterQuery(QueryEvent event) {
System.out.println(event.name() + " completed in " + event.elapsed().toMillis() + "ms");
}
@Override
public void failedQuery(QueryEvent event) {
System.err.println(
event.name().orElse("unnamed")
+ " failed after "
+ event.elapsed().toMillis()
+ "ms: "
+ event.error().map(Throwable::getMessage).orElse("unknown"));
}
};
object logger extends QueryListener:
override def beforeQuery(sql: String, name: java.util.Optional[String]): Unit =
println(s"Executing: $sql")
override def afterQuery(event: QueryEvent): Unit =
println(s"${event.name().orElse("unnamed")} completed in ${event.elapsed().toMillis}ms")
override def failedQuery(event: QueryEvent): Unit =
System.err.println(s"${event.name().orElse("unnamed")} failed after ${event.elapsed().toMillis}ms: ${event.error().map(_.getMessage).orElse("unknown")}")
Attaching to a transactor
Attach a listener to a Transactor so all operations are observed:
- Kotlin
- Java
- Scala
val tx: Transactor = Transactor.create(config)
val txWithLogging: Transactor = tx.mergeListener(logger)
Transactor withLogging = tx.withListener(logger);
Transactor withBoth = tx.withListener(logger).mergeListener(metrics);
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:
- Kotlin
- Java
- Scala
val users: OperationRead<List<String>> =
sql { "SELECT name FROM users" }
.query(RowCodec.of(PgTypes.text).all())
.named("load-users")
.timeout(Duration.ofSeconds(5))
OperationRead<List<String>> users =
Fragment.of("SELECT name FROM users")
.query(RowCodec.of(PgTypes.text).all())
.named("load-users")
.timeout(Duration.ofSeconds(5));
val users: OperationRead[List[String]] =
sql"SELECT name FROM users"
.queryAll(PgTypes.text)
.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:
- Kotlin
- Java
- Scala
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()}")
}
}
QueryListener debugListener =
new QueryListener() {
@Override
public void beforeQuery(String sql, java.util.Optional<String> name) {}
@Override
public void afterQuery(QueryEvent event) {
// SQL with values inlined for debugging:
// SELECT * FROM users WHERE id = 42
System.out.println(event.interpolatedSql());
}
@Override
public void failedQuery(QueryEvent event) {
System.err.println("Failed: " + event.interpolatedSql());
}
};
object debugListener extends QueryListener:
override def beforeQuery(sql: String, name: java.util.Optional[String]): Unit = ()
override def afterQuery(event: QueryEvent): Unit =
println(event.interpolatedSql())
override def failedQuery(event: QueryEvent): Unit =
System.err.println(s"Failed: ${event.interpolatedSql()}")
For listener configuration, see Transactors.
Patterns
Slow query detection
- Kotlin
- Java
- Scala
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) {}
}
QueryListener slowQueryDetector(Duration threshold) {
return new QueryListener() {
@Override
public void beforeQuery(String sql, java.util.Optional<String> name) {}
@Override
public void afterQuery(QueryEvent event) {
if (event.elapsed().compareTo(threshold) > 0) {
System.err.println(
"SLOW QUERY [" + event.elapsed().toMillis() + "ms]: " + event.interpolatedSql());
}
}
@Override
public void failedQuery(QueryEvent event) {}
};
}
def slowQueryDetector(threshold: Duration): QueryListener =
new QueryListener:
override def beforeQuery(sql: String, name: java.util.Optional[String]): Unit = ()
override def afterQuery(event: QueryEvent): Unit =
if event.elapsed().compareTo(threshold) > 0 then System.err.println(s"SLOW QUERY [${event.elapsed().toMillis}ms]: ${event.interpolatedSql()}")
override def failedQuery(event: QueryEvent): Unit = ()
Micrometer integration
- Kotlin
- Java
- Scala
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())
}
}
QueryListener metricsListener(/* MeterRegistry registry */ ) {
return new QueryListener() {
@Override
public void beforeQuery(String sql, java.util.Optional<String> name) {}
@Override
public void afterQuery(QueryEvent event) {
// registry.timer("db.query",
// "name", event.name(),
// "outcome", "success"
// ).record(event.elapsed());
}
@Override
public void failedQuery(QueryEvent event) {
// registry.timer("db.query",
// "name", event.name(),
// "outcome", "failure"
// ).record(event.elapsed());
}
};
}
def metricsListener( /* registry: MeterRegistry */ ): QueryListener =
new QueryListener:
override def beforeQuery(sql: String, name: java.util.Optional[String]): Unit = ()
override def afterQuery(event: QueryEvent): Unit = ()
// registry.timer("db.query",
// "name", event.name(),
// "outcome", "success"
// ).record(event.elapsed())
override def failedQuery(event: QueryEvent): Unit = ()
// 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.