Skip to content

Commit fb794eb

Browse files
JPeer264claude
andcommitted
test(e2e): Port cloudflare-workers to span streaming and keep a static copy
Duplicates cloudflare-workers verbatim into cloudflare-workers-static, which stays on traceLifecycle: 'static' and the transaction-based specs, so the static trace lifecycle keeps E2E coverage. cloudflare-workers itself drops the static pin and asserts on streamed spans instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8c2ab94 commit fb794eb

13 files changed

Lines changed: 633 additions & 24 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "cloudflare-workers-static",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "wrangler deploy",
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+
"typecheck": "tsc --noEmit",
10+
"cf-typegen": "wrangler types --include-runtime=false",
11+
"test:build": "pnpm install && pnpm build",
12+
"test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod",
13+
"test:prod": "TEST_ENV=production playwright test",
14+
"test:dev": "TEST_ENV=development playwright test"
15+
},
16+
"dependencies": {
17+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz"
18+
},
19+
"devDependencies": {
20+
"@playwright/test": "~1.56.0",
21+
"@cloudflare/workers-types": "^4.20240725.0",
22+
"@sentry-internal/test-utils": "link:../../../test-utils",
23+
"typescript": "^5.5.2",
24+
"wrangler": "^4.61.0",
25+
"ws": "^8.18.3"
26+
},
27+
"volta": {
28+
"node": "24.15.0",
29+
"extends": "../../package.json"
30+
},
31+
"pnpm": {
32+
"overrides": {
33+
"strip-literal": "~2.0.0"
34+
}
35+
}
36+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
export const INSPECTOR_PORT = 9230;
10+
11+
const config = getPlaywrightConfig(
12+
{
13+
startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`,
14+
port: APP_PORT,
15+
},
16+
{
17+
// This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize
18+
workers: '100%',
19+
retries: 0,
20+
},
21+
);
22+
23+
export default config;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time)
2+
// by running `wrangler types`
3+
4+
interface Env {
5+
E2E_TEST_DSN: '';
6+
MY_DURABLE_OBJECT: DurableObjectNamespace;
7+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Welcome to Cloudflare Workers! This is your first worker.
3+
*
4+
* - Run `npm run dev` in your terminal to start a development server
5+
* - Open a browser tab at http://localhost:8787/ to see your worker in action
6+
* - Run `npm run deploy` to publish your worker
7+
*
8+
* Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the
9+
* `Env` object can be regenerated with `npm run cf-typegen`.
10+
*
11+
* Learn more at https://developers.cloudflare.com/workers/
12+
*/
13+
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 alarm(): Promise<void> {
23+
const action = await this.ctx.storage.get<string>('alarm-action');
24+
if (action === 'throw') {
25+
throw new Error('Alarm error captured by Sentry');
26+
}
27+
}
28+
29+
async fetch(request: Request) {
30+
const url = new URL(request.url);
31+
switch (url.pathname) {
32+
case '/throwException': {
33+
await this.throwException();
34+
break;
35+
}
36+
case '/ws': {
37+
const webSocketPair = new WebSocketPair();
38+
const [client, server] = Object.values(webSocketPair);
39+
this.ctx.acceptWebSocket(server);
40+
return new Response(null, { status: 101, webSocket: client });
41+
}
42+
case '/setAlarm': {
43+
const action = url.searchParams.get('action') || 'succeed';
44+
await this.ctx.storage.put('alarm-action', action);
45+
await this.ctx.storage.setAlarm(Date.now() + 500);
46+
return new Response('Alarm set');
47+
}
48+
case '/storage/put': {
49+
await this.ctx.storage.put('test-key', 'test-value');
50+
return new Response('Stored');
51+
}
52+
case '/storage/get': {
53+
const value = await this.ctx.storage.get('test-key');
54+
return new Response(`Got: ${value}`);
55+
}
56+
}
57+
return new Response('DO is fine');
58+
}
59+
60+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void> {
61+
if (message === 'throwException') {
62+
throw new Error('Should be recorded in Sentry: webSocketMessage');
63+
} else if (message === 'throwOnExit') {
64+
this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose'));
65+
}
66+
}
67+
68+
webSocketClose(ws: WebSocket): void | Promise<void> {
69+
if (this.throwOnExit.has(ws)) {
70+
const error = this.throwOnExit.get(ws)!;
71+
this.throwOnExit.delete(ws);
72+
throw error;
73+
}
74+
}
75+
}
76+
77+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
78+
(env: Env) => ({
79+
traceLifecycle: 'static',
80+
dsn: env.E2E_TEST_DSN,
81+
environment: 'qa', // dynamic sampling bias to keep transactions
82+
tunnel: `http://localhost:3031/`, // proxy server
83+
tracesSampleRate: 1.0,
84+
transportOptions: {
85+
// We are doing a lot of events at once in this test
86+
bufferSize: 1000,
87+
},
88+
}),
89+
MyDurableObjectBase,
90+
);
91+
92+
export default Sentry.withSentry(
93+
(env: Env) => ({
94+
traceLifecycle: 'static',
95+
dsn: env.E2E_TEST_DSN,
96+
environment: 'qa', // dynamic sampling bias to keep transactions
97+
tunnel: `http://localhost:3031/`, // proxy server
98+
tracesSampleRate: 1.0,
99+
transportOptions: {
100+
// We are doing a lot of events at once in this test
101+
bufferSize: 1000,
102+
},
103+
rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'],
104+
}),
105+
{
106+
async fetch(request, env) {
107+
const url = new URL(request.url);
108+
switch (url.pathname) {
109+
case '/rpc/throwException':
110+
{
111+
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
112+
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
113+
try {
114+
await stub.throwException();
115+
} catch (e) {
116+
//We will catch this to be sure not to log inside withSentry
117+
return new Response(null, { status: 500 });
118+
}
119+
}
120+
break;
121+
case '/throwException':
122+
throw new Error('To be recorded in Sentry.');
123+
default:
124+
if (url.pathname.startsWith('/pass-to-object/')) {
125+
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
126+
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
127+
url.pathname = url.pathname.replace('/pass-to-object/', '');
128+
return stub.fetch(new Request(url, request));
129+
}
130+
}
131+
return new Response('Hello World!');
132+
},
133+
} satisfies ExportedHandler<Env>,
134+
);
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-static',
6+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForRequest, waitForTransaction } from '@sentry-internal/test-utils';
3+
import { SDK_VERSION } from '@sentry/cloudflare';
4+
import { WebSocket } from 'ws';
5+
6+
test('Index page', async ({ baseURL }) => {
7+
const result = await fetch(baseURL!);
8+
expect(result.status).toBe(200);
9+
await expect(result.text()).resolves.toBe('Hello World!');
10+
});
11+
12+
test("worker's withSentry", async ({ baseURL }) => {
13+
const eventWaiter = waitForError('cloudflare-workers-static', event => {
14+
return event.exception?.values?.[0]?.mechanism?.type === 'auto.http.cloudflare';
15+
});
16+
const response = await fetch(`${baseURL}/throwException`);
17+
expect(response.status).toBe(500);
18+
const event = await eventWaiter;
19+
expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.');
20+
});
21+
22+
test('RPC method which throws an exception to be logged to sentry', async ({ baseURL }) => {
23+
const eventWaiter = waitForError('cloudflare-workers-static', event => {
24+
return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object';
25+
});
26+
const response = await fetch(`${baseURL}/rpc/throwException`);
27+
expect(response.status).toBe(500);
28+
const event = await eventWaiter;
29+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
30+
});
31+
32+
test("Request processed by DurableObject's fetch is recorded", async ({ baseURL }) => {
33+
const eventWaiter = waitForError('cloudflare-workers-static', event => {
34+
return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object';
35+
});
36+
const response = await fetch(`${baseURL}/pass-to-object/throwException`);
37+
expect(response.status).toBe(500);
38+
const event = await eventWaiter;
39+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
40+
});
41+
42+
test('Websocket.webSocketMessage', async ({ baseURL }) => {
43+
const eventWaiter = waitForError('cloudflare-workers-static', event => {
44+
return !!event.exception?.values?.[0];
45+
});
46+
const url = new URL('/pass-to-object/ws', baseURL);
47+
url.protocol = url.protocol.replace('http', 'ws');
48+
const socket = new WebSocket(url.toString());
49+
socket.addEventListener('open', () => {
50+
socket.send('throwException');
51+
});
52+
const event = await eventWaiter;
53+
socket.close();
54+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage');
55+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
56+
});
57+
58+
test('Websocket.webSocketClose', async ({ baseURL }) => {
59+
const eventWaiter = waitForError('cloudflare-workers-static', event => {
60+
return !!event.exception?.values?.[0];
61+
});
62+
const url = new URL('/pass-to-object/ws', baseURL);
63+
url.protocol = url.protocol.replace('http', 'ws');
64+
const socket = new WebSocket(url.toString());
65+
socket.addEventListener('open', () => {
66+
socket.send('throwOnExit');
67+
socket.close();
68+
});
69+
const event = await eventWaiter;
70+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose');
71+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
72+
});
73+
74+
test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => {
75+
const requestPromise = waitForRequest('cloudflare-workers-static', () => true);
76+
77+
await fetch(`${baseURL}/throwException`);
78+
79+
const request = await requestPromise;
80+
81+
expect(request.rawProxyRequestHeaders).toMatchObject({
82+
'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`,
83+
});
84+
});
85+
86+
test('Storage operations create spans in Durable Object transactions', async ({ baseURL }) => {
87+
const transactionWaiter = waitForTransaction('cloudflare-workers-static', event => {
88+
return event.spans?.some(span => span.op === 'db' && span.description === 'durable_object_storage_put') ?? false;
89+
});
90+
91+
const response = await fetch(`${baseURL}/pass-to-object/storage/put`);
92+
expect(response.status).toBe(200);
93+
94+
const transaction = await transactionWaiter;
95+
const putSpan = transaction.spans?.find(span => span.description === 'durable_object_storage_put');
96+
97+
expect(putSpan).toBeDefined();
98+
expect(putSpan?.op).toBe('db');
99+
expect(putSpan?.data?.['db.system.name']).toBe('cloudflare.durable_object.storage');
100+
expect(putSpan?.data?.['db.operation.name']).toBe('put');
101+
});
102+
103+
test.describe('Alarm instrumentation', () => {
104+
test.describe.configure({ mode: 'serial' });
105+
106+
test('captures error from alarm handler', async ({ baseURL }) => {
107+
const errorWaiter = waitForError('cloudflare-workers-static', event => {
108+
return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry';
109+
});
110+
111+
const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`);
112+
expect(response.status).toBe(200);
113+
114+
const event = await errorWaiter;
115+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
116+
});
117+
118+
test('creates a transaction for alarm with new trace linked to setAlarm', async ({ baseURL }) => {
119+
const setAlarmTransactionWaiter = waitForTransaction('cloudflare-workers-static', event => {
120+
return event.spans?.some(span => span.description?.includes('storage_setAlarm')) ?? false;
121+
});
122+
123+
const alarmTransactionWaiter = waitForTransaction('cloudflare-workers-static', event => {
124+
return event.transaction === 'alarm' && event.contexts?.trace?.op === 'function';
125+
});
126+
127+
const response = await fetch(`${baseURL}/pass-to-object/setAlarm`);
128+
expect(response.status).toBe(200);
129+
130+
const setAlarmTransaction = await setAlarmTransactionWaiter;
131+
const alarmTransaction = await alarmTransactionWaiter;
132+
133+
// Alarm creates a transaction with correct attributes
134+
expect(alarmTransaction.contexts?.trace?.op).toBe('function');
135+
expect(alarmTransaction.contexts?.trace?.origin).toBe('auto.faas.cloudflare.durable_object');
136+
137+
// Alarm starts a new trace (different trace ID from the request that called setAlarm)
138+
expect(alarmTransaction.contexts?.trace?.trace_id).not.toBe(setAlarmTransaction.contexts?.trace?.trace_id);
139+
140+
// Alarm links to the trace that called setAlarm via sentry.previous_trace attribute
141+
const previousTrace = alarmTransaction.contexts?.trace?.data?.['sentry.previous_trace'];
142+
expect(previousTrace).toBeDefined();
143+
expect(previousTrace).toContain(setAlarmTransaction.contexts?.trace?.trace_id);
144+
});
145+
});

0 commit comments

Comments
 (0)