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.
@@ -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
+ )
@@ -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
+ )