diff --git a/README.md b/README.md index 95a7fc1..bf4001d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ ## ⚡ Highlights +- **Native TypeScript**: Written entirely in strict TypeScript with complete auto-generated type declarations (`.d.ts`). - **Zero External Dependencies**: Lightweight and fast, zero extra npm package footprint. - **Embedded Real-Time Dashboard**: Built-in dark mode dashboard UI with Server-Sent Events (SSE) live streaming. - **True Percentile Latencies**: Sliding window calculation of **p50, p90, p95, and p99** response times globally and per-route. diff --git a/index.js b/index.js deleted file mode 100644 index c76a7f1..0000000 --- a/index.js +++ /dev/null @@ -1,109 +0,0 @@ -import createMiddleware from './src/middleware.js'; -import store from './src/store.js'; -import { prometheusHandler as createPrometheusHandler, getPrometheusMetrics as generatePrometheusMetrics } from './src/prometheus.js'; -import { dashboardHandler as createDashboardHandler } from './src/dashboard.js'; -import { generateCurl as buildCurl } from './src/utils.js'; - -/** - * Express Monitor Middleware Factory - * @param {Object} options Configuration options - * @returns {Function} Express middleware function - */ -export default function monitor(options = {}) { - return createMiddleware(options); -} - -/** - * Get a JSON snapshot of all current metrics. - * @returns {Object} Current metrics object - */ -export function getMetrics() { - return store.getMetrics(); -} - -/** - * Calculate percentiles (p50, p90, p95, p99) for latency samples. - * @param {number[]} [samples] Optional latency samples array - * @returns {Object} Percentiles object { p50, p90, p95, p99 } - */ -export function getPercentiles(samples) { - return store.getPercentiles(samples); -} - -/** - * Reset all stored metrics to their initial state. - */ -export function resetMetrics() { - store.reset(); -} - -/** - * Express middleware route handler to serve metrics JSON over HTTP. - * @returns {Function} Express request handler (req, res) - */ -export function metricsHandler() { - return function (_req, res) { - if (typeof res.setHeader === 'function') { - res.setHeader('Content-Type', 'application/json'); - } - if (typeof res.json === 'function') { - res.json(getMetrics()); - } else if (typeof res.send === 'function') { - res.send(JSON.stringify(getMetrics(), null, 2)); - } else if (typeof res.end === 'function') { - res.end(JSON.stringify(getMetrics(), null, 2)); - } - }; -} - -/** - * Formats metrics in standard Prometheus exposition format text. - * @returns {string} Prometheus formatted string - */ -export function getPrometheusMetrics() { - return generatePrometheusMetrics(); -} - -/** - * Express middleware route handler to serve Prometheus format metrics over HTTP. - * @returns {Function} Express request handler (req, res) - */ -export function prometheusHandler() { - return createPrometheusHandler(); -} - -/** - * Express middleware route handler to serve the interactive web dashboard & SSE events stream. - * @returns {Function} Express request handler (req, res) - */ -export function dashboardHandler() { - return createDashboardHandler(); -} - -/** - * Exports stored requests as a standard HTTP Archive (HAR 1.2) JSON object. - * @param {string} [title] Optional log title - * @returns {Object} HAR 1.2 compliant log object - */ -export function exportHAR(title) { - return store.exportHAR(title); -} - -/** - * Replays a previously captured HTTP request by its ID. - * @param {string} requestId - Unique ID of the captured request - * @param {Function} [fetchFn] - Custom fetch implementation - * @returns {Promise} Execution result - */ -export function replayRequest(requestId, fetchFn) { - return store.replayRequest(requestId, fetchFn); -} - -/** - * Generates a cURL command string for an HTTP request object. - * @param {Object} req - Request payload { method, url, headers, body } - * @returns {string} Formatted cURL command - */ -export function generateCurl(req) { - return buildCurl(req); -} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..1333fab --- /dev/null +++ b/index.ts @@ -0,0 +1,115 @@ +import createMiddleware from './src/middleware.ts'; +import type { ExpressLensOptions } from './src/middleware.ts'; +import store from './src/store.ts'; +import type { ExpressLensMetrics, Percentiles } from './src/store.ts'; +import { + prometheusHandler as createPrometheusHandler, + getPrometheusMetrics as generatePrometheusMetrics, +} from './src/prometheus.ts'; +import { dashboardHandler as createDashboardHandler } from './src/dashboard.ts'; +import { generateCurl as buildCurl } from './src/utils.ts'; +import type { RequestPayload } from './src/utils.ts'; + +/** + * Express Monitor Middleware Factory + * @param options Configuration options + * @returns Express middleware function + */ +export default function monitor(options: ExpressLensOptions = {}): (req: any, res: any, next: any) => void { + return createMiddleware(options); +} + +/** + * Get a JSON snapshot of all current metrics. + * @returns Current metrics object + */ +export function getMetrics(): ExpressLensMetrics { + return store.getMetrics(); +} + +/** + * Calculate percentiles (p50, p90, p95, p99) for latency samples. + * @param samples Optional latency samples array + * @returns Percentiles object { p50, p90, p95, p99 } + */ +export function getPercentiles(samples?: number[]): Percentiles { + return store.getPercentiles(samples); +} + +/** + * Reset all stored metrics to their initial state. + */ +export function resetMetrics(): void { + store.reset(); +} + +/** + * Express middleware route handler to serve metrics JSON over HTTP. + * @returns Express request handler (req, res) + */ +export function metricsHandler(): (req: any, res: any) => void { + return function (_req: any, res: any): void { + if (typeof res.setHeader === 'function') { + res.setHeader('Content-Type', 'application/json'); + } + if (typeof res.json === 'function') { + res.json(getMetrics()); + } else if (typeof res.send === 'function') { + res.send(JSON.stringify(getMetrics(), null, 2)); + } else if (typeof res.end === 'function') { + res.end(JSON.stringify(getMetrics(), null, 2)); + } + }; +} + +/** + * Formats metrics in standard Prometheus exposition format text. + * @returns Prometheus formatted string + */ +export function getPrometheusMetrics(): string { + return generatePrometheusMetrics(); +} + +/** + * Express middleware route handler to serve Prometheus format metrics over HTTP. + * @returns Express request handler (req, res) + */ +export function prometheusHandler(): (_req: any, res: any) => void { + return createPrometheusHandler(); +} + +/** + * Express middleware route handler to serve the interactive web dashboard & SSE events stream. + * @returns Express request handler (req, res, next) + */ +export function dashboardHandler(): (req: any, res: any, next: any) => void { + return createDashboardHandler(); +} + +/** + * Exports stored requests as a standard HTTP Archive (HAR 1.2) JSON object. + * @param title Optional log title + * @returns HAR 1.2 compliant log object + */ +export function exportHAR(title?: string): any { + return store.exportHAR(title); +} + +/** + * Replays a previously captured HTTP request by its ID. + * @param requestId Unique ID of the captured request + * @param fetchFn Custom fetch implementation + * @returns Execution result promise + */ +export function replayRequest(requestId: string, fetchFn?: any): Promise { + return store.replayRequest(requestId, fetchFn); +} + +/** + * Generates a cURL command string for an HTTP request object. + * @param req Request payload { method, url, headers, body } + * @returns Formatted cURL command + */ +export function generateCurl(req?: RequestPayload): string { + return buildCurl(req); +} diff --git a/package-lock.json b/package-lock.json index cfc00f0..08e4e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.2.0", "license": "MIT", "devDependencies": { + "@types/node": "^26.1.2", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^6.0.3" @@ -858,6 +859,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2829,6 +2840,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 69c1cf1..a54f81e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codebygarv/express-lens", - "version": "2.2.0", + "version": "2.3.0", "type": "module", "publishConfig": { "access": "public" @@ -76,6 +76,7 @@ "express": "^4.0.0 || ^5.0.0" }, "devDependencies": { + "@types/node": "^26.1.2", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^6.0.3" diff --git a/src/dashboard.js b/src/dashboard.ts similarity index 98% rename from src/dashboard.js rename to src/dashboard.ts index 77f92e0..5ff4d18 100644 --- a/src/dashboard.js +++ b/src/dashboard.ts @@ -1,10 +1,10 @@ -import store from './store.js'; +import store from './store.ts'; /** * Returns the embedded single-page HTML dashboard UI template. - * @returns {string} HTML document + * @returns HTML document */ -export function getDashboardHTML() { +export function getDashboardHTML(): string { return ` @@ -213,7 +213,6 @@ export function getDashboardHTML() { document.getElementById('valP99').innerText = metrics.percentiles.p99 || 0; } - // Render routes if (metrics.routes) { const routesBody = document.getElementById('routesBody'); routesBody.innerHTML = ''; @@ -231,7 +230,6 @@ export function getDashboardHTML() { } } - // Render slow requests if (metrics.slowRequests && metrics.slowRequests.length > 0) { const slowBody = document.getElementById('slowBody'); slowBody.innerHTML = ''; @@ -307,10 +305,10 @@ export function getDashboardHTML() { /** * Express middleware route handler to serve the interactive web dashboard & SSE events stream. - * @returns {Function} Express request handler (req, res) + * @returns Express request handler (req, res, next) */ export function dashboardHandler() { - return function (req, res, next) { + return function (req: any, res: any, next: any): void { const url = req.url || req.originalUrl || ''; // 1. SSE Events endpoint diff --git a/src/middleware.js b/src/middleware.ts similarity index 70% rename from src/middleware.js rename to src/middleware.ts index 7332c9f..72b11da 100644 --- a/src/middleware.js +++ b/src/middleware.ts @@ -1,9 +1,20 @@ -import store from './store.js'; -import { getSystemMetrics } from './system.js'; -import { redactHeaders, generateCurl } from './utils.js'; +import store from './store.ts'; +import { getSystemMetrics } from './system.ts'; +import { redactHeaders, generateCurl } from './utils.ts'; +import type { AlertOptions } from './store.ts'; + +export interface ExpressLensOptions { + logAnalytics?: boolean; + logInterval?: number; + colorize?: boolean; + ignoreRoutes?: (string | RegExp)[]; + slowThresholdMs?: number; + redactHeaders?: string[]; + alerts?: AlertOptions; +} // ANSI color helpers -function colorizeStatus(status, colorize = true) { +function colorizeStatus(status: number, colorize: boolean = true): string { if (!colorize) return String(status); if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow @@ -12,15 +23,15 @@ function colorizeStatus(status, colorize = true) { return String(status); } -function colorizeLatency(timeMs, colorize = true) { +function colorizeLatency(timeMs: number, colorize: boolean = true): string { const formatted = `${timeMs.toFixed(2)}ms`; if (!colorize) return formatted; if (timeMs > 1000) return `\x1b[1;\x1b[31m${formatted}\x1b[0m`; // Bold Red - if (timeMs > 500) return `\x1b[33m${formatted}\x1b[0m`; // Yellow - return `\x1b[32m${formatted}\x1b[0m`; // Green + if (timeMs > 500) return `\x1b[33m${formatted}\x1b[0m`; // Yellow + return `\x1b[32m${formatted}\x1b[0m`; // Green } -function shouldIgnoreRoute(url, ignoreRoutes = []) { +function shouldIgnoreRoute(url: string, ignoreRoutes: (string | RegExp)[] = []): boolean { if (!ignoreRoutes || ignoreRoutes.length === 0) return false; return ignoreRoutes.some((pattern) => { if (typeof pattern === 'string') { @@ -35,7 +46,7 @@ function shouldIgnoreRoute(url, ignoreRoutes = []) { let requestIdCounter = 0; -export default function createMiddleware(options = {}) { +export default function createMiddleware(options: ExpressLensOptions = {}) { const state = { lastLogTime: Date.now(), intervalReqCount: 0, @@ -44,7 +55,7 @@ export default function createMiddleware(options = {}) { const slowThresholdMs = options.slowThresholdMs != null ? options.slowThresholdMs : 500; const customRedactList = Array.isArray(options.redactHeaders) ? options.redactHeaders : []; - return function trackRequest(req, res, next) { + return function trackRequest(req: any, res: any, next: any): void { const url = req.originalUrl || req.url || req.path || ''; // Skip ignored routes @@ -68,7 +79,7 @@ export default function createMiddleware(options = {}) { // Hook into response finish event res.on('finish', () => { const diff = process.hrtime(startAt); - const timeMs = (diff[0] * 1e3) + (diff[1] * 1e-6); + const timeMs = diff[0] * 1e3 + diff[1] * 1e-6; store.totalDuration += timeMs; store.recordLatency(timeMs); @@ -124,7 +135,9 @@ export default function createMiddleware(options = {}) { }); if (options.logAnalytics !== false) { - console.warn(`[Analytics SLOW REQUEST] ${req.method} ${url} took ${timeMs.toFixed(2)}ms (Threshold: ${slowThresholdMs}ms)`); + console.warn( + `[Analytics SLOW REQUEST] ${req.method} ${url} took ${timeMs.toFixed(2)}ms (Threshold: ${slowThresholdMs}ms)` + ); } } @@ -140,14 +153,19 @@ export default function createMiddleware(options = {}) { if (logInterval === 0 || Date.now() - state.lastLogTime >= logInterval) { const { memory } = getSystemMetrics(); const rssMb = (memory.rss / 1024 / 1024).toFixed(2); - const avgDuration = store.totalRequests > 0 ? (store.totalDuration / store.totalRequests).toFixed(2) : '0.00'; + const avgDuration = + store.totalRequests > 0 ? (store.totalDuration / store.totalRequests).toFixed(2) : '0.00'; const coloredStatus = colorizeStatus(status, useColors); const coloredLatency = colorizeLatency(timeMs, useColors); if (logInterval === 0) { - console.log(`[Analytics] ${req.method} ${url} -> ${coloredStatus} (${coloredLatency}) | Total Reqs: ${store.totalRequests} | Errors: ${store.totalErrors} | Avg: ${avgDuration}ms | Mem: ${rssMb}MB`); + console.log( + `[Analytics] ${req.method} ${url} -> ${coloredStatus} (${coloredLatency}) | Total Reqs: ${store.totalRequests} | Errors: ${store.totalErrors} | Avg: ${avgDuration}ms | Mem: ${rssMb}MB` + ); } else { - console.log(`[Analytics Summary] Interval Reqs: ${state.intervalReqCount} | Total Reqs: ${store.totalRequests} | Errors: ${store.totalErrors} | Avg: ${avgDuration}ms | Mem: ${rssMb}MB`); + console.log( + `[Analytics Summary] Interval Reqs: ${state.intervalReqCount} | Total Reqs: ${store.totalRequests} | Errors: ${store.totalErrors} | Avg: ${avgDuration}ms | Mem: ${rssMb}MB` + ); state.intervalReqCount = 0; state.lastLogTime = Date.now(); } diff --git a/src/prometheus.js b/src/prometheus.ts similarity index 94% rename from src/prometheus.js rename to src/prometheus.ts index 6600bc7..e25faf2 100644 --- a/src/prometheus.js +++ b/src/prometheus.ts @@ -1,12 +1,12 @@ -import store from './store.js'; +import store from './store.ts'; /** * Formats Express Lens metrics into standard Prometheus Exposition text format (v0.0.4). - * @returns {string} Prometheus formatted metrics string + * @returns Prometheus formatted metrics string */ -export function getPrometheusMetrics() { +export function getPrometheusMetrics(): string { const metrics = store.getMetrics(); - const lines = []; + const lines: string[] = []; lines.push('# HELP http_requests_total Total number of HTTP requests processed'); lines.push('# TYPE http_requests_total counter'); @@ -74,10 +74,10 @@ export function getPrometheusMetrics() { /** * Express middleware route handler to serve Prometheus format metrics over HTTP. - * @returns {Function} Express request handler (req, res) + * @returns Express request handler (req, res) */ export function prometheusHandler() { - return function (_req, res) { + return function (_req: any, res: any): void { if (typeof res.setHeader === 'function') { res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); } diff --git a/src/store.js b/src/store.ts similarity index 72% rename from src/store.js rename to src/store.ts index a8f0708..7b39316 100644 --- a/src/store.js +++ b/src/store.ts @@ -1,63 +1,119 @@ -import { getSystemMetrics } from './system.js'; -import { calculatePercentile, redactHeaders } from './utils.js'; +import { getSystemMetrics } from './system.ts'; +import type { SystemMetrics } from './system.ts'; +import { calculatePercentile, redactHeaders } from './utils.ts'; const MAX_ROUTES = 500; const MAX_LATENCY_SAMPLES = 1000; const MAX_RECENT_REQUESTS = 500; const MAX_SLOW_REQUESTS = 50; -const store = { - // Overall metrics - totalRequests: 0, - totalErrors: 0, - totalDuration: 0, - - // Sliding window duration samples (capped at MAX_LATENCY_SAMPLES) - latencies: [], - - // Status codes - statusCodes: { +export interface RequestEntry { + id: string; + timestamp: string; + method: string; + url: string; + status: number; + durationMs: number; + headers: Record; + ip?: string; + curl?: string; + slowThresholdMs?: number; +} + +export interface RouteStats { + count: number; + totalDuration: number; + maxDuration: number; + minDuration: number; + latencies: number[]; +} + +export interface FormattedRouteStats { + count: number; + avgDuration: number; + minDuration: number; + maxDuration: number; + percentiles: Percentiles; +} + +export interface Percentiles { + p50: number; + p90: number; + p95: number; + p99: number; +} + +export interface AlertTrigger { + type: 'ERROR_RATE_EXCEEDED' | 'MEMORY_LIMIT_EXCEEDED' | 'LATENCY_THRESHOLD_EXCEEDED'; + message: string; + metric: string; + value: number; + threshold: number; + timestamp: string; +} + +export interface AlertOptions { + errorRateThreshold?: number; + memoryThresholdMb?: number; + avgDurationThresholdMs?: number; + onAlert?: (alert: AlertTrigger) => void; +} + +export interface StoreMetrics { + totalRequests: number; + totalErrors: number; + errorRate: string; + avgDurationMs: number; + percentiles: Percentiles; + statusCodes: Record; + methods: Record; + routes: Record; + recentErrors: RequestEntry[]; + slowRequests: RequestEntry[]; + system: SystemMetrics; +} + +export type ExpressLensMetrics = StoreMetrics; + +class MetricsStore { + totalRequests: number = 0; + totalErrors: number = 0; + totalDuration: number = 0; + + latencies: number[] = []; + + statusCodes: Record = { 200: 0, 201: 0, 400: 0, 401: 0, 404: 0, 500: 0, - }, + }; - // Method metrics - methods: { + methods: Record = { GET: 0, POST: 0, PUT: 0, DELETE: 0, PATCH: 0, OPTIONS: 0, - }, - - // Route metrics map (capped at MAX_ROUTES to prevent memory leaks) - routes: new Map(), - - // Recent errors buffer (last 50 errors) - recentErrors: [], - - // Recent requests ring buffer (last 500 requests for dashboard & HAR export) - recentRequests: [], - - // Slow requests buffer (last 50 requests exceeding slowThresholdMs) - slowRequests: [], + }; - // Active SSE subscribers for real-time dashboard updates - sseClients: new Set(), + routes: Map = new Map(); + recentErrors: RequestEntry[] = []; + recentRequests: RequestEntry[] = []; + slowRequests: RequestEntry[] = []; + sseClients: Set = new Set(); - recordLatency(timeMs) { + recordLatency(timeMs: number): void { this.latencies.push(timeMs); if (this.latencies.length > MAX_LATENCY_SAMPLES) { this.latencies.shift(); } - }, + } - recordRoute(routeKey, timeMs) { + recordRoute(routeKey: string, timeMs: number): void { let key = routeKey; if (!this.routes.has(key) && this.routes.size >= MAX_ROUTES) { key = 'OTHER /other'; @@ -73,7 +129,7 @@ const store = { }); } - const routeStats = this.routes.get(key); + const routeStats = this.routes.get(key)!; routeStats.count++; routeStats.totalDuration += timeMs; routeStats.maxDuration = Math.max(routeStats.maxDuration, timeMs); @@ -83,22 +139,21 @@ const store = { if (routeStats.latencies.length > MAX_LATENCY_SAMPLES) { routeStats.latencies.shift(); } - }, + } - recordError(errorInfo) { + recordError(errorInfo: RequestEntry): void { this.recentErrors.push(errorInfo); if (this.recentErrors.length > 50) { this.recentErrors.shift(); } - }, + } - recordRequest(requestEntry) { + recordRequest(requestEntry: RequestEntry): void { this.recentRequests.push(requestEntry); if (this.recentRequests.length > MAX_RECENT_REQUESTS) { this.recentRequests.shift(); } - // Broadcast live event to connected SSE subscribers if (this.sseClients.size > 0) { const payload = `data: ${JSON.stringify(requestEntry)}\n\n`; for (const res of this.sseClients) { @@ -109,16 +164,16 @@ const store = { } } } - }, + } - recordSlowRequest(slowEntry) { + recordSlowRequest(slowEntry: RequestEntry): void { this.slowRequests.push(slowEntry); if (this.slowRequests.length > MAX_SLOW_REQUESTS) { this.slowRequests.shift(); } - }, + } - async replayRequest(requestId, fetchFn = globalThis.fetch) { + async replayRequest(requestId: string, fetchFn: any = globalThis.fetch): Promise { const entry = this.recentRequests.find((r) => r.id === requestId); if (!entry) { throw new Error(`Request with ID "${requestId}" not found in recent history.`); @@ -142,25 +197,25 @@ const store = { durationMs, originalRequestId: requestId, }; - } catch (err) { + } catch (err: any) { return { success: false, error: err.message, originalRequestId: requestId, }; } - }, + } - getPercentiles(samples = this.latencies) { + getPercentiles(samples: number[] = this.latencies): Percentiles { return { p50: calculatePercentile(samples, 50), p90: calculatePercentile(samples, 90), p95: calculatePercentile(samples, 95), p99: calculatePercentile(samples, 99), }; - }, + } - checkAlerts(options = {}) { + checkAlerts(options: { alerts?: AlertOptions } = {}): void { const alertsConfig = options.alerts || {}; if (!alertsConfig.onAlert || typeof alertsConfig.onAlert !== 'function') { return; @@ -169,7 +224,6 @@ const store = { const metrics = this.getMetrics(); const now = new Date().toISOString(); - // 1. Error rate threshold alert if (alertsConfig.errorRateThreshold != null) { const numericErrorRate = parseFloat(metrics.errorRate); if (numericErrorRate >= alertsConfig.errorRateThreshold) { @@ -184,7 +238,6 @@ const store = { } } - // 2. RSS Memory threshold alert (MB) if (alertsConfig.memoryThresholdMb != null) { const rssMb = Number((metrics.system.memory.rss / (1024 * 1024)).toFixed(2)); if (rssMb >= alertsConfig.memoryThresholdMb) { @@ -199,7 +252,6 @@ const store = { } } - // 3. Average duration threshold alert (ms) if (alertsConfig.avgDurationThresholdMs != null) { if (metrics.avgDurationMs >= alertsConfig.avgDurationThresholdMs) { alertsConfig.onAlert({ @@ -212,13 +264,13 @@ const store = { }); } } - }, + } - getMetrics() { + getMetrics(): StoreMetrics { const avgDuration = this.totalRequests > 0 ? Number((this.totalDuration / this.totalRequests).toFixed(2)) : 0; const errorRate = this.totalRequests > 0 ? Number(((this.totalErrors / this.totalRequests) * 100).toFixed(2)) : 0; - const routesObject = {}; + const routesObject: Record = {}; for (const [key, stats] of this.routes.entries()) { routesObject[key] = { count: stats.count, @@ -242,9 +294,9 @@ const store = { slowRequests: [...this.slowRequests], system: getSystemMetrics(), }; - }, + } - exportHAR(title = 'Express Lens HTTP Archive') { + exportHAR(title: string = 'Express Lens HTTP Archive'): any { const entries = this.recentRequests.map((req) => { const startTime = new Date(req.timestamp || Date.now()).toISOString(); const headers = Object.entries(redactHeaders(req.headers || {})).map(([k, v]) => ({ @@ -306,9 +358,9 @@ const store = { entries, }, }; - }, + } - reset() { + reset(): void { this.totalRequests = 0; this.totalErrors = 0; this.totalDuration = 0; @@ -319,7 +371,8 @@ const store = { this.recentErrors = []; this.recentRequests = []; this.slowRequests = []; - }, -}; + } +} +const store = new MetricsStore(); export default store; diff --git a/src/system.js b/src/system.ts similarity index 50% rename from src/system.js rename to src/system.ts index 5703124..1518069 100644 --- a/src/system.js +++ b/src/system.ts @@ -1,16 +1,36 @@ import os from 'os'; -let cachedMetrics = null; +export interface SystemMetrics { + uptime: number; + memory: { + heapTotal: number; + heapUsed: number; + rss: number; + external: number; + }; + system: { + totalMem: number; + freeMem: number; + cpus: number; + loadavg: number[]; + }; +} + +let cachedMetrics: SystemMetrics | null = null; let lastMetricFetchTime = 0; -export function getSystemMetrics(cacheTTL = 5000) { +/** + * Retrieves process and system metrics with a configurable cache TTL. + * @param cacheTTL Cache duration in milliseconds (default: 5000ms) + */ +export function getSystemMetrics(cacheTTL: number = 5000): SystemMetrics { const now = Date.now(); - if (cachedMetrics && (now - lastMetricFetchTime < cacheTTL)) { + if (cachedMetrics && now - lastMetricFetchTime < cacheTTL) { return cachedMetrics; } const memoryUsage = process.memoryUsage(); - + cachedMetrics = { uptime: process.uptime(), memory: { @@ -24,10 +44,9 @@ export function getSystemMetrics(cacheTTL = 5000) { freeMem: os.freemem(), cpus: os.cpus().length, loadavg: os.loadavg(), // [1, 5, 15] minute load averages - } + }, }; - + lastMetricFetchTime = now; return cachedMetrics; } - diff --git a/src/utils.js b/src/utils.ts similarity index 67% rename from src/utils.js rename to src/utils.ts index 0c87574..ce0a109 100644 --- a/src/utils.js +++ b/src/utils.ts @@ -2,10 +2,14 @@ * Express Lens Utility Functions */ -/** - * Default headers that should be redacted for security. - */ -const SENSITIVE_HEADERS = new Set([ +export interface RequestPayload { + method?: string; + url?: string; + headers?: Record; + body?: any; +} + +const SENSITIVE_HEADERS = new Set([ 'authorization', 'cookie', 'set-cookie', @@ -19,11 +23,11 @@ const SENSITIVE_HEADERS = new Set([ /** * Calculates a specific percentile from an array of numbers. * Uses linear interpolation for exact results. - * @param {number[]} values - Array of numerical values (latencies) - * @param {number} percentile - Percentile to calculate (0 to 100) - * @returns {number} The calculated percentile value + * @param values Array of numerical values (latencies) + * @param percentile Percentile to calculate (0 to 100) + * @returns The calculated percentile value */ -export function calculatePercentile(values, percentile) { +export function calculatePercentile(values: number[], percentile: number): number { if (!values || values.length === 0) return 0; if (values.length === 1) return Number(values[0].toFixed(2)); @@ -43,15 +47,21 @@ export function calculatePercentile(values, percentile) { /** * Redacts sensitive HTTP headers. - * @param {Object} headers - Key-value pair of headers - * @param {string[]} [customSensitiveHeaders=[]] - Additional headers to redact - * @returns {Object} Sanitized headers object + * @param headers Key-value pair of headers + * @param customSensitiveHeaders Additional headers to redact + * @returns Sanitized headers object */ -export function redactHeaders(headers = {}, customSensitiveHeaders = []) { +export function redactHeaders( + headers: Record = {}, + customSensitiveHeaders: string[] = [] +): Record { if (!headers || typeof headers !== 'object') return {}; - const sensitiveSet = new Set([...SENSITIVE_HEADERS, ...customSensitiveHeaders.map((h) => h.toLowerCase())]); - const sanitized = {}; + const sensitiveSet = new Set([ + ...SENSITIVE_HEADERS, + ...customSensitiveHeaders.map((h) => h.toLowerCase()), + ]); + const sanitized: Record = {}; for (const [key, value] of Object.entries(headers)) { const lowerKey = key.toLowerCase(); @@ -67,10 +77,10 @@ export function redactHeaders(headers = {}, customSensitiveHeaders = []) { /** * Generates a copy-pasteable cURL command string for an HTTP request. - * @param {Object} req - Request info { method, url, headers, body } - * @returns {string} Formatted cURL command + * @param req Request info { method, url, headers, body } + * @returns Formatted cURL command */ -export function generateCurl(req = {}) { +export function generateCurl(req: RequestPayload = {}): string { const method = (req.method || 'GET').toUpperCase(); const url = req.url || '/'; const headers = redactHeaders(req.headers || {}); diff --git a/test/dashboard.test.js b/test/dashboard.test.js index 64ca014..cf8ee3d 100644 --- a/test/dashboard.test.js +++ b/test/dashboard.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { dashboardHandler, getDashboardHTML } from '../src/dashboard.js'; -import store from '../src/store.js'; +import { dashboardHandler, getDashboardHTML } from '../src/dashboard.ts'; +import store from '../src/store.ts'; test('Dashboard Module Suite', async (t) => { t.afterEach(() => { diff --git a/test/index.test.js b/test/index.test.js index ca270be..7f6b125 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert'; -import monitor, { getMetrics, resetMetrics, metricsHandler } from '../index.js'; +import monitor, { getMetrics, resetMetrics, metricsHandler } from '../index.ts'; test('ExpressLens Middleware Suite', async (t) => { t.afterEach(() => { diff --git a/test/middleware.test.js b/test/middleware.test.js index 638aedd..2fb2e2f 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert'; -import monitor, { getMetrics, resetMetrics } from '../index.js'; +import monitor, { getMetrics, resetMetrics } from '../index.ts'; test('Middleware Integration Suite', async (t) => { t.afterEach(() => { diff --git a/test/prometheus.test.js b/test/prometheus.test.js index e9f2916..2633385 100644 --- a/test/prometheus.test.js +++ b/test/prometheus.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { getPrometheusMetrics, prometheusHandler } from '../src/prometheus.js'; -import store from '../src/store.js'; +import { getPrometheusMetrics, prometheusHandler } from '../src/prometheus.ts'; +import store from '../src/store.ts'; test('Prometheus Exporter Suite', async (t) => { t.afterEach(() => { diff --git a/test/store.test.js b/test/store.test.js index 69fbb43..7078e6d 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert'; -import store from '../src/store.js'; +import store from '../src/store.ts'; test('Store Module Upgrades Suite', async (t) => { t.afterEach(() => { diff --git a/test/utils.test.js b/test/utils.test.js index da95f56..58591cb 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert'; -import { calculatePercentile, redactHeaders, generateCurl } from '../src/utils.js'; +import { calculatePercentile, redactHeaders, generateCurl } from '../src/utils.ts'; test('Utils Module Suite', async (t) => { await t.test('calculatePercentile computes p50, p90, p95, p99 correctly', () => { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4afadca --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src/**/*", "index.ts"] +} diff --git a/tsup.config.js b/tsup.config.js deleted file mode 100644 index fb88615..0000000 --- a/tsup.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['index.js'], - format: ['cjs', 'esm'], - clean: true, - footer({ format }) { - if (format === 'cjs') { - return { - js: 'if (typeof module !== "undefined" && module.exports) { module.exports = monitor; module.exports.default = monitor; Object.assign(module.exports, { getMetrics, resetMetrics, metricsHandler }); }', - }; - } - }, -}); diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..ea39eff --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, + target: 'node16', +});