Skip to content

[BUG]: ai plugin leaks heap on every AI SDK call: LLMObs sub-plugin's #availableTools Set retains every tool() object forever (plus unbounded #toolCallIdsToName) #9276

Description

@vasilii-kurochkin

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
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions