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
9 changes: 7 additions & 2 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,19 @@
"@opentelemetry/api": "^1.9.1",
"@sentry/core": "10.66.0",
"@sentry/node": "10.66.0",
"@sentry/server-utils": "10.66.0"
"@sentry/server-utils": "10.66.0",
"magic-string": "~0.30.21"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.x || ^5.x"
"@cloudflare/workers-types": "^4.x || ^5.x",
"wrangler": "^4.x"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
"optional": true
},
"wrangler": {
"optional": true
}
},
"devDependencies": {
Expand Down
46 changes: 46 additions & 0 deletions packages/cloudflare/src/defineCloudflareOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { env as cloudflareEnv } from 'cloudflare:workers';
import type { CloudflareOptions } from './client';

/**
* Define the Sentry options for a Cloudflare Worker in a dedicated module.
*
* This is the recommended way to configure the SDK when using the Vite plugin's
* auto-instrumentation: place an `instrument.server.{ts,js,mjs}` file next to
* the worker entry whose **default export** is the result of this function. The
* plugin picks it up automatically and hands it to `withSentry`.
*
* Unlike Node's `Sentry.init(...)`, the options cannot be applied at module
* load time on Cloudflare: the DSN and other settings typically come from the
* per-request `env`, which only exists inside the handler. Pass a callback to
* read from `env`, or a static object when no `env` access is needed — either
* way you get full type-checking and autocomplete on {@link CloudflareOptions}.
*
* At runtime this is a thin pass-through; it only normalizes a static object
* into a callback so the plugin always imports a `(env) => options` function.
*
* @example
* ```ts
* // src/instrument.server.ts
* import { defineCloudflareOptions } from '@sentry/cloudflare';
*
* export default defineCloudflareOptions((env) => ({
* dsn: env.SENTRY_DSN,
* tracesSampleRate: 1.0,
* }));
* ```
*
* @example
* ```ts
* // Static options — no `env` access needed
* export default defineCloudflareOptions({ tracesSampleRate: 1.0 });
* ```
*/
export function defineCloudflareOptions<Env = typeof cloudflareEnv>(
optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined),
): (env: Env) => CloudflareOptions | undefined {
if (typeof optionsOrCallback === 'function') {
return optionsOrCallback as (env: Env) => CloudflareOptions | undefined;
}

return () => optionsOrCallback;
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
} from '@sentry/core';

export { withSentry } from './withSentry';
export { defineCloudflareOptions } from './defineCloudflareOptions';
export { instrumentDurableObjectWithSentry } from './durableobject';
export { sentryPagesPlugin } from './pages-plugin';

Expand Down
53 changes: 53 additions & 0 deletions packages/cloudflare/src/vite/instrumentFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { existsSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';

// Fallback options callback used when no instrument file is present. Returning
// `undefined` makes the SDK read all configuration (DSN, release, environment,
// sample rate, …) from the worker's `env` at runtime.
export const ENV_FALLBACK_OPTIONS_FN = '() => undefined';

// Identifier the generated import binds the user's options module to.
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
// 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.
*/
export function resolveInstrumentFile(entryFilePath: string): string | undefined {
const dir = dirname(entryFilePath);
for (const ext of INSTRUMENT_FILE_EXTENSIONS) {
const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`);
if (existsSync(candidate)) return candidate;
}
return undefined;
}

/**
* Build the `optionsFn` reference and `import` statement for the instrument
* module whose **default export** is the options callback
* `(env) => CloudflareOptions`.
*
* The import is emitted relative to `entryFilePath` because it is injected into
* the entry file's source. The file extension is kept: extensionless specifiers
* only resolve for extensions in Vite's default `resolve.extensions` (which
* excludes `.cjs`), and keeping it makes our probe order authoritative when
* several `instrument.server.*` files coexist.
*/
export function buildOptionsImport(
entryFilePath: string,
instrumentFilePath: string,
): { optionsFn: string; importStmt: string } {
let relativePath = relative(dirname(entryFilePath), instrumentFilePath).replace(/\\/g, '/');
if (!relativePath.startsWith('.')) relativePath = `./${relativePath}`;

return {
optionsFn: OPTIONS_IMPORT_IDENTIFIER,
importStmt: `import ${OPTIONS_IMPORT_IDENTIFIER} from '${relativePath}';\n`,
};
}
67 changes: 67 additions & 0 deletions packages/cloudflare/src/vite/wranglerConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { type Unstable_Config, unstable_readConfig } from 'wrangler';

/**
* The slice of the wrangler configuration the auto-instrument plugin cares
* about. `main` is an absolute path (wrangler resolves it against the config
* file's directory).
*/
export interface WranglerConfig {
main?: string;
durableObjects: Array<{ name: string; className: string }>;
}

/**
* Locate and resolve the wrangler configuration via wrangler's own
* `unstable_readConfig` — the API `@cloudflare/vite-plugin` uses.
*
* We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside
* `root` with wrangler's own precedence, since it discovers from `cwd` rather
* than an arbitrary root); wrangler then parses it, flattens the active
* environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute
* path. Durable Object bindings are the active environment's, matching what the
* deployed Worker actually binds.
*
* Returns `undefined` when no config file is found or it can't be read/parsed
* (the caller warns and disables auto-instrumentation rather than failing the
* whole build).
*/
export function resolveWranglerConfig(
root: string,
explicitPath?: string,
): { config: WranglerConfig; configDir: string } | undefined {
const configPath = explicitPath
? resolve(root, explicitPath)
: ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync);

if (!configPath || !existsSync(configPath)) {
return undefined;
}

let raw: Unstable_Config;
try {
// `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO
// migrations) out of the Vite build output.
raw = unstable_readConfig({ config: configPath }, { hideWarnings: true });
} catch {
return undefined;
}

const durableObjects: WranglerConfig['durableObjects'] = [];
const seenClassNames = new Set<string>();
for (const binding of raw.durable_objects?.bindings ?? []) {
// `script_name` bindings reference a class exported by a *different* worker
// — there is nothing to wrap in this worker's entry file.
if (typeof binding?.class_name !== 'string' || binding.script_name || seenClassNames.has(binding.class_name)) {
continue;
}
seenClassNames.add(binding.class_name);
durableObjects.push({ name: binding.name, className: binding.class_name });
}

return {
config: { main: raw.main, durableObjects },
configDir: dirname(raw.configPath ?? configPath),
};
}
28 changes: 28 additions & 0 deletions packages/cloudflare/test/defineCloudflareOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { defineCloudflareOptions } from '../src/defineCloudflareOptions';

describe('defineCloudflareOptions', () => {
it('returns the callback unchanged', () => {
const callback = (env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN });
expect(defineCloudflareOptions(callback)).toBe(callback);
});

it('passes env through to the callback', () => {
const callback = defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));

expect(callback({ SENTRY_DSN: 'https://example' })).toEqual({
dsn: 'https://example',
tracesSampleRate: 1.0,
});
});

it('normalizes a static options object into a callback', () => {
const callback = defineCloudflareOptions({ tracesSampleRate: 0.5 });

expect(typeof callback).toBe('function');
expect(callback({} as never)).toEqual({ tracesSampleRate: 0.5 });
});
});
Loading
Loading