@marcel91200 / caddy Mirror · read-only / commits / dc360828
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 14:45 UTC

caddyhttp: Collect metrics once per route instead of per handler (#7492)

* perf: collect metrics once per route instead of per handler (#4644) Move Prometheus metrics instrumentation from the per-handler level to the per-route level. Previously, every middleware handler in a route was individually wrapped with metricsInstrumentedHandler, causing metrics to be collected N times per request (once per handler in the chain). Since all handlers in a route see the same request, these per-handler metrics were redundant and added significant CPU overhead (73% of request handling time per the original profiling). The fix introduces metricsInstrumentedRoute which wraps the entire compiled handler chain once in wrapRoute, collecting metrics only when the route actually matches. The handler label uses the first handler's module name, which is the most meaningful identifier for the route. Benchmark results (5 handlers per route): Old (per-handler): ~4650 ns/op, 4400 B/op, 45 allocs/op New (per-route): ~940 ns/op, 816 B/op, 8 allocs/op Improvement: ~5x faster, ~5.4x less memory, ~5.6x fewer allocs Signed-off-by: Varun Chawla <varun_6april@hotmail.com> * Remove unused metricsInstrumentedHandler code Delete the metricsInstrumentedHandler type, its constructor, and ServeHTTP method since they are no longer used after switching to route-level metrics collection via metricsInstrumentedRoute. Also remove the unused metrics parameter from wrapMiddleware and the middlewareHandlerFunc test helper, and convert existing tests to use the new route-level API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review feedback: restore comments, move function to bottom - Move computeApproximateRequestSize back to bottom of file to minimize diff - Restore all useful comments that were accidentally dropped - Old metricsInstrumentedHandler already removed in previous commit --------- Signed-off-by: Varun Chawla <varun_6april@hotmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Varun Chawla committed Mar 3, 2026 at 14:15 UTC dc360828598440beb1dfdb21c0ba14e09bcd565b
3 files changed +161 -57
modules/caddyhttp/metrics.go
+11 -8
@@ -214,21 +214,24 @@ func serverNameFromContext(ctx context.Context) string {
214 return srv.name
215 }
216
217 -type metricsInstrumentedHandler struct {
217 +// metricsInstrumentedRoute wraps a compiled route Handler with metrics
218 +// instrumentation. It wraps the entire compiled route chain once,
219 +// collecting metrics only once per route match.
220 +type metricsInstrumentedRoute struct {
221 handler string
219 - mh MiddlewareHandler
222 + next Handler
223 metrics *Metrics
224 }
225
223 -func newMetricsInstrumentedHandler(ctx caddy.Context, handler string, mh MiddlewareHandler, metrics *Metrics) *metricsInstrumentedHandler {
224 - metrics.init.Do(func() {
225 - initHTTPMetrics(ctx, metrics)
226 +func newMetricsInstrumentedRoute(ctx caddy.Context, handler string, next Handler, m *Metrics) *metricsInstrumentedRoute {
227 + m.init.Do(func() {
228 + initHTTPMetrics(ctx, m)
229 })
230
228 - return &metricsInstrumentedHandler{handler, mh, metrics}
231 + return &metricsInstrumentedRoute{handler: handler, next: next, metrics: m}
232 }
233
231 -func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error {
234 +func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
235 server := serverNameFromContext(r.Context())
236 labels := prometheus.Labels{"server": server, "handler": h.handler}
237 method := metrics.SanitizeMethod(r.Method)
@@ -267,7 +270,7 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
270 return false
271 })
272 wrec := NewResponseRecorder(w, nil, writeHeaderRecorder)
270 - err := h.mh.ServeHTTP(wrec, r, next)
273 + err := h.next.ServeHTTP(wrec, r)
274 dur := time.Since(start).Seconds()
275 h.metrics.httpMetrics.requestCount.With(labels).Inc()
276
modules/caddyhttp/metrics_test.go
+124 -38
@@ -47,16 +47,12 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
47 return handlerErr
48 })
49
50 - mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
51 - return h.ServeHTTP(w, r)
52 - })
53 -
54 - ih := newMetricsInstrumentedHandler(ctx, "bar", mh, metrics)
50 + ih := newMetricsInstrumentedRoute(ctx, "bar", h, metrics)
51
52 r := httptest.NewRequest("GET", "/", nil)
53 w := httptest.NewRecorder()
54
59 - if actual := ih.ServeHTTP(w, r, h); actual != handlerErr {
55 + if actual := ih.ServeHTTP(w, r); actual != handlerErr {
56 t.Errorf("Not same: expected %#v, but got %#v", handlerErr, actual)
57 }
58 if actual := testutil.ToFloat64(metrics.httpMetrics.requestInFlight); actual != 0.0 {
@@ -64,19 +60,19 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
60 }
61
62 handlerErr = nil
67 - if err := ih.ServeHTTP(w, r, h); err != nil {
63 + if err := ih.ServeHTTP(w, r); err != nil {
64 t.Errorf("Received unexpected error: %v", err)
65 }
66
67 // an empty handler - no errors, no header written
72 - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
68 + emptyHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
69 return nil
70 })
75 - ih = newMetricsInstrumentedHandler(ctx, "empty", mh, metrics)
71 + ih = newMetricsInstrumentedRoute(ctx, "empty", emptyHandler, metrics)
72 r = httptest.NewRequest("GET", "/", nil)
73 w = httptest.NewRecorder()
74
79 - if err := ih.ServeHTTP(w, r, h); err != nil {
75 + if err := ih.ServeHTTP(w, r); err != nil {
76 t.Errorf("Received unexpected error: %v", err)
77 }
78 if actual := w.Result().StatusCode; actual != 200 {
@@ -87,16 +83,16 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
83 }
84
85 // handler returning an error with an HTTP status
90 - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
86 + errHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
87 return Error(http.StatusTooManyRequests, nil)
88 })
89
94 - ih = newMetricsInstrumentedHandler(ctx, "foo", mh, metrics)
90 + ih = newMetricsInstrumentedRoute(ctx, "foo", errHandler, metrics)
91
92 r = httptest.NewRequest("GET", "/", nil)
93 w = httptest.NewRecorder()
94
99 - if err := ih.ServeHTTP(w, r, nil); err == nil {
95 + if err := ih.ServeHTTP(w, r); err == nil {
96 t.Errorf("expected error to be propagated")
97 }
98
@@ -225,16 +221,12 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
221 return handlerErr
222 })
223
228 - mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
229 - return h.ServeHTTP(w, r)
230 - })
231 -
232 - ih := newMetricsInstrumentedHandler(ctx, "bar", mh, metrics)
224 + ih := newMetricsInstrumentedRoute(ctx, "bar", h, metrics)
225
226 r := httptest.NewRequest("GET", "/", nil)
227 w := httptest.NewRecorder()
228
237 - if actual := ih.ServeHTTP(w, r, h); actual != handlerErr {
229 + if actual := ih.ServeHTTP(w, r); actual != handlerErr {
230 t.Errorf("Not same: expected %#v, but got %#v", handlerErr, actual)
231 }
232 if actual := testutil.ToFloat64(metrics.httpMetrics.requestInFlight); actual != 0.0 {
@@ -242,19 +234,19 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
234 }
235
236 handlerErr = nil
245 - if err := ih.ServeHTTP(w, r, h); err != nil {
237 + if err := ih.ServeHTTP(w, r); err != nil {
238 t.Errorf("Received unexpected error: %v", err)
239 }
240
241 // an empty handler - no errors, no header written
250 - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
242 + emptyHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
243 return nil
244 })
253 - ih = newMetricsInstrumentedHandler(ctx, "empty", mh, metrics)
245 + ih = newMetricsInstrumentedRoute(ctx, "empty", emptyHandler, metrics)
246 r = httptest.NewRequest("GET", "/", nil)
247 w = httptest.NewRecorder()
248
257 - if err := ih.ServeHTTP(w, r, h); err != nil {
249 + if err := ih.ServeHTTP(w, r); err != nil {
250 t.Errorf("Received unexpected error: %v", err)
251 }
252 if actual := w.Result().StatusCode; actual != 200 {
@@ -265,16 +257,16 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
257 }
258
259 // handler returning an error with an HTTP status
268 - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
260 + errHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
261 return Error(http.StatusTooManyRequests, nil)
262 })
263
272 - ih = newMetricsInstrumentedHandler(ctx, "foo", mh, metrics)
264 + ih = newMetricsInstrumentedRoute(ctx, "foo", errHandler, metrics)
265
266 r = httptest.NewRequest("GET", "/", nil)
267 w = httptest.NewRecorder()
268
277 - if err := ih.ServeHTTP(w, r, nil); err == nil {
269 + if err := ih.ServeHTTP(w, r); err == nil {
270 t.Errorf("expected error to be propagated")
271 }
272
@@ -397,30 +389,30 @@ func TestMetricsCardinalityProtection(t *testing.T) {
389 // Add one allowed host
390 metrics.allowedHosts["allowed.com"] = struct{}{}
391
400 - mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
392 + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
393 w.Write([]byte("hello"))
394 return nil
395 })
396
405 - ih := newMetricsInstrumentedHandler(ctx, "test", mh, metrics)
397 + ih := newMetricsInstrumentedRoute(ctx, "test", h, metrics)
398
399 // Test request to allowed host
400 r1 := httptest.NewRequest("GET", "http://allowed.com/", nil)
401 r1.Host = "allowed.com"
402 w1 := httptest.NewRecorder()
411 - ih.ServeHTTP(w1, r1, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
403 + ih.ServeHTTP(w1, r1)
404
405 // Test request to unknown host (should be mapped to "_other")
406 r2 := httptest.NewRequest("GET", "http://attacker.com/", nil)
407 r2.Host = "attacker.com"
408 w2 := httptest.NewRecorder()
417 - ih.ServeHTTP(w2, r2, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
409 + ih.ServeHTTP(w2, r2)
410
411 // Test request to another unknown host (should also be mapped to "_other")
412 r3 := httptest.NewRequest("GET", "http://evil.com/", nil)
413 r3.Host = "evil.com"
414 w3 := httptest.NewRecorder()
423 - ih.ServeHTTP(w3, r3, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
415 + ih.ServeHTTP(w3, r3)
416
417 // Check that metrics contain:
418 // - One entry for "allowed.com"
@@ -452,26 +444,26 @@ func TestMetricsHTTPSCatchAll(t *testing.T) {
444 allowedHosts: make(map[string]struct{}), // Empty - no explicitly allowed hosts
445 }
446
455 - mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
447 + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
448 w.Write([]byte("hello"))
449 return nil
450 })
451
460 - ih := newMetricsInstrumentedHandler(ctx, "test", mh, metrics)
452 + ih := newMetricsInstrumentedRoute(ctx, "test", h, metrics)
453
454 // Test HTTPS request (should be allowed even though not in allowedHosts)
455 r1 := httptest.NewRequest("GET", "https://unknown.com/", nil)
456 r1.Host = "unknown.com"
457 r1.TLS = &tls.ConnectionState{} // Mark as TLS/HTTPS
458 w1 := httptest.NewRecorder()
467 - ih.ServeHTTP(w1, r1, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
459 + ih.ServeHTTP(w1, r1)
460
461 // Test HTTP request (should be mapped to "_other")
462 r2 := httptest.NewRequest("GET", "http://unknown.com/", nil)
463 r2.Host = "unknown.com"
464 // No TLS field = HTTP request
465 w2 := httptest.NewRecorder()
474 - ih.ServeHTTP(w2, r2, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
466 + ih.ServeHTTP(w2, r2)
467
468 // Check that HTTPS request gets real host, HTTP gets "_other"
469 expected := `
@@ -488,8 +480,102 @@ func TestMetricsHTTPSCatchAll(t *testing.T) {
480 }
481 }
482
491 -type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error
483 +func TestMetricsInstrumentedRoute(t *testing.T) {
484 + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
485 + m := &Metrics{
486 + init: sync.Once{},
487 + httpMetrics: &httpMetrics{},
488 + }
489
493 -func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error {
494 - return f(w, r, h)
490 + handlerErr := errors.New("oh noes")
491 + response := []byte("hello world!")
492 + innerHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
493 + if actual := testutil.ToFloat64(m.httpMetrics.requestInFlight); actual != 1.0 {
494 + t.Errorf("Expected requestInFlight to be 1.0, got %v", actual)
495 + }
496 + if handlerErr == nil {
497 + w.Write(response)
498 + }
499 + return handlerErr
500 + })
501 +
502 + ih := newMetricsInstrumentedRoute(ctx, "test_handler", innerHandler, m)
503 +
504 + r := httptest.NewRequest("GET", "/", nil)
505 + w := httptest.NewRecorder()
506 +
507 + // Test with error
508 + if actual := ih.ServeHTTP(w, r); actual != handlerErr {
509 + t.Errorf("Expected error %v, got %v", handlerErr, actual)
510 + }
511 + if actual := testutil.ToFloat64(m.httpMetrics.requestInFlight); actual != 0.0 {
512 + t.Errorf("Expected requestInFlight to be 0.0 after request, got %v", actual)
513 + }
514 + if actual := testutil.ToFloat64(m.httpMetrics.requestErrors); actual != 1.0 {
515 + t.Errorf("Expected requestErrors to be 1.0, got %v", actual)
516 + }
517 +
518 + // Test without error
519 + handlerErr = nil
520 + w = httptest.NewRecorder()
521 + if err := ih.ServeHTTP(w, r); err != nil {
522 + t.Errorf("Unexpected error: %v", err)
523 + }
524 +}
525 +
526 +func BenchmarkMetricsInstrumentedRoute(b *testing.B) {
527 + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
528 + m := &Metrics{
529 + init: sync.Once{},
530 + httpMetrics: &httpMetrics{},
531 + }
532 +
533 + noopHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
534 + w.Write([]byte("ok"))
535 + return nil
536 + })
537 +
538 + ih := newMetricsInstrumentedRoute(ctx, "bench_handler", noopHandler, m)
539 +
540 + r := httptest.NewRequest("GET", "/", nil)
541 + w := httptest.NewRecorder()
542 +
543 + b.ResetTimer()
544 + b.ReportAllocs()
545 + for i := 0; i < b.N; i++ {
546 + ih.ServeHTTP(w, r)
547 + }
548 +}
549 +
550 +// BenchmarkSingleRouteMetrics simulates the new behavior where metrics
551 +// are collected once for the entire route.
552 +func BenchmarkSingleRouteMetrics(b *testing.B) {
553 + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
554 + m := &Metrics{
555 + init: sync.Once{},
556 + httpMetrics: &httpMetrics{},
557 + }
558 +
559 + // Build a chain of 5 plain middleware handlers (no per-handler metrics)
560 + var next Handler = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
561 + return nil
562 + })
563 + for i := 0; i < 5; i++ {
564 + capturedNext := next
565 + next = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
566 + return capturedNext.ServeHTTP(w, r)
567 + })
568 + }
569 +
570 + // Wrap the entire chain with a single route-level metrics handler
571 + ih := newMetricsInstrumentedRoute(ctx, "handler", next, m)
572 +
573 + r := httptest.NewRequest("GET", "/", nil)
574 + w := httptest.NewRecorder()
575 +
576 + b.ResetTimer()
577 + b.ReportAllocs()
578 + for i := 0; i < b.N; i++ {
579 + ih.ServeHTTP(w, r)
580 + }
581 }
modules/caddyhttp/routes.go
+26 -11
@@ -97,7 +97,10 @@ type Route struct {
97 MatcherSets MatcherSets `json:"-"`
98 Handlers []MiddlewareHandler `json:"-"`
99
100 - middleware []Middleware
100 + middleware []Middleware
101 + metrics *Metrics
102 + metricsCtx caddy.Context
103 + handlerName string
104 }
105
106 // Empty returns true if the route has all zero/default values.
@@ -162,12 +165,20 @@ func (r *Route) ProvisionHandlers(ctx caddy.Context, metrics *Metrics) error {
165 r.Handlers = append(r.Handlers, handler.(MiddlewareHandler))
166 }
167
168 + // Store metrics info for route-level instrumentation (applied once
169 + // per route in wrapRoute, instead of per-handler which was redundant).
170 + r.metrics = metrics
171 + r.metricsCtx = ctx
172 + if len(r.Handlers) > 0 {
173 + r.handlerName = caddy.GetModuleName(r.Handlers[0])
174 + }
175 +
176 // Make ProvisionHandlers idempotent by clearing the middleware field
177 r.middleware = []Middleware{}
178
179 // pre-compile the middleware handler chain
180 for _, midhandler := range r.Handlers {
170 - r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler, metrics))
181 + r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler))
182 }
183 return nil
184 }
@@ -298,6 +309,16 @@ func wrapRoute(route Route) Middleware {
309 nextCopy = route.middleware[i](nextCopy)
310 }
311
312 + // Apply metrics instrumentation once for the entire route,
313 + // rather than wrapping each individual handler. This avoids
314 + // redundant metrics collection that caused significant CPU
315 + // overhead (see issue #4644).
316 + if route.metrics != nil {
317 + nextCopy = newMetricsInstrumentedRoute(
318 + route.metricsCtx, route.handlerName, nextCopy, route.metrics,
319 + )
320 + }
321 +
322 return nextCopy.ServeHTTP(rw, req)
323 })
324 }
@@ -306,20 +327,14 @@ func wrapRoute(route Route) Middleware {
327 // wrapMiddleware wraps mh such that it can be correctly
328 // appended to a list of middleware in preparation for
329 // compiling into a handler chain.
309 -func wrapMiddleware(ctx caddy.Context, mh MiddlewareHandler, metrics *Metrics) Middleware {
310 - handlerToUse := mh
311 - if metrics != nil {
312 - // wrap the middleware with metrics instrumentation
313 - handlerToUse = newMetricsInstrumentedHandler(ctx, caddy.GetModuleName(mh), mh, metrics)
314 - }
315 -
330 +func wrapMiddleware(ctx caddy.Context, mh MiddlewareHandler) Middleware {
331 return func(next Handler) Handler {
332 return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
333 // EXPERIMENTAL: Trace each module that gets invoked
334 if server, ok := r.Context().Value(ServerCtxKey).(*Server); ok && server != nil {
320 - server.logTrace(handlerToUse)
335 + server.logTrace(mh)
336 }
322 - return handlerToUse.ServeHTTP(w, r, next)
337 + return mh.ServeHTTP(w, r, next)
338 })
339 }
340 }