257 lines
8.1 KiB
TypeScript
257 lines
8.1 KiB
TypeScript
import type { LightPlugin, LightClient, NetworkEvent, LightConfig } from '../types';
|
|
import { now, isObject } from '../utils/helper';
|
|
import { sanitizeUrl, shouldIgnoreUrl } from '../utils/env';
|
|
|
|
interface NetworkPluginConfig {
|
|
ignoreUrls?: (string | RegExp)[];
|
|
captureXHR?: boolean;
|
|
captureFetch?: boolean;
|
|
captureSuccess?: boolean;
|
|
captureBody?: boolean;
|
|
captureRequestBody?: boolean;
|
|
captureResponseBody?: boolean;
|
|
captureRequestHeaders?: string[];
|
|
captureResponseHeaders?: string[];
|
|
errorSampleRate?: number;
|
|
successSampleRate?: number;
|
|
slowThreshold?: number;
|
|
slowSampleRate?: number;
|
|
ignoreStatusCodes?: number[];
|
|
}
|
|
|
|
class NetworkPlugin implements LightPlugin {
|
|
name = 'network';
|
|
version = '1.0.0';
|
|
|
|
private client!: LightClient;
|
|
private config: NetworkPluginConfig = {
|
|
captureXHR: true,
|
|
captureFetch: true,
|
|
captureSuccess: false,
|
|
captureBody: false,
|
|
captureRequestBody: false,
|
|
captureResponseBody: false,
|
|
captureRequestHeaders: ['content-type', 'user-agent'],
|
|
captureResponseHeaders: ['content-type', 'content-length'],
|
|
errorSampleRate: 1.0,
|
|
successSampleRate: 0.01,
|
|
slowThreshold: 3000,
|
|
slowSampleRate: 1.0,
|
|
ignoreStatusCodes: [],
|
|
};
|
|
|
|
private originalFetch!: typeof fetch;
|
|
private originalXHROpen!: typeof XMLHttpRequest.prototype.open;
|
|
private originalXHRSend!: typeof XMLHttpRequest.prototype.send;
|
|
|
|
setup(client: LightClient): void {
|
|
this.client = client;
|
|
this.loadConfig();
|
|
if (this.config.captureFetch) {
|
|
this.patchFetch();
|
|
}
|
|
if (this.config.captureXHR) {
|
|
this.patchXHR();
|
|
}
|
|
}
|
|
|
|
private loadConfig(): void {
|
|
const clientConfig = this.client.config as LightConfig;
|
|
if (clientConfig && isObject(clientConfig.network)) {
|
|
this.config = { ...this.config, ...(clientConfig.network as object) };
|
|
}
|
|
if (clientConfig.ignoreUrls) {
|
|
this.config.ignoreUrls = [...(this.config.ignoreUrls || []), ...clientConfig.ignoreUrls];
|
|
}
|
|
}
|
|
|
|
private shouldIgnore(url: string): boolean {
|
|
return shouldIgnoreUrl(url, this.config.ignoreUrls || []);
|
|
}
|
|
|
|
private shouldSample(isError: boolean, duration: number): boolean {
|
|
let rate = isError ? (this.config.errorSampleRate ?? 1.0) : (this.config.successSampleRate ?? 0.01);
|
|
|
|
if (!isError && duration >= (this.config.slowThreshold ?? 3000)) {
|
|
rate = this.config.slowSampleRate ?? 1.0;
|
|
}
|
|
|
|
if (rate >= 1) return true;
|
|
if (rate <= 0) return false;
|
|
return Math.random() < rate;
|
|
}
|
|
|
|
private patchFetch(): void {
|
|
if (typeof fetch === 'undefined') return;
|
|
|
|
this.originalFetch = window.fetch;
|
|
const self = this;
|
|
|
|
window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
const startTime = now();
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
const method = init?.method || 'GET';
|
|
|
|
if (self.shouldIgnore(url)) {
|
|
return self.originalFetch.call(window, input, init);
|
|
}
|
|
|
|
const sanitizedUrl = sanitizeUrl(url);
|
|
const promise = self.originalFetch.call(window, input, init);
|
|
|
|
promise.then(
|
|
(response) => {
|
|
const duration = now() - startTime;
|
|
const isSuccess = response.ok;
|
|
const statusCode = response.status;
|
|
|
|
if (self.config.ignoreStatusCodes?.includes(statusCode)) {
|
|
return response;
|
|
}
|
|
|
|
if (!self.shouldSample(!isSuccess, duration)) {
|
|
return response;
|
|
}
|
|
|
|
if (!isSuccess || self.config.captureSuccess) {
|
|
self.reportNetwork({
|
|
sub_type: 'fetch',
|
|
method: method.toUpperCase(),
|
|
url: sanitizedUrl,
|
|
status_code: statusCode,
|
|
duration,
|
|
success: isSuccess,
|
|
error: !isSuccess ? `HTTP ${statusCode}` : undefined,
|
|
});
|
|
}
|
|
|
|
return response;
|
|
},
|
|
(error) => {
|
|
const duration = now() - startTime;
|
|
|
|
if (self.shouldSample(true, duration)) {
|
|
self.reportNetwork({
|
|
sub_type: 'fetch',
|
|
method: method.toUpperCase(),
|
|
url: sanitizedUrl,
|
|
duration,
|
|
success: false,
|
|
error: error?.message || 'Network error',
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
);
|
|
|
|
return promise;
|
|
};
|
|
}
|
|
|
|
private patchXHR(): void {
|
|
if (typeof XMLHttpRequest === 'undefined') return;
|
|
|
|
this.originalXHROpen = XMLHttpRequest.prototype.open;
|
|
this.originalXHRSend = XMLHttpRequest.prototype.send;
|
|
const self = this;
|
|
|
|
XMLHttpRequest.prototype.open = function (method: string, url: string | URL) {
|
|
(this as { _lightMethod?: string })._lightMethod = method;
|
|
(this as { _lightUrl?: string })._lightUrl = typeof url === 'string' ? url : url.toString();
|
|
(this as { _lightTracked?: boolean })._lightTracked = false;
|
|
return self.originalXHROpen.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.open>);
|
|
};
|
|
|
|
XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
|
|
const startTime = now();
|
|
const method = (this as { _lightMethod?: string })._lightMethod || 'GET';
|
|
const url = (this as { _lightUrl?: string })._lightUrl || '';
|
|
const tracked = (this as { _lightTracked?: boolean })._lightTracked;
|
|
|
|
if (self.shouldIgnore(url) || tracked) {
|
|
return self.originalXHRSend.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>);
|
|
}
|
|
|
|
(this as { _lightTracked?: boolean })._lightTracked = true;
|
|
const sanitizedUrl = sanitizeUrl(url);
|
|
let requestSize = 0;
|
|
if (body && typeof body === 'string') {
|
|
requestSize = body.length;
|
|
}
|
|
|
|
const originalOnLoadEnd = (this as XMLHttpRequest & { onloadend?: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null }).onloadend;
|
|
(this as XMLHttpRequest & { onloadend?: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null }).onloadend = function (ev: ProgressEvent) {
|
|
const duration = now() - startTime;
|
|
const status = this.status;
|
|
const isSuccess = status >= 200 && status < 300;
|
|
|
|
if (!self.config.ignoreStatusCodes?.includes(status)
|
|
&& self.shouldSample(!isSuccess, duration)
|
|
&& (isSuccess ? self.config.captureSuccess : true)) {
|
|
let responseSize = 0;
|
|
try {
|
|
const sizeHeader = this.getResponseHeader('content-length');
|
|
if (sizeHeader) {
|
|
responseSize = parseInt(sizeHeader, 10);
|
|
}
|
|
if (!responseSize && this.responseText) {
|
|
responseSize = this.responseText.length;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
self.reportNetwork({
|
|
sub_type: 'xhr',
|
|
method: method.toUpperCase(),
|
|
url: sanitizedUrl,
|
|
status_code: status,
|
|
duration,
|
|
request_size: requestSize || undefined,
|
|
response_size: responseSize || undefined,
|
|
success: isSuccess,
|
|
error: !isSuccess ? `HTTP ${status}` : undefined,
|
|
});
|
|
}
|
|
|
|
if (originalOnLoadEnd) {
|
|
return originalOnLoadEnd.call(this, ev);
|
|
}
|
|
};
|
|
|
|
return self.originalXHRSend.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>);
|
|
};
|
|
}
|
|
|
|
private reportNetwork(data: Omit<NetworkEvent, 'type' | 'level' | 'timestamp'>): void {
|
|
const event: NetworkEvent = {
|
|
type: 'network',
|
|
level: data.success ? 'info' : 'error',
|
|
timestamp: now(),
|
|
tags: {
|
|
method: data.method,
|
|
sub_type: data.sub_type,
|
|
success: String(data.success),
|
|
},
|
|
...data,
|
|
};
|
|
|
|
this.client.captureEvent(event);
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.originalFetch) {
|
|
window.fetch = this.originalFetch;
|
|
}
|
|
if (this.originalXHROpen) {
|
|
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
|
}
|
|
if (this.originalXHRSend) {
|
|
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
}
|
|
}
|
|
}
|
|
|
|
export default NetworkPlugin;
|