diff --git a/demos/prometheus.ts b/demos/prometheus.ts new file mode 100644 index 0000000..9a5f7de --- /dev/null +++ b/demos/prometheus.ts @@ -0,0 +1,279 @@ +import http from '../index' +import {createPrometheusIntegration} from '../lib/middleware/prometheus' + +// Create Prometheus integration with custom options +const prometheus = createPrometheusIntegration({ + // Collect default Node.js metrics (memory, CPU, etc.) + collectDefaultMetrics: true, + + // Exclude certain paths from metrics + excludePaths: ['/health', '/favicon.ico'], + + // Skip metrics for certain HTTP methods + skipMethods: ['OPTIONS'], + + // Custom route normalization + normalizeRoute: (req) => { + const url = new URL(req.url, 'http://localhost') + let pathname = url.pathname + + // Custom patterns for this demo + pathname = pathname + .replace(/\/users\/\d+/, '/users/:id') + .replace(/\/products\/[a-zA-Z0-9-]+/, '/products/:slug') + .replace(/\/api\/v\d+/, '/api/:version') + + return pathname + }, + + // Add custom labels to metrics + extractLabels: (req, response) => { + const labels: Record = {} + + // Add user agent category + const userAgent = req.headers.get('user-agent') || '' + if (userAgent.includes('curl')) { + labels.client_type = 'curl' + } else if (userAgent.includes('Chrome')) { + labels.client_type = 'browser' + } else { + labels.client_type = 'other' + } + + // Add response type + const contentType = response?.headers?.get('content-type') || '' + if (contentType.includes('json')) { + labels.response_type = 'json' + } else if (contentType.includes('html')) { + labels.response_type = 'html' + } else { + labels.response_type = 'other' + } + + return labels + }, +}) + +// Create custom metrics for business logic +const {promClient} = prometheus + +const orderCounter = new promClient.Counter({ + name: 'orders_total', + help: 'Total number of orders processed', + labelNames: ['status', 'payment_method'], +}) + +const orderValue = new promClient.Histogram({ + name: 'order_value_dollars', + help: 'Value of orders in dollars', + labelNames: ['payment_method'], + buckets: [10, 50, 100, 500, 1000, 5000], +}) + +const activeUsers = new promClient.Gauge({ + name: 'active_users', + help: 'Number of currently active users', +}) + +// Simulate some active users +let userCount = 0 +setInterval(() => { + userCount = Math.floor(Math.random() * 100) + 50 + activeUsers.set(userCount) +}, 5000) + +// Configure the server +const {router} = http({}) + +// Apply Prometheus middleware +router.use(prometheus.middleware) + +// Health check endpoint (excluded from metrics) +router.get('/health', () => { + return new Response( + JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }), + { + headers: {'Content-Type': 'application/json'}, + }, + ) +}) + +// User endpoints +router.get('/users/:id', (req) => { + const id = req.params?.id + return new Response( + JSON.stringify({ + id: parseInt(id), + name: `User ${id}`, + email: `user${id}@example.com`, + created_at: new Date().toISOString(), + }), + { + headers: {'Content-Type': 'application/json'}, + }, + ) +}) + +router.post('/users', async (req) => { + const body = await req.json() + const user = { + id: Math.floor(Math.random() * 1000), + name: body.name || 'Anonymous', + email: body.email || `user${Date.now()}@example.com`, + created_at: new Date().toISOString(), + } + + return new Response(JSON.stringify(user), { + status: 201, + headers: {'Content-Type': 'application/json'}, + }) +}) + +// Product endpoints +router.get('/products/:slug', (req) => { + const slug = req.params?.slug + return new Response( + JSON.stringify({ + slug, + name: slug + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '), + price: Math.floor(Math.random() * 500) + 10, + in_stock: Math.random() > 0.2, + }), + { + headers: {'Content-Type': 'application/json'}, + }, + ) +}) + +// Order endpoint with custom metrics +router.post('/orders', async (req) => { + try { + const body = await req.json() + const amount = body.amount || 0 + const method = body.method || 'unknown' + + // Simulate order processing + const success = Math.random() > 0.1 // 90% success rate + const status = success ? 'completed' : 'failed' + + // Record custom metrics + orderCounter.inc({status, payment_method: method}) + + if (success && amount > 0) { + orderValue.observe({payment_method: method}, amount) + } + + const order = { + id: `order_${Date.now()}`, + amount, + payment_method: method, + status, + created_at: new Date().toISOString(), + } + + return new Response(JSON.stringify(order), { + status: success ? 201 : 402, + headers: {'Content-Type': 'application/json'}, + }) + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Invalid JSON body', + }), + { + status: 400, + headers: {'Content-Type': 'application/json'}, + }, + ) + } +}) + +// Slow endpoint for testing duration metrics +router.get('/slow', async () => { + // Random delay between 1-3 seconds + const delay = Math.floor(Math.random() * 2000) + 1000 + await new Promise((resolve) => setTimeout(resolve, delay)) + + return new Response( + JSON.stringify({ + message: `Processed after ${delay}ms`, + timestamp: new Date().toISOString(), + }), + { + headers: {'Content-Type': 'application/json'}, + }, + ) +}) + +// Error endpoint for testing error metrics +router.get('/error', () => { + // Randomly throw different types of errors + const errorType = Math.floor(Math.random() * 3) + + switch (errorType) { + case 0: + return new Response('Not Found', {status: 404}) + case 1: + return new Response('Internal Server Error', {status: 500}) + case 2: + throw new Error('Unhandled error for testing') + default: + return new Response('Bad Request', {status: 400}) + } +}) + +// Versioned API endpoint +router.get('/api/:version/data', (req) => { + const version = req.params?.version + return new Response( + JSON.stringify({ + api_version: version, + data: {message: 'Hello from versioned API'}, + timestamp: new Date().toISOString(), + }), + { + headers: {'Content-Type': 'application/json'}, + }, + ) +}) + +// Metrics endpoint - this should be added last +router.get('/metrics', prometheus.metricsHandler) + +// Server startup logic +const port = process.env.PORT || 3003 + +console.log('🚀 Starting Prometheus Demo Server') +console.log('=====================================') +console.log(`📊 Metrics endpoint: http://localhost:${port}/metrics`) +console.log(`🏠 Demo page: http://localhost:${port}/`) +console.log(`� Health check: http://localhost:${port}/health`) +console.log(`🔧 Port: ${port}`) +console.log('=====================================') +console.log('') +console.log('Try these commands to generate metrics:') +console.log('curl http://localhost:' + port + '/metrics') +console.log('curl http://localhost:' + port + '/users/123') +console.log('curl http://localhost:' + port + '/products/awesome-widget') +console.log( + 'curl -X POST http://localhost:' + + port + + '/orders -H \'Content-Type: application/json\' -d \'{"amount": 99.99, "method": "card"}\'', +) +console.log('curl http://localhost:' + port + '/slow') +console.log('curl http://localhost:' + port + '/error') +console.log('') + +console.log(`✅ Server running at http://localhost:${port}/`) + +export default { + port, + fetch: router.fetch.bind(router), +} diff --git a/lib/middleware/README.md b/lib/middleware/README.md index f23b51a..65503aa 100644 --- a/lib/middleware/README.md +++ b/lib/middleware/README.md @@ -10,6 +10,7 @@ - [CORS](#cors) - [JWT Authentication](#jwt-authentication) - [Logger](#logger) + - [Prometheus Metrics](#prometheus-metrics) - [Rate Limiting](#rate-limiting) - [Creating Custom Middleware](#creating-custom-middleware) @@ -50,6 +51,7 @@ import { createLogger, createJWTAuth, createRateLimit, + createPrometheusIntegration, } from '0http-bun/lib/middleware' ``` @@ -503,6 +505,202 @@ router.use(createLogger(loggerOptions)) - `tiny` - Minimal output - `dev` - Development-friendly colored output +### Prometheus Metrics + +Comprehensive Prometheus metrics integration for monitoring and observability with built-in security and performance optimizations. + +```javascript +import {createPrometheusIntegration} from '0http-bun/lib/middleware/prometheus' + +// Simple setup with default metrics +const prometheus = createPrometheusIntegration() + +router.use(prometheus.middleware) +router.get('/metrics', prometheus.metricsHandler) +``` + +#### Default Metrics Collected + +The Prometheus middleware automatically collects: + +- **HTTP Request Duration** - Histogram of request durations in seconds (buckets: 0.001, 0.005, 0.015, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1, 2, 5, 10) +- **HTTP Request Count** - Counter of total requests by method, route, and status +- **HTTP Request Size** - Histogram of request body sizes (buckets: 1B, 10B, 100B, 1KB, 10KB, 100KB, 1MB, 10MB) +- **HTTP Response Size** - Histogram of response body sizes (buckets: 1B, 10B, 100B, 1KB, 10KB, 100KB, 1MB, 10MB) +- **Active Connections** - Gauge of currently active HTTP connections +- **Node.js Metrics** - Memory usage, CPU, garbage collection (custom buckets), event loop lag (5ms precision) + +#### Advanced Configuration + +```javascript +const prometheus = createPrometheusIntegration({ + // Control default Node.js metrics collection + collectDefaultMetrics: true, + + // Exclude paths from metrics collection (optimized for performance) + excludePaths: ['/health', '/ping', '/favicon.ico'], + + // Skip certain HTTP methods + skipMethods: ['OPTIONS'], + + // Custom route normalization with security controls + normalizeRoute: (req) => { + const url = new URL(req.url, 'http://localhost') + return url.pathname + .replace(/\/users\/\d+/, '/users/:id') + .replace(/\/api\/v\d+/, '/api/:version') + }, + + // Add custom labels with automatic sanitization + extractLabels: (req, response) => { + return { + user_type: req.headers.get('x-user-type') || 'anonymous', + api_version: req.headers.get('x-api-version') || 'v1', + } + }, + + // Use custom metrics object instead of default metrics + metrics: customMetricsObject, +}) +``` + +#### Custom Business Metrics + +```javascript +const {promClient} = prometheus + +// Create custom metrics +const orderCounter = new promClient.Counter({ + name: 'orders_total', + help: 'Total number of orders processed', + labelNames: ['status', 'payment_method'], +}) + +const orderValue = new promClient.Histogram({ + name: 'order_value_dollars', + help: 'Value of orders in dollars', + labelNames: ['payment_method'], + buckets: [10, 50, 100, 500, 1000, 5000], +}) + +// Use in your routes +router.post('/orders', async (req) => { + const order = await processOrder(req.body) + + // Record custom metrics + orderCounter.inc({ + status: order.status, + payment_method: order.payment_method, + }) + + if (order.status === 'completed') { + orderValue.observe( + { + payment_method: order.payment_method, + }, + order.amount, + ) + } + + return Response.json(order) +}) +``` + +#### Metrics Endpoint Options + +```javascript +// Custom metrics endpoint +const metricsHandler = createMetricsHandler({ + endpoint: '/custom-metrics', // Default: '/metrics' + registry: customRegistry, // Default: promClient.register +}) + +router.get('/custom-metrics', metricsHandler) +``` + +#### Route Normalization & Security + +The middleware automatically normalizes routes and implements security measures to prevent high cardinality and potential attacks: + +```javascript +// URLs like these: +// /users/123, /users/456, /users/789 +// Are normalized to: /users/:id + +// /products/abc-123, /products/def-456 +// Are normalized to: /products/:slug + +// /api/v1/data, /api/v2/data +// Are normalized to: /api/:version/data + +// Route sanitization examples: +// /users/:id → _users__id (special characters replaced with underscores) +// /api/v1/orders → _api_v1_orders +// Very long tokens → _api__token (pattern-based normalization) +``` + +**Route Sanitization:** + +- Special characters (`/`, `:`, etc.) are replaced with underscores (`_`) for Prometheus compatibility +- UUIDs are automatically normalized to `:id` patterns +- Long tokens (>20 characters) are normalized to `:token` patterns +- Numeric IDs are normalized to `:id` patterns +- Route complexity is limited to 10 segments maximum + +**Security Features:** + +- **Label Sanitization**: Removes potentially dangerous characters from metric labels and truncates values to 100 characters +- **Cardinality Limits**: Prevents memory exhaustion from too many unique metric combinations +- **Route Complexity Limits**: Caps the number of route segments to 10 to prevent DoS attacks +- **Size Limits**: Limits request/response body size processing (up to 100MB) to prevent memory issues +- **Header Processing Limits**: Caps the number of headers processed per request (50 for requests, 20 for responses) +- **URL Processing**: Handles both full URLs and pathname-only URLs with proper fallback handling + +#### Performance Optimizations + +- **Fast Path for Excluded Routes**: Bypasses all metric collection for excluded paths with smart URL parsing +- **Lazy Evaluation**: Only processes metrics when actually needed +- **Efficient Size Calculation**: Optimized request/response size measurement with capping at 1MB estimation +- **Error Handling**: Graceful handling of malformed URLs and invalid data with fallback mechanisms +- **Header Count Limits**: Prevents excessive header processing overhead (50 request headers, 20 response headers) +- **Smart URL Parsing**: Handles both full URLs and pathname-only URLs efficiently + +#### Production Considerations + +- **Performance**: Adds <1ms overhead per request with optimized fast paths +- **Memory**: Metrics stored in memory with cardinality controls; use recording rules for high cardinality +- **Security**: Built-in protections against label injection and cardinality bombs +- **Cardinality**: Automatic limits prevent high cardinality issues +- **Monitoring**: Consider protecting `/metrics` endpoint in production + +#### Integration with Monitoring + +```yaml +# prometheus.yml +scrape_configs: + - job_name: '0http-bun-app' + static_configs: + - targets: ['localhost:3000'] + scrape_interval: 15s + metrics_path: /metrics +``` + +#### Troubleshooting + +**Common Issues:** + +- **High Memory Usage**: Check for high cardinality metrics. Route patterns should be normalized (e.g., `/users/:id` not `/users/12345`) +- **Missing Metrics**: Ensure paths aren't in `excludePaths` and HTTP methods aren't in `skipMethods` +- **Route Sanitization**: Routes are automatically sanitized (special characters become underscores: `/users/:id` → `_users__id`) +- **URL Parsing Errors**: The middleware handles both full URLs and pathname-only URLs with graceful fallback + +**Performance Tips:** + +- Use `excludePaths` for health checks and static assets +- Consider using `skipMethods` for OPTIONS requests +- Monitor memory usage in production for metric cardinality +- Use Prometheus recording rules for high-cardinality aggregations + ### Rate Limiting Configurable rate limiting middleware with multiple store options. diff --git a/lib/middleware/index.d.ts b/lib/middleware/index.d.ts index 4f569af..2eaa5ae 100644 --- a/lib/middleware/index.d.ts +++ b/lib/middleware/index.d.ts @@ -1,5 +1,4 @@ -import {RequestHandler, ZeroRequest, StepFunction} from '../../common' -import {Logger} from 'pino' +import {RequestHandler, ZeroRequest} from '../../common' // Logger middleware types export interface LoggerOptions { @@ -234,3 +233,68 @@ export function createMultipartParser( export function createBodyParser(options?: BodyParserOptions): RequestHandler export function hasBody(req: ZeroRequest): boolean export function shouldParse(req: ZeroRequest, type: string): boolean + +// Prometheus metrics middleware types +export interface PrometheusMetrics { + httpRequestDuration: any // prom-client Histogram + httpRequestTotal: any // prom-client Counter + httpRequestSize: any // prom-client Histogram + httpResponseSize: any // prom-client Histogram + httpActiveConnections: any // prom-client Gauge +} + +export interface PrometheusMiddlewareOptions { + /** Custom metrics object to use instead of default metrics */ + metrics?: PrometheusMetrics + /** Paths to exclude from metrics collection (default: ['/health', '/ping', '/favicon.ico', '/metrics']) */ + excludePaths?: string[] + /** Whether to collect default Node.js metrics (default: true) */ + collectDefaultMetrics?: boolean + /** Custom route normalization function */ + normalizeRoute?: (req: ZeroRequest) => string + /** Custom label extraction function */ + extractLabels?: ( + req: ZeroRequest, + response: Response, + ) => Record + /** HTTP methods to skip from metrics collection */ + skipMethods?: string[] +} + +export interface MetricsHandlerOptions { + /** The endpoint path for metrics (default: '/metrics') */ + endpoint?: string + /** Custom Prometheus registry to use */ + registry?: any // prom-client Registry +} + +export interface PrometheusIntegration { + /** The middleware function */ + middleware: RequestHandler + /** The metrics handler function */ + metricsHandler: RequestHandler + /** The Prometheus registry */ + registry: any // prom-client Registry + /** The prom-client module */ + promClient: any +} + +export function createPrometheusMiddleware( + options?: PrometheusMiddlewareOptions, +): RequestHandler +export function createMetricsHandler( + options?: MetricsHandlerOptions, +): RequestHandler +export function createPrometheusIntegration( + options?: PrometheusMiddlewareOptions & MetricsHandlerOptions, +): PrometheusIntegration +export function createDefaultMetrics(): PrometheusMetrics +export function extractRoutePattern(req: ZeroRequest): string + +// Simple interface exports for common use cases +export const logger: typeof createLogger +export const jwtAuth: typeof createJWTAuth +export const rateLimit: typeof createRateLimit +export const cors: typeof createCORS +export const bodyParser: typeof createBodyParser +export const prometheus: typeof createPrometheusIntegration diff --git a/lib/middleware/index.js b/lib/middleware/index.js index 98ead03..19585b0 100644 --- a/lib/middleware/index.js +++ b/lib/middleware/index.js @@ -4,6 +4,7 @@ const jwtAuthModule = require('./jwt-auth') const rateLimitModule = require('./rate-limit') const corsModule = require('./cors') const bodyParserModule = require('./body-parser') +const prometheusModule = require('./prometheus') module.exports = { // Simple interface for common use cases (matches test expectations) @@ -12,6 +13,7 @@ module.exports = { rateLimit: rateLimitModule.createRateLimit, cors: corsModule.createCORS, bodyParser: bodyParserModule.createBodyParser, + prometheus: prometheusModule.createPrometheusIntegration, // Complete factory functions for advanced usage createLogger: loggerModule.createLogger, @@ -42,4 +44,11 @@ module.exports = { createBodyParser: bodyParserModule.createBodyParser, hasBody: bodyParserModule.hasBody, shouldParse: bodyParserModule.shouldParse, + + // Prometheus metrics middleware + createPrometheusMiddleware: prometheusModule.createPrometheusMiddleware, + createMetricsHandler: prometheusModule.createMetricsHandler, + createPrometheusIntegration: prometheusModule.createPrometheusIntegration, + createDefaultMetrics: prometheusModule.createDefaultMetrics, + extractRoutePattern: prometheusModule.extractRoutePattern, } diff --git a/lib/middleware/prometheus.js b/lib/middleware/prometheus.js new file mode 100644 index 0000000..ae94ca9 --- /dev/null +++ b/lib/middleware/prometheus.js @@ -0,0 +1,468 @@ +const promClient = require('prom-client') + +// Security: Limit label cardinality +const MAX_LABEL_VALUE_LENGTH = 100 +const MAX_ROUTE_SEGMENTS = 10 + +/** + * Sanitize label values to prevent high cardinality + */ +function sanitizeLabelValue(value) { + if (typeof value !== 'string') { + value = String(value) + } + + // Truncate long values + if (value.length > MAX_LABEL_VALUE_LENGTH) { + value = value.substring(0, MAX_LABEL_VALUE_LENGTH) + } + + // Replace invalid characters + return value.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +/** + * Validate route pattern to prevent injection attacks + */ +function validateRoute(route) { + if (typeof route !== 'string' || route.length === 0) { + return '/unknown' + } + + // Limit route complexity + const segments = route.split('/').filter(Boolean) + if (segments.length > MAX_ROUTE_SEGMENTS) { + return '/' + segments.slice(0, MAX_ROUTE_SEGMENTS).join('/') + } + + return sanitizeLabelValue(route) +} + +/** + * Default Prometheus metrics for HTTP requests + */ +function createDefaultMetrics() { + // HTTP request duration histogram + const httpRequestDuration = new promClient.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.001, 0.005, 0.015, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1, 2, 5, 10], + }) + + // HTTP request counter + const httpRequestTotal = new promClient.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], + }) + + // HTTP request size histogram + const httpRequestSize = new promClient.Histogram({ + name: 'http_request_size_bytes', + help: 'Size of HTTP requests in bytes', + labelNames: ['method', 'route'], + buckets: [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000], + }) + + // HTTP response size histogram + const httpResponseSize = new promClient.Histogram({ + name: 'http_response_size_bytes', + help: 'Size of HTTP responses in bytes', + labelNames: ['method', 'route', 'status_code'], + buckets: [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000], + }) + + // Active HTTP connections gauge + const httpActiveConnections = new promClient.Gauge({ + name: 'http_active_connections', + help: 'Number of active HTTP connections', + }) + + return { + httpRequestDuration, + httpRequestTotal, + httpRequestSize, + httpResponseSize, + httpActiveConnections, + } +} + +/** + * Extract route pattern from request + * This function attempts to extract a meaningful route pattern from the request + * for use in Prometheus metrics labels + */ +function extractRoutePattern(req) { + try { + // If route pattern is available from router context + if (req.ctx && req.ctx.route) { + return validateRoute(req.ctx.route) + } + + // If params exist, try to reconstruct the pattern + if (req.params && Object.keys(req.params).length > 0) { + const url = new URL(req.url, 'http://localhost') + let pattern = url.pathname + + // Replace parameter values with parameter names + Object.entries(req.params).forEach(([key, value]) => { + if (typeof key === 'string' && typeof value === 'string') { + const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + pattern = pattern.replace( + new RegExp(`/${escapedValue}(?=/|$)`), + `/:${sanitizeLabelValue(key)}`, + ) + } + }) + + return validateRoute(pattern) + } + + // Try to normalize common patterns + const url = new URL(req.url, 'http://localhost') + let pathname = url.pathname + + // Replace UUIDs, numbers, and other common ID patterns + pathname = pathname + .replace( + /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, + '/:id', + ) + .replace(/\/\d+/g, '/:id') + .replace(/\/[a-zA-Z0-9_-]{20,}/g, '/:token') + + return validateRoute(pathname) + } catch (error) { + // Fallback for malformed URLs + return '/unknown' + } +} + +/** + * Get request size in bytes - optimized for performance + */ +function getRequestSize(req) { + try { + const contentLength = req.headers.get('content-length') + if (contentLength) { + const size = parseInt(contentLength, 10) + return size >= 0 && size <= 100 * 1024 * 1024 ? size : 0 // Max 100MB + } + + // Fast estimation based on headers only + let size = 0 + const url = req.url || '' + size += req.method.length + url.length + 12 // HTTP/1.1 + spaces + + // Quick header size estimation + if (req.headers && typeof req.headers.forEach === 'function') { + let headerCount = 0 + req.headers.forEach((value, key) => { + if (headerCount < 50) { + // Limit header processing for performance + size += key.length + value.length + 4 // ": " + "\r\n" + headerCount++ + } + }) + } + + return Math.min(size, 1024 * 1024) // Cap at 1MB for estimation + } catch (error) { + return 0 + } +} + +/** + * Get response size in bytes - optimized for performance + */ +function getResponseSize(response) { + try { + // Check content-length header first (fastest) + const contentLength = response.headers?.get('content-length') + if (contentLength) { + const size = parseInt(contentLength, 10) + return size >= 0 && size <= 100 * 1024 * 1024 ? size : 0 // Max 100MB + } + + // Try to estimate from response body if available + if ( + response._bodyForLogger && + typeof response._bodyForLogger === 'string' + ) { + return Math.min( + Buffer.byteLength(response._bodyForLogger, 'utf8'), + 1024 * 1024, + ) + } + + // Fast estimation for headers only + let size = 15 // "HTTP/1.1 200 OK\r\n" + + if (response.headers && typeof response.headers.forEach === 'function') { + let headerCount = 0 + response.headers.forEach((value, key) => { + if (headerCount < 20) { + // Limit for performance + size += key.length + value.length + 4 // ": " + "\r\n" + headerCount++ + } + }) + } + + return Math.min(size, 1024) // Cap header estimation at 1KB + } catch (error) { + return 0 + } +} + +/** + * Creates a Prometheus metrics middleware + * @param {Object} options - Prometheus middleware configuration + * @param {Object} options.metrics - Custom metrics object (optional) + * @param {Array} options.excludePaths - Paths to exclude from metrics + * @param {boolean} options.collectDefaultMetrics - Whether to collect default Node.js metrics + * @param {Function} options.normalizeRoute - Custom route normalization function + * @param {Function} options.extractLabels - Custom label extraction function + * @param {Array} options.skipMethods - HTTP methods to skip from metrics + * @returns {Function} Middleware function + */ +function createPrometheusMiddleware(options = {}) { + const { + metrics: customMetrics, + excludePaths = ['/health', '/ping', '/favicon.ico', '/metrics'], + collectDefaultMetrics = true, + normalizeRoute = extractRoutePattern, + extractLabels, + skipMethods = [], + } = options + + // Collect default Node.js metrics + if (collectDefaultMetrics) { + promClient.collectDefaultMetrics({ + timeout: 5000, + gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], + eventLoopMonitoringPrecision: 5, + }) + } + + // Use custom metrics or create default ones + const metrics = customMetrics || createDefaultMetrics() + + return async function prometheusMiddleware(req, next) { + const startHrTime = process.hrtime() + + // Skip metrics collection for excluded paths (performance optimization) + const url = req.url || '' + let pathname + try { + // Handle both full URLs and pathname-only URLs + if (url.startsWith('http')) { + pathname = new URL(url).pathname + } else { + pathname = url.split('?')[0] // Fast pathname extraction + } + } catch (error) { + pathname = url.split('?')[0] // Fallback to simple splitting + } + + if (excludePaths.some((path) => pathname.startsWith(path))) { + return next() + } + + // Skip metrics collection for specified methods + const method = req.method?.toUpperCase() || 'GET' + if (skipMethods.includes(method)) { + return next() + } + + // Increment active connections + if (metrics.httpActiveConnections) { + metrics.httpActiveConnections.inc() + } + + try { + // Get request size (lazy evaluation) + let requestSize = 0 + + // Execute the request + const response = await next() + + // Calculate duration (high precision) + const duration = process.hrtime(startHrTime) + const durationInSeconds = duration[0] + duration[1] * 1e-9 + + // Extract route pattern (cached/optimized) + const route = normalizeRoute(req) + const statusCode = sanitizeLabelValue( + response?.status?.toString() || 'unknown', + ) + + // Create base labels with sanitized values + let labels = { + method: sanitizeLabelValue(method), + route: route, + status_code: statusCode, + } + + // Add custom labels if extractor provided + if (extractLabels && typeof extractLabels === 'function') { + try { + const customLabels = extractLabels(req, response) + if (customLabels && typeof customLabels === 'object') { + // Sanitize custom labels + Object.entries(customLabels).forEach(([key, value]) => { + if (typeof key === 'string' && key.length <= 50) { + labels[sanitizeLabelValue(key)] = sanitizeLabelValue( + String(value), + ) + } + }) + } + } catch (error) { + // Ignore custom label extraction errors + } + } + + // Record metrics efficiently + if (metrics.httpRequestDuration) { + metrics.httpRequestDuration.observe( + { + method: labels.method, + route: labels.route, + status_code: labels.status_code, + }, + durationInSeconds, + ) + } + + if (metrics.httpRequestTotal) { + metrics.httpRequestTotal.inc({ + method: labels.method, + route: labels.route, + status_code: labels.status_code, + }) + } + + if (metrics.httpRequestSize) { + requestSize = getRequestSize(req) + if (requestSize > 0) { + metrics.httpRequestSize.observe( + {method: labels.method, route: labels.route}, + requestSize, + ) + } + } + + if (metrics.httpResponseSize) { + const responseSize = getResponseSize(response) + if (responseSize > 0) { + metrics.httpResponseSize.observe( + { + method: labels.method, + route: labels.route, + status_code: labels.status_code, + }, + responseSize, + ) + } + } + + return response + } catch (error) { + // Record error metrics + const duration = process.hrtime(startHrTime) + const durationInSeconds = duration[0] + duration[1] * 1e-9 + const route = normalizeRoute(req) + const sanitizedMethod = sanitizeLabelValue(method) + + if (metrics.httpRequestDuration) { + metrics.httpRequestDuration.observe( + {method: sanitizedMethod, route: route, status_code: '500'}, + durationInSeconds, + ) + } + + if (metrics.httpRequestTotal) { + metrics.httpRequestTotal.inc({ + method: sanitizedMethod, + route: route, + status_code: '500', + }) + } + + throw error + } finally { + // Decrement active connections + if (metrics.httpActiveConnections) { + metrics.httpActiveConnections.dec() + } + } + } +} + +/** + * Creates a metrics endpoint handler that serves Prometheus metrics + * @param {Object} options - Metrics endpoint configuration + * @param {string} options.endpoint - The endpoint path (default: '/metrics') + * @param {Object} options.registry - Custom Prometheus registry + * @returns {Function} Request handler function + */ +function createMetricsHandler(options = {}) { + const {endpoint = '/metrics', registry = promClient.register} = options + + return async function metricsHandler(req) { + const url = new URL(req.url, 'http://localhost') + + if (url.pathname === endpoint) { + try { + const metrics = await registry.metrics() + return new Response(metrics, { + status: 200, + headers: { + 'Content-Type': registry.contentType, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + }) + } catch (error) { + return new Response('Error collecting metrics', { + status: 500, + headers: {'Content-Type': 'text/plain'}, + }) + } + } + + return null // Let other middleware handle the request + } +} + +/** + * Simple helper to create both middleware and metrics endpoint + * @param {Object} options - Combined configuration options + * @returns {Object} Object containing middleware and handler functions + */ +function createPrometheusIntegration(options = {}) { + const middleware = createPrometheusMiddleware(options) + const metricsHandler = createMetricsHandler(options) + + return { + middleware, + metricsHandler, + // Expose the registry for custom metrics + registry: promClient.register, + // Expose prom-client for creating custom metrics + promClient, + } +} + +module.exports = { + createPrometheusMiddleware, + createMetricsHandler, + createPrometheusIntegration, + createDefaultMetrics, + extractRoutePattern, + promClient, + register: promClient.register, +} diff --git a/package.json b/package.json index 13b492a..0664b70 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ }, "dependencies": { "fast-querystring": "^1.1.2", - "trouter": "^4.0.0", "jose": "^6.0.11", - "pino": "^9.7.0" + "pino": "^9.7.0", + "prom-client": "^15.1.3", + "trouter": "^4.0.0" }, "repository": { "type": "git", diff --git a/test-types.ts b/test-types.ts index 6796b9d..5bcf498 100644 --- a/test-types.ts +++ b/test-types.ts @@ -23,6 +23,12 @@ import { createTextParser, createURLEncodedParser, createMultipartParser, + // Prometheus middleware functions + createPrometheusIntegration, + createPrometheusMiddleware, + createMetricsHandler, + createDefaultMetrics, + extractRoutePattern, // Type definitions JWTAuthOptions, APIKeyAuthOptions, @@ -38,6 +44,11 @@ import { MultipartParserOptions, JWKSLike, TokenExtractionOptions, + // Prometheus type definitions + PrometheusMiddlewareOptions, + MetricsHandlerOptions, + PrometheusIntegration, + PrometheusMetrics, // Available utility functions extractTokenFromHeader, defaultKeyGenerator, @@ -390,6 +401,206 @@ const testBodyParserUtilities = (req: ZeroRequest) => { const shouldParseJson = shouldParse(req, 'application/json') } +// ============================================================================= +// PROMETHEUS METRICS MIDDLEWARE VALIDATION +// ============================================================================= + +console.log('✅ Prometheus Metrics Middleware') + +// Clear the Prometheus registry at the start to avoid conflicts +try { + const promClient = require('prom-client') + promClient.register.clear() +} catch (error) { + // Ignore if prom-client is not available +} + +// Test comprehensive Prometheus middleware options +const prometheusMiddlewareOptions: PrometheusMiddlewareOptions = { + // Use custom metrics to avoid registry conflicts + metrics: undefined, // Will create default metrics once + + // Paths to exclude from metrics collection + excludePaths: ['/health', '/ping', '/favicon.ico', '/metrics'], + + // Whether to collect default Node.js metrics + collectDefaultMetrics: false, // Disable to avoid conflicts + + // Custom route normalization function + normalizeRoute: (req: ZeroRequest) => { + const url = new URL(req.url, 'http://localhost') + let pathname = url.pathname + + // Custom normalization logic + return pathname + .replace(/\/users\/\d+/, '/users/:id') + .replace(/\/api\/v\d+/, '/api/:version') + .replace(/\/items\/[a-f0-9-]{36}/, '/items/:uuid') + }, + + // Custom label extraction function + extractLabels: (req: ZeroRequest, response: Response) => { + return { + user_type: req.headers.get('x-user-type') || 'anonymous', + api_version: req.headers.get('x-api-version') || 'v1', + region: req.headers.get('x-region') || 'us-east-1', + } + }, + + // HTTP methods to skip from metrics collection + skipMethods: ['OPTIONS', 'HEAD'], +} + +// Test metrics handler options +const metricsHandlerOptions: MetricsHandlerOptions = { + endpoint: '/custom-metrics', + registry: undefined, // Would be prom-client registry in real usage +} + +// Test creating individual components (create only once to avoid registry conflicts) +const defaultMetrics: PrometheusMetrics = createDefaultMetrics() +const prometheusMiddleware = createPrometheusMiddleware({ + ...prometheusMiddlewareOptions, + metrics: defaultMetrics, +}) +const metricsHandler = createMetricsHandler(metricsHandlerOptions) + +// Test the integration function (use existing metrics) +const prometheusIntegration: PrometheusIntegration = + createPrometheusIntegration({ + ...prometheusMiddlewareOptions, + ...metricsHandlerOptions, + metrics: defaultMetrics, // Reuse existing metrics + }) + +// Test the integration object structure +const testPrometheusIntegration = () => { + // Test middleware function + const middleware: RequestHandler = prometheusIntegration.middleware + + // Test metrics handler function + const handler: RequestHandler = prometheusIntegration.metricsHandler + + // Test registry access + const registry = prometheusIntegration.registry + + // Test prom-client access for custom metrics + const promClient = prometheusIntegration.promClient +} + +// Test default metrics structure +const testDefaultMetrics = () => { + // Use the already created metrics to avoid registry conflicts + const metrics = defaultMetrics + + // Test that all expected metrics are present + const duration = metrics.httpRequestDuration + const total = metrics.httpRequestTotal + const requestSize = metrics.httpRequestSize + const responseSize = metrics.httpResponseSize + const activeConnections = metrics.httpActiveConnections + + // All should be defined (prom-client objects) + console.assert( + duration !== undefined, + 'httpRequestDuration should be defined', + ) + console.assert(total !== undefined, 'httpRequestTotal should be defined') + console.assert(requestSize !== undefined, 'httpRequestSize should be defined') + console.assert( + responseSize !== undefined, + 'httpResponseSize should be defined', + ) + console.assert( + activeConnections !== undefined, + 'httpActiveConnections should be defined', + ) +} + +// Test route pattern extraction +const testRoutePatternExtraction = () => { + // Mock request objects for testing (using unknown casting for test purposes) + const reqWithContext = { + ctx: {route: '/users/:id'}, + url: 'http://localhost:3000/users/123', + } as unknown as ZeroRequest + + const reqWithParams = { + url: 'http://localhost:3000/users/123', + params: {id: '123'}, + } as unknown as ZeroRequest + + const reqWithUUID = { + url: 'http://localhost:3000/items/550e8400-e29b-41d4-a716-446655440000', + } as unknown as ZeroRequest + + const reqWithNumericId = { + url: 'http://localhost:3000/posts/12345', + } as unknown as ZeroRequest + + const reqWithLongToken = { + url: 'http://localhost:3000/auth/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + } as unknown as ZeroRequest + + const reqMalformed = { + url: 'not-a-valid-url', + } as unknown as ZeroRequest + + // Test route extraction + const pattern1 = extractRoutePattern(reqWithContext) + const pattern2 = extractRoutePattern(reqWithParams) + const pattern3 = extractRoutePattern(reqWithUUID) + const pattern4 = extractRoutePattern(reqWithNumericId) + const pattern5 = extractRoutePattern(reqWithLongToken) + const pattern6 = extractRoutePattern(reqMalformed) + + // All should return strings (exact patterns depend on implementation) + console.assert(typeof pattern1 === 'string', 'Route pattern should be string') + console.assert(typeof pattern2 === 'string', 'Route pattern should be string') + console.assert(typeof pattern3 === 'string', 'Route pattern should be string') + console.assert(typeof pattern4 === 'string', 'Route pattern should be string') + console.assert(typeof pattern5 === 'string', 'Route pattern should be string') + console.assert(typeof pattern6 === 'string', 'Route pattern should be string') +} + +// Test custom metrics scenarios +const testCustomMetricsScenarios = () => { + // Create custom metrics object (reuse existing to avoid conflicts) + const customMetrics: PrometheusMetrics = defaultMetrics + + // Use custom metrics in middleware + const middlewareWithCustomMetrics = createPrometheusMiddleware({ + metrics: customMetrics, + collectDefaultMetrics: false, + }) + + // Test minimal configuration (reuse existing metrics) + const minimalMiddleware = createPrometheusMiddleware({ + metrics: customMetrics, + collectDefaultMetrics: false, + }) + const minimalIntegration = createPrometheusIntegration({ + metrics: customMetrics, + collectDefaultMetrics: false, + }) + + // Test with only specific options + const selectiveOptions: PrometheusMiddlewareOptions = { + excludePaths: ['/api/internal/*'], + skipMethods: ['TRACE', 'CONNECT'], + metrics: customMetrics, // Reuse existing + collectDefaultMetrics: false, // Disable to avoid conflicts + } + + const selectiveMiddleware = createPrometheusMiddleware(selectiveOptions) +} + +// Execute Prometheus tests +testPrometheusIntegration() +testDefaultMetrics() +testRoutePatternExtraction() +testCustomMetricsScenarios() + // ============================================================================= // COMPLEX INTEGRATION SCENARIOS // ============================================================================= @@ -434,6 +645,27 @@ const fullMiddlewareStack = () => { }), ) + // Prometheus metrics middleware (reuse existing metrics to avoid registry conflicts) + router.use( + createPrometheusMiddleware({ + metrics: defaultMetrics, // Reuse existing metrics + collectDefaultMetrics: false, // Disable to avoid conflicts + excludePaths: ['/health', '/metrics'], + extractLabels: (req: ZeroRequest, response: Response) => ({ + user_type: req.ctx?.user?.type || 'anonymous', + api_version: req.headers.get('x-api-version') || 'v1', + }), + }), + ) + + // Metrics endpoint (reuse existing metrics) + const prometheusIntegration = createPrometheusIntegration({ + endpoint: '/metrics', + metrics: defaultMetrics, // Reuse existing metrics + collectDefaultMetrics: false, // Disable to avoid conflicts + }) + router.get('/metrics', prometheusIntegration.metricsHandler) + // JWT authentication for API routes router.use( '/api/*', @@ -554,6 +786,12 @@ const runValidations = async () => { testRateLimitUtilities(mockRequest) testCORSUtilities(mockRequest) testBodyParserUtilities(mockRequest) + + // Test Prometheus utilities + testPrometheusIntegration() + testDefaultMetrics() + testRoutePatternExtraction() + testCustomMetricsScenarios() } // Run all validations @@ -567,6 +805,7 @@ runValidations() console.log('✅ Rate limiting middleware') console.log('✅ CORS middleware') console.log('✅ Body parser middleware') + console.log('✅ Prometheus metrics middleware') console.log('✅ Complex integration scenarios') console.log('✅ Error handling scenarios') console.log('✅ Async middleware patterns') diff --git a/test/unit/prometheus.test.js b/test/unit/prometheus.test.js new file mode 100644 index 0000000..802c49e --- /dev/null +++ b/test/unit/prometheus.test.js @@ -0,0 +1,480 @@ +/* global describe, it, expect, beforeEach, afterEach, jest */ + +const { + createPrometheusMiddleware, + createMetricsHandler, + createPrometheusIntegration, + createDefaultMetrics, + extractRoutePattern, +} = require('../../lib/middleware/prometheus') +const {createTestRequest} = require('../helpers') + +describe('Prometheus Middleware', () => { + let req, next, mockMetrics + + beforeEach(() => { + req = createTestRequest('GET', '/api/test') + next = jest.fn(() => new Response('Success', {status: 200})) + + // Create mock metrics + mockMetrics = { + httpRequestDuration: { + observe: jest.fn(), + }, + httpRequestTotal: { + inc: jest.fn(), + }, + httpRequestSize: { + observe: jest.fn(), + }, + httpResponseSize: { + observe: jest.fn(), + }, + httpActiveConnections: { + inc: jest.fn(), + dec: jest.fn(), + }, + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Security Features', () => { + it('should sanitize label values to prevent high cardinality', async () => { + const middleware = createPrometheusMiddleware({ + metrics: mockMetrics, + collectDefaultMetrics: false, + }) + + // Create request with very long path + req = createTestRequest('GET', '/api/' + 'x'.repeat(200)) + + await middleware(req, next) + + expect(mockMetrics.httpRequestTotal.inc).toHaveBeenCalledWith({ + method: 'GET', + route: '_api__token', // Long string gets normalized to token pattern + status_code: '200', + }) + }) + + it('should limit route complexity to prevent DoS', async () => { + const middleware = createPrometheusMiddleware({ + metrics: mockMetrics, + collectDefaultMetrics: false, + }) + + // Create request with many segments + const manySegments = Array(20).fill('segment').join('/') + req = createTestRequest('GET', '/' + manySegments) + + await middleware(req, next) + + expect(mockMetrics.httpRequestTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + route: expect.not.stringMatching( + /segment.*segment.*segment.*segment.*segment.*segment.*segment.*segment.*segment.*segment.*segment/, + ), // Should be limited + }), + ) + }) + + it('should handle malformed URLs gracefully', async () => { + const middleware = createPrometheusMiddleware({ + metrics: mockMetrics, + collectDefaultMetrics: false, + }) + + // Simulate malformed URL + req.url = 'not-a-valid-url' + + const response = await middleware(req, next) + + expect(response).toBeDefined() + expect(next).toHaveBeenCalled() + }) + + it('should sanitize custom labels to prevent injection', async () => { + const middleware = createPrometheusMiddleware({ + metrics: mockMetrics, + collectDefaultMetrics: false, + extractLabels: () => ({ + 'malicious