UNPKG

@erickluis00/otelviewer

Version:

Shared OpenTelemetry tracing utilities, types, and batch processor for Realtime OpenTelemetry Viewer [WIP]

254 lines 11.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FetchInstrumentation = void 0; exports.createFetchInstrumentation = createFetchInstrumentation; const api_1 = require("@opentelemetry/api"); /** * FetchInstrumentation class that implements the Instrumentation interface * Patches global fetch and creates spans for tracing */ class FetchInstrumentation { constructor(config) { this.instrumentationName = '@erickluis00/otelviewer-fetch'; this.instrumentationVersion = '1.0.0'; this._enabled = false; this._originalFetch = null; this._config = { enabled: true }; this._tracerProvider = null; this._meterProvider = null; this._loggerProvider = null; this._config = { enabled: true, ...config }; if (this._config.enabled !== false) { this.enable(); } } enable() { if (this._enabled) { return; } if (!globalThis.fetch) { console.warn('fetch is not available in this environment'); return; } this._originalFetch = globalThis.fetch; this._enabled = true; const tracer = api_1.trace.getTracer('fetch-instrumentation', this.instrumentationVersion); const originalFetch = this._originalFetch; globalThis.fetch = async function patchedFetch(...args) { // Extract request info const [input, init] = args; const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; const method = init?.method || (input instanceof Request ? input.method : 'GET'); // Start a span in the current context (maintains parent-child relationships) return tracer.startActiveSpan(`${method}`, { kind: api_1.SpanKind.CLIENT, attributes: { 'http.method': method, 'http.url': url, 'middleware.type': 'fetch-instrumentation' } }, async (span) => { try { // Parse URL to extract path and query let parsedPath = url; const query = {}; try { const urlObj = new URL(url); parsedPath = urlObj.pathname; urlObj.searchParams.forEach((value, key) => { query[key] = value; }); } catch { // If URL parsing fails, try to extract path manually const pathMatch = url.split('?')[0]; parsedPath = pathMatch || '/'; } span.setAttribute('http.path', parsedPath); // Capture request headers const requestHeaders = {}; if (init?.headers) { const headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers); headers.forEach((value, key) => { requestHeaders[key] = value; }); } else if (input instanceof Request) { input.headers.forEach((value, key) => { requestHeaders[key] = value; }); } // Capture request body if present let requestBody; if (init?.body) { try { if (typeof init.body === 'string') { const contentType = requestHeaders['content-type'] || requestHeaders['Content-Type'] || ''; if (contentType.includes('application/json')) { requestBody = JSON.parse(init.body); } else { requestBody = init.body; } } else { // For other types (FormData, Blob, etc.), we can't easily capture requestBody = '[Non-string body]'; } } catch { requestBody = init.body; } } else if (input instanceof Request && input.body) { // Request object with body - try to clone and read try { const clonedRequest = input.clone(); const bodyText = await clonedRequest.text(); const contentType = requestHeaders['content-type'] || requestHeaders['Content-Type'] || ''; if (contentType.includes('application/json')) { requestBody = JSON.parse(bodyText); } else { requestBody = bodyText; } } catch { requestBody = '[Unable to read request body]'; } } // Build request data matching HTTP instrumentation pattern const requestData = { method, url, path: parsedPath, headers: requestHeaders, query, timestamp: new Date().toISOString() }; if (requestBody !== undefined) { requestData.body = requestBody; } span.setAttribute('input', JSON.stringify(requestData)); // Make the actual fetch call const response = await originalFetch(...args); // Capture response headers first const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); // Check content-type header const contentType = responseHeaders['content-type'] || responseHeaders['Content-Type'] || ''; const isReadableContentType = contentType.includes('application/json') || contentType.includes('text/'); // Parse response body let parsedBody = '[Empty response]'; if (isReadableContentType) { // Only read JSON/text content types (content-encoding is handled automatically by browser) try { // Clone to read body without consuming the original const clonedResponse = response.clone(); const bodyText = await clonedResponse.text(); // Try to parse as JSON try { parsedBody = JSON.parse(bodyText); } catch { // Not JSON, keep as text parsedBody = bodyText; } } catch { parsedBody = '[Unable to read response body]'; } } else { // Non-readable content type (binary, image, etc.) parsedBody = `[Non-readable content type${contentType ? `: ${contentType}` : ''}]`; } // Build response data matching HTTP instrumentation pattern const responseData = { status: response.status, statusText: response.statusText, headers: responseHeaders, body: parsedBody, timestamp: new Date().toISOString() }; span.setAttribute('output', JSON.stringify(responseData)); span.setAttribute('http.status_code', response.status); span.setAttribute('http.status_text', response.statusText); // Set span status based on HTTP status if (response.status >= 400) { span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); } else { span.setStatus({ code: api_1.SpanStatusCode.OK }); } span.end(); return response; } catch (error) { span.recordException(error); span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message }); span.end(); throw error; } }); }; } disable() { if (!this._enabled || !this._originalFetch) { return; } globalThis.fetch = this._originalFetch; this._originalFetch = null; this._enabled = false; } setTracerProvider(tracerProvider) { this._tracerProvider = tracerProvider; } setMeterProvider(meterProvider) { this._meterProvider = meterProvider; } setLoggerProvider(loggerProvider) { this._loggerProvider = loggerProvider; } setConfig(config) { this._config = { ...this._config, ...config }; if (config.enabled === false && this._enabled) { this.disable(); } else if (config.enabled !== false && !this._enabled) { this.enable(); } } getConfig() { return { ...this._config }; } } exports.FetchInstrumentation = FetchInstrumentation; /** * Creates a fetch instrumentation that patches global fetch and creates spans * Much simpler than using UndiciInstrumentation - just intercepts fetch directly * * This maintains proper span hierarchy and context propagation automatically * when using tracer.startActiveSpan() * * Returns an Instrumentation instance that can be used in Instrumentation[] arrays */ function createFetchInstrumentation(config) { return new FetchInstrumentation(config); } //# sourceMappingURL=fetch-instrumentation.js.map