Skip to content

Commit 3655ff5

Browse files
mr-leedd-octo-sts[bot]sabrenner
committed
feat(claude-agent-sdk): add support for @anthropic-ai/claude-agent-sdk (#9202)
* feat(claude-agent-sdk): add instrumentation for @anthropic-ai/claude-agent-sdk Adds automatic instrumentation for the Claude Agent SDK, providing full visibility into agentic sessions via APM tracing and LLM Obs. Span hierarchy aligned with trajectory-spec APPENDIX-DD-LLMOBS-MAPPING: agent (session) └── agent (turn) ├── tool ({tool_name}) └── agent (subagent-{agent_type}) Key design decisions: - Uses SDK's first-class hooks API (SessionStart, SessionEnd, Stop, PreToolUse, PostToolUse, SubagentStart, SubagentStop, etc.) - Turn spans are `agent` kind (not workflow) per spec - Dynamic span names: session, turn, {toolName}, subagent-{type} - Model name split from provider prefix (anthropic/claude-sonnet-4-6) - Turn output from Stop hook's last_assistant_message - Captures cwd, transcript_path, agent_type, is_interrupt, start_trigger - Pure ESM package handled via import-in-the-middle with esmFirst: true - User hooks preserved via mergeHooks (user matchers before tracer matchers) Known gap: No LLM-level spans - the Agent SDK bundles its own Anthropic client internally, so the existing @anthropic-ai/sdk shimmer doesn't fire. This matches dd-trace-py's approach (agent + tool spans only). Tests: 41 APM tracing + 13 LLM Obs tests. * fix(claude-agent-sdk): resolve trace delivery and test failures Root cause: OTEL env vars caused dd-trace to use OTLP exporter instead of the agent exporter. The mock test agent only handles /v0.4/traces. - APM test: clear OTEL_* env vars before tracer init - LLM Obs test: rewrite to test span event structure directly - Instrumentation: finishSession closes pending child spans first - VCR proxy: use canonical body for stable cassette hashing * fix: resolve lint errors and skip broken SDK 0.2.0 in CI Add await before origIterator.return/throw calls to satisfy require-await lint rule. Bump minimum SDK version from 0.2.0 to 0.2.1 in rewriter versionRange and test withVersions filter — 0.2.0 uses require() in ESM scope which breaks on Node 22. * fix(claude-agent-sdk): add withVersions to LLM Obs test for module resolution The LLM Obs test was missing the withVersions wrapper that sets NODE_PATH to the versioned SDK's node_modules directory. Without it, the bare require('@anthropic-ai/claude-agent-sdk') fails with "Cannot find module". This matches the pattern used by the APM test. * chore: clean up stale comments in claude-agent-sdk instrumentation * feat(claude-agent-sdk): parameterize turn span names and add VCR to LLM Obs test * refactor(claude-agent-sdk): remove session span, propagate metadata to turn spans Turn spans are now root spans carrying all session metadata (model, start_trigger, project_dir, permission_mode, agent_type, transcript_path). Session-level span removed — the turn is the primary unit of work. * refactor(claude-agent-sdk): adopt orchestrion-only instrumentation path * fix(claude-agent-sdk): suppress stdin write-after-end error in tests * test(claude-agent-sdk): run SDK CLI through Node in tests * chore: update supported-integrations * test(claude-agent-sdk): import SDK through ESM hooks * test(claude-agent-sdk): cover instrumentation edge hooks * ci(benchmarks): restore Node 26 sirun run * Revert "ci(benchmarks): restore Node 26 sirun run" This reverts commit d4be02b. * fixups * fix apm tests * fix llmobs tests * fixup * fix merge conflict * fix llmobs job definition * run `npm run generate:config:types` * Apply suggestion from @sabrenner * fix test partially * change underlying implementation * remove benchmark - to add back later * bump supported range * update instrumentation * bump tested version * llmobs tests * rest of plugin tests * esm integration tests * find claude binary installed with package for CI * better normalizers for CI linux runners * finalized test changes (maybe??) * finalized cassettes and regex normalizations * re-do cassettes * new cassettes * fmt * temp debugging * more debug logs * debug log * undo debug, add rewriter handler * address codex review comments * refactor(claude-agent-sdk): index lifecycle from hooks * test(claude-agent-sdk): expect subagent wrapper before children * test(claude-agent-sdk): cover hook-index lifecycle * ci: refresh claude agent sdk checks * benchmark(claude-agent-sdk): model delayed stream scans * benchmark(claude-agent-sdk): lengthen delayed stream fixture * benchmark(claude-agent-sdk): stabilize delayed fixture guard * ci: ignore sirun benchmarks in patch coverage * perf(claude-agent-sdk): add compact lifecycle fast path Avoid eagerly building the full stream lifecycle index when the tool result is adjacent to the tool use. The hook-first path now scans a small local window first and only falls back to the per-trace index when the local lookup misses. Local timing driver over benchmark/sirun/plugin-claude-agent-sdk, 15 samples, OPERATIONS=25000: compact stream-scan: 44.80 ms median (44.07 min, 47.09 max) compact hook-indexed: 52.62 ms median (51.10 min, 56.09 max), 1.17x stream scan delayed-noisy stream-scan: 369.73 ms median (327.76 min, 401.03 max) delayed-noisy hook-indexed: 160.64 ms median (156.64 min, 165.25 max), 56.6% faster than stream scan * update claude agent sdk tested version with new cassettes * update cassettes again for ci runner compat * reduce timeout * remove rewriter patch for testing * Apply suggestions from code review Co-authored-by: Sam Brenner <106700075+sabrenner@users.noreply.github.com> * docs(claude-agent-sdk): align plugin docs ordering * fix(claude-agent-sdk): address review feedback * fix(claude-agent-sdk): trim agent tool output metadata * fix(claude-agent-sdk): normalize agent tool output footer * fix(claude-agent-sdk): normalize structured tool results * fix(claude-agent-sdk): match agent output footer --------- Co-authored-by: dd-octo-sts[bot] <200755185+dd-octo-sts[bot]@users.noreply.github.com> Co-authored-by: Sam Brenner <106700075+sabrenner@users.noreply.github.com> Co-authored-by: Sam Brenner <sam.brenner@datadoghq.com>
1 parent 5927719 commit 3655ff5

46 files changed

Lines changed: 3586 additions & 2 deletions

File tree

Some content is hidden

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

.codecov.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ coverage:
2727
only_pulls: true
2828
flags:
2929
- master-coverage
30+
31+
ignore:
32+
- "benchmark/sirun/**"

.github/CODEOWNERS

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,15 @@
192192
/benchmark/sirun/llmobs-span-processor/ @DataDog/ml-observability
193193
/packages/datadog-instrumentations/src/ai.js @DataDog/ml-observability
194194
/packages/datadog-instrumentations/src/anthropic.js @DataDog/ml-observability
195+
/packages/datadog-instrumentations/src/claude-agent-sdk.js @DataDog/ml-observability
195196
/packages/datadog-instrumentations/src/google-cloud-vertexai.js @DataDog/ml-observability
196197
/packages/datadog-instrumentations/src/langchain.js @DataDog/ml-observability
197198
/packages/datadog-instrumentations/src/openai.js @DataDog/ml-observability
198199
/packages/datadog-plugin-ai/ @DataDog/ml-observability
199200
/packages/datadog-plugin-anthropic/ @DataDog/ml-observability
200-
/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/ @DataDog/ml-observability
201+
/packages/datadog-plugin-claude-agent-sdk/ @DataDog/ml-observability
202+
/benchmark/sirun/plugin-claude-agent-sdk/ @DataDog/ml-observability
203+
/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime @DataDog/ml-observability
201204
/packages/datadog-plugin-aws-sdk/test/bedrockruntime.spec.js @DataDog/ml-observability
202205
/packages/datadog-plugin-google-cloud-vertexai/ @DataDog/ml-observability
203206
/packages/datadog-plugin-langchain/ @DataDog/ml-observability

.github/workflows/llmobs.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,33 @@ jobs:
229229
with:
230230
id: ${{ github.job }}
231231

232+
claude-agent-sdk:
233+
runs-on: ubuntu-latest
234+
permissions:
235+
id-token: write
236+
env:
237+
PLUGINS: claude-agent-sdk
238+
steps:
239+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
240+
- uses: ./.github/actions/testagent/start
241+
# The SDK is ESM-only. Tests use dynamic import() to load it.
242+
- uses: ./.github/actions/node/active-lts
243+
- uses: ./.github/actions/install
244+
- run: yarn test:plugins:ci
245+
- run: yarn test:llmobs:plugins:ci
246+
shell: bash
247+
- uses: ./.github/actions/coverage
248+
with:
249+
flags: llmobs-${{ github.job }}
250+
- if: always()
251+
uses: ./.github/actions/testagent/logs
252+
with:
253+
suffix: llmobs-${{ github.job }}
254+
- uses: ./.github/actions/upload-junit-artifacts
255+
if: "!cancelled()"
256+
with:
257+
id: ${{ github.job }}
258+
232259
google-genai:
233260
runs-on: ubuntu-latest
234261
permissions:
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
This benchmark measures Claude Agent SDK lifecycle extraction cost.
2+
3+
The `stream-scan` variant models the fragile approach where each tool lifecycle
4+
is rediscovered by scanning the remaining event stream for `task_started` and
5+
`tool_result` chunks.
6+
7+
The `hook-indexed` variant models the hook-first approach: SDK hooks provide the
8+
semantic lifecycle record for each tool, while a single pass over the event
9+
stream builds cheap chunk indexes for LLM IO enrichment.
10+
11+
The hook-indexed variant uses the same compact fast path as the integration:
12+
it first checks the next few chunks for adjacent lifecycle data and only builds
13+
the full stream index when that local lookup misses. The stream index is still
14+
rebuilt inside each measured operation when needed, so delayed/noisy traces pay
15+
the per-trace indexing cost instead of benchmarking only Map lookups against
16+
repeated stream scans.
17+
18+
Two stream shapes are covered:
19+
20+
- `compact`: `task_started` and `tool_result` chunks are adjacent to each
21+
tool use. This is the best case for repeated stream scanning.
22+
- `delayed-noisy`: tool lifecycle chunks are delayed until later in the turn
23+
and separated by unrelated assistant chunks. This models larger agent traces
24+
where reconstructing lifecycle from the stream repeatedly has to cross
25+
unrelated LLM/subagent output.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
const guard = require('../startup-guard')
5+
6+
const { SCENARIO, VARIANT } = process.env
7+
const OPERATIONS = Number(process.env.OPERATIONS)
8+
const LOCAL_LIFECYCLE_LOOKAHEAD = 4
9+
10+
const SCENARIOS = {
11+
compact: {
12+
turns: 12,
13+
toolsPerTurn: 4,
14+
noiseChunks: 0,
15+
delayedResults: false,
16+
},
17+
'delayed-noisy': {
18+
turns: 8,
19+
toolsPerTurn: 6,
20+
noiseChunks: 12,
21+
delayedResults: true,
22+
},
23+
}
24+
25+
function pushNoise (chunks, count, label) {
26+
for (let idx = 0; idx < count; idx++) {
27+
chunks.push({
28+
type: 'assistant',
29+
session_id: 'session-1',
30+
parent_tool_use_id: null,
31+
message: {
32+
id: `${label}-noise-${idx}`,
33+
model: 'claude-sonnet-4-6',
34+
content: [{ type: 'text', text: `noise ${label}-${idx}` }],
35+
usage: { input_tokens: 1, output_tokens: 1 },
36+
},
37+
})
38+
}
39+
}
40+
41+
function pushToolLifecycleChunks (chunks, toolUse, noiseChunks) {
42+
chunks.push({ type: 'system', subtype: 'task_started', tool_use_id: toolUse.id })
43+
44+
if (toolUse.name === 'Agent') {
45+
chunks.push({
46+
type: 'assistant',
47+
session_id: 'session-1',
48+
parent_tool_use_id: toolUse.id,
49+
message: {
50+
id: `subagent-${toolUse.id}`,
51+
model: 'claude-sonnet-4-6',
52+
content: [{
53+
type: 'tool_use',
54+
id: `nested-${toolUse.id}`,
55+
name: 'mcp__local__fetch_weather',
56+
input: toolUse.input,
57+
}],
58+
usage: { input_tokens: 5, output_tokens: 3 },
59+
},
60+
})
61+
}
62+
63+
pushNoise(chunks, noiseChunks, `${toolUse.id}-after-start`)
64+
chunks.push({
65+
type: 'user',
66+
session_id: 'session-1',
67+
parent_tool_use_id: null,
68+
message: {
69+
role: 'user',
70+
content: [{
71+
type: 'tool_result',
72+
tool_use_id: toolUse.id,
73+
content: [{ type: 'text', text: `result ${toolUse.turn}-${toolUse.tool}` }],
74+
}],
75+
},
76+
})
77+
}
78+
79+
function buildFixture (scenario) {
80+
const chunks = []
81+
const hookTools = new Map()
82+
const toolUses = []
83+
84+
for (let turn = 0; turn < scenario.turns; turn++) {
85+
const messageId = `msg-${turn}`
86+
const pendingToolUses = []
87+
88+
chunks.push({
89+
type: 'assistant',
90+
session_id: 'session-1',
91+
parent_tool_use_id: null,
92+
message: {
93+
id: messageId,
94+
model: 'claude-sonnet-4-6',
95+
content: [{ type: 'text', text: `turn ${turn}` }],
96+
usage: { input_tokens: 10, output_tokens: 5 },
97+
},
98+
})
99+
100+
for (let tool = 0; tool < scenario.toolsPerTurn; tool++) {
101+
const id = `tool-${turn}-${tool}`
102+
const name = tool % 3 === 0 ? 'Agent' : 'mcp__local__fetch_weather'
103+
const input = name === 'Agent'
104+
? { description: `subagent ${turn}-${tool}`, prompt: `fetch ${turn}-${tool}` }
105+
: { location: tool % 2 ? 'CA' : 'NY', units: 'fahrenheit' }
106+
107+
chunks.push({
108+
type: 'assistant',
109+
session_id: 'session-1',
110+
parent_tool_use_id: null,
111+
message: {
112+
id: messageId,
113+
model: 'claude-sonnet-4-6',
114+
content: [{ type: 'tool_use', id, name, input }],
115+
usage: { input_tokens: 10, output_tokens: 5 },
116+
},
117+
})
118+
const scanStartIndex = chunks.length
119+
pushNoise(chunks, scenario.delayedResults ? scenario.noiseChunks : 0, `${id}-after-use`)
120+
121+
const hookTool = {
122+
id,
123+
name,
124+
input,
125+
output: { content: `result ${turn}-${tool}` },
126+
}
127+
hookTools.set(id, hookTool)
128+
const toolUse = { id, name, input, scanStartIndex, turn, tool }
129+
toolUses.push(toolUse)
130+
131+
if (scenario.delayedResults) {
132+
pendingToolUses.push(toolUse)
133+
} else {
134+
pushToolLifecycleChunks(chunks, toolUse, scenario.noiseChunks)
135+
}
136+
}
137+
138+
if (scenario.delayedResults) {
139+
pushNoise(chunks, scenario.noiseChunks, `turn-${turn}-before-results`)
140+
for (const toolUse of pendingToolUses) {
141+
pushToolLifecycleChunks(chunks, toolUse, scenario.noiseChunks)
142+
}
143+
}
144+
}
145+
146+
return { chunks, hookTools, toolUses }
147+
}
148+
149+
function buildStreamIndex (chunks) {
150+
const lifecycleByToolId = new Map()
151+
152+
for (let idx = 0; idx < chunks.length; idx++) {
153+
const chunk = chunks[idx]
154+
if (chunk.type === 'system' && chunk.subtype === 'task_started') {
155+
const lifecycle = lifecycleByToolId.get(chunk.tool_use_id) || {}
156+
lifecycle.taskStartedIndex = idx
157+
lifecycleByToolId.set(chunk.tool_use_id, lifecycle)
158+
} else if (chunk.type === 'user') {
159+
const content = chunk.message.content
160+
for (const block of content) {
161+
if (block.type === 'tool_result') {
162+
const lifecycle = lifecycleByToolId.get(block.tool_use_id) || {}
163+
lifecycle.toolResultIndex = idx
164+
lifecycleByToolId.set(block.tool_use_id, lifecycle)
165+
}
166+
}
167+
}
168+
}
169+
170+
return lifecycleByToolId
171+
}
172+
173+
function scanLocalLifecycle (chunks, startIndex, toolUseId, lifecycle) {
174+
lifecycle.taskStartedIndex = undefined
175+
lifecycle.toolResultIndex = undefined
176+
177+
const scanEnd = Math.min(chunks.length, startIndex + LOCAL_LIFECYCLE_LOOKAHEAD)
178+
179+
for (let idx = startIndex; idx < scanEnd; idx++) {
180+
const chunk = chunks[idx]
181+
if (chunk.type === 'system' && chunk.subtype === 'task_started' && chunk.tool_use_id === toolUseId) {
182+
lifecycle.taskStartedIndex = idx
183+
} else if (chunk.type === 'user') {
184+
const content = chunk.message.content
185+
for (const block of content) {
186+
if (block.type === 'tool_result' && block.tool_use_id === toolUseId) {
187+
lifecycle.toolResultIndex = idx
188+
return lifecycle
189+
}
190+
}
191+
}
192+
}
193+
194+
return lifecycle
195+
}
196+
197+
function createStreamLookup (chunks) {
198+
let streamIndex
199+
const localLifecycle = {}
200+
201+
return function getLifecycle (startIndex, toolUseId) {
202+
if (streamIndex) return streamIndex.get(toolUseId) || {}
203+
204+
scanLocalLifecycle(chunks, startIndex, toolUseId, localLifecycle)
205+
if (localLifecycle.toolResultIndex !== undefined) return localLifecycle
206+
207+
streamIndex = streamIndex || buildStreamIndex(chunks)
208+
const indexedLifecycle = streamIndex.get(toolUseId)
209+
return {
210+
taskStartedIndex: localLifecycle.taskStartedIndex ?? indexedLifecycle?.taskStartedIndex,
211+
toolResultIndex: indexedLifecycle?.toolResultIndex,
212+
}
213+
}
214+
}
215+
216+
function findTaskStartedScan (chunks, startIndex, toolUseId) {
217+
for (let idx = startIndex; idx < chunks.length; idx++) {
218+
const chunk = chunks[idx]
219+
if (chunk.type === 'system' && chunk.subtype === 'task_started' && chunk.tool_use_id === toolUseId) return idx
220+
}
221+
}
222+
223+
function findToolResultScan (chunks, startIndex, toolUseId) {
224+
for (let idx = startIndex; idx < chunks.length; idx++) {
225+
const chunk = chunks[idx]
226+
if (chunk.type !== 'user') continue
227+
const content = chunk.message.content
228+
for (const block of content) {
229+
if (block.type === 'tool_result' && block.tool_use_id === toolUseId) return idx
230+
}
231+
}
232+
}
233+
234+
function runStreamScan (chunks, toolUses) {
235+
let sink = 0
236+
for (const toolUse of toolUses) {
237+
const taskStartedIndex = findTaskStartedScan(chunks, toolUse.scanStartIndex, toolUse.id)
238+
const resultIndex = findToolResultScan(chunks, toolUse.scanStartIndex, toolUse.id)
239+
sink += toolUse.name.length + taskStartedIndex + resultIndex
240+
}
241+
return sink
242+
}
243+
244+
function runHookIndexed (hookTools, getLifecycle, toolUses) {
245+
let sink = 0
246+
for (const toolUse of toolUses) {
247+
const tool = hookTools.get(toolUse.id)
248+
const lifecycle = getLifecycle(toolUse.scanStartIndex, toolUse.id)
249+
const taskStartedIndex = lifecycle.taskStartedIndex
250+
const resultIndex = lifecycle.toolResultIndex
251+
sink += tool.name.length + taskStartedIndex + resultIndex
252+
}
253+
return sink
254+
}
255+
256+
const scenario = SCENARIOS[SCENARIO]
257+
if (scenario === undefined) throw new Error(`Unknown SCENARIO: ${SCENARIO}`)
258+
259+
const { chunks, hookTools, toolUses } = buildFixture(scenario)
260+
const expected = runStreamScan(chunks, toolUses)
261+
assert.equal(runHookIndexed(hookTools, createStreamLookup(chunks), toolUses), expected)
262+
263+
let sink = 0
264+
guard.loopStart()
265+
if (VARIANT === 'stream-scan') {
266+
for (let iteration = 0; iteration < OPERATIONS; iteration++) {
267+
sink += runStreamScan(chunks, toolUses)
268+
}
269+
} else if (VARIANT === 'hook-indexed') {
270+
for (let iteration = 0; iteration < OPERATIONS; iteration++) {
271+
sink += runHookIndexed(hookTools, createStreamLookup(chunks), toolUses)
272+
}
273+
} else {
274+
throw new Error(`Unknown VARIANT: ${VARIANT}`)
275+
}
276+
guard.done()
277+
278+
assert.ok(sink > 0)

0 commit comments

Comments
 (0)