nullrun 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.
@@ -0,0 +1,257 @@
1
+ """
2
+ Auto-instrumentation for the `requests` library — Phase P2 of the audit
3
+ fix plan.
4
+
5
+ Mirrors `auto.py` (the httpx transport hook) for the `requests` HTTP
6
+ client. The motivation: 30-50% of real codebases use `requests` directly
7
+ (e.g. via `langchain-community` or `llama-index` adapters, or hand-rolled
8
+ clients for OpenAI/Anthropic). Without this patch those calls are
9
+ invisible to NullRun even though the SDK is installed.
10
+
11
+ Reuses from `auto.py`:
12
+ - PROVIDER_EXTRACTORS and the 5 per-vendor extractor functions
13
+ (`_openai_extractor`, `_anthropic_extractor`, etc.)
14
+ - `_match_extractor(host)` — exact + subdomain match
15
+ - `_provider_label(host)` — short label for the `provider` event field
16
+ - `_fingerprint_for(host, body, status)` — dedup fingerprint
17
+ - `_safe_bump_coverage(runtime, target_attr, host)` — bounded counter
18
+ bump that tolerates stub runtimes (MagicMock, custom test doubles)
19
+
20
+ What this module owns:
21
+ - `patch_requests(runtime)` — wraps `requests.Session.send` so every
22
+ call routed through a session is observed. Idempotent.
23
+ - Streaming handling: `requests.get(url, stream=True)` and
24
+ `Accept: text/event-stream` are skipped with a `streaming-skipped`
25
+ coverage marker. We do NOT buffer the response — that would break
26
+ user-facing streaming (the caller reads `iter_content`/`iter_lines`
27
+ chunk-by-chunk). The known limit is documented in
28
+ `docs/known-limitations.md`.
29
+ - Double-emission guard: `request._nullrun_tracked = True` is set on
30
+ the PreparedRequest after a successful track, so a future
31
+ `urllib3` patch (which `requests` uses under the hood) can skip
32
+ already-tracked requests. See plan section P2 / "requests ↔ urllib3".
33
+
34
+ `aiohttp` is deliberately out of scope for this phase — see
35
+ `docs/known-limitations.md` and the plan's open questions.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ import threading
42
+ from typing import Any
43
+
44
+ from nullrun.instrumentation.auto import (
45
+ _fingerprint_for,
46
+ _match_extractor,
47
+ _provider_label,
48
+ _safe_bump_coverage,
49
+ )
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ # Streaming detection. `stream=True` is a kwarg on the high-level
55
+ # `requests.get/post/etc.` and is also the underlying flag in
56
+ # `Session.send(prepared_request, stream=...)`. The `Accept` header
57
+ # is a server-side indicator that the user (or the high-level
58
+ # helper) declared a streaming response.
59
+ _STREAMING_CONTENT_TYPES = ("text/event-stream",)
60
+
61
+
62
+ def _is_streaming_request(request: Any, send_kwargs: dict[str, Any]) -> bool:
63
+ """Return True when this request should be skipped because the caller
64
+ is consuming a streaming response. We skip rather than bufferize —
65
+ buffering `iter_content`/`iter_lines` would break user-facing SSE
66
+ parsing, which is the dominant reason someone passes `stream=True`.
67
+ """
68
+ if send_kwargs.get("stream") is True:
69
+ return True
70
+ accept = ""
71
+ headers = getattr(request, "headers", None)
72
+ if headers is not None:
73
+ try:
74
+ accept = headers.get("Accept", "") or ""
75
+ except Exception: # pragma: no cover — defensive
76
+ accept = ""
77
+ return any(ct in accept for ct in _STREAMING_CONTENT_TYPES)
78
+
79
+
80
+ def _bump_streaming_skipped(runtime: Any, host: str) -> None:
81
+ """Phase P2: bump a `streaming-skipped` counter so the dashboard
82
+ surfaces *known* untracked hosts (vs. just "seen but unknown
83
+ extractor"). Mirrors the structure of `_safe_bump_coverage` to
84
+ tolerate stub runtimes.
85
+ """
86
+ target = getattr(runtime, "_coverage_streaming_skipped", None)
87
+ if target is None:
88
+ return
89
+ bump = getattr(runtime, "_bump_coverage_counter", None)
90
+ if bump is None:
91
+ return
92
+ try:
93
+ bump(target, host)
94
+ except Exception as e: # pragma: no cover — defensive
95
+ logger.debug("NullRun streaming-skipped bump failed: %s", e)
96
+
97
+
98
+ def _emit_to_runtime(
99
+ runtime: Any,
100
+ request: Any,
101
+ host: str,
102
+ usage: dict[str, Any],
103
+ body: bytes,
104
+ status: int,
105
+ ) -> None:
106
+ """Single-source-of-truth for emitting an LLM call event from any
107
+ transport. Kept in this module (rather than re-exported from
108
+ `auto.py`) so the requests path is self-contained and the
109
+ `requests` dep is not pulled into `auto.py`'s import graph.
110
+ """
111
+ _safe_bump_coverage(runtime, "_coverage_tracked", host)
112
+ try:
113
+ runtime.track(
114
+ {
115
+ "type": "llm_call",
116
+ "provider": _provider_label(host),
117
+ "host": host,
118
+ "model": usage.get("model"),
119
+ "tokens": usage.get("total_tokens", 0),
120
+ "input_tokens": usage.get("prompt_tokens", 0),
121
+ "output_tokens": usage.get("completion_tokens", 0),
122
+ "has_usage": True,
123
+ "raw_usage": usage,
124
+ "_fingerprint": _fingerprint_for(host, body, status),
125
+ }
126
+ )
127
+ except Exception as e:
128
+ logger.debug("NullRun requests transport: track failed: %s", e)
129
+
130
+
131
+ _requests_patched = False
132
+ _requests_lock = threading.Lock()
133
+ _orig_session_send: Any = None
134
+
135
+
136
+ def patch_requests(runtime: Any) -> bool:
137
+ """Wrap `requests.Session.send` so every call routed through a
138
+ session is observed. Idempotent: subsequent calls are no-ops.
139
+
140
+ Returns True on success, False if `requests` is not installed.
141
+ """
142
+ global _requests_patched, _orig_session_send
143
+ with _requests_lock:
144
+ if _requests_patched:
145
+ return True
146
+ try:
147
+ import requests # type: ignore[import-not-found]
148
+ except ImportError:
149
+ logger.debug("requests not installed; auto-instrumentation skipped")
150
+ return False
151
+
152
+ # Idempotency marker — `Session` is a class-level singleton of
153
+ # sorts for `requests`'s module-level API too, so a class marker
154
+ # is the cheapest way to detect "already patched".
155
+ from requests import Session
156
+
157
+ if getattr(Session, "_nullrun_patched", False):
158
+ _requests_patched = True
159
+ return True
160
+
161
+ # Stash original on first patch so `reset_for_tests` can
162
+ # restore. Without this, a second `patch_requests` would
163
+ # no-op (class marker still set) AND the closure inside the
164
+ # existing wrap would still reference the first runtime —
165
+ # silently losing track() calls from later test runs.
166
+ _orig_session_send = Session.send
167
+
168
+ def _wrapped_send(self: Any, request: Any, **kwargs: Any) -> Any:
169
+ # Cheap dedup: if a previous wrapper (e.g. a future
170
+ # urllib3 patch) already tracked this request, do nothing.
171
+ if getattr(request, "_nullrun_tracked", False):
172
+ return _orig_session_send(self, request, **kwargs)
173
+
174
+ url = getattr(request, "url", "") or ""
175
+ # `urllib.parse` is stdlib; cheap to import lazily here
176
+ # rather than at module load (so this module imports
177
+ # quickly even when `requests` is not used).
178
+ import urllib.parse
179
+
180
+ host = urllib.parse.urlparse(url).hostname or ""
181
+
182
+ # Phase 1.1: bump seen-counter for *every* host, including
183
+ # ones we don't have an extractor for. Same pattern as
184
+ # the httpx transport.
185
+ _safe_bump_coverage(runtime, "_coverage_seen", host)
186
+
187
+ # Streaming skip: do NOT read `response.content` here —
188
+ # that would buffer the entire stream and break the
189
+ # caller's chunked consumption. Mark as `streaming-skipped`
190
+ # so the dashboard can show "known but untracked".
191
+ if _is_streaming_request(request, kwargs):
192
+ _bump_streaming_skipped(runtime, host)
193
+ return _orig_session_send(self, request, **kwargs)
194
+
195
+ extractor = _match_extractor(host)
196
+ if extractor is None:
197
+ return _orig_session_send(self, request, **kwargs)
198
+
199
+ response = _orig_session_send(self, request, **kwargs)
200
+ try:
201
+ # `response.content` is the fully-materialized bytes.
202
+ # For non-streaming responses this is the body; for
203
+ # `stream=True` we already returned above so we never
204
+ # reach this line on a stream.
205
+ body = response.content
206
+ except Exception as e: # pragma: no cover — defensive
207
+ logger.debug(
208
+ "NullRun requests transport: failed to read body: %s", e
209
+ )
210
+ return response
211
+ if not body:
212
+ return response
213
+
214
+ usage = extractor(body, response.status_code)
215
+ if usage is None:
216
+ return response
217
+
218
+ # Mark BEFORE the track call so a track-failure (network,
219
+ # validation) still records the request as tracked from a
220
+ # coverage perspective — the response WAS successfully
221
+ # extracted, even if the server rejected the event.
222
+ try:
223
+ request._nullrun_tracked = True
224
+ except Exception: # pragma: no cover — defensive
225
+ # Some PreparedRequest subclasses disallow attribute
226
+ # assignment; we just lose the dedup marker in that
227
+ # case (a future urllib3 patch may double-emit, which
228
+ # is deduped by fingerprint at the track() sink).
229
+ pass
230
+ _emit_to_runtime(
231
+ runtime, request, host, usage, body, response.status_code
232
+ )
233
+ return response
234
+
235
+ Session.send = _wrapped_send # type: ignore[method-assign]
236
+ Session._nullrun_patched = True # type: ignore[attr-defined]
237
+ _requests_patched = True
238
+ logger.info("requests auto-instrumentation installed")
239
+ return True
240
+
241
+
242
+ def reset_for_tests() -> None:
243
+ """Restore `requests.Session.send` to its pre-patch implementation.
244
+ Mirrors `auto.reset_for_tests` — the next `patch_requests` installs
245
+ a fresh wrap bound to the new runtime. Test-only.
246
+ """
247
+ global _requests_patched, _orig_session_send
248
+ _requests_patched = False
249
+ if _orig_session_send is not None:
250
+ try:
251
+ from requests import Session
252
+
253
+ Session.send = _orig_session_send # type: ignore[method-assign]
254
+ Session._nullrun_patched = False # type: ignore[attr-defined]
255
+ except Exception as e: # pragma: no cover — defensive
256
+ logger.debug("reset_for_tests: failed to restore Session: %s", e)
257
+ _orig_session_send = None
@@ -0,0 +1,163 @@
1
+ """
2
+ autogen auto-instrumentation for NullRun SDK.
3
+
4
+ Mirrors the structure of ``patch_llama_index`` (see that file for
5
+ detailed comments). Two integration points:
6
+
7
+ 1. ``BaseChatAgent.on_messages`` (from autogen_agentchat.agents) —
8
+ wrapped to push a tracing span on entry / pop on exit. This
9
+ covers the agent lifecycle regardless of which LLM client the
10
+ user chose.
11
+
12
+ 2. ``OpenAIChatCompletionClient.create`` (from
13
+ autogen_ext.models.openai) — wrapped to capture streaming-safe
14
+ usage. autogen does not always use httpx (some clients hit
15
+ gRPC), so we cannot rely on the httpx transport hook.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from collections.abc import Callable
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _autogen_patched = False
26
+ _orig_on_messages: Callable[..., Any] | None = None
27
+ _orig_openai_create: Callable[..., Any] | None = None
28
+
29
+
30
+ def patch_autogen(runtime: Any) -> bool:
31
+ global _autogen_patched
32
+ if _autogen_patched:
33
+ return True
34
+ try:
35
+ from autogen_agentchat.agents import BaseChatAgent # type: ignore[import-not-found]
36
+ except ImportError:
37
+ logger.debug("autogen not installed; auto-patch skipped")
38
+ return False
39
+
40
+ if getattr(BaseChatAgent, "_nullrun_patched", False):
41
+ _autogen_patched = True
42
+ return True
43
+
44
+ global _orig_on_messages
45
+ _orig_on_messages = BaseChatAgent.on_messages
46
+
47
+ def _wrap_on_messages(
48
+ self: Any, messages: Any, cancellation_token: Any = None
49
+ ) -> Any:
50
+ try:
51
+ runtime.track_event(
52
+ event_type="span_start",
53
+ fn_name=getattr(self, "name", "agent") or "agent",
54
+ span_kind="agent",
55
+ )
56
+ except Exception: # pragma: no cover
57
+ pass
58
+
59
+ try:
60
+ resp = _orig_on_messages(self, messages, cancellation_token=cancellation_token)
61
+ except Exception as e:
62
+ try:
63
+ runtime.track_event(
64
+ event_type="span_end",
65
+ error=str(e),
66
+ )
67
+ except Exception: # pragma: no cover
68
+ pass
69
+ raise
70
+
71
+ try:
72
+ runtime.track_event(event_type="span_end")
73
+ except Exception: # pragma: no cover
74
+ pass
75
+ return resp
76
+
77
+ BaseChatAgent.on_messages = _wrap_on_messages # type: ignore[method-assign]
78
+
79
+ # Belt-and-suspenders: capture streaming-safe usage off the
80
+ # OpenAI client's CreateResult.usage.
81
+ try:
82
+ from autogen_ext.models.openai import (
83
+ OpenAIChatCompletionClient, # type: ignore[import-not-found]
84
+ )
85
+
86
+ if not getattr(OpenAIChatCompletionClient, "_nullrun_patched", False):
87
+ global _orig_openai_create
88
+ _orig_openai_create = OpenAIChatCompletionClient.create
89
+
90
+ def _wrap_create(self: Any, *args: Any, **kwargs: Any) -> Any:
91
+ result = _orig_openai_create(self, *args, **kwargs)
92
+ usage = getattr(result, "usage", None)
93
+ if usage is not None:
94
+ prompt = int(
95
+ getattr(usage, "prompt_tokens", 0) or 0
96
+ )
97
+ completion = int(
98
+ getattr(usage, "completion_tokens", 0) or 0
99
+ )
100
+ total = int(
101
+ getattr(usage, "total_tokens", 0) or 0
102
+ ) or (prompt + completion)
103
+ if prompt or completion or total:
104
+ try:
105
+ runtime.track(
106
+ {
107
+ "type": "llm_call",
108
+ "provider": "autogen",
109
+ "model": getattr(self, "model", None),
110
+ "tokens": total,
111
+ "input_tokens": prompt,
112
+ "output_tokens": completion,
113
+ "has_usage": True,
114
+ "raw_usage": {
115
+ "prompt_tokens": prompt,
116
+ "completion_tokens": completion,
117
+ },
118
+ }
119
+ )
120
+ except Exception as e: # pragma: no cover
121
+ logger.debug("autogen create emit failed: %s", e)
122
+ return result
123
+
124
+ OpenAIChatCompletionClient.create = _wrap_create # type: ignore[method-assign]
125
+ OpenAIChatCompletionClient._nullrun_patched = True # type: ignore[attr-defined]
126
+ except ImportError:
127
+ # autogen-agentchat present but autogen-ext not installed —
128
+ # spans still work; usage capture silently skipped.
129
+ pass
130
+
131
+ BaseChatAgent._nullrun_patched = True # type: ignore[attr-defined]
132
+ _autogen_patched = True
133
+ logger.info("autogen auto-instrumentation installed")
134
+ return True
135
+
136
+
137
+ def unpatch_autogen() -> None:
138
+ """Detach our wrappers. Test-only."""
139
+ global _autogen_patched
140
+ if not _autogen_patched:
141
+ return
142
+ try:
143
+ from autogen_agentchat.agents import BaseChatAgent # type: ignore[import-not-found]
144
+ except ImportError:
145
+ _autogen_patched = False
146
+ return
147
+
148
+ if _orig_on_messages is not None:
149
+ BaseChatAgent.on_messages = _orig_on_messages # type: ignore[method-assign]
150
+ BaseChatAgent._nullrun_patched = False # type: ignore[attr-defined]
151
+
152
+ try:
153
+ from autogen_ext.models.openai import (
154
+ OpenAIChatCompletionClient, # type: ignore[import-not-found]
155
+ )
156
+
157
+ if _orig_openai_create is not None:
158
+ OpenAIChatCompletionClient.create = _orig_openai_create # type: ignore[method-assign]
159
+ OpenAIChatCompletionClient._nullrun_patched = False # type: ignore[attr-defined]
160
+ except ImportError:
161
+ pass
162
+
163
+ _autogen_patched = False
@@ -0,0 +1,140 @@
1
+ """
2
+ crewai auto-instrumentation for NullRun SDK.
3
+
4
+ Mirrors the structure of ``patch_llama_index`` (see that file for
5
+ detailed comments). CrewAI's canonical integration point is the
6
+ ``step_callback`` / ``task_callback`` parameters on ``Crew``.
7
+
8
+ Hook: ``Crew.kickoff`` and ``Crew.kickoff_async`` are wrapped so a
9
+ ``step_callback`` and ``task_callback`` are installed on every crew
10
+ the user creates (unless they already supplied one). After the
11
+ crew completes, ``crew.usage_metrics`` is read once and emitted as
12
+ an ``llm_call`` event with the aggregated prompt / completion
13
+ token totals. Token usage for httpx-routed providers is already
14
+ captured by the auto-patch in ``auto.py``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _crewai_patched = False
25
+ _orig_kickoff: Callable[..., Any] | None = None
26
+ _orig_kickoff_async: Callable[..., Any] | None = None
27
+
28
+
29
+ def _emit_usage_metrics(runtime: Any, crew: Any) -> None:
30
+ """Read ``crew.usage_metrics`` post-run and emit one llm_call per model."""
31
+ metrics_obj = getattr(crew, "usage_metrics", None) or {}
32
+ if not isinstance(metrics_obj, dict):
33
+ return
34
+ for model, m in metrics_obj.items():
35
+ if not isinstance(m, dict):
36
+ continue
37
+ prompt = int(m.get("prompt_tokens", 0) or 0)
38
+ completion = int(m.get("completion_tokens", 0) or 0)
39
+ total = int(m.get("total_tokens", 0) or 0) or (prompt + completion)
40
+ if not (prompt or completion or total):
41
+ continue
42
+ try:
43
+ runtime.track(
44
+ {
45
+ "type": "llm_call",
46
+ "provider": "crewai",
47
+ "model": model,
48
+ "tokens": total,
49
+ "input_tokens": prompt,
50
+ "output_tokens": completion,
51
+ "has_usage": True,
52
+ "raw_usage": dict(m),
53
+ }
54
+ )
55
+ except Exception as e: # pragma: no cover - defensive
56
+ logger.debug("crewai usage_metrics emit failed: %s", e)
57
+
58
+
59
+ def patch_crewai(runtime: Any) -> bool:
60
+ global _crewai_patched
61
+ if _crewai_patched:
62
+ return True
63
+ try:
64
+ from crewai import Crew # type: ignore[import-not-found]
65
+ except ImportError:
66
+ logger.debug("crewai not installed; auto-patch skipped")
67
+ return False
68
+
69
+ if getattr(Crew, "_nullrun_patched", False):
70
+ _crewai_patched = True
71
+ return True
72
+
73
+ global _orig_kickoff, _orig_kickoff_async
74
+ _orig_kickoff = Crew.kickoff
75
+ _orig_kickoff_async = getattr(Crew, "kickoff_async", None)
76
+
77
+ def _wrap_kickoff(self: Any, inputs: Any = None, **kwargs: Any) -> Any:
78
+ # Install step_callback if absent.
79
+ if "step_callback" not in kwargs:
80
+ def step_cb(step: Any) -> None:
81
+ # Steps carry tool/agent metadata; emit a span_start.
82
+ try:
83
+ runtime.track_event(
84
+ event_type="span_start",
85
+ fn_name="crewai_step",
86
+ span_kind="agent",
87
+ )
88
+ except Exception: # pragma: no cover
89
+ pass
90
+
91
+ kwargs["step_callback"] = step_cb
92
+
93
+ result = _orig_kickoff(self, inputs=inputs, **kwargs)
94
+ _emit_usage_metrics(runtime, self)
95
+ return result
96
+
97
+ async def _wrap_kickoff_async(self: Any, inputs: Any = None, **kwargs: Any) -> Any:
98
+ if "step_callback" not in kwargs:
99
+ def step_cb(step: Any) -> None:
100
+ try:
101
+ runtime.track_event(
102
+ event_type="span_start",
103
+ fn_name="crewai_step",
104
+ span_kind="agent",
105
+ )
106
+ except Exception: # pragma: no cover
107
+ pass
108
+
109
+ kwargs["step_callback"] = step_cb
110
+
111
+ result = await _orig_kickoff_async(self, inputs=inputs, **kwargs)
112
+ _emit_usage_metrics(runtime, self)
113
+ return result
114
+
115
+ Crew.kickoff = _wrap_kickoff # type: ignore[method-assign]
116
+ if _orig_kickoff_async is not None:
117
+ Crew.kickoff_async = _wrap_kickoff_async # type: ignore[method-assign]
118
+ Crew._nullrun_patched = True # type: ignore[attr-defined]
119
+ _crewai_patched = True
120
+ logger.info("crewai auto-instrumentation installed")
121
+ return True
122
+
123
+
124
+ def unpatch_crewai() -> None:
125
+ """Detach our Crew.kickoff / kickoff_async wrappers. Test-only."""
126
+ global _crewai_patched
127
+ if not _crewai_patched:
128
+ return
129
+ try:
130
+ from crewai import Crew # type: ignore[import-not-found]
131
+ except ImportError:
132
+ _crewai_patched = False
133
+ return
134
+
135
+ if _orig_kickoff is not None:
136
+ Crew.kickoff = _orig_kickoff # type: ignore[method-assign]
137
+ if _orig_kickoff_async is not None:
138
+ Crew.kickoff_async = _orig_kickoff_async # type: ignore[method-assign]
139
+ Crew._nullrun_patched = False # type: ignore[attr-defined]
140
+ _crewai_patched = False