Ctrl K
ComunidadGuías…

metrics_readme_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/pingular/pingularity/internal/settings"
	"github.com/pingular/pingularity/internal/store"
	"github.com/pingular/pingularity/internal/web"
)

// promMetricName matches one Prometheus metric name, in README prose or in Go
// source. The trailing `+` is load-bearing: the install instructions carry
// package globs like pingularity_*.deb, and `*` is not part of a name, so those
// never match.
var promMetricName = regexp.MustCompile(`pingularity_[a-z0-9_]+`)

// TestREADMEMetricsInventoryMatchesTheExposition holds the metrics inventory in
// docs/metrics.md to the families /metrics actually exports. The inventory lived
// in README.md until it was split out; the README now carries only a summary and
// a link, so this test follows the prose rather than the filename.
//
// That inventory is maintained BY HAND - one bullet per family, carrying the
// prose that says when a series goes absent and what its labels mean - and
// nothing regenerates it, yet it is the only place those names are explained:
// the endpoint itself ships one line of HELP each. So a family added to the
// exposition and forgotten here is invisible to everyone who did not read the
// diff. That is not hypothetical - it is the drift this test was written after,
// where the six chart-aggregate cache counters and the
// pingularity_series_query_seconds histogram shipped while the inventory named
// neither, reachable in the meantime only as pingularity_stat_total{stat}.
//
// The check runs both ways round on purpose. A family the exposition emits with
// no mention in the README is an undocumented metric; a name the README carries
// that the exposition no longer contains is a bullet for a series nobody can
// scrape. Either is a defect in the same document, and only the pair of them
// pins it.
//
// Two limits, stated so they are not mistaken for coverage. The forward
// direction reads the two hand-written tables, writeHistograms and
// writeNamedStats, because those are lists a new entry is appended to and
// forgotten; the gauges handleMetrics prints inline are held only by the reverse
// direction. And nothing here checks the PROSE - whether a bullet still
// describes when its series is absent needs a human to read it. This only makes
// a NAME impossible to leave behind.
//
// The README is deliberately NOT read here. Its summary names no metric, so
// checking it either way round would fail on every family or pass on anything.
func TestREADMEMetricsInventoryMatchesTheExposition(t *testing.T) {
	_, thisFile, ok := callerFile()
	if !ok {
		t.Fatal("runtime.Caller failed; cannot locate the module root")
	}
	root := filepath.Dir(thisFile) // this file lives at the module root

	read := func(rel string) string {
		b, err := os.ReadFile(filepath.Join(root, rel))
		if err != nil {
			t.Fatalf("read %s: %v", rel, err)
		}
		return string(b)
	}

	inventory := read(filepath.Join("docs", "metrics.md"))
	webSrc := read(filepath.Join("internal", "web", "web.go"))

	exported := exportedMetricNames(t, webSrc)
	if len(exported) < 20 {
		t.Fatalf("found only %d metric names in the exposition tables - the slice has lost its shape, and this test would pass on anything", len(exported))
	}
	for _, name := range exported {
		if !strings.Contains(inventory, name) {
			t.Errorf("/metrics exports %s but docs/metrics.md never names it - add it to the metrics inventory, beside the family it belongs with", name)
		}
	}

	for _, name := range uniqueSorted(promMetricName.FindAllString(inventory, -1)) {
		if !strings.Contains(webSrc, trimSampleSuffix(name)) {
			t.Errorf("docs/metrics.md documents %s, which internal/web/web.go no longer exports - drop the entry, or restore the metric", name)
		}
	}
}

// exportedMetricNames returns the metric names the two hand-written exposition
// tables emit, deduplicated and with the _sum/_count/_bucket sample suffixes
// trimmed back to the family name the README documents.
//
// Comments are stripped first. Both functions carry doc comments that MENTION
// families they do not emit (writeNamedStats explains itself against
// pingularity_stat_total, which writeStatMetrics owns), and a name discussed in
// prose is not a name exported here.
func exportedMetricNames(t *testing.T, webSrc string) []string {
	t.Helper()
	const from, to = "func writeHistograms(", "func writeStatMetrics("
	start, end := strings.Index(webSrc, from), strings.Index(webSrc, to)
	if start < 0 || end < start {
		t.Fatalf("internal/web/web.go no longer holds %q ... %q in that order - the exposition was restructured, and this test now reads the wrong region", from, to)
	}

	var code []string
	for _, line := range strings.Split(webSrc[start:end], "\n") {
		if i := strings.Index(line, "//"); i >= 0 {
			line = line[:i]
		}
		code = append(code, line)
	}

	var names []string
	for _, n := range promMetricName.FindAllString(strings.Join(code, "\n"), -1) {
		names = append(names, trimSampleSuffix(n))
	}
	return uniqueSorted(names)
}

// trimSampleSuffix reduces one sample's name to its family's: a histogram and a
// summary are documented under one name in the README, but reach the wire as
// _bucket/_sum/_count.
func trimSampleSuffix(name string) string {
	for _, suffix := range []string{"_bucket", "_sum", "_count"} {
		if trimmed, ok := strings.CutSuffix(name, suffix); ok {
			return trimmed
		}
	}
	return name
}

// uniqueSorted deduplicates and orders, so failures read in a stable order
// rather than the order the file happens to mention things in.
func uniqueSorted(in []string) []string {
	seen := map[string]bool{}
	var out []string
	for _, s := range in {
		if !seen[s] {
			seen[s] = true
			out = append(out, s)
		}
	}
	sort.Strings(out)
	return out
}

// TestSpeedHealthyBulletDescribesTheVerdictTheScrapeCarries holds the one bullet
// an operator builds an alert on - and the HELP line the scrape ships beside the
// gauge, which repeats it - to what pingularity_speed_healthy really carries.
// The inventory test above pins only NAMES, and this bullet's prose was wrong in
// the way that matters: it said the series is absent "when no thresholds are
// configured", present tense, as though the scrape judged the last run against
// the thresholds of the moment. It does not. The verdict is decided once, when
// the run happens, against the thresholds in force then, and stored on the row
// - the rule the README's Alerts bullet states - so clearing every threshold to
// retire the `== 0` recipe left the old 0 exported until the next run landed
// (with scheduled tests off, never), and a threshold tightened since reported a
// pass that the same scrape's download figure contradicted. Whichever way the
// exposition goes, the three surfaces have to describe the same moment.
func TestSpeedHealthyBulletDescribesTheVerdictTheScrapeCarries(t *testing.T) {
	_, thisFile, ok := callerFile()
	if !ok {
		t.Fatal("runtime.Caller failed; cannot locate the module root")
	}
	root := filepath.Dir(thisFile)
	read := func(rel string) string {
		b, err := os.ReadFile(filepath.Join(root, rel))
		if err != nil {
			t.Fatalf("read %s: %v", rel, err)
		}
		return string(b)
	}
	bullet := bulletContaining(t, read(filepath.Join("docs", "metrics.md")), "`pingularity_speed_healthy` - ")
	alerts := bulletContaining(t, read(filepath.Join("docs", "dashboard.md")), "*Thresholds* (min download/upload")

	body := scrapeWithThresholdsCleared(t)
	line, present := metricLine(body, "pingularity_speed_healthy")
	if !present {
		// The scrape drops the series with the thresholds: then the bullet's
		// promise is true as written and has to stay.
		if !strings.Contains(plainProse(bullet), "absent when no thresholds are configured") {
			t.Errorf("with every threshold cleared the scrape no longer carries pingularity_speed_healthy, but the docs/metrics.md bullet no longer says so:\n%s", bullet)
		}
		return
	}
	if line != "pingularity_speed_healthy 0" {
		t.Fatalf("scrape carries %q with every threshold cleared and an unhealthy verdict stored on the last run; expected the stored 0", line)
	}
	help := speedHealthyHelp(t, body)
	for _, s := range []struct{ name, text string }{
		{"the docs/metrics.md bullet", bullet},
		{"the # HELP line the scrape ships", help},
		{"the README's Alerts bullet", alerts},
	} {
		if !strings.Contains(plainProse(s.text), "when it ran") {
			t.Errorf("%s never says the verdict is the one judged when the run happened, but that is what the gauge carries: with every threshold cleared the scrape still exported %q\n%s", s.name, line, s.text)
		}
	}
	for _, s := range []struct{ name, text string }{
		{"the docs/metrics.md bullet", bullet},
		{"the # HELP line the scrape ships", help},
	} {
		if strings.Contains(plainProse(s.text), "absent when no thresholds are configured") {
			t.Errorf("%s promises the series is absent when no thresholds are configured, but with every threshold cleared the scrape still exported %q - the promise names the wrong moment, and clearing thresholds is how the recipes below the bullet suggest retiring the alert\n%s", s.name, line, s.text)
		}
	}
}

// scrapeWithThresholdsCleared serves /metrics for the install an operator reaches
// by clearing thresholds to silence the `== 0` alert: no threshold configured,
// and a last run whose stored verdict is "unhealthy".
func scrapeWithThresholdsCleared(t *testing.T) string {
	t.Helper()
	ctx := context.Background()
	st, err := store.Open(":memory:")
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { st.Close() })
	unhealthy := false
	if err := st.InsertSpeed(ctx, store.SpeedSample{
		TS: time.Now().Unix() - 60, Server: "Sponsor, City", Trigger: "scheduled", Engine: "ookla",
		DownMbps: 94.5, UpMbps: 12.5, PingMS: 8.5, Healthy: &unhealthy,
	}); err != nil {
		t.Fatalf("insert judged run: %v", err)
	}
	set, err := settings.New(ctx, st, settings.Values{
		Latency: 5 * time.Second, Speed: time.Hour, Timeout: 2 * time.Second,
		DownAfter: 3, UpAfter: 2, AccessLocalOnly: true,
	})
	if err != nil {
		t.Fatalf("settings: %v", err)
	}
	if set.Thresholds().Any() {
		t.Fatal("the fixture has a threshold configured; it is meant to have none")
	}
	status := func() web.LiveStatus {
		return web.LiveStatus{Online: true, Since: time.Unix(1_700_000_000, 0)}
	}
	h := web.New(st, status, nil, set, nil, "test", slog.New(slog.DiscardHandler)).Handler()
	req := httptest.NewRequest("GET", "/metrics", nil)
	req.RemoteAddr = "127.0.0.1:54321" // local-only access is the default
	req.Host = "127.0.0.1:9000"        // and the DNS-rebinding guard refuses example.com
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("GET /metrics: status %d, body %s", rec.Code, rec.Body.String())
	}
	return rec.Body.String()
}

// metricLine returns an unlabelled sample's line from a scrape, and whether the
// scrape carried the series at all.
func metricLine(body, name string) (string, bool) {
	for _, line := range strings.Split(body, "\n") {
		if strings.HasPrefix(line, name+" ") {
			return line, true
		}
	}
	return "", false
}

// speedHealthyHelp is the HELP line beside the gauge - the only sentence about
// it a reader of the endpoint alone ever sees.
func speedHealthyHelp(t *testing.T, body string) string {
	t.Helper()
	for _, line := range strings.Split(body, "\n") {
		if strings.HasPrefix(line, "# HELP pingularity_speed_healthy ") {
			return line
		}
	}
	t.Fatalf("scrape carries pingularity_speed_healthy with no HELP line:\n%s", body)
	return ""
}

// plainProse strips markdown emphasis and line wrapping so a phrase can be
// matched across "**absent** when no\n  thresholds" and the HELP line alike.
func plainProse(text string) string {
	return strings.Join(strings.Fields(strings.NewReplacer("**", "", "*", "", "`", "").Replace(text)), " ")
}