Skip to content

Commit 8ec2804

Browse files
authored
sdk-core: reduce breadcrumbs size (#228)
* sdk-core: add limitObjectDepth * sdk-core: add breadcrumb limits options * sdk-core: limit breadcrumb attributes object depth * sdk-core: limit breadcrumb message length * sdk-core: add jsonSize * sdk-core: add breadcrumb size check and move limiting after intercept and level/type checks * sdk-core: add breadcrumb factories * sdk-core: add breadcrumb file size limiting in InMemoryBreadcrumbsStorage * sdk-core: update docs for breadcrumb limits * sdk-core: allow for unlimited breacrumbs in InMemoryBreadcrumbsStorage * sdk-core: PR suggestion changes * sdk-core: fix jsonSize returning invalid sizes for objects with toJSON * sdk-core: refactored OverwritingArray, added pop and shift * sdk-core: InMemoryBreadcrumbsStorage: shift breadcrumbs if larger than limit * sdk-core: clear items removed by pop and shift in OverwritingArray * sdk-core: reduce breadcrumb size PR changes * node, react-native: update defaults to compile * sdk-core: allow returning undefined from BreadcrumbsStorage.add --------- Co-authored-by: Sebastian Alex <sebastian.alex@saucelabs.com>
1 parent 07327b3 commit 8ec2804

26 files changed

+1654
-123
lines changed

packages/node/src/BacktraceClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
4848
FileBreadcrumbsStorage.create(
4949
this.sessionFiles,
5050
fileSystem,
51-
clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100,
51+
(clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100) || 100,
5252
),
5353
);
5454
}

packages/react-native/src/BacktraceClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
5959
FileBreadcrumbsStorage.create(
6060
fileSystem,
6161
this.sessionFiles,
62-
clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100,
62+
(clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100) || 100,
6363
),
6464
);
6565
}

packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { BacktraceReportSubmission } from '../model/http/BacktraceReportSubmissi
22
import { BacktraceRequestHandler } from '../model/http/BacktraceRequestHandler.js';
33
import { BacktraceModule } from '../modules/BacktraceModule.js';
44
import { BacktraceAttributeProvider } from '../modules/attribute/BacktraceAttributeProvider.js';
5-
import { BreadcrumbsEventSubscriber, BreadcrumbsStorage } from '../modules/breadcrumbs/index.js';
5+
import {
6+
BreadcrumbsEventSubscriber,
7+
BreadcrumbsStorage,
8+
BreadcrumbsStorageFactory,
9+
} from '../modules/breadcrumbs/index.js';
610
import { BacktraceStackTraceConverter } from '../modules/converter/index.js';
711
import { BacktraceSessionProvider } from '../modules/metrics/BacktraceSessionProvider.js';
812
import { MetricsQueue } from '../modules/metrics/MetricsQueue.js';
@@ -39,12 +43,22 @@ export abstract class BacktraceCoreClientBuilder<S extends Partial<CoreClientSet
3943
return this;
4044
}
4145

42-
public useBreadcrumbsStorage(storage: BreadcrumbsStorage): this {
46+
public useBreadcrumbsStorage(storageFactory: BreadcrumbsStorageFactory): this;
47+
/**
48+
* @deprecated Use `useBreadcrumbsStorage` with `BreadcrumbsStorageFactory`.
49+
*/
50+
public useBreadcrumbsStorage(storage: BreadcrumbsStorage): this;
51+
public useBreadcrumbsStorage(storage: BreadcrumbsStorage | BreadcrumbsStorageFactory): this {
4352
if (!this.clientSetup.breadcrumbsSetup) {
4453
this.clientSetup.breadcrumbsSetup = {};
4554
}
4655

47-
this.clientSetup.breadcrumbsSetup.storage = storage;
56+
if (typeof storage === 'function') {
57+
this.clientSetup.breadcrumbsSetup.storage = storage;
58+
} else {
59+
this.clientSetup.breadcrumbsSetup.storage = () => storage;
60+
}
61+
4862
return this;
4963
}
5064

packages/sdk-core/src/common/jsonEscaper.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ export function jsonEscaper() {
55
// in TypeScript add "this: any" param to avoid compliation errors - as follows
66
// return function (this: any, field: any, value: any) {
77
return function (this: unknown, key: string, value: unknown) {
8-
if (!key) {
9-
return value;
10-
}
118
if (value === null) {
129
return value;
1310
}
11+
1412
const valueType = typeof value;
1513

1614
if (valueType === 'bigint') {
@@ -28,7 +26,7 @@ export function jsonEscaper() {
2826
keys.pop();
2927
}
3028
if (ancestors.includes(value)) {
31-
return `[Circular].${keys.join('.')}.${key}`;
29+
return `[Circular].${keys.filter((k) => !!k).join('.')}.${key}`;
3230
}
3331
keys.push(key);
3432
ancestors.push(value);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
type JsonReplacer = (this: unknown, key: string, value: unknown) => unknown;
2+
3+
function stringifiedSize<T>(value: T): number {
4+
return JSON.stringify(value).length;
5+
}
6+
7+
function toStringSize<T extends { toString(): string }>(value: T): number {
8+
return value.toString().length;
9+
}
10+
11+
const stringSize = (value: string) => stringifiedSize(value);
12+
const numberSize = toStringSize<number>;
13+
const bigintSize = toStringSize<bigint>;
14+
const symbolSize = 0;
15+
const functionSize = 0;
16+
const booleanSize = (value: boolean) => (value ? 4 : 5);
17+
const undefinedSize = 0;
18+
const nullSize = 'null'.length;
19+
20+
function arraySize(array: unknown[], replacer?: JsonReplacer): number {
21+
const bracketLength = 2;
22+
const commaLength = array.length - 1;
23+
let elementsLength = 0;
24+
for (let i = 0; i < array.length; i++) {
25+
const element = array[i];
26+
switch (typeof element) {
27+
case 'function':
28+
case 'symbol':
29+
case 'undefined':
30+
elementsLength += nullSize;
31+
break;
32+
default:
33+
elementsLength += _jsonSize(array, i.toString(), element, replacer);
34+
}
35+
}
36+
37+
return bracketLength + commaLength + elementsLength;
38+
}
39+
40+
const objectSize = (obj: object, replacer?: JsonReplacer): number => {
41+
const entries = Object.entries(obj);
42+
const bracketLength = 2;
43+
44+
let entryCount = 0;
45+
let entriesLength = 0;
46+
47+
for (const [k, v] of entries) {
48+
const valueSize = _jsonSize(obj, k, v, replacer);
49+
if (valueSize === 0) {
50+
continue;
51+
}
52+
53+
entryCount++;
54+
55+
// +1 adds the comma size
56+
entriesLength += keySize(k) + valueSize + 1;
57+
}
58+
59+
// -1 removes previously added last comma size (there is no trailing comma)
60+
const commaLength = Math.max(0, entryCount - 1);
61+
62+
return bracketLength + commaLength + entriesLength;
63+
};
64+
65+
function keySize(key: unknown): number {
66+
const QUOTE_SIZE = 2;
67+
68+
if (key === null) {
69+
return nullSize + QUOTE_SIZE;
70+
} else if (key === undefined) {
71+
return '"undefined"'.length;
72+
}
73+
74+
switch (typeof key) {
75+
case 'string':
76+
return stringSize(key);
77+
case 'number':
78+
return numberSize(key) + QUOTE_SIZE;
79+
case 'boolean':
80+
return booleanSize(key) + QUOTE_SIZE;
81+
case 'symbol':
82+
return symbolSize; // key not used in JSON
83+
default:
84+
return stringSize(key.toString());
85+
}
86+
}
87+
88+
function _jsonSize(parent: unknown, key: string, value: unknown, replacer?: JsonReplacer): number {
89+
if (value && typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') {
90+
value = value.toJSON() as object;
91+
}
92+
93+
value = replacer ? replacer.call(parent, key, value) : value;
94+
if (value === null) {
95+
return nullSize;
96+
} else if (value === undefined) {
97+
return undefinedSize;
98+
}
99+
100+
if (Array.isArray(value)) {
101+
return arraySize(value, replacer);
102+
}
103+
104+
switch (typeof value) {
105+
case 'bigint':
106+
return bigintSize(value);
107+
case 'boolean':
108+
return booleanSize(value);
109+
case 'function':
110+
return functionSize;
111+
case 'number':
112+
return numberSize(value);
113+
case 'object':
114+
return objectSize(value, replacer);
115+
case 'string':
116+
return stringSize(value);
117+
case 'symbol':
118+
return symbolSize;
119+
case 'undefined':
120+
return undefinedSize;
121+
}
122+
123+
return 0;
124+
}
125+
126+
/**
127+
* Calculates size of the object as it would be serialized into JSON.
128+
*
129+
* _Should_ return the same value as `JSON.stringify(value, replacer).length`.
130+
* This may not be 100% accurate, but should work for our requirements.
131+
* @param value Value to compute length for.
132+
* @param replacer A function that transforms the results as in `JSON.stringify`.
133+
* @returns Final string length.
134+
*/
135+
export function jsonSize(value: unknown, replacer?: JsonReplacer): number {
136+
return _jsonSize(undefined, '', value, replacer);
137+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
type DeepPartial<T extends object> = Partial<{ [K in keyof T]: T[K] extends object ? DeepPartial<T[K]> : T[K] }>;
2+
3+
const REMOVED_PLACEHOLDER = '<removed>';
4+
5+
export type Limited<T extends object> = DeepPartial<T> | typeof REMOVED_PLACEHOLDER;
6+
7+
export function limitObjectDepth<T extends object>(obj: T, depth: number): Limited<T> {
8+
if (!(depth < Infinity)) {
9+
return obj;
10+
}
11+
12+
if (depth < 0) {
13+
return REMOVED_PLACEHOLDER;
14+
}
15+
16+
const limitIfObject = (value: unknown) =>
17+
typeof value === 'object' && value ? limitObjectDepth(value, depth - 1) : value;
18+
19+
const result: DeepPartial<T> = {};
20+
for (const key in obj) {
21+
const value = obj[key];
22+
if (Array.isArray(value)) {
23+
result[key] = value.map(limitIfObject) as never;
24+
} else {
25+
result[key] = limitIfObject(value) as never;
26+
}
27+
}
28+
29+
return result;
30+
}
Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,120 @@
1-
import { OverwritingArrayIterator } from './OverwritingArrayIterator.js';
1+
import { ConstrainedNumber, clamped, wrapped } from './numbers.js';
22

33
export class OverwritingArray<T> {
44
private _array: T[];
5-
private _index = 0;
6-
private _size = 0;
7-
private _startIndex = 0;
8-
constructor(public readonly capacity: number) {
9-
this._array = this.createArray();
5+
6+
private readonly _headConstraint: ConstrainedNumber;
7+
private readonly _lengthConstraint: ConstrainedNumber;
8+
9+
private _head = 0;
10+
private _length = 0;
11+
12+
private get head() {
13+
return this._head;
14+
}
15+
16+
private set head(value: number) {
17+
this._head = this._headConstraint(value);
1018
}
11-
public add(value: T): this {
12-
this._array[this._index] = value;
13-
this._index = this.incrementIndex(this._index);
14-
this._startIndex = this.incrementStartingIndex();
15-
this._size = this.incrementSize();
16-
return this;
19+
20+
public get length() {
21+
return this._length;
1722
}
1823

19-
public clear(): void {
20-
this._array = this.createArray();
24+
public set length(value: number) {
25+
this._length = this._lengthConstraint(value);
2126
}
2227

23-
public values(): IterableIterator<T> {
24-
return new OverwritingArrayIterator<T>(this._array, this._startIndex, this._size);
28+
private get start() {
29+
return this._headConstraint(this.head - this.length);
2530
}
2631

27-
[Symbol.iterator](): IterableIterator<T> {
28-
return new OverwritingArrayIterator<T>(this._array, this._startIndex, this._size);
32+
constructor(
33+
public readonly capacity: number,
34+
items?: T[],
35+
) {
36+
this._array = new Array(capacity);
37+
38+
// Head must be always between 0 and capacity.
39+
// If lower than 0, it needs to go from the end
40+
// If larger than capacity, it needs to go from the start
41+
// Wrapping solves this
42+
this._headConstraint = wrapped(0, capacity);
43+
44+
// Length must be always no less than 0 and no larger than capacity
45+
this._lengthConstraint = clamped(0, capacity);
46+
47+
if (items) {
48+
this.push(...items);
49+
}
2950
}
3051

31-
private incrementIndex(index: number) {
32-
return (index + 1) % this.capacity;
52+
public add(item: T) {
53+
return this.pushOne(item);
3354
}
3455

35-
private incrementStartingIndex() {
36-
if (this._size !== this.capacity) {
37-
return this._startIndex;
56+
public push(...items: T[]): number {
57+
for (const item of items) {
58+
this.pushOne(item);
3859
}
39-
return this.incrementIndex(this._startIndex);
60+
return this.length;
61+
}
62+
63+
public pop(): T | undefined {
64+
this.head--;
65+
const element = this._array[this.head];
66+
this._array[this.head] = undefined as never;
67+
this.length--;
68+
return element;
4069
}
41-
private incrementSize() {
42-
return Math.min(this.capacity, this._size + 1);
70+
71+
public shift(): T | undefined {
72+
const element = this._array[this.start];
73+
this._array[this.start] = undefined as never;
74+
this.length--;
75+
return element;
76+
}
77+
78+
public at(index: number): T | undefined {
79+
return this._array[this.index(index)];
4380
}
44-
private createArray() {
45-
return new Array(this.capacity);
81+
82+
public *values(): IterableIterator<T> {
83+
for (let i = 0; i < this.length; i++) {
84+
const index = this.index(i);
85+
yield this._array[index];
86+
}
87+
}
88+
89+
public *keys(): IterableIterator<number> {
90+
for (let i = 0; i < this.length; i++) {
91+
yield i;
92+
}
93+
}
94+
95+
public *entries(): IterableIterator<[number, T]> {
96+
for (let i = 0; i < this.length; i++) {
97+
const index = this.index(i);
98+
yield [i, this._array[index]];
99+
}
100+
}
101+
102+
public [Symbol.iterator]() {
103+
return this.values();
104+
}
105+
106+
private pushOne(item: T) {
107+
this._array[this.head] = item;
108+
this.head++;
109+
this.length++;
110+
}
111+
112+
private index(value: number) {
113+
if (!this.length) {
114+
return this._headConstraint(value);
115+
}
116+
117+
const index = (value % this.length) + this.start;
118+
return this._headConstraint(index);
46119
}
47120
}

0 commit comments

Comments
 (0)