Observability & Tracing
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-09-30 against the OpenTelemetry Python SDK and langchain-core 1.6.6 on Python 3.12,
Spring AI 2.0.1 with Micrometer on Spring Boot 4.1.1, LangChain4j 1.20.2 with the OpenTelemetry Java API, and the
OpenTelemetry semantic conventions for generative AI, which are still marked as in development (see the
version baseline). The Python tracing code was run with the SDK’s
in-memory span exporter; the Java classes were compiled against the released jars and were not run.
"The assistant gave a wrong answer yesterday" is only actionable if you can see what happened in that request: what the condensed query was, which route was taken, which chunks came back with which scores, what the prompt contained, which model answered, how long each step took and what it cost. That is a trace. Aggregated over all requests, the same data become the metrics that tell you whether the service is healthy. The framework mechanics are in LangChain: Observability with LangSmith, Spring AI: Observability and, for agents in general, Observability for Agents; this page defines what DocsAssistant records and why.
One trace per request
DocsAssistant creates one root span per request (POST /v1/ask) and a child span per stage. The spans carry the
attributes that make a trace searchable and aggregatable:
| Span | Name | Key attributes |
|---|---|---|
Request |
|
|
Condensation |
|
|
Retrieval |
|
|
Rerank |
|
|
Generation |
|
|
Tool call |
|
|
Grounding check |
|
|
The OpenTelemetry GenAI semantic conventions standardise the model and tool spans (gen_ai.operation.name values
such as chat, embeddings and execute_tool, the gen_ai.request., gen_ai.response. and gen_ai.usage.
attributes, and the gen_ai.client.operation.duration and gen_ai.client.token.usage metrics), so that any backend
can render them. They do not yet define retrieval and reranking spans; DocsAssistant uses its own docsassistant.
attributes for those, and keeps them stable so dashboards survive framework upgrades. The conventions are still in
development: pin the version you emit (OTEL_SEMCONV_STABILITY_OPT_IN where an SDK supports it) and expect renames.
Content capture — prompts, retrieved text, answers — is off by default in the conventions and should stay off in production, or be redacted, because it is exactly the personal and confidential data the rest of the service protects. Chunk IDs and scores are enough to reproduce a retrieval later.
Python: spans around each stage
The orchestrator wraps its stages in spans with the OpenTelemetry API; LangChain’s own callbacks can additionally export to LangSmith or, through OpenInference instrumentation, to any OTLP backend:
import time
from contextlib import contextmanager
from langchain_core.documents import Document
from langchain_core.messages import AIMessage
from opentelemetry import trace
tracer = trace.get_tracer("docsassistant")
@contextmanager
def retrieve_span(index: str, top_k: int, filter_hash: str):
with tracer.start_as_current_span(f"retrieve {index}") as span:
span.set_attribute("docsassistant.retrieval.top_k", top_k)
span.set_attribute("docsassistant.retrieval.filter_hash", filter_hash)
result: dict = {}
yield result # the caller stores the documents in result["docs"]
docs: list[Document] = result.get("docs", [])
span.set_attribute("docsassistant.retrieval.returned", len(docs))
span.set_attribute("docsassistant.retrieval.chunk_ids", [d.metadata.get("chunk_id", "") for d in docs])
if not docs:
span.add_event("empty_retrieval")
@contextmanager
def generate_span(model: str):
with tracer.start_as_current_span(f"chat {model}") as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.request.model", model)
state = {"start": time.perf_counter(), "first": None}
yield state # the caller sets state["first"] on the first token
if state["first"] is not None:
span.set_attribute("docsassistant.ttft_ms", round((state["first"] - state["start"]) * 1000, 1))
final: AIMessage | None = state.get("message")
if final is not None and final.usage_metadata:
span.set_attribute("gen_ai.usage.input_tokens", final.usage_metadata["input_tokens"])
span.set_attribute("gen_ai.usage.output_tokens", final.usage_metadata["output_tokens"])
Export with the OTLP exporter to a collector, and from the collector to the backend of your choice. The FastAPI app of
Serving RAG as an API gets its root span from FastAPI’s OpenTelemetry
instrumentation, which is what makes trace.get_current_span() return the request’s trace ID for the traceId field.
The metrics that matter
| Metric | Definition | Why |
|---|---|---|
TTFT P50/P95 |
Time from request to the first |
What users perceive; the first line of the latency budget (latency budget). |
End-to-end P95 |
Time to the |
Capacity and timeouts. |
Tokens and cost per request |
Input, cached input and output tokens; cost from the price table. |
Budget alerts; the effect of caching and cascades (Caching, Latency & Cost). |
Empty-retrieval rate |
Share of requests whose retrieval returned nothing after filtering. |
Missing content, broken filters, ACL mistakes, ingestion outages. |
Citation rate and validity |
Share of answers with at least one citation; share of citations that verify. |
Grounding health; the earliest signal of a prompt or model regression. |
Abstention and handoff rate |
Share of "I could not find this" answers and escalations to a human. |
Coverage gaps; too high means missing content, too low can mean guessing. |
Feedback rate |
Thumbs-down per hundred answers, by route and component. |
User-level quality. |
Cache hit rate |
Per tier. |
Cost; a sudden drop after a deploy means a key change. |
Several of these are ratios; alert on them with a minimum volume, and break them down by route, component, model and
config_version so that a regression can be traced to a change.
Tracing backends: Langfuse, Phoenix and LangSmith
All three accept traces from the service and add LLM-specific views — prompt and completion rendering, token and cost roll-ups, evaluation scores attached to traces, datasets built from traces:
| Backend | Ingestion | Notes |
|---|---|---|
Langfuse |
OTLP endpoint, plus SDKs and a LangChain callback handler. |
Open source, self-hostable; sessions, scores and datasets; a good fit for the sampled judging of Evaluation at Scale. |
Arize Phoenix |
OTLP, with OpenInference instrumentors for LangChain, LlamaIndex and others. |
Open source, self-hostable; strong retrieval and evaluation views. |
LangSmith |
LangChain’s tracing (environment variables), plus OpenTelemetry ingestion. |
Hosted (and self-hosted for enterprise); tight integration with LangChain, LangGraph and its evaluation features. |
Choosing OpenTelemetry as the emission format keeps the choice of backend reversible: the collector can send the same spans to a general APM and to an LLM-specific backend. Whatever the backend, apply the same retention and access rules as for the conversation store if any content is captured.
Spring AI: Micrometer observations
Spring AI instruments ChatClient, advisors, chat models, embedding models, tools and vector stores with Micrometer
observations, which Spring Boot exports as metrics and, with a tracing bridge, as spans — including the
gen_ai.client.operation timer, the gen_ai.client.token.usage counter and execute_tool spans, and vector-store query
spans for the retrieval step. What it does not know is RAG-level outcome, such as an empty retrieval; an advisor adds
that from the retrieved documents:
package com.example.docsassistant.rag.observability;
import java.util.List;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.core.Ordered;
public class RagOutcomeMetricsAdvisor implements CallAdvisor {
private final MeterRegistry meters;
private final DistributionSummary retrieved;
public RagOutcomeMetricsAdvisor(MeterRegistry meters) {
this.meters = meters;
this.retrieved = DistributionSummary.builder("docsassistant.rag.documents").register(meters);
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
ChatClientResponse response = chain.nextCall(request);
if (response.chatResponse() != null) {
List<Document> docs = response.chatResponse().getMetadata().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT);
int count = docs == null ? 0 : docs.size();
retrieved.record(count);
if (count == 0) {
meters.counter("docsassistant.rag.empty_retrieval").increment();
}
}
return response;
}
@Override
public String getName() {
return "ragOutcomeMetrics";
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 500; // outside the RAG advisor, so it sees its result
}
}
Source: Spring AI — Observability and Micrometer — Concepts.
Enable percentiles for the latency timer (management.metrics.distribution.percentiles-histogram.gen_ai.client.operation=true)
to get P95 in Prometheus, and keep spring.ai.chat.observations.log-prompt and log-completion off in production for
the privacy reasons above. The dashboards and the full list of observations are on
Spring AI: Observability.
LangChain4j: model and store listeners
LangChain4j exposes listeners rather than built-in tracing: a ChatModelListener on each chat model and an
EmbeddingStoreListener on each embedding store. A pair of listeners turns them into OpenTelemetry spans with the GenAI
attributes and records empty retrievals:
package com.example.docsassistant.l4j.observability;
import dev.langchain4j.model.chat.listener.ChatModelErrorContext;
import dev.langchain4j.model.chat.listener.ChatModelListener;
import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
import dev.langchain4j.model.output.TokenUsage;
import dev.langchain4j.store.embedding.listener.EmbeddingStoreListener;
import dev.langchain4j.store.embedding.listener.EmbeddingStoreResponseContext;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
public final class OtelListeners {
private OtelListeners() {
}
public static ChatModelListener chat(Tracer tracer) {
return new ChatModelListener() {
@Override
public void onRequest(ChatModelRequestContext ctx) {
String model = ctx.chatRequest().modelName() == null ? "unknown" : ctx.chatRequest().modelName();
Span span = tracer.spanBuilder("chat " + model).setSpanKind(SpanKind.CLIENT).startSpan();
span.setAttribute("gen_ai.operation.name", "chat");
span.setAttribute("gen_ai.request.model", model);
ctx.attributes().put(Span.class, span); // attributes are shared by the three callbacks
}
@Override
public void onResponse(ChatModelResponseContext ctx) {
Span span = (Span) ctx.attributes().get(Span.class);
TokenUsage usage = ctx.chatResponse().tokenUsage();
if (usage != null && usage.inputTokenCount() != null) {
span.setAttribute("gen_ai.usage.input_tokens", usage.inputTokenCount());
span.setAttribute("gen_ai.usage.output_tokens", usage.outputTokenCount());
}
if (ctx.chatResponse().modelName() != null) {
span.setAttribute("gen_ai.response.model", ctx.chatResponse().modelName());
}
span.end();
}
@Override
public void onError(ChatModelErrorContext ctx) {
Span span = (Span) ctx.attributes().get(Span.class);
if (span != null) {
span.setStatus(StatusCode.ERROR, ctx.error().getClass().getSimpleName());
span.end();
}
}
};
}
public static EmbeddingStoreListener retrieval() {
return new EmbeddingStoreListener() {
@Override
public void onResponse(EmbeddingStoreResponseContext<?> ctx) {
if (ctx instanceof EmbeddingStoreResponseContext.Search<?> search) {
int returned = search.searchResult().matches().size();
Span.current().setAttribute("docsassistant.retrieval.returned", returned);
if (returned == 0) {
Span.current().addEvent("empty_retrieval");
}
}
}
};
}
}
Source: LangChain4j — Observability and OpenTelemetry — Java API.
Register the chat listener with the model builder’s .listeners(…) and the store listener with
embeddingStore.addListener(…). In Quarkus and in the LangChain4j Spring Boot starters, ready-made observability
integrations exist; the listener approach works everywhere.
Agent observability with the OpenTelemetry GenAI conventions, Langfuse, Phoenix and LangSmith, and the latency and P95 metrics follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapters 4 and 7), paraphrased; see the bibliography.