repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

9.8 KB · 332 lines · Go Raw History
  1package web
  2
  3import (
  4	"bytes"
  5	"context"
  6	"log/slog"
  7	"sync"
  8	"testing"
  9	"time"
 10)
 11
 12// collector is a Sink that remembers what it was handed.
 13type collector struct {
 14	mu      sync.Mutex
 15	records []Record
 16	flushes int
 17}
 18
 19func (c *collector) sink(source string, records []Record) {
 20	c.mu.Lock()
 21	defer c.mu.Unlock()
 22	c.records = append(c.records, records...)
 23	c.flushes++
 24}
 25
 26func (c *collector) all() []Record {
 27	c.mu.Lock()
 28	defer c.mu.Unlock()
 29	return append([]Record{}, c.records...)
 30}
 31
 32// withShipper installs a shipper over a buffer-backed JSON handler and restores
 33// the process logger afterwards. ShipLogs replaces slog's default, which is
 34// global state, so a test that did not restore it would silently change every
 35// test that ran after it.
 36func withShipper(t *testing.T, source string, sink Sink) (*bytes.Buffer, *Shipper) {
 37	t.Helper()
 38
 39	var buf bytes.Buffer
 40	previous := slog.Default()
 41	slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
 42
 43	s := ShipLogs(source, sink)
 44	t.Cleanup(func() {
 45		s.Close()
 46		slog.SetDefault(previous)
 47	})
 48	return &buf, s
 49}
 50
 51// The whole design is a tee, not a replacement. stdout stays the source of
 52// truth, so a record has to appear in both places.
 53func TestShipLogsTeesRatherThanReplaces(t *testing.T) {
 54	c := &collector{}
 55	buf, shipper := withShipper(t, "blog", c.sink)
 56
 57	slog.Info("request",
 58		slog.Int("status", 200),
 59		slog.String("path", "/feed.atom"),
 60		slog.Float64("ms", 1.25))
 61
 62	// Close is the deterministic flush. The ticker would get there in five
 63	// seconds, which is not a thing to put in a test.
 64	shipper.Close()
 65
 66	if !bytes.Contains(buf.Bytes(), []byte(`"path":"/feed.atom"`)) {
 67		t.Errorf("the record did not reach the original handler:\n%s", buf.String())
 68	}
 69
 70	got := c.all()
 71	if len(got) != 1 {
 72		t.Fatalf("shipped %d records, want 1", len(got))
 73	}
 74	r := got[0]
 75	if r.Msg != "request" || r.Level != "INFO" {
 76		t.Errorf("msg/level = %q/%q", r.Msg, r.Level)
 77	}
 78	if r.Attrs["status"] != int64(200) {
 79		t.Errorf("status = %#v, want int64(200)", r.Attrs["status"])
 80	}
 81	if r.Attrs["ms"] != 1.25 {
 82		t.Errorf("ms = %#v, want 1.25", r.Attrs["ms"])
 83	}
 84	// Milliseconds, not nanoseconds and not seconds. The ingest side stores
 85	// this straight into a column and a wrong unit here would put every
 86	// record either in 1970 or fifty thousand years out.
 87	if delta := time.Now().UTC().UnixMilli() - r.Time; delta < 0 || delta > 60_000 {
 88		t.Errorf("Time = %d, which is not unix milliseconds near now", r.Time)
 89	}
 90}
 91
 92// Attribute kinds have to survive the crossing intact: a duration that became
 93// the string "1.042ms" is exactly what the hardening pass moved away from.
 94func TestShipLogsFlattensAttributeKinds(t *testing.T) {
 95	c := &collector{}
 96	_, shipper := withShipper(t, "blog", c.sink)
 97
 98	slog.Info("mixed",
 99		slog.Bool("ok", true),
100		slog.Int64("n", 42),
101		slog.Float64("f", 0.5),
102		slog.String("s", "text"),
103		slog.Duration("d", 1500*time.Millisecond),
104		slog.Any("err", context.Canceled),
105	)
106	shipper.Close()
107
108	got := c.all()
109	if len(got) != 1 {
110		t.Fatalf("shipped %d records, want 1", len(got))
111	}
112	a := got[0].Attrs
113	if a["ok"] != true || a["n"] != int64(42) || a["f"] != 0.5 || a["s"] != "text" {
114		t.Errorf("scalar attributes did not survive: %#v", a)
115	}
116	if a["d"] != "1.5s" {
117		t.Errorf("duration = %#v, want \"1.5s\"", a["d"])
118	}
119	// An arbitrary value is formatted rather than marshalled, because one
120	// value that cannot marshal must not cost the whole batch.
121	if a["err"] != "context canceled" {
122		t.Errorf("error = %#v, want its message", a["err"])
123	}
124}
125
126// A group is flattened to dotted keys. JSON has no notion of a slog group, and
127// a nested object would mean the ingest side could not pull "status" out with
128// one lookup.
129func TestShipLogsFlattensGroups(t *testing.T) {
130	c := &collector{}
131	_, shipper := withShipper(t, "blog", c.sink)
132
133	slog.Info("grouped", slog.Group("http", slog.Int("status", 503)))
134	shipper.Close()
135
136	got := c.all()
137	if len(got) != 1 {
138		t.Fatalf("shipped %d records, want 1", len(got))
139	}
140	if got[0].Attrs["http.status"] != int64(503) {
141		t.Errorf("attrs = %#v, want http.status", got[0].Attrs)
142	}
143}
144
145// WithAttrs and WithGroup have to return something that still tees. An embedded
146// handler's versions return the inner handler, and the failure mode is logs
147// that quietly stop being shipped from whichever subsystem used a child logger.
148func TestShipLogsSurvivesWithAttrsAndWithGroup(t *testing.T) {
149	c := &collector{}
150	_, shipper := withShipper(t, "status", c.sink)
151
152	child := slog.Default().With(slog.String("component", "crawler"))
153	child.Warn("crawl slow")
154
155	nested := slog.Default().WithGroup("job").With(slog.Int("attempt", 2))
156	nested.Info("retrying")
157
158	shipper.Close()
159
160	got := c.all()
161	if len(got) != 2 {
162		t.Fatalf("shipped %d records, want 2: a child logger stopped teeing", len(got))
163	}
164	if got[0].Attrs["component"] != "crawler" {
165		t.Errorf("With attrs lost: %#v", got[0].Attrs)
166	}
167	if got[1].Attrs["job.attempt"] != int64(2) {
168		t.Errorf("WithGroup attrs lost: %#v", got[1].Attrs)
169	}
170}
171
172// The failure this pins has no symptom until somebody uses a child logger, and
173// then it is silent: an attribute added before a WithGroup must NOT gain that
174// group's prefix. If it does, the ingest side stops recognising "component" and
175// "status" as hot columns, the record stores an empty component and a zero
176// status, and its hourly rollup is keyed wrong, while stdout shows the same
177// line correctly.
178func TestWithGroupDoesNotRePrefixEarlierAttrs(t *testing.T) {
179	c := &collector{}
180	_, shipper := withShipper(t, "status", c.sink)
181
182	logger := slog.Default().
183		With(slog.String("component", "crawler")).
184		WithGroup("http")
185	logger.Info("request", slog.Int("status", 503))
186
187	shipper.Close()
188
189	got := c.all()
190	if len(got) != 1 {
191		t.Fatalf("shipped %d records, want 1", len(got))
192	}
193	a := got[0].Attrs
194	if a["component"] != "crawler" {
195		t.Errorf("component = %#v, want \"crawler\" unprefixed; got attrs %#v", a["component"], a)
196	}
197	// The record's own attribute is inside the group, so it
198	// correctly gains the prefix. Only the earlier one must not.
199	if a["http.status"] != int64(503) {
200		t.Errorf("http.status = %#v, want int64(503); got attrs %#v", a["http.status"], a)
201	}
202	if _, bad := a["http.component"]; bad {
203		t.Errorf("component was retroactively moved into the group: %#v", a)
204	}
205}
206
207// An empty group name is a no-op in slog. Without the short-circuit the prefix
208// becomes "g." and every key under it gains a double dot.
209func TestWithGroupEmptyNameIsNoOp(t *testing.T) {
210	c := &collector{}
211	_, shipper := withShipper(t, "blog", c.sink)
212
213	h := slog.Default().Handler().WithGroup("").WithAttrs([]slog.Attr{slog.Int("n", 1)})
214	slog.New(h).Info("x")
215	shipper.Close()
216
217	got := c.all()
218	if len(got) != 1 {
219		t.Fatalf("shipped %d records, want 1", len(got))
220	}
221	if got[0].Attrs["n"] != int64(1) {
222		t.Errorf("attrs = %#v, want a bare \"n\"", got[0].Attrs)
223	}
224}
225
226// Nothing here may ever block the caller. A full queue drops, because the
227// record is already safely on stdout and a stalled site is a worse outcome than
228// a gap in a dashboard.
229func TestEnqueueDropsRatherThanBlocking(t *testing.T) {
230	s := &Shipper{ch: make(chan Record, 2)}
231
232	done := make(chan struct{})
233	go func() {
234		for i := 0; i < 100; i++ {
235			s.enqueue(Record{Msg: "x"})
236		}
237		close(done)
238	}()
239
240	select {
241	case <-done:
242	case <-time.After(2 * time.Second):
243		t.Fatal("enqueue blocked on a full queue")
244	}
245
246	if len(s.ch) != 2 {
247		t.Errorf("queue holds %d, want 2", len(s.ch))
248	}
249}
250
251// Handle can run after Close, on a shutdown path where something logs from a
252// later defer. A send on a closed channel panics, which would be the one way a
253// log shipper could take a site down with it, so the channel is never closed.
254func TestLoggingAfterCloseDoesNotPanic(t *testing.T) {
255	c := &collector{}
256	_, shipper := withShipper(t, "blog", c.sink)
257
258	shipper.Close()
259	shipper.Close() // idempotent, because a defer plus an explicit call happens
260
261	slog.Info("logged during shutdown")
262	// And a queue's worth more, to reach the point a closed channel would
263	// have been written to rather than merely selected on.
264	for i := 0; i < shipQueue+10; i++ {
265		slog.Info("more")
266	}
267}
268
269// A wedged sink must not hold shutdown open. Before this bound, a full queue
270// against a sink that accepts and never answers cost nine flushes at ten
271// seconds each: 100 seconds, against a Docker stop grace of ten, so every site
272// shipping to a hung logging container took a SIGKILL.
273func TestCloseIsBoundedWhenTheSinkHangs(t *testing.T) {
274	wedged := func(source string, records []Record) {
275		// Longer than any plausible Close budget, and longer than the whole
276		// test would tolerate if the bound were missing.
277		time.Sleep(30 * time.Second)
278	}
279	_, shipper := withShipper(t, "blog", wedged)
280
281	for i := 0; i < shipBatch*3; i++ {
282		slog.Info("burst")
283	}
284
285	start := time.Now()
286	shipper.Close()
287	elapsed := time.Since(start)
288
289	if elapsed > closeTimeout*3 {
290		t.Errorf("Close took %v against a wedged sink, want bounded near %v", elapsed, closeTimeout)
291	}
292}
293
294// Close flushes what is queued. A deploy kills these processes constantly and
295// the last few seconds of records are the ones that say why.
296func TestCloseFlushesPendingRecords(t *testing.T) {
297	c := &collector{}
298	_, shipper := withShipper(t, "blog", c.sink)
299
300	for i := 0; i < 10; i++ {
301		slog.Info("pending")
302	}
303	shipper.Close()
304
305	if got := len(c.all()); got != 10 {
306		t.Errorf("flushed %d records on close, want 10", got)
307	}
308}
309
310// The batch cap has to actually cap. Without it a burst would be handed to the
311// sink as one enormous POST that the ingest side would refuse with 413.
312func TestFlushesAtBatchSize(t *testing.T) {
313	c := &collector{}
314	_, shipper := withShipper(t, "blog", c.sink)
315
316	for i := 0; i < shipBatch+5; i++ {
317		slog.Info("burst")
318	}
319	shipper.Close()
320
321	c.mu.Lock()
322	flushes, total := c.flushes, len(c.records)
323	c.mu.Unlock()
324
325	if flushes < 2 {
326		t.Errorf("flushes = %d, want at least 2 past a batch of %d", flushes, shipBatch)
327	}
328	if total != shipBatch+5 {
329		t.Errorf("shipped %d records, want %d", total, shipBatch+5)
330	}
331}