← Back to blog

Observability for AI Applications: Monitoring, Tracing and Debugging LLM Calls

AI application observability is more complex than traditional backend observability — LLM call latencies vary wildly, cost and output quality are tightly coupled, and free-text outputs resist simple schema assertions. This article covers three layers: LLM call metrics collection, distributed tracing with context preservation, and a debugging/evaluation workflow — for teams integrating LLMs into production systems.

The Bottom Line: AI Observability Is Not “Add a Few More Logs”

After integrating LLM calls into production, teams quickly discover that the traditional observability trinity (logs, metrics, traces) falls short for AI workloads.

LLM calls have three characteristics that break traditional approaches:

  1. Extreme latency variance — same model, same prompt, response times can differ by 10x
  2. Cost and output are coupled — slow requests are also expensive (more tokens)
  3. Output cannot be asserted — free-text cannot be schema-validated as “right or wrong”

This article covers three layers: metrics collection → tracing with context → debugging and evaluation.


1. Metrics: The Foundation Layer

1.1 Metrics You Must Collect

CategoryMetricDescriptionAggregation
LatencyTTFTTime to first tokenP50/P95/P99
LatencyTPOTTime per output tokenP50/P95/P99
LatencyEnd-to-endFull request to complete responseP50/P95/P99
CostInput tokensTokens per callSum/daily average
CostOutput tokensTokens per callSum/daily average
CostPer-call costCalculated by model unit priceDaily/monthly
QualityError rateGrouped by status/error typePercentage
QualityUser feedbackThumbs up/down/reportRatio
QualityResponse lengthOutput character countMean/distribution

1.2 Implementation

async function tracedLLMCall(params: {
  model: string;
  messages: ChatMessage[];
  metadata?: Record<string, string>;
}): Promise<LLMResponse> {
  const start = performance.now();
  const traceId = crypto.randomUUID();

  try {
    const response = await openai.chat.completions.create({
      model: params.model,
      messages: params.messages,
      stream: true,
    });

    let ttft: number | null = null;
    let outputTokens = 0;
    let fullResponse = '';

    for await (const chunk of response) {
      if (!ttft) ttft = performance.now() - start;
      outputTokens += chunk.usage?.completionTokens ?? 0;
      fullResponse += chunk.choices[0]?.delta?.content ?? '';
    }

    const endToEnd = performance.now() - start;

    metrics.record('llm.ttft', ttft, { model: params.model });
    metrics.record('llm.e2e', endToEnd, { model: params.model });
    metrics.record('llm.output_tokens', outputTokens, { model: params.model });
    metrics.record('llm.cost', calculateCost(params.model, inputTokens, outputTokens), { model: params.model });

    return { response: fullResponse, traceId, usage: { inputTokens, outputTokens } };
  } catch (error) {
    metrics.record('llm.error', 1, { model: params.model, errorType: error.code });
    throw error;
  }
}

1.3 Metric Aggregation Notes

  • Do not average latencies — LLM latency follows a long-tail distribution; P95 is the meaningful metric
  • Group by model and prompt pattern — different models and contexts produce vastly different metrics; mixing them is meaningless
  • Cost and latency must be correlated — a “slow” request is almost always an “expensive” request

2. Tracing and Context Preservation

2.1 Call Chain

A typical AI application call chain:

User request → Gateway → Agent/orchestration → LLM call → Tool call → LLM call → Response

Traditional tracing tools (Jaeger, Zipkin) handle RPC tracing well, but LLM context is far more complex — we need to know:

  • What prompt was used
  • What content was returned
  • How many tokens were consumed
  • Whether there were intermediate tool call (Function Calling) results

2.2 Implementation

Extend OpenTelemetry with custom Span attributes for LLM calls:

import { Span, trace, SpanStatusCode } from '@opentelemetry/api';

async function tracedLLM(model: string, messages: ChatMessage[]): Promise<LLMResponse> {
  const tracer = trace.getTracer('llm-instrumentation');
  const span = tracer.startSpan('llm.call', {
    attributes: {
      'llm.model': model,
      'llm.request.messages': JSON.stringify(
        messages.map(m => ({ role: m.role, content: truncate(m.content, 500) }))
      ),
    },
  });

  try {
    const response = await openai.chat.completions.create({ model, messages });
    const usage = response.usage;

    span.setAttributes({
      'llm.response.tokens.input': usage?.promptTokens ?? 0,
      'llm.response.tokens.output': usage?.completionTokens ?? 0,
      'llm.response.total_tokens': usage?.totalTokens ?? 0,
      'llm.response.finish_reason': response.choices[0]?.finishReason ?? 'unknown',
      'llm.latency_ms': performance.now() - start,
    });

    // Store full response in a separate Trace Store (not in span attributes — too large)
    await traceStore.put(span.spanContext().spanId, {
      messages,
      response: response.choices[0]?.message,
      usage,
    });

    span.setStatus({ code: SpanStatusCode.OK });
    return response;
  } catch (error) {
    span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
    span.recordException(error);
    throw error;
  } finally {
    span.end();
  }
}

2.3 Sensitive Data Handling

Full prompts and responses may contain PII. Handling strategy:

Data LevelExampleHandling
MetadataModel name, latency, token countWrite to Span attributes, aggregatable
SanitizedPII-stripped prompt excerptWrite to Trace Store, 7-day retention
RawFull prompt and responseDo not persist; store only a hash for audit

3. Debugging and Evaluation Workflow

3.1 Online Debugging

When a user reports “the AI gave a wrong answer”, traditional logs tell you “input X, output Y” — but you need to answer “why did the AI give that answer.”

Context required:

  • Complete prompt (System + User + conversation history)
  • Raw model output
  • Token usage and latency
  • Function calling results (if applicable)
  • Temperature and other parameters

Recommended practice: retain full call context in Trace Store, searchable by User ID, Session ID, or Trace ID. When a user reports an issue, ops can reconstruct the full call context in one click.

3.2 Regression Testing (Golden Dataset)

After a model upgrade or prompt change, how do you ensure output quality has not regressed?

Build a Golden Dataset:

golden-dataset/
  test-cases/
    - case-001.json    # Simple Q&A
    - case-002.json    # Multi-turn conversation
    - case-003.json    # Tool calling
    - case-004.json    # Edge cases (empty input, very long input)
  expected/
    - expected-001.json  # Expected output (or evaluation criteria)

Run regression tests on every change:

llm-eval run --dataset golden-dataset/ --model gpt-4o-mini --output results/
llm-eval diff --baseline results/v1.0/ --current results/v1.1/

3.3 Automated Evaluation Metrics

DimensionMethodDescription
AccuracyJudge LLM scoringAnother LLM evaluates “did it answer correctly”
Hallucination rateFactual consistencyDoes the output contradict the given context
Instruction followingCompleteness checkExpected format, all required fields
SafetyContent safety scanHarmful content, system prompt leakage
Semantic similarityEmbedding distanceVector distance between output and expected output

4. Toolchain

PurposeToolDescription
MetricsOpenTelemetry + PrometheusStandard metrics pipeline
LLM tracingOpenTelemetry + custom SpansStandard approach, define your own LLM Span attributes
Dedicated LLM observabilityLangfuse / Arize Phoenix / LangSmithPurpose-built for LLM, works out of the box
Evaluationdeep-eval / RagasGolden Dataset evaluation and auto-scoring
Debug UILangfuse / Weights & Biases PromptsVisual prompt/response/token consumption view

Summary

LayerKey PointsCommon Mistake
MetricsGroup by model and prompt pattern, watch P95 not averageAdded latency monitoring but not cost or quality
TracingOpenTelemetry Span + separate Trace StoreWriting full prompts into logs (data leak risk)
DebuggingRetain full call context, one-click replayOnly “user says it’s wrong” — no reproducible context
EvaluationBuild a Golden Dataset, run regression on changesNever measured output quality after going live

AI observability is not a “cost” — it is the only tool you have to answer “why did the AI answer that way” in production. Without it, every user report of “the AI got it wrong” is a blind diagnostic exercise.

FAQ

What is fundamentally different about LLM observability vs. regular API observability?

Three differences: ① Latency distribution — regular API calls are 10-500ms; LLM calls are 1-30s with extreme variance (the same model on the same prompt can differ 10x depending on context length), so average latency is meaningless — you need percentiles. ② Cost and output are coupled — every call bills per token, so a slow response is also an expensive one; you must track latency and token consumption together. ③ Output cannot be asserted — regular APIs return structured data you can schema-validate; LLM free-text needs an entirely separate evaluation mechanism.

Which LLM metrics matter most?

Three categories: ① Service quality — TTFT (time to first token, reflects perceived latency), TPOT (time per output token, reflects streaming speed), end-to-end latency, error rate aggregated by status and error type. ② Cost — input tokens, output tokens, per-call cost, daily/monthly totals. ③ Quality — user feedback rate (thumbs up/down), semantic similarity score (embedding distance between output and expected answer), manual review pass rate. Quality metrics are hardest to collect but most important — cost optimization without quality metrics is blind.

How do you trace LLM call context — prompt, response, token usage?

Generate a unique trace ID per LLM call, wrap the call in a custom Span containing: system_prompt, user_message, assistant_response, input_tokens, output_tokens, model_name, latency_ms. Report these Spans to OpenTelemetry Collector or a dedicated LLM observability platform (Langfuse, Arize Phoenix). Key design rule: do not write full Prompts/Responses into logs or metric labels (they may contain sensitive data) — store them in a separate Trace Store linked by trace ID.

How do you automate LLM output quality validation in CI/CD?

Three-layer validation: ① Format layer — if output is expected as JSON, validate parsing and field completeness (Zod or Pydantic schema). ② Semantic layer — use a Judge LLM to evaluate output quality: "did it answer the question", "does it contain hallucinations", "did it follow instructions". ③ Regression layer — maintain a Golden Dataset of fixed test cases; on every model upgrade or prompt change, run the full suite and compare output semantic similarity against the baseline; alert if similarity drops below a threshold. Not every commit needs a full run, but every prompt change and model version bump should trigger one.

This article comes from AI Enable Harness front-line delivery practice. Need a similar system or optimization service?

Subscribe to Updates

Get notified when new articles are published. No spam, occasional updates only.

Subscribe →