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