Unverified Commit 94f71eea authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 17ede3c6
Loading
Loading
Loading
Loading
+20 −0
Changes for docs/openapi.yaml: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/api.go: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/metrics.go: 34 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/api/metrics_test.go: 44 added lines, 0 removed lines.
Original line number Diff line number Diff line
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)
	}
}