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
agentloop/otel.py ADDED
@@ -0,0 +1,715 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from copy import deepcopy
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from agentloop.events import AgentEvent
9
+ from agentloop.operations import LEGACY_OPERATION_KINDS, normalize_operation_kind
10
+ from agentloop.otel_ids import to_span_id, to_trace_id
11
+ from agentloop.otel_semantics import (
12
+ INPUT_USAGE,
13
+ OUTPUT_USAGE,
14
+ evaluation_result,
15
+ extended_kind,
16
+ normalize_metadata,
17
+ usage_count,
18
+ )
19
+ from agentloop.schema import TraceValidationError
20
+ from agentloop.tokens import PROVIDER, UNAVAILABLE
21
+ from agentloop.tracer import AgentTrace
22
+ from agentloop.version import __version__
23
+
24
+ # Prefix under which AgentLoop exports user event metadata as span attributes.
25
+ _METADATA_PREFIX = "agentloop.metadata."
26
+
27
+ _NANOSECONDS_PER_MILLISECOND = 1_000_000
28
+
29
+
30
+ def trace_from_otel(
31
+ payload: dict[str, Any] | list[dict[str, Any]], name: str | None = None
32
+ ) -> AgentTrace:
33
+ """Convert a single-trace OTLP/GenAI-style JSON payload into an AgentLoop trace.
34
+
35
+ This accepts the standard OTLP JSON shape (`resourceSpans -> scopeSpans -> spans`),
36
+ a dictionary with a top-level `spans` list, or a raw list of span dictionaries.
37
+
38
+ The payload must describe **one** trace. OTLP exporters routinely batch spans
39
+ from several traces into one request; passing such a batch here raises
40
+ :class:`agentloop.schema.TraceValidationError` rather than silently collapsing
41
+ the traces into one. Use :func:`traces_from_otel` for batches. An empty payload
42
+ yields an empty trace, preserving the historical single-trace behavior.
43
+ """
44
+
45
+ traces = traces_from_otel(payload, name=name)
46
+ if len(traces) > 1:
47
+ raise TraceValidationError(
48
+ "traceId",
49
+ f"payload contains {len(traces)} distinct traces; use traces_from_otel() "
50
+ "to import a batch",
51
+ )
52
+ if not traces:
53
+ return _build_trace([], {}, name=name)
54
+ return traces[0]
55
+
56
+
57
+ def traces_from_otel(
58
+ payload: dict[str, Any] | list[dict[str, Any]], name: str | None = None
59
+ ) -> list[AgentTrace]:
60
+ """Convert an OTLP/GenAI-style batch into one AgentLoop trace per trace id.
61
+
62
+ Spans are grouped by their ``traceId`` in first-seen order regardless of how
63
+ they are interleaved in the payload, so a batch of unrelated traces keeps its
64
+ boundaries. Parent links are resolved within each trace only. Spans with no
65
+ trace id are grouped together into a single trace. Accepts the same input
66
+ shapes as :func:`trace_from_otel`.
67
+ """
68
+
69
+ groups: dict[str, list[dict[str, Any]]] = {}
70
+ group_resource: dict[str, dict[str, Any]] = {}
71
+ for resource_attrs, spans in _iter_resource_blocks(payload):
72
+ if not spans and _native_run_id(resource_attrs):
73
+ key = to_trace_id(_native_run_id(resource_attrs))
74
+ groups.setdefault(key, [])
75
+ group_resource[key] = resource_attrs
76
+ block_trace_keys = {_trace_key(span) for span in spans}
77
+ block_is_single_trace = len(block_trace_keys) == 1
78
+ for span in spans:
79
+ key = _trace_key(span)
80
+ groups.setdefault(key, []).append(span)
81
+ # Only adopt a resource block's native identity when that block holds
82
+ # exactly one trace; a multi-trace block cannot name every trace.
83
+ if key not in group_resource:
84
+ group_resource[key] = resource_attrs if block_is_single_trace else {}
85
+ elif not block_is_single_trace or group_resource[key] != resource_attrs:
86
+ group_resource[key] = {}
87
+ traces = [
88
+ _build_trace(spans, group_resource.get(key, {}), name=name) for key, spans in groups.items()
89
+ ]
90
+ by_id = dict(zip(groups, traces))
91
+ events_by_trace = {
92
+ key: {event.metadata.get("otel_span_id"): event for event in trace.events}
93
+ for key, trace in by_id.items()
94
+ }
95
+ if isinstance(payload, dict):
96
+ for resource in payload.get("resourceLogs", []):
97
+ for scope in resource.get("scopeLogs", []):
98
+ for record in scope.get("logRecords", []):
99
+ key = _trace_key(record)
100
+ if key not in by_id:
101
+ trace = AgentTrace(
102
+ name=name or "otel_log_trace",
103
+ run_id="run_" + key if key else "run_otel_logs",
104
+ metadata={"source": "otel", "execution_data_present": False},
105
+ elapsed_ms=0,
106
+ )
107
+ by_id[key] = trace
108
+ traces.append(trace)
109
+ trace = by_id[key]
110
+ event = events_by_trace.get(key, {}).get(record.get("spanId"))
111
+ metadata = event.metadata if event is not None else trace.metadata
112
+ metadata.setdefault("otel_log_records", []).append(
113
+ {
114
+ "record": deepcopy(record),
115
+ "resource": deepcopy(resource.get("resource", {})),
116
+ "scope": deepcopy(scope.get("scope", {})),
117
+ }
118
+ )
119
+ result = evaluation_result(
120
+ _attributes(record), source=str(record.get("eventName") or "otel_log")
121
+ )
122
+ if result:
123
+ metadata.setdefault("evaluation_results", []).append(result)
124
+ return traces
125
+
126
+
127
+ def _build_trace(
128
+ spans: list[dict[str, Any]], resource_attrs: dict[str, Any], *, name: str | None
129
+ ) -> AgentTrace:
130
+ run_id = _native_run_id(resource_attrs) or _run_id_from_spans(spans)
131
+ trace_name = name or _native_trace_name(resource_attrs) or _trace_name(spans)
132
+ native_metadata = resource_attrs.get("agentloop.trace.metadata")
133
+ trace = AgentTrace(
134
+ name=trace_name,
135
+ run_id=run_id,
136
+ metadata=deepcopy(native_metadata)
137
+ if isinstance(native_metadata, dict)
138
+ else {"source": "otel"},
139
+ )
140
+ for span in spans:
141
+ trace.add_event(_event_from_span(span, run_id))
142
+ bounds = _trace_bounds_ns(spans)
143
+ if bounds is not None:
144
+ started_ns, ended_ns = bounds
145
+ trace.started_at = _iso_from_ns(started_ns)
146
+ trace.ended_at = _iso_from_ns(ended_ns)
147
+ trace.elapsed_ms = (ended_ns - started_ns) / _NANOSECONDS_PER_MILLISECOND
148
+ if "agentloop.trace.started_at" in resource_attrs:
149
+ trace.started_at = resource_attrs["agentloop.trace.started_at"]
150
+ trace.ended_at = resource_attrs.get("agentloop.trace.ended_at")
151
+ trace.elapsed_ms = resource_attrs.get("agentloop.trace.elapsed_ms")
152
+ if trace.elapsed_ms is not None:
153
+ trace.elapsed_ms = _nonnegative_timing(trace.elapsed_ms, "agentloop.trace.elapsed_ms")
154
+ return trace
155
+
156
+
157
+ def trace_to_otel(trace: AgentTrace) -> dict[str, Any]:
158
+ """Export an AgentLoop trace as dependency-free OTLP-like GenAI JSON."""
159
+
160
+ spans = [_span_from_event(trace, event) for event in trace.events]
161
+ return {
162
+ "resourceSpans": [
163
+ {
164
+ "resource": {
165
+ "attributes": [
166
+ _attribute("service.name", "agentloop"),
167
+ _attribute("agentloop.trace.name", trace.name),
168
+ _attribute("agentloop.run_id", trace.run_id),
169
+ _attribute("agentloop.trace.metadata", trace.metadata),
170
+ _attribute("agentloop.trace.started_at", trace.started_at),
171
+ _attribute("agentloop.trace.ended_at", trace.ended_at),
172
+ _attribute("agentloop.trace.elapsed_ms", trace.elapsed_ms),
173
+ ]
174
+ },
175
+ "scopeSpans": [
176
+ {
177
+ "scope": {"name": "agentloop", "version": __version__},
178
+ "spans": spans,
179
+ }
180
+ ],
181
+ }
182
+ ]
183
+ }
184
+
185
+
186
+ def _iter_resource_blocks(
187
+ payload: dict[str, Any] | list[dict[str, Any]],
188
+ ) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]:
189
+ """Return ``(resource_attributes, spans)`` blocks, preserving payload order.
190
+
191
+ Resource attributes carry the AgentLoop-native identity that
192
+ :func:`trace_to_otel` writes (trace name and run id). Raw span lists and
193
+ top-level ``spans`` payloads have no resource, so their attributes are empty.
194
+ """
195
+
196
+ if isinstance(payload, list):
197
+ return [({}, list(payload))]
198
+ if "spans" in payload and isinstance(payload["spans"], list):
199
+ return [(_resource_attributes(payload.get("resource")), list(payload["spans"]))]
200
+
201
+ blocks: list[tuple[dict[str, Any], list[dict[str, Any]]]] = []
202
+ for resource_span in payload.get("resourceSpans", []):
203
+ resource_attrs = _resource_attributes(resource_span.get("resource"))
204
+ spans: list[dict[str, Any]] = []
205
+ for scope_span in resource_span.get("scopeSpans", []):
206
+ for span in scope_span.get("spans", []):
207
+ spans.append(
208
+ {
209
+ **span,
210
+ "_resource_attributes": resource_attrs,
211
+ "_scope": scope_span.get("scope", {}),
212
+ "_schema_url": scope_span.get("schemaUrl", resource_span.get("schemaUrl")),
213
+ }
214
+ )
215
+ blocks.append((resource_attrs, spans))
216
+ return blocks
217
+
218
+
219
+ def _resource_attributes(resource: Any) -> dict[str, Any]:
220
+ if not isinstance(resource, dict):
221
+ return {}
222
+ return _attributes(resource)
223
+
224
+
225
+ def _trace_key(span: dict[str, Any]) -> str:
226
+ return str(span.get("traceId") or span.get("trace_id") or "").lower()
227
+
228
+
229
+ def _native_run_id(resource_attrs: dict[str, Any]) -> str | None:
230
+ run_id = resource_attrs.get("agentloop.run_id")
231
+ return str(run_id) if run_id else None
232
+
233
+
234
+ def _native_trace_name(resource_attrs: dict[str, Any]) -> str | None:
235
+ name = resource_attrs.get("agentloop.trace.name")
236
+ return str(name) if name else None
237
+
238
+
239
+ def _run_id_from_spans(spans: list[dict[str, Any]]) -> str:
240
+ for span in spans:
241
+ trace_id = str(span.get("traceId") or span.get("trace_id") or "")
242
+ if trace_id:
243
+ # Preserve the full trace id (not just the last 16 chars) so a valid
244
+ # OTLP id round-trips back out unchanged via to_trace_id().
245
+ return "run_" + trace_id.lower()
246
+ return "run_otel_import"
247
+
248
+
249
+ def _trace_name(spans: list[dict[str, Any]]) -> str:
250
+ for span in spans:
251
+ attrs = _attributes(span)
252
+ workflow = attrs.get("gen_ai.workflow.name") or attrs.get("agentloop.trace.name")
253
+ if workflow:
254
+ return str(workflow)
255
+ return "otel_trace"
256
+
257
+
258
+ def _event_from_span(span: dict[str, Any], run_id: str) -> AgentEvent:
259
+ attrs = _attributes(span)
260
+ operation = str(attrs.get("gen_ai.operation.name") or attrs.get("agentloop.event_type") or "")
261
+ event_type = str(attrs.get("agentloop.event_type") or _event_type(operation, span.get("name")))
262
+ kind = extended_kind(attrs)
263
+ if kind is not None and "agentloop.event_type" not in attrs:
264
+ event_type = "model_call" if kind == "model" else "tool_call"
265
+ started_ns = _int_or_none(span.get("startTimeUnixNano") or span.get("start_time_unix_nano"))
266
+ ended_ns = _int_or_none(span.get("endTimeUnixNano") or span.get("end_time_unix_nano"))
267
+ duration_ms = _duration_ms(started_ns, ended_ns, span)
268
+ if "agentloop.duration_ms" in attrs:
269
+ duration_ms = _nonnegative_timing(attrs["agentloop.duration_ms"], "agentloop.duration_ms")
270
+ span_id = str(span.get("spanId") or span.get("span_id") or "")
271
+ parent_span_id = str(span.get("parentSpanId") or span.get("parent_span_id") or "") or None
272
+ status = _status(span)
273
+ error = _error(span)
274
+
275
+ metadata = _user_metadata(attrs, span, span_id)
276
+ if "operation_kind" not in metadata:
277
+ if "agentloop.operation_kind" in attrs:
278
+ metadata["operation_kind"] = attrs["agentloop.operation_kind"]
279
+ elif "agentloop.event_type" not in attrs:
280
+ metadata["operation_kind"] = kind or _import_operation_kind(operation, event_type)
281
+ normalize_metadata(attrs, metadata)
282
+ _preserve_span_details(span, attrs, metadata)
283
+
284
+ # Restore AgentLoop-native ids when the exporter preserved them, so repeated
285
+ # round trips keep stable event and parent identity (issue #63). Fall back to
286
+ # the OTLP span ids for third-party payloads.
287
+ native_event_id = attrs.get("agentloop.native_event_id")
288
+ event_id = str(native_event_id) if native_event_id else ("span_" + span_id if span_id else "")
289
+ native_parent_id = attrs.get("agentloop.native_parent_id")
290
+ if native_parent_id:
291
+ parent_id = str(native_parent_id)
292
+ elif parent_span_id:
293
+ parent_id = "span_" + parent_span_id
294
+ else:
295
+ parent_id = None
296
+
297
+ return AgentEvent(
298
+ event_id=event_id,
299
+ run_id=run_id,
300
+ event_type=event_type,
301
+ name=str(
302
+ attrs.get("gen_ai.tool.name")
303
+ or attrs.get("tool.name")
304
+ or attrs.get("agentloop.name")
305
+ or span.get("name")
306
+ or event_type
307
+ ),
308
+ started_at=_iso_from_ns(started_ns),
309
+ ended_at=_iso_from_ns(ended_ns),
310
+ duration_ms=duration_ms,
311
+ parent_id=parent_id,
312
+ model=_first_string(
313
+ attrs,
314
+ "agentloop.model",
315
+ "gen_ai.response.model",
316
+ "gen_ai.request.model",
317
+ "llm.response.model_name",
318
+ "llm.model_name",
319
+ "llm.request.model_name",
320
+ "embedding.model_name",
321
+ ),
322
+ input_tokens=usage_count(attrs, ("agentloop.input_tokens", *INPUT_USAGE)),
323
+ output_tokens=usage_count(attrs, ("agentloop.output_tokens", *OUTPUT_USAGE)),
324
+ token_provenance=_token_provenance(attrs),
325
+ status=status,
326
+ error=error,
327
+ metadata=metadata,
328
+ )
329
+
330
+
331
+ def _span_from_event(trace: AgentTrace, event: AgentEvent) -> dict[str, Any]:
332
+ start_ns = _ns_from_iso(event.started_at)
333
+ end_ns = _ns_from_iso(event.ended_at)
334
+ generic = event.event_type not in LEGACY_OPERATION_KINDS
335
+ attrs = (
336
+ []
337
+ if generic
338
+ else [
339
+ _attribute(
340
+ "gen_ai.operation.name", _operation_name(event.event_type, event.operation_kind)
341
+ )
342
+ ]
343
+ ) + [
344
+ _attribute("agentloop.event_type", event.event_type),
345
+ _attribute("agentloop.name", event.name),
346
+ _attribute("agentloop.duration_ms", event.duration_ms),
347
+ _attribute("agentloop.run_id", trace.run_id),
348
+ _attribute(
349
+ "agentloop.input_tokens" if generic else "gen_ai.usage.input_tokens", event.input_tokens
350
+ ),
351
+ _attribute(
352
+ "agentloop.output_tokens" if generic else "gen_ai.usage.output_tokens",
353
+ event.output_tokens,
354
+ ),
355
+ ]
356
+ if "operation_kind" in event.metadata:
357
+ attrs.append(_attribute("agentloop.operation_kind", event.metadata["operation_kind"]))
358
+ preserved_fields = sorted(
359
+ set(event.metadata) & (_reserved_metadata_keys() - {"otel_span_id", "otel_trace_id"})
360
+ )
361
+ if preserved_fields:
362
+ attrs.append(_attribute("agentloop.preserved_span_fields", preserved_fields))
363
+ if event.model:
364
+ attrs.append(
365
+ _attribute("agentloop.model" if generic else "gen_ai.request.model", event.model)
366
+ )
367
+ attrs.append(_attribute("agentloop.token_provenance", event.token_provenance))
368
+ for key, value in sorted((event.metadata or {}).items()):
369
+ if key in _reserved_metadata_keys():
370
+ # Transport diagnostics (otel_span_id/otel_trace_id) are re-derived on
371
+ # import; re-exporting them would let user metadata grow each round trip.
372
+ continue
373
+ attrs.append(_attribute(f"{_METADATA_PREFIX}{key}", value))
374
+
375
+ # Keep the original native ids in attributes so a remapped (non-hex/custom)
376
+ # id stays diagnosable; the run id is already carried as agentloop.run_id.
377
+ attrs.append(_attribute("agentloop.native_event_id", event.event_id))
378
+ if event.parent_id:
379
+ attrs.append(_attribute("agentloop.native_parent_id", event.parent_id))
380
+
381
+ span = {
382
+ "traceId": to_trace_id(trace.run_id),
383
+ "spanId": to_span_id(event.event_id),
384
+ "name": event.name,
385
+ "kind": event.metadata.get("otel_span_kind", "SPAN_KIND_INTERNAL"),
386
+ "startTimeUnixNano": str(start_ns),
387
+ "endTimeUnixNano": str(end_ns),
388
+ "attributes": attrs,
389
+ "status": {"code": "STATUS_CODE_OK" if event.status == "ok" else "STATUS_CODE_ERROR"},
390
+ }
391
+ if event.parent_id:
392
+ span["parentSpanId"] = to_span_id(event.parent_id)
393
+ if event.error:
394
+ span["status"]["message"] = event.error
395
+ for source, target in (
396
+ ("otel_links", "links"),
397
+ ("otel_events", "events"),
398
+ ("otel_trace_state", "traceState"),
399
+ ("otel_span_flags", "flags"),
400
+ ):
401
+ if source in event.metadata:
402
+ span[target] = deepcopy(event.metadata[source])
403
+ return span
404
+
405
+
406
+ def _user_metadata(attrs: dict[str, Any], span: dict[str, Any], span_id: str) -> dict[str, Any]:
407
+ """Build user event metadata from span attributes (issue #63).
408
+
409
+ Decodes the ``agentloop.metadata.`` namespace exactly once, preserves genuine
410
+ third-party attributes, and drops AgentLoop transport/native-id and gen_ai
411
+ bookkeeping keys so they never leak into or accumulate in user metadata.
412
+ """
413
+
414
+ decoded: dict[str, Any] = {}
415
+ passthrough: dict[str, Any] = {}
416
+ for key, value in attrs.items():
417
+ if key.startswith(_METADATA_PREFIX):
418
+ decoded[key[len(_METADATA_PREFIX) :]] = value
419
+ elif "agentloop.event_type" not in attrs and key == "gen_ai.operation.name":
420
+ passthrough[key] = value
421
+ elif "agentloop.event_type" in attrs and key in _USAGE_ATTRIBUTE_KEYS:
422
+ continue
423
+ elif key in _direct_attribute_keys() or key in _transport_attribute_keys():
424
+ continue
425
+ else:
426
+ passthrough[key] = value # third-party attribute, preserved as-is
427
+ # Native AgentLoop metadata wins over any colliding third-party attribute.
428
+ metadata = {**passthrough, **decoded}
429
+ # Transport diagnostics: available on the imported event but never re-exported.
430
+ metadata["otel_span_id"] = span_id
431
+ metadata["otel_trace_id"] = span.get("traceId") or span.get("trace_id")
432
+ return metadata
433
+
434
+
435
+ def _preserve_span_details(
436
+ span: dict[str, Any], attrs: dict[str, Any], metadata: dict[str, Any]
437
+ ) -> None:
438
+ native = "agentloop.event_type" in attrs
439
+ preserved = attrs.get("agentloop.preserved_span_fields", [])
440
+ for source, target in (
441
+ ("links", "otel_links"),
442
+ ("events", "otel_events"),
443
+ ("traceState", "otel_trace_state"),
444
+ ("flags", "otel_span_flags"),
445
+ ("kind", "otel_span_kind"),
446
+ ):
447
+ if source in span and (not native or target in preserved):
448
+ metadata.setdefault(target, deepcopy(span[source]))
449
+ if not native:
450
+ resource = span.get("_resource_attributes")
451
+ if resource:
452
+ metadata.setdefault("otel_resource_attributes", deepcopy(resource))
453
+ if span.get("_scope"):
454
+ metadata.setdefault("otel_scope", deepcopy(span["_scope"]))
455
+ if span.get("_schema_url"):
456
+ metadata.setdefault("otel_schema_url", span["_schema_url"])
457
+ evaluations = []
458
+ result = evaluation_result(attrs, source="span_attributes")
459
+ if result:
460
+ evaluations.append(result)
461
+ for event in span.get("events", []):
462
+ result = evaluation_result(_attributes(event), source=str(event.get("name", "span_event")))
463
+ if result:
464
+ evaluations.append(result)
465
+ if evaluations:
466
+ metadata.setdefault("evaluation_results", evaluations)
467
+
468
+
469
+ def _transport_attribute_keys() -> set[str]:
470
+ return {
471
+ "agentloop.run_id",
472
+ "agentloop.native_event_id",
473
+ "agentloop.native_parent_id",
474
+ "agentloop.preserved_span_fields",
475
+ }
476
+
477
+
478
+ def _reserved_metadata_keys() -> set[str]:
479
+ return {
480
+ "otel_span_id",
481
+ "otel_trace_id",
482
+ "otel_links",
483
+ "otel_events",
484
+ "otel_trace_state",
485
+ "otel_span_flags",
486
+ "otel_span_kind",
487
+ }
488
+
489
+
490
+ def _event_type(operation: str, name: Any) -> str:
491
+ op = operation.lower()
492
+ span_name = str(name or "").lower()
493
+ if op in {"chat", "text_completion", "embeddings", "generate_content"}:
494
+ return "model_call"
495
+ if op in {"execute_tool", "invoke_agent", "create_agent", "invoke_workflow", "retrieval"}:
496
+ return "tool_call"
497
+ if op in LEGACY_OPERATION_KINDS:
498
+ return op
499
+ if "tool" in span_name:
500
+ return "tool_call"
501
+ if op == "retry" or "retry" in span_name:
502
+ return "retry"
503
+ return "tool_call" if op in {"invoke_agent", "invoke_workflow"} else "model_call"
504
+
505
+
506
+ def _import_operation_kind(operation: str, event_type: str) -> str:
507
+ mapping = {
508
+ "chat": "model",
509
+ "text_completion": "model",
510
+ "embeddings": "model",
511
+ "generate_content": "model",
512
+ "execute_tool": "tool",
513
+ "invoke_agent": "agent",
514
+ "create_agent": "agent",
515
+ "invoke_workflow": "workflow",
516
+ "retrieval": "retriever",
517
+ **LEGACY_OPERATION_KINDS,
518
+ }
519
+ if not operation:
520
+ return LEGACY_OPERATION_KINDS.get(event_type, "unknown")
521
+ normalized = normalize_operation_kind(operation)
522
+ return mapping.get(
523
+ operation.strip().lower(), operation if normalized == "unknown" else normalized
524
+ )
525
+
526
+
527
+ def _operation_name(event_type: str, kind: str) -> str:
528
+ if kind in {"agent", "workflow", "retriever"}:
529
+ return {"agent": "invoke_agent", "workflow": "invoke_workflow", "retriever": "retrieval"}[
530
+ kind
531
+ ]
532
+ if kind in {"memory", "reranker", "guardrail", "evaluator", "retry"}:
533
+ return kind
534
+ if event_type == "model_call":
535
+ return "chat"
536
+ if event_type == "tool_call":
537
+ return "execute_tool"
538
+ return event_type
539
+
540
+
541
+ def _attributes(span: dict[str, Any]) -> dict[str, Any]:
542
+ raw = span.get("attributes", {})
543
+ if isinstance(raw, dict):
544
+ return raw.copy()
545
+ attrs: dict[str, Any] = {}
546
+ for item in raw or []:
547
+ key = item.get("key")
548
+ if key:
549
+ attrs[key] = _attribute_value(item.get("value", {}))
550
+ return attrs
551
+
552
+
553
+ def _attribute_value(value: dict[str, Any]) -> Any:
554
+ if not isinstance(value, dict):
555
+ return value
556
+ if "intValue" in value:
557
+ raw = value["intValue"]
558
+ if isinstance(raw, bool) or not isinstance(raw, int | str):
559
+ raise TraceValidationError("intValue", "must be an integer or decimal integer string")
560
+ try:
561
+ return int(raw)
562
+ except ValueError as exc:
563
+ raise TraceValidationError(
564
+ "intValue", "must be an integer or decimal integer string"
565
+ ) from exc
566
+ for key in ("stringValue", "doubleValue", "boolValue"):
567
+ if key in value:
568
+ return value[key]
569
+ if "arrayValue" in value:
570
+ values = value.get("arrayValue", {}).get("values", [])
571
+ return [_attribute_value(item) for item in values]
572
+ if "kvlistValue" in value:
573
+ return {
574
+ item["key"]: _attribute_value(item.get("value", {}))
575
+ for item in value["kvlistValue"].get("values", [])
576
+ }
577
+ if "bytesValue" in value:
578
+ return {"otel_bytes_base64": value["bytesValue"]}
579
+ if value:
580
+ return {"otel_unrecognized_value": deepcopy(value)}
581
+ return None
582
+
583
+
584
+ def _attribute(key: str, value: Any) -> dict[str, Any]:
585
+ if value is None:
586
+ encoded = {}
587
+ elif isinstance(value, dict):
588
+ encoded = {
589
+ "kvlistValue": {"values": [_attribute(str(name), item) for name, item in value.items()]}
590
+ }
591
+ elif isinstance(value, list):
592
+ encoded = {"arrayValue": {"values": [_attribute("", item)["value"] for item in value]}}
593
+ elif isinstance(value, bool):
594
+ encoded = {"boolValue": value}
595
+ elif isinstance(value, int):
596
+ encoded = {"intValue": str(value)}
597
+ elif isinstance(value, float):
598
+ encoded = {"doubleValue": value}
599
+ else:
600
+ encoded = {"stringValue": str(value)}
601
+ return {"key": key, "value": encoded}
602
+
603
+
604
+ def _nonnegative_timing(value: Any, field: str) -> float:
605
+ if (
606
+ isinstance(value, bool)
607
+ or not isinstance(value, int | float)
608
+ or not math.isfinite(value)
609
+ or value < 0
610
+ ):
611
+ raise TraceValidationError(field, "must be a finite nonnegative number")
612
+ return float(value)
613
+
614
+
615
+ def _duration_ms(started_ns: int | None, ended_ns: int | None, span: dict[str, Any]) -> float:
616
+ if started_ns is not None and ended_ns is not None and ended_ns >= started_ns:
617
+ return (ended_ns - started_ns) / _NANOSECONDS_PER_MILLISECOND
618
+ return float(span.get("duration_ms") or span.get("durationMs") or 0.0)
619
+
620
+
621
+ def _trace_bounds_ns(spans: list[dict[str, Any]]) -> tuple[int, int] | None:
622
+ if not spans:
623
+ return None
624
+ bounds: list[tuple[int, int]] = []
625
+ for span in spans:
626
+ started_ns = _int_or_none(span.get("startTimeUnixNano") or span.get("start_time_unix_nano"))
627
+ ended_ns = _int_or_none(span.get("endTimeUnixNano") or span.get("end_time_unix_nano"))
628
+ if started_ns is None or ended_ns is None or ended_ns < started_ns:
629
+ return None
630
+ bounds.append((started_ns, ended_ns))
631
+ return min(started for started, _ in bounds), max(ended for _, ended in bounds)
632
+
633
+
634
+ def _iso_from_ns(value: int | None) -> str:
635
+ if value is None:
636
+ return datetime.now(timezone.utc).isoformat()
637
+ return datetime.fromtimestamp(value / 1_000_000_000, tz=timezone.utc).isoformat()
638
+
639
+
640
+ def _ns_from_iso(value: str) -> int:
641
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
642
+ return int(parsed.timestamp() * 1_000_000_000)
643
+
644
+
645
+ def _int_or_none(value: Any) -> int | None:
646
+ if value is None or value == "":
647
+ return None
648
+ return int(value)
649
+
650
+
651
+ def _first_string(attrs: dict[str, Any], *keys: str) -> str | None:
652
+ for key in keys:
653
+ value = attrs.get(key)
654
+ if value:
655
+ return str(value)
656
+ return None
657
+
658
+
659
+ def _status(span: dict[str, Any]) -> str:
660
+ status = span.get("status", {})
661
+ if isinstance(status, dict) and (
662
+ status.get("code") in (2, "2") or str(status.get("code", "")).endswith("ERROR")
663
+ ):
664
+ return "error"
665
+ return "ok"
666
+
667
+
668
+ def _error(span: dict[str, Any]) -> str | None:
669
+ status = span.get("status", {})
670
+ if isinstance(status, dict):
671
+ if status.get("message"):
672
+ return str(status["message"])
673
+ for event in span.get("events", []):
674
+ if event.get("name") == "exception":
675
+ return _first_string(_attributes(event), "exception.message", "exception.type")
676
+ return None
677
+
678
+
679
+ _USAGE_ATTRIBUTE_KEYS = INPUT_USAGE + OUTPUT_USAGE
680
+
681
+
682
+ def _token_provenance(attrs: dict[str, Any]) -> str | None:
683
+ """Resolve a span's token provenance on import.
684
+
685
+ An AgentLoop-produced span carries its native provenance verbatim, so a round
686
+ trip preserves it. A third-party span does not, and is classified from what
687
+ it actually carried: standard ``gen_ai``/``llm`` usage attributes are emitted
688
+ by the instrumented SDK from provider usage, so they count as
689
+ ``provider``-reported; a span with no usage attributes has no counts to
690
+ report, which is ``unavailable`` rather than a measured zero.
691
+ """
692
+
693
+ native = attrs.get("agentloop.token_provenance")
694
+ if "agentloop.token_provenance" in attrs and (native is None or isinstance(native, str)):
695
+ return native
696
+ return PROVIDER if any(key in attrs for key in _USAGE_ATTRIBUTE_KEYS) else UNAVAILABLE
697
+
698
+
699
+ def _direct_attribute_keys() -> set[str]:
700
+ return {
701
+ "agentloop.model",
702
+ "agentloop.input_tokens",
703
+ "agentloop.output_tokens",
704
+ "agentloop.event_type",
705
+ "agentloop.operation_kind",
706
+ "agentloop.name",
707
+ "agentloop.duration_ms",
708
+ "agentloop.token_provenance",
709
+ "agentloop.trace.name",
710
+ "agentloop.trace.metadata",
711
+ "gen_ai.operation.name",
712
+ "gen_ai.request.model",
713
+ "gen_ai.response.model",
714
+ "gen_ai.tool.name",
715
+ }