Skip to content

Commit

Permalink
feat: Add support for breadcrumb filtering. (#733)
Browse files Browse the repository at this point in the history
This PR adds the ability to specify a filter that runs when adding any
breadcrumb. The filter can return a modified breadcrumb, or return
undefined to filter the breadcrumb entirely.

This could be used to redact any information which a customer doesn't
want in a bread crumb.

---------

Co-authored-by: Stacy Harrison <[email protected]>
  • Loading branch information
kinyoklion and stasquatch authored Jan 16, 2025
1 parent bc4a994 commit 5c327a1
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const defaultOptions: ParsedOptions = {
},
evaluations: true,
flagChange: true,
filters: [],
},
stack: {
source: {
Expand Down Expand Up @@ -208,3 +209,153 @@ it('unregisters collectors on close', () => {

expect(mockCollector.unregister).toHaveBeenCalled();
});

it('filters breadcrumbs using provided filters', () => {
const options: ParsedOptions = {
...defaultOptions,
breadcrumbs: {
...defaultOptions.breadcrumbs,
click: false,
evaluations: false,
flagChange: false,
http: { instrumentFetch: false, instrumentXhr: false },
keyboardInput: false,
filters: [
// Filter to remove breadcrumbs with id:2
(breadcrumb) => {
if (breadcrumb.type === 'custom' && breadcrumb.data?.id === 2) {
return undefined;
}
return breadcrumb;
},
// Filter to transform breadcrumbs with id:3
(breadcrumb) => {
if (breadcrumb.type === 'custom' && breadcrumb.data?.id === 3) {
return {
...breadcrumb,
data: { id: 'filtered-3' },
};
}
return breadcrumb;
},
],
},
};
const telemetry = new BrowserTelemetryImpl(options);

telemetry.addBreadcrumb({
type: 'custom',
data: { id: 1 },
timestamp: Date.now(),
class: 'custom',
level: 'info',
});

telemetry.addBreadcrumb({
type: 'custom',
data: { id: 2 },
timestamp: Date.now(),
class: 'custom',
level: 'info',
});

telemetry.addBreadcrumb({
type: 'custom',
data: { id: 3 },
timestamp: Date.now(),
class: 'custom',
level: 'info',
});

const error = new Error('Test error');
telemetry.captureError(error);
telemetry.register(mockClient);

expect(mockClient.track).toHaveBeenCalledWith(
'$ld:telemetry:error',
expect.objectContaining({
breadcrumbs: expect.arrayContaining([
expect.objectContaining({ data: { id: 1 } }),
expect.objectContaining({ data: { id: 'filtered-3' } }),
]),
}),
);

// Verify breadcrumb with id:2 was filtered out
expect(mockClient.track).toHaveBeenCalledWith(
'$ld:telemetry:error',
expect.objectContaining({
breadcrumbs: expect.not.arrayContaining([expect.objectContaining({ data: { id: 2 } })]),
}),
);
});

it('omits breadcrumb when a filter throws an exception', () => {
const breadSpy = jest.fn((breadcrumb) => breadcrumb);
const options: ParsedOptions = {
...defaultOptions,
breadcrumbs: {
...defaultOptions.breadcrumbs,
filters: [
() => {
throw new Error('Filter error');
},
// This filter should never run
breadSpy,
],
},
};
const telemetry = new BrowserTelemetryImpl(options);

telemetry.addBreadcrumb({
type: 'custom',
data: { id: 1 },
timestamp: Date.now(),
class: 'custom',
level: 'info',
});

const error = new Error('Test error');
telemetry.captureError(error);
telemetry.register(mockClient);

expect(mockClient.track).toHaveBeenCalledWith(
'$ld:telemetry:error',
expect.objectContaining({
breadcrumbs: [],
}),
);

expect(breadSpy).not.toHaveBeenCalled();
});

it('omits breadcrumbs when a filter is not a function', () => {
const options: ParsedOptions = {
...defaultOptions,
breadcrumbs: {
...defaultOptions.breadcrumbs,
// @ts-ignore
filters: ['potato'],
},
};
const telemetry = new BrowserTelemetryImpl(options);

telemetry.addBreadcrumb({
type: 'custom',
data: { id: 1 },
timestamp: Date.now(),
class: 'custom',
level: 'info',
});

const error = new Error('Test error');
telemetry.captureError(error);
telemetry.register(mockClient);

expect(mockClient.track).toHaveBeenCalledWith(
'$ld:telemetry:error',
expect.objectContaining({
breadcrumbs: [],
}),
);
});
18 changes: 18 additions & 0 deletions packages/telemetry/browser-telemetry/__tests__/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ it('can set all options at once', () => {
click: false,
evaluations: false,
flagChange: false,
filters: [(breadcrumb) => breadcrumb],
},
collectors: [new ErrorCollector(), new ErrorCollector()],
});
Expand All @@ -38,6 +39,7 @@ it('can set all options at once', () => {
instrumentFetch: true,
instrumentXhr: true,
},
filters: expect.any(Array),
},
stack: {
source: {
Expand Down Expand Up @@ -420,3 +422,19 @@ it('warns when breadcrumbs.http.customUrlFilter is not a function', () => {
'The "breadcrumbs.http.customUrlFilter" must be a function. Received string',
);
});

it('warns when filters is not an array', () => {
const outOptions = parse(
{
breadcrumbs: {
// @ts-ignore
filters: 'not an array',
},
},
mockLogger,
);
expect(outOptions.breadcrumbs.filters).toEqual([]);
expect(mockLogger.warn).toHaveBeenCalledWith(
'Config option "breadcrumbs.filters" should be of type array, got string, using default value',
);
});
33 changes: 29 additions & 4 deletions packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import type { LDContext, LDEvaluationDetail, LDInspection } from '@launchdarkly/js-client-sdk';

import { LDClientTracking } from './api';
import { BreadcrumbFilter, LDClientTracking } from './api';
import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb';
import { BrowserTelemetry } from './api/BrowserTelemetry';
import { Collector } from './api/Collector';
Expand Down Expand Up @@ -52,6 +52,28 @@ function safeValue(u: unknown): string | boolean | number | undefined {
}
}

function applyBreadcrumbFilter(
breadcrumb: Breadcrumb | undefined,
filter: BreadcrumbFilter,
): Breadcrumb | undefined {
return breadcrumb === undefined ? undefined : filter(breadcrumb);
}

function applyBreadcrumbFilters(
breadcrumb: Breadcrumb,
filters: BreadcrumbFilter[],
): Breadcrumb | undefined {
try {
return filters.reduce(
(breadcrumbToFilter: Breadcrumb | undefined, filter: BreadcrumbFilter) =>
applyBreadcrumbFilter(breadcrumbToFilter, filter),
breadcrumb,
);
} catch (e) {
return undefined;
}
}

function configureTraceKit(options: ParsedStackOptions) {
const TraceKit = getTraceKit();
// Include before + after + source line.
Expand Down Expand Up @@ -191,9 +213,12 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
}

addBreadcrumb(breadcrumb: Breadcrumb): void {
this._breadcrumbs.push(breadcrumb);
if (this._breadcrumbs.length > this._maxBreadcrumbs) {
this._breadcrumbs.shift();
const filtered = applyBreadcrumbFilters(breadcrumb, this._options.breadcrumbs.filters);
if (filtered !== undefined) {
this._breadcrumbs.push(filtered);
if (this._breadcrumbs.length > this._maxBreadcrumbs) {
this._breadcrumbs.shift();
}
}
}

Expand Down
46 changes: 44 additions & 2 deletions packages/telemetry/browser-telemetry/src/api/Options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Breadcrumb } from './Breadcrumb';
import { Collector } from './Collector';

/**
Expand All @@ -22,7 +23,17 @@ export interface UrlFilter {
(url: string): string;
}

export interface HttpBreadCrumbOptions {
/**
* Interface for breadcrumb filters.
*
* Given a breadcrumb the filter may return a modified breadcrumb or undefined to
* exclude the breadcrumb.
*/
export interface BreadcrumbFilter {
(breadcrumb: Breadcrumb): Breadcrumb | undefined;
}

export interface HttpBreadcrumbOptions {
/**
* If fetch should be instrumented and breadcrumbs included for fetch requests.
*
Expand Down Expand Up @@ -131,7 +142,38 @@ export interface Options {
* http: false
* ```
*/
http?: HttpBreadCrumbOptions | false;
http?: HttpBreadcrumbOptions | false;

/**
* Custom breadcrumb filters.
*
* Can be used to redact or modify breadcrumbs.
*
* Example:
* ```
* // We want to redact any click events that include the message 'sneaky-button'
* filters: [
* (breadcrumb) => {
* if(
* breadcrumb.class === 'ui' &&
* breadcrumb.type === 'click' &&
* breadcrumb.message?.includes('sneaky-button')
* ) {
* return;
* }
* return breadcrumb;
* }
* ]
* ```
*
* If you want to redact or modify URLs in breadcrumbs, then a urlFilter should be used.
*
* If any breadcrumb filters throw an exception while processing a breadcrumb, then that breadcrumb will be excluded.
*
* If any breadcrumbFilter cannot be executed, for example because it is not a function, then all breadcrumbs will
* be excluded.
*/
filters?: BreadcrumbFilter[];
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ export default function decorateFetch(callback: (breadcrumb: HttpBreadcrumb) =>
return response;
});
}
wrapper.prototype = originalFetch.prototype;

wrapper.prototype = originalFetch?.prototype;

try {
// Use defineProperty to prevent this value from being enumerable.
Expand Down
21 changes: 19 additions & 2 deletions packages/telemetry/browser-telemetry/src/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Collector } from './api/Collector';
import { HttpBreadCrumbOptions, Options, StackOptions, UrlFilter } from './api/Options';
import {
BreadcrumbFilter,
HttpBreadcrumbOptions,
Options,
StackOptions,
UrlFilter,
} from './api/Options';
import { MinLogger } from './MinLogger';

export function defaultOptions(): ParsedOptions {
Expand All @@ -14,6 +20,7 @@ export function defaultOptions(): ParsedOptions {
instrumentFetch: true,
instrumentXhr: true,
},
filters: [],
},
stack: {
source: {
Expand Down Expand Up @@ -55,7 +62,7 @@ function itemOrDefault<T>(item: T | undefined, defaultValue: T, checker?: (item:
}

function parseHttp(
options: HttpBreadCrumbOptions | false | undefined,
options: HttpBreadcrumbOptions | false | undefined,
defaults: ParsedHttpOptions,
logger?: MinLogger,
): ParsedHttpOptions {
Expand Down Expand Up @@ -163,6 +170,11 @@ export default function parse(options: Options, logger?: MinLogger): ParsedOptio
checkBasic('boolean', 'breadcrumbs.keyboardInput', logger),
),
http: parseHttp(options.breadcrumbs?.http, defaults.breadcrumbs.http, logger),
filters: itemOrDefault(
options.breadcrumbs?.filters,
defaults.breadcrumbs.filters,
checkBasic('array', 'breadcrumbs.filters', logger),
),
},
stack: parseStack(options.stack, defaults.stack),
maxPendingEvents: itemOrDefault(
Expand Down Expand Up @@ -271,6 +283,11 @@ export interface ParsedOptions {
* Settings for http instrumentation and breadcrumbs.
*/
http: ParsedHttpOptions;

/**
* Custom breadcrumb filters.
*/
filters: BreadcrumbFilter[];
};

/**
Expand Down

0 comments on commit 5c327a1

Please sign in to comment.