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.1 KB · 344 lines · Go Raw History
  1package web
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"log/slog"
 10	"net/http"
 11	"os"
 12	"sync"
 13	"time"
 14)
 15
 16// Log shipping: a slog.Handler that tees every record to logging.bythewood.me
 17// on top of the existing stdout handler. stdout stays the source of truth, so
 18// nothing here ever blocks the caller. A full queue drops and a failed POST
 19// drops.
 20
 21// ShipEndpoint is a container name on the orchard-edge bridge, never the public
 22// hostname. Anything that can reach it is already inside the network, so there
 23// is no token to configure.
 24const ShipEndpoint = "http://orchard-logging:8000/ingest"
 25
 26const (
 27	// Bounded, so the failure mode is dropping rather than blocking.
 28	shipQueue = 4096
 29	// Whichever comes first. Each flush is one POST that the logging site
 30	// itself logs, so a faster cadence would make ingest the loudest thing
 31	// in the database.
 32	shipBatch   = 500
 33	shipEvery   = 5 * time.Second
 34	shipTimeout = 10 * time.Second
 35
 36	// The hard ceiling on Close, and what keeps a wedged logging site from
 37	// reaching the sites it watches. A sink that accepts the connection and
 38	// never answers costs shipTimeout per flush, which unbounded runs past
 39	// Docker's stop grace and turns one hung container into a SIGKILL for
 40	// every other site, skipping their db.Close(). A healthy sink drains in
 41	// milliseconds and Close returns immediately either way.
 42	closeTimeout = 2 * time.Second
 43)
 44
 45// Record is one slog record on the wire, and the shape the ingest endpoint
 46// parses. Short keys because this is machine to machine at volume.
 47type Record struct {
 48	// Unix milliseconds, UTC.
 49	Time  int64          `json:"t"`
 50	Level string         `json:"l"`
 51	Msg   string         `json:"m"`
 52	Attrs map[string]any `json:"a,omitempty"`
 53}
 54
 55// Batch is one POST body.
 56type Batch struct {
 57	Source  string   `json:"source"`
 58	Records []Record `json:"records"`
 59}
 60
 61// Sink consumes a flush. HTTPSink posts to the logging site, which passes its
 62// own database writer instead so it never posts to itself.
 63type Sink func(source string, records []Record)
 64
 65// Shipper owns the queue and the goroutine that drains it.
 66type Shipper struct {
 67	source string
 68	sink   Sink
 69	ch     chan Record
 70
 71	quit chan struct{}
 72	done chan struct{}
 73	stop sync.Once
 74}
 75
 76// ShipLogs installs the tee on top of whatever slog.Default() already is, so
 77// SetupLogging must have run first. The returned Shipper flushes what it holds
 78// on Close.
 79func ShipLogs(source string, sink Sink) *Shipper {
 80	s := &Shipper{
 81		source: source,
 82		sink:   sink,
 83		ch:     make(chan Record, shipQueue),
 84		quit:   make(chan struct{}),
 85		done:   make(chan struct{}),
 86	}
 87	slog.SetDefault(slog.New(&teeHandler{next: slog.Default().Handler(), ship: s}))
 88	go s.run()
 89	return s
 90}
 91
 92// enqueue never blocks. A full queue means the record is already safely on
 93// stdout either way.
 94//
 95// The channel is never closed. Handle can still run after Close, from a later
 96// defer, and a send on a closed channel panics, which is the one way a log
 97// shipper could take a site down with it.
 98func (s *Shipper) enqueue(r Record) {
 99	select {
100	case s.ch <- r:
101	default:
102	}
103}
104
105func (s *Shipper) run() {
106	defer close(s.done)
107
108	tick := time.NewTicker(shipEvery)
109	defer tick.Stop()
110
111	batch := make([]Record, 0, shipBatch)
112	flush := func() {
113		if len(batch) == 0 {
114			return
115		}
116		s.sink(s.source, batch)
117		batch = batch[:0]
118	}
119
120	for {
121		select {
122		case r := <-s.ch:
123			batch = append(batch, r)
124			if len(batch) >= shipBatch {
125				flush()
126			}
127		case <-tick.C:
128			flush()
129		case <-s.quit:
130			// Drain what is queued, then flush once. Anything enqueued
131			// after this drops.
132			for {
133				select {
134				case r := <-s.ch:
135					batch = append(batch, r)
136					if len(batch) >= shipBatch {
137						flush()
138					}
139					continue
140				default:
141				}
142				break
143			}
144			flush()
145			return
146		}
147	}
148}
149
150// Close drains what is queued and waits for the last flush, but never longer
151// than closeTimeout.
152func (s *Shipper) Close() {
153	s.stop.Do(func() { close(s.quit) })
154
155	t := time.NewTimer(closeTimeout)
156	defer t.Stop()
157	select {
158	case <-s.done:
159	case <-t.C:
160		// Sink is not answering. Those records are on stdout anyway.
161		fmt.Fprintf(os.Stderr, "log shipping: gave up draining after %s\n", closeTimeout)
162	}
163}
164
165// teeHandler writes through to the real handler and copies to the queue. It
166// cannot embed the next handler, because an embedded WithAttrs or WithGroup
167// returns the inner handler and silently stops teeing.
168type teeHandler struct {
169	next slog.Handler
170	ship *Shipper
171	// Each attribute keeps the group prefix in force when it was added, not
172	// the current one. A flat []slog.Attr would retroactively re-prefix
173	// anything attached before a later WithGroup, so .With(component=crawler)
174	// then .WithGroup("http") would ship "http.component" where slog says it
175	// must stay "component", and the ingest side matches keys by exact name.
176	attrs []groupedAttr
177	group string
178}
179
180// groupedAttr is one attribute frozen with the prefix it was added under.
181type groupedAttr struct {
182	prefix string
183	attr   slog.Attr
184}
185
186func (h *teeHandler) Enabled(ctx context.Context, l slog.Level) bool {
187	return h.next.Enabled(ctx, l)
188}
189
190func (h *teeHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
191	out := &teeHandler{next: h.next.WithAttrs(attrs), ship: h.ship, group: h.group}
192	out.attrs = append([]groupedAttr{}, h.attrs...)
193	for _, a := range attrs {
194		out.attrs = append(out.attrs, groupedAttr{prefix: h.group, attr: a})
195	}
196	return out
197}
198
199func (h *teeHandler) WithGroup(name string) slog.Handler {
200	// An empty name is a no-op, matching slog.Logger.WithGroup. Without it
201	// every key under the group gains a double dot.
202	if name == "" {
203		return h
204	}
205	prefix := name
206	if h.group != "" {
207		prefix = h.group + "." + name
208	}
209	out := &teeHandler{next: h.next.WithGroup(name), ship: h.ship, group: prefix}
210	out.attrs = append([]groupedAttr{}, h.attrs...)
211	return out
212}
213
214func (h *teeHandler) Handle(ctx context.Context, r slog.Record) error {
215	// stdout first and unconditionally.
216	err := h.next.Handle(ctx, r)
217
218	out := Record{
219		Time:  r.Time.UTC().UnixMilli(),
220		Level: r.Level.String(),
221		Msg:   r.Message,
222	}
223	attrs := make(map[string]any, r.NumAttrs()+len(h.attrs))
224	for _, a := range h.attrs {
225		flatten(attrs, a.prefix, a.attr)
226	}
227	r.Attrs(func(a slog.Attr) bool {
228		flatten(attrs, h.group, a)
229		return true
230	})
231	if len(attrs) > 0 {
232		out.Attrs = attrs
233	}
234	h.ship.enqueue(out)
235
236	return err
237}
238
239// flatten writes one attribute into the map, resolving LogValuer and turning a
240// group into dotted keys, so the ingest side can pull "status" out with one
241// lookup.
242func flatten(dst map[string]any, prefix string, a slog.Attr) {
243	a.Value = a.Value.Resolve()
244	key := a.Key
245	if prefix != "" {
246		key = prefix + "." + key
247	}
248
249	switch a.Value.Kind() {
250	case slog.KindGroup:
251		for _, sub := range a.Value.Group() {
252			flatten(dst, key, sub)
253		}
254	case slog.KindTime:
255		dst[key] = a.Value.Time().UTC().Format(time.RFC3339Nano)
256	case slog.KindDuration:
257		dst[key] = a.Value.Duration().String()
258	case slog.KindBool:
259		dst[key] = a.Value.Bool()
260	case slog.KindInt64:
261		dst[key] = a.Value.Int64()
262	case slog.KindUint64:
263		dst[key] = a.Value.Uint64()
264	case slog.KindFloat64:
265		dst[key] = a.Value.Float64()
266	case slog.KindString:
267		dst[key] = a.Value.String()
268	default:
269		// Formatted rather than marshalled, because an arbitrary value can
270		// fail to marshal and one bad attribute must not cost the batch.
271		dst[key] = fmt.Sprint(a.Value.Any())
272	}
273}
274
275// HTTPSink posts batches to the logging site. It never calls slog, because a
276// shipper that logged its own failures would enqueue a record about failing to
277// ship. State changes go straight to stderr instead.
278func HTTPSink() Sink {
279	client := &http.Client{Timeout: shipTimeout}
280	var (
281		mu      sync.Mutex
282		healthy = true
283		dropped int
284	)
285
286	note := func(ok bool, detail string, n int) {
287		mu.Lock()
288		defer mu.Unlock()
289		if ok == healthy {
290			if !ok {
291				dropped += n
292			}
293			return
294		}
295		healthy = ok
296		if ok {
297			fmt.Fprintf(os.Stderr, "log shipping recovered after dropping %d records\n", dropped+n)
298			dropped = 0
299			return
300		}
301		dropped = n
302		fmt.Fprintf(os.Stderr, "log shipping unavailable, dropping records: %s\n", detail)
303	}
304
305	return func(source string, records []Record) {
306		body, err := json.Marshal(Batch{Source: source, Records: records})
307		if err != nil {
308			note(false, err.Error(), len(records))
309			return
310		}
311
312		ctx, cancel := context.WithTimeout(context.Background(), shipTimeout)
313		defer cancel()
314
315		req, err := http.NewRequestWithContext(ctx, http.MethodPost, ShipEndpoint, bytes.NewReader(body))
316		if err != nil {
317			note(false, err.Error(), len(records))
318			return
319		}
320		req.Header.Set("Content-Type", "application/json")
321
322		resp, err := client.Do(req)
323		if err != nil {
324			note(false, err.Error(), len(records))
325			return
326		}
327		defer resp.Body.Close()
328		// Drained as well as closed, or the connection leaks out of the
329		// pool, and this one is reused every five seconds forever.
330		_, _ = io.Copy(io.Discard, resp.Body)
331
332		// 429 is the logging site shedding load, a healthy answer, so
333		// drop the batch without marking the sink down.
334		if resp.StatusCode == http.StatusTooManyRequests {
335			return
336		}
337		if resp.StatusCode >= 300 {
338			note(false, resp.Status, len(records))
339			return
340		}
341		note(true, "", 0)
342	}
343}