Skip to content

Commit 84bdeaa

Browse files
committed
add all unit/contract tests
1 parent 10a40d2 commit 84bdeaa

File tree

15 files changed

+677
-81
lines changed

15 files changed

+677
-81
lines changed

.coveragerc-py39

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[run]
2+
omit =
3+
*/instrumentation/crewai/*
4+
5+
[report]
6+
include_namespace_packages = true
7+
fail_under = 95.00
8+
precision = 2

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
1212

1313
## Unreleased
1414

15+
- Add native CrewAI instrumentation support
16+
([#586](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/586))
1517
- Fix: Support new fields in X-Ray API responses
1618
([#577](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/577))
1719
- Sign Lambda layer by AWS Signer

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_configurator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -897,4 +897,4 @@ def _create_aws_otlp_exporter(endpoint: str, service: str, region: str):
897897
# pylint: disable=broad-exception-caught
898898
except Exception as errors:
899899
_logger.error("Failed to create AWS OTLP exporter: %s", errors)
900-
return None
900+
return None

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/instrumentation/crewai/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,20 @@ class CrewAIInstrumentor(BaseInstrumentor):
2424
Note: Semantic conventions may change in future versions.
2525
"""
2626

27-
def instrumentation_dependencies(self) -> Collection[str]:
28-
return ("crewai >= 0.119.0",)
27+
def instrumentation_dependencies(self) -> Collection[str]: # pylint: disable=no-self-use
28+
return ("crewai >= 0.41.0",)
2929

30-
def _instrument(self, **kwargs: Any) -> None:
30+
# disabling these linters rules as these are instance methods from BaseInstrumentor
31+
def _instrument(self, **kwargs: Any) -> None: # pylint: disable=no-self-use
3132
tracer_provider = kwargs.get("tracer_provider") or trace.get_tracer_provider()
3233
tracer = trace.get_tracer(__name__, __version__, tracer_provider=tracer_provider)
3334

3435
wrap_function_wrapper("crewai", "Crew.kickoff", _CrewKickoffWrapper(tracer))
3536
wrap_function_wrapper("crewai", "Task._execute_core", _TaskExecuteCoreWrapper(tracer))
3637
wrap_function_wrapper("crewai.tools.tool_usage", "ToolUsage._use", _ToolUseWrapper(tracer))
3738

38-
def _uninstrument(self, **kwargs: Any) -> None:
39+
def _uninstrument(self, **kwargs: Any) -> None: # pylint: disable=no-self-use
40+
# pylint: disable=import-outside-toplevel
3941
import crewai
4042
from crewai.tools import tool_usage
4143

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/instrumentation/crewai/_wrappers.py

Lines changed: 68 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,6 @@
55
from abc import ABC, abstractmethod
66
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Tuple
77

8-
from opentelemetry import context
9-
10-
if TYPE_CHECKING:
11-
from crewai.agent import Agent
12-
from crewai.crew import Crew
13-
from crewai.llm import LLM
14-
from crewai.task import Task
15-
from crewai.tools.structured_tool import CrewStructuredTool
16-
from crewai.tools.tool_calling import ToolCalling
17-
from crewai.tools.tool_usage import ToolUsage
18-
from pydantic import BaseModel
19-
208
from amazon.opentelemetry.distro.semconv._incubating.attributes.gen_ai_attributes import (
219
GEN_AI_AGENT_DESCRIPTION,
2210
GEN_AI_AGENT_ID,
@@ -28,21 +16,33 @@
2816
GEN_AI_REQUEST_TEMPERATURE,
2917
GEN_AI_SYSTEM_INSTRUCTIONS,
3018
GEN_AI_TOOL_CALL_ARGUMENTS,
31-
GEN_AI_TOOL_CALL_ID,
19+
GEN_AI_TOOL_CALL_RESULT,
3220
GEN_AI_TOOL_DEFINITIONS,
3321
GEN_AI_TOOL_DESCRIPTION,
3422
GEN_AI_TOOL_NAME,
23+
GEN_AI_TOOL_TYPE,
3524
)
36-
from opentelemetry import trace
25+
from opentelemetry import context, trace
26+
from opentelemetry.semconv._incubating.attributes.error_attributes import ERROR_TYPE
3727
from opentelemetry.trace import SpanKind, Status, StatusCode
3828

29+
if TYPE_CHECKING:
30+
from crewai.agent import Agent
31+
from crewai.crew import Crew
32+
from crewai.llm import LLM
33+
from crewai.task import Task
34+
from crewai.tools.structured_tool import CrewStructuredTool
35+
from crewai.tools.tool_calling import ToolCalling
36+
from crewai.tools.tool_usage import ToolUsage
37+
from pydantic import BaseModel
38+
3939
_OPERATION_INVOKE_AGENT = "invoke_agent"
4040
_OPERATION_EXECUTE_TOOL = "execute_tool"
41-
# default value for gen_ai.provider.name, a required attribute per OpenTelemetry semantic conventions.
41+
# default value for gen_ai.provider.name, a required attribute per OpenTelemetry
42+
# semantic conventions.
4243
# "crewai" is not a standard provider name in semconv v1.39, but serves as a fallback when the
4344
# underlying LLM provider cannot be determined.
4445
_PROVIDER_CREWAI = "crewai"
45-
_ERROR_TYPE = "error.type"
4646

4747

4848
class _BaseWrapper(ABC):
@@ -70,6 +70,32 @@ class _BaseWrapper(ABC):
7070
def __init__(self, tracer: Optional[trace.Tracer] = None) -> None:
7171
self._tracer = tracer or trace.get_tracer(__name__)
7272

73+
def __call__(
74+
self,
75+
wrapped: Callable[..., Any],
76+
instance: Any,
77+
args: Tuple[Any, ...],
78+
kwargs: Mapping[str, Any],
79+
) -> Any:
80+
if context.get_value(context._SUPPRESS_INSTRUMENTATION_KEY):
81+
return wrapped(*args, **kwargs)
82+
83+
with self._tracer.start_as_current_span(
84+
self._get_span_name(instance, args, kwargs),
85+
kind=SpanKind.INTERNAL,
86+
attributes=self._get_attributes(instance, args, kwargs),
87+
) as span:
88+
try:
89+
result = wrapped(*args, **kwargs)
90+
self._on_success(span, result)
91+
span.set_status(Status(StatusCode.OK))
92+
return result
93+
except Exception as exc: # pylint: disable=broad-exception-caught
94+
span.set_status(Status(StatusCode.ERROR, str(exc)))
95+
span.set_attribute(ERROR_TYPE, type(exc).__name__)
96+
span.record_exception(exc)
97+
raise
98+
7399
def _extract_provider_and_model(self, llm: Optional["LLM"]) -> Tuple[Optional[str], Optional[str]]:
74100
# extracts provider name and model from CrewAI LLM object
75101
if not llm:
@@ -93,7 +119,8 @@ def _extract_provider_and_model(self, llm: Optional["LLM"]) -> Tuple[Optional[st
93119

94120
return None, model
95121

96-
def _serialize_to_json(self, value: Any, max_depth: int = 10) -> str:
122+
@staticmethod
123+
def _serialize_to_json(value: Any, max_depth: int = 10) -> str:
97124
def _truncate(obj: Any, depth: int) -> Any:
98125
if depth <= 0:
99126
return "..."
@@ -116,49 +143,24 @@ def _get_span_name(self, instance: Any, args: Tuple[Any, ...], kwargs: Mapping[s
116143
def _get_attributes(self, instance: Any, args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> Dict[str, Any]:
117144
pass
118145

119-
def __call__(
120-
self,
121-
wrapped: Callable[..., Any],
122-
instance: Any,
123-
args: Tuple[Any, ...],
124-
kwargs: Mapping[str, Any],
125-
) -> Any:
126-
if context.get_value(context._SUPPRESS_INSTRUMENTATION_KEY):
127-
return wrapped(*args, **kwargs)
128-
129-
with self._tracer.start_as_current_span(
130-
self._get_span_name(instance, args, kwargs),
131-
kind=SpanKind.INTERNAL,
132-
attributes=self._get_attributes(instance, args, kwargs),
133-
) as span:
134-
try:
135-
result = wrapped(*args, **kwargs)
136-
span.set_status(Status(StatusCode.OK))
137-
return result
138-
except Exception as e:
139-
span.set_status(Status(StatusCode.ERROR, str(e)))
140-
span.set_attribute(_ERROR_TYPE, type(e).__name__)
141-
span.record_exception(e)
142-
raise
146+
def _on_success(self, span: trace.Span, result: Any) -> None:
147+
"""Hook called on successful execution."""
143148

144149

145150
class _CrewKickoffWrapper(_BaseWrapper):
146-
# wraps Crew.kickoff which is responsible for starting the entire agentic workflow.
147-
# see: https://github.com/crewAIInc/crewAI/blob/main/src/crewai/crew.py
151+
# wraps Crew.kickoff which is responsible for starting the agentic workflow.
152+
# see:
153+
# https://github.com/crewAIInc/crewAI/blob/06d953bf46c636ff9f2d64f45574493d05fb7771/lib/crewai/src/crewai/crew.py#L676-L679
148154
# Note: The span name "crew_kickoff {crew_name}" does not conform to any current OTel semantic
149155
# conventions. This is because CrewAI's orchestration workflow where a Crew can contain multiple
150156
# agents but there currently does not exist any semantic convention naming schema to capture
151157
# this architecture.
152158

153-
def _get_span_name(
154-
self, instance: "Crew", args: Tuple[Any, ...], kwargs: Mapping[str, Any]
155-
) -> str:
159+
def _get_span_name(self, instance: "Crew", args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> str:
156160
crew_name = getattr(instance, "name", None)
157161
return f"crew_kickoff {crew_name}" if crew_name else "crew_kickoff"
158162

159-
def _get_attributes(
160-
self, instance: "Crew", args: Tuple[Any, ...], kwargs: Mapping[str, Any]
161-
) -> Dict[str, Any]:
163+
def _get_attributes(self, instance: "Crew", args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> Dict[str, Any]:
162164
attributes: Dict[str, Any] = {
163165
GEN_AI_OPERATION_NAME: _OPERATION_INVOKE_AGENT,
164166
GEN_AI_PROVIDER_NAME: _PROVIDER_CREWAI,
@@ -190,7 +192,8 @@ def _get_attributes(
190192

191193
return attributes
192194

193-
def _extract_tool_definitions(self, tools: Any) -> list:
195+
@staticmethod
196+
def _extract_tool_definitions(tools: Any) -> list:
194197
defs = []
195198
for tool in tools:
196199
tool_def: Dict[str, Any] = {"type": "function"}
@@ -202,26 +205,24 @@ def _extract_tool_definitions(self, tools: Any) -> list:
202205
if args_schema is not None:
203206
try:
204207
tool_def["parameters"] = args_schema.model_json_schema()
205-
except Exception:
208+
except Exception: # pylint: disable=broad-exception-caught
206209
pass
207210
defs.append(tool_def)
208211
return defs
209212

210213

211214
class _TaskExecuteCoreWrapper(_BaseWrapper):
212-
# wraps Task._execute_core which is responsible for running a single task with its assigned agent.
213-
# see: https://github.com/crewAIInc/crewAI/blob/main/src/crewai/task.py
215+
# wraps Task._execute_core which is responsible for running a single task
216+
# with its assigned agent.
217+
# see:
218+
# https://github.com/crewAIInc/crewAI/blob/06d953bf46c636ff9f2d64f45574493d05fb7771/lib/crewai/src/crewai/task.py#L604-L608
214219

215-
def _get_span_name(
216-
self, instance: "Task", args: Tuple[Any, ...], kwargs: Mapping[str, Any]
217-
) -> str:
220+
def _get_span_name(self, instance: "Task", args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> str:
218221
agent: Optional[Agent] = args[0] if args else kwargs.get("agent")
219222
agent_role = getattr(agent, "role", None) if agent else None
220223
return f"{_OPERATION_INVOKE_AGENT} {agent_role}" if agent_role else _OPERATION_INVOKE_AGENT
221224

222-
def _get_attributes(
223-
self, instance: "Task", args: Tuple[Any, ...], kwargs: Mapping[str, Any]
224-
) -> Dict[str, Any]:
225+
def _get_attributes(self, instance: "Task", args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> Dict[str, Any]:
225226
agent: Optional[Agent] = args[0] if args else kwargs.get("agent")
226227
attributes: Dict[str, Any] = {
227228
GEN_AI_OPERATION_NAME: _OPERATION_INVOKE_AGENT,
@@ -263,11 +264,10 @@ def _get_attributes(
263264

264265
class _ToolUseWrapper(_BaseWrapper):
265266
# Wraps ToolUsage._use which executes a tool call during agent task execution.
266-
# see: https://github.com/crewAIInc/crewAI/blob/main/src/crewai/tools/tool_usage.py
267+
# see:
268+
# https://github.com/crewAIInc/crewAI/blob/06d953bf46c636ff9f2d64f45574493d05fb7771/lib/crewai/src/crewai/tools/tool_usage.py#L423-L427
267269

268-
def _get_span_name(
269-
self, instance: "ToolUsage", args: Tuple[Any, ...], kwargs: Mapping[str, Any]
270-
) -> str:
270+
def _get_span_name(self, instance: "ToolUsage", args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> str:
271271
tool: Optional[CrewStructuredTool] = args[1] if len(args) > 1 else kwargs.get("tool")
272272
tool_name = getattr(tool, "name", None) if tool else None
273273
return f"{_OPERATION_EXECUTE_TOOL} {tool_name}" if tool_name else _OPERATION_EXECUTE_TOOL
@@ -280,6 +280,7 @@ def _get_attributes(
280280
attributes: Dict[str, Any] = {
281281
GEN_AI_OPERATION_NAME: _OPERATION_EXECUTE_TOOL,
282282
GEN_AI_PROVIDER_NAME: _PROVIDER_CREWAI,
283+
GEN_AI_TOOL_TYPE: "function",
283284
}
284285

285286
if tool:
@@ -291,9 +292,6 @@ def _get_attributes(
291292
attributes[GEN_AI_TOOL_DESCRIPTION] = tool_desc
292293

293294
if calling:
294-
call_id = getattr(calling, "id", None)
295-
if call_id:
296-
attributes[GEN_AI_TOOL_CALL_ID] = str(call_id)
297295
call_args = getattr(calling, "arguments", None)
298296
if call_args:
299297
attributes[GEN_AI_TOOL_CALL_ARGUMENTS] = self._serialize_to_json(call_args)
@@ -308,3 +306,7 @@ def _get_attributes(
308306
attributes[GEN_AI_REQUEST_MODEL] = model
309307

310308
return attributes
309+
310+
def _on_success(self, span: trace.Span, result: Any) -> None:
311+
if result is not None:
312+
span.set_attribute(GEN_AI_TOOL_CALL_RESULT, self._serialize_to_json(result))

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/semconv/_incubating/attributes/gen_ai_attributes.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@
2525
GEN_AI_TOOL_DESCRIPTION = "gen_ai.tool.description"
2626
GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id"
2727
GEN_AI_TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
28+
GEN_AI_TOOL_CALL_RESULT = "gen_ai.tool.call.result"
2829
GEN_AI_TOOL_DEFINITIONS = "gen_ai.tool.definitions"
30+
GEN_AI_TOOL_TYPE = "gen_ai.tool.type"
2931
GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
30-
31-
# Deprecated - use GEN_AI_PROVIDER_NAME instead
32-
GEN_AI_SYSTEM = "gen_ai.system"

0 commit comments

Comments
 (0)