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
11 changes: 11 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,16 @@ export default withSentry(

return handler.fetch(request, env, ctx);
},
tail: createSentryTail({
samplingRate: 1,
headersToRedact: [
'authorization',
'cookie',
'cf-connecting-ip',
'x-forwarded-for',
'x-real-ip',
],
}),
}
);

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?

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.

For what exactly?

"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"
}
}
16 changes: 16 additions & 0 deletions packages/cloudflare-sentry-tail/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { processTraceItem } from './utils/processTailItem';

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);
}
};
}
233 changes: 233 additions & 0 deletions packages/cloudflare-sentry-tail/src/utils/processTailItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { type Event, captureEvent } from '@sentry/cloudflare';

import { SentryTailWorkerOptions } from '../index';
import {
consoleLogLevelToSentryLevel,
consoleLogToString,
determineSeverityLevel,
workerOutcomeToEventMessage,
} from './sentry';

/**
* Extracts relevant data from the provided {@link TraceItem} & sends it to
* Sentry if determined to be something worthy of reporting
*/
export 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(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Timestamps passed in milliseconds instead of required seconds

High Severity

Cloudflare's TraceItem.eventTimestamp is in milliseconds since epoch, and Date.now() also returns milliseconds. However, Sentry's Event.timestamp and Breadcrumb.timestamp fields expect seconds since epoch. Passing milliseconds directly causes Sentry to interpret all event timestamps as dates far in the future (approximately year 50,000+), resulting in validation errors or completely unusable event timeline data. The values need to be divided by 1000.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fc180d5. Configure here.

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);
}

/**
* 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';
}

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,
},
};

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;

// Preallocate space for the elements we're gonna add
sentryEvent.breadcrumbs.length +=
item.logs.length +
item.diagnosticsChannelEvents.length +
item.exceptions.length;

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;

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 UINT_32_LIMIT = 2 ** 32;
const random = buffer[0] / UINT_32_LIMIT;

return random <= sampleRate;
}
68 changes: 68 additions & 0 deletions packages/cloudflare-sentry-tail/src/utils/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { type SeverityLevel } from '@sentry/cloudflare';

export 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);
}

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

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

export 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';
}

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

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

export function consoleLogToString(logMessage: unknown): string {
const pieces = Array.isArray(logMessage) ? logMessage : [logMessage];
return pieces
.map(p => (typeof p === 'string' ? p : JSON.stringify(p)))
.join(', ');
}
Loading
Loading