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.tsAt 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 patchesModule._loadfor CommonJS.import-in-the-middle, registered throughModule.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._loadandModule.register(). - the dependency present on disk as its own package directory,
with a readable
package.jsonand 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_channelis built into Node, and is implemented by Bun, Deno, and Cloudflare Workers (withnodejs_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, andSENTRY_PRELOAD_INTEGRATIONSare 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-corehas been folded back into@sentry/node.generateInstrumentOnce(),SentryContextManager,SentrySampler, andSentrySpanProcessorwere 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 keepSentry.init()above every import of an instrumented library. --requireis no longer supported. Node re-runs--requirepreloads on the loader thread thatModule.register()spawns, so an instrument file would runSentry.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_modulesand never reaches the transform, so its channels are silently never injected. The Sentry build plugin strips known instrumented packages back out ofexternal, 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. Atinit()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 withdebug: truein 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, andSentrySpanProcessorwere removed. Export over OTLP instead, and point your exporter atSentry.getOtlpTracesEndpoint(dsn). otlpIntegrationmoved. It is exported from the main entry of every server SDK, so@sentry/node-core/light/otlpis gone.setupOtlpTracesExporterandcollectorUrlwere removed. The integration reports itself asOtlprather thanOtlpIntegration.- Watch for duplicate spans. Sentry instruments many of the
same libraries you do. Leaving
tracesSampleRateunset is the simple answer. If you want Sentry spans alongside your own, filter the overlapping integrations, and usehttpIntegration({ spans: false })andnativeNodeFetchIntegration({ spans: false })rather than removing them, becausehttpIntegrationalso provides request isolation, request data, and session tracking. - HTTP and fetch spans changed. In v10,
skipOpenTelemetrySetup: truealso turned those off. In v11 Sentry emits them whenever tracing is enabled. - Resources are no longer collected.
contexts.otel.resourcewas dropped from events, and the SDK no longer readsOTEL_SERVICE_NAMEorOTEL_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.