UNPKG

toucan-js

Version:

Cloudflare Workers client for Sentry

890 lines (877 loc) 30.4 kB
import { GLOBAL_OBJ, isError, isPlainObject, extractExceptionKeysForMessage, normalizeToSize, addExceptionTypeValue, addExceptionMechanism, isInstanceOf, truncate, resolvedSyncPromise, createStackParser, nodeStackLineParser, basename, rejectedSyncPromise, stackParserFromStackParserOptions } from '@sentry/utils'; import { defineIntegration as defineIntegration$1, ServerRuntimeClient, createTransport, Scope, getIntegrationsToSetup } from '@sentry/core'; export { dedupeIntegration, extraErrorDataIntegration, rewriteFramesIntegration, sessionTimingIntegration } from '@sentry/core'; function isObject(value) { return typeof value === 'object' && value !== null; } function isMechanism(value) { return (isObject(value) && 'handled' in value && typeof value.handled === 'boolean' && 'type' in value && typeof value.type === 'string'); } function containsMechanism(value) { return (isObject(value) && 'mechanism' in value && isMechanism(value['mechanism'])); } /** * Tries to find release in a global */ function getSentryRelease() { // Most of the plugins from https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/ inject SENTRY_RELEASE global to the bundle if (GLOBAL_OBJ.SENTRY_RELEASE && GLOBAL_OBJ.SENTRY_RELEASE.id) { return GLOBAL_OBJ.SENTRY_RELEASE.id; } } /** * Creates an entry on existing object and returns it, or creates a new object with the entry if it doesn't exist. * * @param target * @param entry * @returns Object with new entry. */ function setOnOptional(target, entry) { if (target !== undefined) { target[entry[0]] = entry[1]; return target; } else { return { [entry[0]]: entry[1] }; } } /** * Extracts stack frames from the error.stack string */ function parseStackFrames(stackParser, error) { return stackParser(error.stack || '', 1); } /** * There are cases where stacktrace.message is an Event object * https://github.com/getsentry/sentry-javascript/issues/1949 * In this specific case we try to extract stacktrace.message.error.message */ function extractMessage(ex) { const message = ex && ex.message; if (!message) { return 'No error message'; } if (message.error && typeof message.error.message === 'string') { return message.error.message; } return message; } /** * Extracts stack frames from the error and builds a Sentry Exception */ function exceptionFromError(stackParser, error) { const exception = { type: error.name || error.constructor.name, value: extractMessage(error), }; const frames = parseStackFrames(stackParser, error); if (frames.length) { exception.stacktrace = { frames }; } if (exception.type === undefined && exception.value === '') { exception.value = 'Unrecoverable error caught'; } return exception; } /** * Builds and Event from a Exception */ function eventFromUnknownInput(sdk, stackParser, exception, hint) { let ex; const providedMechanism = hint && hint.data && containsMechanism(hint.data) ? hint.data.mechanism : undefined; const mechanism = providedMechanism ?? { handled: true, type: 'generic', }; if (!isError(exception)) { if (isPlainObject(exception)) { // This will allow us to group events based on top-level keys // which is much better than creating new group when any key/value change const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage(exception)}`; const client = sdk?.getClient(); const normalizeDepth = client && client.getOptions().normalizeDepth; sdk?.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth)); ex = (hint && hint.syntheticException) || new Error(message); ex.message = message; } else { // This handles when someone does: `throw "something awesome";` // We use synthesized Error here so we can extract a (rough) stack trace. ex = (hint && hint.syntheticException) || new Error(exception); ex.message = exception; } mechanism.synthetic = true; } else { ex = exception; } const event = { exception: { values: [exceptionFromError(stackParser, ex)], }, }; addExceptionTypeValue(event, undefined, undefined); addExceptionMechanism(event, mechanism); return { ...event, event_id: hint && hint.event_id, }; } /** * Builds and Event from a Message */ function eventFromMessage(stackParser, message, level = 'info', hint, attachStacktrace) { const event = { event_id: hint && hint.event_id, level, message, }; if (attachStacktrace && hint && hint.syntheticException) { const frames = parseStackFrames(stackParser, hint.syntheticException); if (frames.length) { event.exception = { values: [ { value: message, stacktrace: { frames }, }, ], }; } } return event; } const DEFAULT_LIMIT$1 = 5; const linkedErrorsIntegration = defineIntegration$1((options = { limit: DEFAULT_LIMIT$1 }) => { return { name: 'LinkedErrors', processEvent: (event, hint, client) => { return handler(client.getOptions().stackParser, options.limit, event, hint); }, }; }); function handler(parser, limit, event, hint) { if (!event.exception || !event.exception.values || !hint || !isInstanceOf(hint.originalException, Error)) { return event; } const linkedErrors = walkErrorTree(parser, limit, hint.originalException); event.exception.values = [...linkedErrors, ...event.exception.values]; return event; } function walkErrorTree(parser, limit, error, stack = []) { if (!isInstanceOf(error.cause, Error) || stack.length + 1 >= limit) { return stack; } const exception = exceptionFromError(parser, error.cause); return walkErrorTree(parser, limit, error.cause, [ exception, ...stack, ]); } const defaultRequestDataOptions = { allowedHeaders: ['CF-RAY', 'CF-Worker'], }; const requestDataIntegration = defineIntegration$1((userOptions = {}) => { const options = { ...defaultRequestDataOptions, ...userOptions }; return { name: 'RequestData', preprocessEvent: (event) => { const { sdkProcessingMetadata } = event; if (!sdkProcessingMetadata) { return event; } if ('request' in sdkProcessingMetadata && sdkProcessingMetadata.request instanceof Request) { event.request = toEventRequest(sdkProcessingMetadata.request, options); event.user = toEventUser(event.user ?? {}, sdkProcessingMetadata.request, options); } if ('requestData' in sdkProcessingMetadata) { if (event.request) { event.request.data = sdkProcessingMetadata.requestData; } else { event.request = { data: sdkProcessingMetadata.requestData, }; } } return event; }, }; }); /** * Applies allowlists on existing user object. * * @param user * @param request * @param options * @returns New copy of user */ function toEventUser(user, request, options) { const ip_address = request.headers.get('CF-Connecting-IP'); const { allowedIps } = options; const newUser = { ...user }; if (!('ip_address' in user) && // If ip_address is already set from explicitly called setUser, we don't want to overwrite it ip_address && allowedIps !== undefined && testAllowlist(ip_address, allowedIps)) { newUser.ip_address = ip_address; } return Object.keys(newUser).length > 0 ? newUser : undefined; } /** * Converts data from fetch event's Request to Sentry Request used in Sentry Event * * @param request Native Request object * @param options Integration options * @returns Sentry Request object */ function toEventRequest(request, options) { // Build cookies const cookieString = request.headers.get('cookie'); let cookies = undefined; if (cookieString) { try { cookies = parseCookie(cookieString); } catch (e) { // Cookie string failed to parse, no need to do anything } } const headers = {}; // Build headers (omit cookie header, because we used it in the previous step) for (const [k, v] of request.headers.entries()) { if (k !== 'cookie') { headers[k] = v; } } const eventRequest = { method: request.method, cookies, headers, }; try { const url = new URL(request.url); eventRequest.url = `${url.protocol}//${url.hostname}${url.pathname}`; eventRequest.query_string = url.search; } catch (e) { // `new URL` failed, let's try to split URL the primitive way const qi = request.url.indexOf('?'); if (qi < 0) { // no query string eventRequest.url = request.url; } else { eventRequest.url = request.url.substr(0, qi); eventRequest.query_string = request.url.substr(qi + 1); } } // Let's try to remove sensitive data from incoming Request const { allowedHeaders, allowedCookies, allowedSearchParams } = options; if (allowedHeaders !== undefined && eventRequest.headers) { eventRequest.headers = applyAllowlistToObject(eventRequest.headers, allowedHeaders); if (Object.keys(eventRequest.headers).length === 0) { delete eventRequest.headers; } } else { delete eventRequest.headers; } if (allowedCookies !== undefined && eventRequest.cookies) { eventRequest.cookies = applyAllowlistToObject(eventRequest.cookies, allowedCookies); if (Object.keys(eventRequest.cookies).length === 0) { delete eventRequest.cookies; } } else { delete eventRequest.cookies; } if (allowedSearchParams !== undefined) { const params = Object.fromEntries(new URLSearchParams(eventRequest.query_string)); const allowedParams = new URLSearchParams(); Object.keys(applyAllowlistToObject(params, allowedSearchParams)).forEach((allowedKey) => { allowedParams.set(allowedKey, params[allowedKey]); }); eventRequest.query_string = allowedParams.toString(); } else { delete eventRequest.query_string; } return eventRequest; } /** * Helper function that tests 'allowlist' on string. * * @param target * @param allowlist * @returns True if target is allowed. */ function testAllowlist(target, allowlist) { if (typeof allowlist === 'boolean') { return allowlist; } else if (allowlist instanceof RegExp) { return allowlist.test(target); } else if (Array.isArray(allowlist)) { const allowlistLowercased = allowlist.map((item) => item.toLowerCase()); return allowlistLowercased.includes(target); } else { return false; } } /** * Helper function that applies 'allowlist' to target's entries. * * @param target * @param allowlist * @returns New object with allowed keys. */ function applyAllowlistToObject(target, allowlist) { let predicate = () => false; if (typeof allowlist === 'boolean') { return allowlist ? target : {}; } else if (allowlist instanceof RegExp) { predicate = (item) => allowlist.test(item); } else if (Array.isArray(allowlist)) { const allowlistLowercased = allowlist.map((item) => item.toLowerCase()); predicate = (item) => allowlistLowercased.includes(item.toLowerCase()); } else { return {}; } return Object.keys(target) .filter(predicate) .reduce((allowed, key) => { allowed[key] = target[key]; return allowed; }, {}); } /** * Converts cookie string to an object. * * @param cookieString * @returns Object of cookie entries, or empty object if something went wrong during the conversion. */ function parseCookie(cookieString) { if (typeof cookieString !== 'string') { return {}; } try { return cookieString .split(';') .map((part) => part.split('=')) .reduce((acc, [cookieKey, cookieValue]) => { acc[decodeURIComponent(cookieKey.trim())] = decodeURIComponent(cookieValue.trim()); return acc; }, {}); } catch { return {}; } } /** * Define an integration function that can be used to create an integration instance. * Note that this by design hides the implementation details of the integration, as they are considered internal. * * Inlined from https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/integration.ts#L165 */ function defineIntegration(fn) { return fn; } // Adapted from: https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/integrations/zoderrors.ts const INTEGRATION_NAME = 'ZodErrors'; const DEFAULT_LIMIT = 10; function originalExceptionIsZodError(originalException) { return (isError(originalException) && originalException.name === 'ZodError' && Array.isArray(originalException.issues)); } /** * Formats child objects or arrays to a string * that is preserved when sent to Sentry. * * Without this, we end up with something like this in Sentry: * * [ * [Object], * [Object], * [Object], * [Object] * ] */ function flattenIssue(issue) { return { ...issue, path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined, keys: 'keys' in issue ? JSON.stringify(issue.keys) : undefined, unionErrors: 'unionErrors' in issue ? JSON.stringify(issue.unionErrors) : undefined, }; } /** * Takes ZodError issue path array and returns a flattened version as a string. * This makes it easier to display paths within a Sentry error message. * * Array indexes are normalized to reduce duplicate entries * * @param path ZodError issue path * @returns flattened path * * @example * flattenIssuePath([0, 'foo', 1, 'bar']) // -> '<array>.foo.<array>.bar' */ function flattenIssuePath(path) { return path .map((p) => { if (typeof p === 'number') { return '<array>'; } else { return p; } }) .join('.'); } /** * Zod error message is a stringified version of ZodError.issues * This doesn't display well in the Sentry UI. Replace it with something shorter. */ function formatIssueMessage(zodError) { const errorKeyMap = new Set(); for (const iss of zodError.issues) { const issuePath = flattenIssuePath(iss.path); if (issuePath.length > 0) { errorKeyMap.add(issuePath); } } const errorKeys = Array.from(errorKeyMap); if (errorKeys.length === 0) { // If there are no keys, then we're likely validating the root // variable rather than a key within an object. This attempts // to extract what type it was that failed to validate. // For example, z.string().parse(123) would return "string" here. let rootExpectedType = 'variable'; if (zodError.issues.length > 0) { const iss = zodError.issues[0]; if (iss !== undefined && 'expected' in iss && typeof iss.expected === 'string') { rootExpectedType = iss.expected; } } return `Failed to validate ${rootExpectedType}`; } return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`; } /** * Applies ZodError issues to an event extra and replaces the error message */ function applyZodErrorsToEvent(limit, event, saveAttachments = false, hint) { if (!event.exception?.values || !hint.originalException || !originalExceptionIsZodError(hint.originalException) || hint.originalException.issues.length === 0) { return event; } try { const issuesToFlatten = saveAttachments ? hint.originalException.issues : hint.originalException.issues.slice(0, limit); const flattenedIssues = issuesToFlatten.map(flattenIssue); if (saveAttachments) { // Sometimes having the full error details can be helpful. // Attachments have much higher limits, so we can include the full list of issues. if (!Array.isArray(hint.attachments)) { hint.attachments = []; } hint.attachments.push({ filename: 'zod_issues.json', data: JSON.stringify({ issues: flattenedIssues, }), }); } return { ...event, exception: { ...event.exception, values: [ { ...event.exception.values[0], value: formatIssueMessage(hint.originalException), }, ...event.exception.values.slice(1), ], }, extra: { ...event.extra, 'zoderror.issues': flattenedIssues.slice(0, limit), }, }; } catch (e) { // Hopefully we never throw errors here, but record it // with the event just in case. return { ...event, extra: { ...event.extra, 'zoderrors sentry integration parse error': { message: 'an exception was thrown while processing ZodError within applyZodErrorsToEvent()', error: e instanceof Error ? `${e.name}: ${e.message}\n${e.stack}` : 'unknown', }, }, }; } } /** * Sentry integration to process Zod errors, making them easier to work with in Sentry. */ const zodErrorsIntegration = defineIntegration((options = {}) => { const limit = options.limit ?? DEFAULT_LIMIT; return { name: INTEGRATION_NAME, processEvent(originalEvent, hint) { const processedEvent = applyZodErrorsToEvent(limit, originalEvent, options.saveAttachments, hint); return processedEvent; }, }; }); /** * Installs integrations on the current scope. * * @param integrations array of integration instances */ function setupIntegrations(integrations, sdk) { const integrationIndex = {}; integrations.forEach((integration) => { integrationIndex[integration.name] = integration; // `setupOnce` is only called the first time if (typeof integration.setupOnce === 'function') { integration.setupOnce(); } const client = sdk.getClient(); if (!client) { return; } // `setup` is run for each client if (typeof integration.setup === 'function') { integration.setup(client); } if (typeof integration.preprocessEvent === 'function') { const callback = integration.preprocessEvent.bind(integration); client.on('preprocessEvent', (event, hint) => callback(event, hint, client)); } if (typeof integration.processEvent === 'function') { const callback = integration.processEvent.bind(integration); const processor = Object.assign((event, hint) => callback(event, hint, client), { id: integration.name, }); client.addEventProcessor(processor); } }); return integrationIndex; } /** * The Cloudflare Workers SDK Client. */ class ToucanClient extends ServerRuntimeClient { /** * Some functions need to access the scope (Toucan instance) this client is bound to, * but calling 'getCurrentHub()' is unsafe because it uses globals. * So we store a reference to the Hub after binding to it and provide it to methods that need it. */ #sdk = null; #integrationsInitialized = false; /** * Creates a new Toucan SDK instance. * @param options Configuration options for this SDK. */ constructor(options) { options._metadata = options._metadata || {}; options._metadata.sdk = options._metadata.sdk || { name: 'toucan-js', packages: [ { name: 'npm:' + 'toucan-js', version: '4.1.1', }, ], version: '4.1.1', }; super(options); } /** * By default, integrations are stored in a global. We want to store them in a local instance because they may have contextual data, such as event request. */ setupIntegrations() { if (this._isEnabled() && !this.#integrationsInitialized && this.#sdk) { this._integrations = setupIntegrations(this._options.integrations, this.#sdk); this.#integrationsInitialized = true; } } eventFromException(exception, hint) { return resolvedSyncPromise(eventFromUnknownInput(this.#sdk, this._options.stackParser, exception, hint)); } eventFromMessage(message, level = 'info', hint) { return resolvedSyncPromise(eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace)); } _prepareEvent(event, hint, scope) { event.platform = event.platform || 'javascript'; if (this.getOptions().request) { // Set 'request' on sdkProcessingMetadata to be later processed by RequestData integration event.sdkProcessingMetadata = setOnOptional(event.sdkProcessingMetadata, [ 'request', this.getOptions().request, ]); } if (this.getOptions().requestData) { // Set 'requestData' on sdkProcessingMetadata to be later processed by RequestData integration event.sdkProcessingMetadata = setOnOptional(event.sdkProcessingMetadata, [ 'requestData', this.getOptions().requestData, ]); } return super._prepareEvent(event, hint, scope); } getSdk() { return this.#sdk; } setSdk(sdk) { this.#sdk = sdk; } /** * Sets the request body context on all future events. * * @param body Request body. * @example * const body = await request.text(); * toucan.setRequestBody(body); */ setRequestBody(body) { this.getOptions().requestData = body; } /** * Enable/disable the SDK. * * @param enabled */ setEnabled(enabled) { this.getOptions().enabled = enabled; } } /** * Stack line parser for Cloudflare Workers. * This wraps node stack parser and adjusts root paths to match with source maps. * */ function workersStackLineParser(getModule) { const [arg1, arg2] = nodeStackLineParser(getModule); const fn = (line) => { const result = arg2(line); if (result) { const filename = result.filename; // Workers runtime runs a single bundled file that is always in a virtual root result.abs_path = filename !== undefined && !filename.startsWith('/') ? `/${filename}` : filename; // There is no way to tell what code is in_app and what comes from dependencies (node_modules), since we have one bundled file. // So everything is in_app, unless an error comes from runtime function (ie. JSON.parse), which is determined by the presence of filename. result.in_app = filename !== undefined; } return result; }; return [arg1, fn]; } /** * Gets the module from filename. * * @param filename * @returns Module name */ function getModule(filename) { if (!filename) { return; } // In Cloudflare Workers there is always only one bundled file return basename(filename, '.js'); } /** Cloudflare Workers stack parser */ const defaultStackParser = createStackParser(workersStackLineParser(getModule)); /** * Creates a Transport that uses native fetch. This transport automatically extends the Workers lifetime with 'waitUntil'. */ function makeFetchTransport(options) { function makeRequest({ body, }) { try { const fetchFn = options.fetcher ?? fetch; const request = fetchFn(options.url, { method: 'POST', headers: options.headers, body, }).then((response) => { return { statusCode: response.status, headers: { 'retry-after': response.headers.get('Retry-After'), 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), }, }; }); /** * Call waitUntil to extend Workers Event lifetime */ if (options.context) { options.context.waitUntil(request); } return request; } catch (e) { return rejectedSyncPromise(e); } } return createTransport(options, makeRequest); } /** * The Cloudflare Workers SDK. */ class Toucan extends Scope { #options; constructor(options) { super(); options.defaultIntegrations = options.defaultIntegrations === false ? [] : [ ...(Array.isArray(options.defaultIntegrations) ? options.defaultIntegrations : [ requestDataIntegration(options.requestDataOptions), linkedErrorsIntegration(), zodErrorsIntegration(), ]), ]; if (options.release === undefined) { const detectedRelease = getSentryRelease(); if (detectedRelease !== undefined) { options.release = detectedRelease; } } this.#options = options; this.attachNewClient(); } /** * Creates new ToucanClient and links it to this instance. */ attachNewClient() { const client = new ToucanClient({ ...this.#options, transport: makeFetchTransport, integrations: getIntegrationsToSetup(this.#options), stackParser: stackParserFromStackParserOptions(this.#options.stackParser || defaultStackParser), transportOptions: { ...this.#options.transportOptions, context: this.#options.context, }, }); this.setClient(client); client.setSdk(this); client.setupIntegrations(); } /** * Sets the request body context on all future events. * * @param body Request body. * @example * const body = await request.text(); * toucan.setRequestBody(body); */ setRequestBody(body) { this.getClient()?.setRequestBody(body); } /** * Enable/disable the SDK. * * @param enabled */ setEnabled(enabled) { this.getClient()?.setEnabled(enabled); } /** * Create a cron monitor check in and send it to Sentry. * * @param checkIn An object that describes a check in. * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want * to create a monitor automatically when sending a check in. */ captureCheckIn(checkIn, monitorConfig, scope) { if (checkIn.status === 'in_progress') { this.setContext('monitor', { slug: checkIn.monitorSlug }); } const client = this.getClient(); return client.captureCheckIn(checkIn, monitorConfig, scope); } /** * Add a breadcrumb to the current scope. */ addBreadcrumb(breadcrumb, maxBreadcrumbs = 100) { const client = this.getClient(); const max = client.getOptions().maxBreadcrumbs || maxBreadcrumbs; return super.addBreadcrumb(breadcrumb, max); } /** * Clone all data from this instance into a new Toucan instance. * * @override * @returns New Toucan instance. */ clone() { // Create new scope using the same options const toucan = new Toucan({ ...this.#options }); // And copy all the scope data toucan._breadcrumbs = [...this._breadcrumbs]; toucan._tags = { ...this._tags }; toucan._extra = { ...this._extra }; toucan._contexts = { ...this._contexts }; toucan._user = this._user; toucan._level = this._level; toucan._session = this._session; toucan._transactionName = this._transactionName; toucan._fingerprint = this._fingerprint; toucan._eventProcessors = [...this._eventProcessors]; toucan._requestSession = this._requestSession; toucan._attachments = [...this._attachments]; toucan._sdkProcessingMetadata = { ...this._sdkProcessingMetadata }; toucan._propagationContext = { ...this._propagationContext }; toucan._lastEventId = this._lastEventId; return toucan; } /** * Creates a new scope with and executes the given operation within. * The scope is automatically removed once the operation * finishes or throws. */ withScope(callback) { const toucan = this.clone(); return callback(toucan); } } export { Toucan, linkedErrorsIntegration, requestDataIntegration, zodErrorsIntegration };