Skip to content
Open
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,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

// The worker entry is a package, so the plugin reads this file from beside
// the wrangler config.
export default defineCloudflareOptions((env: { E2E_TEST_DSN: string }) => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "cloudflare-autoinstrument-package-entry",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
},
"dependencies": {
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"worker-entry": "file:../../test-applications/cloudflare-autoinstrument-package-entry/worker-entry"
Comment thread
isaacs marked this conversation as resolved.
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.63.0",
"@sentry/conventions": "0.27.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"vite": "^8.1.5",
"wrangler": "^4.114.0"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument-package-entry',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from '@playwright/test';
import { SENTRY_ENVIRONMENT, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { HTTP_SERVER } from '@sentry/conventions/op';
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';

// The worker entry is a package with no Sentry calls. Any event that arrives
// proves the plugin wrapped the module that wrangler's `main` resolved to.

test('wraps a package entry with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const spanPromise = waitForStreamedSpan(
'cloudflare-autoinstrument-package-entry',
span => getSpanOp(span) === HTTP_SERVER && span.is_segment,
);

const res = await fetch(baseURL!);
expect(res.status).toBe(200);

const span = await spanPromise;
expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare');
// Only `instrument.server.ts` sets this environment.
expect(span.attributes[SENTRY_ENVIRONMENT]?.value).toBe('qa');
});

test('captures an error thrown by a package entry', async ({ baseURL }) => {
const errorPromise = waitForError(
'cloudflare-autoinstrument-package-entry',
event => event.exception?.values?.[0]?.value === 'Thrown from a package entry',
);

const res = await fetch(`${baseURL}/throw`);
expect(res.status).toBe(500);

const error = await errorPromise;
expect(error.environment).toBe('qa');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Shaped like bundler output (e.g. `@astrojs/cloudflare`): the default export
// is a specifier, not `export default <expr>`.
var fetch_handler_default = {
async fetch(request) {
if (new URL(request.url).pathname === '/throw') {
throw new Error('Thrown from a package entry');
}
return new Response('ok');
},
};
export { fetch_handler_default as default };
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "worker-entry",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./fetch-handler": "./fetch-handler.js"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument-package-entry",
// A package export, not a file path. The Sentry plugin resolves it with Vite.
"main": "worker-entry/fetch-handler",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],
}
4 changes: 4 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.x || ^5.x",
"vite": "^6.1.0 || ^7.0.0 || ^8.0.0",
"wrangler": "^4.x"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
"optional": true
},
"vite": {
"optional": true
},
"wrangler": {
"optional": true
}
Expand Down
86 changes: 64 additions & 22 deletions packages/cloudflare/src/vite/autoInstrument.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { basename } from 'node:path';
import { basename, dirname, isAbsolute } from 'node:path';
import { createIdResolver, type ResolvedConfig } from 'vite';
import { collectAgentCandidates, detectAgentClasses, type ModuleResolver } from './agentClass';
import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile';
import { applyAutoInstrumentTransforms, type ClassWrapperKind, type ProgramBody } from './transform';
Expand Down Expand Up @@ -30,14 +31,48 @@ async function resolvesNextjsCloudflare(resolver: ModuleResolver, importer: stri
}
}

type TransformContext = ModuleResolver & {
warn?(msg: string): void;
environment?: Parameters<ReturnType<typeof createIdResolver>>[0];
};

export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPath?: string } = {}) {
let wranglerConfig: WranglerConfig | undefined;
let entryFilePath: string | undefined;

let optionsFn = ENV_FALLBACK_OPTIONS_FN;
let optionsImport: string | undefined;
// A `main` that is not a file path (a package export or virtual module).
let entrySpecifier: string | undefined;
let wranglerConfigPath: string | undefined;
let resolveId: ReturnType<typeof createIdResolver> | undefined;
// Each environment resolves with its own conditions, so the entry may differ between them.
const resolvedEntries = new Map<string, Promise<string | undefined>>();

let instrumentFilePath: string | undefined;
let isVinext = false;

function resolveEntrySpecifier(ctx: TransformContext, specifier: string): Promise<string | undefined> {
const environment = ctx.environment;
if (!environment || !resolveId) return Promise.resolve(undefined);

let entry = resolvedEntries.get(environment.name);
if (!entry) {
// Resolved from the wrangler config, as wrangler does. Without an importer, Vite's resolver
// tries the specifier as a path below the root first.
entry = resolveId(environment, specifier, wranglerConfigPath)
.catch(() => undefined)
.then(id => {
if (!id) {
ctx.warn?.(
`[sentry] Could not resolve the wrangler \`main\` "${specifier}". Auto-instrumentation disabled.`,
);
return undefined;
}
return normalizePath(id.replace(/[?#].*$/, ''));
});
Comment thread
isaacs marked this conversation as resolved.
resolvedEntries.set(environment.name, entry);
}
return entry;
}

return {
name: 'sentry-cloudflare-auto-instrument',

Expand All @@ -64,40 +99,43 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa
}

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;
}
const main = wranglerConfig.main;
if (main && isAbsolute(main)) {
// Normalize separators so the entry-module comparison holds on Windows.
entryFilePath = normalizePath(main);
instrumentFilePath = resolveInstrumentFile(dirname(main));
} else if (main) {
entrySpecifier = main;
wranglerConfigPath = result.configPath;
// Not `this.resolve()`: Rollup's commonjs resolver loads the module it resolves, so a
// resolve of the entry from the entry's own `transform` never finishes.
resolveId = createIdResolver(config as ResolvedConfig);
// A package entry has no project directory of its own.
instrumentFilePath = resolveInstrumentFile(result.configDir);
}
},

async transform(
this: ModuleResolver & { warn?(msg: string): void; environment?: { name?: string } },
this: TransformContext,
code: string,
id: string,
): Promise<{ code: string; map: unknown } | undefined> {
if (!wranglerConfig || !entryFilePath) return undefined;
if (!wranglerConfig || (!entryFilePath && !entrySpecifier)) 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;

const entryPath = entrySpecifier ? await resolveEntrySpecifier(this, entrySpecifier) : entryFilePath;
if (!entryPath) return undefined;

// Vite may append query/hash params to the module ID.
const normalizedId = normalizePath(id.replace(/[?#].*$/, ''));
if (normalizedId !== entryFilePath) {
if (normalizedId !== entryPath) {
// 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, '')) {
if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryPath)) return undefined;
if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryPath.replace(JS_EXTENSION_REGEX, '')) {
return undefined;
}
}
Expand Down Expand Up @@ -145,6 +183,10 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa
const sentryModule =
isVinext && (await resolvesNextjsCloudflare(this, normalizedId)) ? NEXTJS_CLOUDFLARE_MODULE : undefined;

const { optionsFn, importStmt: optionsImport } = instrumentFilePath
? buildOptionsImport(entryPath, instrumentFilePath)
: { optionsFn: ENV_FALLBACK_OPTIONS_FN, importStmt: undefined };

// No registration import is injected here: the orchestrion plugin's
// subscribe-injection makes each bundled package self-register its channel
// subscriber on the global marker, so wrapping the entry with `withSentry`
Expand Down
10 changes: 5 additions & 5 deletions packages/cloudflare/src/vite/instrumentFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ export const ENV_FALLBACK_OPTIONS_FN = '() => undefined';
const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__';

// Conventional, non-configurable name of the Sentry options module. It is
// looked up next to the worker entry file; its default export is the options
// looked up next to the worker entry file, or next to the wrangler config
// when `main` is not a file path; its default export is the options
// callback `(env) => CloudflareOptions`.
const INSTRUMENT_FILE_BASENAME = 'instrument.server';
const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs'];

/**
* Locate the conventional `instrument.server.*` module sitting next to the
* worker entry file. Returns its absolute path, or `undefined` when absent.
* Locate the conventional `instrument.server.*` module in `dir`. Returns its
* absolute path, or `undefined` when absent.
*/
export function resolveInstrumentFile(entryFilePath: string): string | undefined {
const dir = dirname(entryFilePath);
export function resolveInstrumentFile(dir: string): string | undefined {
for (const ext of INSTRUMENT_FILE_EXTENSIONS) {
const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`);
if (existsSync(candidate)) return candidate;
Expand Down
Loading
Loading