Post

LLM Observability and Tracing: How to Debug Prompts, Retrieval, Tools, and Failures

LLM applications are difficult to debug when all you log is the final answer. By the time a user sees a bad response, the underlying cause may be several steps upstream: a bad retrieval result, an overly broad prompt, a tool error, malformed intermediate state, or a schema validation failure.

This is why observability is a first-class requirement for serious LLM systems. If you cannot trace what happened across the prompt, retrieval, tool, and output layers, you will spend too much time guessing at root causes. In mature systems, observability is not just for incident response. It is also the foundation for evaluation, release management, cost control, and governance.

Why Traditional Logging Falls Short

Standard application logs usually capture request IDs, response codes, and backend exceptions. That is not enough for LLM systems. You also need visibility into model-facing behavior.

Useful traces often include:

  • prompt version
  • system and user messages
  • retrieved documents
  • tool calls and arguments
  • structured outputs
  • validation failures
  • token counts
  • model latency

Without this, debugging becomes anecdotal. Teams start arguing about whether the model is weak, the prompt is bad, or retrieval failed, without enough evidence to answer any of those questions cleanly.

The Minimum Useful Trace

For each request, a strong trace should answer:

  • what input the system received
  • what context it added
  • what prompt template was used
  • which model was called
  • what tools were invoked
  • what validations passed or failed
  • what answer was returned

That sequence is often enough to localize most failures quickly. A high-quality trace turns an opaque generation pipeline into an explainable sequence of events.

Observability for Prompts

Prompt behavior is often treated as something too subjective to measure. That is a mistake. Prompt-level observability should include:

  • prompt template identifier
  • prompt length in tokens
  • dynamic variables injected into the template
  • system instruction set
  • response format requested
  • fallback prompts triggered during retries

This matters because prompt changes are product changes. If a prompt is updated and latency increases by 25% or schema validity drops, the trace should make that relationship visible.

Observability for Retrieval

Retrieval errors are among the most common causes of poor LLM behavior. Good observability should therefore capture:

  • query used for retrieval
  • documents returned
  • document scores or ranks
  • metadata filters applied
  • reranking outcomes
  • context actually sent to the model

This matters because the document that was retrieved is not always the document that was included in the final prompt. In many systems, failures happen not because retrieval found nothing, but because context assembly selected the wrong passages or overfilled the context window with noise.

Observability for Tool Use

When models can call tools, tracing must include:

  • tool selected
  • arguments generated by the model
  • tool execution success or failure
  • retries and fallback behavior
  • whether the tool result was actually used in the final answer

A system that calls many tools is not necessarily intelligent. It may simply be poorly constrained. Observability helps distinguish effective tool use from thrashing behavior.

State Transitions Matter in Agentic Systems

For multi-step systems, traces should not stop at input and output. They should expose state transitions:

  • current plan state
  • branch taken at decision nodes
  • retry counts
  • human-approval checkpoints
  • termination reason

Without this, agentic workflows become difficult to audit. A final answer may look wrong, but the deeper issue could be that the system took the wrong branch six steps earlier.

Metrics to Track Over Time

Operational dashboards for LLM systems should usually include:

  • request volume
  • token consumption
  • average and p95 latency
  • structured output validity rate
  • retrieval hit rate or grounded answer rate
  • tool success rate
  • refusal rate
  • user feedback or human-review disagreement rate

These metrics help turn qualitative complaints into diagnosable patterns. They also make it possible to track drift after model upgrades, prompt edits, or corpus changes.

Cost Observability Is Part of Reliability

A system that answers correctly but at unsustainable cost is not healthy. Strong observability should therefore track:

  • cost per request
  • cost per successful task
  • average prompt size
  • average retrieved context size
  • cost spikes by route or feature

This often reveals opportunities that quality-only dashboards miss. For example, an expensive chain of retries may improve quality only marginally while doubling serving cost.

Privacy and Governance Considerations

Observability must be designed with governance in mind. Prompt and response logs may contain sensitive data. That means teams need policies for:

  • redaction
  • retention limits
  • access control
  • environment separation
  • auditability

Strong observability does not mean logging everything carelessly. It means logging enough to diagnose behavior while preserving legal, security, and privacy constraints.

A Practical Maturity Model

You can think about observability maturity in stages:

  1. basic request and response logging
  2. prompt and retrieval tracing
  3. tool and state-transition tracing
  4. integrated quality, cost, and safety dashboards
  5. continuous correlation between traces and evaluation failures

Teams that reach the later stages can improve systems much faster because they spend less time guessing and more time fixing the right layer.

Technical Appendix: Trace Record Example

1
2
3
4
5
6
7
8
9
{
  "request_id": "req_1842",
  "prompt_version": "support_v7",
  "retrieved_doc_ids": ["doc_12", "doc_98"],
  "tool_calls": [{"tool": "crm_lookup", "status": "success"}],
  "schema_valid": true,
  "latency_ms": 1640,
  "cost_usd": 0.009
}

Trace structures like this make it possible to correlate poor answers with retrieval quality, tool behavior, prompt versions, or latency regressions.

Implementing Observability: Code Patterns

Structured Logging

The simplest starting point is structured, machine-readable log records for every inference request:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import json
import time
import uuid
import logging
from datetime import datetime

logger = logging.getLogger("llm_observability")
logger.setLevel(logging.INFO)

class LLMObserver:
    """Wraps an LLM client with structured observability."""

    def __init__(self, llm_client, logger=logger):
        self.llm   = llm_client
        self.logger = logger

    def invoke(self, prompt: str, prompt_version: str = "unknown", **kwargs) -> dict:
        request_id  = str(uuid.uuid4())
        start_time  = time.perf_counter()
        timestamp   = datetime.utcnow().isoformat()

        trace = {
            "request_id":     request_id,
            "timestamp":      timestamp,
            "prompt_version": prompt_version,
            "input_length":   len(prompt),
            "prompt_tokens":  None,
            "completion_tokens": None,
            "latency_ms":     None,
            "cost_usd":       None,
            "schema_valid":   None,
            "error":          None,
        }

        try:
            response = self.llm.invoke(prompt, **kwargs)
            elapsed  = (time.perf_counter() - start_time) * 1000

            trace["latency_ms"]         = round(elapsed, 2)
            trace["prompt_tokens"]      = response.usage.prompt_tokens
            trace["completion_tokens"]  = response.usage.completion_tokens
            trace["cost_usd"]           = self._estimate_cost(
                response.usage.prompt_tokens,
                response.usage.completion_tokens,
            )
            trace["schema_valid"] = True
            self.logger.info(json.dumps(trace))
            return response

        except Exception as e:
            trace["error"]      = str(e)
            trace["latency_ms"] = round((time.perf_counter() - start_time) * 1000, 2)
            self.logger.error(json.dumps(trace))
            raise

    def _estimate_cost(self, prompt_tokens: int, completion_tokens: int) -> float:
        # gpt-4o pricing (adjust per model)
        return round(prompt_tokens * 2.5e-6 + completion_tokens * 10e-6, 6)

LangSmith Integration

LangSmith is the reference platform for LangChain-based LLM observability. It automatically captures traces, inputs, outputs, latency, and evaluation scores.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# Enable tracing — set once in environment or at startup
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"]     = "ls__your_langsmith_key"
os.environ["LANGCHAIN_PROJECT"]     = "production-support-assistant"
os.environ["LANGCHAIN_ENDPOINT"]    = "https://api.smith.langchain.com"

llm    = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful support assistant. Answer based only on the provided context."),
    ("human",  "Context:\n{context}\n\nQuestion: {question}")
])
parser = StrOutputParser()
chain  = prompt | llm | parser

# All invocations are automatically traced in LangSmith
result = chain.invoke({
    "context":  "Our return policy allows returns within 30 days.",
    "question": "Can I return an item after 3 weeks?"
})

Adding Custom Metadata

1
2
3
4
5
6
7
8
9
10
11
12
from langsmith import traceable, Client
from langsmith.run_helpers import get_current_run_tree

@traceable(name="rag_pipeline", tags=["production", "v2"])
def rag_pipeline(question: str, user_id: str) -> str:
    run_tree = get_current_run_tree()
    if run_tree:
        run_tree.add_metadata({"user_id": user_id, "feature_flag": "rag_v2"})

    docs    = retriever.invoke(question)
    context = "\n".join(d.page_content for d in docs)
    return chain.invoke({"context": context, "question": question})

Programmatic Evaluation via LangSmith

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from langsmith import Client
from langsmith.evaluation import evaluate

client = Client()

# Define evaluation dataset
dataset = client.create_dataset("hr-policy-qa")
client.create_examples(
    inputs=[{"question": "What is the PTO policy?"}],
    outputs=[{"answer": "Employees receive 15 days PTO per year."}],
    dataset_id=dataset.id,
)

# Define evaluator functions
def faithfulness_evaluator(run, example):
    score = judge_faithfulness(run.outputs["output"], example.outputs["answer"])
    return {"key": "faithfulness", "score": score}

def relevance_evaluator(run, example):
    score = judge_relevance(run.inputs["question"], run.outputs["output"])
    return {"key": "relevance", "score": score}

# Run evaluation against the dataset
results = evaluate(
    rag_pipeline,
    data=dataset.name,
    evaluators=[faithfulness_evaluator, relevance_evaluator],
    experiment_prefix="rag-v2-test",
    max_concurrency=5,
)
print(results.to_pandas())

OpenTelemetry Traces

For vendor-neutral observability, OpenTelemetry spans integrate LLM traces into your existing APM stack (Datadog, Grafana Tempo, Jaeger, etc.).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

# Configure OTLP exporter (e.g., Grafana Tempo / Datadog)
resource = Resource.create({"service.name": "llm-service", "deployment.environment": "prod"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://tempo:4317"))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("llm-observability")

def traced_llm_call(prompt: str, prompt_version: str) -> str:
    with tracer.start_as_current_span("llm.completion") as span:
        span.set_attributes({
            "llm.prompt_version": prompt_version,
            "llm.input.token_count": count_tokens(prompt),
            "llm.model": "gpt-4o-mini",
        })

        try:
            response = llm.invoke(prompt)
            span.set_attributes({
                "llm.output.token_count": response.usage.completion_tokens,
                "llm.latency_ms":         response.response_ms,
                "llm.cost_usd":           estimate_cost(response.usage),
            })
            span.set_status(trace.StatusCode.OK)
            return response.content

        except Exception as e:
            span.record_exception(e)
            span.set_status(trace.StatusCode.ERROR, str(e))
            raise

# Retrieval span
def traced_retrieval(query: str, retriever) -> list:
    with tracer.start_as_current_span("retrieval.search") as span:
        span.set_attribute("retrieval.query", query)
        docs = retriever.invoke(query)
        span.set_attributes({
            "retrieval.result_count": len(docs),
            "retrieval.doc_ids":      [d.metadata.get("id") for d in docs],
        })
        return docs

Phoenix / Arize for LLM Observability

Phoenix by Arize provides a local-first observability UI optimized for LLM traces and embedding drift.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import phoenix as px
from phoenix.otel import register

# Start the Phoenix server (in-process for dev, separate for prod)
session = px.launch_app()

# Register OpenTelemetry tracer pointing to Phoenix
tracer_provider = register(
    project_name="my-llm-project",
    endpoint="http://localhost:6006/v1/traces",
)

# Phoenix auto-instruments LangChain, OpenAI SDK, LlamaIndex
from openinference.instrumentation.langchain import LangChainInstrumentor
from openinference.instrumentation.openai import OpenAIInstrumentor

LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)

# All subsequent LangChain/OpenAI calls are automatically traced
# Traces appear in the Phoenix UI at http://localhost:6006

Retrieval-Specific Tracing

Retrieval failures are among the most common causes of poor LLM outputs. Capture them explicitly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def traced_rag_pipeline(question: str) -> dict:
    with tracer.start_as_current_span("rag_pipeline") as root_span:
        root_span.set_attribute("question", question)

        # Retrieval span
        with tracer.start_as_current_span("retrieval") as ret_span:
            docs = retriever.invoke(question)
            ret_span.set_attributes({
                "retrieval.query":        question,
                "retrieval.doc_count":    len(docs),
                "retrieval.scores":       [d.metadata.get("score", 0) for d in docs],
                "retrieval.doc_ids":      [d.metadata.get("source", "") for d in docs],
            })

        # Reranking span
        with tracer.start_as_current_span("reranking") as rank_span:
            ranked_docs = reranker.compress_documents(docs, question)
            rank_span.set_attributes({
                "reranking.input_count":  len(docs),
                "reranking.output_count": len(ranked_docs),
            })

        # Context assembly span
        with tracer.start_as_current_span("context_assembly") as ctx_span:
            context = "\n\n".join(d.page_content for d in ranked_docs)
            ctx_span.set_attributes({
                "context.char_length":    len(context),
                "context.token_estimate": len(context) // 4,
            })

        # Generation span
        with tracer.start_as_current_span("generation") as gen_span:
            answer = llm.invoke(build_prompt(question, context))
            gen_span.set_attributes({
                "generation.model":            "gpt-4o-mini",
                "generation.input_tokens":     answer.usage.prompt_tokens,
                "generation.completion_tokens": answer.usage.completion_tokens,
                "generation.cost_usd":          estimate_cost(answer.usage),
            })

        return {"answer": answer.content, "docs": ranked_docs}

Metrics Dashboard Design

A production LLM observability dashboard should include at minimum:

Tier 1 — SLA metrics (alert on these)

MetricAlert ThresholdPanel Type
p95 latency> 5 secondsLine chart
Error rate> 1% over 5 minLine chart + alert
Schema validation failure rate> 0.5%Bar chart
Cost per request (hourly)> $0.05Gauge

Tier 2 — Quality metrics (review daily)

MetricNormal RangePanel Type
Faithfulness score (LLM judge)> 0.80Time series
Relevance score> 0.75Time series
Refusal rate1–5%Bar chart
Tool success rate> 95%Gauge
Retrieval hit rate> 70%Line chart

Tier 3 — Capacity metrics (review weekly)

MetricPanel Type
Token consumption by modelStacked bar
Request volume by feature/routeHeat map
Cost breakdown (prompt vs completion)Pie chart
Latency by prompt versionMulti-line chart

Alerting Rules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Prometheus alerting rules for LLM systems
groups:
  - name: llm_sla
    rules:
    - alert: LLMHighLatency
      expr: histogram_quantile(0.95, sum(rate(llm_request_latency_seconds_bucket[5m])) by (le)) > 5
      for: 2m
      labels:
        severity: warning
      annotations:
        summary: "LLM p95 latency exceeds 5s"

    - alert: LLMHighErrorRate
      expr: rate(llm_requests_total{status="error"}[5m]) / rate(llm_requests_total[5m]) > 0.01
      for: 2m
      labels:
        severity: critical
      annotations:
        summary: "LLM error rate above 1%"

    - alert: LLMSchemaValidationFailures
      expr: rate(llm_schema_validation_failures_total[10m]) > 0.005
      for: 5m
      labels:
        severity: warning
      annotations:
        summary: "LLM schema validation failures elevated"

    - alert: LLMCostSpike
      expr: increase(llm_cost_usd_total[1h]) > 100
      labels:
        severity: warning
      annotations:
        summary: "LLM cost exceeded $100 in last hour"

Privacy-Compliant Logging

Observability must be designed with governance in mind:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import re
from typing import Optional

class PrivacyAwareLLMLogger:
    # Patterns to redact from logs
    PII_PATTERNS = {
        "email":      re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'),
        "phone":      re.compile(r'\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b'),
        "ssn":        re.compile(r'\b\d{3}-\d{2}-\d{4}\b'),
        "credit_card": re.compile(r'\b(?:\d{4}[- ]){3}\d{4}\b'),
        "ip_address": re.compile(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'),
    }

    def redact(self, text: str) -> str:
        for name, pattern in self.PII_PATTERNS.items():
            text = pattern.sub(f"[REDACTED_{name.upper()}]", text)
        return text

    def log_trace(self, trace: dict) -> None:
        safe_trace = {
            "request_id":     trace["request_id"],
            "timestamp":      trace["timestamp"],
            "prompt_version": trace["prompt_version"],
            # Redact content — log only metadata
            "input_token_count":  trace.get("prompt_tokens"),
            "output_token_count": trace.get("completion_tokens"),
            "latency_ms":         trace.get("latency_ms"),
            "cost_usd":           trace.get("cost_usd"),
            "schema_valid":       trace.get("schema_valid"),
            "route":              trace.get("route"),
            # Never log raw prompt or response in production
        }
        logger.info(json.dumps(safe_trace))

Observability Maturity Model

StageCapabilitiesTeams at This Stage
Level 0 — BlindNo loggingPrototype only
Level 1 — BasicRequest/response logging, latencyEarly production
Level 2 — TracingPrompt/retrieval/tool spans, token countsGrowing teams
Level 3 — QualityAutomated faithfulness/relevance scores, cost dashboardsMature production
Level 4 — IntegratedTraces linked to eval failures, CI/CD quality gates, SLA dashboardsAdvanced teams
Level 5 — ContinuousReal-time drift detection, automatic root-cause correlationEnterprise LLM platforms

Operational Runbook Template

Every production LLM system should have a runbook that answers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
## LLM System Runbook — Support Assistant

### Latency SLO: p95 < 3s

**Symptoms**: Users reporting slow responses; p95 > 5s in dashboard

**Investigation**:
1. Check `llm_request_latency_seconds` by component (retrieval vs generation vs tool)
2. Check if a specific prompt_version introduced the regression
3. Check queue depth — scaling may be needed
4. Check retrieval latency separately from model latency

**Remediation**:
- If retrieval is slow: check vector DB load, consider metadata pre-filter
- If generation is slow: check GPU utilization, consider smaller model on easy tasks
- If overall volume spike: trigger autoscaling rule

### Quality SLO: faithfulness > 0.80

**Symptoms**: LLM-as-judge faithfulness drops below 0.80 for 30+ minutes

**Investigation**:
1. Check whether retrieval quality dropped (retrieval hit rate, document scores)
2. Check whether a prompt update was deployed
3. Sample failing traces manually in LangSmith

**Remediation**:
- Roll back prompt version if a recent update is the cause
- Check corpus for stale or corrupted documents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
---

## Langfuse: Open-Source LLM Observability

Langfuse is a popular open-source alternative to LangSmith, offering self-hosting and fine-grained cost tracking:

```python
from langfuse import Langfuse
from langfuse.decorators import observe, langfuse_context

langfuse = Langfuse(
    public_key="pk-lf-...",
    secret_key="sk-lf-...",
    host="https://cloud.langfuse.com",  # or self-hosted URL
)

@observe()
def rag_pipeline(question: str, user_id: str) -> str:
    # Automatically creates a trace in Langfuse
    langfuse_context.update_current_trace(
        user_id=user_id,
        metadata={"feature": "hr-assistant"},
    )

    # Nested spans are created automatically for each @observe call
    docs    = retrieve_docs(question)
    context = "\n".join(d.text for d in docs)

    langfuse_context.update_current_observation(
        metadata={"doc_count": len(docs)},
    )

    return generate_answer(question, context)

@observe(name="retrieve_docs")
def retrieve_docs(question: str) -> list:
    return retriever.invoke(question)

@observe(name="generate_answer")
def generate_answer(question: str, context: str) -> str:
    response = llm.invoke(f"Context:\n{context}\n\nQ: {question}")
    langfuse_context.update_current_observation(
        usage={"input": response.usage.prompt_tokens, "output": response.usage.completion_tokens},
        model="gpt-4o-mini",
    )
    return response.content

Manual Langfuse tracing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from langfuse import Langfuse

langfuse = Langfuse()

# Create a trace
trace = langfuse.trace(
    name="support-assistant",
    user_id="user_12345",
    metadata={"session_id": "sess_abc"},
)

# Create spans for each step
retrieval_span = trace.span(name="retrieval", input={"query": question})
docs           = retriever.invoke(question)
retrieval_span.end(output={"doc_count": len(docs)})

generation_span = trace.span(name="generation")
response        = llm.invoke(build_prompt(question, docs))
generation_span.end(
    output={"answer": response.content[:100]},
    usage={"promptTokens": response.usage.prompt_tokens, "completionTokens": response.usage.completion_tokens},
    model="gpt-4o-mini",
    level="DEFAULT",
)

# Score the trace (e.g., from user feedback)
langfuse.score(
    trace_id=trace.id,
    name="user_satisfaction",
    value=5,  # thumbs up = 5, thumbs down = 1
    comment="User clicked helpful",
)

Distributed Tracing with W&B Weave

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import weave
from openai import OpenAI

weave.init("my-llm-project")

client = OpenAI()

@weave.op()
def retrieve(query: str) -> list[dict]:
    docs = retriever.invoke(query)
    return [{"text": d.page_content, "source": d.metadata.get("source")} for d in docs]

@weave.op()
def generate(question: str, context: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Answer based only on the context."},
            {"role": "user",   "content": f"Context: {context}\n\nQuestion: {question}"},
        ],
    )
    return response.choices[0].message.content

@weave.op()
def rag_pipeline(question: str) -> dict:
    docs    = retrieve(question)
    context = "\n\n".join(d["text"] for d in docs)
    answer  = generate(question, context)
    return {"question": question, "answer": answer, "doc_count": len(docs)}

# All calls are automatically traced in W&B Weave
result = rag_pipeline("What are the benefits of RAG over fine-tuning?")

Sampling-Based Observability for Cost Control

Full tracing of every request is expensive at scale. Use sampling:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import random
import time

class SampledObserver:
    def __init__(
        self,
        base_sample_rate:    float = 0.05,   # 5% of all requests
        error_sample_rate:   float = 1.00,   # 100% of errors
        slow_sample_rate:    float = 1.00,   # 100% of slow requests
        slow_threshold_ms:   float = 5000,
    ):
        self.base_rate  = base_sample_rate
        self.error_rate = error_sample_rate
        self.slow_rate  = slow_sample_rate
        self.slow_ms    = slow_threshold_ms

    def should_trace(
        self,
        latency_ms:   float = 0,
        had_error:    bool  = False,
        force_trace:  bool  = False,
    ) -> bool:
        if force_trace:
            return True
        if had_error:
            return random.random() < self.error_rate
        if latency_ms > self.slow_ms:
            return random.random() < self.slow_rate
        return random.random() < self.base_rate

sampled_observer = SampledObserver(base_sample_rate=0.05)

def observed_request(question: str, llm) -> str:
    t0       = time.perf_counter()
    error    = False
    try:
        result = llm.invoke(question).content
    except Exception as e:
        error  = True
        result = ""
        raise
    finally:
        latency = (time.perf_counter() - t0) * 1000
        if sampled_observer.should_trace(latency_ms=latency, had_error=error):
            # Log full trace
            pass   # emit to observability backend

    return result

Conclusion

LLM observability is the operational layer that makes quality improvement possible. It turns vague complaints into traceable failures and transforms model behavior from something mysterious into something diagnosable. In production, this is not an optional enhancement — it is part of the control system that allows the product to evolve without becoming opaque. Teams that invest in structured tracing, cost dashboards, quality metrics, and automated alerting can diagnose and resolve issues in hours instead of days, and detect silent degradation before users report it. The combination of LangSmith (or Langfuse), OpenTelemetry, and domain-specific quality judges creates a complete observability stack that covers every layer from token generation to business outcome.


Observability Quick Reference

What to TraceWhyTool
Input prompt + versionAttribution of behaviorLangSmith trace metadata
Retrieved doc IDs + scoresDiagnose RAG failuresCustom span attributes
Tool calls + argumentsDebug tool misuseOTel tool span
Output tokens + costCost controlCustom metric
Schema validation resultReliability trackingPrometheus counter
Faithfulness scoreQuality driftLLM judge metric
Latency by componentBottleneck identificationHistogram
1
2
3
4
5
6
7
8
9
# Minimum viable observability setup
import os

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"]    = os.getenv("LANGSMITH_API_KEY", "")
os.environ["LANGCHAIN_PROJECT"]    = "my-llm-project"

# That's it — all LangChain/LangGraph calls are now traced
# View at: https://smith.langchain.com

Observability Maturity Quick Reference

CapabilityWhen to Add
Basic request/response loggingDay 1 (always)
Structured prompt + retrieval tracesWeek 1
Cost tracking per requestMonth 1
LLM-as-judge quality scoringMonth 1-2
Automated alerts on quality dropMonth 2
A/B experiment trackingMonth 3+
Real-time drift detectionMonth 6+

Observability Engineering Principles

  1. Trace every layer: Input, retrieval, generation, tool calls, and validation each get their own span
  2. Log metadata, not content: Redact prompt and response content from logs; log only token counts, costs, and IDs
  3. Sample intelligently: 100% tracing is expensive; sample 5-10% normally, 100% on errors and slow requests
  4. Correlate traces to eval failures: When an automated judge flags a response, the trace must tell you why
  5. Monitor cost by route/feature: Cost spikes often originate from one unexpected use pattern
  6. Alert on quality, not just errors: A 10% faithfulness drop is as serious as a 10% error rate
  7. Trace format is a contract: Breaking trace schema changes downstream dashboards and alerts
  8. Privacy is part of observability design: Define retention policies and access controls before collecting data

Observability is not a dashboard — it is the feedback loop that allows a production AI system to improve over time with evidence rather than intuition.


LLM Observability: Key Takeaways

  • Enable LangSmith tracing from day one—the overhead is negligible, the value is immediate
  • Trace each component separately: retrieval, prompt, generation, tool calls, validation
  • Log metadata (token counts, costs, latency, IDs) but never raw prompt/response content in production logs
  • Sample 5% normally; capture 100% of errors and slow requests
  • Alert on quality (faithfulness drop) as aggressively as you alert on errors
  • Cost per request should be a dashboard metric from day one; surprises compound
  • Connect traces to eval failures—when a judge flags a response, you should be able to see the full trace
  • Observability is not optional in production; it’s what makes debugging possible

Production Engineering Notes

Building production LLM systems requires continuous learning and adaptation. The patterns and tools in this ecosystem evolve rapidly, but certain engineering principles remain constant:

On reliability: The most reliable systems are built with defense in depth multiple independent validation layers, graceful degradation paths, circuit breakers, and comprehensive observability. No single component should be a single point of failure.

On evaluation: Automated evaluation enables speed; human evaluation provides ground truth. Calibrate your automated judges regularly against human labels. A 10-point quality regression detected in CI costs hours to fix; the same regression discovered after deployment costs days or weeks of user trust.

On iteration: LLM systems improve through measurement, not intuition. Every prompt change, model upgrade, and retrieval configuration should be evaluated against a representative dataset before deployment. Blind experimentation is expensive; instrumented experimentation is invaluable.

On operations: Monitor cost, latency, and quality as a unified picture. A system that is fast and cheap but inaccurate is not production-grade. A system that is accurate but 10x over budget is not sustainable. The engineering goal is acceptable quality at acceptable cost within acceptable latency.


Conclusion

LLM observability is the operational layer that turns a model-powered application into a manageable product. Without it, teams debug by anecdote, optimize by intuition, and respond to production incidents with guesswork. With structured tracing, cost dashboards, quality metrics, and automated alerting, the same team can diagnose failures in minutes, detect quality drift before users notice, and iterate on the system with confidence. The investment in observability is never wasted—every trace, every quality score, and every cost metric accumulated in production becomes evidence for the next improvement decision.

This post is licensed under CC BY 4.0 by the author.