Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion py/examples/dspy/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ def main():
print("🔍 Braintrust logging enabled - view traces at https://braintrust.dev")

# Disable DSPy's disk cache (keep memory cache for performance)
dspy.configure_cache(enable_disk_cache=False, enable_memory_cache=True)
if hasattr(dspy, "configure_cache"):
dspy.configure_cache(enable_disk_cache=False, enable_memory_cache=True) # pylint: disable=no-member

# Configure DSPy with Braintrust callback
lm = dspy.LM("openai/gpt-4o-mini")
Expand Down
16 changes: 16 additions & 0 deletions py/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,22 @@ def test_dspy(session, version):
_run_tests(session, f"{INTEGRATION_DIR}/dspy/test_dspy.py", version=version)


CREWAI_VERSIONS = _get_matrix_versions("crewai")


@nox.session()
@nox.parametrize("version", CREWAI_VERSIONS, ids=CREWAI_VERSIONS)
def test_crewai(session, version):
if sys.version_info >= (3, 14):
session.skip(
"CrewAI currently resolves instructor -> pydantic-core builds that do not ship Python 3.14 wheels"
)
_install_test_deps(session)
_install_group_locked(session, "test-crewai")
_install_matrix_dep(session, "crewai", version)
_run_tests(session, f"{INTEGRATION_DIR}/crewai/test_crewai.py", version=version)


GOOGLE_ADK_VERSIONS = _get_matrix_versions("google-adk")


Expand Down
26 changes: 26 additions & 0 deletions py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ test-langchain = [
"langgraph==1.1.6",
]

test-crewai = [
{include-group = "test"},
# CrewAI's no-network smoke test forces the LiteLLM fallback path via
# ``is_litellm=True`` + ``mock_response``.
"litellm==1.83.10",
]

test-cli = [
{include-group = "test"},
"httpx==0.28.1",
Expand All @@ -170,6 +177,15 @@ lint = [
"cohere",
"autoevals",
"braintrust-core",
# crewai's transitive dep instructor pulls in pydantic-core 2.33.x, which
# does not yet ship wheels for Python 3.14. Exclude 3.14 so pylint can
# resolve the rest of the environment. Keep the package itself unpinned so
# the resolver can still pick a mutually compatible version with the other
# lint deps.
"crewai; python_version<'3.14'",
# onnxruntime 1.24.3 dropped cp310 wheels. Constrain the transitive
# resolution on Python 3.10 so pylint can still build its environment.
"onnxruntime<1.24; python_version<'3.11'",
"dspy",
"google-adk",
"google-genai",
Expand Down Expand Up @@ -216,6 +232,7 @@ conflicts = [
[
{group = "test-openai-agents"},
{group = "test-litellm"},
{group = "test-crewai"},
{group = "test-agno"},
{group = "test-agentscope"},
{group = "test-langchain"},
Expand Down Expand Up @@ -299,6 +316,13 @@ latest = "google-genai==1.73.1"
latest = "dspy==3.2.0"
"2.6.0" = "dspy==2.6.0"

[tool.braintrust.matrix.crewai]
# 1.13.0 is the first release with the full causal-id surface (event_id /
# parent_event_id / started_event_id on BaseEvent) plus the ``usage`` field on
# LLMCallCompletedEvent that the Braintrust CrewAI integration depends on.
latest = "crewai==1.14.2"
"1.13.0" = "crewai==1.13.0"

[tool.braintrust.matrix.google-adk]
latest = "google-adk==1.31.1"
"1.14.1" = "google-adk==1.14.1"
Expand Down Expand Up @@ -351,6 +375,7 @@ agno = ["agno"]
anthropic = ["anthropic"]
cohere = ["cohere"]
claude_agent_sdk = ["claude-agent-sdk"]
crewai = ["crewai"]
dspy = ["dspy"]
google_genai = ["google-genai"]
langchain = ["langchain-core"]
Expand All @@ -368,6 +393,7 @@ anthropic = "anthropic"
cohere = "cohere"
autoevals = "autoevals"
braintrust-core = "braintrust_core"
crewai = "crewai"
dspy = "dspy"
google-adk = "google.adk"
google-genai = "google.genai"
Expand Down
5 changes: 5 additions & 0 deletions py/src/braintrust/auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AnthropicIntegration,
ClaudeAgentSDKIntegration,
CohereIntegration,
CrewAIIntegration,
DSPyIntegration,
GoogleGenAIIntegration,
LangChainIntegration,
Expand Down Expand Up @@ -60,6 +61,7 @@ def auto_instrument(
langchain: bool = True,
openai_agents: bool = True,
cohere: bool = True,
crewai: bool = True,
) -> dict[str, bool]:
"""
Auto-instrument supported AI/ML libraries for Braintrust tracing.
Expand All @@ -86,6 +88,7 @@ def auto_instrument(
langchain: Enable LangChain instrumentation (default: True)
openai_agents: Enable OpenAI Agents SDK instrumentation (default: True)
cohere: Enable Cohere instrumentation (default: True)
crewai: Enable CrewAI instrumentation (default: True)

Returns:
Dict mapping integration name to whether it was successfully instrumented.
Expand Down Expand Up @@ -163,6 +166,8 @@ def auto_instrument(
results["openai_agents"] = _instrument_integration(OpenAIAgentsIntegration)
if cohere:
results["cohere"] = _instrument_integration(CohereIntegration)
if crewai:
results["crewai"] = _instrument_integration(CrewAIIntegration)

return results

Expand Down
2 changes: 2 additions & 0 deletions py/src/braintrust/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .anthropic import AnthropicIntegration
from .claude_agent_sdk import ClaudeAgentSDKIntegration
from .cohere import CohereIntegration
from .crewai import CrewAIIntegration
from .dspy import DSPyIntegration
from .google_genai import GoogleGenAIIntegration
from .langchain import LangChainIntegration
Expand All @@ -22,6 +23,7 @@
"AnthropicIntegration",
"ClaudeAgentSDKIntegration",
"CohereIntegration",
"CrewAIIntegration",
"DSPyIntegration",
"GoogleGenAIIntegration",
"LiteLLMIntegration",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Test auto_instrument for CrewAI.

Verifies that ``auto_instrument(crewai=True)`` registers the Braintrust
CrewAI listener on ``crewai_event_bus`` and is idempotent across repeated
calls. Full span-shape coverage lives in ``test_crewai.py``.
"""

# pylint: disable=import-error

from braintrust.auto import auto_instrument
from braintrust.integrations.crewai import BraintrustCrewAIListener
from braintrust.integrations.crewai.patchers import _get_registered_listener


# 1. Not registered initially.
assert _get_registered_listener() is None

# 2. Instrument once.
results = auto_instrument()
assert results.get("crewai") is True
listener1 = _get_registered_listener()
assert listener1 is not None
assert isinstance(listener1, BraintrustCrewAIListener)

# 3. Idempotent — same listener, still reports True.
results2 = auto_instrument()
assert results2.get("crewai") is True
assert _get_registered_listener() is listener1

# 4. Listener is actually subscribed on the CrewAI event bus.
from crewai.events import CrewKickoffStartedEvent
from crewai.events.event_bus import crewai_event_bus


sync_handlers = crewai_event_bus._sync_handlers.get(CrewKickoffStartedEvent, frozenset())
assert sync_handlers, "Expected at least one sync handler registered for CrewKickoffStartedEvent"

print("SUCCESS")
73 changes: 73 additions & 0 deletions py/src/braintrust/integrations/crewai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Braintrust integration for CrewAI.

Public entry points:

- :func:`setup_crewai` — mirrors :func:`setup_agno`/``setup_dspy`` ergonomics.
Initializes a Braintrust logger (when one is not already set) and
registers the CrewAI event-bus listener.
- :func:`patch_crewai` — thin setup-only helper (no logger init), matches
the ``patch_*`` naming used elsewhere.
- :class:`CrewAIIntegration` — used by :func:`braintrust.auto_instrument`.
- :class:`BraintrustCrewAIListener` — exposed for advanced users who want
to register the listener manually on their own event-bus instance (e.g.
for tests).
"""

import logging

from braintrust.logger import NOOP_SPAN, current_span, init_logger

from .integration import CrewAIIntegration
from .tracing import BraintrustCrewAIListener


logger = logging.getLogger(__name__)


__all__ = [
"BraintrustCrewAIListener",
"CrewAIIntegration",
"patch_crewai",
"setup_crewai",
]


def setup_crewai(
api_key: str | None = None,
project_id: str | None = None,
project_name: str | None = None,
) -> bool:
"""Set up Braintrust tracing for CrewAI.

Initializes a Braintrust logger (unless one is already active) and
registers :class:`BraintrustCrewAIListener` on the singleton
``crewai_event_bus``. Safe to call multiple times.

Args:
api_key: Braintrust API key (optional, ``BRAINTRUST_API_KEY`` env var works too).
project_id: Braintrust project id (optional).
project_name: Braintrust project name (optional, ``BRAINTRUST_PROJECT`` env var works too).

Returns:
``True`` on successful (or already-registered) setup, ``False`` when
CrewAI is not importable at the required minimum version.
"""
span = current_span()
if span == NOOP_SPAN:
init_logger(project=project_name, api_key=api_key, project_id=project_id)

return CrewAIIntegration.setup()


def patch_crewai() -> bool:
"""Register the Braintrust CrewAI listener without initializing a logger.

Equivalent to ``CrewAIIntegration.setup()``. Use this when the calling
code already sets up Braintrust (e.g. via :func:`braintrust.init_logger`)
and only needs CrewAI tracing wired up.

Returns:
``True`` if CrewAI was patched (or already patched), ``False`` when
CrewAI is not installed at the required minimum version.
"""
return CrewAIIntegration.setup()
29 changes: 29 additions & 0 deletions py/src/braintrust/integrations/crewai/integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""CrewAI integration — orchestration class.

Registers a single event-bus listener on :data:`crewai.events.crewai_event_bus`
that maps CrewAI events (crew kickoff, tasks, agent execution, LLM calls,
tool usage) into Braintrust spans.

Requires CrewAI 1.13.0 or newer, which is the first release that exposes
the full causal-id surface (``event_id``, ``parent_event_id``,
``started_event_id``) plus the ``usage`` field on
``LLMCallCompletedEvent`` the integration consumes.
"""

from braintrust.integrations.base import BaseIntegration

from .patchers import EventBusPatcher


class CrewAIIntegration(BaseIntegration):
"""Braintrust instrumentation for CrewAI."""

name = "crewai"
import_names = ("crewai",)
# 1.13.0 is the first release with the event-bus surface this
# integration depends on (``started_event_id`` on BaseEvent + ``usage``
# on ``LLMCallCompletedEvent``). Older 1.x releases ship the bus but
# not these fields, so we gate instead of trying to version-branch the
# listener.
min_version = "1.13.0"
patchers = (EventBusPatcher,)
100 changes: 100 additions & 0 deletions py/src/braintrust/integrations/crewai/patchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""CrewAI patchers — one ``CallbackPatcher`` that registers the listener."""

from typing import Any, ClassVar

from braintrust.integrations.base import CallbackPatcher


# Module-level cache for the single registered listener instance. Keeping
# it at module scope (rather than on the integration class) means
# ``setup_crewai`` / ``patch_crewai`` / ``auto_instrument`` all see the
# same ``BraintrustCrewAIListener`` regardless of entry point.
_LISTENER: Any | None = None


def _unregister_event_handler(event_bus: Any, event_type: Any, handler: Any) -> None:
"""Best-effort wrapper around ``CrewAIEventsBus.off``.

Pylint cannot currently infer the dynamically-generated event-bus API in
CrewAI, so we use ``getattr`` here instead of calling ``off`` directly.
"""
unregister = getattr(event_bus, "off", None)
if callable(unregister):
unregister(event_type, handler)


def _register_braintrust_listener() -> bool:
"""Idempotently create and register the Braintrust listener.

The listener subclasses :class:`crewai.events.BaseEventListener`, whose
``__init__`` registers handlers on the process-singleton
``crewai_event_bus``. We cache the instance at module scope so repeat
calls (e.g. ``setup_crewai()`` followed by ``auto_instrument()``) do
not register a second listener.
"""
global _LISTENER # noqa: PLW0603
if _LISTENER is not None:
return True

# Lazy import: CrewAI may not be installed in the environment.
from .tracing import BraintrustCrewAIListener

_LISTENER = BraintrustCrewAIListener()
return True


def _listener_registered() -> bool:
"""Return whether the Braintrust listener is currently registered."""
return _LISTENER is not None


def _get_registered_listener() -> Any | None:
"""Return the registered listener, or ``None`` when setup has not run."""
return _LISTENER


def _reset_for_testing() -> None:
"""Unregister the Braintrust listener and forget cached runtime state.

Intended for pytest fixtures that need to restart from a clean slate.
Safe to call when CrewAI is not importable or nothing has been
registered. Not part of the public API.
"""
global _LISTENER # noqa: PLW0603
if _LISTENER is None:
return

try:
from crewai.events.event_bus import crewai_event_bus
except ImportError:
_LISTENER = None
return

for event_type, handlers in list(crewai_event_bus._sync_handlers.items()):
for handler in list(handlers):
handler_mod = getattr(handler, "__module__", "")
if "braintrust" in handler_mod and "crewai" in handler_mod:
_unregister_event_handler(crewai_event_bus, event_type, handler)

_LISTENER = None

# Clear the runtime subclass cache so the next setup rebuilds it; this
# matters for tests that monkey-patch the listener base class.
from .tracing import BraintrustCrewAIListener

BraintrustCrewAIListener._cls = None


class EventBusPatcher(CallbackPatcher):
"""Register :class:`BraintrustCrewAIListener` on ``crewai_event_bus``.

The target module check gates this patcher on the event-bus module being
importable, not the top-level ``crewai`` package. That means users who
install only a CrewAI fork missing the event-bus surface get a clean
skip rather than an import error during setup.
"""

name: ClassVar[str] = "crewai.event_bus"
target_module: ClassVar[str] = "crewai.events.event_bus"
callback: ClassVar[Any] = staticmethod(_register_braintrust_listener)
state_getter: ClassVar[Any] = staticmethod(_listener_registered)
Loading