Skip to content

Commit 6ab7fba

Browse files
authored
feat(cursor_sdk): instrument Cursor agent runs (#665)
resolves https://github.com/braintrustdata/braintrust-sdk-python/issues Automatic instrumentation is preferred and must run before importing `cursor_sdk`: ```python import braintrust braintrust.auto_instrument() braintrust.init_logger(project="my-project") ``` For manual setup: ```python from braintrust.integrations.cursor_sdk import setup_cursor_sdk setup_cursor_sdk(project="my-project") ``` Custom agent classes can also be instrumented directly: ```python from braintrust.integrations.cursor_sdk import wrap_cursor_sdk_agent wrap_cursor_sdk_agent(MyAgent) ``` Requires Python >=3.10 and cursor-sdk >=1.0.25. The test matrix covers the minimum 1.0.25 release and the latest pinned release, 1.0.26.
1 parent bd6616d commit 6ab7fba

58 files changed

Lines changed: 11757 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ BRAINTRUST_API_KEY=<YOUR_API_KEY> braintrust eval tutorial_eval.py
7272
| [DSPy](py/src/braintrust/integrations/dspy/) | Yes | `dspy>=2.6.0` |
7373
| [OpenAI Agents](py/src/braintrust/integrations/openai_agents/) | Yes | `openai-agents>=0.0.19` |
7474
| [Claude Agent SDK](py/src/braintrust/integrations/claude_agent_sdk/) | Yes | `claude_agent_sdk>=0.1.10` |
75+
| [Cursor SDK](py/src/braintrust/integrations/cursor_sdk/) | Yes | `cursor-sdk>=1.0.25` |
7576
| [AutoGen](py/src/braintrust/integrations/autogen/) | Yes | `autogen-agentchat>=0.7.0` |
7677
| [CrewAI](py/src/braintrust/integrations/crewai/) | Yes | `crewai>=1.13.0` |
7778
| [Strands](py/src/braintrust/integrations/strands/) | Yes | `strands-agents>=1.20.0` |

examples/cursor_sdk/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Cursor SDK + Braintrust
2+
3+
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.
4+
5+
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.
6+
7+
## Run
8+
9+
```bash
10+
export BRAINTRUST_API_KEY=...
11+
export CURSOR_API_KEY=...
12+
13+
uv sync
14+
uv run python example.py
15+
```

examples/cursor_sdk/example.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env python
2+
"""Cursor SDK agent traced with Braintrust auto-instrumentation."""
3+
4+
import os
5+
6+
import braintrust
7+
8+
9+
braintrust.auto_instrument()
10+
braintrust.init_logger(project="example-cursor-sdk")
11+
12+
from cursor_sdk import Agent, LocalAgentOptions # pylint: disable=import-error,wrong-import-position
13+
14+
15+
with Agent.create(
16+
model="composer-2.5",
17+
local=LocalAgentOptions(cwd=os.getcwd()),
18+
) as agent:
19+
result = agent.send("Summarize what this repository does").wait()
20+
print(result.result)

examples/cursor_sdk/pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[project]
2+
name = "braintrust-cursor-sdk-example"
3+
version = "0.1.0"
4+
description = "Cursor SDK traced with Braintrust"
5+
requires-python = ">=3.10"
6+
dependencies = [
7+
"braintrust",
8+
"cursor-sdk",
9+
]
10+
11+
[tool.uv.sources]
12+
braintrust = { path = "../../py", editable = true }

py/noxfile.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,22 @@ def test_claude_agent_sdk(session, version):
366366
_run_tests(session, f"{INTEGRATION_DIR}/claude_agent_sdk/test_claude_agent_sdk.py", version=version)
367367

368368

369+
CURSOR_SDK_VERSIONS = _get_matrix_versions("cursor-sdk")
370+
371+
372+
@nox.session()
373+
@nox.parametrize("version", CURSOR_SDK_VERSIONS, ids=CURSOR_SDK_VERSIONS)
374+
def test_cursor_sdk(session, version):
375+
_install_test_deps(session)
376+
_install_matrix_dep(session, "cursor-sdk", version)
377+
# Characterization coverage enables likely downstream provider
378+
# instrumentation and verifies that Cursor's subprocess bridge does not
379+
# emit provider-owned Python spans for its internal model requests.
380+
_install_matrix_dep(session, "openai", LATEST)
381+
_install_matrix_dep(session, "anthropic", LATEST)
382+
_run_tests(session, f"{INTEGRATION_DIR}/cursor_sdk/test_cursor_sdk.py", version=version)
383+
384+
369385
# Pin 2.4.0 to cover the 2.4 -> 2.5 breaking change to internals we leverage for instrumentation.
370386
AGNO_VERSIONS = _get_matrix_versions("agno")
371387

py/pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,12 @@ latest = "pipecat-ai==1.4.0"
382382
latest = "claude-agent-sdk==0.2.129"
383383
"0.1.10" = "claude-agent-sdk==0.1.10"
384384

385+
[tool.braintrust.matrix.cursor-sdk]
386+
# 1.0.25 fixes bridge callback tokens that could be parsed as CLI flags while
387+
# retaining per-turn usage, sync/async consumption, multimodal, and custom tools.
388+
latest = "cursor-sdk==1.0.26"
389+
"1.0.25" = "cursor-sdk==1.0.25"
390+
385391
[tool.braintrust.matrix.agno]
386392
latest = "agno==2.8.6"
387393
"2.4.0" = "agno==2.4.0"
@@ -517,6 +523,7 @@ anthropic = ["anthropic"]
517523
bedrock_runtime = ["boto3", "botocore"]
518524
cohere = ["cohere"]
519525
claude_agent_sdk = ["claude-agent-sdk"]
526+
cursor_sdk = ["cursor-sdk"]
520527
crewai = ["crewai"]
521528
dspy = ["dspy"]
522529
google_genai = ["google-genai"]
@@ -547,6 +554,7 @@ braintrust-core = "braintrust_core"
547554
boto3 = "boto3"
548555
botocore = "botocore"
549556
crewai = "crewai"
557+
cursor-sdk = "cursor_sdk"
550558
dspy = "dspy"
551559
google-adk = "google.adk"
552560
google-genai = "google.genai"

py/src/braintrust/auto.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
ClaudeAgentSDKIntegration,
1919
CohereIntegration,
2020
CrewAIIntegration,
21+
CursorSDKIntegration,
2122
DSPyIntegration,
2223
GoogleGenAIIntegration,
2324
HuggingFaceHubIntegration,
@@ -69,6 +70,7 @@ def auto_instrument(
6970
agno: bool = True,
7071
agentscope: bool = True,
7172
claude_agent_sdk: bool = True,
73+
cursor_sdk: bool = True,
7274
dspy: bool = True,
7375
adk: bool = True,
7476
langchain: bool = True,
@@ -106,6 +108,7 @@ def auto_instrument(
106108
agno: Enable Agno instrumentation (default: True)
107109
agentscope: Enable AgentScope instrumentation (default: True)
108110
claude_agent_sdk: Enable Claude Agent SDK instrumentation (default: True)
111+
cursor_sdk: Enable Cursor SDK instrumentation (default: True)
109112
dspy: Enable DSPy instrumentation (default: True)
110113
adk: Enable Google ADK instrumentation (default: True)
111114
langchain: Enable LangChain instrumentation (default: True)
@@ -192,6 +195,8 @@ def auto_instrument(
192195
results["agentscope"] = _instrument_integration(AgentScopeIntegration)
193196
if claude_agent_sdk:
194197
results["claude_agent_sdk"] = _instrument_integration(ClaudeAgentSDKIntegration)
198+
if cursor_sdk:
199+
results["cursor_sdk"] = _instrument_integration(CursorSDKIntegration)
195200
if dspy:
196201
results["dspy"] = _instrument_integration(DSPyIntegration)
197202
if adk:

py/src/braintrust/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .claude_agent_sdk import ClaudeAgentSDKIntegration
99
from .cohere import CohereIntegration
1010
from .crewai import CrewAIIntegration
11+
from .cursor_sdk import CursorSDKIntegration
1112
from .dspy import DSPyIntegration
1213
from .google_genai import GoogleGenAIIntegration
1314
from .huggingface_hub import HuggingFaceHubIntegration
@@ -37,6 +38,7 @@
3738
"ClaudeAgentSDKIntegration",
3839
"CohereIntegration",
3940
"CrewAIIntegration",
41+
"CursorSDKIntegration",
4042
"DSPyIntegration",
4143
"GoogleGenAIIntegration",
4244
"HuggingFaceHubIntegration",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Subprocess coverage for Cursor SDK auto-instrumentation and import order."""
2+
3+
# pylint: disable=import-error
4+
5+
import os
6+
import tempfile
7+
from pathlib import Path
8+
9+
import cursor_sdk
10+
from braintrust.auto import auto_instrument
11+
from braintrust.integrations.cursor_sdk._test_vcr import cursor_vcr_config
12+
from braintrust.integrations.test_utils import autoinstrument_test_context
13+
from braintrust.span_types import SpanTypeAttribute
14+
from braintrust.test_helpers import find_spans_by_type
15+
16+
17+
results = auto_instrument()
18+
assert results.get("cursor_sdk") is True
19+
assert auto_instrument().get("cursor_sdk") is True
20+
21+
22+
with tempfile.TemporaryDirectory() as workspace:
23+
Path(workspace, "README.md").write_text("Cursor auto-instrumentation workspace.\n", encoding="utf-8")
24+
with autoinstrument_test_context(
25+
"test_auto_cursor_sdk",
26+
integration="cursor_sdk",
27+
vcr_config=cursor_vcr_config(),
28+
) as memory_logger:
29+
with cursor_sdk.CursorClient.launch_bridge(workspace=workspace) as client:
30+
with client.agents.create(
31+
model="composer-2.5",
32+
api_key=os.environ.get("CURSOR_API_KEY", "crsr_test_key_for_cassette_playback"),
33+
local=cursor_sdk.LocalAgentOptions(cwd=workspace),
34+
) as agent:
35+
result = agent.send("Reply with exactly: cursor tracing complete").wait()
36+
37+
assert result.status == "finished"
38+
spans = memory_logger.pop()
39+
assert find_spans_by_type(spans, SpanTypeAttribute.TASK)
40+
assert find_spans_by_type(spans, SpanTypeAttribute.LLM)
41+
assert all(span["context"]["span_origin"]["instrumentation"]["name"] == "cursor-sdk-auto" for span in spans)
42+
43+
print("SUCCESS")
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Braintrust tracing integration for the Cursor Python SDK."""
2+
3+
import inspect
4+
from typing import Any
5+
6+
from braintrust.logger import NOOP_SPAN, current_span, init_logger
7+
8+
from .integration import CursorSDKIntegration
9+
from .patchers import AgentClosePatcher, AgentSendPatcher, AsyncAgentClosePatcher, AsyncAgentSendPatcher
10+
11+
12+
__all__ = ["CursorSDKIntegration", "setup_cursor_sdk", "wrap_cursor_sdk_agent"]
13+
14+
15+
def setup_cursor_sdk(
16+
api_key: str | None = None,
17+
project_id: str | None = None,
18+
project: str | None = None,
19+
) -> bool:
20+
"""Patch Cursor SDK agent runs for Braintrust tracing."""
21+
if current_span() == NOOP_SPAN:
22+
init_logger(project=project, api_key=api_key, project_id=project_id)
23+
return CursorSDKIntegration.setup()
24+
25+
26+
def wrap_cursor_sdk_agent(agent_class: Any) -> Any:
27+
"""Instrument a Cursor ``Agent`` or ``AsyncAgent`` class in place.
28+
29+
Cursor traces model turns off ``Run``, not ``Agent``, so this also runs
30+
the full integration setup to patch the run lifecycle.
31+
"""
32+
CursorSDKIntegration.setup()
33+
# Both flavors spell these methods `send`/`close`, so the composite
34+
# patcher cannot tell them apart when wrapping a class directly.
35+
is_async = inspect.iscoroutinefunction(getattr(agent_class, "send", None))
36+
send_patcher = AsyncAgentSendPatcher if is_async else AgentSendPatcher
37+
close_patcher = AsyncAgentClosePatcher if is_async else AgentClosePatcher
38+
send_patcher.wrap_target(agent_class)
39+
close_patcher.wrap_target(agent_class)
40+
return agent_class

0 commit comments

Comments
 (0)