Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,13 @@ interface BaseCloudflareOptions {
*/
durableObjectStorageSpanAllowlist?: Array<string | RegExp>;

/**
* Sets an optional server name (device name).
*
* This is useful for identifying which server or instance is sending events.
*/
serverName?: string;

/**
* If you use Spotlight by Sentry during development, use
* this option to forward captured Sentry events to Spotlight.
Expand Down
12 changes: 11 additions & 1 deletion packages/cloudflare/src/defineCloudflareOptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CloudflareOptions } from './client';
import type { DefaultEnv } from './types';
import type { DefaultEnv, StrictCloudflareOptions } from './types';

/**
* Define the Sentry options for a Cloudflare Worker in a dedicated module.
Expand Down Expand Up @@ -35,6 +35,16 @@ import type { DefaultEnv } from './types';
* export default defineCloudflareOptions({ tracesSampleRate: 1.0 });
* ```
*/
// Overloads rather than a union parameter: TypeScript does not infer `O` out of a union member,
// so a union signature falls back to the default and the unknown-key check never runs. The object
// overload stays on plain `CloudflareOptions` — a direct object literal is still excess property
// checked, and a callback cannot match it.
export function defineCloudflareOptions<Env = DefaultEnv, O = unknown>(
callback: (env: Env) => StrictCloudflareOptions<O> | undefined,
): (env: Env) => CloudflareOptions | undefined;
export function defineCloudflareOptions<Env = DefaultEnv>(
options: CloudflareOptions,
): (env: Env) => CloudflareOptions | undefined;
export function defineCloudflareOptions<Env = DefaultEnv>(
optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined),
): (env: Env) => CloudflareOptions | undefined {
Expand Down
8 changes: 5 additions & 3 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { init } from './sdk';
import { instrumentContext } from './utils/instrumentContext';
import { hasRpcMeta } from './utils/rpcMeta';
import { instrumentCloudflareAgent } from './instrumentations/agents';
import type { DefaultEnv, ResolveEnv } from './types';
import type { DefaultEnv, ResolveEnv, StrictCloudflareOptions } from './types';
import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry';

/**
Expand Down Expand Up @@ -433,7 +433,8 @@ export function instrumentDurableObjectWithSentry<
T extends DurableObject<any> = DurableObject<Env>,
// oxlint-disable-next-line typescript/no-explicit-any
C extends new (state: DurableObjectState, env: any) => T = new (state: DurableObjectState, env: any) => T,
>(optionsCallback: (env: ResolveEnv<C, Env>) => CloudflareOptions, DurableObjectClass: C): C {
O = unknown,
>(optionsCallback: (env: ResolveEnv<C, Env>) => StrictCloudflareOptions<O>, DurableObjectClass: C): C {
return new Proxy(DurableObjectClass, {
construct(target, [ctx, env], newTarget) {
const { obj, options, context, frameworkManagedMethods } = constructInstrumentedDurableObject(
Expand Down Expand Up @@ -494,7 +495,8 @@ export function instrumentAgentWithSentry<
T extends DurableObject<any> = DurableObject<Env>,
// oxlint-disable-next-line typescript/no-explicit-any
C extends new (state: DurableObjectState, env: any) => T = new (state: DurableObjectState, env: any) => T,
>(optionsCallback: (env: ResolveEnv<C, Env>) => CloudflareOptions, AgentClass: C): C {
O = unknown,
>(optionsCallback: (env: ResolveEnv<C, Env>) => StrictCloudflareOptions<O>, AgentClass: C): C {
return new Proxy(AgentClass, {
construct(target, [ctx, env], newTarget) {
const { obj, options, context, frameworkManagedMethods } = constructInstrumentedDurableObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from '../client';
import { getFinalOptions } from '../options';
import type { DefaultEnv, ResolveEnv } from '../types';
import type { DefaultEnv, ResolveEnv, StrictCloudflareOptions } from '../types';
import { instrumentContext } from '../utils/instrumentContext';
import { extractRpcMeta } from '../utils/rpcMeta';
import { type UncheckedMethod, wrapMethodWithSentry } from '../wrapMethodWithSentry';
Expand Down Expand Up @@ -155,7 +155,8 @@ export function instrumentWorkerEntrypoint<
T extends WorkerEntrypoint<any, any> = WorkerEntrypoint<Env, Props>,
// oxlint-disable-next-line typescript/no-explicit-any
C extends new (ctx: ExecutionContext, env: any) => T = new (ctx: ExecutionContext, env: any) => T,
>(optionsCallback: (env: ResolveEnv<C, Env>) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C {
O = unknown,
>(optionsCallback: (env: ResolveEnv<C, Env>) => StrictCloudflareOptions<O> | undefined, WorkerEntrypointClass: C): C {
// Set up AsyncLocalStorage strategy ONCE at instrumentation time, not per-request
// This is critical - calling this per-request would create a new AsyncLocalStorage
// each time, breaking scope isolation for concurrent requests
Expand Down
26 changes: 26 additions & 0 deletions packages/cloudflare/src/pages-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { wrapRequestHandlerWithInit } from './request';
import { init } from './sdk';
import type { StrictCloudflareOptions } from './types';

/**
* Plugin middleware for Cloudflare Pages.
Expand Down Expand Up @@ -35,6 +36,31 @@ import { init } from './sdk';
* @param handlerOrOptions Configuration options or a function that returns configuration options.
* @returns A plugin function that can be used in Cloudflare Pages.
*/
// Overloads rather than a union parameter: TypeScript does not infer `O` out of a union member,
// so a union signature falls back to the default and the unknown-key check never runs. The object
// overload stays on plain `CloudflareOptions` — a direct object literal is still excess property
// checked, and a callback cannot match it.
export function sentryPagesPlugin<
// oxlint-disable-next-line typescript/no-explicit-any
Env = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Params extends string = any,
Data extends Record<string, unknown> = Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PluginParams = any,
O = unknown,
>(
handler: (context: EventPluginContext<Env, Params, Data, PluginParams>) => StrictCloudflareOptions<O>,
): PagesPluginFunction<Env, Params, Data, PluginParams>;
export function sentryPagesPlugin<
// oxlint-disable-next-line typescript/no-explicit-any
Env = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Params extends string = any,
Data extends Record<string, unknown> = Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PluginParams = any,
>(options: CloudflareOptions): PagesPluginFunction<Env, Params, Data, PluginParams>;
export function sentryPagesPlugin<
// oxlint-disable-next-line typescript/no-explicit-any
Env = any,
Expand Down
31 changes: 31 additions & 0 deletions packages/cloudflare/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
import type { env as cloudflareEnv } from 'cloudflare:workers';
import type { CloudflareOptions } from './client';

type IsAny<T> = 0 extends 1 & T ? true : false;

/**
* `CloudflareOptions` for the options *callbacks*, where the excess property check does not reach.
*
* A plain `CloudflareOptions` parameter already rejects unknown keys when the caller writes a
* direct object literal, which is why it is enough everywhere else. That check only applies to
* literals TypeScript still sees as "fresh", though, and freshness is lost across a function
* boundary — the literal returned from `withSentry(() => ({ dsn, tracesSampleRte: 1 }), handler)`
* is compared as part of a function type, so the typo passes. Since `env` only exists at request
* time, a callback is the only way to configure these APIs, so the check has to be rebuilt:
* inferring the literal into `O` and intersecting it with a `never` map over its extra keys puts
* the error back on the offending property.
*
* `O` is deliberately unconstrained: a `CloudflareOptions` constraint makes inference fail for an
* options object whose keys are *all* unknown, and TypeScript then silently falls back to the type
* parameter default instead of reporting anything. The `CloudflareOptions` member of the
* intersection carries the actual check on known keys.
*
* The function-rejecting branch keeps "returned the options factory instead of calling it" an
* error. A plain `CloudflareOptions` target rejects functions via the weak type check (no shared
* properties), but an intersection is only weak-type checked when every member is weak, and the
* `Record` member has no properties at all — so without the guard a function would slip through.
* `keyof` of a function type is `never`, so the never-map alone cannot catch it.
*
* Two known gaps: this only covers top level keys, and unlike a plain excess property check it
* also rejects a pre-built object carrying extra keys.
*/
export type StrictCloudflareOptions<O> = O extends (...args: never[]) => unknown
? never
: O & CloudflareOptions & Record<Exclude<keyof O, keyof CloudflareOptions>, never>;

/**
* A handler method of an `ExportedHandler` (`fetch`, `scheduled`, `queue`, ...).
*/
Expand Down
6 changes: 3 additions & 3 deletions packages/cloudflare/src/withSentry.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from './client';
import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail';
import { instrumentExportedHandlerFetch } from './instrumentations/worker/instrumentFetch';
import { instrumentExportedHandlerQueue } from './instrumentations/worker/instrumentQueue';
import { instrumentExportedHandlerScheduled } from './instrumentations/worker/instrumentScheduled';
import { instrumentExportedHandlerTail } from './instrumentations/worker/instrumentTail';
import { isCloudflareClass } from './utils/isCloudflareClass';
import type { AnyExportedHandler, DefaultEnv, ResolveEnv } from './types';
import type { AnyExportedHandler, DefaultEnv, ResolveEnv, StrictCloudflareOptions } from './types';
import {
instrumentWorkerEntrypoint,
type WorkerEntrypointConstructor,
Expand All @@ -31,7 +30,8 @@ export function withSentry<
T extends AnyExportedHandler | WorkerEntrypointConstructor<any, any> =
| ExportedHandler<Env, QueueHandlerMessage, CfHostMetadata>
| WorkerEntrypointConstructor<Env>,
>(optionsCallback: (env: ResolveEnv<T, Env>) => CloudflareOptions | undefined, handler: T): T {
O = unknown,
>(optionsCallback: (env: ResolveEnv<T, Env>) => StrictCloudflareOptions<O> | undefined, handler: T): T {
if (isCloudflareClass(handler, 'WorkerEntrypoint')) {
// oxlint-disable-next-line typescript/no-explicit-any
return instrumentWorkerEntrypoint(optionsCallback as any, handler);
Expand Down
5 changes: 3 additions & 2 deletions packages/cloudflare/src/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { instrumentEnv } from './instrumentations/worker/instrumentEnv';
import { addCloudResourceContext } from './scope-utils';
import { init } from './sdk';
import { instrumentContext } from './utils/instrumentContext';
import type { DefaultEnv, ResolveEnv } from './types';
import type { DefaultEnv, ResolveEnv, StrictCloudflareOptions } from './types';

const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;

Expand Down Expand Up @@ -213,7 +213,8 @@ export function instrumentWorkflowWithSentry<
// oxlint-disable-next-line typescript/no-explicit-any
env: any,
) => T, // Constructor type of the WorkflowEntrypoint class
>(optionsCallback: (env: ResolveEnv<C, E>) => CloudflareOptions, WorkFlowClass: C): C {
O = unknown,
>(optionsCallback: (env: ResolveEnv<C, E>) => StrictCloudflareOptions<O>, WorkFlowClass: C): C {
return new Proxy(WorkFlowClass, {
// oxlint-disable-next-line typescript/no-explicit-any
construct(target: C, args: [ctx: ExecutionContext, env: any], newTarget) {
Expand Down
137 changes: 137 additions & 0 deletions packages/cloudflare/test/options.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { DurableObject, WorkerEntrypoint, WorkflowEntrypoint } from 'cloudflare:workers';
import { describe, it } from 'vitest';
import type { CloudflareOptions } from '../src/client';
import { defineCloudflareOptions } from '../src/defineCloudflareOptions';
import { instrumentAgentWithSentry, instrumentDurableObjectWithSentry } from '../src/durableobject';
import { instrumentWorkerEntrypoint } from '../src/instrumentations/instrumentWorkerEntrypoint';
import { sentryPagesPlugin } from '../src/pages-plugin';
import { withSentry } from '../src/withSentry';
import { instrumentWorkflowWithSentry } from '../src/workflows';

interface TestEnv {
SENTRY_DSN: string;
}

const dsn = 'https://public@dsn.ingest.sentry.io/1337';

const handler = {
fetch(): Response {
return new Response('ok');
},
} satisfies ExportedHandler<TestEnv>;

class TestDurableObject extends DurableObject<TestEnv> {}

class TestWorkerEntrypoint extends WorkerEntrypoint<TestEnv> {
public ping(): string {
return 'pong';
}
}

class TestWorkflow extends WorkflowEntrypoint<TestEnv> {
public async run(): Promise<void> {}
}

declare const flag: boolean;
declare const preTypedOptions: CloudflareOptions;
declare const makeOptions: () => CloudflareOptions;

// The options callback returns a *fresh* object literal across a function boundary, where
// TypeScript's excess property check does not reach. `StrictCloudflareOptions` restores it —
// without these assertions a typo like `tracesSampleRte` silently compiles.
//
// Keep each asserted call on a single line: the formatter wraps longer calls and would move
// the `@ts-expect-error` directive away from the line the error is reported on.
describe('options are checked for unknown keys', () => {
it('rejects an unknown key alongside valid ones', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
withSentry(env => ({ dsn: env.SENTRY_DSN, wrongKey: 123 }), handler);
});

it('rejects an options object where every key is unknown', () => {
// @ts-expect-error - `tracesSampleRte` is a typo for `tracesSampleRate`
withSentry(() => ({ tracesSampleRte: 1 }), handler);
});

it('rejects a wrong value type on a known key', () => {
// @ts-expect-error - `tracesSampleRate` is a number
withSentry(() => ({ dsn, tracesSampleRate: 'high' }), handler);
});

it('rejects a callback returning a function instead of options', () => {
// @ts-expect-error - the options factory was returned instead of called
withSentry(() => makeOptions, handler);
});

it('rejects unknown keys in instrumentDurableObjectWithSentry', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
instrumentDurableObjectWithSentry(() => ({ dsn, wrongKey: 1 }), TestDurableObject);
});

it('rejects unknown keys in instrumentAgentWithSentry', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
instrumentAgentWithSentry(() => ({ dsn, wrongKey: 1 }), TestDurableObject);
});

it('rejects unknown keys in instrumentWorkerEntrypoint', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
instrumentWorkerEntrypoint(() => ({ dsn, wrongKey: 1 }), TestWorkerEntrypoint);
});

it('rejects unknown keys in instrumentWorkflowWithSentry', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
instrumentWorkflowWithSentry(() => ({ dsn, wrongKey: 1 }), TestWorkflow);
});

it('rejects unknown keys in defineCloudflareOptions', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
defineCloudflareOptions(() => ({ dsn, wrongKey: 1 }));
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
defineCloudflareOptions({ dsn, wrongKey: 1 });
});

it('rejects unknown keys in sentryPagesPlugin', () => {
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
sentryPagesPlugin(() => ({ dsn, wrongKey: 1 }));
// @ts-expect-error - `wrongKey` is not a CloudflareOptions key
sentryPagesPlugin({ dsn, wrongKey: 1 });
});
});

describe('valid options keep compiling', () => {
it('accepts known keys, including Cloudflare-specific ones', () => {
withSentry(
env => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1,
serverName: 'my-worker',
enableRpcTracePropagation: false,
durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/],
beforeSend: event => event,
integrations: [],
}),
handler,
);
});

it('accepts arbitrary keys under `_experiments`', () => {
withSentry(() => ({ _experiments: { someExperimentalFlag: true } }), handler);
});

it('accepts an undefined return, conditional or not', () => {
withSentry(() => undefined, handler);
withSentry(() => (flag ? { dsn } : undefined), handler);
});

it('accepts a pre-typed options object and spreads of it', () => {
withSentry(() => preTypedOptions, handler);
withSentry(() => ({ ...preTypedOptions, dsn }), handler);
});

it('still infers the env from the handler', () => {
withSentry(env => {
const envDsn: string = env.SENTRY_DSN;
return { dsn: envDsn };
}, handler);
});
});
8 changes: 8 additions & 0 deletions packages/cloudflare/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ import baseConfig from '../../vite/vite.config';

export default defineConfig({
...baseConfig,
test: {
...baseConfig.test,
typecheck: {
enabled: true,
tsconfig: './tsconfig.test.json',
ignoreSourceErrors: true,
},
},
});
6 changes: 5 additions & 1 deletion packages/hono/src/cloudflare/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ export function sentry<E extends Env>(

honoOptions.debug && debug.log('Initialized Sentry Hono middleware (Cloudflare)');

// `shouldHandleError` is a middleware option, not an SDK option — it is read from
// `options` directly in the response handler below.
const { shouldHandleError: _shouldHandleError, ...sdkOptions } = honoOptions;

return {
...honoOptions,
...sdkOptions,
ignoreSpans: [...(honoOptions.ignoreSpans || []), ...LOW_QUALITY_TRANSACTION_PATTERNS],
// Always filter out the Hono integration from defaults and user integrations.
// The Hono integration is already set up by withSentry, so adding it again would cause capturing too early (in Cloudflare SDK) and non-parametrized URLs.
Expand Down
Loading