Skip to content

Commit 5dd3005

Browse files
committed
feat: Add the ability to filter errors.
1 parent 833f4ce commit 5dd3005

File tree

5 files changed

+231
-27
lines changed

5 files changed

+231
-27
lines changed

packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const defaultOptions: ParsedOptions = {
3333
},
3434
},
3535
collectors: [],
36+
errorFilters: [],
3637
};
3738

3839
it('sends buffered events when client is registered', () => {
@@ -534,3 +535,125 @@ it('sends session init event when client is registered', () => {
534535
}),
535536
);
536537
});
538+
539+
it('applies error filters to captured errors', () => {
540+
const options: ParsedOptions = {
541+
...defaultOptions,
542+
errorFilters: [
543+
(error) => ({
544+
...error,
545+
message: error.message.replace('secret', 'redacted'),
546+
}),
547+
],
548+
};
549+
const telemetry = new BrowserTelemetryImpl(options);
550+
551+
telemetry.captureError(new Error('Error with secret info'));
552+
telemetry.register(mockClient);
553+
554+
expect(mockClient.track).toHaveBeenCalledWith(
555+
'$ld:telemetry:error',
556+
expect.objectContaining({
557+
message: 'Error with redacted info',
558+
}),
559+
);
560+
});
561+
562+
it('filters out errors when filter returns undefined', () => {
563+
const options: ParsedOptions = {
564+
...defaultOptions,
565+
errorFilters: [() => undefined],
566+
};
567+
const telemetry = new BrowserTelemetryImpl(options);
568+
569+
telemetry.captureError(new Error('Test error'));
570+
telemetry.register(mockClient);
571+
572+
// Verify only session init event was tracked
573+
expect(mockClient.track).toHaveBeenCalledTimes(1);
574+
expect(mockClient.track).toHaveBeenCalledWith(
575+
'$ld:telemetry:session:init',
576+
expect.objectContaining({
577+
sessionId: expect.any(String),
578+
}),
579+
);
580+
});
581+
582+
it('applies multiple error filters in sequence', () => {
583+
const options: ParsedOptions = {
584+
...defaultOptions,
585+
errorFilters: [
586+
(error) => ({
587+
...error,
588+
message: error.message.replace('secret', 'redacted'),
589+
}),
590+
(error) => ({
591+
...error,
592+
message: error.message.replace('redacted', 'sneaky'),
593+
}),
594+
],
595+
};
596+
const telemetry = new BrowserTelemetryImpl(options);
597+
598+
telemetry.captureError(new Error('Error with secret info'));
599+
telemetry.register(mockClient);
600+
601+
expect(mockClient.track).toHaveBeenCalledWith(
602+
'$ld:telemetry:error',
603+
expect.objectContaining({
604+
message: 'Error with sneaky info',
605+
}),
606+
);
607+
});
608+
609+
it('handles error filter throwing an exception', () => {
610+
const mockLogger = {
611+
warn: jest.fn(),
612+
};
613+
const options: ParsedOptions = {
614+
...defaultOptions,
615+
errorFilters: [
616+
() => {
617+
throw new Error('Filter error');
618+
},
619+
],
620+
logger: mockLogger,
621+
};
622+
const telemetry = new BrowserTelemetryImpl(options);
623+
624+
telemetry.captureError(new Error('Test error'));
625+
telemetry.register(mockClient);
626+
627+
expect(mockLogger.warn).toHaveBeenCalledWith(
628+
'LaunchDarkly - Browser Telemetry: Error applying error filters: Error: Filter error',
629+
);
630+
// Verify only session init event was tracked
631+
expect(mockClient.track).toHaveBeenCalledTimes(1);
632+
expect(mockClient.track).toHaveBeenCalledWith(
633+
'$ld:telemetry:session:init',
634+
expect.objectContaining({
635+
sessionId: expect.any(String),
636+
}),
637+
);
638+
});
639+
640+
it('only logs error filter error once', () => {
641+
const mockLogger = {
642+
warn: jest.fn(),
643+
};
644+
const options: ParsedOptions = {
645+
...defaultOptions,
646+
errorFilters: [
647+
() => {
648+
throw new Error('Filter error');
649+
},
650+
],
651+
logger: mockLogger,
652+
};
653+
const telemetry = new BrowserTelemetryImpl(options);
654+
655+
telemetry.captureError(new Error('Error 1'));
656+
telemetry.captureError(new Error('Error 2'));
657+
658+
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
659+
});

packages/telemetry/browser-telemetry/__tests__/options.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Breadcrumb } from '../src/api/Breadcrumb';
2+
import { ErrorData } from '../src/api/ErrorData';
23
import ErrorCollector from '../src/collectors/error';
34
import parse, { defaultOptions } from '../src/options';
45

@@ -16,17 +17,19 @@ it('handles an empty configuration', () => {
1617
});
1718

1819
it('can set all options at once', () => {
19-
const filter = (breadcrumb: Breadcrumb) => breadcrumb;
20+
const breadcrumbFilter = (breadcrumb: Breadcrumb) => breadcrumb;
21+
const errorFilter = (error: ErrorData) => error;
2022
const outOptions = parse({
2123
maxPendingEvents: 1,
2224
breadcrumbs: {
2325
maxBreadcrumbs: 1,
2426
click: false,
2527
evaluations: false,
2628
flagChange: false,
27-
filters: [filter],
29+
filters: [breadcrumbFilter],
2830
},
2931
collectors: [new ErrorCollector(), new ErrorCollector()],
32+
errorFilters: [errorFilter],
3033
});
3134
expect(outOptions).toEqual({
3235
maxPendingEvents: 1,
@@ -41,7 +44,7 @@ it('can set all options at once', () => {
4144
instrumentFetch: true,
4245
instrumentXhr: true,
4346
},
44-
filters: expect.arrayContaining([filter]),
47+
filters: expect.arrayContaining([breadcrumbFilter]),
4548
},
4649
stack: {
4750
source: {
@@ -51,6 +54,7 @@ it('can set all options at once', () => {
5154
},
5255
},
5356
collectors: [new ErrorCollector(), new ErrorCollector()],
57+
errorFilters: expect.arrayContaining([errorFilter]),
5458
});
5559
expect(mockLogger.warn).not.toHaveBeenCalled();
5660
});
@@ -441,3 +445,31 @@ it('warns when filters is not an array', () => {
441445
'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.filters" should be of type BreadcrumbFilter[], got string, using default value',
442446
);
443447
});
448+
449+
it('warns when errorFilters is not an array', () => {
450+
const outOptions = parse(
451+
{
452+
// @ts-ignore
453+
errorFilters: 'not an array',
454+
},
455+
mockLogger,
456+
);
457+
458+
expect(outOptions.errorFilters).toEqual([]);
459+
expect(mockLogger.warn).toHaveBeenCalledWith(
460+
'LaunchDarkly - Browser Telemetry: Config option "errorFilters" should be of type ErrorDataFilter[], got string, using default value',
461+
);
462+
});
463+
464+
it('accepts valid error filters array', () => {
465+
const errorFilters = [(error: any) => error];
466+
const outOptions = parse(
467+
{
468+
errorFilters,
469+
},
470+
mockLogger,
471+
);
472+
473+
expect(outOptions.errorFilters).toEqual(errorFilters);
474+
expect(mockLogger.warn).not.toHaveBeenCalled();
475+
});

packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk';
77

8-
import { BreadcrumbFilter, LDClientLogging, LDClientTracking, MinLogger } from './api';
8+
import { LDClientLogging, LDClientTracking, MinLogger } from './api';
99
import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb';
1010
import { BrowserTelemetry } from './api/BrowserTelemetry';
1111
import { BrowserTelemetryInspector } from './api/client/BrowserTelemetryInspector';
@@ -54,11 +54,8 @@ function safeValue(u: unknown): string | boolean | number | undefined {
5454
}
5555
}
5656

57-
function applyBreadcrumbFilter(
58-
breadcrumb: Breadcrumb | undefined,
59-
filter: BreadcrumbFilter,
60-
): Breadcrumb | undefined {
61-
return breadcrumb === undefined ? undefined : filter(breadcrumb);
57+
function applyFilter<T>(item: T | undefined, filter: (item: T) => T | undefined): T | undefined {
58+
return item === undefined ? undefined : filter(item);
6259
}
6360

6461
function configureTraceKit(options: ParsedStackOptions) {
@@ -69,7 +66,7 @@ function configureTraceKit(options: ParsedStackOptions) {
6966
// from the before context.
7067
// The typing for this is a bool, but it accepts a number.
7168
const beforeAfterMax = Math.max(options.source.afterLines, options.source.beforeLines);
72-
// The assignment here has bene split to prevent esbuild from complaining about an assigment to
69+
// The assignment here has bene split to prevent esbuild from complaining about an assignment to
7370
// an import. TraceKit exports a single object and the interface requires modifying an exported
7471
// var.
7572
const anyObj = TraceKit as any;
@@ -105,6 +102,8 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
105102
private _eventsDropped: boolean = false;
106103
// Used to ensure we only log the breadcrumb filter error once.
107104
private _breadcrumbFilterError: boolean = false;
105+
// Used to ensure we only log the error filter error once.
106+
private _errorFilterError: boolean = false;
108107

109108
constructor(private _options: ParsedOptions) {
110109
configureTraceKit(_options.stack);
@@ -198,8 +197,18 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
198197
* @param event The event data.
199198
*/
200199
private _capture(type: string, event: EventData) {
200+
const filteredEvent = this._applyFilters(event, this._options.errorFilters, (e: unknown) => {
201+
if (!this._errorFilterError) {
202+
this._errorFilterError = true;
203+
this._logger.warn(prefixLog(`Error applying error filters: ${e}`));
204+
}
205+
});
206+
if (filteredEvent === undefined) {
207+
return;
208+
}
209+
201210
if (this._client === undefined) {
202-
this._pendingEvents.push({ type, data: event });
211+
this._pendingEvents.push({ type, data: filteredEvent });
203212
if (this._pendingEvents.length > this._maxPendingEvents) {
204213
if (!this._eventsDropped) {
205214
this._eventsDropped = true;
@@ -212,7 +221,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
212221
this._pendingEvents.shift();
213222
}
214223
}
215-
this._client?.track(type, event);
224+
this._client?.track(type, filteredEvent);
216225
}
217226

218227
captureError(exception: Error): void {
@@ -241,27 +250,34 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
241250
this.captureError(errorEvent.error);
242251
}
243252

244-
private _applyBreadcrumbFilters(
245-
breadcrumb: Breadcrumb,
246-
filters: BreadcrumbFilter[],
247-
): Breadcrumb | undefined {
253+
private _applyFilters<T>(
254+
item: T,
255+
filters: ((item: T) => T | undefined)[],
256+
handleError: (e: unknown) => void,
257+
): T | undefined {
248258
try {
249259
return filters.reduce(
250-
(breadcrumbToFilter: Breadcrumb | undefined, filter: BreadcrumbFilter) =>
251-
applyBreadcrumbFilter(breadcrumbToFilter, filter),
252-
breadcrumb,
260+
(itemToFilter: T | undefined, filter: (item: T) => T | undefined) =>
261+
applyFilter(itemToFilter, filter),
262+
item,
253263
);
254264
} catch (e) {
255-
if (!this._breadcrumbFilterError) {
256-
this._breadcrumbFilterError = true;
257-
this._logger.warn(prefixLog(`Error applying breadcrumb filters: ${e}`));
258-
}
265+
handleError(e);
259266
return undefined;
260267
}
261268
}
262269

263270
addBreadcrumb(breadcrumb: Breadcrumb): void {
264-
const filtered = this._applyBreadcrumbFilters(breadcrumb, this._options.breadcrumbs.filters);
271+
const filtered = this._applyFilters(
272+
breadcrumb,
273+
this._options.breadcrumbs.filters,
274+
(e: unknown) => {
275+
if (!this._breadcrumbFilterError) {
276+
this._breadcrumbFilterError = true;
277+
this._logger.warn(prefixLog(`Error applying breadcrumb filters: ${e}`));
278+
}
279+
},
280+
);
265281
if (filtered !== undefined) {
266282
this._breadcrumbs.push(filtered);
267283
if (this._breadcrumbs.length > this._maxBreadcrumbs) {
@@ -275,7 +291,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
275291
}
276292

277293
/**
278-
* Used to automatically collect flag usage for breacrumbs.
294+
* Used to automatically collect flag usage for breadcrumbs.
279295
*
280296
* When session replay is in use the data is also forwarded to the session
281297
* replay collector.

packages/telemetry/browser-telemetry/src/api/Options.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Breadcrumb } from './Breadcrumb';
22
import { Collector } from './Collector';
3+
import { ErrorData } from './ErrorData';
34
import { MinLogger } from './MinLogger';
45

56
/**
@@ -27,13 +28,21 @@ export interface UrlFilter {
2728
/**
2829
* Interface for breadcrumb filters.
2930
*
30-
* Given a breadcrumb the filter may return a modified breadcrumb or undefined to
31-
* exclude the breadcrumb.
31+
* Given a breadcrumb the filter may return a modified breadcrumb or undefined to exclude the breadcrumb.
3232
*/
3333
export interface BreadcrumbFilter {
3434
(breadcrumb: Breadcrumb): Breadcrumb | undefined;
3535
}
3636

37+
/**
38+
* Interface for filtering error data before it is sent to LaunchDarkly.
39+
*
40+
* Given {@link ErrorData} the filter may return modified data or undefined to exclude the breadcrumb.
41+
*/
42+
export interface ErrorDataFilter {
43+
(event: ErrorData): ErrorData | undefined;
44+
}
45+
3746
export interface HttpBreadcrumbOptions {
3847
/**
3948
* If fetch should be instrumented and breadcrumbs included for fetch requests.
@@ -197,4 +206,14 @@ export interface Options {
197206
* logger. The 3.x SDKs do not expose their logger.
198207
*/
199208
logger?: MinLogger;
209+
210+
/**
211+
* Custom error data filters.
212+
*
213+
* Can be used to redact or modify error data.
214+
*
215+
* For filtering breadcrumbs or URLs in error data, see {@link breadcrumbs.filters} and
216+
* {@link breadcrumbs.http.customUrlFilter}.
217+
*/
218+
errorFilters?: ErrorDataFilter[];
200219
}

0 commit comments

Comments
 (0)