UNPKG

@erickluis00/otelviewer

Version:

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

303 lines 17.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createHttpInstrumentation = createHttpInstrumentation; const instrumentation_http_1 = require("@opentelemetry/instrumentation-http"); const requestBodyMap = new WeakMap(); const spanRequestDataMap = new WeakMap(); /** * Creates a configured HttpInstrumentation with request/response body capture * Following the middleware pattern for consistent data structure */ function createHttpInstrumentation(config) { return new instrumentation_http_1.HttpInstrumentation({ ...config, requestHook: (span, request) => { const timestamp = new Date().toISOString(); if ('headers' in request && 'method' in request && request.method) { // IncomingMessage (incoming server request) const urlPath = request.url || '/'; const method = request.method || 'GET'; const headers = request.headers || {}; // Build full URL with protocol, host, path, and query const host = headers.host || 'localhost'; const protocol = headers['x-forwarded-proto'] || (request.socket?.encrypted ? 'https' : 'http') || 'http'; const fullUrl = `${protocol}://${host}${urlPath}`; // Extract path and query from URL let path = urlPath; const query = {}; try { const urlObj = new URL(fullUrl); path = urlObj.pathname; urlObj.searchParams.forEach((value, key) => { query[key] = value; }); } catch (error) { const pathMatch = urlPath.split('?')[0]; path = pathMatch || '/'; } const contentType = headers['content-type'] || headers['Content-Type'] || ''; // Set basic span attributes span.setAttribute('http.method', method); span.setAttribute('http.url', fullUrl); span.setAttribute('http.path', path); span.setAttribute('http.host', host); span.setAttribute('middleware.type', 'http-instrumentation'); // Build initial request data (without body) const requestData = { method, url: fullUrl, path, headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, String(v)])), query, timestamp }; // Set input attribute immediately span.setAttribute('input', JSON.stringify(requestData)); // Store requestData in WeakMap for later updates spanRequestDataMap.set(span, { requestData, span }); // For POST/PUT/PATCH, capture body by reading the stream ONCE if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { const capture = { chunks: [], finished: false }; requestBodyMap.set(request, capture); // Store original read method const originalRead = request.read.bind(request); // Helper function to process captured body const processCapturedBody = () => { const totalSize = capture.chunks.reduce((sum, chunk) => sum + chunk.length, 0); if (!capture.finished && capture.chunks.length > 0) { capture.finished = true; const bodyString = Buffer.concat(capture.chunks).toString('utf8'); try { if (contentType.includes('application/json')) { requestData.body = JSON.parse(bodyString); } else { requestData.body = bodyString; } } catch { requestData.body = bodyString; } const updatedInput = JSON.stringify(requestData); span.setAttribute('input', updatedInput); requestBodyMap.delete(request); } }; // Intercept request.read() to capture chunks synchronously (like serverResponse.end) request.read = function (size) { const chunk = originalRead(size); if (chunk && !capture.finished) { const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); capture.chunks.push(chunkBuffer); const currentTotalSize = capture.chunks.reduce((sum, c) => sum + c.length, 0); // If we have all the data (check content-length), process immediately const contentLength = parseInt(String(headers['content-length'] || headers['Content-Length'] || '0'), 10); if (contentLength > 0 && currentTotalSize >= contentLength) { processCapturedBody(); } } else if (chunk === null && !capture.finished && capture.chunks.length > 0) { // Stream ended (read() returned null), process any remaining chunks processCapturedBody(); } return chunk; }; } } else if ('method' in request && request.method) { // ClientRequest (outgoing client request) const method = request.method || 'GET'; span.setAttribute('http.method', method); span.setAttribute('middleware.type', 'http-instrumentation-client'); } }, responseHook: (span, response) => { const timestamp = new Date().toISOString(); if ('headers' in response && 'statusCode' in response) { // IncomingMessage (client response from outgoing request) const status = response.statusCode || 200; const headers = response.headers || {}; const contentType = headers['content-type'] || headers['Content-Type'] || ''; const contentEncodingRaw = headers['content-encoding'] || headers['Content-Encoding'] || ''; const contentEncoding = Array.isArray(contentEncodingRaw) ? contentEncodingRaw[0] : String(contentEncodingRaw); // Check if content is encoded (Node.js HTTP streams give us raw compressed bytes) const isContentEncoded = Boolean(contentEncoding && contentEncoding.toLowerCase() !== 'identity'); const bodyChunks = []; response.on('data', (chunk) => { const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bodyChunks.push(chunkBuffer); }); response.on('end', () => { let responseBody = '[Empty response]'; // Normalize contentType for checking const contentTypeNormalized = Array.isArray(contentType) ? contentType[0] : String(contentType); const isReadableContentType = contentTypeNormalized.includes('application/json') || contentTypeNormalized.includes('text/'); if (isContentEncoded) { // Content is encoded (gzip, deflate, etc.) - Node.js gives us raw compressed bytes // Don't try to read as UTF-8, it will show strange characters responseBody = '[Content Encoded - Not Accessible] - Use Framework Specific Middleware if Provided to get Full Data'; } else if (bodyChunks.length > 0) { if (isReadableContentType) { // Only read JSON/text content types const bodyString = Buffer.concat(bodyChunks).toString('utf8'); try { if (contentTypeNormalized.includes('application/json') || contentTypeNormalized.includes('text/json')) { responseBody = JSON.parse(bodyString); } else { responseBody = bodyString; } } catch { responseBody = bodyString; } } else { // Non-readable content type (binary, image, etc.) responseBody = `[Non-readable content type${contentTypeNormalized ? `: ${contentTypeNormalized}` : ''}]`; } } const responseData = { status, statusText: status >= 200 && status < 300 ? 'OK' : status >= 400 ? 'ERROR' : 'UNKNOWN', headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, String(v)])), body: responseBody, timestamp }; span.setAttribute('output', JSON.stringify(responseData)); span.setAttribute('http.status_code', status); if (status >= 400) { span.setStatus({ code: 2, message: `HTTP ${status}` }); } else { span.setStatus({ code: 1 }); } }); } else { // ServerResponse (outgoing response to incoming request) // Type guard: we know it's ServerResponse in the else branch const serverResponse = response; const bodyChunks = []; const capturedHeaders = {}; const originalSetHeader = serverResponse.setHeader.bind(serverResponse); const originalWriteHead = serverResponse.writeHead.bind(serverResponse); const originalWrite = serverResponse.write.bind(serverResponse); const originalEnd = serverResponse.end.bind(serverResponse); // Intercept setHeader serverResponse.setHeader = function (name, value) { capturedHeaders[name] = value; return originalSetHeader(name, value); }; // Intercept writeHead (frameworks like Hono use this) serverResponse.writeHead = function (statusCode, statusMessage, headers) { let finalHeaders = headers; if (statusMessage && typeof statusMessage === 'object' && !headers) { finalHeaders = statusMessage; } if (finalHeaders) { Object.assign(capturedHeaders, finalHeaders); } return originalWriteHead(statusCode, statusMessage, headers); }; // Intercept write to capture body serverResponse.write = function (chunk, ...args) { if (chunk) { bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return originalWrite(chunk, ...args); }; // Intercept end to capture final data and update span serverResponse.end = function (chunk, ...args) { if (chunk) { bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } // Get final headers const allHeaders = { ...capturedHeaders, ...serverResponse.getHeaders() }; const status = serverResponse.statusCode || 200; const contentTypeRaw = allHeaders['content-type'] || allHeaders['Content-Type'] || ''; const contentType = Array.isArray(contentTypeRaw) ? contentTypeRaw[0] : String(contentTypeRaw); const contentEncodingRaw = allHeaders['content-encoding'] || allHeaders['Content-Encoding'] || ''; const contentEncoding = Array.isArray(contentEncodingRaw) ? contentEncodingRaw[0] : String(contentEncodingRaw); // Check if content is encoded (Node.js HTTP streams give us raw compressed bytes) const isContentEncoded = Boolean(contentEncoding && contentEncoding.toLowerCase() !== 'identity'); // Check if content type is readable const isReadableContentType = contentType.includes('application/json') || contentType.includes('text/'); // Parse response body let responseBody = '[Empty response]'; if (isContentEncoded) { // Content is encoded (gzip, deflate, etc.) - Node.js gives us raw compressed bytes // Don't try to read as UTF-8, it will show strange characters responseBody = '[Content Encoded - Not Accessible] - Use Framework Specific Middleware if Provided to get Full Data'; } else if (bodyChunks.length > 0) { if (isReadableContentType) { // Only read JSON/text content types const bodyString = Buffer.concat(bodyChunks).toString('utf8'); try { responseBody = JSON.parse(bodyString); } catch { responseBody = bodyString; } } else { // Non-readable content type (binary, image, etc.) responseBody = `[Non-readable content type${contentType ? `: ${contentType}` : ''}]`; } } // Helper to finalize span const finalizeSpan = () => { // Build response data const responseData = { status, statusText: status >= 200 && status < 300 ? 'OK' : 'ERROR', headers: Object.fromEntries(Object.entries(allHeaders).map(([k, v]) => [k, String(v)])), body: responseBody, timestamp }; span.setAttribute('output', JSON.stringify(responseData)); span.setAttribute('http.status_code', status); if (status >= 400) { span.setStatus({ code: 2, message: `HTTP ${status}` }); } else { span.setStatus({ code: 1 }); } }; // Try to update input with body from request if available const request = serverResponse.req; if (request) { const capture = requestBodyMap.get(request); const spanData = spanRequestDataMap.get(span); if (capture && spanData && capture.chunks.length > 0 && !capture.finished) { capture.finished = true; const bodyString = Buffer.concat(capture.chunks).toString('utf8'); try { spanData.requestData.body = JSON.parse(bodyString); } catch { spanData.requestData.body = bodyString; } span.setAttribute('input', JSON.stringify(spanData.requestData)); requestBodyMap.delete(request); } } finalizeSpan(); return originalEnd(chunk, ...args); }; } } }); } //# sourceMappingURL=http-instrumentation.js.map