Skip to content

Commit a37cec7

Browse files
committed
feat(open-next): create cloudflare-sentry-tail package
Creates a `cloudflare-sentry-tail` package that enables us to add a tail worker to the open next deployment of the site. This package should be publishable as well so that we can reuse it in the release worker. Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
1 parent 97e28c5 commit a37cec7

10 files changed

Lines changed: 395 additions & 4 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ apps/site/site.json @nodejs/web-infra
3030
apps/site/wrangler.jsonc @nodejs/web-infra
3131
apps/site/open-next.config.ts @nodejs/web-infra
3232
apps/site/redirects.json @nodejs/web-infra
33+
packages/cloudflare-sentry-tail @nodejs/web-infra
3334

3435
# Critical Documents
3536
LICENSE @nodejs/tsc

apps/site/cloudflare/worker-entrypoint.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// - the official sentry docs: https://docs.sentry.io/platforms/javascript/guides/cloudflare
55

66
import { setTags, withSentry } from '@sentry/cloudflare';
7+
import { createSentryTail } from '@node-core/cloudflare-sentry-tail';
78

89
import type {
910
ExecutionContext,
@@ -47,6 +48,10 @@ export default withSentry(
4748

4849
return handler.fetch(request, env, ctx);
4950
},
51+
tail: createSentryTail({
52+
samplingRate: 1,
53+
headersToRedact: ['authorization', 'cookie'],
54+
}),
5055
}
5156
);
5257

apps/site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"dependencies": {
3535
"@heroicons/react": "~2.2.0",
3636
"@mdx-js/mdx": "^3.1.1",
37+
"@node-core/cloudflare-sentry-tail": "workspace:*",
3738
"@node-core/rehype-shiki": "workspace:*",
3839
"@node-core/ui-components": "workspace:*",
3940
"@node-core/website-i18n": "workspace:*",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"**/*.{ts}": ["prettier --check --write", "eslint --fix"]
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from '../../eslint.config.js';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@node-core/cloudflare-sentry-tail",
3+
"description": "Cloudflare Tail Worker for Sentry",
4+
"version": "1.0.0",
5+
"type": "module",
6+
"exports": {
7+
".": {
8+
"default": "./src/index.ts"
9+
}
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/nodejs/nodejs.org",
14+
"directory": "packages/cloudflare-sentry-tail"
15+
},
16+
"scripts": {
17+
"lint": "node --run lint:js",
18+
"lint:fix": "node --run lint:js:fix",
19+
"lint:js": "eslint \"**/*.ts\"",
20+
"lint:js:fix": "node --run lint:js -- --fix"
21+
},
22+
"engines": {
23+
"node": ">=20"
24+
},
25+
"devDependencies": {
26+
"@cloudflare/workers-types": "^4.20260422.1"
27+
},
28+
"dependencies": {
29+
"@sentry/cloudflare": "^10.49.0"
30+
}
31+
}
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
export type SentryTailWorkerOptions = {
4+
samplingRate: number;
5+
headersToRedact?: Array<string>;
6+
};
7+
8+
export function createSentryTail<Env = unknown>(
9+
options: SentryTailWorkerOptions
10+
): ExportedHandlerTailHandler<Env> {
11+
return (items: Array<TraceItem>): void => {
12+
for (const item of items) {
13+
processTraceItem(options, item);
14+
}
15+
};
16+
}
17+
18+
function processTraceItem(
19+
options: SentryTailWorkerOptions,
20+
item: TraceItem
21+
): void {
22+
const severityLevel = determineSeverityLevel(item);
23+
if (!severityLevel) {
24+
// Not an error
25+
return;
26+
}
27+
28+
if (
29+
options.samplingRate !== 1 &&
30+
!shouldSampleTraceItem(options.samplingRate)
31+
) {
32+
return;
33+
}
34+
35+
const event: Sentry.Event = {
36+
level: severityLevel,
37+
timestamp: item.eventTimestamp ?? Date.now(),
38+
logger: '@node-core/cloudflare-sentry-tail',
39+
message: workerOutcomeToEventMessage(item.outcome),
40+
fingerprint: [],
41+
breadcrumbs: [],
42+
exception: {},
43+
tags: {
44+
outcome: item.outcome,
45+
script_name: item.scriptName,
46+
script_version: item.scriptVersion?.tag,
47+
cpu_time: item.cpuTime,
48+
wall_time: item.wallTime,
49+
},
50+
};
51+
52+
// Populate data specific to the type of trace event we got
53+
handleTraceItemEvent(options, item, event);
54+
55+
// Populate breadcrumbs with any relevant data
56+
addRemainingBreadcrumbs(item, event);
57+
58+
// Sort breadcrumbs by their timestamps
59+
event.breadcrumbs?.sort((a, b) => {
60+
if (!a.timestamp || !b.timestamp) {
61+
return 0;
62+
}
63+
64+
return a.timestamp - b.timestamp;
65+
});
66+
67+
Sentry.captureEvent(event);
68+
}
69+
70+
function determineSeverityLevel(
71+
item: TraceItem
72+
): Sentry.SeverityLevel | undefined {
73+
// Two scenarios where we want to report back to Sentry:
74+
// 1. Trace item outcome isn't 'ok'
75+
// 2. We have a status code >= 500
76+
//
77+
// Note that outcome is determined by if the worker executed to completion,
78+
// not if it returned a successful status code
79+
80+
if (item.outcome === 'ok') {
81+
const response =
82+
item.event && 'response' in item.event ? item.event.response : undefined;
83+
84+
if (response?.status && response?.status >= 500) {
85+
return 'error';
86+
} else {
87+
// Don't care
88+
return undefined;
89+
}
90+
}
91+
92+
return workerOutcomeToSeverityLevel(item.outcome);
93+
}
94+
95+
/**
96+
* Determines what kind of trace item event we received and adds any
97+
* event-specific properties to the Sentry event to be reported.
98+
*/
99+
function handleTraceItemEvent(
100+
options: SentryTailWorkerOptions,
101+
item: TraceItem,
102+
sentryEvent: Sentry.Event
103+
): void {
104+
if (!item.event) {
105+
return;
106+
}
107+
108+
if ('request' in item.event) {
109+
const request = item.event.request;
110+
const response = item.event.response;
111+
112+
const redactedHeaders: Record<string, string> = {};
113+
for (let [key, value] of Object.entries(request.headers)) {
114+
key = key.toLowerCase();
115+
116+
if (options.headersToRedact && key in options.headersToRedact) {
117+
value = 'redacted';
118+
}
119+
120+
redactedHeaders[key] = value;
121+
}
122+
123+
sentryEvent.request = {
124+
method: request.method,
125+
url: request.url,
126+
headers: redactedHeaders,
127+
env: {
128+
asn: request.cf?.asn,
129+
colo: request.cf?.colo,
130+
continent: request.cf?.continent,
131+
country: request.cf?.country,
132+
timezone: request.cf?.timezone,
133+
httpProtocol: request.cf?.httpProtocol,
134+
requestPriority: request.cf?.requestPriority,
135+
tlsCipher: request.cf?.tlsCipher,
136+
tlsClientAuth: request.cf?.tlsClientAuth,
137+
tlsExportedAuthenticator: request.cf?.tlsExportedAuthenticator,
138+
tlsVersion: request.cf?.tlsVersion,
139+
},
140+
};
141+
142+
const responseStatusCode = response?.status ?? 'Unknown';
143+
144+
sentryEvent.message = response
145+
? `${responseStatusCode} Response`
146+
: 'No response';
147+
148+
sentryEvent.breadcrumbs?.push({
149+
type: 'http',
150+
category: 'request',
151+
timestamp: item.eventTimestamp ?? Date.now(),
152+
data: {
153+
url: request.url,
154+
method: request.method,
155+
status_code: responseStatusCode,
156+
},
157+
});
158+
159+
const requestUrl = new URL(request.url);
160+
sentryEvent.fingerprint?.push(
161+
requestUrl.origin,
162+
requestUrl.pathname,
163+
request.method,
164+
`${responseStatusCode}`
165+
);
166+
167+
sentryEvent.tags!.event = 'fetch';
168+
sentryEvent.tags!.ray_id = redactedHeaders['cf-ray'];
169+
} else if ('rpcMethod' in item.event) {
170+
sentryEvent.tags!.event = 'js-rpc';
171+
sentryEvent.tags!.rpc_method = item.event.rpcMethod;
172+
} else if ('scheduledTime' in item.event) {
173+
if ('cron' in item.event) {
174+
sentryEvent.tags!.event = 'scheduled';
175+
sentryEvent.tags!.scheduled_time = item.event.scheduledTime;
176+
sentryEvent.tags!.cron = item.event.cron;
177+
return;
178+
}
179+
180+
sentryEvent.tags!.event = 'alarm';
181+
sentryEvent.tags!.scheduled_time = item.event.scheduledTime.toUTCString();
182+
} else if ('queue' in item.event) {
183+
sentryEvent.tags!.event = 'queue';
184+
sentryEvent.tags!.queue = item.event.queue;
185+
sentryEvent.tags!.batchSize = item.event.batchSize;
186+
} else if ('mailFrom' in item.event) {
187+
sentryEvent.tags!.event = 'email';
188+
sentryEvent.tags!.rawSize = item.event.rawSize;
189+
}
190+
}
191+
192+
function addRemainingBreadcrumbs(item: TraceItem, sentryEvent: Sentry.Event) {
193+
if (!sentryEvent.breadcrumbs) {
194+
return;
195+
}
196+
197+
let breadcrumbsIdx = sentryEvent.breadcrumbs.length;
198+
199+
// Allocate space for the elements we're gonna add
200+
sentryEvent.breadcrumbs.length +=
201+
item.logs.length +
202+
item.diagnosticsChannelEvents.length +
203+
item.exceptions.length;
204+
205+
for (const log of item.logs) {
206+
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
207+
type: 'debug',
208+
category: `console.${log.level}`,
209+
message: consoleLogToString(log.message),
210+
level: consoleLogLevelToSentryLevel(log.level),
211+
timestamp: log.timestamp,
212+
};
213+
}
214+
215+
for (const payload of item.diagnosticsChannelEvents) {
216+
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
217+
type: 'debug',
218+
category: `channel.${payload.channel}`,
219+
message: consoleLogToString(payload.message),
220+
level: 'debug',
221+
timestamp: payload.timestamp,
222+
};
223+
}
224+
225+
let fingerprintIdx = sentryEvent.fingerprint!.length;
226+
let exceptionValueIdx = sentryEvent.exception!.values!.length;
227+
228+
sentryEvent.fingerprint!.length += item.exceptions.length;
229+
sentryEvent.exception!.values!.length += item.exceptions.length;
230+
231+
for (const exception of item.exceptions) {
232+
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
233+
type: 'error',
234+
level: 'error',
235+
category: exception.name,
236+
message: exception.message,
237+
timestamp: exception.timestamp,
238+
data: {
239+
stack: exception.stack,
240+
},
241+
};
242+
243+
sentryEvent.fingerprint![fingerprintIdx++] = exception.name;
244+
sentryEvent.exception!.values![exceptionValueIdx++] = {
245+
type: exception.name,
246+
value: exception.message,
247+
};
248+
}
249+
}
250+
251+
function shouldSampleTraceItem(sampleRate: number) {
252+
const buffer = new Uint32Array(1);
253+
crypto.getRandomValues(buffer);
254+
255+
const random = buffer[0] / 4294967295;
256+
257+
return random <= sampleRate;
258+
}
259+
260+
function workerOutcomeToSeverityLevel(outcome: string): Sentry.SeverityLevel {
261+
const map: Record<string, Sentry.SeverityLevel> = {
262+
exceededCpu: 'fatal',
263+
exceededMemory: 'fatal',
264+
exception: 'error',
265+
ok: 'info',
266+
};
267+
268+
return map[outcome] ?? 'warning';
269+
}
270+
271+
function workerOutcomeToEventMessage(outcome: string): string {
272+
const map: Record<string, string> = {
273+
exceededCpu: 'Exceeded CPU',
274+
exceededMemory: 'Exceeded Memory',
275+
exception: 'Script Threw Exception',
276+
canceled: 'Client Disconnected',
277+
ok: 'Success',
278+
};
279+
280+
return map[outcome] ?? 'Internal';
281+
}
282+
283+
function consoleLogLevelToSentryLevel(logLevel: string): Sentry.SeverityLevel {
284+
const map: Record<string, Sentry.SeverityLevel> = {
285+
debug: 'debug',
286+
log: 'info',
287+
error: 'error',
288+
warn: 'warning',
289+
trace: 'debug',
290+
};
291+
292+
return map[logLevel] ?? 'debug';
293+
}
294+
295+
function consoleLogToString(logMessage: unknown): string {
296+
const pieces = Array.isArray(logMessage) ? logMessage : [logMessage];
297+
return pieces
298+
.map(p => (typeof p === 'string' ? p : JSON.stringify(p)))
299+
.join(', ');
300+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"compilerOptions": {
3+
"target": "esnext",
4+
"module": "esnext",
5+
"lib": ["esnext"],
6+
"types": ["@cloudflare/workers-types"],
7+
"allowJs": true,
8+
"skipLibCheck": true,
9+
"strict": true,
10+
"forceConsistentCasingInFileNames": true,
11+
"esModuleInterop": true,
12+
"moduleResolution": "bundler",
13+
"resolveJsonModule": true,
14+
"isolatedModules": true,
15+
"incremental": true,
16+
"baseUrl": ".",
17+
"outDir": "dist",
18+
"rootDir": "."
19+
},
20+
"include": ["src"]
21+
}

0 commit comments

Comments
 (0)