-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(cloudflare): Auto-instrument the worker entry with withSentry #22433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| interface Env { | ||
| SENTRY_DSN: string; | ||
| } | ||
|
|
||
| // A plain, unwrapped worker — no manual `Sentry.withSentry`. The | ||
| // `@sentry/cloudflare/vite` plugin's auto-instrumentation wraps the default | ||
| // export with `withSentry` at build time, sourcing options from | ||
| // `instrument.server.ts`. | ||
| export default { | ||
| async fetch(request: Request): Promise<Response> { | ||
| const url = new URL(request.url); | ||
|
|
||
| if (url.pathname === '/hello') { | ||
| return Response.json({ status: 'ok' }); | ||
| } | ||
|
|
||
| return new Response('Not found', { status: 404 }); | ||
| }, | ||
| } satisfies ExportedHandler<Env>; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { defineCloudflareOptions } from '@sentry/cloudflare'; | ||
|
|
||
| export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ | ||
| dsn: env.SENTRY_DSN, | ||
| tracesSampleRate: 1.0, | ||
| })); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import type { TransactionEvent } from '@sentry/core'; | ||
| import { expect, it } from 'vitest'; | ||
| import { createRunner } from '../../../runner'; | ||
|
|
||
| // The worker entry is a plain, unwrapped `export default {...}`. The runner | ||
| // detects `vite.config.mts`, runs `vite build`, and serves the generated output | ||
| // — so this transaction only arrives if the build-time transform wrapped the | ||
| // default export with `withSentry`. | ||
| it('auto-instruments a plain default-export handler', async ({ signal }) => { | ||
| const runner = createRunner(__dirname) | ||
| .expect(envelope => { | ||
| const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; | ||
| expect(transactionEvent.transaction).toBe('GET /hello'); | ||
| expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); | ||
| expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); | ||
| }) | ||
| .start(signal); | ||
|
|
||
| await runner.makeRequest('get', '/hello'); | ||
| await runner.completed(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { cloudflare } from '@cloudflare/vite-plugin'; | ||
| import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; | ||
| import { defineConfig } from 'vite'; | ||
|
|
||
| export default defineConfig({ | ||
| // The Sentry plugin runs first so its build-time transform wraps the worker's | ||
| // default export before the Cloudflare plugin bundles it. | ||
| plugins: [ | ||
| cloudflare(), | ||
| sentryCloudflareVitePlugin({ | ||
| _experimental: { | ||
| autoInstrumentation: true, | ||
| }, | ||
| }), | ||
| ], | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "$schema": "../../../node_modules/wrangler/config-schema.json", | ||
| "name": "cloudflare-vite-autoinstrument-default-export", | ||
| // `main` points at the source entry; the Sentry Vite plugin builds from it (so | ||
| // the auto-instrument transform runs) and the runner serves the built output. | ||
| "main": "index.ts", | ||
| "compatibility_date": "2025-06-17", | ||
| "compatibility_flags": ["nodejs_als"], | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile'; | ||
| import { applyAutoInstrumentTransforms, type ProgramBody } from './transform'; | ||
| import { resolveWranglerConfig, type WranglerConfig } from './wranglerConfig'; | ||
|
|
||
| // Vite normalizes module IDs to posix separators even on Windows, while | ||
| // `path.resolve` yields backslashes there — normalize before comparing. | ||
| function normalizePath(path: string): string { | ||
| return path.replace(/\\/g, '/'); | ||
| } | ||
|
|
||
| // Extensions the entry-module match may tolerate swapping (e.g. wrangler's | ||
| // `main` says `.ts` but the served module is `.js`). Anything else — `.css`, | ||
| // `.html`, … — sharing the entry's basename must never be treated as the entry. | ||
| const JS_EXTENSION_REGEX = /\.[cm]?[jt]sx?$/; | ||
|
|
||
| export function sentryCloudflareAutoInstrumentPlugin() { | ||
| let wranglerConfig: WranglerConfig | undefined; | ||
| let entryFilePath: string | undefined; | ||
|
|
||
| let optionsFn = ENV_FALLBACK_OPTIONS_FN; | ||
| let optionsImport: string | undefined; | ||
|
|
||
| return { | ||
| name: 'sentry-cloudflare-auto-instrument', | ||
|
|
||
| configResolved(config: { root: string; logger?: { warn(msg: string): void } }): void { | ||
| const result = resolveWranglerConfig(config.root); | ||
| if (!result) { | ||
| config.logger?.warn('[sentry] No parseable wrangler config found — auto-instrumentation disabled.'); | ||
| return; | ||
| } | ||
|
|
||
| wranglerConfig = result.config; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The code incorrectly accesses Suggested FixThe Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| if (wranglerConfig.main) { | ||
| // `main` is already absolute (wrangler resolves it); just normalize | ||
| // separators so the entry-module comparison holds on Windows. | ||
| entryFilePath = normalizePath(wranglerConfig.main); | ||
| } | ||
|
|
||
| if (entryFilePath) { | ||
| const instrumentFilePath = resolveInstrumentFile(entryFilePath); | ||
| if (instrumentFilePath) { | ||
| const built = buildOptionsImport(entryFilePath, instrumentFilePath); | ||
| optionsFn = built.optionsFn; | ||
| optionsImport = built.importStmt; | ||
| } | ||
| } | ||
| }, | ||
|
|
||
| transform( | ||
| this: { parse(code: string): ProgramBody; warn?(msg: string): void; environment?: { name?: string } }, | ||
| code: string, | ||
| id: string, | ||
| ): { code: string; map: unknown } | undefined { | ||
| if (!wranglerConfig || !entryFilePath) return undefined; | ||
|
|
||
| // The worker entry never belongs to the client (browser) environment. | ||
| // Skipping it keeps a same-basename sibling (e.g. a `src/index.tsx` | ||
| // client entry next to a `src/index.ts` worker) out of the browser bundle. | ||
| if (this.environment?.name === 'client') return undefined; | ||
|
|
||
| // Vite may append query/hash params to the module ID. | ||
| const normalizedId = normalizePath(id.replace(/[?#].*$/, '')); | ||
| if (normalizedId !== entryFilePath) { | ||
| // Tolerate a differing JS-flavored extension (e.g. `.js` vs `.ts`). | ||
| if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryFilePath)) return undefined; | ||
| if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryFilePath.replace(JS_EXTENSION_REGEX, '')) { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| let ast: ProgramBody; | ||
| try { | ||
| ast = this.parse(code); | ||
| } catch { | ||
| // Raw TypeScript or syntax error — esbuild hasn't run yet (unlikely) | ||
| // or the file is genuinely broken. Either way, skip silently. | ||
| return undefined; | ||
| } | ||
|
|
||
| const result = applyAutoInstrumentTransforms(code, ast, { | ||
| optionsFn, | ||
| optionsImport, | ||
| }); | ||
|
|
||
| return result ?? undefined; | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import MagicString from 'magic-string'; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Minimal ESTree node types for the AST nodes we inspect. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export interface BaseNode { | ||
| type: string; | ||
| start: number; | ||
| end: number; | ||
| } | ||
|
|
||
| export interface ProgramBody { | ||
| body: BaseNode[]; | ||
| } | ||
|
|
||
| interface CalleeNode { | ||
| type: string; | ||
| name?: string; | ||
| property?: { type: string; name?: string }; | ||
| } | ||
|
|
||
| interface CallExpressionNode extends BaseNode { | ||
| callee?: CalleeNode; | ||
| } | ||
|
|
||
| interface ExportDefaultNode extends BaseNode { | ||
| declaration: BaseNode; | ||
| } | ||
|
|
||
| function isCallToMethod(node: BaseNode, methodName: string): boolean { | ||
| if (node.type !== 'CallExpression') return false; | ||
| const callee = (node as CallExpressionNode).callee; | ||
| if (!callee) return false; | ||
| if (callee.type === 'Identifier' && callee.name === methodName) return true; | ||
| return ( | ||
| callee.type === 'MemberExpression' && callee.property?.type === 'Identifier' && callee.property.name === methodName | ||
| ); | ||
| } | ||
|
|
||
| export interface TransformContext { | ||
| optionsFn: string; | ||
| /** Import statement prepended when `optionsFn` references a separate module. */ | ||
| optionsImport?: string; | ||
| } | ||
|
|
||
| export interface TransformResult { | ||
| code: string; | ||
| map: ReturnType<MagicString['generateMap']>; | ||
| } | ||
|
|
||
| /** | ||
| * Rewrite the worker entry source to wrap its default export with `withSentry`. | ||
| * | ||
| * Exported (rather than inlined into the plugin) so it can be unit-tested with a | ||
| * plain AST and no Vite context. Returns `undefined` when nothing was wrapped. | ||
| */ | ||
| export function applyAutoInstrumentTransforms( | ||
| code: string, | ||
| ast: ProgramBody, | ||
| ctx: TransformContext, | ||
| ): TransformResult | undefined { | ||
| const ms = new MagicString(code); | ||
| const state: TransformState = { ms, needsImport: false }; | ||
|
|
||
| for (const node of ast.body) { | ||
| if (node.type === 'ExportDefaultDeclaration') { | ||
| wrapDefaultExport(node as ExportDefaultNode, ctx, state); | ||
| } | ||
| } | ||
|
|
||
| if (!state.needsImport) return undefined; | ||
|
|
||
| if (ctx.optionsImport) ms.prepend(ctx.optionsImport); | ||
| ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n"); | ||
|
|
||
| return { | ||
| code: ms.toString(), | ||
| map: ms.generateMap({ hires: true }), | ||
| }; | ||
| } | ||
|
|
||
| interface TransformState { | ||
| ms: MagicString; | ||
| needsImport: boolean; | ||
| } | ||
|
|
||
| function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void { | ||
| const decl = node.declaration; | ||
|
|
||
| // Already wrapped — leave it alone | ||
| if (isCallToMethod(decl, 'withSentry')) return; | ||
|
|
||
| // `export default <expr>` → `const __SENTRY_DEFAULT_EXPORT__ = <expr>` | ||
| // MagicString positions are always relative to the original source. | ||
| state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = '); | ||
| state.ms.append(`\nexport default __SENTRY__.withSentry(${ctx.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); | ||
| state.needsImport = true; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The
sentryCloudflareAutoInstrumentPluginis missingenforce: 'pre', which makes its execution order dependent on its position in thepluginsarray, creating a fragile setup.Severity: MEDIUM
Suggested Fix
Add
enforce: 'pre'to the plugin object returned bysentryCloudflareAutoInstrumentPlugininpackages/cloudflare/src/vite/autoInstrument.ts. This will ensure it runs before other plugins, like the Cloudflare plugin, making the auto-instrumentation robust and explicit.Prompt for AI Agent
Also affects:
packages/cloudflare/src/vite/index.ts:75~84