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.
- nullrun/__init__.py +282 -0
- nullrun/__version__.py +4 -0
- nullrun/actions.py +455 -0
- nullrun/breaker/__init__.py +27 -0
- nullrun/breaker/circuit_breaker.py +402 -0
- nullrun/breaker/exceptions.py +319 -0
- nullrun/context.py +208 -0
- nullrun/decorators.py +649 -0
- nullrun/instrumentation/__init__.py +23 -0
- nullrun/instrumentation/_safe_patch.py +99 -0
- nullrun/instrumentation/auto.py +1095 -0
- nullrun/instrumentation/auto_requests.py +257 -0
- nullrun/instrumentation/autogen.py +163 -0
- nullrun/instrumentation/crewai.py +140 -0
- nullrun/instrumentation/langgraph.py +412 -0
- nullrun/instrumentation/llama_index.py +110 -0
- nullrun/observability.py +160 -0
- nullrun/py.typed +0 -0
- nullrun/runtime.py +1806 -0
- nullrun/toolbox/__init__.py +20 -0
- nullrun/toolbox/langgraph.py +94 -0
- nullrun/tracing.py +155 -0
- nullrun/transport.py +1509 -0
- nullrun/transport_websocket.py +627 -0
- nullrun-0.4.0.dist-info/METADATA +194 -0
- nullrun-0.4.0.dist-info/RECORD +28 -0
- nullrun-0.4.0.dist-info/WHEEL +4 -0
- nullrun-0.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|