← Back to Blog Home

Sentry JavaScript SDK v11 adds tracing for all JS platforms

Sentry JavaScript SDK v11 adds tracing for all JS platforms

This post is about a recent change to the Sentry JavaScript SDKs. If you don’t use Sentry with JavaScript, it may still be useful as a look at how we made the change and why.

In the Sentry JavaScript SDK v11, we rebuilt how our tracing integrations work. While we still support the OpenTelemetry standard (described in more detail below), we now instrument your dependencies with a code transformer instead of using the OpenTelemetry instrumentation classes as we did in previous versions.

This approach is built on the apm-js-collab packages @apm-js-collab/code-transformer and @apm-js-collab/tracing-hooks, a shared effort across application performance monitoring (APM) vendors.

Injection & subscription

The new tracing instrumentations work in two parts. The first injects diagnostics channels into a module as it’s being loaded, and the second listens on those channel events to gather tracing information to send to Sentry.

Part 1: injection

When a supported module is loaded, either at runtime or as part of a bundler job, the transform rewrites the source to wrap the functions we care about in a tracingChannel() call.

Each transform is driven by a config that names the package, a semver range, a file inside the package, and a function:

{
  channelName: 'query',
  module: {
    name: 'mysql',
    versionRange: '>=2.0.0 <3',
    filePath: 'lib/Connection.js',
  },
  functionQuery: { expressionName: 'query', kind: 'Auto' },
}

Once transformed, the mysql module now publishes start, end, error, asyncStart, and asyncEnd diagnostics whenever the query function is called.

Part 2: subscription

Each Sentry integration (mysqlIntegration, redisIntegration, openAIIntegration, and so on) subscribes to its channels and turns the events into Sentry spans.

The subscriber holds off until it hears that its module is actually loaded, to avoid creating excess channels.

Injection at run time & build time

The same transform runs in two places, and both can be active at once. Either a module is loaded as part of a bundler, or from node_modules, but not both, so there’s no chance of double-wrapping.

At run time

In recent Node versions, and in Deno 2.8.3 and up, Sentry.init() calls registerDiagnosticsChannelInjection() automatically, which will use the Module.registerHooks API to instrument any supported modules loaded after that point.

You can also register the hooks before your app loads at all, by using the import endpoints provided in the Deno and Node SDKs:

node --import @sentry/node/import app.js
deno run --preload=@sentry/deno/import app.ts

At build time

If you are using bun build, or bundling your program using a bundler like Vite or esbuild, you can use the provided plugins to instrument modules as they are built into the application.

@sentry/node ships plugins for Vite, Rollup, webpack, and esbuild; @sentry/cloudflare includes one for Vite; @sentry/bun ships one for bun build; Next.js wires the webpack loader into both webpack and Turbopack.

The plugin transforms each instrumented dependency as it is bundled, and splices in a snippet that registers the module (and its subscriber factory) on a global marker when the bundled module is first evaluated.

Usage is the same as any other Sentry bundler plugin:

// vite.config.ts
import { sentryVitePlugin } from '@sentry/node/vite';
export default {
  plugins: [sentryVitePlugin({ org: '...', project: '...' })],
};

The approach before v11

Before v11, Sentry’s integrations were almost entirely based on the OpenTelemetry instrumentation APIs.

This let us use work from the open source community, but it also had significant drawbacks that grew worse as more JavaScript platforms gained popularity.

Inside an OpenTelemetry instrumentation

An OpenTelemetry instrumentation package, such as @opentelemetry/instrumentation-express, exports a class that extends InstrumentationBase. It declares a package name, a supported semver range, and a patch(moduleExports, version) function. It can also declare InstrumentationNodeModuleFile entries to only patch individual files inside the package.

registerInstrumentations() installs two loader hooks:

  • require-in-the-middle, which patches Module._load for CommonJS.
  • import-in-the-middle, registered through Module.register() as an ECMAScript module (ESM) loader hook.

When an instrumented module loads, these loader hooks hand the OpenTelemetry instrumentation the module’s exports object and its base directory. The instrumentation reads <baseDir>/package.json off disk to find the version, checks it against the declared range, and calls patch(exports). The patch uses shimmer to replace properties on the exports object in place. The replacement function opens an OpenTelemetry span, and Sentry’s SentrySpanProcessor, SentrySampler, and SentryContextManager convert that span into a Sentry span.

ESM namespace objects are immutable, so import-in-the-middle cannot simply assign to them. It reads each module’s source from disk, lexes the export names (cjs-module-lexer for CommonJS, a source scan for ESM), generates a replacement module that re-exports through mutable local bindings, and wraps the namespace in a Proxy. Only then can shimmer’s assignment take effect.

A chain of assumptions

Each step in this process assumes something about the environment. Together they need:

  • a live module loader that runs a require() or a Node ESM resolve/load hook for every dependency.
  • Node’s loader internals, Module._load and Module.register().
  • the dependency present on disk as its own package directory, with a readable package.json and readable source files.
  • exports that the patch can reassign.
  • the hooks installed before the first import of the library. OpenTelemetry warns when it is too late: “Module x has been loaded before y so it might not work”.

Limits placed on bundlers

After a bundler inlines a dependency, none of those assumptions hold. require('express') becomes a reference to a function defined higher up in the same file, so there is no load event to intercept. There is no node_modules/express/package.json to read, so the version check has nothing to check against. ESM const bindings inside a bundle cannot be reassigned, and a call site may already hold a direct reference to the function before any patch could run.

The workarounds pushed the work back to the user: mark every instrumented package as external, ship a node_modules tree next to the bundle, and accept that this undoes much of the reason for bundling. Platforms that bundle your server code for you, such as Vercel and Netlify, left no place to apply that workaround at all.

Node.js only

Everything about this approach is Node-specific. When Sentry’s JS SDK started down this path a few years ago, that was a reasonable restriction, because Node.js was the main server-side JavaScript runtime. As more runtimes became popular, it stopped being reasonable.

Cloudflare Workers have no part of the chain at all: no CommonJS loader, no Module.register(), no filesystem to read a package.json from, and module resolution happens at build time in the Workers bundler. Bun and Deno implement much of the user-facing portions of Node’s module system, but not Module._load patching, and the older Module.register hooks are empty stub functions on these platforms.

Ordering & preloads

Because ESM import statements are hoisted and evaluated before any code in the importing file, Sentry.init() in an ESM entry point runs after the libraries it wants to patch have already been loaded. In v10, the options were --import=./instrument.mjs, the @sentry/node/preload entry point, preloadOpenTelemetry(), and a SENTRY_PRELOAD_INTEGRATIONS environment variable for apps that needed the patches installed early but could not configure the SDK yet.

Weight

Every supported library needed its own @opentelemetry/instrumentation-* package, pinned against that library’s internals, and Sentry had to keep the OpenTelemetry SDK present (tracer provider, sampler, span processor, context manager, and propagator) to receive the spans.

Fixing these problems in v11

The new approach replaces that entire chain, by injecting tracing channels just like the built-in channels library authors can use:

  • Bundled server deployments just work. A source transform can run during a build, because it modifies the source code, not the resulting object. The bundler plugin instruments dependencies while they are being bundled, which a loader-hook system cannot do at all. This covers Vercel and Netlify, which bundle your server code for you.
  • Non-Node runtimes work. node:diagnostics_channel is built into Node, and is implemented by Bun, Deno, and Cloudflare Workers (with nodejs_compat). This was the main reason for the change.
  • It edits library code, not exports. The transform wraps the function inside the module’s own source, so it does not matter whether a package freezes its exports, re-exports through a barrel file, or has been inlined by a bundler. Export object monkey-patching fails in all of those cases.
  • One ordering rule instead of several. A library instruments itself the moment it is loaded, so preloadOpenTelemetry(), @sentry/node/preload, and SENTRY_PRELOAD_INTEGRATIONS are all gone. At runtime, a single registration call covers every library.
  • Less to install, and less to go wrong. The dependency tree is cut back to just @opentelemetry/api, instead of a tree of instrumentation packages, and @sentry/node-core has been folded back into @sentry/node. generateInstrumentOnce(), SentryContextManager, SentrySampler, and SentrySpanProcessor were removed.
  • Sentry stays out of your OpenTelemetry setup. Sentry spans no longer leak into your pipeline, and your pipeline no longer has to use Sentry’s context manager and propagator. There’s still quite a bit of flexibility in how you can use Sentry with a custom OpenTelemetry setup, as discussed below.

This instrumentation, which injects diagnostics channels with a source transform, already existed in v10 behind experimentalUseDiagnosticsChannelInjection(); v11 makes it the default and removes the export-patching instrumentations.

Platform coverage

Before adopting this approach, we explored quite a few other options. It’s a big change, and the open source OpenTelemetry-based instrumentations have a long history of reliable use in production systems. Part of the motivation was to deliver similar OpenTelemetry support to all JavaScript platforms.

Node itself is moving its module loader hooks in a different direction, so porting import-in-the-middle and require-in-the-middle to every JS platform did not seem worth the effort. The new approach gave us results on three platforms right away.

Cloudflare Workers

Workers have no Node module loader, so v10’s approach could not attach to anything. Tracing beyond fetch and the request handler meant calling the manual wrappers yourself (for example, instrumentOpenAiClient and the like).

v11 adds @sentry/cloudflare/vite. sentryCloudflareVitePlugin() transforms your dependencies as the worker bundle is built, so database drivers and AI clients emit spans inside the worker. The subscriber integrations are registered by the injected snippet rather than imported, so a worker built without the plugin ships none of that code.

Bun

@sentry/bun/plugin exports sentryBunPlugin() for bun build.

Unfortunately, this is build-only for now. Due to a known issue, any module returned by a runtime onLoad plugin in Bun loses its CommonJS named exports (oven-sh/bun#34112), so a runtime hook would break the libraries it instruments.

Running unbundled source with bun run therefore gets no channel instrumentation, which we felt was better than partial or broken coverage. The Bun team is working on the issue, so we expect this to be temporary. In the meantime, production systems on Bun often use bun build for optimization anyway.

Deno

In 2026, Deno greatly improved its diagnostics_channel coverage and its support for module loader hooks, so tracing on Deno now works much like tracing on Node.

deno run --preload=@sentry/deno/import registers the runtime hook. Deno’s minimum supported version (2.8.3) already has stable synchronous module hooks, so this is the same code path as modern Node. (These are Module.registerHooks(), a different API from the legacy hooks the OpenTelemetry instrumentations use.)

@sentry/deno now exports the whole channel integration set, including database connectors, web frameworks, AI provider integrations, and more being added all the time. In v10 it had none of them.

Pitfalls & caveats

There are a few things to watch for when you upgrade. Every improvement is a change, as they say. The short version: read the migration guide and follow it.

  • Load order still matters. The transform only applies to modules loaded after the hooks are registered. Register through --import, or keep Sentry.init() above every import of an instrumented library.
  • --require is no longer supported. Node re-runs --require preloads on the loader thread that Module.register() spawns, so an instrument file would run Sentry.init() a second time on a thread that would never execute your code. Use --import, which works for CommonJS apps too.
  • Do not externalize instrumented libraries in a build-time setup. An external dependency is loaded from node_modules and never reaches the transform, so its channels are silently never injected. The Sentry build plugin strips known instrumented packages back out of external, and warns about the ones it cannot fix.
  • Verify with debug: true. We designed the system to work without extra setup, but we also planned for the cases where it doesn’t. At init() time, the SDK reports whether the runtime hook registered, whether the bundler plugin ran, and which modules each one injected. If you run your test and CI setup with debug: true in your Sentry settings (especially while making changes to your setup), it’ll catch most mistakes.

Custom OpenTelemetry setups in v11

Instrumentation and OpenTelemetry are now separate concerns. Sentry still works with OpenTelemetry, and the architecture behind it is simpler.

Channel integrations create native Sentry spans directly from the channel events, so whichever of the following you pick, your dependencies stay instrumented.

skipOpenTelemetrySetup is replaced by enableOpenTelemetrySetup, with the meaning inverted. It defaults to false on most server SDKs and to true on @sentry/nextjs and @sentry/sveltekit, which still register a light tracer provider to pick up the spans those frameworks emit.

Option 1: Sentry only (the default)

Set tracesSampleRate and you are done.

Option 2: OpenTelemetry-compatible mode

Set enableOpenTelemetrySetup: true. Sentry registers a minimal tracer provider, context manager, and propagator, so spans created through @opentelemetry/api become native Sentry spans. This is not a general OpenTelemetry pipeline: there is no exporter and no OpenTelemetry Protocol (OTLP) output. Sentry refuses to register its provider if you already registered one, and logs a warning instead.

Option 3: Your own OpenTelemetry, with Sentry linked to it

Leave enableOpenTelemetrySetup at false, leave tracesSampleRate unset, run your own provider, and add Sentry.otlpIntegration():

const provider = new NodeTracerProvider({
  spanProcessors: [
    new BatchSpanProcessor(
      new OTLPTraceExporter(Sentry.getOtlpTracesEndpoint('__DSN__')),
    )
  ],
});
provider.register();

Sentry.init({
  dsn: '__DSN__',
  integrations: [Sentry.otlpIntegration()],
});

otlpIntegration() attaches Sentry errors, logs, metrics, and crons to whatever OpenTelemetry span is active, so all your telemetry lands in one trace. It sets up no exporter, span processor, or tracer provider, and leaves outgoing propagation to your propagator.

If you had a custom setup in v10, the migration points are:

  • There is no longer a way to route spans from your own provider into Sentry as Sentry spans. SentryContextManager, SentrySampler, and SentrySpanProcessor were removed. Export over OTLP instead, and point your exporter at Sentry.getOtlpTracesEndpoint(dsn).
  • otlpIntegration moved. It is exported from the main entry of every server SDK, so @sentry/node-core/light/otlp is gone. setupOtlpTracesExporter and collectorUrl were removed. The integration reports itself as Otlp rather than OtlpIntegration.
  • Watch for duplicate spans. Sentry instruments many of the same libraries you do. Leaving tracesSampleRate unset is the simple answer. If you want Sentry spans alongside your own, filter the overlapping integrations, and use httpIntegration({ spans: false }) and nativeNodeFetchIntegration({ spans: false }) rather than removing them, because httpIntegration also provides request isolation, request data, and session tracking.
  • HTTP and fetch spans changed. In v10, skipOpenTelemetrySetup: true also turned those off. In v11 Sentry emits them whenever tracing is enabled.
  • Resources are no longer collected. contexts.otel.resource was dropped from events, and the SDK no longer reads OTEL_SERVICE_NAME or OTEL_RESOURCE_ATTRIBUTES.

Upgrading to v11

v11 moves tracing off the OpenTelemetry instrumentation packages and onto source transforms and diagnostics channels. On Node, that means fewer dependencies and one ordering rule instead of several. If you run on Cloudflare Workers, Deno, or Bun (with bun build), or deploy bundled server code to Vercel or Netlify, your dependencies now get automatic tracing where they had little or none before.

To upgrade, start with the migration guide, and turn on debug: true the first time you run the new version so you can see which modules were instrumented.

The transform and hooks come from the apm-js-collab packages, which we build together with other APM vendors. If something does not work the way you expect, open an issue in the sentry-javascript repository.

Syntax.fm logo

Listen to the Syntax Podcast

Of course we sponsor a developer podcast. Check it out on your favorite listening platform.

Listen To Syntax