Skip to content
AI Product Engineer Wiki

    ↑ ↓ to move between results, Enter to open, Esc to close

    Menu

    Tracing and observability for LLM calls

    Trace every LLM call in production, from what to record to linking traces with users and outcomes, reviewing traces, and monitoring cost and latency.

    Level: Advanced 9 min read Updated
    On this page

    Why this matters

    When a user reports that “the AI gave me a weird answer”, you need to see exactly what happened: what went in, what came out, which prompt and model produced it, how it ended, how long it took and what it cost. Without that, the report becomes a shrug and a guess. With it, it becomes a bug fix and a new eval case.

    Traces are also how you watch the feature as a system: latency creeping up, refusals spiking after a prompt change, cost per request doubling because someone added a long document to the context. This article covers what a trace should contain, a small wrapper that records one per call, a habit for reviewing them, and the alerts worth setting.

    What a trace should contain

    A trace is the record of one AI request, from your code’s point of view. For each model call, record:

    What one trace records. Enough to answer “what happened?” without reproducing the request.
    Show text descriptionHide text description

    4 parts, in order:

    1. Context: Feature, prompt version, model, flag variant, pseudonymous user Example: reply-drafts · reply-v4 · Opus · variant B
    2. Outcome: Stop reason or error type, and the provider request ID Example: end_turn · req_011C…
    3. Performance: Latency, input, output and cached tokens, cost Example: 3.1 s · 2,900 in · 410 out
    4. Content (optional): Input and output text, only where policy allows Example: Stored separately, short retention

    Two of these deserve emphasis:

    • The prompt version. Without it, you can’t tell whether a spike in bad outputs started with a prompt change.
    • The provider’s request ID. Every API response carries one. If you ever need to ask the provider about a specific request, it’s the first thing they’ll want.

    Content is the sensitive part. Inputs and outputs are what make traces useful for debugging, and they’re user data. Decide deliberately: store content for a sample or for internal users only, keep it in a system with restricted access and short retention, redact what you can, and follow your privacy rules. Many teams keep metadata for every request and content for a small, consented sample.

    Instrumenting calls

    A thin wrapper around the SDK call is enough to record a trace for every request. This one records context, outcome, tokens, latency and the request ID, and stores content only when you say so:

    lib/traced-claude.ts
    import Anthropic from '@anthropic-ai/sdk';
    import { randomUUID } from 'node:crypto';
    
    const client = new Anthropic();
    
    export interface Trace {
      traceId: string;
      feature: string;
      promptVersion: string;
      model: string;
      startedAt: string;
      latencyMs: number;
      outcome: string;
      requestId?: string | null | undefined;
      inputTokens?: number;
      outputTokens?: number;
      cacheReadTokens?: number | null;
      /** Only when allowed by policy, and stored with restricted access and short retention. */
      content?: { input: Anthropic.MessageParam[]; output: string };
    }
    
    type Sink = (trace: Trace) => void;
    // Replace with your tracing backend. A JSON line per request is already searchable in most log tools.
    const logSink: Sink = (trace) => console.log(JSON.stringify(trace));
    
    interface TraceOptions {
      feature: string;
      promptVersion: string;
      storeContent?: boolean;
      sink?: Sink;
    }
    
    export async function tracedCreate(
      params: Anthropic.MessageCreateParamsNonStreaming,
      { feature, promptVersion, storeContent = false, sink = logSink }: TraceOptions,
    ): Promise<Anthropic.Message> {
      const startedAt = new Date();
      const base = { traceId: randomUUID(), feature, promptVersion, model: params.model, startedAt: startedAt.toISOString() };
      try {
        const message = await client.messages.create(params);
        const output = message.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
        sink({
          ...base,
          latencyMs: Date.now() - startedAt.getTime(),
          outcome: String(message.stop_reason),
          requestId: message._request_id,
          inputTokens: message.usage.input_tokens,
          outputTokens: message.usage.output_tokens,
          cacheReadTokens: message.usage.cache_read_input_tokens,
          ...(storeContent && { content: { input: params.messages, output } }),
        });
        return message;
      } catch (error) {
        // Record failures too: error spikes are one of the most useful things a trace shows.
        sink({
          ...base,
          latencyMs: Date.now() - startedAt.getTime(),
          outcome: error instanceof Anthropic.APIError ? `error ${error.status ?? 'network'}` : 'error unknown',
          requestId: error instanceof Anthropic.APIError ? error.requestID : undefined,
        });
        throw error;
      }
    }
    
    // Usage: same parameters as client.messages.create, plus trace context.
    const message = await tracedCreate(
      {
        model: 'claude-opus-5-5',
        max_tokens: 4096,
        output_config: { effort: 'low' },
        messages: [{ role: 'user', content: 'Summarize: the launch moved to the 19th because legal needs a week.' }],
      },
      { feature: 'thread-summary', promptVersion: 'summary-v2' },
    );
    // The caller still handles every stop reason, as in every other example on this site.
    console.log(message.stop_reason);

    Things to notice:

    • Errors are traced too, with the HTTP status and request ID, then re-thrown so the caller handles them as usual.
    • Content is opt-in. The default records metadata only.
    • The wrapper doesn’t change behavior. It returns the same message the SDK would; stop-reason handling stays in your feature code.
    • The same idea works for streaming: record the trace after finalMessage(), as the day-one demo route does in its log line.

    If you already run an observability backend, you may not need to write this yourself. The Claude SDKs and API can emit OpenTelemetry (opens in a new tab) spans for Messages API requests, following the OpenTelemetry GenAI conventions, so they appear alongside your application’s own traces. It’s in beta at the time of writing, so check the docs for current setup. You’ll still want to attach your own context, such as feature and prompt version.

    Reviewing traces

    Traces are most valuable when someone reads them regularly, not only during incidents. A weekly habit that takes 30 minutes:

    1. Sample broadly: 20 random traces from the week, plus every trace with an error, a refusal, a truncation, or a thumbs-down, edit or retry linked to it through the request ID in your analytics events.
    2. Read inputs and outputs, where you’re allowed to. Note anything surprising: requests outside the intended scope, outputs that are technically fine but unhelpful, unusually long inputs.
    3. Tag and count, as in capturing feedback.
    4. Act: new eval cases for real failures, bug tickets for integration problems, and questions for the next product review.

    Reading real traces is also the fastest way to keep your intuition calibrated. Teams that never read traces gradually lose touch with what users actually ask.

    Monitoring and alerts

    From the same traces, build a small dashboard and a few alerts:

    Signal Why it matters Example alert
    Error rate API or integration problems Above 2% for 10 minutes
    Refusal rate Prompt or input changes triggering declines Twice the 7-day average
    Truncation rate Output limits too tight, or verbose prompts Above 5%
    Latency (slow tail) User experience degrading 95th percentile above budget for 15 minutes
    Cost per request Prompt or context growth, wrong model 50% above the 7-day average
    Volume Abuse, loops, or a broken client Three times normal for the hour

    Break every chart down by prompt version and model. Most regressions line up with a change, and seeing that immediately saves hours. Cost views get their own article: cost dashboards and budgets.

    Common mistakes

    • No prompt version in traces. Regressions after a prompt change become impossible to pin down.
    • Tracing only successes. Errors, refusals and truncations are where traces are most useful.
    • Logging all content by default. Store metadata for every request and content only where policy allows, with restricted access and short retention.
    • Traces nobody reads. Review a sample weekly; dashboards alone miss the surprising cases.
    • Alerts without a breakdown by version. Split monitoring by prompt version and model so you can see which change caused a spike.

    Try it

    Exercise

    About 60 min

    Add tracing to a feature and review twenty traces

    1. Wrap your feature’s model calls with a tracing wrapper like the one above, including prompt version and request ID.
    2. Generate traffic by using the feature yourself, including error cases (for example, an invalid model name in a test).
    3. Review twenty traces: tag anything surprising and note one improvement.
    4. Write down the three alerts you’d set first, with thresholds based on what you saw.
    Show a hint

    Link traces to your analytics events with a shared request ID. Then “show me the traces behind last week’s thumbs-down” becomes a simple lookup.

    Show a worked solution

    Example findings from twenty traces of a summary feature (illustrative):

    • Two requests included entire email threads with signatures and disclaimers, tripling input tokens. Improvement: strip quoted replies and signatures before sending.
    • One truncation on a very long thread. Improvement: raise the output limit slightly and test long threads in evals.
    • First alerts: error rate above 2% for 10 minutes; 95th percentile latency above 8 seconds; cost per request 50% above the weekly average.

    Check your understanding

    Question 1 of 4: Which field in a trace is most useful for linking a spike in bad outputs to its cause?

    Show answer

    B. The prompt version

    Most regressions coincide with a prompt or model change; the version in every trace makes that visible.

    Question 2 of 4: Why record the provider request ID?

    Show answer

    B. It identifies the exact request if you need to investigate it with the provider

    Every API response carries a request ID; keeping it lets you trace a specific call end to end.

    Question 3 of 4: What is a sensible default for storing prompt and output content in traces?

    Show answer

    B. Store metadata for every request, and content only where policy allows, with restricted access and short retention

    Content is valuable for debugging and sensitive as user data; keep it deliberate and limited.

    Question 4 of 4: In the wrapper, why is the error re-thrown after recording the trace?

    Show answer

    B. So the caller still handles the error as usual; tracing should not change behavior

    Instrumentation observes; it must not swallow errors or change what the feature does.

    Key takeaways

    • A trace records context, outcome, performance and, where allowed, content for every model call.
    • Always include the prompt version and the provider request ID; trace errors as well as successes.
    • Review a sample of traces weekly and turn findings into eval cases and fixes.
    • Alert on errors, refusals, truncation, slow-tail latency, cost and volume, broken down by prompt version and model.

    Progress is saved in this browser and lights up the roadmap.