181 lines
6.8 KiB
Go
181 lines
6.8 KiB
Go
package metrics
|
|
|
|
import (
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// scrape renders the exposition the way a scraper would read it.
|
|
func scrape(t *testing.T, m *Metrics) string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
m.Handler().ServeHTTP(w, httptest.NewRequest("GET", "/metrics", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("scrape: %d %s", w.Code, w.Body)
|
|
}
|
|
return w.Body.String()
|
|
}
|
|
|
|
// Labels carry the route PATTERN and never the path. A raw path is a user's library in an operator's
|
|
// index (PD-3) and an unbounded number of series besides — the label would be chosen by whoever
|
|
// sends the request.
|
|
func TestRequestsAreCountedByRoutePatternAndNeverByPath(t *testing.T) {
|
|
m := New()
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /v0/books/{bookId}", m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
})))
|
|
mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/v0/books/bk_secret", nil))
|
|
|
|
body := scrape(t, m)
|
|
if !strings.Contains(body, `tm_platform_http_requests_total{code="404",method="GET",route="GET /v0/books/{bookId}"} 1`) {
|
|
t.Errorf("the counter does not carry the route pattern:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "bk_secret") {
|
|
t.Errorf("a book id reached the exposition:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "tm_platform_http_request_duration_seconds_bucket") {
|
|
t.Error("no latency histogram: an average is the one statistic that hides the tail")
|
|
}
|
|
}
|
|
|
|
// An unrouted request is where the label would be chosen by an attacker, so there is exactly one
|
|
// series for all of them.
|
|
func TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath(t *testing.T) {
|
|
m := New()
|
|
h := m.Middleware()(http.NotFoundHandler())
|
|
for _, p := range []string{"/../etc/passwd", "/random-1", "/random-2"} {
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", p, nil))
|
|
}
|
|
body := scrape(t, m)
|
|
if !strings.Contains(body, `route="(unmatched)"} 3`) {
|
|
t.Errorf("unmatched requests are not folded into one series:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "random-1") {
|
|
t.Errorf("a caller-chosen path became a label:\n%s", body)
|
|
}
|
|
// The METHOD is a token the caller chooses too, and on an unrouted request it is theirs to invent.
|
|
for _, verb := range []string{"FOOBAR", "WHATEVER"} {
|
|
r := httptest.NewRequest("GET", "/nope", nil)
|
|
r.Method = verb
|
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
|
}
|
|
body = scrape(t, m)
|
|
for _, verb := range []string{"FOOBAR", "WHATEVER"} {
|
|
if strings.Contains(body, verb) {
|
|
t.Errorf("a caller-chosen method became a label:\n%s", body)
|
|
}
|
|
}
|
|
if !strings.Contains(body, `method="(other)"`) {
|
|
t.Errorf("unknown methods are not folded into one series:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// The wrapper must stay transparent to http.ResponseController, which is how the upload route
|
|
// reaches its own read deadline. Asserted here as well as through the real server, because this is
|
|
// where the property lives.
|
|
func TestTheWrapperKeepsTheResponseControllerReachable(t *testing.T) {
|
|
m := New()
|
|
var sawFlush bool
|
|
h := m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
// httptest's recorder supports Flush and nothing else, which is enough: what is being
|
|
// asserted is that the controller finds the wrapped writer at all.
|
|
sawFlush = http.NewResponseController(w).Flush() == nil
|
|
}))
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
|
if !sawFlush {
|
|
t.Fatal("the wrapper hides the ResponseWriter from http.ResponseController: Unwrap is missing")
|
|
}
|
|
}
|
|
|
|
// The numbers П-11 was opened for: each one used to be answerable only in psql.
|
|
func TestTheRunnersStateIsExposedWithItsUnits(t *testing.T) {
|
|
m := New()
|
|
m.ObserveRunner(Runner{QueueDepth: 3, OldestHoldSeconds: 61.5, QuarantinedAttempts: 1,
|
|
LiveRuns: 2, BooksUploading: 4, BooksParsing: 5})
|
|
m.ObserveTailerLag(4096)
|
|
m.ObserveSweep("runs", 1500*time.Millisecond, true)
|
|
body := scrape(t, m)
|
|
for _, want := range []string{
|
|
"tm_platform_queue_depth 3",
|
|
"tm_platform_oldest_open_hold_seconds 61.5",
|
|
"tm_platform_quarantined_attempts 1",
|
|
"tm_platform_live_runs 2",
|
|
"tm_platform_tailer_lag_bytes 4096",
|
|
`tm_platform_books_in_intake{status="uploading"} 4`,
|
|
`tm_platform_books_in_intake{status="parsing"} 5`,
|
|
`tm_platform_sweep_unfinished_total{sweep="runs"} 1`,
|
|
`tm_platform_sweep_duration_seconds_sum{sweep="runs"} 1.5`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the exposition is missing %q", want)
|
|
}
|
|
}
|
|
// Base units and no unit inside a label, per the naming practices this axis is measured against.
|
|
// Judged over OUR namespace only: the Go collector's own series are its to name (and one of them
|
|
// is `go_memstats_mspan_inuse_bytes`, which is how this assertion first failed).
|
|
for _, line := range strings.Split(body, "\n") {
|
|
if !strings.HasPrefix(line, namespace+"_") {
|
|
continue
|
|
}
|
|
for _, wrong := range []string{"_ms ", "_ms{", "_millis", `unit="`} {
|
|
if strings.Contains(line, wrong) {
|
|
t.Errorf("%q is not a base-unit name", line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A process that measures nothing must still serve: every instrument is nil-safe, because the
|
|
// alternative is a telemetry failure taking down the thing it measures.
|
|
func TestAServiceWithoutTelemetryStillWorks(t *testing.T) {
|
|
var m *Metrics
|
|
m.ObserveRunner(Runner{QueueDepth: 1})
|
|
m.ObserveTailerLag(1)
|
|
m.ObserveSweep("runs", time.Second, false)
|
|
h := m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusTeapot)
|
|
}))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
|
if w.Code != http.StatusTeapot {
|
|
t.Fatalf("status %d through a nil registry", w.Code)
|
|
}
|
|
}
|
|
|
|
// The gauge's whole argument for being alertable is that a deployment with NO complete restore point
|
|
// reads +Inf rather than 0 or NaN: `> threshold` is FALSE for both of those, so an instance that has
|
|
// never made a backup would sit under every alert ever written for this series. The doc comment names
|
|
// that as the wrong answer; nothing checked it.
|
|
func TestTheBackupAgeGaugeIsInfiniteWhenThereIsNoPoint(t *testing.T) {
|
|
m := New()
|
|
read := func() float64 {
|
|
fams, err := m.registry.Gather()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range fams {
|
|
if f.GetName() == "tm_platform_backup_age_seconds" {
|
|
return f.GetMetric()[0].GetGauge().GetValue()
|
|
}
|
|
}
|
|
t.Fatal("the gauge is not registered")
|
|
return 0
|
|
}
|
|
// Before anything has observed: a fresh process must already say "no copy", not "zero seconds old".
|
|
if v := read(); !math.IsInf(v, 1) {
|
|
t.Errorf("a fresh registry reads %v; an alert on `> threshold` would never fire", v)
|
|
}
|
|
m.ObserveBackupAge(0, false)
|
|
if v := read(); !math.IsInf(v, 1) {
|
|
t.Errorf("no restore point reads %v, want +Inf", v)
|
|
}
|
|
m.ObserveBackupAge(90*time.Minute, true)
|
|
if v := read(); v != 5400 {
|
|
t.Errorf("age = %v seconds, want 5400 (the base unit is seconds, not minutes)", v)
|
|
}
|
|
}
|