diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts new file mode 100644 index 000000000000..abc4a78578af --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts @@ -0,0 +1,46 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; + TAIL: Fetcher; +} + +export class TestDurableObject extends DurableObject { + async fetch(): Promise { + return new Response('OK'); + } + + async ping(): Promise { + return 'pong'; + } + + async setAlarm(): Promise { + await this.ctx.storage.setAlarm(Date.now()); + } + + async alarm(): Promise {} +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/invocations') { + return env.TAIL.fetch(request); + } + + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName(url.pathname)); + + if (url.pathname === '/rpc') { + return new Response(await stub.ping()); + } + + if (url.pathname === '/alarm') { + await stub.setAlarm(); + return new Response('Alarm scheduled'); + } + + return stub.fetch(request); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts new file mode 100644 index 000000000000..865d977d6a5c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts @@ -0,0 +1,26 @@ +interface Invocation { + trigger: string; + durationMs: number; +} + +const invocations: Invocation[] = []; + +export default { + async tail(events: TraceItem[]): Promise { + // `wallTime` is always 0 in local dev, so the duration ends when the tail event arrives. + const receivedAt = Date.now(); + + for (const { event, eventTimestamp, executionModel } of events) { + if (executionModel !== 'durableObject' || !event || eventTimestamp === null) { + continue; + } + + const trigger = 'rpcMethod' in event ? event.rpcMethod : 'scheduledTime' in event ? 'alarm' : 'fetch'; + invocations.push({ trigger, durationMs: receivedAt - eventTimestamp }); + } + }, + + async fetch(): Promise { + return Response.json(invocations); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts new file mode 100644 index 000000000000..11db17ae2348 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { createRunner } from '../../../runner'; + +interface Invocation { + trigger: string; + durationMs: number; +} + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/25489 +// workerd keeps a Durable Object invocation open until its pending timers fire, so an SDK timer +// that is still pending when the method returns makes the invocation longer, and billed. +it('does not keep Durable Object invocations open after the method returns', async ({ signal }) => { + // The tail worker must run in the same `wrangler dev` process to receive the tail events. + const runner = createRunner(__dirname) + .withWranglerArgs('--config', join(__dirname, 'wrangler-tail.jsonc')) + .start(signal); + + await runner.makeRequest('get', '/fetch'); + await runner.makeRequest('get', '/rpc'); + await runner.makeRequest('get', '/alarm'); + + const { fetchInvocation, rpcInvocation, alarmInvocation } = await vi.waitFor( + async () => { + const invocations = (await runner.makeRequest('get', '/invocations')) ?? []; + const result = { + fetchInvocation: invocations.find(invocation => invocation.trigger === 'fetch'), + rpcInvocation: invocations.find(invocation => invocation.trigger === 'ping'), + alarmInvocation: invocations.find(invocation => invocation.trigger === 'alarm'), + }; + + expect(result.fetchInvocation).toBeDefined(); + expect(result.rpcInvocation).toBeDefined(); + expect(result.alarmInvocation).toBeDefined(); + + return result; + }, + { timeout: 10_000, interval: 100 }, + ); + + // An invocation takes a few ms locally. The limit leaves room for slow CI machines and still + // fails for a 500 ms timer. + expect(fetchInvocation?.durationMs).toBeLessThan(250); + expect(rpcInvocation?.durationMs).toBeLessThan(250); + expect(alarmInvocation?.durationMs).toBeLessThan(250); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc new file mode 100644 index 000000000000..88d1d67db66f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-invocation-duration-tail", + "main": "tail.ts", + "compatibility_date": "2025-06-17", +} diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc new file mode 100644 index 000000000000..f37ce093d9ce --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-invocation-duration", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "TEST_DURABLE_OBJECT", "class_name": "TestDurableObject" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TestDurableObject"] }], + "services": [{ "binding": "TAIL", "service": "durableobject-invocation-duration-tail" }], + "tail_consumers": [{ "service": "durableobject-invocation-duration-tail" }], +}