Skip to content

Commit 6e60ddc

Browse files
authored
feat: Add http collectors. (#673)
Best reviewed after: #672
1 parent 4473a06 commit 6e60ddc

File tree

13 files changed

+700
-0
lines changed

13 files changed

+700
-0
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { HttpBreadcrumb } from '../../../src/api/Breadcrumb';
2+
import { Recorder } from '../../../src/api/Recorder';
3+
import FetchCollector from '../../../src/collectors/http/fetch';
4+
5+
const initialFetch = window.fetch;
6+
7+
describe('given a FetchCollector with a mock recorder', () => {
8+
let mockRecorder: Recorder;
9+
let collector: FetchCollector;
10+
11+
beforeEach(() => {
12+
// Create mock recorder
13+
mockRecorder = {
14+
addBreadcrumb: jest.fn(),
15+
captureError: jest.fn(),
16+
captureErrorEvent: jest.fn(),
17+
};
18+
// Create collector with default options
19+
collector = new FetchCollector({
20+
urlFilters: [], // Add required urlFilters property
21+
});
22+
});
23+
24+
it('registers recorder and uses it for fetch calls', async () => {
25+
collector.register(mockRecorder, 'test-session');
26+
27+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
28+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
29+
30+
await fetch('https://api.example.com/data', {
31+
method: 'POST',
32+
body: JSON.stringify({ test: true }),
33+
});
34+
35+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
36+
expect.objectContaining<HttpBreadcrumb>({
37+
class: 'http',
38+
type: 'fetch',
39+
level: 'info',
40+
timestamp: expect.any(Number),
41+
data: {
42+
method: 'POST',
43+
url: 'https://api.example.com/data',
44+
statusCode: 200,
45+
statusText: 'OK',
46+
},
47+
}),
48+
);
49+
});
50+
51+
it('stops adding breadcrumbs after unregistering', async () => {
52+
collector.register(mockRecorder, 'test-session');
53+
collector.unregister();
54+
55+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
56+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
57+
58+
await fetch('https://api.example.com/data');
59+
60+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
61+
});
62+
63+
it('filters URLs based on provided options', async () => {
64+
collector = new FetchCollector({
65+
urlFilters: [(url: string) => url.replace(/token=.*/, 'token=REDACTED')], // Convert urlFilter to urlFilters array
66+
});
67+
collector.register(mockRecorder, 'test-session');
68+
69+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
70+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
71+
72+
await fetch('https://api.example.com/data?token=secret123');
73+
74+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
75+
expect.objectContaining<HttpBreadcrumb>({
76+
data: {
77+
method: 'GET',
78+
url: 'https://api.example.com/data?token=REDACTED',
79+
statusCode: 200,
80+
statusText: 'OK',
81+
},
82+
class: 'http',
83+
timestamp: expect.any(Number),
84+
level: 'info',
85+
type: 'fetch',
86+
}),
87+
);
88+
});
89+
90+
it('handles fetch calls with Request objects', async () => {
91+
collector.register(mockRecorder, 'test-session');
92+
93+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
94+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
95+
96+
const request = new Request('https://api.example.com/data', {
97+
method: 'PUT',
98+
body: JSON.stringify({ test: true }),
99+
});
100+
await fetch(request);
101+
102+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
103+
expect.objectContaining<HttpBreadcrumb>({
104+
data: {
105+
method: 'PUT',
106+
url: 'https://api.example.com/data',
107+
statusCode: 200,
108+
statusText: 'OK',
109+
},
110+
class: 'http',
111+
timestamp: expect.any(Number),
112+
level: 'info',
113+
type: 'fetch',
114+
}),
115+
);
116+
});
117+
118+
it('handles fetch calls with URL objects', async () => {
119+
collector.register(mockRecorder, 'test-session');
120+
121+
const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
122+
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);
123+
124+
const url = new URL('https://api.example.com/data');
125+
await fetch(url);
126+
127+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
128+
expect.objectContaining<HttpBreadcrumb>({
129+
data: {
130+
method: 'GET',
131+
url: 'https://api.example.com/data',
132+
statusCode: 200,
133+
statusText: 'OK',
134+
},
135+
class: 'http',
136+
timestamp: expect.any(Number),
137+
level: 'info',
138+
type: 'fetch',
139+
}),
140+
);
141+
});
142+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { HttpBreadcrumb } from '../../../src/api/Breadcrumb';
2+
import { Recorder } from '../../../src/api/Recorder';
3+
import XhrCollector from '../../../src/collectors/http/xhr';
4+
5+
const initialXhr = window.XMLHttpRequest;
6+
7+
it('registers recorder and uses it for xhr calls', () => {
8+
const mockRecorder: Recorder = {
9+
addBreadcrumb: jest.fn(),
10+
captureError: jest.fn(),
11+
captureErrorEvent: jest.fn(),
12+
};
13+
14+
const collector = new XhrCollector({
15+
urlFilters: [],
16+
});
17+
18+
collector.register(mockRecorder, 'test-session');
19+
20+
const xhr = new XMLHttpRequest();
21+
xhr.open('POST', 'https://api.example.com/data');
22+
xhr.send(JSON.stringify({ test: true }));
23+
24+
// Simulate successful response
25+
Object.defineProperty(xhr, 'status', { value: 200 });
26+
Object.defineProperty(xhr, 'statusText', { value: 'OK' });
27+
xhr.dispatchEvent(new Event('loadend'));
28+
29+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
30+
expect.objectContaining<HttpBreadcrumb>({
31+
class: 'http',
32+
type: 'xhr',
33+
level: 'info',
34+
timestamp: expect.any(Number),
35+
data: {
36+
method: 'POST',
37+
url: 'https://api.example.com/data',
38+
statusCode: 200,
39+
statusText: 'OK',
40+
},
41+
}),
42+
);
43+
});
44+
45+
it('stops adding breadcrumbs after unregistering', () => {
46+
const mockRecorder: Recorder = {
47+
addBreadcrumb: jest.fn(),
48+
captureError: jest.fn(),
49+
captureErrorEvent: jest.fn(),
50+
};
51+
52+
const collector = new XhrCollector({
53+
urlFilters: [],
54+
});
55+
56+
collector.register(mockRecorder, 'test-session');
57+
collector.unregister();
58+
59+
const xhr = new XMLHttpRequest();
60+
xhr.open('GET', 'https://api.example.com/data');
61+
xhr.send();
62+
63+
xhr.dispatchEvent(new Event('loadend'));
64+
65+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
66+
});
67+
68+
it('marks requests with error events as errors', () => {
69+
const mockRecorder: Recorder = {
70+
addBreadcrumb: jest.fn(),
71+
captureError: jest.fn(),
72+
captureErrorEvent: jest.fn(),
73+
};
74+
75+
const collector = new XhrCollector({
76+
urlFilters: [],
77+
});
78+
79+
collector.register(mockRecorder, 'test-session');
80+
81+
const xhr = new XMLHttpRequest();
82+
xhr.open('GET', 'https://api.example.com/data');
83+
xhr.send();
84+
85+
xhr.dispatchEvent(new Event('error'));
86+
xhr.dispatchEvent(new Event('loadend'));
87+
88+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
89+
expect.objectContaining<HttpBreadcrumb>({
90+
level: 'error',
91+
data: expect.objectContaining({
92+
method: 'GET',
93+
statusCode: 0,
94+
statusText: '',
95+
url: 'https://api.example.com/data',
96+
}),
97+
class: 'http',
98+
timestamp: expect.any(Number),
99+
type: 'xhr',
100+
}),
101+
);
102+
});
103+
104+
it('applies URL filters to requests', () => {
105+
const mockRecorder: Recorder = {
106+
addBreadcrumb: jest.fn(),
107+
captureError: jest.fn(),
108+
captureErrorEvent: jest.fn(),
109+
};
110+
111+
const collector = new XhrCollector({
112+
urlFilters: [(url) => url.replace(/token=.*/, 'token=REDACTED')],
113+
});
114+
115+
collector.register(mockRecorder, 'test-session');
116+
117+
const xhr = new XMLHttpRequest();
118+
xhr.open('GET', 'https://api.example.com/data?token=secret123');
119+
xhr.send();
120+
121+
Object.defineProperty(xhr, 'status', { value: 200 });
122+
xhr.dispatchEvent(new Event('loadend'));
123+
124+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
125+
expect.objectContaining<HttpBreadcrumb>({
126+
data: expect.objectContaining({
127+
url: 'https://api.example.com/data?token=REDACTED',
128+
}),
129+
class: 'http',
130+
timestamp: expect.any(Number),
131+
level: 'info',
132+
type: 'xhr',
133+
}),
134+
);
135+
});
136+
137+
afterEach(() => {
138+
window.XMLHttpRequest = initialXhr;
139+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import defaultUrlFilter from '../../src/filters/defaultUrlFilter';
2+
3+
it('filters polling urls', () => {
4+
// Added -_ to the end as we use those in the base64 URL safe character set.
5+
const context =
6+
'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_';
7+
const filteredCotext =
8+
'************************************************************************************';
9+
const baseUrl = 'https://sdk.launchdarkly.com/sdk/evalx/thesdkkey/contexts/';
10+
const filteredUrl = `${baseUrl}${filteredCotext}`;
11+
const testUrl = `${baseUrl}${context}`;
12+
const testUrlWithReasons = `${testUrl}?withReasons=true`;
13+
const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`;
14+
15+
expect(defaultUrlFilter(testUrl)).toBe(filteredUrl);
16+
expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons);
17+
});
18+
19+
it('filters streaming urls', () => {
20+
// Added -_ to the end as we use those in the base64 URL safe character set.
21+
const context =
22+
'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_';
23+
const filteredCotext =
24+
'************************************************************************************';
25+
const baseUrl = `https://clientstream.launchdarkly.com/eval/thesdkkey/`;
26+
const filteredUrl = `${baseUrl}${filteredCotext}`;
27+
const testUrl = `${baseUrl}${context}`;
28+
const testUrlWithReasons = `${testUrl}?withReasons=true`;
29+
const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`;
30+
31+
expect(defaultUrlFilter(testUrl)).toBe(filteredUrl);
32+
expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons);
33+
});
34+
35+
it.each([
36+
'http://events.launchdarkly.com/events/bulk/thesdkkey',
37+
'http://localhost:8080',
38+
'http://some.other.base64like/eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6vcmciOnsiaIjoidGFjb2h1dCJ9fQ-_',
39+
])('passes through other URLs unfiltered', (url) => {
40+
expect(defaultUrlFilter(url)).toBe(url);
41+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { HttpBreadcrumb } from '../../src/api/Breadcrumb';
2+
import filterHttpBreadcrumb from '../../src/filters/filterHttpBreadcrumb';
3+
4+
it('filters breadcrumbs with the provided filters', () => {
5+
const breadcrumb: HttpBreadcrumb = {
6+
class: 'http',
7+
timestamp: Date.now(),
8+
level: 'info',
9+
type: 'xhr',
10+
data: {
11+
method: 'GET',
12+
url: 'dog',
13+
statusCode: 200,
14+
statusText: 'ok',
15+
},
16+
};
17+
filterHttpBreadcrumb(breadcrumb, {
18+
urlFilters: [(url) => url.replace('dog', 'cat')],
19+
});
20+
expect(breadcrumb.data?.url).toBe('cat');
21+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import filterUrl from '../../src/filters/filterUrl';
2+
3+
it('runs the specified filters in the given order', () => {
4+
const filterA = (url: string): string => url.replace('dog', 'cat');
5+
const filterB = (url: string): string => url.replace('cat', 'mouse');
6+
7+
// dog -> cat -> mouse
8+
expect(filterUrl([filterA, filterB], 'dog')).toBe('mouse');
9+
// dog -> dog -> cat
10+
expect(filterUrl([filterB, filterA], 'dog')).toBe('cat');
11+
// cat -> mouse -> mouse
12+
expect(filterUrl([filterB, filterA], 'cat')).toBe('mouse');
13+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { UrlFilter } from '../../api/Options';
2+
3+
/**
4+
* Options which impact the behavior of http collectors.
5+
*/
6+
export default interface HttpCollectorOptions {
7+
/**
8+
* A list of filters to execute on the URL of the breadcrumb.
9+
*
10+
* This allows for redaction of potentially sensitive information in URLs.
11+
*/
12+
urlFilters: UrlFilter[];
13+
}

0 commit comments

Comments
 (0)