Skip to content

Commit 32cec6a

Browse files
authored
fix: Fix issue processing URLs for fetch and XHR requests. (#783)
A URL filter was throwing an exception. This fixes the filter to prevent that exception, but it also makes the fetch/xhr wrappers more resilient to ensure we do not prevent requests from functioning when a filter fails.
1 parent a955174 commit 32cec6a

File tree

9 files changed

+206
-23
lines changed

9 files changed

+206
-23
lines changed

packages/telemetry/browser-telemetry/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Telemetry integration for LaunchDarkly browser SDKs.
22

3-
43
[![NPM][browser-telemetry-npm-badge]][browser-telemetry-npm-link]
54
[![Actions Status][browser-telemetry-ci-badge]][browser-telemetry-ci]
65
[![Documentation][browser-telemetry-ghp-badge]][browser-telemetry-ghp-link]

packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HttpBreadcrumb } from '../../../src/api/Breadcrumb';
2+
import { MinLogger } from '../../../src/api/MinLogger';
23
import { Recorder } from '../../../src/api/Recorder';
34
import FetchCollector from '../../../src/collectors/http/fetch';
45

@@ -140,3 +141,64 @@ describe('given a FetchCollector with a mock recorder', () => {
140141
);
141142
});
142143
});
144+
145+
describe('given a FetchCollector with a URL filter that throws an error', () => {
146+
let mockRecorder: Recorder;
147+
let collector: FetchCollector;
148+
let mockLogger: MinLogger;
149+
beforeEach(() => {
150+
mockLogger = {
151+
warn: jest.fn(),
152+
};
153+
// Create mock recorder
154+
mockRecorder = {
155+
addBreadcrumb: jest.fn(),
156+
captureError: jest.fn(),
157+
captureErrorEvent: jest.fn(),
158+
};
159+
collector = new FetchCollector({
160+
urlFilters: [
161+
() => {
162+
throw new Error('test error');
163+
},
164+
],
165+
getLogger: () => mockLogger,
166+
});
167+
});
168+
169+
it('logs an error if it fails to filter a breadcrumb', async () => {
170+
collector.register(mockRecorder, 'test-session');
171+
172+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
173+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
174+
175+
await fetch('https://api.example.com/data');
176+
177+
expect(mockLogger.warn).toHaveBeenCalledWith(
178+
'Error filtering http breadcrumb',
179+
new Error('test error'),
180+
);
181+
expect(initialFetch).toHaveBeenCalledWith('https://api.example.com/data');
182+
183+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
184+
});
185+
186+
it('only logs the filter error once for multiple requests', async () => {
187+
collector.register(mockRecorder, 'test-session');
188+
189+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
190+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
191+
192+
// Make multiple fetch calls that will trigger the filter error
193+
await fetch('https://api.example.com/data');
194+
await fetch('https://api.example.com/data2');
195+
await fetch('https://api.example.com/data3');
196+
197+
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
198+
expect(mockLogger.warn).toHaveBeenCalledWith(
199+
'Error filtering http breadcrumb',
200+
new Error('test error'),
201+
);
202+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
203+
});
204+
});

packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HttpBreadcrumb } from '../../../src/api/Breadcrumb';
2+
import { MinLogger } from '../../../src/api/MinLogger';
23
import { Recorder } from '../../../src/api/Recorder';
34
import XhrCollector from '../../../src/collectors/http/xhr';
45

@@ -137,3 +138,70 @@ it('applies URL filters to requests', () => {
137138
afterEach(() => {
138139
window.XMLHttpRequest = initialXhr;
139140
});
141+
142+
describe('given a XhrCollector with a URL filter that throws an error', () => {
143+
let mockRecorder: Recorder;
144+
let collector: XhrCollector;
145+
let mockLogger: MinLogger;
146+
beforeEach(() => {
147+
mockLogger = {
148+
warn: jest.fn(),
149+
};
150+
mockRecorder = {
151+
addBreadcrumb: jest.fn(),
152+
captureError: jest.fn(),
153+
captureErrorEvent: jest.fn(),
154+
};
155+
collector = new XhrCollector({
156+
urlFilters: [
157+
() => {
158+
throw new Error('test error');
159+
},
160+
],
161+
getLogger: () => mockLogger,
162+
});
163+
});
164+
165+
it('logs an error if it fails to filter a breadcrumb', async () => {
166+
collector.register(mockRecorder, 'test-session');
167+
168+
const xhr = new XMLHttpRequest();
169+
xhr.open('GET', 'https://api.example.com/data?token=secret123');
170+
xhr.send();
171+
172+
Object.defineProperty(xhr, 'status', { value: 200 });
173+
xhr.dispatchEvent(new Event('loadend'));
174+
175+
expect(mockLogger.warn).toHaveBeenCalledWith(
176+
'Error filtering http breadcrumb',
177+
new Error('test error'),
178+
);
179+
180+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
181+
});
182+
183+
it('only logs the filter error once for multiple requests', async () => {
184+
collector.register(mockRecorder, 'test-session');
185+
186+
const xhr = new XMLHttpRequest();
187+
xhr.open('GET', 'https://api.example.com/data?token=secret123');
188+
xhr.send();
189+
190+
Object.defineProperty(xhr, 'status', { value: 200 });
191+
xhr.dispatchEvent(new Event('loadend'));
192+
193+
const xhr2 = new XMLHttpRequest();
194+
xhr2.open('GET', 'https://api.example.com/data?token=secret123');
195+
xhr2.send();
196+
197+
Object.defineProperty(xhr2, 'status', { value: 200 });
198+
xhr2.dispatchEvent(new Event('loadend'));
199+
200+
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
201+
expect(mockLogger.warn).toHaveBeenCalledWith(
202+
'Error filtering http breadcrumb',
203+
new Error('test error'),
204+
);
205+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
206+
});
207+
});

packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,11 @@ it('filters out username and password from URLs', () => {
6363
expect(defaultUrlFilter(input)).toBe(expected);
6464
});
6565
});
66+
67+
it('can handle partial URLs', () => {
68+
expect(defaultUrlFilter('/partial/url')).toBe('/partial/url');
69+
});
70+
71+
it('can handle invalid URLs', () => {
72+
expect(defaultUrlFilter('invalid url')).toBe('invalid url');
73+
});

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,6 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
114114

115115
private _registrationComplete: boolean = false;
116116

117-
// Used to ensure we only log the event dropped message once.
118-
private _clientRegistered: boolean = false;
119117
// Used to ensure we only log the event dropped message once.
120118
private _eventsDropped: boolean = false;
121119
// Used to ensure we only log the breadcrumb filter error once.
@@ -133,6 +131,10 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
133131
this._maxPendingEvents = _options.maxPendingEvents;
134132
this._maxBreadcrumbs = _options.breadcrumbs.maxBreadcrumbs;
135133

134+
// Set the initial logger, it may be replaced when the client is registered.
135+
// For typescript purposes, we need the logger to be directly set in the constructor.
136+
this._logger = this._options.logger ?? fallbackLogger;
137+
136138
const urlFilters = [defaultUrlFilter];
137139
if (_options.breadcrumbs.http.customUrlFilter) {
138140
urlFilters.push(_options.breadcrumbs.http.customUrlFilter);
@@ -142,6 +144,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
142144
this._collectors.push(
143145
new FetchCollector({
144146
urlFilters,
147+
getLogger: () => this._logger,
145148
}),
146149
);
147150
}
@@ -150,6 +153,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
150153
this._collectors.push(
151154
new XhrCollector({
152155
urlFilters,
156+
getLogger: () => this._logger,
153157
}),
154158
);
155159
}
@@ -170,10 +174,6 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
170174
const inspectors: BrowserTelemetryInspector[] = [];
171175
makeInspectors(_options, inspectors, impl);
172176
this._inspectorInstances.push(...inspectors);
173-
174-
// Set the initial logger, it may be replaced when the client is registered.
175-
// For typescript purposes, we need the logger to be directly set in the constructor.
176-
this._logger = this._options.logger ?? fallbackLogger;
177177
}
178178

179179
register(client: LDClientTracking): void {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MinLogger } from '../../api/MinLogger';
12
import { UrlFilter } from '../../api/Options';
23

34
/**
@@ -10,4 +11,12 @@ export default interface HttpCollectorOptions {
1011
* This allows for redaction of potentially sensitive information in URLs.
1112
*/
1213
urlFilters: UrlFilter[];
14+
15+
/**
16+
* Method to get a logger for warnings.
17+
*
18+
* This is a function to allow for accessing the current logger, as the logger
19+
* instance may change during runtime.
20+
*/
21+
getLogger?: () => MinLogger;
1322
}

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,25 @@ import HttpCollectorOptions from './HttpCollectorOptions';
99
*/
1010
export default class FetchCollector implements Collector {
1111
private _destination?: Recorder;
12+
private _loggedIssue: boolean = false;
1213

1314
constructor(options: HttpCollectorOptions) {
1415
decorateFetch((breadcrumb) => {
15-
filterHttpBreadcrumb(breadcrumb, options);
16-
this._destination?.addBreadcrumb(breadcrumb);
16+
let filtersExecuted = false;
17+
try {
18+
filterHttpBreadcrumb(breadcrumb, options);
19+
filtersExecuted = true;
20+
} catch (err) {
21+
if (!this._loggedIssue) {
22+
options.getLogger?.()?.warn('Error filtering http breadcrumb', err);
23+
this._loggedIssue = true;
24+
}
25+
}
26+
// Only add the breadcrumb if the filter didn't throw. We don't want to
27+
// report a breadcrumb that may have not have had the correct information redacted.
28+
if (filtersExecuted) {
29+
this._destination?.addBreadcrumb(breadcrumb);
30+
}
1731
});
1832
}
1933

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,25 @@ import decorateXhr from './xhrDecorator';
1010
*/
1111
export default class XhrCollector implements Collector {
1212
private _destination?: Recorder;
13+
private _loggedIssue: boolean = false;
1314

1415
constructor(options: HttpCollectorOptions) {
1516
decorateXhr((breadcrumb) => {
16-
filterHttpBreadcrumb(breadcrumb, options);
17-
this._destination?.addBreadcrumb(breadcrumb);
17+
let filtersExecuted = false;
18+
try {
19+
filterHttpBreadcrumb(breadcrumb, options);
20+
filtersExecuted = true;
21+
} catch (err) {
22+
if (!this._loggedIssue) {
23+
options.getLogger?.()?.warn('Error filtering http breadcrumb', err);
24+
this._loggedIssue = true;
25+
}
26+
}
27+
// Only add the breadcrumb if the filter didn't throw. We don't want to
28+
// report a breadcrumb that may have not have had the correct information redacted.
29+
if (filtersExecuted) {
30+
this._destination?.addBreadcrumb(breadcrumb);
31+
}
1832
});
1933
}
2034

packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,27 @@ function authorityUrlFilter(url: string): string {
1414
// This will work in browser environments, but in the future we may want to consider an approach
1515
// which doesn't rely on the browser's URL parsing. This is because other environments we may
1616
// want to target, such as ReactNative, may not have as robust URL parsing.
17-
const urlObj = new URL(url);
18-
let hadAuth = false;
19-
if (urlObj.username) {
20-
urlObj.username = 'redacted';
21-
hadAuth = true;
22-
}
23-
if (urlObj.password) {
24-
urlObj.password = 'redacted';
25-
hadAuth = true;
26-
}
27-
if (hadAuth) {
28-
return urlObj.toString();
17+
// We first check if the URL can be parsed, because it may not include the base URL.
18+
try {
19+
// If the URL includes a protocol, if so, then it can probably be parsed.
20+
// Credentials require a full URL.
21+
if (url.includes('://')) {
22+
const urlObj = new URL(url);
23+
let hadAuth = false;
24+
if (urlObj.username) {
25+
urlObj.username = 'redacted';
26+
hadAuth = true;
27+
}
28+
if (urlObj.password) {
29+
urlObj.password = 'redacted';
30+
hadAuth = true;
31+
}
32+
if (hadAuth) {
33+
return urlObj.toString();
34+
}
35+
}
36+
} catch {
37+
// Could not parse the URL.
2938
}
3039
// If there was no auth information, then we don't need to modify the URL.
3140
return url;

0 commit comments

Comments
 (0)