agent-observability-trace-cli 0.1.2__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 (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. agent_trace/py.typed +0 -0
@@ -0,0 +1,199 @@
1
+ """
2
+ OTLP exporter — sends spans to an OpenTelemetry collector endpoint.
3
+
4
+ Requires: pip install opentelemetry-exporter-otlp-proto-grpc
5
+
6
+ This exporter converts agent-trace Span objects to OTLP format and
7
+ sends them via gRPC. Use this to integrate with Jaeger, Grafana Tempo,
8
+ or any OTLP-compatible backend.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from agent_trace.core.span import SpanStatus
17
+
18
+ if TYPE_CHECKING:
19
+ from agent_trace import Span, Trace
20
+
21
+ __all__ = [
22
+ "OTLPExporter",
23
+ ]
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _OTLP_INSTALL_HINT = (
28
+ "The OTLP exporter requires the OpenTelemetry gRPC package.\n"
29
+ "Install it with:\n\n"
30
+ " pip install opentelemetry-exporter-otlp-proto-grpc\n"
31
+ )
32
+
33
+ # Nanoseconds per second — OTLP uses int nanoseconds for timestamps
34
+ _NS_PER_SEC: int = 1_000_000_000
35
+
36
+
37
+ class OTLPExporter:
38
+ """Export a :class:`~agent_trace.Trace` to an OTLP-compatible backend.
39
+
40
+ Converts agent-trace spans to OpenTelemetry ``ResourceSpan`` format and
41
+ ships them to *endpoint* via gRPC.
42
+
43
+ Parameters
44
+ ----------
45
+ endpoint:
46
+ gRPC endpoint of the OpenTelemetry collector, e.g.
47
+ ``"http://localhost:4317"`` (the default).
48
+ """
49
+
50
+ def __init__(self, endpoint: str = "http://localhost:4317") -> None:
51
+ self.endpoint: str = endpoint
52
+
53
+ # ------------------------------------------------------------------
54
+ # Public API
55
+ # ------------------------------------------------------------------
56
+
57
+ def export(self, trace: Trace) -> None:
58
+ """Send all spans in *trace* to the configured OTLP endpoint."""
59
+ try:
60
+ from opentelemetry import context as otel_context
61
+ from opentelemetry import trace as otel_trace
62
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
63
+ OTLPSpanExporter,
64
+ )
65
+ from opentelemetry.sdk.resources import Resource
66
+ from opentelemetry.sdk.trace import TracerProvider
67
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
68
+ from opentelemetry.trace import (
69
+ NonRecordingSpan,
70
+ SpanContext,
71
+ StatusCode,
72
+ TraceFlags,
73
+ )
74
+ except ImportError as exc:
75
+ raise ImportError(_OTLP_INSTALL_HINT) from exc
76
+
77
+ # Build the status map once per export call rather than per span.
78
+ status_map: dict[SpanStatus, Any] = {
79
+ SpanStatus.OK: StatusCode.OK,
80
+ SpanStatus.ERROR: StatusCode.ERROR,
81
+ SpanStatus.UNSET: StatusCode.UNSET,
82
+ }
83
+
84
+ service_name = str(trace.metadata.get("name", "agent-trace"))
85
+ exporter = OTLPSpanExporter(endpoint=self.endpoint)
86
+ resource = Resource.create({"service.name": service_name})
87
+ provider = TracerProvider(resource=resource)
88
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
89
+ otel_tracer = provider.get_tracer("agent-trace")
90
+ failed = 0
91
+
92
+ try:
93
+ for span in trace.spans:
94
+ otlp_data = self._span_to_otlp(span, status_map)
95
+ try:
96
+ trace_id_int = (
97
+ int(span.trace_id, 16)
98
+ if _is_hex(span.trace_id)
99
+ else hash(span.trace_id) & ((1 << 128) - 1)
100
+ )
101
+ parent_ctx = otel_context.get_current()
102
+ if span.parent_id is not None:
103
+ parent_span_id_int = (
104
+ int(span.parent_id, 16)
105
+ if _is_hex(span.parent_id)
106
+ else hash(span.parent_id) & ((1 << 64) - 1)
107
+ )
108
+ parent_span_context = SpanContext(
109
+ trace_id=trace_id_int,
110
+ span_id=parent_span_id_int,
111
+ is_remote=True,
112
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
113
+ )
114
+ parent_ctx = otel_trace.set_span_in_context(
115
+ NonRecordingSpan(parent_span_context)
116
+ )
117
+
118
+ otel_span = otel_tracer.start_span(
119
+ span.name,
120
+ context=parent_ctx,
121
+ start_time=otlp_data["start_time_unix_nano"],
122
+ )
123
+ for k, v in span.attributes.items():
124
+ attr_v = v if isinstance(v, (bool, int, float, str)) else str(v)
125
+ otel_span.set_attribute(k, attr_v)
126
+ for event in span.events:
127
+ otel_span.add_event(
128
+ event.name,
129
+ attributes={k: str(v) for k, v in event.attributes.items()},
130
+ )
131
+ otel_span.set_status(otlp_data["status_code"])
132
+ otel_span.end(end_time=otlp_data["end_time_unix_nano"])
133
+ except Exception:
134
+ failed += 1
135
+ logger.debug(
136
+ "agent-trace: failed to export span %r to OTLP",
137
+ span.span_id,
138
+ exc_info=True,
139
+ )
140
+ finally:
141
+ try:
142
+ provider.shutdown()
143
+ except Exception:
144
+ logger.debug(
145
+ "agent-trace: OTLP provider shutdown failed", exc_info=True
146
+ )
147
+
148
+ if failed:
149
+ logger.warning(
150
+ "agent-trace: %d/%d span(s) failed to export to %s",
151
+ failed,
152
+ len(trace.spans),
153
+ self.endpoint,
154
+ )
155
+
156
+ def _span_to_otlp(
157
+ self, span: Span, status_map: dict[SpanStatus, Any]
158
+ ) -> dict[str, Any]:
159
+ """Convert a :class:`~agent_trace.Span` to an OTLP-compatible dict.
160
+
161
+ The returned dict contains::
162
+
163
+ {
164
+ "name": str,
165
+ "trace_id": str,
166
+ "span_id": str,
167
+ "parent_span_id": str | None,
168
+ "start_time_unix_nano": int,
169
+ "end_time_unix_nano": int | None,
170
+ "status_code": StatusCode,
171
+ "attributes": list[KeyValue],
172
+ }
173
+ """
174
+ end_ns: int | None = (
175
+ int(span.end_time * _NS_PER_SEC) if span.end_time is not None else None
176
+ )
177
+ return {
178
+ "name": span.name,
179
+ "trace_id": span.trace_id,
180
+ "span_id": span.span_id,
181
+ "parent_span_id": span.parent_id,
182
+ "start_time_unix_nano": int(span.start_time * _NS_PER_SEC),
183
+ "end_time_unix_nano": end_ns,
184
+ "status_code": status_map.get(span.status, status_map[SpanStatus.UNSET]),
185
+ "attributes": [{"key": k, "value": v} for k, v in span.attributes.items()],
186
+ }
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Helpers
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ def _is_hex(s: str) -> bool:
195
+ try:
196
+ int(s, 16)
197
+ return True
198
+ except ValueError:
199
+ return False
@@ -0,0 +1,307 @@
1
+ """
2
+ Durable/remote fixture backend — so a recording survives a killed or swept
3
+ worker process.
4
+
5
+ Background
6
+ ----------
7
+
8
+ Fixtures and trace files are written only to the invoking process's local
9
+ filesystem (``Tracer.start_trace()``'s ``run_dir``); the only non-local
10
+ exporter is OTLP, which carries span metadata but not HTTP request/response
11
+ bodies. On managed platforms (e.g. LangGraph Cloud — issue #7417), a worker
12
+ whose run gets swept/re-dispatched (or simply killed) has an ephemeral,
13
+ developer-inaccessible local filesystem — any fixture recorded there is
14
+ unrecoverable after the fact.
15
+
16
+ This module adds an object-store-backed ``RemoteFixtureBackend`` so a
17
+ recorded trace and its captured wire-level bodies persist independently of
18
+ the worker process that produced them:
19
+
20
+ - :class:`RemoteFixtureBackend` — the storage-agnostic protocol
21
+ (``put_bytes``/``get_bytes``/``list_keys``).
22
+ - :class:`LocalDirRemoteFixtureBackend` — writes to a second directory,
23
+ e.g. a network-mounted (NFS/EFS/SMB) path distinct from the worker's own
24
+ ephemeral local disk. A fully real, dependency-free backend on its own
25
+ for any deployment where a shared mount is available, and the reference
26
+ implementation the two cloud backends below are tested against.
27
+ - :class:`S3RemoteFixtureBackend` — lazy ``import boto3``; requires
28
+ ``pip install boto3``.
29
+ - :class:`GCSRemoteFixtureBackend` — lazy
30
+ ``import google.cloud.storage``; requires
31
+ ``pip install google-cloud-storage``.
32
+
33
+ Usage — durable per-exchange sync while recording (survives the worker
34
+ being killed mid-run, not just at clean exit)::
35
+
36
+ from agent_trace.exporters.remote_fixture import (
37
+ LocalDirRemoteFixtureBackend,
38
+ remote_sync_callback,
39
+ )
40
+
41
+ backend = LocalDirRemoteFixtureBackend(Path("/mnt/shared/agent-trace"))
42
+ with tracer.start_trace("my_graph", record=True,
43
+ remote_backend=backend) as trace:
44
+ ...
45
+
46
+ Usage — one-shot sync/restore of an already-recorded run::
47
+
48
+ from agent_trace.exporters.remote_fixture import (
49
+ sync_run_to_remote,
50
+ restore_run_from_remote,
51
+ )
52
+
53
+ sync_run_to_remote(run_dir, backend, run_id="run_abc123")
54
+ restored_dir = restore_run_from_remote(backend, run_id="run_abc123",
55
+ dest_dir=Path("/tmp/restored"))
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import json
61
+ import logging
62
+ from abc import ABC, abstractmethod
63
+ from pathlib import Path
64
+ from typing import Any
65
+
66
+ __all__ = [
67
+ "GCSRemoteFixtureBackend",
68
+ "LocalDirRemoteFixtureBackend",
69
+ "RemoteFixtureBackend",
70
+ "S3RemoteFixtureBackend",
71
+ "remote_sync_callback",
72
+ "restore_run_from_remote",
73
+ "sync_run_to_remote",
74
+ ]
75
+
76
+ logger = logging.getLogger(__name__)
77
+
78
+ _S3_INSTALL_HINT = (
79
+ "S3RemoteFixtureBackend requires boto3.\nInstall it with:\n\n"
80
+ " pip install boto3\n"
81
+ )
82
+ _GCS_INSTALL_HINT = (
83
+ "GCSRemoteFixtureBackend requires google-cloud-storage.\n"
84
+ "Install it with:\n\n"
85
+ " pip install google-cloud-storage\n"
86
+ )
87
+
88
+
89
+ class RemoteFixtureBackend(ABC):
90
+ """Storage-agnostic protocol for durably persisting fixture data
91
+ somewhere other than the worker process's own local filesystem."""
92
+
93
+ @abstractmethod
94
+ def put_bytes(self, key: str, data: bytes) -> None:
95
+ """Durably store *data* under *key*, overwriting any existing value."""
96
+
97
+ @abstractmethod
98
+ def get_bytes(self, key: str) -> bytes | None:
99
+ """Return the bytes stored under *key*, or None if absent."""
100
+
101
+ @abstractmethod
102
+ def list_keys(self, prefix: str) -> list[str]:
103
+ """Return every stored key starting with *prefix*."""
104
+
105
+
106
+ class LocalDirRemoteFixtureBackend(RemoteFixtureBackend):
107
+ """Writes to a second directory — e.g. a network-mounted path distinct
108
+ from the worker's own ephemeral local disk. Real and fully functional
109
+ (not a mock/stub): a legitimate deployment choice on its own whenever a
110
+ shared mount (NFS/EFS/SMB) is available, and the backend the cloud
111
+ implementations below are tested against for correctness."""
112
+
113
+ def __init__(self, root: Path) -> None:
114
+ self.root = Path(root)
115
+ self.root.mkdir(parents=True, exist_ok=True)
116
+
117
+ def _path_for(self, key: str) -> Path:
118
+ base = self.root.resolve()
119
+ candidate = (base / key).resolve()
120
+ try:
121
+ candidate.relative_to(base)
122
+ except ValueError:
123
+ raise ValueError(f"Invalid key {key!r}: path traversal detected") from None
124
+ return candidate
125
+
126
+ def put_bytes(self, key: str, data: bytes) -> None:
127
+ path = self._path_for(key)
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ path.write_bytes(data)
130
+
131
+ def get_bytes(self, key: str) -> bytes | None:
132
+ path = self._path_for(key)
133
+ if not path.exists():
134
+ return None
135
+ return path.read_bytes()
136
+
137
+ def list_keys(self, prefix: str) -> list[str]:
138
+ base = self.root.resolve()
139
+ prefix_path = self._path_for(prefix)
140
+ search_root = prefix_path if prefix_path.is_dir() else prefix_path.parent
141
+ if not search_root.exists():
142
+ return []
143
+ return sorted(
144
+ str(p.relative_to(base))
145
+ for p in search_root.rglob("*")
146
+ if p.is_file() and str(p.relative_to(base)).startswith(prefix)
147
+ )
148
+
149
+
150
+ class S3RemoteFixtureBackend(RemoteFixtureBackend):
151
+ """S3-backed remote fixture store. ``boto3`` is imported lazily — this
152
+ class can be referenced (e.g. for isinstance checks) even when boto3
153
+ isn't installed; only constructing an instance requires it."""
154
+
155
+ def __init__(self, bucket: str, prefix: str = "", client: Any = None) -> None:
156
+ self.bucket = bucket
157
+ self.prefix = prefix.rstrip("/")
158
+ self._client = client or self._make_client()
159
+
160
+ def _make_client(self) -> Any:
161
+ try:
162
+ import boto3
163
+ except ImportError as exc:
164
+ raise ImportError(_S3_INSTALL_HINT) from exc
165
+ return boto3.client("s3")
166
+
167
+ def _full_key(self, key: str) -> str:
168
+ return f"{self.prefix}/{key}" if self.prefix else key
169
+
170
+ def put_bytes(self, key: str, data: bytes) -> None:
171
+ self._client.put_object(Bucket=self.bucket, Key=self._full_key(key), Body=data)
172
+
173
+ def get_bytes(self, key: str) -> bytes | None:
174
+ try:
175
+ response = self._client.get_object(
176
+ Bucket=self.bucket, Key=self._full_key(key)
177
+ )
178
+ except Exception:
179
+ # botocore raises a dynamically-generated ClientError subclass
180
+ # (e.g. NoSuchKey) — catching Exception broadly here (rather
181
+ # than importing botocore just to catch its specific error
182
+ # type) keeps this backend usable with any S3-compatible client
183
+ # a caller supplies via the `client=` param.
184
+ return None
185
+ return response["Body"].read() # type: ignore[no-any-return]
186
+
187
+ def list_keys(self, prefix: str) -> list[str]:
188
+ full_prefix = self._full_key(prefix)
189
+ keys: list[str] = []
190
+ continuation_token: str | None = None
191
+ while True:
192
+ kwargs: dict[str, Any] = {"Bucket": self.bucket, "Prefix": full_prefix}
193
+ if continuation_token:
194
+ kwargs["ContinuationToken"] = continuation_token
195
+ response = self._client.list_objects_v2(**kwargs)
196
+ for obj in response.get("Contents", []):
197
+ remote_key = obj["Key"]
198
+ keys.append(
199
+ remote_key[len(self.prefix) + 1 :]
200
+ if self.prefix
201
+ else remote_key
202
+ )
203
+ if not response.get("IsTruncated"):
204
+ break
205
+ continuation_token = response.get("NextContinuationToken")
206
+ return sorted(keys)
207
+
208
+
209
+ class GCSRemoteFixtureBackend(RemoteFixtureBackend):
210
+ """Google Cloud Storage-backed remote fixture store.
211
+ ``google-cloud-storage`` is imported lazily."""
212
+
213
+ def __init__(self, bucket: str, prefix: str = "", client: Any = None) -> None:
214
+ self.prefix = prefix.rstrip("/")
215
+ self._bucket = client or self._make_bucket(bucket)
216
+
217
+ def _make_bucket(self, bucket_name: str) -> Any:
218
+ try:
219
+ from google.cloud import storage
220
+ except ImportError as exc:
221
+ raise ImportError(_GCS_INSTALL_HINT) from exc
222
+ return storage.Client().bucket(bucket_name)
223
+
224
+ def _full_key(self, key: str) -> str:
225
+ return f"{self.prefix}/{key}" if self.prefix else key
226
+
227
+ def put_bytes(self, key: str, data: bytes) -> None:
228
+ blob = self._bucket.blob(self._full_key(key))
229
+ blob.upload_from_string(data)
230
+
231
+ def get_bytes(self, key: str) -> bytes | None:
232
+ blob = self._bucket.blob(self._full_key(key))
233
+ if not blob.exists():
234
+ return None
235
+ return blob.download_as_bytes() # type: ignore[no-any-return]
236
+
237
+ def list_keys(self, prefix: str) -> list[str]:
238
+ full_prefix = self._full_key(prefix)
239
+ blobs = self._bucket.list_blobs(prefix=full_prefix)
240
+ keys = [
241
+ blob.name[len(self.prefix) + 1 :] if self.prefix else blob.name
242
+ for blob in blobs
243
+ ]
244
+ return sorted(keys)
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Sync helpers
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def remote_sync_callback(
253
+ backend: RemoteFixtureBackend, run_id: str
254
+ ) -> Any:
255
+ """Return an ``on_exchange_recorded`` callback (see ``Fixture.__init__``)
256
+ that durably uploads each exchange to *backend* as it's recorded — so a
257
+ worker killed mid-run still has every exchange recorded up to that
258
+ point recoverable from remote storage, not just whatever made it into
259
+ the local, possibly-never-read-again ``fixture.db``.
260
+
261
+ Best-effort by design (matching Fixture's own on_exchange_recorded
262
+ contract): swallows every exception itself so a remote upload failure
263
+ can never break local recording.
264
+ """
265
+
266
+ def _callback(exchange: dict[str, Any]) -> None:
267
+ try:
268
+ key = f"{run_id}/exchanges/{exchange['sequence_num']:08d}.json"
269
+ backend.put_bytes(key, json.dumps(exchange).encode("utf-8"))
270
+ except Exception:
271
+ logger.warning(
272
+ "agent-trace: failed to sync exchange %s to remote backend",
273
+ exchange.get("sequence_num"),
274
+ exc_info=True,
275
+ )
276
+
277
+ return _callback
278
+
279
+
280
+ def sync_run_to_remote(
281
+ run_dir: Path, backend: RemoteFixtureBackend, run_id: str
282
+ ) -> None:
283
+ """Upload ``run_dir/fixture.db`` and ``run_dir/trace.json`` (whichever
284
+ exist) to *backend* under ``{run_id}/fixture.db`` /
285
+ ``{run_id}/trace.json`` — a one-shot sync of an already-recorded run,
286
+ complementary to (not a replacement for) :func:`remote_sync_callback`'s
287
+ per-exchange durability during recording."""
288
+ for filename in ("fixture.db", "trace.json"):
289
+ path = run_dir / filename
290
+ if path.exists():
291
+ backend.put_bytes(f"{run_id}/{filename}", path.read_bytes())
292
+
293
+
294
+ def restore_run_from_remote(
295
+ backend: RemoteFixtureBackend, run_id: str, dest_dir: Path
296
+ ) -> Path:
297
+ """Download ``{run_id}/fixture.db`` / ``{run_id}/trace.json`` (whichever
298
+ exist in *backend*) into ``dest_dir/run_id/``, returning that directory
299
+ — the recovery path for a worker whose local filesystem is gone but
300
+ whose recordings were synced to remote storage while it ran."""
301
+ run_dir = dest_dir / run_id
302
+ run_dir.mkdir(parents=True, exist_ok=True)
303
+ for filename in ("fixture.db", "trace.json"):
304
+ data = backend.get_bytes(f"{run_id}/{filename}")
305
+ if data is not None:
306
+ (run_dir / filename).write_bytes(data)
307
+ return run_dir