Skip to content
Merged
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
8 changes: 8 additions & 0 deletions e2e-tests/tests/cloudflare-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,12 @@ describe('cloudflare-worker', () => {
'"binding": "CF_VERSION_METADATA"',
]);
});

it('modifies the worker file to include Sentry initialization', () => {
checkFileContents(`${projectDir}/src/index.ts`, [
'import * as Sentry from "@sentry/cloudflare";',
'export default Sentry.withSentry(env => ({',
'dsn: "https://[email protected]/1337",',
]);
});
});
42 changes: 30 additions & 12 deletions src/cloudflare/cloudflare-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import { createSentryInitFile } from './sdk-setup';
import { abortIfSpotlightNotSupported } from '../utils/abort-if-sportlight-not-supported';
import { ensureWranglerConfig } from './wrangler/ensure-wrangler-config';
import { updateWranglerConfig } from './wrangler/update-wrangler-config';
import { debug } from '../utils/debug';
import {
defaultEntryPoint,
getEntryPointFromWranglerConfig,
} from './wrangler/get-entry-point-from-wrangler-config';

export async function runCloudflareWizard(
options: WizardOptions,
Expand Down Expand Up @@ -58,16 +63,6 @@ async function runCloudflareWizardWithTelemetry(
ensureWranglerConfig();
});

await traceStep('Update Wrangler config with Sentry requirements', () =>
updateWranglerConfig({
compatibility_flags: ['nodejs_als'],
compatibility_date: new Date().toISOString().slice(0, 10),
version_metadata: {
binding: 'CF_VERSION_METADATA',
},
}),
);

const projectData = await getOrAskForProjectData(
options,
'node-cloudflare-workers',
Expand Down Expand Up @@ -96,8 +91,31 @@ async function runCloudflareWizardWithTelemetry(
},
] as const);

traceStep('Create Sentry initialization', () =>
createSentryInitFile(selectedProject.keys[0].dsn.public, selectedFeatures),
await traceStep('Create Sentry initialization', async () => {
try {
await createSentryInitFile(
selectedProject.keys[0].dsn.public,
selectedFeatures,
);
} catch (e) {
clack.log.warn(
'Could not automatically set up Sentry initialization. Please set it up manually using instructions from https://docs.sentry.io/platforms/javascript/guides/cloudflare/',
);
debug(e);
Comment on lines +99 to +104

This comment was marked as outdated.

}
});

const mainFile = getEntryPointFromWranglerConfig();

await traceStep('Update Wrangler config with Sentry requirements', () =>
updateWranglerConfig({
...(mainFile ? {} : { main: defaultEntryPoint }),
compatibility_flags: ['nodejs_als'],
compatibility_date: new Date().toISOString().slice(0, 10),
version_metadata: {
binding: 'CF_VERSION_METADATA',
},
}),
);

await runPrettierIfInstalled({ cwd: undefined });
Expand Down
73 changes: 64 additions & 9 deletions src/cloudflare/sdk-setup.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,76 @@
// @ts-expect-error - clack is ESM and TS complains about that. It works though
import clack from '@clack/prompts';
import chalk from 'chalk';
import { getCloudflareWorkerTemplate } from './templates';
import fs from 'node:fs';
import path from 'node:path';
import {
getCloudflareWorkerTemplate,
getCloudflareWorkerTemplateWithHandler,
} from './templates';
import {
defaultEntryPoint,
getEntryPointFromWranglerConfig,
} from './wrangler/get-entry-point-from-wrangler-config';
import { wrapWorkerWithSentry } from './wrap-worker';

/**
* Prints the Sentry worker template to the console.
* Currently focused on Cloudflare Workers, but the structure can be
* extended for other Cloudflare products in the future.
* Creates or updates the main worker file with Sentry initialization.
* Currently focused on Cloudflare Workers
*/
export function createSentryInitFile(
export async function createSentryInitFile(
dsn: string,
selectedFeatures: {
performance: boolean;
},
): void {
clack.log.step('Please wrap your handler with Sentry initialization:');
): Promise<void> {
const entryPointFromConfig = getEntryPointFromWranglerConfig();

// eslint-disable-next-line no-console
console.log(chalk.cyan(getCloudflareWorkerTemplate(dsn, selectedFeatures)));
if (!entryPointFromConfig) {
clack.log.info(
'No entry point found in wrangler config, creating a new one.',
);

const cloudflareWorkerTemplate = getCloudflareWorkerTemplateWithHandler();

await fs.promises.mkdir(
path.join(process.cwd(), path.dirname(defaultEntryPoint)),
{
recursive: true,
},
);
await fs.promises.writeFile(
path.join(process.cwd(), defaultEntryPoint),
cloudflareWorkerTemplate,
{ encoding: 'utf-8', flag: 'w' },
);

clack.log.success(`Created ${chalk.cyan(defaultEntryPoint)}.`);

return;
}

const entryPointPath = path.join(process.cwd(), entryPointFromConfig);

if (fs.existsSync(entryPointPath)) {
clack.log.info(
Comment on lines +52 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: For new projects, the wizard creates a wrangler.jsonc pointing to a non-existent entry point, then createSentryInitFile silently fails, leaving the project in a broken state.
Severity: CRITICAL | Confidence: High

🔍 Detailed Analysis

In a fresh project without a wrangler.jsonc, the wizard first calls ensureWranglerConfig(), which creates a config file pointing to a non-existent entry point like src/index.ts. Subsequently, createSentryInitFile() is called. It reads this entry point, but because the file doesn't exist (fs.existsSync() returns false), the function silently returns without creating the necessary Sentry initialization file. The wizard completes successfully, but the wrangler.jsonc remains pointing to a non-existent file, causing subsequent wrangler deploy commands to fail.

💡 Suggested Fix

The createSentryInitFile function should handle the case where the entry point file does not exist. Instead of silently returning, it should create the entry point file with the Sentry wrapper. This ensures the file pointed to by wrangler.jsonc actually exists and is correctly configured.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/cloudflare/sdk-setup.ts#L52-L55

Potential issue: In a fresh project without a `wrangler.jsonc`, the wizard first calls
`ensureWranglerConfig()`, which creates a config file pointing to a non-existent entry
point like `src/index.ts`. Subsequently, `createSentryInitFile()` is called. It reads
this entry point, but because the file doesn't exist (`fs.existsSync()` returns false),
the function silently returns without creating the necessary Sentry initialization file.
The wizard completes successfully, but the `wrangler.jsonc` remains pointing to a
non-existent file, causing subsequent `wrangler deploy` commands to fail.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 8325139

`Found existing entry point: ${chalk.cyan(entryPointFromConfig)}`,
);

try {
await wrapWorkerWithSentry(entryPointPath, dsn, selectedFeatures);
clack.log.success(
`Wrapped ${chalk.cyan(
entryPointFromConfig,
)} with Sentry initialization.`,
);
} catch (error) {
clack.log.warn('Failed to wrap worker automatically.');
clack.log.step('Please wrap your handler with Sentry initialization:');

clack.note(
chalk.cyan(getCloudflareWorkerTemplate(dsn, selectedFeatures)),
);
}
return;
}
}
17 changes: 17 additions & 0 deletions src/cloudflare/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,20 @@ export default Sentry.withSentry(
);
`;
}

export function getCloudflareWorkerTemplateWithHandler(): string {
return `export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/message':
return new Response('Hello, World!');
case '/random':
return new Response(crypto.randomUUID());
default:
return new Response('Not Found', { status: 404 });
}
},
} satisfies ExportedHandler<Env>;
`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from 'node:path';
import { findWranglerConfig } from './find-wrangler-config';
import { parseJsonC, getObjectProperty } from '../../utils/ast-utils';

export const defaultEntryPoint = 'src/index.ts';

/**
* Reads the main entry point from the wrangler config file
* Returns undefined if no config exists or if main field is not specified
Expand Down
119 changes: 119 additions & 0 deletions src/cloudflare/wrap-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import * as recast from 'recast';
import type { namedTypes as t } from 'ast-types';
// @ts-expect-error - magicast is ESM and TS complains about that. It works though
import { loadFile, writeFile } from 'magicast';
// @ts-expect-error - clack is ESM and TS complains about that. It works though
import * as clack from '@clack/prompts';
import { hasSentryContent } from '../utils/ast-utils';
import chalk from 'chalk';
import { ExpressionKind } from 'ast-types/lib/gen/kinds';

const b = recast.types.builders;

/**
* Wraps a Cloudflare Worker's default export with Sentry.withSentry()
*
* Before:
* ```
* export default {
* async fetch(request, env, ctx) { ... }
* } satisfies ExportedHandler<Env>;
* ```
*
* After:
* ```
* import * as Sentry from '@sentry/cloudflare';
*
* export default Sentry.withSentry(
* (env) => ({
* dsn: 'your-dsn',
* tracesSampleRate: 1,
* }),
* {
* async fetch(request, env, ctx) { ... }
* } satisfies ExportedHandler<Env>
* );
* ```
*
* @param workerFilePath - Path to the worker file to wrap
* @param dsn - Sentry DSN for initialization
* @param selectedFeatures - Feature flags for optional Sentry features
*/
export async function wrapWorkerWithSentry(
workerFilePath: string,
dsn: string,
selectedFeatures: {
performance: boolean;
},
): Promise<void> {
const workerAst = await loadFile(workerFilePath);

if (hasSentryContent(workerAst.$ast as t.Program)) {
clack.log.warn(
`Sentry is already configured in ${chalk.cyan(
workerFilePath,
)}. Skipping wrapping with Sentry.`,
);
return;
}

workerAst.imports.$add({
from: '@sentry/cloudflare',
imported: '*',
local: 'Sentry',
});

recast.visit(workerAst.$ast, {
visitExportDefaultDeclaration(path) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const originalDeclaration = path.value.declaration as ExpressionKind;
const sentryConfig = createSentryConfigFunction(dsn, selectedFeatures);
const wrappedExport = b.callExpression(
b.memberExpression(b.identifier('Sentry'), b.identifier('withSentry')),
[sentryConfig, originalDeclaration],
);

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
path.value.declaration = wrappedExport;

return false;
},
});

await writeFile(workerAst.$ast, workerFilePath);
}

/**
* Creates the Sentry config function: (env) => ({ dsn: '...', ... })
*/
function createSentryConfigFunction(
dsn: string,
selectedFeatures: {
performance: boolean;
},
): t.ArrowFunctionExpression {
const configProperties: t.ObjectProperty[] = [
b.objectProperty(b.identifier('dsn'), b.stringLiteral(dsn)),
];

if (selectedFeatures.performance) {
const tracesSampleRateProperty = b.objectProperty(
b.identifier('tracesSampleRate'),
b.numericLiteral(1),
);

tracesSampleRateProperty.comments = [
b.commentLine(
' Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.',
true,
false,
),
];

configProperties.push(tracesSampleRateProperty);
}

const configObject = b.objectExpression(configProperties);

return b.arrowFunctionExpression([b.identifier('env')], configObject);
}
Loading
Loading