@erickluis00/otelviewer
Version:
Shared OpenTelemetry tracing utilities, types, and batch processor for Realtime OpenTelemetry Viewer [WIP]
303 lines • 17.1 kB
JavaScript
;
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