Skip to content

Commit

Permalink
feat: Add http collectors. (#673)
Browse files Browse the repository at this point in the history
Best reviewed after: #672
  • Loading branch information
kinyoklion authored Nov 8, 2024
1 parent 4473a06 commit 6e60ddc
Show file tree
Hide file tree
Showing 13 changed files with 700 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { HttpBreadcrumb } from '../../../src/api/Breadcrumb';
import { Recorder } from '../../../src/api/Recorder';
import FetchCollector from '../../../src/collectors/http/fetch';

const initialFetch = window.fetch;

describe('given a FetchCollector with a mock recorder', () => {
let mockRecorder: Recorder;
let collector: FetchCollector;

beforeEach(() => {
// Create mock recorder
mockRecorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};
// Create collector with default options
collector = new FetchCollector({
urlFilters: [], // Add required urlFilters property
});
});

it('registers recorder and uses it for fetch calls', async () => {
collector.register(mockRecorder, 'test-session');

const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);

await fetch('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ test: true }),
});

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
class: 'http',
type: 'fetch',
level: 'info',
timestamp: expect.any(Number),
data: {
method: 'POST',
url: 'https://api.example.com/data',
statusCode: 200,
statusText: 'OK',
},
}),
);
});

it('stops adding breadcrumbs after unregistering', async () => {
collector.register(mockRecorder, 'test-session');
collector.unregister();

const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);

await fetch('https://api.example.com/data');

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});

it('filters URLs based on provided options', async () => {
collector = new FetchCollector({
urlFilters: [(url: string) => url.replace(/token=.*/, 'token=REDACTED')], // Convert urlFilter to urlFilters array
});
collector.register(mockRecorder, 'test-session');

const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);

await fetch('https://api.example.com/data?token=secret123');

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
data: {
method: 'GET',
url: 'https://api.example.com/data?token=REDACTED',
statusCode: 200,
statusText: 'OK',
},
class: 'http',
timestamp: expect.any(Number),
level: 'info',
type: 'fetch',
}),
);
});

it('handles fetch calls with Request objects', async () => {
collector.register(mockRecorder, 'test-session');

const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);

const request = new Request('https://api.example.com/data', {
method: 'PUT',
body: JSON.stringify({ test: true }),
});
await fetch(request);

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
data: {
method: 'PUT',
url: 'https://api.example.com/data',
statusCode: 200,
statusText: 'OK',
},
class: 'http',
timestamp: expect.any(Number),
level: 'info',
type: 'fetch',
}),
);
});

it('handles fetch calls with URL objects', async () => {
collector.register(mockRecorder, 'test-session');

const mockResponse = new Response('test response', { status: 200, statusText: 'OK' });
(initialFetch as jest.Mock).mockResolvedValue(mockResponse);

const url = new URL('https://api.example.com/data');
await fetch(url);

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
data: {
method: 'GET',
url: 'https://api.example.com/data',
statusCode: 200,
statusText: 'OK',
},
class: 'http',
timestamp: expect.any(Number),
level: 'info',
type: 'fetch',
}),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { HttpBreadcrumb } from '../../../src/api/Breadcrumb';
import { Recorder } from '../../../src/api/Recorder';
import XhrCollector from '../../../src/collectors/http/xhr';

const initialXhr = window.XMLHttpRequest;

it('registers recorder and uses it for xhr calls', () => {
const mockRecorder: Recorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};

const collector = new XhrCollector({
urlFilters: [],
});

collector.register(mockRecorder, 'test-session');

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://api.example.com/data');
xhr.send(JSON.stringify({ test: true }));

// Simulate successful response
Object.defineProperty(xhr, 'status', { value: 200 });
Object.defineProperty(xhr, 'statusText', { value: 'OK' });
xhr.dispatchEvent(new Event('loadend'));

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
class: 'http',
type: 'xhr',
level: 'info',
timestamp: expect.any(Number),
data: {
method: 'POST',
url: 'https://api.example.com/data',
statusCode: 200,
statusText: 'OK',
},
}),
);
});

it('stops adding breadcrumbs after unregistering', () => {
const mockRecorder: Recorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};

const collector = new XhrCollector({
urlFilters: [],
});

collector.register(mockRecorder, 'test-session');
collector.unregister();

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.send();

xhr.dispatchEvent(new Event('loadend'));

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});

it('marks requests with error events as errors', () => {
const mockRecorder: Recorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};

const collector = new XhrCollector({
urlFilters: [],
});

collector.register(mockRecorder, 'test-session');

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.send();

xhr.dispatchEvent(new Event('error'));
xhr.dispatchEvent(new Event('loadend'));

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
level: 'error',
data: expect.objectContaining({
method: 'GET',
statusCode: 0,
statusText: '',
url: 'https://api.example.com/data',
}),
class: 'http',
timestamp: expect.any(Number),
type: 'xhr',
}),
);
});

it('applies URL filters to requests', () => {
const mockRecorder: Recorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};

const collector = new XhrCollector({
urlFilters: [(url) => url.replace(/token=.*/, 'token=REDACTED')],
});

collector.register(mockRecorder, 'test-session');

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data?token=secret123');
xhr.send();

Object.defineProperty(xhr, 'status', { value: 200 });
xhr.dispatchEvent(new Event('loadend'));

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<HttpBreadcrumb>({
data: expect.objectContaining({
url: 'https://api.example.com/data?token=REDACTED',
}),
class: 'http',
timestamp: expect.any(Number),
level: 'info',
type: 'xhr',
}),
);
});

afterEach(() => {
window.XMLHttpRequest = initialXhr;
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import defaultUrlFilter from '../../src/filters/defaultUrlFilter';

it('filters polling urls', () => {
// Added -_ to the end as we use those in the base64 URL safe character set.
const context =
'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_';
const filteredCotext =
'************************************************************************************';
const baseUrl = 'https://sdk.launchdarkly.com/sdk/evalx/thesdkkey/contexts/';
const filteredUrl = `${baseUrl}${filteredCotext}`;
const testUrl = `${baseUrl}${context}`;
const testUrlWithReasons = `${testUrl}?withReasons=true`;
const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`;

expect(defaultUrlFilter(testUrl)).toBe(filteredUrl);
expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons);
});

it('filters streaming urls', () => {
// Added -_ to the end as we use those in the base64 URL safe character set.
const context =
'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_';
const filteredCotext =
'************************************************************************************';
const baseUrl = `https://clientstream.launchdarkly.com/eval/thesdkkey/`;
const filteredUrl = `${baseUrl}${filteredCotext}`;
const testUrl = `${baseUrl}${context}`;
const testUrlWithReasons = `${testUrl}?withReasons=true`;
const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`;

expect(defaultUrlFilter(testUrl)).toBe(filteredUrl);
expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons);
});

it.each([
'http://events.launchdarkly.com/events/bulk/thesdkkey',
'http://localhost:8080',
'http://some.other.base64like/eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6vcmciOnsiaIjoidGFjb2h1dCJ9fQ-_',
])('passes through other URLs unfiltered', (url) => {
expect(defaultUrlFilter(url)).toBe(url);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { HttpBreadcrumb } from '../../src/api/Breadcrumb';
import filterHttpBreadcrumb from '../../src/filters/filterHttpBreadcrumb';

it('filters breadcrumbs with the provided filters', () => {
const breadcrumb: HttpBreadcrumb = {
class: 'http',
timestamp: Date.now(),
level: 'info',
type: 'xhr',
data: {
method: 'GET',
url: 'dog',
statusCode: 200,
statusText: 'ok',
},
};
filterHttpBreadcrumb(breadcrumb, {
urlFilters: [(url) => url.replace('dog', 'cat')],
});
expect(breadcrumb.data?.url).toBe('cat');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import filterUrl from '../../src/filters/filterUrl';

it('runs the specified filters in the given order', () => {
const filterA = (url: string): string => url.replace('dog', 'cat');
const filterB = (url: string): string => url.replace('cat', 'mouse');

// dog -> cat -> mouse
expect(filterUrl([filterA, filterB], 'dog')).toBe('mouse');
// dog -> dog -> cat
expect(filterUrl([filterB, filterA], 'dog')).toBe('cat');
// cat -> mouse -> mouse
expect(filterUrl([filterB, filterA], 'cat')).toBe('mouse');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { UrlFilter } from '../../api/Options';

/**
* Options which impact the behavior of http collectors.
*/
export default interface HttpCollectorOptions {
/**
* A list of filters to execute on the URL of the breadcrumb.
*
* This allows for redaction of potentially sensitive information in URLs.
*/
urlFilters: UrlFilter[];
}
Loading

0 comments on commit 6e60ddc

Please sign in to comment.