From a64b786497a44b6a97ab2a2bf6da5fbe1af90402 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 19 Oct 2026 11:01:06 +0200 Subject: [PATCH] test(cloudflare): Check that Durable Object invocations end when the method returns workerd keeps a Durable Object invocation open until its pending timers fire, so an SDK timer that outlives the method makes every invocation longer, and billed. The suite instruments the worker with the Sentry Vite plugin and runs a tail worker in the same `wrangler dev` process. It asserts that DO fetch, propagated RPC and alarm invocations end within 250 ms. They take a few ms locally, and about 500 ms with the segment-end flush timer of span streaming. Co-Authored-By: Claude Opus 5.5 --- .../invocation-duration/index.ts | 46 +++++++++++++++++++ .../invocation-duration/instrument.server.ts | 6 +++ .../durableobject/invocation-duration/tail.ts | 26 +++++++++++ .../durableobject/invocation-duration/test.ts | 46 +++++++++++++++++++ .../invocation-duration/vite.config.mts | 7 +++ .../invocation-duration/wrangler-tail.jsonc | 6 +++ .../invocation-duration/wrangler.jsonc | 13 ++++++ 7 files changed, 150 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/tail.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler-tail.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/invocation-duration/wrangler.jsonc 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" }], +}