agent-tracegraph 0.2.0__py3-none-any.whl
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.2.0.dist-info/METADATA +380 -0
- agent_tracegraph-0.2.0.dist-info/RECORD +28 -0
- agent_tracegraph-0.2.0.dist-info/WHEEL +4 -0
- agent_tracegraph-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tracegraph-0.2.0.dist-info/licenses/LICENSE +191 -0
- tracegraph/__init__.py +38 -0
- tracegraph/adapters/__init__.py +13 -0
- tracegraph/adapters/base.py +22 -0
- tracegraph/adapters/langgraph_checkpoint.py +804 -0
- tracegraph/adapters/otlp_spans.py +703 -0
- tracegraph/adapters/phoenix_export.py +218 -0
- tracegraph/analysis/__init__.py +55 -0
- tracegraph/analysis/ahu.py +329 -0
- tracegraph/analysis/diagnose.py +676 -0
- tracegraph/analysis/explain.py +46 -0
- tracegraph/analysis/patterns.py +628 -0
- tracegraph/artifact.py +192 -0
- tracegraph/cli.py +1242 -0
- tracegraph/input_validation.py +19 -0
- tracegraph/model.py +225 -0
- tracegraph/normalize.py +248 -0
- tracegraph/py.typed +0 -0
- tracegraph/review_candidates.py +136 -0
- tracegraph/sqlite_snapshot.py +65 -0
- tracegraph/store/__init__.py +22 -0
- tracegraph/store/base.py +43 -0
- tracegraph/store/in_memory.py +94 -0
- tracegraph/store/ladybug.py +505 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"""Ingest OpenInference / OTLP spans into a :class:`RawTrace`.
|
|
2
|
+
|
|
3
|
+
Parses an exported OTLP trace document (the protobuf-JSON shape with
|
|
4
|
+
``resourceSpans[].scopeSpans[].spans[]`` emitted by the OpenTelemetry Collector or another
|
|
5
|
+
OTLP source) and reconstructs one trace's *raw* causal graph. One span
|
|
6
|
+
becomes one :class:`Step`; causal edges (``CAUSED_BY``, effect → cause) come from two
|
|
7
|
+
**declared** signals only — never from inferred sibling order:
|
|
8
|
+
|
|
9
|
+
* **One parent per span — the logical causal edge.** We use the OpenInference
|
|
10
|
+
``graph.node.parent_id`` (the *agent-graph* parent): a single span carrying that
|
|
11
|
+
``graph.node.id`` is trusted outright, and a loop (several do) resolves to the nearest
|
|
12
|
+
*preceding* execution. If ``graph.node.parent_id`` is declared but resolves to no single
|
|
13
|
+
in-trace span, we fall back to the structural ``parentSpanId`` — and raise if there is none
|
|
14
|
+
(see below). When no ``graph.node.parent_id`` is declared, ``parentSpanId`` is the parent.
|
|
15
|
+
Span-tree nesting is treated as structural **containment, not an independent cause** — we
|
|
16
|
+
deliberately do not record both, which would flag almost every span ``projection_lossy``
|
|
17
|
+
for mere hierarchy rather than real data-flow fan-in.
|
|
18
|
+
* **Span links — zero or more additional causes.** Each OTLP ``link`` that explicitly names
|
|
19
|
+
*this* trace and an in-trace span is a declared cause; a missing or foreign ``traceId`` is
|
|
20
|
+
never silently treated as local. We trust declared links regardless of timestamp (seq is
|
|
21
|
+
re-derived by topological order, and a genuine cycle raises). Links are where span traces
|
|
22
|
+
become a true multi-parent DAG — a span with a parent *and* a link is flagged
|
|
23
|
+
``projection_lossy`` because the single-parent tree view must drop one of those causes.
|
|
24
|
+
|
|
25
|
+
Honesty guards, mirrored from the LangGraph adapter:
|
|
26
|
+
|
|
27
|
+
* **Never fabricate a root.** A ``parentSpanId`` pointing outside the ingested trace means
|
|
28
|
+
a partial/corrupt export; we raise rather than silently reparent the span to a new root.
|
|
29
|
+
Likewise, a declared ``graph.node.parent_id`` that cannot be resolved and has no
|
|
30
|
+
``parentSpanId`` fallback raises — we never root a span whose declared parent we can't honor.
|
|
31
|
+
* **Never invent causality.** Sequentially-adjacent sibling spans are *not* linked — only
|
|
32
|
+
the declared parent and explicit links become edges, because only those are asserted by
|
|
33
|
+
the source data. Guessing "B ran after A so A caused B" is exactly the fabrication the
|
|
34
|
+
raw/derived contract exists to prevent.
|
|
35
|
+
|
|
36
|
+
Documented MVP assumptions:
|
|
37
|
+
|
|
38
|
+
* ``traceId``/``spanId`` are opaque string ids (OTLP/JSON hex encoding) — not decoded.
|
|
39
|
+
* ``seq`` is a **topological order** of the reconstructed causal DAG (every cause precedes
|
|
40
|
+
its effect), tie-broken by start time so it tracks real time wherever the DAG leaves the
|
|
41
|
+
order free. This holds even under clock skew — a child stamped *before* its parent keeps
|
|
42
|
+
its declared parent edge rather than being silently dropped into a fabricated root —
|
|
43
|
+
because we trust declared structure over timestamps. A genuine cycle in the declared
|
|
44
|
+
parent/link edges (contradictory causality) raises.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import heapq
|
|
50
|
+
import json
|
|
51
|
+
from datetime import datetime, timezone
|
|
52
|
+
from decimal import Decimal, InvalidOperation
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
import re
|
|
55
|
+
from typing import Any, Iterator
|
|
56
|
+
|
|
57
|
+
from tracegraph.input_validation import loads, object_field
|
|
58
|
+
|
|
59
|
+
from tracegraph.model import (
|
|
60
|
+
CausalFidelity,
|
|
61
|
+
DecisionEvidence,
|
|
62
|
+
Edge,
|
|
63
|
+
EdgeOrigin,
|
|
64
|
+
EdgeType,
|
|
65
|
+
EvaluationSummary,
|
|
66
|
+
RawTrace,
|
|
67
|
+
Step,
|
|
68
|
+
StepEvidence,
|
|
69
|
+
StepKind,
|
|
70
|
+
StepSource,
|
|
71
|
+
StepStatus,
|
|
72
|
+
Trace,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_SPAN_KIND_ATTR = "openinference.span.kind"
|
|
76
|
+
_GRAPH_NODE_ID = "graph.node.id"
|
|
77
|
+
_GRAPH_NODE_PARENT_ID = "graph.node.parent_id"
|
|
78
|
+
_SYNCMILL_RUN_ID = "syncmill.run_id"
|
|
79
|
+
_TOOLGRAPH_PREFLIGHT_DIGEST = "toolgraph.preflight.artifact_digest"
|
|
80
|
+
_TOOLGRAPH_GRAPH_GENERATION = "toolgraph.graph_generation"
|
|
81
|
+
_TOOLGRAPH_PREFLIGHT_VERDICT = "toolgraph.preflight.verdict"
|
|
82
|
+
|
|
83
|
+
#: OpenInference span kinds that map cleanly onto a :class:`StepKind`. Anything else
|
|
84
|
+
#: (EMBEDDING, RERANKER, GUARDRAIL, EVALUATOR, or absent) defaults to ``CHAIN`` — see
|
|
85
|
+
#: the ``StepKind`` docstring: spans that can't be classified more precisely are chains.
|
|
86
|
+
_KIND_MAP = {
|
|
87
|
+
"AGENT": StepKind.AGENT,
|
|
88
|
+
"CHAIN": StepKind.CHAIN,
|
|
89
|
+
"TOOL": StepKind.TOOL,
|
|
90
|
+
"LLM": StepKind.LLM,
|
|
91
|
+
"RETRIEVER": StepKind.RETRIEVER,
|
|
92
|
+
"EMBEDDING": StepKind.EMBEDDING,
|
|
93
|
+
"RERANKER": StepKind.RERANKER,
|
|
94
|
+
"GUARDRAIL": StepKind.GUARDRAIL,
|
|
95
|
+
"EVALUATOR": StepKind.EVALUATOR,
|
|
96
|
+
"PROMPT": StepKind.PROMPT,
|
|
97
|
+
"UNKNOWN": StepKind.UNKNOWN,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_ERROR_CODES = {2, "STATUS_CODE_ERROR", "ERROR"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _first(d: dict, *names: str, default: Any = None) -> Any:
|
|
104
|
+
"""Return the first present, non-None key from ``names`` (camelCase/snake_case tolerant)."""
|
|
105
|
+
for n in names:
|
|
106
|
+
if d.get(n) is not None:
|
|
107
|
+
return d[n]
|
|
108
|
+
return default
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _attr_value(v: Any) -> Any:
|
|
112
|
+
"""Unwrap an OTLP ``AnyValue`` (``{"stringValue": ...}``) to a Python scalar."""
|
|
113
|
+
if not isinstance(v, dict):
|
|
114
|
+
return v
|
|
115
|
+
for key in ("stringValue", "string_value"):
|
|
116
|
+
if key in v:
|
|
117
|
+
return v[key]
|
|
118
|
+
for key in ("intValue", "int_value"):
|
|
119
|
+
if key in v: # protobuf-JSON encodes int64 as a string
|
|
120
|
+
try:
|
|
121
|
+
return int(v[key])
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
return v[key]
|
|
124
|
+
for key in ("boolValue", "bool_value", "doubleValue", "double_value"):
|
|
125
|
+
if key in v:
|
|
126
|
+
return v[key]
|
|
127
|
+
return v # array/kvlist values: hand back as-is (not needed by the causal model)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _attributes(span: dict) -> dict[str, Any]:
|
|
131
|
+
raw = span.get("attributes")
|
|
132
|
+
if raw is None:
|
|
133
|
+
raw = []
|
|
134
|
+
if isinstance(raw, dict):
|
|
135
|
+
return dict(raw)
|
|
136
|
+
if not isinstance(raw, list):
|
|
137
|
+
raise ValueError("attributes must be an object or array")
|
|
138
|
+
out: dict[str, Any] = {}
|
|
139
|
+
for a in raw:
|
|
140
|
+
if not isinstance(a, dict):
|
|
141
|
+
raise ValueError("attributes[] must be an object")
|
|
142
|
+
key = a.get("key")
|
|
143
|
+
if key is not None and not isinstance(key, str):
|
|
144
|
+
raise ValueError("attributes[].key must be a string")
|
|
145
|
+
if key is not None:
|
|
146
|
+
out[key] = _attr_value(a.get("value"))
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _iter_spans(document: dict) -> Iterator[dict]:
|
|
151
|
+
# Malformed nesting raises a clean ValueError rather than an AttributeError deep in
|
|
152
|
+
# a caller — the CLI's load boundary only translates OSError/ValueError/KeyError.
|
|
153
|
+
for rs in _span_array(document, "resourceSpans", "resource_spans"):
|
|
154
|
+
if not isinstance(rs, dict):
|
|
155
|
+
raise ValueError("OTLP resourceSpans entries must be objects")
|
|
156
|
+
for ss in _span_array(rs, "scopeSpans", "scope_spans"):
|
|
157
|
+
if not isinstance(ss, dict):
|
|
158
|
+
raise ValueError("OTLP scopeSpans entries must be objects")
|
|
159
|
+
for span in _span_array(ss, "spans"):
|
|
160
|
+
if not isinstance(span, dict):
|
|
161
|
+
raise ValueError("OTLP spans entries must be objects")
|
|
162
|
+
yield span
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _span_array(container: dict, *keys: str) -> list:
|
|
166
|
+
"""Fetch a spans-shaped array field, treating only missing/None as empty.
|
|
167
|
+
|
|
168
|
+
Any other non-array value (including falsey ones like ``{}`` or ``0``) is malformed
|
|
169
|
+
input and raises — an ``or []`` would silently swallow it as "no spans".
|
|
170
|
+
"""
|
|
171
|
+
value = _first(container, *keys, default=None)
|
|
172
|
+
if value is None:
|
|
173
|
+
return []
|
|
174
|
+
if not isinstance(value, list):
|
|
175
|
+
raise ValueError(f"OTLP {keys[0]} must be an array")
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _exception_message(span: dict) -> str | None:
|
|
180
|
+
for ev in _span_array(span, "events"):
|
|
181
|
+
if not isinstance(ev, dict):
|
|
182
|
+
raise ValueError("events[] must be an object")
|
|
183
|
+
attrs = _attributes(ev)
|
|
184
|
+
if ev.get("name") == "exception":
|
|
185
|
+
msg = attrs.get("exception.message") or attrs.get("exception.type")
|
|
186
|
+
if msg:
|
|
187
|
+
return str(msg)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _status(span: dict) -> tuple[StepStatus, str | None]:
|
|
192
|
+
st = object_field(span.get("status"), "status")
|
|
193
|
+
code = st.get("code")
|
|
194
|
+
exception = _exception_message(span)
|
|
195
|
+
if code in (1, "STATUS_CODE_OK", "OK"):
|
|
196
|
+
return StepStatus.OK, None
|
|
197
|
+
if code in (2, "STATUS_CODE_ERROR", "ERROR"):
|
|
198
|
+
return StepStatus.ERROR, st.get("message") or exception or "error"
|
|
199
|
+
if code not in (0, "STATUS_CODE_UNSET", "UNSET", None):
|
|
200
|
+
raise ValueError("status.code must be OK, ERROR, or UNSET")
|
|
201
|
+
if exception is not None:
|
|
202
|
+
return StepStatus.ERROR, exception
|
|
203
|
+
return StepStatus.UNSET, None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _iso_ts(start_nano: Any) -> str | None:
|
|
207
|
+
try:
|
|
208
|
+
nanos = int(start_nano)
|
|
209
|
+
except (TypeError, ValueError):
|
|
210
|
+
return None
|
|
211
|
+
return datetime.fromtimestamp(nanos / 1e9, tz=timezone.utc).isoformat()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _int_attr(attrs: dict[str, Any], *names: str) -> int | None:
|
|
215
|
+
for name in names:
|
|
216
|
+
value = attrs.get(name)
|
|
217
|
+
if value is None:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
parsed = int(value)
|
|
221
|
+
except (TypeError, ValueError):
|
|
222
|
+
continue
|
|
223
|
+
if parsed < 0 or (isinstance(value, float) and not value.is_integer()):
|
|
224
|
+
continue
|
|
225
|
+
return parsed
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _decimal_attr(attrs: dict[str, Any], *names: str) -> str | None:
|
|
230
|
+
for name in names:
|
|
231
|
+
value = attrs.get(name)
|
|
232
|
+
if value is None or not isinstance(value, (int, float, str)):
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
parsed = Decimal(str(value))
|
|
236
|
+
except InvalidOperation:
|
|
237
|
+
continue
|
|
238
|
+
if parsed.is_finite() and parsed >= 0:
|
|
239
|
+
return format(parsed, "f")
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _currency_attr(attrs: dict[str, Any]) -> str | None:
|
|
244
|
+
value = attrs.get("llm.cost.currency")
|
|
245
|
+
if not isinstance(value, str):
|
|
246
|
+
return None
|
|
247
|
+
normalized = value.upper()
|
|
248
|
+
return normalized if re.fullmatch(r"[A-Z]{3,8}", normalized) else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _digest_attr(attrs: dict[str, Any], name: str) -> str | None:
|
|
252
|
+
value = attrs.get(name)
|
|
253
|
+
if value is None:
|
|
254
|
+
return None
|
|
255
|
+
if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value):
|
|
256
|
+
raise ValueError(f"{name} must be a lowercase sha256 digest")
|
|
257
|
+
return value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _evidence(span: dict, attrs: dict[str, Any]) -> StepEvidence | None:
|
|
261
|
+
start = _first(span, "startTimeUnixNano", "start_time_unix_nano")
|
|
262
|
+
end = _first(span, "endTimeUnixNano", "end_time_unix_nano")
|
|
263
|
+
duration_ms: float | None = None
|
|
264
|
+
try:
|
|
265
|
+
if start is not None and end is not None:
|
|
266
|
+
duration_ms = round((int(end) - int(start)) / 1_000_000, 6)
|
|
267
|
+
except (TypeError, ValueError):
|
|
268
|
+
duration_ms = None
|
|
269
|
+
|
|
270
|
+
evaluations = [
|
|
271
|
+
EvaluationSummary.model_validate(item)
|
|
272
|
+
for item in span.get("_tracegraph_evaluations", [])
|
|
273
|
+
if isinstance(item, dict) and item.get("name")
|
|
274
|
+
]
|
|
275
|
+
evaluations.sort(key=lambda item: (item.name, item.label or "", item.score or 0.0))
|
|
276
|
+
evidence = StepEvidence(
|
|
277
|
+
end_ts=_iso_ts(end),
|
|
278
|
+
duration_ms=duration_ms,
|
|
279
|
+
prompt_tokens=_int_attr(attrs, "llm.token_count.prompt", "llm.token_count.input"),
|
|
280
|
+
completion_tokens=_int_attr(
|
|
281
|
+
attrs, "llm.token_count.completion", "llm.token_count.output"
|
|
282
|
+
),
|
|
283
|
+
total_tokens=_int_attr(attrs, "llm.token_count.total"),
|
|
284
|
+
prompt_cost=_decimal_attr(attrs, "llm.cost.prompt", "llm.cost.input"),
|
|
285
|
+
completion_cost=_decimal_attr(attrs, "llm.cost.completion", "llm.cost.output"),
|
|
286
|
+
total_cost=_decimal_attr(attrs, "llm.cost.total"),
|
|
287
|
+
cost_currency=_currency_attr(attrs),
|
|
288
|
+
artifact_digest=_digest_attr(attrs, "syncmill.artifact_digest"),
|
|
289
|
+
evaluations=evaluations,
|
|
290
|
+
)
|
|
291
|
+
scalar_values = evidence.model_dump(exclude={"evaluations"}).values()
|
|
292
|
+
return evidence if evaluations or any(value is not None for value in scalar_values) else None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class OTLPSpanAdapter:
|
|
296
|
+
"""Reconstruct one trace's raw causal graph from an OTLP/OpenInference span export."""
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
document: dict,
|
|
301
|
+
*,
|
|
302
|
+
source_kind: str = "otlp",
|
|
303
|
+
prefer_graph_parent: bool = True,
|
|
304
|
+
links_as_causes: bool = True,
|
|
305
|
+
include_error_messages: bool = True,
|
|
306
|
+
preserve_unset_status: bool = False,
|
|
307
|
+
causal_fidelity: CausalFidelity | None = None,
|
|
308
|
+
links_preserved: bool | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
self._doc = document
|
|
311
|
+
self._source_kind = source_kind
|
|
312
|
+
#: Prefer ``graph.node.parent_id`` (logical agent graph) over ``parentSpanId``.
|
|
313
|
+
self._prefer_graph_parent = prefer_graph_parent
|
|
314
|
+
#: Treat OTLP span links as additional causes (the multi-parent fan-in source).
|
|
315
|
+
self._links_as_causes = links_as_causes
|
|
316
|
+
self._include_error_messages = include_error_messages
|
|
317
|
+
self._preserve_unset_status = preserve_unset_status
|
|
318
|
+
self._causal_fidelity = causal_fidelity or (
|
|
319
|
+
CausalFidelity.DECLARED_DAG if links_as_causes else CausalFidelity.PARENT_ONLY
|
|
320
|
+
)
|
|
321
|
+
self._links_preserved = links_as_causes if links_preserved is None else links_preserved
|
|
322
|
+
self._spans_by_trace: dict[str, list[dict]] | None = None
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def from_json(cls, text: str, **kwargs: Any) -> "OTLPSpanAdapter":
|
|
326
|
+
try:
|
|
327
|
+
payload = loads(text)
|
|
328
|
+
documents = payload if isinstance(payload, list) else [payload]
|
|
329
|
+
except json.JSONDecodeError:
|
|
330
|
+
# The Collector file exporter writes one top-level TracesData object per line.
|
|
331
|
+
documents = [loads(line) for line in text.splitlines() if line.strip()]
|
|
332
|
+
if not documents or not all(isinstance(item, dict) for item in documents):
|
|
333
|
+
raise ValueError("OTLP input must contain one or more JSON trace objects")
|
|
334
|
+
merged: dict[str, Any] = {"resourceSpans": []}
|
|
335
|
+
for document in documents:
|
|
336
|
+
# _span_array validates the container BEFORE merging, so e.g.
|
|
337
|
+
# {"resourceSpans": 1} is a clean ValueError, not a TypeError from extend().
|
|
338
|
+
merged["resourceSpans"].extend(
|
|
339
|
+
_span_array(document, "resourceSpans", "resource_spans")
|
|
340
|
+
)
|
|
341
|
+
return cls(merged, **kwargs)
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def from_file(cls, path: str | Path, **kwargs: Any) -> "OTLPSpanAdapter":
|
|
345
|
+
return cls.from_json(Path(path).read_text(encoding="utf-8"), **kwargs)
|
|
346
|
+
|
|
347
|
+
# --- TraceAdapter ---
|
|
348
|
+
|
|
349
|
+
def discover(self) -> list[str]:
|
|
350
|
+
"""Distinct ``traceId``s present in the document, in first-seen order."""
|
|
351
|
+
return list(self._grouped())
|
|
352
|
+
|
|
353
|
+
def ingest(self, trace_id: str) -> RawTrace:
|
|
354
|
+
by_id = self._spans_of(trace_id)
|
|
355
|
+
parent_span = self._structural_parents(trace_id, by_id)
|
|
356
|
+
depth = self._depths(parent_span)
|
|
357
|
+
attrs = {sid: _attributes(by_id[sid]) for sid in by_id}
|
|
358
|
+
run_id = self._run_id(trace_id, attrs)
|
|
359
|
+
|
|
360
|
+
# prerank is a deterministic *temporal* pre-order (real start time, then span-tree
|
|
361
|
+
# depth and spanId only as tiebreaks). It is used solely to decide which spans
|
|
362
|
+
# count as "preceding" when resolving a logical parent or a link cause — NOT as the
|
|
363
|
+
# final seq, because clock skew could otherwise order a real cause after its effect.
|
|
364
|
+
prerank = {
|
|
365
|
+
sid: i
|
|
366
|
+
for i, sid in enumerate(
|
|
367
|
+
sorted(by_id, key=lambda s: (self._start_nano(by_id[s]), depth[s], s))
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# Reconstruct the raw causes (effect -> [causes]) from declared signals only, then
|
|
372
|
+
# assign seq as a topological order of that DAG so a cause always precedes its
|
|
373
|
+
# effect by construction (validate_raw can never reject our own ordering). A genuine
|
|
374
|
+
# cycle in the declared parent/link edges raises rather than silently lying.
|
|
375
|
+
causes, origins = self._causes(by_id, attrs, prerank, parent_span)
|
|
376
|
+
seq = self._topo_seq(by_id, causes, prerank)
|
|
377
|
+
|
|
378
|
+
steps: list[Step] = []
|
|
379
|
+
for sid in by_id:
|
|
380
|
+
span = by_id[sid]
|
|
381
|
+
status, err = _status(span)
|
|
382
|
+
if status is StepStatus.UNSET and not self._preserve_unset_status:
|
|
383
|
+
status = StepStatus.OK
|
|
384
|
+
steps.append(
|
|
385
|
+
Step(
|
|
386
|
+
step_id=sid,
|
|
387
|
+
trace_id=trace_id,
|
|
388
|
+
seq=seq[sid],
|
|
389
|
+
ts=_iso_ts(_first(span, "startTimeUnixNano", "start_time_unix_nano")),
|
|
390
|
+
kind=_KIND_MAP.get(str(attrs[sid].get(_SPAN_KIND_ATTR, "")).upper(), StepKind.CHAIN),
|
|
391
|
+
source=StepSource.LOOP, # spans carry no LangGraph source semantics
|
|
392
|
+
name=span.get("name"),
|
|
393
|
+
status=status,
|
|
394
|
+
error_msg=err if self._include_error_messages else None,
|
|
395
|
+
evidence=_evidence(span, attrs[sid]),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
steps.sort(key=lambda s: s.seq)
|
|
399
|
+
|
|
400
|
+
edges = [
|
|
401
|
+
Edge(
|
|
402
|
+
type=EdgeType.CAUSED_BY,
|
|
403
|
+
src=effect,
|
|
404
|
+
dst=cause,
|
|
405
|
+
origin=origins[(effect, cause)],
|
|
406
|
+
)
|
|
407
|
+
for effect in sorted(causes, key=lambda s: prerank[s])
|
|
408
|
+
for cause in sorted(causes[effect], key=lambda c: prerank[c])
|
|
409
|
+
]
|
|
410
|
+
any_error = any(s.status is StepStatus.ERROR for s in steps)
|
|
411
|
+
trace = Trace(
|
|
412
|
+
trace_id=trace_id,
|
|
413
|
+
source_kind=self._source_kind,
|
|
414
|
+
run_id=run_id,
|
|
415
|
+
thread_id=trace_id,
|
|
416
|
+
status=StepStatus.ERROR if any_error else StepStatus.OK,
|
|
417
|
+
causal_fidelity=self._causal_fidelity,
|
|
418
|
+
links_preserved=self._links_preserved,
|
|
419
|
+
decision_evidence=self._decision_evidence(trace_id, attrs),
|
|
420
|
+
)
|
|
421
|
+
return RawTrace(trace=trace, steps=steps, causal_edges=edges)
|
|
422
|
+
|
|
423
|
+
# --- helpers ---
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _run_id(trace_id: str, attrs: dict[str, dict[str, Any]]) -> str | None:
|
|
427
|
+
"""Return one consistent optional SyncMill run id for the trace.
|
|
428
|
+
|
|
429
|
+
``run_id`` is common correlation metadata, not a causal signal. We retain only this
|
|
430
|
+
allowlisted value and continue to discard all other vendor attributes. A partially
|
|
431
|
+
annotated trace is acceptable, but two different declared values are not.
|
|
432
|
+
"""
|
|
433
|
+
values: set[str] = set()
|
|
434
|
+
for span_attrs in attrs.values():
|
|
435
|
+
if _SYNCMILL_RUN_ID not in span_attrs:
|
|
436
|
+
continue
|
|
437
|
+
value = span_attrs[_SYNCMILL_RUN_ID]
|
|
438
|
+
if (
|
|
439
|
+
not isinstance(value, str)
|
|
440
|
+
or not value
|
|
441
|
+
or any(ch.isspace() for ch in value)
|
|
442
|
+
):
|
|
443
|
+
raise ValueError(
|
|
444
|
+
f"trace {trace_id!r} has an empty, whitespace-containing, or non-string "
|
|
445
|
+
"syncmill.run_id"
|
|
446
|
+
)
|
|
447
|
+
values.add(value)
|
|
448
|
+
if len(values) > 1:
|
|
449
|
+
raise ValueError(f"trace {trace_id!r} has conflicting syncmill.run_id values")
|
|
450
|
+
return next(iter(values), None)
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _decision_evidence(
|
|
454
|
+
trace_id: str, attrs: dict[str, dict[str, Any]]
|
|
455
|
+
) -> list[DecisionEvidence]:
|
|
456
|
+
"""Read one consistent, body-free Toolgraph preflight reference."""
|
|
457
|
+
records: set[tuple[str, int, str]] = set()
|
|
458
|
+
partial = False
|
|
459
|
+
for span_attrs in attrs.values():
|
|
460
|
+
has_digest = _TOOLGRAPH_PREFLIGHT_DIGEST in span_attrs
|
|
461
|
+
has_generation = _TOOLGRAPH_GRAPH_GENERATION in span_attrs
|
|
462
|
+
has_verdict = _TOOLGRAPH_PREFLIGHT_VERDICT in span_attrs
|
|
463
|
+
if not has_digest and not has_generation and not has_verdict:
|
|
464
|
+
continue
|
|
465
|
+
if not (has_digest and has_generation and has_verdict):
|
|
466
|
+
partial = True
|
|
467
|
+
continue
|
|
468
|
+
digest = span_attrs[_TOOLGRAPH_PREFLIGHT_DIGEST]
|
|
469
|
+
generation = span_attrs[_TOOLGRAPH_GRAPH_GENERATION]
|
|
470
|
+
verdict = span_attrs[_TOOLGRAPH_PREFLIGHT_VERDICT]
|
|
471
|
+
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
|
|
472
|
+
raise ValueError(f"trace {trace_id!r} has an invalid Toolgraph preflight digest")
|
|
473
|
+
if isinstance(generation, bool) or not isinstance(generation, int) or generation < 0:
|
|
474
|
+
raise ValueError(f"trace {trace_id!r} has an invalid Toolgraph graph generation")
|
|
475
|
+
if not isinstance(verdict, str) or not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", verdict):
|
|
476
|
+
raise ValueError(f"trace {trace_id!r} has an invalid Toolgraph preflight verdict")
|
|
477
|
+
records.add((digest, generation, verdict))
|
|
478
|
+
if partial:
|
|
479
|
+
raise ValueError(
|
|
480
|
+
f"trace {trace_id!r} must declare Toolgraph preflight digest, graph generation, and verdict together"
|
|
481
|
+
)
|
|
482
|
+
if len(records) > 1:
|
|
483
|
+
raise ValueError(f"trace {trace_id!r} has conflicting Toolgraph preflight evidence")
|
|
484
|
+
return [
|
|
485
|
+
DecisionEvidence(
|
|
486
|
+
artifact_digest=digest,
|
|
487
|
+
graph_generation=generation,
|
|
488
|
+
verdict=verdict,
|
|
489
|
+
)
|
|
490
|
+
for digest, generation, verdict in sorted(records)
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def _start_nano(span: dict) -> int:
|
|
495
|
+
try:
|
|
496
|
+
return int(_first(span, "startTimeUnixNano", "start_time_unix_nano"))
|
|
497
|
+
except (TypeError, ValueError):
|
|
498
|
+
return 0
|
|
499
|
+
|
|
500
|
+
def _grouped(self) -> dict[str, list[dict]]:
|
|
501
|
+
"""Spans grouped by ``traceId`` in first-seen order, built once per adapter.
|
|
502
|
+
|
|
503
|
+
Grouping only — per-trace validation (missing/duplicate spanId) stays in
|
|
504
|
+
:meth:`_spans_of` so a malformed trace fails when *it* is ingested, not when a
|
|
505
|
+
sibling trace in the same document is.
|
|
506
|
+
"""
|
|
507
|
+
if self._spans_by_trace is None:
|
|
508
|
+
grouped: dict[str, list[dict]] = {}
|
|
509
|
+
for span in _iter_spans(self._doc):
|
|
510
|
+
tid = _first(span, "traceId", "trace_id")
|
|
511
|
+
if tid is None:
|
|
512
|
+
# Skipping it would drop the span from every trace with no error, and
|
|
513
|
+
# its children would then fail with a misleading "parent not in trace"
|
|
514
|
+
# message pointing at the wrong span. A missing spanId already raises;
|
|
515
|
+
# a missing traceId is the same class of corrupt export.
|
|
516
|
+
sid = _first(span, "spanId", "span_id")
|
|
517
|
+
raise ValueError(
|
|
518
|
+
f"span {sid!r} has no traceId; the export is partial or corrupt"
|
|
519
|
+
if sid is not None
|
|
520
|
+
else "a span has neither traceId nor spanId; the export is corrupt"
|
|
521
|
+
)
|
|
522
|
+
grouped.setdefault(tid, []).append(span)
|
|
523
|
+
self._spans_by_trace = grouped
|
|
524
|
+
return self._spans_by_trace
|
|
525
|
+
|
|
526
|
+
def _spans_of(self, trace_id: str) -> dict[str, dict]:
|
|
527
|
+
by_id: dict[str, dict] = {}
|
|
528
|
+
for span in self._grouped().get(trace_id, []):
|
|
529
|
+
sid = _first(span, "spanId", "span_id")
|
|
530
|
+
if sid is None:
|
|
531
|
+
raise ValueError(f"trace {trace_id!r} has a span with no spanId")
|
|
532
|
+
if sid in by_id:
|
|
533
|
+
raise ValueError(f"duplicate spanId {sid!r} in trace {trace_id!r}")
|
|
534
|
+
by_id[sid] = span
|
|
535
|
+
if not by_id:
|
|
536
|
+
raise KeyError(f"no spans for trace_id {trace_id!r}")
|
|
537
|
+
return by_id
|
|
538
|
+
|
|
539
|
+
@staticmethod
|
|
540
|
+
def _structural_parents(trace_id: str, by_id: dict[str, dict]) -> dict[str, str | None]:
|
|
541
|
+
"""``spanId -> parentSpanId`` (None for roots), refusing dangling parents."""
|
|
542
|
+
parents: dict[str, str | None] = {}
|
|
543
|
+
for sid, span in by_id.items():
|
|
544
|
+
pid = _first(span, "parentSpanId", "parent_span_id") or None
|
|
545
|
+
if pid is not None and pid not in by_id:
|
|
546
|
+
raise ValueError(
|
|
547
|
+
f"span {sid!r} has parentSpanId {pid!r} not in trace {trace_id!r}; the "
|
|
548
|
+
"export is partial/corrupt — tracegraph will not fabricate a root for it"
|
|
549
|
+
)
|
|
550
|
+
parents[sid] = pid
|
|
551
|
+
return parents
|
|
552
|
+
|
|
553
|
+
@staticmethod
|
|
554
|
+
def _depths(parent_span: dict[str, str | None]) -> dict[str, int]:
|
|
555
|
+
# Walk each span's parent chain only until a span with a known depth, then unwind —
|
|
556
|
+
# every span's depth is computed exactly once (O(n) overall, not O(n·depth)).
|
|
557
|
+
depth: dict[str, int] = {}
|
|
558
|
+
for sid in parent_span:
|
|
559
|
+
chain: list[str] = []
|
|
560
|
+
on_chain: set[str] = set()
|
|
561
|
+
cur: str | None = sid
|
|
562
|
+
while cur is not None and cur not in depth:
|
|
563
|
+
if cur in on_chain:
|
|
564
|
+
raise ValueError(f"cycle in parentSpanId chain at span {cur!r}")
|
|
565
|
+
chain.append(cur)
|
|
566
|
+
on_chain.add(cur)
|
|
567
|
+
cur = parent_span[cur]
|
|
568
|
+
base = 0 if cur is None else depth[cur] + 1
|
|
569
|
+
for offset, node in enumerate(reversed(chain)):
|
|
570
|
+
depth[node] = base + offset
|
|
571
|
+
return depth
|
|
572
|
+
|
|
573
|
+
def _causes(
|
|
574
|
+
self,
|
|
575
|
+
by_id: dict[str, dict],
|
|
576
|
+
attrs: dict[str, dict],
|
|
577
|
+
prerank: dict[str, int],
|
|
578
|
+
parent_span: dict[str, str | None],
|
|
579
|
+
) -> tuple[dict[str, list[str]], dict[tuple[str, str], EdgeOrigin]]:
|
|
580
|
+
"""Reconstruct ``effect -> [causes]`` from declared signals: one parent + links."""
|
|
581
|
+
node_index: dict[str, list[str]] = {}
|
|
582
|
+
if self._prefer_graph_parent:
|
|
583
|
+
for sid in sorted(by_id, key=lambda s: prerank[s]): # prerank order
|
|
584
|
+
nid = attrs[sid].get(_GRAPH_NODE_ID)
|
|
585
|
+
if nid is not None:
|
|
586
|
+
node_index.setdefault(str(nid), []).append(sid)
|
|
587
|
+
|
|
588
|
+
causes: dict[str, list[str]] = {sid: [] for sid in by_id}
|
|
589
|
+
seen: set[tuple[str, str]] = set()
|
|
590
|
+
origins: dict[tuple[str, str], EdgeOrigin] = {}
|
|
591
|
+
|
|
592
|
+
def add(effect: str, cause: str, origin: EdgeOrigin) -> None:
|
|
593
|
+
if cause == effect or (effect, cause) in seen:
|
|
594
|
+
return # self-edges and duplicates would both break validate_raw
|
|
595
|
+
seen.add((effect, cause))
|
|
596
|
+
causes[effect].append(cause)
|
|
597
|
+
origins[(effect, cause)] = origin
|
|
598
|
+
|
|
599
|
+
for sid in by_id:
|
|
600
|
+
parent, parent_origin = self._resolve_parent(
|
|
601
|
+
sid, attrs, node_index, prerank, parent_span
|
|
602
|
+
)
|
|
603
|
+
if parent is not None:
|
|
604
|
+
add(sid, parent, parent_origin)
|
|
605
|
+
if self._links_as_causes:
|
|
606
|
+
own_trace = _first(by_id[sid], "traceId", "trace_id")
|
|
607
|
+
for link in _span_array(by_id[sid], "links"):
|
|
608
|
+
# A link is a declared cause only if it explicitly names THIS trace and an
|
|
609
|
+
# in-trace span. A missing/foreign traceId is NOT silently treated as local
|
|
610
|
+
# (that would fabricate causality). Declared links are trusted regardless of
|
|
611
|
+
# timestamp — topo ordering sequences them and a real cycle raises.
|
|
612
|
+
if not isinstance(link, dict):
|
|
613
|
+
raise ValueError("OTLP links entries must be objects")
|
|
614
|
+
link_trace = _first(link, "traceId", "trace_id")
|
|
615
|
+
lsid = _first(link, "spanId", "span_id")
|
|
616
|
+
if any(value is not None and not isinstance(value, str) for value in (link_trace, lsid)):
|
|
617
|
+
raise ValueError("links[].traceId and spanId must be strings")
|
|
618
|
+
if link_trace != own_trace:
|
|
619
|
+
continue
|
|
620
|
+
lsid = _first(link, "spanId", "span_id")
|
|
621
|
+
if lsid in by_id:
|
|
622
|
+
add(sid, lsid, EdgeOrigin.SPAN_LINK)
|
|
623
|
+
return causes, origins
|
|
624
|
+
|
|
625
|
+
def _resolve_parent(
|
|
626
|
+
self,
|
|
627
|
+
sid: str,
|
|
628
|
+
attrs: dict[str, dict],
|
|
629
|
+
node_index: dict[str, list[str]],
|
|
630
|
+
prerank: dict[str, int],
|
|
631
|
+
parent_span: dict[str, str | None],
|
|
632
|
+
) -> tuple[str | None, EdgeOrigin]:
|
|
633
|
+
"""The single chosen parent cause: the logical graph parent if resolvable, else the
|
|
634
|
+
declared span parent.
|
|
635
|
+
|
|
636
|
+
Resolving ``graph.node.parent_id`` to a span never gates on the clock (which would
|
|
637
|
+
silently drop a real parent under skew or a timestamp tie):
|
|
638
|
+
|
|
639
|
+
* a single span carries that ``graph.node.id`` → trust it outright (unambiguous);
|
|
640
|
+
* several do (a loop) → pick the nearest *preceding* execution.
|
|
641
|
+
|
|
642
|
+
If ``graph.node.parent_id`` is declared but resolves to no in-trace span, or is
|
|
643
|
+
ambiguous (several candidates, none preceding), we fall back to the structural
|
|
644
|
+
``parentSpanId`` — and if there is no ``parentSpanId`` either, we **raise** rather than
|
|
645
|
+
fabricate a root for a declared parent we cannot honor (mirroring the dangling-parent
|
|
646
|
+
guard). When no ``graph.node.parent_id`` is declared, ``parentSpanId`` is the parent;
|
|
647
|
+
``None`` only when the span truly has no declared parent of either kind.
|
|
648
|
+
"""
|
|
649
|
+
pid = parent_span.get(sid)
|
|
650
|
+
if self._prefer_graph_parent:
|
|
651
|
+
pnode = attrs[sid].get(_GRAPH_NODE_PARENT_ID)
|
|
652
|
+
if pnode is not None:
|
|
653
|
+
candidates = [c for c in node_index.get(str(pnode), []) if c != sid]
|
|
654
|
+
preceding = [c for c in candidates if prerank[c] < prerank[sid]]
|
|
655
|
+
if preceding:
|
|
656
|
+
return max(preceding, key=lambda c: prerank[c]), EdgeOrigin.GRAPH_PARENT
|
|
657
|
+
if len(candidates) == 1:
|
|
658
|
+
return candidates[0], EdgeOrigin.GRAPH_PARENT
|
|
659
|
+
if pid is None:
|
|
660
|
+
detail = "no in-trace span" if not candidates else "multiple ambiguous spans"
|
|
661
|
+
raise ValueError(
|
|
662
|
+
f"span {sid!r} declares graph.node.parent_id={pnode!r} that resolves to "
|
|
663
|
+
f"{detail} and has no parentSpanId fallback; tracegraph will not "
|
|
664
|
+
"fabricate a root for a declared logical parent it cannot honor"
|
|
665
|
+
)
|
|
666
|
+
return pid, EdgeOrigin.SPAN_PARENT_FALLBACK
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def _topo_seq(
|
|
670
|
+
by_id: dict[str, dict],
|
|
671
|
+
causes: dict[str, list[str]],
|
|
672
|
+
prerank: dict[str, int],
|
|
673
|
+
) -> dict[str, int]:
|
|
674
|
+
"""seq as a topological order of the causal DAG: every cause is numbered before its
|
|
675
|
+
effects. Ties break by ``prerank`` so the order tracks real time where the DAG
|
|
676
|
+
leaves it free. A node that never reaches in-degree 0 means the declared parent/link
|
|
677
|
+
edges form a causal cycle (contradictory causality) — so we raise instead of lying.
|
|
678
|
+
"""
|
|
679
|
+
effects_of: dict[str, list[str]] = {sid: [] for sid in by_id}
|
|
680
|
+
indegree: dict[str, int] = dict.fromkeys(by_id, 0)
|
|
681
|
+
for effect, cs in causes.items():
|
|
682
|
+
for cause in cs:
|
|
683
|
+
effects_of[cause].append(effect)
|
|
684
|
+
indegree[effect] += 1
|
|
685
|
+
|
|
686
|
+
ready = [(prerank[sid], sid) for sid in by_id if indegree[sid] == 0]
|
|
687
|
+
heapq.heapify(ready)
|
|
688
|
+
order: list[str] = []
|
|
689
|
+
while ready:
|
|
690
|
+
_, sid = heapq.heappop(ready)
|
|
691
|
+
order.append(sid)
|
|
692
|
+
for effect in effects_of[sid]:
|
|
693
|
+
indegree[effect] -= 1
|
|
694
|
+
if indegree[effect] == 0:
|
|
695
|
+
heapq.heappush(ready, (prerank[effect], effect))
|
|
696
|
+
|
|
697
|
+
if len(order) != len(by_id):
|
|
698
|
+
stuck = sorted(s for s in by_id if indegree[s] > 0)
|
|
699
|
+
raise ValueError(
|
|
700
|
+
f"causal cycle among spans {stuck[:5]} in trace; the declared parent/link "
|
|
701
|
+
"edges are not acyclic (a cause cannot also be a descendant of its effect)"
|
|
702
|
+
)
|
|
703
|
+
return {sid: i for i, sid in enumerate(order)}
|