Skip to content

Commit eb2d012

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}`, 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 `jsonc-parser`, `smol-toml`, and `magic-string` as dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent afcb71d commit eb2d012

8 files changed

Lines changed: 496 additions & 3 deletions

File tree

packages/cloudflare/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@
6666
"@opentelemetry/api": "^1.9.1",
6767
"@sentry/core": "10.65.0",
6868
"@sentry/node": "10.65.0",
69-
"@sentry/server-utils": "10.65.0"
69+
"@sentry/server-utils": "10.65.0",
70+
"jsonc-parser": "^3.3.1",
71+
"magic-string": "~0.30.21",
72+
"smol-toml": "^1.7.0"
7073
},
7174
"peerDependencies": {
7275
"@cloudflare/workers-types": "^4.x || ^5.x"
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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { dirname, resolve } from 'node:path';
3+
import * as jsoncParser from 'jsonc-parser';
4+
import TOML from 'smol-toml';
5+
6+
/**
7+
* The slice of the wrangler configuration the auto-instrument plugin cares
8+
* about, normalized into a single shape regardless of the source format.
9+
*/
10+
export interface WranglerConfig {
11+
main?: string;
12+
durableObjects: Array<{ name: string; className: string }>;
13+
}
14+
15+
/**
16+
* The raw wrangler config as parsed from disk. Both TOML and JSONC decode to
17+
* this shape (snake_case keys, `durable_objects.bindings`), matching wrangler's
18+
* own schema; {@link normalizeWranglerConfig} maps it to {@link WranglerConfig}.
19+
* Named environments (`[env.<name>]` / `"env"`) repeat the same shape.
20+
*/
21+
interface RawWranglerEnvironment {
22+
main?: string;
23+
durable_objects?: { bindings?: Array<{ name: string; class_name: string; script_name?: string }> };
24+
}
25+
26+
interface RawWranglerConfig extends RawWranglerEnvironment {
27+
env?: Record<string, RawWranglerEnvironment>;
28+
}
29+
30+
/**
31+
* Locate and parse a wrangler configuration file.
32+
*
33+
* When `explicitPath` is provided it is resolved against `root` and used
34+
* directly. Otherwise the function probes for `wrangler.json`,
35+
* `wrangler.jsonc` and `wrangler.toml` inside `root` — the same precedence
36+
* wrangler itself applies, so we read the file wrangler would actually use
37+
* when more than one exists.
38+
*
39+
* Returns `undefined` when no config file exists or the file cannot be parsed
40+
* (the caller warns and disables auto-instrumentation rather than failing the
41+
* whole build on a malformed config).
42+
*/
43+
export function resolveWranglerConfig(
44+
root: string,
45+
explicitPath?: string,
46+
): { config: WranglerConfig; configDir: string } | undefined {
47+
if (explicitPath) {
48+
const filePath = resolve(root, explicitPath);
49+
if (!existsSync(filePath)) return undefined;
50+
const config = parseWranglerFile(filePath);
51+
return config && { config, configDir: dirname(filePath) };
52+
}
53+
54+
for (const filename of ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml']) {
55+
const filePath = resolve(root, filename);
56+
if (existsSync(filePath)) {
57+
const config = parseWranglerFile(filePath);
58+
return config && { config, configDir: root };
59+
}
60+
}
61+
62+
return undefined;
63+
}
64+
65+
/**
66+
* Parse a wrangler config file into the normalized {@link WranglerConfig}.
67+
*
68+
* TOML is parsed with `smol-toml` and JSON/JSONC with `jsonc-parser` — the same
69+
* libraries wrangler itself uses — so comments, trailing commas and the full
70+
* TOML grammar are handled correctly rather than approximated. Returns
71+
* `undefined` for empty or unparseable files.
72+
*/
73+
function parseWranglerFile(filePath: string): WranglerConfig | undefined {
74+
let raw: unknown;
75+
try {
76+
const content = readFileSync(filePath, 'utf-8');
77+
raw = filePath.endsWith('.toml') ? TOML.parse(content) : jsoncParser.parse(content);
78+
} catch {
79+
return undefined;
80+
}
81+
if (typeof raw !== 'object' || raw === null) return undefined;
82+
return normalizeWranglerConfig(raw as RawWranglerConfig);
83+
}
84+
85+
function normalizeWranglerConfig(raw: RawWranglerConfig): WranglerConfig {
86+
// `main` follows the active wrangler environment (selected via CLOUDFLARE_ENV,
87+
// as `@cloudflare/vite-plugin` does). Durable Object class names are unioned
88+
// across ALL environments instead: wrapping a class that is only bound in
89+
// another environment is harmless, while missing one loses instrumentation.
90+
const activeEnvName = process.env.CLOUDFLARE_ENV;
91+
const activeEnv = activeEnvName ? raw.env?.[activeEnvName] : undefined;
92+
93+
const durableObjects: WranglerConfig['durableObjects'] = [];
94+
const seenClassNames = new Set<string>();
95+
for (const environment of [raw, ...Object.values(raw.env ?? {})]) {
96+
for (const binding of environment.durable_objects?.bindings ?? []) {
97+
// `script_name` bindings reference a class exported by a *different*
98+
// worker — there is nothing to wrap in this worker's entry file.
99+
if (typeof binding?.class_name !== 'string' || binding.script_name || seenClassNames.has(binding.class_name)) {
100+
continue;
101+
}
102+
seenClassNames.add(binding.class_name);
103+
durableObjects.push({ name: binding.name, className: binding.class_name });
104+
}
105+
}
106+
107+
return { main: activeEnv?.main ?? raw.main, durableObjects };
108+
}
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)