agent-tracegraph 0.6.1__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/CHANGELOG.md +111 -0
  2. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/PKG-INFO +34 -10
  3. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/README.md +33 -9
  4. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/SECURITY.md +18 -10
  5. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/pyproject.toml +1 -1
  6. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/langgraph_checkpoint.py +16 -5
  7. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/otlp_spans.py +39 -5
  8. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/phoenix_export.py +69 -6
  9. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/check.py +2 -1
  10. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/diagnose.py +13 -0
  11. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/memo.py +14 -2
  12. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/artifact.py +1 -0
  13. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/cli.py +50 -8
  14. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/model.py +4 -0
  15. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/sqlite_snapshot.py +11 -2
  16. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/.gitignore +0 -0
  17. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/LICENSE +0 -0
  18. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/analysis-report.schema.json +0 -0
  19. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/check-report.schema.json +0 -0
  20. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/phoenix-runtime.json +0 -0
  21. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/review-candidates.schema.json +0 -0
  22. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/__init__.py +0 -0
  23. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/_atomic_write.py +0 -0
  24. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/_errors.py +0 -0
  25. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/__init__.py +0 -0
  26. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/base.py +0 -0
  27. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/__init__.py +0 -0
  28. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/ahu.py +0 -0
  29. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/explain.py +0 -0
  30. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/motifs.py +0 -0
  31. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/patterns.py +0 -0
  32. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/graph_ref.py +0 -0
  33. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/input_validation.py +0 -0
  34. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/normalize.py +0 -0
  35. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/py.typed +0 -0
  36. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/review_candidates.py +0 -0
  37. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/__init__.py +0 -0
  38. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/base.py +0 -0
  39. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/in_memory.py +0 -0
  40. {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/ladybug.py +0 -0
@@ -6,6 +6,117 @@ The **artifact** schema version and the **report**/**review-candidate** JSON sch
6
6
  separate contracts from this version number; changes to any of them are called out
7
7
  explicitly.
8
8
 
9
+ ## 0.8.0 - 2026-10-20
10
+
11
+ ### Changed
12
+
13
+ - **`analyze` and the incident memo say when a causal parent was chosen heuristically** (#138).
14
+ Ingest records `otlp.graph_parent_by_timing` (a loop parent taken by start time or, since #136,
15
+ by start number) or `otlp.graph_parent_fallback` (a declared graph parent not honored, so the
16
+ span parent is used) in the artifact, and `check` and `summarize` read those codes, but
17
+ `analyze` and `export-incident-memo` did not, so a report or memo built from the artifact did
18
+ not say that a parent may have been chosen heuristically, and the memo wrote a graph parent
19
+ taken by start order as "caused by". `analyze` now adds a warning for each side that
20
+ carries either code, and the memo adds a fidelity disclosure. The record does not say which
21
+ edge was chosen that way, so in a trace with `otlp.graph_parent_by_timing` the memo writes every
22
+ `graph_parent` edge in its causal ancestry graph as "preceded by (graph parent, possibly chosen heuristically)" instead of
23
+ "caused by"; checkpoint parents and span links keep "caused by". Reports and memos of traces
24
+ without these codes are unchanged.
25
+
26
+ - **`explain`, `query --explain` and `inspect` say when a causal parent was chosen heuristically**
27
+ (#140). They read the same two codes: `inspect` prints the caveat `analyze` uses under its
28
+ tree, and `explain` and `query --explain` under every chain with at least one cause (a root
29
+ step has no parent to qualify). In such a trace their fan-in note no longer says "multiple real
30
+ causes" or "trust this raw chain, not the tree"; it says the single-parent tree drops some
31
+ recorded causes and the raw chain lists every recorded ancestor, and the `query --explain` note
32
+ says "in this chain" rather than "in this match", since the step with several causes can be an
33
+ ancestor outside the match. Output for traces without these codes is unchanged. `analyze` still
34
+ classifies failures through a `graph_parent` edge as it did.
35
+
36
+ - **A Phoenix loop parent is chosen by start numbers whose unit is unknown again** (#136). Since
37
+ 0.7.0 a Phoenix time given as a number below 10^17 is reported as unavailable, and with it the
38
+ only evidence for choosing among several spans that carry the `graph.node.id` a span names as
39
+ its `graph.node.parent_id`. The span then fell back to its `parent_id`, or the export was
40
+ refused without one. Now, when that span and every candidate start at such a number, the
41
+ nearest preceding candidate by those numbers is taken. 0.6.1 also chose by them, read as
42
+ nanoseconds, but it dropped a float's fraction first, so with fractional seconds (`a` at
43
+ 1783987200.25, `b` at 1783987200.75, the span at 1783987200.5) it took `b` where this release
44
+ takes `a`. The times themselves stay unavailable. The choice is counted under the existing
45
+ `otlp.graph_parent_by_timing` gap with its own warning line, which says it assumes the export
46
+ gives every time in one unit; `check` treats it as a heuristic edge, as it does a choice by
47
+ readable time. Exports with ISO or nanosecond times are read as before, and so is a set of
48
+ candidates where any one has a readable time, no start, or no number below the floor. The
49
+ 0.7.0 example (spans `a` and `b` at 1783987200 and 1783987201, `c` at 1783987202 with no
50
+ `parent_id`) is accepted again with `b` as `c`'s parent. Where 0.7.0 fell back to the span's
51
+ `parent_id`, the parent taken now can differ, and so can what follows from it. Each case below
52
+ is a separate export built from that example, adding only what it names:
53
+ - The export can be refused if the new choice closes a causal cycle. Add a root span `r` at
54
+ 1783987199 and give `c` the `parent_id` `r` and `b` the `parent_id` `c`: 0.7.0 kept `r` as
55
+ `c`'s parent and exited 0; this release takes `b`, so `ingest-phoenix` and `analyze` exit 2
56
+ and `phoenix diagnose` exits 1 (0.6.1 refused that export too).
57
+ - `analyze` can classify failures differently, because the edge is now a `graph_parent`, which
58
+ it reads as explicit ancestry (#142). Give `c` the `parent_id` `b` and let `b` and `c` fail:
59
+ 0.7.0 reported both as primary failures; this release reports `b` as primary and `c` as
60
+ propagated.
61
+ - Step numbers can change. Add spans `x`, `y` (`parent_id` `x`) and `z` with no start time,
62
+ and give `b` the `parent_id` `y` and `c` the `parent_id` `z`: 0.7.0 numbered `c` 3 and `b` 5;
63
+ this release numbers `b` 4 and `c` 5.
64
+
65
+ Ties are still broken with these spans at time 0, so step order can also differ from 0.6.1.
66
+
67
+ ## 0.7.0 - 2026-10-20
68
+
69
+ ### Added
70
+
71
+ - **`--redact-errors` on `ingest-otlp` and `ingest`** (#125) leaves error messages out of the
72
+ artifact. The OTLP and LangGraph adapters record the producer's error text by default
73
+ (`status.message` and exception events; the error channel's value and a raised exception's
74
+ `repr`), and `inspect` and `explain` print it; until now only Phoenix ingestion could drop it.
75
+ With the flag a failed step keeps its `error` status and lists `error_msg` in
76
+ `unknown_fields`, and nothing else in the artifact changes. A rule that reads the message
77
+ therefore treats it as unknown, not absent: `check --rule pattern-not-increased:timeout` reports
78
+ `INSUFFICIENT_EVIDENCE` for a redacted current run, and against a redacted baseline it can still
79
+ pass but not fail. Names are still recorded as given. The default is unchanged. In Python,
80
+ `LangGraphCheckpointAdapter` and `ingest_snapshot` take `include_error_messages=False`, as
81
+ `OTLPSpanAdapter` already did.
82
+
83
+ ### Changed
84
+
85
+ - **`phoenix diagnose` exits 1, not 2, when `px` is unavailable or its answer cannot be used**
86
+ (#122). That covers a `px` that is missing or cannot run, fails, times out, prints too much,
87
+ prints output that is not UTF-8 or not valid JSON, reports no recognizable version or one
88
+ older than the supported floor, or returns an export tracegraph cannot read, and a project
89
+ with no traces. The command now
90
+ prints `FAIL <reason>` on stderr, as `phoenix doctor` does, instead of the usage banner.
91
+ `diagnose` used 2 for these, the same code as an invalid invocation, so a wrapper could not
92
+ tell "called wrong" from "Phoenix is not reachable". Some exports 0.6.1 accepted are now refused
93
+ as unreadable, so `diagnose` goes from 0 to 1 on them; see the Phoenix time fix below. 2 still
94
+ means invalid arguments or output paths, such as `--limit 0`, two outputs naming the same file,
95
+ or a destination that cannot be written. `doctor` is unchanged.
96
+
97
+ ### Fixed
98
+
99
+ - **A Phoenix time given as a number too small to be epoch nanoseconds is no longer read as one**
100
+ (#124). The Phoenix adapter took every numeric `start_time` / `end_time` as nanoseconds, so a
101
+ time in seconds, milliseconds or microseconds became a moment near 1970, with its sub-unit
102
+ digits truncated and no warning: a 500 ms span given in float seconds was recorded at
103
+ `1970-01-01T00:00:01.783987+00:00` with a duration of 0.0. A number is now read as nanoseconds
104
+ only from 10^17 (1973-03-03T09:46:40Z) up. Below that, seconds, milliseconds and microseconds
105
+ for any date before the year 5138 cannot be told apart, so the time is reported as unavailable
106
+ and disclosed as `otlp.unreadable_timestamp`, the code an unreadable time already uses, which
107
+ 0.6.x readers know. That applies to numbers, digit strings, and strings `int()` reads with a
108
+ sign, surrounding whitespace or underscores. ISO strings are read as before, including compact
109
+ forms such as `20260714_123456`, and so are nanosecond values. Phoenix's own `trace get` export
110
+ uses ISO strings, so artifacts change only for exports that carried such numbers.
111
+
112
+ For an export that carries such numbers, the step sequence, the parent chosen among several
113
+ spans sharing a `graph.node.id`, and whether the export is accepted at all can also differ from
114
+ 0.6.1, which read the numbers as nanoseconds and ordered spans by them. For example, take spans
115
+ `a` and `b` with `graph.node.id` `loop`, starting at 1783987200 and 1783987201, and a span `c`
116
+ starting at 1783987202 with `graph.node.parent_id` `loop` and no `parent_id`. 0.6.1 took `b` as
117
+ `c`'s parent and exited 0; now `ingest-phoenix` and `analyze` exit 2 and `phoenix diagnose`
118
+ exits 1.
119
+
9
120
  ## 0.6.1 - 2026-10-20
10
121
 
11
122
  ### Dependencies
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.6.1
3
+ Version: 0.8.0
4
4
  Summary: Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph.
5
5
  Project-URL: Homepage, https://github.com/memtomem/tracegraph
6
6
  Project-URL: Repository, https://github.com/memtomem/tracegraph
@@ -38,7 +38,7 @@ tracegraph ingests agent execution traces that already exist (LangGraph checkpoi
38
38
  Phoenix CLI exports, and OpenInference/OTLP spans) into a **normalized causal graph**,
39
39
  then runs analyses the mainstream observability tools don't:
40
40
 
41
- - **`explain`** — walk backward from a failure over the *raw* causal graph to every real cause.
41
+ - **`explain`** — walk backward from a failure over the *raw* causal graph to every recorded cause.
42
42
  - **`diff`** — exact structural regression diff between two runs (rooted-tree / AHU isomorphism).
43
43
  - **cross-trace pattern matching** — e.g. "tool X → retry → tool X → failure" across all runs.
44
44
 
@@ -201,8 +201,9 @@ trace. If none failed, it says so and diagnoses the newest trace, so a first suc
201
201
  still useful. Explicit ids bypass that selection. The command invokes only read-only
202
202
  `px trace list/get --include-annotations` exports, analyzes them in memory, and does not persist
203
203
  the raw Phoenix response. Phoenix connection details and credentials remain owned by `px`
204
- profiles or environment variables; tracegraph never accepts an API key option. For an already
205
- exported file or a shell pipeline:
204
+ profiles or environment variables; tracegraph never accepts an API key option. Both commands exit
205
+ 1 when `px` is missing, cannot run, or returns a response tracegraph cannot use, and 2 for
206
+ invalid arguments or output paths. For an already exported file or a shell pipeline:
206
207
 
207
208
  Automatic retry diagnosis intentionally requires an upstream `retry:` CHAIN marker. SyncMill's
208
209
  controlled retry telemetry emits this contract; generic OTLP and LangGraph-checkpoint producers
@@ -220,6 +221,15 @@ Phoenix exports preserve span parents but currently do not expose the original O
220
221
  links. These artifacts are explicitly marked `causal_fidelity=parent_only`; tracegraph warns
221
222
  that additional fan-in causes may be missing instead of claiming a complete DAG.
222
223
 
224
+ Phoenix's own export gives times as ISO strings. A time given as a number is read as epoch
225
+ nanoseconds only from 10^17 up; a smaller one (seconds, milliseconds, a relative offset) is in
226
+ a unit tracegraph cannot tell, so it is reported as unavailable and disclosed as
227
+ `otlp.unreadable_timestamp`. Such numbers still order spans for one purpose: when several
228
+ spans carry the `graph.node.id` that a span names as its `graph.node.parent_id`, and that span
229
+ and every candidate start at such a number, the nearest preceding candidate by those numbers is
230
+ the parent. That assumes the export gives every time in one unit, and it is disclosed under
231
+ `otlp.graph_parent_by_timing` with its own warning line, so `check` treats the edge as heuristic.
232
+
223
233
  The exact Phoenix CLI and server versions used by CI are centrally pinned. See the
224
234
  [Phoenix runtime pin maintenance runbook](docs/phoenix-cli-contract.md) for the controlled
225
235
  upgrade and contract-verification procedure, and the
@@ -237,8 +247,11 @@ identifiers, and credentials. Missing metrics are reported as `unavailable`, nev
237
247
  (identifier-shaped span names only, error messages dropped), but the OTLP and LangGraph
238
248
  adapters record the span name and error text they were given: OTLP keeps `status.message`
239
249
  and exception messages, and LangGraph stringifies the configured error state channel. Those
240
- values reach the artifact on disk and are printed verbatim by `inspect` and `explain`. If
241
- your producer puts prompt text or secrets in span names or error strings, treat artifacts
250
+ values reach the artifact on disk and are printed verbatim by `inspect` and `explain`.
251
+ `ingest-otlp --redact-errors` and `ingest --redact-errors` leave the error text out: a failed
252
+ step keeps its `error` status and lists `error_msg` in `unknown_fields`, so a rule that reads
253
+ the message (the `timeout` preset) reports it as unknown rather than absent. Names are still
254
+ recorded as given. If your producer puts prompt text or secrets in span names, treat artifacts
242
255
  from those two adapters as sensitive, or ingest through Phoenix. See "Analysis and output
243
256
  guarantees" below.
244
257
 
@@ -253,14 +266,16 @@ tracegraph analyze full-dag.json
253
266
  ```
254
267
 
255
268
  `analyze` reads an OTLP span export (JSON or Collector JSONL) directly, with the same adapter
256
- settings as `ingest-otlp`, so the report is byte-identical to analysing the ingested artifact;
269
+ settings as `ingest-otlp`, so the report is byte-identical to analysing the ingested artifact
270
+ (one ingested with `--redact-errors` can differ where a rule reads the message);
257
271
  nothing is written unless `--json-out` asks for a report. The input is identified by shape alone
258
272
  (`schema_version` → artifact, `resourceSpans` → OTLP, `spans`/`traces`/`data` → Phoenix); a
259
273
  payload carrying both OTLP and Phoenix records is refused, naming the explicit ingest command
260
274
  for each format (or, for a list, JSONL or envelope, asking you to extract one format's records). Already have an export? The shortest offline first run is
261
275
  `tracegraph analyze your-export.otlp.json` (add `--trace <id>` when the file holds several), then
262
276
  copy the `explain` command it prints after saving the artifact it names. The artifact caveat
263
- above still applies: `ingest-otlp` keeps error text, reports stay `safe-v1`.
277
+ above still applies: `ingest-otlp` keeps error text unless given `--redact-errors`, and
278
+ reports stay `safe-v1`.
264
279
 
265
280
  The JSON analysis-report contract is versioned at
266
281
  [`contracts/analysis-report.schema.json`](contracts/analysis-report.schema.json).
@@ -391,7 +406,7 @@ Known limits:
391
406
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
392
407
  causes may be missing.
393
408
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
394
- producer's names and error messages.
409
+ producer's names, and its error messages unless ingested with `--redact-errors`.
395
410
  - CI covers Python 3.12 on Linux.
396
411
 
397
412
  Caveat for the checkpoint adapter: it is a **checkpoint-level** view (one node per
@@ -437,7 +452,8 @@ with deterministic SHA-256 aliases, and disclose in `warnings` when an alias act
437
452
  the report. Trace/step identifiers remain; this is not anonymization.
438
453
  Raw artifact names and bytes are not rewritten for report privacy or matching, so the
439
454
  artifact — unlike the report — can still carry whatever the producer put in a span name or
440
- error message. Phoenix ingestion is the exception: it filters at the source, keeping span
455
+ error message (`--redact-errors` on `ingest` and `ingest-otlp` drops the message, not the
456
+ name). Phoenix ingestion is the exception: it filters at the source, keeping span
441
457
  names and annotation names and labels only when they are identifier-shaped, and dropping
442
458
  status messages. Identifier-shaped is not the same as anonymous: an e-mail address passes.
443
459
  `export-review-candidates` output is not passed through the report redactor: its `tool_key`
@@ -449,6 +465,14 @@ origins, fidelity disclosures, and the digest of the exact artifact bytes. Logic
449
465
  comparison keys are JSON-encoded typed paths; consumers should treat them as opaque strings.
450
466
  Containment and unknown edges preserve all error investigation candidates, deeper first.
451
467
  Explicit causal ancestry supplies context, not proof that an exception propagated.
468
+ When the artifact records that a causal parent was chosen heuristically at ingest
469
+ (`otlp.graph_parent_by_timing` or `otlp.graph_parent_fallback`), `analyze` adds a warning for
470
+ that side and the memo a fidelity disclosure. The record does not say which edge, so a memo of
471
+ a trace with `otlp.graph_parent_by_timing` writes every `graph_parent` edge as "preceded by
472
+ (graph parent, possibly chosen heuristically)" instead of "caused by". `inspect` prints the
473
+ same caveat under its tree, and `explain` and `query --explain` under every chain that has at
474
+ least one cause; there their fan-in note no longer calls the causes real or says to trust the
475
+ chain.
452
476
  Explicit OTLP OK takes precedence over handled exception events on new ingestion; old
453
477
  artifacts are not reinterpreted.
454
478
  A span whose status is UNSET and that carries no exception event (one with a message or
@@ -7,7 +7,7 @@ tracegraph ingests agent execution traces that already exist (LangGraph checkpoi
7
7
  Phoenix CLI exports, and OpenInference/OTLP spans) into a **normalized causal graph**,
8
8
  then runs analyses the mainstream observability tools don't:
9
9
 
10
- - **`explain`** — walk backward from a failure over the *raw* causal graph to every real cause.
10
+ - **`explain`** — walk backward from a failure over the *raw* causal graph to every recorded cause.
11
11
  - **`diff`** — exact structural regression diff between two runs (rooted-tree / AHU isomorphism).
12
12
  - **cross-trace pattern matching** — e.g. "tool X → retry → tool X → failure" across all runs.
13
13
 
@@ -170,8 +170,9 @@ trace. If none failed, it says so and diagnoses the newest trace, so a first suc
170
170
  still useful. Explicit ids bypass that selection. The command invokes only read-only
171
171
  `px trace list/get --include-annotations` exports, analyzes them in memory, and does not persist
172
172
  the raw Phoenix response. Phoenix connection details and credentials remain owned by `px`
173
- profiles or environment variables; tracegraph never accepts an API key option. For an already
174
- exported file or a shell pipeline:
173
+ profiles or environment variables; tracegraph never accepts an API key option. Both commands exit
174
+ 1 when `px` is missing, cannot run, or returns a response tracegraph cannot use, and 2 for
175
+ invalid arguments or output paths. For an already exported file or a shell pipeline:
175
176
 
176
177
  Automatic retry diagnosis intentionally requires an upstream `retry:` CHAIN marker. SyncMill's
177
178
  controlled retry telemetry emits this contract; generic OTLP and LangGraph-checkpoint producers
@@ -189,6 +190,15 @@ Phoenix exports preserve span parents but currently do not expose the original O
189
190
  links. These artifacts are explicitly marked `causal_fidelity=parent_only`; tracegraph warns
190
191
  that additional fan-in causes may be missing instead of claiming a complete DAG.
191
192
 
193
+ Phoenix's own export gives times as ISO strings. A time given as a number is read as epoch
194
+ nanoseconds only from 10^17 up; a smaller one (seconds, milliseconds, a relative offset) is in
195
+ a unit tracegraph cannot tell, so it is reported as unavailable and disclosed as
196
+ `otlp.unreadable_timestamp`. Such numbers still order spans for one purpose: when several
197
+ spans carry the `graph.node.id` that a span names as its `graph.node.parent_id`, and that span
198
+ and every candidate start at such a number, the nearest preceding candidate by those numbers is
199
+ the parent. That assumes the export gives every time in one unit, and it is disclosed under
200
+ `otlp.graph_parent_by_timing` with its own warning line, so `check` treats the edge as heuristic.
201
+
192
202
  The exact Phoenix CLI and server versions used by CI are centrally pinned. See the
193
203
  [Phoenix runtime pin maintenance runbook](docs/phoenix-cli-contract.md) for the controlled
194
204
  upgrade and contract-verification procedure, and the
@@ -206,8 +216,11 @@ identifiers, and credentials. Missing metrics are reported as `unavailable`, nev
206
216
  (identifier-shaped span names only, error messages dropped), but the OTLP and LangGraph
207
217
  adapters record the span name and error text they were given: OTLP keeps `status.message`
208
218
  and exception messages, and LangGraph stringifies the configured error state channel. Those
209
- values reach the artifact on disk and are printed verbatim by `inspect` and `explain`. If
210
- your producer puts prompt text or secrets in span names or error strings, treat artifacts
219
+ values reach the artifact on disk and are printed verbatim by `inspect` and `explain`.
220
+ `ingest-otlp --redact-errors` and `ingest --redact-errors` leave the error text out: a failed
221
+ step keeps its `error` status and lists `error_msg` in `unknown_fields`, so a rule that reads
222
+ the message (the `timeout` preset) reports it as unknown rather than absent. Names are still
223
+ recorded as given. If your producer puts prompt text or secrets in span names, treat artifacts
211
224
  from those two adapters as sensitive, or ingest through Phoenix. See "Analysis and output
212
225
  guarantees" below.
213
226
 
@@ -222,14 +235,16 @@ tracegraph analyze full-dag.json
222
235
  ```
223
236
 
224
237
  `analyze` reads an OTLP span export (JSON or Collector JSONL) directly, with the same adapter
225
- settings as `ingest-otlp`, so the report is byte-identical to analysing the ingested artifact;
238
+ settings as `ingest-otlp`, so the report is byte-identical to analysing the ingested artifact
239
+ (one ingested with `--redact-errors` can differ where a rule reads the message);
226
240
  nothing is written unless `--json-out` asks for a report. The input is identified by shape alone
227
241
  (`schema_version` → artifact, `resourceSpans` → OTLP, `spans`/`traces`/`data` → Phoenix); a
228
242
  payload carrying both OTLP and Phoenix records is refused, naming the explicit ingest command
229
243
  for each format (or, for a list, JSONL or envelope, asking you to extract one format's records). Already have an export? The shortest offline first run is
230
244
  `tracegraph analyze your-export.otlp.json` (add `--trace <id>` when the file holds several), then
231
245
  copy the `explain` command it prints after saving the artifact it names. The artifact caveat
232
- above still applies: `ingest-otlp` keeps error text, reports stay `safe-v1`.
246
+ above still applies: `ingest-otlp` keeps error text unless given `--redact-errors`, and
247
+ reports stay `safe-v1`.
233
248
 
234
249
  The JSON analysis-report contract is versioned at
235
250
  [`contracts/analysis-report.schema.json`](contracts/analysis-report.schema.json).
@@ -360,7 +375,7 @@ Known limits:
360
375
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
361
376
  causes may be missing.
362
377
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
363
- producer's names and error messages.
378
+ producer's names, and its error messages unless ingested with `--redact-errors`.
364
379
  - CI covers Python 3.12 on Linux.
365
380
 
366
381
  Caveat for the checkpoint adapter: it is a **checkpoint-level** view (one node per
@@ -406,7 +421,8 @@ with deterministic SHA-256 aliases, and disclose in `warnings` when an alias act
406
421
  the report. Trace/step identifiers remain; this is not anonymization.
407
422
  Raw artifact names and bytes are not rewritten for report privacy or matching, so the
408
423
  artifact — unlike the report — can still carry whatever the producer put in a span name or
409
- error message. Phoenix ingestion is the exception: it filters at the source, keeping span
424
+ error message (`--redact-errors` on `ingest` and `ingest-otlp` drops the message, not the
425
+ name). Phoenix ingestion is the exception: it filters at the source, keeping span
410
426
  names and annotation names and labels only when they are identifier-shaped, and dropping
411
427
  status messages. Identifier-shaped is not the same as anonymous: an e-mail address passes.
412
428
  `export-review-candidates` output is not passed through the report redactor: its `tool_key`
@@ -418,6 +434,14 @@ origins, fidelity disclosures, and the digest of the exact artifact bytes. Logic
418
434
  comparison keys are JSON-encoded typed paths; consumers should treat them as opaque strings.
419
435
  Containment and unknown edges preserve all error investigation candidates, deeper first.
420
436
  Explicit causal ancestry supplies context, not proof that an exception propagated.
437
+ When the artifact records that a causal parent was chosen heuristically at ingest
438
+ (`otlp.graph_parent_by_timing` or `otlp.graph_parent_fallback`), `analyze` adds a warning for
439
+ that side and the memo a fidelity disclosure. The record does not say which edge, so a memo of
440
+ a trace with `otlp.graph_parent_by_timing` writes every `graph_parent` edge as "preceded by
441
+ (graph parent, possibly chosen heuristically)" instead of "caused by". `inspect` prints the
442
+ same caveat under its tree, and `explain` and `query --explain` under every chain that has at
443
+ least one cause; there their fan-in note no longer calls the causes real or says to trust the
444
+ chain.
421
445
  Explicit OTLP OK takes precedence over handled exception events on new ingestion; old
422
446
  artifacts are not reinterpreted.
423
447
  A span whose status is UNSET and that carries no exception event (one with a message or
@@ -42,19 +42,23 @@ prompts, inputs and outputs, tool arguments and results, raw errors and stacktra
42
42
  session/user/project identifiers. Other display text is replaced with a deterministic
43
43
  SHA-256 alias, and the report discloses in `warnings` when an alias actually reached it.
44
44
 
45
- The artifact on disk is a different thing. Phoenix ingestion filters at the source, but the
46
- OTLP and LangGraph adapters record what they were given:
45
+ The artifact on disk is a different thing. Phoenix ingestion filters at the source, but by
46
+ default the OTLP and LangGraph adapters record what they were given:
47
47
 
48
48
  - OTLP keeps `status.message` and exception messages;
49
49
  - LangGraph stringifies the configured error state channel, **and** the `repr` of an
50
50
  exception a node raised, which it reads from checkpoint pending writes.
51
51
 
52
- Those values reach the artifact and are printed verbatim by `inspect` and `explain`.
52
+ Those values reach the artifact and are printed verbatim by `inspect` and `explain`, unless
53
+ the trace is ingested with `--redact-errors` (`ingest-otlp` and `ingest` both take it), which
54
+ leaves every error message out and marks it unknown on the step. Span and node names are
55
+ recorded as given either way.
53
56
  `export-review-candidates` is likewise not passed through the report redactor: its
54
57
  `tool_key` is the raw endpoint span name.
55
58
 
56
59
  **So: if your producer puts prompt text, customer data, or credentials into span names or
57
- exception messages, those land in the artifact.** Treat artifacts with the same care as the
60
+ exception messages, those land in the artifact** — the messages only when ingested without
61
+ `--redact-errors`, the names always. Treat artifacts with the same care as the
58
62
  traces they came from. Reports, not artifacts, are the artifact you share.
59
63
 
60
64
  Identifiers are not anonymized in either form — trace and step ids are preserved by design,
@@ -64,12 +68,16 @@ because they are what makes a finding traceable back to the run.
64
68
 
65
69
  Artifact and span-export loading is a parsing boundary, not a trust boundary: input is
66
70
  validated, never executed. Malformed input raises a clean error rather than a traceback, and
67
- tree walks are iterative so a deeply nested document cannot exhaust the stack. **CLI inputs**,
68
- files named on the command line and `-` for stdin alike, are size-capped by default at 256 MiB
69
- (`TRACEGRAPH_MAX_ARTIFACT_BYTES` changes it, and `0` disables it). That cap is a property of
70
- the command-line entry points, not of the library: `artifact.load()` and
71
- `OTLPSpanAdapter.from_file()` read what they are given, so a library caller handling untrusted
72
- input should bound it themselves. Still, analyzing an artifact you did not produce means reading
71
+ tree walks are iterative so a deeply nested document cannot exhaust the stack. **CLI JSON
72
+ inputs** (artifacts and span exports), files named on the command line and `-` for stdin
73
+ alike, are size-capped by default at 256 MiB (`TRACEGRAPH_MAX_ARTIFACT_BYTES` changes it, and
74
+ `0` disables it). A LangGraph checkpoint database (`ingest --sqlite`) is not byte-capped: it
75
+ is copied to a private snapshot under a deadline (30 seconds by default) before it is read.
76
+ The cap is a property of the command-line entry points, not of the library:
77
+ `artifact.load()`, `OTLPSpanAdapter.from_file()`, `PhoenixExportAdapter.from_file()`, and
78
+ `InMemoryStore.load_artifact()` and `LadybugStore.load_artifact()` (which call
79
+ `artifact.load()`) read what they are given, so a library caller handling untrusted input
80
+ should bound it themselves. Still, analyzing an artifact you did not produce means reading
73
81
  strings an attacker may have chosen; render them as data.
74
82
 
75
83
  Subprocess use is limited to one call site, the Phoenix CLI (`px`), invoked as an argument
@@ -4,7 +4,7 @@
4
4
  # the schema `kind` constants and the TRACEGRAPH_* environment variables — those are package
5
5
  # identity, not distribution identity, and renaming them would break published contracts.
6
6
  name = "agent-tracegraph"
7
- version = "0.6.1"
7
+ version = "0.8.0"
8
8
  description = "Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -209,6 +209,7 @@ class LangGraphCheckpointAdapter:
209
209
  error_channel: str = "error",
210
210
  source_kind: str = "langgraph",
211
211
  schedule_graph: Any = None,
212
+ include_error_messages: bool = True,
212
213
  ) -> None:
213
214
  self._saver = saver
214
215
  self._error_channel = error_channel
@@ -216,6 +217,9 @@ class LangGraphCheckpointAdapter:
216
217
  #: A compiled LangGraph graph to ask which tasks are scheduled at the head; see the
217
218
  #: module docstring. ``None`` leaves scheduled-but-unwritten tasks unreported.
218
219
  self._schedule_graph = schedule_graph
220
+ #: Off, a failed step keeps its ERROR status but not the error channel's value or the
221
+ #: exception text, and lists ``error_msg`` as unknown, as OTLP's option of that name does.
222
+ self._include_error_messages = include_error_messages
219
223
 
220
224
  # --- TraceAdapter ---
221
225
 
@@ -388,14 +392,18 @@ class LangGraphCheckpointAdapter:
388
392
  rewritten_errors += self._rewrote_error(checkpoints[step_id], history)
389
393
  if err is not None:
390
394
  step.status = StepStatus.ERROR
391
- step.error_msg = err
395
+ if self._include_error_messages:
396
+ step.error_msg = err
397
+ else:
398
+ step.unknown_fields.insert(0, "error_msg") # sorts before kind and name
392
399
 
393
400
  # Derived task steps come last: everything above indexes `steps` by checkpoint id
394
401
  # (`checkpoints[step_id]`, `key_by_step_id[step_id]`), and a derived id has no entry
395
402
  # in either. Building them here also means they inherit each carrying step's *final*
396
403
  # seq, after `_topo_seq` may have re-sequenced the whole thread.
397
404
  disclosures = self._derive_task_steps(
398
- trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
405
+ trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges,
406
+ include_error_messages=self._include_error_messages,
399
407
  )
400
408
  disclosures.extend(
401
409
  self._derive_interrupt_steps(
@@ -463,6 +471,8 @@ class LangGraphCheckpointAdapter:
463
471
  original_seq: dict[str, int],
464
472
  writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]],
465
473
  edges: list[Edge],
474
+ *,
475
+ include_error_messages: bool,
466
476
  ) -> Disclosures:
467
477
  """Add one ERROR step per failed task and return any ingestion disclosures.
468
478
 
@@ -506,8 +516,9 @@ class LangGraphCheckpointAdapter:
506
516
  "in LangGraph's task-id algorithm would look like.",
507
517
  )
508
518
  unknown: list[UnknownField] = [] if name else ["kind", "name"]
509
- if error is None and not cancelled:
510
- # _error_message's placeholder: the checkpointer kept no exception text.
519
+ if (error is None or not include_error_messages) and not cancelled:
520
+ # _error_message's placeholder (the checkpointer kept no exception text),
521
+ # or a message withheld on request.
511
522
  unknown.insert(0, "error_msg")
512
523
  derived_id = f"{step_id}#task:{task_id}"
513
524
  if derived_id in steps or derived_id in derived_ids:
@@ -533,7 +544,7 @@ class LangGraphCheckpointAdapter:
533
544
  # without claiming it failed, and leaves the trace status to the task
534
545
  # that actually raised.
535
546
  status=StepStatus.UNSET if cancelled else StepStatus.ERROR,
536
- error_msg=None if cancelled else message,
547
+ error_msg=None if cancelled or not include_error_messages else message,
537
548
  unknown_fields=unknown,
538
549
  ),
539
550
  ))
@@ -376,11 +376,16 @@ class OTLPSpanAdapter:
376
376
  causal_fidelity: CausalFidelity | None = None,
377
377
  links_preserved: bool | None = None,
378
378
  evaluation_channel: str | None = None,
379
+ start_order_channel: str | None = None,
379
380
  ) -> None:
380
381
  self._doc = document
381
382
  #: Span key carrying pre-sanitized evaluation summaries. Only an adapter that builds
382
383
  #: the span dicts itself (Phoenix) sets it; raw OTLP input never supplies evaluations.
383
384
  self._evaluation_channel = evaluation_channel
385
+ #: Span key carrying a start time whose unit is unknown, as a plain number that orders
386
+ #: spans within one export. Only Phoenix sets it, for a number below the epoch-nanosecond
387
+ #: floor; the time itself stays unavailable.
388
+ self._start_order_channel = start_order_channel
384
389
  self._source_kind = source_kind
385
390
  #: Prefer ``graph.node.parent_id`` (logical agent graph) over ``parentSpanId``.
386
391
  self._prefer_graph_parent = prefer_graph_parent
@@ -423,6 +428,7 @@ class OTLPSpanAdapter:
423
428
 
424
429
  @classmethod
425
430
  def from_file(cls, path: str | Path, **kwargs: Any) -> "OTLPSpanAdapter":
431
+ """Reads the whole file: the CLI's ``TRACEGRAPH_MAX_ARTIFACT_BYTES`` cap does not apply here."""
426
432
  return cls.from_json(Path(path).read_text(encoding="utf-8"), **kwargs)
427
433
 
428
434
  # --- TraceAdapter ---
@@ -461,8 +467,18 @@ class OTLPSpanAdapter:
461
467
  for sid, span in by_id.items()
462
468
  if _parse_nanos(_first(span, "startTimeUnixNano", "start_time_unix_nano")) is None
463
469
  }
470
+ # A unit-unknown start number orders spans that all carry one, with prerank's
471
+ # tiebreaks; it says nothing about how they relate to a span with a readable time.
472
+ channel = self._start_order_channel
473
+ order_key = {
474
+ sid: (value, depth[sid], sid)
475
+ for sid, span in by_id.items()
476
+ if channel is not None and (value := span.get(channel)) is not None
477
+ }
464
478
  disclosures = Disclosures()
465
- causes, origins = self._causes(by_id, attrs, prerank, parent_span, untimed, disclosures)
479
+ causes, origins = self._causes(
480
+ by_id, attrs, prerank, parent_span, untimed, order_key, disclosures
481
+ )
466
482
  seq = self._topo_seq(by_id, causes, prerank)
467
483
 
468
484
  steps: list[Step] = []
@@ -724,6 +740,7 @@ class OTLPSpanAdapter:
724
740
  prerank: dict[str, int],
725
741
  parent_span: dict[str, str | None],
726
742
  untimed: set[str],
743
+ order_key: dict[str, tuple[Any, int, str]],
727
744
  disclosures: Disclosures,
728
745
  ) -> tuple[dict[str, list[str]], dict[tuple[str, str], EdgeOrigin]]:
729
746
  """Reconstruct ``effect -> [causes]`` from declared signals: one parent + links.
@@ -751,12 +768,13 @@ class OTLPSpanAdapter:
751
768
  causes[effect].append(cause)
752
769
  origins[(effect, cause)] = origin
753
770
 
754
- by_timing = fallback = 0
771
+ by_timing = by_order = fallback = 0
755
772
  for sid in by_id:
756
773
  parent, parent_origin, resolution = self._resolve_parent(
757
- sid, attrs, node_index, prerank, parent_span, untimed
774
+ sid, attrs, node_index, prerank, parent_span, untimed, order_key
758
775
  )
759
776
  by_timing += resolution == "by_timing"
777
+ by_order += resolution == "by_order"
760
778
  fallback += resolution == "fallback"
761
779
  if parent is not None:
762
780
  add(sid, parent, parent_origin)
@@ -788,6 +806,16 @@ class OTLPSpanAdapter:
788
806
  "the nearest preceding one by start time was taken as the parent.",
789
807
  by_timing,
790
808
  )
809
+ if by_order:
810
+ # Same code as by_timing: a heuristic choice among candidates, which readers since
811
+ # 0.6 already treat as one.
812
+ disclosures.add(
813
+ "otlp.graph_parent_by_timing",
814
+ f"{by_order} span(s) declare a graph.node.parent_id that several spans carry; "
815
+ "the nearest preceding one was taken by start-time numbers whose unit is "
816
+ "unknown, assuming the export gives every time in one unit.",
817
+ by_order,
818
+ )
791
819
  if fallback:
792
820
  disclosures.add(
793
821
  "otlp.graph_parent_fallback",
@@ -825,6 +853,7 @@ class OTLPSpanAdapter:
825
853
  prerank: dict[str, int],
826
854
  parent_span: dict[str, str | None],
827
855
  untimed: set[str],
856
+ order_key: dict[str, tuple[Any, int, str]],
828
857
  ) -> tuple[str | None, EdgeOrigin, str | None]:
829
858
  """The single chosen parent cause: the logical graph parent if resolvable, else the
830
859
  declared span parent.
@@ -834,7 +863,8 @@ class OTLPSpanAdapter:
834
863
 
835
864
  * a single span carries that ``graph.node.id`` → trust it outright (unambiguous);
836
865
  * several do (a loop) → pick the nearest *preceding* execution, but only when this
837
- span and every candidate have a readable start time; otherwise there is no
866
+ span and every candidate have a readable start time, or else all carry a
867
+ unit-unknown start number (``order_key``) to compare; otherwise there is no
838
868
  evidence of order and the parent is ambiguous.
839
869
 
840
870
  If ``graph.node.parent_id`` is declared but resolves to no in-trace span, or is
@@ -845,7 +875,7 @@ class OTLPSpanAdapter:
845
875
  ``None`` only when the span truly has no declared parent of either kind.
846
876
 
847
877
  The third value says how a declared logical parent was resolved when that was not
848
- unambiguous: ``"by_timing"`` or ``"fallback"``; otherwise ``None``.
878
+ unambiguous: ``"by_timing"``, ``"by_order"`` or ``"fallback"``; otherwise ``None``.
849
879
  """
850
880
  pid = parent_span.get(sid)
851
881
  if self._prefer_graph_parent:
@@ -858,6 +888,10 @@ class OTLPSpanAdapter:
858
888
  preceding = [c for c in candidates if timed and prerank[c] < prerank[sid]]
859
889
  if preceding:
860
890
  return max(preceding, key=lambda c: prerank[c]), EdgeOrigin.GRAPH_PARENT, "by_timing"
891
+ ordered = not timed and all(s in order_key for s in (sid, *candidates))
892
+ preceding = [c for c in candidates if ordered and order_key[c] < order_key[sid]]
893
+ if preceding:
894
+ return max(preceding, key=lambda c: order_key[c]), EdgeOrigin.GRAPH_PARENT, "by_order"
861
895
  if pid is None:
862
896
  detail = "no in-trace span" if not candidates else "multiple ambiguous spans"
863
897
  raise ValueError(
@@ -9,7 +9,13 @@ import re
9
9
  from typing import Any
10
10
 
11
11
  from tracegraph.adapters.base import Disclosures
12
- from tracegraph.adapters.otlp_spans import OTLPSpanAdapter, _attributes, _first, _span_array
12
+ from tracegraph.adapters.otlp_spans import (
13
+ OTLPSpanAdapter,
14
+ _attributes,
15
+ _first,
16
+ _parse_nanos,
17
+ _span_array,
18
+ )
13
19
  from tracegraph.input_validation import loads, object_field
14
20
  from tracegraph.model import RawTrace
15
21
 
@@ -30,27 +36,81 @@ _SAFE_ATTRIBUTES = {
30
36
  }
31
37
 
32
38
 
39
+ #: The smallest number read as epoch nanoseconds: 1973-03-03T09:46:40Z. Seconds, milliseconds and
40
+ #: microseconds for any date before the year 5138 all fall below it, so a smaller number is in
41
+ #: some other unit, and which one cannot be told from its size.
42
+ _MIN_EPOCH_NANOS = 10**17
43
+ #: Handed on for a number below the floor. ``int()`` cannot read it, so the span builder reports
44
+ #: the time as unavailable and counts it under ``otlp.unreadable_timestamp``. It is fixed text, so
45
+ #: no producer code (a ``__repr__``) runs, and only the parsed time ever reaches the artifact.
46
+ _NOT_EPOCH_NANOS = "not epoch nanoseconds"
47
+
48
+
49
+ def _epoch_nanos(number: int) -> str:
50
+ return str(number) if number >= _MIN_EPOCH_NANOS else _NOT_EPOCH_NANOS
51
+
52
+
33
53
  def _nanos(value: Any) -> Any:
34
- """Phoenix's time to OTLP nanoseconds. A value that cannot be converted is passed on
35
- unchanged, so the span builder reports it as unavailable *and* discloses it."""
54
+ """Phoenix's time to OTLP nanoseconds. A value that cannot be converted, or a number too
55
+ small to be epoch nanoseconds, is handed on in a form the span builder cannot read, so it
56
+ reports the time as unavailable *and* discloses it."""
36
57
  if value is None:
37
58
  return None
38
59
  if isinstance(value, float) and not math.isfinite(value):
39
60
  return value
40
61
  if isinstance(value, (int, float)):
41
- return str(int(value))
62
+ return _epoch_nanos(int(value))
42
63
  text = str(value)
43
64
  if text.isdigit():
44
- return text
65
+ try:
66
+ return _epoch_nanos(int(text))
67
+ except ValueError: # a non-ASCII digit, or past the interpreter's int-string limit
68
+ return text
45
69
  try:
46
70
  parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
47
71
  except ValueError:
48
- return value
72
+ # Whatever the span builder would still read as a time (a sign, whitespace, underscores,
73
+ # bytes) is a number, so the floor applies to it too. Asking the builder's own parser
74
+ # keeps the two from disagreeing about what is a number.
75
+ number = _parse_nanos(value)
76
+ return value if number is None else _epoch_nanos(number)
49
77
  if parsed.tzinfo is None:
50
78
  parsed = parsed.replace(tzinfo=timezone.utc)
51
79
  return str(int(parsed.timestamp() * 1_000_000_000))
52
80
 
53
81
 
82
+ def _start_order(value: Any) -> int | float | None:
83
+ """A start time below the epoch-nanosecond floor, as the number it is, or ``None``.
84
+
85
+ Its unit is unknown, so it is no time, but within one export it still orders spans: the
86
+ span builder uses it only to choose among several candidate loop parents, and discloses
87
+ that it did. It follows ``_nanos``'s branches, so an ISO string (a compact one such as
88
+ ``20260714_123456`` included) never reaches the number path. A bool or bytes value is not
89
+ taken as a number here, although ``_nanos`` refuses those as times too.
90
+ """
91
+ if isinstance(value, bool):
92
+ return None
93
+ if isinstance(value, float):
94
+ return value if math.isfinite(value) and int(value) < _MIN_EPOCH_NANOS else None
95
+ if isinstance(value, int):
96
+ return value if value < _MIN_EPOCH_NANOS else None
97
+ if not isinstance(value, str):
98
+ return None
99
+ number: int | None
100
+ if value.isdigit():
101
+ try:
102
+ number = int(value)
103
+ except ValueError: # a non-ASCII digit, or past the interpreter's int-string limit
104
+ return None
105
+ else:
106
+ try:
107
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
108
+ return None
109
+ except ValueError:
110
+ number = _parse_nanos(value)
111
+ return number if number is not None and number < _MIN_EPOCH_NANOS else None
112
+
113
+
54
114
  _OPERATION_NAME = re.compile(r"[A-Za-z0-9_.:/#@-]{1,200}")
55
115
 
56
116
 
@@ -151,6 +211,7 @@ class PhoenixExportAdapter:
151
211
 
152
212
  @classmethod
153
213
  def from_file(cls, path: str | Path) -> "PhoenixExportAdapter":
214
+ """Reads the whole file: the CLI's ``TRACEGRAPH_MAX_ARTIFACT_BYTES`` cap does not apply here."""
154
215
  return cls.from_json(Path(path).read_text(encoding="utf-8"))
155
216
 
156
217
  def discover(self) -> list[str]:
@@ -173,6 +234,7 @@ class PhoenixExportAdapter:
173
234
  include_error_messages=False,
174
235
  preserve_unset_status=True,
175
236
  evaluation_channel="_tracegraph_evaluations",
237
+ start_order_channel="_tracegraph_start_order",
176
238
  ).ingest(trace_id)
177
239
  if unreadable_scores:
178
240
  disclosures = Disclosures()
@@ -228,4 +290,5 @@ class PhoenixExportAdapter:
228
290
  "attributes": _attrs(span),
229
291
  "events": _span_array(span, "events"),
230
292
  "_tracegraph_evaluations": _evaluations(span),
293
+ "_tracegraph_start_order": _start_order(_first(span, "start_time", "startTime")),
231
294
  }
@@ -34,6 +34,7 @@ from tracegraph.analysis.diagnose import _ALIAS, _digest, _display_name, _safe
34
34
  from tracegraph.analysis.patterns import PRESETS, PathPattern, StepPredicate, build_index, find_matches
35
35
  from tracegraph.model import (
36
36
  CAPTURE_GAP_CODES,
37
+ HEURISTIC_EDGE_GAP_CODES,
37
38
  NormalizedTrace,
38
39
  Step,
39
40
  StepStatus,
@@ -66,7 +67,7 @@ _MAY_OMIT_STEPS = frozenset({
66
67
  #: is unaffected, so only the count rules read this set.
67
68
  _REWRITTEN_ERRORS = frozenset({"langgraph.rewritten_error_channel"})
68
69
  _MAY_HIDE_FAILURES = _MAY_OMIT_STEPS | _REWRITTEN_ERRORS
69
- _HEURISTIC_EDGES = frozenset({"otlp.graph_parent_by_timing", "otlp.graph_parent_fallback"})
70
+ _HEURISTIC_EDGES = HEURISTIC_EDGE_GAP_CODES
70
71
 
71
72
  LANGGRAPH_LIMITATION = (
72
73
  "LangGraph checkpoint layouts this adapter does not recognize record no failure, so a "
@@ -29,6 +29,7 @@ from tracegraph.model import (
29
29
  EXPLICIT_ORIGINS,
30
30
  MAX_COST_CHARS,
31
31
  CausalFidelity,
32
+ HEURISTIC_EDGE_GAP_CODES,
32
33
  EdgeType,
33
34
  NormalizedTrace,
34
35
  Step,
@@ -54,6 +55,16 @@ LANGGRAPH_FIDELITY_WARNING = (
54
55
  LINKS_PRESERVED_FIDELITY_WARNING = (
55
56
  "Link preservation covers valid in-trace links only; foreign or unresolved links are omitted."
56
57
  )
58
+ HEURISTIC_PARENT_FIDELITY_WARNING = (
59
+ "Some causal parents were chosen heuristically at ingest, by start order or by falling back "
60
+ "to the span parent; a chain through them is inferred, not declared."
61
+ )
62
+
63
+
64
+ def has_heuristic_edges(nt: NormalizedTrace) -> bool:
65
+ """Whether the trace's capture record discloses a causal parent chosen heuristically."""
66
+ capture = nt.trace.capture
67
+ return capture is not None and any(gap.code in HEURISTIC_EDGE_GAP_CODES for gap in capture.gaps)
57
68
 
58
69
 
59
70
  def _clause(sentence: str) -> str:
@@ -677,6 +688,8 @@ def analyze(nt: NormalizedTrace, *, baseline: NormalizedTrace | None = None) ->
677
688
  if trace.trace.links_preserved:
678
689
  # Mid-sentence after the label, so the shared sentence starts in lower case here.
679
690
  warnings.append(f"{label}: {_clause(LINKS_PRESERVED_FIDELITY_WARNING)}")
691
+ if has_heuristic_edges(trace):
692
+ warnings.append(f"{label}: {_clause(HEURISTIC_PARENT_FIDELITY_WARNING)}")
680
693
  if primary or propagated:
681
694
  warnings.append(INCIDENT_FIDELITY_WARNING)
682
695
  if any(getattr(metrics, field) is not None for field in ("prompt_tokens", "completion_tokens", "total_tokens", "total_cost")):
@@ -7,10 +7,12 @@ from pathlib import Path
7
7
 
8
8
  from tracegraph._atomic_write import write_text_atomic
9
9
  from tracegraph.analysis.diagnose import (
10
+ HEURISTIC_PARENT_FIDELITY_WARNING,
10
11
  INCIDENT_FIDELITY_WARNING,
11
12
  LANGGRAPH_FIDELITY_WARNING,
12
13
  LINKS_PRESERVED_FIDELITY_WARNING,
13
14
  PARENT_ONLY_FIDELITY_WARNING,
15
+ has_heuristic_edges,
14
16
  pattern_findings,
15
17
  )
16
18
  from tracegraph.model import (
@@ -43,7 +45,11 @@ def _format_origin(origin: EdgeOrigin | None) -> str:
43
45
  return f"origin: {origin.value}"
44
46
 
45
47
 
46
- def _relation_phrase(origin: EdgeOrigin | None) -> str:
48
+ def _relation_phrase(origin: EdgeOrigin | None, *, timed_graph_parents: bool) -> str:
49
+ # The capture record says a graph parent was chosen by start order somewhere in the trace,
50
+ # not which one, so no graph-parent edge there is presented as a declared cause.
51
+ if origin is EdgeOrigin.GRAPH_PARENT and timed_graph_parents:
52
+ return "preceded by (graph parent, possibly chosen heuristically)"
47
53
  if origin in EXPLICIT_ORIGINS:
48
54
  return "caused by"
49
55
  if origin is EdgeOrigin.SPAN_PARENT_FALLBACK:
@@ -161,12 +167,16 @@ def build_incident_memo(
161
167
  if edge.src in reachable_step_ids
162
168
  ]
163
169
 
170
+ capture = nt.trace.capture
171
+ timed_graph_parents = capture is not None and any(
172
+ gap.code == "otlp.graph_parent_by_timing" for gap in capture.gaps
173
+ )
164
174
  if ancestry_edges:
165
175
  lines.append("### Causal Ancestry Graph")
166
176
  for edge in ancestry_edges:
167
177
  eff_alias = alias_by_id[edge.src]
168
178
  cause_alias = alias_by_id[edge.dst]
169
- rel = _relation_phrase(edge.origin)
179
+ rel = _relation_phrase(edge.origin, timed_graph_parents=timed_graph_parents)
170
180
  origin_str = f" [{_format_origin(edge.origin)}]"
171
181
  lines.append(f"- `{eff_alias}` {rel} `{cause_alias}`{origin_str}")
172
182
  lines.append("")
@@ -182,6 +192,8 @@ def build_incident_memo(
182
192
  disclosures.append(LANGGRAPH_FIDELITY_WARNING)
183
193
  if nt.trace.links_preserved:
184
194
  disclosures.append(LINKS_PRESERVED_FIDELITY_WARNING)
195
+ if has_heuristic_edges(nt):
196
+ disclosures.append(HEURISTIC_PARENT_FIDELITY_WARNING)
185
197
 
186
198
  lines.append("## Fidelity Disclosures")
187
199
  for d in disclosures:
@@ -243,6 +243,7 @@ def save_atomic(nt: NormalizedTrace, path: str | Path, *, replace: bool = True)
243
243
 
244
244
 
245
245
  def load(path: str | Path) -> NormalizedTrace:
246
+ """Reads the whole file: the CLI's ``TRACEGRAPH_MAX_ARTIFACT_BYTES`` cap does not apply here."""
246
247
  return loads(Path(path).read_text(encoding="utf-8"))
247
248
 
248
249
 
@@ -33,7 +33,8 @@ from tracegraph import artifact
33
33
  from tracegraph.input_validation import loads as load_json
34
34
  from tracegraph.analysis import PRESETS
35
35
  from tracegraph.analysis import check as regression_check
36
- from tracegraph.analysis.diagnose import AnalysisReport
36
+ from tracegraph.analysis.diagnose import HEURISTIC_PARENT_FIDELITY_WARNING, AnalysisReport
37
+ from tracegraph.analysis.diagnose import has_heuristic_edges
37
38
  from tracegraph.analysis.diagnose import analyze as build_analysis
38
39
  from tracegraph.analysis.diagnose import save_atomic as save_analysis_report
39
40
  from tracegraph.analysis import motifs
@@ -1278,6 +1279,16 @@ def _render_tree(nt: NormalizedTrace) -> None:
1278
1279
  f"\n[dim]{len(nt.steps)} steps · {n_tool} tool · "
1279
1280
  f"{n_err} error · {n_lossy} lossy-projection{paused}{scheduled}[/]"
1280
1281
  )
1282
+ # The tree projects CAUSED_BY, so a heuristic parent can place a step whether or not
1283
+ # any step is lossy.
1284
+ if has_heuristic_edges(nt):
1285
+ console.print(f"[yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
1286
+
1287
+
1288
+ _REDACT_ERRORS_HELP = (
1289
+ "Leave error messages out of the artifact: a failed step keeps its error status and "
1290
+ "lists its message as unknown. Step names are still recorded as given."
1291
+ )
1281
1292
 
1282
1293
 
1283
1294
  @app.command()
@@ -1300,14 +1311,18 @@ def ingest(
1300
1311
  "module, and the graph's state callbacks while reading; never a node."
1301
1312
  ),
1302
1313
  ),
1314
+ redact_errors: bool = typer.Option(False, "--redact-errors", help=_REDACT_ERRORS_HELP),
1303
1315
  ) -> None:
1304
1316
  """Ingest a LangGraph thread's checkpoint history into a portable artifact."""
1305
1317
  from tracegraph.sqlite_snapshot import ingest_snapshot
1306
1318
 
1307
1319
  target = out or artifact.default_path(thread)
1308
1320
  _distinct_paths([sqlite], [target])
1309
- # Passed only when given, so an ingest without --graph makes exactly the call it always has.
1321
+ # Passed only when given, so an ingest without --graph or --redact-errors makes exactly the
1322
+ # call it always has.
1310
1323
  extra: dict[str, Any] = {}
1324
+ if redact_errors:
1325
+ extra["include_error_messages"] = False
1311
1326
  if graph is not None:
1312
1327
  from tracegraph.graph_ref import load_graph
1313
1328
 
@@ -1328,6 +1343,7 @@ def ingest_otlp(
1328
1343
  file: str = typer.Option(..., "--file", "-f", help="OTLP JSON/JSONL span export (resourceSpans), or '-' for stdin."),
1329
1344
  trace: str = typer.Option(None, "--trace", "-t", help="traceId to ingest (default: the file's only trace)."),
1330
1345
  out: Path = typer.Option(None, "--out", "-o", help="Artifact path (default <traceId>.json)."),
1346
+ redact_errors: bool = typer.Option(False, "--redact-errors", help=_REDACT_ERRORS_HELP),
1331
1347
  ) -> None:
1332
1348
  """Ingest one trace from an OpenInference/OTLP span export into a portable artifact."""
1333
1349
  from tracegraph.adapters import OTLPSpanAdapter
@@ -1336,7 +1352,9 @@ def ingest_otlp(
1336
1352
  # Bounded read through the common size-limited boundary (from_file would
1337
1353
  # read_text the whole file with no ceiling). KeyError joins the usual load
1338
1354
  # errors: the adapter raises it for an unknown/missing trace id.
1339
- adapter = OTLPSpanAdapter.from_json(_read_source(file))
1355
+ adapter = OTLPSpanAdapter.from_json(
1356
+ _read_source(file), include_error_messages=not redact_errors
1357
+ )
1340
1358
  trace = _select_trace(adapter, trace, what="file")
1341
1359
  nt = _normalize_read(adapter.ingest(trace))
1342
1360
  except (*_LOAD_ERRORS, KeyError) as exc:
@@ -1511,7 +1529,10 @@ def phoenix_diagnose(
1511
1529
  )
1512
1530
  baseline_nt = _px_trace(baseline, project=project) if baseline else None
1513
1531
  except _PxError as exc:
1514
- raise typer.BadParameter(str(exc)) from exc
1532
+ # Phoenix or `px` is unavailable or answered unusably: report it as `doctor` does
1533
+ # (exit 1), keeping 2 for an invalid invocation.
1534
+ err_console.print(f"[bold red]FAIL[/] {escape(str(exc))}")
1535
+ raise typer.Exit(1) from exc
1515
1536
  report = build_analysis(nt, baseline=baseline_nt)
1516
1537
  _render_analysis(report, limit=limit)
1517
1538
  if save_artifact:
@@ -1610,7 +1631,9 @@ def explain(
1610
1631
  """Trace a step's raw causal chain back toward its root cause."""
1611
1632
  store = _store_from_artifact(artifact_path, backend)
1612
1633
  try:
1613
- steps = store.trace().steps_by_id()
1634
+ trace = store.trace()
1635
+ steps = trace.steps_by_id()
1636
+ heuristic = has_heuristic_edges(trace)
1614
1637
  # An exact full-id match always wins — even when that id is also a *suffix* of a longer
1615
1638
  # namespaced id (e.g. "abc" vs "ns:abc"). Only fall back to suffix matching when the
1616
1639
  # input isn't itself a full step id, so a valid id is never rejected as "ambiguous".
@@ -1635,11 +1658,20 @@ def explain(
1635
1658
  for s in result.chain:
1636
1659
  flag = " [yellow]⚠ projection-lossy[/]" if s.projection_lossy else ""
1637
1660
  console.print(f" ← {escape(s.name or s.kind.value)} (step {s.seq}){flag}")
1638
- if result.is_lossy:
1661
+ # Where the capture record says a parent may have been chosen heuristically, the chain is
1662
+ # not called real or trustworthy; elsewhere the wording is unchanged.
1663
+ if result.is_lossy and heuristic:
1664
+ console.print(
1665
+ "\n[yellow]Some steps had more than one recorded cause; the single-parent tree view "
1666
+ "drops some of them, and this raw chain lists every recorded ancestor.[/]"
1667
+ )
1668
+ elif result.is_lossy:
1639
1669
  console.print(
1640
1670
  "\n[yellow]Some steps had multiple real causes; the single-parent tree view "
1641
1671
  "is lossy for them — trust this raw chain, not the tree.[/]"
1642
1672
  )
1673
+ if heuristic and result.chain:
1674
+ console.print(f"\n[yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
1643
1675
 
1644
1676
 
1645
1677
  @app.command()
@@ -1770,16 +1802,26 @@ def query(
1770
1802
  # Same fallback label as `tracegraph explain` and the tree renderers.
1771
1803
  console.print(f" ← {escape(s.name or s.kind.value)} (step {s.seq}){flag}")
1772
1804
  # Causal-honesty signal: when the matched effect (or anything in the chain)
1773
- # has more than one real cause, the single-parent tree projection dropped
1805
+ # has more than one recorded cause, the single-parent tree projection dropped
1774
1806
  # at least one. `tracegraph explain` surfaces this same warning — `query
1775
1807
  # --explain` must too, or a fan-in effect's lossiness becomes invisible
1776
1808
  # (target lossiness doesn't show up in the per-step ⚠ flags above, since
1777
1809
  # we only flag chain ancestors there).
1778
- if result.is_lossy:
1810
+ # A lossy step can be an ancestor outside the matched path, hence "chain".
1811
+ heuristic = has_heuristic_edges(traces_by_id[m.trace_id])
1812
+ if result.is_lossy and heuristic:
1813
+ console.print(
1814
+ " [yellow]⚠ some steps in this chain had more than one recorded cause — "
1815
+ "the single-parent tree drops some of them; this raw chain lists every "
1816
+ "recorded ancestor.[/]"
1817
+ )
1818
+ elif result.is_lossy:
1779
1819
  console.print(
1780
1820
  " [yellow]⚠ some steps in this match had multiple real causes — "
1781
1821
  "trust this raw chain, not the tree.[/]"
1782
1822
  )
1823
+ if heuristic and result.chain:
1824
+ console.print(f" [yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
1783
1825
 
1784
1826
  n_traces = len({m.trace_id for m in matches})
1785
1827
  suffix = f" — truncated from {total}" if truncated else ""
@@ -284,6 +284,10 @@ CAPTURE_GAP_CODES = frozenset(
284
284
  "phoenix.unreadable_evaluation_score",
285
285
  }
286
286
  )
287
+ #: The gaps that say a causal parent was chosen heuristically at ingest: among several spans by
288
+ #: start order, or by falling back to the span parent. ``check``, ``summarize``, ``analyze`` and the
289
+ #: incident memo read this set.
290
+ HEURISTIC_EDGE_GAP_CODES = frozenset({"otlp.graph_parent_by_timing", "otlp.graph_parent_fallback"})
287
291
 
288
292
 
289
293
  class CaptureGap(BaseModel):
@@ -36,7 +36,13 @@ def _inert_serde():
36
36
 
37
37
 
38
38
  def ingest_snapshot(
39
- source: Path, thread: str, *, error_channel: str, timeout: float = 30, graph: Any = None
39
+ source: Path,
40
+ thread: str,
41
+ *,
42
+ error_channel: str,
43
+ timeout: float = 30,
44
+ graph: Any = None,
45
+ include_error_messages: bool = True,
40
46
  ) -> RawTrace:
41
47
  from langgraph.checkpoint.sqlite import SqliteSaver
42
48
 
@@ -64,7 +70,10 @@ def ingest_snapshot(
64
70
  with closing(sqlite3.connect(str(snapshot), check_same_thread=False)) as conn:
65
71
  saver = SqliteSaver(conn, serde=_inert_serde())
66
72
  return LangGraphCheckpointAdapter(
67
- saver, error_channel=error_channel, schedule_graph=graph
73
+ saver,
74
+ error_channel=error_channel,
75
+ schedule_graph=graph,
76
+ include_error_messages=include_error_messages,
68
77
  ).ingest(thread)
69
78
  except sqlite3.Error as exc:
70
79
  raise ValueError("cannot read SQLite checkpoint snapshot") from exc