agentloop-profiler 0.1.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.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
@@ -0,0 +1,775 @@
1
+ """Bounded OTLP JSON/JSONL ingestion using AgentLoop's existing OTel parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from hashlib import sha256
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from agentloop.interoperability.artifacts import native_bytes, write_import_bundle
13
+ from agentloop.interoperability.contracts import (
14
+ IDENTITY_FIELDS,
15
+ ImportReceipt,
16
+ external_id,
17
+ receipt_id,
18
+ summarize_receipts,
19
+ )
20
+ from agentloop.interoperability.evidence import EXTERNAL_KEY
21
+ from agentloop.interoperability.otlp_validation import fail, flatten
22
+ from agentloop.interoperability.validation import (
23
+ ImportBudget,
24
+ ImportLimits,
25
+ ImportValidationError,
26
+ load_json_artifact,
27
+ parse_json_bytes,
28
+ safe_artifact_path,
29
+ )
30
+ from agentloop.interventions import canonical_json
31
+ from agentloop.otel import traces_from_otel
32
+ from agentloop.otel_semantics import INPUT_USAGE, OUTPUT_USAGE
33
+ from agentloop.schema import TraceValidationError
34
+ from agentloop.tracer import AgentTrace
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class OtlpOptions:
39
+ system: str = "otel"
40
+ producer_version: str | None = None
41
+ producer_revision: str | None = None
42
+ identity: dict[str, str] = field(default_factory=dict)
43
+ continue_on_error: bool = True
44
+ capture_content: bool = False
45
+ synthetic_fixture: bool = False
46
+
47
+ def __post_init__(self) -> None:
48
+ if not isinstance(self.system, str) or self.system not in {"otel", "harbor", "omnigent"}:
49
+ fail("system", "unsupported source system")
50
+ for key in ("continue_on_error", "capture_content", "synthetic_fixture"):
51
+ if type(getattr(self, key)) is not bool:
52
+ fail("options", "import flags must be boolean")
53
+ if not isinstance(self.identity, dict) or set(self.identity) - (
54
+ IDENTITY_FIELDS - {"trace_id", "trajectory_id", "session_id"}
55
+ ):
56
+ fail("identity", "unsupported caller identity fields")
57
+ for value in (*self.identity.values(), self.producer_version):
58
+ if value is not None and (
59
+ not isinstance(value, str) or not value.strip() or len(value.encode()) > 512
60
+ ):
61
+ fail("identity", "identities/versions must be bounded strings")
62
+ if self.producer_revision is not None and (
63
+ not isinstance(self.producer_revision, str)
64
+ or not re.fullmatch(r"[0-9a-f]{40}", self.producer_revision)
65
+ ):
66
+ fail("producer_revision", "revision must be a full lowercase Git SHA")
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class OtlpImportResult:
71
+ traces: tuple[AgentTrace, ...]
72
+ source_receipts: tuple[ImportReceipt, ...]
73
+ _inventory_json: str
74
+
75
+ def inventory(self) -> dict:
76
+ return json.loads(self._inventory_json)
77
+
78
+ def write(self, out: str | Path) -> Path:
79
+ return write_import_bundle(self.traces, self.source_receipts, out, self.inventory())
80
+
81
+
82
+ class _BatchImporter:
83
+ def __init__(
84
+ self, root: Path, reference: str, options: OtlpOptions, limits: ImportLimits, jsonl: bool
85
+ ):
86
+ self.root, self.reference, self.options, self.limits, self.jsonl = (
87
+ root,
88
+ reference,
89
+ options,
90
+ limits,
91
+ jsonl,
92
+ )
93
+ self.budget = ImportBudget(limits)
94
+ self.file_hash = sha256()
95
+ self.artifact_sha256 = ""
96
+ self.groups: dict[str, dict] = {}
97
+ self.errors: list[dict] = []
98
+ self.records: list[dict] = []
99
+ self.blank_lines = 0
100
+ self.raw_spans = self.raw_logs = self.duplicates = self.conflicts = 0
101
+
102
+ def notice(
103
+ self,
104
+ code: str,
105
+ line: int | None = None,
106
+ field: str | None = None,
107
+ severity: str = "warning",
108
+ ) -> dict:
109
+ return {
110
+ "code": code,
111
+ "severity": severity,
112
+ "artifact": self.reference,
113
+ "field": field,
114
+ "record": line,
115
+ }
116
+
117
+ def invalid(self, exc: ImportValidationError, line: int) -> None:
118
+ if not self.options.continue_on_error:
119
+ raise exc
120
+ self.errors.append(self.notice(exc.code, line, exc.field, "error"))
121
+ self.records.append({"record": line, "status": "invalid", "diagnostic": exc.to_dict()})
122
+
123
+ def read(self) -> None:
124
+ if not self.jsonl:
125
+ try:
126
+ artifact = load_json_artifact(
127
+ self.root, self.reference, limits=self.limits, budget=self.budget
128
+ )
129
+ self.artifact_sha256 = artifact.artifact_sha256
130
+ self.record(artifact.payload, 1, artifact.artifact_sha256)
131
+ except ImportValidationError as exc:
132
+ if exc.code == "limit_exceeded":
133
+ raise
134
+ self.artifact_sha256 = getattr(exc, "artifact_sha256", "")
135
+ if not self.artifact_sha256:
136
+ raise
137
+ self.invalid(exc, 1)
138
+ return
139
+ path = safe_artifact_path(self.root, self.reference, self.limits)
140
+ with path.open("rb") as stream:
141
+ line = 0
142
+ while True:
143
+ remaining = self.limits.max_total_bytes - self.budget.total_bytes
144
+ data = stream.readline(min(self.limits.max_line_bytes, remaining) + 1)
145
+ if not data:
146
+ break
147
+ line += 1
148
+ self.budget.consume(total_bytes=len(data), records=1)
149
+ self.file_hash.update(data)
150
+ line_hash = sha256(data)
151
+ oversized = len(data) > self.limits.max_line_bytes
152
+ # Drain an oversized record in bounded chunks. It remains one
153
+ # physical record; total byte overflow is always terminal.
154
+ while oversized and not data.endswith(b"\n"):
155
+ remaining = self.limits.max_total_bytes - self.budget.total_bytes
156
+ data = stream.readline(min(self.limits.max_line_bytes, remaining) + 1)
157
+ if not data:
158
+ break
159
+ self.budget.consume(total_bytes=len(data))
160
+ self.file_hash.update(data)
161
+ line_hash.update(data)
162
+ if oversized:
163
+ self.invalid(
164
+ ImportValidationError(
165
+ "record_limit_exceeded",
166
+ "line.bytes",
167
+ "JSONL line exceeds configured bounds",
168
+ ),
169
+ line,
170
+ )
171
+ continue
172
+ if not data.strip():
173
+ self.blank_lines += 1
174
+ continue
175
+ try:
176
+ payload = parse_json_bytes(data, self.limits)
177
+ self.record(payload, line, line_hash.hexdigest())
178
+ except ImportValidationError as exc:
179
+ if exc.code == "limit_exceeded" and exc.field in {
180
+ "total_bytes",
181
+ "records",
182
+ "spans",
183
+ "trajectories",
184
+ "references",
185
+ "events",
186
+ }:
187
+ raise
188
+ self.invalid(exc, line)
189
+ if safe_artifact_path(self.root, self.reference, self.limits) != path:
190
+ fail("reference", "artifact path changed while reading", "unsafe_path")
191
+ self.artifact_sha256 = self.file_hash.hexdigest()
192
+
193
+ def record(self, payload: Any, line: int, record_hash: str) -> None:
194
+ try:
195
+ clean, spans, logs = flatten(
196
+ payload, self.limits, capture_content=self.options.capture_content
197
+ )
198
+ self.budget.consume(spans=len(spans) + len(logs))
199
+ self.budget.consume(
200
+ references=sum(len(item["span"].get("links", [])) for item in spans)
201
+ )
202
+ self.raw_spans += len(spans)
203
+ self.raw_logs += len(logs)
204
+ witnesses: dict[tuple[str, str], dict] = {}
205
+ by_group: dict[str, list[dict]] = {}
206
+ for index, (is_span, witness) in enumerate(
207
+ [(True, item) for item in spans] + [(False, item) for item in logs]
208
+ ):
209
+ group = (
210
+ witness["trace_id"]
211
+ or sha256(
212
+ canonical_json({"record": line, "sha256": record_hash}).encode()
213
+ ).hexdigest()[:32]
214
+ )
215
+ item = witness["span"]
216
+ item["traceId"] = group
217
+ if not witness["span_id"]:
218
+ item["spanId"] = sha256(
219
+ canonical_json(
220
+ {"record": line, "ordinal": index, "sha256": record_hash}
221
+ ).encode()
222
+ ).hexdigest()[:16]
223
+ else:
224
+ item["spanId"] = witness["span_id"]
225
+ witness.update(group=group, record=line, record_sha256=record_hash)
226
+ by_group.setdefault(group, []).append(witness)
227
+ if is_span:
228
+ key = (group, item["spanId"])
229
+ if (
230
+ key in witnesses
231
+ and witnesses[key]["source_sha256"] != witness["source_sha256"]
232
+ ):
233
+ fail("spanId", "conflicting span identity within one record")
234
+ witnesses[key] = witness
235
+ # All span/log interpretation belongs to the existing parser. We
236
+ # qualify its native projection rather than building another parser.
237
+ projected = traces_from_otel(clean)
238
+ pending = []
239
+ for trace in projected:
240
+ group_ids = {event.metadata.get("otel_trace_id") for event in trace.events}
241
+ if not group_ids:
242
+ group_ids = {trace.run_id.removeprefix("run_")}
243
+ if len(group_ids) != 1:
244
+ fail("traceId", "native projection has ambiguous trace identity")
245
+ group = next(iter(group_ids))
246
+ source_witnesses = by_group.get(group, [])
247
+ if not source_witnesses:
248
+ fail("traceId", "native identity does not match source telemetry")
249
+ for event in trace.events:
250
+ witness = witnesses[(group, event.metadata["otel_span_id"])]
251
+ self.qualify_event(event, witness)
252
+ if (
253
+ len(canonical_json(event.metadata).encode())
254
+ > self.limits.max_metadata_bytes
255
+ ):
256
+ fail(
257
+ "span.metadata",
258
+ "projected metadata exceeds configured bounds",
259
+ "record_limit_exceeded",
260
+ )
261
+ if len(trace.events) > self.limits.max_events_per_trace:
262
+ fail("events", "event count exceeds configured bounds", "record_limit_exceeded")
263
+ pending.append((group, trace, source_witnesses, witnesses))
264
+ new_groups = {group for group, *_ in pending} - set(self.groups)
265
+ self.budget.consume(trajectories=len(new_groups))
266
+ for group, trace, source_witnesses, witnesses in pending:
267
+ self.merge(group, trace, source_witnesses, witnesses)
268
+ self.records.append(
269
+ {
270
+ "record": line,
271
+ "status": "imported",
272
+ "sha256": record_hash,
273
+ "trace_groups": [item[0] for item in pending],
274
+ }
275
+ )
276
+ except TraceValidationError:
277
+ fail("record", "OTLP semantic validation failed")
278
+ except (OverflowError, TypeError, KeyError, AttributeError, ValueError) as exc:
279
+ if isinstance(exc, ImportValidationError):
280
+ raise
281
+ fail("record", "OTLP structural or semantic validation failed")
282
+
283
+ def qualify_event(self, event: Any, witness: dict) -> None:
284
+ attrs, span = witness["attributes"], witness["span"]
285
+ converter = (
286
+ witness["resource"].get("telemetry.sdk.name") == "harbor-atif2otel"
287
+ or witness["scope"].get("name") == "harbor-atif2otel"
288
+ )
289
+ event.metadata["otel_parent_span_id"] = (span.get("parentSpanId") or "").lower() or None
290
+ event.metadata["source_interval"] = {
291
+ "start_unix_nano": span.get("startTimeUnixNano"),
292
+ "end_unix_nano": span.get("endTimeUnixNano"),
293
+ "duration_ms": event.duration_ms,
294
+ }
295
+ event.metadata["unrepresented_structural_fields"] = witness["unrepresented_fields"]
296
+ native_identity = {
297
+ "event_id": attrs.get("agentloop.native_event_id"),
298
+ "parent_id": attrs.get("agentloop.native_parent_id"),
299
+ "run_id": witness["resource"].get("agentloop.run_id") or attrs.get("agentloop.run_id"),
300
+ }
301
+ if any(
302
+ value is not None and (not isinstance(value, str) or len(value.encode()) > 512)
303
+ for value in native_identity.values()
304
+ ):
305
+ fail(
306
+ "agentloop.native_identity",
307
+ "native identity assertions must be bounded strings or null",
308
+ )
309
+ if any(value is not None for value in native_identity.values()):
310
+ event.metadata["source_native_identity"] = native_identity
311
+ start, end = span.get("startTimeUnixNano"), span.get("endTimeUnixNano")
312
+ if start is None:
313
+ event.started_at = "unknown"
314
+ if end is None:
315
+ event.ended_at = "unknown"
316
+ timing = start is not None and end is not None and not converter
317
+ if timing:
318
+ try:
319
+ timing = (
320
+ type(start) in {int, str}
321
+ and type(end) in {int, str}
322
+ and int(end) >= int(start) >= 0
323
+ )
324
+ except (ValueError, OverflowError):
325
+ timing = False
326
+ status = span.get("status", {}).get("code")
327
+ status_available = status in {1, 2, "STATUS_CODE_OK", "STATUS_CODE_ERROR"} and not converter
328
+ if event.operation_kind == "unknown" or not any(
329
+ isinstance(attrs.get(key), str) and bool(attrs[key].strip())
330
+ for key in (
331
+ "gen_ai.operation.name",
332
+ "openinference.span.kind",
333
+ "mcp.method.name",
334
+ "agentloop.event_type",
335
+ )
336
+ ):
337
+ event.event_type = "source_span"
338
+ event.metadata["operation_kind"] = "unknown"
339
+ if not timing:
340
+ event.duration_ms = 0.0
341
+ input_available = any(key in attrs for key in ("agentloop.input_tokens", *INPUT_USAGE))
342
+ output_available = any(key in attrs for key in ("agentloop.output_tokens", *OUTPUT_USAGE))
343
+ event.metadata.update(
344
+ external_evidence_schema="1.0",
345
+ timing_available=bool(timing),
346
+ timing_provenance="inferred"
347
+ if converter
348
+ else "external_reported"
349
+ if timing
350
+ else "unknown",
351
+ source_status_available=status_available,
352
+ source_execution_status="failed"
353
+ if status in {2, "STATUS_CODE_ERROR"} and not converter
354
+ else "completed"
355
+ if status_available
356
+ else "unknown",
357
+ input_tokens_available=input_available,
358
+ output_tokens_available=output_available,
359
+ source_token_provenance=event.token_provenance,
360
+ source_span_sha256=witness["source_sha256"],
361
+ source_span_id=witness["span_id"],
362
+ source_trace_id=witness["trace_id"],
363
+ source_record=witness["record"],
364
+ converter_generated=converter,
365
+ )
366
+ if event.event_type == "model_call":
367
+ multiplicity = attrs.get("metadata.aggregated_llm_calls", 1)
368
+ if type(multiplicity) is not int or multiplicity < 1:
369
+ fail(
370
+ "metadata.aggregated_llm_calls",
371
+ "model block multiplicity must be a positive integer",
372
+ )
373
+ event.metadata["source_model_call_count"] = multiplicity
374
+ event.token_provenance = (
375
+ "external_reported" if input_available and output_available else "unavailable"
376
+ )
377
+ cache = event.metadata.get("cached_input_tokens")
378
+ if cache is not None and input_available and cache > event.input_tokens:
379
+ fail("usage", "cache usage must be a prompt subset")
380
+
381
+ def merge(
382
+ self, group: str, trace: AgentTrace, witnesses: list[dict], span_witnesses: dict
383
+ ) -> None:
384
+ state = self.groups.setdefault(
385
+ group,
386
+ {
387
+ "events": {},
388
+ "trace_id": witnesses[0]["trace_id"],
389
+ "records": [],
390
+ "notices": [],
391
+ "logs": [],
392
+ "sessions": set(),
393
+ "bounds": [],
394
+ "conflicts": 0,
395
+ "source_aggregates": [],
396
+ },
397
+ )
398
+ state["records"].append(witnesses[0]["record"])
399
+ for event in trace.events:
400
+ state["logs"].extend(event.metadata.get("otel_log_records", []))
401
+ for witness in witnesses:
402
+ for source_attrs in (witness["attributes"], witness["resource"]):
403
+ session = source_attrs.get("session.id") or source_attrs.get(
404
+ "gen_ai.conversation.id"
405
+ )
406
+ if isinstance(session, str) and session.strip() and len(session.encode()) <= 512:
407
+ state["sessions"].add(session)
408
+ for event in trace.events:
409
+ witness = span_witnesses[(group, event.metadata["otel_span_id"])]
410
+ span_id = event.metadata["otel_span_id"]
411
+ existing = state["events"].get(span_id)
412
+ if existing is not None:
413
+ if existing.metadata["source_span_sha256"] == event.metadata["source_span_sha256"]:
414
+ self.duplicates += 1
415
+ state["notices"].append(
416
+ self.notice("identical_span_duplicate", witness["record"], "spanId", "info")
417
+ )
418
+ else:
419
+ self.conflicts += 1
420
+ state["conflicts"] += 1
421
+ state["notices"].append(
422
+ self.notice(
423
+ "conflicting_span_segment", witness["record"], "spanId", "error"
424
+ )
425
+ )
426
+ continue
427
+ if len(state["events"]) >= self.limits.max_events_per_trace:
428
+ fail("events", "merged trace exceeds configured bounds", "limit_exceeded")
429
+ event.metadata.pop("agentloop.external", None)
430
+ state["events"][span_id] = event
431
+ if event.metadata["unrepresented_structural_fields"]:
432
+ state["notices"].append(
433
+ self.notice("unrepresented_structural_fields", witness["record"], "record")
434
+ )
435
+ if event.operation_kind != "model" and any(
436
+ key in witness["attributes"]
437
+ for key in (*INPUT_USAGE, *OUTPUT_USAGE, "llm.cost.total")
438
+ ):
439
+ state["source_aggregates"].append(
440
+ {
441
+ "span_id": span_id,
442
+ "input_tokens": event.input_tokens
443
+ if event.metadata["input_tokens_available"]
444
+ else None,
445
+ "output_tokens": event.output_tokens
446
+ if event.metadata["output_tokens_available"]
447
+ else None,
448
+ "cost_usd": event.metadata.get("provider_reported_cost_usd"),
449
+ "basis": "external_reported_nonmodel_aggregate",
450
+ }
451
+ )
452
+ if event.metadata["timing_available"]:
453
+ state["bounds"].append(
454
+ (
455
+ int(witness["span"]["startTimeUnixNano"]),
456
+ int(witness["span"]["endTimeUnixNano"]),
457
+ )
458
+ )
459
+ if event.metadata["converter_generated"]:
460
+ state["notices"].append(
461
+ self.notice("converter_inferred_timing_status", witness["record"], "timing")
462
+ )
463
+ log_records = trace.metadata.get("otel_log_records", [])
464
+ state["logs"].extend(log_records)
465
+
466
+ def receipt(
467
+ self,
468
+ identity: dict,
469
+ *,
470
+ reason: str | None,
471
+ notices: list[dict],
472
+ metadata: dict,
473
+ traces: list[dict] | None = None,
474
+ completeness: dict | None = None,
475
+ execution: str = "unknown",
476
+ ) -> dict:
477
+ return {
478
+ "schema_version": "1.0",
479
+ "receipt_id": "pending",
480
+ "source": {
481
+ "system": self.options.system,
482
+ "producer_version": self.options.producer_version,
483
+ "producer_revision": self.options.producer_revision,
484
+ "format": "otlp_jsonl" if self.jsonl else "otlp_json",
485
+ "format_version": None,
486
+ "artifact_reference": self.reference,
487
+ "artifact_sha256": self.artifact_sha256,
488
+ "trust": "external_reported",
489
+ },
490
+ "external_identity": identity,
491
+ "identity_provenance": {
492
+ key: "calculated"
493
+ if key == "trajectory_id" and value is not None
494
+ else "external_reported"
495
+ if value is not None
496
+ else "unknown"
497
+ for key, value in identity.items()
498
+ },
499
+ "traces": traces or [],
500
+ "missing_trace_reason": reason,
501
+ "outcome": {
502
+ "execution_status": execution,
503
+ "verifier_status": "unknown",
504
+ "verifier_dimensions": {},
505
+ "quality_pass": None, # nosec B105
506
+ "quality_basis": "unknown",
507
+ "scoring_contract": None,
508
+ "verifier_isolation": "unknown",
509
+ },
510
+ "completeness": completeness
511
+ or dict.fromkeys(
512
+ ("trajectory", "usage", "quality", "parentage", "timing", "cost"), "unknown"
513
+ ),
514
+ "relationships": [],
515
+ "notices": notices,
516
+ "source_metadata": metadata,
517
+ }
518
+
519
+ def finish(self) -> OtlpImportResult:
520
+ traces, receipts = [], []
521
+ for group, state in self.groups.items():
522
+ identity = {
523
+ **dict.fromkeys(sorted(IDENTITY_FIELDS)),
524
+ **self.options.identity,
525
+ "trace_id": state["trace_id"],
526
+ }
527
+ if state["trace_id"] is None:
528
+ identity["trajectory_id"] = external_id(
529
+ "group",
530
+ self.options.system,
531
+ {
532
+ "artifact_reference": self.reference,
533
+ "artifact_sha256": self.artifact_sha256,
534
+ "source_record": state["records"][0],
535
+ },
536
+ )
537
+ state["notices"].append(
538
+ self.notice("missing_trace_identity", state["records"][0], "traceId")
539
+ )
540
+ if len(state["sessions"]) == 1:
541
+ identity["session_id"] = next(iter(state["sessions"]))
542
+ elif len(state["sessions"]) > 1:
543
+ state["notices"].append(self.notice("multiple_trace_sessions", field="session.id"))
544
+ events = list(state["events"].values())
545
+ cyclic = _cyclic_parents(state["events"])
546
+ models = [event for event in events if event.event_type == "model_call"]
547
+ timing = (
548
+ bool(events)
549
+ and state["trace_id"] is not None
550
+ and all(event.metadata["timing_available"] for event in events)
551
+ and not state["conflicts"]
552
+ and not cyclic
553
+ )
554
+ usage = (
555
+ bool(models)
556
+ and all(
557
+ event.metadata["input_tokens_available"]
558
+ and event.metadata["output_tokens_available"]
559
+ for event in models
560
+ )
561
+ and not state["conflicts"]
562
+ )
563
+ runtime = (
564
+ (
565
+ max(end for _, end in state["bounds"])
566
+ - min(start for start, _ in state["bounds"])
567
+ )
568
+ / 1_000_000
569
+ if timing
570
+ else None
571
+ )
572
+ execution = (
573
+ "failed"
574
+ if any(
575
+ event.status == "error" and event.metadata["source_status_available"]
576
+ for event in events
577
+ )
578
+ else "unknown"
579
+ )
580
+ metadata = {
581
+ "records": sorted(set(state["records"])),
582
+ "raw_unique_spans": len(events),
583
+ "conflicting_segments": state["conflicts"],
584
+ "source_aggregates": state["source_aggregates"],
585
+ "source_logs": state["logs"],
586
+ "coverage_basis": "exported telemetry; source execution/usage completeness not established",
587
+ "atif_losses": [
588
+ "original interaction/context history",
589
+ "source timestamps vs generated intervals",
590
+ "source execution outcome",
591
+ "unconverted or unresolved documents",
592
+ ]
593
+ if self.options.system == "harbor"
594
+ or any(event.metadata["converter_generated"] for event in events)
595
+ else [],
596
+ }
597
+ receipt = self.receipt(
598
+ identity,
599
+ reason="no_execution_spans" if not events else None,
600
+ notices=state["notices"],
601
+ metadata=metadata,
602
+ completeness={
603
+ "trajectory": "partial" if events else "missing",
604
+ "usage": "complete" if usage else "partial" if models else "unknown",
605
+ "quality": "unknown",
606
+ "parentage": "unknown",
607
+ "timing": "complete" if timing else "missing",
608
+ "cost": "unknown",
609
+ },
610
+ execution=execution,
611
+ )
612
+ receipt["receipt_id"] = receipt_id(receipt)
613
+ if events:
614
+ run_id = external_id(
615
+ "run", self.options.system, {**identity, "source_group": group}
616
+ )
617
+ event_ids = {
618
+ span_id: external_id(
619
+ "event", self.options.system, {"run_id": run_id, "span_id": span_id}
620
+ )
621
+ for span_id in state["events"]
622
+ }
623
+ trace = AgentTrace(
624
+ name="external_otel_trace",
625
+ run_id=run_id,
626
+ started_at=events[0].started_at,
627
+ elapsed_ms=runtime,
628
+ metadata={
629
+ "source": self.options.system,
630
+ **({"synthetic": True} if self.options.synthetic_fixture else {}),
631
+ },
632
+ )
633
+ for event in events:
634
+ parent_span = event.metadata.get("otel_parent_span_id")
635
+ event.run_id, event.event_id = run_id, event_ids[event.metadata["otel_span_id"]]
636
+ source_parent = state["events"].get(parent_span)
637
+ event.parent_id = (
638
+ event_ids.get(parent_span)
639
+ if state["trace_id"] is not None
640
+ and source_parent is not None
641
+ and source_parent.metadata["source_span_id"] is not None
642
+ else None
643
+ )
644
+ if event.metadata["source_span_id"] is None:
645
+ receipt["notices"].append(
646
+ self.notice(
647
+ "missing_span_identity", event.metadata["source_record"], "spanId"
648
+ )
649
+ )
650
+ if event.metadata["otel_span_id"] in cyclic:
651
+ event.parent_id = None
652
+ event.metadata["unresolved_parent_span_id"] = parent_span
653
+ receipt["notices"].append(
654
+ self.notice(
655
+ "cyclic_parentage",
656
+ event.metadata["source_record"],
657
+ "parentSpanId",
658
+ "error",
659
+ )
660
+ )
661
+ if parent_span is not None and event.parent_id is None:
662
+ event.metadata["unresolved_parent_span_id"] = parent_span
663
+ receipt["notices"].append(
664
+ self.notice(
665
+ "unresolved_parent_span",
666
+ event.metadata["source_record"],
667
+ "parentSpanId",
668
+ )
669
+ )
670
+ event.metadata["source_receipt_id"] = receipt["receipt_id"]
671
+ trace.events = events
672
+ trace.metadata[EXTERNAL_KEY] = {
673
+ "schema_version": "1.0",
674
+ "source": {
675
+ "system": self.options.system,
676
+ "format": receipt["source"]["format"],
677
+ },
678
+ "receipt_id": receipt["receipt_id"],
679
+ "runtime_ms": runtime,
680
+ "event_timing_complete": timing,
681
+ "execution_status": execution,
682
+ "usage_complete": usage,
683
+ "reported_model_call_count": sum(
684
+ event.metadata["source_model_call_count"] for event in models
685
+ )
686
+ if models
687
+ else None,
688
+ "comparison_eligible": False,
689
+ }
690
+ receipt["traces"] = [
691
+ {
692
+ "run_id": run_id,
693
+ "trace_file": f"traces/{run_id}.json",
694
+ "trace_sha256": sha256(native_bytes(trace)).hexdigest(),
695
+ }
696
+ ]
697
+ traces.append(trace)
698
+ receipts.append(ImportReceipt.from_dict(receipt, limits=self.limits))
699
+ if self.errors or not self.groups:
700
+ identity = {**dict.fromkeys(sorted(IDENTITY_FIELDS)), **self.options.identity}
701
+ receipt = self.receipt(
702
+ identity,
703
+ reason="invalid_artifact" if self.errors else "no_execution_spans",
704
+ notices=self.errors,
705
+ metadata={
706
+ "invalid_record_count": len(self.errors),
707
+ "blank_line_count": self.blank_lines,
708
+ },
709
+ )
710
+ receipt["receipt_id"] = receipt_id(receipt)
711
+ receipts.append(ImportReceipt.from_dict(receipt, limits=self.limits))
712
+ inventory = {
713
+ "schema_version": "1.0",
714
+ "source_system": self.options.system,
715
+ "artifact_reference": self.reference,
716
+ "artifact_sha256": self.artifact_sha256,
717
+ "source_format": "otlp_jsonl" if self.jsonl else "otlp_json",
718
+ "physical_records": self.budget.records,
719
+ "blank_lines": self.blank_lines,
720
+ "valid_records": sum(row["status"] == "imported" for row in self.records),
721
+ "invalid_records": len(self.errors),
722
+ "raw_spans": self.raw_spans,
723
+ "raw_log_records": self.raw_logs,
724
+ "identical_duplicate_spans": self.duplicates,
725
+ "conflicting_span_segments": self.conflicts,
726
+ "trace_groups": len(self.groups),
727
+ "record_rows": self.records,
728
+ **summarize_receipts(receipts),
729
+ }
730
+ return OtlpImportResult(tuple(traces), tuple(receipts), canonical_json(inventory))
731
+
732
+
733
+ def _cyclic_parents(events: dict[str, Any]) -> set[str]:
734
+ """Find cycles in a source parent forest without recursion or invented edges."""
735
+ parents = {key: event.metadata.get("otel_parent_span_id") for key, event in events.items()}
736
+ done, cyclic = set(), set()
737
+ for start in parents:
738
+ current, path, positions = start, [], {}
739
+ while current in parents and current not in done:
740
+ if current in positions:
741
+ cyclic.update(path[positions[current] :])
742
+ break
743
+ positions[current] = len(path)
744
+ path.append(current)
745
+ current = parents[current]
746
+ done.update(path)
747
+ return cyclic
748
+
749
+
750
+ def import_otlp(
751
+ path: str | Path,
752
+ *,
753
+ root: str | Path | None = None,
754
+ options: OtlpOptions = OtlpOptions(),
755
+ limits: ImportLimits = ImportLimits(),
756
+ jsonl: bool | None = None,
757
+ ) -> OtlpImportResult:
758
+ """Import a bounded file offline; malformed independent records stay visible."""
759
+ if jsonl is not None and type(jsonl) is not bool:
760
+ fail("jsonl", "format selection must be boolean or null")
761
+ selected = Path(path).absolute()
762
+ source_root = Path(root).absolute() if root is not None else selected.parent
763
+ try:
764
+ reference = selected.relative_to(source_root).as_posix()
765
+ except ValueError:
766
+ fail("path", "input lies outside the selected source root", "unsafe_path")
767
+ importer = _BatchImporter(
768
+ source_root.resolve(),
769
+ reference,
770
+ options,
771
+ limits,
772
+ selected.suffix.lower() == ".jsonl" if jsonl is None else jsonl,
773
+ )
774
+ importer.read()
775
+ return importer.finish()