UNPKG

@backtrace/sdk-core

Version:
1,468 lines (1,439 loc) 116 kB
class BacktraceReportSubmissionResult { message; get result() { return this._result; } status = 'Ok'; _result; constructor(statusOrResponse, message) { this.message = message; if (this.isSubmissionResponse(statusOrResponse)) { this.status = statusOrResponse; return; } this._result = statusOrResponse; } static OnLimitReached(target = 'Server') { return new BacktraceReportSubmissionResult('Limit reached', `${target} report limit reached`); } static SdkDisabled() { return new BacktraceReportSubmissionResult('Disabled SDK'); } static Unsupported(message) { return new BacktraceReportSubmissionResult('Unsupported', message); } static ReportSkipped() { return new BacktraceReportSubmissionResult('Report skipped'); } static OnInternalServerError(message) { return new BacktraceReportSubmissionResult('Server Error', message); } static OnInvalidToken() { return new BacktraceReportSubmissionResult('Invalid token'); } static OnUnknownError(message) { return new BacktraceReportSubmissionResult('Unknown', message); } static OnNetworkingError(message) { return new BacktraceReportSubmissionResult('Network Error', message); } static Ok(response) { return new BacktraceReportSubmissionResult(response); } isSubmissionResponse(statusOrResponse) { return typeof statusOrResponse === 'string'; } } function jsonEscaper() { const ancestors = []; const keys = []; // in TypeScript add "this: any" param to avoid compliation errors - as follows // return function (this: any, field: any, value: any) { return function (key, value) { if (value === null) { return value; } const valueType = typeof value; if (valueType === 'bigint') { return value.toString(); } if (valueType !== 'object') { return value; } // `this` is the object that value is contained in, // i.e., its direct parent. while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { ancestors.pop(); keys.pop(); } if (ancestors.includes(value)) { return `[Circular].${keys.filter((k) => !!k).join('.')}.${key}`; } keys.push(key); ancestors.push(value); return value; }; } class SubmissionUrlInformation { static SUBMIT_PREFIX = 'submit.backtrace.io/'; /** * Convert url/token from credentials to JSON submission URL * @param url credentials URL * @param token credentials token * @returns JSON submissionURL */ static toJsonReportSubmissionUrl(url, token) { // if the token doesn't exist - use URL if (!token) { return url; } // if the url points to submit, we should always use it without any modifications if (url.includes(this.SUBMIT_PREFIX)) { return url; } // if the URL has token in the URL, the user probably added a token once again // in this case, don't do anything if (url.indexOf(token) !== -1) { return url; } const result = new URL(`/post`, url); result.searchParams.append('format', 'json'); result.searchParams.append('token', token); return result.href; } /** * Converts full submission JSON URL to PlCrashReporter submission URL * @param submissionUrl Backtrace Submission URL */ static toPlCrashReporterSubmissionUrl(submissionUrl) { return this.changeSubmissionFormat(submissionUrl, 'plcrash'); } /** * Converts full submission JSON URL to minidump submission URL * @param submissionUrl Backtrace Submission URL */ static toMinidumpSubmissionUrl(submissionUrl) { return this.changeSubmissionFormat(submissionUrl, 'minidump'); } static toAttachmentSubmissionUrl(submissionUrl, rxid, attachmentName) { const query = `object=${rxid}&attachment_name=${attachmentName}`; if (submissionUrl.includes('?')) { return (submissionUrl += `&` + query); } return (submissionUrl += '?' + query); } /** * Find the universe based on the submission URL * @param submissionUrl submission URL - full submission URL to Backtrace. * @returns universe name */ static findUniverse(submissionUrl) { const submitIndex = submissionUrl.indexOf(this.SUBMIT_PREFIX); if (submitIndex !== -1) { // submit format URL // submit.backtrace.io/universe/token/format // we can expect the universe name just after the hostname const universeStartIndex = submitIndex + this.SUBMIT_PREFIX.length; const endOfUniverseName = submissionUrl.indexOf('/', universeStartIndex); return submissionUrl.substring(universeStartIndex, endOfUniverseName); } // the universe name should be available in the hostname // for example abc.sp.backtrace.io or zyx.in.backtrace.io or foo.backtrace.io const domainIndex = submissionUrl.indexOf('.backtrace.io'); if (domainIndex === -1) { return undefined; } const protocolSeparator = '://'; let protocolEndIndex = submissionUrl.indexOf(protocolSeparator); if (protocolEndIndex === -1) { protocolEndIndex = 0; } else { protocolEndIndex += protocolSeparator.length; } const hostname = submissionUrl.substring(protocolEndIndex, domainIndex); const endOfUniverseName = hostname.indexOf('.'); return endOfUniverseName === -1 ? hostname : hostname.substring(0, endOfUniverseName); } static findToken(submissionUrl) { const submitIndex = submissionUrl.indexOf(this.SUBMIT_PREFIX); if (submitIndex !== -1) { const submissionUrlParts = submissionUrl.split('/'); // submit format URL // submit.backtrace.io/universe/token/format // by spliting the submission URL by `/` and dropping the last // part of the URL, the last element on the list is the token. return submissionUrlParts[submissionUrlParts.length - 2] ?? null; } const url = new URL(submissionUrl); return url.searchParams.get('token'); } static changeSubmissionFormat(submissionUrl, desiredFormat) { const submitIndex = submissionUrl.indexOf(this.SUBMIT_PREFIX); if (submitIndex !== -1) { const queryParametersIndex = submissionUrl.indexOf('?'); const queryParameters = queryParametersIndex === -1 ? '' : submissionUrl.substring(queryParametersIndex); const pathname = submissionUrl.substring(submitIndex + this.SUBMIT_PREFIX.length, queryParametersIndex === -1 ? undefined : queryParametersIndex); const pathParts = pathname.split('/'); // path parts are prefixed with '/' character. Expected and valid submit format is: // /universe/token/format // splitting pathname should generate at least 4 elements ('', universe, token, format) // if pathParts length is not equal to 4 then the invalid were passed. const expectedMinimalPathParts = 3; if (pathParts.length < expectedMinimalPathParts) { return submissionUrl; } pathParts[2] = desiredFormat; return (submissionUrl.substring(0, submitIndex + this.SUBMIT_PREFIX.length) + pathParts.join('/') + queryParameters); } else { const url = new URL(submissionUrl); url.searchParams.set('format', desiredFormat); return url.href; } } } class RequestBacktraceReportSubmission { _requestHandler; _submissionUrl; constructor(options, _requestHandler) { this._requestHandler = _requestHandler; this._submissionUrl = SubmissionUrlInformation.toJsonReportSubmissionUrl(options.url, options.token); } send(data, attachments, abortSignal) { const json = JSON.stringify(data, jsonEscaper()); return this._requestHandler.postError(this._submissionUrl, json, attachments, abortSignal); } async sendAttachment(rxid, attachment, abortSignal) { if (!this._requestHandler.postAttachment) { return BacktraceReportSubmissionResult.Unsupported('postAttachment is not implemented'); } return await this._requestHandler.postAttachment(SubmissionUrlInformation.toAttachmentSubmissionUrl(this._submissionUrl, rxid, attachment.name), attachment, abortSignal); } } const DEFAULT_TIMEOUT = 15_000; class ConnectionError { /** * Verifies if an Error is a connection error * @param err error * @returns true if the error was caused by ETIMEDOUT or ECONNRESET or ECONNABORTED */ static isConnectionError(err) { const error = err; return error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET' || error.code === 'ECONNABORTED'; } } class MetricsUrlInformation { static generateSummedEventsUrl(hostname, submissionUrl, credentialsToken) { const submissionInformation = this.findSubmissionInformation(submissionUrl, credentialsToken); if (!submissionInformation) { return undefined; } return this.generateEventsServiceUrl(hostname, 'summed-events', submissionInformation.universe, submissionInformation.token); } static generateUniqueEventsUrl(hostname, submissionUrl, credentialsToken) { const submissionInformation = this.findSubmissionInformation(submissionUrl, credentialsToken); if (!submissionInformation) { return undefined; } return this.generateEventsServiceUrl(hostname, 'unique-events', submissionInformation.universe, submissionInformation.token); } static generateEventsServiceUrl(hostname, eventServiceName, universe, token) { return new URL(`/api/${eventServiceName}/submit?universe=${universe}&token=${token}`, hostname).toString(); } static findSubmissionInformation(submissionUrl, token) { const universe = SubmissionUrlInformation.findUniverse(submissionUrl); if (!universe) { return undefined; } token = token ?? SubmissionUrlInformation.findToken(submissionUrl); if (!token) { return undefined; } return { universe, token }; } } class BacktraceCoreApi { _requestHandler; _summedMetricsSubmissionUrl; _uniqueMetricsSubmissionUrl; _requestBacktraceReportSubmission; constructor(options, _requestHandler) { this._requestHandler = _requestHandler; this._summedMetricsSubmissionUrl = MetricsUrlInformation.generateSummedEventsUrl(options.metrics?.url ?? 'https://events.backtrace.io', options.url, options.token); this._uniqueMetricsSubmissionUrl = MetricsUrlInformation.generateUniqueEventsUrl(options.metrics?.url ?? 'https://events.backtrace.io', options.url, options.token); this._requestBacktraceReportSubmission = options.requestBacktraceReportSubmission ?? new RequestBacktraceReportSubmission({ url: options.url, }, this._requestHandler); } sendReport(data, attachments, abortSignal) { return this._requestBacktraceReportSubmission.send(data, attachments, abortSignal); } sendAttachment(rxid, attachment, abortSignal) { return this._requestBacktraceReportSubmission.sendAttachment(rxid, attachment, abortSignal); } sendUniqueMetrics(metrics, abortSignal) { if (!this._uniqueMetricsSubmissionUrl) { throw new Error('Unique metrics URL is not available.'); } return this._requestHandler.post(this._uniqueMetricsSubmissionUrl, JSON.stringify(metrics), abortSignal); } sendSummedMetrics(metrics, abortSignal) { if (!this._summedMetricsSubmissionUrl) { throw new Error('Summed metrics URL is not available.'); } return this._requestHandler.post(this._summedMetricsSubmissionUrl, JSON.stringify(metrics), abortSignal); } } class Events { _callbacks = {}; on(event, callback) { this.addCallback(event, { callback }); return this; } once(event, callback) { this.addCallback(event, { callback, once: true }); return this; } off(event, callback) { this.removeCallback(event, callback); return this; } emit(event, ...args) { const callbacks = this._callbacks[event]; if (!callbacks || !callbacks.length) { return false; } for (const { callback, once } of [...callbacks]) { try { callback(...args); } catch { // Do nothing } if (once) { this.removeCallback(event, callback); } } return true; } addCallback(event, callback) { const list = this._callbacks[event]; if (list) { list.push(callback); } else { this._callbacks[event] = [callback]; } } removeCallback(event, callback) { const list = this._callbacks[event]; if (!list) { return; } const index = list.findIndex((el) => el.callback === callback); if (index === -1) { return; } list.splice(index, 1); if (!list.length) { delete this._callbacks[event]; } } } class TimeHelper { static now() { return Date.now(); } static toTimestampInSec(timestampMs) { return Math.floor(timestampMs / 1000); } static convertSecondsToMilliseconds(timeInSec) { return timeInSec * 1000; } } class BacktraceReport { data; attributes; attachments; /** * Report classifiers */ classifiers = []; /** * Report annotations */ annotations = {}; /** * Report stack trace */ stackTrace = {}; /** * Report message */ message; /** * Report inner errors */ innerReport = []; /** * Report timestamp in ms */ timestamp = TimeHelper.now(); /** * Sets how many top frames should be skipped. */ skipFrames = 0; addStackTrace(name, stack, message = '') { if (typeof stack === 'string') { this.stackTrace[name] = { stack, message, }; } else { this.stackTrace[name] = stack; } return this; } constructor(data, attributes = {}, attachments = [], options = {}) { this.data = data; this.attributes = attributes; this.attachments = attachments; this.skipFrames = options?.skipFrames ?? 0; let errorType = 'Exception'; if (data instanceof Error) { this.message = this.generateErrorMessage(data.message); this.annotations['error'] = { ...data, message: this.message, name: data.name, stack: data.stack, }; this.classifiers = [data.name]; this.stackTrace['main'] = { stack: data.stack ?? '', message: this.message, }; // Supported in ES2022 if (data.cause) { this.innerReport.push(data.cause); } } else { this.message = this.generateErrorMessage(data); this.stackTrace['main'] = { stack: new Error().stack ?? '', message: this.message, }; this.classifiers = ['Message']; errorType = 'Message'; this.skipFrames += 1; } if (!this.attributes['error.type']) { this.attributes['error.type'] = errorType; } this.attributes['error.message'] = this.message; if (options?.timestamp) { this.timestamp = options.timestamp; } if (options?.classifiers) { this.classifiers.unshift(...options.classifiers); } } generateErrorMessage(data) { return typeof data === 'object' ? JSON.stringify(data, jsonEscaper()) : (data?.toString() ?? ''); } } class AttachmentManager { attachmentEvents; _attachmentProviders = []; constructor() { this.attachmentEvents = new Events(); } /** * Adds attachment to manager cache. * @param attachments attachments or attachment returning functions */ add(...attachments) { this.addProviders(...attachments.map((a) => typeof a === 'function' ? { type: 'dynamic', get: a, } : { type: 'scoped', get: () => a, })); } /** * Adds `BacktraceAttachmentProvider` to manager cache. * @param attachmentProviders attachment providers */ addProviders(...attachmentProviders) { let anyScoped = false; for (const provider of attachmentProviders) { if (provider.type === 'dynamic') { this._attachmentProviders.push(provider); } else { const attachment = provider.get(); this._attachmentProviders.push({ type: 'scoped', get: () => attachment, }); anyScoped = true; } } if (anyScoped) { this.attachmentEvents.emit('scoped-attachments-updated', this.get('scoped')); } } /** * Returns scoped, dynamic, or all attachments. * @param type optional type to filter attachments * @returns array of `BacktraceAttachment` */ get(type) { const result = []; for (const provider of this._attachmentProviders) { if (type && provider.type !== type) { continue; } const attachment = provider.get(); if (!attachment) { continue; } if (Array.isArray(attachment)) { result.push(...attachment); } else { result.push(attachment); } } return result; } } class ReportDataBuilder { static build(attributes) { const result = { annotations: {}, attributes: {} }; if (!attributes) { return result; } for (const attributeKey in attributes) { const attribute = attributes[attributeKey]; if (attribute == null) { result.attributes[attributeKey] = attribute; continue; } switch (typeof attribute) { case 'object': { result.annotations[attributeKey] = attribute; break; } case 'bigint': { result.attributes[attributeKey] = attribute.toString(); break; } default: { result.attributes[attributeKey] = attribute; break; } } } return result; } } class AttributeManager { attributeEvents; _attributeProviders = []; constructor(providers) { this.attributeEvents = new Events(); for (const provider of providers) { this.addProvider(provider); } } /** * Adds attributes to manager cache * @param attributes attributes object */ add(attributes) { if (typeof attributes === 'function') { this.addProvider({ type: 'dynamic', get: attributes }); } else { this.addProvider({ type: 'scoped', get: () => attributes }); } } /** * Adds attribute provider to the manager * @param attributeProvider * @returns */ addProvider(attributeProvider) { if (attributeProvider.type === 'dynamic') { this._attributeProviders.push(attributeProvider); return; } else { const attributes = attributeProvider.get(); this._attributeProviders.push({ type: 'scoped', get: () => attributes, }); this.attributeEvents.emit('scoped-attributes-updated', this.get('scoped')); } } /** * Gets client attributes * @returns Report attribute - client attributes and annotations */ get(attributeType) { const result = { annotations: {}, attributes: {}, }; for (const attributeProvider of this._attributeProviders) { if (attributeType && attributeProvider.type != attributeType) { continue; } const providerResult = ReportDataBuilder.build(attributeProvider.get()); result.attributes = { ...result.attributes, ...providerResult.attributes, }; result.annotations = { ...result.annotations, ...providerResult.annotations, }; } return result; } } class ClientAttributeProvider { _sdkName; _sdkVersion; _sessionId; constructor(_sdkName, _sdkVersion, _sessionId) { this._sdkName = _sdkName; this._sdkVersion = _sdkVersion; this._sessionId = _sessionId; } get type() { return 'scoped'; } get() { return { 'application.session': this._sessionId, 'backtrace.agent': this._sdkName, 'backtrace.version': this._sdkVersion, }; } } class UserAttributeProvider { type; _source; constructor(source) { this._source = typeof source === 'function' ? source : () => source; this.type = typeof source === 'function' ? 'dynamic' : 'scoped'; } get() { return this._source(); } } function stringifiedSize(value) { return JSON.stringify(value).length; } function toStringSize(value) { return value.toString().length; } const stringSize = (value) => stringifiedSize(value); const numberSize = (toStringSize); const bigintSize = (toStringSize); const symbolSize = 0; const functionSize = 0; const booleanSize = (value) => (value ? 4 : 5); const undefinedSize = 0; const nullSize = 'null'.length; function arraySize(array, replacer) { const bracketLength = 2; const commaLength = array.length - 1; let elementsLength = 0; for (let i = 0; i < array.length; i++) { const element = array[i]; switch (typeof element) { case 'function': case 'symbol': case 'undefined': elementsLength += nullSize; break; default: elementsLength += _jsonSize(array, i.toString(), element, replacer); } } return bracketLength + commaLength + elementsLength; } const objectSize = (obj, replacer) => { const entries = Object.entries(obj); const bracketLength = 2; let entryCount = 0; let entriesLength = 0; for (const [k, v] of entries) { const valueSize = _jsonSize(obj, k, v, replacer); if (valueSize === 0) { continue; } entryCount++; // +1 adds the comma size entriesLength += keySize(k) + valueSize + 1; } // -1 removes previously added last comma size (there is no trailing comma) const commaLength = Math.max(0, entryCount - 1); return bracketLength + commaLength + entriesLength; }; function keySize(key) { const QUOTE_SIZE = 2; if (key === null) { return nullSize + QUOTE_SIZE; } else if (key === undefined) { return '"undefined"'.length; } switch (typeof key) { case 'string': return stringSize(key); case 'number': return numberSize(key) + QUOTE_SIZE; case 'boolean': return booleanSize(key) + QUOTE_SIZE; case 'symbol': return symbolSize; // key not used in JSON default: return stringSize(key.toString()); } } function _jsonSize(parent, key, value, replacer) { if (value && typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') { value = value.toJSON(); } value = replacer ? replacer.call(parent, key, value) : value; if (value === null) { return nullSize; } else if (value === undefined) { return undefinedSize; } if (Array.isArray(value)) { return arraySize(value, replacer); } switch (typeof value) { case 'bigint': return bigintSize(value); case 'boolean': return booleanSize(value); case 'function': return functionSize; case 'number': return numberSize(value); case 'object': return objectSize(value, replacer); case 'string': return stringSize(value); case 'symbol': return symbolSize; case 'undefined': return undefinedSize; } return 0; } /** * Calculates size of the object as it would be serialized into JSON. * * _Should_ return the same value as `JSON.stringify(value, replacer).length`. * This may not be 100% accurate, but should work for our requirements. * @param value Value to compute length for. * @param replacer A function that transforms the results as in `JSON.stringify`. * @returns Final string length. */ function jsonSize(value, replacer) { return _jsonSize(undefined, '', value, replacer); } const REMOVED_PLACEHOLDER = '<removed>'; function limitObjectDepth(obj, depth) { if (!(depth < Infinity)) { return obj; } if (depth < 0) { return REMOVED_PLACEHOLDER; } const limitIfObject = (value) => typeof value === 'object' && value ? limitObjectDepth(value, depth - 1) : value; const result = {}; for (const key in obj) { const value = obj[key]; if (Array.isArray(value)) { result[key] = value.map(limitIfObject); } else { result[key] = limitIfObject(value); } } return result; } function textFormatter() { const defaultFormatter = fallbackFormatter(jsonEscaper()); try { // eslint-disable-next-line @typescript-eslint/no-var-requires const util = require('util'); return util.format ?? defaultFormatter; } catch { return defaultFormatter; } } function fallbackFormatter(jsonEscapeFunction) { return function fallbackFormatter(...params) { let result = ''; for (const param of params) { result += typeof param === 'object' ? JSON.stringify(param, jsonEscapeFunction) : param?.toString(); } return result; }; } var BreadcrumbLogLevel; (function (BreadcrumbLogLevel) { BreadcrumbLogLevel[BreadcrumbLogLevel["Verbose"] = 1] = "Verbose"; BreadcrumbLogLevel[BreadcrumbLogLevel["Debug"] = 2] = "Debug"; BreadcrumbLogLevel[BreadcrumbLogLevel["Info"] = 4] = "Info"; BreadcrumbLogLevel[BreadcrumbLogLevel["Warning"] = 8] = "Warning"; BreadcrumbLogLevel[BreadcrumbLogLevel["Error"] = 16] = "Error"; })(BreadcrumbLogLevel || (BreadcrumbLogLevel = {})); const defaultBreadcrumbsLogLevel = (1 << 5) - 1; var BreadcrumbType; (function (BreadcrumbType) { BreadcrumbType[BreadcrumbType["Manual"] = 1] = "Manual"; BreadcrumbType[BreadcrumbType["Log"] = 2] = "Log"; BreadcrumbType[BreadcrumbType["Navigation"] = 4] = "Navigation"; BreadcrumbType[BreadcrumbType["Http"] = 8] = "Http"; BreadcrumbType[BreadcrumbType["System"] = 16] = "System"; BreadcrumbType[BreadcrumbType["User"] = 32] = "User"; BreadcrumbType[BreadcrumbType["Configuration"] = 64] = "Configuration"; })(BreadcrumbType || (BreadcrumbType = {})); const defaultBreadcurmbType = (1 << 7) - 1; class ConsoleEventSubscriber { /** * All overriden console events */ _events = {}; _formatter; start(backtraceBreadcrumbs) { if ((backtraceBreadcrumbs.breadcrumbsType & BreadcrumbType.Log) !== BreadcrumbType.Log) { return; } this._formatter = textFormatter(); this.bindToConsoleMethod('log', BreadcrumbLogLevel.Info, backtraceBreadcrumbs); this.bindToConsoleMethod('warn', BreadcrumbLogLevel.Warning, backtraceBreadcrumbs); this.bindToConsoleMethod('error', BreadcrumbLogLevel.Error, backtraceBreadcrumbs); this.bindToConsoleMethod('debug', BreadcrumbLogLevel.Debug, backtraceBreadcrumbs); this.bindToConsoleMethod('trace', BreadcrumbLogLevel.Verbose, backtraceBreadcrumbs); } dispose() { for (const key in this._events) { const consoleMethod = this._events[key]; console[key] = consoleMethod; } } bindToConsoleMethod(name, level, backtraceBreadcrumbs) { const originalMethod = console[name]; console[name] = (...args) => { originalMethod(...args); const message = this._formatter(...args); backtraceBreadcrumbs.addBreadcrumb(message, level, BreadcrumbType.Log); }; this._events[name] = originalMethod; } } /** * Constrains `value` to `min` and `max` values, wrapping not matching values around. * @param min minimum value to allow * @param max maximum value to allow * @returns function accepting `value` * * @example * const wrap = wrapped(10, 20); * console.log(wrap(15)); // 15 * console.log(wrap(21)); // 10, wrapped around * console.log(wrap(8)); // 18, wrapped around */ function wrapped(min, max) { function wrapped(value) { const range = max - min; let newValue; if (value < min) { newValue = max - ((min - value) % range); if (newValue === max) { newValue = min; } } else if (value >= max) { newValue = min + ((value - max) % range); if (newValue === max) { newValue = min; } } else { newValue = value; } return newValue; } return wrapped; } /** * Constrains `value` to `min` and `max` values. * @param min minimum value to allow * @param max maximum value to allow * @returns function accepting `value` * * @example * const clamp = clamped(10, 20); * console.log(wrap(15)); // 15 * console.log(wrap(21)); // 20 * console.log(wrap(8)); // 10 */ function clamped(min, max) { function clamped(value) { return Math.max(min, Math.min(value, max)); } return clamped; } class OverwritingArray { capacity; _array; _headConstraint; _lengthConstraint; _head = 0; _length = 0; get head() { return this._head; } set head(value) { this._head = this._headConstraint(value); } get length() { return this._length; } set length(value) { this._length = this._lengthConstraint(value); } get start() { return this._headConstraint(this.head - this.length); } constructor(capacity, items) { this.capacity = capacity; this._array = new Array(capacity); // Head must be always between 0 and capacity. // If lower than 0, it needs to go from the end // If larger than capacity, it needs to go from the start // Wrapping solves this this._headConstraint = wrapped(0, capacity); // Length must be always no less than 0 and no larger than capacity this._lengthConstraint = clamped(0, capacity); if (items) { this.push(...items); } } add(item) { return this.pushOne(item); } push(...items) { for (const item of items) { this.pushOne(item); } return this.length; } pop() { this.head--; const element = this._array[this.head]; this._array[this.head] = undefined; this.length--; return element; } shift() { const element = this._array[this.start]; this._array[this.start] = undefined; this.length--; return element; } at(index) { return this._array[this.index(index)]; } *values() { for (let i = 0; i < this.length; i++) { const index = this.index(i); yield this._array[index]; } } *keys() { for (let i = 0; i < this.length; i++) { yield i; } } *entries() { for (let i = 0; i < this.length; i++) { const index = this.index(i); yield [i, this._array[index]]; } } [Symbol.iterator]() { return this.values(); } pushOne(item) { this._array[this.head] = item; this.head++; this.length++; } index(value) { if (!this.length) { return this._headConstraint(value); } const index = (value % this.length) + this.start; return this._headConstraint(index); } } class InMemoryBreadcrumbsStorage { _limits; get lastBreadcrumbId() { return this._lastBreadcrumbId; } /** * Breadcrumb name */ name = 'bt-breadcrumbs-0'; _lastBreadcrumbId = TimeHelper.toTimestampInSec(TimeHelper.now()); _breadcrumbs; _breadcrumbSizes; constructor(_limits) { this._limits = _limits; this._breadcrumbs = new OverwritingArray(_limits.maximumBreadcrumbs ?? 100); this._breadcrumbSizes = new OverwritingArray(this._breadcrumbs.capacity); } getAttachments() { return [this]; } getAttachmentProviders() { return [ { get: () => this, type: 'scoped', }, ]; } static factory({ limits }) { return new InMemoryBreadcrumbsStorage(limits); } /** * Returns breadcrumbs in the JSON format * @returns Breadcrumbs JSON */ get() { return JSON.stringify([...this._breadcrumbs], jsonEscaper()); } add(rawBreadcrumb) { this._lastBreadcrumbId++; const id = this._lastBreadcrumbId; const breadcrumb = { id, message: rawBreadcrumb.message, timestamp: TimeHelper.now(), type: BreadcrumbType[rawBreadcrumb.type].toLowerCase(), level: BreadcrumbLogLevel[rawBreadcrumb.level].toLowerCase(), }; if (rawBreadcrumb.attributes) { breadcrumb.attributes = rawBreadcrumb.attributes; } this._breadcrumbs.add(breadcrumb); if (this._limits.maximumTotalBreadcrumbsSize) { const size = jsonSize(breadcrumb, jsonEscaper()); this._breadcrumbSizes.add(size); let totalSize = this.totalSize(); while (totalSize > this._limits.maximumTotalBreadcrumbsSize) { this._breadcrumbs.shift(); const removedSize = this._breadcrumbSizes.shift() ?? 0; // We subtract removedSize plus comma in JSON totalSize -= removedSize + 1; } } return id; } totalSize() { let sum = 0; for (const size of this._breadcrumbSizes) { sum += size; } // Sum of: // - all breadcrumbs // - comma count // - brackets return sum + Math.max(0, this._breadcrumbSizes.length - 1) + 2; } } const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId'; /** * @returns `undefined` if value is `false`, else `value` if defined, else `defaultValue` */ const defaultIfNotFalse = (value, defaultValue) => { return value === false ? undefined : value !== undefined ? value : defaultValue; }; class BreadcrumbsManager { /** * Breadcrumbs type */ breadcrumbsType; /** * Breadcrumbs Log level */ logLevel; /** * Determines if the breadcrumb manager is enabled. */ _enabled = false; _limits; _eventSubscribers = [new ConsoleEventSubscriber()]; _interceptor; _storage; constructor(configuration, options) { this._limits = { maximumBreadcrumbs: defaultIfNotFalse(configuration?.maximumBreadcrumbs, 100), maximumAttributesDepth: defaultIfNotFalse(configuration?.maximumAttributesDepth, 2), maximumBreadcrumbMessageLength: defaultIfNotFalse(configuration?.maximumBreadcrumbMessageLength, 255), maximumBreadcrumbSize: defaultIfNotFalse(configuration?.maximumBreadcrumbSize, 64 * 1024), maximumTotalBreadcrumbsSize: defaultIfNotFalse(configuration?.maximumTotalBreadcrumbsSize, 1024 * 1024), }; this.breadcrumbsType = configuration?.eventType ?? defaultBreadcurmbType; this.logLevel = configuration?.logLevel ?? defaultBreadcrumbsLogLevel; this._storage = (options?.storage ?? InMemoryBreadcrumbsStorage.factory)({ limits: this._limits }); this._interceptor = configuration?.intercept; if (options?.subscribers) { this._eventSubscribers.push(...options.subscribers); } } addEventSubscriber(subscriber) { if (this._enabled) { subscriber.start(this); } this._eventSubscribers.push(subscriber); } setStorage(storage) { if (typeof storage === 'function') { this._storage = storage({ limits: this._limits }); } else { this._storage = storage; } } dispose() { this._enabled = false; for (const subscriber of this._eventSubscribers) { subscriber.dispose(); } } bind({ client, attachmentManager }) { if (this._storage.getAttachmentProviders) { attachmentManager.addProviders(...this._storage.getAttachmentProviders()); } else { attachmentManager.add(...this._storage.getAttachments()); } client.addAttribute(() => ({ [BREADCRUMB_ATTRIBUTE_NAME]: this._storage.lastBreadcrumbId, })); client.on('before-skip', (report) => this.logReport(report)); } initialize() { if (this._enabled) { return; } for (const subscriber of this._eventSubscribers) { subscriber.start(this); } this._enabled = true; } verbose(message, attributes) { return this.log(message, BreadcrumbLogLevel.Verbose, attributes); } debug(message, attributes) { return this.log(message, BreadcrumbLogLevel.Debug, attributes); } info(message, attributes) { return this.log(message, BreadcrumbLogLevel.Info, attributes); } warn(message, attributes) { return this.log(message, BreadcrumbLogLevel.Warning, attributes); } error(message, attributes) { return this.log(message, BreadcrumbLogLevel.Error, attributes); } log(message, level, attributes) { return this.addBreadcrumb(message, level, BreadcrumbType.Manual, attributes); } logReport(report) { const level = report.data instanceof Error ? BreadcrumbLogLevel.Error : BreadcrumbLogLevel.Warning; return this.addBreadcrumb(report.message, level, BreadcrumbType.System); } addBreadcrumb(message, level, type, attributes) { if (!this._enabled) { return false; } let rawBreadcrumb = { message: this.prepareBreadcrumbMessage(message), level, type, attributes, }; if (this._interceptor) { const interceptorBreadcrumb = this._interceptor(rawBreadcrumb); if (!interceptorBreadcrumb) { return false; } rawBreadcrumb = interceptorBreadcrumb; } if ((this.logLevel & rawBreadcrumb.level) !== level) { return false; } if ((this.breadcrumbsType & rawBreadcrumb.type) !== type) { return false; } if (this._limits.maximumBreadcrumbMessageLength !== undefined) { rawBreadcrumb.message = rawBreadcrumb.message.substring(0, this._limits.maximumBreadcrumbMessageLength); } let limitedBreadcrumb; if (this._limits.maximumAttributesDepth !== undefined && rawBreadcrumb.attributes) { limitedBreadcrumb = { ...rawBreadcrumb, attributes: limitObjectDepth(rawBreadcrumb.attributes, this._limits.maximumAttributesDepth), }; } else { limitedBreadcrumb = rawBreadcrumb; } if (this._limits.maximumBreadcrumbSize !== undefined) { const breadcrumbSize = jsonSize(limitedBreadcrumb, jsonEscaper()); if (breadcrumbSize > this._limits.maximumBreadcrumbSize) { // TODO: Trim the breadcrumb return false; } } const id = this._storage.add(limitedBreadcrumb); return id !== undefined; } /** * The expectation is, message should always be defined and passed as string. * However, logger can pass as a message an object or any other unknown type. * To be sure the code won't break, this method ensures the message is always a string * no matter what the logger gives us. * @param message breadcrumb message */ prepareBreadcrumbMessage(message) { if (message == null) { return ''; } const messageType = typeof message; switch (messageType) { case 'string': { return message; } case 'object': { return JSON.stringify(message, jsonEscaper()); } default: { return message.toString(); } } } } const UNKNOWN_FRAME = 'unknown'; const ANONYMOUS_FUNCTION = 'anonymous'; class V8StackTraceConverter { addressSeparator; get engine() { return 'v8'; } constructor(addressSeparator = '') { this.addressSeparator = addressSeparator; } convert(stackTrace, message) { const result = []; let stackFrames = stackTrace.split('\n'); const errorHeader = message.split('\n'); // remove error header from stack trace - if the error header exists if (stackFrames[0].indexOf(errorHeader[0]) !== -1) { stackFrames = stackFrames.slice(errorHeader.length); } else { stackFrames = stackFrames.slice(1); } for (const stackFrame of stackFrames) { const normalizedStackFrame = stackFrame.trim(); if (!normalizedStackFrame) { continue; } const frame = this.parseFrame(normalizedStackFrame); result.push(frame); } return result; } parseFrame(stackFrame) { const frameSeparator = 'at '; if (!stackFrame.startsWith(frameSeparator)) { return { funcName: stackFrame, library: UNKNOWN_FRAME, }; } stackFrame = stackFrame.substring(stackFrame.indexOf(frameSeparator) + frameSeparator.length); const asyncKeyword = 'async '; const sourceCodeSeparator = ' ('; let sourceCodeStartIndex = stackFrame.indexOf(sourceCodeSeparator); const anonymousFunction = sourceCodeStartIndex === -1; if (anonymousFunction) { if (stackFrame.startsWith(asyncKeyword)) { stackFrame = stackFrame.substring(asyncKeyword.length); } return { funcName: ANONYMOUS_FUNCTION, ...this.parseSourceCodeInformation(stackFrame), }; } let sourceCodeInformation = stackFrame.substring(sourceCodeStartIndex + sourceCodeSeparator.length - 1, stackFrame.length); const anonymousGenericSymbol = '(<anonymous>)'; if (sourceCodeInformation.startsWith(anonymousGenericSymbol)) { sourceCodeStartIndex += anonymousGenericSymbol.length + 1; sourceCodeInformation = sourceCodeInformation.substring(anonymousGenericSymbol.length); } if (sourceCodeInformation.startsWith(` ${frameSeparator}`)) { sourceCodeInformation = sourceCodeInformation.substring(frameSeparator.length + 1); } else { sourceCodeInformation = sourceCodeInformation.substring(1, sourceCodeInformation.length - 1); } let functionName = stackFrame.substring(0, sourceCodeStartIndex); if (functionName.startsWith(asyncKeyword)) { functionName = functionName.substring(asyncKeyword.length); } return { funcName: functionName, ...this.parseSourceCodeInformation(sourceCodeInformation), }; } parseSourceCodeInformation(sourceCodeInformation) { if (sourceCodeInformation.startsWith('eval')) { return this.extractEvalInformation(sourceCodeInformation); } if (this.addressSeparator && sourceCodeInformation.startsWith(this.addressSeparator)) { sourceCodeInformation = sourceCodeInformation.substring(this.addressSeparator.length).trimStart(); } const sourceCodeParts = sourceCodeInformation.split(':'); const column = parseInt(sourceCodeParts[sourceCodeParts.length - 1]); const lineNumber = parseInt(sourceCodeParts[sourceCodeParts.length - 2]); const library = sourceCodeParts.slice(0, sourceCodeParts.length - 2).join(':'); return { library, column: isNaN(column) ? undefined : column, line: isNaN(lineNumber) ? undefined : lineNumber, }; } extractEvalInformation(evalSourceCodeInformation) { const sourceCodeStartSeparatorChar = '('; const sourceCodeEndSeparatorChar = ')'; const sourceCodeStart = evalSourceCodeInformation.indexOf(sourceCodeStartSeparatorChar); const sourceCodeEnd = evalSourceCodeInformation.indexOf(sourceCodeEndSeparatorChar); if (sourceCodeStart === -1 || sourceCodeEnd === -1 || sourceCodeStart > sourceCodeEnd) { return { library: UNKNOWN_FRAME, }; } const sourceCodeInformation = evalSourceCodeInformation.substring(sourceCodeStart + sourceCodeStartSeparatorChar.length, sourceCodeEnd); return this.parseSourceCodeInformation(sourceCodeInformation); } } class IdGenerator { static uuid() { const bytes = [...new Array(16)].map(() => Math.floor(Math.random() * 256)); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; return (bytes .slice(0, 4) .map((n) => n.toString(16).padStart(2, '0')) .join('') + '-' + bytes .slice(4, 6) .map((n) => n.toString(16).padStart(2, '0')) .join('') + '-' + bytes .slice(6, 8) .map((n) => n.toString(16).padStart(2, '0')) .join('') + '-' + bytes .slice(8, 10) .map((n) => n.toString(16).padStart(2, '0')) .join('') + '-' + bytes .slice(10, 16) .map((n) => n.toString(16).padStart(2, '0')) .join('')); } } class BacktraceDataBuilder { _sdkOptions; _stackTraceConverter; _attributeManager; _debugIdProvider; MAIN_THREAD_NAME = 'main'; constructor(_sdkOptions, _stackTraceConverter, _attributeManager, _debugIdProvider) { this._sdkOptions = _sdkOptions; this._stackTraceConverter = _stackTraceConverter; this._attributeManager = _attributeManager; this._debugIdProvider = _debugIdProvider; } build(report) { const { annotations, attributes } = this._attributeManager.get(); const reportData = ReportDataBuilder.build(report.attributes); const { threads, detectedDebugIdentifier } = this.getThreads(report); const result = { uuid: IdGenerator.uuid(), timestamp: TimeHelper.toTimestampInSec(report.timestamp), agent: this._sdkOptions.agent, agentVersion: this._sdkOptions.agentVersion, lang: this._sdkOptions.langName, langVersion: this._sdkOptions.langVersion, classifiers: report.classifiers, mainThread: this.MAIN_THREAD_NAME, threads, annotations: { ...annotations, ...reportData.annotations, ...report.annotations, }, attributes: { ...attributes, ...reportData.attributes, }, }; if (detectedDebugIdentifier) { result.symbolication = 'sourcemap'; } return result; } getThreads(report) { const threads = {}; let detectedDebugIdentifier = false; for (const [name, traceInfo] of Object.entries(report.stackTrace)) { let stackFrames; if (Array.i