Generated banner for Agent Observability Replay Trace
Generated logo for Agent Observability Replay Trace

Agent Observability Replay Trace

DatadogOfficialPublished by Datadog

  • Skill
  • Workflow
  • Software development
  • MIT
  • Low risk
  • Has scripts

From the author

Use when a developer wants to iterate on ONE specific Agent Observability / LLM Obs trace whose output they didn't like — re-running that trace against their LOCAL code, seeing a concise diff of the old vs new output, and looping (change code → replay → diff) until satisfied. Invoked as /agent-observability-replay-trace <trace-id> [changes to test]. Signals: "replay this trace"; "iterate on a trace"; "this trace's output is wrong, fix it and re-run"; "re-run trace <id> with <change>"; pasting a trace id from the Agent Observability UI with a description of what to fix. It fetches the trace via the datadog-llmo MCP or the pup CLI, edits code, re-runs the app to emit a NEW trace, and diffs the two — no local server, no browser. For agents traced with ddtrace / LLM Obs (Python first-class), with JSON-serializable entry input. Do NOT use for: scored Experiments or the browser "Replay" button (that's agent-observability-replay-experiment), building an experiment from a dataset/CSV, writing evaluators,
— the description by Datadog

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a fast iteration loop for developers to troubleshoot and improve agent performance. By fetching a specific Agent Observability trace, the agent allows you to modify your local code and re-run the trace locally to observe the impact of your changes. It automates the process of replaying the trace, comparing the new output against the original baseline, and presenting a concise diff. This workflow is designed for Python-based agents instrumented with ddtrace/LLM Obs, ensuring that local testing remains isolated from production data by using a dedicated local ml_app environment.

What it does

  • Fetches a specific production trace using the datadog-llmo MCP or pup CLI.
  • Identifies the entrypoint and input for the trace to enable local execution.
  • Scaffolds a local runner to execute the agent logic with the original trace input.
  • Configures the local environment to ensure traces are tagged with a -local suffix for isolation.
  • Performs a side-by-side diff between the original production output and the new local replay.
  • Supports an iterative loop where you can modify code and re-replay until the desired output is achieved.

When to use it

  • When you need to fix an agent's output that was incorrect in a production trace.
  • When you want to verify that a code change produces the expected improvement without deploying.
  • When you need to perform regression testing on a specific trace to ensure stability.
  • When you need to isolate and debug a specific branch of a complex, fan-out agent execution.

Advantages

  • Rapid feedback loop by running code locally instead of waiting for deployment.
  • Prevents production pollution by enforcing strict -local ml_app isolation.
  • Provides clear, concise diffs to visualize the impact of code changes.
  • Reduces manual effort in setting up test harnesses for complex agent logic.

Usage examples

“/agent-observability-replay-trace trace_id_12345”
The agent fetches the specified trace, sets up a local replay environment, and displays the original output for comparison.
“/agent-observability-replay-trace trace_id_12345 update the prompt to be more concise”
The agent fetches the trace, applies the requested prompt change to your local code, re-runs the trace, and shows a diff of the output.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/datadog-labs/agent-observability-replay-trace/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip agent-observability-replay-trace.zip -d .agents/skills

Documentation by Datadog

Replay a trace against local code

A fast iteration loop on a single production trace: take a trace whose output a developer didn't like, optionally change the code, re-run it against their LOCAL code, and show a concise diff of old vs new output — repeating until they're happy. Assumes nothing about the project's layout.

Invoked from the developer's coding agent: /agent-observability-replay-trace <trace-id> [<changes to test>]. With no modification, do the replay + diff only (a reproduce/regression check), then offer to enter the loop.

This file is the workflow spine — terse on purpose. The depth lives in references/details.md (trace backend + pup flags, the runner contract, export mode, polling, the trace-link scoping fix) and references/local-setup.md (making a deployed-only app locally runnable). Read details.md before you touch pup or generate the runner.

Writing code — keep comments minimal to none. Everything you generate or edit (the annotation, the runner's ENTRYPOINTS entries, a local harness, iteration edits) should match the surrounding code and carry no unnecessary comments — don't narrate what the code plainly does; add a comment only for a genuinely non-obvious why.

Intent tagging: On every datadog-llmo MCP tool call, prefix telemetry.intent with skill:agent-observability-replay-trace[<inv_id>] — (a short per-run id, generated once and reused for every call) followed by a description of why the tool is being called. On the first MCP tool call only, use skill:agent-observability-replay-trace:start[<inv_id>] — instead (note the :start suffix). Example first call: skill:agent-observability-replay-trace:start[3a9f1c2b] — fetch the original trace's baseline output. pup-CLI calls carry no telemetry.intent, so this applies only on the MCP path.

Interaction model — selector gates, never a hard stop

This is a live loop. At every decision point present the choices as an AskUserQuestion selector (the plan-mode-style menu), not a plain question that ends your turn. Two gates: (a) after you propose code changes, before replaying; (b) after each diff. The selector's free-text option lets the user type detail (what to refine) inline — act on it directly, don't ask a follow-up. Keep re-presenting after every replay until they pick "stop here".

Scope — check first

  • Traced with ddtrace / LLM Obs (an ml_app + a discoverable entrypoint). Python is first-class; other languages work but you write the runner to the contract in their SDK/build tooling.

  • JSON-serializable entrypoint input, and a callable seam for the root span (see step 3.5 — not a binary "is it runnable?"; deployed-only apps often still expose a plain callable).

  • A trace-access backend — the datadog-llmo MCP (used when present) or the pup CLI (fallback, and the easier install if you have neither) (step 0).

  • Credentials: DD_API_KEY + DD_SITE + provider key(s). Not DD_APP_KEY — plain trace, not an Experiment (that's agent-observability-replay-experiment).

  • Side effects, irreversible: replaying re-runs real code (model spend + real writes), and LLM Obs traces cannot be deleted — a mis-scoped replay (wrong ml_app) permanently pollutes the production app's dashboards/eval sets. That's why the <ml_app>-local isolation (steps 4/6/7) is load-bearing, not tidy. Warn before the first replay.

Workflow

0. Ensure a trace-access backend

Pick, in order: (1) the MCP if mcp__datadog-llmo-mcp__* tools are present — the default (slightly richer for reads: structured tree + content_info); (2) else pup if installed and pup auth targets the app's org; (3) else the user has neither → guide the pup install (it's easier to set up than the MCP, so recommend pup here):

brew tap datadog-labs/pack && brew install datadog-labs/pack/pup
pup auth login

(MCP alternative: claude mcp add --scope user --transport http "datadog-llmo-mcp" "https://mcp.datadoghq.com/v1/mcp?toolsets=llmobs"; see https://docs.datadoghq.com/bits_ai/mcp_server/setup/.) Don't proceed without a backend. The backend↔operation mapping and pup's exact flags/gotchas are in details.md — read that section before using pup. Two pup musts: (1) results come back at data.spans[] or top-level spans[] (varies by version/--no-agent) — parse whichever is present, or you get zero hits on an ingested trace (a silent false negative, step 7); (2) check token expiry (pup auth status), not just that auth exists — expiry mid-loop looks like "trace not found."

1. Parse the command

<trace-id> + optional free-text modification (everything after the id); none → diff-only mode. Determine the ml_app from the project (LLMObs.enable(ml_app=…) / DD_LLMOBS_ML_APP) or the trace; confirm if ambiguous.

2. Fetch the trace + locate the baseline

Fetch via the backend; note total_duration_ms (drives step 7), the trace_url, and metadata.replay_input/replay_entrypoint if present. Locate the baseline field — it's not always the root output: the value the developer dislikes may be a tool-call input or an intermediate output several levels deep, and the app may post-process it before the span records it. Pick the field the code change can actually move, or the delta drowns in noise.

2.5. Check for fan-out

If the root span fans out into repeated sibling subtrees (a batch/map over N parallel sub-runs), the change under test is usually visible in a single branch — replaying the whole root costs ~N× spend and time for no extra signal. Offer to replay one representative branch; log what you skipped. Pick deliberately: the cheapest branch that reached the terminal / side-effecting tool (most branches are no-ops that prove nothing), and reconstruct its input from the child span's input, not the root's. Full root only if the change is inherently cross-branch.

3. Resolve the entrypoint + input
  • Entrypoint: metadata.replay_entrypoint if present; else infer from the root span (name/kind) + code and confirm with the user.

  • Input: metadata.replay_input if present; else derive a suggested input (prefer the code signature — the rendered prompt is lossy) and have the user confirm/edit.

3.5. Ensure a local run path (find the innermost callable seam)

Ask "what is the innermost callable seam for this root span, and can I call it directly with JSON?" — not "is the app runnable?". Already directly callable → skip, continue. Buried under a handler/service (deployed-only, no local __main__, live-infra coupling) → follow references/local-setup.md (detect → propose → approve → build). The common middle case — a deployed service whose core logic is already a plain callable (ports-and-adapters) — just extract/call that seam; full local-setup is overkill.

4. Ensure the two persistent artifacts (one-time setup)
  • a) In-entrypoint annotation on the app's real entrypoint, so all future traces (production too) self-describe. Stamp it at span start, not the success/deferred-finish path — a failed run must still carry replay_input (those are the ones you most want to replay):

    LLMObs.annotate(span=span, metadata={"replay_entrypoint": "<stable id>", "replay_input": <extractor>})

    No replay_output — the original trace is the baseline. (Non-Python annotate APIs differ — e.g. Go span.Annotate(llmobs.WithAnnotatedMetadata(...)); see details.md.)

  • Isolation pre-flight (before writing the runner): grep the entrypoint's call path for per-span/ per-call ml_app overrides (Go llmobs.WithMLApp; Python ml_app= on a decorator or in LLMObs.annotate). Those beat the init-level -local, so the app's spans can still land in production — tracer-level config is not proof of isolation. If any exist, the app's ml_app must resolve from env so -local wins.

  • b) The runner — satisfies the language-independent runner contract in details.md (load env → derive <ml_app>-local → dispatch one entrypoint on JSON → flush on every exit path incl. errors → refuse to start unless ml_app ends in -local → print the -local ml_app). Python: copy scripts/replay_runner_template.py and fill ENTRYPOINTS. Other languages: write to the contract — don't assume the Python API carries over (Go APIs + export-mode gotchas in details.md), and where the language has no in-process dotenv add a run wrapper (artifact c) that sources the project env, unsets ambient provider vars, and exports the -local override. Infer + confirm the run command; follow the host repo's build-file conventions (Bazel/Gazelle → cmd/<name>/, run Gazelle, build before replay).

5. (If a change was requested) edit, then gate

Make the code changes, show the developer the diff of your changes, then an AskUserQuestion selector: Replay now / Adjust the changes first / Cancel. Only replay on "Replay now".

6. Replay

Before the first replay: warn (re-running is real — model spend + real writes), and sanitize the environment. The coding agent's own env (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL set by Claude Code, and other provider keys) can make the app's SDK bypass its configured model gateway — a fidelity gap invisible in the diff. Unset ambient provider vars by default and report that you did (don't just ask); grep the app for its own ambient-key guards. Also verify the credential's org matches the trace's org — a mismatch ships the replay somewhere you can't query (looks like ingest lag). On confirmation, record t0 and run — source the project's env file, never inline secrets (the marker tag is fine on the command; DD_API_KEY=<value> inline is blocked by the permission classifier and leaks to history/transcript — use the wrapper/env-file):

DD_TAGS=replay_run_id:<unique-id> <run cmd or wrapper> --entrypoint <id> --input-file <path>

The runner emits under <ml_app>-local (idempotent, so replays never pollute production) and prints that name — poll for the new trace under it.

7. Wait for the new trace
  • Runner subprocess timeout = max(120s, ~3 × total_duration_ms).

  • Ingest poll: after it returns, poll the backend every ~5s up to ~2 min for the replay_run_id tag under <ml_app>-local (pup: --query "replay_run_id:<id>", plain key:value). Before ever reporting "not found," re-query with no tag filter (just <ml_app>-local + window): if that returns spans, your filter/parse/scope is wrong — not ingestion. A false "no trace" reads as normal and invites a wasteful re-run.

  • Verify isolation on each hit — a tag match is NOT proof. --query/tag matching can return a span whose real ml_app is a different app (the --ml-app filter gets ignored). Read ml_app off every returned span and assert it ends in -local before reporting a clean replay — otherwise you report "clean replay under -local" while the trace is actually in production (which you can't undo). This false confidence is worse than the false negative. Don't hard-fail on timeout; offer to keep waiting.

Concise summary of how the new output differs from the old — meaningful differences only. Note live-world drift; and because any nondeterministic agent varies run-to-run, default to two replays (diff-only mode too, not just model-facing edits) and use replay-to-replay comparison — if the two local runs differ from each other about as much as from production, the delta is sampling variance, not your change. If the replay disables a side-effecting integration (dry-run), that integration's subtree is absent — exclude it from both sides before comparing span counts, or the structural diff is junk. Lead the diff with both trace links:

  • Old: trace_url verbatim — but under fan-out (you replayed one branch) link the branch span, not the whole-root url.

  • New (replay): must carry ml_app=<ml_app>-local or it opens empty — and the trace_url is an org-switch wrapper (…/switch_to_user/<id>?next=<encoded /llm/traces …>&flow=org_switch), so inject ml_app=<ml_app>-local into the decoded next query and re-encode; do NOT append to the outer URL (mechanics in details.md). Browser-unverifiable from here — confirm once it opens non-empty.

9. Gate — iterate, or stop on a broken harness

Harness-failure gate (before the diff): if a replay reveals the harness is wrong — trace landed under the wrong ml_app, no trace after the step-7 sanity checks, missing flush, or auth/org misrouted — do NOT proceed to a diff on bad data. Stop and present a selector to fix the harness (re-scope ml_app / add flush / fix env) and re-replay. Otherwise, after the diff, an AskUserQuestion selector: Looks good — stop here (finish; leave the edits in the working tree) / Make more changes (free-text inline → back to step 5). Re-present after every replay; end only on "stop here".

Reference

  • references/details.md — trace backend + pup exact flags, the runner contract (+ Go, export mode), polling + the false-negative sanity check, the trace-link scoping fix, limitations. Read before pup / the runner.

  • references/local-setup.md — making a deployed-only app locally runnable (step 3.5). Read when that gap shows.

  • scripts/replay_runner_template.py — the Python runner to copy + fill.

Files

references/details.md

Replay a trace against local code — details & rationale

Read this before generating the runner. It covers the trace-access backend, the artifacts, the correlation/polling mechanism, the diff, and the known limitations.

Trace-access backend (MCP or pup)

The skill reads traces through one of two backends; every other step is backend-agnostic:

operationMCP backendpup backend
fetch a traceget_llmobs_tracepup llm-obs spans get-trace --trace-id <id>
read span outputget_llmobs_span_contentpup llm-obs spans get-content
poll for the replaysearch_llmobs_spanspup llm-obs spans search

Use the MCP when it's present — the default; slightly richer for reads (structured tree + content_info, plus a ready trace_url). Fall back to pup when the MCP isn't installed (pup auth must point at the app's org); pup returns everything this skill needs too — trace_url, span output, and a tree via --include-tree. If neither is available, guide the pup install — it's easier to set up than the MCP (and the other Agent Observability skills fall back to pup): brew tap datadog-labs/pack && brew install datadog-labs/pack/pup, then pup auth login. MCP alternative: claude mcp add --scope user --transport http "datadog-llmo-mcp" "https://mcp.datadoghq.com/v1/mcp?toolsets=llmobs" (see https://docs.datadoghq.com/bits_ai/mcp_server/setup/). Both the MCP and pup return a ready trace_url — use it verbatim (no construction).

pup exact usage (flags are non-obvious, and this copy has drifted from reality before — treat pup <cmd> --help and pup's own error text as the source of truth; keep only the non-obvious bits here):

  • Response shape (parse defensively — it has drifted between versions): in AI-agent mode pup wraps the payload in {status, data, metadata} with spans under data.spans[]; with --no-agent (pup 1.8.0) they have come back at top-level spans[]. Read whichever of data.spans[] / spans[] is present. Parsing the wrong level returns zero hits on an already-ingested trace — a silent false negative (see polling). Prefer --no-agent for a stable script path.

  • pup llm-obs spans get-trace --trace-id <id> --from 30d — --trace-id is a flag, not positional; the default window is 1h, so pass --from as a bare duration (30d/7d/1h). now-30d is also accepted. Returns total_duration_ms and trace_url. Carry trace_id forward, not apm_trace_id (also present in results) — feeding apm_trace_id into get-trace 404s.

  • pup llm-obs spans get-content --trace-id <id> --span-id <root-span-id> --field output --from 30d — span-id from get-trace; --field is required (input/output/messages/…). Same 1h default window as get-trace, so pass --from for older traces — a 404 "span not found: <id>" here is usually the window, not a bad span id.

  • pup llm-obs spans search --ml-app <app> --root-spans-only --from <t0> --query "replay_run_id:<id>" — the tag filter is a plain key:value in --query; the MCP-style @replay_run_id: matches nothing. Full results already include each span's output, tags, and trace_url, so the poll can read the new trace's output straight from the hit (no separate get-content needed).

  • Multi-org: add --org <name>, or make sure pup auth selected the app's org — a mismatched org returns a bare 404 "no spans found", not an auth error.

  • Check token expiry, not just presence. Read pup auth status for the remaining token time before a loop; expiry mid-loop surfaces as 404 "no spans found" and feeds the false-negative trap. Re-auth if it's short.

Two persistent artifacts (one-time setup, reused every iteration)

  1. In-entrypoint annotation — the entrypoint stamps its root span so future traces self-describe:

    LLMObs.annotate(span=span, metadata={
        "replay_entrypoint": "<stable id for this execution type>",
        "replay_input": <input extractor>,   # e.g. {"tickers": tickers}
    })

    Only replay_input + replay_entrypoint — no replay_output. The original trace already holds its output; the diff reads outputs from the two traces via the backend, so storing it would be redundant. Stamp it at span START, not on the success/deferred-finish path — otherwise a run that errors carries no replay_input, and failed runs are exactly the ones you most want to replay.

  2. The runner — a small CLI (not a server) with an ENTRYPOINTS dispatch table keyed by replay_entrypoint. The skill invokes it to re-run one entrypoint on a given input. Satisfies the runner contract below. Keep the file; extend the table when new entrypoints appear.

  3. A run wrapper (only where the language has no in-process dotenv) — e.g. a shell script for Go. It is the natural home for the env work the Python template does inline: source the project's env file (never inline DD_API_KEY=<value> on the command — the permission classifier blocks it and it leaks into shell history + the transcript; see contract point 2), unset ambient provider vars, and export the <ml_app>-local override before invoking the runner. Where in-process dotenv exists (Python), the runner covers this and no wrapper is needed.

The annotation + runner are set up once per app. The per-iteration cost is just: edit → run → poll → diff.

replay_entrypoint is the dispatch key (not a hint)

The runner routes on it: ENTRYPOINTS[replay_entrypoint] → the function to call. When a trace carries it, dispatch directly. When it doesn't (an old, pre-annotation trace), infer the entrypoint from the root span + code, confirm with the user, then add the annotation so future traces carry it.

replay_input is the same story: present on the trace → use it; absent → derive a suggested input (prefer the code signature; the rendered prompt on the span is lossy) and have the user confirm/edit.

The runner contract (language-independent)

The runner is generated in the app's language; the Python template is the reference implementation, not the definition. Any runner — Python, Go, TS, … — must satisfy this contract; don't assume the Python API shape carries over:

  1. Inputs: a --entrypoint <id> + --input-file <path.json> (JSON kwargs) CLI, invoked with the marker in the environment: DD_TAGS=replay_run_id:<marker> <run cmd> --entrypoint <id> --input-file <path>.

  2. Env: make the project's env authoritative over ambient vars (Python: load_dotenv(override=True); languages without in-process dotenv → do it in the run wrapper, artifact 3). A shell configured for the MCP often exports DD_* for a different org, and the coding agent itself exports provider vars. Never inline secret values on the command — source the env file (the permission classifier blocks DD_API_KEY=<value> …, and it leaks into history + the transcript).

  3. ml_app derivation: enable LLM Obs under <ml_app>-local (idempotent + "-local") so replay/test traces never pollute production. Tracer/init-level ml_app is NOT proof of isolation: a per-span/per-call override (Go llmobs.WithMLApp(...); Python ml_app= on a decorator or in LLMObs.annotate) beats the init setting, so the app's spans can still land in production even with the runner set to -local. Before writing the runner, grep the entrypoint's call path for such overrides; if any exist, the app's ml_app constant must resolve from env so the -local override wins — configuring the runner alone won't do it.

  4. Dispatch: route <id> → the entrypoint function and call it directly — no wrapper span — so the replay trace is structurally identical to a normal run.

  5. Flush on EVERY exit path, including errors (try/finally). A failed replay that exits without flushing leaves no diffable partial trace — the worst case, because the developer sees nothing.

  6. On exit, print the -local ml_app the caller should poll under.

  7. Fail-fast interlock: refuse to start unless the resolved ml_app ends in -local (one line). Cheaper to trip here than to discover a permanently-polluted production ml_app afterward — LLM Obs traces cannot be deleted (see limitations).

Per-language notes (this is where the template does not transfer):

  • Go (dd-trace-go) — the Python LLMObs.* shape does not carry over; the actual APIs:

    • init: tracer.Start(WithLLMObsEnabled(true), WithLLMObsMLApp("<ml_app>-local"), WithLLMObsAgentlessEnabled(true)).

    • flush/stop: defer tracer.Stop() (calls llmobs.Stop()) and tracer.Flush() (flushes APM + LLM Obs) on every exit incl. the error path — in a defer.

    • annotate: span.Annotate(llmobs.WithAnnotatedMetadata(map[string]any{...})) (or the option to AnnotateTextIO) — not LLMObs.annotate(span=…, metadata=…).

    • common first failure: WithLLMObsAgentlessEnabled(true) errors hard without DD_API_KEY — name it.

  • Other stacks: learn the SDK's enable/flush/annotate API and build/run command; keep the seven contract points.

Export mode (agentless vs Agent sidecar). The template enables agentless (agentless_enabled=True), which is correct for local replay, but a service may be wired to a local Agent sidecar — check before replaying and prefer agentless locally. Known benign gotcha: with agentless LLM Obs on, the APM tracer may still dial localhost:8126 and log ERROR: lost N traces … connection refused. Harmless — LLM Obs spans ship independently and arrive fine — but it looks like a failed replay, so flag it pre-emptively (it compounds the false-negative failure mode in the polling step).

DD_TAGS=replay_run_id:<marker> makes ddtrace stamp the marker on every span of the run (the same channel that carries git_commit_sha, env, etc.), so the caller can locate the new trace by that tag without altering the trace shape. An earlier version wrapped the run in a replay workflow span; that showed up as a visible extra root, so it was dropped in favor of the tag.

Timeouts & finding the new trace

Two separate waits, keyed off the original trace's total_duration_ms (read via get_llmobs_trace in step 2):

  • Runner subprocess timeout = max(120s, ~3 × total_duration_ms). The replay runs the same code, so it takes roughly the original duration; 3× catches a hung/stuck run without tripping on a normal one.

  • Ingest poll (after the runner returns): ingest lag is seconds-to-~2 min and does not scale with duration, so poll every ~5s up to a flat ~2 min. Preferred: search the backend for the replay_run_id tag (from ≈ the replay launch time) — MCP search_llmobs_spans (tags: {replay_run_id: <id>}) or pup spans search --ml-app <ml_app>-local --root-spans-only --from <t0> --query "replay_run_id:<id>". Fallback: the newest root span for this ml_app

    • entrypoint created after launch. Treat "not found yet" as normal for the first attempts; on timeout don't hard-fail — tell the user it hasn't appeared yet and offer to keep waiting.

  • Before ever reporting "not found," re-query with no tag filter (just <ml_app>-local + window). This distinguishes "nothing ingested" from "my filter/parse/scope is wrong" (e.g. reading the wrong spans array, or polling the production ml_app). A parse/scope bug produces a confident false negative — the worst outcome the skill can produce, because it looks like a normal "no trace" and invites a wasteful re-run. If the unfiltered query returns spans, the problem is the filter, not ingest.

  • False-positive twin — verify the ml_app on each hit, don't trust the query. pup … search --ml-app <app> --query "<tag>:<val>" has returned a span whose actual ml_app was a different app — the --query tag did the matching and --ml-app was effectively ignored. So a hit is not proof of isolation. Read ml_app (or the ml_app: tag) off every returned span and assert it ends in -local before reporting a clean replay. This is worse than the false negative because it produces false confidence: "clean replay under -local" while the trace is actually sitting in production (which you can't undo — traces don't delete).

The diff

Fetch the new trace and produce a concise natural-language summary of how the new output differs from the old output — the meaningful differences only, not the full span trees or tool-by-tool trace. Keep it short; the developer is iterating fast. Call out that live-world drift (time, prices, live search results) can change the output even when the code didn't — so not every diff is attributable to the code change.

Sampling variance is a separate confound from drift — n=1 can't isolate it. Any nondeterministic agent needs a control, not just prompt edits: default to two replays (in diff-only mode too, not only when the edit targets model-facing text). The technique that makes a run conclusive is replay-to-replay comparison — if two local runs differ from each other about as much as either differs from production, the wording delta is sampling variance while the decision layer is stable. One replay can't produce that signal. Deterministic-code edits are the only case where a single replay suffices.

Exclude disabled-integration subtrees before comparing structure. If the replay disables a downstream side-effecting integration (dry-run/stub/nil-adapter), that integration's whole subtree is absent from the new trace — so raw span-count / tool-count comparisons against production are junk (they mostly measure the skipped subtree, not your change). Identify the subtree the dry-run skips and exclude it from both sides before any structural diff.

Always lead the diff with clickable links to both traces, so the developer can open either run in the UI. Both backends return a ready trace_url, with one scoping trap on the replay link:

  • Old trace → use its trace_url verbatim; do NOT hand-construct a /llm/traces URL. Under fan-out (you replayed one branch — step 2.5 / below), the old trace_url opens the whole root; instead link the specific old branch span (or tell the user which child to open) so old and new line up.

  • New (replay) trace → the trace_url has no ml_app, so as-is it opens scoped to whatever app the user's picker last had (usually production) and renders empty; it needs ml_app=<ml_app>-local. The trap: the returned URL is an org-switch wrapper — …/switch_to_user/<id>?next=<URL-encoded /llm/traces …>&flow=org_switch — with the real /llm/traces query encoded inside next. Appending &ml_app=… to the outer URL is a no-op (it sits after &flow=org_switch; the switch_to_user endpoint drops it and redirects to the decoded next without ml_app). Correct fix: URL-decode next, add ml_app=<ml_app>-local to its /llm/traces query, re-encode next (keep &flow=org_switch). Bare /llm/traces?… URLs (no wrapper) → append directly. Browser-unverifiable from here — confirm once that the replay link opens non-empty.

- [Old trace](<old trace_url — verbatim>)
- [New trace](<new trace_url with ml_app=<ml_app>-local injected into `next`>)

The link text is just "Old trace" / "New trace".

Interaction: selector gates, not hard stops

This is a loop, so the two decision points — (a) after proposing code changes, (b) after each diff — must be AskUserQuestion selectors, not plain questions that end the turn. Present the choices, act on the pick, and after every replay re-present the diff gate. Only finish when the user selects the "stop" option.

  • Gate (a) — after proposing changes: Replay now / Adjust the changes first / Cancel.

  • Gate (b) — after each diff: Looks good — stop here / Make more changes (describe the change → edit → replay → new diff). "Make more changes" covers the first change in diff-only mode too.

The selector always exposes a free-text option, so the user can type the refinement/adjustment inline in the same view — use that text directly instead of asking a separate follow-up question.

This keeps the loop live the way plan mode stays open until you approve.

Determining the run command

Infer it from the project (Python venv/interpreter, package.json, a build step for compiled languages) and confirm with the user before using it — don't silently assume. Ask the user only when detection fails or the entrypoint can't be called with just JSON input (needs live infra built first). On a transient dependency-fetch failure, retry the build once before reporting a break.

Monorepo / generated build systems. Follow the host repo's build-file conventions for the new runner (and wrapper): repos with generated metadata (Bazel/Gazelle, Pants, Buck, lockfiles) need the regeneration step or the file won't build. For Bazel: put the runner at cmd/<name>/ under the app, run Gazelle on the touched dirs, build before replaying, and prefer executing the built binary from bazel-bin/… directly over bazel run (cleaner env control — matters for the -local override and unset provider vars).

Baseline field & fan-out (get these right or the diff means less than it looks)

  • The baseline is not necessarily the root output. The value the developer is dissatisfied with can be a tool-call input or an intermediate output several levels deep; the root output may be a summary that carries nothing about the change. Locate the field(s) that actually express the complaint. Also check for post-processing between what the model produced and what the span records — diff the value the code change can move, not the boilerplate-appended one.

  • Fan-out roots. If the root dispatches N repeated sibling subtrees (a batch/map), the change is usually visible in one representative branch; replaying the whole root costs ~N× spend/time for no extra signal. Offer a single-branch replay and log what was skipped. Pick the branch deliberately: prefer the cheapest branch that reached the terminal / side-effecting tool — most branches are no-ops that would reproduce trivially and prove nothing. Reconstruct that branch's input from the child span's input, not the root's.

Known limitations

  • Side effects, and traces are permanent. Replaying re-runs real code: real model spend every iteration, and any real writes the agent performs (DB, email, billing, queues) happen again — warn before the first replay (safe pattern: test doubles / dry-run / read-only creds). And LLM Obs traces cannot be deleted — a mis-scoped replay (wrong ml_app) permanently contaminates the production app's dashboards and eval sets. That irreversibility is what makes the -local isolation load-bearing, not tidy: contract points 3 + 7, the per-span-override pre-flight, and the poll ml_app-verification all exist to prevent it.

  • Dry-run changes what the model sees. The "safe pattern" isn't free: a stubbed/dry-run tool returns different text to the model mid-loop, so every turn after the first side-effecting call diverges from production. Treat post-stub turns as lower fidelity, and prefer the app's own dry-run affordance over a hand-written stub (closer to the real return).

  • Ambient environment — the coding agent is the likely contaminant; unset by default. The agent's own env (e.g. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL set by Claude Code) can make the app's SDK bypass its configured model gateway — invisible in the diff — and many SDKs log a warning about exactly this. So unset ambient provider vars by default and report that you did (not just "surface and let the user decide"), and grep the app for its own guards against ambient keys. Also verify the credential's org matches the trace's org before replaying — a mismatch ships the replay somewhere you can't query, which again looks like ingest lag. (override=True / the wrapper handles ambient DD_*.)

  • Ingest lag. The "wait for the new trace" step is the loop's main latency, not the skill logic.

  • Not locally runnable → local setup. If the app can't be invoked locally with a JSON input (deployed-only service, HTTP/gRPC handler, live-infra deps), the skill sets up a local testing flow first — see references/local-setup.md (detect → propose → build; hands back for secrets + the stub-vs-real decision). Input still has to be JSON-serializable once the local path exists.

  • Language — Python is the only templated path. The loop (fetch/edit/diff/backend) is language-neutral, but only Python ships a runner template (scripts/replay_runner_template.py); other languages are not at parity — write the runner (+ wrapper) to the contract from scratch using their SDK/build tooling (Go APIs named above). Treat non-Python as "supported via the contract," not "templated."

  • Credentials. DD_API_KEY + DD_SITE + provider key(s). No DD_APP_KEY (no Experiments API here).

  • Revert. Code edits accumulate in the working tree across iterations; the skill does not auto-revert (deferred) — the user reviews/keeps/restores at the end.

Requirements and compatibility

Written for: Claude Code

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Names Claude Code tools, Needs an MCP server, Needs an API key, Needs a command-line tool, Needs Python

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Datadog
Publisher
Datadog (Official)
Source
datadog-labs/agent-skills · agent-observability/agent-observability-replay-trace · commit 8e1306a
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Datadog or Datadog.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download agent-observability-replay-trace.zip