Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add http collectors. #673

Merged
merged 9 commits into from
Nov 8, 2024
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
@@ -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