diff --git a/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/app-external-dep.mjs b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/app-external-dep.mjs new file mode 100644 index 000000000000..b72ef7fdd587 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/app-external-dep.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ tracesSampleRate: 0 }); + +// `dataloader` is left external by the build, so it loads through Node's module +// loader and the runtime hook has to transform it. That is the only path on +// which a stripped code transformer actually costs the user instrumentation. +const { default: DataLoader } = await import('dataloader'); +await new DataLoader(async keys => keys).load(1); + +// `runtime` lists the modules the runtime hook actually transformed. It stays empty when the +// transformer was stripped (dep loaded uninstrumented) and lists `dataloader` when the hook ran — +// so the tests can assert on the outcome, not just on whether a warning printed. +const runtime = JSON.stringify(globalThis.__SENTRY_ORCHESTRION__?.runtime ?? []); +// eslint-disable-next-line no-console +console.log( + `DEP_LOADED bundler_marker=${globalThis.__SENTRY_ORCHESTRION__?.bundler instanceof Set} runtime=${runtime}`, +); diff --git a/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/app.mjs b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/app.mjs new file mode 100644 index 000000000000..65715143acbd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/app.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +// No DSN: nothing is sent. `init()` installs the runtime diagnostics-channel +// hook regardless, and that is the code path under test. +Sentry.init({ tracesSampleRate: 0 }); + +// The marker is printed for diagnosis only. The test asserts on whether the SDK +// warned, not on how it decided to, so it does not pin one implementation. +// eslint-disable-next-line no-console +console.log(`APP_STARTED bundler_marker=${globalThis.__SENTRY_ORCHESTRION__?.bundler instanceof Set}`); diff --git a/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/entry-a.mjs b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/entry-a.mjs new file mode 100644 index 000000000000..105b977a50dd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/entry-a.mjs @@ -0,0 +1,3 @@ +// Second entry point for the code-splitting case: sharing `app.mjs` with +// `entry-b.mjs` forces esbuild to move it into a shared chunk. +import './app.mjs'; diff --git a/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/entry-b.mjs b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/entry-b.mjs new file mode 100644 index 000000000000..cb42a29cd155 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/entry-b.mjs @@ -0,0 +1 @@ +import './app.mjs'; diff --git a/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/test.ts b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/test.ts new file mode 100644 index 000000000000..03722fb25d77 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/orchestrion-bundled/test.ts @@ -0,0 +1,146 @@ +import { spawnSync } from 'child_process'; +import { rmSync } from 'fs'; +import { join } from 'path'; +import { sentryEsbuildPlugin } from '@sentry/node/esbuild'; +import { build } from 'esbuild'; +import type { Plugin } from 'esbuild'; +import { afterAll, describe, expect, test } from 'vitest'; + +// `@sentry/node` installs its diagnostics-channel instrumentation through a runtime module hook +// that ships in `@sentry/server-utils` and only works from `node_modules`. Bundling that package +// strips its vendored code transformer, so the SDK warns that auto-instrumentation is off. +// +// Using the Sentry bundler plugin is the supported alternative: instrumentation is injected at +// build time, the runtime hook is redundant, and the SDK must stay quiet. +// +// The assertions are on "did the SDK warn", not on how it decided to, so this test does not pin +// one implementation of the check. +const OUT_DIR = join(__dirname, 'tmp_build'); + +/** Every always-on `[Sentry]` line the SDK printed at startup. */ +function sentryWarnings(stderr: string): string[] { + return stderr.split('\n').filter(line => line.startsWith('[Sentry]')); +} + +/** + * Keep debug-ID injection on — that is part of what the plugin normally does to a build, and it is + * what rewrites the entry point — while skipping release creation and upload, so the test needs no + * auth token and makes no network calls. + */ +function sentryPlugin(): Plugin { + return sentryEsbuildPlugin({ + telemetry: false, + release: { create: false }, + sourcemaps: { disable: 'disable-upload' }, + }) as Plugin; +} + +function run(entry: string): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync('node', [entry], { encoding: 'utf-8' }); + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; +} + +describe('esbuild + orchestrion build-time instrumentation', () => { + afterAll(() => { + rmSync(OUT_DIR, { recursive: true, force: true }); + }); + + test('stays quiet when the Sentry esbuild plugin ran', async () => { + const outfile = join(OUT_DIR, 'single', 'app.mjs'); + + await build({ + entryPoints: [join(__dirname, 'app.mjs')], + outfile, + platform: 'node', + format: 'esm', + bundle: true, + logLevel: 'silent', + plugins: [sentryPlugin()], + }); + + const { stdout, stderr, status } = run(outfile); + + expect(status).toBe(0); + // Build-time instrumentation is in place, so telling the user to set up build-time + // instrumentation would be wrong. + expect(sentryWarnings(stderr)).toEqual([]); + expect(stdout).toContain('APP_STARTED'); + }); + + test('stays quiet when the bundle is code-split and `init()` runs from a shared chunk', async () => { + const splitDir = join(OUT_DIR, 'split'); + + await build({ + entryPoints: [join(__dirname, 'entry-a.mjs'), join(__dirname, 'entry-b.mjs')], + outdir: splitDir, + platform: 'node', + format: 'esm', + bundle: true, + splitting: true, + logLevel: 'silent', + plugins: [sentryPlugin()], + }); + + const { stdout, stderr, status } = run(join(splitDir, 'entry-a.js')); + + expect(status).toBe(0); + expect(sentryWarnings(stderr)).toEqual([]); + expect(stdout).toContain('APP_STARTED'); + }); + + // The positive control. Without it the two tests above would still pass on a build where the SDK + // never warns at all, which is the regression they are meant to catch. `dataloader` is left + // external so it loads through Node's loader, which is where a stripped transformer actually + // loses the user instrumentation. + test('warns when an external instrumented dependency loads and no plugin ran', async () => { + const outfile = join(OUT_DIR, 'no-plugin', 'app.mjs'); + + await build({ + entryPoints: [join(__dirname, 'app-external-dep.mjs')], + outfile, + platform: 'node', + format: 'esm', + bundle: true, + external: ['dataloader'], + logLevel: 'silent', + }); + + const { stdout, stderr, status } = run(outfile); + + expect(status).toBe(0); + expect(stdout).toContain('DEP_LOADED'); + // The stripped transformer left `dataloader` uninstrumented — nothing recorded on `runtime`. + expect(stdout).toContain('runtime=[]'); + // Nothing instrumented `dataloader`, so the user has to be told, and the warning names the + // module that was lost. + const warning = sentryWarnings(stderr).join('\n'); + expect(warning).toContain('@sentry/server-utils'); + expect(warning).toContain('dataloader'); + // One broken transformer breaks every module, so the fix is stated exactly once. + expect(sentryWarnings(stderr)).toHaveLength(1); + }); + + // The counterpart to the warning: it tells the user to keep `@sentry/server-utils` external, so + // that remedy has to actually work. External, the package loads from `node_modules` with its + // vendored transformer intact, instruments the (also external) `dataloader`, and stays quiet. + test('instruments the dependency and stays quiet when `@sentry/server-utils` is kept external', async () => { + const outfile = join(OUT_DIR, 'external-server-utils', 'app.mjs'); + + await build({ + entryPoints: [join(__dirname, 'app-external-dep.mjs')], + outfile, + platform: 'node', + format: 'esm', + bundle: true, + external: ['dataloader', '@sentry/server-utils'], + logLevel: 'silent', + }); + + const { stdout, stderr, status } = run(outfile); + + expect(status).toBe(0); + // The runtime hook transformed `dataloader`, so it is recorded and there is nothing to warn about. + expect(stdout).toContain('runtime=["dataloader"]'); + expect(sentryWarnings(stderr)).toEqual([]); + }); +}); diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 78f31d194911..9b56621fd389 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -77,6 +77,13 @@ export type InternalGlobal = { * `init()` and instantiates them. */ integrations?: Map Integration>; + /** + * Set once `registerDiagnosticsChannelInjection()` has run but could not + * install the runtime module hooks — the Node runtime lacks the required + * module-hook API, or registration threw. Dedupes the one-time warning and + * short-circuits repeat calls. + */ + runtimeUnavailable?: boolean; }; } & Carrier; diff --git a/packages/node/README.md b/packages/node/README.md index 6471538fb4f0..49ee5dbd9e05 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -72,6 +72,25 @@ If it is not possible for you to pass the `--import` flag to the Node.js binary, NODE_OPTIONS="--import ./instrument.mjs" npm run start ``` +### Bundling your server + +`@sentry/node` installs its automatic (diagnostics-channel) instrumentation through a runtime module +hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. There are two +supported ways to keep auto-instrumentation working when you bundle your server: + +1. **Keep `@sentry/server-utils` external** (do not inline it into the bundle) so the runtime hook + loads from `node_modules`. Most bundlers externalize `node_modules` for a Node target by default; + if yours inlines everything, mark `@sentry/server-utils` as external explicitly. +2. **Instrument at build time** with the Sentry bundler plugins (`@sentry/node/esbuild`, + `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which inject the + instrumentation into your bundled dependencies during the build. In this mode the runtime hook is + not needed. + +If you bundle `@sentry/server-utils` **and** don't use the build-time plugin, its internal code +transformer is stripped and runtime auto-instrumentation is disabled. `@sentry/node` warns the +first time an instrumented dependency loads uninstrumented, so a build-time-instrumented app never +sees the warning. + ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index b7978c288ffd..c4a2eef6eae6 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -28,4 +28,9 @@ functionality related to Nuxt. ## Troubleshoot +If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro +presets), make sure `@sentry/server-utils` is kept **external** in the Nitro/server build rather than +inlined, as its runtime module hook must resolve from `node_modules`. `@sentry/node` logs a warning +the first time an instrumented dependency loads uninstrumented. + If you encounter any issues with error tracking or integrations, refer to the official [Sentry Nuxt SDK documentation](https://docs.sentry.io/platforms/javascript/guides/nuxt/). If the documentation does not provide the necessary information, consider opening an issue on GitHub. diff --git a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts index 24a6b6a58a0f..52cb699ef42b 100644 --- a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -22,7 +22,7 @@ declare module '@apm-js-collab/tracing-hooks/lib/diagnostics.js' { declare module '@apm-js-collab/tracing-hooks/hook-sync.mjs' { import type { MessagePort } from 'node:worker_threads'; - import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + import type { InstrumentationConfig } from '../apmTypes'; type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; type InitializeData = { instrumentations?: InstrumentationConfig[]; diagnosticsPort?: MessagePort }; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 10865145b605..b446accba64f 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,4 +1,4 @@ -import { debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; +import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; import { SENTRY_INSTRUMENTATIONS } from '../config'; @@ -12,6 +12,9 @@ type NodeModule = { register?: typeof register; }; +// Surfaced in the always-on warnings below so users can find the fix. +const BUNDLING_DOCS_URL = 'https://docs.sentry.io/platforms/javascript/guides/node/troubleshooting/'; + /** `Module.registerHooks` only became stable in Node 24.13 / 25.1. */ function hasStableSyncModuleHooks(isDeno: boolean): boolean { // The minimum supported Deno (2.8.3) always has stable sync module hooks. @@ -23,6 +26,48 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } +/** + * Emit a single, always-on warning that runtime channel injection is disabled, with the actionable + * fix. Unlike `debug.warn` (gated behind `debug: true`), this reaches every user — otherwise the + * SDK silently records no channel-based spans. + */ +function warnRuntimeUnavailable(message: string): void { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`); + }); +} + +// One broken transformer breaks every module, so state the fix once. +let warnedTransformerUnavailable = false; + +/** + * Warn that the vendored code transformer could not run, so `moduleName` loaded uninstrumented. + * + * This package ships the transformer (meriyah/astring/source-map) inline and is meant to run from + * `node_modules`. A bundler that inlines and tree-shakes `@sentry/server-utils` strips it, so every + * transform throws `TypeError: parse is not a function` — swallowed inside the loader, once per + * module, visible only with `debug: true`. + * + * Warning from here rather than probing the transformer at `init()` keeps the check honest. A + * module only reaches this callback by coming through Node's loader, which means the build-time + * bundler plugin did not cover it, which means the instrumentation really is lost. Probing at + * `init()` instead has to guess at that from a global the plugin's entry banner may not have + * written yet. + */ +function warnTransformerUnavailable(moduleName: string): void { + if (warnedTransformerUnavailable) { + return; + } + warnedTransformerUnavailable = true; + + warnRuntimeUnavailable( + `\`@sentry/server-utils\` was bundled into your application, so ${moduleName} and any other ` + + 'instrumented dependency load uninstrumented. Keep `@sentry/server-utils` external in your ' + + 'server bundle, or use the Sentry bundler plugin for build-time instrumentation.', + ); +} + /** * Synchronously register the diagnostics-channel injection module hooks. * @@ -36,7 +81,10 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(): void { - if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); + + // Already hooked, or we already ran and found runtime injection unavailable (and warned once). + if (marker.runtime || marker.runtimeUnavailable) { return; } @@ -49,6 +97,12 @@ export function registerDiagnosticsChannelInjection(): void { setDiagnosticsHook(({ moduleName, error }): void => { if (error) { + // A stripped transformer surfaces as a `TypeError` (`parse`/`generate` are `undefined`) and + // costs the user this module's instrumentation, so it is worth an always-on warning. Every + // other transform failure stays debug-only. + if (error instanceof TypeError) { + warnTransformerUnavailable(moduleName); + } debug.warn(`[instrumentation] failed to inject diagnostics-channel into ${moduleName}:`, error); } else { GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; @@ -64,8 +118,7 @@ export function registerDiagnosticsChannelInjection(): void { // runs both at `--import` time and (synchronously) inside `Sentry.init()`, // so an unguarded throw would either abort startup or make `init()` throw. // On any failure (e.g. dep resolution, `require(esm)` / Node-compat - // incompatibility) we warn (DEBUG only) and continue without channel - // injection + // incompatibility) we warn and continue without channel injection. try { if (typeof mod.registerHooks === 'function' && stableSyncHooks) { initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); @@ -102,17 +155,18 @@ export function registerDiagnosticsChannelInjection(): void { new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); debug.log('Registered diagnostics-channel injection via Module.register()'); } else { + marker.runtimeUnavailable = true; debug.warn('No available Node API to register diagnostics-channel injection hooks; skipping.'); return; } } catch (error) { - debug.warn( - 'Failed to register diagnostics-channel injection hooks; channel-based integrations will not record spans.', - error, + marker.runtimeUnavailable = true; + warnRuntimeUnavailable( + 'Failed to register diagnostics-channel injection hooks, so channel-based integrations will not record spans.', ); + debug.warn('Diagnostics-channel injection registration error:', error); return; } - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + marker.runtime = marker.runtime || []; } diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 581d9cf552fa..85ed0e6e11b6 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as barrel from '../../src/index'; +import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config'; import { CHANNEL_INTEGRATION_DEFINITIONS, subscriberExportForModule, @@ -28,17 +30,15 @@ describe('channel integration definitions', () => { expect(subscriberExportForModule('not-a-package')).toBeUndefined(); }); - it('references only real named exports of @sentry/server-utils', async () => { + it('references only real named exports of @sentry/server-utils', () => { // The injected snippet imports each factory from `@sentry/server-utils` // (the `DEFAULT_IMPORT_SPECIFIER`), so the export must exist on that entry. - const barrel = await import('../../src/index'); for (const { exportName } of CHANNEL_INTEGRATION_DEFINITIONS) { expect(typeof (barrel as Record)[exportName]).toBe('function'); } }); - it('covers every instrumented module that has a channel-subscriber integration', async () => { - const { SENTRY_INSTRUMENTATIONS } = await import('../../src/orchestrion/config'); + it('covers every instrumented module that has a channel-subscriber integration', () => { const configured = new Set(SENTRY_INSTRUMENTATIONS.map(c => c.module.name)); const defined = new Set(CHANNEL_INTEGRATION_DEFINITIONS.flatMap(d => d.modules as readonly string[])); diff --git a/packages/server-utils/test/orchestrion/register.test.ts b/packages/server-utils/test/orchestrion/register.test.ts new file mode 100644 index 000000000000..0b44f19dd73c --- /dev/null +++ b/packages/server-utils/test/orchestrion/register.test.ts @@ -0,0 +1,114 @@ +import type * as SentryCore from '@sentry/core'; +import type * as NodeModule from 'node:module'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The registration installs real Node module hooks, which we neither want nor need here. Stub the +// tracing-hooks surface so the tests can drive the diagnostics callback directly, and neuter +// `node:module`'s hook installers: on Node 24.13+/26 the stable-sync-hooks path would otherwise call +// the real `Module.registerHooks({ resolve, load })` with the mocked (undefined-returning) callbacks, +// leaving a broken resolve hook installed process-wide that crashes vitest's next dynamic `import()`. +vi.mock('node:module', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, registerHooks: vi.fn(), register: vi.fn() }; +}); + +const setDiagnosticsHookMock = vi.fn<(cb: DiagnosticsCallback) => void>(); +vi.mock('@apm-js-collab/tracing-hooks/lib/diagnostics.js', () => ({ + setDiagnosticsHook: (cb: DiagnosticsCallback) => setDiagnosticsHookMock(cb), +})); +vi.mock('@apm-js-collab/tracing-hooks', () => ({ + default: class { + patch(): void {} + }, +})); +vi.mock('@apm-js-collab/tracing-hooks/hook-sync.mjs', () => ({ + initialize: vi.fn(), + load: vi.fn(), + resolve: vi.fn(), + createDiagnosticsPort: vi.fn(), +})); + +// Neutralise `consoleSandbox` (it swaps in the pristine console during its callback, which would +// bypass a spy) so we can assert the always-on warning directly. +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, consoleSandbox: (cb: () => unknown) => cb() }; +}); + +import { GLOBAL_OBJ } from '@sentry/core'; +import { registerDiagnosticsChannelInjection } from '../../src/orchestrion/runtime/register'; + +type DiagnosticsCallback = (event: { moduleName: string; error?: unknown }) => void; + +describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', () => { + let warnSpy: ReturnType; + + /** + * The bundling warnings only. Registration itself can warn for unrelated reasons in this + * environment (Node's module-hook APIs are not stubbed), which is not what these tests assert on. + */ + function bundlingWarnings(): string[] { + return warnSpy.mock.calls.map(([message]) => String(message)).filter(m => m.includes('was bundled into')); + } + + /** The callback the registration handed to tracing-hooks. */ + function diagnosticsCallback(): DiagnosticsCallback { + const [callback] = setDiagnosticsHookMock.mock.lastCall ?? []; + if (!callback) { + throw new Error('registerDiagnosticsChannelInjection() did not install a diagnostics hook'); + } + return callback; + } + + beforeEach(async () => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + setDiagnosticsHookMock.mockClear(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // The one-warning-per-process flag is module state, so each test needs a fresh module. + vi.resetModules(); + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + warnSpy.mockRestore(); + }); + + it('warns once when a module fails to transform because the transformer was tree-shaken', () => { + registerDiagnosticsChannelInjection(); + const onDiagnostics = diagnosticsCallback(); + + // A tree-shaken chain: `parse`/`generate` are `undefined`, so the transform throws a TypeError. + onDiagnostics({ moduleName: 'mysql2', error: new TypeError('parse is not a function') }); + onDiagnostics({ moduleName: 'pg', error: new TypeError('parse is not a function') }); + + expect(bundlingWarnings()).toHaveLength(1); + expect(bundlingWarnings()[0]).toContain('mysql2'); + expect(bundlingWarnings()[0]).toContain('docs.sentry.io'); + }); + + it('stays quiet for transform failures that are not a stripped transformer', () => { + registerDiagnosticsChannelInjection(); + + diagnosticsCallback()({ moduleName: 'mysql2', error: new Error('Failed to find injection points') }); + + expect(bundlingWarnings()).toEqual([]); + }); + + it('does not warn when modules transform successfully', () => { + registerDiagnosticsChannelInjection(); + const onDiagnostics = diagnosticsCallback(); + + onDiagnostics({ moduleName: 'mysql2' }); + + expect(bundlingWarnings()).toEqual([]); + // The module is recorded so channel integrations know to subscribe. + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toEqual(['mysql2']); + }); + + it('installs hooks only once', () => { + registerDiagnosticsChannelInjection(); + registerDiagnosticsChannelInjection(); + + expect(setDiagnosticsHookMock).toHaveBeenCalledTimes(1); + }); +});