UNPKG

@erickluis00/otelviewer

Version:

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

279 lines 13.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createUndiciInstrumentation = createUndiciInstrumentation; const instrumentation_undici_1 = require("@opentelemetry/instrumentation-undici"); const api_1 = require("@opentelemetry/api"); // Map to store response bodies keyed by request URL (since span objects differ between wrapper and hook) const responseBodyCache = new Map(); /** * Creates a configured UndiciInstrumentation (for fetch/undici) with request/response capture * Following the same pattern as HTTP instrumentation for consistent data structure */ function createUndiciInstrumentation(config) { // Store original fetch for body interception const originalFetch = globalThis.fetch; // Patch fetch to intercept response bodies if (originalFetch) { globalThis.fetch = async function patchedFetch(...args) { const response = await originalFetch.apply(this, args); // Get the current active span from context const activeSpan = api_1.trace.getActiveSpan(); // Clone response to read body without consuming the original const clonedResponse = response.clone(); // Create cache key from request details const requestUrl = typeof args[0] === 'string' ? args[0] : args[0] instanceof URL ? args[0].toString() : args[0].url; const method = args[1]?.method || args[0]?.method || 'GET'; // Use a separator that won't appear in URLs const cacheKey = `${method}|||${requestUrl}|||${Date.now()}`; // IMMEDIATELY read the cloned response body (AWAIT it before returning) // This ensures the body is captured BEFORE responseHook is called try { const bodyText = await clonedResponse.text(); // Try to parse as JSON let parsedBody = bodyText; try { parsedBody = JSON.parse(bodyText); } catch { // Not JSON, keep as text } // Store in cache with timestamp responseBodyCache.set(cacheKey, { body: parsedBody, resolved: true, timestamp: Date.now() }); // Clean up old entries (older than 5 seconds) const fiveSecondsAgo = Date.now() - 5000; for (const [key, value] of responseBodyCache.entries()) { if (value.timestamp < fiveSecondsAgo) { responseBodyCache.delete(key); } } } catch (error) { // If reading fails, mark as failed but don't throw console.warn('Failed to read response body for tracing:', error); } return response; }; } return new instrumentation_undici_1.UndiciInstrumentation({ ...config, requestHook: (span, request) => { const timestamp = new Date().toISOString(); try { const method = request.method || 'GET'; const origin = request.origin || ''; const path = request.path || '/'; const fullUrl = `${origin}${path}`; // Parse URL to get query params let parsedPath = path; const query = {}; try { const urlObj = new URL(fullUrl); parsedPath = urlObj.pathname; urlObj.searchParams.forEach((value, key) => { query[key] = value; }); } catch { // If URL parsing fails, use path as is } // Get headers - undici v6 uses array format: [key1, value1, key2, value2, ...] const headers = {}; if (request.headers) { if (typeof request.headers === 'string') { // v5 format: "name: value\r\n" request.headers.split('\r\n').forEach(line => { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).trim(); const value = line.substring(colonIndex + 1).trim(); headers[key] = value; } }); } else if (Array.isArray(request.headers)) { // v6 format: [key1, value1, key2, value2] for (let i = 0; i < request.headers.length; i += 2) { const key = request.headers[i]; const value = request.headers[i + 1]; if (key && value !== undefined) { headers[String(key)] = Array.isArray(value) ? value.join(', ') : String(value); } } } } // Get request body if present let body = undefined; if (request.body !== undefined && request.body !== null) { try { if (typeof request.body === 'string') { // Try to parse JSON if content-type suggests it const contentType = request.contentType || headers['content-type'] || ''; if (contentType.includes('application/json')) { try { body = JSON.parse(request.body); } catch { body = request.body; } } else { body = request.body; } } else if (Buffer.isBuffer(request.body)) { const bodyString = request.body.toString('utf8'); const contentType = request.contentType || headers['content-type'] || ''; if (contentType.includes('application/json')) { try { body = JSON.parse(bodyString); } catch { body = bodyString; } } else { body = bodyString; } } else { // For other types (ReadableStream, etc.), we can't easily capture body = '[Stream or non-serializable body]'; } } catch { body = '[Body parse error]'; } } // Build request data matching HTTP instrumentation pattern const requestData = { method, url: fullUrl, path: parsedPath, headers, query, timestamp }; // Add body if present if (body !== undefined) { requestData.body = body; } // Set custom attributes to match HTTP instrumentation span.setAttribute('input', JSON.stringify(requestData)); span.setAttribute('http.method', method); span.setAttribute('http.url', fullUrl); span.setAttribute('http.path', parsedPath); span.setAttribute('middleware.type', 'undici-instrumentation'); } catch (error) { console.error('Error in undici requestHook:', error); } }, responseHook: (span, { request, response }) => { const timestamp = new Date().toISOString(); try { const status = response.statusCode || 200; // Parse response headers (Buffer[] format in undici) const headers = {}; if (response.headers && Array.isArray(response.headers)) { for (let i = 0; i < response.headers.length; i += 2) { const key = response.headers[i]?.toString() || ''; const value = response.headers[i + 1]?.toString() || ''; if (key) { headers[key] = value; } } } const statusText = response.statusText || (status >= 200 && status < 300 ? 'OK' : status >= 400 ? 'ERROR' : 'UNKNOWN'); // Set HTTP status attributes first span.setAttribute('http.status_code', status); span.setAttribute('http.status_text', statusText); // Build response data structure const responseData = { status, statusText, headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, String(v)])), timestamp }; // Try to find cached body by matching request URL const method = request.method || 'GET'; const origin = request.origin || ''; const path = request.path || '/'; const fullUrl = `${origin}${path}`; // Normalize URL (remove trailing slash, ensure consistent format) const normalizeUrl = (url) => { try { const urlObj = new URL(url); return urlObj.toString().replace(/\/$/, ''); } catch { return url.replace(/\/$/, ''); } }; const normalizedFullUrl = normalizeUrl(fullUrl); // Look for cached body matching this request // Find the most recent entry that matches URL and method let cachedBody = undefined; let foundMatch = false; let mostRecentTimestamp = 0; let matchedEntry = undefined; for (const [key, value] of responseBodyCache.entries()) { // Extract method and URL from cache key (format: "METHOD|||URL|||TIMESTAMP") const keyParts = key.split('|||'); if (keyParts.length === 3) { const keyMethod = keyParts[0]; const keyUrl = normalizeUrl(keyParts[1]); // Match by URL and method (normalized) if (keyMethod === method && keyUrl === normalizedFullUrl) { // Find the most recent matching entry if (value.timestamp > mostRecentTimestamp) { mostRecentTimestamp = value.timestamp; cachedBody = value.body; matchedEntry = value; foundMatch = true; } } } } // Update matched entry with response metadata if (matchedEntry) { matchedEntry.status = status; matchedEntry.statusText = statusText; matchedEntry.headers = headers; } // Set body in response data if (foundMatch && cachedBody !== undefined) { responseData.body = cachedBody; } else { // Debug: log what we're looking for vs what's in cache console.log('Cache lookup failed:', { lookingFor: { method, fullUrl, normalizedFullUrl }, cacheKeys: Array.from(responseBodyCache.keys()).slice(0, 5), // First 5 for debugging cacheSize: responseBodyCache.size }); responseData.body = '[Body unavailable - not cached]'; } span.setAttribute('output', JSON.stringify(responseData)); // Set span status based on HTTP status if (status >= 400) { span.setStatus({ code: 2, message: `HTTP ${status}` }); } else { span.setStatus({ code: 1 }); } } catch (error) { console.error('Error in undici responseHook:', error); } } }); } //# sourceMappingURL=undici-instrumentation.js.map