-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmetrics_readme_test.go
More file actions
284 lines (265 loc) · 11.7 KB
/
Copy pathmetrics_readme_test.go
File metadata and controls
284 lines (265 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
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("README.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)), " ")
}