Trace-Augmented Debugging: Feeding Execution Data to Code Debugging AI for Accurate Patches
Large language models are surprisingly competent at pinpointing suspicious code and producing minimal diffs. But most LLM-powered bug fixes still guess. They infer intent from limited context and produce patches that satisfy the obvious reading of the code — not the ground truth the program demonstrated at runtime. Without execution evidence, the model overfits to the prompt and underfits to reality.
Trace-augmented debugging flips that dynamic. Instead of asking a model to guess, we give it the ground truth: the error, the stack frames where it happened, the inputs that caused it, the logs leading up to it, and the tests that reproduce it. The model stops hallucinating and starts aligning to observable behavior. The result is not just higher fix accuracy — it’s CI-ready patches, fewer regressions, and faster iteration loops.
This article is a practical deep dive into designing such a pipeline. It covers the execution data you should collect, how to normalize and feed it to a code-fixing model, examples across languages, pitfalls to avoid, and how to wire it all into your CI/CD so that every auto-generated fix comes with a repro and a safety net.
Why traces, not guesses
- Guesswork patches fail silently. A patch can look plausible but mismatch actual contracts. Execution data constrains the search space.
- Runtime context is the shortest path to intent. Logs, spans, and stack frames tell you what the code did, not what it should do. Reproducing that path provides a spec-by-example.
- Testing is the arbiter. If the model can run the failing test and see it succeed after the patch, you’ve raised confidence substantially.
Benchmarks and industry experience reinforce this. Public datasets like SWE-bench and SWE-bench Verified show that giving models access to tests and the ability to run them dramatically improves end-to-end task success. The automatic program repair literature (e.g., GenProg, Prophet, SPR, PAR, and the Repairnator project) demonstrates that test-driven repair is both feasible and robust when the fix search is constrained by failing/passing examples.
The core idea: couple an LLM’s generative power with an empirical oracle: your runtime traces and your tests.
What to feed the model: the execution evidence menu
The goal is to give the model enough structured reality to (a) localize the fault, (b) infer constraints on a correct fix, and (c) validate the fix in a loop. The following modalities are most useful.
1) Exception and stack traces
- File, function, and line numbers where the error surfaced.
- Full stack frames, including snippet context (±10 lines) and local variable names/values if safe.
- Error types and messages.
- If minified or optimized, include symbolized frames.
Make it structured. A machine-readable representation is far easier to consume and compress than raw text.
Example minimal JSON-ish shape (use your logging inline format; the point is structure):
{
'error_type': 'ValueError',
'message': 'time data not in expected format',
'frames': [
{'file': 'app/handlers/report.py', 'line': 214, 'function': 'parse_report', 'locals': {'ts': '2025-07-01 12:03'}},
{'file': 'app/main.py', 'line': 88, 'function': 'handle_request'}
],
'env': {'python': '3.11.7', 'tz': 'UTC', 'locale': 'en_US', 'feature_flags': ['strict_dates']}
}
Guidance:
- Keep local values small (truncate large strings, redact PII, cap container dumps).
- Include module versions and build SHA so the model can match code to the trace.
2) Logs (structured, correlated)
Logs provide the surrounding narrative: which code paths led to the error, what inputs went through, and which branches were taken.
Best practices:
- Use structured logs with a consistent schema and fields like trace_id, span_id, user_id (pseudonymized), request_id.
- Include key parameters and decision flags; avoid dumping entire payloads.
- Adopt sampling that guarantees you keep error paths (tail-based sampling works well with tracing).
- Redact or tokenize sensitive values.
3) Distributed traces and spans
OpenTelemetry (OTel) spans add causal structure: parent-child relationships, timings, and event annotations. Error spans often include stack frames and attributes that pinpoint the failing subsystem.
Feed the model:
- The critical span (status=error) with attributes.
- Its immediate parents/children to show context.
- Trace-level baggage (user type, plan, region) — after redaction.
- Metrics exemplars linking spikes to specific trace_ids.
4) Coverage and test results
When the model can see the failing test output and the coverage report, it understands what parts of the code are implicated and which behavior remains green.
Provide:
- Test runner output for failing tests (pytest, JUnit, Jest, Go test, etc.).
- Coverage deltas: lines executed on failure vs lines never hit.
- Flakiness signals (e.g., recent pass/fail rates) to avoid chasing non-determinism.
5) Recording and replay
Reproducibility kills guesswork. Record the minimal stimulus and runtime that reproduces the failure.
Options:
- For C/C++: rr (record-and-replay), gdb with core dumps, addresses symbolized with DWARF.
- For the JVM: Java Flight Recorder (JFR), Async-profiler traces, thread dumps.
- For .NET: dotnet-trace, dotnet-dump, ETW/PerfView.
- For Node/Browser: Chrome DevTools Protocol traces, Replay.io, Playwright/Cypress traces.
- For Go: pprof profiles, execution traces (runtime/trace), and race detector output.
You don’t need full-time travel; even a small reproduction harness plus environment snapshot is enough for deterministic CI execution.
6) Environment snapshot
Bugs hide in edges: versions, locales, timezones, CPU features.
Capture:
- OS/kernel, container image digest, CPU arch.
- Language/runtime versions.
- Dependency lockfiles (requirements.txt + hashes, package-lock.json, go.sum, Cargo.lock).
- Feature flag states.
- Timezone, locale, monotonic vs wall-clock use, random seeds.
7) Core dumps / minidumps (optional)
For crash faults, a symbolized core provides precise memory and state at the crash site. Use addr2line/llvm-symbolizer, Breakpad/Crashpad, or language-specific equivalents to transform raw addresses into code locations.
Making it consumable: normalize, compress, and budget tokens
LLMs are hungry, but your prompt budget is finite. Treat the evidence as a dataset, not a wall of text.
- Normalize: choose a schema for errors, spans, logs, and tests. OTel’s semantic conventions are a pragmatic base for spans and logs. Store as JSON Lines to stream and filter.
- Redact: define PII/PHI/PCI detectors and scrubbers with deterministic pseudonymization so the model can correlate entities within the session without seeing raw identifiers.
- Summarize: preprocess logs into structured summaries: windowed key events, frequency counts, decision outcomes, last-N lines around error.
- Select: retrieve only relevant files and symbols. Use code embeddings/retrieval to pull k most relevant files/functions tied to the stack frames.
- Compress: elide boilerplate stack frames, collapse repetitive log lines (e.g., 1,322 similar lines), and include a single representative with a counter.
Token-efficient shapes:
- Provide a short incident summary first (what failed, where, since when, regression candidate commit).
- Provide the failing test output verbatim.
- Provide the minimal span/log window around the error.
- Provide only code for implicated modules + tests.
The prompt, the tools, and the contract
A robust patching session has three pillars:
- A constrained prompt that establishes the contract.
- Tooling rights to read files, run tests, and re-run the repro.
- A patch size and style budget.
Example high-level instruction block you can adapt (conceptual):
System: You are a senior software engineer tasked with fixing a bug using execution evidence. Follow these rules:
- Use the failing test(s) and reproduction script as the source of truth.
- Change as little code as possible to satisfy the tests and preserve existing behavior.
- Add/modify tests only to encode demonstrated intent (no speculative behavior changes).
- Do not introduce dependencies. Match the repository's style.
- Provide a brief rationale and a diff.
Pass in:
- Repo metadata (language, build system, test command).
- Failure artifacts (tests, traces, logs) described above.
- A reproduction command the model can execute via a tool-calling interface.
Then let the model iterate: propose a patch, run tests, observe failures, refine. Keep temperature low and limit patch size per iteration.
Note: If you’re integrating with a model via a function-calling/tool API, sandbox the tools in an ephemeral environment (disposable container/VM) to avoid state drift and to isolate secrets.
End-to-end example 1: Python, timezone bug
Scenario
- A Flask API receives timestamps from clients. Sometimes the server throws ValueError and returns 500.
- Sentry reports the error with stack frames and a breadcrumb log trail.
- Pytest has a failing test seeded from a real request.
Artifacts (simplified):
Error snippet:
ValueError: time data '2025-07-01 12:03' does not match format '%Y-%m-%dT%H:%M:%S%z'
File 'app/handlers/report.py', line 214, in parse_report
parsed = datetime.strptime(ts, '%Y-%m-%dT%H:%M:%S%z')
Logs around failure:
INFO request_id=abc path=/api/report user=U-42 tz_hint=None
DEBUG parsing ts='2025-07-01 12:03'
ERROR parse_failed reason='format'
Failing test (derived from trace):
# tests/test_report.py
from app.handlers.report import parse_report
def test_parse_accepts_naive_local_with_server_tz(monkeypatch):
monkeypatch.setenv('TZ', 'UTC')
ts = '2025-07-01 12:03'
assert parse_report(ts).tzinfo is not None
Root cause hypothesis via evidence:
- The timestamp arrives without timezone.
- The API previously accepted naive timestamps by assuming server TZ; a new strict format enforcement broke that behavior.
Patch (minimal):
# app/handlers/report.py
from datetime import datetime, timezone
import os
SERVER_TZ = os.environ.get('TZ', 'UTC')
def parse_report(ts: str) -> datetime:
# Accept strict RFC3339 first
try:
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%S%z')
except ValueError:
pass
# Fallback: accept naive 'YYYY-MM-DD HH:MM' by assuming server TZ
try:
dt = datetime.strptime(ts, '%Y-%m-%d %H:%M')
return dt.replace(tzinfo=timezone.utc) # keep SERVER_TZ mapping if needed
except ValueError as e:
raise ValueError('Invalid timestamp format') from e
Add/adjust tests to encode both behaviors:
# tests/test_report.py
from app.handlers.report import parse_report
from datetime import timezone
def test_parse_rfc3339():
assert parse_report('2025-07-01T12:03:00+0000').tzinfo is not None
def test_parse_accepts_naive_local_with_server_tz(monkeypatch):
monkeypatch.setenv('TZ', 'UTC')
dt = parse_report('2025-07-01 12:03')
assert dt.tzinfo == timezone.utc
CI outcome: failing test goes green; unrelated tests remain green due to minimal change. The patch reflects observed behavior and prevents a regression by codifying it in tests.
End-to-end example 2: Node/TypeScript, unhandled rejection and schema drift
Scenario
- A service calls an external API that recently changed a field from string to number. Your code parses JSON into a typed interface.
- Production shows spikes in UnhandledPromiseRejection warnings and 500s.
- OTel traces show error spans in fetchUserProfile with attribute api_version=v2.
Artifacts:
- Stack trace: TypeError: cannot read properties of undefined (reading 'name') at parseProfile.
- Logs: payload contained name: null, id: 1234.
- Failing Jest test seeded from recorded payload.
Patch strategy:
- Make the parser tolerant to null/number; add type guards aligned to OpenAPI schema.
Code diff:
// src/profile.ts
export interface ApiProfileV2 { id: number; name: string | null }
export function parseProfile(payload: unknown): { id: string; name: string } {
if (!payload || typeof payload !== 'object') {
throw new Error('invalid payload')
}
const p = payload as { id?: number | string; name?: string | null }
const id = typeof p.id === 'number' ? String(p.id) : (p.id ?? '').trim()
const name = (p.name ?? '').toString().trim()
if (!id) throw new Error('missing id')
return { id, name }
}
Added test:
// tests/profile.test.ts
import { parseProfile } from '../src/profile'
test('parses v2 payload with numeric id and null name', () => {
const result = parseProfile({ id: 1234, name: null })
expect(result).toEqual({ id: '1234', name: '' })
})
The key: the failing example came directly from a trace. The test encodes exactly that edge case so the fix can be validated repeatedly.
End-to-end example 3: Go, data race under load
Scenario
- Sporadic crashes under load; request metrics show spikes in latency.
- go test -race flags a race in a global cache mutation path.
- pprof/trace shows goroutine interleavings when a refresh occurs.
Artifacts:
- Race detector output pinpointing a shared map write without a mutex.
- Failing test that runs the refresh concurrently with reads, derived from production stack timings.
Fix:
// cache/cache.go
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(k string) (string, bool) {
c.mu.RLock(); defer c.mu.RUnlock()
v, ok := c.data[k]
return v, ok
}
func (c *Cache) Refresh(newData map[string]string) {
c.mu.Lock(); defer c.mu.Unlock()
c.data = newData
}
Test:
func TestRefreshRace(t *testing.T) {
c := &Cache{data: map[string]string{'a': '1'}}
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ { c.Get('a') }
close(done)
}()
c.Refresh(map[string]string{'a': '2'})
<-done
}
When the LLM sees the race output and the minimal test, it switches from guessing to applying a standard concurrency guard idiom.
Building the pipeline: architecture blueprint
A practical trace-augmented debugging system usually looks like this:
- Observability intake
- OTel Collector receives traces/logs/metrics from services.
- Error events route to a debugging queue with attachments (e.g., Sentry/Datadog alert webhook pushes exception + breadcrumbs + trace_id).
- Artifact assembler
- Correlate the error with its trace and logs via trace_id/request_id.
- Pull code revision (commit SHA) from the span attribute.
- Build an artifact bundle: minimal logs, symbolized stack, failing test template, environment snapshot.
- Persist bundles in object storage; attach a unique incident ID.
- Reproducer synthesizer
- Generate failing tests from traces (e.g., reconstruct HTTP requests, serialized inputs, flags) and inject into a temporary branch.
- Create a reproducible script: container image + deterministic seeds + test command.
- Model orchestration
- Provide the bundle to a code-specialized LLM via a tool-enabled agent.
- Tools: read files, search repo, run tests, run lints, run targeted profilers if needed.
- Guardrails: timeouts, patch size limits, allowlist of files.
- CI integration
- Open a PR with the patch and the failing test.
- Link the incident/trace IDs and attach artifact bundle metadata.
- Run full CI: unit + integration + coverage + static checks.
- Rollout and verification
- Canary the fix behind a feature flag if appropriate.
- Use metrics exemplars to verify the fix reduces error rate for the specific trace patterns.
Prompt hygiene and retrieval: how to feed code at scale
Repos dwarf prompt limits. Don’t paste the world; retrieve slices intelligently.
- Start from stack frames: open those files and their imports.
- Use embeddings to pick top N related files for each frame (name, path similarity, import graph proximity).
- Include only function bodies and docstrings near error lines; elide unrelated code.
- Provide test files that fail and those that assert nearby behavior.
- Provide interface schemas (e.g., OpenAPI/Protobuf) if schema drift is suspected.
Tip: Always include the exact commit SHA to avoid misalignment between code and traces.
Failure taxonomy and what evidence helps most
Different bug classes benefit from different evidence.
- Crash/exception faults: stack trace + locals + minimal inputs.
- Logic bugs: failing tests + log decisions + configuration flags.
- Performance regressions: flame graphs (pprof/Parca), critical path spans, GC/memory stats.
- Concurrency bugs: race detector output, thread/goroutine dumps, execution traces.
- Resource leaks: heap profiles, file descriptor counts, open handles.
- API/schema drift: payload samples from traces, schema definitions, compatibility matrices.
Tune your artifact bundle templates per category.
Redaction and privacy: ship the signals, not the secrets
Execution data is sensitive by default. Bake redaction in.
- Classify fields: identifiers, secrets, content, metadata.
- Scrub PII/PHI/PCI with deterministic tokens: replace emails with token like user:U-123, so references still correlate.
- Configure allowlists for attributes sent to the model; denylist everything else.
- Store raw artifacts encrypted with limited retention; store redacted copies for model use.
- Consider self-hosted models for highly sensitive code/data.
Fighting non-determinism: make flaky failures reproducible
Auto-repair fails if the repro is flaky. Stabilize it.
- Fix random seeds (property-based tests, random clients).
- Freeze wall-clock (fake timers) or use monotonic time APIs.
- Pin dependencies and container image digests.
- Isolate network and file system via hermetic tests where possible.
- Repeat failing tests multiple times to confirm stability before accepting patches.
CI-ready patches: from patch to pull request safely
Checklist for a patch you can merge confidently:
- Includes at least one failing test that previously reproduced the bug and now passes.
- Leaves unrelated tests green and coverage unchanged or improved.
- References the incident/trace IDs and includes a short rationale.
- Changes are minimal and localized; refactors are out-of-scope for hotfixes.
- Adheres to style/lint rules; no new dependencies.
- Includes changelog entry if user-facing behavior changed.
Example PR description template:
Title: Fix: handle naive timestamps in report parsing
Incident: INC-4521
Trace: trace_id=5d2c..., span=report.parse
Root cause: Strict RFC3339 parsing rejected naive timestamps seen in production.
Fix: Fallback to server TZ for 'YYYY-MM-DD HH:MM' inputs.
Tests: Added test_parse_rfc3339, test_parse_accepts_naive_local_with_server_tz.
Risk: Low; behavior matches pre-regression traces and is guarded by tests.
Practical orchestration: GitHub Actions example
A minimal orchestrator that packages artifacts and triggers a model job can live in CI.
Workflow outline:
- On issue labeled bug with an attached incident ID, fetch bundle from storage.
- Check out the corresponding commit SHA.
- Generate a failing test file from the bundle.
- Run tests to verify failure reproduces.
- Invoke the LLM job with the repo context and artifact bundle.
- Apply returned diff, push a branch, and open a PR.
Example GitHub Actions step sketch:
- name: Assemble artifacts
run: |
python scripts/assemble_bundle.py --incident ${{ inputs.incident_id }} --out bundle.json
- name: Generate failing test
run: |
python scripts/gen_test_from_bundle.py bundle.json > tests/test_from_incident.py
- name: Verify failure
run: |
pytest -q tests/test_from_incident.py || echo 'Expected failure'
- name: Ask model for patch
env:
MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
run: |
python scripts/request_patch.py --bundle bundle.json --out patch.diff
- name: Apply patch
run: |
git checkout -b fix/${{ inputs.incident_id }}
git apply patch.diff
pytest -q
git commit -am 'Fix: ${ { inputs.incident_id } }'
git push origin HEAD
Your request_patch.py should implement the prompt rules, attach the code retrieval slices, and expose tools for test execution in a sandbox.
Model choices and settings
- Prefer code-specialized models with good reasoning under tool feedback. Keep temperature low (0–0.2) for deterministic patches.
- Allow multiple short iterations rather than one giant change.
- If available, use tool-use/function-calling to let the model run tests and inspect files.
- Enforce patch size and file allowlists.
- Consider ensemble attempts with different seeds and pick the patch that passes the most tests with the smallest diff.
Lessons from program repair research
The automatic repair community provides hard-won lessons that transfer well to LLM pipelines:
- Tests define correctness. Don’t accept a patch that passes only the failing test; run a broad suite and consider metamorphic tests for invariants.
- Overfitting is real. Augment with independent checks: static analyzers, linters, property-based tests.
- Search spaces explode. Constraining with execution evidence and localization dramatically improves success.
- Patch simplicity correlates with correctness. Prefer small, localized edits.
Projects to learn from: GenProg, Prophet, SPR, PAR, and Repairnator. While their search mechanisms differ from LLM generation, the evaluation methodology (fix under tests) and the need to avoid overfitting are directly relevant.
Performance and memory bugs: traces beyond exceptions
Not all failures throw. For slowdowns, leaks, and bloat, feed the model performance evidence.
- Include CPU flame graphs (pprof, Parca), allocation profiles, and hot span paths.
- Provide before/after metrics from the same endpoint and exemplar trace links.
- Add a performance test to CI: budget-based assertions like p95 latency < 120 ms under a canned workload.
Example perf test snippet (Go):
func BenchmarkHandler(b *testing.B) {
for i := 0; i < b.N; i++ {
// call handler with fixed payload
}
}
Turn the regression into a failing benchmark (time budget exceeded), fix it, and make the benchmark pass. The model will learn to optimize along the observed hot path.
Common pitfalls and how to avoid them
- Missing the true root cause. A stack trace shows the crash site, not always the fault site. Include a few parent spans and logs, and consider historical diffs (git blame around the frame lines) to surface recent changes.
- Flaky repros. Stabilize clocks, seeds, and dependencies. If the test flickers, don’t accept the patch.
- Oversharing sensitive data. Redact by default and only allow whitelisted fields; prefer self-hosted models for high-sensitivity repos.
- Model drift into refactoring. Enforce a patch-size cap and instruct the model to minimize changes.
- Ignoring non-functional specs. Add lints, type checks, and perf assertions to the validation loop.
A minimal schema for your artifact bundle
Define a compact bundle file the orchestrator and model agree on. For instance:
version: 1
commit_sha: abcdef1234
language: python
build: 'pip install -r requirements.txt'
test_cmd: 'pytest -q'
incident:
id: INC-4521
summary: 'ValueError parsing naive timestamps in report handler'
first_seen: '2025-07-01T12:10:00Z'
count_24h: 122
trace:
trace_id: 5d2c...
span_path:
- service: api
name: report.parse
status: error
attrs:
tz_hint: null
user_tier: pro
exception:
type: ValueError
message: 'time data not in expected format'
frames:
- file: app/handlers/report.py
line: 214
func: parse_report
logs:
window:
- level: INFO
msg: 'request start'
- level: DEBUG
msg: 'parsing ts=2025-07-01 12:03'
repro:
env:
TZ: UTC
inputs:
ts: '2025-07-01 12:03'
YAML is human-readable; store the canonical form as JSON to feed the model.
Observability integration sketch with OpenTelemetry
- Instrument services with OTel SDKs for traces and logs.
- Use tail-based sampling in the collector to always keep error traces.
- Export error traces with attached exception.events and attributes (commit SHA, build ID).
- On error export, trigger a webhook to your debugger service with trace_id and commit SHA.
- The debugger service pulls the full trace + logs, builds the bundle, and kicks off the repair flow.
This creates a closed loop from incident to patch proposal.
Security and governance
- Auditability: store the artifact bundle, the prompt, and the generated patch with signatures. This creates an audit trail.
- Policy: blocklist sensitive code paths from automatic patching; require human review for certain directories.
- Supply chain: sign the patch commits (Sigstore), run SAST/DAST and SBOM checks in CI.
Measuring success
Track metrics to prove the system adds value:
- Mean time to resolution (MTTR) from incident to merged fix.
- Patch acceptance rate after human review.
- Post-merge regression rate for AI-generated patches vs human-only.
- Test stability: reduction in flaky failures.
- Incident recurrence for the same root cause.
A/B test: route a portion of incidents through the trace-augmented flow and compare outcomes to manual triage.
Future directions
- Execution-aware training: fine-tune models on corpora that include traces and test diffs, not just code.
- Proof obligations: pair LLM patches with static contracts or model checking for critical modules.
- Time-travel debugging as a service: integrate rr/pernosco-like capabilities into the agent loop.
- Cross-service causality: multi-repo patches driven by a single distributed trace.
A concise checklist to get started
- Instrument: adopt OpenTelemetry for traces/logs; attach commit SHA and feature flags.
- Capture: ship exceptions with stack frames and locals (redacted).
- Bundle: define a minimal JSON schema for incident + trace + repro.
- Reproduce: synthesize failing tests in a hermetic environment.
- Orchestrate: give the model tools to read code and run the repro; cap patch size.
- Validate: run full tests, lints, type checks, and, when relevant, perf budgets.
- Govern: redact, audit, and require human review.
Trace-augmented debugging is not just about stuffing more context into a prompt. It’s a design pattern: bind model creativity to the truth of execution and the discipline of testing. Done well, it replaces guesswork with grounded engineering, turning your observability exhaust into the fastest path to reliable, CI-ready fixes.