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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ BRAINTRUST_API_KEY=<YOUR_API_KEY> braintrust eval tutorial_eval.py
| [DSPy](py/src/braintrust/integrations/dspy/) | Yes | `dspy>=2.6.0` |
| [OpenAI Agents](py/src/braintrust/integrations/openai_agents/) | Yes | `openai-agents>=0.0.19` |
| [Claude Agent SDK](py/src/braintrust/integrations/claude_agent_sdk/) | Yes | `claude_agent_sdk>=0.1.10` |
| [Cursor SDK](py/src/braintrust/integrations/cursor_sdk/) | Yes | `cursor-sdk>=1.0.25` |
| [AutoGen](py/src/braintrust/integrations/autogen/) | Yes | `autogen-agentchat>=0.7.0` |
| [CrewAI](py/src/braintrust/integrations/crewai/) | Yes | `crewai>=1.13.0` |
| [Strands](py/src/braintrust/integrations/strands/) | Yes | `strands-agents>=1.20.0` |
Expand Down
15 changes: 15 additions & 0 deletions examples/cursor_sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Cursor SDK + Braintrust

Calls `braintrust.auto_instrument()` to wrap Cursor's `Agent`/`AsyncAgent` runs, then sends a single prompt. The trace shows a task span for the run, an LLM span per model turn, and a tool span for each tool the agent invokes.

Cursor executes the model calls inside its own bridge subprocess, so the LLM span is reconstructed from the run's streamed events rather than from a provider client.

## Run

```bash
export BRAINTRUST_API_KEY=...
export CURSOR_API_KEY=...

uv sync
uv run python example.py
```
20 changes: 20 additions & 0 deletions examples/cursor_sdk/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python
"""Cursor SDK agent traced with Braintrust auto-instrumentation."""

import os

import braintrust


braintrust.auto_instrument()
braintrust.init_logger(project="example-cursor-sdk")

from cursor_sdk import Agent, LocalAgentOptions # pylint: disable=import-error,wrong-import-position


with Agent.create(
model="composer-2.5",
local=LocalAgentOptions(cwd=os.getcwd()),
) as agent:
result = agent.send("Summarize what this repository does").wait()
print(result.result)
12 changes: 12 additions & 0 deletions examples/cursor_sdk/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "braintrust-cursor-sdk-example"
version = "0.1.0"
description = "Cursor SDK traced with Braintrust"
requires-python = ">=3.10"
dependencies = [
"braintrust",
"cursor-sdk",
]

[tool.uv.sources]
braintrust = { path = "../../py", editable = true }
16 changes: 16 additions & 0 deletions py/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,22 @@ def test_claude_agent_sdk(session, version):
_run_tests(session, f"{INTEGRATION_DIR}/claude_agent_sdk/test_claude_agent_sdk.py", version=version)


CURSOR_SDK_VERSIONS = _get_matrix_versions("cursor-sdk")


@nox.session()
@nox.parametrize("version", CURSOR_SDK_VERSIONS, ids=CURSOR_SDK_VERSIONS)
def test_cursor_sdk(session, version):
_install_test_deps(session)
_install_matrix_dep(session, "cursor-sdk", version)
# Characterization coverage enables likely downstream provider
# instrumentation and verifies that Cursor's subprocess bridge does not
# emit provider-owned Python spans for its internal model requests.
_install_matrix_dep(session, "openai", LATEST)
_install_matrix_dep(session, "anthropic", LATEST)
_run_tests(session, f"{INTEGRATION_DIR}/cursor_sdk/test_cursor_sdk.py", version=version)


# Pin 2.4.0 to cover the 2.4 -> 2.5 breaking change to internals we leverage for instrumentation.
AGNO_VERSIONS = _get_matrix_versions("agno")

Expand Down
8 changes: 8 additions & 0 deletions py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ latest = "pipecat-ai==1.4.0"
latest = "claude-agent-sdk==0.2.129"
"0.1.10" = "claude-agent-sdk==0.1.10"

[tool.braintrust.matrix.cursor-sdk]
# 1.0.25 fixes bridge callback tokens that could be parsed as CLI flags while
# retaining per-turn usage, sync/async consumption, multimodal, and custom tools.
latest = "cursor-sdk==1.0.26"
"1.0.25" = "cursor-sdk==1.0.25"

[tool.braintrust.matrix.agno]
latest = "agno==2.8.6"
"2.4.0" = "agno==2.4.0"
Expand Down Expand Up @@ -511,6 +517,7 @@ anthropic = ["anthropic"]
bedrock_runtime = ["boto3", "botocore"]
cohere = ["cohere"]
claude_agent_sdk = ["claude-agent-sdk"]
cursor_sdk = ["cursor-sdk"]
crewai = ["crewai"]
dspy = ["dspy"]
google_genai = ["google-genai"]
Expand Down Expand Up @@ -540,6 +547,7 @@ braintrust-core = "braintrust_core"
boto3 = "boto3"
botocore = "botocore"
crewai = "crewai"
cursor-sdk = "cursor_sdk"
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 @@ -17,6 +17,7 @@
ClaudeAgentSDKIntegration,
CohereIntegration,
CrewAIIntegration,
CursorSDKIntegration,
DSPyIntegration,
GoogleGenAIIntegration,
HuggingFaceHubIntegration,
Expand Down Expand Up @@ -67,6 +68,7 @@ def auto_instrument(
agno: bool = True,
agentscope: bool = True,
claude_agent_sdk: bool = True,
cursor_sdk: bool = True,
dspy: bool = True,
adk: bool = True,
langchain: bool = True,
Expand Down Expand Up @@ -103,6 +105,7 @@ def auto_instrument(
agno: Enable Agno instrumentation (default: True)
agentscope: Enable AgentScope instrumentation (default: True)
claude_agent_sdk: Enable Claude Agent SDK instrumentation (default: True)
cursor_sdk: Enable Cursor SDK instrumentation (default: True)
dspy: Enable DSPy instrumentation (default: True)
adk: Enable Google ADK instrumentation (default: True)
langchain: Enable LangChain instrumentation (default: True)
Expand Down Expand Up @@ -187,6 +190,8 @@ def auto_instrument(
results["agentscope"] = _instrument_integration(AgentScopeIntegration)
if claude_agent_sdk:
results["claude_agent_sdk"] = _instrument_integration(ClaudeAgentSDKIntegration)
if cursor_sdk:
results["cursor_sdk"] = _instrument_integration(CursorSDKIntegration)
if dspy:
results["dspy"] = _instrument_integration(DSPyIntegration)
if adk:
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 @@ -7,6 +7,7 @@
from .claude_agent_sdk import ClaudeAgentSDKIntegration
from .cohere import CohereIntegration
from .crewai import CrewAIIntegration
from .cursor_sdk import CursorSDKIntegration
from .dspy import DSPyIntegration
from .google_genai import GoogleGenAIIntegration
from .huggingface_hub import HuggingFaceHubIntegration
Expand Down Expand Up @@ -35,6 +36,7 @@
"ClaudeAgentSDKIntegration",
"CohereIntegration",
"CrewAIIntegration",
"CursorSDKIntegration",
"DSPyIntegration",
"GoogleGenAIIntegration",
"HuggingFaceHubIntegration",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Subprocess coverage for Cursor SDK auto-instrumentation and import order."""

# pylint: disable=import-error

import os
import tempfile
from pathlib import Path

import cursor_sdk
from braintrust.auto import auto_instrument
from braintrust.integrations.cursor_sdk._test_vcr import cursor_vcr_config
from braintrust.integrations.test_utils import autoinstrument_test_context
from braintrust.span_types import SpanTypeAttribute
from braintrust.test_helpers import find_spans_by_type


results = auto_instrument()
assert results.get("cursor_sdk") is True
assert auto_instrument().get("cursor_sdk") is True


with tempfile.TemporaryDirectory() as workspace:
Path(workspace, "README.md").write_text("Cursor auto-instrumentation workspace.\n", encoding="utf-8")
with autoinstrument_test_context(
"test_auto_cursor_sdk",
integration="cursor_sdk",
vcr_config=cursor_vcr_config(),
) as memory_logger:
with cursor_sdk.CursorClient.launch_bridge(workspace=workspace) as client:
with client.agents.create(
model="composer-2.5",
api_key=os.environ.get("CURSOR_API_KEY", "crsr_test_key_for_cassette_playback"),
local=cursor_sdk.LocalAgentOptions(cwd=workspace),
) as agent:
result = agent.send("Reply with exactly: cursor tracing complete").wait()

assert result.status == "finished"
spans = memory_logger.pop()
assert find_spans_by_type(spans, SpanTypeAttribute.TASK)
assert find_spans_by_type(spans, SpanTypeAttribute.LLM)
assert all(span["context"]["span_origin"]["instrumentation"]["name"] == "cursor-sdk-auto" for span in spans)

print("SUCCESS")
40 changes: 40 additions & 0 deletions py/src/braintrust/integrations/cursor_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Braintrust tracing integration for the Cursor Python SDK."""

import inspect
from typing import Any

from braintrust.logger import NOOP_SPAN, current_span, init_logger

from .integration import CursorSDKIntegration
from .patchers import AgentClosePatcher, AgentSendPatcher, AsyncAgentClosePatcher, AsyncAgentSendPatcher


__all__ = ["CursorSDKIntegration", "setup_cursor_sdk", "wrap_cursor_sdk_agent"]


def setup_cursor_sdk(
api_key: str | None = None,
project_id: str | None = None,
project: str | None = None,
) -> bool:
"""Patch Cursor SDK agent runs for Braintrust tracing."""
if current_span() == NOOP_SPAN:
init_logger(project=project, api_key=api_key, project_id=project_id)
return CursorSDKIntegration.setup()


def wrap_cursor_sdk_agent(agent_class: Any) -> Any:
"""Instrument a Cursor ``Agent`` or ``AsyncAgent`` class in place.

Cursor traces model turns off ``Run``, not ``Agent``, so this also runs
the full integration setup to patch the run lifecycle.
"""
CursorSDKIntegration.setup()
# Both flavors spell these methods `send`/`close`, so the composite
# patcher cannot tell them apart when wrapping a class directly.
is_async = inspect.iscoroutinefunction(getattr(agent_class, "send", None))
send_patcher = AsyncAgentSendPatcher if is_async else AgentSendPatcher
close_patcher = AsyncAgentClosePatcher if is_async else AgentClosePatcher
send_patcher.wrap_target(agent_class)
close_patcher.wrap_target(agent_class)
return agent_class
71 changes: 71 additions & 0 deletions py/src/braintrust/integrations/cursor_sdk/_test_vcr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Cursor bridge request scrubbing for pytest-vcr cassettes."""

import json
import re
import struct
from collections.abc import Mapping
from urllib.parse import urlsplit, urlunsplit

from braintrust.conftest import get_vcr_config


# `cursor_sdk._tool_callback.TOOL_CALLBACK_SERVICE`, inlined so this module stays
# importable without the SDK installed.
_TOOL_CALLBACK_PATH_PREFIX = "/sdk.v1.SdkCustomToolCallbackService/"
_SENSITIVE_KEY_RE = re.compile(r"(?:api.?key|authorization|auth.?token|secret|password|env.?vars|headers)$", re.I)
_PATH_KEY_RE = re.compile(r"(?:cwd|workspace|state.?root)$", re.I)
_IDEMPOTENCY_KEY_RE = re.compile(r"idempotency.?key$", re.I)


def _scrub_value(value, *, key=""):
if key == "data" and isinstance(value, str) and len(value) >= 20:
return "<BASE64_DATA>"
if _SENSITIVE_KEY_RE.search(key):
return "<REDACTED>"
if _PATH_KEY_RE.search(key):
return "<WORKSPACE>"
if _IDEMPOTENCY_KEY_RE.search(key):
return "<IDEMPOTENCY_KEY>"
if isinstance(value, Mapping):
return {str(item_key): _scrub_value(item, key=str(item_key)) for item_key, item in value.items()}
if isinstance(value, list):
return [_scrub_value(item, key=key) for item in value]
return value


def scrub_cursor_bridge_request(request):
"""Redact secrets/paths and normalize the bridge's dynamic loopback URL."""
parts = urlsplit(request.uri)
# Let shutdown reach the real test bridge. Replaying this request would
# leave the bridge's child Node process running after the test exits.
if parts.path.endswith("/Shutdown"):
return None
# Custom tools arrive as a request *into* the SDK's loopback callback
# server, which VCR cannot record. Let the test drive that server for real
# instead of matching these against the cassette.
if parts.path.startswith(_TOOL_CALLBACK_PATH_PREFIX):
return None
request.uri = urlunsplit((parts.scheme, "cursor-sdk-bridge", parts.path, parts.query, parts.fragment))
body = request.body
if not isinstance(body, bytes) or not body:
return request
is_connect_stream = request.headers.get("Content-Type") == "application/connect+json"
prefix = body[:5] if is_connect_stream else b""
payload = body[5:] if is_connect_stream else body
try:
scrubbed = json.dumps(_scrub_value(json.loads(payload.decode("utf-8")))).encode("utf-8")
except (UnicodeDecodeError, json.JSONDecodeError):
return request
if is_connect_stream:
prefix = bytes([prefix[0]]) + struct.pack(">I", len(scrubbed))
request.body = prefix + scrubbed
return request


def cursor_vcr_config():
"""Return standard VCR configuration specialized for Cursor bridge RPCs."""
config = get_vcr_config()
config["before_record_request"] = scrub_cursor_bridge_request
config["filter_headers"] = [*config["filter_headers"], "host"]
config["match_on"] = ["method", "path", "body"]
return config
Loading