Skip to content

Commit b67a626

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 4ffcf03 commit b67a626

11 files changed

Lines changed: 401 additions & 0 deletions

File tree

‎.github/CODEOWNERS‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ crowdin.yml @nodejs/web-infra
2828
apps/site/redirects.json @nodejs/web-infra
2929
apps/site/site.json @nodejs/web-infra
3030
platforms @nodejs/web-infra
31+
packages/cloudflare-sentry-tail @nodejs/web-infra
3132

3233
# Critical Documents
3334
LICENSE @nodejs/tsc

‎apps/site/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@heroicons/react": "~2.2.0",
3131
"@mdx-js/mdx": "^3.1.1",
32+
"@node-core/cloudflare-sentry-tail": "workspace:*",
3233
"@node-core/rehype-shiki": "workspace:*",
3334
"@node-core/ui-components": "workspace:*",
3435
"@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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { processTraceItem } from './utils/processTailItem';
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+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { type Event, captureEvent } from '@sentry/cloudflare';
2+
3+
import { SentryTailWorkerOptions } from '../index';
4+
import {
5+
consoleLogLevelToSentryLevel,
6+
consoleLogToString,
7+
determineSeverityLevel,
8+
workerOutcomeToEventMessage,
9+
} from './sentry';
10+
11+
/**
12+
* Extracts relevant data from the provided {@link TraceItem} & sends it to
13+
* Sentry if determined to be something worthy of reporting
14+
*/
15+
export function processTraceItem(
16+
options: SentryTailWorkerOptions,
17+
item: TraceItem
18+
): void {
19+
const severityLevel = determineSeverityLevel(item);
20+
if (!severityLevel) {
21+
// Not an error
22+
return;
23+
}
24+
25+
if (
26+
options.samplingRate !== 1 &&
27+
!shouldSampleTraceItem(options.samplingRate)
28+
) {
29+
return;
30+
}
31+
32+
const event: Event = {
33+
level: severityLevel,
34+
timestamp: item.eventTimestamp ?? Date.now(),
35+
logger: '@node-core/cloudflare-sentry-tail',
36+
message: workerOutcomeToEventMessage(item.outcome),
37+
fingerprint: [],
38+
breadcrumbs: [],
39+
exception: {
40+
values: [],
41+
},
42+
tags: {
43+
outcome: item.outcome,
44+
script_name: item.scriptName,
45+
script_version: item.scriptVersion?.tag,
46+
cpu_time: item.cpuTime,
47+
wall_time: item.wallTime,
48+
},
49+
};
50+
51+
// Populate data specific to the type of trace event we got
52+
handleTraceItemEvent(options, item, event);
53+
54+
// Populate breadcrumbs with any relevant data
55+
addRemainingBreadcrumbs(item, event);
56+
57+
// Sort breadcrumbs by their timestamps
58+
event.breadcrumbs?.sort((a, b) => {
59+
if (!a.timestamp || !b.timestamp) {
60+
return 0;
61+
}
62+
63+
return a.timestamp - b.timestamp;
64+
});
65+
66+
captureEvent(event);
67+
}
68+
69+
/**
70+
* Determines what kind of trace item event we received and adds any
71+
* event-specific properties to the Sentry event to be reported.
72+
*/
73+
function handleTraceItemEvent(
74+
options: SentryTailWorkerOptions,
75+
item: TraceItem,
76+
sentryEvent: Event
77+
): void {
78+
if (!item.event) {
79+
return;
80+
}
81+
82+
if ('request' in item.event) {
83+
const request = item.event.request;
84+
const response = item.event.response;
85+
86+
const redactedHeaders: Record<string, string> = {};
87+
for (let [key, value] of Object.entries(request.headers)) {
88+
key = key.toLowerCase();
89+
90+
if (options.headersToRedact && options.headersToRedact.includes(key)) {
91+
value = 'redacted';
92+
}
93+
94+
redactedHeaders[key] = value;
95+
}
96+
97+
sentryEvent.request = {
98+
method: request.method,
99+
url: request.url,
100+
headers: redactedHeaders,
101+
env: {
102+
asn: request.cf?.asn,
103+
colo: request.cf?.colo,
104+
continent: request.cf?.continent,
105+
country: request.cf?.country,
106+
timezone: request.cf?.timezone,
107+
httpProtocol: request.cf?.httpProtocol,
108+
requestPriority: request.cf?.requestPriority,
109+
tlsCipher: request.cf?.tlsCipher,
110+
tlsClientAuth: request.cf?.tlsClientAuth,
111+
tlsExportedAuthenticator: request.cf?.tlsExportedAuthenticator,
112+
tlsVersion: request.cf?.tlsVersion,
113+
},
114+
};
115+
116+
const responseStatusCode = response?.status ?? 'Unknown';
117+
118+
sentryEvent.message = response
119+
? `${responseStatusCode} Response`
120+
: 'No response';
121+
122+
sentryEvent.breadcrumbs?.push({
123+
type: 'http',
124+
category: 'request',
125+
timestamp: item.eventTimestamp ?? Date.now(),
126+
data: {
127+
url: request.url,
128+
method: request.method,
129+
status_code: responseStatusCode,
130+
},
131+
});
132+
133+
const requestUrl = new URL(request.url);
134+
sentryEvent.fingerprint?.push(
135+
requestUrl.origin,
136+
requestUrl.pathname,
137+
request.method,
138+
`${responseStatusCode}`
139+
);
140+
141+
sentryEvent.tags!.event = 'fetch';
142+
sentryEvent.tags!.ray_id = redactedHeaders['cf-ray'];
143+
} else if ('rpcMethod' in item.event) {
144+
sentryEvent.tags!.event = 'js-rpc';
145+
sentryEvent.tags!.rpc_method = item.event.rpcMethod;
146+
} else if ('scheduledTime' in item.event) {
147+
if ('cron' in item.event) {
148+
sentryEvent.tags!.event = 'scheduled';
149+
sentryEvent.tags!.scheduled_time = item.event.scheduledTime;
150+
sentryEvent.tags!.cron = item.event.cron;
151+
return;
152+
}
153+
154+
sentryEvent.tags!.event = 'alarm';
155+
sentryEvent.tags!.scheduled_time = item.event.scheduledTime.toUTCString();
156+
} else if ('queue' in item.event) {
157+
sentryEvent.tags!.event = 'queue';
158+
sentryEvent.tags!.queue = item.event.queue;
159+
sentryEvent.tags!.batchSize = item.event.batchSize;
160+
} else if ('mailFrom' in item.event) {
161+
sentryEvent.tags!.event = 'email';
162+
sentryEvent.tags!.rawSize = item.event.rawSize;
163+
}
164+
}
165+
166+
function addRemainingBreadcrumbs(item: TraceItem, sentryEvent: Event) {
167+
if (!sentryEvent.breadcrumbs) {
168+
return;
169+
}
170+
171+
let breadcrumbsIdx = sentryEvent.breadcrumbs.length;
172+
173+
// Preallocate space for the elements we're gonna add
174+
sentryEvent.breadcrumbs.length +=
175+
item.logs.length +
176+
item.diagnosticsChannelEvents.length +
177+
item.exceptions.length;
178+
179+
for (const log of item.logs) {
180+
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
181+
type: 'debug',
182+
category: `console.${log.level}`,
183+
message: consoleLogToString(log.message),
184+
level: consoleLogLevelToSentryLevel(log.level),
185+
timestamp: log.timestamp,
186+
};
187+
}
188+
189+
for (const payload of item.diagnosticsChannelEvents) {
190+
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
191+
type: 'debug',
192+
category: `channel.${payload.channel}`,
193+
message: consoleLogToString(payload.message),
194+
level: 'debug',
195+
timestamp: payload.timestamp,
196+
};
197+
}
198+
199+
let fingerprintIdx = sentryEvent.fingerprint!.length;
200+
let exceptionValueIdx = sentryEvent.exception!.values!.length;
201+
202+
sentryEvent.fingerprint!.length += item.exceptions.length;
203+
sentryEvent.exception!.values!.length += item.exceptions.length;
204+
205+
for (const exception of item.exceptions) {
206+
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
207+
type: 'error',
208+
level: 'error',
209+
category: exception.name,
210+
message: exception.message,
211+
timestamp: exception.timestamp,
212+
data: {
213+
stack: exception.stack,
214+
},
215+
};
216+
217+
sentryEvent.fingerprint![fingerprintIdx++] = exception.name;
218+
sentryEvent.exception!.values![exceptionValueIdx++] = {
219+
type: exception.name,
220+
value: exception.message,
221+
};
222+
}
223+
}
224+
225+
function shouldSampleTraceItem(sampleRate: number) {
226+
const buffer = new Uint32Array(1);
227+
crypto.getRandomValues(buffer);
228+
229+
const UINT_32_LIMIT = 2 ** 32;
230+
const random = buffer[0] / UINT_32_LIMIT;
231+
232+
return random <= sampleRate;
233+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { type SeverityLevel } from '@sentry/cloudflare';
2+
3+
export function determineSeverityLevel(
4+
item: TraceItem
5+
): SeverityLevel | undefined {
6+
// Two scenarios where we want to report back to Sentry:
7+
// 1. Trace item outcome isn't 'ok'
8+
// 2. We have a status code >= 500
9+
//
10+
// Note that outcome is determined by if the worker executed to completion,
11+
// not if it returned a successful status code
12+
13+
if (item.outcome === 'ok') {
14+
const response =
15+
item.event && 'response' in item.event ? item.event.response : undefined;
16+
17+
if (response?.status && response?.status >= 500) {
18+
return 'error';
19+
} else {
20+
// Don't care
21+
return undefined;
22+
}
23+
}
24+
25+
return workerOutcomeToSeverityLevel(item.outcome);
26+
}
27+
28+
export function workerOutcomeToSeverityLevel(outcome: string): SeverityLevel {
29+
const map: Record<string, SeverityLevel> = {
30+
exceededCpu: 'fatal',
31+
exceededMemory: 'fatal',
32+
exception: 'error',
33+
ok: 'info',
34+
};
35+
36+
return map[outcome] ?? 'warning';
37+
}
38+
39+
export function workerOutcomeToEventMessage(outcome: string): string {
40+
const map: Record<string, string> = {
41+
exceededCpu: 'Exceeded CPU',
42+
exceededMemory: 'Exceeded Memory',
43+
exception: 'Script Threw Exception',
44+
canceled: 'Client Disconnected',
45+
ok: 'Success',
46+
};
47+
48+
return map[outcome] ?? 'Internal';
49+
}
50+
51+
export function consoleLogLevelToSentryLevel(logLevel: string): SeverityLevel {
52+
const map: Record<string, SeverityLevel> = {
53+
debug: 'debug',
54+
log: 'info',
55+
error: 'error',
56+
warn: 'warning',
57+
trace: 'debug',
58+
};
59+
60+
return map[logLevel] ?? 'debug';
61+
}
62+
63+
export function consoleLogToString(logMessage: unknown): string {
64+
const pieces = Array.isArray(logMessage) ? logMessage : [logMessage];
65+
return pieces
66+
.map(p => (typeof p === 'string' ? p : JSON.stringify(p)))
67+
.join(', ');
68+
}
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)