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.
@@ -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)}