-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(open-next): create cloudflare-sentry-tail package #8842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "**/*.{ts}": ["prettier --check --write", "eslint --fix"] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from '../../eslint.config.js'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| { | ||
| "name": "@node-core/cloudflare-sentry-tail", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you open an issue in admin for this package?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
| }, | ||
|
flakey5 marked this conversation as resolved.
|
||
| "engines": { | ||
| "node": ">=20" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/workers-types": "^4.20260422.1" | ||
| }, | ||
| "dependencies": { | ||
| "@sentry/cloudflare": "^10.49.0" | ||
| } | ||
| } | ||
| 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); | ||
| } | ||
| }; | ||
| } |
| 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(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Timestamps passed in milliseconds instead of required secondsHigh Severity Cloudflare's Additional Locations (1)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; | ||
| } | ||
| 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(', '); | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.