Skip to content
Draft
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
@@ -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"],
}
6 changes: 3 additions & 3 deletions packages/cloudflare/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
Expand All @@ -43,7 +43,7 @@
}
},
{
"files": ["**/src/nodejs_compat/**"],
"files": ["**/src/nodejs_compat/**", "**/src/vite/**"],
"rules": {
"no-restricted-imports": "off"
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,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",
Expand Down
89 changes: 89 additions & 0 deletions packages/cloudflare/src/vite/autoInstrument.ts
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;
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;
},
};
}
23 changes: 18 additions & 5 deletions packages/cloudflare/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand All @@ -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;
};
}

Expand Down Expand Up @@ -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()] : []),
];
}
99 changes: 99 additions & 0 deletions packages/cloudflare/src/vite/transform.ts
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;
}
Loading
Loading