From 27bc6831701c7518b3999d0f2adcd807173df4af Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 3 Aug 2026 11:21:27 +0200 Subject: [PATCH 1/2] fix(cloudflare): Restore env type inference for options callbacks The env parameter of the options callbacks of withSentry, instrumentDurableObjectWithSentry and instrumentWorkflowWithSentry collapsed to unknown when the handler was typed via satisfies, a pre-typed constant, a Hono app or a bare WorkerEntrypoint / DurableObject / WorkflowEntrypoint class, so property access on env failed to compile. Resolve the callback env from the passed handler first (its handler methods or class constructor), then the explicit Env generic, then the wrangler-generated Cloudflare.Env, and fall back to any so untyped setups keep compiling. Also loosen the constructor constraint of instrumentWorkflowWithSentry: workers-types defaults WorkflowEntrypoint's Env to unknown (unlike WorkerEntrypoint / DurableObject), so bare workflow classes were rejected in wrangler-types projects. Add a tsc type-test suite to the Cloudflare integration tests covering the inference scenarios against workers-types v4 and the built d.ts, including a separate program simulating a wrangler types project, and drop the now-unneeded @ts-expect-error in the TanStack Start e2e app. Co-Authored-By: Claude --- .../cloudflare-integration-tests/package.json | 3 +- .../suites/types/durableobject.ts | 68 ++++ .../suites/types/misc.ts | 42 +++ .../suites/types/tsconfig.json | 5 + .../suites/types/tsconfig.v4.json | 18 + .../suites/types/tsconfig.v5.json | 9 + .../suites/types/typegen/tsconfig.v4.json | 18 + .../suites/types/typegen/tsconfig.v5.json | 9 + .../suites/types/typegen/withsentry.ts | 81 +++++ .../suites/types/withsentry-props.ts | 50 +++ .../suites/types/withsentry.ts | 311 ++++++++++++++++++ .../suites/types/workerentrypoint.ts | 77 +++++ .../suites/types/workflow.ts | 51 +++ .../tsconfig.json | 5 +- .../cloudflare-agent/worker/index.ts | 8 +- .../src/server.ts | 1 - .../cloudflare/src/defineCloudflareOptions.ts | 4 +- packages/cloudflare/src/durableobject.ts | 21 +- .../instrumentWorkerEntrypoint.ts | 13 +- .../worker/instrumentEmail.ts | 6 +- .../worker/instrumentFetch.ts | 10 +- .../worker/instrumentQueue.ts | 6 +- .../worker/instrumentScheduled.ts | 6 +- .../instrumentations/worker/instrumentTail.ts | 6 +- packages/cloudflare/src/pages-plugin.ts | 3 +- packages/cloudflare/src/types.ts | 118 +++++++ .../cloudflare/src/utils/isCloudflareClass.ts | 6 +- packages/cloudflare/src/withSentry.ts | 17 +- packages/cloudflare/src/workflows.ts | 23 +- yarn.lock | 5 + 30 files changed, 944 insertions(+), 56 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/misc.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/types/workflow.ts create mode 100644 packages/cloudflare/src/types.ts diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index f6d2fc8cddde..9daaf32f6d20 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -9,7 +9,7 @@ "scripts": { "lint": "oxlint . --type-aware", "lint:fix": "oxlint . --fix --type-aware", - "lint:types": "tsc --noEmit", + "lint:types": "tsc --noEmit && tsc --noEmit -p suites/types/tsconfig.v4.json && tsc --noEmit -p suites/types/tsconfig.v5.json && tsc --noEmit -p suites/types/typegen/tsconfig.v4.json && tsc --noEmit -p suites/types/typegen/tsconfig.v5.json", "test": "vitest run", "test:watch": "yarn test --watch" }, @@ -31,6 +31,7 @@ "devDependencies": { "@cloudflare/vite-plugin": "1.34.0", "@cloudflare/workers-types": "^4.20260426.0", + "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@sentry-internal/test-utils": "10.67.0", "@sentry/conventions": "0.16.0", "eslint-plugin-regexp": "^3.1.0", diff --git a/dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts b/dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts new file mode 100644 index 000000000000..19aa2ae70f8f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts @@ -0,0 +1,68 @@ +/** + * Type tests for `instrumentDurableObjectWithSentry`. + * + * The env of the options callback must be inferred from the Durable Object class — + * via its `DurableObject` base or an explicit constructor — and never collapse + * to `unknown`. + */ +import { DurableObject } from 'cloudflare:workers'; +import { instrumentDurableObjectWithSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface DoEnv { + SENTRY_DSN: string; + MY_DO: DurableObjectNamespace; +} + +// --------------------------------------------------------------------------- +// 1. Env inferred from the `DurableObject` base class +// --------------------------------------------------------------------------- +class MyDurableObject extends DurableObject { + async fetch(request: Request): Promise { + return new Response(request.url); + } +} + +export const instrumentedDo = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObject); + +// The instrumented class keeps its type, including RPC methods and the namespace typing. +const _doClass: typeof MyDurableObject = instrumentedDo; + +// --------------------------------------------------------------------------- +// 2. Explicit constructor with env annotation, bare base class +// --------------------------------------------------------------------------- +class MyDurableObjectCustomCtor extends DurableObject { + constructor(ctx: DurableObjectState, env: DoEnv) { + super(ctx, env); + } +} + +export const instrumentedDoCustomCtor = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObjectCustomCtor); + +// --------------------------------------------------------------------------- +// 3. Explicit generic +// --------------------------------------------------------------------------- +export const instrumentedDoExplicit = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObject); + +// --------------------------------------------------------------------------- +// 4. DurableObject +// --------------------------------------------------------------------------- +interface DoProps { + shard: string; +} + +class MyDurableObjectWithProps extends DurableObject {} + +export const instrumentedDoWithProps = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObjectWithProps); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/misc.ts b/dev-packages/cloudflare-integration-tests/suites/types/misc.ts new file mode 100644 index 000000000000..d67a6ec729e3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/misc.ts @@ -0,0 +1,42 @@ +/** + * Type tests for `sentryPagesPlugin` and `defineCloudflareOptions`. + */ +import { defineCloudflareOptions, sentryPagesPlugin } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface PagesEnv { + SENTRY_DSN: string; +} + +// --------------------------------------------------------------------------- +// sentryPagesPlugin: explicit Env is typed, the default does not error +// --------------------------------------------------------------------------- +export const pagesPluginExplicit = sentryPagesPlugin(context => { + // `env` is `PagesEnv & { ASSETS: ... }` — the workers-types Pages intersection. + expectTypeOf(context.env.SENTRY_DSN).toEqualTypeOf(); + return { dsn: context.env.SENTRY_DSN }; +}); + +export const pagesPluginDefault = sentryPagesPlugin(context => { + // No explicit generic: env access must not fail compilation. + return { dsn: context.env.SENTRY_DSN }; +}); + +// --------------------------------------------------------------------------- +// defineCloudflareOptions: explicit Env is typed, the default does not error +// --------------------------------------------------------------------------- +interface OptionsEnv { + SENTRY_DSN: string; +} + +export const optionsExplicit = defineCloudflareOptions(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}); + +export const optionsDefault = defineCloudflareOptions(env => { + expectTypeOf(env).toBeAny(); + return { dsn: env.SENTRY_DSN }; +}); + +export const optionsStatic = defineCloudflareOptions({ tracesSampleRate: 1.0 }); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json new file mode 100644 index 000000000000..9533ab134c6e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json @@ -0,0 +1,5 @@ +{ + // this tsconfig.json is only here so the IDE can find the types + // the actual lint:types goes against tsconfig.v4.json and tsconfig.v5.json directly + "extends": "./tsconfig.v5.json" +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json new file mode 100644 index 000000000000..f17d7f6ec09e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + + // The shared type tests in this folder run once per supported `@cloudflare/workers-types` + // major (see `./tsconfig.v5.json` for v5). The env inference machinery reads generic + // defaults off `ExportedHandler`, `WorkerEntrypoint`, `DurableObject` and + // `WorkflowEntrypoint`, so a change to any of those between majors has to fail here + // rather than in a user's project. + "include": ["./*.ts"], + + // The extended config hands this whole folder to these per-version programs by excluding + // it; the inherited exclude must be cleared here or no inputs remain. + "exclude": [], + + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json new file mode 100644 index 000000000000..6c6e59f522b1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.v4.json", + + // Same files as `./tsconfig.v4.json`, against `@cloudflare/workers-types` v5 (installed + // under the `@cloudflare/workers-types-v5` alias). + "compilerOptions": { + "types": ["@cloudflare/workers-types-v5"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json new file mode 100644 index 000000000000..588eef1f2f74 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + + // Separate program: the global `Cloudflare.Env` augmentation in this folder simulates + // a project that ran `wrangler types`. It must not leak into the main program, where + // the no-typegen fallback (`env` resolves to `any`) is under test. Runs once per + // supported `@cloudflare/workers-types` major (see `./tsconfig.v5.json` for v5). + "include": ["./**/*.ts"], + + // The extended config excludes this folder (to keep the augmentation out of the main + // program); since this program is exactly that folder, the inherited exclude must be + // cleared or no inputs remain. + "exclude": [], + + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json new file mode 100644 index 000000000000..6c6e59f522b1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.v4.json", + + // Same files as `./tsconfig.v4.json`, against `@cloudflare/workers-types` v5 (installed + // under the `@cloudflare/workers-types-v5` alias). + "compilerOptions": { + "types": ["@cloudflare/workers-types-v5"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts b/dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts new file mode 100644 index 000000000000..814d37748d86 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts @@ -0,0 +1,81 @@ +/** + * Type tests for the wrangler-generated (`wrangler types`) setup. + * + * This folder is a separate tsc program (see `./tsconfig.json`) so that the global + * `Cloudflare.Env` augmentation below simulates a project that ran `wrangler types`, + * without affecting the other suites: in the main program `Cloudflare.Env` stays the + * empty interface. + */ +import { DurableObject, WorkerEntrypoint, WorkflowEntrypoint } from 'cloudflare:workers'; +import { instrumentDurableObjectWithSentry, instrumentWorkflowWithSentry, withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +// Simulates `wrangler types` output. +declare global { + namespace Cloudflare { + interface Env { + SENTRY_DSN: string; + MY_KV: KVNamespace; + } + } +} + +// --------------------------------------------------------------------------- +// Bare handler, no annotations: env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +export const typegen = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + async fetch(request, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return new Response(request.url); + }, + }, +); + +// --------------------------------------------------------------------------- +// Bare Durable Object (no generic): env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +class MyDurableObject extends DurableObject {} + +export const instrumentedDo = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObject); + +// --------------------------------------------------------------------------- +// Bare WorkerEntrypoint / WorkflowEntrypoint (no generic), the setup Cloudflare +// recommends: env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +class MyEntrypoint extends WorkerEntrypoint {} + +export const entrypoint = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypoint); + +class MyWorkflow extends WorkflowEntrypoint {} + +export const workflow = instrumentWorkflowWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyWorkflow); + +// --------------------------------------------------------------------------- +// Framework-wrapped handlers without their own env type (TanStack's +// `ServerEntry`): env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +type ServerEntry = { + fetch: (request: Request, opts?: unknown) => Promise | Response; +}; + +declare const serverEntry: ServerEntry; + +export const tanstack = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, serverEntry); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts b/dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts new file mode 100644 index 000000000000..5153939cefd4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts @@ -0,0 +1,50 @@ +/** + * Type tests for `ExportedHandler`'s 4th generic — `Props` for `ExecutionContext`. + * + * A `Props`-typed handler must still be accepted by `withSentry`, keep its exact type, + * and have its env inferred. Runs in every program, since both `@cloudflare/workers-types` + * v4 and v5 carry the `Props` generic. + */ +import { withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface PropsEnv { + SENTRY_DSN: string; +} + +interface MyProps { + jobId: string; +} + +const propsHandler: ExportedHandler = { + async fetch(_, env, ctx) { + expectTypeOf(ctx.props.jobId).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, +}; + +// A pre-typed handler with `ExecutionContext` is accepted and its env inferred. +export const props = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, propsHandler); + +// The wrapped handler keeps its exact type, including the Props. +expectTypeOf(props).toEqualTypeOf>(); + +// A `satisfies` handler literal with `ExecutionContext` works too. +export const propsSatisfies = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + async fetch(_, env, ctx) { + expectTypeOf(env).toEqualTypeOf(); + expectTypeOf(ctx.props.jobId).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts b/dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts new file mode 100644 index 000000000000..ca5505fdd514 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts @@ -0,0 +1,311 @@ +/** + * Type tests for `withSentry` with `ExportedHandler` (object) handlers. + * + * Regression coverage for https://github.com/getsentry/sentry-javascript/issues/18294: + * the `env` of the options callback must be inferred from the passed handler wherever + * possible, fall back to the explicit `Env` generic / wrangler-generated `Cloudflare.Env`, + * and never collapse to `unknown` (which breaks compilation). + * + * NOTE: This tsc program intentionally has no `Cloudflare.Env` augmentation — the + * wrangler-generated scenario lives in `suites/types/typegen/` (separate tsconfig). + */ +import { Hono } from 'hono'; +import { withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +// --------------------------------------------------------------------------- +// 1. The issue reproduction: manual (exported) Env interface + `satisfies` +// --------------------------------------------------------------------------- +interface ManualEnv { + SENTRY_DATA_SOURCE_NAME: string; +} + +export const reproduction = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME, sendDefaultPii: true }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + return Response.json({ env }, { status: 200 }); + }, + } satisfies ExportedHandler, +); + +// The wrapped handler stays assignable to the user's handler type. +const _reproductionHandler: ExportedHandler = reproduction; + +// --------------------------------------------------------------------------- +// 2. Pre-typed handler constant — env inferred from the declared handler type +// --------------------------------------------------------------------------- +interface PretypedEnv { + MY_KV: KVNamespace; +} + +const pretypedHandler: ExportedHandler = { + async fetch(_, env) { + await env.MY_KV.get('key'); + return new Response('ok'); + }, +}; + +export const pretyped = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; +}, pretypedHandler); + +// An annotated callback pins the env independent of the handler. +export const annotatedCallback = withSentry((env: PretypedEnv) => { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; +}, pretypedHandler); + +// ...including with a bare handler literal (the most common setup in the suites). +export const annotatedCallbackBareHandler = withSentry((_: PretypedEnv) => ({}), { + async fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return new Response('ok'); + }, +}); + +// --------------------------------------------------------------------------- +// 3. Bare handler, no annotations, no `wrangler types` — env must not error +// (falls back to `any`; with typegen it would be `Cloudflare.Env`, see typegen/) +// --------------------------------------------------------------------------- +export const bare = withSentry( + env => { + expectTypeOf(env).toBeAny(); + // Unknown bindings must not fail compilation when no types were generated. + return { dsn: env.SENTRY_DSN }; + }, + { + async fetch(request, env, ctx) { + ctx.waitUntil(Promise.resolve()); + return new Response(request.url); + }, + }, +); + +// --------------------------------------------------------------------------- +// 4. Explicit `withSentry(...)` — with satisfies-typed and bare handlers +// --------------------------------------------------------------------------- +interface ExplicitEnv { + SENTRY_DSN: string; +} + +export const explicitGeneric = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + } satisfies ExportedHandler, +); + +export const explicitGenericBareHandler = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + }, +); + +// --------------------------------------------------------------------------- +// 5. Fully explicit `withSentry` +// --------------------------------------------------------------------------- +interface QueueMessage { + userId: string; +} +interface CfMetadata { + country: string; +} + +export const fullyExplicit = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + async queue(batch) { + expectTypeOf(batch).toEqualTypeOf>(); + void batch.messages[0]?.body.userId; + }, + }, +); + +// --------------------------------------------------------------------------- +// 6. env is inferred from non-fetch handler methods too (scheduled, queue, ...) +// --------------------------------------------------------------------------- +interface CronEnv { + SENTRY_DSN: string; +} + +export const scheduledOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + scheduled(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +export const queueOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + queue(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +export const tailOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + tail(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +export const emailOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + email(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +// A handler with several methods still infers one consistent env. +export const mixedMethods = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + async queue(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + async scheduled(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +// The options callback may also return `undefined`. +export const undefinedOptions = withSentry(() => undefined, { + async fetch() { + return new Response('ok'); + }, +}); + +// --------------------------------------------------------------------------- +// 7. Hono apps: `Bindings` is inferred as the callback env and members beyond +// `fetch` survive the wrapping +// --------------------------------------------------------------------------- +interface HonoEnv { + SENTRY_DSN: string; +} + +const realHonoApp = new Hono<{ Bindings: HonoEnv }>(); + +export const realHono = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoApp); + +// Members beyond `fetch` survive the wrapping. +realHono.onError(err => new Response(`Error: ${err.message}`, { status: 500 })); + +// Hono apps using `Variables` (context state) still infer `Bindings` as the env. +interface HonoVariables { + user: string; +} + +const realHonoAppWithVariables = new Hono<{ Bindings: HonoEnv; Variables: HonoVariables }>(); + +export const realHonoWithVariables = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoAppWithVariables); + +// Hono with an annotated callback and with an explicit generic. +export const realHonoAnnotated = withSentry((env: HonoEnv) => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoApp); + +export const realHonoExplicit = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoApp); + +// --------------------------------------------------------------------------- +// 8. TanStack-style wrapped handlers (`ServerEntry` carries no env type): +// an annotated callback or an explicit generic pins the env; unannotated +// falls back to `any` here / `Cloudflare.Env` with typegen (see typegen/) +// --------------------------------------------------------------------------- +type ServerEntry = { + fetch: (request: Request, opts?: unknown) => Promise | Response; +}; + +declare const tanstackHandler: ServerEntry; + +export const tanstack = withSentry((env: ManualEnv) => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME }; +}, tanstackHandler); + +export const tanstackExplicit = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME }; +}, tanstackHandler); + +export const tanstackBare = withSentry(env => { + expectTypeOf(env).toBeAny(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME }; +}, tanstackHandler); + +// --------------------------------------------------------------------------- +// 9. Inferred envs are exact: the `expectTypeOf(env).toEqualTypeOf()` +// assertions above only hold for the precise interface — never for `any`, +// `unknown`, or a wider type — so a silent fallback cannot sneak back in. +// --------------------------------------------------------------------------- diff --git a/dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts b/dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts new file mode 100644 index 000000000000..bf45979478cc --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts @@ -0,0 +1,77 @@ +/** + * Type tests for `withSentry` with `WorkerEntrypoint` class handlers. + * + * The env of the options callback must be inferred from the class — via its + * `WorkerEntrypoint` base or an explicit constructor — and never collapse + * to `unknown`. + */ +import { WorkerEntrypoint } from 'cloudflare:workers'; +import { withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface EntrypointEnv { + SENTRY_DSN: string; +} + +// --------------------------------------------------------------------------- +// 1. Env inferred from the `WorkerEntrypoint` base class +// --------------------------------------------------------------------------- +class MyEntrypoint extends WorkerEntrypoint { + override async fetch(request: Request): Promise { + return new Response(request.url); + } +} + +export const entrypoint = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypoint); + +// The instrumented class keeps its type (constructor + instance shape). +const _entrypointClass: typeof MyEntrypoint = entrypoint; + +// --------------------------------------------------------------------------- +// 2. WorkerEntrypoint +// --------------------------------------------------------------------------- +interface EntrypointProps { + name: string; +} + +class MyEntrypointWithProps extends WorkerEntrypoint {} + +export const entrypointWithProps = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypointWithProps); + +// --------------------------------------------------------------------------- +// 3. Explicit constructor with env annotation, bare base class +// --------------------------------------------------------------------------- +class MyEntrypointCustomCtor extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: EntrypointEnv) { + super(ctx, env); + } +} + +export const entrypointCustomCtor = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypointCustomCtor); + +// --------------------------------------------------------------------------- +// 4. Bare `WorkerEntrypoint` without typegen — env must not error (any fallback) +// --------------------------------------------------------------------------- +class MyBareEntrypoint extends WorkerEntrypoint {} + +export const bareEntrypoint = withSentry(env => { + expectTypeOf(env).toBeAny(); + return { dsn: env.SENTRY_DSN }; +}, MyBareEntrypoint); + +// --------------------------------------------------------------------------- +// 5. Explicit generic with a class handler +// --------------------------------------------------------------------------- +export const entrypointExplicit = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypoint); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/workflow.ts b/dev-packages/cloudflare-integration-tests/suites/types/workflow.ts new file mode 100644 index 000000000000..4fd7e57f28ad --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/workflow.ts @@ -0,0 +1,51 @@ +/** + * Type tests for `instrumentWorkflowWithSentry`. + * + * The env of the options callback must be inferred from the Workflow class, and the + * payload type must keep flowing into `run`. + */ +import { WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; +import { instrumentWorkflowWithSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface WorkflowEnv { + SENTRY_DSN: string; +} + +interface WorkflowPayload { + orderId: string; +} + +// --------------------------------------------------------------------------- +// 1. Env and payload inferred from the `WorkflowEntrypoint` base class +// --------------------------------------------------------------------------- +class MyWorkflow extends WorkflowEntrypoint { + override async run(event: WorkflowEvent, step: WorkflowStep): Promise { + expectTypeOf(event.payload.orderId).toEqualTypeOf(); + await step.do('step', async () => { + void event.payload.orderId; + }); + } +} + +export const instrumentedWorkflow = instrumentWorkflowWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyWorkflow); + +const _workflowClass: typeof MyWorkflow = instrumentedWorkflow; + +// --------------------------------------------------------------------------- +// 2. Explicit constructor with env annotation, bare base class +// --------------------------------------------------------------------------- +class MyWorkflowCustomCtor extends WorkflowEntrypoint { + constructor(ctx: ExecutionContext, env: WorkflowEnv) { + super(ctx, env); + } +} + +export const instrumentedWorkflowCustomCtor = instrumentWorkflowWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyWorkflowCustomCtor); diff --git a/dev-packages/cloudflare-integration-tests/tsconfig.json b/dev-packages/cloudflare-integration-tests/tsconfig.json index f90c59831b76..a074c80918ef 100644 --- a/dev-packages/cloudflare-integration-tests/tsconfig.json +++ b/dev-packages/cloudflare-integration-tests/tsconfig.json @@ -5,7 +5,10 @@ // `suites/prisma` imports a Prisma-generated client (`./generated`) that only exists after codegen, // so it can't be type-checked as part of the static suite check. - "exclude": ["suites/prisma/**"], + // `suites/types` has its own per-version programs (`tsconfig.v4.json` / `tsconfig.v5.json`, + // and the same pair under `typegen/`), since each set of files is checked once per + // supported `@cloudflare/workers-types` major. See `lint:types`. + "exclude": ["suites/prisma/**", "suites/types/**"], "compilerOptions": { // Although this seems wrong to include `DOM` here, it's necessary to make diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts index 46ac9c5d909a..7c45eddf4c16 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts @@ -34,7 +34,7 @@ function streamWorkersAi(): Response { return result.toTextStreamResponse(); } -class MyBaseAgent extends Agent { +class MyBaseAgent extends Agent { @callable() async greet(name: string): Promise { // User keys — instrumented, spans expected @@ -57,7 +57,7 @@ class MyBaseAgent extends Agent { } } -class MyChatAgentBase extends AIChatAgent { +class MyChatAgentBase extends AIChatAgent { @callable() async greet(name: string): Promise { return `Hello, ${name}!`; @@ -79,7 +79,7 @@ export const MyAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyBaseAge export const MyChatAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyChatAgentBase); export default Sentry.withSentry(sentryOptions, { - async fetch(request: Request, env: Env): Promise { + async fetch(request, env): Promise { const agentResponse = await routeAgentRequest(request, env); if (agentResponse) { @@ -88,4 +88,4 @@ export default Sentry.withSentry(sentryOptions, { return new Response(null, { status: 404 }); }, -} satisfies ExportedHandler); +} satisfies ExportedHandler); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts index 1fcaac159709..154f291d0462 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -10,6 +10,5 @@ export default Sentry.withSentry( tracesSampleRate: 1.0, environment: 'qa', }), - // @ts-expect-error - handler is not typed as a Cloudflare handler wrapFetchWithSentry(handler), ); diff --git a/packages/cloudflare/src/defineCloudflareOptions.ts b/packages/cloudflare/src/defineCloudflareOptions.ts index b751221f0f61..a05686a2a9bd 100644 --- a/packages/cloudflare/src/defineCloudflareOptions.ts +++ b/packages/cloudflare/src/defineCloudflareOptions.ts @@ -1,5 +1,5 @@ -import type { env as cloudflareEnv } from 'cloudflare:workers'; import type { CloudflareOptions } from './client'; +import type { DefaultEnv } from './types'; /** * Define the Sentry options for a Cloudflare Worker in a dedicated module. @@ -35,7 +35,7 @@ import type { CloudflareOptions } from './client'; * export default defineCloudflareOptions({ tracesSampleRate: 1.0 }); * ``` */ -export function defineCloudflareOptions( +export function defineCloudflareOptions( optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined), ): (env: Env) => CloudflareOptions | undefined { if (typeof optionsOrCallback === 'function') { diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e087ef8eb3bc..fe72ccb9b0a9 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -12,6 +12,7 @@ import { instrumentContext } from './utils/instrumentContext'; import { extractRpcMeta } from './utils/rpcMeta'; import { getEffectiveRpcPropagation } from './utils/rpcOptions'; import { instrumentCloudflareAgent } from './instrumentations/agents'; +import type { DefaultEnv, ResolveEnv } from './types'; import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; /** @@ -280,10 +281,12 @@ export function finalizeWithRpcInstrumentation( * ``` */ export function instrumentDurableObjectWithSentry< - E, - T extends DurableObject, - C extends new (state: DurableObjectState, env: E) => T, ->(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { + Env = DefaultEnv, + // oxlint-disable-next-line typescript/no-explicit-any + T extends DurableObject = DurableObject, + // 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) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { construct(target, [ctx, env], newTarget) { const { obj, options, context } = constructInstrumentedDurableObject( @@ -340,10 +343,12 @@ export function instrumentDurableObjectWithSentry< * ``` */ export function instrumentAgentWithSentry< - E, - T extends DurableObject, - C extends new (state: DurableObjectState, env: E) => T, ->(optionsCallback: (env: E) => CloudflareOptions, AgentClass: C): C { + Env = DefaultEnv, + // oxlint-disable-next-line typescript/no-explicit-any + T extends DurableObject = DurableObject, + // 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) => CloudflareOptions, AgentClass: C): C { return new Proxy(AgentClass, { construct(target, [ctx, env], newTarget) { const { obj, options, context } = constructInstrumentedDurableObject( diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index c0b46f027be8..c2646ad32e5a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -2,6 +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 { instrumentContext } from '../utils/instrumentContext'; import { extractRpcMeta } from '../utils/rpcMeta'; import { type UncheckedMethod, wrapMethodWithSentry } from '../wrapMethodWithSentry'; @@ -148,11 +149,13 @@ function instrumentMethod( * ``` */ export function instrumentWorkerEntrypoint< - Env, - Props, - T extends InstanceType>, - C extends new (ctx: ExecutionContext, env: Env) => T, ->(optionsCallback: (env: Env) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C { + Env = DefaultEnv, + Props = {}, + // oxlint-disable-next-line typescript/no-explicit-any + T extends WorkerEntrypoint = WorkerEntrypoint, + // 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) => CloudflareOptions | 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 diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 323ae2712835..ed2d9a05f7f5 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -1,4 +1,5 @@ -import type { EmailMessage, ExportedHandler } from '@cloudflare/workers-types'; +import type { EmailMessage } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv } from 'cloudflare:workers'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { GENERAL_FUNCTION_SPAN_OP } from '@sentry/conventions/op'; @@ -62,8 +63,7 @@ function wrapEmailHandler( /** * Instruments an email handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerEmail>( +export function instrumentExportedHandlerEmail( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts index 4f96447e9e34..6c6bf443036c 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -1,4 +1,4 @@ -import type { ExportedHandler } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import type { CloudflareOptions } from '../../client'; import { ensureInstrumented } from '../../instrument'; @@ -11,8 +11,7 @@ import { instrumentEnv } from './instrumentEnv'; /** * Instruments a fetch handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerFetch>( +export function instrumentExportedHandlerFetch( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { @@ -25,7 +24,10 @@ export function instrumentExportedHandlerFetch new Proxy(original, { apply(target, thisArg, args: Parameters>) { - const [request, env, ctx] = args; + const [rawRequest, env, ctx] = args; + // `T['fetch']` resolves to the structural `AnyHandlerMethod`, whose parameters are + // `any` — but the fetch event is always a `Request` at runtime. + const request = rawRequest as Request; if (request.method === 'OPTIONS' || request.method === 'HEAD') { return target.apply(thisArg, args); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index 00e532d6bf8c..ac609e241a55 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -1,4 +1,5 @@ -import type { ExportedHandler, MessageBatch } from '@cloudflare/workers-types'; +import type { MessageBatch } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { MESSAGING_QUEUE_PROCESS_SPAN_OP } from '@sentry/conventions/op'; @@ -68,8 +69,7 @@ function wrapQueueHandler( /** * Instruments a queue handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerQueue>( +export function instrumentExportedHandlerQueue( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 5c8eb3a3f90c..018dd8b56ee1 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -1,4 +1,5 @@ -import type { ExportedHandler, ScheduledController } from '@cloudflare/workers-types'; +import type { ScheduledController } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { GENERAL_FUNCTION_SPAN_OP } from '@sentry/conventions/op'; @@ -61,8 +62,7 @@ function wrapScheduledHandler( /** * Instruments a scheduled handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerScheduled>( +export function instrumentExportedHandlerScheduled( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts index 9b1dcc2d22a0..925f2b504605 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts @@ -1,4 +1,5 @@ -import type { ExportedHandler, TraceItem } from '@cloudflare/workers-types'; +import type { TraceItem } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import { captureException, withIsolationScope } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; @@ -37,8 +38,7 @@ function wrapTailHandler(options: CloudflareOptions, context: ExecutionContext, /** * Instruments a tail handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerTail>( +export function instrumentExportedHandlerTail( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/pages-plugin.ts b/packages/cloudflare/src/pages-plugin.ts index 518b1c448017..888b352300ce 100644 --- a/packages/cloudflare/src/pages-plugin.ts +++ b/packages/cloudflare/src/pages-plugin.ts @@ -36,7 +36,8 @@ import { init } from './sdk'; * @returns A plugin function that can be used in Cloudflare Pages. */ export function sentryPagesPlugin< - Env = unknown, + // 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 = Record, diff --git a/packages/cloudflare/src/types.ts b/packages/cloudflare/src/types.ts new file mode 100644 index 000000000000..97b4403f872a --- /dev/null +++ b/packages/cloudflare/src/types.ts @@ -0,0 +1,118 @@ +import type { env as cloudflareEnv } from 'cloudflare:workers'; + +type IsAny = 0 extends 1 & T ? true : false; + +/** + * A handler method of an `ExportedHandler` (`fetch`, `scheduled`, `queue`, ...). + */ +// oxlint-disable-next-line typescript/no-explicit-any +type AnyHandlerMethod = (event: any, env: any, ctx: any) => any; + +/** + * Structural version of `ExportedHandler` that accepts every env/message/metadata/props + * combination. Used as the handler constraint of `withSentry` instead of + * `ExportedHandler`: on `@cloudflare/workers-types` v5, `ExportedHandler` + * has a 4th generic (`ExecutionContext`) which defaults to `unknown`, so a direct + * `ExportedHandler` constraint rejects handlers typed with a concrete + * `Props`. Referencing the 4th generic is not an option either — it does not exist on v4, + * which the SDK also supports. + */ +export interface AnyExportedHandler { + fetch?: AnyHandlerMethod; + scheduled?: AnyHandlerMethod; + queue?: AnyHandlerMethod; + email?: AnyHandlerMethod; + tail?: AnyHandlerMethod; + trace?: AnyHandlerMethod; + tailStream?: AnyHandlerMethod; + connect?: AnyHandlerMethod; + test?: AnyHandlerMethod; +} + +type HandlerMethodName = keyof AnyExportedHandler; + +/** + * Extracts the `Env` type from a handler method signature (its second parameter). + * `any` methods are skipped: framework app types like Hono's carry a string index + * signature, which would otherwise make every missing handler method read as `any` + * and poison the inferred env. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type EnvFromMethod = + IsAny extends true + ? never + : // oxlint-disable-next-line typescript/no-explicit-any + Method extends (event: any, env: infer Env, ctx: any) => any + ? Env + : never; + +/** + * Extracts the `Env` type from an `ExportedHandler`-shaped type. + * + * This infers the env from any of the handler methods (`fetch`, `scheduled`, `queue`, ...), + * so a handler literal with `satisfies ExportedHandler` or a pre-typed + * `ExportedHandler` both yield their `Env`. The structural read (instead of + * `T extends ExportedHandler`) keeps inference working for + * `ExecutionContext`-typed handlers on workers-types v5. + */ +type InferEnvFromHandler = T extends AnyExportedHandler + ? { + [K in HandlerMethodName]: K extends keyof T ? EnvFromMethod : never; + }[HandlerMethodName] + : never; + +/** + * Extracts the `Env` type from a class constructor type (`WorkerEntrypoint`, `DurableObject` + * or `WorkflowEntrypoint` subclasses). The env is inferred from the second constructor parameter, + * which carries the user's env type both for explicit constructors and for constructors inherited + * from a `WorkerEntrypoint` / `DurableObject` / `WorkflowEntrypoint` base class. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type InferEnvFromConstructor = T extends new (ctx: any, env: infer Env) => any ? Env : never; + +/** + * Infers the `Env` type from a handler passed to one of the instrumentation functions. + * Returns `never` when nothing can be inferred. + */ +export type InferEnv = [InferEnvFromHandler] extends [never] + ? InferEnvFromConstructor + : InferEnvFromHandler; + +/** + * Removes empty object types (`{}`) from a union. Framework handler types like Hono's + * `fetch(request, env?: E['Bindings'] | {}, ...)` union the env with `{}`, which would + * otherwise collapse the inferred env to "empty" — filtering keeps the meaningful member. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type FilterEmptyObjects = T extends any ? ([keyof T] extends [never] ? never : T) : never; + +/** + * Resolves the env type exposed on the options callback of the instrumentation functions. + * + * Resolution order: + * 1. The env inferred from the passed handler (e.g. via `satisfies ExportedHandler`, + * a pre-typed handler, or a `WorkerEntrypoint` subclass). + * 2. The explicitly provided `Env` generic (e.g. `withSentry(...)`), which defaults + * to the wrangler-generated `Cloudflare.Env` (via `typeof cloudflareEnv`). + * 3. `any` as the last resort, so untyped setups keep compiling instead of failing on + * an empty `Cloudflare.Env` interface. + */ +export type ResolveEnv = + IsAny>> extends true + ? ExplicitEnv + : unknown extends FilterEmptyObjects> + ? ExplicitEnv + : [FilterEmptyObjects>] extends [never] + ? ExplicitEnv + : FilterEmptyObjects>; + +/** + * The default env type when nothing can be inferred and no explicit generic is provided: + * the wrangler-generated `Cloudflare.Env` (via `cloudflare:workers`), falling back to `any` + * when the project never ran `wrangler types` (the interface is empty) so property access + * on `env` does not fail. + */ +export type DefaultEnv = [keyof typeof cloudflareEnv] extends [never] + ? // oxlint-disable-next-line typescript/no-explicit-any + any + : typeof cloudflareEnv; diff --git a/packages/cloudflare/src/utils/isCloudflareClass.ts b/packages/cloudflare/src/utils/isCloudflareClass.ts index 40ee8a3fd1a5..3e728ce9040d 100644 --- a/packages/cloudflare/src/utils/isCloudflareClass.ts +++ b/packages/cloudflare/src/utils/isCloudflareClass.ts @@ -26,7 +26,11 @@ type CloudflareClassName = 'WorkerEntrypoint' | 'DurableObject' | 'WorkflowEntry * isCloudflareClass(MyDO, 'DurableObject') // true if MyDO extends DurableObject * ``` */ -export function isCloudflareClass(value: unknown, className: 'WorkerEntrypoint'): value is WorkerEntrypointConstructor; +export function isCloudflareClass( + value: unknown, + className: 'WorkerEntrypoint', + // oxlint-disable-next-line typescript/no-explicit-any +): value is WorkerEntrypointConstructor; export function isCloudflareClass( value: unknown, className: 'DurableObject', diff --git a/packages/cloudflare/src/withSentry.ts b/packages/cloudflare/src/withSentry.ts index 50acb67541dc..460a6eed4015 100644 --- a/packages/cloudflare/src/withSentry.ts +++ b/packages/cloudflare/src/withSentry.ts @@ -1,4 +1,3 @@ -import type { env as cloudflareEnv } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail'; @@ -7,6 +6,7 @@ import { instrumentExportedHandlerQueue } from './instrumentations/worker/instru 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 { instrumentWorkerEntrypoint, type WorkerEntrypointConstructor, @@ -23,18 +23,15 @@ import { * @param handler {ExportedHandler} The handler to wrap. * @returns The wrapped handler. */ -// TODO(v11): The generic types need to be rewritten to following to improve type safety: -// T extends ExportedHandler | WorkerEntrypointConstructor export function withSentry< - Env = typeof cloudflareEnv, + Env = DefaultEnv, QueueHandlerMessage = unknown, CfHostMetadata = unknown, - T extends ExportedHandler | WorkerEntrypointConstructor = ExportedHandler< - Env, - QueueHandlerMessage, - CfHostMetadata - >, ->(optionsCallback: (env: Env) => CloudflareOptions | undefined, handler: T): T { + // oxlint-disable-next-line typescript/no-explicit-any + T extends AnyExportedHandler | WorkerEntrypointConstructor = + | ExportedHandler + | WorkerEntrypointConstructor, +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions | undefined, handler: T): T { if (isCloudflareClass(handler, 'WorkerEntrypoint')) { // oxlint-disable-next-line typescript/no-explicit-any return instrumentWorkerEntrypoint(optionsCallback as any, handler); diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 721cb752d1ba..58b560c143c3 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -30,6 +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'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; @@ -198,13 +199,23 @@ class WrappedWorkflowStep implements WorkflowStep { * @returns Instrumented workflow class with the same interface */ export function instrumentWorkflowWithSentry< - E, // Environment type - P, // Payload type - T extends WorkflowEntrypoint, // WorkflowEntrypoint type - C extends new (ctx: ExecutionContext, env: E) => T, // Constructor type of the WorkflowEntrypoint class ->(optionsCallback: (env: E) => CloudflareOptions, WorkFlowClass: C): C { + E = DefaultEnv, // Environment type + P = unknown, // Payload type + // oxlint-disable-next-line typescript/no-explicit-any + T extends WorkflowEntrypoint = WorkflowEntrypoint, // WorkflowEntrypoint type + // The constraint must not route through `T`: workers-types defaults `WorkflowEntrypoint`'s + // `Env` to `unknown` (unlike `WorkerEntrypoint`/`DurableObject`, which default to + // `Cloudflare.Env`), so a bare subclass would be rejected in a `wrangler types` project. + // The callback env is resolved from the inferred constructor via `ResolveEnv` instead. + // oxlint-disable-next-line typescript/no-explicit-any + C extends new (ctx: ExecutionContext, env: any) => WorkflowEntrypoint = new ( + ctx: ExecutionContext, + env: any, + ) => T, // Constructor type of the WorkflowEntrypoint class +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions, WorkFlowClass: C): C { return new Proxy(WorkFlowClass, { - construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { + // oxlint-disable-next-line typescript/no-explicit-any + construct(target: C, args: [ctx: ExecutionContext, env: any], newTarget) { const [ctx, env] = args; const context = instrumentContext(ctx); const options = optionsCallback(env); diff --git a/yarn.lock b/yarn.lock index c5c4cf35fb14..332f6fcedf51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3113,6 +3113,11 @@ resolved "https://registry.yarnpkg.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260426.1.tgz#b21d2a24afe0b274982d7ccbe7163a5689da1507" integrity sha512-d3Xj/IjINRgNVwH+eKhpUn4xkkcEewbWXbOvBlapiirKWh5zl9m0Epi3qOqmjyRYK6MICqIGXg4qZBEt0lxudw== +"@cloudflare/workers-types-v5@npm:@cloudflare/workers-types@5.20260710.1": + version "5.20260710.1" + resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-5.20260710.1.tgz#215c0cf84c3917552b53a1f5129150abf0b6009f" + integrity sha512-4ooaY2Pb5XGwDn8Fzm6jnTAJkIX0R5LBvL9euQpp2T58sQItlAQd9yivAlkwGhpY5cM1u81/9HaXwKAjXwtyzA== + "@cloudflare/workers-types@4.20250214.0": version "4.20250214.0" resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20250214.0.tgz#f9a8109cdba9425e2a934d6a0870eca2769a5b6f" From 2c98f16cfec9ce1f575e3fa0e5e1d337b83f8a8a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 3 Aug 2026 18:05:29 +0200 Subject: [PATCH 2/2] fixup! fix(cloudflare): Restore env type inference for options callbacks --- packages/cloudflare/src/workflows.ts | 1 + .../tanstackstart-react/src/server/wrapFetchWithSentry.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 58b560c143c3..fb74b5e8b5a6 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -210,6 +210,7 @@ export function instrumentWorkflowWithSentry< // oxlint-disable-next-line typescript/no-explicit-any C extends new (ctx: ExecutionContext, env: any) => WorkflowEntrypoint = new ( ctx: ExecutionContext, + // oxlint-disable-next-line typescript/no-explicit-any env: any, ) => T, // Constructor type of the WorkflowEntrypoint class >(optionsCallback: (env: ResolveEnv) => CloudflareOptions, WorkFlowClass: C): C { diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index fab2788cd234..1039686381be 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -10,7 +10,11 @@ import { updateSpanWithRouteParametrization } from './routeParametrization'; declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined; export type ServerEntry = { - fetch: (request: Request, opts?: unknown) => Promise | Response; + // `opts` is forwarded verbatim to the wrapped handler, so this must accept whatever shape + // the real framework entry uses (e.g. TanStack's `RequestOptions`). Under + // parameter contravariance `unknown` would reject such an entry; `any` keeps it assignable. + // oxlint-disable-next-line typescript/no-explicit-any + fetch: (request: Request, opts?: any) => Promise | Response; }; /**