Enhanced Tracing: New Use Cases
This document lists the concrete use cases unlocked by the add-enhanced-tracing implementation
(TraceCollectorTracing, InMemoryTraceStore, TraceStore, TraceModel). All of these were
impossible before — the previous tracing infrastructure (ConsoleTracing, LangfuseTracing,
NoOpTracing) was write-only: events were emitted outward with no return path.
Background
The existing Tracing trait and its implementations are pure fire-and-forget sinks. Once a
TraceEvent is emitted, the data is gone from the process. There was no way to read back, query,
or act on trace data within the JVM.
The new TraceCollectorTracing + TraceStore layer adds structured, queryable, in-process
trace storage as a standard Tracing implementation — composable with any existing backend via
TracingComposer.combine().
These types ship in llm4s-observability ("org.llm4s" %% "llm4s-observability"), with
Langfuse; the Tracing contract, ConsoleTracing and NoOpTracing stay in llm4s-core.
Package names are unchanged. On 0.4.1 and earlier they are part of llm4s-core.
Use Cases
1. In-Process Trace Retrieval by ID
InMemoryTraceStore.getTrace(traceId) and getSpans(traceId) return structured data after
agent execution completes.
1
2
3
4
5
6
val store = InMemoryTraceStore()
val spans: Result[List[Span]] = for {
tracer <- TraceCollectorTracing(store)
agent <- Agent.builder("assistant", client).withTools(tools).withTracing(tracer).build()
_ <- agent.run("query")
} yield store.getSpans(tracer.traceId) // the run's `graph.*` events, as `custom:graph.*` spans
2. Deterministic Agent Testing
Wire TraceCollectorTracing(InMemoryTraceStore()) in tests and assert directly on recorded
spans — no mocking of external systems, no console output parsing.
1
2
3
4
5
6
7
8
9
10
11
12
val store = InMemoryTraceStore()
val tracer = TraceCollectorTracing(store).getOrElse(fail("tracing init failed"))
val agent = Agent.builder("assistant", client).withTools(tools).withTracing(tracer).build()
.getOrElse(fail("agent build failed"))
agent.run("query") shouldBe a[Right[_, _]]
// one completed `call-tool` task per tool call the model made
val toolCalls = store.getSpans(tracer.traceId).filter { s =>
s.name == "custom:graph.task_completed" &&
s.attributes.get("nodeId").flatMap(_.asString).exists(_.endsWith("/call-tool"))
}
toolCalls should have size 2
3. Time-Range Queries
Retrieve all traces recorded within a time window.
1
2
val query = TraceQuery.withTimeRange(Instant.now.minusSeconds(3600), Instant.now)
val page = store.queryTraces(query)
4. Status-Based Filtering
Surface failed traces across all runs without touching external systems.
1
2
val query = TraceQuery.withStatus(SpanStatus.Error(""))
val errors = store.queryTraces(query).traces
5. Metadata-Based Trace Lookup (Experiment Grouping)
Tag traces at creation with arbitrary key-value metadata, then retrieve them by tag. Enables A/B experiment grouping entirely within the process.
1
2
3
val trace = Trace.start(Map("experiment" -> "v2", "user_id" -> userId))
// ...
val results = store.searchByMetadata("experiment", "v2") // List[TraceId]
6. Latency Measurement Per Span Kind
Every Span carries startTime and endTime. Filter by SpanKind and compute durations.
1
2
3
4
store.getSpans(traceId)
.filter(_.kind == SpanKind.ToolCall)
.flatMap(s => s.endTime.map(e => e.toEpochMilli - s.startTime.toEpochMilli))
.sum // total milliseconds spent in tool calls
7. Structured Error Attribution
SpanStatus.Error(message) makes failed spans first-class queryable objects, with their error
message, timing, and all typed attributes.
1
2
3
store.getSpans(traceId)
.collect { case s if s.status.isInstanceOf[SpanStatus.Error] => s }
.map(s => s.attributes("error_type").asString)
8. Cache Hit Rate Measurement
Cache spans carry hit: Boolean, similarity, and threshold. Compute cache hit rate across
a session. Directly supports the semantic caching feature.
1
2
3
4
val cacheSpans = store.getSpans(traceId).filter(_.kind == SpanKind.Cache)
val hitRate = cacheSpans.count(
_.attributes.get("hit").flatMap(_.asBoolean).contains(true)
).toDouble / cacheSpans.size
9. Per-Kind Cost and Token Breakdowns
LlmCall spans carry prompt_tokens, completion_tokens; Embedding spans carry
input_count; Rag spans carry total_cost_usd. Sum them per operation type.
1
2
3
4
val totalPromptTokens = store.getSpans(traceId)
.filter(_.kind == SpanKind.LlmCall)
.flatMap(_.attributes.get("prompt_tokens").flatMap(_.asLong))
.sum
10. Pluggable Storage Backend
TraceStore is a trait. Implement it backed by PostgreSQL, Redis, DynamoDB, or any store
without modifying any tracing code. InMemoryTraceStore is the reference implementation.
1
2
3
4
5
class PostgresTraceStore(ds: DataSource) extends TraceStore {
override def saveTrace(trace: Trace): Unit = // ...
override def queryTraces(query: TraceQuery): TracePage = // ...
// ...
}
11. Span and Trace JSON Round-Trip
TraceModelJson provides both serialization and deserialization for the first time. Enables
writing spans to disk and reloading them, or forwarding structured span data to a custom HTTP
backend.
1
2
3
4
5
6
7
8
val json = span.toJson // ujson.Value
val parsed = TraceModelJson.parseSpan(json) // Result[Span]
// Handle the result
parsed match {
case Right(s) => println(s.name)
case Left(error) => println(s"Parse error: ${error.field} — ${error.reason}")
}
12. Trace Deletion and Lifecycle Management
store.deleteTrace(traceId) removes a trace and all its spans atomically. store.clear() wipes
everything. Enables test fixture cleanup between cases and on-demand removal of sensitive traces.
1
override def afterEach(): Unit = store.clear()
13. Dataset Construction for LLM Evaluation
Query LlmCall spans filtered by metadata tag to collect structured (input, output, model,
token count) records. This is a direct prerequisite for the datasets and evaluators planned in
later phases of the Observability & Evaluation initiative.
1
2
3
4
5
6
7
store.searchByMetadata("dataset", "eval-set-v1")
.flatMap(traceId => store.getSpans(traceId).filter(_.kind == SpanKind.LlmCall))
.map(s => EvalRecord(
model = s.attributes("model").asString.get,
completionId = s.attributes("completion_id").asString.get,
promptTokens = s.attributes("prompt_tokens").asLong.get
))
14. Sub-Span Event Timeline
Span.events: List[SpanEvent] allows point-in-time annotations within a single span, each with
a timestamp and typed attributes — without creating child spans.
1
2
3
span
.withEvent(SpanEvent("cache-lookup-started"))
.withEvent(SpanEvent("chunks-retrieved", Map("count" -> SpanValue.LongValue(5L))))