UNPKG

@erickluis00/otelviewer

Version:

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

597 lines 24.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createExpressMiddleware = createExpressMiddleware; exports.createHonoMiddleware = createHonoMiddleware; exports.createTRPCMiddleware = createTRPCMiddleware; exports.createNextJSWrapper = createNextJSWrapper; exports.generateRequestId = generateRequestId; exports.shouldTrace = shouldTrace; exports.formatDuration = formatDuration; const tracing_utils_1 = require("./tracing-utils"); const DEFAULT_CONFIG = { appName: 'otel-app', skipPaths: ['/health', '/ping'], captureRequestBody: true, captureResponseBody: true, maxBodySize: 1024 * 1024 // 1MB }; // Helper function to parse request body safely async function parseRequestBody(contentType, bodyGetter) { if (!contentType) return undefined; try { let requestBody; if (contentType.includes('application/json')) { requestBody = await bodyGetter(); } else if (contentType.includes('text/')) { requestBody = await bodyGetter(); } else if (contentType.includes('image/') || contentType.includes('audio/') || contentType.includes('video/')) { const buffer = await bodyGetter(); const base64Data = Buffer.from(buffer).toString('base64'); requestBody = { dataType: contentType, data: base64Data }; } else if (contentType.includes('application/octet-stream') || contentType.includes('binary')) { const buffer = await bodyGetter(); const base64Data = Buffer.from(buffer).toString('base64'); requestBody = { dataType: 'binary', data: base64Data }; } else { // Try to get as text for other content types try { requestBody = await bodyGetter(); if (!requestBody) { requestBody = undefined; } } catch { requestBody = undefined; } } return requestBody; } catch { return undefined; } } // Helper function to parse response body safely async function parseResponseBody(contentType, bodyGetter) { if (!contentType) return '[No content-type]'; try { if (contentType.includes('application/json')) { return await bodyGetter(); } else if (contentType.includes('text/')) { return await bodyGetter(); } else { return '[Binary or non-text response]'; } } catch { return '[Could not capture response body]'; } } // Core middleware logic async function coreMiddleware(span, requestData, next, getResponse, config) { const startTime = Date.now(); console.log(`${requestData.method} ${requestData.path} - ${requestData.timestamp}`); // Set span attributes span.setAttribute('http.method', requestData.method); span.setAttribute('http.path', requestData.path); span.setAttribute('middleware.type', 'http-tracer'); span.setAttribute('app.name', config.appName); span.setAttribute('http.url', requestData.url); // Extract host from URL or headers let host = ''; try { const url = new URL(requestData.url); host = url.host; } catch { // Fallback to host header if URL parsing fails host = requestData.headers.host || requestData.headers.Host || ''; } span.setAttribute('http.host', host); // Set input data span.setAttribute('input', JSON.stringify(requestData)); try { await next(); // Capture response const responseData = await getResponse(); const duration = Date.now() - startTime; console.log(`${requestData.method} ${requestData.path} completed in ${duration}ms - Status: ${responseData.status}`); // Set output data span.setAttribute('output', JSON.stringify(responseData)); span.setAttribute('http.status_code', responseData.status); span.setAttribute('request.duration_ms', duration); // Set span status based on HTTP status code if (responseData.status >= 400) { span.setStatus({ code: 2, // ERROR message: `HTTP ${responseData.status}` }); } else { span.setStatus({ code: 1 }); // OK } } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); console.log(`${requestData.method} ${requestData.path} failed in ${duration}ms - Error: ${errorMessage}`); span.setAttribute('request.duration_ms', duration); span.setAttribute('output', JSON.stringify({ success: false, error: errorMessage, timestamp: new Date().toISOString() })); throw error; } } // ============================================================================= // EXPRESS MIDDLEWARE // ============================================================================= /** * Express middleware factory for OpenTelemetry tracing * * @example * ```typescript * import express from 'express' * import { createExpressMiddleware } from '@erickluis00/otelviewer-shared' * * const app = express() * * // Use the middleware * app.use(createExpressMiddleware({ * appName: 'my-express-api', * skipPaths: ['/health'] * })) * * app.get('/users', (req, res) => { * res.json({ users: [] }) * }) * ``` */ function createExpressMiddleware(config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; return (req, res, next) => { // Skip certain paths if (finalConfig.skipPaths.includes(req.path)) { return next(); } const spanName = `${req.method} ${req.path}`; return (0, tracing_utils_1.track)(spanName, async () => { const span = (0, tracing_utils_1.getCurrentSpan)(); // Prepare request data const requestData = { method: req.method, url: req.url, path: req.path, headers: req.headers || {}, query: req.query || {}, timestamp: new Date().toISOString() }; // Parse request body if needed if (finalConfig.captureRequestBody && req.body !== undefined) { requestData.body = req.body; } // Set request headers res.setHeader('X-App-Name', finalConfig.appName); res.setHeader('X-Request-Id', Math.random().toString(36).substring(7)); // Store original res.end to capture response const originalEnd = res.end; let responseBody; if (finalConfig.captureResponseBody) { res.end = function (chunk, encoding) { if (chunk) { try { responseBody = JSON.parse(chunk); } catch { responseBody = chunk.toString(); } } originalEnd.call(this, chunk, encoding); }; } if (span) { await coreMiddleware(span, requestData, () => new Promise((resolve, reject) => { const originalNext = next; next = (err) => { if (err) reject(err); else resolve(); originalNext(err); }; next(); }), async () => ({ status: res.statusCode, statusText: res.statusCode >= 200 && res.statusCode < 300 ? 'OK' : 'ERROR', headers: res.getHeaders ? Object.fromEntries(Object.entries(res.getHeaders()).map(([k, v]) => [k, String(v)])) : {}, body: responseBody || '[Response body not captured]', timestamp: new Date().toISOString() }), finalConfig); } }); }; } // ============================================================================= // HONO MIDDLEWARE // ============================================================================= /** * Hono middleware factory for OpenTelemetry tracing * * @example * ```typescript * import { Hono } from 'hono' * import { createHonoMiddleware } from '@erickluis00/otelviewer-shared' * * const app = new Hono() * * // Use the middleware * app.use('*', createHonoMiddleware({ * appName: 'my-hono-api', * skipPaths: ['/health'] * })) * * app.get('/users', (c) => c.json({ users: [] })) * ``` */ function createHonoMiddleware(config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; return async (c, next) => { // Skip certain paths if (finalConfig.skipPaths.includes(c.req.path)) { await next(); return; } const spanName = `${c.req.method} ${c.req.path}`; return await (0, tracing_utils_1.track)(spanName, async () => { const span = (0, tracing_utils_1.getCurrentSpan)(); // Prepare request data const requestData = { method: c.req.method, url: c.req.url, path: c.req.path, headers: c.req.header(), query: c.req.query(), timestamp: new Date().toISOString() }; // Parse request body if needed if (finalConfig.captureRequestBody) { const contentType = c.req.header('content-type') || ''; requestData.body = await parseRequestBody(contentType, async () => { if (contentType.includes('application/json')) { return await c.req.json(); } else if (contentType.includes('text/')) { return await c.req.text(); } else if (contentType.includes('image/') || contentType.includes('audio/') || contentType.includes('video/')) { return await c.req.arrayBuffer(); } else if (contentType.includes('application/octet-stream') || contentType.includes('binary')) { return await c.req.arrayBuffer(); } else { return await c.req.text(); } }); } // Set response headers c.res.headers.set('X-App-Name', finalConfig.appName); c.res.headers.set('X-Request-Id', Math.random().toString(36).substring(7)); if (span) { await coreMiddleware(span, requestData, () => next(), async () => { let responseBody = '[Response body not captured]'; if (finalConfig.captureResponseBody) { try { const responseClone = c.res.clone(); const contentType = c.res.headers.get('content-type') || ''; responseBody = await parseResponseBody(contentType, async () => { if (contentType.includes('application/json')) { return await responseClone.json(); } else if (contentType.includes('text/')) { return await responseClone.text(); } else { return '[Binary or non-text response]'; } }); } catch { responseBody = '[Could not capture response body]'; } } return { status: c.res.status, statusText: c.res.status >= 200 && c.res.status < 300 ? 'OK' : 'ERROR', headers: Object.fromEntries(c.res.headers.entries()), body: responseBody, timestamp: new Date().toISOString() }; }, finalConfig); } }); }; } // ============================================================================= // TRPC MIDDLEWARE // ============================================================================= /** * TRPC middleware factory for OpenTelemetry tracing * * @example * ```typescript * import { initTRPC } from '@trpc/server' * import { createTRPCMiddleware } from '@erickluis00/otelviewer-shared' * * const t = initTRPC.create() * * // Create the middleware * const tracingMiddleware = t.middleware(createTRPCMiddleware({ * appName: 'my-trpc-api' * })) * * // Use in procedures * const publicProcedure = t.procedure.use(tracingMiddleware) * * export const appRouter = t.router({ * getUsers: publicProcedure.query(async () => { * return { users: [] } * }), * createUser: publicProcedure * .input(z.object({ name: z.string() })) * .mutation(async ({ input }) => { * return { user: input } * }) * }) * ``` */ function createTRPCMiddleware(config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; return async (opts) => { const { path, type, next, input, ctx, getRawInput } = opts; const spanName = `TRPC ${type.toUpperCase()} ${path}`; // Use createSpan for proper tracing with return value handling return (0, tracing_utils_1.createSpan)(spanName, async (span) => { const startTime = Date.now(); console.log(`TRPC ${type.toUpperCase()} ${path} - ${new Date().toISOString()}`); // Set span attributes specific to TRPC span.setAttribute('trpc.procedure.path', path); span.setAttribute('trpc.procedure.type', type); span.setAttribute('middleware.type', 'trpc-tracer'); span.setAttribute('app.name', finalConfig.appName); // Set input data - try both input and getRawInput let actualInput = input; if (getRawInput && (input === undefined || input === null)) { try { actualInput = await getRawInput(); } catch (error) { // Silently fail if getRawInput fails } } if (finalConfig.captureRequestBody) { span.setAttribute('input', JSON.stringify({ input: actualInput, path: path, type: type, timestamp: new Date().toISOString() })); } const result = await next(); const duration = Date.now() - startTime; console.log(`TRPC ${type.toUpperCase()} ${path} completed in ${duration}ms`); // Set output with the actual TRPC result if (finalConfig.captureResponseBody) { if (result && typeof result === 'object' && 'ok' in result) { // TRPC middleware result format if (result.ok && 'data' in result) { span.setAttribute('output', JSON.stringify({ success: true, data: result.data, timestamp: new Date().toISOString() })); } else if (!result.ok && 'error' in result) { span.setAttribute('output', JSON.stringify({ success: false, error: result.error, timestamp: new Date().toISOString() })); } } else { // Direct result (for mutations/queries that return data directly) span.setAttribute('output', JSON.stringify({ success: true, data: result, timestamp: new Date().toISOString() })); } } // Set additional TRPC-specific attributes span.setAttribute('trpc.result.ok', result && typeof result === 'object' && 'ok' in result ? result.ok : true); span.setAttribute('http.status_code', 200); // TRPC successful calls are always 200 span.setAttribute('request.duration_ms', duration); return result; }); }; } // ============================================================================= // NEXT.JS API HANDLER WRAPPER // ============================================================================= /** * Next.js API handler wrapper for OpenTelemetry tracing * * @example * ```typescript * import { createNextJSWrapper } from '@erickluis00/otelviewer-shared' * import { appRouter } from '@/trpc/server/router' * import { createNextApiHandler, NextApiRequest, NextApiResponse } from '@trpc/server/adapters/next' * import { createTRPCContext } from '@/trpc/server/procedures' * * const nextApiHandler = createNextApiHandler({ * router: appRouter, * createContext: createTRPCContext, * }) * * // Wrap with tracing * export default createNextJSWrapper({ * appName: 'my-nextjs-api', * skipPaths: ['/health'] * })(nextApiHandler) * ``` */ function createNextJSWrapper(config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; return function wrapHandler(handler) { return async (req, res) => { // Skip certain paths if (finalConfig.skipPaths.some(skipPath => req.url?.includes(skipPath))) { return await handler(req, res); } const method = req.method || 'GET'; const path = req.url || '/'; const spanName = `${method} ${path}`; return await (0, tracing_utils_1.track)(spanName, async () => { const span = (0, tracing_utils_1.getCurrentSpan)(); const startTime = Date.now(); console.log(`${method} ${path} - ${new Date().toISOString()}`); // Prepare request data const requestData = { method, url: req.url || '/', path, headers: req.headers || {}, query: req.query || {}, timestamp: new Date().toISOString() }; // Parse request body if needed if (finalConfig.captureRequestBody && req.body !== undefined) { requestData.body = req.body; } // Set span attributes span?.setAttribute('http.method', method); span?.setAttribute('http.path', path); span?.setAttribute('http.url', req.url || '/'); span?.setAttribute('middleware.type', 'nextjs-tracer'); span?.setAttribute('app.name', finalConfig.appName); // Set input data span?.setAttribute('input', JSON.stringify(requestData)); // Set request headers res.setHeader('X-App-Name', finalConfig.appName); res.setHeader('X-Request-Id', generateRequestId()); // Store original res.end to capture response const originalEnd = res.end; const originalJson = res.json; let responseBody = '[Response body not captured]'; let responseStatus = 200; if (finalConfig.captureResponseBody) { // Override res.end to capture response body res.end = function (chunk, encoding) { if (chunk) { try { responseBody = JSON.parse(chunk); } catch { responseBody = chunk.toString(); } } responseStatus = res.statusCode || 200; originalEnd.call(this, chunk, encoding); }; // Override res.json to capture JSON responses res.json = function (data) { responseBody = data; responseStatus = res.statusCode || 200; return originalJson.call(this, data); }; } try { // Execute the handler await handler(req, res); const duration = Date.now() - startTime; responseStatus = res.statusCode || 200; console.log(`${method} ${path} completed in ${duration}ms - Status: ${responseStatus}`); // Prepare response data const responseData = { status: responseStatus, statusText: responseStatus >= 200 && responseStatus < 300 ? 'OK' : 'ERROR', headers: res.getHeaders ? Object.fromEntries(Object.entries(res.getHeaders()).map(([k, v]) => [k, String(v)])) : {}, body: responseBody, timestamp: new Date().toISOString() }; // Set output data span?.setAttribute('output', JSON.stringify(responseData)); span?.setAttribute('http.status_code', responseStatus); span?.setAttribute('request.duration_ms', duration); // Set span status based on HTTP status code if (responseStatus >= 400) { span?.setStatus({ code: 2, // ERROR message: `HTTP ${responseStatus}` }); } else { span?.setStatus({ code: 1 }); // OK } } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); console.log(`${method} ${path} failed in ${duration}ms - Error: ${errorMessage}`); span?.setAttribute('request.duration_ms', duration); span?.setAttribute('output', JSON.stringify({ success: false, error: errorMessage, timestamp: new Date().toISOString() })); span?.setStatus({ code: 2, // ERROR message: errorMessage }); throw error; } }); }; }; } // ============================================================================= // UTILITY FUNCTIONS // ============================================================================= /** * Simple function to create a request ID */ function generateRequestId() { return Math.random().toString(36).substring(7); } /** * Helper function to check if a request should be traced */ function shouldTrace(path, skipPaths) { return !skipPaths.some(skipPath => path === skipPath || path.startsWith(skipPath)); } /** * Helper to format duration */ function formatDuration(ms) { if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; return `${(ms / 60000).toFixed(2)}m`; } //# sourceMappingURL=middleware.js.map