Skip to content

[BUG]: Jest environments leak via id.js identity-channel subscription even with the no-op tracer (6.16.0 / 6.20.1) #10702

Open

Description

@SinBirb

Tracer Version(s)

Reproduced with 6.16.0 and 6.20.1 (latest release at the time of this report).

Node.js Version(s)

22.23.3; Jest 30.5.2.

Bug Report

Simply importing dd-trace from a Jest test file retains the completed test environment, even when the tracer correctly selects its no-op implementation. No tracer.init(), spans, HTTP requests, Datadog agent, application code, transpilation, or coverage are needed to reproduce it.

Each test file adds another reseed subscriber to the process-wide datadog:identity:update diagnostics channel. The subscriptions remain after Jest tears down the file's test environment. --detectLeaks fails for every file.

The no-op selection in packages/dd-trace/src/index.js does run, but the no-op implementation still imports the helper with the side effect:

src/index.js
  -> noop/proxy.js
    -> noop/tracer.js
      -> noop/span.js
        -> id.js
          -> channel('datadog:identity:update').subscribe(reseed)

Specifically, noop/span.js requires ../id. The subscription executes at module load; reseed does not need to be called for the leak to occur. Jest reloads these modules in each test environment, while the built-in diagnostics-channel registry survives between files.

Expected: importing the no-op tracer must allow completed Jest test environments to be garbage-collected, without accumulating process-wide callbacks.

Observed on both tested versions:

Experiment Result
Three test files importing dd-trace All three fail --detectLeaks
Count identity-channel subscribers after each file 0 -> 1 -> 2 -> 3, all named reseed; still 3 after the run
Same tests, unsubscribe only each file's new reseed callback in afterAll All three pass --detectLeaks

The unsubscribe experiment isolates this subscription as sufficient to cause the minimal reproduction's retention. It uses a private Node field for diagnosis only, not as a proposed supported application workaround.

Related history:

Reproduction Code

Create an empty directory and install the two dependencies:

mkdir dd-trace-jest-leak-repro
cd dd-trace-jest-leak-repro
npm init -y
npm install --save-dev --save-exact dd-trace@6.20.1 jest@30.5.2

jest.config.cjs:

module.exports = {
  testEnvironment: 'node',
  testEnvironmentOptions: { globalsCleanup: 'on' },
  transform: {},
};

Create three identical files, tracer-1.test.js, tracer-2.test.js, and tracer-3.test.js:

const tracer = require('dd-trace');

test('importing dd-trace should not retain the completed test environment', () => {
  expect(tracer).toBeDefined();
});

Run:

node --expose-gc node_modules/jest/bin/jest.js --runInBand --detectLeaks --logHeapUsage

This also reproduces with dd-trace@6.16.0.

To observe the retained subscribers, add identity-reporter.cjs:

const { channel } = require('node:diagnostics_channel');

module.exports = class IdentityReporter {
  print(label) {
    const callbacks = channel('datadog:identity:update')._subscribers ?? [];
    console.log(`${label}: ${callbacks.length} subscribers (${callbacks.map(callback => callback.name).join(', ')})`);
  }
  onRunStart() { this.print('Before tests'); }
  onTestResult() { this.print('After test file teardown'); }
  onRunComplete() { this.print('After all tests'); }
};
node --expose-gc node_modules/jest/bin/jest.js --runInBand --reporters ./identity-reporter.cjs

Output:

Before tests: 0 subscribers ()
After test file teardown: 1 subscribers (reseed)
After test file teardown: 2 subscribers (reseed, reseed)
After test file teardown: 3 subscribers (reseed, reseed, reseed)
After all tests: 3 subscribers (reseed, reseed, reseed)

For the controlled cleanup experiment, add cleanup.cjs:

const { channel } = require('node:diagnostics_channel');
const identityChannel = channel('datadog:identity:update');
const existing = new Set(identityChannel._subscribers ?? []);

afterAll(() => {
  for (const callback of [...(identityChannel._subscribers ?? [])]) {
    if (!existing.has(callback) && callback.name === 'reseed') {
      identityChannel.unsubscribe(callback);
    }
  }
});

Add setupFilesAfterEnv: ['./cleanup.cjs'] to the Jest config and rerun the original --detectLeaks command. The result changes to:

Test Suites: 3 passed, 3 total
Tests:       3 passed, 3 total

This experiment removes only the newly registered reseed callbacks and leaves all other tracer behavior unchanged.

Error Logs

Without the cleanup, on both 6.16.0 and 6.20.1:

FAIL ./tracer-1.test.js
  ● Test suite failed to run

    EXPERIMENTAL FEATURE!
    Your test suite is leaking memory. Please ensure all references are cleaned.

Test Suites: 3 failed, 3 total

The other two files report the same leak error. This result also occurs without the custom reporter, so it is not caused by the diagnostic reporter retaining anything.

Tracer Config

No initialization or configuration. Only require('dd-trace') inside Jest; no tracer preload or Datadog agent.

Operating System

macOS 27.0.1, arm64.

Bundling

No Bundling. Plain CommonJS JavaScript; Jest transforms disabled.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions