Skip to content

Commit 9544834

Browse files
committed
feat: add Prometheus middleware typescript support
1 parent 44574e0 commit 9544834

File tree

3 files changed

+307
-2
lines changed

3 files changed

+307
-2
lines changed

lib/middleware/index.d.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import {RequestHandler, ZeroRequest, StepFunction} from '../../common'
2-
import {Logger} from 'pino'
1+
import {RequestHandler, ZeroRequest} from '../../common'
32

43
// Logger middleware types
54
export interface LoggerOptions {
@@ -234,3 +233,68 @@ export function createMultipartParser(
234233
export function createBodyParser(options?: BodyParserOptions): RequestHandler
235234
export function hasBody(req: ZeroRequest): boolean
236235
export function shouldParse(req: ZeroRequest, type: string): boolean
236+
237+
// Prometheus metrics middleware types
238+
export interface PrometheusMetrics {
239+
httpRequestDuration: any // prom-client Histogram
240+
httpRequestTotal: any // prom-client Counter
241+
httpRequestSize: any // prom-client Histogram
242+
httpResponseSize: any // prom-client Histogram
243+
httpActiveConnections: any // prom-client Gauge
244+
}
245+
246+
export interface PrometheusMiddlewareOptions {
247+
/** Custom metrics object to use instead of default metrics */
248+
metrics?: PrometheusMetrics
249+
/** Paths to exclude from metrics collection (default: ['/health', '/ping', '/favicon.ico', '/metrics']) */
250+
excludePaths?: string[]
251+
/** Whether to collect default Node.js metrics (default: true) */
252+
collectDefaultMetrics?: boolean
253+
/** Custom route normalization function */
254+
normalizeRoute?: (req: ZeroRequest) => string
255+
/** Custom label extraction function */
256+
extractLabels?: (
257+
req: ZeroRequest,
258+
response: Response,
259+
) => Record<string, string>
260+
/** HTTP methods to skip from metrics collection */
261+
skipMethods?: string[]
262+
}
263+
264+
export interface MetricsHandlerOptions {
265+
/** The endpoint path for metrics (default: '/metrics') */
266+
endpoint?: string
267+
/** Custom Prometheus registry to use */
268+
registry?: any // prom-client Registry
269+
}
270+
271+
export interface PrometheusIntegration {
272+
/** The middleware function */
273+
middleware: RequestHandler
274+
/** The metrics handler function */
275+
metricsHandler: RequestHandler
276+
/** The Prometheus registry */
277+
registry: any // prom-client Registry
278+
/** The prom-client module */
279+
promClient: any
280+
}
281+
282+
export function createPrometheusMiddleware(
283+
options?: PrometheusMiddlewareOptions,
284+
): RequestHandler
285+
export function createMetricsHandler(
286+
options?: MetricsHandlerOptions,
287+
): RequestHandler
288+
export function createPrometheusIntegration(
289+
options?: PrometheusMiddlewareOptions & MetricsHandlerOptions,
290+
): PrometheusIntegration
291+
export function createDefaultMetrics(): PrometheusMetrics
292+
export function extractRoutePattern(req: ZeroRequest): string
293+
294+
// Simple interface exports for common use cases
295+
export const logger: typeof createLogger
296+
export const jwtAuth: typeof createJWTAuth
297+
export const rateLimit: typeof createRateLimit
298+
export const cors: typeof createCORS
299+
export const bodyParser: typeof createBodyParser
300+
export const prometheus: typeof createPrometheusIntegration

lib/middleware/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,6 @@ module.exports = {
4949
createPrometheusMiddleware: prometheusModule.createPrometheusMiddleware,
5050
createMetricsHandler: prometheusModule.createMetricsHandler,
5151
createPrometheusIntegration: prometheusModule.createPrometheusIntegration,
52+
createDefaultMetrics: prometheusModule.createDefaultMetrics,
53+
extractRoutePattern: prometheusModule.extractRoutePattern,
5254
}

test-types.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import {
2323
createTextParser,
2424
createURLEncodedParser,
2525
createMultipartParser,
26+
// Prometheus middleware functions
27+
createPrometheusIntegration,
28+
createPrometheusMiddleware,
29+
createMetricsHandler,
30+
createDefaultMetrics,
31+
extractRoutePattern,
2632
// Type definitions
2733
JWTAuthOptions,
2834
APIKeyAuthOptions,
@@ -38,6 +44,11 @@ import {
3844
MultipartParserOptions,
3945
JWKSLike,
4046
TokenExtractionOptions,
47+
// Prometheus type definitions
48+
PrometheusMiddlewareOptions,
49+
MetricsHandlerOptions,
50+
PrometheusIntegration,
51+
PrometheusMetrics,
4152
// Available utility functions
4253
extractTokenFromHeader,
4354
defaultKeyGenerator,
@@ -390,6 +401,206 @@ const testBodyParserUtilities = (req: ZeroRequest) => {
390401
const shouldParseJson = shouldParse(req, 'application/json')
391402
}
392403

404+
// =============================================================================
405+
// PROMETHEUS METRICS MIDDLEWARE VALIDATION
406+
// =============================================================================
407+
408+
console.log('✅ Prometheus Metrics Middleware')
409+
410+
// Clear the Prometheus registry at the start to avoid conflicts
411+
try {
412+
const promClient = require('prom-client')
413+
promClient.register.clear()
414+
} catch (error) {
415+
// Ignore if prom-client is not available
416+
}
417+
418+
// Test comprehensive Prometheus middleware options
419+
const prometheusMiddlewareOptions: PrometheusMiddlewareOptions = {
420+
// Use custom metrics to avoid registry conflicts
421+
metrics: undefined, // Will create default metrics once
422+
423+
// Paths to exclude from metrics collection
424+
excludePaths: ['/health', '/ping', '/favicon.ico', '/metrics'],
425+
426+
// Whether to collect default Node.js metrics
427+
collectDefaultMetrics: false, // Disable to avoid conflicts
428+
429+
// Custom route normalization function
430+
normalizeRoute: (req: ZeroRequest) => {
431+
const url = new URL(req.url, 'http://localhost')
432+
let pathname = url.pathname
433+
434+
// Custom normalization logic
435+
return pathname
436+
.replace(/\/users\/\d+/, '/users/:id')
437+
.replace(/\/api\/v\d+/, '/api/:version')
438+
.replace(/\/items\/[a-f0-9-]{36}/, '/items/:uuid')
439+
},
440+
441+
// Custom label extraction function
442+
extractLabels: (req: ZeroRequest, response: Response) => {
443+
return {
444+
user_type: req.headers.get('x-user-type') || 'anonymous',
445+
api_version: req.headers.get('x-api-version') || 'v1',
446+
region: req.headers.get('x-region') || 'us-east-1',
447+
}
448+
},
449+
450+
// HTTP methods to skip from metrics collection
451+
skipMethods: ['OPTIONS', 'HEAD'],
452+
}
453+
454+
// Test metrics handler options
455+
const metricsHandlerOptions: MetricsHandlerOptions = {
456+
endpoint: '/custom-metrics',
457+
registry: undefined, // Would be prom-client registry in real usage
458+
}
459+
460+
// Test creating individual components (create only once to avoid registry conflicts)
461+
const defaultMetrics: PrometheusMetrics = createDefaultMetrics()
462+
const prometheusMiddleware = createPrometheusMiddleware({
463+
...prometheusMiddlewareOptions,
464+
metrics: defaultMetrics,
465+
})
466+
const metricsHandler = createMetricsHandler(metricsHandlerOptions)
467+
468+
// Test the integration function (use existing metrics)
469+
const prometheusIntegration: PrometheusIntegration =
470+
createPrometheusIntegration({
471+
...prometheusMiddlewareOptions,
472+
...metricsHandlerOptions,
473+
metrics: defaultMetrics, // Reuse existing metrics
474+
})
475+
476+
// Test the integration object structure
477+
const testPrometheusIntegration = () => {
478+
// Test middleware function
479+
const middleware: RequestHandler = prometheusIntegration.middleware
480+
481+
// Test metrics handler function
482+
const handler: RequestHandler = prometheusIntegration.metricsHandler
483+
484+
// Test registry access
485+
const registry = prometheusIntegration.registry
486+
487+
// Test prom-client access for custom metrics
488+
const promClient = prometheusIntegration.promClient
489+
}
490+
491+
// Test default metrics structure
492+
const testDefaultMetrics = () => {
493+
// Use the already created metrics to avoid registry conflicts
494+
const metrics = defaultMetrics
495+
496+
// Test that all expected metrics are present
497+
const duration = metrics.httpRequestDuration
498+
const total = metrics.httpRequestTotal
499+
const requestSize = metrics.httpRequestSize
500+
const responseSize = metrics.httpResponseSize
501+
const activeConnections = metrics.httpActiveConnections
502+
503+
// All should be defined (prom-client objects)
504+
console.assert(
505+
duration !== undefined,
506+
'httpRequestDuration should be defined',
507+
)
508+
console.assert(total !== undefined, 'httpRequestTotal should be defined')
509+
console.assert(requestSize !== undefined, 'httpRequestSize should be defined')
510+
console.assert(
511+
responseSize !== undefined,
512+
'httpResponseSize should be defined',
513+
)
514+
console.assert(
515+
activeConnections !== undefined,
516+
'httpActiveConnections should be defined',
517+
)
518+
}
519+
520+
// Test route pattern extraction
521+
const testRoutePatternExtraction = () => {
522+
// Mock request objects for testing (using unknown casting for test purposes)
523+
const reqWithContext = {
524+
ctx: {route: '/users/:id'},
525+
url: 'http://localhost:3000/users/123',
526+
} as unknown as ZeroRequest
527+
528+
const reqWithParams = {
529+
url: 'http://localhost:3000/users/123',
530+
params: {id: '123'},
531+
} as unknown as ZeroRequest
532+
533+
const reqWithUUID = {
534+
url: 'http://localhost:3000/items/550e8400-e29b-41d4-a716-446655440000',
535+
} as unknown as ZeroRequest
536+
537+
const reqWithNumericId = {
538+
url: 'http://localhost:3000/posts/12345',
539+
} as unknown as ZeroRequest
540+
541+
const reqWithLongToken = {
542+
url: 'http://localhost:3000/auth/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
543+
} as unknown as ZeroRequest
544+
545+
const reqMalformed = {
546+
url: 'not-a-valid-url',
547+
} as unknown as ZeroRequest
548+
549+
// Test route extraction
550+
const pattern1 = extractRoutePattern(reqWithContext)
551+
const pattern2 = extractRoutePattern(reqWithParams)
552+
const pattern3 = extractRoutePattern(reqWithUUID)
553+
const pattern4 = extractRoutePattern(reqWithNumericId)
554+
const pattern5 = extractRoutePattern(reqWithLongToken)
555+
const pattern6 = extractRoutePattern(reqMalformed)
556+
557+
// All should return strings (exact patterns depend on implementation)
558+
console.assert(typeof pattern1 === 'string', 'Route pattern should be string')
559+
console.assert(typeof pattern2 === 'string', 'Route pattern should be string')
560+
console.assert(typeof pattern3 === 'string', 'Route pattern should be string')
561+
console.assert(typeof pattern4 === 'string', 'Route pattern should be string')
562+
console.assert(typeof pattern5 === 'string', 'Route pattern should be string')
563+
console.assert(typeof pattern6 === 'string', 'Route pattern should be string')
564+
}
565+
566+
// Test custom metrics scenarios
567+
const testCustomMetricsScenarios = () => {
568+
// Create custom metrics object (reuse existing to avoid conflicts)
569+
const customMetrics: PrometheusMetrics = defaultMetrics
570+
571+
// Use custom metrics in middleware
572+
const middlewareWithCustomMetrics = createPrometheusMiddleware({
573+
metrics: customMetrics,
574+
collectDefaultMetrics: false,
575+
})
576+
577+
// Test minimal configuration (reuse existing metrics)
578+
const minimalMiddleware = createPrometheusMiddleware({
579+
metrics: customMetrics,
580+
collectDefaultMetrics: false,
581+
})
582+
const minimalIntegration = createPrometheusIntegration({
583+
metrics: customMetrics,
584+
collectDefaultMetrics: false,
585+
})
586+
587+
// Test with only specific options
588+
const selectiveOptions: PrometheusMiddlewareOptions = {
589+
excludePaths: ['/api/internal/*'],
590+
skipMethods: ['TRACE', 'CONNECT'],
591+
metrics: customMetrics, // Reuse existing
592+
collectDefaultMetrics: false, // Disable to avoid conflicts
593+
}
594+
595+
const selectiveMiddleware = createPrometheusMiddleware(selectiveOptions)
596+
}
597+
598+
// Execute Prometheus tests
599+
testPrometheusIntegration()
600+
testDefaultMetrics()
601+
testRoutePatternExtraction()
602+
testCustomMetricsScenarios()
603+
393604
// =============================================================================
394605
// COMPLEX INTEGRATION SCENARIOS
395606
// =============================================================================
@@ -434,6 +645,27 @@ const fullMiddlewareStack = () => {
434645
}),
435646
)
436647

648+
// Prometheus metrics middleware (reuse existing metrics to avoid registry conflicts)
649+
router.use(
650+
createPrometheusMiddleware({
651+
metrics: defaultMetrics, // Reuse existing metrics
652+
collectDefaultMetrics: false, // Disable to avoid conflicts
653+
excludePaths: ['/health', '/metrics'],
654+
extractLabels: (req: ZeroRequest, response: Response) => ({
655+
user_type: req.ctx?.user?.type || 'anonymous',
656+
api_version: req.headers.get('x-api-version') || 'v1',
657+
}),
658+
}),
659+
)
660+
661+
// Metrics endpoint (reuse existing metrics)
662+
const prometheusIntegration = createPrometheusIntegration({
663+
endpoint: '/metrics',
664+
metrics: defaultMetrics, // Reuse existing metrics
665+
collectDefaultMetrics: false, // Disable to avoid conflicts
666+
})
667+
router.get('/metrics', prometheusIntegration.metricsHandler)
668+
437669
// JWT authentication for API routes
438670
router.use(
439671
'/api/*',
@@ -554,6 +786,12 @@ const runValidations = async () => {
554786
testRateLimitUtilities(mockRequest)
555787
testCORSUtilities(mockRequest)
556788
testBodyParserUtilities(mockRequest)
789+
790+
// Test Prometheus utilities
791+
testPrometheusIntegration()
792+
testDefaultMetrics()
793+
testRoutePatternExtraction()
794+
testCustomMetricsScenarios()
557795
}
558796

559797
// Run all validations
@@ -567,6 +805,7 @@ runValidations()
567805
console.log('✅ Rate limiting middleware')
568806
console.log('✅ CORS middleware')
569807
console.log('✅ Body parser middleware')
808+
console.log('✅ Prometheus metrics middleware')
570809
console.log('✅ Complex integration scenarios')
571810
console.log('✅ Error handling scenarios')
572811
console.log('✅ Async middleware patterns')

0 commit comments

Comments
 (0)