Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
import time
from typing import Collection
Expand All @@ -22,6 +23,22 @@

_instruments = ("crewai >= 1.0.0",)

# crewai >= 1.x routes `LLM(...)` through `LLM.__new__`, which returns native
# provider classes (e.g. `OpenAICompletion`) instead of an `LLM` instance —
# those classes inherit from `BaseLLM`, not `LLM`, so wrapping only
# `crewai.llm.LLM.call` (the LiteLLM fallback path) misses every native call.
# Each provider class overrides `call`, so the base class cannot be wrapped
# instead; every native class needs its own wrap. Provider modules import
# their SDK lazily and may be absent, hence the try/except around each.
_NATIVE_LLM_WRAPPED_METHODS = [
# (module, class qualname, method) — import failures are non-fatal.
("crewai.llms.providers.openai.completion", "OpenAICompletion", "call"),
("crewai.llms.providers.azure.completion", "AzureCompletion", "call"),
("crewai.llms.providers.anthropic.completion", "AnthropicCompletion", "call"),
("crewai.llms.providers.gemini.completion", "GeminiCompletion", "call"),
("crewai.llms.providers.bedrock.completion", "BedrockCompletion", "call"),
]

# Maps LiteLLM vendor prefixes (e.g. "openai" in "openai/gpt-4") to OTel provider name values.
# Uses GenAISystem (semconv-ai) and GenAiSystemValues (OTel upstream) — no raw strings.
_LITELLM_PREFIX_TO_OTEL_PROVIDER = {
Expand Down Expand Up @@ -92,12 +109,26 @@ def _instrument(self, **kwargs):
wrap_task_execute(tracer, duration_histogram, token_histogram))
wrap_function_wrapper("crewai.llm", "LLM.call",
wrap_llm_call(tracer, duration_histogram, token_histogram))
for module, class_name, method in _NATIVE_LLM_WRAPPED_METHODS:
try:
wrap_function_wrapper(
module, f"{class_name}.{method}",
wrap_llm_call(tracer, duration_histogram, token_histogram),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
)
except (ImportError, AttributeError):
# Provider SDK not installed — the class is never used.
logging.debug("crewai native LLM provider %s.%s not importable; skipping wrap", class_name, method)

def _uninstrument(self, **kwargs):
unwrap("crewai.crew.Crew", "kickoff")
unwrap("crewai.agent.Agent", "execute_task")
unwrap("crewai.task.Task", "execute_sync")
unwrap("crewai.llm.LLM", "call")
for module, class_name, method in _NATIVE_LLM_WRAPPED_METHODS:
try:
unwrap(f"{module}.{class_name}", method)
except (ImportError, AttributeError):
pass


def with_tracer_wrapper(func):
Expand Down
69 changes: 69 additions & 0 deletions packages/opentelemetry-instrumentation-crewai/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Unit tests configuration module."""

import pytest
from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
from opentelemetry.sdk.metrics.export import (
AggregationTemporality,
InMemoryMetricReader,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

pytest_plugins = []


@pytest.fixture(scope="function", name="span_exporter")
def fixture_span_exporter():
exporter = InMemorySpanExporter()
yield exporter


@pytest.fixture(scope="function", name="tracer_provider")
def fixture_tracer_provider(span_exporter):
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(span_exporter))
return provider


@pytest.fixture(scope="function", name="reader")
def fixture_reader():
reader = InMemoryMetricReader(
{Counter: AggregationTemporality.DELTA, Histogram: AggregationTemporality.DELTA}
)
return reader


@pytest.fixture(scope="function", name="meter_provider")
def fixture_meter_provider(reader):
resource = Resource.create()
meter_provider = MeterProvider(metric_readers=[reader], resource=resource)
return meter_provider


@pytest.fixture(scope="function")
def instrument(reader, tracer_provider, meter_provider):
"""Real instrumentation against an in-memory exporter.

BaseInstrumentor is a singleton (its __new__ returns a shared instance),
so any instance-attribute mocks left behind by other tests must be
removed before the real methods can run again.
"""
from opentelemetry.instrumentation.crewai import CrewAIInstrumentor

instrumentor = CrewAIInstrumentor()
# Restore real methods in case a previous test mocked them on the singleton.
instrumentor.__dict__.pop("instrument", None)
instrumentor.__dict__.pop("uninstrument", None)
if instrumentor._is_instrumented_by_opentelemetry:
instrumentor.uninstrument()

instrumentor.instrument(
tracer_provider=tracer_provider,
meter_provider=meter_provider,
)

yield instrumentor

instrumentor.uninstrument()
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,25 @@ def test_trace_status(mock_crew, mock_instrumentor):

mock_instrumentor.uninstrument()
mock_instrumentor.uninstrument.assert_called_once()




def test_native_provider_call_is_wrapped(instrument):
"""crewai's native provider classes (BaseLLM subclasses, not LLM) must have
their call wrapped — previously only crewai.llm.LLM.call was patched, which
the LLM.__new__ factory never returns on the native path."""
from crewai.llms.providers.openai.completion import OpenAICompletion

assert getattr(OpenAICompletion.call, "__wrapped__", None) is not None


def test_native_provider_uninstrument_restores_call(instrument):
from crewai.llms.providers.openai.completion import OpenAICompletion

instrument.uninstrument()
try:
assert getattr(OpenAICompletion.call, "__wrapped__", None) is None
finally:
# re-instrument so the autouse-style fixture teardown stays balanced
instrument.instrument()