agentloop-profiler 0.4.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.

Potentially problematic release.


This version of agentloop-profiler might be problematic. Click here for more details.

Files changed (50) hide show
  1. agentloop/__init__.py +68 -0
  2. agentloop/audit.py +34 -0
  3. agentloop/autoinstrument.py +102 -0
  4. agentloop/ci.py +135 -0
  5. agentloop/cli.py +876 -0
  6. agentloop/client.py +170 -0
  7. agentloop/config.py +50 -0
  8. agentloop/costs.py +15 -0
  9. agentloop/decorators.py +160 -0
  10. agentloop/demo.py +283 -0
  11. agentloop/doctor.py +344 -0
  12. agentloop/events.py +49 -0
  13. agentloop/exporters.py +60 -0
  14. agentloop/findings.py +215 -0
  15. agentloop/graph.py +312 -0
  16. agentloop/integrations/__init__.py +1 -0
  17. agentloop/integrations/crewai.py +144 -0
  18. agentloop/integrations/langgraph.py +140 -0
  19. agentloop/integrations/openai.py +369 -0
  20. agentloop/integrations/openai_agents.py +212 -0
  21. agentloop/integrations/sdk.py +82 -0
  22. agentloop/integrations/vercel_ai.py +83 -0
  23. agentloop/issues.py +88 -0
  24. agentloop/markdown.py +102 -0
  25. agentloop/metrics.py +123 -0
  26. agentloop/optimizer.py +323 -0
  27. agentloop/otel.py +302 -0
  28. agentloop/otel_ids.py +81 -0
  29. agentloop/patches.py +583 -0
  30. agentloop/plan_export.py +72 -0
  31. agentloop/quality.py +222 -0
  32. agentloop/replay.py +424 -0
  33. agentloop/runtime.py +244 -0
  34. agentloop/savings.py +135 -0
  35. agentloop/server.py +292 -0
  36. agentloop/store.py +840 -0
  37. agentloop/timing.py +124 -0
  38. agentloop/tracer.py +356 -0
  39. agentloop/value.py +243 -0
  40. agentloop/version.py +3 -0
  41. agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
  42. agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
  43. agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
  44. agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
  45. agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
  46. agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
  47. agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
  48. dashboard/__init__.py +1 -0
  49. dashboard/app.py +698 -0
  50. dashboard/value_view.py +101 -0
@@ -0,0 +1,369 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import time
5
+ from functools import wraps
6
+ from typing import Any
7
+
8
+ from agentloop.events import utc_now_iso
9
+ from agentloop.tracer import current_event_id, current_trace, record_model_call
10
+
11
+ # Marker set on wrappers so repeated instrumentation of the same callable is a
12
+ # no-op instead of stacking wrappers and double-recording.
13
+ _INSTRUMENTED_FLAG = "__agentloop_instrumented__"
14
+
15
+
16
+ def _usage_get(usage: Any, *names: str) -> int:
17
+ for name in names:
18
+ if usage is None:
19
+ continue
20
+ if isinstance(usage, dict) and name in usage:
21
+ return int(usage.get(name) or 0)
22
+ value = getattr(usage, name, None)
23
+ if value is not None:
24
+ return int(value or 0)
25
+ return 0
26
+
27
+
28
+ def _find_usage(obj: Any) -> Any:
29
+ """Locate a usage object on a response or a streaming event.
30
+
31
+ Non-streaming responses and chat chunks expose ``usage`` directly. The locked
32
+ Responses streaming API instead delivers final usage on the terminal
33
+ ``ResponseCompletedEvent`` under ``event.response.usage`` (the event itself has
34
+ no ``usage``), so fall back to the nested response shape.
35
+ """
36
+
37
+ if obj is None:
38
+ return None
39
+ usage = getattr(obj, "usage", None)
40
+ if usage is None and isinstance(obj, dict):
41
+ usage = obj.get("usage")
42
+ if usage is not None:
43
+ return usage
44
+ response = getattr(obj, "response", None)
45
+ if response is None and isinstance(obj, dict):
46
+ response = obj.get("response")
47
+ if response is None:
48
+ return None
49
+ nested = getattr(response, "usage", None)
50
+ if nested is None and isinstance(response, dict):
51
+ nested = response.get("usage")
52
+ return nested
53
+
54
+
55
+ def _extract_usage(result: Any) -> tuple[int, int]:
56
+ usage = _find_usage(result)
57
+ input_tokens = _usage_get(usage, "input_tokens", "prompt_tokens")
58
+ output_tokens = _usage_get(usage, "output_tokens", "completion_tokens")
59
+ return input_tokens, output_tokens
60
+
61
+
62
+ def _metadata_from_call(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
63
+ metadata: dict[str, Any] = {"integration": "openai"}
64
+ if "tools" in kwargs and kwargs["tools"] is not None:
65
+ try:
66
+ metadata["tool_count"] = len(kwargs["tools"])
67
+ except TypeError:
68
+ metadata["tool_count"] = 1
69
+ if "messages" in kwargs and kwargs["messages"] is not None:
70
+ try:
71
+ metadata["message_count"] = len(kwargs["messages"])
72
+ except TypeError:
73
+ metadata["message_count"] = 1
74
+ if args:
75
+ metadata["positional_args"] = len(args)
76
+ return metadata
77
+
78
+
79
+ def _model_from_call(kwargs: dict[str, Any], default_model: str | None = None) -> str | None:
80
+ return (
81
+ str(kwargs.get("model") or default_model)
82
+ if (kwargs.get("model") or default_model)
83
+ else None
84
+ )
85
+
86
+
87
+ class _CallRecorder:
88
+ """Collects timing for one instrumented call and records it exactly once.
89
+
90
+ Recording is deferred so streaming calls can be finalized when the stream is
91
+ consumed rather than when the iterator is created. Trace ownership is captured
92
+ at invocation time: the event is recorded into the trace that was active when
93
+ the call was made, regardless of which context consumes the stream later. If no
94
+ trace was active at invocation the call is never recorded, and the application
95
+ call is not disturbed.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ name: str,
101
+ args: tuple[Any, ...],
102
+ kwargs: dict[str, Any],
103
+ default_model: str | None,
104
+ ) -> None:
105
+ self._name = name
106
+ self._args = args
107
+ self._kwargs = kwargs
108
+ self._default_model = default_model
109
+ self._started_at = utc_now_iso()
110
+ self._start = time.perf_counter()
111
+ self._done = False
112
+ # Capture ownership now, at invocation time, not at finalization time.
113
+ self._trace = current_trace()
114
+ self._parent_id = current_event_id()
115
+
116
+ def finalize(self, result: Any = None, *, status: str = "ok", error: str | None = None) -> None:
117
+ if self._done:
118
+ return
119
+ self._done = True
120
+ if self._trace is None:
121
+ # No trace was active when the call was invoked: never record, and do
122
+ # not turn the caller's successful application call into an error.
123
+ return
124
+ input_tokens, output_tokens = _extract_usage(result)
125
+ record_model_call(
126
+ self._name,
127
+ started_at=self._started_at,
128
+ duration_ms=(time.perf_counter() - self._start) * 1000,
129
+ model=_model_from_call(self._kwargs, self._default_model),
130
+ input_tokens=input_tokens,
131
+ output_tokens=output_tokens,
132
+ status=status,
133
+ error=error,
134
+ metadata=_metadata_from_call(self._args, self._kwargs),
135
+ parent_id=self._parent_id,
136
+ trace=self._trace,
137
+ )
138
+
139
+
140
+ def _has_usage(obj: Any) -> bool:
141
+ return _find_usage(obj) is not None
142
+
143
+
144
+ def _stream_requested(kwargs: dict[str, Any]) -> bool:
145
+ return bool(kwargs.get("stream"))
146
+
147
+
148
+ def _is_sync_stream(result: Any) -> bool:
149
+ if isinstance(result, (dict, list, tuple, set, str, bytes, bytearray)):
150
+ return False
151
+ return (
152
+ hasattr(result, "__iter__") or hasattr(result, "__next__") or hasattr(result, "__enter__")
153
+ )
154
+
155
+
156
+ def _is_async_stream(result: Any) -> bool:
157
+ return (
158
+ hasattr(result, "__aiter__")
159
+ or hasattr(result, "__anext__")
160
+ or hasattr(result, "__aenter__")
161
+ or inspect.isasyncgen(result)
162
+ )
163
+
164
+
165
+ class _InstrumentedSyncStream:
166
+ """Proxy over a sync streaming response that finalizes on consumption.
167
+
168
+ Iteration, context-manager use, and ``close()`` all end the stream; whichever
169
+ happens first records the call (once). Every other attribute is delegated to
170
+ the wrapped stream so SDK behavior is preserved.
171
+ """
172
+
173
+ def __init__(self, stream: Any, recorder: _CallRecorder) -> None:
174
+ self._stream = stream
175
+ self._recorder = recorder
176
+ self._iter: Any = None
177
+ self._usage_source: Any = None
178
+
179
+ def __iter__(self) -> _InstrumentedSyncStream:
180
+ self._iter = iter(self._stream)
181
+ return self
182
+
183
+ def __next__(self) -> Any:
184
+ if self._iter is None:
185
+ self._iter = iter(self._stream)
186
+ try:
187
+ chunk = next(self._iter)
188
+ except StopIteration:
189
+ self._recorder.finalize(self._usage_source)
190
+ raise
191
+ except BaseException as exc: # noqa: BLE001 - record then propagate unchanged
192
+ self._recorder.finalize(self._usage_source, status="error", error=str(exc))
193
+ raise
194
+ if _has_usage(chunk):
195
+ self._usage_source = chunk
196
+ return chunk
197
+
198
+ def __enter__(self) -> _InstrumentedSyncStream:
199
+ if hasattr(self._stream, "__enter__"):
200
+ self._stream.__enter__()
201
+ return self
202
+
203
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
204
+ suppress = None
205
+ if hasattr(self._stream, "__exit__"):
206
+ suppress = self._stream.__exit__(exc_type, exc, tb)
207
+ if exc is not None:
208
+ self._recorder.finalize(self._usage_source, status="error", error=str(exc))
209
+ else:
210
+ self._recorder.finalize(self._usage_source)
211
+ return suppress
212
+
213
+ def close(self) -> Any:
214
+ try:
215
+ if hasattr(self._stream, "close"):
216
+ return self._stream.close()
217
+ return None
218
+ finally:
219
+ self._recorder.finalize(self._usage_source)
220
+
221
+ def __getattr__(self, item: str) -> Any:
222
+ return getattr(self._stream, item)
223
+
224
+
225
+ class _InstrumentedAsyncStream:
226
+ """Async counterpart of :class:`_InstrumentedSyncStream`."""
227
+
228
+ def __init__(self, stream: Any, recorder: _CallRecorder) -> None:
229
+ self._stream = stream
230
+ self._recorder = recorder
231
+ self._iter: Any = None
232
+ self._usage_source: Any = None
233
+
234
+ def __aiter__(self) -> _InstrumentedAsyncStream:
235
+ self._iter = (
236
+ self._stream.__aiter__() if hasattr(self._stream, "__aiter__") else self._stream
237
+ )
238
+ return self
239
+
240
+ async def __anext__(self) -> Any:
241
+ if self._iter is None:
242
+ self._iter = (
243
+ self._stream.__aiter__() if hasattr(self._stream, "__aiter__") else self._stream
244
+ )
245
+ try:
246
+ chunk = await self._iter.__anext__()
247
+ except StopAsyncIteration:
248
+ self._recorder.finalize(self._usage_source)
249
+ raise
250
+ except BaseException as exc: # noqa: BLE001 - record then propagate unchanged
251
+ self._recorder.finalize(self._usage_source, status="error", error=str(exc))
252
+ raise
253
+ if _has_usage(chunk):
254
+ self._usage_source = chunk
255
+ return chunk
256
+
257
+ async def __aenter__(self) -> _InstrumentedAsyncStream:
258
+ if hasattr(self._stream, "__aenter__"):
259
+ await self._stream.__aenter__()
260
+ return self
261
+
262
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
263
+ suppress = None
264
+ if hasattr(self._stream, "__aexit__"):
265
+ suppress = await self._stream.__aexit__(exc_type, exc, tb)
266
+ if exc is not None:
267
+ self._recorder.finalize(self._usage_source, status="error", error=str(exc))
268
+ else:
269
+ self._recorder.finalize(self._usage_source)
270
+ return suppress
271
+
272
+ async def aclose(self) -> Any:
273
+ try:
274
+ if hasattr(self._stream, "aclose"):
275
+ return await self._stream.aclose()
276
+ return None
277
+ finally:
278
+ self._recorder.finalize(self._usage_source)
279
+
280
+ def __getattr__(self, item: str) -> Any:
281
+ return getattr(self._stream, item)
282
+
283
+
284
+ def instrument_callable(
285
+ fn: Any,
286
+ *,
287
+ name: str = "openai.model_call",
288
+ default_model: str | None = None,
289
+ ) -> Any:
290
+ """Wrap a sync or async OpenAI-like callable and record token usage.
291
+
292
+ The wrapper is dependency-free and works with OpenAI SDK resource methods such
293
+ as `client.responses.create(...)` or `client.chat.completions.create(...)`.
294
+ It records prompt/completion usage when the returned object exposes a
295
+ `.usage` object or a `{"usage": ...}` dict.
296
+
297
+ Wrapping is idempotent: re-instrumenting an already-instrumented callable
298
+ returns it unchanged, so one request records one event. Streaming responses
299
+ (requested with `stream=True`) are finalized when the stream is consumed,
300
+ closed, fails, or is cancelled — not when the iterator is created — so the
301
+ recorded duration covers consumption and final usage is captured when the SDK
302
+ provides it. Calls made without an active trace are not recorded and are not
303
+ turned into errors.
304
+ """
305
+
306
+ if getattr(fn, _INSTRUMENTED_FLAG, False):
307
+ return fn
308
+
309
+ if inspect.iscoroutinefunction(fn):
310
+
311
+ @wraps(fn)
312
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
313
+ recorder = _CallRecorder(name, args, kwargs, default_model)
314
+ try:
315
+ result = await fn(*args, **kwargs)
316
+ except Exception as exc:
317
+ recorder.finalize(None, status="error", error=str(exc))
318
+ raise
319
+ if _stream_requested(kwargs) and _is_async_stream(result):
320
+ return _InstrumentedAsyncStream(result, recorder)
321
+ recorder.finalize(result)
322
+ return result
323
+
324
+ setattr(async_wrapper, _INSTRUMENTED_FLAG, True)
325
+ return async_wrapper
326
+
327
+ @wraps(fn)
328
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
329
+ recorder = _CallRecorder(name, args, kwargs, default_model)
330
+ try:
331
+ result = fn(*args, **kwargs)
332
+ except Exception as exc:
333
+ recorder.finalize(None, status="error", error=str(exc))
334
+ raise
335
+ if _stream_requested(kwargs) and _is_sync_stream(result):
336
+ return _InstrumentedSyncStream(result, recorder)
337
+ recorder.finalize(result)
338
+ return result
339
+
340
+ setattr(wrapper, _INSTRUMENTED_FLAG, True)
341
+ return wrapper
342
+
343
+
344
+ def instrument_openai_client(client: Any) -> Any:
345
+ """Patch common OpenAI SDK call sites in-place.
346
+
347
+ Supported call sites when present:
348
+ - client.responses.create
349
+ - client.chat.completions.create
350
+
351
+ The function returns the same client object for ergonomic usage:
352
+
353
+ ```python
354
+ client = instrument_openai_client(OpenAI())
355
+ ```
356
+ """
357
+
358
+ responses = getattr(client, "responses", None)
359
+ if responses is not None and callable(getattr(responses, "create", None)):
360
+ responses.create = instrument_callable(responses.create, name="openai.responses.create")
361
+
362
+ chat = getattr(client, "chat", None)
363
+ completions = getattr(chat, "completions", None) if chat is not None else None
364
+ if completions is not None and callable(getattr(completions, "create", None)):
365
+ completions.create = instrument_callable(
366
+ completions.create, name="openai.chat.completions.create"
367
+ )
368
+
369
+ return client
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from agentloop.otel import trace_from_otel
8
+ from agentloop.otel_ids import to_span_id, to_trace_id
9
+ from agentloop.tracer import AgentTrace
10
+
11
+
12
+ class AgentLoopTracingProcessor:
13
+ """OpenAI Agents SDK-compatible tracing processor.
14
+
15
+ The OpenAI Agents SDK calls `on_trace_start`, `on_trace_end`, `on_span_start`,
16
+ and `on_span_end` on registered processors. This class avoids importing the SDK
17
+ so AgentLoop can expose the integration without adding a hard dependency.
18
+ """
19
+
20
+ def __init__(self, out_dir: str | Path | None = None, *, retain_exported: bool = True):
21
+ self.out_dir = Path(out_dir) if out_dir is not None else None
22
+ # Retain exported AgentTrace objects on ``exported_traces``. Long-lived
23
+ # processors that never read that list can set ``retain_exported=False``
24
+ # so completed traces are not held in memory.
25
+ self.retain_exported = retain_exported
26
+ self._traces: dict[str, Any] = {}
27
+ # Trace ids started but not yet ended, in start order. Used to attribute
28
+ # spans that arrive without a readable trace id to a single open trace.
29
+ self._active_trace_ids: list[str] = []
30
+ # Spans are grouped by their owning trace id and released when that trace
31
+ # ends, so memory does not grow with the number of completed traces.
32
+ self._spans_by_trace: dict[str, list[Any]] = {}
33
+ self.exported_traces: list[AgentTrace] = []
34
+
35
+ def on_trace_start(self, trace: Any) -> None:
36
+ trace_id = str(_read(trace, "trace_id", "id") or "trace_unknown")
37
+ self._traces[trace_id] = trace
38
+ if trace_id not in self._active_trace_ids:
39
+ self._active_trace_ids.append(trace_id)
40
+
41
+ def on_trace_end(self, trace: Any) -> None:
42
+ trace_id = str(_read(trace, "trace_id", "id") or "trace_unknown")
43
+ if not self._is_known(trace_id):
44
+ # Unknown trace id: already exported (duplicate on_trace_end) or never
45
+ # observed. Nothing to export and nothing to release.
46
+ return
47
+
48
+ try:
49
+ agent_trace = self._build_trace(trace, trace_id)
50
+ if self.retain_exported:
51
+ self.exported_traces.append(agent_trace)
52
+ if self.out_dir is not None:
53
+ self.out_dir.mkdir(parents=True, exist_ok=True)
54
+ agent_trace.export_json(self.out_dir / f"{agent_trace.run_id}.json")
55
+ finally:
56
+ # Release state for the completed trace even if build/export fails, so a
57
+ # failed export cannot re-introduce the completed-trace memory leak.
58
+ self._traces.pop(trace_id, None)
59
+ self._spans_by_trace.pop(trace_id, None)
60
+ if trace_id in self._active_trace_ids:
61
+ self._active_trace_ids.remove(trace_id)
62
+
63
+ def on_span_start(self, span: Any) -> None:
64
+ return None
65
+
66
+ def on_span_end(self, span: Any) -> None:
67
+ trace_id = _span_trace_id(span)
68
+ if trace_id is None:
69
+ # No readable trace id: attribute the span to the most recently started
70
+ # open trace. If none is open, drop it rather than copy it into an
71
+ # unrelated trace that ends later.
72
+ trace_id = self._current_active_trace_id()
73
+ if trace_id is None:
74
+ return
75
+ self._spans_by_trace.setdefault(trace_id, []).append(span)
76
+
77
+ def shutdown(self) -> None:
78
+ # Release all retained state; further callbacks start from empty.
79
+ self._traces.clear()
80
+ self._active_trace_ids.clear()
81
+ self._spans_by_trace.clear()
82
+
83
+ def force_flush(self) -> None:
84
+ # Spans are exported when their trace ends; nothing is buffered to flush.
85
+ return None
86
+
87
+ def _current_active_trace_id(self) -> str | None:
88
+ return self._active_trace_ids[-1] if self._active_trace_ids else None
89
+
90
+ def _is_known(self, trace_id: str) -> bool:
91
+ return (
92
+ trace_id in self._traces
93
+ or trace_id in self._spans_by_trace
94
+ or trace_id in self._active_trace_ids
95
+ )
96
+
97
+ def _build_trace(self, trace: Any, trace_id: str | None = None) -> AgentTrace:
98
+ if trace_id is None:
99
+ trace_id = str(_read(trace, "trace_id", "id") or "trace_unknown")
100
+ name = str(_read(trace, "name", "workflow_name") or "openai_agents_trace")
101
+ spans = [_span_to_otel(span, trace_id) for span in self._spans_by_trace.get(trace_id, [])]
102
+ return trace_from_otel({"spans": spans}, name=name)
103
+
104
+
105
+ def _span_to_otel(span: Any, trace_id: str) -> dict[str, Any]:
106
+ span_data = _read(span, "span_data") or _read(span, "data") or {}
107
+ span_type = _read(span_data, "type") or span_data.__class__.__name__.lower()
108
+ name = str(_read(span_data, "name") or _read(span, "name") or span_type or "openai_agents_span")
109
+ model = _read(span_data, "model")
110
+ usage = _read(span_data, "usage") or {}
111
+ attrs = [
112
+ _attribute("gen_ai.operation.name", _operation_from_span_type(str(span_type))),
113
+ _attribute("agentloop.event_type", _event_type_from_span_type(str(span_type))),
114
+ _attribute("agentloop.name", name),
115
+ ]
116
+ if model:
117
+ attrs.append(_attribute("gen_ai.request.model", str(model)))
118
+ input_tokens = (
119
+ _read(usage, "input_tokens", "prompt_tokens")
120
+ or _read(span_data, "input_tokens", "prompt_tokens")
121
+ or 0
122
+ )
123
+ output_tokens = (
124
+ _read(usage, "output_tokens", "completion_tokens")
125
+ or _read(span_data, "output_tokens", "completion_tokens")
126
+ or 0
127
+ )
128
+ attrs.append(_attribute("gen_ai.usage.input_tokens", int(input_tokens or 0)))
129
+ attrs.append(_attribute("gen_ai.usage.output_tokens", int(output_tokens or 0)))
130
+
131
+ native_span_id = str(_read(span, "span_id", "id") or "0")
132
+ # Preserve the original native ids for diagnosability (see agentloop.otel_ids).
133
+ attrs.append(_attribute("agentloop.native_span_id", native_span_id))
134
+ attrs.append(_attribute("agentloop.native_trace_id", str(trace_id)))
135
+ out = {
136
+ "traceId": to_trace_id(trace_id),
137
+ "spanId": to_span_id(native_span_id),
138
+ "name": name,
139
+ "startTimeUnixNano": str(_time_ns(_read(span, "started_at", "start_time"))),
140
+ "endTimeUnixNano": str(_time_ns(_read(span, "ended_at", "end_time"))),
141
+ "attributes": attrs,
142
+ "status": {"code": "STATUS_CODE_OK"},
143
+ }
144
+ parent_id = _read(span, "parent_id", "parent_span_id")
145
+ if parent_id:
146
+ attrs.append(_attribute("agentloop.native_parent_id", str(parent_id)))
147
+ out["parentSpanId"] = to_span_id(str(parent_id))
148
+ return out
149
+
150
+
151
+ def _read(obj: Any, *names: str) -> Any:
152
+ for name in names:
153
+ if isinstance(obj, dict) and name in obj:
154
+ return obj[name]
155
+ if hasattr(obj, name):
156
+ return getattr(obj, name)
157
+ return None
158
+
159
+
160
+ def _span_trace_id(span: Any) -> str | None:
161
+ value = _read(span, "trace_id")
162
+ return str(value) if value else None
163
+
164
+
165
+ def _operation_from_span_type(span_type: str) -> str:
166
+ lower = span_type.lower()
167
+ if "generation" in lower or "response" in lower or "llm" in lower:
168
+ return "chat"
169
+ if "function" in lower or "tool" in lower:
170
+ return "execute_tool"
171
+ return "invoke_agent"
172
+
173
+
174
+ def _event_type_from_span_type(span_type: str) -> str:
175
+ operation = _operation_from_span_type(span_type)
176
+ return "model_call" if operation == "chat" else "tool_call"
177
+
178
+
179
+ def _attribute(key: str, value: Any) -> dict[str, Any]:
180
+ if isinstance(value, bool):
181
+ encoded = {"boolValue": value}
182
+ elif isinstance(value, int):
183
+ encoded = {"intValue": value}
184
+ elif isinstance(value, float):
185
+ encoded = {"doubleValue": value}
186
+ else:
187
+ encoded = {"stringValue": str(value)}
188
+ return {"key": key, "value": encoded}
189
+
190
+
191
+ def _time_ns(value: Any) -> int:
192
+ if value is None:
193
+ return 0
194
+ if isinstance(value, int):
195
+ return value
196
+ if isinstance(value, float):
197
+ return int(value * 1_000_000_000)
198
+ try:
199
+ from datetime import datetime
200
+
201
+ return int(
202
+ datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() * 1_000_000_000
203
+ )
204
+ except ValueError:
205
+ return 0
206
+
207
+
208
+ def export_trace_json(trace: AgentTrace, path: str | Path) -> Path:
209
+ out = Path(path)
210
+ out.parent.mkdir(parents=True, exist_ok=True)
211
+ out.write_text(json.dumps(trace.to_dict(), indent=2), encoding="utf-8")
212
+ return out
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def instrument(obj: Any, *, kind: str | None = None, name: str | None = None) -> Any:
7
+ """Best-effort one-line instrumentation for common agent objects.
8
+
9
+ Explicit `kind` values:
10
+ - `openai`
11
+ - `crewai_crew`
12
+ - `crewai_agent`
13
+ - `crewai_task`
14
+ - `langgraph_state_graph`
15
+ - `langgraph_runnable`
16
+
17
+ Without `kind`, AgentLoop uses duck-typing to choose the safest wrapper.
18
+ """
19
+
20
+ normalized = kind.lower().replace("-", "_") if kind else None
21
+
22
+ if normalized == "openai" or _looks_like_openai_client(obj):
23
+ from agentloop.integrations.openai import instrument_openai_client
24
+
25
+ return instrument_openai_client(obj)
26
+
27
+ if normalized == "crewai_crew" or _looks_like_crewai_crew(obj):
28
+ from agentloop.integrations.crewai import instrument_crew
29
+
30
+ return instrument_crew(obj, name=name)
31
+
32
+ if normalized == "crewai_agent":
33
+ from agentloop.integrations.crewai import instrument_agent
34
+
35
+ return instrument_agent(obj, name=name)
36
+
37
+ if normalized == "crewai_task":
38
+ from agentloop.integrations.crewai import instrument_task
39
+
40
+ return instrument_task(obj, name=name)
41
+
42
+ if normalized == "langgraph_state_graph" or _looks_like_state_graph(obj):
43
+ from agentloop.integrations.langgraph import instrument_state_graph
44
+
45
+ return instrument_state_graph(obj)
46
+
47
+ if normalized == "langgraph_runnable" or _looks_like_runnable(obj):
48
+ from agentloop.integrations.langgraph import trace_runnable
49
+
50
+ return trace_runnable(
51
+ obj, name=name or getattr(obj, "name", None) or obj.__class__.__name__
52
+ )
53
+
54
+ raise ValueError(
55
+ "Could not infer object type for AgentLoop instrumentation. "
56
+ "Pass kind='openai', 'crewai_crew', 'crewai_agent', 'crewai_task', "
57
+ "'langgraph_state_graph', or 'langgraph_runnable'."
58
+ )
59
+
60
+
61
+ def _looks_like_openai_client(obj: Any) -> bool:
62
+ responses = getattr(obj, "responses", None)
63
+ chat = getattr(obj, "chat", None)
64
+ completions = getattr(chat, "completions", None) if chat is not None else None
65
+ return bool(
66
+ responses is not None
67
+ and callable(getattr(responses, "create", None))
68
+ or completions is not None
69
+ and callable(getattr(completions, "create", None))
70
+ )
71
+
72
+
73
+ def _looks_like_crewai_crew(obj: Any) -> bool:
74
+ return callable(getattr(obj, "kickoff", None)) or callable(getattr(obj, "kickoff_async", None))
75
+
76
+
77
+ def _looks_like_state_graph(obj: Any) -> bool:
78
+ return callable(getattr(obj, "add_node", None)) and callable(getattr(obj, "compile", None))
79
+
80
+
81
+ def _looks_like_runnable(obj: Any) -> bool:
82
+ return callable(getattr(obj, "invoke", None)) or callable(getattr(obj, "ainvoke", None))