/** * Cross-browser stack trace computation. * * Reference implementation: https://github.com/csnover/TraceKit/blob/04530298073c3823de72deb0b97e7b38ca7bcb59/tracekit.js */ import { isIndexableObject } from '../utils/typeUtils' export interface StackFrame { url?: string func?: string /** The arguments passed to the function, if known. */ args?: string[] line?: number column?: number /** An array of source code lines; the middle element corresponds to the correct line. */ context?: string[] } export interface StackTrace { name?: string message?: string url?: string stack: StackFrame[] incomplete?: boolean partial?: boolean } const UNKNOWN_FUNCTION = '?' export function computeStackTrace(ex: unknown): StackTrace { const stack: StackFrame[] = [] let stackProperty = tryToGetString(ex, 'stack') const exString = String(ex) if (stackProperty && stackProperty.startsWith(exString)) { stackProperty = stackProperty.slice(exString.length) } if (stackProperty) { stackProperty.split('\n').forEach((line) => { const stackFrame = parseChromeLine(line) || parseChromeAnonymousLine(line) || parseWinLine(line) || parseGeckoLine(line) if (stackFrame) { if (!stackFrame.func && stackFrame.line) { stackFrame.func = UNKNOWN_FUNCTION } stack.push(stackFrame) } }) } if (stack.length > 0 && isWronglyReportingCustomErrors() && ex instanceof Error) { // if we are wrongly reporting custom errors const constructors: string[] = [] // go through each inherited constructor let currentPrototype: object | undefined = ex while ( (currentPrototype = Object.getPrototypeOf(currentPrototype)) && isNonNativeClassPrototype(currentPrototype) ) { const constructorName = currentPrototype.constructor?.name || UNKNOWN_FUNCTION constructors.push(constructorName) } // traverse the stacktrace in reverse order because the stacktrace starts with the last inherited constructor // we check constructor names to ensure we remove the correct frame (and there isn't a weird unsupported environment behavior) for (let i = constructors.length - 1; i >= 0 && stack[0]?.func === constructors[i]; i--) { // if the first stack frame is the custom error constructor // null stack frames may represent frames that failed to be parsed because the error class did not have a constructor stack.shift() // remove it } } return { message: tryToGetString(ex, 'message'), name: tryToGetString(ex, 'name'), stack, } } const fileUrl = '((?:file|https?|blob|chrome-extension|electron|native|eval|webpack|snippet||\\w+\\.|\\/).*?)' const filePosition = '(?::(\\d+))' const CHROME_LINE_RE = new RegExp(`^\\s*at (.*?) ?\\(${fileUrl}${filePosition}?${filePosition}?\\)?\\s*$`, 'i') const CHROME_EVAL_RE = new RegExp(`\\((\\S*)${filePosition}${filePosition}\\)`) function parseChromeLine(line: string): StackFrame | undefined { const parts = CHROME_LINE_RE.exec(line) if (!parts) { return } const isNative = parts[2] && parts[2].indexOf('native') === 0 // start of line const isEval = parts[2] && parts[2].indexOf('eval') === 0 // start of line const submatch = CHROME_EVAL_RE.exec(parts[2]) if (isEval && submatch) { // throw out eval line/column and use top-most line/column number parts[2] = submatch[1] // url parts[3] = submatch[2] // line parts[4] = submatch[3] // column } return { args: isNative ? [parts[2]] : [], column: parts[4] ? +parts[4] : undefined, func: parts[1] || UNKNOWN_FUNCTION, line: parts[3] ? +parts[3] : undefined, url: !isNative ? parts[2] : undefined, } } const htmlAnonymousPart = '(?:(.*)?(?: @))' const CHROME_ANONYMOUS_FUNCTION_RE = new RegExp( `^\\s*at\\s*${htmlAnonymousPart}?\\s*${fileUrl}${filePosition}?${filePosition}??\\s*$`, 'i' ) function parseChromeAnonymousLine(line: string): StackFrame | undefined { const parts = CHROME_ANONYMOUS_FUNCTION_RE.exec(line) if (!parts) { return } return { args: [], column: parts[4] ? +parts[4] : undefined, func: parts[1] || UNKNOWN_FUNCTION, line: parts[3] ? +parts[3] : undefined, url: parts[2], } } const WINJS_LINE_RE = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i function parseWinLine(line: string): StackFrame | undefined { const parts = WINJS_LINE_RE.exec(line) if (!parts) { return } return { args: [], column: parts[4] ? +parts[4] : undefined, func: parts[1] || UNKNOWN_FUNCTION, line: +parts[3], url: parts[2], } } const GECKO_LINE_RE = /^\s*(.*?)(?:\((.*?)\))?(?:(?:(?:^|@)((?:file|https?|blob|chrome|webpack|resource|capacitor|\[native).*?|[^@]*bundle|\[wasm code\])(?::(\d+))?(?::(\d+))?)|@)\s*$/i const GECKO_EVAL_RE = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i function parseGeckoLine(line: string): StackFrame | undefined { const parts = GECKO_LINE_RE.exec(line) if (!parts) { return } const isEval = parts[3] && parts[3].indexOf(' > eval') > -1 const submatch = GECKO_EVAL_RE.exec(parts[3]) if (isEval && submatch) { // throw out eval line/column and use top-most line number parts[3] = submatch[1] parts[4] = submatch[2] parts[5] = undefined! // no column when eval } return { args: parts[2] ? parts[2].split(',') : [], column: parts[5] ? +parts[5] : undefined, func: parts[1] || UNKNOWN_FUNCTION, line: parts[4] ? +parts[4] : undefined, url: parts[3], } } function tryToGetString(candidate: unknown, property: string) { return isIndexableObject(candidate) && typeof candidate[property] === 'string' ? candidate[property] : undefined } export function computeStackTraceFromOnErrorMessage( messageObj: unknown, url?: string, line?: number, column?: number ): StackTrace | undefined { if (url === undefined) { return } const { name, message } = tryToParseMessage(messageObj) return { name, message, stack: [{ url, column, line }], } } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types const ERROR_TYPES_RE = /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?([\s\S]*)$/ function tryToParseMessage(messageObj: unknown) { let name let message if ({}.toString.call(messageObj) === '[object String]') { ;[, name, message] = ERROR_TYPES_RE.exec(messageObj as string)! } return { name, message } } // Custom error stacktrace fix // Some browsers (safari/firefox) add the error constructor as a frame in the stacktrace // In order to normalize the stacktrace, we need to remove it function isNonNativeClassPrototype(prototype: object) { return String(prototype.constructor).startsWith('class ') } let isWronglyReportingCustomErrorsCache: boolean | undefined function isWronglyReportingCustomErrors() { if (isWronglyReportingCustomErrorsCache !== undefined) { return isWronglyReportingCustomErrorsCache } /* eslint-disable no-restricted-syntax */ class DatadogTestCustomError extends Error { constructor() { super() this.name = 'Error' // set name to Error so that no browser would default to the constructor name } } const [customError, nativeError] = [DatadogTestCustomError, Error].map((errConstructor) => new errConstructor()) // so that both errors should exactly have the same stacktrace isWronglyReportingCustomErrorsCache = // If customError is not a class, it means that this was built with ES5 as target, converting the class to a normal object. // Thus, error constructors will be reported on all browsers, which is the expected behavior. isNonNativeClassPrototype(Object.getPrototypeOf(customError)) && // If the browser is correctly reporting the stacktrace, the normal error stacktrace should be the same as the custom error stacktrace nativeError.stack !== customError.stack return isWronglyReportingCustomErrorsCache }