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 { 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); }; 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); } (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); }; } private reportNetwork(data: Omit): 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;