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,137 @@
1
+ """
2
+ Logging-capture layer for non-HTTP, non-callback runtime warnings.
3
+
4
+ Some real, reproducible agent failures never raise an exception and never
5
+ touch the network — e.g. LangGraph's pregel scheduler logging ``"wrote to
6
+ unknown channel X, ignoring it"`` when ``push_ui_message`` targets an
7
+ unregistered state channel (langgraph#5464). Neither the HTTP interceptor
8
+ (``agent_trace.interceptor.httpx_hook``/``requests_patch``) nor
9
+ ``LangGraphTracer``'s callback handlers
10
+ (``agent_trace.integrations.langgraph``) can ever see this class of
11
+ failure — nothing in agent-trace previously attached to Python's own
12
+ ``logging`` module at all.
13
+
14
+ Usage::
15
+
16
+ from agent_trace import tracer
17
+ from agent_trace.interceptor.logging_hook import capture_logging
18
+
19
+ with tracer.start_trace("my_graph") as trace:
20
+ with capture_logging(tracer, logger_names=["langgraph"]):
21
+ graph.invoke(state, config={"callbacks": [...]})
22
+
23
+ Every ``WARNING``-or-above record emitted by any of the named loggers while
24
+ the ``with`` block is active is persisted as a ``runtime_log`` event on a
25
+ dedicated ``logging:capture`` span — independent of, and in addition to,
26
+ whatever HTTP/callback capture is also active.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import contextlib
32
+ import logging
33
+ from collections.abc import Generator, Sequence
34
+ from typing import TYPE_CHECKING
35
+
36
+ from agent_trace.core.span import Span, SpanStatus
37
+
38
+ if TYPE_CHECKING:
39
+ from agent_trace import Tracer
40
+
41
+ __all__ = ["capture_logging"]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Bounds — this is diagnostic capture, not a full log mirror. A pathological
46
+ # hot loop logging thousands of warnings must not grow a single span's event
47
+ # list unboundedly; logging.captured_count keeps counting past the cap.
48
+ _MAX_ATTR_LEN = 4_000
49
+ _MAX_LOG_EVENTS = 200
50
+
51
+ # Loggers known to emit real, reproducible non-exception runtime warnings
52
+ # relevant to agent failures — e.g. langgraph#5464's pregel-scheduler
53
+ # "wrote to unknown channel" line. Callers can pass their own logger_names
54
+ # to widen or narrow this.
55
+ _DEFAULT_LOGGER_NAMES: tuple[str, ...] = ("langgraph", "langchain_core")
56
+
57
+
58
+ class _SpanLoggingHandler(logging.Handler):
59
+ """logging.Handler that persists each record as a SpanEvent on *span*.
60
+
61
+ Formatting/attribute-extraction failures on a single record must never
62
+ break the caller's real logging pipeline — every step is best-effort.
63
+ """
64
+
65
+ def __init__(self, span: Span, level: int = logging.WARNING) -> None:
66
+ super().__init__(level=level)
67
+ self._span = span
68
+ self.captured_count = 0
69
+
70
+ def emit(self, record: logging.LogRecord) -> None:
71
+ self.captured_count += 1
72
+ if self.captured_count > _MAX_LOG_EVENTS:
73
+ return
74
+ try:
75
+ message = self.format(record)
76
+ except Exception:
77
+ message = record.getMessage()
78
+ try:
79
+ self._span.add_event(
80
+ "runtime_log",
81
+ attributes={
82
+ "log.level": record.levelname,
83
+ "log.logger": record.name,
84
+ "log.message": str(message)[:_MAX_ATTR_LEN],
85
+ "log.filename": str(record.pathname),
86
+ "log.lineno": int(record.lineno),
87
+ },
88
+ )
89
+ except Exception:
90
+ logger.debug(
91
+ "agent-trace: failed to record captured log event",
92
+ exc_info=True,
93
+ )
94
+
95
+
96
+ @contextlib.contextmanager
97
+ def capture_logging(
98
+ tracer: Tracer,
99
+ *,
100
+ logger_names: Sequence[str] = _DEFAULT_LOGGER_NAMES,
101
+ level: int = logging.WARNING,
102
+ span_name: str = "logging:capture",
103
+ ) -> Generator[Span, None, None]:
104
+ """Attach a logging.Handler to *logger_names* for the lifetime of the
105
+ ``with`` block, persisting each captured record onto a dedicated
106
+ ``logging:capture`` span (opened via *tracer*).
107
+
108
+ *logger_names* defaults to LangGraph/LangChain's own logger namespaces
109
+ — the loggers a pregel-scheduler warning like langgraph#5464's actually
110
+ goes through. Pass an explicit list to widen/narrow the set.
111
+
112
+ The span closes ``OK`` when the block exits normally (log capture is a
113
+ diagnostic side-channel, not a success/failure signal in itself) or
114
+ ``ERROR`` if the block itself raises (the exception is recorded onto the
115
+ span, then re-raised unchanged).
116
+ """
117
+ span = tracer.start_span(span_name)
118
+ handler = _SpanLoggingHandler(span, level=level)
119
+ attached: list[logging.Logger] = []
120
+ try:
121
+ for name in logger_names:
122
+ target = logging.getLogger(name)
123
+ target.addHandler(handler)
124
+ attached.append(target)
125
+ yield span
126
+ except Exception as exc:
127
+ span.record_exception(exc)
128
+ if span.end_time is None:
129
+ span.end(SpanStatus.ERROR)
130
+ raise
131
+ else:
132
+ span.set_attribute("logging.captured_count", handler.captured_count)
133
+ if span.end_time is None:
134
+ span.end(SpanStatus.OK)
135
+ finally:
136
+ for target in attached:
137
+ target.removeHandler(handler)
@@ -0,0 +1,184 @@
1
+ """
2
+ requests adapters for recording and replaying HTTP exchanges.
3
+
4
+ These are the requests-library equivalents of httpx_hook.py's transports.
5
+ RecordingAdapter wraps the real HTTPAdapter and saves each exchange.
6
+ ReplayAdapter serves responses from the fixture without touching the network.
7
+
8
+ Why two separate modules (httpx_hook + requests_patch)?
9
+ Many AI SDKs use httpx (e.g. the Anthropic Python SDK); others use requests
10
+ (e.g. OpenAI's legacy client). Supporting both lets agent-trace intercept
11
+ the full HTTP layer regardless of which SDK the user chooses.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import logging
18
+ import time
19
+ import warnings
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from requests import PreparedRequest, Response
23
+ from requests.adapters import BaseAdapter, HTTPAdapter
24
+
25
+ if TYPE_CHECKING:
26
+ from agent_trace._replay.fixture import Fixture
27
+
28
+ from agent_trace.core.exceptions import NetworkGuardError, guard_active
29
+
30
+ __all__ = [
31
+ "NetworkGuardError",
32
+ "RecordingAdapter",
33
+ "ReplayAdapter",
34
+ ]
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class RecordingAdapter(HTTPAdapter):
40
+ """requests HTTPAdapter that persists each exchange to a Fixture.
41
+
42
+ Mount this adapter on a requests.Session before making calls:
43
+
44
+ session = requests.Session()
45
+ session.mount("https://", RecordingAdapter(fixture))
46
+
47
+ Pass *inner* to wrap an existing adapter (preserves connection pooling
48
+ and custom adapters) instead of creating a fresh HTTPAdapter pool.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ fixture: Fixture,
54
+ inner: BaseAdapter | None = None,
55
+ **kwargs: Any,
56
+ ) -> None:
57
+ super().__init__(**kwargs)
58
+ self._fixture = fixture
59
+ self._inner = inner
60
+
61
+ def send(
62
+ self,
63
+ request: PreparedRequest,
64
+ *args: Any,
65
+ **kwargs: Any,
66
+ ) -> Response:
67
+ """Send the request, record the exchange, return the response.
68
+
69
+ A pre-response exception (connection refused, DNS failure, TLS
70
+ failure, ...) is recorded too — as a failed-before-response exchange
71
+ (error_type/error_message, no response_status) — instead of being
72
+ silently lost, then re-raised unchanged so the caller sees the exact
73
+ same failure it would without recording active.
74
+ """
75
+ url = str(request.url or "")
76
+ method = str(request.method or "GET").upper()
77
+ req_headers = dict(request.headers or {})
78
+ # PreparedRequest.body can be bytes, str, or None.
79
+ body = request.body
80
+ if isinstance(body, bytes):
81
+ req_body = body.decode("utf-8", errors="replace")
82
+ elif isinstance(body, str):
83
+ req_body = body
84
+ else:
85
+ req_body = ""
86
+
87
+ start = time.monotonic()
88
+ try:
89
+ if self._inner is not None:
90
+ response: Response = self._inner.send(request, *args, **kwargs)
91
+ else:
92
+ response = super().send(request, *args, **kwargs)
93
+ except Exception as exc:
94
+ duration_ms = (time.monotonic() - start) * 1000
95
+ self._fixture.record_exchange(
96
+ url=url,
97
+ method=method,
98
+ request_headers=req_headers,
99
+ request_body=req_body,
100
+ duration_ms=duration_ms,
101
+ error_type=type(exc).__qualname__,
102
+ error_message=str(exc),
103
+ )
104
+ raise
105
+ duration_ms = (time.monotonic() - start) * 1000
106
+
107
+ resp_headers = dict(response.headers)
108
+ resp_body = response.text # reads and caches the body
109
+
110
+ self._fixture.record_exchange(
111
+ url=url,
112
+ method=method,
113
+ request_headers=req_headers,
114
+ request_body=req_body,
115
+ response_status=response.status_code,
116
+ response_headers=resp_headers,
117
+ response_body=resp_body,
118
+ duration_ms=duration_ms,
119
+ )
120
+
121
+ return response
122
+
123
+
124
+ class ReplayAdapter(BaseAdapter):
125
+ """requests adapter that serves responses from a Fixture without network I/O.
126
+
127
+ Mount this adapter on a requests.Session to intercept all outbound calls:
128
+
129
+ session = requests.Session()
130
+ session.mount("https://", ReplayAdapter(fixture))
131
+ session.mount("http://", ReplayAdapter(fixture))
132
+ """
133
+
134
+ def __init__(self, fixture: Fixture) -> None:
135
+ super().__init__()
136
+ self._fixture = fixture
137
+
138
+ def close(self) -> None:
139
+ pass
140
+
141
+ def send(
142
+ self,
143
+ request: PreparedRequest,
144
+ *args: Any,
145
+ **kwargs: Any,
146
+ ) -> Response:
147
+ """Return the next recorded response for this request without I/O."""
148
+ url = str(request.url or "")
149
+ method = str(request.method or "GET").upper()
150
+
151
+ exchange: dict[str, Any] | None = self._fixture.next_exchange(url, method)
152
+
153
+ if exchange is None:
154
+ if guard_active():
155
+ raise NetworkGuardError(
156
+ f"No recorded exchange for {method} {url} and "
157
+ "AGENT_TRACE_NETWORK_GUARD=1 is set. "
158
+ "Run in recording mode first to capture this request."
159
+ )
160
+ warnings.warn(
161
+ f"agent-trace: no fixture entry for {method} {url}; "
162
+ "falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
163
+ "to make this an error.",
164
+ stacklevel=2,
165
+ )
166
+ fallback = HTTPAdapter()
167
+ try:
168
+ return fallback.send(request, *args, **kwargs)
169
+ finally:
170
+ fallback.close()
171
+
172
+ response = Response()
173
+ response.status_code = int(exchange["response_status"])
174
+ response.headers.update(exchange["response_headers"])
175
+ # _content must be set as bytes so that response.text works correctly.
176
+ content = exchange["response_body"].encode("utf-8")
177
+ response._content = content
178
+ response.encoding = "utf-8"
179
+ response.url = url
180
+ response.request = request
181
+ # Wrap bytes in a BytesIO so raw.read() works if callers check it.
182
+ response.raw = io.BytesIO(content)
183
+
184
+ return response
@@ -0,0 +1,176 @@
1
+ """
2
+ SSE-aware parsing of streamed HTTP response bodies stored in a Fixture.
3
+
4
+ RecordingTransport/AsyncRecordingTransport already capture the full raw
5
+ Server-Sent-Events text off the wire — framework-agnostic, regardless of
6
+ provider (OpenAI/Azure/Groq-style ``data: {...}`` chunks, Anthropic's
7
+ ``event: ...`` + ``data: ...`` pairs, ...). What's missing is turning that
8
+ opaque text into diffable, addressable per-chunk data: today a developer has
9
+ to hand-parse ``response_body`` themselves to see what each streamed delta
10
+ actually contained.
11
+
12
+ This module is deliberately read-only and operates purely on an already
13
+ -recorded ``response_body`` string (or a fixture exchange dict) — it does not
14
+ change how RecordingTransport captures bytes off the wire. Use it against
15
+ ``Fixture.all_exchanges()``/``Fixture.next_exchange()`` output, e.g.:
16
+
17
+ from agent_trace.interceptor.sse import (
18
+ is_sse_exchange,
19
+ parse_sse_events,
20
+ reconstruct_streamed_message,
21
+ )
22
+
23
+ for exchange in fixture.all_exchanges():
24
+ if is_sse_exchange(exchange):
25
+ events = parse_sse_events(exchange["response_body"])
26
+ merged = reconstruct_streamed_message(events)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from typing import Any
33
+
34
+ __all__ = [
35
+ "is_sse_exchange",
36
+ "parse_sse_events",
37
+ "reconstruct_streamed_message",
38
+ ]
39
+
40
+ # Sentinel value some providers (OpenAI-style) send as the final SSE data
41
+ # line to signal "no more chunks" — not JSON, must be special-cased rather
42
+ # than fed to json.loads.
43
+ _DONE_SENTINEL = "[DONE]"
44
+
45
+
46
+ def is_sse_exchange(exchange: dict[str, Any]) -> bool:
47
+ """True if *exchange* (as returned by Fixture.all_exchanges()/
48
+ next_exchange()) looks like a captured Server-Sent-Events response.
49
+
50
+ Checks the recorded ``Content-Type`` response header first (the
51
+ authoritative signal); falls back to sniffing the body for an SSE
52
+ ``data: `` line when headers are missing/lowercased differently than
53
+ expected, since some proxies/test fixtures don't preserve casing.
54
+ """
55
+ headers = exchange.get("response_headers") or {}
56
+ for key, value in headers.items():
57
+ if str(key).lower() != "content-type":
58
+ continue
59
+ if "text/event-stream" in str(value).lower():
60
+ return True
61
+ body = exchange.get("response_body") or ""
62
+ return _looks_like_sse_body(body)
63
+
64
+
65
+ def _looks_like_sse_body(body: str) -> bool:
66
+ """Best-effort sniff: at least one line starts with the SSE ``data: ``
67
+ field prefix. Deliberately conservative — a false negative just means a
68
+ caller has to fall back to treating the body as an opaque string, which
69
+ is the pre-existing behavior anyway."""
70
+ for line in body.splitlines():
71
+ if line.startswith("data:"):
72
+ return True
73
+ return False
74
+
75
+
76
+ def parse_sse_events(body: str) -> list[dict[str, Any] | str]:
77
+ """Split a raw SSE response body on ``data: `` boundaries and parse each
78
+ event's payload.
79
+
80
+ Returns an ordered list — one entry per ``data:`` line found, in the
81
+ order they appear in *body* (the order they were streamed). Each entry
82
+ is either:
83
+
84
+ - a parsed JSON object, for the common ``data: {...}`` shape every major
85
+ provider's streaming chat-completions endpoint uses, or
86
+ - the raw string payload, when the line isn't valid JSON (e.g. the
87
+ ``[DONE]`` sentinel, or a provider using a non-JSON SSE payload).
88
+
89
+ A blank body, or a body with no ``data:`` lines at all, returns ``[]`` —
90
+ callers should treat that as "not an SSE payload" rather than an error.
91
+ """
92
+ events: list[dict[str, Any] | str] = []
93
+ for line in body.splitlines():
94
+ if not line.startswith("data:"):
95
+ continue
96
+ payload = line[len("data:") :].strip()
97
+ if not payload or payload == _DONE_SENTINEL:
98
+ continue
99
+ try:
100
+ events.append(json.loads(payload))
101
+ except (json.JSONDecodeError, TypeError):
102
+ events.append(payload)
103
+ return events
104
+
105
+
106
+ def _delta_from_event(event: dict[str, Any] | str) -> dict[str, Any] | None:
107
+ """Extract the OpenAI-style ``choices[0].delta`` dict from one parsed SSE
108
+ event, or None if this event isn't that shape (a non-dict event, a
109
+ provider using a different streaming schema, etc.)."""
110
+ if not isinstance(event, dict):
111
+ return None
112
+ choices = event.get("choices")
113
+ if not choices or not isinstance(choices, list):
114
+ return None
115
+ first = choices[0]
116
+ if not isinstance(first, dict):
117
+ return None
118
+ delta = first.get("delta")
119
+ return delta if isinstance(delta, dict) else None
120
+
121
+
122
+ def reconstruct_streamed_message(
123
+ events: list[dict[str, Any] | str],
124
+ ) -> dict[str, Any]:
125
+ """Merge an ordered list of parsed SSE events (from parse_sse_events())
126
+ into one addressable, reassembled message.
127
+
128
+ Concatenates every ``delta.content`` fragment into ``content``, and
129
+ merges every ``delta.tool_calls[]`` fragment — keyed by its ``index``
130
+ field, the same index OpenAI-style providers use to identify which
131
+ parallel tool call a given argument fragment belongs to — into
132
+ ``tool_calls``, concatenating each tool call's ``function.arguments``
133
+ string across chunks in the order they arrived.
134
+
135
+ Returns ``{"content": str, "tool_calls": {index: {...}}}``. Events that
136
+ don't match the OpenAI-style ``choices[0].delta`` shape are ignored
137
+ (not raised on) — this reconstruction targets that one well-known wire
138
+ shape; a caller working with a different provider's streaming schema
139
+ should read ``events`` directly instead.
140
+ """
141
+ content_parts: list[str] = []
142
+ tool_calls: dict[int, dict[str, Any]] = {}
143
+
144
+ for event in events:
145
+ delta = _delta_from_event(event)
146
+ if delta is None:
147
+ continue
148
+
149
+ content_piece = delta.get("content")
150
+ if isinstance(content_piece, str):
151
+ content_parts.append(content_piece)
152
+
153
+ for tc_fragment in delta.get("tool_calls") or []:
154
+ if not isinstance(tc_fragment, dict):
155
+ continue
156
+ index = tc_fragment.get("index", 0)
157
+ try:
158
+ index = int(index)
159
+ except (TypeError, ValueError):
160
+ index = 0
161
+ entry = tool_calls.setdefault(
162
+ index,
163
+ {"id": None, "function": {"name": "", "arguments": ""}},
164
+ )
165
+ if tc_fragment.get("id"):
166
+ entry["id"] = tc_fragment["id"]
167
+ fn_fragment = tc_fragment.get("function") or {}
168
+ if fn_fragment.get("name"):
169
+ entry["function"]["name"] += fn_fragment["name"]
170
+ if fn_fragment.get("arguments"):
171
+ entry["function"]["arguments"] += fn_fragment["arguments"]
172
+
173
+ return {
174
+ "content": "".join(content_parts),
175
+ "tool_calls": tool_calls,
176
+ }