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

gantt title One /v1/ask request (P95 budget view) dateFormat X axisFormat %L ms section API auth and guardrails :a, 0, 120 section Orchestrator condense query :b, 120, 260 retrieve vector :c, 260, 520 retrieve keyword :d, 260, 430 rerank :e, 520, 660 section Model generate TTFT :f, 660, 1400 generate stream :g, 1400, 5200 section Checks grounding check :h, 5200, 5290

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

POST /v1/ask

enduser.id (pseudonymous), docsassistant.conversation_id, docsassistant.route, docsassistant.config_version

Condensation

chat {model} (gen_ai.operation.name=chat)

gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens

Retrieval

retrieve {index}

docsassistant.retrieval.top_k, .returned, .top_score, .filter_hash, .chunk_ids

Rerank

rerank

docsassistant.rerank.candidates, .kept, .model

Generation

chat {model}

gen_ai.request.model, gen_ai.response.model, gen_ai.usage.*, gen_ai.response.finish_reasons, docsassistant.ttft_ms

Tool call

execute_tool {tool} (gen_ai.operation.name=execute_tool)

gen_ai.tool.name, gen_ai.tool.call.id

Grounding check

grounding_check

docsassistant.faithfulness, .citation_validity, .action

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:

docsassistant/telemetry.py — stage spans with GenAI attributes (Python, OpenTelemetry API)
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 token event.

What users perceive; the first line of the latency budget (latency budget).

End-to-end P95

Time to the done event.

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:

RagOutcomeMetricsAdvisor.java — empty-retrieval and document-count metrics (Java, Spring AI 2.0.1, Micrometer)
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
    }
}

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:

OtelListeners.java — GenAI spans for model calls and a retrieval signal (Java, LangChain4j 1.20.2, OpenTelemetry API)
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");
                    }
                }
            }
        };
    }
}

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.