From 859e5fe2828712c0ec8e27d6eaf93bf6f85255a3 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 16:31:08 +0200 Subject: [PATCH] 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 --- packages/cloudflare/package.json | 9 +- .../cloudflare/src/defineCloudflareOptions.ts | 46 ++++ packages/cloudflare/src/index.ts | 1 + .../cloudflare/src/vite/instrumentFile.ts | 53 ++++ .../cloudflare/src/vite/wranglerConfig.ts | 67 +++++ .../test/defineCloudflareOptions.test.ts | 28 ++ .../test/vite/wranglerConfig.test.ts | 250 ++++++++++++++++++ yarn.lock | 12 +- 8 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 packages/cloudflare/src/defineCloudflareOptions.ts create mode 100644 packages/cloudflare/src/vite/instrumentFile.ts create mode 100644 packages/cloudflare/src/vite/wranglerConfig.ts create mode 100644 packages/cloudflare/test/defineCloudflareOptions.test.ts create mode 100644 packages/cloudflare/test/vite/wranglerConfig.test.ts diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 33a0228e9157..ddce243bb58b 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -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": { diff --git a/packages/cloudflare/src/defineCloudflareOptions.ts b/packages/cloudflare/src/defineCloudflareOptions.ts new file mode 100644 index 000000000000..6e905dad7fa1 --- /dev/null +++ b/packages/cloudflare/src/defineCloudflareOptions.ts @@ -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( + optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined), +): (env: Env) => CloudflareOptions | undefined { + if (typeof optionsOrCallback === 'function') { + return optionsOrCallback as (env: Env) => CloudflareOptions | undefined; + } + + return () => optionsOrCallback; +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 20a537c5b307..b93d9626bde9 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -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'; diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts new file mode 100644 index 000000000000..ffd79ca0104c --- /dev/null +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -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`, + }; +} diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts new file mode 100644 index 000000000000..267e0fb10f4c --- /dev/null +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -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(); + 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), + }; +} diff --git a/packages/cloudflare/test/defineCloudflareOptions.test.ts b/packages/cloudflare/test/defineCloudflareOptions.test.ts new file mode 100644 index 000000000000..08a6a8233024 --- /dev/null +++ b/packages/cloudflare/test/defineCloudflareOptions.test.ts @@ -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 }); + }); +}); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts new file mode 100644 index 000000000000..04b22cbffb67 --- /dev/null +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -0,0 +1,250 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveWranglerConfig } from '../../src/vite/wranglerConfig'; + +function writeTempDir(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +describe('resolveWranglerConfig', () => { + it('parses wrangler.toml', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDurableObject"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + // wrangler resolves `main` to an absolute path against the config dir. + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); + }); + + it('parses wrangler.json', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/worker.ts', + durable_objects: { + bindings: [{ name: 'DO_A', class_name: 'A' }], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/worker.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO_A', className: 'A' }]); + }); + + it('parses wrangler.jsonc (strips comments)', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' // Entry point', + ' "main": "src/index.ts",', + ' /* DO bindings */', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" }', + ' ]', + ' }', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses JSONC with trailing commas', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' "main": "src/index.ts",', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" },', + ' ],', + ' },', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses TOML single-quoted (literal) strings', () => { + const dir = writeTempDir({ 'wrangler.toml': "main = 'src/index.ts'" }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + }); + + it('prefers wrangler.json over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.json': '{ "main": "from-json.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'from-json.ts')); + }); + + it('prefers wrangler.jsonc over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.jsonc': '{ "main": "from-jsonc.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'from-jsonc.ts')); + }); + + it('handles TOML with commented-out bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '# [[durable_objects.bindings]]', + '# name = "IGNORED"', + '# class_name = "IgnoredDO"', + '', + '[[durable_objects.bindings]]', + 'name = "REAL"', + 'class_name = "RealDO"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'REAL', className: 'RealDO' }]); + }); + + it('handles multiple DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_A"', + 'class_name = "A"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_B"', + 'class_name = "B"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toHaveLength(2); + expect(result!.config.durableObjects[0]).toEqual({ name: 'DO_A', className: 'A' }); + expect(result!.config.durableObjects[1]).toEqual({ name: 'DO_B', className: 'B' }); + }); + + it('returns undefined when no config exists', () => { + const dir = writeTempDir({}); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for explicit non-existent path', () => { + expect(resolveWranglerConfig('/tmp', '/tmp/nonexistent.toml')).toBeUndefined(); + }); + + it('resolves a relative explicit path against the root', () => { + const dir = writeTempDir({ 'custom.toml': 'main = "src/index.ts"' }); + + const result = resolveWranglerConfig(dir, 'custom.toml'); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.configDir).toBe(dir); + }); + + it('returns undefined for an empty config file instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.json': '' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for invalid TOML instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.toml': 'main = [' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('skips DO bindings with a script_name (class lives in another worker)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'LOCAL', class_name: 'LocalDO' }, + { name: 'EXTERNAL', class_name: 'ExternalDO', script_name: 'other-worker' }, + ], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'LOCAL', className: 'LocalDO' }]); + }); + + it('uses only the active environment DO bindings (does not union across envs)', () => { + // wrangler flattens to the active environment (top level here, since no + // CLOUDFLARE_ENV), matching what the deployed Worker actually binds. A + // class bound only in a non-active env is intentionally not included. + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + production: { + durable_objects: { + bindings: [{ name: 'PROD_ONLY', class_name: 'ProdDO' }], + }, + }, + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'TOP', className: 'TopDO' }]); + }); + + it('honors CLOUDFLARE_ENV for both main and DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + staging: { + main: 'src/staging.ts', + durable_objects: { bindings: [{ name: 'STAGING_DO', class_name: 'StagingDO' }] }, + }, + }, + }), + }); + + const previous = process.env.CLOUDFLARE_ENV; + process.env.CLOUDFLARE_ENV = 'staging'; + try { + const result = resolveWranglerConfig(dir)!; + expect(result.config.main).toBe(join(dir, 'src/staging.ts')); + expect(result.config.durableObjects).toEqual([{ name: 'STAGING_DO', className: 'StagingDO' }]); + } finally { + if (previous === undefined) delete process.env.CLOUDFLARE_ENV; + else process.env.CLOUDFLARE_ENV = previous; + } + }); +}); diff --git a/yarn.lock b/yarn.lock index 381e90c80f85..5755e951e6ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19920,6 +19920,11 @@ jsonc-parser@3.2.0, jsonc-parser@^3.0.0: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsondiffpatch@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz#daa6a25bedf0830974c81545568d5f671c82551f" @@ -20795,7 +20800,7 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -27294,6 +27299,11 @@ smol-toml@1.6.1: resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== +smol-toml@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.7.0.tgz#ed1b259ce7e05907df1abe758971bd0a0ef2c0dd" + integrity sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ== + snake-case@^3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"