Skip to content

Commit ae68e23

Browse files
committed
feat: Add support for breadcrumb filtering.
1 parent 6ad7e51 commit ae68e23

File tree

6 files changed

+250
-9
lines changed

6 files changed

+250
-9
lines changed

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

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const defaultOptions: ParsedOptions = {
2222
},
2323
evaluations: true,
2424
flagChange: true,
25+
breadcrumbFilters: [],
2526
},
2627
stack: {
2728
source: {
@@ -208,3 +209,153 @@ it('unregisters collectors on close', () => {
208209

209210
expect(mockCollector.unregister).toHaveBeenCalled();
210211
});
212+
213+
it('filters breadcrumbs using provided filter', () => {
214+
const options: ParsedOptions = {
215+
...defaultOptions,
216+
breadcrumbs: {
217+
...defaultOptions.breadcrumbs,
218+
click: false,
219+
evaluations: false,
220+
flagChange: false,
221+
http: { instrumentFetch: false, instrumentXhr: false },
222+
keyboardInput: false,
223+
breadcrumbFilters: [
224+
// Filter to remove breadcrumbs with id:2
225+
(breadcrumb) => {
226+
if (breadcrumb.type === 'custom' && breadcrumb.data?.id === 2) {
227+
return undefined;
228+
}
229+
return breadcrumb;
230+
},
231+
// Filter to transform breadcrumbs with id:3
232+
(breadcrumb) => {
233+
if (breadcrumb.type === 'custom' && breadcrumb.data?.id === 3) {
234+
return {
235+
...breadcrumb,
236+
data: { id: 'filtered-3' },
237+
};
238+
}
239+
return breadcrumb;
240+
},
241+
],
242+
},
243+
};
244+
const telemetry = new BrowserTelemetryImpl(options);
245+
246+
telemetry.addBreadcrumb({
247+
type: 'custom',
248+
data: { id: 1 },
249+
timestamp: Date.now(),
250+
class: 'custom',
251+
level: 'info',
252+
});
253+
254+
telemetry.addBreadcrumb({
255+
type: 'custom',
256+
data: { id: 2 },
257+
timestamp: Date.now(),
258+
class: 'custom',
259+
level: 'info',
260+
});
261+
262+
telemetry.addBreadcrumb({
263+
type: 'custom',
264+
data: { id: 3 },
265+
timestamp: Date.now(),
266+
class: 'custom',
267+
level: 'info',
268+
});
269+
270+
const error = new Error('Test error');
271+
telemetry.captureError(error);
272+
telemetry.register(mockClient);
273+
274+
expect(mockClient.track).toHaveBeenCalledWith(
275+
'$ld:telemetry:error',
276+
expect.objectContaining({
277+
breadcrumbs: expect.arrayContaining([
278+
expect.objectContaining({ data: { id: 1 } }),
279+
expect.objectContaining({ data: { id: 'filtered-3' } }),
280+
]),
281+
}),
282+
);
283+
284+
// Verify breadcrumb with id:2 was filtered out
285+
expect(mockClient.track).toHaveBeenCalledWith(
286+
'$ld:telemetry:error',
287+
expect.objectContaining({
288+
breadcrumbs: expect.not.arrayContaining([expect.objectContaining({ data: { id: 2 } })]),
289+
}),
290+
);
291+
});
292+
293+
it('omits breadcrumb when a filter throws an exception', () => {
294+
const breadSpy = jest.fn((breadcrumb) => breadcrumb);
295+
const options: ParsedOptions = {
296+
...defaultOptions,
297+
breadcrumbs: {
298+
...defaultOptions.breadcrumbs,
299+
breadcrumbFilters: [
300+
() => {
301+
throw new Error('Filter error');
302+
},
303+
// This filter should never run
304+
breadSpy,
305+
],
306+
},
307+
};
308+
const telemetry = new BrowserTelemetryImpl(options);
309+
310+
telemetry.addBreadcrumb({
311+
type: 'custom',
312+
data: { id: 1 },
313+
timestamp: Date.now(),
314+
class: 'custom',
315+
level: 'info',
316+
});
317+
318+
const error = new Error('Test error');
319+
telemetry.captureError(error);
320+
telemetry.register(mockClient);
321+
322+
expect(mockClient.track).toHaveBeenCalledWith(
323+
'$ld:telemetry:error',
324+
expect.objectContaining({
325+
breadcrumbs: [],
326+
}),
327+
);
328+
329+
expect(breadSpy).not.toHaveBeenCalled();
330+
});
331+
332+
it('omits breadcrumb when a filter is not a function throws an exception', () => {
333+
const options: ParsedOptions = {
334+
...defaultOptions,
335+
breadcrumbs: {
336+
...defaultOptions.breadcrumbs,
337+
// @ts-ignore
338+
breadcrumbFilters: ['potato'],
339+
},
340+
};
341+
const telemetry = new BrowserTelemetryImpl(options);
342+
343+
telemetry.addBreadcrumb({
344+
type: 'custom',
345+
data: { id: 1 },
346+
timestamp: Date.now(),
347+
class: 'custom',
348+
level: 'info',
349+
});
350+
351+
const error = new Error('Test error');
352+
telemetry.captureError(error);
353+
telemetry.register(mockClient);
354+
355+
expect(mockClient.track).toHaveBeenCalledWith(
356+
'$ld:telemetry:error',
357+
expect.objectContaining({
358+
breadcrumbs: [],
359+
}),
360+
);
361+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ it('can set all options at once', () => {
2222
click: false,
2323
evaluations: false,
2424
flagChange: false,
25+
breadcrumbFilters: [(breadcrumb) => breadcrumb],
2526
},
2627
collectors: [new ErrorCollector(), new ErrorCollector()],
2728
});
@@ -38,6 +39,7 @@ it('can set all options at once', () => {
3839
instrumentFetch: true,
3940
instrumentXhr: true,
4041
},
42+
breadcrumbFilters: expect.any(Array),
4143
},
4244
stack: {
4345
source: {

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

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

8-
import { LDClientTracking } from './api';
8+
import { BreadcrumbFilter, LDClientTracking } from './api';
99
import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb';
1010
import { BrowserTelemetry } from './api/BrowserTelemetry';
1111
import { Collector } from './api/Collector';
@@ -52,6 +52,28 @@ function safeValue(u: unknown): string | boolean | number | undefined {
5252
}
5353
}
5454

55+
function applyBreadcrumbFilter(
56+
breadcrumb: Breadcrumb | undefined,
57+
filter: BreadcrumbFilter,
58+
): Breadcrumb | undefined {
59+
return breadcrumb === undefined ? undefined : filter(breadcrumb);
60+
}
61+
62+
function applyBreadcrumbFilters(
63+
breadcrumb: Breadcrumb,
64+
filters: BreadcrumbFilter[],
65+
): Breadcrumb | undefined {
66+
try {
67+
return filters.reduce(
68+
(breadcrumbToFilter: Breadcrumb | undefined, filter: BreadcrumbFilter) =>
69+
applyBreadcrumbFilter(breadcrumbToFilter, filter),
70+
breadcrumb,
71+
);
72+
} catch (e) {
73+
return undefined;
74+
}
75+
}
76+
5577
function configureTraceKit(options: ParsedStackOptions) {
5678
const TraceKit = getTraceKit();
5779
// Include before + after + source line.
@@ -191,9 +213,15 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
191213
}
192214

193215
addBreadcrumb(breadcrumb: Breadcrumb): void {
194-
this._breadcrumbs.push(breadcrumb);
195-
if (this._breadcrumbs.length > this._maxBreadcrumbs) {
196-
this._breadcrumbs.shift();
216+
const filtered = applyBreadcrumbFilters(
217+
breadcrumb,
218+
this._options.breadcrumbs.breadcrumbFilters,
219+
);
220+
if (filtered !== undefined) {
221+
this._breadcrumbs.push(filtered);
222+
if (this._breadcrumbs.length > this._maxBreadcrumbs) {
223+
this._breadcrumbs.shift();
224+
}
197225
}
198226
}
199227

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Breadcrumb } from './Breadcrumb';
12
import { Collector } from './Collector';
23

34
/**
@@ -22,7 +23,17 @@ export interface UrlFilter {
2223
(url: string): string;
2324
}
2425

25-
export interface HttpBreadCrumbOptions {
26+
/**
27+
* Interface for breadcrumb filters.
28+
*
29+
* Given a breadcrumb the filter may return a modified breadcrumb or undefined to
30+
* exclude the breadcrumb.
31+
*/
32+
export interface BreadcrumbFilter {
33+
(breadcrumb: Breadcrumb): Breadcrumb | undefined;
34+
}
35+
36+
export interface HttpBreadcrumbOptions {
2637
/**
2738
* If fetch should be instrumented and breadcrumbs included for fetch requests.
2839
*
@@ -131,7 +142,38 @@ export interface Options {
131142
* http: false
132143
* ```
133144
*/
134-
http?: HttpBreadCrumbOptions | false;
145+
http?: HttpBreadcrumbOptions | false;
146+
147+
/**
148+
* Custom breadcrumb filters.
149+
*
150+
* Can be used to redact or modify breadcrumbs.
151+
*
152+
* Example:
153+
* ```
154+
* // We want to redact any click events that include the message 'sneaky-button'
155+
* breadcrumbFilters: [
156+
* (breadcrumb) => {
157+
* if(
158+
* breadcrumb.class === 'ui' &&
159+
* breadcrumb.type === 'click' &&
160+
* breadcrumb.message?.includes('sneaky-button')
161+
* ) {
162+
* return;
163+
* }
164+
* return breadcrumb;
165+
* }
166+
* ]
167+
* ```
168+
*
169+
* If you want to redact or modify URLs in breadcrumbs, then a urlFilter should be used.
170+
*
171+
* If any breadcrumFilters throw an exception while processing a breadcrumb, then that breadcrumb will be excluded.
172+
*
173+
* If any breadcrumbFilter cannot be executed, for example because it is not a function, then all breadcrumbs will
174+
* be excluded.
175+
*/
176+
breadcrumbFilters?: BreadcrumbFilter[];
135177
};
136178

137179
/**

packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ export default function decorateFetch(callback: (breadcrumb: HttpBreadcrumb) =>
7777
return response;
7878
});
7979
}
80-
wrapper.prototype = originalFetch.prototype;
80+
81+
wrapper.prototype = originalFetch?.prototype;
8182

8283
try {
8384
// Use defineProperty to prevent this value from being enumerable.

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Collector } from './api/Collector';
2-
import { HttpBreadCrumbOptions, Options, StackOptions, UrlFilter } from './api/Options';
2+
import {
3+
BreadcrumbFilter,
4+
HttpBreadcrumbOptions,
5+
Options,
6+
StackOptions,
7+
UrlFilter,
8+
} from './api/Options';
39
import { MinLogger } from './MinLogger';
410

511
export function defaultOptions(): ParsedOptions {
@@ -14,6 +20,7 @@ export function defaultOptions(): ParsedOptions {
1420
instrumentFetch: true,
1521
instrumentXhr: true,
1622
},
23+
breadcrumbFilters: [],
1724
},
1825
stack: {
1926
source: {
@@ -55,7 +62,7 @@ function itemOrDefault<T>(item: T | undefined, defaultValue: T, checker?: (item:
5562
}
5663

5764
function parseHttp(
58-
options: HttpBreadCrumbOptions | false | undefined,
65+
options: HttpBreadcrumbOptions | false | undefined,
5966
defaults: ParsedHttpOptions,
6067
logger?: MinLogger,
6168
): ParsedHttpOptions {
@@ -163,6 +170,11 @@ export default function parse(options: Options, logger?: MinLogger): ParsedOptio
163170
checkBasic('boolean', 'breadcrumbs.keyboardInput', logger),
164171
),
165172
http: parseHttp(options.breadcrumbs?.http, defaults.breadcrumbs.http, logger),
173+
breadcrumbFilters: itemOrDefault(
174+
options.breadcrumbs?.breadcrumbFilters,
175+
defaults.breadcrumbs.breadcrumbFilters,
176+
checkBasic('array', 'breadcrumbs.breadcrumbFilters', logger),
177+
),
166178
},
167179
stack: parseStack(options.stack, defaults.stack),
168180
maxPendingEvents: itemOrDefault(
@@ -271,6 +283,11 @@ export interface ParsedOptions {
271283
* Settings for http instrumentation and breadcrumbs.
272284
*/
273285
http: ParsedHttpOptions;
286+
287+
/**
288+
* Custom breadcrumb filters.
289+
*/
290+
breadcrumbFilters: BreadcrumbFilter[];
274291
};
275292

276293
/**

0 commit comments

Comments
 (0)