Instrumentation Recipe
How to add per-stage tracing to a responder, using the pattern established for SearchResponderV2
(Gravsearch). Follow it to instrument a second vertical without re-deriving the design. The
reference implementation lives in
modules/webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala.
The pattern in one sentence: open one root INTERNAL span named after the vertical, wrap each
pipeline stage in a child span via a small stageSpan helper, attach a bounded shape fingerprint
to the root as an attribute and the raw submitted payload to the root as an event, and make
failures and interruptions legible without widening what reaches span status or metric labels.
1. Wire Tracing into the service
Declare tracing as an abstract member of the trait (not only a constructor param of the live
impl) so that any default methods on the trait can open spans before delegating:
trait SearchResponderV2 {
// Telemetry used to open the root span and its per-stage child spans. Declared as an abstract
// member so the trait's default methods can open the root + parse spans before delegating.
protected def tracing: Tracing
// ...
}
Provide it in the live class and add Tracing to the module's Dependencies alias so
ZLayer.derive picks it up from the environment:
final class SearchResponderV2Live(
// ...other deps...
override protected val tracing: Tracing,
) extends SearchResponderV2
// SearchResponderV2Module.scala
type Dependencies = /* ...other deps... */ & Tracing
2. Add the stageSpan helper
Copy this helper (companion object of the responder). It opens an INTERNAL span that is
automatically a child of whatever span is active on the fiber, records a sanitized error on
failure, and marks interruptions — then maps the library status to UNSET so the library's own
status-setter is a no-op and never overwrites what we set.
def stageSpan[A](tracing: Tracing, name: String)(effect: Task[A]): Task[A] =
tracing.span(name, SpanKind.INTERNAL, statusMapper = unsetOnFailure) {
tracing.getCurrentSpanUnsafe.flatMap { span =>
effect
.tapErrorCause(cause => ZIO.succeed(markSanitizedError(span, name, cause)))
.onExit {
case Exit.Failure(cause) if cause.isInterrupted =>
ZIO.succeed {
val _ = span.setAttribute("gravsearch.exit_reason", "interrupted")
val _ = span.setStatus(StatusCode.ERROR, "interrupted")
}
case _ => ZIO.unit
}
}
}
A thin protected final wrapper on the trait lets methods call stageSpan("name") { ... } without
passing tracing each time:
protected final def stageSpan[A](name: String)(effect: Task[A]): Task[A] =
SearchResponderV2.stageSpan(tracing, name)(effect)
The root span is opened with the same helper — there is no separate root helper. Open the root, then open each stage inside it; FiberRef-carried context makes them children automatically.
3. Name the spans
- Root span = the vertical name:
gravsearch. - Stage spans =
<vertical>.<stage>, lowercase, dotted, from a bounded set:gravsearch.parse,gravsearch.type_inspection,gravsearch.prequery.generate,gravsearch.prequery.execute,gravsearch.mainquery.generate,gravsearch.mainquery.execute,gravsearch.result_transform. - Never put variable data (IRIs, counts, user input) in a span name — that explodes cardinality. Variable data goes in attributes, bounded data goes in the shape (step 5).
4. Wrap each stage — and omit stages that did not run
Wrap each stage effect in stageSpan. For example, the main-query trio runs only when the prequery
returned at least one resource — keep those spans inside the conditional so an empty result
simply has no main-query spans, rather than zero-duration placeholders:
mainQueryResults <-
if (mainResourceIris.nonEmpty) {
for {
sparql <- stageSpan("gravsearch.mainquery.generate")(/* build SPARQL */)
response <- stageSpan("gravsearch.mainquery.execute")(/* triplestore.query(...) */)
result <- stageSpan("gravsearch.result_transform")(/* permission filter + assemble */)
} yield result
} else {
ZIO.attempt(/* empty result */)
}
Absent spans are a documented, legible signal — see the runbook's
four absent-data topologies.
The triplestore CLIENT span nests automatically under the *.execute stage because it runs inside
that stage's effect.
5. Attach a bounded shape as an attribute, never raw payload
The single most important attribute rule: never set raw query text, instance IRIs, or user IDs as span attributes. Derive a bounded shape from the parsed query and attach that to the root span instead — the raw text has a different home, see step 6:
def setShapeOnRoot(tracing: Tracing, query: ConstructQuery, resultType: QueryResultType): UIO[Unit] =
tracing.getCurrentSpanUnsafe.map { span =>
val shape = queryShape(query, resultType)
val _ = span.setAttribute("gravsearch.query.shape", shape.label)
val _ = span.setAttribute("gravsearch.schema_predicates", shape.predicates.mkString(","))
shape.flags.foreach { case (flag, value) => val _ = span.setAttribute(s"gravsearch.shape.$flag", value) }
}
Split the cardinality deliberately:
| Kind | Example | Cardinality | Use as |
|---|---|---|---|
| Composite shape label | gravsearch.query.shape = resource-list\|has_filter\|patterns:4-7\|joins:1 |
Bounded (enums + bucketed counts) | Span attribute, safe as a metric label |
| Per-flag booleans | gravsearch.shape.has_filter = true |
Bounded (fixed flag set) | Span attribute (for TraceQL filtering) |
| Ontology predicate names | gravsearch.schema_predicates = hasTitle,isPartOf |
Higher (but ontology-bounded, never instance IRIs) | Span attribute only — never a metric label |
Bucket open-ended counts (pattern count, join count) into ranges (0, 1, 2-3, 4-7, 8+) so
the shape label stays bounded. Set the shape on the root immediately after parse succeeds.
6. Capture the raw payload as an event
The shape tells you what kind of request this was; it does not tell you which request, so a slow trace cannot be reproduced from the shape alone. Capture the raw submitted payload — the query string, the request body — as a span event on the root span:
def recordQueryOnRoot(tracing: Tracing, query: IRI): UIO[Unit] =
tracing.getCurrentSpanUnsafe.map { span =>
val _ = span.addEvent("gravsearch.query", Attributes.of(DbAttributes.DB_QUERY_TEXT, query))
}
Three rules, all load-bearing:
Event, not attribute. The Alloy otelcol.connector.spanmetrics dimension list reads span
attributes, so raw payload in an attribute is one config line away from becoming an unbounded
Prometheus label. An event attribute is unreachable from there — the cardinality accident becomes
impossible rather than merely forbidden — while staying fully searchable in Tempo via the event:name
/ event.<key> scope.
Root span only, and never inside stageSpan. Stage spans are asserted to carry no events at
all; that assertion is what locks the sanitized-error contract in step 7. Putting payload on a stage
span would quietly retire the lock.
Record it before the first stage. The shape is derived after parsing, so a payload that fails to parse never gets one. Capturing at entry means the least-diagnosable trace gains the most, at no extra cost.
Naming follows OpenTelemetry Semantic Conventions where they reach:
| Thing | Name | Why |
|---|---|---|
| Payload attribute | db.query.text (use the generated DbAttributes.DB_QUERY_TEXT) |
The stable semconv key for query text, and the rename target of the deprecated db.statement. Pairs with the shape attribute, which is db.query.summary semantics |
| Event name | <vertical>.query, e.g. gravsearch.query |
Semconv defines no event name for "payload captured", so this one is ours. Keep it bounded and predictable |
Two caveats worth stating rather than glossing: semconv scopes db.* to database queries, and the
statement actually sent to the database here is the generated SPARQL, not the client's Gravsearch;
and the event carries unredacted user input, which is a deliberate, signed-off position for
Gravsearch (search terms, not record content) — re-decide it per vertical rather than inheriting it.
7. Errors and interruptions without leaks
Use SanitizedSpan.withSpan rather than writing this yourself.
org.knora.webapi.slice.infrastructure.SanitizedSpan implements everything in this section — the
UNSET failure mapper, the sanitized ERROR status, error.type, and the interruption branch — and
hands your effect the raw span so it can attach its own bounded attributes:
SanitizedSpan.withSpan(tracing, "admin.sparql.query", "sparql_passthrough.exit_reason") { span =>
effect // attach bounded attributes to `span`; failures and interruptions are handled for you
}
The exit-reason key is the caller's, because it belongs to the caller's attribute namespace and is
what its own TraceQL queries select on. SearchResponderV2.stageSpan is a two-line wrapper over this
that supplies gravsearch.exit_reason. The rest of this section explains what the helper does and
why, so that a change to it is made knowingly — it is not an invitation to copy the snippets.
The error handling has one load-bearing invariant. zio-telemetry writes cause.prettyPrint into
the span status description on the ERROR branch — and for a SPARQL failure that string echoes the
offending FILTER literal (user data). To prevent the leak, the failure status mapper must map to
UNSET (which the OTel SDK no-ops), and we set our own sanitized status separately:
// LOAD-BEARING: must map to UNSET, never ERROR — UNSET is what stops cause.prettyPrint
// (which echoes the user's FILTER literal) from reaching the span status description.
private val unsetOnFailure: StatusMapper[Throwable, Any] =
StatusMapper.failureNoException[Throwable](_ => StatusCode.UNSET)
/** Writes the sanitized ERROR status ("<stage>: <Class>", no message) + error.type onto the span. */
private def markSanitizedError(span: Span, stage: String, cause: Cause[Throwable]): Unit = {
val kind = cause.failureOption.map(_.getClass.getSimpleName).getOrElse("defect")
val _ = span.setStatus(StatusCode.ERROR, s"$stage: $kind")
cause.failureOption.foreach { e => val _ = span.setAttribute("error.type", e.getClass.getSimpleName) }
}
- Typed failure → status
ERROR, description exactly"<stage>: <ClassName>"(e.g.gravsearch.prequery.execute: TriplestoreException), pluserror.type. No message, no stacktrace. - Defect → status
ERROR, description exactly"<stage>: defect", noerror.type. The status mapper alone does not achieve this:zio-telemetryreaches the mapper throughcause.failureOption, which aCause.Diedoes not have, so a defect falls through to the sameERROR+cause.prettyPrintdefault.SanitizedSpan.withSpantherefore carries a defect across the span boundary as a typed failure and re-throws it as a defect once the span has closed. Callers still observe a defect; the span never sees its message. - Interruption →
<vertical>.exit_reason = interrupted+ statusERROR "interrupted"(set inSanitizedSpan.withSpan'sonExit, which is uninterruptible and so still runs during teardown). OTel has nocancelledstatus, so this attribute is what distinguishes an interrupted query from a typed failure and from a benign empty result. This is the one cause whose status description is still the library's, because a cause cannot be converted without swallowing the interrupt — safe rather than accepted, since an interrupt cause carries no message. Select on the attribute, not the description.
Do not relax the status mapper
Changing unsetOnFailure to map failures to ERROR re-introduces the cause.prettyPrint leak
in one edit. It is guarded by a description-equality test — keep that test.
What this covers, and what it does not
The helper protects the span it opens. Two things outside it are worth knowing, both API-wide and
pre-existing. The outer HTTP SERVER span in DspApiServer uses a StatusMapper.Success[Response],
and nearly every failure arrives there as a 500 response — ztapir's zServerLogic wraps the logic
in .either.resurrect, promoting a defect to a typed failure before the interpreter — so that span
gets ERROR with no description. Only what stays in the routes' error channel (an interrupt, a
non-NonFatal throwable) still takes zio-telemetry's cause.prettyPrint default. And tapir's
default serverLog is not customised, so a defect reaching the server logic is additionally written
to the log at ERROR with its raw message and stacktrace. Sanitizing the span does not sanitize
that line.
Checklist for a new vertical
-
tracingis an abstract member of the trait;Tracingadded to the moduleDependencies. - One root
INTERNALspan named after the vertical; one child span per stage, bounded names. - Stages that may not run are wrapped inside their conditional (no placeholder spans).
- A bounded shape on the root; no raw text / instance IRIs / user IDs as attributes.
- Cardinality split: composite label + booleans are metric-safe; predicate lists are drill-down only.
- Raw payload captured as an event on the root span, before the first stage — never an attribute, never inside
stageSpan;db.query.textfor query text. - Spans opened through
SanitizedSpan.withSpan(failure mapperUNSET, sanitizedERROR+error.type, interruption setsexit_reason). - A test asserting the failure status description equals
"<stage>: <Class>"(no message), and one asserting a defect yields"<stage>: defect"— the two go through different mechanisms. - A test asserting the payload event carries the exact submitted text, on both the success and the parse-failure path.