Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('orchestrion mysql instrumentation (Bun)', () => {
// with the expected SQL
expect(line).toContain('statement=SELECT 1 AS solution');
// injected banner ran at bundle boot
expect(line).toContain('"bundler":true');
expect(line).toContain('"bundler":[]');
} finally {
if (outfile) {
rmSync(dirname(outfile), { recursive: true, force: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('orchestrion pg instrumentation (Bun)', () => {
// with the expected SQL
expect(line).toContain('statement=SELECT 1 AS solution');
// injected banner ran at bundle boot
expect(line).toContain('"bundler":true');
expect(line).toContain('"bundler":[]');
} finally {
if (outfile) {
rmSync(dirname(outfile), { recursive: true, force: true });
Expand Down
4 changes: 2 additions & 2 deletions packages/bun/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
} from '@sentry/server-utils/orchestrion/config';

const BUNDLER_MARKER_BANNER =
';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;';
';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=[];';

// Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead
// of depending on `bun-types`, which would pull Bun's globals.
Expand All @@ -57,7 +57,7 @@ interface BunPluginBuilder {
* with the central `SENTRY_INSTRUMENTATIONS`. The plugin injects
* `diagnostics_channel.tracingChannel` calls into the instrumented libraries as
* `bun build` bundles them, and injects a banner that sets
* `globalThis.__SENTRY_ORCHESTRION__.bundler = true` when the bundle boots
* `globalThis.__SENTRY_ORCHESTRION__.bundler = []` when the bundle boots
*
* Pass the result to `Bun.build({ plugins: [...] })`.
*
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public on(hook: 'stopUIProfiler', callback: () => void): () => void;

/**
* A hook that is called when the orchestrion runtime hook injects diagnostics
* channels into a module as it is loaded.
*
* The callback receives the name of the instrumented module.
*
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void;

/**
* Register a hook on this client.
*/
Expand Down Expand Up @@ -1188,6 +1198,12 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public emit(hook: 'stopUIProfiler'): void;

/**
* Emit a hook event when the orchestrion runtime hook injects diagnostics
* channels into a module as it is loaded.
*/
public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void;

/**
* Emit a hook that was previously registered via `on()`.
*/
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export type InternalGlobal = {
/** Empty array signifies bundler plugin ran */
bundler?: string[];
};
/**
* Bridge set by the SDK during `init` so that build-time (bundler) injected modules can announce
* themselves the moment they load. Bundlers that transform modules but provide no runtime boot hook
* (e.g. Turbopack, which only takes loaders) prepend a prologue to each instrumented module that
* calls this with the module name; the bridge emits `orchestrion.module-runtime-injected` so channel
* subscribers get wired up before the module publishes. Mirrors the runtime module hook's own event.
*/
__SENTRY_ORCHESTRION_ON_INJECT__?: (moduleName: string) => void;
} & Carrier;

/** Get's the global object for the current JavaScript runtime */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { LoaderThis } from './types';

export type ValueInjectionLoaderOptions = {
values: Record<string, unknown>;
// Raw JS prepended before the value assignments. Unlike `values` (plain `globalThis[key] = ...`
// assignments), this is emitted verbatim, so it can run merge-safe logic that a plain assignment
// can't express (e.g. seeding a global without clobbering an existing one).
prefixCode?: string;
};

/**
Expand Down Expand Up @@ -154,7 +158,7 @@ function findDirectiveTerminator(userCode: string, startIndex: number): number |
*/
export default function valueInjectionLoader(this: LoaderThis<ValueInjectionLoaderOptions>, userCode: string): string {
// We know one or the other will be defined, depending on the version of webpack being used
const { values } = 'getOptions' in this ? this.getOptions() : this.query;
const { values, prefixCode } = 'getOptions' in this ? this.getOptions() : this.query;

// We do not want to cache injected values across builds
this.cacheable(false);
Expand All @@ -163,6 +167,7 @@ export default function valueInjectionLoader(this: LoaderThis<ValueInjectionLoad
const injectedCode =
// eslint-disable-next-line prefer-template
';' +
(prefixCode ?? '') +
Object.entries(values)
.map(([key, value]) => `globalThis["${key}"] = ${JSON.stringify(value)};`)
.join('');
Expand Down
46 changes: 22 additions & 24 deletions packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { debug } from '@sentry/core';
import * as path from 'path';
import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack';
import { getSentryOrchestrionLoaderPath } from '@sentry/server-utils/orchestrion/webpack';
import type { VercelCronsConfig } from '../../common/types';
import type { RouteManifest } from '../manifest/types';
import type {
JSONValue,
NextConfigObject,
SentryBuildOptions,
TurbopackMatcherWithRule,
TurbopackOptions,
} from '../types';
import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types';
import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util';
import { generateValueInjectionRules } from './generateValueInjectionRules';

Expand All @@ -36,6 +30,15 @@ export function constructTurbopackConfig({
nextJsVersion?: string;
vercelCronsConfig?: VercelCronsConfig;
}): TurbopackOptions {
// The orchestrion code-transform loader only runs on Next.js versions that support Turbopack rule
// conditions. Gate the boot-time marker on the same condition so `isOrchestrionInjected()` can't
// report `true` when no module was actually transformed (which would make integrations subscribe to
// diagnostics channels that never publish).
const orchestrionInjectionActive =
!!userSentryOptions?._experimental?.useDiagnosticsChannelInjection &&
!!nextJsVersion &&
supportsTurbopackRuleCondition(nextJsVersion);

// If sourcemaps are disabled, we don't need to enable native debug ids as this will add build time.
const shouldEnableNativeDebugIds =
(supportsNativeDebugIds(nextJsVersion ?? '') && userNextConfig?.turbopack?.debugIds) ??
Expand All @@ -58,6 +61,10 @@ export function constructTurbopackConfig({
nextJsVersion,
tunnelPath,
vercelCronsConfig,
// Mark the bundler injection path as active at boot (before `Sentry.init()`), so
// `isOrchestrionInjected()` is reliable even without the runtime hook. Individual modules add
// themselves to this list as they load; see the orchestrion loader's injected prologue.
injectOrchestrionBundlerMarker: orchestrionInjectionActive,
});

for (const { matcher, rule } of valueInjectionRules) {
Expand Down Expand Up @@ -113,38 +120,29 @@ export function constructTurbopackConfig({
});
}

newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, userSentryOptions, nextJsVersion);
newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, orchestrionInjectionActive);

return newConfig;
}

/**
* Adds the orchestrion code-transform loader rule when diagnostics-channel injection is enabled.
* Adds the orchestrion code-transform loader rule when diagnostics-channel injection is active.
*/
function maybeAddOrchestrionRule(
rules: TurbopackOptions['rules'],
userSentryOptions: SentryBuildOptions | undefined,
nextJsVersion: string | undefined,
orchestrionInjectionActive: boolean,
): TurbopackOptions['rules'] {
if (
!userSentryOptions?._experimental?.useDiagnosticsChannelInjection ||
!nextJsVersion ||
!supportsTurbopackRuleCondition(nextJsVersion)
) {
if (!orchestrionInjectionActive) {
return rules;
}

return safelyAddTurbopackRule(rules, {
matcher: '*.{js,mjs,cjs}',
rule: {
condition: 'node',
loaders: [
{
loader: getOrchestrionLoaderPath(),
// `instrumentations` is JSON-serializable
options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] },
},
],
// The Sentry loader reads the instrumentation config itself and appends a self-registration
// prologue to each transformed module, so it needs no `options` — use the string shortcut.
loaders: [getSentryOrchestrionLoaderPath()],
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import type { RouteManifest } from '../manifest/types';
import type { JSONValue, TurbopackMatcherWithRule } from '../types';
import { getPackageModules, supportsTurbopackRuleCondition } from '../util';

// Marks the bundler (build-time) orchestrion injection as active at boot, without clobbering any
// marker an earlier injector already set. It only creates `__SENTRY_ORCHESTRION__`/`.bundler` if
// absent, so in a hybrid setup the runtime hook's `runtime` list (and any module already recorded by
// the per-module loader prologue) survives. Mirrors the merge-safe pattern of the Bun banner and
// `buildInjectPrologue`. Emitted as a single line so it never shifts the module's source-map mappings.
const ORCHESTRION_BUNDLER_MARKER =
';(function(){try{var g=(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{});g.bundler=g.bundler||[];}catch(e){}})();';

/**
* Generate the value injection rules for client and server in turbopack config.
*/
Expand All @@ -12,11 +20,13 @@ export function generateValueInjectionRules({
nextJsVersion,
tunnelPath,
vercelCronsConfig,
injectOrchestrionBundlerMarker,
}: {
routeManifest?: RouteManifest;
nextJsVersion?: string;
tunnelPath?: string;
vercelCronsConfig?: VercelCronsConfig;
injectOrchestrionBundlerMarker?: boolean;
}): TurbopackMatcherWithRule[] {
const rules: TurbopackMatcherWithRule[] = [];
const isomorphicValues: Record<string, JSONValue> = {};
Expand Down Expand Up @@ -85,6 +95,11 @@ export function generateValueInjectionRules({
loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'),
options: {
values: serverValues,
// Runs at the top of the server `instrumentation` file — before `Sentry.init()` — so
// `isOrchestrionInjected()` is reliable for bundler-only setups too. Turbopack has no
// plugin/boot hook to emit the full transformed-module list the way the webpack plugin
// does; each transformed module appends itself as it loads (see the orchestrion loader).
...(injectOrchestrionBundlerMarker ? { prefixCode: ORCHESTRION_BUNDLER_MARKER } : {}),
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1574,4 +1574,64 @@ describe('safelyAddTurbopackRule', () => {
});
});
});

describe('orchestrion diagnostics-channel injection', () => {
it('does not add the orchestrion loader rule by default', () => {
const result = constructTurbopackConfig({
userNextConfig: {},
userSentryOptions: {},
nextJsVersion: '16.0.0',
});

expect(result.rules?.['*.{js,mjs,cjs}']).toBeUndefined();
});

it('adds the Sentry orchestrion loader rule (node server only) when injection is enabled', () => {
const result = constructTurbopackConfig({
userNextConfig: {},
userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
nextJsVersion: '16.0.0',
});

const rule = result.rules?.['*.{js,mjs,cjs}'] as { condition?: unknown; loaders?: unknown[] } | undefined;
expect(rule?.condition).toBe('node');
// A single loader referenced by path (string shortcut, no options).
expect(rule?.loaders).toHaveLength(1);
expect(typeof rule?.loaders?.[0]).toBe('string');
});

const getServerPrefixCode = (result: ReturnType<typeof constructTurbopackConfig>): string | undefined => {
const serverRule = result.rules?.['**/instrumentation.*'] as
| { loaders?: [{ options?: { prefixCode?: string } }] }
| undefined;
return serverRule?.loaders?.[0]?.options?.prefixCode;
};

it('seeds the bundler marker when the loader rule is actually added', () => {
const result = constructTurbopackConfig({
userNextConfig: {},
userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
nextJsVersion: '16.0.0',
});

expect(result.rules?.['*.{js,mjs,cjs}']).toBeDefined();
expect(getServerPrefixCode(result)).toContain('__SENTRY_ORCHESTRION__');
});

it.each([
['an unsupported Next.js version', '15.0.0'],
['an unknown Next.js version', undefined],
])('does not seed the bundler marker with %s (loader rule cannot run)', (_label, nextJsVersion) => {
const result = constructTurbopackConfig({
userNextConfig: {},
userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
nextJsVersion,
});

// The code-transform loader rule is not added, so `isOrchestrionInjected()` must not be
// seeded either - otherwise integrations subscribe to channels that never publish.
expect(result.rules?.['*.{js,mjs,cjs}']).toBeUndefined();
expect(getServerPrefixCode(result)).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@ describe('generateValueInjectionRules', () => {
});
});

describe('orchestrion bundler marker', () => {
function getServerLoaderOptions(result: ReturnType<typeof generateValueInjectionRules>): {
values: Record<string, unknown>;
prefixCode?: string;
} {
const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*');
return (serverRule?.rule as { loaders: [{ options: { values: Record<string, unknown>; prefixCode?: string } }] })
.loaders[0].options;
}

it('does not inject the orchestrion marker by default', () => {
const options = getServerLoaderOptions(generateValueInjectionRules({}));

expect(options.values).not.toHaveProperty('__SENTRY_ORCHESTRION__');
expect(options).not.toHaveProperty('prefixCode');
});

it('seeds the bundler marker merge-safely at boot when injectOrchestrionBundlerMarker is set', () => {
const options = getServerLoaderOptions(generateValueInjectionRules({ injectOrchestrionBundlerMarker: true }));

// The marker must not be a plain assignment (which would clobber an existing marker), so it is
// never emitted as a `values` entry.
expect(options.values).not.toHaveProperty('__SENTRY_ORCHESTRION__');
// It only creates the global if absent, preserving a runtime injector's `runtime` list.
expect(options.prefixCode).toContain('globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}');
expect(options.prefixCode).toContain('g.bundler=g.bundler||[]');
});
});

describe('with nextJsVersion only', () => {
it('should generate client and server rules when nextJsVersion is provided', () => {
const result = generateValueInjectionRules({
Expand Down
34 changes: 34 additions & 0 deletions packages/nextjs/test/config/valueInjectionLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,40 @@ describe.each([[clientConfigLoaderThis], [instrumentationLoaderThis]])('valueInj
});
});

describe('valueInjectionLoader prefixCode', () => {
const prefixLoaderThis = {
...defaultLoaderThis,
resourcePath: './instrumentation.js',
getOptions() {
return {
values: { foo: 'bar' },
prefixCode: ';globalThis.__MARKER__ = globalThis.__MARKER__ || {};',
};
},
} satisfies LoaderThis<ValueInjectionLoaderOptions>;

it('emits prefixCode verbatim before the value assignments', () => {
const userCode = "import * as Sentry from '@sentry/nextjs';\nSentry.init();";

const result = valueInjectionLoader.call(prefixLoaderThis, userCode);

const prefixIndex = result.indexOf('globalThis.__MARKER__ = globalThis.__MARKER__ || {};');
const valueIndex = result.indexOf('globalThis["foo"] = "bar";');

expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(valueIndex).toBeGreaterThan(prefixIndex);
});

it('does not emit any prefix when prefixCode is omitted', () => {
const userCode = "import * as Sentry from '@sentry/nextjs';\nSentry.init();";

const result = valueInjectionLoader.call(clientConfigLoaderThis, userCode);

expect(result).toContain(';globalThis["foo"] = "bar";');
expect(result).not.toContain('__MARKER__');
});
});

describe('findInjectionIndexAfterDirectives', () => {
it('returns the position immediately after the last directive', () => {
const userCode = '"use strict";\n"use client";\nimport React from \'react\';';
Expand Down
Loading