From 1518a4750e2dfdf13f94a3525fc62c9a9bc43e87 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 18 Aug 2026 20:48:56 +0100 Subject: [PATCH 1/5] bootstrap: allow exporters to add routes and a metrics path envar The bootstrap package builds its own mux, so an exporter that serves more than /metrics and the landing page cannot adopt it. postgres_exporter, for example, serves the multi-target /probe endpoint and the net/http/pprof handlers, and node_exporter-style adoption would silently drop both. Give Bootstrap a Handle/HandleFunc pair. Routes registered from the metrics handler factory are applied to the mux next to the metrics endpoint, which keeps them constructible from state that only exists after flags are parsed, such as the logger and the loaded config. Exporters that shipped --web.telemetry-path with an environment variable also cannot keep that behavior once bootstrap owns the flag, so allow the envar name to be configured. Signed-off-by: Nicolas Takashi --- bootstrap/bootstrap.go | 54 +++++++++++++++++++++++++++++++---- bootstrap/bootstrap_test.go | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 2d1e4c0a..d6f9a6c7 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -59,6 +59,26 @@ type Bootstrap struct { DisableExporterMetrics bool // MaxRequests is the parsed value of --web.max-requests. MaxRequests int + + routes []route +} + +// route is an additional handler registered next to the metrics endpoint. +type route struct { + pattern string + handler http.Handler +} + +// Handle registers an additional handler on the exporter mux. Handlers +// registered from a MetricsHandlerFactory are served alongside the metrics +// endpoint and the landing page. +func (b *Bootstrap) Handle(pattern string, handler http.Handler) { + b.routes = append(b.routes, route{pattern: pattern, handler: handler}) +} + +// HandleFunc registers an additional handler function on the exporter mux. +func (b *Bootstrap) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { + b.Handle(pattern, http.HandlerFunc(handler)) } // Config defines the generic exporter bootstrap inputs. @@ -72,6 +92,9 @@ type Config struct { Description string // DefaultAddress is the default value for --web.listen-address. DefaultAddress string + // MetricsPathEnvar is an optional environment variable that overrides the + // default value of --web.telemetry-path. + MetricsPathEnvar string // Logger is the logger to use. When nil, toolkit configures promslog flags // and builds a logger during Parse. Logger *slog.Logger @@ -110,6 +133,8 @@ type Runner struct { MetricsHandler http.Handler // MetricsHandlerFactory is the configured deferred metrics handler builder. MetricsHandlerFactory MetricsHandlerFactory + + bootstrap *Bootstrap } // addFlags adds the common exporter web flags to a Kingpin application. @@ -117,6 +142,19 @@ func addFlags(a *kingpin.Application, defaultAddress string) *web.FlagConfig { return kingpinflag.AddFlags(a, defaultAddress) } +// metricsPathFlag registers the metrics path flag, optionally backed by an +// exporter-specific environment variable. +func metricsPathFlag(a *kingpin.Application, envar string) *string { + f := a.Flag( + "web.telemetry-path", + "Path under which to expose metrics.", + ).Default("/metrics") + if envar != "" { + f = f.Envar(envar) + } + return f.String() +} + // New creates a generic exporter bootstrap instance. func New(c Config) *Runner { app := c.App @@ -132,10 +170,7 @@ func New(c Config) *Runner { MetricsHandler: c.MetricsHandler, MetricsHandlerFactory: c.MetricsHandlerFactory, FlagConfig: addFlags(app, c.DefaultAddress), - metricsPath: app.Flag( - "web.telemetry-path", - "Path under which to expose metrics.", - ).Default("/metrics").String(), + metricsPath: metricsPathFlag(app, c.MetricsPathEnvar), disableExporterMetrics: app.Flag( "web.disable-exporter-metrics", "Exclude metrics about the exporter itself (promhttp_*, process_*, go_*).", @@ -220,13 +255,14 @@ func (t *Runner) resolveMetricsHandler() (http.Handler, error) { return nil, errMultipleMetricsSource } if t.MetricsHandlerFactory != nil { - return t.MetricsHandlerFactory(&Bootstrap{ + t.bootstrap = &Bootstrap{ Logger: t.Logger, MetricsPath: t.MetricsPath, FlagConfig: t.FlagConfig, DisableExporterMetrics: t.DisableExporterMetrics, MaxRequests: t.MaxRequests, - }) + } + return t.MetricsHandlerFactory(t.bootstrap) } return t.MetricsHandler, nil } @@ -236,6 +272,12 @@ func (t *Runner) newServer(metricsHandler http.Handler) (*http.Server, error) { metricsPath := t.MetricsPath mux.Handle(metricsPath, metricsHandler) + if t.bootstrap != nil { + for _, r := range t.bootstrap.routes { + mux.Handle(r.pattern, r.handler) + } + } + if metricsPath != "/" { landingConfig := t.LandingConfig landingConfig.Links = append(landingConfig.Links, web.LandingLinks{ diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index dfa43ec1..dd5d3bf1 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -129,3 +129,60 @@ func TestNewServerRegistersMetricsAndLandingPage(t *testing.T) { t.Fatalf("unexpected landing body: %q", body) } } + +func TestNewServerRegistersFactoryRoutes(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + Name: "test_exporter", + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandlerFactory: func(b *Bootstrap) (http.Handler, error) { + b.HandleFunc("/probe", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("probe body")) + }) + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("metrics body")) + }), nil + }, + }) + + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + handler, err := tk.resolveMetricsHandler() + if err != nil { + t.Fatalf("unexpected handler resolution error: %v", err) + } + server, err := tk.newServer(handler) + if err != nil { + t.Fatalf("unexpected server creation error: %v", err) + } + + rec := httptest.NewRecorder() + server.Handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/probe", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected probe status: got %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "probe body" { + t.Fatalf("unexpected probe body: got %q", body) + } +} + +func TestMetricsPathEnvarOverridesDefault(t *testing.T) { + t.Setenv("TEST_EXPORTER_WEB_TELEMETRY_PATH", "/envar-metrics") + + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsPathEnvar: "TEST_EXPORTER_WEB_TELEMETRY_PATH", + MetricsHandler: http.NotFoundHandler(), + }) + + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if tk.MetricsPath != "/envar-metrics" { + t.Fatalf("unexpected metrics path: got %q, want %q", tk.MetricsPath, "/envar-metrics") + } +} From f4ecc5f9415a3523262e5035d52e9f49af82304c Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Fri, 21 Aug 2026 20:11:45 +0100 Subject: [PATCH 2/5] bootstrap: drop MetricsPathEnvar support Per review feedback on prometheus/exporter-toolkit#430, exporters should not gain new env-var-backed flags; existing env var support should be deprecated and removed rather than extended. Signed-off-by: Nicolas Takashi --- bootstrap/bootstrap.go | 18 +++++------------- bootstrap/bootstrap_test.go | 19 ------------------- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index d6f9a6c7..e05c0478 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -92,9 +92,6 @@ type Config struct { Description string // DefaultAddress is the default value for --web.listen-address. DefaultAddress string - // MetricsPathEnvar is an optional environment variable that overrides the - // default value of --web.telemetry-path. - MetricsPathEnvar string // Logger is the logger to use. When nil, toolkit configures promslog flags // and builds a logger during Parse. Logger *slog.Logger @@ -142,17 +139,12 @@ func addFlags(a *kingpin.Application, defaultAddress string) *web.FlagConfig { return kingpinflag.AddFlags(a, defaultAddress) } -// metricsPathFlag registers the metrics path flag, optionally backed by an -// exporter-specific environment variable. -func metricsPathFlag(a *kingpin.Application, envar string) *string { - f := a.Flag( +// metricsPathFlag registers the metrics path flag. +func metricsPathFlag(a *kingpin.Application) *string { + return a.Flag( "web.telemetry-path", "Path under which to expose metrics.", - ).Default("/metrics") - if envar != "" { - f = f.Envar(envar) - } - return f.String() + ).Default("/metrics").String() } // New creates a generic exporter bootstrap instance. @@ -170,7 +162,7 @@ func New(c Config) *Runner { MetricsHandler: c.MetricsHandler, MetricsHandlerFactory: c.MetricsHandlerFactory, FlagConfig: addFlags(app, c.DefaultAddress), - metricsPath: metricsPathFlag(app, c.MetricsPathEnvar), + metricsPath: metricsPathFlag(app), disableExporterMetrics: app.Flag( "web.disable-exporter-metrics", "Exclude metrics about the exporter itself (promhttp_*, process_*, go_*).", diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index dd5d3bf1..26622461 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -167,22 +167,3 @@ func TestNewServerRegistersFactoryRoutes(t *testing.T) { t.Fatalf("unexpected probe body: got %q", body) } } - -func TestMetricsPathEnvarOverridesDefault(t *testing.T) { - t.Setenv("TEST_EXPORTER_WEB_TELEMETRY_PATH", "/envar-metrics") - - tk := New(Config{ - App: kingpin.New("test", ""), - DefaultAddress: ":9100", - Logger: promslog.NewNopLogger(), - MetricsPathEnvar: "TEST_EXPORTER_WEB_TELEMETRY_PATH", - MetricsHandler: http.NotFoundHandler(), - }) - - if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { - t.Fatalf("unexpected parse error: %v", err) - } - if tk.MetricsPath != "/envar-metrics" { - t.Fatalf("unexpected metrics path: got %q, want %q", tk.MetricsPath, "/envar-metrics") - } -} From 70219560988bbae7e23d84fc75bbf0502bb65ef3 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Fri, 21 Aug 2026 20:14:18 +0100 Subject: [PATCH 3/5] bootstrap: inline the metrics path flag registration The metricsPathFlag helper only existed to branch on MetricsPathEnvar, which was dropped in a3566c1. With no branching left, the extraction added nothing. Signed-off-by: Nicolas Takashi --- bootstrap/bootstrap.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index e05c0478..0432a049 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -139,14 +139,6 @@ func addFlags(a *kingpin.Application, defaultAddress string) *web.FlagConfig { return kingpinflag.AddFlags(a, defaultAddress) } -// metricsPathFlag registers the metrics path flag. -func metricsPathFlag(a *kingpin.Application) *string { - return a.Flag( - "web.telemetry-path", - "Path under which to expose metrics.", - ).Default("/metrics").String() -} - // New creates a generic exporter bootstrap instance. func New(c Config) *Runner { app := c.App @@ -162,7 +154,10 @@ func New(c Config) *Runner { MetricsHandler: c.MetricsHandler, MetricsHandlerFactory: c.MetricsHandlerFactory, FlagConfig: addFlags(app, c.DefaultAddress), - metricsPath: metricsPathFlag(app), + metricsPath: app.Flag( + "web.telemetry-path", + "Path under which to expose metrics.", + ).Default("/metrics").String(), disableExporterMetrics: app.Flag( "web.disable-exporter-metrics", "Exclude metrics about the exporter itself (promhttp_*, process_*, go_*).", From c5b079eb174db3ecdc80e68a0c33bb63722fc8c9 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Sun, 23 Aug 2026 19:44:05 +0100 Subject: [PATCH 4/5] bootstrap: reject routes that conflict with protected paths Handle/HandleFunc let callers register any pattern, including the metrics path or root, which the mux would otherwise reject with a raw ServeMux panic at startup. Validate against the protected set (--web.telemetry-path and /) in newServer and return a normal error instead. Signed-off-by: Nicolas Takashi --- bootstrap/bootstrap.go | 8 ++++++++ bootstrap/bootstrap_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 0432a049..026f7885 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -18,6 +18,7 @@ package bootstrap import ( "errors" + "fmt" "log/slog" "net/http" "os" @@ -40,6 +41,9 @@ var ( errEmptyMetricsPath = errors.New("metrics path must not be empty") // errNegativeMaxRequests is returned when max requests is configured below zero. errNegativeMaxRequests = errors.New("web max requests must be greater than or equal to zero") + // errProtectedRoutePattern is returned when a caller-registered route + // conflicts with a path bootstrap manages itself. + errProtectedRoutePattern = errors.New("route pattern is reserved by bootstrap") ) // MetricsHandlerFactory builds an exporter-specific metrics handler after the @@ -260,7 +264,11 @@ func (t *Runner) newServer(metricsHandler http.Handler) (*http.Server, error) { mux.Handle(metricsPath, metricsHandler) if t.bootstrap != nil { + protected := map[string]bool{metricsPath: true, "/": true} for _, r := range t.bootstrap.routes { + if protected[r.pattern] { + return nil, fmt.Errorf("%w: %q", errProtectedRoutePattern, r.pattern) + } mux.Handle(r.pattern, r.handler) } } diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 26622461..a7f5063d 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -14,6 +14,7 @@ package bootstrap import ( + "errors" "net/http" "net/http/httptest" "strings" @@ -167,3 +168,38 @@ func TestNewServerRegistersFactoryRoutes(t *testing.T) { t.Fatalf("unexpected probe body: got %q", body) } } + +func TestNewServerRejectsProtectedRoutePatterns(t *testing.T) { + cases := []struct { + name string + pattern string + }{ + {name: "metrics path", pattern: "/metrics"}, + {name: "root path", pattern: "/"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandlerFactory: func(b *Bootstrap) (http.Handler, error) { + b.HandleFunc(tc.pattern, func(http.ResponseWriter, *http.Request) {}) + return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil + }, + }) + + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + handler, err := tk.resolveMetricsHandler() + if err != nil { + t.Fatalf("unexpected handler resolution error: %v", err) + } + if _, err := tk.newServer(handler); !errors.Is(err, errProtectedRoutePattern) { + t.Fatalf("unexpected error: got %v, want %v", err, errProtectedRoutePattern) + } + }) + } +} From 157a6e48e49cc60b12f39a39c081a0068ed556c2 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 25 Aug 2026 19:20:22 +0100 Subject: [PATCH 5/5] bootstrap: allow overriding the root route, keep the metrics path reserved Per review on prometheus/exporter-toolkit#430: reserving / blocks exporters that already serve something there (Prometheus itself redirects / to /query). A caller-registered route at / now takes precedence over the generated landing page. The metrics path stays reserved, since silently losing the scrape endpoint to a route collision is a much worse failure mode than losing the landing page. Signed-off-by: Nicolas Takashi --- bootstrap/bootstrap.go | 18 ++++---- bootstrap/bootstrap_test.go | 83 +++++++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 37 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 026f7885..94acb964 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -41,9 +41,10 @@ var ( errEmptyMetricsPath = errors.New("metrics path must not be empty") // errNegativeMaxRequests is returned when max requests is configured below zero. errNegativeMaxRequests = errors.New("web max requests must be greater than or equal to zero") - // errProtectedRoutePattern is returned when a caller-registered route - // conflicts with a path bootstrap manages itself. - errProtectedRoutePattern = errors.New("route pattern is reserved by bootstrap") + // errReservedMetricsPath is returned when a caller-registered route + // conflicts with --web.telemetry-path. The root path may be overridden + // by a caller-registered route, but the metrics path may not. + errReservedMetricsPath = errors.New("route pattern is reserved for the metrics handler") ) // MetricsHandlerFactory builds an exporter-specific metrics handler after the @@ -263,17 +264,20 @@ func (t *Runner) newServer(metricsHandler http.Handler) (*http.Server, error) { metricsPath := t.MetricsPath mux.Handle(metricsPath, metricsHandler) + rootOverridden := false if t.bootstrap != nil { - protected := map[string]bool{metricsPath: true, "/": true} for _, r := range t.bootstrap.routes { - if protected[r.pattern] { - return nil, fmt.Errorf("%w: %q", errProtectedRoutePattern, r.pattern) + if r.pattern == metricsPath { + return nil, fmt.Errorf("%w: %q", errReservedMetricsPath, r.pattern) + } + if r.pattern == "/" { + rootOverridden = true } mux.Handle(r.pattern, r.handler) } } - if metricsPath != "/" { + if metricsPath != "/" && !rootOverridden { landingConfig := t.LandingConfig landingConfig.Links = append(landingConfig.Links, web.LandingLinks{ Address: metricsPath, diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index a7f5063d..a77633db 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -169,37 +169,60 @@ func TestNewServerRegistersFactoryRoutes(t *testing.T) { } } -func TestNewServerRejectsProtectedRoutePatterns(t *testing.T) { - cases := []struct { - name string - pattern string - }{ - {name: "metrics path", pattern: "/metrics"}, - {name: "root path", pattern: "/"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - tk := New(Config{ - App: kingpin.New("test", ""), - DefaultAddress: ":9100", - Logger: promslog.NewNopLogger(), - MetricsHandlerFactory: func(b *Bootstrap) (http.Handler, error) { - b.HandleFunc(tc.pattern, func(http.ResponseWriter, *http.Request) {}) - return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil - }, +func TestNewServerRejectsMetricsPathOverride(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandlerFactory: func(b *Bootstrap) (http.Handler, error) { + b.HandleFunc("/metrics", func(http.ResponseWriter, *http.Request) {}) + return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil + }, + }) + + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + handler, err := tk.resolveMetricsHandler() + if err != nil { + t.Fatalf("unexpected handler resolution error: %v", err) + } + if _, err := tk.newServer(handler); !errors.Is(err, errReservedMetricsPath) { + t.Fatalf("unexpected error: got %v, want %v", err, errReservedMetricsPath) + } +} + +func TestNewServerAllowsRootRouteOverride(t *testing.T) { + tk := New(Config{ + App: kingpin.New("test", ""), + DefaultAddress: ":9100", + Logger: promslog.NewNopLogger(), + MetricsHandlerFactory: func(b *Bootstrap) (http.Handler, error) { + b.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("custom root")) }) + return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil + }, + }) - if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { - t.Fatalf("unexpected parse error: %v", err) - } - handler, err := tk.resolveMetricsHandler() - if err != nil { - t.Fatalf("unexpected handler resolution error: %v", err) - } - if _, err := tk.newServer(handler); !errors.Is(err, errProtectedRoutePattern) { - t.Fatalf("unexpected error: got %v, want %v", err, errProtectedRoutePattern) - } - }) + if err := tk.parse([]string{"--web.listen-address=:9100"}); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + handler, err := tk.resolveMetricsHandler() + if err != nil { + t.Fatalf("unexpected handler resolution error: %v", err) + } + server, err := tk.newServer(handler) + if err != nil { + t.Fatalf("unexpected server creation error: %v", err) + } + + rec := httptest.NewRecorder() + server.Handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected root status: got %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "custom root" { + t.Fatalf("unexpected root body: got %q, want the caller's override to take precedence over the landing page", body) } }