Skip to content

Commit

Permalink
feat: properly serialize the properties of a console error (#904)
Browse files Browse the repository at this point in the history
  • Loading branch information
eskirk authored Jan 30, 2025
1 parent 1b5cc70 commit 24e8359
Show file tree
Hide file tree
Showing 17 changed files with 430 additions and 45 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
(#923)
- Improvement (`@grafana/faro-web-sdk`): Send an event for `service.name` overrides (#903)
- Improvement (`@grafana/faro-*`) Add required Node engines to package.json ()
- Feature (`@grafana/faro-web-sdk`) Adds the option to provide an serializer to serialize console error properties (#901)

## 1.12.3

Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/api/exceptions/const.ts
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
import { isObject, stringifyExternalJson } from '../../utils';

export const defaultExceptionType = 'Error';

export const defaultErrorArgsSerializer = (args: [any?, ...any[]]) => {
return args
.map((arg) => {
if (isObject(arg)) {
return stringifyExternalJson(arg);
}

return String(arg);
})
.join(' ');
};
2 changes: 1 addition & 1 deletion packages/core/src/api/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { defaultExceptionType } from './const';
export { defaultExceptionType, defaultErrorArgsSerializer } from './const';

export { initializeExceptionsAPI } from './initialize';

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type { API, APIEvent } from './types';

export type { EventAttributes, EventEvent, EventsAPI, PushEventOptions } from './events';

export { defaultExceptionType } from './exceptions';
export { defaultExceptionType, defaultErrorArgsSerializer } from './exceptions';
export type {
ExceptionEvent,
ExceptionStackFrame,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@ export interface Config<P = APIEvent> {
* By default, Faro sends an error for console.error calls. If you want to send a log instead, set this to true.
*/
consoleErrorAsLog?: boolean;

/**
* If true, use the default Faro error serializer for console.error calls. If false, simply call toString() on the error arguments.
* If enabled, payloads containing serialized errors may become very large. If left disabled, some error details may be lost.
* (default: false)
*/
serializeErrors?: boolean;

/**
* Custom function to serialize Error arguments
*/
errorSerializer?: LogArgsSerializer;
};

pageTracking?: {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { defaultExceptionType, defaultLogArgsSerializer } from './api';
export { defaultExceptionType, defaultLogArgsSerializer, defaultErrorArgsSerializer } from './api';
export type {
API,
APIEvent,
Expand Down Expand Up @@ -126,6 +126,8 @@ export {
noop,
dateNow,
isEmpty,
getCircularDependencyReplacer,
stringifyExternalJson,
} from './utils';
export type {
BaseObject,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ export { genShortID } from './shortId';
export { getBundleId } from './sourceMaps';

export { dateNow } from './date';

export { getCircularDependencyReplacer, stringifyExternalJson } from './json';
File renamed without changes.
File renamed without changes.
109 changes: 103 additions & 6 deletions packages/web-sdk/src/instrumentations/console/instrumentation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { initializeFaro, LogLevel, TransportItem } from '@grafana/faro-core';
import { initializeFaro, LogLevel, stringifyExternalJson, TransportItem } from '@grafana/faro-core';
import type { ExceptionEvent, LogEvent } from '@grafana/faro-core';
import { mockConfig, MockTransport } from '@grafana/faro-core/src/testUtils';

Expand Down Expand Up @@ -56,6 +56,41 @@ describe('ConsoleInstrumentation', () => {
);
});

it('sends a faro error serialized with error serializer when console.error is called', () => {
const mockTransport = new MockTransport();

initializeFaro(
makeCoreConfig(
mockConfig({
transports: [mockTransport],
instrumentations: [new ConsoleInstrumentation()],
consoleInstrumentation: {
serializeErrors: true,
},
unpatchedConsole: {
error: jest.fn(),
} as unknown as Console,
})
)!
);

console.error('console.error no 1');

const context = { foo: 'bar', baz: 'bam' };
console.error('with object', context);

expect(mockTransport.items).toHaveLength(2);

expect((mockTransport.items[0] as TransportItem<ExceptionEvent>)?.payload.type).toBe('Error');
expect((mockTransport.items[0] as TransportItem<ExceptionEvent>)?.payload.value).toBe(
'console.error: console.error no 1'
);
expect((mockTransport.items[1] as TransportItem<ExceptionEvent>)?.payload.type).toBe('Error');
expect((mockTransport.items[1] as TransportItem<ExceptionEvent>)?.payload.value).toBe(
'console.error: with object ' + stringifyExternalJson(context)
);
});

it('Handles objects with circular references', () => {
const mockTransport = new MockTransport();

Expand All @@ -81,6 +116,34 @@ describe('ConsoleInstrumentation', () => {
);
});

it('Handles objects with circular references with error serializer', () => {
const mockTransport = new MockTransport();

initializeFaro(
makeCoreConfig(
mockConfig({
transports: [mockTransport],
instrumentations: [new ConsoleInstrumentation()],
consoleInstrumentation: {
serializeErrors: true,
},
unpatchedConsole: {
error: jest.fn(),
} as unknown as Console,
})
)!
);

const objWithCircularRef = { foo: 'bar', baz: 'bam' };
(objWithCircularRef as any).circular = objWithCircularRef;

console.error('with circular refs object', objWithCircularRef);

expect((mockTransport.items[0] as TransportItem<ExceptionEvent>)?.payload.value).toBe(
'console.error: with circular refs object ' + stringifyExternalJson(objWithCircularRef)
);
});

it('sends a faro log for console.error calls if configured', () => {
const mockTransport = new MockTransport();

Expand All @@ -99,14 +162,46 @@ describe('ConsoleInstrumentation', () => {
)!
);

console.error('console.error log no 1');
console.error('console.error log with object', { foo: 'bar', baz: 'bam' });
console.error('log no 1');
console.error('log with object', { foo: 'bar', baz: 'bam' });

expect(mockTransport.items).toHaveLength(2);

expect((mockTransport.items[0] as TransportItem<LogEvent>)?.payload.message).toBe('console.error: log no 1');
expect((mockTransport.items[1] as TransportItem<LogEvent>)?.payload.message).toBe(
'console.error: log with object [object Object]'
);
});

it('sends a faro log using error serializer for console.error calls if configured', () => {
const mockTransport = new MockTransport();

initializeFaro(
makeCoreConfig(
mockConfig({
transports: [mockTransport],
instrumentations: [new ConsoleInstrumentation()],
unpatchedConsole: {
error: jest.fn(),
} as unknown as Console,
consoleInstrumentation: {
consoleErrorAsLog: true,
serializeErrors: true,
},
})
)!
);

console.error('log no 1');

const context = { foo: 'bar', baz: 'bam' };
console.error('log with object', context);

expect(mockTransport.items).toHaveLength(2);

expect((mockTransport.items[0] as TransportItem<LogEvent>)?.payload.message).toBe('console.error log no 1');
expect((mockTransport.items[0] as TransportItem<LogEvent>)?.payload.message).toBe('console.error: log no 1');
expect((mockTransport.items[1] as TransportItem<LogEvent>)?.payload.message).toBe(
'console.error log with object [object Object]'
'console.error: log with object ' + stringifyExternalJson(context)
);
});

Expand Down Expand Up @@ -136,7 +231,9 @@ describe('ConsoleInstrumentation', () => {
console.log('log logs are disabled');

expect(mockTransport.items).toHaveLength(2);
expect((mockTransport.items[0] as TransportItem<LogEvent>)?.payload.message).toBe('error logs are enabled');
expect((mockTransport.items[0] as TransportItem<LogEvent>)?.payload.message).toBe(
'console.error: error logs are enabled'
);
expect((mockTransport.items[1] as TransportItem<LogEvent>)?.payload.message).toBe('info logs are enabled');
});

Expand Down
40 changes: 37 additions & 3 deletions packages/web-sdk/src/instrumentations/console/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { allLogLevels, BaseInstrumentation, defaultLogArgsSerializer, LogLevel, VERSION } from '@grafana/faro-core';
import {
allLogLevels,
BaseInstrumentation,
defaultErrorArgsSerializer,
defaultLogArgsSerializer,
LogArgsSerializer,
LogLevel,
VERSION,
} from '@grafana/faro-core';

import { getDetailsFromConsoleErrorArgs } from '../errors/getErrorDetails';

import type { ConsoleInstrumentationOptions } from './types';

Expand All @@ -7,15 +17,21 @@ export class ConsoleInstrumentation extends BaseInstrumentation {
readonly version = VERSION;

static defaultDisabledLevels: LogLevel[] = [LogLevel.DEBUG, LogLevel.TRACE, LogLevel.LOG];
static consoleErrorPrefix = 'console.error: ';
private errorSerializer: LogArgsSerializer = defaultLogArgsSerializer;

constructor(private options: ConsoleInstrumentationOptions = {}) {
super();
}

initialize() {
this.logDebug('Initializing\n', this.options);
this.options = { ...this.options, ...this.config.consoleInstrumentation };

const serializeErrors = this.options?.serializeErrors || !!this.options?.errorSerializer;
this.errorSerializer = serializeErrors
? (this.options?.errorSerializer ?? defaultErrorArgsSerializer)
: defaultLogArgsSerializer;

allLogLevels
.filter(
(level) => !(this.options?.disabledLevels ?? ConsoleInstrumentation.defaultDisabledLevels).includes(level)
Expand All @@ -25,7 +41,25 @@ export class ConsoleInstrumentation extends BaseInstrumentation {
console[level] = (...args) => {
try {
if (level === LogLevel.ERROR && !this.options?.consoleErrorAsLog) {
this.api.pushError(new Error('console.error: ' + defaultLogArgsSerializer(args)));
const { value, type, stackFrames } = getDetailsFromConsoleErrorArgs(args, this.errorSerializer);

if (value && !type && !stackFrames) {
this.api.pushError(new Error(ConsoleInstrumentation.consoleErrorPrefix + value));
return;
}

this.api.pushError(new Error(ConsoleInstrumentation.consoleErrorPrefix + value), { type, stackFrames });
} else if (level === LogLevel.ERROR && this.options?.consoleErrorAsLog) {
const { value, type, stackFrames } = getDetailsFromConsoleErrorArgs(args, this.errorSerializer);

this.api.pushLog(value ? [ConsoleInstrumentation.consoleErrorPrefix + value] : args, {
level,
context: {
value: value ?? '',
type: type ?? '',
stackFrames: stackFrames?.length ? defaultErrorArgsSerializer(stackFrames) : '',
},
});
} else {
this.api.pushLog(args, { level });
}
Expand Down
Loading

0 comments on commit 24e8359

Please sign in to comment.