Skip to content

Commit 623d0c5

Browse files
committed
feat(bun): Add orchestrion bun build plugin (#21410)
Use the orchestrion plugin defined in server-utils, and create a plugin that Bun can use in `bun build` mode. Note: this does *not* provide a plugin for use with `bun run`, because that feature is blocked by oven-sh/bun#31770 When that issue resolves, we can look into providing this for the bun runtime, likely with a version guard to avoid the footgun of removing CommonJS exports in some cases.
1 parent 92b508d commit 623d0c5

10 files changed

Lines changed: 300 additions & 45 deletions

File tree

dev-packages/bun-integration-tests/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"dependencies": {
1616
"@sentry/bun": "10.58.0",
1717
"@sentry/hono": "10.58.0",
18-
"hono": "^4.12.25"
18+
"hono": "^4.12.25",
19+
"mysql": "^2.18.1"
1920
},
2021
"devDependencies": {
2122
"@sentry-internal/test-utils": "10.58.0",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Builds the smoke scenario with the orchestrion `bun build` plugin and writes
2+
// the bundle to a temp dir, printing the output path for test.ts to execute.
3+
//
4+
// A successful build proves `bun build` runs with the plugin; running the bundle
5+
// (see test.ts) then proves the bundled `mysql` is actually instrumented.
6+
7+
// @ts-ignore -- subpath export resolved by Bun at runtime; the package
8+
// tsconfig's node module resolution can't see `exports` subpaths.
9+
import { sentryBunPlugin } from '@sentry/bun/plugin';
10+
import { tmpdir } from 'os';
11+
import { join } from 'path';
12+
13+
void (async () => {
14+
const outdir = join(tmpdir(), `sentry-bun-orchestrion-${process.pid}-${Date.now()}`);
15+
const result = await Bun.build({
16+
entrypoints: [join(__dirname, 'scenario.ts')],
17+
target: 'bun',
18+
outdir,
19+
plugins: [sentryBunPlugin()],
20+
});
21+
22+
if (!result.success) {
23+
// eslint-disable-next-line no-console
24+
console.error('BUILD_FAILED', result.logs);
25+
process.exit(1);
26+
}
27+
28+
const output = result.outputs[0];
29+
if (!output) {
30+
// eslint-disable-next-line no-console
31+
console.error('BUILD_FAILED no outputs');
32+
process.exit(1);
33+
}
34+
35+
// eslint-disable-next-line no-console
36+
console.log(`BUILD_OK outfile=${output.path}`);
37+
})();
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Bundled entry for the `bun build` smoke test.
2+
//
3+
// Once `Bun.build` (with the orchestrion plugin) has transformed `mysql`,
4+
// calling `connection.query()` publishes to the `orchestrion:mysql:query`
5+
// tracing channel.
6+
//
7+
// `start` fires synchronously on the call, so no live database is needed.
8+
//
9+
// We subscribe, run a query, and report which channel events fired
10+
// (plus the detection marker the plugin's banner sets at boot).
11+
12+
import { tracingChannel } from 'node:diagnostics_channel';
13+
14+
// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime.
15+
import mysql from 'mysql';
16+
17+
interface QueryContext {
18+
arguments?: unknown[];
19+
}
20+
interface Connection {
21+
query(sql: string, cb: () => void): void;
22+
destroy(): void;
23+
}
24+
interface MysqlModule {
25+
createConnection(opts: { host: string; user: string }): Connection;
26+
}
27+
28+
const events: string[] = [];
29+
let statement = '';
30+
31+
tracingChannel('orchestrion:mysql:query').subscribe({
32+
start(message: unknown) {
33+
events.push('start');
34+
const first = (message as QueryContext).arguments?.[0];
35+
statement = typeof first === 'string' ? first : '';
36+
},
37+
end() {
38+
events.push('end');
39+
},
40+
asyncStart() {},
41+
asyncEnd() {
42+
events.push('asyncEnd');
43+
},
44+
error() {},
45+
});
46+
47+
const conn = (mysql as MysqlModule).createConnection({ host: '127.0.0.1', user: 'root' });
48+
try {
49+
conn.query('SELECT 1 AS solution', () => {});
50+
} catch {
51+
// No live server — `start` has already published synchronously by this point.
52+
}
53+
try {
54+
conn.destroy();
55+
} catch {
56+
// ignore
57+
}
58+
59+
const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } })
60+
.__SENTRY_ORCHESTRION__;
61+
62+
setTimeout(() => {
63+
// eslint-disable-next-line no-console
64+
console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(marker ?? null)}`);
65+
process.exit(0);
66+
}, 200);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { spawnSync } from 'child_process';
2+
import { rmSync } from 'fs';
3+
import { dirname, join } from 'path';
4+
import { describe, expect, it } from 'vitest';
5+
6+
const dir = __dirname;
7+
8+
// Cap each `bun` subprocess. The test runs two of them sequentially, so its own
9+
// timeout (see the `it(...)` below) must exceed `2 * SUBPROCESS_TIMEOUT_MS` —
10+
// otherwise the suite's default `testTimeout` (20s) fails the test before these
11+
// caps do, e.g. on a slow CI runner where the build+run legitimately takes >20s.
12+
const SUBPROCESS_TIMEOUT_MS = 60_000;
13+
14+
function runBun(args: string[]): { stdout: string; stderr: string; status: number | null } {
15+
const res = spawnSync('bun', args, { cwd: dir, encoding: 'utf8', timeout: SUBPROCESS_TIMEOUT_MS });
16+
return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', status: res.status };
17+
}
18+
19+
// Bun orchestrion instrumentation is BUILD-ONLY (`@sentry/bun/plugin` is a
20+
// `Bun.build` plugin; there is no `bun run` preload).
21+
//
22+
// A `bun run` runtime plugin cannot instrument CommonJS dependencies like
23+
// `mysql`: any module returned by a runtime `onLoad` plugin in Bun loses its
24+
// CommonJS named exports
25+
//
26+
// When https://github.com/oven-sh/bun/pull/31770 lands, we can revisit an
27+
// auto-load plugin for `bun run`.
28+
describe('orchestrion mysql instrumentation (Bun)', () => {
29+
it(
30+
'bundles `mysql` with the plugin, and the built output fires the mysql channel when run',
31+
() => {
32+
// Build the scenario with the orchestrion `bun build` plugin.
33+
const build = runBun(['run', join(dir, 'build.ts')]);
34+
expect(build.status, `build failed:\nstderr:\n${build.stderr}\nstdout:\n${build.stdout}`).toBe(0);
35+
36+
const outfile = build.stdout.match(/BUILD_OK outfile=(.+)/)?.[1]?.trim();
37+
expect(outfile, `no outfile in build output:\n${build.stdout}`).toBeTruthy();
38+
39+
try {
40+
// Run the built bundle. The bundled (transformed) `mysql` should publish
41+
// to the `orchestrion:mysql:query` channel when `connection.query()` is
42+
// called, and the plugin's banner should set the `bundler` marker at boot.
43+
const run = runBun(['run', outfile as string]);
44+
expect(run.status, `run failed:\nstderr:\n${run.stderr}\nstdout:\n${run.stdout}`).toBe(0);
45+
46+
const line = run.stdout.split('\n').find(l => l.startsWith('SCENARIO')) ?? '';
47+
// channel `start` fired on `connection.query()`
48+
expect(line).toContain('events=start');
49+
// with the expected SQL
50+
expect(line).toContain('statement=SELECT 1 AS solution');
51+
// injected banner ran at bundle boot
52+
expect(line).toContain('"bundler":true');
53+
} finally {
54+
if (outfile) {
55+
rmSync(dirname(outfile), { recursive: true, force: true });
56+
}
57+
}
58+
// Allow for both sequential `runBun` calls hitting their subprocess cap, so
59+
// the `spawnSync` timeouts — not Vitest's 20s default — are the binding limit.
60+
},
61+
2 * SUBPROCESS_TIMEOUT_MS,
62+
);
63+
});

packages/bun/package.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@
2626
"types": "./build/types/index.d.ts",
2727
"default": "./build/cjs/index.js"
2828
}
29+
},
30+
"./plugin": {
31+
"import": {
32+
"types": "./build/types/plugin.d.ts",
33+
"default": "./build/esm/plugin.js"
34+
},
35+
"require": {
36+
"types": "./build/types/plugin.d.ts",
37+
"default": "./build/cjs/plugin.js"
38+
}
2939
}
3040
},
3141
"typesVersions": {
@@ -39,8 +49,10 @@
3949
"access": "public"
4050
},
4151
"dependencies": {
52+
"@apm-js-collab/code-transformer-bundler-plugins": "^0.3.0",
4253
"@sentry/core": "10.58.0",
43-
"@sentry/node": "10.58.0"
54+
"@sentry/node": "10.58.0",
55+
"@sentry/server-utils": "10.58.0"
4456
},
4557
"devDependencies": {
4658
"bun-types": "^1.2.9"

packages/bun/rollup.npm.config.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
22

3-
export default makeNPMConfigVariants(makeBaseNPMConfig());
3+
export default makeNPMConfigVariants(
4+
makeBaseNPMConfig({
5+
// `src/plugin.ts` backs the `@sentry/bun/plugin` subpath (the orchestrion
6+
// `bun build` plugin). It isn't reachable from `src/index.ts`, so we list it
7+
// as a separate entrypoint to get both ESM and CJS builds.
8+
entrypoints: ['src/index.ts', 'src/plugin.ts'],
9+
}),
10+
);

packages/bun/src/plugin.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* orchestrion code-transform plugin for Bun's bundler (`bun build`).
3+
*
4+
* Usage:
5+
*
6+
* ```ts
7+
* import { sentryBunPlugin } from '@sentry/bun/plugin';
8+
* await Bun.build({
9+
* entrypoints: ['./app.ts'],
10+
* plugins: [sentryBunPlugin()],
11+
* });
12+
* ```
13+
*
14+
* This is BUILD-ONLY. Runtime instrumentation (`bun run`) is intentionally not
15+
* offered: a module returned by a runtime `onLoad` plugin in Bun loses its
16+
* CommonJS named exports.
17+
*
18+
* When https://github.com/oven-sh/bun/pull/31770 lands, we can revisit.
19+
*
20+
* Until then, Bun apps must bundle to get orchestrion instrumentation. In dev
21+
* (ie, `bun run`) there is simply no instrumentation, which is clearer than
22+
* partial/inconsistent coverage.
23+
*
24+
* Shipped as both ESM and CJS (via the `@sentry/bun/plugin` subpath) so a user's
25+
* `bun build` script can be authored in either module system. It's a plain
26+
* library import here (not a `--import`/`--preload` hook), so CJS is fine; Bun
27+
* resolves the underlying ESM-only transformer in either module system.
28+
*
29+
* @module
30+
*/
31+
32+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33+
type UnknownPlugin = any;
34+
35+
// `@apm-js-collab/code-transformer-bundler-plugins/bun` is published ESM-only
36+
// (no `require` arm, unlike its `/vite` entry). The ESM build imports it; the
37+
// CJS build requires it. Bun resolves correctly for ESM modules in either
38+
// module system.
39+
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun';
40+
import { SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config';
41+
42+
const BUNDLER_MARKER_BANNER =
43+
';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;';
44+
45+
// Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead
46+
// of depending on `bun-types`, which would pull Bun's globals.
47+
interface BunPluginBuilder {
48+
config?: { banner?: string };
49+
}
50+
51+
/**
52+
* Returns the orchestrion code-transform plugin for Bun's bundler, configured
53+
* with the central `SENTRY_INSTRUMENTATIONS`. The plugin injects
54+
* `diagnostics_channel.tracingChannel` calls into the instrumented libraries as
55+
* `bun build` bundles them, and injects a banner that sets
56+
* `globalThis.__SENTRY_ORCHESTRION__.bundler = true` when the bundle boots
57+
*
58+
* Pass the result to `Bun.build({ plugins: [...] })`.
59+
*
60+
* @example
61+
* ```ts
62+
* import { sentryBunPlugin } from '@sentry/bun/plugin';
63+
* await Bun.build({ entrypoints: ['./app.ts'], plugins: [sentryBunPlugin()] });
64+
* ```
65+
*/
66+
export function sentryBunPlugin(): UnknownPlugin {
67+
// Typed upstream as an esbuild `Plugin`, but Bun passes its own
68+
// `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`.
69+
// Cast to the Bun-compatible shape so we can forward Bun's builder to its
70+
// `setup`.
71+
const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as {
72+
setup: (build: BunPluginBuilder) => void;
73+
};
74+
75+
return {
76+
name: 'sentry-orchestrion',
77+
setup(build: BunPluginBuilder): void {
78+
// Inject a banner so the bundled output sets `bundler: true` at boot.
79+
// `config` is the `Bun.build` config and is present when this plugin
80+
// is passed to `Bun.build({ plugins: [...] })`.
81+
if (build.config) {
82+
const existing = build.config.banner ?? '';
83+
build.config.banner = existing ? `${existing}\n${BUNDLER_MARKER_BANNER}` : BUNDLER_MARKER_BANNER;
84+
}
85+
86+
// Delegate to the upstream code-transformer, which registers the `onLoad`
87+
// hook that does the actual channel injection.
88+
transformer.setup(build);
89+
},
90+
};
91+
}

packages/bun/tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
"compilerOptions": {
77
// package-specific options
8-
"types": ["bun-types"]
8+
"types": ["bun-types"],
9+
"module": "nodenext",
10+
"moduleResolution": "nodenext"
911
}
1012
}

packages/server-utils/package.json

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,55 +18,31 @@
1818
"exports": {
1919
"./package.json": "./package.json",
2020
".": {
21-
"import": {
22-
"types": "./build/types/index.d.ts",
23-
"default": "./build/esm/index.js"
24-
},
25-
"require": {
26-
"types": "./build/types/index.d.ts",
27-
"default": "./build/cjs/index.js"
28-
}
21+
"types": "./build/types/index.d.ts",
22+
"import": "./build/esm/index.js",
23+
"require": "./build/cjs/index.js"
2924
},
3025
"./orchestrion": {
31-
"import": {
32-
"types": "./build/types/orchestrion/index.d.ts",
33-
"default": "./build/esm/orchestrion/index.js"
34-
},
35-
"require": {
36-
"types": "./build/types/orchestrion/index.d.ts",
37-
"default": "./build/cjs/orchestrion/index.js"
38-
}
26+
"types": "./build/types/orchestrion/index.d.ts",
27+
"import": "./build/esm/orchestrion/index.js",
28+
"require": "./build/cjs/orchestrion/index.js"
3929
},
4030
"./orchestrion/config": {
41-
"import": {
42-
"types": "./build/types/orchestrion/config.d.ts",
43-
"default": "./build/esm/orchestrion/config.js"
44-
},
45-
"require": {
46-
"types": "./build/types/orchestrion/config.d.ts",
47-
"default": "./build/cjs/orchestrion/config.js"
48-
}
31+
"types": "./build/types/orchestrion/config.d.ts",
32+
"import": "./build/esm/orchestrion/config.js",
33+
"require": "./build/cjs/orchestrion/config.js"
4934
},
5035
"./orchestrion/register": {
51-
"import": {
52-
"types": "./build/types/orchestrion/runtime/register.d.ts",
53-
"default": "./build/esm/orchestrion/runtime/register.js"
54-
},
55-
"require": {
56-
"types": "./build/types/orchestrion/runtime/register.d.ts",
57-
"default": "./build/cjs/orchestrion/runtime/register.js"
58-
}
36+
"types": "./build/types/orchestrion/runtime/register.d.ts",
37+
"import": "./build/esm/orchestrion/runtime/register.js",
38+
"require": "./build/cjs/orchestrion/runtime/register.js"
5939
},
6040
"./orchestrion/vite": {
6141
"types": "./build/types/orchestrion/bundler/vite.d.ts",
62-
"import": {
63-
"default": "./build/esm/orchestrion/bundler/vite.js"
64-
}
42+
"import": "./build/esm/orchestrion/bundler/vite.js"
6543
},
6644
"./orchestrion/import-hook": {
67-
"import": {
68-
"default": "./build/orchestrion/import-hook.mjs"
69-
}
45+
"import": "./build/orchestrion/import-hook.mjs"
7046
}
7147
},
7248
"typesVersions": {

0 commit comments

Comments
 (0)