Skip to content

Commit c19a423

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Read wrangler config and resolve the Sentry options module
Add the building blocks the auto-instrument Vite plugin will use, with no wiring into a plugin yet: - `wranglerConfig`: locate and parse `wrangler.{json,jsonc,toml}` via wrangler's own `unstable_readConfig`, returning the worker entry (`main`) and the configured Durable Object class names. - `instrumentFile`: find a conventional `instrument.server.{ts,js,mjs,cjs}` next to the entry and build the options import, falling back to an env-based callback when absent. - `defineCloudflareOptions`: identity helper that gives the options callback its type in that module. Adds `wrangler` as an optional peer dependency (used to read the config). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 532ac86 commit c19a423

8 files changed

Lines changed: 451 additions & 2 deletions

File tree

packages/cloudflare/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,15 @@
6868
"@sentry/server-utils": "10.67.0"
6969
},
7070
"peerDependencies": {
71-
"@cloudflare/workers-types": "^4.x || ^5.x"
71+
"@cloudflare/workers-types": "^4.x || ^5.x",
72+
"wrangler": "^4.x"
7273
},
7374
"peerDependenciesMeta": {
7475
"@cloudflare/workers-types": {
7576
"optional": true
77+
},
78+
"wrangler": {
79+
"optional": true
7680
}
7781
},
7882
"devDependencies": {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { env as cloudflareEnv } from 'cloudflare:workers';
2+
import type { CloudflareOptions } from './client';
3+
4+
/**
5+
* Define the Sentry options for a Cloudflare Worker in a dedicated module.
6+
*
7+
* This is the recommended way to configure the SDK when using the Vite plugin's
8+
* auto-instrumentation: place an `instrument.server.{ts,js,mjs}` file next to
9+
* the worker entry whose **default export** is the result of this function. The
10+
* plugin picks it up automatically and hands it to `withSentry`.
11+
*
12+
* Unlike Node's `Sentry.init(...)`, the options cannot be applied at module
13+
* load time on Cloudflare: the DSN and other settings typically come from the
14+
* per-request `env`, which only exists inside the handler. Pass a callback to
15+
* read from `env`, or a static object when no `env` access is needed — either
16+
* way you get full type-checking and autocomplete on {@link CloudflareOptions}.
17+
*
18+
* At runtime this is a thin pass-through; it only normalizes a static object
19+
* into a callback so the plugin always imports a `(env) => options` function.
20+
*
21+
* @example
22+
* ```ts
23+
* // src/instrument.server.ts
24+
* import { defineCloudflareOptions } from '@sentry/cloudflare';
25+
*
26+
* export default defineCloudflareOptions((env) => ({
27+
* dsn: env.SENTRY_DSN,
28+
* tracesSampleRate: 1.0,
29+
* }));
30+
* ```
31+
*
32+
* @example
33+
* ```ts
34+
* // Static options — no `env` access needed
35+
* export default defineCloudflareOptions({ tracesSampleRate: 1.0 });
36+
* ```
37+
*/
38+
export function defineCloudflareOptions<Env = typeof cloudflareEnv>(
39+
optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined),
40+
): (env: Env) => CloudflareOptions | undefined {
41+
if (typeof optionsOrCallback === 'function') {
42+
return optionsOrCallback as (env: Env) => CloudflareOptions | undefined;
43+
}
44+
45+
return () => optionsOrCallback;
46+
}

packages/cloudflare/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export {
117117
} from '@sentry/core';
118118

119119
export { withSentry } from './withSentry';
120+
export { defineCloudflareOptions } from './defineCloudflareOptions';
120121
export { instrumentDurableObjectWithSentry } from './durableobject';
121122
export { sentryPagesPlugin } from './pages-plugin';
122123

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { existsSync } from 'node:fs';
2+
import { dirname, relative, resolve } from 'node:path';
3+
4+
// Fallback options callback used when no instrument file is present. Returning
5+
// `undefined` makes the SDK read all configuration (DSN, release, environment,
6+
// sample rate, …) from the worker's `env` at runtime.
7+
export const ENV_FALLBACK_OPTIONS_FN = '() => undefined';
8+
9+
// Identifier the generated import binds the user's options module to.
10+
const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__';
11+
12+
// Conventional, non-configurable name of the Sentry options module. It is
13+
// looked up next to the worker entry file; its default export is the options
14+
// callback `(env) => CloudflareOptions`.
15+
const INSTRUMENT_FILE_BASENAME = 'instrument.server';
16+
const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs'];
17+
18+
/**
19+
* Locate the conventional `instrument.server.*` module sitting next to the
20+
* worker entry file. Returns its absolute path, or `undefined` when absent.
21+
*/
22+
export function resolveInstrumentFile(entryFilePath: string): string | undefined {
23+
const dir = dirname(entryFilePath);
24+
for (const ext of INSTRUMENT_FILE_EXTENSIONS) {
25+
const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`);
26+
if (existsSync(candidate)) return candidate;
27+
}
28+
return undefined;
29+
}
30+
31+
/**
32+
* Build the `optionsFn` reference and `import` statement for the instrument
33+
* module whose **default export** is the options callback
34+
* `(env) => CloudflareOptions`.
35+
*
36+
* The import is emitted relative to `entryFilePath` because it is injected into
37+
* the entry file's source. The file extension is kept: extensionless specifiers
38+
* only resolve for extensions in Vite's default `resolve.extensions` (which
39+
* excludes `.cjs`), and keeping it makes our probe order authoritative when
40+
* several `instrument.server.*` files coexist.
41+
*/
42+
export function buildOptionsImport(
43+
entryFilePath: string,
44+
instrumentFilePath: string,
45+
): { optionsFn: string; importStmt: string } {
46+
let relativePath = relative(dirname(entryFilePath), instrumentFilePath).replace(/\\/g, '/');
47+
if (!relativePath.startsWith('.')) relativePath = `./${relativePath}`;
48+
49+
return {
50+
optionsFn: OPTIONS_IMPORT_IDENTIFIER,
51+
importStmt: `import ${OPTIONS_IMPORT_IDENTIFIER} from '${relativePath}';\n`,
52+
};
53+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { existsSync } from 'node:fs';
2+
import { dirname, resolve } from 'node:path';
3+
import { type Unstable_Config, unstable_readConfig } from 'wrangler';
4+
5+
/**
6+
* The slice of the wrangler configuration the auto-instrument plugin cares
7+
* about. `main` is an absolute path (wrangler resolves it against the config
8+
* file's directory).
9+
*/
10+
export interface WranglerConfig {
11+
main?: string;
12+
durableObjects: Array<{ name: string; className: string }>;
13+
}
14+
15+
/**
16+
* Locate and resolve the wrangler configuration via wrangler's own
17+
* `unstable_readConfig` — the API `@cloudflare/vite-plugin` uses.
18+
*
19+
* We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside
20+
* `root` with wrangler's own precedence, since it discovers from `cwd` rather
21+
* than an arbitrary root); wrangler then parses it, flattens the active
22+
* environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute
23+
* path. Durable Object bindings are the active environment's, matching what the
24+
* deployed Worker actually binds.
25+
*
26+
* Returns `undefined` when no config file is found or it can't be read/parsed
27+
* (the caller warns and disables auto-instrumentation rather than failing the
28+
* whole build).
29+
*/
30+
export function resolveWranglerConfig(
31+
root: string,
32+
explicitPath?: string,
33+
): { config: WranglerConfig; configDir: string } | undefined {
34+
const configPath = explicitPath
35+
? resolve(root, explicitPath)
36+
: ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync);
37+
38+
if (!configPath || !existsSync(configPath)) {
39+
return undefined;
40+
}
41+
42+
let raw: Unstable_Config;
43+
try {
44+
// `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO
45+
// migrations) out of the Vite build output.
46+
raw = unstable_readConfig({ config: configPath }, { hideWarnings: true });
47+
} catch {
48+
return undefined;
49+
}
50+
51+
const durableObjects: WranglerConfig['durableObjects'] = [];
52+
const seenClassNames = new Set<string>();
53+
for (const binding of raw.durable_objects?.bindings ?? []) {
54+
// `script_name` bindings reference a class exported by a *different* worker
55+
// — there is nothing to wrap in this worker's entry file.
56+
if (typeof binding?.class_name !== 'string' || binding.script_name || seenClassNames.has(binding.class_name)) {
57+
continue;
58+
}
59+
seenClassNames.add(binding.class_name);
60+
durableObjects.push({ name: binding.name, className: binding.class_name });
61+
}
62+
63+
return {
64+
config: { main: raw.main, durableObjects },
65+
configDir: dirname(raw.configPath ?? configPath),
66+
};
67+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { defineCloudflareOptions } from '../src/defineCloudflareOptions';
3+
4+
describe('defineCloudflareOptions', () => {
5+
it('returns the callback unchanged', () => {
6+
const callback = (env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN });
7+
expect(defineCloudflareOptions(callback)).toBe(callback);
8+
});
9+
10+
it('passes env through to the callback', () => {
11+
const callback = defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
12+
dsn: env.SENTRY_DSN,
13+
tracesSampleRate: 1.0,
14+
}));
15+
16+
expect(callback({ SENTRY_DSN: 'https://example' })).toEqual({
17+
dsn: 'https://example',
18+
tracesSampleRate: 1.0,
19+
});
20+
});
21+
22+
it('normalizes a static options object into a callback', () => {
23+
const callback = defineCloudflareOptions({ tracesSampleRate: 0.5 });
24+
25+
expect(typeof callback).toBe('function');
26+
expect(callback({} as never)).toEqual({ tracesSampleRate: 0.5 });
27+
});
28+
});

0 commit comments

Comments
 (0)