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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
TEST_DURABLE_OBJECT: DurableObjectNamespace<TestDurableObject>;
TAIL: Fetcher;
}

export class TestDurableObject extends DurableObject<Env> {
async fetch(): Promise<Response> {
return new Response('OK');
}

async ping(): Promise<string> {
return 'pong';
}

async setAlarm(): Promise<void> {
await this.ctx.storage.setAlarm(Date.now());
}

async alarm(): Promise<void> {}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
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<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface Invocation {
trigger: string;
durationMs: number;
}

const invocations: Invocation[] = [];

export default {
async tail(events: TraceItem[]): Promise<void> {
// `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<Response> {
return Response.json(invocations);
},
} satisfies ExportedHandler;
Original file line number Diff line number Diff line change
@@ -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<Invocation[]>('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);
});
Original file line number Diff line number Diff line change
@@ -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()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "durableobject-invocation-duration-tail",
"main": "tail.ts",
"compatibility_date": "2025-06-17",
}
Original file line number Diff line number Diff line change
@@ -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" }],
}
Loading