From 5c327a1c42625ec606a8599f59d58a1686f050e1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:44:51 -0800 Subject: [PATCH] feat: Add support for breadcrumb filtering. (#733) 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 <11901347+stasquatch@users.noreply.github.com> --- .../__tests__/BrowserTelemetryImpl.test.ts | 151 ++++++++++++++++++ .../__tests__/options.test.ts | 18 +++ .../src/BrowserTelemetryImpl.ts | 33 +++- .../browser-telemetry/src/api/Options.ts | 46 +++++- .../src/collectors/http/fetchDecorator.ts | 3 +- .../browser-telemetry/src/options.ts | 21 ++- 6 files changed, 263 insertions(+), 9 deletions(-) diff --git a/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts b/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts index 8233f46fb4..bb27da82ce 100644 --- a/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts @@ -22,6 +22,7 @@ const defaultOptions: ParsedOptions = { }, evaluations: true, flagChange: true, + filters: [], }, stack: { source: { @@ -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: [], + }), + ); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/options.test.ts b/packages/telemetry/browser-telemetry/__tests__/options.test.ts index 1d5004ba08..c3c51f7314 100644 --- a/packages/telemetry/browser-telemetry/__tests__/options.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/options.test.ts @@ -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()], }); @@ -38,6 +39,7 @@ it('can set all options at once', () => { instrumentFetch: true, instrumentXhr: true, }, + filters: expect.any(Array), }, stack: { source: { @@ -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', + ); +}); diff --git a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts index 6de65b1ed6..2e16a4f249 100644 --- a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts +++ b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts @@ -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'; @@ -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. @@ -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(); + } } } diff --git a/packages/telemetry/browser-telemetry/src/api/Options.ts b/packages/telemetry/browser-telemetry/src/api/Options.ts index 4238af19b6..ea8de25b06 100644 --- a/packages/telemetry/browser-telemetry/src/api/Options.ts +++ b/packages/telemetry/browser-telemetry/src/api/Options.ts @@ -1,3 +1,4 @@ +import { Breadcrumb } from './Breadcrumb'; import { Collector } from './Collector'; /** @@ -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. * @@ -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[]; }; /** diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts index 85dea49da7..d87d26d07d 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts @@ -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. diff --git a/packages/telemetry/browser-telemetry/src/options.ts b/packages/telemetry/browser-telemetry/src/options.ts index a801f5ed47..8621fcc7b8 100644 --- a/packages/telemetry/browser-telemetry/src/options.ts +++ b/packages/telemetry/browser-telemetry/src/options.ts @@ -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 { @@ -14,6 +20,7 @@ export function defaultOptions(): ParsedOptions { instrumentFetch: true, instrumentXhr: true, }, + filters: [], }, stack: { source: { @@ -55,7 +62,7 @@ function itemOrDefault(item: T | undefined, defaultValue: T, checker?: (item: } function parseHttp( - options: HttpBreadCrumbOptions | false | undefined, + options: HttpBreadcrumbOptions | false | undefined, defaults: ParsedHttpOptions, logger?: MinLogger, ): ParsedHttpOptions { @@ -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( @@ -271,6 +283,11 @@ export interface ParsedOptions { * Settings for http instrumentation and breadcrumbs. */ http: ParsedHttpOptions; + + /** + * Custom breadcrumb filters. + */ + filters: BreadcrumbFilter[]; }; /**