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

11.7 KB · 363 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"net"
  8	"net/http"
  9	"strings"
 10	"sync"
 11	"time"
 12)
 13
 14// The health strip. This is a public page, so a row carries a name, an up or
 15// down, a latency and a count of errors, and never a path, a message, a status
 16// code or an address. What logging hands over is already limited to that, and
 17// the limit lives at both ends so neither has to remember for the other.
 18//
 19// This is not a second status.bythewood.me. There are no phase timings, no
 20// history and no alerting here, because status already does all of that
 21// properly and a strip of dots is what a dashboard wants.
 22
 23// probeEvery is the strip's cadence. Slower than the markets panel because a
 24// site that has been up for eight hours is not news, and every probe is against
 25// Isaac's own machine rather than someone else's endpoint.
 26const probeEvery = 60 * time.Second
 27
 28// aggregateURL is a container name on the orchard-edge bridge, never the public
 29// hostname. Caddy refuses this path from outside.
 30const aggregateURL = "http://orchard-logging:8000/aggregate"
 31
 32// Monitored is one row. Source is the label it ships its logs under and, for a
 33// site, the first hostname label, so it is also half the container name.
 34type Monitored struct {
 35	Label  string
 36	Source string
 37
 38	// Host is the public hostname, and is empty for something that has none.
 39	// Without one there is no fallback probe, so it reads unknown rather than
 40	// down wherever the bridge is not reachable.
 41	Host string
 42
 43	// Bridge overrides the usual <container>:8000/healthz.
 44	Bridge string
 45
 46	// AnyStatus means any HTTP response at all proves the process is serving.
 47	// Caddy has no health endpoint and answers its catch-all with a 404, which
 48	// is still Caddy answering.
 49	AnyStatus bool
 50}
 51
 52var monitored = []Monitored{
 53	{Label: "Portfolio", Source: "isaacbythewood", Host: "isaacbythewood.com"},
 54	{Label: "Blog", Source: "blog", Host: "blog.bythewood.me"},
 55	{Label: "Analytics", Source: "analytics", Host: "analytics.bythewood.me"},
 56	{Label: "Auth", Source: "auth", Host: "auth.bythewood.me"},
 57	{Label: "Status", Source: "status", Host: "status.bythewood.me"},
 58	{Label: "Logging", Source: "logging", Host: "logging.bythewood.me"},
 59	{Label: "Repos", Source: "repos", Host: "repos.bythewood.me"},
 60	{Label: "Search", Source: "search", Host: "search.bythewood.me"},
 61	{Label: "Chat", Source: "chat", Host: "chat.bythewood.me"},
 62	{Label: "LLM", Source: "llm", Host: "llm.bythewood.me"},
 63	{Label: "Dash", Source: "dash", Host: "dash.bythewood.me"},
 64
 65	// The edge itself, and the only part of it on this strip: Caddy is the one
 66	// that ships its access log, so it is the one with an error count to show
 67	// beside the others. cloudflared and ntfy log to stdout and cannot write to
 68	// a socket, so neither has anything here to be counted.
 69	{Label: "Caddy", Source: "caddy", Bridge: "http://orchard-caddy:80/", AnyStatus: true},
 70}
 71
 72type SystemRow struct {
 73	Label     string `json:"label"`
 74	Host      string `json:"host"`
 75	URL       string `json:"url"`
 76	State     string `json:"state"`
 77	Errors    int64  `json:"errors"`
 78	KnowError bool   `json:"know_error"`
 79
 80	// How long this site takes to answer at the slow end of a day, read off
 81	// its own access log rather than measured here. The probe runs over the
 82	// Docker bridge, so timing it reported the bridge: every site came back
 83	// between one and two milliseconds whatever it was actually doing.
 84	Response string `json:"response"`
 85
 86	// Traffic over the same window as Errors, plus where it sits against this
 87	// site's own preceding week. Busy is relative here or it is meaningless:
 88	// a day that would be dead for a news site is a good day for a personal
 89	// blog, so the comparison is always against the site itself.
 90	Requests int64  `json:"requests"`
 91	KnowTraf bool   `json:"know_traf"`
 92	Level    int    `json:"level"`
 93	Trend    string `json:"trend"`
 94}
 95
 96type Systems struct {
 97	Rows     []SystemRow `json:"rows"`
 98	Up       int         `json:"up"`
 99	Total    int         `json:"total"`
100	Errors   int64       `json:"errors"`
101	Requests int64       `json:"requests"`
102	Window   int         `json:"window"`
103	Checked  string      `json:"checked"`
104}
105
106// probeClient is separate from the shared JSON client because these are health
107// probes and a pooled connection would report a warm socket rather than a
108// reachable site. Nothing here is trying to be a real uptime measurement, that
109// is what status is for, but a probe that reuses a socket to a container that
110// died two seconds ago is wrong rather than approximate.
111var probeClient = &http.Client{
112	Timeout: 5 * time.Second,
113	Transport: &http.Transport{
114		DisableKeepAlives: true,
115	},
116	// A health check that follows a redirect is checking somewhere else.
117	CheckRedirect: func(*http.Request, []*http.Request) error {
118		return http.ErrUseLastResponse
119	},
120}
121
122// probeResult separates the three things a probe attempt can mean. Collapsing
123// them was a real bug: the goroutines start at once, the guard paced all but one of
124// them, and a paced probe read as an unreachable site, so the strip reported
125// every other site down while every one of them was serving.
126type probeResult int
127
128const (
129	probeAnswered probeResult = iota
130	// The name does not resolve, so this is the wrong route rather than a
131	// failure and the caller should try the next one.
132	probeNoRoute
133	// The guard said no, so nothing went out and nothing was learned.
134	probeSkipped
135)
136
137// probe answers whether one site is serving, and how fast.
138//
139// The container on the bridge is asked first and the public hostname is only a
140// fallback, because Cloudflare will happily serve a cached 200 for /healthz
141// long after the origin behind it has stopped answering. Two of these sites
142// return CF-Cache-Status: HIT for that path right now, so a public probe is not
143// evidence the site is up. When the fallback is what answered, the row says
144// cached rather than up and means it.
145func probe(ctx context.Context, g *Guard, m Monitored) SystemRow {
146	row := SystemRow{Label: m.Label, Host: m.Host, State: "unknown"}
147	if m.Host != "" {
148		row.URL = visitURL(m.Host)
149	}
150
151	bridge := m.Bridge
152	switch {
153	case bridge != "":
154	case m.Source == selfSource:
155		// Asking the bridge for our own name works in the container and not in
156		// a dev run, and loopback is the same answer in both.
157		bridge = "http://127.0.0.1:8000/healthz"
158	default:
159		bridge = "http://orchard-" + m.Source + ":8000/healthz"
160	}
161
162	attempts := []struct {
163		url    string
164		public bool
165	}{{bridge, false}}
166	if m.Host != "" {
167		attempts = append(attempts, struct {
168			url    string
169			public bool
170		}{"https://" + m.Host + "/healthz", true})
171	}
172
173	for _, attempt := range attempts {
174		state, result := hit(ctx, g, attempt.url, attempt.public, m.AnyStatus)
175		switch result {
176		case probeAnswered:
177			row.State = state
178			return row
179		case probeSkipped:
180			// Nothing was measured, so the row stays unknown rather than
181			// claiming a site is down on the strength of a paced request.
182			return row
183		}
184	}
185
186	return row
187}
188
189// hit runs one request. public marks a request that went over the internet, so
190// an edge cache hit can be reported as what it is.
191func hit(ctx context.Context, g *Guard, url string, public, anyStatus bool) (state string, result probeResult) {
192	if err := g.Allow("uptime"); err != nil {
193		return "", probeSkipped
194	}
195
196	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
197	if err != nil {
198		return "", probeSkipped
199	}
200	req.Header.Set("User-Agent", "dash.bythewood.me health strip")
201	req.Header.Set("Cache-Control", "no-cache")
202
203	resp, err := probeClient.Do(req)
204	if err != nil {
205		var dns *net.DNSError
206		if errors.As(err, &dns) {
207			return "", probeNoRoute
208		}
209		g.Fail("uptime", 0, 0)
210		return "down", probeAnswered
211	}
212	defer resp.Body.Close()
213
214	g.Succeed("uptime")
215
216	state = "down"
217	if anyStatus || resp.StatusCode == http.StatusOK {
218		state = "up"
219		if public && cacheHit(resp.Header.Get("CF-Cache-Status")) {
220			state = "cached"
221		}
222	}
223	return state, probeAnswered
224}
225
226// humanMS keeps a response time to six characters. These sites answer an
227// embedded page in a fraction of a millisecond and repos answers a git pack in a
228// fifth of a second, so the column has to carry four orders of magnitude without
229// going ragged. Anything under a millisecond is still printed rather than
230// rounded to "<1ms", or half the strip reads as the same number when the sites
231// are a factor of ten apart.
232func humanMS(ms float64) string {
233	switch {
234	case ms <= 0:
235		return ""
236	case ms >= 1000:
237		return fmt.Sprintf("%.1fs", ms/1000)
238	case ms >= 10:
239		return fmt.Sprintf("%.0fms", ms)
240	case ms >= 1:
241		return fmt.Sprintf("%.1fms", ms)
242	default:
243		return fmt.Sprintf("%.2fms", ms)
244	}
245}
246
247func cacheHit(status string) bool {
248	switch strings.ToUpper(status) {
249	case "HIT", "STALE", "UPDATING", "REVALIDATED":
250		return true
251	}
252	return false
253}
254
255type aggregatePayload struct {
256	WindowHours int `json:"window_hours"`
257	Sources     []struct {
258		Source        string  `json:"source"`
259		Errors        int64   `json:"errors"`
260		Requests      int64   `json:"requests"`
261		BaselineDaily float64 `json:"baseline_daily"`
262		P95MS         float64 `json:"p95_ms"`
263	} `json:"sources"`
264}
265
266// buildSystems probes every site at once and folds in the error counts logging
267// keeps. A logging site that is down costs the strip its error column and
268// nothing else.
269func buildSystems(ctx context.Context, g *Guard, now time.Time) Systems {
270	rows := make([]SystemRow, len(monitored))
271
272	var wg sync.WaitGroup
273	for i, m := range monitored {
274		wg.Add(1)
275		go func() {
276			defer wg.Done()
277			rows[i] = probe(ctx, g, m)
278		}()
279	}
280	wg.Wait()
281
282	s := Systems{Rows: rows, Total: len(rows), Checked: now.UTC().Format("15:04:05")}
283
284	var payload aggregatePayload
285	if err := getJSON(ctx, g, "logging", aggregateURL, &payload); err == nil {
286		type counts struct {
287			errors, requests int64
288			baseline, p95    float64
289		}
290		by := map[string]counts{}
291		var busiest int64
292		for _, src := range payload.Sources {
293			by[src.Source] = counts{src.Errors, src.Requests, src.BaselineDaily, src.P95MS}
294			busiest = max(busiest, src.Requests)
295		}
296
297		s.Window = payload.WindowHours
298		for i, m := range monitored {
299			c, ok := by[m.Source]
300			if !ok {
301				continue
302			}
303			rows[i].Errors, rows[i].KnowError = c.errors, true
304			rows[i].Requests, rows[i].KnowTraf = c.requests, true
305			rows[i].Level = trafficLevel(c.requests, busiest)
306			rows[i].Trend = trafficTrend(c.requests, c.baseline)
307			rows[i].Response = humanMS(c.p95)
308			s.Errors += c.errors
309			s.Requests += c.requests
310		}
311	}
312
313	for _, r := range rows {
314		if r.State == "up" || r.State == "cached" {
315			s.Up++
316		}
317	}
318	return s
319}
320
321// visitURL is what the strip links to. The campaign tag is so the sites can see
322// in their own analytics that a visit came from here, which is the only way to
323// tell dash traffic apart from anything else arriving at a bare hostname.
324func visitURL(host string) string {
325	return "https://" + host + "/?utm_source=dash.bythewood.me&utm_medium=referral&utm_campaign=systems"
326}
327
328// trafficLevel puts a site's day on a four step scale against the busiest site
329// here, so the bars compare like with like on one machine rather than against
330// some idea of what a busy site is.
331func trafficLevel(requests, busiest int64) int {
332	if requests <= 0 || busiest <= 0 {
333		return 0
334	}
335	switch share := float64(requests) / float64(busiest); {
336	case share >= 0.6:
337		return 4
338	case share >= 0.3:
339		return 3
340	case share >= 0.1:
341		return 2
342	default:
343		return 1
344	}
345}
346
347// trafficTrend compares the day against this site's own preceding week. The
348// band is wide because a personal site's daily traffic is noisy enough that
349// anything tighter would call every day unusual.
350func trafficTrend(requests int64, baselineDaily float64) string {
351	if baselineDaily <= 0 {
352		return ""
353	}
354	switch ratio := float64(requests) / baselineDaily; {
355	case ratio >= 1.5:
356		return "up"
357	case ratio <= 0.5:
358		return "down"
359	default:
360		return "flat"
361	}
362}