agent-tracegraph 0.7.0__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.7.0 → agent_tracegraph-0.8.0}/CHANGELOG.md +58 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/PKG-INFO +19 -2
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/README.md +18 -1
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/pyproject.toml +1 -1
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/otlp_spans.py +38 -5
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/phoenix_export.py +34 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/check.py +2 -1
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/diagnose.py +13 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/memo.py +14 -2
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/cli.py +31 -5
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/model.py +4 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/.gitignore +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/LICENSE +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/SECURITY.md +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/analysis-report.schema.json +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/check-report.schema.json +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/phoenix-runtime.json +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/review-candidates.schema.json +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/__init__.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/_atomic_write.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/_errors.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/__init__.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/base.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/langgraph_checkpoint.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/__init__.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/ahu.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/explain.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/motifs.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/patterns.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/artifact.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/graph_ref.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/input_validation.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/normalize.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/py.typed +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/review_candidates.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/sqlite_snapshot.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/__init__.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/base.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/in_memory.py +0 -0
- {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/ladybug.py +0 -0
|
@@ -6,6 +6,64 @@ 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
|
+
|
|
9
67
|
## 0.7.0 - 2026-10-20
|
|
10
68
|
|
|
11
69
|
### Added
|
|
@@ -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
|
|
|
@@ -221,6 +221,15 @@ Phoenix exports preserve span parents but currently do not expose the original O
|
|
|
221
221
|
links. These artifacts are explicitly marked `causal_fidelity=parent_only`; tracegraph warns
|
|
222
222
|
that additional fan-in causes may be missing instead of claiming a complete DAG.
|
|
223
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
|
+
|
|
224
233
|
The exact Phoenix CLI and server versions used by CI are centrally pinned. See the
|
|
225
234
|
[Phoenix runtime pin maintenance runbook](docs/phoenix-cli-contract.md) for the controlled
|
|
226
235
|
upgrade and contract-verification procedure, and the
|
|
@@ -456,6 +465,14 @@ origins, fidelity disclosures, and the digest of the exact artifact bytes. Logic
|
|
|
456
465
|
comparison keys are JSON-encoded typed paths; consumers should treat them as opaque strings.
|
|
457
466
|
Containment and unknown edges preserve all error investigation candidates, deeper first.
|
|
458
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.
|
|
459
476
|
Explicit OTLP OK takes precedence over handled exception events on new ingestion; old
|
|
460
477
|
artifacts are not reinterpreted.
|
|
461
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
|
|
|
@@ -190,6 +190,15 @@ Phoenix exports preserve span parents but currently do not expose the original O
|
|
|
190
190
|
links. These artifacts are explicitly marked `causal_fidelity=parent_only`; tracegraph warns
|
|
191
191
|
that additional fan-in causes may be missing instead of claiming a complete DAG.
|
|
192
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
|
+
|
|
193
202
|
The exact Phoenix CLI and server versions used by CI are centrally pinned. See the
|
|
194
203
|
[Phoenix runtime pin maintenance runbook](docs/phoenix-cli-contract.md) for the controlled
|
|
195
204
|
upgrade and contract-verification procedure, and the
|
|
@@ -425,6 +434,14 @@ origins, fidelity disclosures, and the digest of the exact artifact bytes. Logic
|
|
|
425
434
|
comparison keys are JSON-encoded typed paths; consumers should treat them as opaque strings.
|
|
426
435
|
Containment and unknown edges preserve all error investigation candidates, deeper first.
|
|
427
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.
|
|
428
445
|
Explicit OTLP OK takes precedence over handled exception events on new ingestion; old
|
|
429
446
|
artifacts are not reinterpreted.
|
|
430
447
|
A span whose status is UNSET and that carries no exception event (one with a message or
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -462,8 +467,18 @@ class OTLPSpanAdapter:
|
|
|
462
467
|
for sid, span in by_id.items()
|
|
463
468
|
if _parse_nanos(_first(span, "startTimeUnixNano", "start_time_unix_nano")) is None
|
|
464
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
|
+
}
|
|
465
478
|
disclosures = Disclosures()
|
|
466
|
-
causes, origins = self._causes(
|
|
479
|
+
causes, origins = self._causes(
|
|
480
|
+
by_id, attrs, prerank, parent_span, untimed, order_key, disclosures
|
|
481
|
+
)
|
|
467
482
|
seq = self._topo_seq(by_id, causes, prerank)
|
|
468
483
|
|
|
469
484
|
steps: list[Step] = []
|
|
@@ -725,6 +740,7 @@ class OTLPSpanAdapter:
|
|
|
725
740
|
prerank: dict[str, int],
|
|
726
741
|
parent_span: dict[str, str | None],
|
|
727
742
|
untimed: set[str],
|
|
743
|
+
order_key: dict[str, tuple[Any, int, str]],
|
|
728
744
|
disclosures: Disclosures,
|
|
729
745
|
) -> tuple[dict[str, list[str]], dict[tuple[str, str], EdgeOrigin]]:
|
|
730
746
|
"""Reconstruct ``effect -> [causes]`` from declared signals: one parent + links.
|
|
@@ -752,12 +768,13 @@ class OTLPSpanAdapter:
|
|
|
752
768
|
causes[effect].append(cause)
|
|
753
769
|
origins[(effect, cause)] = origin
|
|
754
770
|
|
|
755
|
-
by_timing = fallback = 0
|
|
771
|
+
by_timing = by_order = fallback = 0
|
|
756
772
|
for sid in by_id:
|
|
757
773
|
parent, parent_origin, resolution = self._resolve_parent(
|
|
758
|
-
sid, attrs, node_index, prerank, parent_span, untimed
|
|
774
|
+
sid, attrs, node_index, prerank, parent_span, untimed, order_key
|
|
759
775
|
)
|
|
760
776
|
by_timing += resolution == "by_timing"
|
|
777
|
+
by_order += resolution == "by_order"
|
|
761
778
|
fallback += resolution == "fallback"
|
|
762
779
|
if parent is not None:
|
|
763
780
|
add(sid, parent, parent_origin)
|
|
@@ -789,6 +806,16 @@ class OTLPSpanAdapter:
|
|
|
789
806
|
"the nearest preceding one by start time was taken as the parent.",
|
|
790
807
|
by_timing,
|
|
791
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
|
+
)
|
|
792
819
|
if fallback:
|
|
793
820
|
disclosures.add(
|
|
794
821
|
"otlp.graph_parent_fallback",
|
|
@@ -826,6 +853,7 @@ class OTLPSpanAdapter:
|
|
|
826
853
|
prerank: dict[str, int],
|
|
827
854
|
parent_span: dict[str, str | None],
|
|
828
855
|
untimed: set[str],
|
|
856
|
+
order_key: dict[str, tuple[Any, int, str]],
|
|
829
857
|
) -> tuple[str | None, EdgeOrigin, str | None]:
|
|
830
858
|
"""The single chosen parent cause: the logical graph parent if resolvable, else the
|
|
831
859
|
declared span parent.
|
|
@@ -835,7 +863,8 @@ class OTLPSpanAdapter:
|
|
|
835
863
|
|
|
836
864
|
* a single span carries that ``graph.node.id`` → trust it outright (unambiguous);
|
|
837
865
|
* several do (a loop) → pick the nearest *preceding* execution, but only when this
|
|
838
|
-
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
|
|
839
868
|
evidence of order and the parent is ambiguous.
|
|
840
869
|
|
|
841
870
|
If ``graph.node.parent_id`` is declared but resolves to no in-trace span, or is
|
|
@@ -846,7 +875,7 @@ class OTLPSpanAdapter:
|
|
|
846
875
|
``None`` only when the span truly has no declared parent of either kind.
|
|
847
876
|
|
|
848
877
|
The third value says how a declared logical parent was resolved when that was not
|
|
849
|
-
unambiguous: ``"by_timing"`` or ``"fallback"``; otherwise ``None``.
|
|
878
|
+
unambiguous: ``"by_timing"``, ``"by_order"`` or ``"fallback"``; otherwise ``None``.
|
|
850
879
|
"""
|
|
851
880
|
pid = parent_span.get(sid)
|
|
852
881
|
if self._prefer_graph_parent:
|
|
@@ -859,6 +888,10 @@ class OTLPSpanAdapter:
|
|
|
859
888
|
preceding = [c for c in candidates if timed and prerank[c] < prerank[sid]]
|
|
860
889
|
if preceding:
|
|
861
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"
|
|
862
895
|
if pid is None:
|
|
863
896
|
detail = "no in-trace span" if not candidates else "multiple ambiguous spans"
|
|
864
897
|
raise ValueError(
|
|
@@ -79,6 +79,38 @@ def _nanos(value: Any) -> Any:
|
|
|
79
79
|
return str(int(parsed.timestamp() * 1_000_000_000))
|
|
80
80
|
|
|
81
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
|
+
|
|
82
114
|
_OPERATION_NAME = re.compile(r"[A-Za-z0-9_.:/#@-]{1,200}")
|
|
83
115
|
|
|
84
116
|
|
|
@@ -202,6 +234,7 @@ class PhoenixExportAdapter:
|
|
|
202
234
|
include_error_messages=False,
|
|
203
235
|
preserve_unset_status=True,
|
|
204
236
|
evaluation_channel="_tracegraph_evaluations",
|
|
237
|
+
start_order_channel="_tracegraph_start_order",
|
|
205
238
|
).ingest(trace_id)
|
|
206
239
|
if unreadable_scores:
|
|
207
240
|
disclosures = Disclosures()
|
|
@@ -257,4 +290,5 @@ class PhoenixExportAdapter:
|
|
|
257
290
|
"attributes": _attrs(span),
|
|
258
291
|
"events": _span_array(span, "events"),
|
|
259
292
|
"_tracegraph_evaluations": _evaluations(span),
|
|
293
|
+
"_tracegraph_start_order": _start_order(_first(span, "start_time", "startTime")),
|
|
260
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:
|
|
@@ -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,10 @@ 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}[/]")
|
|
1281
1286
|
|
|
1282
1287
|
|
|
1283
1288
|
_REDACT_ERRORS_HELP = (
|
|
@@ -1626,7 +1631,9 @@ def explain(
|
|
|
1626
1631
|
"""Trace a step's raw causal chain back toward its root cause."""
|
|
1627
1632
|
store = _store_from_artifact(artifact_path, backend)
|
|
1628
1633
|
try:
|
|
1629
|
-
|
|
1634
|
+
trace = store.trace()
|
|
1635
|
+
steps = trace.steps_by_id()
|
|
1636
|
+
heuristic = has_heuristic_edges(trace)
|
|
1630
1637
|
# An exact full-id match always wins — even when that id is also a *suffix* of a longer
|
|
1631
1638
|
# namespaced id (e.g. "abc" vs "ns:abc"). Only fall back to suffix matching when the
|
|
1632
1639
|
# input isn't itself a full step id, so a valid id is never rejected as "ambiguous".
|
|
@@ -1651,11 +1658,20 @@ def explain(
|
|
|
1651
1658
|
for s in result.chain:
|
|
1652
1659
|
flag = " [yellow]⚠ projection-lossy[/]" if s.projection_lossy else ""
|
|
1653
1660
|
console.print(f" ← {escape(s.name or s.kind.value)} (step {s.seq}){flag}")
|
|
1654
|
-
|
|
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:
|
|
1655
1669
|
console.print(
|
|
1656
1670
|
"\n[yellow]Some steps had multiple real causes; the single-parent tree view "
|
|
1657
1671
|
"is lossy for them — trust this raw chain, not the tree.[/]"
|
|
1658
1672
|
)
|
|
1673
|
+
if heuristic and result.chain:
|
|
1674
|
+
console.print(f"\n[yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
|
|
1659
1675
|
|
|
1660
1676
|
|
|
1661
1677
|
@app.command()
|
|
@@ -1786,16 +1802,26 @@ def query(
|
|
|
1786
1802
|
# Same fallback label as `tracegraph explain` and the tree renderers.
|
|
1787
1803
|
console.print(f" ← {escape(s.name or s.kind.value)} (step {s.seq}){flag}")
|
|
1788
1804
|
# Causal-honesty signal: when the matched effect (or anything in the chain)
|
|
1789
|
-
# has more than one
|
|
1805
|
+
# has more than one recorded cause, the single-parent tree projection dropped
|
|
1790
1806
|
# at least one. `tracegraph explain` surfaces this same warning — `query
|
|
1791
1807
|
# --explain` must too, or a fan-in effect's lossiness becomes invisible
|
|
1792
1808
|
# (target lossiness doesn't show up in the per-step ⚠ flags above, since
|
|
1793
1809
|
# we only flag chain ancestors there).
|
|
1794
|
-
|
|
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:
|
|
1795
1819
|
console.print(
|
|
1796
1820
|
" [yellow]⚠ some steps in this match had multiple real causes — "
|
|
1797
1821
|
"trust this raw chain, not the tree.[/]"
|
|
1798
1822
|
)
|
|
1823
|
+
if heuristic and result.chain:
|
|
1824
|
+
console.print(f" [yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
|
|
1799
1825
|
|
|
1800
1826
|
n_traces = len({m.trace_id for m in matches})
|
|
1801
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):
|
|
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
|
{agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/langgraph_checkpoint.py
RENAMED
|
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
|