Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
);
});
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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small robustness improvement exposed by running tests in VSCode.


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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistent capitalization change.

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
Loading