@backtrace/sdk-core
Version:
Backtrace-JavaScript SDK core library
3,439 lines • 117 kB
JavaScript
'use strict';
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;
};
}
exports.BreadcrumbLogLevel = void 0;
(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";
})(exports.BreadcrumbLogLevel || (exports.BreadcrumbLogLevel = {}));
const defaultBreadcrumbsLogLevel = (1 << 5) - 1;
exports.BreadcrumbType = void 0;
(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";
})(exports.BreadcrumbType || (exports.BreadcrumbType = {}));
const defaultBreadcurmbType = (1 << 7) - 1;
class ConsoleEventSubscriber {
/**
* All overriden console events
*/
_events = {};
_formatter;
start(backtraceBreadcrumbs) {
if ((backtraceBreadcrumbs.breadcrumbsType & exports.BreadcrumbType.Log) !== exports.BreadcrumbType.Log) {
return;
}
this._formatter = textFormatter();
this.bindToConsoleMethod('log', exports.BreadcrumbLogLevel.Info, backtraceBreadcrumbs);
this.bindToConsoleMethod('warn', exports.BreadcrumbLogLevel.Warning, backtraceBreadcrumbs);
this.bindToConsoleMethod('error', exports.BreadcrumbLogLevel.Error, backtraceBreadcrumbs);
this.bindToConsoleMethod('debug', exports.BreadcrumbLogLevel.Debug, backtraceBreadcrumbs);
this.bindToConsoleMethod('trace', exports.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, exports.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: exports.BreadcrumbType[rawBreadcrumb.type].toLowerCase(),
level: exports.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, exports.BreadcrumbLogLevel.Verbose, attributes);
}
debug(message, attributes) {
return this.log(message, exports.BreadcrumbLogLevel.Debug, attributes);
}
info(message, attributes) {
return this.log(message, exports.BreadcrumbLogLevel.Info, attributes);
}
warn(message, attributes) {
return this.log(message, exports.BreadcrumbLogLevel.Warning, attributes);
}
error(message, attributes) {
return this.log(message, exports.BreadcrumbLogLevel.Error, attributes);
}
log(message, level, attributes) {
return this.addBreadcrumb(message, level, exports.BreadcrumbType.Manual, attributes);
}
logReport(report) {
const level = report.data instanceof Error ? exports.BreadcrumbLogLevel.Error : exports.BreadcrumbLogLevel.Warning;
return this.addBreadcrumb(report.message, level, exports.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.isArray(traceInfo)) {
stackFrames = traceInfo;
}
else {
const { message, stack } = traceInfo;
stackFrames = this._stackTraceConverter.convert(stack, message);
}
if (name === this.MAIN_THREAD_NAME && report.skipFrames > 0) {
stackFrames.splice(0, report.skipFrames);
}
for (const frame of stackFrames) {
const debugIdentifier = this._debugIdProvider.getDebugId(frame.library);
if (!debugIdentifier) {
continue;
}
detectedDebugIdentifier = true;
frame.debug_identifier = debugIdentifier;
}
threads[name] = {
fault: name === this.MAIN_THREAD_NAME,
name,
stack: stackFrames,
};
}
return {
threads,
detectedDebugIdentifier,
};
}
}
class AbortError extends Error {
constructor(message) {
super(message ?? 'Operation cancelled.');
}
}
// Required for polyfills to have the same name as the original implementations
function getGlobal(key) {
return typeof window !== 'undefined'
? window[key]
: typeof global !== 'undefined'
? global[key]
: typeof self !== 'undefined'
? self[key]
: undefined;
}
const OriginalAbortController = getGlobal('AbortController');
/**
* Copied and repurposed from https://github.com/mo/abortcontroller-polyfill/blob/master/src/abortcontroller.js
*/
class Emitter {
_listeners = {};
_events = new Events();
addEventListener(type, listener, options) {
if (!(type in this._listeners)) {
this._listeners[type] = [];
}
const callback = (ev) => {
if (typeof listener === 'object') {
return listener.handleEvent.call(this, ev);
}
else {
return listener.call(this, ev);
}
};
const { once, signal } = (typeof options === 'object' ? options : {});
if (once) {
this._events.once(type, callback);
}
else {
this._events.on(type, callback);
}
if (signal) {
const removeFn = () => this.removeEventListener(type, listener);
signal.addEventListener(type, removeFn, { once: true });
}
this._listeners[type].push({ callback, listener, options });
}
removeEventListener(type, listener) {
if (!(type in this._listeners)) {
return;
}
const allListeners = this._listeners[type];
const listeners = allListeners.filter((l) => l.listener === listener);
for (const elem of listeners) {
this._events.off(type, elem.callback);
const index = allListeners.indexOf(elem);
allListeners.splice(index, 1);
}
}
dispatchEvent(event) {
this._events.emit(event.type, event);
return !event.defaultPrevented;
}
}
/**
* Copied and repurposed from https://github.com/mo/abortcontroller-polyfill/blob/master/src/abortcontroller.js
*/
class AbortSignal extends Emitter {
aborted = false;
onabort = null;
reason;
constructor() {
super();
// Compared to assignment, Object.defineProperty makes properties non-enumerable by default and
// we want Object.keys(new AbortController().signal) to be [] for compat with the native impl
Object.defineProperty(this, 'aborted', { writable: true, configurable: true, enumerable: false });
Object.defineProperty(this, 'onabort', { writable: true, configurable: true, enumerable: false });
Object.defineProperty(this, 'reason', { writable: true, configurable: true, enumerable: false });
}
toString() {
return '[object AbortSignal]';
}
throwIfAborted() {
if (this.aborted) {
throw this.reason;
}
}
dispatchEvent(event) {
if (event.type === 'abort') {
this.aborted = true;
if (typeof this.onabort === 'function') {
this.onabort.call(this, event);
}
}
return super.dispatchEvent(event);
}
any(signals) {
return anySignal(...signals);
}
}
/**
* Copied and repurposed from https://github.com/mo/abortcontroller-polyfill/blob/master/src/abortcontroller.js
*/
class AbortController {
signal;
constructor() {
// Compared to assignment, Object.defineProperty makes properties non-enumerable by default and
// we want Object.keys(new AbortController()) to be [] for compat with the native impl
this.signal = new AbortSignal();
Object.defineProperty(this, 'signal', { configurable: true, enumerable: false });
}
abort(reason) {
let event;
try {
event = new Event('abort');
}
catch (e) {
if (typeof document !== 'undefined') {
const ie8Document = document;
if (!ie8Document.createEvent && ie8Document.createEventObject) {
// For Internet Explorer 8:
event = ie8Document.createEventObject();
event.type = 'abort';
}
else {
// For Internet Explorer 11:
event = document.createEvent('Event');
event.initEvent('abort', false, false);
}
}
else {
// Fallback where document isn't available:
event = {
type: 'abort',
bubbles: false,
cancelable: false,
};
}
}
let signalReason = reason;
if (signalReason === undefined) {
if (typeof document === 'undefined') {
signalReason = new AbortError('This operation was aborted');
}
else {
try {
signalReason = new DOMException('signal is aborted without reason');
}
catch (err) {
// IE 11 does not support calling the DOMException constructor, use a
// regular error object on it instead.
signalReason = new AbortError('This operation was aborted');
}
}
}
this.signal.reason = signalReason;
this.signal.dispatchEvent(event);
}
toString() {
return '[object AbortController]';
}
}
/**
* Copied and repurposed from https://github.com/mo/abortcontroller-polyfill/blob/master/src/abortcontroller.js
*/
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
// These are necessary to make sure that we get correct output for:
// Object.prototype.toString.call(new AbortController())
AbortController.prototype[Symbol.toStringTag] = 'AbortController';
AbortSignal.prototype[Symbol.toStringTag] = 'AbortSignal';
}
/**
* Creates a new abort controller.
*
* If the AbortController is not available, a polyfill is used.
* @returns
*/
function createAbortController() {
if (OriginalAbortController) {
return new OriginalAbortController();
}
else {
return new AbortController();
}
}
function anySignal(...signals) {
const controller = createAbortController();
function cleanup() {
// Cleanup
for (const signal of signals) {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
}
}
function onAbort() {
controller.abort();
cleanup();
}
for (const signal of signals) {
if (!signal) {
continue;
}
if (signal.aborted) {
onAbort();
break;
}
signal.addEventListener('abort', onAbort);
}
controller.signal.dispose = cleanup;
return controller.signal;
}
/**
* Due to the event loop, NodeJS application might not exit
* due to some background work that the SDK delegates to setIntervals.
* Thanks to this helper we can cancell interval in NodeJS process.
* @param interval setInterval interval
*/
function unrefInterval(interval) {
if (!interval || typeof interval === 'number') {
return;
}
interval.unref();
}
class BacktraceDatabaseContext {
bucketCount;
_records;
_recordBuckets = {};
constructor(bucketCount = 3) {
this.bucketCount = bucketCount;
this._records = [];
this._recordBuckets = {};
}
find(predicate) {
return this._records.find(predicate);
}
add(record) {
if (this._recordBuckets[record.id] !== undefined) {
this._recordBuckets[record.id] = 0;
return;
}
this._records.push(record);
this._recordBuckets[record.id] = 0;
}
get() {
return this._records.map((r) => ({ ...r }));
}
getBucket(index) {
const bucket = [];
for (const record of this._records) {
if (this._recordBuckets[record.id] === index) {
bucket.push(record);
}
}
return bucket;
}
count() {
return this._records.length;
}
countByType() {
return this._records.reduce((total, current) => {
total[current.type]++;
return total;
}, {});
}
remove(recordOrId) {
const id = typeof recordOrId === 'string' ? recordOrId : recordOrId.id;
const index = this._records.findIndex((r) => r.id === id);
if (index === -1) {
return;
}
this._records.splice(index, 1);
delete this._recordBuckets[id];
}
increaseBucket(bucketStart) {
for (const [id, bucket] of Object.entries(this._recordBuckets)) {
if (bucket < bucketStart) {
continue;
}
const newBucket = bucket + 1;
if (newBucket >= this.bucketCount) {
this.remove(id);
}
this._recordBuckets[id] = newBucket;
}
}
load(records) {
for (const record of records) {
this.add(record);
}
}
dropOverLimits(limits) {
const remaining = { ...limits };
const dropped = [];
for (let i = this._records.length - 1; i >= 0; i--) {
const record = this._records[i];
if (remaining[record.type] === 0) {
this.remove(record);
dropped.push(record);
}
else {
remaining[record.type]--;
}
}
return dropped;
}
}
class BacktraceDatabase extends Events {
_options;
_storageProvider;
_requestHandler;
_sessionFiles;
/**
* Determines if the database is enabled.
*/
get enabled() {
return this._enabled;
}
/**
* Abort controller to cancel asynchronous database operations when
* the database is being disabled by the user.
*/
_abortController = createAbortController();
_databaseRecordContext;
_storageProviders = [];
_recordLimits;
_retryInterval;
_intervalId;
_enabled = false;
constructor(_options, _storageProvider, _requestHandler, _sessionFiles) {
super();
this._options = _options;
this._storageProvider = _storageProvider;
this._requestHandler = _requestHandler;
this._sessionFiles = _sessionFiles;
this._databaseRecordContext = new BacktraceDatabaseContext(this._options?.maximumRetries);
this._recordLimits = {
report: this._options?.maximumNumberOfRecords ?? 8,
attachment: this._options?.maximumNumberOfAttachmentRecords ?? 10,
};
this._retryInterval = this._options?.retryInterval ?? 60_000;
}
/**
* Starts database integration.
* @returns true if the database started successfully. Otherwise false.
*/
initialize() {
if (this._enabled) {
return this._enabled;
}
if (this._options?.enable === false) {
return false;
}
const startResult = this._storageProvider.start();
if (!startResult) {
return false;
}
const lockId = this._sessionFiles?.lockPreviousSessions();
this.loadReports()
.then(() => {
this.setupDatabaseAutoSend();
})
.finally(() => lockId && this._sessionFiles?.unlockPreviousSessions(lockId));
this._enabled = true;
return true;
}
bind({ client }) {
if (this._enabled) {
return;
}
if (this._options?.enable === false) {
return;
}
client.on('before-send', (_, data, attachments) => {
const record = this.add(data, attachments);
if (!record || record.locked) {
return undefined;
}
record.locked = true;
});
client.on('after-send', (_, data, __, submissionResult) => {
const record = this._databaseRecordContext.find((record) => record.type === 'report' && record.data.uuid === data.uuid);
if (!record) {
return;
}
record.locked = false;
if (submissionResult.status === 'Ok') {
this.remove(record);
this._sessionFiles?.unlockPreviousSessions(record.id);
}
});
}
/**
* Adds backtrace data to the database
* @param backtraceData diagnostic data object
* @param attachments attachments
* @returns record if database is enabled. Otherwise undefined.
*/
add(backtraceData, attachments) {
if (!this._enabled) {
return undefined;
}
const sessionId = backtraceData.attributes?.['application.session'];
const record = {
type: 'report',
data: backtraceData,
timestamp: TimeHelper.now(),
id: IdGenerator.uuid(),
locked: false,
attachments: attachments,
sessionId: typeof sessionId === 'string' ? sessionId : undefined,
};
this.prepareDatabase([record]);
const saveResult = this._storageProvider.add(record);
if (!saveResult) {
return undefined;
}
this._databaseRecordContext.add(record);
this.lockSessionWithRecord(record);
this.emit('added', record);
return record;
}
/**
* Adds Bactrace attachment to the database
* @param backtraceData diagnostic data object
* @param attachment the attachment to add
* @param sessionId session ID to use
* @returns record if database is enabled. Otherwise undefined.
*/
addAttachment(rxid, attachment, sessionId) {
if (!this._enabled) {
return undefined;
}
const record = {
type: 'attachment',
timestamp: TimeHelper.now(),
id: IdGenerator.uuid(),
rxid,
locked: false,
attachment,
sessionId,
};
this.prepareDatabase([record]);
const saveResult = this._storageProvider.add(record);
if (!saveResult) {
return undefined;
}
this._databaseRecordContext.add(record);
this.lockSessionWithRecord(record);
this.emit('added', record);
return record;
}
/**
* Returns stored references to Backtrace records
* @returns available records in the database
*/
get() {
return this._databaseRecordContext.get();
}
/**
* @returns Returns number of records stored in the Database
*/
count() {
return this._databaseRecordContext.count();
}
/**
* @returns Returns number of records by type stored in the Database
*/
countByType() {
return this._databaseRecordContext.countByType();
}
/**
* Disables database integration. After running the dispose method, you cannot
* execute any database operations.
*/
dispose() {
this._enabled = false;
clearInterval(this._intervalId);
this._abortController.abort();
}
/**
* Removes the database record/records
* @param recordOrRecords database records
*/
remove(recordOrRecords) {
if (!this._enabled) {
return;
}
const records = Array.isArray(recordOrRecords) ? recordOrRecords : [recordOrRecords];
for (const record of records) {
this._databaseRecordContext.remove(record);
this._storageProvider.delete(record);
this._sessionFiles?.unlockPreviousSessions(record.id);
this.emit('removed', record);
}
}
addStorageProvider(storageProvider) {
if (this._enabled) {
throw new Error('Cannot add storage provider after the database has been enabled.');
}
this._storageProviders.push(storageProvider);
}
/**
* Sends all records available in the database to Backtrace and removes them
* no matter if the submission process was successful or not.
* @param abortSignal optional abort signal to cancel sending requests
*/
async flush(abortSignal) {
const start = TimeHelper.now();
await this.send(abortSignal);
const records = this.get().filter((n) => n.timestamp <= start);
for (const record of records) {
if (abortSignal?.aborted) {
return;
}
this.remove(record);
}
}
/**
* Sends all records available in the database to Backtrace.
* @param abortSignal optional abort signal to cancel sending requests
*/
async send(abortSignal) {
for (let bucketIndex = 0; bucketIndex < this._databaseRecordContext.bucketCount; bucketIndex++) {
// make a copy of records to not update the array after each remove
const records = [...this._databaseRecordContext.getBucket(bucketIndex)];
const signal = anySignal(abortSignal, this._abortController.signal);
try {
for (const record of records) {
if (!this.enabled) {
return;
}
if (record.locked) {
continue;
}
try {
record.locked = true;
this.emit('before-send', record);
const result = record.type === 'report'
? await this._requestHandler.send(record.data, record.attachments, signal)
: await this._requestHandler.sendAttachment(record.rxid, record.attachment, signal);
this.emit('after-send', record, result);
if (result.status === 'Ok' ||
result.status === 'Unsupported' ||
result.status === 'Report skipped') {
this.remove(record);
continue;
}
this._databaseRecordContext.increaseBucket(bucketIndex);
return;
}
finally {
record.locked = false;
}
}
}
finally {
signal.dispose();
}
}
}
/**
* Prepare database to insert records
* @param forRecords records for which to update the database
*/
prepareDatabase(forRecords) {
const dropLimits = { ...this._recordLimits };
for (const record of forRecords) {
dropLimits[record.type]--;
}
const dropped = this._databaseRecordContext.dropOverLimits(dropLimits);
this.remove(dropped);
}
async loadReports() {
const records = await this._storageProvider.get();
// limit only non-attachment records
const countByType = {
attachment: 0,
report: 0,
};
const recordsToAdd = [];
const recordsToRemove = [];
for (const record of records) {
if (countByType[record.type] >= this._recordLimits[record.type]) {
recordsToRemove.push(record);
}
else {
recordsToAdd.push(record);
countByType[record.type]++;
}
}
// delete old records before adding them to the database
if (recordsToRemove.length) {
this.remove(recordsToRemove);
}
this.prepareDatabase(recordsToAdd);
this._databaseRecordContext.load(recordsToAdd);
for (const record of recordsToAdd) {
this.lockSessionWithRecord(record);
this.emit('added', record);
}
}
async setupDatabaseAutoSend() {
if (!this._enabled) {
return;
}
if (this._options?.autoSend === false) {
return;
}
const sendDatabaseReports = async () => {
await this.send();
};
this._intervalId = setInterval(sendDatabaseReports, this._retryInterval);
unrefInterval(this._intervalId);
await this.send();
}
lockSessionWithRecord(record) {
if (!this._sessionFiles) {
return;
}
const sessionId = record.sessionId;
if (typeof sessionId !== 'string') {
this._sessionFiles.lockPreviousSessions(record.id);
return;
}
const session = this._sessionFiles.getSessionWithId(sessionId);
session?.lock(record.id);
}
}
function isFileAttachment(attachment) {
return 'filePath' in attachment && typeof attachment.filePath === 'string';
}
class AttachmentBacktraceDatabaseFileRecord {
type = 'attachment';
id;
rxid;
timestamp;
attachment;
sessionId;
locked;
constructor(record) {
this.attachment = record.attachment;
this.id = record.id;
this.timestamp = record.timestamp;
this.rxid = record.rxid;
this.sessionId = record.sessionId;
// make sure the database record stored in the database directory
// is never locked. By doing this, we want to be sure once we load
// the record once again, the record will be available for future usage
this.locked = false;
}
static fromRecord(record) {
return new AttachmentBacktraceDatabaseFileRecord(record);
}
static fromJson(json, fileSystem) {
try {
const record = typeof json === 'string' ? JSON.parse(json) : json;
if (!isFileAttachment(record.attachment)) {
return undefined;
}
if (!fileSystem.existsSync(record.attachment.filePath)) {
return undefined;
}
const attachment = fileSystem.createAttachment(record.attachment.filePath, record.attachment.name);
return new AttachmentBacktraceDatabaseFileRecord({
...record,
attachment,
});
}
catch {
return undefined;
}
}
}
class ReportBacktraceDatabaseFileRecord {
attachments;
type = 'report';
data;
id;
timestamp;
sessionId;
locked;
constructor(record, attachments) {
this.attachments = attachments;
this.data = record.data;
this.id = record.id;
this.timestamp = record.timestamp;
this.sessionId = record.sessionId;
// make sure the database record stored in the database directory
// is never locked. By doing this, we want to be sure once we load
// the record once again, the record will be available for future usage
this.locked = false;
}
static fromRecord(record) {
return new ReportBacktraceDatabaseFileRecord(record, record.attachments.filter(isFileAttachment));
}
static fromJson(json, fileSystem) {
try {
const record = typeof json === 'string' ? JSON.parse(json) : json;
const attachments = record.attachments
? record.attachments
.filter(isFileAttachment)
.map((n) => fileSystem.createAttachment(n.filePath, n.name))
: [];
return new ReportBacktraceDatabaseFileRecord(record, attachments);
}
catch {
return undefined;
}
}
}
const BacktraceDatabaseFileRecord = {
fromRecord(record) {
switch (record.type) {
case 'attachment':
return AttachmentBacktraceDatabaseFileRecord.fromRecord(record);
case 'report':
return ReportBacktraceDatabaseFileRecord.fromRecord(record);
default:
throw new Error('unknown record type');
}
},
fromJson(json, fileSystem) {
try {
const record = JSON.parse(json);
switch (record.type) {
case 'attachment':
return AttachmentBacktraceDatabaseFileRecord.fromJson(record, fileSystem);
case 'report':
return ReportBacktraceDatabaseFileRecord.fromJson(record, fileSystem);
default:
return undefined;
}
}
catch {
return undefined;
}
},
};
class BacktraceDatabaseFileStorageProvider {
_fileSystem;
_path;
_enabled = true;
RECORD_SUFFIX = '-record.json';
constructor(_fileSystem, _path) {
this._fileSystem = _fileSystem;
this._path = _path;
}
/**
* Create a provider if provided options are valid
* @param options database configuration
* @returns database file storage provider
*/
static createIfValid(fileSystem, options) {
if (!options) {
return undefined;
}
if (!options.enable) {
return undefined;
}
if (options.enable && !options.path) {
throw new Error('Missing mandatory path to the database. Please define the database.path option in the configuration.');
}
return new BacktraceDatabaseFileStorageProvider(fileSystem, options.path);
}
start() {
// make sure by mistake we don't create anything or start any operation
if (this._enabled === false) {
return false;
}
return true;
}
delete(record) {
const recordPath = this.getRecordPath(record.id);
return this.unlinkRecord(recordPath);
}
add(record) {
const recordPath = this.getRecordPath(record.id);
try {
this._fileSystem.writeFileSync(recordPath, JSON.stringify(BacktraceDatabaseFileRecord.fromRecord(record), jsonEscaper()));
return true;
}
catch {
return false;
}
}
async get() {
const databaseFiles = await this._fileSystem.readDir(this._path);
const recordNames = databaseFiles
.filter((file) => file.endsWith(this.RECORD_SUFFIX))
.map((f) => this._path + '/' + f);
const records = [];
for (const recordName of recordNames) {
try {
const recordJson = await this._fileSystem.readFile(recordName);
const record = BacktraceDatabaseFileRecord.fromJson(recordJson, this._fileSystem);
if (!record) {
await this._fileSystem.unlink(recordName);
continue;
}
records.push(record);
}
catch {
await this._fileSystem.unlink(recordName);
}
}
return records;
}
unlinkRecord(recordPath) {
if (!this._fileSystem.existsSync(recordPath)) {
return false;
}
try {
this._fileSystem.unlinkSync(recordPath);
return true;
}
catch {
return false;
}
}
getRecordPath(id) {
return this._path + '/' + `${id}${this.RECORD_SUFFIX}`;
}
}
class MetricsEvent {
metricGroupName;
metricGroupValue;
attributes;
id = IdGenerator.uuid();
constructor(metricGroupName, metricGroupValue, attributes = {}) {
this.metricGroupName = metricGroupName;
this.metricGroupValue = metricGroupValue;
this.attributes = attributes;
}
toJSON() {
return {
timestamp: TimeHelper.toTimestampInSec(TimeHelper.now()),
attributes: this.attributes,
[this.metricGroupName]: this.metricGroupValue,
};
}
}
class SummedEvent extends MetricsEvent {
constructor(metricGroupName, attributes = {}) {
super('metric_group', metricGroupName, attributes);
}
}
class UniqueEvent extends MetricsEvent {
constructor(attributes) {
super('unique', ['guid'], attributes);
}
}
class BacktraceMetrics {
_options;
_sessionProvider;
_attributeManager;
_summedEventsSubmissionQueue;
_uniqueEventsSubmissionQueue;
/**
* Returns current session id.
*/
get sessionId() {
return this._sessionProvider.sessionId;
}
/**
* Default metrics submission interval. The variable defines how often metrics will be sent to metrics system.
*/
DEFAULT_UPDATE_INTERVAL = TimeHelper.convertSecondsToMilliseconds(30 * 60);
DEFAULT_SERVER_URL = 'https://events.backtrace.io';
metricsHost;
_updateInterval;
_updateIntervalId;
_abortController;
constructor(_options, _sessionProvider, _attributeManager, _summedEventsSubmissionQueue, _uniqueEventsSubmissionQueue) {
this._options = _options;
this._sessionProvider = _sessionProvider;
this._attributeManager = _attributeManager;
this._summedEventsSubmissionQueue = _summedEventsSubmissionQueue;
this._uniqueEventsSubmissionQueue = _uniqueEventsSubmissionQueue;
this.metricsHost = this._options.metricsSubmissionUrl ?? this.DEFAULT_SERVER_URL;
this._updateInterval = this._options.autoSendInterval ?? this.DEFAULT_UPDATE_INTERVAL;
this._abortController = createAbortController();
}
/**
* Starts metrics submission.
*/
initialize() {
if (!this._sessionProvider.newSession) {
return;
}
this.addSummedEvent('Application Launches');
this.handleAbort(() => this.send(this._abortController.signal));
if (this._updateInterval === 0) {
return;
}
this._updateIntervalId = setInterval(() => {
this.handleAbort(() => this.send(this._abortController.signal));
}, this._updateInterval);
unrefInterval(this._updateIntervalId);
}
/**
* Returns total number of events in the submission queue.
*/
count() {
return this._summedEventsSubmissionQueue?.total ?? 0 + this._uniqueEventsSubmissionQueue?.total ?? 0;
}
/**
* Add summed event to next Backtrace Metrics request.
* @param metricName Summed event name.
* @param eventAttributes event attributes.
*/
addSummedEvent(metricName, eventAttributes = {}) {
if (!metricName) {
return false;
}
const attributes = this.convertAttributes({
...this._attributeManager.get().attributes,
...ReportDataBuilder.build(eventAttributes ?? {}).attributes,
});
this._summedEventsSubmissionQueue.add(new SummedEvent(metricName, attributes));
return true;
}
/**
* Sends event to the metrics system.
*/
async send(abortSignal) {
if (!this._sessionProvider.shouldSend()) {
return false;
}
await Promise.all([this.sendUniqueEvent(abortSignal), this._summedEventsSubmissionQueue.send(abortSignal)]);
this._sessionProvider.afterMetricsSubmission();
return true;
}
/**
* Cleans up metrics interface.
*/
dispose() {
this._abortController.abort();
if (this._updateIntervalId) {
clearInterval(this._updateIntervalId);
}
this._uniqueEventsSubmissionQueue.dispose && this._uniqueEventsSubmissionQueue.dispose();
this._summedEventsSubmissionQueue.dispose && this._summedEventsSubmissionQueue.dispose();
}
async sendUniqueEvent(abortSignal) {
// always add the same unique event before send.
const { attributes } = this._attributeManager.get();
this._uniqueEventsSubmissionQueue.add(new UniqueEvent(this.convertAttributes(attributes)));
await this._uniqueEventsSubmissionQueue.send(abortSignal);
}
/**
* Event aggregators expecting to retrieve attributes in a string format. They also
* don't expect to retrieve null/undefined as attribute values.
*/
convertAttributes(attributes) {
return Object.keys(attributes)
.filter((n) => attributes[n] != null)
.reduce((acc, n) => {
acc[n] = attributes[n]?.toString();
return acc;
}, {});
}
async handleAbort(fn) {
try {
await fn();
return true;
}
catch (err) {
if (err instanceof AbortError) {
return false;
}
throw err;
}
}
}
class Delay {
/**
* Promise set timeout wrapper.
* @param timeout timeout in ms
* @param signal abort signal
*/
static wait(timeout, signal) {
return new Promise((resolve, reject) => {
// intervalId has to be defined here,
// as abortCallback can execute before setTimeout is executed
// eslint-disable-next-line prefer-const
let intervalId;
function abortCallback() {
clearTimeout(intervalId);
reject(new AbortError());
}
if (signal?.aborted) {
return abortCallback();
}
intervalId = setTimeout(() => {
signal?.removeEventListener('abort', abortCallback);
resolve();
}, timeout);
signal?.addEventListener('abort', abortCallback);
});
}
}
class MetricsSubmissionQueue {
_submissionUrl;
_eventName;
_requestHandler;
_metricMetadata;
maximumEvents;
get total() {
return this._events.length;
}
get submissionUrl() {
return this._submissionUrl;
}
DELAY_BETWEEN_REQUESTS = TimeHelper.convertSecondsToMilliseconds(10);
_events = [];
_numberOfDroppedRequests = 0;
_abortController;
MAXIMUM_NUMBER_OF_ATTEMPTS = 3;
constructor(_submissionUrl, _eventName, _requestHandler, _metricMetadata, maximumEvents = 50) {
this._submissionUrl = _submissionUrl;
this._eventName = _eventName;
this._requestHandler = _requestHandler;
this._metricMetadata = _metricMetadata;
this.maximumEvents = maximumEvents;
this._abortController = createAbortController();
}
add(event) {
this._events.push(event);
if (this.reachedLimit()) {
this.send();
}
}
async send(abortSignal) {
const eventsToProcess = this._events.splice(0);
const signal = anySignal(abortSignal, this._abortController.signal);
try {
return await this.submit(eventsToProcess, signal);
}
finally {
signal.dispose();
}
}
dispose() {
this._abortController.abort();
}
async submit(events, abortSignal) {
for (let attempts = 0; attempts < this.MAXIMUM_NUMBER_OF_ATTEMPTS; attempts++) {
const response = await this._requestHandler.post(this._submissionUrl, JSON.stringify({
...this._metricMetadata,
[this._eventName]: events,
metadata: {
dropped_events: this._numberOfDroppedRequests,
},
}, jsonEscaper()), abortSignal);
if (response.status === 'Ok') {
this._numberOfDroppedRequests = 0;
return;
}
this._numberOfDroppedRequests++;
await Delay.wait(2 ** attempts * this.DELAY_BETWEEN_REQUESTS, abortSignal);
}
// if the code reached this line, it means, we couldn't send data to server
// we need to try to return events to the queue and try to send it once again later.
this.returnEventsIfPossible(events);
}
returnEventsIfPossible(events) {
if (this.maximumEvents < this._events.length + events.length) {
return;
}
// push events to the beginning of the queue
this._events.unshift(...events);
}
reachedLimit() {
return this.maximumEvents === this._events.length && this.maximumEvents !== 0;
}
}
class MetricsBuilder {
_options;
_sessionProvider;
_attributeManager;
_requestHandler;
/**
* Default metrics submission interval. The variable defines how often metrics will be sent to metrics system
* By default 30 mins.
*/
DEFAULT_UPDATE_INTERVAL = TimeHelper.convertSecondsToMilliseconds(30 * 60);
APPLICATION_VERSION_ATTRIBUTE = 'application.version';
APPLICATION_ATTRIBUTE = 'application';
constructor(_options, _sessionProvider, _attributeManager, _requestHandler) {
this._options = _options;
this._sessionProvider = _sessionProvider;
this._attributeManager = _attributeManager;
this._requestHandler = _requestHandler;
}
build(uniqueEventsSubmissionQueue, summedEventsSubmissionQueue) {
const metricsOptions = {
...this.optionsWithDefaults(),
...(this._options.metrics ?? {}),
};
if (!metricsOptions.enable) {
return undefined;
}
const applicationInfo = this.verifyAttributeSetup();
if (!applicationInfo) {
return undefined;
}
uniqueEventsSubmissionQueue =
uniqueEventsSubmissionQueue ??
this.createUniqueEventSubmissionQueue(metricsOptions.metricsSubmissionUrl, applicationInfo);
if (!uniqueEventsSubmissionQueue) {
return undefined;
}
summedEventsSubmissionQueue =
summedEventsSubmissionQueue ??
this.createSummedEventSubmissionQueue(metricsOptions.metricsSubmissionUrl, applicationInfo);
if (!summedEventsSubmissionQueue) {
return undefined;
}
return new BacktraceMetrics(metricsOptions, this._sessionProvider, this._attributeManager, summedEventsSubmissionQueue, uniqueEventsSubmissionQueue);
}
verifyAttributeSetup() {
const { attributes } = this._attributeManager.get();
const application = attributes[this.APPLICATION_ATTRIBUTE];
const applicationVersion = attributes[this.APPLICATION_VERSION_ATTRIBUTE];
if (!application || !applicationVersion) {
return undefined;
}
return { application, applicationVersion };
}
createUniqueEventSubmissionQueue(metricsHost, applicationInfo) {
const uniqueEventsSubmissionUrl = MetricsUrlInformation.generateUniqueEventsUrl(metricsHost, this._options.url, this._options.token);
if (!uniqueEventsSubmissionUrl) {
return undefined;
}
return new MetricsSubmissionQueue(uniqueEventsSubmissionUrl, 'unique_events', this._requestHandler, {
[this.APPLICATION_ATTRIBUTE]: applicationInfo.application,
appversion: applicationInfo.applicationVersion,
}, this._options?.metrics?.size);
}
createSummedEventSubmissionQueue(metricsHost, applicationInfo) {
const summedEventsSubmissionUrl = MetricsUrlInformation.generateSummedEventsUrl(metricsHost, this._options.url, this._options.token);
if (!summedEventsSubmissionUrl) {
return undefined;
}
return new MetricsSubmissionQueue(summedEventsSubmissionUrl, 'summed_events', this._requestHandler, {
[this.APPLICATION_ATTRIBUTE]: applicationInfo.application,
appversion: applicationInfo.applicationVersion,
}, this._options?.metrics?.size);
}
optionsWithDefaults() {
return {
enable: true,
autoSendInterval: this.DEFAULT_UPDATE_INTERVAL,
metricsSubmissionUrl: 'https://events.backtrace.io',
size: 50,
};
}
}
class SingleSessionProvider {
newSession = true;
sessionId = IdGenerator.uuid();
_lastActive = 0;
get lastActive() {
return this._lastActive;
}
afterMetricsSubmission() {
this._lastActive = TimeHelper.now();
}
/**
* Allow to alway send metrics - in the single session there is no reason
* to skip sending metrics.
*/
shouldSend() {
return true;
}
}
class RateLimitWatcher {
/**
* Time the single report can stay in the queue in ms.
*/
MAXIMUM_TIME_IN_QUEUE = 60 * 1000;
_reportPerMin;
_watcherEnable;
_reportQueue = [];
get enabled() {
return this._watcherEnable;
}
constructor(reportPerMin) {
if (reportPerMin == null) {
reportPerMin = 0;
}
if (reportPerMin < 0) {
throw new Error('ReportPerMinute argument must be greater or equal to zero');
}
this._reportPerMin = reportPerMin;
this._watcherEnable = reportPerMin > 0;
}
skipReport() {
const time = TimeHelper.now();
if (!this._watcherEnable) {
return false;
}
this.clear(time);
if (this._reportQueue.length >= this._reportPerMin) {
return true;
}
this._reportQueue.push(time);
return false;
}
clear(time) {
if (this._reportQueue.length === 0) {
return;
}
// we don't have anything to remove
if (time - this._reportQueue[0] < this.MAXIMUM_TIME_IN_QUEUE) {
return;
}
for (let queueIndex = this._reportQueue.length; queueIndex != 0; queueIndex--) {
if (time - this._reportQueue[queueIndex - 1] > this.MAXIMUM_TIME_IN_QUEUE) {
this._reportQueue = this._reportQueue.slice(queueIndex);
return;
}
}
}
}
class BacktraceCoreClient extends Events {
/**
* Backtrace client instance
*/
static _instance;
/**
* Determines if the client is enabled.
*/
get enabled() {
return this._enabled;
}
/**
* Current session id
*/
get sessionId() {
return this._sessionProvider.sessionId;
}
/**
* Backtrace SDK name
*/
get agent() {
return this._sdkOptions.agent;
}
/**
* Backtrace SDK version
*/
get agentVersion() {
return this._sdkOptions.agentVersion;
}
/**
* Available cached client attributes
*/
get attributes() {
return this.attributeManager.get().attributes;
}
/**
* Available cached client annotatations
*/
get annotations() {
return this.attributeManager.get().annotations;
}
get metrics() {
return this._modules.get(BacktraceMetrics);
}
get breadcrumbs() {
return this._modules.get(BreadcrumbsManager);
}
get database() {
return this._modules.get(BacktraceDatabase);
}
/**
* Client cached attachments
*/
get attachments() {
return this.attachmentManager.get();
}
/**
* Modules used by the client
*/
get modules() {
return this._modules;
}
get sessionFiles() {
return this._modules.get(SessionFiles);
}
options;
attributeManager;
attachmentManager;
fileSystem;
_modules = new Map();
_dataBuilder;
_reportSubmission;
_rateLimitWatcher;
_sessionProvider;
_sdkOptions;
_requestHandler;
_enabled = false;
constructor(setup) {
super();
this.options = setup.options;
this.fileSystem = setup.fileSystem;
this._sdkOptions = setup.sdkOptions;
this._sessionProvider = setup.sessionProvider ?? new SingleSessionProvider();
this._reportSubmission =
setup.reportSubmission ?? new RequestBacktraceReportSubmission(this.options, setup.requestHandler);
this._rateLimitWatcher = new RateLimitWatcher(this.options.rateLimit);
this._requestHandler = setup.requestHandler;
const attributeProviders = [
new ClientAttributeProvider(this.agent, this.agentVersion, this._sessionProvider.sessionId),
];
if (setup.attributeProviders) {
attributeProviders.push(...setup.attributeProviders);
}
if (this.options.userAttributes) {
attributeProviders.push(new UserAttributeProvider(this.options.userAttributes));
}
this.attributeManager = new AttributeManager(attributeProviders);
this.attachmentManager = new AttachmentManager();
if (this.options.attachments) {
this.attachmentManager.add(...this.options.attachments);
}
const stackTraceConverter = setup.stackTraceConverter ?? new V8StackTraceConverter();
this._dataBuilder = new BacktraceDataBuilder(this._sdkOptions, stackTraceConverter, this.attributeManager, new DebugIdProvider(stackTraceConverter, setup.debugIdMapProvider));
if (this.options?.database?.enable === true && setup.fileSystem) {
const provider = BacktraceDatabaseFileStorageProvider.createIfValid(setup.fileSystem, this.options.database);
if (this.fileSystem) {
const sessionFiles = new SessionFiles(this.fileSystem, this.options.database.path, this.sessionId, this.options.database.maximumOldSessions ?? 1);
this._modules.set(SessionFiles, sessionFiles);
}
if (provider) {
const database = new BacktraceDatabase(this.options.database, provider, this._reportSubmission, this.sessionFiles);
this._modules.set(BacktraceDatabase, database);
}
}
const metrics = new MetricsBuilder(this.options, this._sessionProvider, this.attributeManager, setup.requestHandler).build(setup.uniqueMetricsQueue, setup.summedMetricsQueue);
if (metrics) {
this._modules.set(BacktraceMetrics, metrics);
}
if (this.options.breadcrumbs?.enable !== false) {
const breadcrumbsManager = new BreadcrumbsManager(this.options?.breadcrumbs, setup.breadcrumbsSetup);
this._modules.set(BreadcrumbsManager, breadcrumbsManager);
}
if (setup.modules) {
for (const module of setup.modules) {
this.addModule(module);
}
}
}
initialize() {
this.validateAttributes();
if (this.fileSystem && this.options.database?.createDatabaseDirectory) {
if (!this.options.database.path) {
throw new Error('Missing mandatory path to the database. Please define the database.path option in the configuration.');
}
this.fileSystem.createDirSync(this.options.database?.path);
}
for (const module of this._modules.values()) {
if (module.bind) {
module.bind(this.getModuleBindData());
}
if (module.initialize) {
module.initialize();
}
}
this.sessionFiles?.clearPreviousSessions();
this._enabled = true;
BacktraceCoreClient._instance = this;
}
addAttribute(attributes) {
this.attributeManager.add(attributes);
}
addAttachment(...attachments) {
this.attachmentManager.add(...attachments);
}
// This function CANNOT be an async function due to possible async state machine stack frame inclusion, which breaks the skip stacks
send(data, reportAttributesOrAbortSignal, reportAttachments = [], abortSignal) {
if (!this._enabled) {
return Promise.resolve(BacktraceReportSubmissionResult.SdkDisabled());
}
if (this._rateLimitWatcher.skipReport()) {
return Promise.resolve(BacktraceReportSubmissionResult.OnLimitReached('Client'));
}
// If data is BacktraceReport, we know that the second argument should be only AbortSignal
const reportAttributes = !this.isReport(data)
? reportAttributesOrAbortSignal
: undefined;
// If data is BacktraceReport, we know that the second argument should be only AbortSignal
abortSignal = !this.isReport(data) ? abortSignal : reportAttributesOrAbortSignal;
const report = this.isReport(data)
? data
: new BacktraceReport(data, reportAttributes, [], {
skipFrames: this.skipFrameOnMessage(data),
});
this.emit('before-skip', report);
if (this.options.skipReport && this.options.skipReport(report)) {
return Promise.resolve(BacktraceReportSubmissionResult.ReportSkipped());
}
const backtraceData = this.generateSubmissionData(report);
if (!backtraceData) {
return Promise.resolve(BacktraceReportSubmissionResult.ReportSkipped());
}
const submissionAttachments = this.generateSubmissionAttachments(report, reportAttachments);
this.emit('before-send', report, backtraceData, submissionAttachments);
return this._reportSubmission
.send(backtraceData, submissionAttachments, abortSignal)
.then((submissionResult) => {
this.emit('after-send', report, backtraceData, submissionAttachments, submissionResult);
return submissionResult;
});
}
/**
* Disposes the client and all client callbacks
*/
dispose() {
this._enabled = false;
for (const module of this._modules.values()) {
if (module.dispose) {
module.dispose();
}
}
BacktraceCoreClient.destroy();
}
addModule(typeOrModule, module) {
let type;
if (typeof typeOrModule === 'function') {
type = typeOrModule;
}
else {
module = typeOrModule;
type = Object.getPrototypeOf(module);
}
if (!module) {
throw new Error('Module implementation is required.');
}
this._modules.set(type, module);
if (this._enabled) {
module.bind && module.bind(this.getModuleBindData());
module.initialize && module.initialize();
}
}
generateSubmissionData(report) {
const backtraceData = this._dataBuilder.build(report);
if (!this.options.beforeSend) {
return backtraceData;
}
return this.options.beforeSend(backtraceData);
}
generateSubmissionAttachments(report, reportAttachments) {
return [...this.attachmentManager.get(), ...(report.attachments ?? []), ...(reportAttachments ?? [])];
}
skipFrameOnMessage(data) {
return typeof data === 'string' ? 1 : 0;
}
isReport(data) {
return data instanceof BacktraceReport;
}
getModuleBindData() {
return {
client: this,
options: this.options,
attributeManager: this.attributeManager,
attachmentManager: this.attachmentManager,
reportSubmission: this._reportSubmission,
requestHandler: this._requestHandler,
database: this.database,
sessionFiles: this.sessionFiles,
fileSystem: this.fileSystem,
};
}
static destroy() {
this._instance = undefined;
}
validateAttributes() {
function validateApplicationAndVersion(attributes) {
if (!attributes['application'] || !attributes['application.version']) {
return 'application and application.version attributes must be defined.';
}
}
// Validate scoped attributes first. If they return no errors, there is no point
// in checking all attributes, which resolving of may be slower.
const scoped = this.attributeManager.get('scoped');
const scopedError = validateApplicationAndVersion(scoped.attributes);
if (!scopedError) {
return;
}
const allAttributes = this.attributeManager.get();
const error = validateApplicationAndVersion(allAttributes.attributes);
if (!error) {
return;
}
throw new Error(error);
}
}
class BacktraceCoreClientBuilder {
clientSetup;
constructor(clientSetup) {
this.clientSetup = clientSetup;
}
addAttributeProvider(provider) {
if (!this.clientSetup.attributeProviders) {
this.clientSetup.attributeProviders = [provider];
}
else {
this.clientSetup.attributeProviders.push(provider);
}
return this;
}
useBreadcrumbSubscriber(breadcrumbSubscriber) {
if (!this.clientSetup.breadcrumbsSetup) {
this.clientSetup.breadcrumbsSetup = {};
}
if (!this.clientSetup.breadcrumbsSetup.subscribers) {
this.clientSetup.breadcrumbsSetup.subscribers = [breadcrumbSubscriber];
}
else {
this.clientSetup.breadcrumbsSetup.subscribers.push(breadcrumbSubscriber);
}
return this;
}
useBreadcrumbsStorage(storage) {
if (!this.clientSetup.breadcrumbsSetup) {
this.clientSetup.breadcrumbsSetup = {};
}
if (typeof storage === 'function') {
this.clientSetup.breadcrumbsSetup.storage = storage;
}
else {
this.clientSetup.breadcrumbsSetup.storage = () => storage;
}
return this;
}
useSessionProvider(sessionProvider) {
this.clientSetup.sessionProvider = sessionProvider;
return this;
}
useRequestHandler(handler) {
this.clientSetup.requestHandler = handler;
return this;
}
useStackTraceConverter(stackTraceConverter) {
this.clientSetup.stackTraceConverter = stackTraceConverter;
return this;
}
useFileSystem(fileSystem) {
this.clientSetup.fileSystem = fileSystem;
return this;
}
useReportSubmission(reportSubmission) {
this.clientSetup.reportSubmission = reportSubmission;
return this;
}
useSummedMetricsQueue(queue) {
this.clientSetup.summedMetricsQueue = queue;
return this;
}
useUniqueMetricsQueue(queue) {
this.clientSetup.uniqueMetricsQueue = queue;
return this;
}
useModule(module) {
if (!this.clientSetup.modules) {
this.clientSetup.modules = [module];
}
else {
this.clientSetup.modules.push(module);
}
return this;
}
}
class BacktraceStringAttachment {
name;
data;
constructor(name, data) {
this.name = name;
this.data = data;
}
get() {
return this.data;
}
}
class BacktraceUint8ArrayAttachment {
name;
data;
constructor(name, data) {
this.name = name;
this.data = data;
}
get() {
return new Blob([this.data.buffer]);
}
}
const ATTRIBUTE_FILE_NAME = 'bt-attributes';
class FileAttributeManager {
_fileSystem;
_fileName;
_attributeManager;
constructor(_fileSystem, _fileName) {
this._fileSystem = _fileSystem;
this._fileName = _fileName;
}
static create(fileSystem) {
return new FileAttributeManager(fileSystem);
}
static createFromSession(sessionFiles, fileSystem) {
const fileName = sessionFiles.getFileName(ATTRIBUTE_FILE_NAME);
return new FileAttributeManager(fileSystem, fileName);
}
initialize() {
this.saveAttributes();
}
bind({ attributeManager, sessionFiles }) {
if (this._fileName) {
throw new Error('This instance is already bound.');
}
if (!sessionFiles) {
return;
}
this._fileName = sessionFiles.getFileName(ATTRIBUTE_FILE_NAME);
this._attributeManager = attributeManager;
attributeManager.attributeEvents.on('scoped-attributes-updated', () => this.saveAttributes());
}
dispose() {
this._fileName = undefined;
}
async get() {
if (!this._fileName) {
return {};
}
try {
const content = await this._fileSystem.readFile(this._fileName);
return JSON.parse(content);
}
catch {
return {};
}
}
async saveAttributes() {
if (!this._fileName || !this._attributeManager) {
return;
}
const reportData = this._attributeManager.get('scoped');
await this._fileSystem.writeFile(this._fileName, JSON.stringify(reportData.attributes, jsonEscaper()));
}
}
const SESSION_MARKER_PREFIX = 'bt-session';
const isDefined = (t) => !!t;
class SessionFiles {
_fileSystem;
_directory;
sessionId;
_maxPreviousLockedSessions;
timestamp;
_lockable;
marker;
_events = new Events();
_escapedSessionId;
_locks = new Set();
_previousSession;
_cleared = false;
constructor(_fileSystem, _directory, sessionId, _maxPreviousLockedSessions = 1, timestamp = Date.now(), _lockable = true) {
this._fileSystem = _fileSystem;
this._directory = _directory;
this.sessionId = sessionId;
this._maxPreviousLockedSessions = _maxPreviousLockedSessions;
this.timestamp = timestamp;
this._lockable = _lockable;
this._escapedSessionId = this.escapeFileName(sessionId);
this.marker = this.getFileName(SESSION_MARKER_PREFIX);
}
initialize() {
this.createSessionMarker();
}
getPreviousSession() {
if (this._previousSession) {
return this._previousSession;
}
const directoryFiles = this.readDirectoryFiles();
if (!directoryFiles.length) {
return undefined;
}
const sessionMarkers = directoryFiles
.filter((f) => f.startsWith(SESSION_MARKER_PREFIX))
.map((f) => this.getFileSession(f))
.filter(isDefined)
.sort((a, b) => b.timestamp - a.timestamp);
const currentSessionMarker = sessionMarkers.find((s) => s.sessionId === this.sessionId);
const lastSessionMarker = currentSessionMarker
? sessionMarkers.filter(({ timestamp }) => currentSessionMarker.timestamp > timestamp)[0]
: sessionMarkers[0];
if (!lastSessionMarker) {
return undefined;
}
return (this._previousSession = new SessionFiles(this._fileSystem, this._directory, lastSessionMarker.sessionId, this._maxPreviousLockedSessions - 1, lastSessionMarker.timestamp, this._maxPreviousLockedSessions > 0));
}
getSessionWithId(sessionId) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
let session = this;
while (session && session.sessionId !== sessionId) {
session = session.getPreviousSession();
}
return session;
}
getPreviousSessions(count = Infinity) {
const result = [];
let current = this.getPreviousSession();
while (current && count > 0) {
result.push(current);
count--;
current = current.getPreviousSession();
}
return result;
}
lockPreviousSessions(lockId) {
lockId = lockId ?? IdGenerator.uuid();
for (const session of this.getPreviousSessions(this._maxPreviousLockedSessions)) {
session.lock(lockId);
}
return lockId;
}
unlockPreviousSessions(lockId) {
for (const session of this.getPreviousSessions(this._maxPreviousLockedSessions)) {
session.unlock(lockId);
}
}
clearPreviousSessions() {
for (const session of this.getPreviousSessions()) {
session.clearSession();
}
}
getFileName(prefix) {
this.throwIfCleared();
return this._directory + '/' + `${this.escapeFileName(prefix)}_${this._escapedSessionId}_${this.timestamp}`;
}
getSessionFiles() {
this.throwIfCleared();
const files = this.readDirectoryFiles();
return files
.map((file) => this.getFileSession(file))
.filter(isDefined)
.filter(({ sessionId }) => sessionId === this.sessionId)
.map(({ file }) => this._directory + '/' + file);
}
clearSession() {
if (this._locks.size > 0) {
this._events.once('unlocked', () => this.clearSession());
return;
}
if (this._cleared) {
return;
}
try {
const sessionFiles = this.getSessionFiles();
for (const file of sessionFiles) {
this._fileSystem.unlinkSync(file);
}
}
catch {
// Don't propagate errors
}
finally {
this._cleared = true;
}
}
lock(lockId) {
if (this._cleared || !this._lockable) {
return;
}
lockId = lockId ?? IdGenerator.uuid();
this._locks.add(lockId);
return lockId;
}
unlock(lockId) {
this._locks.delete(lockId);
if (this._locks.size === 0) {
this._events.emit('unlocked');
}
}
getFileSession(file) {
const [escapedSessionId, rawTimestamp] = this.splitByOneChar(file, '_').slice(-2);
const timestamp = parseInt(rawTimestamp);
if (isNaN(timestamp)) {
return undefined;
}
return { file, escapedSessionId, timestamp, sessionId: this.unescapeFileName(escapedSessionId) };
}
readDirectoryFiles() {
try {
return this._fileSystem.readDirSync(this._directory);
}
catch {
return [];
}
}
createSessionMarker() {
this._fileSystem.writeFileSync(this.marker, '');
}
escapeFileName(name) {
return name.replace(/_/g, '__');
}
unescapeFileName(name) {
return name.replace(/__/g, '_');
}
splitByOneChar(str, char) {
const result = [];
let start = 0;
let index = str.indexOf(char);
while (index !== -1) {
if (str[index + 1] === char) {
index = str.indexOf(char, index + 2);
}
else {
result.push(str.substring(start, index));
start = index + 1;
index = str.indexOf(char, start);
}
}
result.push(str.substring(start));
return result;
}
throwIfCleared() {
if (this._cleared) {
throw new Error('This session files are cleared.');
}
}
}
const SOURCE_DEBUG_ID_VARIABLE = '_btDebugIds';
class DebugIdProvider {
_stackTraceConverter;
_debugIdMapProvider;
_fileDebugIds;
constructor(_stackTraceConverter, _debugIdMapProvider) {
this._stackTraceConverter = _stackTraceConverter;
this._debugIdMapProvider = _debugIdMapProvider;
this._fileDebugIds = this.loadDebugIds();
}
getDebugId(file) {
const debugId = this._fileDebugIds[file];
if (debugId) {
return debugId;
}
// in case of dynamic require - lazy load dynamically debug ids
this._fileDebugIds = this.loadDebugIds();
return this._fileDebugIds[file];
}
loadDebugIds() {
const debugIdMap = this._debugIdMapProvider?.getDebugIdMap();
if (!debugIdMap) {
return {};
}
const message = new Error().message;
const result = {};
for (const [rawStack, debugId] of Object.entries(debugIdMap)) {
const frames = this._stackTraceConverter.convert(rawStack, message);
if (!frames.length) {
continue;
}
// The first frame will have the file's path
const frame = frames[0];
result[frame.library] = debugId;
}
return result;
}
}
class VariableDebugIdMapProvider {
_variable;
constructor(_variable) {
this._variable = _variable;
}
getDebugIdMap() {
return this._variable[SOURCE_DEBUG_ID_VARIABLE] ?? {};
}
}
exports.ANONYMOUS_FUNCTION = ANONYMOUS_FUNCTION;
exports.AttachmentManager = AttachmentManager;
exports.BacktraceCoreApi = BacktraceCoreApi;
exports.BacktraceCoreClient = BacktraceCoreClient;
exports.BacktraceCoreClientBuilder = BacktraceCoreClientBuilder;
exports.BacktraceDatabase = BacktraceDatabase;
exports.BacktraceReport = BacktraceReport;
exports.BacktraceReportSubmissionResult = BacktraceReportSubmissionResult;
exports.BacktraceStringAttachment = BacktraceStringAttachment;
exports.BacktraceUint8ArrayAttachment = BacktraceUint8ArrayAttachment;
exports.BreadcrumbsManager = BreadcrumbsManager;
exports.ConnectionError = ConnectionError;
exports.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
exports.DebugIdProvider = DebugIdProvider;
exports.FileAttributeManager = FileAttributeManager;
exports.IdGenerator = IdGenerator;
exports.OverwritingArray = OverwritingArray;
exports.RequestBacktraceReportSubmission = RequestBacktraceReportSubmission;
exports.SOURCE_DEBUG_ID_VARIABLE = SOURCE_DEBUG_ID_VARIABLE;
exports.SessionFiles = SessionFiles;
exports.SingleSessionProvider = SingleSessionProvider;
exports.SubmissionUrlInformation = SubmissionUrlInformation;
exports.SummedEvent = SummedEvent;
exports.TimeHelper = TimeHelper;
exports.UNKNOWN_FRAME = UNKNOWN_FRAME;
exports.V8StackTraceConverter = V8StackTraceConverter;
exports.VariableDebugIdMapProvider = VariableDebugIdMapProvider;
exports.anySignal = anySignal;
exports.defaultBreadcrumbsLogLevel = defaultBreadcrumbsLogLevel;
exports.defaultBreadcurmbType = defaultBreadcurmbType;
exports.jsonEscaper = jsonEscaper;
//# sourceMappingURL=bundle.cjs.map