light-sentry-sdk/src/plugins/NetworkPlugin.ts

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;