diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8e5899448d137..4bc3a8bfb7876 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/apps/site/cloudflare/worker-entrypoint.ts b/apps/site/cloudflare/worker-entrypoint.ts index bd40543b4b9dd..1d497c6712011 100644 --- a/apps/site/cloudflare/worker-entrypoint.ts +++ b/apps/site/cloudflare/worker-entrypoint.ts @@ -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, @@ -47,6 +48,10 @@ export default withSentry( return handler.fetch(request, env, ctx); }, + tail: createSentryTail({ + samplingRate: 1, + headersToRedact: ['authorization', 'cookie'], + }), } ); diff --git a/apps/site/package.json b/apps/site/package.json index 1fd73ab23c9be..547ceee727c8f 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -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:*", diff --git a/packages/cloudflare-sentry-tail/.lintstagedrc.json b/packages/cloudflare-sentry-tail/.lintstagedrc.json new file mode 100644 index 0000000000000..f222d99e9297b --- /dev/null +++ b/packages/cloudflare-sentry-tail/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "**/*.{ts}": ["prettier --check --write", "eslint --fix"] +} diff --git a/packages/cloudflare-sentry-tail/eslint.config.js b/packages/cloudflare-sentry-tail/eslint.config.js new file mode 100644 index 0000000000000..92c6ea4214f81 --- /dev/null +++ b/packages/cloudflare-sentry-tail/eslint.config.js @@ -0,0 +1 @@ +export { default } from '../../eslint.config.js'; diff --git a/packages/cloudflare-sentry-tail/package.json b/packages/cloudflare-sentry-tail/package.json new file mode 100644 index 0000000000000..9469723290456 --- /dev/null +++ b/packages/cloudflare-sentry-tail/package.json @@ -0,0 +1,31 @@ +{ + "name": "@node-core/cloudflare-sentry-tail", + "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" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260422.1" + }, + "dependencies": { + "@sentry/cloudflare": "^10.49.0" + } +} diff --git a/packages/cloudflare-sentry-tail/src/index.ts b/packages/cloudflare-sentry-tail/src/index.ts new file mode 100644 index 0000000000000..6b9946b8dad31 --- /dev/null +++ b/packages/cloudflare-sentry-tail/src/index.ts @@ -0,0 +1,304 @@ +import { + type Event, + type SeverityLevel, + captureEvent, +} from '@sentry/cloudflare'; + +export type SentryTailWorkerOptions = { + samplingRate: number; + headersToRedact?: Array; +}; + +export function createSentryTail( + options: SentryTailWorkerOptions +): ExportedHandlerTailHandler { + return (items: Array): 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 = {}; + 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; + + // Allocate 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 random = buffer[0] / 4294967295; + + return random <= sampleRate; +} + +function workerOutcomeToSeverityLevel(outcome: string): SeverityLevel { + const map: Record = { + exceededCpu: 'fatal', + exceededMemory: 'fatal', + exception: 'error', + ok: 'info', + }; + + return map[outcome] ?? 'warning'; +} + +function workerOutcomeToEventMessage(outcome: string): string { + const map: Record = { + 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 = { + 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(', '); +} diff --git a/packages/cloudflare-sentry-tail/tsconfig.json b/packages/cloudflare-sentry-tail/tsconfig.json new file mode 100644 index 0000000000000..a550a97774fd0 --- /dev/null +++ b/packages/cloudflare-sentry-tail/tsconfig.json @@ -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"] +} diff --git a/packages/cloudflare-sentry-tail/turbo.json b/packages/cloudflare-sentry-tail/turbo.json new file mode 100644 index 0000000000000..d5005108cf078 --- /dev/null +++ b/packages/cloudflare-sentry-tail/turbo.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "lint:js": { + "inputs": ["src/**/*.{ts}"] + }, + "lint:fix": { + "cache": false + }, + "lint:types": { + "cache": false + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e50916229bc79..f643d15365280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@mdx-js/mdx': specifier: ^3.1.1 version: 3.1.1 + '@node-core/cloudflare-sentry-tail': + specifier: workspace:* + version: link:../../packages/cloudflare-sentry-tail '@node-core/rehype-shiki': specifier: workspace:* version: link:../../packages/rehype-shiki @@ -308,6 +311,16 @@ importers: specifier: ^4.77.0 version: 4.77.0(@cloudflare/workers-types@4.20260422.1) + packages/cloudflare-sentry-tail: + dependencies: + '@sentry/cloudflare': + specifier: ^10.49.0 + version: 10.49.0(@cloudflare/workers-types@4.20260422.1) + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260422.1 + version: 4.20260422.1 + packages/i18n: devDependencies: typescript: @@ -352,7 +365,7 @@ importers: dependencies: '@node-core/doc-kit': specifier: ^1.2.0 - version: 1.2.0(@orama/core@1.2.19)(@types/react@19.2.14)(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.2.0(@orama/core@1.2.19)(@types/react@19.2.14)(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -10343,13 +10356,13 @@ snapshots: '@noble/hashes@1.8.0': {} - '@node-core/doc-kit@1.2.0(@orama/core@1.2.19)(@types/react@19.2.14)(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@node-core/doc-kit@1.2.0(@orama/core@1.2.19)(@types/react@19.2.14)(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)': dependencies: '@actions/core': 3.0.0 '@heroicons/react': 2.2.0(react@19.2.4) '@minify-html/wasm': 0.18.1 '@node-core/rehype-shiki': 1.4.1 - '@node-core/ui-components': 1.6.3(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4)) + '@node-core/ui-components': 1.6.3(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0) '@orama/orama': 3.1.18 '@orama/ui': 1.5.4(@orama/core@1.2.19)(@types/react@19.2.14)(react@19.2.4) '@rollup/plugin-virtual': 3.0.2 @@ -10417,7 +10430,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@node-core/ui-components@1.6.3(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))': + '@node-core/ui-components@1.6.3(jiti@2.6.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0)': dependencies: '@heroicons/react': 2.2.0(react@19.2.4) '@orama/core': 1.2.19