agent-tracegraph 0.6.0__tar.gz → 0.7.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.0 → agent_tracegraph-0.7.0}/CHANGELOG.md +64 -0
  2. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/PKG-INFO +17 -10
  3. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/README.md +15 -8
  4. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/SECURITY.md +18 -10
  5. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/pyproject.toml +5 -2
  6. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/adapters/langgraph_checkpoint.py +16 -5
  7. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/adapters/otlp_spans.py +1 -0
  8. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/adapters/phoenix_export.py +35 -6
  9. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/artifact.py +1 -0
  10. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/cli.py +19 -3
  11. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/sqlite_snapshot.py +11 -2
  12. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/.gitignore +0 -0
  13. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/LICENSE +0 -0
  14. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/contracts/analysis-report.schema.json +0 -0
  15. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/contracts/check-report.schema.json +0 -0
  16. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/contracts/phoenix-runtime.json +0 -0
  17. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/contracts/review-candidates.schema.json +0 -0
  18. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/__init__.py +0 -0
  19. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/_atomic_write.py +0 -0
  20. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/_errors.py +0 -0
  21. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/adapters/__init__.py +0 -0
  22. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/adapters/base.py +0 -0
  23. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/__init__.py +0 -0
  24. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/ahu.py +0 -0
  25. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/check.py +0 -0
  26. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/diagnose.py +0 -0
  27. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/explain.py +0 -0
  28. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/memo.py +0 -0
  29. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/motifs.py +0 -0
  30. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/analysis/patterns.py +0 -0
  31. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/graph_ref.py +0 -0
  32. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/input_validation.py +0 -0
  33. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/model.py +0 -0
  34. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/normalize.py +0 -0
  35. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/py.typed +0 -0
  36. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/review_candidates.py +0 -0
  37. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/store/__init__.py +0 -0
  38. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/store/base.py +0 -0
  39. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/store/in_memory.py +0 -0
  40. {agent_tracegraph-0.6.0 → agent_tracegraph-0.7.0}/src/tracegraph/store/ladybug.py +0 -0
@@ -6,6 +6,70 @@ 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.7.0 - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - **`--redact-errors` on `ingest-otlp` and `ingest`** (#125) leaves error messages out of the
14
+ artifact. The OTLP and LangGraph adapters record the producer's error text by default
15
+ (`status.message` and exception events; the error channel's value and a raised exception's
16
+ `repr`), and `inspect` and `explain` print it; until now only Phoenix ingestion could drop it.
17
+ With the flag a failed step keeps its `error` status and lists `error_msg` in
18
+ `unknown_fields`, and nothing else in the artifact changes. A rule that reads the message
19
+ therefore treats it as unknown, not absent: `check --rule pattern-not-increased:timeout` reports
20
+ `INSUFFICIENT_EVIDENCE` for a redacted current run, and against a redacted baseline it can still
21
+ pass but not fail. Names are still recorded as given. The default is unchanged. In Python,
22
+ `LangGraphCheckpointAdapter` and `ingest_snapshot` take `include_error_messages=False`, as
23
+ `OTLPSpanAdapter` already did.
24
+
25
+ ### Changed
26
+
27
+ - **`phoenix diagnose` exits 1, not 2, when `px` is unavailable or its answer cannot be used**
28
+ (#122). That covers a `px` that is missing or cannot run, fails, times out, prints too much,
29
+ prints output that is not UTF-8 or not valid JSON, reports no recognizable version or one
30
+ older than the supported floor, or returns an export tracegraph cannot read, and a project
31
+ with no traces. The command now
32
+ prints `FAIL <reason>` on stderr, as `phoenix doctor` does, instead of the usage banner.
33
+ `diagnose` used 2 for these, the same code as an invalid invocation, so a wrapper could not
34
+ tell "called wrong" from "Phoenix is not reachable". Some exports 0.6.1 accepted are now refused
35
+ as unreadable, so `diagnose` goes from 0 to 1 on them; see the Phoenix time fix below. 2 still
36
+ means invalid arguments or output paths, such as `--limit 0`, two outputs naming the same file,
37
+ or a destination that cannot be written. `doctor` is unchanged.
38
+
39
+ ### Fixed
40
+
41
+ - **A Phoenix time given as a number too small to be epoch nanoseconds is no longer read as one**
42
+ (#124). The Phoenix adapter took every numeric `start_time` / `end_time` as nanoseconds, so a
43
+ time in seconds, milliseconds or microseconds became a moment near 1970, with its sub-unit
44
+ digits truncated and no warning: a 500 ms span given in float seconds was recorded at
45
+ `1970-01-01T00:00:01.783987+00:00` with a duration of 0.0. A number is now read as nanoseconds
46
+ only from 10^17 (1973-03-03T09:46:40Z) up. Below that, seconds, milliseconds and microseconds
47
+ for any date before the year 5138 cannot be told apart, so the time is reported as unavailable
48
+ and disclosed as `otlp.unreadable_timestamp`, the code an unreadable time already uses, which
49
+ 0.6.x readers know. That applies to numbers, digit strings, and strings `int()` reads with a
50
+ sign, surrounding whitespace or underscores. ISO strings are read as before, including compact
51
+ forms such as `20260714_123456`, and so are nanosecond values. Phoenix's own `trace get` export
52
+ uses ISO strings, so artifacts change only for exports that carried such numbers.
53
+
54
+ For an export that carries such numbers, the step sequence, the parent chosen among several
55
+ spans sharing a `graph.node.id`, and whether the export is accepted at all can also differ from
56
+ 0.6.1, which read the numbers as nanoseconds and ordered spans by them. For example, take spans
57
+ `a` and `b` with `graph.node.id` `loop`, starting at 1783987200 and 1783987201, and a span `c`
58
+ starting at 1783987202 with `graph.node.parent_id` `loop` and no `parent_id`. 0.6.1 took `b` as
59
+ `c`'s parent and exited 0; now `ingest-phoenix` and `analyze` exit 2 and `phoenix diagnose`
60
+ exits 1.
61
+
62
+ ## 0.6.1 - 2026-10-20
63
+
64
+ ### Dependencies
65
+
66
+ - `langgraph-checkpoint-sqlite` now requires `>=3.1.1` (was `>=2`), the first release with the
67
+ fix for GHSA-47pj-3jcm-6whg, where store namespace prefixes could match across segment
68
+ boundaries. Tracegraph reads checkpoints through `SqliteSaver` and never uses the store; the
69
+ floor keeps an install from resolving a flagged version. The source, commands and JSON
70
+ contracts are the same as 0.6.0, but the install range is narrower: an environment that pins
71
+ `langgraph-checkpoint-sqlite==3.1.0` resolves with 0.6.0 and not with 0.6.1.
72
+
9
73
  ## 0.6.0 - 2026-10-20
10
74
 
11
75
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -19,7 +19,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
19
  Classifier: Topic :: Software Development :: Debuggers
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.12
22
- Requires-Dist: langgraph-checkpoint-sqlite>=2
22
+ Requires-Dist: langgraph-checkpoint-sqlite>=3.1.1
23
23
  Requires-Dist: langgraph-checkpoint>=4.1
24
24
  Requires-Dist: pydantic<3,>=2
25
25
  Requires-Dist: rich>=13
@@ -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
@@ -237,8 +238,11 @@ identifiers, and credentials. Missing metrics are reported as `unavailable`, nev
237
238
  (identifier-shaped span names only, error messages dropped), but the OTLP and LangGraph
238
239
  adapters record the span name and error text they were given: OTLP keeps `status.message`
239
240
  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
241
+ values reach the artifact on disk and are printed verbatim by `inspect` and `explain`.
242
+ `ingest-otlp --redact-errors` and `ingest --redact-errors` leave the error text out: a failed
243
+ step keeps its `error` status and lists `error_msg` in `unknown_fields`, so a rule that reads
244
+ the message (the `timeout` preset) reports it as unknown rather than absent. Names are still
245
+ recorded as given. If your producer puts prompt text or secrets in span names, treat artifacts
242
246
  from those two adapters as sensitive, or ingest through Phoenix. See "Analysis and output
243
247
  guarantees" below.
244
248
 
@@ -253,14 +257,16 @@ tracegraph analyze full-dag.json
253
257
  ```
254
258
 
255
259
  `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;
260
+ settings as `ingest-otlp`, so the report is byte-identical to analysing the ingested artifact
261
+ (one ingested with `--redact-errors` can differ where a rule reads the message);
257
262
  nothing is written unless `--json-out` asks for a report. The input is identified by shape alone
258
263
  (`schema_version` → artifact, `resourceSpans` → OTLP, `spans`/`traces`/`data` → Phoenix); a
259
264
  payload carrying both OTLP and Phoenix records is refused, naming the explicit ingest command
260
265
  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
266
  `tracegraph analyze your-export.otlp.json` (add `--trace <id>` when the file holds several), then
262
267
  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`.
268
+ above still applies: `ingest-otlp` keeps error text unless given `--redact-errors`, and
269
+ reports stay `safe-v1`.
264
270
 
265
271
  The JSON analysis-report contract is versioned at
266
272
  [`contracts/analysis-report.schema.json`](contracts/analysis-report.schema.json).
@@ -391,7 +397,7 @@ Known limits:
391
397
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
392
398
  causes may be missing.
393
399
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
394
- producer's names and error messages.
400
+ producer's names, and its error messages unless ingested with `--redact-errors`.
395
401
  - CI covers Python 3.12 on Linux.
396
402
 
397
403
  Caveat for the checkpoint adapter: it is a **checkpoint-level** view (one node per
@@ -437,7 +443,8 @@ with deterministic SHA-256 aliases, and disclose in `warnings` when an alias act
437
443
  the report. Trace/step identifiers remain; this is not anonymization.
438
444
  Raw artifact names and bytes are not rewritten for report privacy or matching, so the
439
445
  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
446
+ error message (`--redact-errors` on `ingest` and `ingest-otlp` drops the message, not the
447
+ name). Phoenix ingestion is the exception: it filters at the source, keeping span
441
448
  names and annotation names and labels only when they are identifier-shaped, and dropping
442
449
  status messages. Identifier-shaped is not the same as anonymous: an e-mail address passes.
443
450
  `export-review-candidates` output is not passed through the report redactor: its `tool_key`
@@ -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
@@ -206,8 +207,11 @@ identifiers, and credentials. Missing metrics are reported as `unavailable`, nev
206
207
  (identifier-shaped span names only, error messages dropped), but the OTLP and LangGraph
207
208
  adapters record the span name and error text they were given: OTLP keeps `status.message`
208
209
  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
210
+ values reach the artifact on disk and are printed verbatim by `inspect` and `explain`.
211
+ `ingest-otlp --redact-errors` and `ingest --redact-errors` leave the error text out: a failed
212
+ step keeps its `error` status and lists `error_msg` in `unknown_fields`, so a rule that reads
213
+ the message (the `timeout` preset) reports it as unknown rather than absent. Names are still
214
+ recorded as given. If your producer puts prompt text or secrets in span names, treat artifacts
211
215
  from those two adapters as sensitive, or ingest through Phoenix. See "Analysis and output
212
216
  guarantees" below.
213
217
 
@@ -222,14 +226,16 @@ tracegraph analyze full-dag.json
222
226
  ```
223
227
 
224
228
  `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;
229
+ settings as `ingest-otlp`, so the report is byte-identical to analysing the ingested artifact
230
+ (one ingested with `--redact-errors` can differ where a rule reads the message);
226
231
  nothing is written unless `--json-out` asks for a report. The input is identified by shape alone
227
232
  (`schema_version` → artifact, `resourceSpans` → OTLP, `spans`/`traces`/`data` → Phoenix); a
228
233
  payload carrying both OTLP and Phoenix records is refused, naming the explicit ingest command
229
234
  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
235
  `tracegraph analyze your-export.otlp.json` (add `--trace <id>` when the file holds several), then
231
236
  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`.
237
+ above still applies: `ingest-otlp` keeps error text unless given `--redact-errors`, and
238
+ reports stay `safe-v1`.
233
239
 
234
240
  The JSON analysis-report contract is versioned at
235
241
  [`contracts/analysis-report.schema.json`](contracts/analysis-report.schema.json).
@@ -360,7 +366,7 @@ Known limits:
360
366
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
361
367
  causes may be missing.
362
368
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
363
- producer's names and error messages.
369
+ producer's names, and its error messages unless ingested with `--redact-errors`.
364
370
  - CI covers Python 3.12 on Linux.
365
371
 
366
372
  Caveat for the checkpoint adapter: it is a **checkpoint-level** view (one node per
@@ -406,7 +412,8 @@ with deterministic SHA-256 aliases, and disclose in `warnings` when an alias act
406
412
  the report. Trace/step identifiers remain; this is not anonymization.
407
413
  Raw artifact names and bytes are not rewritten for report privacy or matching, so the
408
414
  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
415
+ error message (`--redact-errors` on `ingest` and `ingest-otlp` drops the message, not the
416
+ name). Phoenix ingestion is the exception: it filters at the source, keeping span
410
417
  names and annotation names and labels only when they are identifier-shaped, and dropping
411
418
  status messages. Identifier-shaped is not the same as anonymous: an e-mail address passes.
412
419
  `export-review-candidates` output is not passed through the report redactor: its `tool_key`
@@ -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.0"
7
+ version = "0.7.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"
@@ -30,7 +30,10 @@ classifiers = [
30
30
 
31
31
  dependencies = [
32
32
  "pydantic>=2,<3",
33
- "langgraph-checkpoint-sqlite>=2",
33
+ # 3.1.1 fixes GHSA-47pj-3jcm-6whg (store namespace prefixes crossing segment boundaries).
34
+ # Tracegraph reads only SqliteSaver, never the store; the floor keeps installs off the
35
+ # flagged versions.
36
+ "langgraph-checkpoint-sqlite>=3.1.1",
34
37
  # Declared directly, not left transitive, because `ingest` now calls its serializer API:
35
38
  # `JsonPlusSerializer(allowed_msgpack_modules=...)` is what stops a checkpoint payload
36
39
  # invoking arbitrary callables, and it does not exist before 4.1 (4.0.0 raises TypeError,
@@ -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
  ))
@@ -423,6 +423,7 @@ class OTLPSpanAdapter:
423
423
 
424
424
  @classmethod
425
425
  def from_file(cls, path: str | Path, **kwargs: Any) -> "OTLPSpanAdapter":
426
+ """Reads the whole file: the CLI's ``TRACEGRAPH_MAX_ARTIFACT_BYTES`` cap does not apply here."""
426
427
  return cls.from_json(Path(path).read_text(encoding="utf-8"), **kwargs)
427
428
 
428
429
  # --- TraceAdapter ---
@@ -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,22 +36,44 @@ _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))
@@ -151,6 +179,7 @@ class PhoenixExportAdapter:
151
179
 
152
180
  @classmethod
153
181
  def from_file(cls, path: str | Path) -> "PhoenixExportAdapter":
182
+ """Reads the whole file: the CLI's ``TRACEGRAPH_MAX_ARTIFACT_BYTES`` cap does not apply here."""
154
183
  return cls.from_json(Path(path).read_text(encoding="utf-8"))
155
184
 
156
185
  def discover(self) -> list[str]:
@@ -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
 
@@ -1280,6 +1280,12 @@ def _render_tree(nt: NormalizedTrace) -> None:
1280
1280
  )
1281
1281
 
1282
1282
 
1283
+ _REDACT_ERRORS_HELP = (
1284
+ "Leave error messages out of the artifact: a failed step keeps its error status and "
1285
+ "lists its message as unknown. Step names are still recorded as given."
1286
+ )
1287
+
1288
+
1283
1289
  @app.command()
1284
1290
  def ingest(
1285
1291
  sqlite: Path = typer.Option(..., help="Path to a LangGraph SqliteSaver DB file."),
@@ -1300,14 +1306,18 @@ def ingest(
1300
1306
  "module, and the graph's state callbacks while reading; never a node."
1301
1307
  ),
1302
1308
  ),
1309
+ redact_errors: bool = typer.Option(False, "--redact-errors", help=_REDACT_ERRORS_HELP),
1303
1310
  ) -> None:
1304
1311
  """Ingest a LangGraph thread's checkpoint history into a portable artifact."""
1305
1312
  from tracegraph.sqlite_snapshot import ingest_snapshot
1306
1313
 
1307
1314
  target = out or artifact.default_path(thread)
1308
1315
  _distinct_paths([sqlite], [target])
1309
- # Passed only when given, so an ingest without --graph makes exactly the call it always has.
1316
+ # Passed only when given, so an ingest without --graph or --redact-errors makes exactly the
1317
+ # call it always has.
1310
1318
  extra: dict[str, Any] = {}
1319
+ if redact_errors:
1320
+ extra["include_error_messages"] = False
1311
1321
  if graph is not None:
1312
1322
  from tracegraph.graph_ref import load_graph
1313
1323
 
@@ -1328,6 +1338,7 @@ def ingest_otlp(
1328
1338
  file: str = typer.Option(..., "--file", "-f", help="OTLP JSON/JSONL span export (resourceSpans), or '-' for stdin."),
1329
1339
  trace: str = typer.Option(None, "--trace", "-t", help="traceId to ingest (default: the file's only trace)."),
1330
1340
  out: Path = typer.Option(None, "--out", "-o", help="Artifact path (default <traceId>.json)."),
1341
+ redact_errors: bool = typer.Option(False, "--redact-errors", help=_REDACT_ERRORS_HELP),
1331
1342
  ) -> None:
1332
1343
  """Ingest one trace from an OpenInference/OTLP span export into a portable artifact."""
1333
1344
  from tracegraph.adapters import OTLPSpanAdapter
@@ -1336,7 +1347,9 @@ def ingest_otlp(
1336
1347
  # Bounded read through the common size-limited boundary (from_file would
1337
1348
  # read_text the whole file with no ceiling). KeyError joins the usual load
1338
1349
  # errors: the adapter raises it for an unknown/missing trace id.
1339
- adapter = OTLPSpanAdapter.from_json(_read_source(file))
1350
+ adapter = OTLPSpanAdapter.from_json(
1351
+ _read_source(file), include_error_messages=not redact_errors
1352
+ )
1340
1353
  trace = _select_trace(adapter, trace, what="file")
1341
1354
  nt = _normalize_read(adapter.ingest(trace))
1342
1355
  except (*_LOAD_ERRORS, KeyError) as exc:
@@ -1511,7 +1524,10 @@ def phoenix_diagnose(
1511
1524
  )
1512
1525
  baseline_nt = _px_trace(baseline, project=project) if baseline else None
1513
1526
  except _PxError as exc:
1514
- raise typer.BadParameter(str(exc)) from exc
1527
+ # Phoenix or `px` is unavailable or answered unusably: report it as `doctor` does
1528
+ # (exit 1), keeping 2 for an invalid invocation.
1529
+ err_console.print(f"[bold red]FAIL[/] {escape(str(exc))}")
1530
+ raise typer.Exit(1) from exc
1515
1531
  report = build_analysis(nt, baseline=baseline_nt)
1516
1532
  _render_analysis(report, limit=limit)
1517
1533
  if save_artifact:
@@ -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