Skip to content

Commit f82649e

Browse files
committed
feat(cloudflare): Support basic WorkerEntrypoint
1 parent 3bb4325 commit f82649e

24 files changed

+14116
-24
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "cloudflare-workersentrypoint",
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 --strict-vars false",
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": "latest || *"
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+
"extends": "../../package.json"
32+
},
33+
"pnpm": {
34+
"overrides": {
35+
"strip-literal": "~2.0.0"
36+
}
37+
}
38+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
retries: 0,
19+
},
20+
);
21+
22+
export default config;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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, WorkerEntrypoint } 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 url = new URL(request.url);
24+
switch (url.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+
case '/storage/put': {
36+
await this.ctx.storage.put('test-key', 'test-value');
37+
return new Response('Stored');
38+
}
39+
case '/storage/get': {
40+
const value = await this.ctx.storage.get('test-key');
41+
return new Response(`Got: ${value}`);
42+
}
43+
}
44+
return new Response('DO is fine');
45+
}
46+
47+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void> {
48+
if (message === 'throwException') {
49+
throw new Error('Should be recorded in Sentry: webSocketMessage');
50+
} else if (message === 'throwOnExit') {
51+
this.throwOnExit.set(ws, new Error('Should be recorded in Sentry: webSocketClose'));
52+
}
53+
}
54+
55+
webSocketClose(ws: WebSocket): void | Promise<void> {
56+
if (this.throwOnExit.has(ws)) {
57+
const error = this.throwOnExit.get(ws)!;
58+
this.throwOnExit.delete(ws);
59+
throw error;
60+
}
61+
}
62+
}
63+
64+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
65+
(env: Env) => ({
66+
dsn: env.E2E_TEST_DSN,
67+
environment: 'qa', // dynamic sampling bias to keep transactions
68+
tunnel: `http://localhost:3031/`, // proxy server
69+
tracesSampleRate: 1.0,
70+
sendDefaultPii: true,
71+
transportOptions: {
72+
// We are doing a lot of events at once in this test
73+
bufferSize: 1000,
74+
},
75+
instrumentPrototypeMethods: true,
76+
}),
77+
MyDurableObjectBase,
78+
);
79+
80+
class MyWorker extends WorkerEntrypoint {
81+
async fetch(request: Request) {
82+
const url = new URL(request.url);
83+
switch (url.pathname) {
84+
case '/rpc/throwException':
85+
{
86+
const id = this.env.MY_DURABLE_OBJECT.idFromName('foo');
87+
const stub = this.env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
88+
try {
89+
await stub.throwException();
90+
} catch (e) {
91+
//We will catch this to be sure not to log inside withSentry
92+
return new Response(null, { status: 500 });
93+
}
94+
}
95+
break;
96+
case '/throwException':
97+
throw new Error('To be recorded in Sentry.');
98+
default:
99+
if (url.pathname.startsWith('/pass-to-object/')) {
100+
const id = this.env.MY_DURABLE_OBJECT.idFromName('foo');
101+
const stub = this.env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyDurableObjectBase>;
102+
url.pathname = url.pathname.replace('/pass-to-object/', '');
103+
return stub.fetch(new Request(url, request));
104+
}
105+
}
106+
return new Response('Hello World!');
107+
}
108+
}
109+
110+
export default Sentry.withSentry(
111+
env => ({
112+
dsn: env.E2E_TEST_DSN,
113+
environment: 'qa', // dynamic sampling bias to keep transactions
114+
tunnel: `http://localhost:3031/`, // proxy server
115+
tracesSampleRate: 1.0,
116+
sendDefaultPii: true,
117+
transportOptions: {
118+
// We are doing a lot of events at once in this test
119+
bufferSize: 1000,
120+
},
121+
}),
122+
MyWorker,
123+
);
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-workersentrypoint',
6+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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-workersentrypoint', 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-workersentrypoint', 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-workersentrypoint', 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-workersentrypoint', 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-workersentrypoint', 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-workersentrypoint', () => 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-workersentrypoint', 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+
});
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"],
7+
"exclude": []
8+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"compilerOptions": {
3+
/* Visit https://aka.ms/tsconfig.json to read more about this file */
4+
5+
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
6+
"target": "es2021",
7+
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
8+
"lib": ["es2021"],
9+
/* Specify what JSX code is generated. */
10+
"jsx": "react-jsx",
11+
12+
/* Specify what module code is generated. */
13+
"module": "es2022",
14+
/* Specify how TypeScript looks up a file from a given module specifier. */
15+
"moduleResolution": "Bundler",
16+
/* Enable importing .json files */
17+
"resolveJsonModule": true,
18+
19+
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
20+
"allowJs": true,
21+
/* Enable error reporting in type-checked JavaScript files. */
22+
"checkJs": false,
23+
24+
/* Disable emitting files from a compilation. */
25+
"noEmit": true,
26+
27+
/* Ensure that each file can be safely transpiled without relying on other imports. */
28+
"isolatedModules": true,
29+
/* Allow 'import x from y' when a module doesn't have a default export. */
30+
"allowSyntheticDefaultImports": true,
31+
/* Ensure that casing is correct in imports. */
32+
"forceConsistentCasingInFileNames": true,
33+
34+
/* Enable all strict type-checking options. */
35+
"strict": true,
36+
37+
/* Skip type checking all .d.ts files. */
38+
"skipLibCheck": true,
39+
"types": ["./worker-configuration.d.ts"]
40+
},
41+
"exclude": ["test"],
42+
"include": ["src/**/*.ts"]
43+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
2+
3+
export default defineWorkersConfig({
4+
test: {
5+
poolOptions: {
6+
workers: {
7+
wrangler: { configPath: './wrangler.toml' },
8+
},
9+
},
10+
},
11+
});

0 commit comments

Comments
 (0)