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