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 @@ -56,7 +56,7 @@ try {
// ignore
}

const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } })
const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: string[] } })
.__SENTRY_ORCHESTRION__;

setTimeout(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ describe('orchestrion mysql instrumentation (Bun)', () => {
expect(line).toContain('events=start');
// with the expected SQL
expect(line).toContain('statement=SELECT 1 AS solution');
// injected banner ran at bundle boot
expect(line).toContain('"bundler":true');
// the transformed module's injected snippet recorded it on the marker
expect(line).toContain('"bundler":["mysql"]');
} finally {
if (outfile) {
rmSync(dirname(outfile), { recursive: true, force: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ try {
// `start` has already published synchronously by this point.
}

const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } })
const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: string[] } })
.__SENTRY_ORCHESTRION__;

setTimeout(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ describe('orchestrion pg instrumentation (Bun)', () => {
expect(line).toContain('events=start');
// with the expected SQL
expect(line).toContain('statement=SELECT 1 AS solution');
// injected banner ran at bundle boot
expect(line).toContain('"bundler":true');
// the transformed module's injected snippet recorded it on the marker
// (pg-pool may be recorded alongside, in evaluation order)
expect(line).toMatch(/"bundler":\[[^\]]*"pg"/);
} finally {
if (outfile) {
rmSync(dirname(outfile), { recursive: true, force: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ test.describe('Orchestrion build-time injection', () => {

test('injects diagnostics-channel publishers into the server build', () => {
expect(serverBundle).toContain('__SENTRY_ORCHESTRION__');
// Each transformed module carries the module-injected snippet that records it
// on the global marker (and registers its subscriber factory) when evaluated.
expect(serverBundle).toContain('orchestrionModuleInjected');
expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/);
expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/);
expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ test.describe('Orchestrion build-time injection', () => {

test('injects diagnostics-channel publishers into the server build', () => {
expect(serverBundle).toContain('__SENTRY_ORCHESTRION__');
// Each transformed module carries the module-injected snippet that records it
// on the global marker (and registers its subscriber factory) when evaluated.
expect(serverBundle).toContain('orchestrionModuleInjected');
expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/);
expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/);
expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
import { strict as assert } from 'node:assert';
import { tracingChannel } from 'node:diagnostics_channel';
import { orchestrionModuleInjected } from '@sentry/server-utils/orchestrion';

// Reproduces the force-bundled path (vite SSR, nextjs's bundle-safe packages):
// the module is transformed at BUILD time and inlined, so it is never loaded
// through the runtime module hook and its `orchestrion.module-runtime-injected`
// event never fires. Instead the bundler's `injectDiagnostics` boot banner sets
// `.bundler` and calls the on-inject bridge, which must trigger the lazy
// channel subscription. We simulate that banner here, WITHOUT ever importing
// generic-pool.
// through the runtime module hook. Instead, the bundler transform splices a
// snippet into the module that calls `orchestrionModuleInjected` when the
// module is evaluated, which must trigger the lazy channel subscription. We
// simulate that snippet here, WITHOUT ever importing generic-pool.

const channel = tracingChannel('orchestrion:generic-pool:acquire');

// `init()` (in instrument.mjs) installed the bridge and registered the lazy
// listener, but nothing is injected yet, so the channel has no subscriber.
// `init()` (in instrument.mjs) registered the lazy listener, but nothing is
// injected yet, so the channel has no subscriber.
const marker = globalThis.__SENTRY_ORCHESTRION__;
assert.ok(marker, 'expected __SENTRY_ORCHESTRION__ marker to exist after init');
assert.equal(typeof marker.onInject, 'function', 'expected the on-inject bridge to be installed by init()');
assert.equal(
channel.start.hasSubscribers,
false,
'expected NO subscribers before the bundler banner announces the module',
'expected NO subscribers before the injected snippet announces the module',
);

// Simulate the bundler's `injectDiagnostics` boot banner: record the bundled
// module and fire the bridge for it. In a real build this runs when the app
// bundle boots, after `init()`.
marker.bundler = ['generic-pool'];
marker.onInject('generic-pool');
// Simulate the snippet the bundler transform injected into generic-pool: in a
// real build this runs when the bundled module is first evaluated.
orchestrionModuleInjected('generic-pool');

// The bridge re-emitted `orchestrion.module-runtime-injected`, so the
// GenericPool integration must have subscribed, even though generic-pool was
// never loaded through the module hook.
assert.ok(marker.bundler?.includes('generic-pool'), 'expected the module to be recorded as bundler-injected');

// The helper emitted `orchestrion.module-injected`, so the GenericPool
// integration must have subscribed, even though generic-pool was never loaded
// through the module hook.
assert.equal(
channel.start.hasSubscribers,
true,
'expected subscribers after the bundler banner fired the on-inject bridge',
'expected subscribers after the injected snippet announced the module',
);
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ conditionalTest({ min: 20 })('orchestrion lazy channel registration', () => {

// A force-bundled module (vite SSR / nextjs bundle-safe packages) is never
// loaded through the runtime hook, so it can only trigger subscription via
// the bundler's boot banner → on-inject bridge. The scenario simulates that
// banner and asserts the channel subscribes without the module ever being
// loaded through the hook.
test('subscribes for a bundler-announced module via the on-inject bridge', async () => {
// the module-injected snippet the bundler transform splices into it. The
// scenario simulates that snippet and asserts the channel subscribes without
// the module ever being loaded through the hook.
test('subscribes for a bundler-announced module via the module-injected snippet', async () => {
await createRunner(__dirname, 'scenario-bundler.mjs')
.withInstrument(path.join(__dirname, 'instrument.mjs'))
.ensureNoErrorOutput()
Expand Down
17 changes: 4 additions & 13 deletions packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,24 +173,15 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
// Wire up the orchestrion code transform so instrumented server-side dependencies (e.g.
// `mysql`, `ioredis`) get `diagnostics_channel` publishers injected into the SSR bundle at
// build time, with no manual plugin setup. The plugin opts out internally when
// `buildTimeInstrumentation` is `false`.
if (sdkEnabled.server && !isCloudflare) {
// `buildTimeInstrumentation` is `false`. Cloudflare Pages is skipped: it gets no
// `withSentry` wrap, so nothing would read the marker the injected snippets write, and
// keeping the transform off avoids bundling dead subscriber code.
if (sdkEnabled.server && (!isCloudflare || isCloudflareWorkers)) {
updateConfig({
vite: {
plugins: [sentryOrchestrionPlugin({ buildTimeInstrumentation }) as VitePlugin],
},
});
} else if (sdkEnabled.server && isCloudflareWorkers) {
// On Cloudflare Workers, subscribers are wired via a build-time marker the SDK reads at
// runtime (through the `withSentry` wrap added below). Cloudflare Pages is skipped: it gets
// no `withSentry` wrap, so there'd be nothing to read the marker.
updateConfig({
vite: {
plugins: [
sentryOrchestrionPlugin({ buildTimeInstrumentation, injectChannelSubscribers: true }) as VitePlugin,
],
},
});
}

if (isCloudflare) {
Expand Down
15 changes: 6 additions & 9 deletions packages/astro/test/integration/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({

// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in).
// Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant.
const orchestrionVite = vi.fn(
(options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
}),
);
const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
}));
vi.mock('@sentry/server-utils/orchestrion/vite', () => ({
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) =>
orchestrionVite(options),
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options),
}));

// The cloudflare adapter path resolves `@sentry/cloudflare` via `createRequire` and calls
Expand Down Expand Up @@ -451,7 +448,7 @@ describe('sentryAstro integration', () => {
});
});

it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare workers adapter', async () => {
it('adds the orchestrion plugin for the cloudflare workers adapter', async () => {
const integration = sentryAstro({});

const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig;
Expand All @@ -466,7 +463,7 @@ describe('sentryAstro integration', () => {
});

// No wrangler config with `pages_build_output_dir` is present, so this resolves as Workers.
expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true }));
expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined });
expect(updateConfig).toHaveBeenCalledWith({
vite: {
plugins: [{ name: 'sentry-orchestrion-vite' }],
Expand Down
2 changes: 1 addition & 1 deletion packages/bun/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"access": "public"
},
"dependencies": {
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3",
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4",
"@sentry/core": "10.67.0",
"@sentry/conventions": "^0.16.0",
"@sentry/node": "10.67.0",
Expand Down
28 changes: 18 additions & 10 deletions packages/bun/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,12 @@ type UnknownPlugin = any;
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun';
import {
INSTRUMENTED_MODULE_NAMES,
moduleInjectedTransforms,
ORCHESTRION_BUNDLER_MARKER_BANNER,
SENTRY_INSTRUMENTATIONS,
withoutInstrumentedExternals,
} from '@sentry/server-utils/orchestrion/config';

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

// Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead
// of depending on `bun-types`, which would pull Bun's globals.
interface BunPluginBuilder {
Expand All @@ -56,8 +55,11 @@ interface BunPluginBuilder {
* Returns the orchestrion code-transform plugin for Bun's bundler, configured
* 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
* `bun build` bundles them — plus, via the module-injected transform, the
* snippet that records each module on `globalThis.__SENTRY_ORCHESTRION__` when
* it is evaluated — and injects the marker banner so `bundler` is set (to `[]`)
* from boot, which is what gates the SDK's channel-integration setup at
* `init()`.
*
* Pass the result to `Bun.build({ plugins: [...] })`.
*
Expand All @@ -72,19 +74,25 @@ export function sentryBunPlugin(): UnknownPlugin {
// `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`.
// Cast to the Bun-compatible shape so we can forward Bun's builder to its
// `setup`.
const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as {
const transformer = codeTransformer({
instrumentations: SENTRY_INSTRUMENTATIONS,
customTransforms: moduleInjectedTransforms(),
}) as unknown as {
setup: (build: BunPluginBuilder) => void;
};

return {
name: 'sentry-orchestrion',
setup(build: BunPluginBuilder): void {
// Inject a banner so the bundled output sets `bundler: true` at boot.
// `config` is the `Bun.build` config and is present when this plugin
// is passed to `Bun.build({ plugins: [...] })`.
// Inject the marker banner via Bun's native `banner` config (unlike the
// upstream `injectDiagnostics` path, it needs no `outdir`). `config` is
// the `Bun.build` config and is present when this plugin is passed to
// `Bun.build({ plugins: [...] })`.
if (build.config) {
const existing = build.config.banner ?? '';
build.config.banner = existing ? `${existing}\n${BUNDLER_MARKER_BANNER}` : BUNDLER_MARKER_BANNER;
build.config.banner = existing
? `${existing}\n${ORCHESTRION_BUNDLER_MARKER_BANNER}`
: ORCHESTRION_BUNDLER_MARKER_BANNER;

// Force-bundle every instrumented package. An externalized dependency
// is resolved from `node_modules` at runtime and never passes throug
Expand Down
19 changes: 17 additions & 2 deletions packages/cloudflare/src/baseSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { defaultStackParser } from './vendor/stacktrace';
/**
* Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite`
* plugin registered on the global marker. The plugin splices a small snippet
* into each instrumented module that `.set`s its factory here (keyed by export
* into each instrumented module that `.set`s its factory here (keyed by module
* name), so the marker holds one factory per package actually bundled.
*
* The marker is read directly instead of importing the factories, so a worker
Expand Down Expand Up @@ -116,7 +116,22 @@ export function initWithDefaultIntegrations(
setupOpenTelemetryTracer();
}

return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
const client = initAndBind(CloudflareClient, clientOptions) as CloudflareClient;

// An instrumented module that first evaluates AFTER this init (e.g. a driver
// lazily required on first use) stores its subscriber factory on the global
// marker too late for the default-integrations snapshot above. Its injected
// snippet emits this event right after storing the factory, so install the
// integration on the live client here. `addIntegration` dedupes by
// integration name, so already-installed integrations are no-ops.
client.on('orchestrion.module-injected', moduleName => {
const factory = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName);
if (factory) {
client.addIntegration(factory());
}
});

return client;
}

/**
Expand Down
1 change: 0 additions & 1 deletion packages/cloudflare/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export interface SentryCloudflareVitePluginOptions {
export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}): Array<{ name: string }> {
return [
sentryOrchestrionPlugin({
injectChannelSubscribers: true,
buildTimeInstrumentation: options.buildTimeInstrumentation,
}),
...(options._experimental?.autoInstrumentation
Expand Down
29 changes: 23 additions & 6 deletions packages/cloudflare/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ describe('getDefaultIntegrations', () => {
});

test('does not add orchestrion channel integrations when only the bundler marker is set', () => {
globalThis.__SENTRY_ORCHESTRION__ = { bundler: true };
// The plugin's entry banner ran, but no instrumented module has loaded yet.
globalThis.__SENTRY_ORCHESTRION__ = { bundler: [] };

const names = getDefaultIntegrations({}).map(i => i.name);

Expand All @@ -88,15 +89,15 @@ describe('getDefaultIntegrations', () => {
test('adds orchestrion channel integrations registered on the marker by injected modules', async () => {
// Mirror what the snippet the vite plugin injects into each instrumented
// module does at runtime: import its factory and `.set` it on the marker map,
// keyed by export name (so a package split across files registers once).
// keyed by module name (so a package split across files registers once).
const { mysqlIntegration, postgresIntegration, lruMemoizerIntegration } =
await import('@sentry/server-utils/orchestrion');
globalThis.__SENTRY_ORCHESTRION__ = {
bundler: true,
bundler: ['mysql', 'pg', 'lru-memoizer'],
integrations: new Map([
['mysqlIntegration', mysqlIntegration],
['postgresIntegration', postgresIntegration],
['lruMemoizerIntegration', lruMemoizerIntegration],
['mysql', mysqlIntegration],
['pg', postgresIntegration],
['lru-memoizer', lruMemoizerIntegration],
]),
};

Expand All @@ -106,4 +107,20 @@ describe('getDefaultIntegrations', () => {
expect(names).toContain('Postgres');
expect(names).toContain('LruMemoizer');
});

test('installs an integration registered after init via the module-injected event', async () => {
const { mysqlIntegration } = await import('@sentry/server-utils/orchestrion');
const client = init({});
expect(client?.getIntegrationByName('Mysql')).toBeUndefined();

// Mirror `orchestrionModuleInjected` for a driver that first evaluates
// after init: store the factory on the marker, then emit the event.
globalThis.__SENTRY_ORCHESTRION__ = {
bundler: ['mysql'],
integrations: new Map([['mysql', mysqlIntegration]]),
};
client?.emit('orchestrion.module-injected', 'mysql');

expect(client?.getIntegrationByName('Mysql')).toBeDefined();
});
});
Loading
Loading