Skip to content

Commit 5cffb2b

Browse files
authored
feat: Add the ability to filter errors. (#743)
This PR adds support for error level filtering. This is the highest level filter type capable of filtering on any data. Other filters are provided to simplify the implementation of filtering across all error types. Breadcrumb filters could be implemented in terms of an error filter, but they would have higher complexity and different performance characteristics. For example with a breadcrumb filter we filter that breadcrumb regardless of how many events it may appear in, versus having to filter that same breadcrumb each time an event is captures. Custom url filters operate at the HTTP capture level similarly reducing the frequency of redaction.
1 parent 833f4ce commit 5cffb2b

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)