feat(api): serve felis_* metrics on the internal face (build-failure counter's only scrape path)

This commit is contained in:
Lemon-miaow committed 2026-09-23 16:17:50 +08:00
1 parent 17ede3c6aa
commit 94f71eea19
4 files changed
+103 -1

No files matched your search

+20
View File
@@ -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]
+5 -1
View File
@@ -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
+34
View File
@@ -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)
}
+44
View File
@@ -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)
}
}