Logging

In this chapter we discuss how to log statement execution and timing.

Setting Up

import doobie._
import doobie.implicits._
import doobie.util.log.LogEvent
import cats.effect._
import cats.implicits._

// This is just for testing. Consider using cats.effect.IOApp instead of calling
// unsafe methods directly.
import cats.effect.unsafe.implicits.global

Logging in doobie is done via a LogHandler instance that is setup when we create our Transactor.

The LogHandler interface looks like this:

trait LogHandler[M[_]]{
  def run(logEvent: LogEvent): M[Unit]
}

The run method will be called for all queries executed via the Transactor. It will receive a LogEvent which it can handle it inside the M effect (e.g. cats.effect.IO).

For example, let’s construct a LogHandler that only prints out the SQL executed:

val printSqlLogHandler: LogHandler[IO] = new LogHandler[IO] {
  def run(logEvent: LogEvent): IO[Unit] = 
    IO { 
      println(logEvent.sql)
    }
}
// A transactor that gets connections from java.sql.DriverManager and executes blocking operations
// on an our synchronous EC. See the chapter on connection handling for more info.
val xa = Transactor.fromDriverManager[IO]
  .apply(
    driver = "org.postgresql.Driver",     // JDBC driver classname
    url = "jdbc:postgresql:world",        // Connect URL - Driver specific
    user = "postgres",                    // Database user name
    password = "password",                // Database password
    logHandler = Some(printSqlLogHandler) // Here we specify our log event handler
  )

We’re still playing with the country table, shown here for reference.

CREATE TABLE country (
  code       character(3)  NOT NULL,
  name       text          NOT NULL,
  population integer       NOT NULL,
  gnp        numeric(10,2)
  -- more columns, but we won't use them here
)
val pattern = "U%"

sql"select name, code from country where name like $pattern"
    .query[(String, String)]
    .to[List]
    .transact(xa)
    .unsafeRunSync()

By now in the standard output above we you can see this line printed out too (from printSqlLogHandler)

select name, code from country where name like ?

Writing Your Own LogHandler

LogEvent has three constructors, all of which provide the SQL string and argument list.

  • Success indicates successful execution and result processing, and provides timing information for both.
  • ExecFailure indicates that query execution failed, due to a key violation for example. This constructor provides timing information only for the (failed) execution as well as the raised exception.
  • ProcessingFailure indicates that execution was successful but resultset processing failed. This constructor provides timing information for both execution and (failed) processing, as well as the raised exception.

See the Scaladoc for details on this data type.

Note that you can attach a String label when constructing a Query/Update object by calling .queryWithLabel/.updateWithLabel instead of .query/.update:

sql"select name, code from country where name like $pattern"
    .queryWithLabel[(String, String)]("select_country_by_pattern")
    .to[List]
    .transact(xa)
    .unsafeRunSync()

You can find the label in the LogEvent.

Passing additional context to your LogHandler

You can use cats-effect’s IOLocal to pass additional context to the LogHandler (e.g. tracing context), as the following example shows:

import cats.effect.{IOLocal, Ref}
import doobie.util.log.Success

def users = List.range(0, 4).map(n => s"user-$n")

def program: IO[List[String]] =
  for {
    // define an IOLocal where we store the user which caused the query to be run  
    currentUser <- IOLocal("")
    // store all successful sql here, for all users
    successLogsRef <- Ref[IO].of(List.empty[String])
    xa = Transactor.fromDriverManager[IO](
        driver = "org.h2.Driver",
        url = "jdbc:h2:mem:queryspec;DB_CLOSE_DELAY=-1",
        user = "sa", 
        password = "",
        logHandler = Some(new LogHandler[IO] {
          def run(logEvent: LogEvent): IO[Unit] =
            currentUser.get.flatMap(user => successLogsRef.update(logs => s"sql for user $user: '${logEvent.sql}'" :: logs))
        })
      )
    // run a bunch of queries
    _ <- users.parTraverse(user =>
      for {
        _ <- currentUser.set(user)
        _ <- sql"select 1".query[Int].unique.transact(xa)
      } yield ()
    )
    // return collected log messages
    logs <- successLogsRef.get
  } yield logs

program.unsafeRunSync().sorted
// res1: List[String] = List(
//   "sql for user user-0: 'select 1'",
//   "sql for user user-1: 'select 1'",
//   "sql for user user-2: 'select 1'",
//   "sql for user user-3: 'select 1'"
// )

Caveats

Logging is not yet supported for streaming (.stream or YOLO mode’s .quick).

Note that the LogHandler#run invocation is part of your ConnectionIO program, and it is called synchronously. Most back-end loggers are asynchronous so this is unlikely to be an issue, but do take care not to spend too much time in your handler.

Further note that the handler is not transactional; anything your logger does stays done, even if the transaction is rolled back. This is only for diagnostics, not for business logic.

The source code for this page can be found here.