Skip to content

Commit f03fae8

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Auto-instrument the worker entry with withSentry
Add `sentryCloudflareAutoInstrumentPlugin` and fold it into `@sentry/cloudflare/vite`, so a worker needs no manual `Sentry.withSentry` wrapping. Using the wrangler config and options module from the previous commit, the plugin rewrites the worker entry's default export to `withSentry(<options>, <handler>)` — matched by wrangler's `main`, so it applies in both `vite build` and `vite dev`. - Add `magic-string` as a dependency; the transform uses it to rewrite the worker entry source while preserving source maps. - Add a `vite-autoinstrument/default-export` integration suite: a plain unwrapped worker whose default export is wrapped at build time via the runner's Vite path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 61ecd75 commit f03fae8

13 files changed

Lines changed: 575 additions & 55 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
interface Env {
2+
SENTRY_DSN: string;
3+
}
4+
5+
// A plain, unwrapped worker — no manual `Sentry.withSentry`. The
6+
// `@sentry/cloudflare/vite` plugin's auto-instrumentation wraps the default
7+
// export with `withSentry` at build time, sourcing options from
8+
// `instrument.server.ts`.
9+
export default {
10+
async fetch(request: Request): Promise<Response> {
11+
const url = new URL(request.url);
12+
13+
if (url.pathname === '/hello') {
14+
return Response.json({ status: 'ok' });
15+
}
16+
17+
return new Response('Not found', { status: 404 });
18+
},
19+
} satisfies ExportedHandler<Env>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// The worker entry is a plain, unwrapped `export default {...}`. The runner
6+
// detects `vite.config.mts`, runs `vite build`, and serves the generated output
7+
// — so this transaction only arrives if the build-time transform wrapped the
8+
// default export with `withSentry`.
9+
it('auto-instruments a plain default-export handler', async ({ signal }) => {
10+
const runner = createRunner(__dirname)
11+
.expect(envelope => {
12+
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
13+
expect(transactionEvent.transaction).toBe('GET /hello');
14+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
15+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
16+
})
17+
.start(signal);
18+
19+
await runner.makeRequest('get', '/hello');
20+
await runner.completed();
21+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
// The Sentry plugin runs first so its build-time transform wraps the worker's
7+
// default export before the Cloudflare plugin bundles it.
8+
plugins: [
9+
cloudflare(),
10+
sentryCloudflareVitePlugin({
11+
_experimental: {
12+
autoInstrumentation: true,
13+
},
14+
}),
15+
],
16+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-default-export",
4+
// `main` points at the source entry; the Sentry Vite plugin builds from it (so
5+
// the auto-instrument transform runs) and the runner serves the built output.
6+
"main": "index.ts",
7+
"compatibility_date": "2025-06-17",
8+
"compatibility_flags": ["nodejs_als"],
9+
}

packages/cloudflare/.oxlintrc.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@
3030
],
3131
"patterns": [
3232
{
33-
"group": ["@sentry/node/*"],
33+
"group": ["@sentry/node/**"],
3434
"message": "Do not import from `@sentry/node` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points."
3535
},
3636
{
37-
"group": ["@sentry/server-utils/*"],
37+
"group": ["@sentry/server-utils/**"],
3838
"message": "Do not import from `@sentry/server-utils` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points."
3939
}
4040
]
@@ -43,7 +43,7 @@
4343
}
4444
},
4545
{
46-
"files": ["**/src/nodejs_compat/**"],
46+
"files": ["**/src/nodejs_compat/**", "**/src/vite/**"],
4747
"rules": {
4848
"no-restricted-imports": "off"
4949
}

packages/cloudflare/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"@opentelemetry/api": "^1.9.1",
7373
"@sentry/core": "10.67.0",
7474
"@sentry/node": "10.67.0",
75-
"@sentry/server-utils": "10.67.0"
75+
"@sentry/server-utils": "10.67.0",
76+
"magic-string": "~0.30.21"
7677
},
7778
"peerDependencies": {
7879
"@cloudflare/workers-types": "^4.x || ^5.x",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile';
2+
import { applyAutoInstrumentTransforms, type ProgramBody } from './transform';
3+
import { resolveWranglerConfig, type WranglerConfig } from './wranglerConfig';
4+
5+
// Vite normalizes module IDs to posix separators even on Windows, while
6+
// `path.resolve` yields backslashes there — normalize before comparing.
7+
function normalizePath(path: string): string {
8+
return path.replace(/\\/g, '/');
9+
}
10+
11+
// Extensions the entry-module match may tolerate swapping (e.g. wrangler's
12+
// `main` says `.ts` but the served module is `.js`). Anything else — `.css`,
13+
// `.html`, … — sharing the entry's basename must never be treated as the entry.
14+
const JS_EXTENSION_REGEX = /\.[cm]?[jt]sx?$/;
15+
16+
export function sentryCloudflareAutoInstrumentPlugin() {
17+
let wranglerConfig: WranglerConfig | undefined;
18+
let entryFilePath: string | undefined;
19+
20+
let optionsFn = ENV_FALLBACK_OPTIONS_FN;
21+
let optionsImport: string | undefined;
22+
23+
return {
24+
name: 'sentry-cloudflare-auto-instrument',
25+
26+
configResolved(config: { root: string; logger?: { warn(msg: string): void } }): void {
27+
const result = resolveWranglerConfig(config.root);
28+
if (!result) {
29+
config.logger?.warn('[sentry] No parseable wrangler config found — auto-instrumentation disabled.');
30+
return;
31+
}
32+
33+
wranglerConfig = result.config;
34+
if (wranglerConfig.main) {
35+
// `main` is already absolute (wrangler resolves it); just normalize
36+
// separators so the entry-module comparison holds on Windows.
37+
entryFilePath = normalizePath(wranglerConfig.main);
38+
}
39+
40+
if (entryFilePath) {
41+
const instrumentFilePath = resolveInstrumentFile(entryFilePath);
42+
if (instrumentFilePath) {
43+
const built = buildOptionsImport(entryFilePath, instrumentFilePath);
44+
optionsFn = built.optionsFn;
45+
optionsImport = built.importStmt;
46+
}
47+
}
48+
},
49+
50+
transform(
51+
this: { parse(code: string): ProgramBody; warn?(msg: string): void; environment?: { name?: string } },
52+
code: string,
53+
id: string,
54+
): { code: string; map: unknown } | undefined {
55+
if (!wranglerConfig || !entryFilePath) return undefined;
56+
57+
// The worker entry never belongs to the client (browser) environment.
58+
// Skipping it keeps a same-basename sibling (e.g. a `src/index.tsx`
59+
// client entry next to a `src/index.ts` worker) out of the browser bundle.
60+
if (this.environment?.name === 'client') return undefined;
61+
62+
// Vite may append query/hash params to the module ID.
63+
const normalizedId = normalizePath(id.replace(/[?#].*$/, ''));
64+
if (normalizedId !== entryFilePath) {
65+
// Tolerate a differing JS-flavored extension (e.g. `.js` vs `.ts`).
66+
if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryFilePath)) return undefined;
67+
if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryFilePath.replace(JS_EXTENSION_REGEX, '')) {
68+
return undefined;
69+
}
70+
}
71+
72+
let ast: ProgramBody;
73+
try {
74+
ast = this.parse(code);
75+
} catch {
76+
// Raw TypeScript or syntax error — esbuild hasn't run yet (unlikely)
77+
// or the file is genuinely broken. Either way, skip silently.
78+
return undefined;
79+
}
80+
81+
const result = applyAutoInstrumentTransforms(code, ast, {
82+
optionsFn,
83+
optionsImport,
84+
});
85+
86+
return result ?? undefined;
87+
},
88+
};
89+
}

packages/cloudflare/src/vite/index.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// The CJS rollup variant still emits this file, but `package.json` doesn't
55
// expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself.
66
import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite';
7+
import { sentryCloudflareAutoInstrumentPlugin } from './autoInstrument';
78

89
/**
910
* Options for {@link sentryCloudflareVitePlugin}.
@@ -28,6 +29,17 @@ export interface SentryCloudflareVitePluginOptions {
2829
* @experimental May change or be removed in any release.
2930
*/
3031
useDiagnosticsChannelInjection?: boolean;
32+
/**
33+
* Automatically wraps your Worker at build time so you don't have to edit
34+
* your entry: the plugin reads your wrangler config and wraps the default
35+
* export with `Sentry.withSentry()`, sourcing options from a co-located
36+
* `instrument.*` file and falling back to env. Both `vite build` and
37+
* `vite dev` are instrumented.
38+
*
39+
* @default false
40+
* @experimental May change or be removed in any release.
41+
*/
42+
autoInstrumentation?: boolean;
3143
};
3244
}
3345

@@ -63,9 +75,10 @@ export interface SentryCloudflareVitePluginOptions {
6375
* ```
6476
*/
6577
export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}) {
66-
if (!options._experimental?.useDiagnosticsChannelInjection) {
67-
return [];
68-
}
69-
70-
return sentryOrchestrionPlugin({ injectChannelSubscribers: true });
78+
return [
79+
...(options._experimental?.useDiagnosticsChannelInjection
80+
? [sentryOrchestrionPlugin({ injectChannelSubscribers: true })]
81+
: []),
82+
...(options._experimental?.autoInstrumentation ? [sentryCloudflareAutoInstrumentPlugin()] : []),
83+
];
7184
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import MagicString from 'magic-string';
2+
3+
// ---------------------------------------------------------------------------
4+
// Minimal ESTree node types for the AST nodes we inspect.
5+
// ---------------------------------------------------------------------------
6+
7+
export interface BaseNode {
8+
type: string;
9+
start: number;
10+
end: number;
11+
}
12+
13+
export interface ProgramBody {
14+
body: BaseNode[];
15+
}
16+
17+
interface CalleeNode {
18+
type: string;
19+
name?: string;
20+
property?: { type: string; name?: string };
21+
}
22+
23+
interface CallExpressionNode extends BaseNode {
24+
callee?: CalleeNode;
25+
}
26+
27+
interface ExportDefaultNode extends BaseNode {
28+
declaration: BaseNode;
29+
}
30+
31+
function isCallToMethod(node: BaseNode, methodName: string): boolean {
32+
if (node.type !== 'CallExpression') return false;
33+
const callee = (node as CallExpressionNode).callee;
34+
if (!callee) return false;
35+
if (callee.type === 'Identifier' && callee.name === methodName) return true;
36+
return (
37+
callee.type === 'MemberExpression' && callee.property?.type === 'Identifier' && callee.property.name === methodName
38+
);
39+
}
40+
41+
export interface TransformContext {
42+
optionsFn: string;
43+
/** Import statement prepended when `optionsFn` references a separate module. */
44+
optionsImport?: string;
45+
}
46+
47+
export interface TransformResult {
48+
code: string;
49+
map: ReturnType<MagicString['generateMap']>;
50+
}
51+
52+
/**
53+
* Rewrite the worker entry source to wrap its default export with `withSentry`.
54+
*
55+
* Exported (rather than inlined into the plugin) so it can be unit-tested with a
56+
* plain AST and no Vite context. Returns `undefined` when nothing was wrapped.
57+
*/
58+
export function applyAutoInstrumentTransforms(
59+
code: string,
60+
ast: ProgramBody,
61+
ctx: TransformContext,
62+
): TransformResult | undefined {
63+
const ms = new MagicString(code);
64+
const state: TransformState = { ms, needsImport: false };
65+
66+
for (const node of ast.body) {
67+
if (node.type === 'ExportDefaultDeclaration') {
68+
wrapDefaultExport(node as ExportDefaultNode, ctx, state);
69+
}
70+
}
71+
72+
if (!state.needsImport) return undefined;
73+
74+
if (ctx.optionsImport) ms.prepend(ctx.optionsImport);
75+
ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n");
76+
77+
return {
78+
code: ms.toString(),
79+
map: ms.generateMap({ hires: true }),
80+
};
81+
}
82+
83+
interface TransformState {
84+
ms: MagicString;
85+
needsImport: boolean;
86+
}
87+
88+
function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void {
89+
const decl = node.declaration;
90+
91+
// Already wrapped — leave it alone
92+
if (isCallToMethod(decl, 'withSentry')) return;
93+
94+
// `export default <expr>` → `const __SENTRY_DEFAULT_EXPORT__ = <expr>`
95+
// MagicString positions are always relative to the original source.
96+
state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = ');
97+
state.ms.append(`\nexport default __SENTRY__.withSentry(${ctx.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`);
98+
state.needsImport = true;
99+
}

0 commit comments

Comments
 (0)