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.
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/CHANGELOG.md +111 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/PKG-INFO +34 -10
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/README.md +33 -9
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/SECURITY.md +18 -10
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/pyproject.toml +1 -1
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/langgraph_checkpoint.py +16 -5
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/otlp_spans.py +39 -5
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/phoenix_export.py +69 -6
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/check.py +2 -1
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/diagnose.py +13 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/memo.py +14 -2
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/artifact.py +1 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/cli.py +50 -8
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/model.py +4 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/sqlite_snapshot.py +11 -2
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/.gitignore +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/LICENSE +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/analysis-report.schema.json +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/check-report.schema.json +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/phoenix-runtime.json +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/contracts/review-candidates.schema.json +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/__init__.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/_atomic_write.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/_errors.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/__init__.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/base.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/__init__.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/ahu.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/explain.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/motifs.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/patterns.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/graph_ref.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/input_validation.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/normalize.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/py.typed +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/review_candidates.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/__init__.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/base.py +0 -0
- {agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/store/in_memory.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
205
|
-
|
|
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`.
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
174
|
-
|
|
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`.
|
|
210
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
68
|
-
files named on the command line and `-` for stdin
|
|
69
|
-
(`TRACEGRAPH_MAX_ARTIFACT_BYTES` changes it, and
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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"
|
{agent_tracegraph-0.6.1 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/langgraph_checkpoint.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
35
|
-
|
|
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
|
|
62
|
+
return _epoch_nanos(int(value))
|
|
42
63
|
text = str(value)
|
|
43
64
|
if text.isdigit():
|
|
44
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|