Skip to content

Commit 5993a09

Browse files
0xbad0c0d3cod1k
andauthored
fix(cloudflare): Ensure errors get captured from durable objects (#16838)
Fix #16786 + e2e tests for cloudflare workers - [ ] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). --------- Co-authored-by: cod1k <[email protected]>
1 parent cbff908 commit 5993a09

File tree

14 files changed

+355
-164
lines changed

14 files changed

+355
-164
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler

dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,35 @@
44
"private": true,
55
"scripts": {
66
"deploy": "wrangler deploy",
7-
"dev": "wrangler dev --var E2E_TEST_DSN=$E2E_TEST_DSN",
8-
"build": "wrangler deploy --dry-run --var E2E_TEST_DSN=$E2E_TEST_DSN",
9-
"test": "vitest",
7+
"dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
8+
"build": "wrangler deploy --dry-run",
9+
"test": "vitest --run",
1010
"typecheck": "tsc --noEmit",
1111
"cf-typegen": "wrangler types",
1212
"test:build": "pnpm install && pnpm build",
13-
"test:assert": "pnpm typecheck"
13+
"test:assert": "pnpm test:dev && pnpm test:prod",
14+
"test:prod": "TEST_ENV=production playwright test",
15+
"test:dev": "TEST_ENV=development playwright test"
1416
},
1517
"dependencies": {
1618
"@sentry/cloudflare": "latest || *"
1719
},
1820
"devDependencies": {
19-
"@cloudflare/vitest-pool-workers": "^0.4.5",
21+
"@playwright/test": "~1.50.0",
22+
"@cloudflare/vitest-pool-workers": "^0.8.19",
2023
"@cloudflare/workers-types": "^4.20240725.0",
24+
"@sentry-internal/test-utils": "link:../../../test-utils",
2125
"typescript": "^5.5.2",
22-
"vitest": "1.6.1",
23-
"wrangler": "4.22.0"
26+
"vitest": "~3.2.0",
27+
"wrangler": "^4.23.0",
28+
"ws": "^8.18.3"
2429
},
2530
"volta": {
2631
"extends": "../../package.json"
2732
},
28-
"sentryTest": {
29-
"optional": true
33+
"pnpm": {
34+
"overrides": {
35+
"strip-literal": "~2.0.0"
36+
}
3037
}
3138
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
const testEnv = process.env.TEST_ENV;
3+
4+
if (!testEnv) {
5+
throw new Error('No test env defined');
6+
}
7+
8+
const APP_PORT = 38787;
9+
10+
const config = getPlaywrightConfig(
11+
{
12+
startCommand: `pnpm dev --port ${APP_PORT}`,
13+
port: APP_PORT,
14+
},
15+
{
16+
// This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize
17+
workers: '100%',
18+
},
19+
);
20+
21+
export default config;

dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,100 @@
1111
* Learn more at https://developers.cloudflare.com/workers/
1212
*/
1313
import * as Sentry from '@sentry/cloudflare';
14+
import { DurableObject } from "cloudflare:workers";
15+
16+
class MyDurableObjectBase extends DurableObject<Env> {
17+
private throwOnExit = new WeakMap<WebSocket, Error>();
18+
async throwException(): Promise<void> {
19+
throw new Error('Should be recorded in Sentry.');
20+
}
21+
22+
async fetch(request: Request) {
23+
const { pathname } = new URL(request.url);
24+
switch (pathname) {
25+
case '/throwException': {
26+
await this.throwException();
27+
break;
28+
}
29+
case '/ws':
30+
const webSocketPair = new WebSocketPair();
31+
const [client, server] = Object.values(webSocketPair);
32+
this.ctx.acceptWebSocket(server);
33+
return new Response(null, { status: 101, webSocket: client });
34+
}
35+
return new Response('DO is fine');
36+
}
37+
38+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void> {
39+
if (message === 'throwException') {
40+
throw new Error('Should be recorded in Sentry: webSocketMessage');
41+
} else if (message === 'throwOnExit') {
42+
this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose'));
43+
}
44+
}
45+
46+
webSocketClose(ws: WebSocket): void | Promise<void> {
47+
if (this.throwOnExit.has(ws)) {
48+
const error = this.throwOnExit.get(ws)!;
49+
this.throwOnExit.delete(ws);
50+
throw error;
51+
}
52+
}
53+
}
54+
55+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
56+
(env: Env) => ({
57+
dsn: env.E2E_TEST_DSN,
58+
environment: 'qa', // dynamic sampling bias to keep transactions
59+
tunnel: `http://localhost:3031/`, // proxy server
60+
tracesSampleRate: 1.0,
61+
sendDefaultPii: true,
62+
transportOptions: {
63+
// We are doing a lot of events at once in this test
64+
bufferSize: 1000,
65+
},
66+
}),
67+
MyDurableObjectBase,
68+
);
1469

1570
export default Sentry.withSentry(
1671
(env: Env) => ({
1772
dsn: env.E2E_TEST_DSN,
18-
// Set tracesSampleRate to 1.0 to capture 100% of spans for tracing.
73+
environment: 'qa', // dynamic sampling bias to keep transactions
74+
tunnel: `http://localhost:3031/`, // proxy server
1975
tracesSampleRate: 1.0,
76+
sendDefaultPii: true,
77+
transportOptions: {
78+
// We are doing a lot of events at once in this test
79+
bufferSize: 1000,
80+
},
2081
}),
2182
{
22-
async fetch(request, env, ctx) {
83+
async fetch(request, env) {
84+
const url = new URL(request.url);
85+
switch (url.pathname) {
86+
case '/rpc/throwException':
87+
{
88+
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
89+
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
90+
try {
91+
await stub.throwException();
92+
} catch (e) {
93+
//We will catch this to be sure not to log inside withSentry
94+
return new Response(null, { status: 500 });
95+
}
96+
}
97+
break;
98+
case '/throwException':
99+
throw new Error('To be recorded in Sentry.');
100+
default:
101+
if (url.pathname.startsWith('/pass-to-object/')) {
102+
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
103+
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
104+
url.pathname = url.pathname.replace('/pass-to-object/', '');
105+
return stub.fetch(new Request(url, request));
106+
}
107+
}
23108
return new Response('Hello World!');
24109
},
25110
} satisfies ExportedHandler<Env>,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {startEventProxyServer} from '@sentry-internal/test-utils'
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-workers',
6+
})

dev-packages/e2e-tests/test-applications/cloudflare-workers/test/index.spec.ts

Lines changed: 0 additions & 25 deletions
This file was deleted.

dev-packages/e2e-tests/test-applications/cloudflare-workers/test/tsconfig.json

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
import {WebSocket} from 'ws'
4+
5+
test('Index page', async ({ baseURL }) => {
6+
const result = await fetch(baseURL!);
7+
expect(result.status).toBe(200);
8+
await expect(result.text()).resolves.toBe('Hello World!');
9+
})
10+
11+
test('worker\'s withSentry', async ({baseURL}) => {
12+
const eventWaiter = waitForError('cloudflare-workers', (event) => {
13+
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare';
14+
});
15+
const response = await fetch(`${baseURL}/throwException`);
16+
expect(response.status).toBe(500);
17+
const event = await eventWaiter;
18+
expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.');
19+
})
20+
21+
test('RPC method which throws an exception to be logged to sentry', async ({baseURL}) => {
22+
const eventWaiter = waitForError('cloudflare-workers', (event) => {
23+
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
24+
});
25+
const response = await fetch(`${baseURL}/rpc/throwException`);
26+
expect(response.status).toBe(500);
27+
const event = await eventWaiter;
28+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
29+
});
30+
test('Request processed by DurableObject\'s fetch is recorded', async ({baseURL}) => {
31+
const eventWaiter = waitForError('cloudflare-workers', (event) => {
32+
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
33+
});
34+
const response = await fetch(`${baseURL}/pass-to-object/throwException`);
35+
expect(response.status).toBe(500);
36+
const event = await eventWaiter;
37+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
38+
});
39+
test('Websocket.webSocketMessage', async ({baseURL}) => {
40+
const eventWaiter = waitForError('cloudflare-workers', (event) => {
41+
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
42+
});
43+
const url = new URL('/pass-to-object/ws', baseURL);
44+
url.protocol = url.protocol.replace('http', 'ws');
45+
const socket = new WebSocket(url.toString());
46+
socket.addEventListener('open', () => {
47+
socket.send('throwException')
48+
});
49+
const event = await eventWaiter;
50+
socket.close();
51+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage');
52+
})
53+
54+
test('Websocket.webSocketClose', async ({baseURL}) => {
55+
const eventWaiter = waitForError('cloudflare-workers', (event) => {
56+
return event.exception?.values?.[0]?.mechanism?.type === 'cloudflare_durableobject';
57+
});
58+
const url = new URL('/pass-to-object/ws', baseURL);
59+
url.protocol = url.protocol.replace('http', 'ws');
60+
const socket = new WebSocket(url.toString());
61+
socket.addEventListener('open', () => {
62+
socket.send('throwOnExit')
63+
socket.close()
64+
});
65+
const event = await eventWaiter;
66+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose');
67+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["@cloudflare/vitest-pool-workers"]
5+
},
6+
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
7+
"exclude": []
8+
}

0 commit comments

Comments
 (0)