Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ the original path (including the prefix), specify `--strip-path-prefix=false`:
kamal-proxy deploy service1 --target web-1:3000 --path-prefix=/api --strip-path-prefix=false


### Excluding paths from metrics

When metrics are enabled (with `--metrics-port`), every request handled by
the proxy is recorded in the Prometheus output. High-volume traffic from
upstream load balancers or uptime monitors hitting health endpoints can
both inflate the metrics pipeline and dominate aggregate measures like
request rate, latency percentiles, and error rates, making the resulting
metrics a poor reflection of real user traffic.

To exclude one or more paths from the metrics for a service, use
`--exclude-metrics-path` when deploying. The flag may be repeated, and
matches are exact:

kamal-proxy deploy service1 --target web-1:3000 --exclude-metrics-path /up --exclude-metrics-path /healthz

Excluded requests are still logged; only the Prometheus counters and
in-flight gauge are skipped.


### Automatic TLS

Kamal Proxy can automatically obtain and renew TLS certificates for your
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func newDeployCommand() *deployCommand {

deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogRequestHeaders, "log-request-header", nil, "Additional request header to log (may be specified multiple times)")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogResponseHeaders, "log-response-header", nil, "Additional response header to log (may be specified multiple times)")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.ExcludeMetricsPaths, "exclude-metrics-path", nil, "Request path(s) to exclude from Prometheus metrics (may be specified multiple times)")
deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ForwardHeaders, "forward-headers", false, "Forward X-Forwarded headers to target (default false if TLS enabled; otherwise true)")
deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ScopeCookiePaths, "scope-cookie-paths", false, "Scope cookie paths to match path prefix")

Expand Down
5 changes: 4 additions & 1 deletion internal/server/logging_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type loggingRequestContext struct {
Target string
RequestHeaders []string
ResponseHeaders []string
ExcludeMetrics bool
}

type LoggingMiddleware struct {
Expand Down Expand Up @@ -105,7 +106,9 @@ func (h *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
attrs = append(attrs, h.retrieveCustomHeaders(loggingRequestContext.ResponseHeaders, writer.Header(), "resp")...)
h.logger.LogAttrs(context.Background(), slog.LevelInfo, "Request", attrs...)

metrics.Tracker.TrackRequest(loggingRequestContext.Service, r.Method, writer.statusCode, elapsed)
if !loggingRequestContext.ExcludeMetrics {
metrics.Tracker.TrackRequest(loggingRequestContext.Service, r.Method, writer.statusCode, elapsed)
}
Comment on lines +109 to +111
}()

h.next.ServeHTTP(writer, r)
Expand Down
13 changes: 11 additions & 2 deletions internal/server/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ type ServiceOptions struct {
StripPrefix bool `json:"strip_prefix"`
WriterAffinityTimeout time.Duration `json:"writer_affinity_timeout"`
ReadTargetsAcceptWebsockets bool `json:"read_targets_accept_websockets"`
ExcludeMetricsPaths []string `json:"exclude_metrics_paths"`
}

func (so *ServiceOptions) ShouldExcludeMetrics(r *http.Request) bool {
return slices.Contains(so.ExcludeMetricsPaths, r.URL.Path)
}

func (so *ServiceOptions) Normalize() {
Expand Down Expand Up @@ -202,8 +207,12 @@ func (s *Service) StopRollout() error {
}

func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
metrics.Tracker.AddInflightRequest(s.name)
defer metrics.Tracker.SubtractInflightRequest(s.name)
if s.options.ShouldExcludeMetrics(r) {
LoggingRequestContext(r).ExcludeMetrics = true
} else {
metrics.Tracker.AddInflightRequest(s.name)
defer metrics.Tracker.SubtractInflightRequest(s.name)
}

s.middleware.ServeHTTP(w, r)
}
Expand Down
41 changes: 39 additions & 2 deletions internal/server/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -46,7 +47,8 @@ func TestService_RedirectToHTTPSWhenTLSRequired(t *testing.T) {
func TestService_DontRedirectToHTTPSWhenTLSAndPlainHTTPAllowed(t *testing.T) {
var forwardedProto string

service := testCreateServiceWithHandler(t, ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSRedirect: false}, defaultTargetOptions,
service := testCreateServiceWithHandler(
t, ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSRedirect: false}, defaultTargetOptions,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
forwardedProto = r.Header.Get("X-Forwarded-Proto")
}),
Expand Down Expand Up @@ -130,6 +132,40 @@ func TestService_ReturnSuccessfulHealthCheckWhilePausedOrStopped(t *testing.T) {
assert.Equal(t, http.StatusOK, checkRequest("/other"))
}

func TestServiceOptions_ShouldExcludeMetrics(t *testing.T) {
options := ServiceOptions{ExcludeMetricsPaths: []string{"/up", "/healthz"}}

assert.True(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up", nil)))
assert.True(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodPost, "/healthz", nil)))
assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/api/users", nil)))
assert.False(t, options.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up/nested", nil)))

empty := ServiceOptions{}
assert.False(t, empty.ShouldExcludeMetrics(httptest.NewRequest(http.MethodGet, "/up", nil)))
}

func TestService_ExcludeMetricsPathsMarksRequestContext(t *testing.T) {
options := defaultServiceOptions
options.ExcludeMetricsPaths = []string{"/up", "/metrics"}

service := testCreateService(t, options, defaultTargetOptions)

checkExcluded := func(path string) bool {
req := httptest.NewRequest(http.MethodGet, path, nil)
ctx := &loggingRequestContext{}
req = req.WithContext(context.WithValue(req.Context(), contextKeyRequestContext, ctx))

w := httptest.NewRecorder()
service.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
return ctx.ExcludeMetrics
}

assert.True(t, checkExcluded("/up"))
assert.True(t, checkExcluded("/metrics"))
assert.False(t, checkExcluded("/other"))
}

func TestService_MarshallingState(t *testing.T) {
targetOptions := TargetOptions{
HealthCheckConfig: HealthCheckConfig{Path: "/health", Interval: time.Second, Timeout: 2 * time.Second},
Expand Down Expand Up @@ -217,7 +253,8 @@ func TestService_UnmarshallingStateFromLegacyFormat(t *testing.T) {
}

func testCreateService(t *testing.T, options ServiceOptions, targetOptions TargetOptions) *Service {
return testCreateServiceWithHandler(t, options, targetOptions,
return testCreateServiceWithHandler(
t, options, targetOptions,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
)
}
Expand Down
Loading