Debugging a Run with Traces
A run trace is one JSON file that records everything a single question did
inside the engine: every node execution in order (retries and parallel
sub-queries included), the state each node read and the update it returned, its
errors and warnings, and for every LLM call the exact messages sent, the raw
response, the parsed result and the token usage. The final QueryResult is in
it too.
The point is to run a failing question once, then debug it from the file: read it, print its timeline, or replay it through the pipeline without calling the model again.
When a trace is written
Each engine reads these from the settings.traces section of its config, so
two engines in one process trace by their own settings. The 0.4 variable each
one is still set from by the CLI and the playground is in brackets.
| Setting | Default | Meaning |
|---|---|---|
settings.traces.mode |
on_failure |
off, on_failure or always. |
settings.traces.sample_rows |
50 |
Result rows kept in the trace. |
settings.traces.max_field_chars |
20000 |
Longest string kept in a node's recorded inputs and outputs. |
Traces are written through the trace store in storage.traces (see
Storage). The built-in files store writes them
to <storage.path>/traces/; nl2sql init puts storage.path in
.gitignore. A trace store of your own (the TraceStore protocol in
nl2sql_sdk.stores: write, get, purge) receives the whole document.
Traces are kept for the store's max_age_days, 14 days by default
(traces: { type: files, max_age_days: 30 }). engine.storage.purge() deletes
the older ones; nl2sql storage purge calls it, and nl2sql-api calls it at
startup and once a day.
on_failure writes a trace when the run returned errors, needed a retry in any
sub-query, had a node fail, or did not complete (timeout, cancellation, crash).
A run that recovered on retry therefore still leaves a trace.
The demo's config (nl2sql init --demo) sets settings.traces.mode: always
in its settings.yaml, so every playground question is traced.
The trace is recorded by a callback that run_with_graph attaches to every
run, so the CLI, the Python API, the REST API and the playground all produce it.
Writing a trace never fails a run; a write error is logged as a warning.
Where the file went is reported back:
QueryResult.trace_path(Python API) andtrace_pathin the REST/queryresponse: the path, ornullwhen no trace was written or the trace store is not thefilesstore.trace_idis in the file name, andengine.traces.get(trace_id)reads the trace from any store.nl2sql runprintsTrace written to <path>.- The playground shows a Download trace link under Cost & time when Debug is on.
File names are <UTC timestamp>_<trace_id>.json, so a directory listing sorts
by time.
What is in the file
{
"trace_format_version": 1,
"trace_id": "159afe1f-3305-4907-903a-55495d040665",
"started_at": "2026-09-21T16:48:37.324431+00:00",
"finished_at": "2026-09-21T16:48:37.712202+00:00",
"duration_s": 0.3878,
"outcome": "completed",
"failed": true,
"error": null,
"request": {"question": "How many customers are there?", "roles": ["admin"],
"tenant_id": "default_tenant", "datasource_id": null, "execute": true},
"engine": {"version": "0.1.2", "git_sha": "2cfe121", "python": "3.13.14"},
"llm": {"configured": {"default": {"provider": "openai", "model": "gpt-5.4", "temperature": 0.0}},
"by_node": {"decomposer": "gpt-5.4", "ast_planner": "gpt-5.4", "refiner": "gpt-5.4",
"answer_synthesizer": "gpt-5.4"}},
"settings": {"...": "the run's settings section: pipeline, retrieval, results, traces, schema, ..."},
"limits": {"sample_rows": 50, "max_field_chars": 20000, "max_list_items": 200, "note": "..."},
"nodes": ["... one entry per node execution, below ..."],
"events": ["... the run's bus events, below ..."],
"result": {"...": "the QueryResult, rows capped"}
}
events lists the run's own events in the order they were
published (run.started, each node.started and node.finished with its
sub_query_id, result.manifest, llm.call and the rest), every one but
answer.delta, whose text the result holds whole. Each is the event as JSON:
name, at, trace_id, tenant_id and data. The trace is written before
the run's run.finished is published, so the list ends just before it; the
trace's result is what that event summarises.
A log line written while the run was in flight carries the same trace_id,
on the caller's thread and on every graph worker thread, so grepping the
logs for a trace's id finds its lines (Events).
One entry of nodes (the planner's second attempt, from a run whose first plan
named a column that does not exist; long values shortened here):
{
"seq": 20, "end_seq": 21,
"node": "ast_planner", "sub_query_id": "sq_9579d7d41aee", "attempt": 2, "parent": "sql_agent",
"status": "ok",
"started_at": "2026-09-21T16:48:37.683402+00:00", "duration_s": 0.0071,
"inputs": {"sub_query": "...", "relevant_tables": "...", "errors": "...", "retry_count": 1},
"outputs": {"ast_planner_response": "...", "reasoning": "...", "errors": []},
"errors": [], "warnings": [], "exception": null,
"llm_calls": [{
"key": {"node": "ast_planner", "sub_query_id": "sq_9579d7d41aee", "attempt": 2, "call_index": 1},
"model": "gpt-5.4",
"params": {"model": "gpt-5.4", "temperature": 0.0, "seed": 42},
"messages": [{"role": "user", "content": "[ROLE]\nYou are a SQL Planner. ..."}],
"prompt_sha256": "63ccb543...",
"response": {"content": "{\"query_type\": \"READ\", ... \"column_name\": \"CustomerId\" ...}",
"tool_calls": [], "finish_reason": "stop", "model_name": "gpt-5.4"},
"parsed": {"query_type": "READ", "...": "..."},
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2, "...": "..."}
}]
}
seq/attempt/sub_query_id:seqorders executions by start. The attempt counts per node and sub-query, so a retried planner isattempt: 2. Nodes inside the SQL agent carryparent: "sql_agent".status:ok,warning(the node returned warnings, such asCOLUMN_NOT_FOUNDwhen strict columns are off),error(it returned an ERROR or CRITICAL error, raised, or an LLM call failed) orunfinished(it never ended: the run timed out or was cancelled first).inputsare the state fields that node reads, not the whole graph state;outputsis the update it returned.-
llm_calls: the messages exactly as sent, the model's raw answer (text content, or tool calls with their arguments), what the node parsed it into, and the usage record fromTokenUsageCallback(the same recordQueryResult.usage.callsreports).paramsholds what was sent: an agent configured withtemperature: nullhas notemperaturethere, andllm.configuredshows it asnull.modelis the model the provider says answered, per call, so per-node models show up per node inllm.by_node. -
Plan cache hits. When a sub-query's plan came from the plan cache, its
ast_plannerentry hasoutputs.ast_planner_response.plan_source: "cache"andllm_calls: [](zero planner tokens), and the validator, generator and executor entries follow as usual. The result shows the same thing asresult.sub_queries[].plan_sourceandresult.usage.plan_cache_hits. See Determinism → The plan cache.
Retrieval
The datasource_resolver and schema_retriever entries carry a retrieval
object in outputs: what the vector search retrieved and how MMR chose among it
(the engine has no re-ranking model; see
Indexing).
"retrieval": {
"skipped": false,
"query": "Tracks sold per genre, highest first\ngroup_by: genre name ...",
"searches": [{
"search": "tables",
"filter": {"datasource_id": "chinook", "types": ["schema.table", "schema.metric"]},
"k": 8, "fetch_k": 32, "lambda_mult": 0.7,
"pool": [
{"rank": 1, "id": "schema.table:[main].[Track]:<version>", "label": "[main].[Track]",
"type": "schema.table", "datasource_id": "chinook", "table": "[main].[Track]", "column": null,
"similarity": 0.5651, "distance": 0.8698,
"picked": true, "pick_order": 1, "mmr_score": 0.5651, "redundancy": 0.0}
],
"picks": ["<ids in the order MMR picked them>"],
"dropped": ["<ids of the pool entries not picked>"]
}],
"tables": [{"table": "Track", "columns": ["Name", "GenreId"]}]
}
queryis the text actually embedded: the question for the resolver's routing, and for the schema retriever the sub-query's intent, filters, group-by, expected columns and metrics, one per line.poolis every candidate, nearest first.similarityis the cosine similarity MMR uses;distanceis Chroma's (squared L2). The resolver's routing searches record no distance: each ranks its pool by similarity alone (recorded aslambda_mult1), and its picks are each datasource's best entry.pick_orderis the order MMR picked in,mmr_scorethe score the pick won with, andredundancyits similarity to the closest earlier pick.searcheslists each search in order; the schema retriever runstables(orcolumnswhen that finds nothing), thenplanningover the tables found. The resolver runs one search per routing signal (route.description,route.examples,route.curated,route.tables,route.columns) and ends with aroutingrecord: each signal's datasource ranking (signals), the fused reciprocal-rank scores with each datasource's ranks (fused), the datasources kept (picks) and those dropped (dropped, withfloororlimit), and therrf_k,vote_k,candidatesandfloor_ratioused. See Routing.tables(schema retriever) is what survived: the tables and columns sent to the planner.skipped: truewith areasonmeans no search ran: the schema is small enough to send whole (each of the demo's three schemas, at 11, 4 and 5 tables, always is), only one datasource is registered ("single datasource: vector search skipped", which the demo is not: it registers three, so the resolver searches on every question), the request named adatasource_id, or there is no vector store. The resolver's answerability check is an LLM call, so it is in that entry'sllm_calls, not inretrieval.
The record carries entry ids and names, never an entry's embedded text, which
for a column holds its statistics and sample values. A table the run's role
may not read is never searched at all (the searches run over the readable
tables only, and the filter records how many as searched_tables), so it
never appears in the record. The record is built from the
numbers MMR computes anyway, so it is always there and costs no extra search.
The file records what happened. A sub-query's status reflects its final
attempt, so one that recovers on retry reports "success"; the failed
attempts stay in the node entries and in the result's warnings.
Redaction
Every trace is redacted before it is written, with no setting to turn it off:
- every value any secret reference resolved to (
${env:...}and the other secret providers), the configured API keys, and the values of environment variables whose names containKEY,TOKEN,SECRET,PASSWORD,CREDENTIAL,CONNECTION_STRINGorAUTH; - anything shaped like a secret even if the engine never saw the value:
sk-...style keys,Bearer ...tokens, the password inscheme://user:password@host, andpassword=,pwd=,secret=,api_key=,AccountKey=,token=pairs; - the string value of any key named like
api_key,password,secret,authorization,access_key,connection_stringorcredential(s); - the settings snapshot is the
settingssection only, which holds no credential: keys live in thellm,embeddingsanddatasourcessections as references, and their resolved values are redacted as above.
A test runs a real question with the fake key sk-test-not-a-real-key-1234 in
the environment and asserts that string appears nowhere in the file. Redaction
is pattern and value based, so treat a trace as internal: it contains your
prompts, schema and a sample of result rows.
Capping
Node inputs and outputs, and the result, are capped so a trace stays readable:
result rows (rows, and the aggregator's terminal_results) keep
settings.traces.sample_rows rows, other lists keep 200 items, and strings keep
settings.traces.max_field_chars characters. Every cut leaves a [truncated] marker
saying how much was dropped. LLM messages and raw responses are never cut,
because replay needs them whole; a planner prompt on Chinook is about 12 000
characters, and a whole trace is typically a few hundred kilobytes.
Reading a trace: nl2sql trace show
nl2sql trace show traces/20260921T164837755710Z_159afe1f-....json
nl2sql trace show 159afe1f-3305-4907-903a-55495d040665 # looks it up in the trace store (engine.traces.get)
It prints the question, then one row per node execution (nested SQL agent
nodes indented) with sub-query, attempt, duration, tokens and status, then
each error or warning, and marks the first failing node with >. A run with no
failing node but a retry marks the first warning instead, since that is what
set the retry off.
Replaying a trace: nl2sql trace replay
Replay runs the real pipeline again for the recorded question, roles and
datasource, but every LLM call is answered from the trace. The engine's chat
clients are pointed at an in-process transport, so no request reaches a model
provider and no key is needed; the command reports how many recorded calls it
served. It then compares the replayed QueryResult with the recorded one
(ignoring ids, timings, usage and artifact paths) and says whether it matches.
- Keying. Recorded answers are looked up by
(node, sub_query_id, attempt, call_index), never by question text: a retried planner asks the same question twice and gets the answer it got the first time for that attempt, and parallel sub-queries cannot take each other's answers. - Divergence. If the replayed run asks for a call the recording does not
have, or sends a prompt whose hash differs from the recorded one, replay
stops at that call and reports
Replay diverged at <node>, sub-query <id>, attempt <n>, LLM call <k>, with a diff of the prompt when it changed. If the run finishes without using a recorded call, that is reported too. This is information, not a crash: everything before that point ran as recorded. - Plan cache. Replay makes the LLM calls the recording made. If the
recorded planner called the model, replay turns the plan cache off so that
call is replayed rather than answered from a plan cached since. If the
recorded run used the cache, replay uses it too, and reports a divergence
(
missing) when the cached plan has since been cleared. - Date. The planner and refiner prompts carry today's date, and the trace
records it as
request.today. Replay pinssettings.pipeline.todayto that date, so a trace recorded yesterday replays without diverging. A trace written before the date was recorded replays with the current setting. - Its own runtime. The pinned date, the plan cache choice and a zero retry
back-off go into a config derived from the engine's
(
Runtime.derive), and the replay runs on a runtime built from it, with its own playback clients. The engine's config and LLMs are never changed, so a query running on the same engine meanwhile is unaffected. The replayed result is capped with that runtime'ssettings.tracessizes, as the recording was. - Exit code: 0 when the replayed result matches the recording, 1 on a divergence, a different result, or a datasource that cannot be reached.
What replay can and cannot tell you
- Replay tests the code downstream of the model: parsing, validation, SQL generation, execution, aggregation. It is how you check a fix to that code against a real failure without spending tokens.
- After a prompt change the recorded response is stale. Replay reports the node where the prompt diverged; one real run is needed to record a new trace.
- The datasource must be reachable from where you replay, with the same
configuration (the same
--config). Replay says so and stops if it is not. Queries run again, so a database whose data changed gives a different result, and replay reports the difference. - The datasource resolver and schema retriever still run their vector search,
which embeds the question with the configured embedding provider (the local
model in the demo; with
EMBEDDING_PROVIDER=openaithat is an embeddings API call, not a chat model call). - Redaction applies to the recorded answers too: a model answer that contained something secret-shaped is replayed redacted.
The playground
With Debug on, the per-node table under Cost & time puts a bar beside
each node's time, sized to the slowest node, so the slow step stands out. Each
node name opens that node's record from the trace: every attempt and sub-query, its
errors and warnings, what it read and returned, and for LLM nodes the exact
prompt, the raw response and the parsed result. Long text is folded. For the
datasource_resolver and schema_retriever nodes it also shows the retrieval
record as a table: the pool nearest first, picks marked with their MMR order,
dropped entries greyed, the picks in order as a row of chips, and a second row
naming the entries MMR passed over because they repeat an earlier pick. The Retrieval inspector at the top of the page
runs the same search for any text (see the
demo guide). The page
fetches the trace from GET /api/trace/{trace_id}, which reads it with
engine.traces.get: any id that is not a plain token is rejected before the
trace store sees it, and the files store reads only files directly inside its
folder, so ../ or an absolute path never reaches the filesystem.
Below the run, Was this answer right? records a rating keyed by the same
trace id; nl2sql feedback stats reads the ratings together with the traces in
<storage.path>/traces. See Feedback and Signals.