From 9ecc3e990e4eba3232334f8d8fa2e7083a630063 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 16:34:08 +0200 Subject: [PATCH] feat(cloudflare): Auto-instrument the worker entry with withSentry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(, )` — 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 --- .../default-export/index.ts | 19 ++ .../default-export/instrument.server.ts | 6 + .../default-export/test.ts | 21 +++ .../default-export/vite.config.mts | 16 ++ .../default-export/wrangler.jsonc | 9 + packages/cloudflare/.oxlintrc.json | 6 +- packages/cloudflare/package.json | 3 +- .../cloudflare/src/vite/autoInstrument.ts | 89 +++++++++ packages/cloudflare/src/vite/index.ts | 23 ++- packages/cloudflare/src/vite/transform.ts | 99 ++++++++++ .../test/vite/autoInstrument.test.ts | 175 ++++++++++++++++++ .../cloudflare/test/vite/transform.test.ts | 106 +++++++++++ yarn.lock | 58 ++---- 13 files changed, 575 insertions(+), 55 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/wrangler.jsonc create mode 100644 packages/cloudflare/src/vite/autoInstrument.ts create mode 100644 packages/cloudflare/src/vite/transform.ts create mode 100644 packages/cloudflare/test/vite/autoInstrument.test.ts create mode 100644 packages/cloudflare/test/vite/transform.test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/index.ts new file mode 100644 index 000000000000..4ee4132ef903 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/index.ts @@ -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 { + const url = new URL(request.url); + + if (url.pathname === '/hello') { + return Response.json({ status: 'ok' }); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts new file mode 100644 index 000000000000..00ebb3b19fd9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts @@ -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(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/vite.config.mts new file mode 100644 index 000000000000..ec756a37ed0e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/vite.config.mts @@ -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, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/wrangler.jsonc new file mode 100644 index 000000000000..13baff43e6b5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/wrangler.jsonc @@ -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"], +} diff --git a/packages/cloudflare/.oxlintrc.json b/packages/cloudflare/.oxlintrc.json index 401c478775ee..3ec49c6228c2 100644 --- a/packages/cloudflare/.oxlintrc.json +++ b/packages/cloudflare/.oxlintrc.json @@ -30,11 +30,11 @@ ], "patterns": [ { - "group": ["@sentry/node/*"], + "group": ["@sentry/node/**"], "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." }, { - "group": ["@sentry/server-utils/*"], + "group": ["@sentry/server-utils/**"], "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." } ] @@ -43,7 +43,7 @@ } }, { - "files": ["**/src/nodejs_compat/**"], + "files": ["**/src/nodejs_compat/**", "**/src/vite/**"], "rules": { "no-restricted-imports": "off" } diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 05fe00374df9..c4724f6ef29f 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -65,7 +65,8 @@ "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.67.0", "@sentry/node": "10.67.0", - "@sentry/server-utils": "10.67.0" + "@sentry/server-utils": "10.67.0", + "magic-string": "~0.30.21" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x", diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts new file mode 100644 index 000000000000..9a1196ab11d8 --- /dev/null +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -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; + 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; + }, + }; +} diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index 113d18193257..6d6a9c6487f8 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -4,6 +4,7 @@ // The CJS rollup variant still emits this file, but `package.json` doesn't // expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; +import { sentryCloudflareAutoInstrumentPlugin } from './autoInstrument'; /** * Options for {@link sentryCloudflareVitePlugin}. @@ -28,6 +29,17 @@ export interface SentryCloudflareVitePluginOptions { * @experimental May change or be removed in any release. */ useDiagnosticsChannelInjection?: boolean; + /** + * Automatically wraps your Worker at build time so you don't have to edit + * your entry: the plugin reads your wrangler config and wraps the default + * export with `Sentry.withSentry()`, sourcing options from a co-located + * `instrument.*` file and falling back to env. Both `vite build` and + * `vite dev` are instrumented. + * + * @default false + * @experimental May change or be removed in any release. + */ + autoInstrumentation?: boolean; }; } @@ -63,9 +75,10 @@ export interface SentryCloudflareVitePluginOptions { * ``` */ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}) { - if (!options._experimental?.useDiagnosticsChannelInjection) { - return []; - } - - return sentryOrchestrionPlugin({ injectChannelSubscribers: true }); + return [ + ...(options._experimental?.useDiagnosticsChannelInjection + ? [sentryOrchestrionPlugin({ injectChannelSubscribers: true })] + : []), + ...(options._experimental?.autoInstrumentation ? [sentryCloudflareAutoInstrumentPlugin()] : []), + ]; } diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts new file mode 100644 index 000000000000..09680fa5e491 --- /dev/null +++ b/packages/cloudflare/src/vite/transform.ts @@ -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; +} + +/** + * 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 ` → `const __SENTRY_DEFAULT_EXPORT__ = ` + // 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; +} diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts new file mode 100644 index 000000000000..3785c7e6cf96 --- /dev/null +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -0,0 +1,175 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parse } from 'acorn'; +import { describe, expect, it } from 'vitest'; +import { sentryCloudflareAutoInstrumentPlugin } from '../../src/vite/autoInstrument'; + +function parseJS(code: string) { + return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; +} + +function writeTempDir(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +// --------------------------------------------------------------------------- +// Plugin integration (transform hook with mock this.parse) +// --------------------------------------------------------------------------- + +describe('sentryCloudflareAutoInstrumentPlugin', () => { + function createPlugin(wranglerToml: string) { + const dir = writeTempDir({ 'wrangler.toml': wranglerToml }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const mainMatch = wranglerToml.match(/main\s*=\s*"([^"]+)"/); + const entryPath = join(dir, mainMatch?.[1] ?? 'src/index.ts'); + + // Bind a mock `this.parse` that delegates to acorn. + const boundTransform = (code: string, id: string) => + plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, id); + + return { transform: boundTransform, entryPath, plugin }; + } + + it('transforms the entry file', () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, entryPath); + expect(result).toBeDefined(); + expect(result.code).toContain('__SENTRY__.withSentry('); + }); + + it('leaves an already-manually-wrapped entry untouched', () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + // Nothing to wrap → no transform result. + expect(tx(code, entryPath)).toBeUndefined(); + }); + + it('skips non-entry files', () => { + const { transform: tx } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + expect(tx(code, '/some/other/file.ts')).toBeUndefined(); + }); + + it('tolerates query params in module IDs', () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, `${entryPath}?worker_file`); + expect(result).toBeDefined(); + }); + + it('tolerates JS-flavored extension mismatches', () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + const jsPath = entryPath.replace(/\.ts$/, '.js'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, jsPath); + expect(result).toBeDefined(); + }); + + it('does not match a non-JS sibling sharing the entry basename', () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + const cssPath = entryPath.replace(/\.ts$/, '.css'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + expect(tx(code, cssPath)).toBeUndefined(); + }); + + it('matches Windows-style module IDs against the entry path', () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + const windowsId = entryPath.replace(/\//g, '\\'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + expect(tx(code, windowsId)).toBeDefined(); + }); + + it('skips modules served to the client environment', () => { + const { entryPath, plugin } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = plugin.transform.call( + { parse: (c: string) => parseJS(c), environment: { name: 'client' } }, + code, + entryPath, + ); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// instrument.server.* auto-detection (config from a conventional module) +// --------------------------------------------------------------------------- + +describe('instrument file auto-detection', () => { + function createPluginWithDir(files: Record) { + const dir = writeTempDir(files); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const mainMatch = files['wrangler.toml']?.match(/main\s*=\s*"([^"]+)"/); + const entryPath = join(dir, mainMatch?.[1] ?? 'index.ts'); + + const boundTransform = (code: string, id: string) => + plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, id); + + return { transform: boundTransform, entryPath, dir }; + } + + it('imports the callback from an instrument.server file next to the entry', () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': 'main = "index.ts"', + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, entryPath)!; + expect(result).toBeDefined(); + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';"); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS_CALLBACK__,'); + }); + + it('detects alternative extensions (e.g. .mjs)', () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': 'main = "index.ts"', + 'instrument.server.mjs': 'export default () => ({ dsn: "x" });', + }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, entryPath)!; + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.mjs';"); + }); + + it('emits a resolvable import for .cjs instrument files', () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': 'main = "index.ts"', + 'instrument.server.cjs': 'module.exports = () => ({ dsn: "x" });', + }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, entryPath)!; + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.cjs';"); + }); + + it('falls back to an env-based callback when no instrument file exists', () => { + const { transform: tx, entryPath } = createPluginWithDir({ 'wrangler.toml': 'main = "index.ts"' }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, entryPath)!; + expect(result.code).not.toContain('__SENTRY_OPTIONS_CALLBACK__'); + expect(result.code).toContain('__SENTRY__.withSentry(() => undefined,'); + }); +}); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts new file mode 100644 index 000000000000..0c0165303667 --- /dev/null +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -0,0 +1,106 @@ +import { parse } from 'acorn'; +import { describe, expect, it } from 'vitest'; +import { applyAutoInstrumentTransforms, type TransformContext } from '../../src/vite/transform'; + +function parseJS(code: string) { + return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; +} + +function transform(code: string, ctx: TransformContext) { + return applyAutoInstrumentTransforms(code, parseJS(code), ctx); +} + +// --------------------------------------------------------------------------- +// Default export wrapping +// --------------------------------------------------------------------------- + +describe('default export wrapping', () => { + const ctx: TransformContext = { optionsFn: '(env) => ({})' }; + + it('wraps an object-literal default export', () => { + const code = [ + 'const handler = {', + ' fetch() { return new Response("ok"); }', + '};', + 'export default handler;', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain("import * as __SENTRY__ from '@sentry/cloudflare'"); + expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ = handler'); + expect(result.code).toContain('__SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__)'); + expect(result.code).not.toContain('export default handler'); + expect(result.map).toBeDefined(); + }); + + it('wraps an inline object default export', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ ='); + expect(result.code).toContain('__SENTRY__.withSentry('); + }); + + it('wraps a class default export', () => { + const code = [ + 'class Worker {', + ' fetch(request) { return new Response("ok"); }', + '}', + 'export default Worker;', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('__SENTRY__.withSentry('); + }); + + it('uses custom options callback', () => { + const custom: TransformContext = { + optionsFn: '(env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 })', + }; + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = transform(code, custom)!; + expect(result.code).toContain('dsn: env.SENTRY_DSN'); + expect(result.code).toContain('tracesSampleRate: 1.0'); + }); + + it('skips when already wrapped with withSentry', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('skips when already wrapped with Sentry.withSentry', () => { + const code = [ + "import * as Sentry from '@sentry/cloudflare';", + 'export default Sentry.withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('generates a source map', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = transform(code, ctx)!; + expect(result.map).toBeDefined(); + expect(result.map.mappings).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Nothing to wrap +// --------------------------------------------------------------------------- + +describe('nothing to wrap', () => { + it('returns undefined when the entry is already wrapped manually', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + + expect(transform(code, { optionsFn: '(env) => ({})' })).toBeUndefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index fbde115c3e01..19a27d61b9d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9150,20 +9150,20 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.1", "@types/estree@^1.0.6", "@types/estree@^1.0.8": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" - integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/estree@*", "@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.1", "@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/estree@1.0.9": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" - integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/estree@^0.0.51": version "0.0.51" @@ -21058,7 +21058,7 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -26689,41 +26689,7 @@ rollup@^3.27.1: optionalDependencies: fsevents "~2.3.2" -rollup@^4.34.9, rollup@^4.60.2, rollup@^4.60.3: - version "4.60.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.4.tgz#ca3814f5900da3ac3981d2e0c61944b7e6e0cb09" - integrity sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g== - dependencies: - "@types/estree" "1.0.8" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.60.4" - "@rollup/rollup-android-arm64" "4.60.4" - "@rollup/rollup-darwin-arm64" "4.60.4" - "@rollup/rollup-darwin-x64" "4.60.4" - "@rollup/rollup-freebsd-arm64" "4.60.4" - "@rollup/rollup-freebsd-x64" "4.60.4" - "@rollup/rollup-linux-arm-gnueabihf" "4.60.4" - "@rollup/rollup-linux-arm-musleabihf" "4.60.4" - "@rollup/rollup-linux-arm64-gnu" "4.60.4" - "@rollup/rollup-linux-arm64-musl" "4.60.4" - "@rollup/rollup-linux-loong64-gnu" "4.60.4" - "@rollup/rollup-linux-loong64-musl" "4.60.4" - "@rollup/rollup-linux-ppc64-gnu" "4.60.4" - "@rollup/rollup-linux-ppc64-musl" "4.60.4" - "@rollup/rollup-linux-riscv64-gnu" "4.60.4" - "@rollup/rollup-linux-riscv64-musl" "4.60.4" - "@rollup/rollup-linux-s390x-gnu" "4.60.4" - "@rollup/rollup-linux-x64-gnu" "4.60.4" - "@rollup/rollup-linux-x64-musl" "4.60.4" - "@rollup/rollup-openbsd-x64" "4.60.4" - "@rollup/rollup-openharmony-arm64" "4.60.4" - "@rollup/rollup-win32-arm64-msvc" "4.60.4" - "@rollup/rollup-win32-ia32-msvc" "4.60.4" - "@rollup/rollup-win32-x64-gnu" "4.60.4" - "@rollup/rollup-win32-x64-msvc" "4.60.4" - fsevents "~2.3.2" - -rollup@^4.43.0: +rollup@^4.34.9, rollup@^4.43.0, rollup@^4.60.2, rollup@^4.60.3: version "4.62.2" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.2.tgz#d90fc4cb811f071303c890b779595634f35f9541" integrity sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA== @@ -30295,7 +30261,7 @@ vite-plugin-vue-tracer@^1.0.1: pathe "^2.0.3" source-map-js "^1.2.1" -vite@7.3.2: +vite@7.3.2, "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": version "7.3.2" resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== @@ -30320,7 +30286,7 @@ vite@^4.4.9: optionalDependencies: fsevents "~2.3.2" -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.5, vite@^6.4.1, vite@^6.4.3: +vite@^6.3.5, vite@^6.4.1, vite@^6.4.3: version "6.4.3" resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.3.tgz#85a164db7ce706f2a776812efa2b340f1721858e" integrity sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==