Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ apps/site/site.json @nodejs/web-infra
apps/site/wrangler.jsonc @nodejs/web-infra
apps/site/open-next.config.ts @nodejs/web-infra
apps/site/redirects.json @nodejs/web-infra
packages/cloudflare-sentry-tail @nodejs/web-infra

# Critical Documents
LICENSE @nodejs/tsc
Expand Down
5 changes: 5 additions & 0 deletions apps/site/cloudflare/worker-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// - the official sentry docs: https://docs.sentry.io/platforms/javascript/guides/cloudflare

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

import type {
ExecutionContext,
Expand Down Expand Up @@ -47,6 +48,10 @@ export default withSentry(

return handler.fetch(request, env, ctx);
},
tail: createSentryTail({
samplingRate: 1,
headersToRedact: ['authorization', 'cookie'],
}),
}
);

Expand Down
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"dependencies": {
"@heroicons/react": "~2.2.0",
"@mdx-js/mdx": "^3.1.1",
"@node-core/cloudflare-sentry-tail": "workspace:*",
"@node-core/rehype-shiki": "workspace:*",
"@node-core/ui-components": "workspace:*",
"@node-core/website-i18n": "workspace:*",
Expand Down
3 changes: 3 additions & 0 deletions packages/cloudflare-sentry-tail/.lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"**/*.{ts}": ["prettier --check --write", "eslint --fix"]
Comment thread
flakey5 marked this conversation as resolved.
}
1 change: 1 addition & 0 deletions packages/cloudflare-sentry-tail/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '../../eslint.config.js';
31 changes: 31 additions & 0 deletions packages/cloudflare-sentry-tail/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@node-core/cloudflare-sentry-tail",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you open an issue in admin for this package?

"description": "Cloudflare Tail Worker for Sentry",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"default": "./src/index.ts"
}
},
"repository": {
"type": "git",
"url": "https://github.com/nodejs/nodejs.org",
"directory": "packages/cloudflare-sentry-tail"
},
"scripts": {
"lint": "node --run lint:js",
"lint:fix": "node --run lint:js:fix",
"lint:js": "eslint \"**/*.ts\"",
"lint:js:fix": "node --run lint:js -- --fix"
},
Comment thread
flakey5 marked this conversation as resolved.
"engines": {
"node": ">=20"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260422.1"
},
"dependencies": {
"@sentry/cloudflare": "^10.49.0"
}
}
304 changes: 304 additions & 0 deletions packages/cloudflare-sentry-tail/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import {
type Event,
type SeverityLevel,
captureEvent,
} from '@sentry/cloudflare';

export type SentryTailWorkerOptions = {
samplingRate: number;
headersToRedact?: Array<string>;
};

export function createSentryTail<Env = unknown>(
options: SentryTailWorkerOptions
): ExportedHandlerTailHandler<Env> {
return (items: Array<TraceItem>): void => {
for (const item of items) {
processTraceItem(options, item);
}
};
}

function processTraceItem(
options: SentryTailWorkerOptions,
item: TraceItem
): void {
const severityLevel = determineSeverityLevel(item);
if (!severityLevel) {
// Not an error
return;
}

if (
options.samplingRate !== 1 &&
!shouldSampleTraceItem(options.samplingRate)
) {
return;
}

const event: Event = {
level: severityLevel,
timestamp: item.eventTimestamp ?? Date.now(),
logger: '@node-core/cloudflare-sentry-tail',
message: workerOutcomeToEventMessage(item.outcome),
fingerprint: [],
breadcrumbs: [],
exception: {
values: [],
},
tags: {
outcome: item.outcome,
script_name: item.scriptName,
script_version: item.scriptVersion?.tag,
cpu_time: item.cpuTime,
wall_time: item.wallTime,
},
};

// Populate data specific to the type of trace event we got
handleTraceItemEvent(options, item, event);

// Populate breadcrumbs with any relevant data
addRemainingBreadcrumbs(item, event);

// Sort breadcrumbs by their timestamps
event.breadcrumbs?.sort((a, b) => {
if (!a.timestamp || !b.timestamp) {
return 0;
}

return a.timestamp - b.timestamp;
});

captureEvent(event);
}

function determineSeverityLevel(item: TraceItem): SeverityLevel | undefined {
// Two scenarios where we want to report back to Sentry:
// 1. Trace item outcome isn't 'ok'
// 2. We have a status code >= 500
//
// Note that outcome is determined by if the worker executed to completion,
// not if it returned a successful status code

if (item.outcome === 'ok') {
const response =
item.event && 'response' in item.event ? item.event.response : undefined;

if (response?.status && response?.status >= 500) {
return 'error';
} else {
// Don't care
return undefined;
}
}

return workerOutcomeToSeverityLevel(item.outcome);
}

/**
* Determines what kind of trace item event we received and adds any
* event-specific properties to the Sentry event to be reported.
*/
function handleTraceItemEvent(
options: SentryTailWorkerOptions,
item: TraceItem,
sentryEvent: Event
): void {
if (!item.event) {
return;
}

if ('request' in item.event) {
const request = item.event.request;
const response = item.event.response;

const redactedHeaders: Record<string, string> = {};
for (let [key, value] of Object.entries(request.headers)) {
key = key.toLowerCase();

if (options.headersToRedact && options.headersToRedact.includes(key)) {
value = 'redacted';
}
Comment thread
flakey5 marked this conversation as resolved.

redactedHeaders[key] = value;
}

sentryEvent.request = {
method: request.method,
url: request.url,
headers: redactedHeaders,
env: {
asn: request.cf?.asn,
colo: request.cf?.colo,
continent: request.cf?.continent,
country: request.cf?.country,
timezone: request.cf?.timezone,
httpProtocol: request.cf?.httpProtocol,
requestPriority: request.cf?.requestPriority,
tlsCipher: request.cf?.tlsCipher,
tlsClientAuth: request.cf?.tlsClientAuth,
tlsExportedAuthenticator: request.cf?.tlsExportedAuthenticator,
tlsVersion: request.cf?.tlsVersion,
},
};
Comment thread
flakey5 marked this conversation as resolved.

const responseStatusCode = response?.status ?? 'Unknown';

sentryEvent.message = response
? `${responseStatusCode} Response`
: 'No response';

sentryEvent.breadcrumbs?.push({
type: 'http',
category: 'request',
timestamp: item.eventTimestamp ?? Date.now(),
data: {
url: request.url,
method: request.method,
status_code: responseStatusCode,
},
});

const requestUrl = new URL(request.url);
sentryEvent.fingerprint?.push(
requestUrl.origin,
requestUrl.pathname,
request.method,
`${responseStatusCode}`
);

sentryEvent.tags!.event = 'fetch';
sentryEvent.tags!.ray_id = redactedHeaders['cf-ray'];
} else if ('rpcMethod' in item.event) {
sentryEvent.tags!.event = 'js-rpc';
sentryEvent.tags!.rpc_method = item.event.rpcMethod;
} else if ('scheduledTime' in item.event) {
if ('cron' in item.event) {
sentryEvent.tags!.event = 'scheduled';
sentryEvent.tags!.scheduled_time = item.event.scheduledTime;
sentryEvent.tags!.cron = item.event.cron;
return;
}

sentryEvent.tags!.event = 'alarm';
sentryEvent.tags!.scheduled_time = item.event.scheduledTime.toUTCString();
} else if ('queue' in item.event) {
sentryEvent.tags!.event = 'queue';
sentryEvent.tags!.queue = item.event.queue;
sentryEvent.tags!.batchSize = item.event.batchSize;
} else if ('mailFrom' in item.event) {
sentryEvent.tags!.event = 'email';
sentryEvent.tags!.rawSize = item.event.rawSize;
}
}

function addRemainingBreadcrumbs(item: TraceItem, sentryEvent: Event) {
if (!sentryEvent.breadcrumbs) {
return;
}

let breadcrumbsIdx = sentryEvent.breadcrumbs.length;

// Allocate space for the elements we're gonna add
sentryEvent.breadcrumbs.length +=
item.logs.length +
item.diagnosticsChannelEvents.length +
item.exceptions.length;
Comment on lines +203 to +207
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arrays in JavaScript automatically expand as you add elements to them, see:
example of adding elements to an incrementing index in javascript

So I don't think we need to allocate memory for the new elements here 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary yes, however, preallocating makes it so it's only one memory allocation instead of multiple so it's faster and more efficient:
image

(noteworthy that this is in Node, but ofc both runtimes are v8 based, also benchmark src)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks that's quite interesting 😄

However for a tail worker I don't imagine that having a better performance for the tail worker would be in any way helpful/useful, would it? 🤔

(also I am curious about the number of such events that you'd expect, obviously the less the number the less the gain here)


PS: I'm just asking out of curiosity since these lines seem to introduce unnecessary (even if very minimal of course) complexity for no real gain. But of course they don't cause any harm if left in.


for (const log of item.logs) {
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
type: 'debug',
category: `console.${log.level}`,
message: consoleLogToString(log.message),
level: consoleLogLevelToSentryLevel(log.level),
timestamp: log.timestamp,
};
}

for (const payload of item.diagnosticsChannelEvents) {
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
type: 'debug',
category: `channel.${payload.channel}`,
message: consoleLogToString(payload.message),
level: 'debug',
timestamp: payload.timestamp,
};
}

let fingerprintIdx = sentryEvent.fingerprint!.length;
let exceptionValueIdx = sentryEvent.exception!.values!.length;

sentryEvent.fingerprint!.length += item.exceptions.length;
sentryEvent.exception!.values!.length += item.exceptions.length;
Comment thread
flakey5 marked this conversation as resolved.
Comment thread
flakey5 marked this conversation as resolved.

for (const exception of item.exceptions) {
sentryEvent.breadcrumbs[breadcrumbsIdx++] = {
type: 'error',
level: 'error',
category: exception.name,
message: exception.message,
timestamp: exception.timestamp,
data: {
stack: exception.stack,
},
};

sentryEvent.fingerprint![fingerprintIdx++] = exception.name;
sentryEvent.exception!.values![exceptionValueIdx++] = {
type: exception.name,
value: exception.message,
};
}
}

function shouldSampleTraceItem(sampleRate: number) {
const buffer = new Uint32Array(1);
crypto.getRandomValues(buffer);

const random = buffer[0] / 4294967295;
Comment thread
flakey5 marked this conversation as resolved.

return random <= sampleRate;
}

function workerOutcomeToSeverityLevel(outcome: string): SeverityLevel {
const map: Record<string, SeverityLevel> = {
exceededCpu: 'fatal',
exceededMemory: 'fatal',
exception: 'error',
ok: 'info',
};

return map[outcome] ?? 'warning';
}

function workerOutcomeToEventMessage(outcome: string): string {
const map: Record<string, string> = {
exceededCpu: 'Exceeded CPU',
exceededMemory: 'Exceeded Memory',
exception: 'Script Threw Exception',
canceled: 'Client Disconnected',
ok: 'Success',
};

return map[outcome] ?? 'Internal';
}

function consoleLogLevelToSentryLevel(logLevel: string): SeverityLevel {
const map: Record<string, SeverityLevel> = {
debug: 'debug',
log: 'info',
error: 'error',
warn: 'warning',
trace: 'debug',
};

return map[logLevel] ?? 'debug';
}

function consoleLogToString(logMessage: unknown): string {
const pieces = Array.isArray(logMessage) ? logMessage : [logMessage];
return pieces
.map(p => (typeof p === 'string' ? p : JSON.stringify(p)))
.join(', ');
}
21 changes: 21 additions & 0 deletions packages/cloudflare-sentry-tail/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"baseUrl": ".",
"outDir": "dist",
"rootDir": "."
},
"include": ["src"]
}
Loading
Loading