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

385 lines
12 KiB
TypeScript

import type { LightPlugin, LightClient, NetworkEvent, LightConfig, Span, Transaction } 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[];
tracePropagationTargets?: (string | RegExp)[];
}
class NetworkPlugin implements LightPlugin {
name = 'network';
version = '1.1.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];
}
if (clientConfig.tracePropagationTargets) {
this.config.tracePropagationTargets = [...(this.config.tracePropagationTargets || []), ...clientConfig.tracePropagationTargets];
} else if (clientConfig.tracingOrigins) {
this.config.tracePropagationTargets = [...(this.config.tracePropagationTargets || []), ...clientConfig.tracingOrigins];
}
}
private shouldIgnore(url: string): boolean {
return shouldIgnoreUrl(url, this.config.ignoreUrls || []);
}
private shouldTracePropagate(url: string): boolean {
const targets = this.config.tracePropagationTargets;
if (!targets || targets.length === 0) {
return this.isSameOrigin(url);
}
return targets.some(pattern => {
if (typeof pattern === 'string') {
return url.includes(pattern);
}
return pattern.test(url);
});
}
private isSameOrigin(url: string): boolean {
if (typeof location === 'undefined') return false;
try {
const urlObj = new URL(url, location.origin);
return urlObj.origin === location.origin;
} catch {
return false;
}
}
private getBaggageHeader(): string | undefined {
if (!this.client) return undefined;
const clientInternal = this.client as unknown as {
tracingManager?: {
getCurrentTransaction?: () => Transaction | null;
};
};
const tx = clientInternal.tracingManager?.getCurrentTransaction?.();
if (!tx) return undefined;
const transactionImpl = tx as unknown as {
toBaggage?: () => string | undefined;
};
if (transactionImpl.toBaggage) {
return transactionImpl.toBaggage();
}
return undefined;
}
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 createHttpSpan(method: string, url: string): Span | null {
const currentSpan = this.client.getCurrentSpan();
if (!currentSpan) return null;
const span = currentSpan.startChild({
op: 'http.client',
description: `${method.toUpperCase()} ${url}`,
tags: {
'http.method': method.toUpperCase(),
'http.url': sanitizeUrl(url),
},
});
return span;
}
private finishHttpSpan(span: Span, statusCode: number, duration: number, isError: boolean): void {
span.setData('http.response.duration', duration);
if (statusCode > 0) {
span.setHttpStatus(statusCode);
} else if (isError) {
span.setStatus('internal_error');
}
span.finish();
}
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);
let httpSpan = self.createHttpSpan(method, sanitizedUrl);
const shouldInjectTrace = self.shouldTracePropagate(url) && !!httpSpan;
let modifiedInit = init;
if (shouldInjectTrace && httpSpan) {
const traceHeader = httpSpan.toTraceparent();
const baggage = self.getBaggageHeader();
modifiedInit = {
...init,
headers: {
...(init?.headers || {}),
'sentry-trace': traceHeader,
...(baggage ? { 'baggage': baggage } : {}),
},
};
}
const promise = self.originalFetch.call(window, input, modifiedInit);
promise.then(
(response) => {
const duration = now() - startTime;
const isSuccess = response.ok;
const statusCode = response.status;
if (httpSpan) {
self.finishHttpSpan(httpSpan, statusCode, duration, !isSuccess);
}
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 (httpSpan) {
self.finishHttpSpan(httpSpan, 0, duration, true);
}
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 httpSpan = self.createHttpSpan(method, sanitizedUrl);
const shouldInjectTrace = self.shouldTracePropagate(url) && !!httpSpan;
if (shouldInjectTrace && httpSpan) {
try {
const traceHeader = httpSpan.toTraceparent();
const baggage = self.getBaggageHeader();
this.setRequestHeader('sentry-trace', traceHeader);
if (baggage) {
this.setRequestHeader('baggage', baggage);
}
} catch {
// ignore
}
}
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 (httpSpan) {
self.finishHttpSpan(httpSpan, status, duration, !isSuccess);
}
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;