Skip to content

Commit 2d12aaf

Browse files
nicohrubecclaude
andcommitted
test(e2e): Add span streaming test app for Cloudflare Workers
Closes #20671 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e8f4c42 commit 2d12aaf

12 files changed

Lines changed: 481 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "cloudflare-workers-streaming",
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+
"test": "vitest --run",
10+
"typecheck": "tsc --noEmit",
11+
"cf-typegen": "wrangler types",
12+
"test:build": "pnpm install && pnpm build",
13+
"test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod",
14+
"test:prod": "TEST_ENV=production playwright test",
15+
"test:dev": "TEST_ENV=development playwright test"
16+
},
17+
"dependencies": {
18+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz"
19+
},
20+
"devDependencies": {
21+
"@playwright/test": "~1.56.0",
22+
"@cloudflare/vitest-pool-workers": "^0.8.19",
23+
"@cloudflare/workers-types": "^4.20240725.0",
24+
"@sentry-internal/test-utils": "link:../../../test-utils",
25+
"typescript": "^5.5.2",
26+
"vitest": "~3.2.0",
27+
"wrangler": "^4.61.0",
28+
"ws": "^8.18.3"
29+
},
30+
"volta": {
31+
"node": "24.15.0",
32+
"extends": "../../package.json"
33+
},
34+
"pnpm": {
35+
"overrides": {
36+
"strip-literal": "~2.0.0"
37+
}
38+
}
39+
}
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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
dsn: env.E2E_TEST_DSN,
80+
environment: 'qa', // dynamic sampling bias to keep transactions
81+
tunnel: `http://localhost:3031/`, // proxy server
82+
tracesSampleRate: 1.0,
83+
traceLifecycle: 'stream',
84+
sendDefaultPii: true,
85+
transportOptions: {
86+
// We are doing a lot of events at once in this test
87+
bufferSize: 1000,
88+
},
89+
enableRpcTracePropagation: true,
90+
}),
91+
MyDurableObjectBase,
92+
);
93+
94+
export default Sentry.withSentry(
95+
(env: Env) => ({
96+
dsn: env.E2E_TEST_DSN,
97+
environment: 'qa', // dynamic sampling bias to keep transactions
98+
tunnel: `http://localhost:3031/`, // proxy server
99+
tracesSampleRate: 1.0,
100+
traceLifecycle: 'stream',
101+
sendDefaultPii: true,
102+
transportOptions: {
103+
// We are doing a lot of events at once in this test
104+
bufferSize: 1000,
105+
},
106+
enableRpcTracePropagation: true,
107+
}),
108+
{
109+
async fetch(request, env) {
110+
const url = new URL(request.url);
111+
switch (url.pathname) {
112+
case '/rpc/throwException':
113+
{
114+
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
115+
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
116+
try {
117+
await stub.throwException();
118+
} catch (e) {
119+
//We will catch this to be sure not to log inside withSentry
120+
return new Response(null, { status: 500 });
121+
}
122+
}
123+
break;
124+
case '/throwException':
125+
throw new Error('To be recorded in Sentry.');
126+
default:
127+
if (url.pathname.startsWith('/pass-to-object/')) {
128+
const id = env.MY_DURABLE_OBJECT.idFromName('foo');
129+
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
130+
url.pathname = url.pathname.replace('/pass-to-object/', '');
131+
return stub.fetch(new Request(url, request));
132+
}
133+
}
134+
return new Response('Hello World!');
135+
},
136+
} satisfies ExportedHandler<Env>,
137+
);
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-streaming',
6+
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { expect, test } from '@playwright/test';
2+
import {
3+
getSpanOp,
4+
waitForError,
5+
waitForRequest,
6+
waitForStreamedSpan,
7+
waitForStreamedSpans,
8+
} from '@sentry-internal/test-utils';
9+
import { SDK_VERSION } from '@sentry/cloudflare';
10+
import { WebSocket } from 'ws';
11+
12+
test('Index page', async ({ baseURL }) => {
13+
const result = await fetch(baseURL!);
14+
expect(result.status).toBe(200);
15+
await expect(result.text()).resolves.toBe('Hello World!');
16+
});
17+
18+
test('Sends a streamed span for a basic request', async ({ baseURL }) => {
19+
const spanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => {
20+
return getSpanOp(span) === 'http.server' && span.is_segment;
21+
});
22+
23+
await fetch(baseURL!);
24+
25+
const span = await spanPromise;
26+
27+
expect(span.trace_id).toMatch(/[a-f0-9]{32}/);
28+
expect(span.status).toBe('ok');
29+
});
30+
31+
test("worker's withSentry", async ({ baseURL }) => {
32+
const eventWaiter = waitForError('cloudflare-workers-streaming', event => {
33+
return event.exception?.values?.[0]?.mechanism?.type === 'auto.http.cloudflare';
34+
});
35+
const response = await fetch(`${baseURL}/throwException`);
36+
expect(response.status).toBe(500);
37+
const event = await eventWaiter;
38+
expect(event.exception?.values?.[0]?.value).toBe('To be recorded in Sentry.');
39+
});
40+
41+
test('RPC method which throws an exception to be logged to sentry', async ({ baseURL }) => {
42+
const eventWaiter = waitForError('cloudflare-workers-streaming', event => {
43+
return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object';
44+
});
45+
const response = await fetch(`${baseURL}/rpc/throwException`);
46+
expect(response.status).toBe(500);
47+
const event = await eventWaiter;
48+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
49+
});
50+
51+
test("Request processed by DurableObject's fetch is recorded", async ({ baseURL }) => {
52+
const eventWaiter = waitForError('cloudflare-workers-streaming', event => {
53+
return event.exception?.values?.[0]?.mechanism?.type === 'auto.faas.cloudflare.durable_object';
54+
});
55+
const response = await fetch(`${baseURL}/pass-to-object/throwException`);
56+
expect(response.status).toBe(500);
57+
const event = await eventWaiter;
58+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry.');
59+
});
60+
61+
test('Websocket.webSocketMessage', async ({ baseURL }) => {
62+
const eventWaiter = waitForError('cloudflare-workers-streaming', event => {
63+
return !!event.exception?.values?.[0];
64+
});
65+
const url = new URL('/pass-to-object/ws', baseURL);
66+
url.protocol = url.protocol.replace('http', 'ws');
67+
const socket = new WebSocket(url.toString());
68+
socket.addEventListener('open', () => {
69+
socket.send('throwException');
70+
});
71+
const event = await eventWaiter;
72+
socket.close();
73+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketMessage');
74+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
75+
});
76+
77+
test('Websocket.webSocketClose', async ({ baseURL }) => {
78+
const eventWaiter = waitForError('cloudflare-workers-streaming', event => {
79+
return !!event.exception?.values?.[0];
80+
});
81+
const url = new URL('/pass-to-object/ws', baseURL);
82+
url.protocol = url.protocol.replace('http', 'ws');
83+
const socket = new WebSocket(url.toString());
84+
socket.addEventListener('open', () => {
85+
socket.send('throwOnExit');
86+
socket.close();
87+
});
88+
const event = await eventWaiter;
89+
expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose');
90+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
91+
});
92+
93+
test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => {
94+
const requestPromise = waitForRequest('cloudflare-workers-streaming', () => true);
95+
96+
await fetch(`${baseURL}/throwException`);
97+
98+
const request = await requestPromise;
99+
100+
expect(request.rawProxyRequestHeaders).toMatchObject({
101+
'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`,
102+
});
103+
});
104+
105+
test('Storage operations create spans in Durable Object', async ({ baseURL }) => {
106+
const spansPromise = waitForStreamedSpans('cloudflare-workers-streaming', spans => {
107+
return spans.some(span => span.name === 'durable_object_storage_put' && getSpanOp(span) === 'db');
108+
});
109+
110+
const response = await fetch(`${baseURL}/pass-to-object/storage/put`);
111+
expect(response.status).toBe(200);
112+
113+
const spans = await spansPromise;
114+
const putSpan = spans.find(span => span.name === 'durable_object_storage_put' && getSpanOp(span) === 'db');
115+
116+
expect(putSpan).toBeDefined();
117+
expect(putSpan?.attributes?.['db.system.name']?.value).toBe('cloudflare.durable_object.storage');
118+
expect(putSpan?.attributes?.['db.operation.name']?.value).toBe('put');
119+
});
120+
121+
test.describe('Alarm instrumentation', () => {
122+
test.describe.configure({ mode: 'serial' });
123+
124+
test('captures error from alarm handler', async ({ baseURL }) => {
125+
const errorWaiter = waitForError('cloudflare-workers-streaming', event => {
126+
return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry';
127+
});
128+
129+
const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`);
130+
expect(response.status).toBe(200);
131+
132+
const event = await errorWaiter;
133+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
134+
});
135+
136+
test('creates a streamed span for alarm with new trace linked to setAlarm', async ({ baseURL }) => {
137+
const setAlarmSpanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => {
138+
return span.name === 'durable_object_storage_setAlarm' && span.is_segment === false;
139+
});
140+
141+
const alarmSpanPromise = waitForStreamedSpan('cloudflare-workers-streaming', span => {
142+
return span.name === 'alarm' && getSpanOp(span) === 'function' && span.is_segment;
143+
});
144+
145+
const response = await fetch(`${baseURL}/pass-to-object/setAlarm`);
146+
expect(response.status).toBe(200);
147+
148+
const setAlarmSpan = await setAlarmSpanPromise;
149+
const alarmSpan = await alarmSpanPromise;
150+
151+
expect(getSpanOp(alarmSpan)).toBe('function');
152+
expect(alarmSpan.attributes?.['sentry.origin']?.value).toBe('auto.faas.cloudflare.durable_object');
153+
154+
// Alarm starts a new trace (different trace ID from the request that called setAlarm)
155+
expect(alarmSpan.trace_id).not.toBe(setAlarmSpan.trace_id);
156+
157+
// Alarm links to the trace that called setAlarm via sentry.previous_trace attribute
158+
const previousTrace = alarmSpan.attributes?.['sentry.previous_trace']?.value;
159+
expect(previousTrace).toBeDefined();
160+
expect(previousTrace).toContain(setAlarmSpan.trace_id);
161+
});
162+
});

0 commit comments

Comments
 (0)