Files
metric/scrape_test.go
zeekay d13a07e7d6 Rendering a scrape is separate from writing it
Every exposition entry point returned an http.Handler -- HandlerFor,
HandlerForWithOpts, Handler, HTTPHandler, NewHTTPHandler,
InstrumentMetricHandler -- so a caller whose transport is not net/http had
one option: adapt the handler, and serve its own metrics through a
foreign router. Four services in the estate do exactly that, and the only
alternative was for each to reimplement the timeout, the error policy and
the content type a scraper reads.

Scrape renders the exposition as a value: the status, the headers, the
body. ScrapeTimeout reads the deadline off any header lookup, so the two
spellings a scraper may use are known in one place. A caller on any
transport writes those three fields.

HandlerForWithOpts is now a writer over Scrape and decides nothing
itself, so the two cannot answer differently. parseScrapeTimeout went
with it -- it was the same rule, written twice.

The evidence is the order it was taken in: TestScrapeAndTheHandlerAnswerAlike
drives seven paths -- a plain scrape, an empty one, both timeout spellings,
an unparseable one, and a gather failure under each error policy -- and
passed against the ORIGINAL handler before it was reimplemented. So the
value matches what the handler used to write, rather than matching a
handler that now derives from it.
2026-08-25 20:20:15 -07:00

153 lines
5.2 KiB
Go

// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package metric
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// A scraper reads three things and nothing else: the status, the content type,
// and the body. These pin all three for every path a scrape can take, so the
// net/http handler and any other transport driving [Scrape] answer alike — the
// handler is a writer over Scrape, and this is what says the two agree.
type stubGatherer struct {
families []*MetricFamily
err error
}
func (s stubGatherer) Gather() ([]*MetricFamily, error) { return s.families, s.err }
func oneFamily() []*MetricFamily {
return []*MetricFamily{{
Name: "hits_total",
Help: "how many",
Type: MetricTypeCounter,
Metrics: []Metric{{Value: MetricValue{Value: 3}}},
}}
}
// answer drives the net/http handler and reports what a scraper would read.
func answer(t *testing.T, g Gatherer, opts HandlerOpts, header map[string]string) (int, string, string, string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
for k, v := range header {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
HandlerForWithOpts(g, opts).ServeHTTP(rec, req)
res := rec.Result()
return res.StatusCode,
res.Header.Get("Content-Type"),
res.Header.Get("X-Content-Type-Options"),
rec.Body.String()
}
func TestScrapeAndTheHandlerAnswerAlike(t *testing.T) {
for _, tc := range []struct {
name string
g Gatherer
opts HandlerOpts
header map[string]string
}{
{"a plain scrape", stubGatherer{families: oneFamily()}, HandlerOpts{}, nil},
{"a scrape with no families", stubGatherer{}, HandlerOpts{}, nil},
{"the scraper names a deadline", stubGatherer{families: oneFamily()}, HandlerOpts{},
map[string]string{"X-Scrape-Timeout-Seconds": "5"}},
{"the prometheus spelling of that deadline", stubGatherer{families: oneFamily()}, HandlerOpts{},
map[string]string{"X-Prometheus-Scrape-Timeout-Seconds": "5"}},
{"a deadline that is not a number is no deadline", stubGatherer{families: oneFamily()}, HandlerOpts{},
map[string]string{"X-Scrape-Timeout-Seconds": "soon"}},
{"gathering fails", stubGatherer{err: errors.New("boom")}, HandlerOpts{}, nil},
{"gathering fails and the caller said continue", stubGatherer{err: errors.New("boom")},
HandlerOpts{ErrorHandling: HandlerErrorHandlingContinue}, nil},
} {
t.Run(tc.name, func(t *testing.T) {
status, ctype, nosniff, body := answer(t, tc.g, tc.opts, tc.header)
get := func(k string) string {
if tc.header == nil {
return ""
}
return tc.header[k]
}
e := Scrape(context.Background(), tc.g, tc.opts, ScrapeTimeout(get))
if e.Status != status {
t.Errorf("Scrape status = %d, the handler answers %d", e.Status, status)
}
if got := e.Header["Content-Type"]; got != ctype {
t.Errorf("Scrape Content-Type = %q, the handler answers %q", got, ctype)
}
if got := e.Header["X-Content-Type-Options"]; got != nosniff {
t.Errorf("Scrape nosniff = %q, the handler answers %q", got, nosniff)
}
if got := string(e.Body); got != body {
t.Errorf("Scrape body = %q, the handler answers %q", got, body)
}
})
}
}
// The content type carries the exposition VERSION, which a scraper reads to
// choose a parser — so it is pinned as a literal rather than compared to itself.
func TestTheExpositionNamesItsVersion(t *testing.T) {
e := Scrape(context.Background(), stubGatherer{families: oneFamily()}, HandlerOpts{}, 0)
if got, want := e.Header["Content-Type"], "text/plain; version=0.0.4; charset=utf-8"; got != want {
t.Fatalf("Content-Type = %q, want %q", got, want)
}
if e.Status != http.StatusOK {
t.Fatalf("status = %d, want 200", e.Status)
}
if len(e.Body) == 0 {
t.Fatal("body is empty; a gathered family should render")
}
}
// A failed scrape is a refusal a browser must not sniff, and the body ends in a
// newline — http.Error's shape, which callers on every transport reproduce.
func TestAFailedScrapeIsARefusal(t *testing.T) {
e := Scrape(context.Background(), stubGatherer{err: errors.New("boom")}, HandlerOpts{}, 0)
if e.Status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", e.Status)
}
if got, want := e.Header["X-Content-Type-Options"], "nosniff"; got != want {
t.Fatalf("nosniff = %q, want %q", got, want)
}
if got, want := string(e.Body), "metrics gather error\n"; got != want {
t.Fatalf("body = %q, want %q", got, want)
}
}
func TestScrapeTimeoutReadsBothSpellings(t *testing.T) {
for _, tc := range []struct {
name, key, val string
want time.Duration
}{
{"the short spelling", "X-Scrape-Timeout-Seconds", "2.5", 2500 * time.Millisecond},
{"the prometheus spelling", "X-Prometheus-Scrape-Timeout-Seconds", "1", time.Second},
{"not a number", "X-Scrape-Timeout-Seconds", "soon", 0},
{"zero is no deadline", "X-Scrape-Timeout-Seconds", "0", 0},
{"negative is no deadline", "X-Scrape-Timeout-Seconds", "-1", 0},
{"absent", "", "", 0},
} {
t.Run(tc.name, func(t *testing.T) {
got := ScrapeTimeout(func(k string) string {
if k == tc.key {
return tc.val
}
return ""
})
if got != tc.want {
t.Fatalf("ScrapeTimeout = %v, want %v", got, tc.want)
}
})
}
}