Tracer Version(s)
Observed in production on 5.80.0; the code is unchanged in 5.111.0 and 6.0.0 (checked packages/dd-trace/src/llmobs/plugins/ai/index.js on both).
Node.js Version(s)
24.16.0 (ESM app, loaded via node --import dd-trace/register.js)
Bug Report
The ai (Vercel AI SDK) instrumentation wraps tool() and publishes the full tool definition object on every call:
// packages/datadog-instrumentations/src/ai.js
function wrapTool (tool) {
return function () {
const args = arguments[0]
toolCreationChannel.publish(args)
return tool.apply(this, arguments)
}
}
The LLMObs sub-plugin of the composite ai plugin subscribes in its constructor and stores every published tool in a process-lifetime Set that is never cleared:
// packages/dd-trace/src/llmobs/plugins/ai/index.js
this.#availableTools = new Set()
toolCreationCh.subscribe(toolArgs => {
this.#availableTools.add(toolArgs)
})
The Set is only read by findToolName() as a description-matching fallback. Entries are never evicted and are not deduplicated across calls (each tool() invocation creates a fresh object).
A very common AI SDK pattern is creating tools per request so that execute closes over request-scoped context (user id, repositories, abort signals). Each tool object transitively retains its zod inputSchema graph, its cached JSON-schema conversion, and the whole closure environment of execute. With ~24 tools per chat stream this retained ~11 MB of heap per stream in our production, growing heap from ~250 MB to the ~2 GB default limit within a working day and crashing the process daily with FATAL ERROR: Reached heap limit Allocation failed.
#toolCallIdsToName in the same plugin is a plain object keyed by toolCallId that also only ever grows (this.#toolCallIdsToName[toolCall.toolCallId] = name), retaining strings for every tool call for process lifetime — smaller, but the same pattern.
Measurements
Driving 30 identical chat streams through streamText (fully consumed to completion), then global.gc() twice and comparing heapUsed:
| Configuration |
Retained heap after GC |
--import dd-trace/register.js, ai plugin active, llmobs configured |
+15.5 MB per stream |
| same, llmobs disabled |
+12.9 MB per stream |
same, DD_TRACE_DISABLED_PLUGINS=ai |
+0.3 MB per stream |
Heap-snapshot/profiler diffs attribute the retained memory to zod schema construction under createTools, zod/v4/core/to-json-schema results, and prompt/message strings pinned via the per-operation contexts.
Expected behavior
Tool objects registered for name-resolution should not accumulate for process lifetime. A WeakRef/bounded LRU keyed by description, or clearing entries when the corresponding spans finish, would avoid pinning application object graphs.
Workaround
DD_TRACE_DISABLED_PLUGINS=ai (must be set in the environment before Node starts — plugin_manager.js reads it at module load).
Reproduction Code
import 'dd-trace/init.js' // app loaded with --import dd-trace/register.js
import { tool, streamText } from 'ai'
import { z } from 'zod'
// per-request tool creation — recommended AI SDK pattern for request-scoped context
function createTools(requestContext) {
return {
my_tool: tool({
description: 'does things',
inputSchema: z.object({ q: z.string(), filters: z.array(z.object({ k: z.string(), v: z.string() })) }),
execute: async (input) => ({ ok: true, ctx: requestContext.userId }),
}),
// ... more tools
}
}
for (let i = 0; i < 1000; i++) {
const result = streamText({ model, tools: createTools({ userId: String(i), bigBuffer: 'x'.repeat(1e6) }), prompt: 'hi' })
for await (const _ of result.textStream) { /* consume */ }
// every createTools() result is now permanently retained by
// VercelAILLMObsPlugin #availableTools even though the stream completed
}
Tracer Version(s)
Observed in production on 5.80.0; the code is unchanged in 5.111.0 and 6.0.0 (checked
packages/dd-trace/src/llmobs/plugins/ai/index.json both).Node.js Version(s)
24.16.0 (ESM app, loaded via
node --import dd-trace/register.js)Bug Report
The
ai(Vercel AI SDK) instrumentation wrapstool()and publishes the full tool definition object on every call:The LLMObs sub-plugin of the composite
aiplugin subscribes in its constructor and stores every published tool in a process-lifetimeSetthat is never cleared:The Set is only read by
findToolName()as a description-matching fallback. Entries are never evicted and are not deduplicated across calls (eachtool()invocation creates a fresh object).A very common AI SDK pattern is creating tools per request so that
executecloses over request-scoped context (user id, repositories, abort signals). Each tool object transitively retains its zodinputSchemagraph, its cached JSON-schema conversion, and the whole closure environment ofexecute. With ~24 tools per chat stream this retained ~11 MB of heap per stream in our production, growing heap from ~250 MB to the ~2 GB default limit within a working day and crashing the process daily withFATAL ERROR: Reached heap limit Allocation failed.#toolCallIdsToNamein the same plugin is a plain object keyed bytoolCallIdthat also only ever grows (this.#toolCallIdsToName[toolCall.toolCallId] = name), retaining strings for every tool call for process lifetime — smaller, but the same pattern.Measurements
Driving 30 identical chat streams through
streamText(fully consumed to completion), thenglobal.gc()twice and comparingheapUsed:--import dd-trace/register.js,aiplugin active, llmobs configuredDD_TRACE_DISABLED_PLUGINS=aiHeap-snapshot/profiler diffs attribute the retained memory to zod schema construction under
createTools,zod/v4/core/to-json-schemaresults, and prompt/message strings pinned via the per-operation contexts.Expected behavior
Tool objects registered for name-resolution should not accumulate for process lifetime. A
WeakRef/bounded LRU keyed by description, or clearing entries when the corresponding spans finish, would avoid pinning application object graphs.Workaround
DD_TRACE_DISABLED_PLUGINS=ai(must be set in the environment before Node starts —plugin_manager.jsreads it at module load).Reproduction Code