feat(api): serve felis_* metrics on the internal face (build-failure counter's only scrape path)
This commit is contained in:
4 files changed
+103
-1
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user