Tracing with otel4s
In this chapter we discuss the doobie-otel4s module, which adds OpenTelemetry tracing to doobie through otel4s.
Adding the Dependency
libraryDependencies += "org.typelevel" %% "doobie-otel4s" % "1.0.0-RC13"
In addition to doobie-otel4s you will need:
- an
otel4sSDK setup in your application - a
TracerProvider[F], whichdoobie-otel4suses to create spans - a
Transactor[F]that you want to wrap
Setting Up
Tracing is enabled by wrapping a normal doobie transactor with TracedTransactor.create.
import cats.effect.{Async, IO, LiftIO}
import org.typelevel.doobie._
import org.typelevel.doobie.implicits._
import org.typelevel.doobie.otel4s._
import org.typelevel.otel4s.trace.TracerProvider
import org.typelevel.otel4s.semconv.attributes.DbAttributes
object Main {
def tracedTransactor[F[_] : Async : TracerProvider : LiftIO]: F[Transactor[F]] = {
val base =
Transactor.fromDriverManager[F](
driver = "org.postgresql.Driver",
url = "jdbc:postgresql:app",
user = "postgres",
password = "password",
logHandler = None
)
val config =
TracingConfig.recommended(
dbSystemName = DbAttributes.DbSystemNameValue.Postgresql,
dbNamespace = "app"
)
TracedTransactor.create(base, config, logHandler = None)
}
def program(implicit tp: TracerProvider[IO]): IO[Unit] =
tracedTransactor[IO].flatMap { xa =>
sql"select 42".query[Int].unique.transact(xa).void
}
}
With TracingConfig.recommended(...), spans include:
db.system.namedb.namespacedb.operation.namedb.query.textonly for parameterized statements
By default, span names come from db.query.summary when it is present. Otherwise the interpreter uses JDBC operation names such as executeQuery or executeUpdate.
How It Works
For each traced operation the interpreter builds span data in this order:
- Start from
TracingConfig.constAttributes. - Apply
TracingConfig.captureQueryto decide whether to add:db.query.textdb.query.parameter.*db.operation.batch.size
- Read the doobie label, if there is one, and pass it to
TracingConfig.attributesExtractor. - Analyze the raw SQL with
TracingConfig.queryAnalyzer. - Compute an optional custom span name with
TracingConfig.spanNamer. - If no custom name is produced, fall back to JDBC operation names.
The main extension points are:
AttributesExtractor, which reads doobie labelsQueryAnalyzer, which reads SQL textSpanNamer, which chooses the final span name
Recommended Configuration
TracingConfig.recommended(dbSystemName, dbNamespace) provides a default configuration.
It configures:
tracerScopeName = "doobie"defaultSpanName = "doobie:exec"constAttributes = db.system.name + db.namespacecaptureQuery = QueryCaptureConfig.recommendedattributesExtractor = AttributesExtractor.jsonqueryAnalyzer = QueryAnalyzer.noopspanNamer = SpanNamer.fromAttribute(DbAttributes.DbQuerySummary)
In practice this means:
- plain doobie queries are traced immediately
- query text is only recorded for parameterized queries
- parameters are not recorded
- if you use the otel4s syntax helpers described below, the
db.query.summaryattribute becomes the span name
Query Annotations
The module provides helpers on Fragment for attaching tracing metadata to queries and updates.
import org.typelevel.doobie._
import org.typelevel.doobie.implicits._
import org.typelevel.doobie.otel4s.syntax.fragment._
import org.typelevel.otel4s.Attribute
import org.typelevel.otel4s.semconv.attributes.DbAttributes
def byId(id: Int) =
sql"select name from person where id = $id"
.queryWithSummary[String]("select person by id")
def insertPerson(name: String, age: Int) =
sql"insert into person(name, age) values ($name, $age)"
.updateWithAttributes(
DbAttributes.DbQuerySummary("insert person"),
Attribute("app.operation", "create-person")
)
These helpers encode attributes into the doobie label. The default extractor, AttributesExtractor.json, decodes that label back into Attributes during tracing.
The available helpers are:
queryWithSummary- attachesdb.query.summaryattributeupdateWithSummary- attachesdb.query.summaryattributequeryWithAttributes- attaches one or more tracing attributesupdateWithAttributes- attaches one or more tracing attributes
AttributesExtractor.json
The syntax helpers above rely on AttributesExtractor.json.
If you replace the extractor with something incompatible, for example AttributesExtractor.plain(...), the helpers still compile, but their encoded label payload is no longer decoded, so:
db.query.summaryis not recovered from the labelSpanNamer.fromAttribute(...)will not find it- span naming falls back to whatever other strategy you configured
This can be useful, but it should be explicit.
Query Capture Policies
Query capture is controlled by QueryCaptureConfig.
import org.typelevel.doobie.otel4s.QueryCaptureConfig
import org.typelevel.doobie.otel4s.QueryCaptureConfig.{QueryParametersPolicy, QueryTextPolicy}
val captureNothing =
QueryCaptureConfig.disabled
val captureRecommended =
QueryCaptureConfig.recommended
val captureEverything =
QueryCaptureConfig(
queryTextPolicy = QueryTextPolicy.Always,
queryParametersPolicy = QueryParametersPolicy.All
)
The policies are:
QueryTextPolicy.None- does not recorddb.query.textQueryTextPolicy.ParameterizedOnly- recordsdb.query.textonly for statements with bound parametersQueryTextPolicy.Always- recordsdb.query.textfor all statementsQueryParametersPolicy.None- does not record bound parametersQueryParametersPolicy.NonBatchOnly- records parameters only for non-batch operationsQueryParametersPolicy.All- records parameters for both non-batch and batch operations
Notes
recommendedcaptures query text only for parameterized statements and does not record parametersQueryTextPolicy.Alwaysfordb.query.textis only appropriate if raw SQL is acceptable from a privacy and cardinality perspective- parameter capture is usually only appropriate in controlled environments, because values often contain sensitive information or high-cardinality data
QueryParametersPolicy.Allshould be used with care, especially for batch operations
Span Naming
SpanNamer controls how a traced operation gets its final name.
The built-in strategies are:
SpanNamer.noop- does not provide a custom span nameSpanNamer.fromQueryLabel- uses the raw doobie label as the span nameSpanNamer.fromAttribute(key)- uses the value of the given extracted attribute as the span nameSpanNamer.fromQueryMetadata- builds the span name from QueryAnalyzer.QueryMetadata
SpanNamer.fromAttribute
This is the default strategy used by TracingConfig.recommended(...).
import org.typelevel.doobie.otel4s.SpanNamer
import org.typelevel.otel4s.semconv.attributes.DbAttributes
val namer = SpanNamer.fromAttribute(DbAttributes.DbQuerySummary)
It checks extracted attributes for the given key.
- if the attribute exists, its value becomes the span name
- if it does not exist, it returns
None - if attribute extraction failed, it also returns
None
When it returns None, the interpreter falls back to JDBC operation names.
SpanNamer.fromQueryMetadata
This strategy builds names from QueryAnalyzer.QueryMetadata.
Its precedence is:
querySummaryoperationName + collectionNameoperationName + storedProcedureNameoperationNamecollectionNameorstoredProcedureName
Use this when span names should come from SQL analysis rather than from doobie labels.
Composing Naming Strategies
SpanNamer instances compose with orElse.
import org.typelevel.doobie.otel4s.SpanNamer
import org.typelevel.otel4s.AttributeKey
import org.typelevel.otel4s.semconv.attributes.DbAttributes
val legacyLabelKey = AttributeKey[String]("db.query.legacy_label")
val namer =
SpanNamer
.fromAttribute(DbAttributes.DbQuerySummary)
.orElse(SpanNamer.fromQueryMetadata)
.orElse(SpanNamer.fromAttribute(legacyLabelKey))
.orElse(SpanNamer.fromQueryLabel)
This lets you define naming precedence explicitly.
Customizing the Extractor
AttributesExtractor turns the raw doobie label into typed attributes.
The built-ins are:
AttributesExtractor.jsonAttributesExtractor.plain(key)
plain(key) stores the label as a single string attribute:
import org.typelevel.doobie.otel4s.AttributesExtractor
import org.typelevel.otel4s.AttributeKey
val extractor =
AttributesExtractor.plain(AttributeKey[String]("db.query.label"))
This is useful when:
- you already use human-readable labels
- you want labels preserved as attributes
- you do not use the JSON-encoded helper syntax
Like SpanNamer, extractors compose with orElse.
import org.typelevel.doobie.otel4s.AttributesExtractor
import org.typelevel.otel4s.AttributeKey
val legacyLabelKey = AttributeKey[String]("db.query.legacy_label")
val extractor =
AttributesExtractor
.json
.orElse(AttributesExtractor.plain(legacyLabelKey))
This means:
- first try to decode a helper-produced JSON payload
- if that fails, keep the raw label as a fallback attribute
Customizing TracingConfig
All behavior is configured through TracingConfig.
import org.typelevel.doobie.otel4s._
import org.typelevel.otel4s.{Attribute, Attributes}
import org.typelevel.otel4s.semconv.attributes.DbAttributes
val config =
TracingConfig
.recommended(
dbSystemName = DbAttributes.DbSystemNameValue.Postgresql,
dbNamespace = "app"
)
.withTracerScopeName("com.example.doobie")
.withDefaultSpanName("db.exec")
.withCaptureQuery(QueryCaptureConfig.disabled)
.withAttributesExtractor(AttributesExtractor.json)
.withQueryAnalyzer(QueryAnalyzer.noop)
.withSpanNamer(SpanNamer.fromAttribute(DbAttributes.DbQuerySummary))
.addConstAttributes(Attributes(Attribute("system.module", "payments")))
The main customization points are:
withTracerScopeName- changes the tracer scope name used for spans created by doobiewithDefaultSpanName- changes the initial span name used before JDBC operation naming is appliedwithConstAttributes- replaces the set of attributes added to every spanaddConstAttributes- appends more attributes to the existing constant attributeswithCaptureQuery- changes how SQL text and parameters are recordedwithAttributesExtractor- changes how doobie labels are decoded into attributeswithQueryAnalyzer- changes how SQL text is analyzed into query metadatawithSpanNamer- changes how the final span name is chosen
New Syntax and Legacy Labels Together
When migrating existing code it is common to have both of these:
- new code using
queryWithSummaryorqueryWithAttributes - old code using raw doobie labels such as
queryWithLabel("find-user")
In this case, use a composed extractor and a composed namer.
import org.typelevel.doobie.otel4s._
import org.typelevel.otel4s.AttributeKey
import org.typelevel.otel4s.semconv.attributes.DbAttributes
val legacyLabelKey = AttributeKey[String]("db.query.legacy_label")
val config =
TracingConfig
.recommended(
dbSystemName = DbAttributes.DbSystemNameValue.Postgresql,
dbNamespace = "app"
)
.withAttributesExtractor(
AttributesExtractor
.json
.orElse(AttributesExtractor.plain(legacyLabelKey))
)
.withSpanNamer(
SpanNamer
.fromAttribute(DbAttributes.DbQuerySummary)
.orElse(SpanNamer.fromAttribute(legacyLabelKey))
)
With this setup:
- helper-generated summaries still win
- raw legacy labels remain visible
- unlabeled queries still fall back to JDBC operation names
SQL Analysis with QueryAnalyzer
QueryAnalyzer is a separate extension point for deriving structured metadata from SQL text.
It returns QueryAnalyzer.QueryMetadata, which may contain:
queryTextoperationNamecollectionNamestoredProcedureNamequerySummary
Unlike AttributesExtractor, which works from labels, QueryAnalyzer works from the SQL statement itself.
import org.typelevel.doobie.otel4s.QueryAnalyzer
val simpleAnalyzer =
new QueryAnalyzer {
def analyze(sql: String): Option[QueryAnalyzer.QueryMetadata] =
if (sql.trim.toLowerCase.startsWith("select"))
Some(
QueryAnalyzer.QueryMetadata(
queryText = Some(sql),
operationName = Some("SELECT"),
collectionName = None,
storedProcedureName = None,
querySummary = Some("SELECT")
)
)
else None
}
Analyzers also compose with orElse, so you can define a fallback chain.
The default analyzer is
QueryAnalyzer.noop, which does not produce any metadata. If you want SQL analysis, a common choice is SqlQueryAnalyzer from OpenTelemetry Java Instrumentation.
Using QueryAnalyzer for Span Naming
To use SQL analysis for naming, pair the analyzer with SpanNamer.fromQueryMetadata.
import org.typelevel.doobie.otel4s._
import org.typelevel.otel4s.semconv.attributes.DbAttributes
val config =
TracingConfig
.recommended(
dbSystemName = DbAttributes.DbSystemNameValue.Postgresql,
dbNamespace = "app"
)
.withQueryAnalyzer(simpleAnalyzer)
.withSpanNamer(
SpanNamer
.fromQueryMetadata
.orElse(SpanNamer.fromAttribute(DbAttributes.DbQuerySummary))
)
With this configuration:
- SQL-derived metadata is used first
- then label-derived
db.query.summary - finally JDBC operation names
OpenTelemetry Java Instrumentation
If your application already depends on OpenTelemetry Java instrumentation, the SqlQueryAnalyzer can be adapted to doobie’s QueryAnalyzer.
This is useful when:
- you already use OpenTelemetry Java agent or instrumentation libraries elsewhere
- you want query summaries to match the logic used by OpenTelemetry Java instrumentation
- you want
db.query.summary-based naming without hard-coding summaries on every query
Wrap the Java analyzer and convert null values into Option.
import org.typelevel.doobie.otel4s._
import io.opentelemetry.instrumentation.api.incubator.semconv.db.{SqlDialect, SqlQuery, SqlQueryAnalyzer}
import org.typelevel.otel4s.semconv.attributes.DbAttributes
def fromOtelJava(delegate: SqlQueryAnalyzer): QueryAnalyzer =
new QueryAnalyzer {
private val dialect = SqlDialect.DOUBLE_QUOTES_ARE_IDENTIFIERS
def analyze(sql: String): Option[QueryAnalyzer.QueryMetadata] =
Option(delegate.analyzeWithSummary(sql, dialect)).map { q: SqlQuery =>
QueryAnalyzer.QueryMetadata(
queryText = Option(q.getQueryText),
operationName = Option(q.getOperationName),
collectionName = Option(q.getCollectionName),
storedProcedureName = Option(q.getStoredProcedureName),
querySummary = Option(q.getQuerySummary)
)
}
}
val sqlQueryAnalyzer: SqlQueryAnalyzer = SqlQueryAnalyzer.create(true)
val config: TracingConfig =
TracingConfig
.recommended(
dbSystemName = DbAttributes.DbSystemNameValue.Postgresql,
dbNamespace = "app"
)
.withQueryAnalyzer(fromOtelJava(sqlQueryAnalyzer))
.withSpanNamer(
SpanNamer
.fromQueryMetadata
.orElse(SpanNamer.fromAttribute(DbAttributes.DbQuerySummary))
)
Naming Order
A common ordering is:
SpanNamer
.fromQueryMetadata
.orElse(SpanNamer.fromAttribute(DbAttributes.DbQuerySummary))
This means:
- use the analyzer-derived summary first
- if the analyzer does not recognize the query, fall back to explicit query summaries attached via doobie syntax helpers
If you want manual summaries to override analyzer results, reverse the order.
Caveats
- unlabeled queries are still traced; they just rely on SQL analysis or JDBC fallback naming
AttributesExtractor.jsononly works for labels encoded as JSON attributesQueryAnalyzerdoes not currently add analyzed fields as span attributes by itself; it feeds naming throughSpanNamer- capturing parameters can expose secrets, tokens, emails, IDs, or other sensitive values
- high-cardinality span names are usually a bad idea; prefer summaries such as
SELECT usersover raw SQL