Files
plunk/apps/api/src/middleware/requestLogger.ts
T
2025-12-04 12:46:39 +01:00

193 lines
5.8 KiB
TypeScript

import type {NextFunction, Request, Response} from 'express';
import {prisma} from '../database/prisma.js';
import {logger} from '../utils/logger.js';
/**
* Check if request logging is enabled via environment variable
* Set REQUEST_LOGGING=false to disable database logging entirely
*/
const REQUEST_LOGGING_ENABLED = process.env.REQUEST_LOGGING !== 'false';
/**
* Endpoints to exclude from database logging
*
* Guidelines for what to skip:
* - Health checks and monitoring endpoints (called by load balancers every few seconds)
* - User session/auth endpoints (called on every page load in the dashboard)
* - Real-time polling endpoints (called repeatedly for live updates)
* - Static asset requests (should be served by CDN anyway)
* - Internal/admin-only endpoints that don't need audit trails
*
* What TO log (valuable for analytics/debugging):
* - Public API endpoints (/v1/send, /v1/track) - track customer usage
* - Resource CRUD operations (contacts, templates, campaigns) - audit trail
* - Authentication attempts (login, signup) - security monitoring
* - Payment/billing operations - compliance
* - Failed requests (always logged regardless of endpoint)
*/
const SKIP_LOGGING_PATHS = [
// Health/status checks (called by load balancers constantly)
'/health',
'/',
// User session endpoints (called on every dashboard page load)
'/users/@me',
'/users/@me/projects',
'/users/me',
'/users/me/projects',
// Project switching (called frequently when user switches projects)
'/users/@me/projects/:id',
// Real-time polling/stats endpoints
'/queue/stats',
// Feature flags/config (called on app initialization)
'/config',
'/config/features',
'/oauth-config',
// Field introspection (called when building forms/filters)
'/contacts/fields',
'/events/names',
];
/**
* Path patterns to exclude (regex)
* For more complex matching like "/assets/*" or "*.json"
*/
const SKIP_LOGGING_PATTERNS = [
/^\/assets\//i, // Static assets
/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i, // Static files
];
/**
* Check if a request should be logged to the database
*/
function shouldLogRequest(req: Request): boolean {
const path = req.path;
// Check exact path matches
if (SKIP_LOGGING_PATHS.includes(path)) {
return false;
}
// Check pattern matches
if (SKIP_LOGGING_PATTERNS.some(pattern => pattern.test(path))) {
return false;
}
// Log everything else
return true;
}
/**
* Middleware to log API requests to the database for historical tracking and analytics
* This runs asynchronously and does not block the response
*
* IMPORTANT: Only logs important endpoints to avoid noise and reduce database load
*
* Configuration:
* - Set REQUEST_LOGGING=false to disable entirely
* - Modify SKIP_LOGGING_PATHS to exclude specific endpoints
* - Modify SKIP_LOGGING_PATTERNS to exclude path patterns
*/
export const databaseRequestLogger = (req: Request, res: Response, next: NextFunction) => {
// Check if logging is enabled
if (!REQUEST_LOGGING_ENABLED) {
return next();
}
// Skip logging for excluded endpoints
if (!shouldLogRequest(req)) {
return next();
}
const startTime = Date.now();
// Capture the original res.json to log after response
const originalJson = res.json.bind(res);
res.json = function (body: unknown) {
// Call original json method first
const result = originalJson(body);
// Log to database asynchronously (don't await, don't block response)
void logRequestToDatabase(req, res, startTime, body);
return result;
};
next();
};
/**
* Log the request to the database
* This runs asynchronously and failures are logged but don't affect the response
*/
async function logRequestToDatabase(
req: Request,
res: Response,
startTime: number,
responseBody: unknown,
): Promise<void> {
try {
const requestId = res.locals.requestId as string | undefined;
const auth = res.locals.auth as {type?: string; userId?: string; projectId?: string} | undefined;
const duration = Date.now() - startTime;
const statusCode = res.statusCode;
// Extract error information if this is an error response
let errorCode: string | undefined;
let errorMessage: string | undefined;
if (
statusCode >= 400 &&
responseBody &&
typeof responseBody === 'object' &&
'error' in responseBody &&
responseBody.error &&
typeof responseBody.error === 'object'
) {
const error = responseBody.error as {code?: string; message?: string};
errorCode = error.code;
errorMessage = error.message;
}
// Calculate request/response sizes (approximate)
const requestSize = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : undefined;
const responseSize = JSON.stringify(responseBody).length;
// Insert into database
await prisma.apiRequest.create({
data: {
id: requestId || crypto.randomUUID(), // Use request ID as primary key
method: req.method,
path: req.path,
statusCode,
duration,
projectId: auth?.projectId || null,
userId: auth?.userId || null,
authType: auth?.type || null,
ip: req.ip || req.socket.remoteAddress || null,
userAgent: req.get('user-agent') || null,
errorCode: errorCode || null,
errorMessage: errorMessage || null,
requestSize: requestSize || null,
responseSize,
},
});
// Log success for debugging (only in development)
if (process.env.NODE_ENV === 'development') {
logger.debug('Request logged to database', {requestId}, res);
}
} catch (error) {
// Log the error but don't throw - we don't want logging failures to affect the API
logger.error('Failed to log request to database', error, {
path: req.path,
method: req.method,
});
}
}