otelzero 0.2.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.
- otelzero/__init__.py +62 -0
- otelzero/__main__.py +140 -0
- otelzero/context.py +86 -0
- otelzero/detection.py +58 -0
- otelzero/hooks/__init__.py +96 -0
- otelzero/hooks/_content.py +865 -0
- otelzero/hooks/_langsmith.py +504 -0
- otelzero/hooks/_native.py +517 -0
- otelzero/hooks/_usage.py +166 -0
- otelzero/hooks/adk.py +128 -0
- otelzero/hooks/base.py +546 -0
- otelzero/hooks/claude_sdk.py +267 -0
- otelzero/hooks/crewai.py +338 -0
- otelzero/hooks/databricks.py +260 -0
- otelzero/hooks/langchain.py +56 -0
- otelzero/hooks/langgraph.py +57 -0
- otelzero/hooks/llama_index.py +724 -0
- otelzero/hooks/microsoft.py +160 -0
- otelzero/hooks/openai_agents.py +313 -0
- otelzero/hooks/pydantic_ai.py +173 -0
- otelzero/hooks/smolagents.py +331 -0
- otelzero/hooks/snowflake.py +271 -0
- otelzero/hooks/strands.py +173 -0
- otelzero/otel.py +372 -0
- otelzero/semconv.py +327 -0
- otelzero/telemetry.py +238 -0
- otelzero/translate/__init__.py +214 -0
- otelzero/translate/langsmith.py +401 -0
- otelzero-0.2.0.dist-info/METADATA +1345 -0
- otelzero-0.2.0.dist-info/RECORD +33 -0
- otelzero-0.2.0.dist-info/WHEEL +5 -0
- otelzero-0.2.0.dist-info/licenses/LICENSE +202 -0
- otelzero-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Claude Agent SDK instrumentation: wrap the async-generator run chokepoints."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import wrapt
|
|
6
|
+
|
|
7
|
+
from otelzero import semconv
|
|
8
|
+
from otelzero.hooks import _content
|
|
9
|
+
from otelzero.hooks._usage import UsageAccumulator, extract_usage
|
|
10
|
+
from otelzero.hooks.base import _ImportInstrumenter, install_hook, trace_agent_run
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _claude_model_id(options: Any) -> Optional[str]:
|
|
14
|
+
"""Best-effort model identifier for a ``ClaudeAgentOptions`` object.
|
|
15
|
+
|
|
16
|
+
``ClaudeAgentOptions.model`` is an optional model string (``None`` selects
|
|
17
|
+
the SDK/CLI default). Returns the string when set, else ``None``.
|
|
18
|
+
"""
|
|
19
|
+
if options is None:
|
|
20
|
+
return None
|
|
21
|
+
model = getattr(options, "model", None)
|
|
22
|
+
if isinstance(model, str) and model:
|
|
23
|
+
return model
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _options_details(options: Any) -> dict:
|
|
28
|
+
"""Summarise a ``ClaudeAgentOptions`` for a telemetry ``details`` payload.
|
|
29
|
+
|
|
30
|
+
Every field read degrades to a benign default so a run with no options (or
|
|
31
|
+
an SDK whose options shape has drifted) never breaks instrumentation.
|
|
32
|
+
"""
|
|
33
|
+
allowed = getattr(options, "allowed_tools", None)
|
|
34
|
+
mcp = getattr(options, "mcp_servers", None)
|
|
35
|
+
return {
|
|
36
|
+
"model": _claude_model_id(options),
|
|
37
|
+
"allowed_tools": list(allowed) if allowed else [],
|
|
38
|
+
# ClaudeAgentOptions.mcp_servers is a name -> config dict; surface just
|
|
39
|
+
# the server names (configs can hold secrets and are large).
|
|
40
|
+
"mcp_servers": sorted(mcp.keys()) if isinstance(mcp, dict) else None,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClaudeAgentSDKImportHook(_ImportInstrumenter):
|
|
45
|
+
"""Intercept Claude Agent SDK imports and instrument both run chokepoints.
|
|
46
|
+
|
|
47
|
+
The SDK exposes two entry points, both async generators, and this hook wraps
|
|
48
|
+
each so a run is captured exactly once whichever style the caller uses:
|
|
49
|
+
|
|
50
|
+
* ``claude_agent_sdk.query(prompt, options)`` -- the one-shot path, a
|
|
51
|
+
module-level async-generator *function* (so the wrapper's ``instance`` is
|
|
52
|
+
``None`` and options come from ``kwargs``).
|
|
53
|
+
* ``ClaudeSDKClient.receive_response()`` -- the interactive path, an
|
|
54
|
+
async-generator *method* that yields one response turn's messages (options
|
|
55
|
+
live on the client instance).
|
|
56
|
+
|
|
57
|
+
``ClaudeSDKClient.query()`` is wrapped as well, though it is not a run: it
|
|
58
|
+
is where the interactive path's prompt goes out, so the prompt is recorded
|
|
59
|
+
there and attached to the ``receive_response()`` run that follows.
|
|
60
|
+
|
|
61
|
+
All are re-exported from the ``claude_agent_sdk`` package ``__init__``, so a
|
|
62
|
+
single watched module covers them. The SDK's own ``hooks=`` option
|
|
63
|
+
(PreToolUse/PostToolUse) is unrelated to otelzero's import hooks.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
WATCHED = {"claude_agent_sdk": "_wrap_module"}
|
|
67
|
+
|
|
68
|
+
# otelzero's own marker so we never double-wrap a chokepoint.
|
|
69
|
+
_MARKER = "_otelzero_wrapped"
|
|
70
|
+
|
|
71
|
+
def _wrap_module(self, module):
|
|
72
|
+
self._wrap_query(module)
|
|
73
|
+
self._wrap_client(module)
|
|
74
|
+
|
|
75
|
+
def _wrap_query(self, module):
|
|
76
|
+
"""Wrap the module-level ``query`` async-generator function."""
|
|
77
|
+
query = getattr(module, "query", None)
|
|
78
|
+
if query is None or getattr(query, self._MARKER, False):
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
telemetry = self.telemetry
|
|
82
|
+
|
|
83
|
+
@wrapt.decorator
|
|
84
|
+
async def query_wrapper(wrapped, instance, args, kwargs):
|
|
85
|
+
# ``query`` takes options by keyword (``query(prompt, options=...)``).
|
|
86
|
+
options = kwargs.get("options")
|
|
87
|
+
details = _options_details(options)
|
|
88
|
+
# The prompt is the run's intent. Only a string names the session
|
|
89
|
+
# (the streaming-message form has no single title). As input, a
|
|
90
|
+
# string or a materialised list is recorded up front; a streamed
|
|
91
|
+
# prompt is the SDK's to read, so the SDK gets a tee of the
|
|
92
|
+
# caller's iterable and the messages are recorded as they flow
|
|
93
|
+
# past (see ``_content.PromptTee``).
|
|
94
|
+
prompt = kwargs.get("prompt", args[0] if args else None)
|
|
95
|
+
prompt_text = prompt if isinstance(prompt, str) else None
|
|
96
|
+
tee = _content.PromptTee.wrap(prompt)
|
|
97
|
+
if tee is not None:
|
|
98
|
+
args, kwargs = _content.replace_prompt(args, kwargs, tee)
|
|
99
|
+
|
|
100
|
+
with trace_agent_run(
|
|
101
|
+
telemetry,
|
|
102
|
+
semconv.span_name(semconv.OP_INVOKE_AGENT, "Claude Agent"),
|
|
103
|
+
{
|
|
104
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_AGENT,
|
|
105
|
+
semconv.GEN_AI_AGENT_NAME: "Claude Agent",
|
|
106
|
+
semconv.GEN_AI_REQUEST_MODEL: details["model"],
|
|
107
|
+
semconv.GEN_AI_TOOL_DEFINITIONS: semconv.tool_definitions(
|
|
108
|
+
details["allowed_tools"]
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
start_event="agent_start",
|
|
112
|
+
end_event="agent_end",
|
|
113
|
+
error_event="agent_error",
|
|
114
|
+
log_fields={
|
|
115
|
+
"agent_name": "Claude Agent",
|
|
116
|
+
"agent_role": "Claude Agent",
|
|
117
|
+
"model": details["model"],
|
|
118
|
+
},
|
|
119
|
+
start_details=details,
|
|
120
|
+
session_name_hint=prompt_text,
|
|
121
|
+
) as run:
|
|
122
|
+
run.set_input(_content.prompt_messages(prompt))
|
|
123
|
+
# Only the final ResultMessage carries ``.usage`` (input/output/
|
|
124
|
+
# cache-read token counts); other message types yield None.
|
|
125
|
+
# AssistantMessage/UserMessage content blocks (text, tool use,
|
|
126
|
+
# tool results) accumulate into the run's output messages.
|
|
127
|
+
acc = UsageAccumulator()
|
|
128
|
+
out = _content.OutputAccumulator()
|
|
129
|
+
try:
|
|
130
|
+
async for message in wrapped(*args, **kwargs):
|
|
131
|
+
acc.add(extract_usage(getattr(message, "usage", None)))
|
|
132
|
+
out.add(message)
|
|
133
|
+
yield message
|
|
134
|
+
finally:
|
|
135
|
+
if tee is not None:
|
|
136
|
+
run.set_input(tee.result())
|
|
137
|
+
run.set_usage(acc.result())
|
|
138
|
+
run.set_output(out.result(), finish_reasons=out.finish_reasons)
|
|
139
|
+
|
|
140
|
+
wrapped = query_wrapper(query)
|
|
141
|
+
setattr(wrapped, self._MARKER, True)
|
|
142
|
+
module.query = wrapped
|
|
143
|
+
|
|
144
|
+
def _wrap_client_query(self, client):
|
|
145
|
+
"""Wrap ``ClaudeSDKClient.query``: not a run, but where a run's prompt goes out.
|
|
146
|
+
|
|
147
|
+
The interactive client sends a prompt with ``query()`` and reads the
|
|
148
|
+
reply with ``receive_response()``. The prompt is queued on the client
|
|
149
|
+
here and taken, oldest first, as a response's input there; a streamed
|
|
150
|
+
prompt is teed the same way as on the one-shot path.
|
|
151
|
+
"""
|
|
152
|
+
query = getattr(client, "query", None)
|
|
153
|
+
if query is None or getattr(query, self._MARKER, False):
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
@wrapt.decorator
|
|
157
|
+
async def query_wrapper(wrapped, instance, args, kwargs):
|
|
158
|
+
prompt = kwargs.get("prompt", args[0] if args else None)
|
|
159
|
+
tee = _content.PromptTee.wrap(prompt)
|
|
160
|
+
if tee is not None:
|
|
161
|
+
args, kwargs = _content.replace_prompt(args, kwargs, tee)
|
|
162
|
+
result = await wrapped(*args, **kwargs)
|
|
163
|
+
# Queued only once the SDK accepted it: a prompt that never went
|
|
164
|
+
# out is not part of any response's input.
|
|
165
|
+
_content.queue_prompt(
|
|
166
|
+
instance,
|
|
167
|
+
tee.result() if tee is not None else _content.prompt_messages(prompt),
|
|
168
|
+
)
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
wrapped = query_wrapper(query)
|
|
172
|
+
setattr(wrapped, self._MARKER, True)
|
|
173
|
+
client.query = wrapped
|
|
174
|
+
|
|
175
|
+
def _wrap_client(self, module):
|
|
176
|
+
"""Wrap ``ClaudeSDKClient.receive_response`` (the interactive chokepoint)."""
|
|
177
|
+
client = getattr(module, "ClaudeSDKClient", None)
|
|
178
|
+
if client is None:
|
|
179
|
+
return
|
|
180
|
+
self._wrap_client_query(client)
|
|
181
|
+
receive_response = getattr(client, "receive_response", None)
|
|
182
|
+
if receive_response is None or getattr(receive_response, self._MARKER, False):
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
telemetry = self.telemetry
|
|
186
|
+
|
|
187
|
+
@wrapt.decorator
|
|
188
|
+
async def receive_response_wrapper(wrapped, instance, args, kwargs):
|
|
189
|
+
# The client keeps its ClaudeAgentOptions on the instance.
|
|
190
|
+
options = getattr(instance, "options", None)
|
|
191
|
+
details = _options_details(options)
|
|
192
|
+
|
|
193
|
+
with trace_agent_run(
|
|
194
|
+
telemetry,
|
|
195
|
+
semconv.span_name(semconv.OP_INVOKE_AGENT, "Claude Agent"),
|
|
196
|
+
{
|
|
197
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_AGENT,
|
|
198
|
+
semconv.GEN_AI_AGENT_NAME: "Claude Agent",
|
|
199
|
+
semconv.GEN_AI_REQUEST_MODEL: details["model"],
|
|
200
|
+
semconv.GEN_AI_TOOL_DEFINITIONS: semconv.tool_definitions(
|
|
201
|
+
details["allowed_tools"]
|
|
202
|
+
),
|
|
203
|
+
},
|
|
204
|
+
start_event="agent_start",
|
|
205
|
+
end_event="agent_end",
|
|
206
|
+
error_event="agent_error",
|
|
207
|
+
log_fields={
|
|
208
|
+
"agent_name": "Claude Agent",
|
|
209
|
+
"agent_role": "Claude Agent",
|
|
210
|
+
"model": details["model"],
|
|
211
|
+
},
|
|
212
|
+
start_details=details,
|
|
213
|
+
) as run:
|
|
214
|
+
# The prompt went out earlier through ``client.query()``, which
|
|
215
|
+
# queued it on the client; the oldest prompt still unanswered
|
|
216
|
+
# is this response's input (one turn per prompt, see
|
|
217
|
+
# ``_content.take_prompt``).
|
|
218
|
+
run.set_input(_content.take_prompt(instance))
|
|
219
|
+
acc = UsageAccumulator()
|
|
220
|
+
out = _content.OutputAccumulator()
|
|
221
|
+
try:
|
|
222
|
+
async for message in wrapped(*args, **kwargs):
|
|
223
|
+
acc.add(extract_usage(getattr(message, "usage", None)))
|
|
224
|
+
out.add(message)
|
|
225
|
+
yield message
|
|
226
|
+
finally:
|
|
227
|
+
run.set_usage(acc.result())
|
|
228
|
+
run.set_output(out.result(), finish_reasons=out.finish_reasons)
|
|
229
|
+
|
|
230
|
+
wrapped = receive_response_wrapper(receive_response)
|
|
231
|
+
setattr(wrapped, self._MARKER, True)
|
|
232
|
+
client.receive_response = wrapped
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def install_claude_agent_monitoring(
|
|
236
|
+
server_url: str = "http://localhost:8000",
|
|
237
|
+
user: Optional[str] = None,
|
|
238
|
+
api_key: Optional[str] = None,
|
|
239
|
+
session: Optional[str] = None,
|
|
240
|
+
name: Optional[str] = None,
|
|
241
|
+
):
|
|
242
|
+
"""
|
|
243
|
+
Install Claude Agent SDK monitoring instrumentation.
|
|
244
|
+
|
|
245
|
+
Instruments the SDK's two async-generator entry points --
|
|
246
|
+
``claude_agent_sdk.query(...)`` (one-shot) and
|
|
247
|
+
``ClaudeSDKClient.receive_response()`` (interactive) -- so every agent run
|
|
248
|
+
streams start/end/error telemetry with zero changes to the agent code. Call
|
|
249
|
+
this *before* ``claude_agent_sdk`` is imported.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
server_url: URL of the monitoring server
|
|
253
|
+
user: Name of the executing user
|
|
254
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
255
|
+
session: Per-run id used to group events in the app
|
|
256
|
+
name: Explicit session title (else derived from the query prompt)
|
|
257
|
+
"""
|
|
258
|
+
return install_hook(
|
|
259
|
+
ClaudeAgentSDKImportHook,
|
|
260
|
+
framework="Claude Agent SDK",
|
|
261
|
+
label="Claude Agent SDK",
|
|
262
|
+
server_url=server_url,
|
|
263
|
+
user=user,
|
|
264
|
+
api_key=api_key,
|
|
265
|
+
session=session,
|
|
266
|
+
name=name,
|
|
267
|
+
)
|
otelzero/hooks/crewai.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""CrewAI instrumentation: wrap Crew/Agent/Task entry points."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import wrapt
|
|
6
|
+
|
|
7
|
+
from otelzero import semconv
|
|
8
|
+
from otelzero.hooks import _content
|
|
9
|
+
from otelzero.hooks._usage import extract_usage
|
|
10
|
+
from otelzero.hooks.base import _ImportInstrumenter, install_hook, trace_agent_run
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _crewai_model_id(agent: Any) -> Optional[str]:
|
|
14
|
+
"""Best-effort model identifier for a CrewAI agent's ``llm`` field.
|
|
15
|
+
|
|
16
|
+
``Agent.llm`` is either a bare model string (the modern idiom, e.g.
|
|
17
|
+
``"gpt-4o-mini"``) or a CrewAI ``LLM`` object whose ``.model`` attribute
|
|
18
|
+
holds the name. Returns ``None`` when unset -- the agent then falls back to
|
|
19
|
+
CrewAI's default LLM, which has no id we can read here.
|
|
20
|
+
"""
|
|
21
|
+
llm = getattr(agent, "llm", None)
|
|
22
|
+
if llm is None:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(llm, str):
|
|
25
|
+
return llm or None
|
|
26
|
+
model = getattr(llm, "model", None)
|
|
27
|
+
if isinstance(model, str) and model:
|
|
28
|
+
return model
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _crew_model_id(instance: Any) -> Optional[str]:
|
|
33
|
+
"""The shared model of a crew's agents, if they all agree.
|
|
34
|
+
|
|
35
|
+
A crew can mix models across agents; a single crew-level ``model`` is only
|
|
36
|
+
meaningful when every agent resolves to the same one, so return that when the
|
|
37
|
+
distinct set is a singleton and ``None`` otherwise.
|
|
38
|
+
"""
|
|
39
|
+
models = {
|
|
40
|
+
m for a in (getattr(instance, "agents", None) or []) if (m := _crewai_model_id(a))
|
|
41
|
+
}
|
|
42
|
+
return next(iter(models)) if len(models) == 1 else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _executor_messages(agent: Any) -> Optional[list]:
|
|
46
|
+
"""The model messages CrewAI's executor accumulated for this agent.
|
|
47
|
+
|
|
48
|
+
``Agent.agent_executor`` is built when a task is executed and keeps the
|
|
49
|
+
exchange in OpenAI's shape: a system turn, the task prompt as ``user``, an
|
|
50
|
+
assistant turn whose ``tool_calls`` name the tools it wants, each result
|
|
51
|
+
under the ``tool`` role, then the final assistant turn.
|
|
52
|
+
"""
|
|
53
|
+
messages = getattr(getattr(agent, "agent_executor", None), "messages", None)
|
|
54
|
+
return messages if isinstance(messages, list) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _role_of(message: Any) -> Optional[str]:
|
|
58
|
+
if isinstance(message, dict):
|
|
59
|
+
return message.get("role")
|
|
60
|
+
return getattr(message, "role", None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _content_of(message: Any) -> Any:
|
|
64
|
+
if isinstance(message, dict):
|
|
65
|
+
return message.get("content")
|
|
66
|
+
return getattr(message, "content", None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_run(history: Optional[list], before: int):
|
|
70
|
+
"""Split an executor's messages into system prompt, input and output.
|
|
71
|
+
|
|
72
|
+
The executor keeps the whole exchange, so a run that called a tool can be
|
|
73
|
+
recorded with its ``tool_call`` and ``tool_call_response`` parts rather than
|
|
74
|
+
as the answer text alone -- the agent's final string says nothing about the
|
|
75
|
+
tools that produced it. The system turn becomes the system instructions,
|
|
76
|
+
everything up to and including the task prompt is the input, and the
|
|
77
|
+
assistant turns with the tool results they triggered are the output.
|
|
78
|
+
|
|
79
|
+
``None`` when there is nothing to split, so the caller keeps its fallback.
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(history, list) or not 0 <= before < len(history):
|
|
82
|
+
return None
|
|
83
|
+
prior = [m for m in history[:before] if _role_of(m) != "system"]
|
|
84
|
+
new = history[before:]
|
|
85
|
+
system = [m for m in new if _role_of(m) == "system"]
|
|
86
|
+
rest = [m for m in new if _role_of(m) != "system"]
|
|
87
|
+
lead = 0
|
|
88
|
+
while lead < len(rest) and _role_of(rest[lead]) == "user":
|
|
89
|
+
lead += 1
|
|
90
|
+
return system, prior + rest[:lead], rest[lead:]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CrewAIImportHook(_ImportInstrumenter):
|
|
94
|
+
"""Intercept CrewAI imports and instrument Crew/Agent/Task."""
|
|
95
|
+
|
|
96
|
+
WATCHED = {
|
|
97
|
+
"crewai.crew": "_wrap_crew_class",
|
|
98
|
+
"crewai.agent": "_wrap_agent_class",
|
|
99
|
+
"crewai.task": "_wrap_task_class",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def _wrap_crew_class(self, module):
|
|
103
|
+
"""Wrap Crew class methods"""
|
|
104
|
+
if not hasattr(module, "Crew"):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
original_kickoff = module.Crew.kickoff
|
|
108
|
+
telemetry = self.telemetry
|
|
109
|
+
|
|
110
|
+
@wrapt.decorator
|
|
111
|
+
def kickoff_wrapper(wrapped, instance, args, kwargs):
|
|
112
|
+
crew_model = _crew_model_id(instance)
|
|
113
|
+
crew_info = {
|
|
114
|
+
"num_agents": len(instance.agents)
|
|
115
|
+
if hasattr(instance, "agents")
|
|
116
|
+
else 0,
|
|
117
|
+
"num_tasks": len(instance.tasks) if hasattr(instance, "tasks") else 0,
|
|
118
|
+
"model": crew_model,
|
|
119
|
+
}
|
|
120
|
+
# Name the session after the crew's first task -- the run's headline
|
|
121
|
+
# intent -- so it reads e.g. "Research competitor pricing".
|
|
122
|
+
tasks = getattr(instance, "tasks", None) or []
|
|
123
|
+
crew_hint = getattr(tasks[0], "description", None) if tasks else None
|
|
124
|
+
# A crew coordinates several agents, which is exactly what the
|
|
125
|
+
# conventions call a workflow -- they name CrewAI as the example.
|
|
126
|
+
crew_name = getattr(instance, "name", None)
|
|
127
|
+
|
|
128
|
+
with trace_agent_run(
|
|
129
|
+
telemetry,
|
|
130
|
+
semconv.span_name(semconv.OP_INVOKE_WORKFLOW, crew_name),
|
|
131
|
+
{
|
|
132
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_WORKFLOW,
|
|
133
|
+
semconv.GEN_AI_WORKFLOW_NAME: crew_name,
|
|
134
|
+
semconv.GEN_AI_REQUEST_MODEL: crew_model,
|
|
135
|
+
semconv.OTELZERO_NUM_AGENTS: crew_info["num_agents"],
|
|
136
|
+
semconv.OTELZERO_NUM_TASKS: crew_info["num_tasks"],
|
|
137
|
+
},
|
|
138
|
+
start_event="crew_start",
|
|
139
|
+
end_event="crew_end",
|
|
140
|
+
error_event="crew_error",
|
|
141
|
+
log_fields={"model": crew_model},
|
|
142
|
+
start_details=crew_info,
|
|
143
|
+
end_details={"status": "completed"},
|
|
144
|
+
session_name_hint=crew_hint,
|
|
145
|
+
) as run:
|
|
146
|
+
# ``kickoff(inputs={...})`` -- the template variables are the
|
|
147
|
+
# crew's prompt; recorded as their JSON.
|
|
148
|
+
inputs = kwargs.get("inputs", args[0] if args else None)
|
|
149
|
+
run.set_input(_content.messages_from(inputs))
|
|
150
|
+
result = wrapped(*args, **kwargs)
|
|
151
|
+
# CrewOutput.token_usage is a UsageMetrics (prompt_tokens,
|
|
152
|
+
# completion_tokens, cached_prompt_tokens, total_tokens);
|
|
153
|
+
# CrewOutput.raw is the final answer.
|
|
154
|
+
run.set_usage(extract_usage(getattr(result, "token_usage", None)))
|
|
155
|
+
run.set_output(_content.text_of(result))
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
module.Crew.kickoff = kickoff_wrapper(original_kickoff)
|
|
159
|
+
|
|
160
|
+
def _wrap_agent_class(self, module):
|
|
161
|
+
"""Wrap Agent class methods"""
|
|
162
|
+
if not hasattr(module, "Agent"):
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Wrap execute_task if it exists
|
|
166
|
+
if hasattr(module.Agent, "execute_task"):
|
|
167
|
+
original_execute = module.Agent.execute_task
|
|
168
|
+
telemetry = self.telemetry
|
|
169
|
+
|
|
170
|
+
@wrapt.decorator
|
|
171
|
+
def execute_wrapper(wrapped, instance, args, kwargs):
|
|
172
|
+
agent_info = {
|
|
173
|
+
"role": getattr(instance, "role", "Unknown"),
|
|
174
|
+
"goal": getattr(instance, "goal", "N/A")[:100],
|
|
175
|
+
"model": _crewai_model_id(instance),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
with trace_agent_run(
|
|
179
|
+
telemetry,
|
|
180
|
+
semconv.span_name(semconv.OP_INVOKE_AGENT, agent_info["role"]),
|
|
181
|
+
{
|
|
182
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_AGENT,
|
|
183
|
+
semconv.GEN_AI_AGENT_NAME: agent_info["role"],
|
|
184
|
+
semconv.GEN_AI_REQUEST_MODEL: agent_info["model"],
|
|
185
|
+
semconv.GEN_AI_AGENT_DESCRIPTION: agent_info["goal"],
|
|
186
|
+
},
|
|
187
|
+
start_event="agent_start",
|
|
188
|
+
end_event="agent_end",
|
|
189
|
+
error_event="agent_error",
|
|
190
|
+
log_fields={
|
|
191
|
+
"agent_role": agent_info["role"],
|
|
192
|
+
"model": agent_info["model"],
|
|
193
|
+
},
|
|
194
|
+
start_details=agent_info,
|
|
195
|
+
# Raw goal (not the "N/A"-defaulted copy above) names a
|
|
196
|
+
# standalone agent run; a crew run is already named by its
|
|
197
|
+
# first task, and the first non-empty hint wins.
|
|
198
|
+
session_name_hint=getattr(instance, "goal", None),
|
|
199
|
+
) as run:
|
|
200
|
+
# ``execute_task(task, context=None, tools=None)``: the task
|
|
201
|
+
# description (plus any context from earlier tasks) is the
|
|
202
|
+
# prompt; the agent's backstory is its system prompt.
|
|
203
|
+
task = kwargs.get("task", args[0] if args else None)
|
|
204
|
+
context = kwargs.get("context", args[1] if len(args) > 1 else None)
|
|
205
|
+
prompt = getattr(task, "description", None)
|
|
206
|
+
if isinstance(prompt, str) and isinstance(context, str) and context:
|
|
207
|
+
prompt = f"{prompt}\n\nContext:\n{context}"
|
|
208
|
+
run.set_input(_content.messages_from(prompt))
|
|
209
|
+
run.set_system(_content.text_of(getattr(instance, "backstory", None)))
|
|
210
|
+
# Watermark the executor's history so this run's turns can
|
|
211
|
+
# be told from the ones it inherited; a fresh executor for
|
|
212
|
+
# this task starts the count over.
|
|
213
|
+
history = _executor_messages(instance)
|
|
214
|
+
before = len(history) if history is not None else 0
|
|
215
|
+
result = wrapped(*args, **kwargs)
|
|
216
|
+
after = _executor_messages(instance)
|
|
217
|
+
split = _split_run(after, before if after is history else 0)
|
|
218
|
+
messages = None
|
|
219
|
+
if split is not None:
|
|
220
|
+
system, inputs, outputs = split
|
|
221
|
+
if system:
|
|
222
|
+
# The prompt actually sent, which carries the role
|
|
223
|
+
# and goal the backstory alone does not.
|
|
224
|
+
run.set_system(_content.text_of(_content_of(system[0])))
|
|
225
|
+
run.set_input(_content.messages_from(inputs))
|
|
226
|
+
messages = _content.messages_from(
|
|
227
|
+
outputs, default_role="assistant"
|
|
228
|
+
)
|
|
229
|
+
run.set_output(messages or _content.text_of(result))
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
module.Agent.execute_task = execute_wrapper(original_execute)
|
|
233
|
+
|
|
234
|
+
def _wrap_task_class(self, module):
|
|
235
|
+
"""Wrap Task class methods"""
|
|
236
|
+
if not hasattr(module, "Task"):
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
telemetry = self.telemetry
|
|
240
|
+
|
|
241
|
+
def _traced(instance, args, kwargs):
|
|
242
|
+
"""Build the trace context manager for one task execution."""
|
|
243
|
+
task_info = {
|
|
244
|
+
"description": getattr(instance, "description", "N/A")[:100],
|
|
245
|
+
"expected_output": getattr(instance, "expected_output", "N/A")[:100],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# The agent passed to this execution wins over the task's own, as it
|
|
249
|
+
# does inside CrewAI (a hierarchical crew assigns one at run time).
|
|
250
|
+
agent = (args[0] if args else kwargs.get("agent")) or getattr(
|
|
251
|
+
instance, "agent", None
|
|
252
|
+
)
|
|
253
|
+
agent_role = None
|
|
254
|
+
model = None
|
|
255
|
+
if agent:
|
|
256
|
+
agent_role = getattr(agent, "role", "Unknown")
|
|
257
|
+
model = _crewai_model_id(agent)
|
|
258
|
+
|
|
259
|
+
# A CrewAI Task is the *parent* of the agent invocation it
|
|
260
|
+
# delegates to, so no predefined operation fits; execute_task is
|
|
261
|
+
# otelzero's documented custom value (see otelzero.semconv).
|
|
262
|
+
return trace_agent_run(
|
|
263
|
+
telemetry,
|
|
264
|
+
semconv.span_name(semconv.OP_EXECUTE_TASK, agent_role),
|
|
265
|
+
{
|
|
266
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_EXECUTE_TASK,
|
|
267
|
+
semconv.GEN_AI_AGENT_NAME: agent_role,
|
|
268
|
+
semconv.GEN_AI_REQUEST_MODEL: model,
|
|
269
|
+
semconv.OTELZERO_TASK_DESCRIPTION: task_info["description"],
|
|
270
|
+
semconv.OTELZERO_TASK_EXPECTED_OUTPUT: task_info["expected_output"],
|
|
271
|
+
},
|
|
272
|
+
start_event="task_start",
|
|
273
|
+
end_event="task_end",
|
|
274
|
+
error_event="task_error",
|
|
275
|
+
log_fields={"agent_role": agent_role, "model": model},
|
|
276
|
+
start_details=task_info,
|
|
277
|
+
end_details={"status": "completed"},
|
|
278
|
+
session_name_hint=getattr(instance, "description", None),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _record_input(run, instance):
|
|
282
|
+
# The full description (the [:100] copy above is a preview).
|
|
283
|
+
run.set_input(_content.messages_from(getattr(instance, "description", None)))
|
|
284
|
+
|
|
285
|
+
@wrapt.decorator
|
|
286
|
+
def execute_wrapper(wrapped, instance, args, kwargs):
|
|
287
|
+
with _traced(instance, args, kwargs) as run:
|
|
288
|
+
_record_input(run, instance)
|
|
289
|
+
result = wrapped(*args, **kwargs)
|
|
290
|
+
# TaskOutput.raw is the task's result text.
|
|
291
|
+
run.set_output(_content.text_of(result))
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
@wrapt.decorator
|
|
295
|
+
async def aexecute_wrapper(wrapped, instance, args, kwargs):
|
|
296
|
+
with _traced(instance, args, kwargs) as run:
|
|
297
|
+
_record_input(run, instance)
|
|
298
|
+
result = await wrapped(*args, **kwargs)
|
|
299
|
+
run.set_output(_content.text_of(result))
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
# Current CrewAI has no Task.execute: execute_sync and execute_async
|
|
303
|
+
# both run _execute_core, and aexecute_sync runs _aexecute_core.
|
|
304
|
+
for name in ("_execute_core", "execute_sync", "execute"):
|
|
305
|
+
if hasattr(module.Task, name):
|
|
306
|
+
setattr(module.Task, name, execute_wrapper(getattr(module.Task, name)))
|
|
307
|
+
break
|
|
308
|
+
if hasattr(module.Task, "_aexecute_core"):
|
|
309
|
+
module.Task._aexecute_core = aexecute_wrapper(module.Task._aexecute_core)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def install_crewai_monitoring(
|
|
313
|
+
server_url: str = "http://localhost:8000",
|
|
314
|
+
user: Optional[str] = None,
|
|
315
|
+
api_key: Optional[str] = None,
|
|
316
|
+
session: Optional[str] = None,
|
|
317
|
+
name: Optional[str] = None,
|
|
318
|
+
):
|
|
319
|
+
"""
|
|
320
|
+
Install CrewAI monitoring instrumentation
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
server_url: URL of the monitoring server
|
|
324
|
+
user: Name of the executing user
|
|
325
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
326
|
+
session: Per-run id used to group events in the app
|
|
327
|
+
name: Explicit session title (else derived from the crew's first task)
|
|
328
|
+
"""
|
|
329
|
+
return install_hook(
|
|
330
|
+
CrewAIImportHook,
|
|
331
|
+
framework="CrewAI",
|
|
332
|
+
label="CrewAI",
|
|
333
|
+
server_url=server_url,
|
|
334
|
+
user=user,
|
|
335
|
+
api_key=api_key,
|
|
336
|
+
session=session,
|
|
337
|
+
name=name,
|
|
338
|
+
)
|