Skip to content

Commit 8804c4e

Browse files
authored
feat(cloudflare): Support basic WorkerEntrypoint (#19884)
part of #15942 This is a basic instrumentation for `WorkerEntrypoint`. There is no extra `instrumentWorkerEntrypoint` exported, and should work directly with `withSentry` OOTB: ```js class MyWorker extends WorkerEntrypoint {} export default Sentry.withSentry(() => ({}), MyWorker) ``` To support the full `WorkerEntrypoint` there is the need to instrument [RPC methods](#16898). Without that we can't fully support `WorkerEntrypoint`s but just the basics of it. This can be added once #19991 lands (it is safe to review now and instrument the env in a separate PR) --- If you look at the instrumentations themselves, there is no `isInstrumented` or `markAsInstrumented`. The reason is that on the one hand this is not required, as every request is spawning a new instance of the `WorkerEntrypoint` and on the other hand it wouldn't work as it would mark it as instrumented, but on the request the `fetch` is overridden and not instrumented.
1 parent 0f35883 commit 8804c4e

File tree

26 files changed

+14154
-32
lines changed

26 files changed

+14154
-32
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7+
- **feat(cloudflare): Support basic WorkerEntrypoint ([#19884](https://github.com/getsentry/sentry-javascript/pull/19884))**
8+
9+
`withSentry` now supports instrumenting classes extending Cloudflare's `WorkerEntrypoint`. This instruments `fetch`, `scheduled`, `queue`, and `tail` handlers.
10+
11+
```ts
12+
import * as Sentry from '@sentry/cloudflare';
13+
import { WorkerEntrypoint } from 'cloudflare:workers';
14+
15+
class MyWorker extends WorkerEntrypoint {
16+
async fetch(request: Request): Promise<Response> {
17+
return new Response('Hello World!');
18+
}
19+
}
20+
21+
export default Sentry.withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), MyWorker);
22+
```
23+
724
- **ref(core): Unify .do\* span ops to `gen_ai.generate_content` ([#20074](https://github.com/getsentry/sentry-javascript/pull/20074))**
825

926
All Vercel AI `do*` spans (`ai.generateText.doGenerate`, `ai.streamText.doStream`, `ai.generateObject.doGenerate`, `ai.streamObject.doStream`) now use a single unified span op `gen_ai.generate_content` instead of separate ops like `gen_ai.generate_text`, `gen_ai.stream_text`, `gen_ai.generate_object`, and `gen_ai.stream_object`.
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time)
22
// by running `wrangler types`
3-
4-
interface Env {
5-
E2E_TEST_DSN: '';
6-
MY_DURABLE_OBJECT: DurableObjectNamespace;
3+
declare namespace Cloudflare {
4+
interface Env {
5+
E2E_TEST_DSN: '';
6+
MY_DURABLE_OBJECT: DurableObjectNamespace;
7+
}
78
}
9+
interface Env extends Cloudflare.Env {}
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+
}

0 commit comments

Comments
 (0)