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
29 changes: 29 additions & 0 deletions .github/workflows/llmobs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,35 @@ jobs:
api_key: ${{ secrets.DD_API_KEY }}
service: dd-trace-js-tests

claude-agent-sdk:
runs-on: ubuntu-latest
env:
PLUGINS: claude-agent-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/testagent/start
- uses: ./.github/actions/node/oldest-maintenance-lts
- uses: ./.github/actions/install
- run: yarn test:plugins:ci
- run: yarn test:llmobs:plugins:ci
shell: bash
- uses: ./.github/actions/node/latest
- run: yarn test:plugins:ci
- run: yarn test:llmobs:plugins:ci
shell: bash
- uses: ./.github/actions/coverage
with:
flags: llmobs-${{ github.job }}
- if: always()
uses: ./.github/actions/testagent/logs
with:
suffix: llmobs-${{ github.job }}
- uses: DataDog/junit-upload-github-action@055560f63c405095e9228ba443eee7987e22bb94 # v2.1.1
if: always() && github.actor != 'dependabot[bot]'
with:
api_key: ${{ secrets.DD_API_KEY }}
service: dd-trace-js-tests

google-genai:
runs-on: ubuntu-latest
env:
Expand Down
3 changes: 3 additions & 0 deletions benchmark/sirun/plugin-claude-agent-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Simulates 500 agent sessions, each with 3 turns and 2 tool calls per turn
(22 hook invocations per session). Measures the overhead of the shimmer's
hook injection, diagnostics channel publishing, and span creation/finishing.
63 changes: 63 additions & 0 deletions benchmark/sirun/plugin-claude-agent-sdk/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use strict'

if (Number(process.env.USE_TRACER)) {
require('../../..').init()

// Simulate SDK module load so the plugin manager activates channel
// subscriptions (same event that fires when the real ESM SDK is loaded).
const { channel } = require('dc-polyfill')
channel('dd-trace:instrumentation:load').publish({ name: '@anthropic-ai/claude-agent-sdk' })
}

const { wrapQuery } = require('../../../packages/datadog-instrumentations/src/claude-agent-sdk')

const SESSIONS = 500

// Simulate the SDK calling registered hooks, matching the real SDK's
// matcher-array contract: each event is an array of matchers, each
// matcher has an array of hook functions.
function callHooks (matchers, input, extraArg) {
if (!matchers) return
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
hook(input, extraArg)
}
}
}

// Mock query() that simulates a realistic agent session:
// 3 turns, each with 2 tool calls = 22 hook invocations per call.
// This exercises the full shimmer path: mergeHooks, buildTracerHooks,
// runStores, and all 9 hook callbacks.
function mockQuery ({ prompt, options }) {
const hooks = options && options.hooks ? options.hooks : {}

callHooks(hooks.SessionStart, { session_id: 'bench', source: 'api' })

for (let t = 0; t < 3; t++) {
callHooks(hooks.UserPromptSubmit, { session_id: 'bench', prompt: 'p' })

for (let u = 0; u < 2; u++) {
const id = 'tu-' + t + '-' + u
callHooks(hooks.PreToolUse, {
session_id: 'bench', tool_name: 'Read',

Check failure on line 43 in benchmark/sirun/plugin-claude-agent-sdk/index.js

View workflow job for this annotation

GitHub Actions / lint

Object properties must go on a new line if they aren't all on the same line
tool_input: { path: '/' }, tool_use_id: id

Check failure on line 44 in benchmark/sirun/plugin-claude-agent-sdk/index.js

View workflow job for this annotation

GitHub Actions / lint

Missing trailing comma

Check failure on line 44 in benchmark/sirun/plugin-claude-agent-sdk/index.js

View workflow job for this annotation

GitHub Actions / lint

Object properties must go on a new line if they aren't all on the same line
}, id)
callHooks(hooks.PostToolUse, {
session_id: 'bench', tool_response: 'ok', tool_use_id: id

Check failure on line 47 in benchmark/sirun/plugin-claude-agent-sdk/index.js

View workflow job for this annotation

GitHub Actions / lint

Missing trailing comma
}, id)
}

callHooks(hooks.Stop, { stop_reason: 'end_turn' })
}

callHooks(hooks.SessionEnd, { reason: 'done' })

return { ok: true }
}

const wrapped = wrapQuery(mockQuery)

for (let i = 0; i < SESSIONS; i++) {
wrapped({ prompt: 'bench', options: { model: 'claude-opus-4-6' } })
}
17 changes: 17 additions & 0 deletions benchmark/sirun/plugin-claude-agent-sdk/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "plugin-claude-agent-sdk",
"run": "node index.js",
"run_with_affinity": "bash -c \"taskset -c $CPU_AFFINITY node index.js\"",
"cachegrind": false,
"iterations": 40,
"instructions": true,
"variants": {
"control": {
"env": { "USE_TRACER": "0" }
},
"with-tracer": {
"baseline": "control",
"env": { "USE_TRACER": "1" }
}
}
}
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ tracer.use('pg', {
<h5 id="ai"></h5>
<h5 id="amqp10"></h5>
<h5 id="anthropic"></h5>
<h5 id="claude-agent-sdk"></h5>
<h5 id="apollo"></h5>
<h5 id="avsc"></h5>
<h5 id="aws-sdk"></h5>
Expand Down Expand Up @@ -106,6 +107,7 @@ tracer.use('pg', {
* [amqp10](./interfaces/export_.plugins.amqp10.html)
* [amqplib](./interfaces/export_.plugins.amqplib.html)
* [anthropic](./interfaces/export_.plugins.anthropic.html)
* [claude-agent-sdk](./interfaces/export_.plugins.claude_agent_sdk.html)
* [apollo](./interfaces/export_.plugins.apollo.html)
* [avsc](./interfaces/export_.plugins.avsc.html)
* [aws-sdk](./interfaces/export_.plugins.aws_sdk.html)
Expand Down
1 change: 1 addition & 0 deletions docs/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ tracer.use('ai', true)
tracer.use('amqp10');
tracer.use('amqplib');
tracer.use('anthropic');
tracer.use('claude-agent-sdk');
tracer.use('avsc');
tracer.use('aws-sdk');
tracer.use('aws-sdk', awsSdkOptions);
Expand Down
7 changes: 7 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ interface Plugins {
"amqp10": tracer.plugins.amqp10;
"amqplib": tracer.plugins.amqplib;
"anthropic": tracer.plugins.anthropic;
"claude-agent-sdk": tracer.plugins.claude_agent_sdk;
"apollo": tracer.plugins.apollo;
"avsc": tracer.plugins.avsc;
"aws-sdk": tracer.plugins.aws_sdk;
Expand Down Expand Up @@ -1824,6 +1825,12 @@ declare namespace tracer {
*/
interface amqplib extends Instrumentation {}

/**
* This plugin automatically instruments the
* [@anthropic-ai/claude-agent-sdk](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) module.
*/
interface claude_agent_sdk extends Instrumentation {}

/**
* This plugin automatically instruments the
* [anthropic](https://www.npmjs.com/package/@anthropic-ai/sdk) module.
Expand Down
224 changes: 224 additions & 0 deletions packages/datadog-instrumentations/src/claude-agent-sdk.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
'use strict'

// Shimmer is used here because we must merge user-provided Claude hook matchers
// into `options.hooks` at runtime before calling `query()`, which orchestrion
// cannot express without custom argument mutation logic.

const { tracingChannel } = require('dc-polyfill')
const shimmer = require('../../datadog-shimmer')
const { addHook } = require('./helpers/instrument')

const sessionCh = tracingChannel('apm:claude-agent-sdk:session')
const turnCh = tracingChannel('apm:claude-agent-sdk:turn')
const toolCh = tracingChannel('apm:claude-agent-sdk:tool')
const subagentCh = tracingChannel('apm:claude-agent-sdk:subagent')

function mergeHooks (userHooks, tracerHooks) {
const merged = {}

for (const event of Object.keys(tracerHooks)) {
const userMatchers = (userHooks && userHooks[event]) || []
const tracerMatchers = tracerHooks[event]
merged[event] = [...userMatchers, ...tracerMatchers]
}

// Preserve any user hooks for events we don't trace
if (userHooks) {
for (const event of Object.keys(userHooks)) {
if (!merged[event]) {
merged[event] = userHooks[event]
}
}
}

return merged
}

function buildTracerHooks (sessionCtx) {
return {
SessionStart: [{
hooks: [function onSessionStart (input) {
sessionCtx.sessionId = input.session_id
sessionCtx.source = input.source
return {}
}],
}],

SessionEnd: [{
hooks: [function onSessionEnd (input) {
sessionCtx.endReason = input.reason
sessionCh.asyncEnd.publish(sessionCtx)
return {}
}],
}],

UserPromptSubmit: [{
hooks: [function onUserPromptSubmit (input) {
const turnCtx = {
sessionId: input.session_id,
prompt: input.prompt,
}

sessionCtx.currentTurn = turnCtx
turnCh.start.runStores(turnCtx, () => {
turnCh.end.publish(turnCtx)
})
return {}
}],
}],

Stop: [{
hooks: [function onStop (input) {
const turnCtx = sessionCtx.currentTurn
if (turnCtx) {
turnCtx.stopReason = input.stop_reason
turnCh.asyncEnd.publish(turnCtx)
sessionCtx.currentTurn = null
}
return {}
}],
}],

PreToolUse: [{
hooks: [function onPreToolUse (input, toolUseId) {
const id = toolUseId || input.tool_use_id
if (!id) return {}

const toolCtx = {
sessionId: input.session_id,
toolName: input.tool_name,
toolInput: input.tool_input,
toolUseId: id,
}

sessionCtx.pendingTools.set(id, toolCtx)
toolCh.start.runStores(toolCtx, () => {
toolCh.end.publish(toolCtx)
})
return {}
}],
}],

PostToolUse: [{
hooks: [function onPostToolUse (input, toolUseId) {
const id = toolUseId || input.tool_use_id
const toolCtx = sessionCtx.pendingTools.get(id)
if (toolCtx) {
toolCtx.toolResponse = input.tool_response
sessionCtx.pendingTools.delete(id)
toolCh.asyncEnd.publish(toolCtx)
}
return {}
}],
}],

PostToolUseFailure: [{
hooks: [function onPostToolUseFailure (input, toolUseId) {
const id = toolUseId || input.tool_use_id
const toolCtx = sessionCtx.pendingTools.get(id)
if (toolCtx) {
toolCtx.error = input.error
sessionCtx.pendingTools.delete(id)
toolCh.error.publish(toolCtx)
toolCh.asyncEnd.publish(toolCtx)
}
return {}
}],
}],

SubagentStart: [{
hooks: [function onSubagentStart (input) {
const agentId = input.agent_id
if (!agentId) return {}

const subagentCtx = {
sessionId: input.session_id,
agentId,
agentType: input.agent_type,
}

sessionCtx.pendingSubagents.set(agentId, subagentCtx)
subagentCh.start.runStores(subagentCtx, () => {
subagentCh.end.publish(subagentCtx)
})
return {}
}],
}],

SubagentStop: [{
hooks: [function onSubagentStop (input) {
const agentId = input.agent_id
const subagentCtx = sessionCtx.pendingSubagents.get(agentId)
if (subagentCtx) {
subagentCtx.transcriptPath = input.agent_transcript_path
sessionCtx.pendingSubagents.delete(agentId)
subagentCh.asyncEnd.publish(subagentCtx)
}
return {}
}],
}],
}
}

function wrapQuery (originalQuery) {
return function wrappedQuery ({ prompt, options }) {
if (!sessionCh.start.hasSubscribers) {
return originalQuery.call(this, { prompt, options })
}

const resolvedOptions = options || {}
const sessionCtx = {
prompt: typeof prompt === 'string' ? prompt : '[async iterable]',
model: resolvedOptions.model,
resume: resolvedOptions.resume,
maxTurns: resolvedOptions.maxTurns,
permissionMode: resolvedOptions.permissionMode,
currentTurn: null,
pendingTools: new Map(),
pendingSubagents: new Map(),
}

const tracerHooks = buildTracerHooks(sessionCtx)
const mergedOptions = {
...resolvedOptions,
hooks: mergeHooks(resolvedOptions.hooks, tracerHooks),
}

return sessionCh.start.runStores(sessionCtx, () => {
let result
try {
result = originalQuery.call(this, { prompt, options: mergedOptions })
} catch (err) {
sessionCtx.error = err
sessionCh.error.publish(sessionCtx)
sessionCh.asyncEnd.publish(sessionCtx)
throw err
}

sessionCh.end.publish(sessionCtx)

// If query() returns a promise/thenable, catch rejections
if (result && typeof result.then === 'function') {
result.then(null, (err) => {
sessionCtx.error = err
sessionCh.error.publish(sessionCtx)
sessionCh.asyncEnd.publish(sessionCtx)
})
}

return result
})
}
}

addHook({
name: '@anthropic-ai/claude-agent-sdk',
file: 'sdk.mjs',
versions: ['>=0.2.0'],
}, (exports) => {
shimmer.wrap(exports, 'query', wrapQuery)
return exports
})

// Exported for unit testing
module.exports = { mergeHooks, buildTracerHooks, wrapQuery }
1 change: 1 addition & 0 deletions packages/datadog-instrumentations/src/helpers/hooks.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

module.exports = {
'@anthropic-ai/claude-agent-sdk': { esmFirst: true, fn: () => require('../claude-agent-sdk') },
'@anthropic-ai/sdk': { esmFirst: true, fn: () => require('../anthropic') },
'@apollo/server': () => require('../apollo-server'),
'@apollo/gateway': () => require('../apollo'),
Expand Down
Loading
Loading