Skip to content

Commit b4c8678

Browse files
konraddysputKonrad Dysput
andauthored
sdk-core: Allow to serialize and capture invalid attribute types (#365)
* sdk-core: Allow to serialize and capture invalid attribute types * node, sdk-core: Implement similar fix to the breadcrumbs storage and limit object depth * node: correct cast * sdk-core: Catch Array.isArray with proxies --------- Co-authored-by: Konrad Dysput <konrad.dysput@saucelabs.com>
1 parent 9bfe6d7 commit b4c8678

8 files changed

Lines changed: 292 additions & 23 deletions

File tree

‎packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AttributeType,
23
BacktraceAttachment,
34
BacktraceAttachmentProvider,
45
Breadcrumb,
@@ -107,9 +108,8 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
107108
timestamp: TimeHelper.now(),
108109
type: BreadcrumbType[rawBreadcrumb.type].toLowerCase(),
109110
level: BreadcrumbLogLevel[rawBreadcrumb.level].toLowerCase(),
110-
attributes: rawBreadcrumb.attributes,
111+
attributes: this.prepareAttributes(rawBreadcrumb.attributes),
111112
};
112-
113113
const breadcrumbJson = JSON.stringify(breadcrumb, jsonEscaper());
114114
const jsonLength = breadcrumbJson.length + 1; // newline
115115
const sizeLimit = this._limits.maximumTotalBreadcrumbsSize;
@@ -123,6 +123,46 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
123123
return id;
124124
}
125125

126+
private prepareAttributes(attributes?: Record<string, AttributeType>): Record<string, AttributeType> | undefined {
127+
const result: Record<string, AttributeType> = {};
128+
if (!attributes) {
129+
return undefined;
130+
}
131+
for (const key in attributes) {
132+
const value = attributes[key];
133+
switch (typeof value) {
134+
case 'number':
135+
case 'boolean':
136+
case 'string':
137+
case 'undefined':
138+
result[key] = value;
139+
break;
140+
case 'bigint':
141+
result[key] = (value as bigint).toString();
142+
break;
143+
case 'object': {
144+
if (!value) {
145+
result[key] = value;
146+
break;
147+
}
148+
const unknownValue = value as unknown;
149+
try {
150+
if (unknownValue instanceof Date) {
151+
result[key] = unknownValue.toISOString();
152+
} else if (unknownValue instanceof URL) {
153+
result[key] = unknownValue.toString();
154+
}
155+
} catch {
156+
// revoked proxy or broken object — drop it
157+
}
158+
// drop all other objects
159+
break;
160+
}
161+
}
162+
}
163+
return result;
164+
}
165+
126166
private static getFileName(index: number) {
127167
return `${FILE_PREFIX}-${index}`;
128168
}

‎packages/sdk-core/src/common/jsonSize.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function arraySize(array: unknown[], replacer?: JsonReplacer): number {
3030
elementsLength += nullSize;
3131
break;
3232
default:
33-
elementsLength += _jsonSize(array, i.toString(), element, replacer);
33+
elementsLength += _safeJsonSize(array, i.toString(), element, replacer);
3434
}
3535
}
3636

@@ -45,7 +45,7 @@ const objectSize = (obj: object, replacer?: JsonReplacer): number => {
4545
let entriesLength = 0;
4646

4747
for (const [k, v] of entries) {
48-
const valueSize = _jsonSize(obj, k, v, replacer);
48+
const valueSize = _safeJsonSize(obj, k, v, replacer);
4949
if (valueSize === 0) {
5050
continue;
5151
}
@@ -85,9 +85,25 @@ function keySize(key: unknown): number {
8585
}
8686
}
8787

88+
function _safeJsonSize(parent: unknown, key: string, value: unknown, replacer?: JsonReplacer): number {
89+
try {
90+
return _jsonSize(parent, key, value, replacer);
91+
} catch (err) {
92+
return 0;
93+
}
94+
}
95+
8896
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;
97+
try {
98+
if (value && typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') {
99+
value = value.toJSON() as object;
100+
}
101+
} catch (err) {
102+
// handle proxy errors that will break other parts of the flow
103+
if (err instanceof TypeError) {
104+
return 0;
105+
}
106+
// continue in case of the error in the toJSON method or unsupported toJSON method
91107
}
92108

93109
value = replacer ? replacer.call(parent, key, value) : value;
@@ -133,5 +149,5 @@ function _jsonSize(parent: unknown, key: string, value: unknown, replacer?: Json
133149
* @returns Final string length.
134150
*/
135151
export function jsonSize(value: unknown, replacer?: JsonReplacer): number {
136-
return _jsonSize(undefined, '', value, replacer);
152+
return _safeJsonSize(undefined, '', value, replacer);
137153
}

‎packages/sdk-core/src/common/limitObjectDepth.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,48 @@ type DeepPartial<T extends object> = Partial<{ [K in keyof T]: T[K] extends obje
22

33
const REMOVED_PLACEHOLDER = '<removed>';
44

5-
export type Limited<T extends object> = DeepPartial<T> | typeof REMOVED_PLACEHOLDER;
5+
export type Limited<T> = (T extends object ? DeepPartial<T> : T) | typeof REMOVED_PLACEHOLDER;
6+
7+
export function limitObjectDepth<T>(val: T, depth: number): Limited<T> {
8+
if (typeof val !== 'object' || !val) {
9+
return val as Limited<T>;
10+
}
611

7-
export function limitObjectDepth<T extends object>(obj: T, depth: number): Limited<T> {
812
if (!(depth < Infinity)) {
9-
return obj;
13+
return val as Limited<T>;
1014
}
1115

1216
if (depth < 0) {
1317
return REMOVED_PLACEHOLDER;
1418
}
1519

16-
const limitIfObject = (value: unknown) =>
17-
typeof value === 'object' && value ? limitObjectDepth(value, depth - 1) : value;
20+
try {
21+
if ('toJSON' in val && typeof val.toJSON === 'function') {
22+
return limitObjectDepth(val.toJSON(), depth);
23+
}
24+
} catch (err) {
25+
if (err instanceof TypeError) {
26+
return REMOVED_PLACEHOLDER;
27+
}
28+
// broken toJSON — fall through to iterate own properties
29+
}
30+
31+
const limitChild = (value: unknown) => limitObjectDepth(value, depth - 1);
1832

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;
33+
const result: DeepPartial<T & object> = {};
34+
for (const key in val) {
35+
try {
36+
const value = val[key];
37+
if (Array.isArray(value)) {
38+
result[key] = value.map(limitChild) as never;
39+
} else {
40+
result[key] = limitChild(value) as never;
41+
}
42+
} catch {
43+
// catch revoked proxies and other broken objects
44+
result[key] = REMOVED_PLACEHOLDER as never;
2645
}
2746
}
2847

29-
return result;
48+
return result as Limited<T>;
3049
}

‎packages/sdk-core/src/model/http/BacktraceReportSubmission.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,20 @@ export class RequestBacktraceReportSubmission implements BacktraceReportSubmissi
3030
this._submissionUrl = SubmissionUrlInformation.toJsonReportSubmissionUrl(options.url, options.token);
3131
}
3232

33-
public send(data: BacktraceSubmitBody, attachments: BacktraceAttachment[], abortSignal?: AbortSignal) {
34-
const json = JSON.stringify(data, jsonEscaper());
35-
return this._requestHandler.postError(this._submissionUrl, json, attachments, abortSignal);
33+
public send(
34+
data: BacktraceSubmitBody,
35+
attachments: BacktraceAttachment[],
36+
abortSignal?: AbortSignal,
37+
): Promise<BacktraceReportSubmissionResult<BacktraceSubmissionResponse>> {
38+
try {
39+
const json = JSON.stringify(data, jsonEscaper());
40+
return this._requestHandler.postError(this._submissionUrl, json, attachments, abortSignal);
41+
} catch (error) {
42+
// catch error generated during toJSON execution or unsupported objects to not cause the app crash.
43+
return Promise.resolve(
44+
BacktraceReportSubmissionResult.OnUnknownError(error instanceof Error ? error.message : String(error)),
45+
);
46+
}
3647
}
3748

3849
public async sendAttachment(

‎packages/sdk-core/src/modules/attribute/ReportDataBuilder.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ export class ReportDataBuilder {
1515
}
1616
switch (typeof attribute) {
1717
case 'object': {
18+
try {
19+
// try to convert known objects into attributes
20+
if (attribute instanceof Date) {
21+
result.attributes[attributeKey] = attribute.toISOString();
22+
break;
23+
} else if (attribute instanceof URL) {
24+
result.attributes[attributeKey] = attribute.toString();
25+
break;
26+
}
27+
} catch {
28+
// invalid attribute type - not able to serialize, skip it.
29+
break;
30+
}
1831
result.annotations[attributeKey] = attribute;
1932
break;
2033
}

‎packages/sdk-core/tests/breadcrumbs/breadcrumbsCreationTests.spec.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AttributeType } from '../../src/index.js';
12
import { BreadcrumbsManager } from '../../src/modules/breadcrumbs/BreadcrumbsManager.js';
23
import { BreadcrumbLogLevel, BreadcrumbType } from '../../src/modules/breadcrumbs/index.js';
34
import { InMemoryBreadcrumbsStorage } from '../../src/modules/breadcrumbs/storage/InMemoryBreadcrumbsStorage.js';
@@ -123,4 +124,22 @@ describe('Breadcrumbs creation tests', () => {
123124

124125
expect(breadcrumb.attributes).toMatchObject(attributes);
125126
});
127+
it('Should handle breadcrumb with not serializable attributes', () => {
128+
const message = 'test';
129+
const level = BreadcrumbLogLevel.Warning;
130+
const attributes = {
131+
url: new URL('https://example.com/path?q=1'),
132+
date: new Date(),
133+
objectCreatePrototype: Object.create(Date.prototype),
134+
destroyedUrl: { ...new URL('https://example.com/path?q=1'), date: new Date() },
135+
} as unknown as Record<string, AttributeType>;
136+
const storage = new InMemoryBreadcrumbsStorage({ maximumBreadcrumbs: 100 });
137+
const breadcrumbsManager = new BreadcrumbsManager(undefined, { storage: () => storage });
138+
breadcrumbsManager.initialize();
139+
breadcrumbsManager.log(message, level, attributes);
140+
const [breadcrumb] = JSON.parse(storage.get() as string);
141+
142+
expect(breadcrumb.attributes['url']).toBeDefined();
143+
expect(breadcrumb.attributes['date']).toBeDefined();
144+
});
126145
});

‎packages/sdk-core/tests/client/attributesTests.spec.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { BacktraceTestClient } from '../mocks/BacktraceTestClient.js';
2+
import { testHttpClient } from '../mocks/testHttpClient.js';
23

34
describe('Attributes tests', () => {
5+
beforeEach(() => {
6+
jest.mocked(testHttpClient.postError).mockClear();
7+
});
8+
49
describe('Client attribute add', () => {
510
it('Should add an attribute to the client cache', () => {
611
const client = BacktraceTestClient.buildFakeClient();
@@ -80,4 +85,93 @@ describe('Attributes tests', () => {
8085
expect(scopedAttributeGetFunction).toHaveBeenCalledTimes(2);
8186
});
8287
});
88+
89+
describe('Non-serializable attributes', () => {
90+
it('Should convert Date attribute to ISO string', async () => {
91+
const client = BacktraceTestClient.buildFakeClient();
92+
const date = new Date();
93+
94+
await client.send(new Error('test'), { date });
95+
96+
const [[, json]] = (client.requestHandler.postError as jest.Mock).mock.calls;
97+
const body = JSON.parse(json);
98+
expect(body.attributes.date).toEqual(date.toISOString());
99+
});
100+
101+
it('Should convert URL attribute to string', async () => {
102+
const client = BacktraceTestClient.buildFakeClient();
103+
const url = new URL('https://example.com/path?q=1');
104+
105+
await client.send(new Error('test'), { url });
106+
107+
const [[, json]] = (client.requestHandler.postError as jest.Mock).mock.calls;
108+
const body = JSON.parse(json);
109+
expect(body.attributes.url).toEqual(url.toString());
110+
});
111+
112+
it('Should handle URL instance as annotation', async () => {
113+
const client = BacktraceTestClient.buildFakeClient();
114+
115+
await client.send(new Error('test'), {
116+
destroyedClassInstance: { ...new URL('https://example.com') },
117+
});
118+
119+
const [[, json]] = (client.requestHandler.postError as jest.Mock).mock.calls;
120+
expect(() => JSON.parse(json)).not.toThrow();
121+
});
122+
123+
it('Should handle Object.create with URL prototype', async () => {
124+
const client = BacktraceTestClient.buildFakeClient();
125+
126+
await client.send(new Error('test'), {
127+
createdObjectViaPrototype: Object.create(URL.prototype),
128+
});
129+
130+
const [[, json]] = (client.requestHandler.postError as jest.Mock).mock.calls;
131+
expect(() => JSON.parse(json)).not.toThrow();
132+
});
133+
134+
it('Should return submission error for object with broken toJSON', async () => {
135+
const client = BacktraceTestClient.buildFakeClient();
136+
137+
const result = await client.send(new Error('test'), {
138+
brokenToJSON: {
139+
toJSON() {
140+
throw new Error('broken toJSON');
141+
},
142+
},
143+
});
144+
145+
expect(result.status).toEqual('Unknown');
146+
});
147+
148+
it('Should handle spread class instance with private fields', async () => {
149+
class Strict {
150+
#data = 'secret';
151+
toJSON() {
152+
return this.#data;
153+
}
154+
}
155+
const client = BacktraceTestClient.buildFakeClient();
156+
157+
await client.send(new Error('test'), {
158+
strict: { ...new Strict() },
159+
});
160+
161+
const [[, json]] = (client.requestHandler.postError as jest.Mock).mock.calls;
162+
expect(() => JSON.parse(json)).not.toThrow();
163+
});
164+
165+
it('Should handle revoked proxy nested in object', async () => {
166+
const { proxy, revoke } = Proxy.revocable({ toJSON: () => 'ok' }, {});
167+
revoke();
168+
const client = BacktraceTestClient.buildFakeClient();
169+
170+
const result = await client.send(new Error('test'), {
171+
revokedProxy: { data: proxy },
172+
});
173+
174+
expect(result.status).toEqual('Unknown');
175+
});
176+
});
83177
});

0 commit comments

Comments
 (0)