Skip to content

Commit d5649bc

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Wrap vinext Worker entries with @sentry/nextjs/cloudflare
`sentryCloudflareVitePlugin` wrapped the Worker entry of a vinext app with `withSentry` of `@sentry/cloudflare`, so the app sent Cloudflare data but no Next.js spans. In a build with the vinext plugins, the generated code now imports from `@sentry/nextjs/cloudflare` when it resolves from the entry. Other builds, and vinext builds without `@sentry/nextjs`, keep `@sentry/cloudflare`. Vite throws instead of returning `null` when `@sentry/nextjs` is installed in a version without the `./cloudflare` entry, so a throw of the resolve counts as unresolved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4318233 commit d5649bc

3 files changed

Lines changed: 105 additions & 2 deletions

File tree

‎packages/cloudflare/src/vite/autoInstrument.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,39 @@ function normalizePath(path: string): string {
1515
// `.html`, … — sharing the entry's basename must never be treated as the entry.
1616
const JS_EXTENSION_REGEX = /\.[cm]?[jt]sx?$/;
1717

18+
// Re-exports everything the generated code uses, and adds the Next.js span handling and OpenTelemetry context.
19+
const NEXTJS_CLOUDFLARE_MODULE = '@sentry/nextjs/cloudflare';
20+
21+
/**
22+
* Whether `@sentry/nextjs/cloudflare` resolves from `importer`. Vite throws instead of returning `null` when
23+
* `@sentry/nextjs` is installed in a version without that entry.
24+
*/
25+
async function resolvesNextjsCloudflare(resolver: ModuleResolver, importer: string): Promise<boolean> {
26+
try {
27+
return !!(await resolver.resolve?.(NEXTJS_CLOUDFLARE_MODULE, importer));
28+
} catch {
29+
return false;
30+
}
31+
}
32+
1833
export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPath?: string } = {}) {
1934
let wranglerConfig: WranglerConfig | undefined;
2035
let entryFilePath: string | undefined;
2136

2237
let optionsFn = ENV_FALLBACK_OPTIONS_FN;
2338
let optionsImport: string | undefined;
39+
let isVinext = false;
2440

2541
return {
2642
name: 'sentry-cloudflare-auto-instrument',
2743

28-
configResolved(config: { root: string; logger?: { warn(msg: string): void } }): void {
44+
configResolved(config: {
45+
root: string;
46+
plugins?: readonly { name?: string }[];
47+
logger?: { warn(msg: string): void };
48+
}): void {
49+
isVinext = config.plugins?.some(plugin => plugin.name?.startsWith('vinext:')) ?? false;
50+
2951
const result = resolveWranglerConfig(config.root, options.wranglerConfigPath);
3052
if (!result) {
3153
// An explicit path that fails is a misconfiguration worth naming;
@@ -119,6 +141,10 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa
119141
})
120142
: undefined;
121143

144+
// A vinext app with `@sentry/nextjs` installed gets `withSentry` of `@sentry/nextjs/cloudflare`.
145+
const sentryModule =
146+
isVinext && (await resolvesNextjsCloudflare(this, normalizedId)) ? NEXTJS_CLOUDFLARE_MODULE : undefined;
147+
122148
// No registration import is injected here: the orchestrion plugin's
123149
// subscribe-injection makes each bundled package self-register its channel
124150
// subscriber on the global marker, so wrapping the entry with `withSentry`
@@ -129,6 +155,7 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa
129155
optionsFn,
130156
optionsImport,
131157
sameWorkerBindings: wranglerConfig.sameWorkerBindings,
158+
sentryModule,
132159
});
133160

134161
const wrappedClasses = result?.wrappedClasses ?? new Set<string>();

‎packages/cloudflare/src/vite/transform.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export interface TransformContext {
114114
optionsFn: string;
115115
/** Import statement prepended when `optionsFn` references a separate module. */
116116
optionsImport?: string;
117+
/** Module the generated code imports `withSentry` and the class wrappers from. Defaults to `@sentry/cloudflare`. */
118+
sentryModule?: string;
117119
/** @see {@link import('./wranglerConfig').WranglerConfig.sameWorkerBindings} */
118120
sameWorkerBindings?: readonly SameWorkerBinding[];
119121
}
@@ -198,7 +200,7 @@ export function applyAutoInstrumentTransforms(
198200
ms.prepend(buildMergedOptionsDeclaration(sameWorkerBindings, ctx.optionsFn, state));
199201
}
200202
if (ctx.optionsImport) ms.prepend(ctx.optionsImport);
201-
ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n");
203+
ms.prepend(`import * as __SENTRY__ from '${ctx.sentryModule ?? '@sentry/cloudflare'}';\n`);
202204

203205
return {
204206
code: ms.toString(),

‎packages/cloudflare/test/vite/autoInstrument.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,80 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => {
5555
expect(result.code).toContain('__SENTRY__.withSentry(');
5656
});
5757

58+
it('wraps the entry with `@sentry/nextjs/cloudflare` in a vinext build', async () => {
59+
const dir = writeTempDir({ 'wrangler.toml': 'main = "src/index.ts"' });
60+
const plugin = sentryCloudflareAutoInstrumentPlugin();
61+
plugin.configResolved({ root: dir, plugins: [{ name: 'vinext:config' }] });
62+
63+
const code = 'export default { fetch() { return new Response("ok"); } };';
64+
const result = await plugin.transform.call(
65+
{
66+
parse: (c: string) => parseJS(c),
67+
resolve: async (source: string) => (source === '@sentry/nextjs/cloudflare' ? { id: source } : null),
68+
},
69+
code,
70+
join(dir, 'src/index.ts'),
71+
);
72+
73+
expect(result.code).toContain("import * as __SENTRY__ from '@sentry/nextjs/cloudflare';");
74+
expect(result.code).toContain('__SENTRY__.withSentry(');
75+
});
76+
77+
it('wraps the entry with `@sentry/cloudflare` when `@sentry/nextjs/cloudflare` resolves outside a vinext build', async () => {
78+
const dir = writeTempDir({ 'wrangler.toml': 'main = "src/index.ts"' });
79+
const plugin = sentryCloudflareAutoInstrumentPlugin();
80+
plugin.configResolved({ root: dir, plugins: [{ name: 'vite:react' }] });
81+
82+
const code = 'export default { fetch() { return new Response("ok"); } };';
83+
const result = await plugin.transform.call(
84+
{
85+
parse: (c: string) => parseJS(c),
86+
resolve: async (source: string) => (source === '@sentry/nextjs/cloudflare' ? { id: source } : null),
87+
},
88+
code,
89+
join(dir, 'src/index.ts'),
90+
);
91+
92+
expect(result.code).toContain("import * as __SENTRY__ from '@sentry/cloudflare';");
93+
});
94+
95+
it('wraps the entry with `@sentry/cloudflare` in a vinext build without `@sentry/nextjs`', async () => {
96+
const dir = writeTempDir({ 'wrangler.toml': 'main = "src/index.ts"' });
97+
const plugin = sentryCloudflareAutoInstrumentPlugin();
98+
plugin.configResolved({ root: dir, plugins: [{ name: 'vinext:config' }] });
99+
100+
const code = 'export default { fetch() { return new Response("ok"); } };';
101+
const result = await plugin.transform.call(
102+
{ parse: (c: string) => parseJS(c), resolve: async () => null },
103+
code,
104+
join(dir, 'src/index.ts'),
105+
);
106+
107+
expect(result.code).toContain("import * as __SENTRY__ from '@sentry/cloudflare';");
108+
});
109+
110+
it('wraps the entry with `@sentry/cloudflare` in a vinext build with an `@sentry/nextjs` without the `./cloudflare` entry', async () => {
111+
const dir = writeTempDir({ 'wrangler.toml': 'main = "src/index.ts"' });
112+
const plugin = sentryCloudflareAutoInstrumentPlugin();
113+
plugin.configResolved({ root: dir, plugins: [{ name: 'vinext:config' }] });
114+
115+
const code = 'export default { fetch() { return new Response("ok"); } };';
116+
const result = await plugin.transform.call(
117+
{
118+
parse: (c: string) => parseJS(c),
119+
resolve: async () => {
120+
throw new Error(
121+
'"./cloudflare" is not exported under the conditions ["workerd"] from package @sentry/nextjs',
122+
);
123+
},
124+
},
125+
code,
126+
join(dir, 'src/index.ts'),
127+
);
128+
129+
expect(result.code).toContain("import * as __SENTRY__ from '@sentry/cloudflare';");
130+
});
131+
58132
it('leaves an already-manually-wrapped entry untouched', async () => {
59133
const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"');
60134

0 commit comments

Comments
 (0)