diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index 71113d98..45384c35 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -48,7 +48,7 @@ export class BacktraceClient extends BacktraceCoreClient FileBreadcrumbsStorage.create( this.sessionFiles, fileSystem, - clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100, + (clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100) || 100, ), ); } diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index a7bc7004..fe076eff 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -59,7 +59,7 @@ export class BacktraceClient extends BacktraceCoreClient FileBreadcrumbsStorage.create( fileSystem, this.sessionFiles, - clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100, + (clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100) || 100, ), ); } diff --git a/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts b/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts index 47abf2ff..cf81b4f4 100644 --- a/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts +++ b/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts @@ -2,7 +2,11 @@ import { BacktraceReportSubmission } from '../model/http/BacktraceReportSubmissi import { BacktraceRequestHandler } from '../model/http/BacktraceRequestHandler.js'; import { BacktraceModule } from '../modules/BacktraceModule.js'; import { BacktraceAttributeProvider } from '../modules/attribute/BacktraceAttributeProvider.js'; -import { BreadcrumbsEventSubscriber, BreadcrumbsStorage } from '../modules/breadcrumbs/index.js'; +import { + BreadcrumbsEventSubscriber, + BreadcrumbsStorage, + BreadcrumbsStorageFactory, +} from '../modules/breadcrumbs/index.js'; import { BacktraceStackTraceConverter } from '../modules/converter/index.js'; import { BacktraceSessionProvider } from '../modules/metrics/BacktraceSessionProvider.js'; import { MetricsQueue } from '../modules/metrics/MetricsQueue.js'; @@ -39,12 +43,22 @@ export abstract class BacktraceCoreClientBuilder storage; + } + return this; } diff --git a/packages/sdk-core/src/common/jsonEscaper.ts b/packages/sdk-core/src/common/jsonEscaper.ts index c07ed8cf..60130609 100644 --- a/packages/sdk-core/src/common/jsonEscaper.ts +++ b/packages/sdk-core/src/common/jsonEscaper.ts @@ -5,12 +5,10 @@ export function jsonEscaper() { // in TypeScript add "this: any" param to avoid compliation errors - as follows // return function (this: any, field: any, value: any) { return function (this: unknown, key: string, value: unknown) { - if (!key) { - return value; - } if (value === null) { return value; } + const valueType = typeof value; if (valueType === 'bigint') { @@ -28,7 +26,7 @@ export function jsonEscaper() { keys.pop(); } if (ancestors.includes(value)) { - return `[Circular].${keys.join('.')}.${key}`; + return `[Circular].${keys.filter((k) => !!k).join('.')}.${key}`; } keys.push(key); ancestors.push(value); diff --git a/packages/sdk-core/src/common/jsonSize.ts b/packages/sdk-core/src/common/jsonSize.ts new file mode 100644 index 00000000..ac857d11 --- /dev/null +++ b/packages/sdk-core/src/common/jsonSize.ts @@ -0,0 +1,137 @@ +type JsonReplacer = (this: unknown, key: string, value: unknown) => unknown; + +function stringifiedSize(value: T): number { + return JSON.stringify(value).length; +} + +function toStringSize(value: T): number { + return value.toString().length; +} + +const stringSize = (value: string) => stringifiedSize(value); +const numberSize = toStringSize; +const bigintSize = toStringSize; +const symbolSize = 0; +const functionSize = 0; +const booleanSize = (value: boolean) => (value ? 4 : 5); +const undefinedSize = 0; +const nullSize = 'null'.length; + +function arraySize(array: unknown[], replacer?: JsonReplacer): number { + 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: object, replacer?: JsonReplacer): number => { + 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: unknown): number { + 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: unknown, key: string, value: unknown, replacer?: JsonReplacer): number { + if (value && typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') { + value = value.toJSON() as object; + } + + 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. + */ +export function jsonSize(value: unknown, replacer?: JsonReplacer): number { + return _jsonSize(undefined, '', value, replacer); +} diff --git a/packages/sdk-core/src/common/limitObjectDepth.ts b/packages/sdk-core/src/common/limitObjectDepth.ts new file mode 100644 index 00000000..04701ff9 --- /dev/null +++ b/packages/sdk-core/src/common/limitObjectDepth.ts @@ -0,0 +1,30 @@ +type DeepPartial = Partial<{ [K in keyof T]: T[K] extends object ? DeepPartial : T[K] }>; + +const REMOVED_PLACEHOLDER = ''; + +export type Limited = DeepPartial | typeof REMOVED_PLACEHOLDER; + +export function limitObjectDepth(obj: T, depth: number): Limited { + if (!(depth < Infinity)) { + return obj; + } + + if (depth < 0) { + return REMOVED_PLACEHOLDER; + } + + const limitIfObject = (value: unknown) => + typeof value === 'object' && value ? limitObjectDepth(value, depth - 1) : value; + + const result: DeepPartial = {}; + for (const key in obj) { + const value = obj[key]; + if (Array.isArray(value)) { + result[key] = value.map(limitIfObject) as never; + } else { + result[key] = limitIfObject(value) as never; + } + } + + return result; +} diff --git a/packages/sdk-core/src/dataStructures/OverwritingArray.ts b/packages/sdk-core/src/dataStructures/OverwritingArray.ts index 18bcde3b..ba8f4ca7 100644 --- a/packages/sdk-core/src/dataStructures/OverwritingArray.ts +++ b/packages/sdk-core/src/dataStructures/OverwritingArray.ts @@ -1,47 +1,120 @@ -import { OverwritingArrayIterator } from './OverwritingArrayIterator.js'; +import { ConstrainedNumber, clamped, wrapped } from './numbers.js'; export class OverwritingArray { private _array: T[]; - private _index = 0; - private _size = 0; - private _startIndex = 0; - constructor(public readonly capacity: number) { - this._array = this.createArray(); + + private readonly _headConstraint: ConstrainedNumber; + private readonly _lengthConstraint: ConstrainedNumber; + + private _head = 0; + private _length = 0; + + private get head() { + return this._head; + } + + private set head(value: number) { + this._head = this._headConstraint(value); } - public add(value: T): this { - this._array[this._index] = value; - this._index = this.incrementIndex(this._index); - this._startIndex = this.incrementStartingIndex(); - this._size = this.incrementSize(); - return this; + + public get length() { + return this._length; } - public clear(): void { - this._array = this.createArray(); + public set length(value: number) { + this._length = this._lengthConstraint(value); } - public values(): IterableIterator { - return new OverwritingArrayIterator(this._array, this._startIndex, this._size); + private get start() { + return this._headConstraint(this.head - this.length); } - [Symbol.iterator](): IterableIterator { - return new OverwritingArrayIterator(this._array, this._startIndex, this._size); + constructor( + public readonly capacity: number, + items?: T[], + ) { + 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); + } } - private incrementIndex(index: number) { - return (index + 1) % this.capacity; + public add(item: T) { + return this.pushOne(item); } - private incrementStartingIndex() { - if (this._size !== this.capacity) { - return this._startIndex; + public push(...items: T[]): number { + for (const item of items) { + this.pushOne(item); } - return this.incrementIndex(this._startIndex); + return this.length; + } + + public pop(): T | undefined { + this.head--; + const element = this._array[this.head]; + this._array[this.head] = undefined as never; + this.length--; + return element; } - private incrementSize() { - return Math.min(this.capacity, this._size + 1); + + public shift(): T | undefined { + const element = this._array[this.start]; + this._array[this.start] = undefined as never; + this.length--; + return element; + } + + public at(index: number): T | undefined { + return this._array[this.index(index)]; } - private createArray() { - return new Array(this.capacity); + + public *values(): IterableIterator { + for (let i = 0; i < this.length; i++) { + const index = this.index(i); + yield this._array[index]; + } + } + + public *keys(): IterableIterator { + for (let i = 0; i < this.length; i++) { + yield i; + } + } + + public *entries(): IterableIterator<[number, T]> { + for (let i = 0; i < this.length; i++) { + const index = this.index(i); + yield [i, this._array[index]]; + } + } + + public [Symbol.iterator]() { + return this.values(); + } + + private pushOne(item: T) { + this._array[this.head] = item; + this.head++; + this.length++; + } + + private index(value: number) { + if (!this.length) { + return this._headConstraint(value); + } + + const index = (value % this.length) + this.start; + return this._headConstraint(index); } } diff --git a/packages/sdk-core/src/dataStructures/OverwritingArrayIterator.ts b/packages/sdk-core/src/dataStructures/OverwritingArrayIterator.ts deleted file mode 100644 index b0f9abc8..00000000 --- a/packages/sdk-core/src/dataStructures/OverwritingArrayIterator.ts +++ /dev/null @@ -1,35 +0,0 @@ -export class OverwritingArrayIterator implements IterableIterator { - private _index?: number; - - constructor( - private readonly _source: T[], - private readonly _offset: number, - private readonly _size: number, - ) {} - - [Symbol.iterator](): IterableIterator { - return new OverwritingArrayIterator(this._source, this._offset, this._size); - } - next(): IteratorResult { - if (this._size === 0) { - return { - done: true, - value: undefined, - }; - } - if (this._index === undefined) { - this._index = 0; - } else if (this._index === this._size - 1) { - return { - done: true, - value: undefined, - }; - } else { - this._index++; - } - return { - done: false, - value: this._source[(this._index + this._offset) % this._size], - }; - } -} diff --git a/packages/sdk-core/src/dataStructures/index.ts b/packages/sdk-core/src/dataStructures/index.ts index 1fd524a8..10fcc037 100644 --- a/packages/sdk-core/src/dataStructures/index.ts +++ b/packages/sdk-core/src/dataStructures/index.ts @@ -1,2 +1 @@ export * from './OverwritingArray.js'; -export * from './OverwritingArrayIterator.js'; diff --git a/packages/sdk-core/src/dataStructures/numbers.ts b/packages/sdk-core/src/dataStructures/numbers.ts new file mode 100644 index 00000000..22b636c1 --- /dev/null +++ b/packages/sdk-core/src/dataStructures/numbers.ts @@ -0,0 +1,56 @@ +export type ConstrainedNumber = (value: number) => number; + +/** + * 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 + */ +export function wrapped(min: number, max: number): ConstrainedNumber { + function wrapped(value: number) { + const range = max - min; + let newValue: number; + 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 + */ +export function clamped(min: number, max: number): ConstrainedNumber { + function clamped(value: number) { + return Math.max(min, Math.min(value, max)); + } + + return clamped; +} diff --git a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts index 5f28335a..afa782fa 100644 --- a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts +++ b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts @@ -47,8 +47,46 @@ export interface BacktraceBreadcrumbsSettings { /** * Specifies maximum number of breadcrumbs stored by the library. By default, only 100 breadcrumbs * will be stored. + * + * Use `false` to disable the limit. + * @default 100 + */ + maximumBreadcrumbs?: number | false; + + /** + * Specifies maximum object depth that are included in breadcrumb attributes. + * + * Use `false` to disable the limit. + * @default 2 + */ + maximumAttributesDepth?: number | false; + + /** + * Specifies maximum breadcrumb message length. + * If the size is exceeded, message will be truncated. + * + * Use `false` to disable the limit. + * @default 255 + */ + maximumBreadcrumbMessageLength?: number | false; + + /** + * Specifies maximum single breadcrumb size in bytes. + * If the size is exceeded, the breadcrumb will be skipped. + * + * Use `false` to disable the limit. + * @default 65536 // 64kB + */ + maximumBreadcrumbSize?: number | false; + + /** + * Specifies maximum breadcrumbs size in bytes. + * If the size is exceeded, oldest breadcrumbs will be skipped. + * + * Use `false` to disable the limit. + * @default 1048576 // 1MB */ - maximumBreadcrumbs?: number; + maximumTotalBreadcrumbsSize?: number | false; /** * Inspects breadcrumb and allows to modify it. If the undefined value is being diff --git a/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts b/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts index f0a01c10..f57d0dd5 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts @@ -1,4 +1,6 @@ import { jsonEscaper } from '../../common/jsonEscaper.js'; +import { jsonSize } from '../../common/jsonSize.js'; +import { limitObjectDepth } from '../../common/limitObjectDepth.js'; import { BacktraceBreadcrumbsSettings } from '../../model/configuration/BacktraceConfiguration.js'; import { AttributeType } from '../../model/data/BacktraceData.js'; import { BacktraceReport } from '../../model/report/BacktraceReport.js'; @@ -10,15 +12,24 @@ import { BreadcrumbLogLevel, BreadcrumbsSetup, BreadcrumbsStorage, + BreadcrumbsStorageFactory, BreadcrumbType, defaultBreadcrumbsLogLevel, defaultBreadcurmbType, } from './index.js'; -import { RawBreadcrumb } from './model/RawBreadcrumb.js'; +import { BreadcrumbLimits } from './model/BreadcrumbLimits.js'; +import { LimitedRawBreadcrumb, RawBreadcrumb } from './model/RawBreadcrumb.js'; import { InMemoryBreadcrumbsStorage } from './storage/InMemoryBreadcrumbsStorage.js'; const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId'; +/** + * @returns `undefined` if value is `false`, else `value` if defined, else `defaultValue` + */ +const defaultIfNotFalse = (value: T | false, defaultValue: T) => { + return value === false ? undefined : value !== undefined ? value : defaultValue; +}; + export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule { /** * Breadcrumbs type @@ -34,14 +45,23 @@ export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule */ private _enabled = false; + private readonly _limits: BreadcrumbLimits; private readonly _eventSubscribers: BreadcrumbsEventSubscriber[] = [new ConsoleEventSubscriber()]; private readonly _interceptor?: (breadcrumb: RawBreadcrumb) => RawBreadcrumb | undefined; private _storage: BreadcrumbsStorage; constructor(configuration?: BacktraceBreadcrumbsSettings, options?: BreadcrumbsSetup) { + 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 ?? new InMemoryBreadcrumbsStorage(configuration?.maximumBreadcrumbs); + this._storage = (options?.storage ?? InMemoryBreadcrumbsStorage.factory)({ limits: this._limits }); this._interceptor = configuration?.intercept; if (options?.subscribers) { this._eventSubscribers.push(...options.subscribers); @@ -55,8 +75,17 @@ export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule this._eventSubscribers.push(subscriber); } - public setStorage(storage: BreadcrumbsStorage) { - this._storage = storage; + public setStorage(storageFactory: BreadcrumbsStorageFactory): void; + /** + * @deprecated Use `useStorage` with `BreadcrumbsStorageFactory`. + */ + public setStorage(storage: BreadcrumbsStorage): void; + public setStorage(storage: BreadcrumbsStorage | BreadcrumbsStorageFactory) { + if (typeof storage === 'function') { + this._storage = storage({ limits: this._limits }); + } else { + this._storage = storage; + } } public dispose(): void { @@ -135,6 +164,7 @@ export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule type, attributes, }; + if (this._interceptor) { const interceptorBreadcrumb = this._interceptor(rawBreadcrumb); if (!interceptorBreadcrumb) { @@ -151,8 +181,30 @@ export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule return false; } - this._storage.add(rawBreadcrumb); - return true; + if (this._limits.maximumBreadcrumbMessageLength !== undefined) { + rawBreadcrumb.message = rawBreadcrumb.message.substring(0, this._limits.maximumBreadcrumbMessageLength); + } + + let limitedBreadcrumb: RawBreadcrumb | LimitedRawBreadcrumb; + 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; } /** diff --git a/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsSetup.ts b/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsSetup.ts index a068bb82..23afb968 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsSetup.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsSetup.ts @@ -1,7 +1,7 @@ import { BreadcrumbsEventSubscriber } from './events/BreadcrumbsEventSubscriber.js'; -import { BreadcrumbsStorage } from './storage/BreadcrumbsStorage.js'; +import { BreadcrumbsStorageFactory } from './storage/BreadcrumbsStorage.js'; export interface BreadcrumbsSetup { - storage?: BreadcrumbsStorage; + storage?: BreadcrumbsStorageFactory; subscribers?: BreadcrumbsEventSubscriber[]; } diff --git a/packages/sdk-core/src/modules/breadcrumbs/model/Breadcrumb.ts b/packages/sdk-core/src/modules/breadcrumbs/model/Breadcrumb.ts index ab2e908c..bf4b9076 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/model/Breadcrumb.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/model/Breadcrumb.ts @@ -1,3 +1,4 @@ +import { Limited } from '../../../common/limitObjectDepth.js'; import { AttributeType } from '../../../model/data/BacktraceData.js'; export interface Breadcrumb { @@ -6,5 +7,5 @@ export interface Breadcrumb { timestamp: number; level: string; type: string; - attributes?: Record; + attributes?: Limited>; } diff --git a/packages/sdk-core/src/modules/breadcrumbs/model/BreadcrumbLimits.ts b/packages/sdk-core/src/modules/breadcrumbs/model/BreadcrumbLimits.ts new file mode 100644 index 00000000..d4969731 --- /dev/null +++ b/packages/sdk-core/src/modules/breadcrumbs/model/BreadcrumbLimits.ts @@ -0,0 +1,39 @@ +export interface BreadcrumbLimits { + /** + * Specifies maximum number of breadcrumbs stored by the library. + * + * @default 100 + */ + readonly maximumBreadcrumbs?: number; + + /** + * Specifies maximum object depth that are included in breadcrumb attributes. + * + * @default 2 + */ + readonly maximumAttributesDepth?: number; + + /** + * Specifies maximum breadcrumb message length. + * If the size is exceeded, message will be truncated. + * + * @default 255 + */ + readonly maximumBreadcrumbMessageLength?: number; + + /** + * Specifies maximum single breadcrumb size in bytes. + * If the size is exceeded, the breadcrumb will be skipped. + * + * @default 65536 // 64kB + */ + readonly maximumBreadcrumbSize?: number; + + /** + * Specifies maximum breadcrumbs size in bytes. + * If the size is exceeded, oldest breadcrumbs will be skipped. + * + * @default 1048576 // 1MB + */ + readonly maximumTotalBreadcrumbsSize?: number; +} diff --git a/packages/sdk-core/src/modules/breadcrumbs/model/RawBreadcrumb.ts b/packages/sdk-core/src/modules/breadcrumbs/model/RawBreadcrumb.ts index 3454f344..bbf67d58 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/model/RawBreadcrumb.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/model/RawBreadcrumb.ts @@ -1,3 +1,4 @@ +import { Limited } from '../../../common/limitObjectDepth.js'; import { AttributeType } from '../../../model/data/index.js'; import { BreadcrumbLogLevel } from './BreadcrumbLogLevel.js'; import { BreadcrumbType } from './BreadcrumbType.js'; @@ -8,3 +9,10 @@ export interface RawBreadcrumb { type: BreadcrumbType; attributes?: Record; } + +export interface LimitedRawBreadcrumb { + message: string; + level: BreadcrumbLogLevel; + type: BreadcrumbType; + attributes?: Limited>; +} diff --git a/packages/sdk-core/src/modules/breadcrumbs/storage/BreadcrumbsStorage.ts b/packages/sdk-core/src/modules/breadcrumbs/storage/BreadcrumbsStorage.ts index cbbfb448..c9b65104 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/storage/BreadcrumbsStorage.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/storage/BreadcrumbsStorage.ts @@ -1,6 +1,26 @@ import { BacktraceAttachment } from '../../../model/attachment/index.js'; import { BacktraceAttachmentProvider } from '../../attachments/BacktraceAttachmentProvider.js'; -import { RawBreadcrumb } from '../model/RawBreadcrumb.js'; +import { LimitedRawBreadcrumb, RawBreadcrumb } from '../model/RawBreadcrumb.js'; + +export interface BreadcrumbsStorageOptions { + readonly limits: BreadcrumbsStorageLimits; +} + +export interface BreadcrumbsStorageLimits { + /** + * Specifies maximum number of breadcrumbs stored by the storage. By default, only 100 breadcrumbs + * will be stored. + */ + readonly maximumBreadcrumbs?: number; + + /** + * Specifies maximum breadcrumbs size in bytes. + * If the size is exceeded, oldest breadcrumbs will be skipped. + */ + readonly maximumTotalBreadcrumbsSize?: number; +} + +export type BreadcrumbsStorageFactory = (options: BreadcrumbsStorageOptions) => BreadcrumbsStorage; export interface BreadcrumbsStorage { /** @@ -12,7 +32,7 @@ export interface BreadcrumbsStorage { * Adds breadcrumb to the storage * @param rawBreadcrumb breadcrumb data */ - add(rawBreadcrumb: RawBreadcrumb): number; + add(rawBreadcrumb: RawBreadcrumb | LimitedRawBreadcrumb): number | undefined; /** * Gets attachments associated with this storage. diff --git a/packages/sdk-core/src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage.ts b/packages/sdk-core/src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage.ts index 74a0af1e..2aff27ad 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage.ts @@ -1,4 +1,5 @@ import { jsonEscaper } from '../../../common/jsonEscaper.js'; +import { jsonSize } from '../../../common/jsonSize.js'; import { TimeHelper } from '../../../common/TimeHelper.js'; import { OverwritingArray } from '../../../dataStructures/OverwritingArray.js'; import { BacktraceAttachment } from '../../../model/attachment/index.js'; @@ -7,7 +8,7 @@ import { Breadcrumb } from '../model/Breadcrumb.js'; import { BreadcrumbLogLevel } from '../model/BreadcrumbLogLevel.js'; import { BreadcrumbType } from '../model/BreadcrumbType.js'; import { RawBreadcrumb } from '../model/RawBreadcrumb.js'; -import { BreadcrumbsStorage } from './BreadcrumbsStorage.js'; +import { BreadcrumbsStorage, BreadcrumbsStorageLimits, BreadcrumbsStorageOptions } from './BreadcrumbsStorage.js'; export class InMemoryBreadcrumbsStorage implements BreadcrumbsStorage, BacktraceAttachment { public get lastBreadcrumbId(): number { @@ -20,9 +21,11 @@ export class InMemoryBreadcrumbsStorage implements BreadcrumbsStorage, Backtrace private _lastBreadcrumbId: number = TimeHelper.toTimestampInSec(TimeHelper.now()); private _breadcrumbs: OverwritingArray; + private _breadcrumbSizes: OverwritingArray; - constructor(maximumBreadcrumbs = 100) { - this._breadcrumbs = new OverwritingArray(maximumBreadcrumbs); + constructor(private readonly _limits: BreadcrumbsStorageLimits) { + this._breadcrumbs = new OverwritingArray(_limits.maximumBreadcrumbs ?? 100); + this._breadcrumbSizes = new OverwritingArray(this._breadcrumbs.capacity); } public getAttachments(): BacktraceAttachment[] { @@ -38,12 +41,16 @@ export class InMemoryBreadcrumbsStorage implements BreadcrumbsStorage, Backtrace ]; } + public static factory({ limits }: BreadcrumbsStorageOptions) { + return new InMemoryBreadcrumbsStorage(limits); + } + /** * Returns breadcrumbs in the JSON format * @returns Breadcrumbs JSON */ public get(): string { - return JSON.stringify([...this._breadcrumbs.values()], jsonEscaper()); + return JSON.stringify([...this._breadcrumbs], jsonEscaper()); } public add(rawBreadcrumb: RawBreadcrumb): number { @@ -63,6 +70,33 @@ export class InMemoryBreadcrumbsStorage implements BreadcrumbsStorage, Backtrace 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; } + + private 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; + } } diff --git a/packages/sdk-core/tests/breadcrumbs/InMemoryBreadcrumbsStorage.spec.ts b/packages/sdk-core/tests/breadcrumbs/InMemoryBreadcrumbsStorage.spec.ts new file mode 100644 index 00000000..eeeb1f73 --- /dev/null +++ b/packages/sdk-core/tests/breadcrumbs/InMemoryBreadcrumbsStorage.spec.ts @@ -0,0 +1,175 @@ +import { Breadcrumb, BreadcrumbLogLevel, BreadcrumbType, RawBreadcrumb } from '../../src'; +import { InMemoryBreadcrumbsStorage } from '../../src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage'; + +describe('InMemoryBreadcrumbsStorage', () => { + it('should return added breadcrumbs', () => { + const storage = new InMemoryBreadcrumbsStorage({ + maximumBreadcrumbs: 100, + }); + + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Info, + message: 'a', + type: BreadcrumbType.Manual, + attributes: { + foo: 'bar', + }, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Warning, + message: 'c', + type: BreadcrumbType.Navigation, + attributes: {}, + }, + ]; + + const expected: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'info', + message: 'a', + timestamp: expect.any(Number), + type: 'manual', + attributes: { + foo: 'bar', + }, + }, + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + { + id: expect.any(Number), + level: 'warning', + message: 'c', + timestamp: expect.any(Number), + type: 'navigation', + attributes: {}, + }, + ]; + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + const actual = JSON.parse(storage.get()); + expect(actual).toEqual(expected); + }); + + it('should return no more than maximumBreadcrumbs breadcrumbs', () => { + const storage = new InMemoryBreadcrumbsStorage({ + maximumBreadcrumbs: 2, + }); + + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Info, + message: 'a', + type: BreadcrumbType.Manual, + attributes: { + foo: 'bar', + }, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Warning, + message: 'c', + type: BreadcrumbType.Navigation, + attributes: {}, + }, + ]; + + const expected: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + { + id: expect.any(Number), + level: 'warning', + message: 'c', + timestamp: expect.any(Number), + type: 'navigation', + attributes: {}, + }, + ]; + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + const actual = JSON.parse(storage.get()); + expect(actual).toEqual(expected); + }); + + it('should return breadcrumbs up to the json size', () => { + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Info, + message: 'a', + type: BreadcrumbType.Manual, + attributes: { + foo: 'bar', + }, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Warning, + message: 'c', + type: BreadcrumbType.Navigation, + attributes: {}, + }, + ]; + + const expected: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + { + id: expect.any(Number), + level: 'warning', + message: 'c', + timestamp: expect.any(Number), + type: 'navigation', + attributes: {}, + }, + ]; + + const size = JSON.stringify(expected).length; + const storage = new InMemoryBreadcrumbsStorage({ + maximumBreadcrumbs: 100, + maximumTotalBreadcrumbsSize: size, + }); + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + const actual = JSON.parse(storage.get()); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/sdk-core/tests/breadcrumbs/breadcrumbsCreationTests.spec.ts b/packages/sdk-core/tests/breadcrumbs/breadcrumbsCreationTests.spec.ts index 5371f3b1..14de9983 100644 --- a/packages/sdk-core/tests/breadcrumbs/breadcrumbsCreationTests.spec.ts +++ b/packages/sdk-core/tests/breadcrumbs/breadcrumbsCreationTests.spec.ts @@ -4,7 +4,7 @@ import { InMemoryBreadcrumbsStorage } from '../../src/modules/breadcrumbs/storag describe('Breadcrumbs creation tests', () => { it('Last breadcrumb id attribute should be equal to last bredcrumb id in the array', () => { - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); storage.add({ level: BreadcrumbLogLevel.Info, message: 'test', type: BreadcrumbType.Manual }); const lastBreadcrumbId = storage.lastBreadcrumbId; @@ -14,8 +14,8 @@ describe('Breadcrumbs creation tests', () => { }); it('Each breadcrumb should have different id', () => { - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.info('test'); breadcrumbsManager.info('test2'); @@ -26,7 +26,7 @@ describe('Breadcrumbs creation tests', () => { }); it('Should update breadcrumb id every time after adding a breadcrumb', () => { - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); storage.add({ level: BreadcrumbLogLevel.Info, message: 'test', type: BreadcrumbType.Manual }); const breadcrumbId1 = storage.lastBreadcrumbId; @@ -38,8 +38,8 @@ describe('Breadcrumbs creation tests', () => { it('Should set expected breadcrumb message', () => { const message = 'test'; - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.info(message); const [breadcrumb] = JSON.parse(storage.get() as string); @@ -51,8 +51,8 @@ describe('Breadcrumbs creation tests', () => { const input = 1; const expectedBreadcrumbValueOutput = input.toString(); - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.info(input as unknown as string); const [breadcrumb] = JSON.parse(storage.get() as string); @@ -64,8 +64,8 @@ describe('Breadcrumbs creation tests', () => { const input = { foo: 1, bar: true, baz: undefined }; const expectedBreadcrumbValueOutput = JSON.stringify(input); - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.info(input as unknown as string); const [breadcrumb] = JSON.parse(storage.get() as string); @@ -77,8 +77,8 @@ describe('Breadcrumbs creation tests', () => { const input = null; const expectedBreadcrumbValueOutput = ''; - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.info(input as unknown as string); const [breadcrumb] = JSON.parse(storage.get() as string); @@ -89,8 +89,8 @@ describe('Breadcrumbs creation tests', () => { it('Should set expected breadcrumb level', () => { const message = 'test'; const level = BreadcrumbLogLevel.Warning; - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.log(message, level); const [breadcrumb] = JSON.parse(storage.get() as string); @@ -102,8 +102,8 @@ describe('Breadcrumbs creation tests', () => { const message = 'test'; const level = BreadcrumbLogLevel.Warning; const type = BreadcrumbType.Configuration; - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.addBreadcrumb(message, level, type); const [breadcrumb] = JSON.parse(storage.get() as string); @@ -115,8 +115,8 @@ describe('Breadcrumbs creation tests', () => { const message = 'test'; const level = BreadcrumbLogLevel.Warning; const attributes = { foo: 'bar', baz: 1 }; - const storage = new InMemoryBreadcrumbsStorage(100); - const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage }); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); + const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage }); breadcrumbsManager.initialize(); breadcrumbsManager.log(message, level, attributes); const [breadcrumb] = JSON.parse(storage.get() as string); diff --git a/packages/sdk-core/tests/breadcrumbs/breadcrumbsFilteringOptionsTests.spec.ts b/packages/sdk-core/tests/breadcrumbs/breadcrumbsFilteringOptionsTests.spec.ts index 00920178..ff1b2dbd 100644 --- a/packages/sdk-core/tests/breadcrumbs/breadcrumbsFilteringOptionsTests.spec.ts +++ b/packages/sdk-core/tests/breadcrumbs/breadcrumbsFilteringOptionsTests.spec.ts @@ -6,13 +6,13 @@ describe('Breadcrumbs filtering options tests', () => { describe('Event type tests', () => { it('Should filter out breadcrumbs based on the event type', () => { const message = 'test'; - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); const breadcrumbsManager = new BreadcrumbsManager( { eventType: BreadcrumbType.Configuration, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); @@ -26,13 +26,13 @@ describe('Breadcrumbs filtering options tests', () => { it('Should allow to add a breadcrumb with allowed event type', () => { const message = 'test'; const allowedBreadcrumbType = BreadcrumbType.Configuration; - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); const breadcrumbsManager = new BreadcrumbsManager( { eventType: allowedBreadcrumbType, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); @@ -47,13 +47,13 @@ describe('Breadcrumbs filtering options tests', () => { describe('Log level tests', () => { it('Should filter out breadcrumbs based on the log level', () => { const message = 'test'; - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); const breadcrumbsManager = new BreadcrumbsManager( { logLevel: BreadcrumbLogLevel.Error, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); @@ -67,13 +67,13 @@ describe('Breadcrumbs filtering options tests', () => { it('Should allow to add a breadcrumb with allowed log level', () => { const message = 'test'; const allowedLogLevel = BreadcrumbLogLevel.Debug; - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); const breadcrumbsManager = new BreadcrumbsManager( { logLevel: allowedLogLevel, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); @@ -123,13 +123,13 @@ describe('Breadcrumbs filtering options tests', () => { describe('Breadcrumbs overflow tests', () => { it('Should always store maximum breadcrumbs', () => { const maximumBreadcrumbs = 2; - const storage = new InMemoryBreadcrumbsStorage(maximumBreadcrumbs); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs }); const breadcrumbsManager = new BreadcrumbsManager( { maximumBreadcrumbs, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); @@ -151,13 +151,13 @@ describe('Breadcrumbs filtering options tests', () => { it('Should drop the oldest event to free up the space for the new one', () => { const maximumBreadcrumbs = 2; - const storage = new InMemoryBreadcrumbsStorage(maximumBreadcrumbs); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs }); const breadcrumbsManager = new BreadcrumbsManager( { maximumBreadcrumbs, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); diff --git a/packages/sdk-core/tests/breadcrumbs/breadcrumbsInterceptorTests.spec.ts b/packages/sdk-core/tests/breadcrumbs/breadcrumbsInterceptorTests.spec.ts index c8a70dff..b2c1dbd0 100644 --- a/packages/sdk-core/tests/breadcrumbs/breadcrumbsInterceptorTests.spec.ts +++ b/packages/sdk-core/tests/breadcrumbs/breadcrumbsInterceptorTests.spec.ts @@ -3,13 +3,13 @@ import { Breadcrumb } from '../../src/modules/breadcrumbs/model/Breadcrumb.js'; import { InMemoryBreadcrumbsStorage } from '../../src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage.js'; describe('Breadcrumbs interceptor tests', () => { it('Should filter out the breadcrumb', () => { - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); const breadcrumbsManager = new BreadcrumbsManager( { intercept: () => undefined, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); @@ -21,7 +21,7 @@ describe('Breadcrumbs interceptor tests', () => { it('Should remove pii information from breadcrumb', () => { const expectedBreadcrumbMessage = 'bar'; - const storage = new InMemoryBreadcrumbsStorage(100); + const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 }); const breadcrumbsManager = new BreadcrumbsManager( { intercept: (breadcrumb) => { @@ -30,7 +30,7 @@ describe('Breadcrumbs interceptor tests', () => { }, }, { - storage, + storage: () => storage, }, ); breadcrumbsManager.initialize(); diff --git a/packages/sdk-core/tests/common/jsonSize.spec.ts b/packages/sdk-core/tests/common/jsonSize.spec.ts new file mode 100644 index 00000000..09d16673 --- /dev/null +++ b/packages/sdk-core/tests/common/jsonSize.spec.ts @@ -0,0 +1,548 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { jsonEscaper } from '../../src/common/jsonEscaper'; +import { jsonSize } from '../../src/common/jsonSize'; + +describe('jsonSize', () => { + it('should compute string size', () => { + const value = 'foo\n\t\rbar""\'\'```123'; + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute number size', () => { + const value = 34534134.24875381; + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute boolean true size', () => { + const value = true; + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute boolean false size', () => { + const value = true; + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute bigint size', () => { + const value = BigInt('32785893475875872652349587624379564329785674325692483657894236597436279586342978563'); + const expected = value.toString().length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute symbol size', () => { + const value = Symbol.for('foobar'); + const expected = JSON.stringify(value)?.length ?? 0; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute function size', () => { + const value = function (arg1: number) { + return arg1; + }; + + const expected = JSON.stringify(value)?.length ?? 0; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute undefined size', () => { + const value = undefined; + const expected = JSON.stringify(value)?.length ?? 0; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute null size', () => { + const value = null; + const expected = JSON.stringify(value)?.length ?? 0; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + describe('objects with values', () => { + it('should compute object size with number value', () => { + const value = { + num: 123, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with number key', () => { + const value = { + [123]: '123', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with string value', () => { + const value = { + string: 'str', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with true value', () => { + const value = { + true: true, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with true key', () => { + const value = { + [true as never]: 'true', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with false value', () => { + const value = { + false: false, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with false key', () => { + const value = { + [false as never]: 'false', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with null value', () => { + const value = { + null: null, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with null key', () => { + const value = { + [null as never]: 'null', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with undefined value', () => { + const value = { + undefined: undefined, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with undefined key', () => { + const value = { + [undefined as never]: 'undefined', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with symbol value', () => { + const value = { + symbol: Symbol.for('key'), + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with symbol key', () => { + const value = { + [Symbol.for('key')]: 'symbol', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with function value', () => { + const value = { + fun: (arg: number) => {}, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with function key', () => { + const value = { + [((arg: number) => {}) as never]: 'fun', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with array value', () => { + const value = { + array: ['element'], + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with array key', () => { + const value = { + [['element'] as never]: 'array', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with object value', () => { + const value = { + obj: { foo: 'bar' }, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with object key', () => { + const value = { + [{ foo: 'bar' } as never]: 'object', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with multiple keys', () => { + const value = { + key1: 'key1', + key2: 'key2', + key3: 'key3', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with no keys', () => { + const value = {}; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with toJSON function', () => { + const value = { + a: 123, + b: 'xyz', + toJSON() { + return { + foo: 'bar', + }; + }, + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with Date value', () => { + const value = { + date: new Date(), + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute object size with every key type', () => { + const value = { + num: 123, + [123]: '123', + string: 'str', + true: true, + [true as never]: 'true', + false: false, + [false as never]: 'false', + null: null, + [null as never]: 'null', + undefined: undefined, + [undefined as never]: 'undefined', + symbol: Symbol.for('key'), + [Symbol.for('key')]: 'symbol', + fun: (arg: number) => {}, + [((arg: number) => {}) as never]: 'fun', + array: ['element'], + [['element'] as never]: 'array', + obj: { foo: 'bar' }, + [{ foo: 'bar' } as never]: 'object', + }; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + }); + + describe('arrays with values', () => { + it('should compute array size with number value', () => { + const value = [123]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with string value', () => { + const value = ['str']; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with true value', () => { + const value = [true]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with false value', () => { + const value = [false]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with null value', () => { + const value = [null]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with undefined value', () => { + const value = [undefined]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with symbol value', () => { + const value = [Symbol.for('symbol')]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with function value', () => { + const value = [(arg: number) => {}]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with array value', () => { + const value = [['123']]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with object value', () => { + const value = [{ foo: 'bar' }]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with multiple values', () => { + const value = ['el1', 'el2', 'el3']; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with Date value', () => { + const value = [new Date()]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + + it('should compute array size with every value type', () => { + const value = [ + 123, + 'str', + true, + false, + null, + undefined, + new Date(), + Symbol.for('symbol'), + (arg: number) => {}, + ['123'], + { foo: 'bar' }, + ]; + + const expected = JSON.stringify(value).length; + + const actual = jsonSize(value); + expect(actual).toEqual(expected); + }); + }); + + describe('circular references', () => { + it('should compute object size for self-referencing object', () => { + const value = { + a: { + b: { + c: undefined as object | undefined, + }, + }, + }; + + value.a.b.c = value; + + const expected = JSON.stringify(value, jsonEscaper()).length; + const actual = jsonSize(value, jsonEscaper()); + + expect(actual).toEqual(expected); + }); + + it('should compute array size for self-referencing array', () => { + const value: unknown[] = ['a', 'b', 'c']; + value.push(value); + + const expected = JSON.stringify(value, jsonEscaper()).length; + const actual = jsonSize(value, jsonEscaper()); + + expect(actual).toEqual(expected); + }); + + it('should compute object size for self-referencing object with toJSON', () => { + const value = { + a: { + b: { + toJSON() { + return value; + }, + }, + }, + }; + + const expected = JSON.stringify(value, jsonEscaper()).length; + const actual = jsonSize(value, jsonEscaper()); + + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/packages/sdk-core/tests/common/limitObjectDepth.spec.ts b/packages/sdk-core/tests/common/limitObjectDepth.spec.ts new file mode 100644 index 00000000..9f38d08d --- /dev/null +++ b/packages/sdk-core/tests/common/limitObjectDepth.spec.ts @@ -0,0 +1,117 @@ +import { limitObjectDepth } from '../../src/common/limitObjectDepth'; + +const EXPECTED_REMOVED_PLACEHOLDER = ''; + +describe('limitObjectDepth', () => { + it(`should replace object keys below depth with ${EXPECTED_REMOVED_PLACEHOLDER}`, () => { + const obj = { + a0: { + a1: { + a2: { + foo: 'bar', + }, + b2: 'xyz', + }, + b1: 'test', + }, + }; + + const depth = 2; + const expected = { + a0: { + a1: { + a2: EXPECTED_REMOVED_PLACEHOLDER, + b2: 'xyz', + }, + b1: 'test', + }, + }; + + const actual = limitObjectDepth(obj, depth); + expect(actual).toEqual(expected); + }); + + it('should replace objects in arrays below depth with ', () => { + const obj = { + a0: { + a1: [{ a2: { foo: 'bar' } }, { b2: 'xyz' }], + b1: 'test', + c1: { + a2: [ + { + foo: 'baz', + }, + 'xyz', + ], + }, + }, + }; + + const depth = 2; + const expected = { + a0: { + a1: [{ a2: EXPECTED_REMOVED_PLACEHOLDER }, { b2: 'xyz' }], + b1: 'test', + c1: { + a2: [EXPECTED_REMOVED_PLACEHOLDER, 'xyz'], + }, + }, + }; + + const actual = limitObjectDepth(obj, depth); + expect(actual).toEqual(expected); + }); + + it('should not change object if depth is greater than the object max depth', () => { + const obj = { + a0: { + a1: { + a2: { + foo: 'bar', + }, + b2: 'xyz', + }, + b1: 'test', + }, + }; + + const depth = 10; + + const actual = limitObjectDepth(obj, depth); + expect(actual).toEqual(obj); + }); + + it(`should not replace null/undefined with ${EXPECTED_REMOVED_PLACEHOLDER}`, () => { + const obj = { + a0: { + a1: { + a2: null, + b2: undefined, + c2: 'xyz', + }, + b1: 'test', + }, + }; + + const depth = 2; + + const actual = limitObjectDepth(obj, depth); + expect(actual).toEqual(obj); + }); + + it('should return the exact same object if depth is Infinity', () => { + const obj = { + a0: { + a1: { + a2: null, + b2: undefined, + c2: 'xyz', + }, + b1: 'test', + }, + }; + + const actual = limitObjectDepth(obj, Infinity); + expect(actual).toBe(obj); + }); +}); diff --git a/packages/sdk-core/tests/dataStructures/OverwritingArray.spec.ts b/packages/sdk-core/tests/dataStructures/OverwritingArray.spec.ts new file mode 100644 index 00000000..3f7706f0 --- /dev/null +++ b/packages/sdk-core/tests/dataStructures/OverwritingArray.spec.ts @@ -0,0 +1,132 @@ +import { OverwritingArray } from '../../src/dataStructures/OverwritingArray'; + +describe('OverwritingArray', () => { + describe('push', () => { + it('should add elements to array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3); + + expect([...array]).toEqual([1, 2, 3]); + }); + + it('should overwrite first elements after adding more than capacity', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3, 4, 5, 6, 7, 8); + + expect([...array]).toEqual([4, 5, 6, 7, 8]); + }); + }); + + describe('at', () => { + it('should return element at index', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3); + + const actual = array.at(1); + expect(actual).toEqual(2); + }); + + it('should return element at index of overwritten array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3, 4, 5, 6, 7, 8); + + const actual = array.at(1); + expect(actual).toEqual(5); + }); + }); + + describe('shift', () => { + it('should remove element from start of array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3); + array.shift(); + + expect([...array]).toEqual([2, 3]); + }); + + it('should remove element from start of overwritten array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3, 4, 5, 6, 7, 8); + array.shift(); + + expect([...array]).toEqual([5, 6, 7, 8]); + }); + + it('should return removed element', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3); + const actual = array.shift(); + + expect(actual).toEqual(1); + }); + + it('should return removed element from overwritten array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3, 4, 5, 6, 7, 8); + const actual = array.shift(); + + expect(actual).toEqual(4); + }); + }); + + describe('pop', () => { + it('should remove element from end of array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3); + array.pop(); + + expect([...array]).toEqual([1, 2]); + }); + + it('should remove element from end of overwritten array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3, 4, 5, 6, 7, 8); + array.pop(); + + expect([...array]).toEqual([4, 5, 6, 7]); + }); + + it('should return removed element', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3); + const actual = array.pop(); + + expect(actual).toEqual(3); + }); + + it('should return removed element from overwritten array', () => { + const array = new OverwritingArray(5); + array.push(1, 2, 3, 4, 5, 6, 7, 8); + const actual = array.pop(); + + expect(actual).toEqual(8); + }); + }); + + describe('values', () => { + it('should iterate values', () => { + const array = new OverwritingArray(5, [1, 2, 3, 4, 5, 6, 7, 8]); + expect([...array.values()]).toEqual([4, 5, 6, 7, 8]); + }); + }); + + describe('keys', () => { + it('should iterate keys', () => { + const array = new OverwritingArray(5, [1, 2, 3, 4, 5, 6, 7, 8]); + expect([...array.keys()]).toEqual([0, 1, 2, 3, 4]); + }); + }); + + describe('entries', () => { + it('should iterate entries', () => { + const array = new OverwritingArray(5, [1, 2, 3, 4, 5, 6, 7, 8]); + expect([...array.entries()]).toEqual([ + [0, 4], + [1, 5], + [2, 6], + [3, 7], + [4, 8], + ]); + }); + }); +}); diff --git a/packages/sdk-core/tests/dataStructures/numbers.spec.ts b/packages/sdk-core/tests/dataStructures/numbers.spec.ts new file mode 100644 index 00000000..6c7f314e --- /dev/null +++ b/packages/sdk-core/tests/dataStructures/numbers.spec.ts @@ -0,0 +1,95 @@ +import { clamped, wrapped } from '../../src/dataStructures/numbers'; + +describe('wrapped', () => { + it('should return set number if between min and max', () => { + const value = 6; + const expected = value; + + const constraint = wrapped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return min if set to min', () => { + const value = -10; + const expected = -10; + + const constraint = wrapped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return min if set to max', () => { + const value = 10; + const expected = -10; + + const constraint = wrapped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return offset value if set not between min and max', () => { + const value = 15; + const expected = -5; + + const constraint = wrapped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); +}); + +describe('clamped', () => { + it('should return set number if between min and max', () => { + const value = 6; + const expected = value; + + const constraint = clamped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return min if set to min', () => { + const value = -10; + const expected = -10; + + const constraint = clamped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return max if set to max', () => { + const value = 10; + const expected = 10; + + const constraint = clamped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return max value if set to larger than max', () => { + const value = 15; + const expected = 10; + + const constraint = clamped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); + + it('should return min value if set to lower than min', () => { + const value = -15; + const expected = -10; + + const constraint = clamped(-10, 10); + const actual = constraint(value); + + expect(actual).toEqual(expected); + }); +});