From 94f71eea19d5c288e6d7c85060d5a39eb6d5c9e4 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Wed, 23 Sep 2026 16:17:50 +0800 Subject: [PATCH] feat(api): serve felis_* metrics on the internal face (build-failure counter's only scrape path) --- docs/openapi.yaml | 20 ++++++++++++++++ internal/api/api.go | 6 ++++- internal/api/metrics.go | 34 ++++++++++++++++++++++++++++ internal/api/metrics_test.go | 44 ++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 internal/api/metrics.go create mode 100644 internal/api/metrics_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f9b5ead..85063b6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -467,6 +467,26 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /metrics: + get: + tags: [metrics] + operationId: metrics + summary: Prometheus metrics (felis_* collectors) on the internal face. + description: >- + Scrape-only infrastructure route, not a product API: the internal listener is + ClusterIP-only and a Prometheus scrape carries no token, the same stance as the + probes. Serves the felis_* exposition documented in troubleshooting §14; the + external face never serves it. + x-felis-face: [internal] + x-felis-tier: public + security: [] + responses: + '200': + description: Prometheus text exposition format. + content: + text/plain: + schema: { type: string } + /session/minecraft/hasJoined: get: tags: [nano] diff --git a/internal/api/api.go b/internal/api/api.go index 9252c7c..bcde814 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -264,11 +264,15 @@ type apiRoute struct { } // internalAPIRoutes is the internal face's served route table (spec §7, §14): -// service-token auth, never Zero Trust. It carries both health probes. +// service-token auth, never Zero Trust. It carries both health probes and the +// metrics scrape. func (a *API) internalAPIRoutes() []apiRoute { return []apiRoute{ {Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz}, {Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz}, + // Prometheus scrape (felis_* collectors); public because a scrape carries + // no token, internal-only so it is never exposed off-cluster. + {Method: "GET", Pattern: "/metrics", Public: true, h: a.handleMetrics}, {Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers}, // The build Pod's context-fetch initContainer streams a submission's stored diff --git a/internal/api/metrics.go b/internal/api/metrics.go new file mode 100644 index 0000000..01d6e9b --- /dev/null +++ b/internal/api/metrics.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + + "felis.lolicon.best/internal/metrics" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// apiMetricsHandler serves the felis_* collectors from a process-local registry. +// The API process is the only producer of felis_image_build_failures_total (the +// build reconcile loop lives in cmd/felis.reconcileBuilds), so this endpoint is +// that counter's sole scrape path; the operator's :8080 carries the fleet +// gauges instead. +var apiMetricsHandler = newAPIMetricsHandler() + +func newAPIMetricsHandler() http.Handler { + reg := prometheus.NewRegistry() + // A fresh registry cannot already hold a collector, so Register's + // AlreadyRegistered tolerance arm never triggers here. + _ = metrics.Register(reg) + return promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) +} + +// handleMetrics is mounted as a Public route on the internal face (the same +// stance as the health probes): the internal listener is ClusterIP-only and a +// Prometheus scrape carries no token. The external face never serves metrics. +// See docs/troubleshooting.md §14 for what to scrape and the alert rules that +// consume these series. +func (a *API) handleMetrics(w http.ResponseWriter, r *http.Request) { + apiMetricsHandler.ServeHTTP(w, r) +} diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go new file mode 100644 index 0000000..534e4e2 --- /dev/null +++ b/internal/api/metrics_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "felis.lolicon.best/internal/metrics" +) + +// TestMetricsEndpoint locks the scrape surface: the internal face serves the +// felis_* collectors (the API process is felis_image_build_failures_total's +// only producer), and the external face never serves metrics. +func TestMetricsEndpoint(t *testing.T) { + // Give every collector a sample so the exposition carries all four families: + // an empty vector family is omitted from the text output entirely. The bumps + // stay inside this test binary — the collectors are per-process globals. + metrics.SyncServerGauge([]string{"Running"}) + metrics.StartDurationSeconds.Observe(3) + metrics.ImageBuildFailuresTotal.Inc() + metrics.ReaperWorldsDeletedTotal.Inc() + + a := &API{} + + w := do(a.InternalHandler(), "GET", "/metrics", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("internal GET /metrics = %d, want 200", w.Code) + } + body := w.Body.String() + for _, want := range []string{ + `felis_servers_total{state="Running"} 1`, + "felis_start_duration_seconds_count 1", + "felis_image_build_failures_total", + "felis_reaper_worlds_deleted_total", + } { + if !strings.Contains(body, want) { + t.Errorf("metrics exposition missing %q", want) + } + } + + if w := do(a.ExternalHandler(), "GET", "/metrics", "", nil); w.Code != http.StatusNotFound { + t.Fatalf("external GET /metrics = %d, want 404 (metrics stay internal)", w.Code) + } +}