Skip to content

fix(cloudflare): Ensure errors get captured from durable objects #16838

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
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 @@
.wrangler
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,35 @@
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev --var E2E_TEST_DSN=$E2E_TEST_DSN",
"build": "wrangler deploy --dry-run --var E2E_TEST_DSN=$E2E_TEST_DSN",
"test": "vitest",
"dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"build": "wrangler deploy --dry-run",
"test": "vitest --run",
"typecheck": "tsc --noEmit",
"cf-typegen": "wrangler types",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck"
"test:assert": "pnpm test:dev && pnpm test:prod",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test"
},
"dependencies": {
"@sentry/cloudflare": "latest || *"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.4.5",
"@playwright/test": "~1.50.0",
"@cloudflare/vitest-pool-workers": "^0.8.19",
"@cloudflare/workers-types": "^4.20240725.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"typescript": "^5.5.2",
"vitest": "1.6.1",
"wrangler": "4.22.0"
"vitest": "~3.2.0",
"wrangler": "^4.23.0",
"ws": "^8.18.3"
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"optional": true
"pnpm": {
"overrides": {
"strip-literal": "~2.0.0"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
const testEnv = process.env.TEST_ENV;

if (!testEnv) {
throw new Error('No test env defined');
}

const APP_PORT = 38787;

const config = getPlaywrightConfig(
{
startCommand: `pnpm dev --port ${APP_PORT}`,
port: APP_PORT,
},
{
// This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize
workers: '100%',
},
);

export default config;
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,100 @@
* Learn more at https://developers.cloudflare.com/workers/
*/
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from "cloudflare:workers";

class MyDurableObjectBase extends DurableObject<Env> {
private throwOnExit = new WeakMap<WebSocket, Error>();
async throwException(): Promise<void> {
throw new Error('Should be recorded in Sentry.');
}

async fetch(request: Request) {
const { pathname } = new URL(request.url);
switch (pathname) {
case '/throwException': {
await this.throwException();
break;
}
case '/ws':
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response('DO is fine');
}

webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void> {
if (message === 'throwException') {
throw new Error('Should be recorded in Sentry: webSocketMessage');
} else if (message === 'throwOnExit') {
this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose'));
}
}

webSocketClose(ws: WebSocket): void | Promise<void> {
if (this.throwOnExit.has(ws)) {
const error = this.throwOnExit.get(ws)!;
this.throwOnExit.delete(ws);
throw error;
}
}
}

export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa', // dynamic sampling bias to keep transactions
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
transportOptions: {
// We are doing a lot of events at once in this test
bufferSize: 1000,
},
}),
MyDurableObjectBase,
);

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.E2E_TEST_DSN,
// Set tracesSampleRate to 1.0 to capture 100% of spans for tracing.
environment: 'qa', // dynamic sampling bias to keep transactions
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
transportOptions: {
// We are doing a lot of events at once in this test
bufferSize: 1000,
},
}),
{
async fetch(request, env, ctx) {
async fetch(request, env) {
const url = new URL(request.url);
switch (url.pathname) {
case '/rpc/throwException':
{
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
try {
await stub.throwException();
} catch (e) {
//We will catch this to be sure not to log inside withSentry
return new Response(null, { status: 500 });
}
}
break;
case '/throwException':
throw new Error('To be recorded in Sentry.');
default:
if (url.pathname.startsWith('/pass-to-object/')) {
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
url.pathname = url.pathname.replace('/pass-to-object/', '');
return stub.fetch(new Request(url, request));
}
}
return new Response('Hello World!');
},
} satisfies ExportedHandler<Env>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {startEventProxyServer} from '@sentry-internal/test-utils'

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-workers',
})

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';
import {WebSocket} from 'ws'

test('Index page', async ({ baseURL }) => {
const result = await fetch(baseURL!);
expect(result.status).toBe(200);
await expect(result.text()).resolves.toBe('Hello World!');
})

test('worker\'s withSentry', async ({baseURL}) => {
const eventWaiter = waitForError('cloudflare-workers', (event) => {
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare';
});
const response = await fetch(`${baseURL}/throwException`);
expect(response.status).toBe(500);
const event = await eventWaiter;
expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.');
})

test('RPC method which throws an exception to be logged to sentry', async ({baseURL}) => {
const eventWaiter = waitForError('cloudflare-workers', (event) => {
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
});
const response = await fetch(`${baseURL}/rpc/throwException`);
expect(response.status).toBe(500);
const event = await eventWaiter;
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
});
test('Request processed by DurableObject\'s fetch is recorded', async ({baseURL}) => {
const eventWaiter = waitForError('cloudflare-workers', (event) => {
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
});
const response = await fetch(`${baseURL}/pass-to-object/throwException`);
expect(response.status).toBe(500);
const event = await eventWaiter;
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
});
test('Websocket.webSocketMessage', async ({baseURL}) => {
const eventWaiter = waitForError('cloudflare-workers', (event) => {
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
});
const url = new URL('/pass-to-object/ws', baseURL);
url.protocol = url.protocol.replace('http', 'ws');
const socket = new WebSocket(url.toString());
socket.addEventListener('open', () => {
socket.send('throwException')
});
const event = await eventWaiter;
socket.close();
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage');
})

test('Websocket.webSocketClose', async ({baseURL}) => {
const eventWaiter = waitForError('cloudflare-workers', (event) => {
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
});
const url = new URL('/pass-to-object/ws', baseURL);
url.protocol = url.protocol.replace('http', 'ws');
const socket = new WebSocket(url.toString());
socket.addEventListener('open', () => {
socket.send('throwOnExit')
socket.close()
});
const event = await eventWaiter;
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose');
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-pool-workers"]
},
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
"exclude": []
}
Loading