feat: log aggregation — the machine ships its logs to Loki

The gameplay logs sat on /dump on the machine, readable only over SSH. Now
they land in Loki for query and tail:

- k8s/loki/loki.yaml: Loki 3.4.4 monolithic on the home cluster's monitoring
  namespace — filesystem storage on CephFS, 30-day retention, traefik ingress
  at loki.k8s-home.lqx.net with a cert-manager cert, Grafana datasource
  auto-registered via the sidecar. reject_old_samples_max_age is a year:
  the machines backfill their history on first ship.
- shipper/: logshipper, a ~300-line static Go service for the machine. Tails
  /dump/mode.log and /dump/switch.log, batches, pushes to Loki's push API.
  Per-file offsets are saved only after a successful push (down Loki =
  retry-then-catch-up, never loss); first run starts at each file's end,
  SHIP_FROM_START=1 ships history; truncation is noticed. Lines keep their
  own RFC 3339 stamp as the Loki timestamp; unstamped lines (the boot
  banner) inherit the next stamped line's, so a stream never starts at
  "now" and jumps back. After a backfill it calls /flush — old entries are
  invisible in the ingester's head until they reach the store (found the
  hard way: Loki answers 204 to an old entry on a new stream and then never
  serves it). Labels: job=pinball, machine, source, game (from the boot
  line). /etc/init.d/log_shipper runs it at boot.

Deployed: Loki is live and verified (query through the ingress). The shipper
is verified end-to-end against the cluster from this Mac; the machine was
unreachable at deploy time — the README has the install commands.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-22 18:48:25 -04:00
co-authored by Claude Code
parent 539bab3bdc
commit 95e7317b81
9 changed files with 885 additions and 6 deletions
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# build.sh - cross-compile the log shipper for the Spike 2 machine (i.MX6, armv7).
# Pure Go (CGO off), so the binary is fully static and needs nothing from the machine.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -ldflags "-s -w" -o logshipper .
echo "built logshipper ($(stat -c %s logshipper 2>/dev/null || stat -f %z logshipper) bytes)"
+3
View File
@@ -0,0 +1,3 @@
module logshipper
go 1.27.1
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# log_shipper - tail /dump/*.log and push them to Loki (see shipper/README.md).
# Config comes from the environment; defaults in the binary match the machine.
case "$1" in
start)
start-stop-daemon -S -b -m -p /run/logshipper.pid -x /usr/local/bin/logshipper
;;
stop)
start-stop-daemon -K -p /run/logshipper.pid
;;
restart)
"$0" stop
sleep 1
"$0" start
;;
*)
echo "usage: $0 {start|stop|restart}"
exit 1
;;
esac
+221
View File
@@ -0,0 +1,221 @@
package main
// logshipper - tails the pinball machine's gameplay logs and pushes them to Loki.
//
// Tails /dump/mode.log and /dump/switch.log, batches their lines, and POSTs them to
// Loki's /loki/api/v1/push. Per-file read offsets are saved only after a successful
// push, so a down Loki means retry-then-catch-up, never loss. Configuration is env:
//
// LOKI_URL push target (default https://loki.k8s-home.lqx.net)
// SHIP_FILES comma list (default /dump/mode.log,/dump/switch.log)
// SHIP_STATE offset state file (default /dump/.logshipper.state)
// MACHINE the machine label (default this host's name)
// SHIP_FROM_START 1 = ship from each file's start on first run (default: from its end)
// SHIP_INSECURE 1 = skip TLS verification (for a Loki without a trusted cert)
// SHIP_EVERY poll interval (default 2s)
//
// Runs under /etc/init.d/log_shipper; its stderr goes to the syslog.
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var (
lokiURL = envOr("LOKI_URL", "https://loki.k8s-home.lqx.net")
files = strings.Split(envOr("SHIP_FILES", "/dump/mode.log,/dump/switch.log"), ",")
statePath = envOr("SHIP_STATE", "/dump/.logshipper.state")
machine = envOr("MACHINE", hostname())
fromStart = os.Getenv("SHIP_FROM_START") == "1"
insecure = os.Getenv("SHIP_INSECURE") == "1"
every = 2 * time.Second
maxPush = 3 << 20 // under Loki's 4 MiB push limit
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func hostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "stern-spike2"
}
// readNewLines returns the complete lines added to path since offset, and the offset to
// save once they are pushed (never past the last newline). A file truncated under us
// (": > mode.log" is safe housekeeping) restarts from 0.
func readNewLines(path string, offset int64) (lines []string, sent int64, err error) {
f, err := os.Open(path)
if err != nil {
return nil, offset, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, offset, err
}
resume := offset
if fi.Size() < offset {
resume = 0
}
if _, err = f.Seek(resume, io.SeekStart); err != nil {
return nil, offset, err
}
buf, err := io.ReadAll(f)
if err != nil {
return nil, resume, err
}
s := string(buf)
if cut := strings.LastIndexByte(s, '\n'); cut >= 0 {
lines = strings.Split(strings.TrimSuffix(s[:cut+1], "\n"), "\n")
sent = resume + int64(cut) + 1
} else {
sent = resume // no complete line yet
}
return lines, sent, nil
}
func push(payload []byte) error {
return post("/loki/api/v1/push", payload)
}
// pushFlush asks Loki to write the ingester's chunks to the store now.
func pushFlush() error {
return post("/flush", nil)
}
func post(path string, payload []byte) error {
transport := http.DefaultTransport.(*http.Transport).Clone()
if insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
client := &http.Client{Timeout: 30 * time.Second, Transport: transport}
var body io.Reader
if payload != nil {
body = bytes.NewReader(payload)
}
resp, err := client.Post(lokiURL+path, "application/json", body)
if err != nil {
return err
}
defer resp.Body.Close()
read, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return fmt.Errorf("loki said %s: %s", resp.Status, strings.TrimSpace(string(read)))
}
return nil
}
type shipFile struct {
path string
source string
game string
}
func main() {
log.SetFlags(log.LstdFlags | log.LUTC)
if v := os.Getenv("SHIP_EVERY"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
every = d
}
}
st, err := loadState(statePath)
if err != nil {
log.Printf("state file unreadable (%v) - starting fresh", err)
st = state{}
}
var ship []shipFile
for _, path := range files {
path = strings.TrimSpace(path)
if path == "" {
continue
}
ship = append(ship, shipFile{path: path, source: baseName(path)})
}
log.Printf("shipping %s to %s (labels: job=pinball machine=%s, every %s)",
strings.Join(files, ", "), lokiURL, machine, every)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
tick := time.Tick(every)
for {
for i := range ship {
f := &ship[i]
offset, known := st[baseName(f.path)]
if !known && !fromStart {
// first run: start from the file's end; run once with SHIP_FROM_START=1
// to ship the history already on disk
if fi, err := os.Stat(f.path); err == nil {
offset = fi.Size()
}
}
lines, sent, err := readNewLines(f.path, offset)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("%s: %v", f.path, err)
}
continue
}
if len(lines) == 0 {
continue
}
stamps := assignTimestamps(lines, time.Now())
var entries []Entry
for i, line := range lines {
if g := extractGame(line); g != "" {
f.game = g
}
entries = append(entries, Entry{Source: f.source, Game: f.game, TS: stamps[i], Line: line})
}
pushed := true
for _, payload := range buildPayloads(entries, machine, maxPush) {
if err := push(payload); err != nil {
log.Printf("push failed (%v) - %s stays at offset %d", err, f.path, offset)
pushed = false
break
}
}
if !pushed {
continue // the offset is saved only when the whole batch is in Loki
}
if needsFlush(entries, time.Now()) {
// a backfill: the old entries are invisible in the ingester's head until
// they reach the store (live tails never come through here)
if err := pushFlush(); err != nil {
log.Printf("flush failed (%v) - old entries may not be queryable yet", err)
}
}
st[baseName(f.path)] = sent
if err := saveState(statePath, st); err != nil {
log.Printf("state save failed: %v", err)
}
}
select {
case <-sig:
log.Printf("stopping")
return
case <-tick:
}
}
}
func baseName(path string) string {
if i := strings.LastIndexByte(path, '/'); i >= 0 {
return path[i+1:]
}
return path
}
+230
View File
@@ -0,0 +1,230 @@
package main
// The pure half of the shipper: line parsing, the Loki push payload, the offset state.
// main.go is the tail-and-push loop around it.
import (
"encoding/json"
"os"
"regexp"
"strconv"
"time"
)
const stampLayout = "2006-01-02T15:04:05.000Z"
var stampRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z`)
var gameRe = regexp.MustCompile(`up: game (\S+)`)
// extractTimestamp finds the line's own RFC 3339 (millisecond, UTC) stamp wherever it sits:
// the runtime prefixes an uptime count and a [who], gamelog puts its stamp after its tag,
// the switch log starts with it. A line without a stamp (the boot banner) reports false and
// the caller stamps it with the shipping time.
func extractTimestamp(line string) (time.Time, bool) {
s := stampRe.FindString(line)
if s == "" {
return time.Time{}, false
}
ts, err := time.Parse(stampLayout, s)
if err != nil {
return time.Time{}, false
}
return ts, true
}
// extractGame names the game from the boot line ("up: game king_kong_le 0.97, ..."), "" elsewhere.
func extractGame(line string) string {
m := gameRe.FindStringSubmatch(line)
if m == nil {
return ""
}
return m[1]
}
// assignTimestamps gives every line a timestamp: its own stamp where it has one; an
// unstamped line (the boot banner) takes the NEXT stamped line's time — a stream must
// not start at the shipping time and then jump back to the game's boot — and a trailing
// unstamped line takes the previous stamped time. fallback covers a batch with no
// stamps at all.
func assignTimestamps(lines []string, fallback time.Time) []time.Time {
stamps := make([]time.Time, len(lines))
next := time.Time{}
for i := len(lines) - 1; i >= 0; i-- {
if ts, ok := extractTimestamp(lines[i]); ok {
next = ts
}
stamps[i] = next
}
prev := time.Time{}
for i := range lines {
if ts, ok := extractTimestamp(lines[i]); ok {
prev = ts
}
if stamps[i].IsZero() {
stamps[i] = prev
}
}
for i := range stamps {
if stamps[i].IsZero() {
stamps[i] = fallback
}
}
return stamps
}
// Entry is one shipped line.
type Entry struct {
Source string
Game string
TS time.Time
Line string
}
// lokiPush is the /loki/api/v1/push body: labelled streams of (nanoseconds, line) pairs.
type lokiPush struct {
Streams []lokiStream `json:"streams"`
}
type lokiStream struct {
Stream map[string]string `json:"stream"`
Values [][]string `json:"values"`
}
// buildPayloads groups entries by their label set and marshals Loki push bodies, each JSON
// under maxBytes (Loki refuses a push past its limit, so a big batch is split).
func buildPayloads(entries []Entry, machine string, maxBytes int) [][]byte {
type group struct {
labels map[string]string
values [][]string
}
groups := map[string]*group{}
var order []string
for _, e := range entries {
ts, ok := extractTimestamp(e.Line)
if !ok {
ts = e.TS
}
labels := map[string]string{"job": "pinball", "machine": machine, "source": e.Source}
if e.Game != "" {
labels["game"] = e.Game
}
key := e.Source + "\x00" + e.Game
g, ok := groups[key]
if !ok {
groups[key] = &group{labels: labels}
g = groups[key]
order = append(order, key)
}
g.values = append(g.values, []string{strconv.FormatInt(ts.UnixNano(), 10), e.Line})
}
var payloads [][]byte
var cur *lokiPush
curBytes := 0
flush := func() {
if cur != nil && len(cur.Streams) > 0 {
if b, err := json.Marshal(cur); err == nil {
payloads = append(payloads, b)
}
}
cur = nil
curBytes = 0
}
add := func(s lokiStream) int {
p := lokiPush{Streams: []lokiStream{s}}
b, err := json.Marshal(p)
if err != nil {
return 0
}
if cur == nil {
cur = &lokiPush{}
}
cur.Streams = append(cur.Streams, s)
curBytes += len(b)
return len(b)
}
for _, key := range order {
g := groups[key]
for len(g.values) > 0 {
// a stream that fits whole goes into the current payload; one that does not is
// halved until a piece fits, then the payload is flushed and the rest continues
take := g.values
var piece []lokiStream
for {
s := lokiStream{Stream: g.labels, Values: take}
p := lokiPush{Streams: []lokiStream{s}}
b, err := json.Marshal(p)
if err != nil {
break
}
if len(b)+curBytes <= maxBytes {
piece = []lokiStream{s}
break
}
if len(take) == 1 {
// a single line over the cap is shipped alone anyway (it cannot split)
piece = []lokiStream{s}
break
}
take = take[:len(take)/2]
}
if piece == nil {
flush()
continue
}
add(piece[0])
g.values = g.values[len(piece[0].Values):]
if len(g.values) > 0 || curBytes > maxBytes/2 {
flush()
}
}
}
flush()
return payloads
}
// needsFlush: entries older than the out-of-order window (Loki serves head entries only
// within max_chunk_age/2 ≈ 1h) sit invisible in the ingester until a flush puts them in the
// store. A backfill (first ship of history) needs one; a live tail never does.
func needsFlush(entries []Entry, now time.Time) bool {
for _, e := range entries {
if now.Sub(e.TS) > time.Hour {
return true
}
}
return false
}
// ---- the offset state -------------------------------------------------------------------
type state map[string]int64
// loadState reads the saved per-file offsets; a missing file is a fresh start, not an error.
func loadState(path string) (state, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return state{}, nil
}
return state{}, err
}
var st state
if err := json.Unmarshal(b, &st); err != nil {
return state{}, err
}
return st, nil
}
// saveState writes the offsets atomically (a tmp file and a rename), so a crash mid-write
// keeps the last fully saved state.
func saveState(path string, st state) error {
b, err := json.Marshal(st)
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
// ---- timestamps -----------------------------------------------------------------------
func TestExtractsTheRfc3339StampFromEveryLineShape(t *testing.T) {
// the runtime writes an uptime-ms prefix before [who]; gamelog puts its stamp after the tag;
// the switch log starts with the stamp. All three must parse, wherever the stamp sits.
lines := []string{
` 1234 [pad] [GAMELOG] 2026-10-12T20:45:12.123Z EV ball_start player=1 ball=1 score=0`,
` 1300 [pad] gamelog: 2026-10-12T20:45:12.500Z MODE START 5 (Island Scene) player=1`,
`2026-10-12T20:45:12.678Z SW 62 "RIGHT FLIPPER BUTTON" level=1 hit=1 game=1`,
` 1400 [pad] port /usr/local/padmode/ports/king_kong_le-0.97.port: king_kong_le 0.97, 121 sites, 55 shots`,
}
want := []bool{true, true, true, false}
wanted := []time.Time{
time.Date(2026, 10, 12, 20, 45, 12, 123000000, time.UTC),
time.Date(2026, 10, 12, 20, 45, 12, 500000000, time.UTC),
time.Date(2026, 10, 12, 20, 45, 12, 678000000, time.UTC),
}
for i, line := range lines {
ts, ok := extractTimestamp(line)
if ok != want[i] {
t.Fatalf("line %d: ok=%v, want %v", i, ok, want[i])
}
if ok && !ts.Equal(wanted[i]) {
t.Fatalf("line %d: wrong stamp %v", i, ts)
}
}
}
// ---- the game label --------------------------------------------------------------------
func TestExtractsTheGameFromTheBootLine(t *testing.T) {
for _, line := range []string{
` 1234 [pad] [GAMELOG] 2026-10-12T20:45:12.123Z up: game king_kong_le 0.97, 55 named shots; event ids 77/37/52/40/200/198; extra_ball_award=13`,
`[GAMELOG] 2026-10-13T09:00:00.000Z up: game godzilla_le 1.16, 40 named shots`,
} {
if g := extractGame(line); g != "king_kong_le" && g != "godzilla_le" {
t.Fatalf("extractGame(%q) = %q", line, g)
}
}
if g := extractGame(` 1234 [pad] [GAMELOG] 2026-10-12T20:45:12.123Z EV bonus_end`); g != "" {
t.Fatalf("a non-boot line named a game: %q", g)
}
}
// ---- the Loki payload -------------------------------------------------------------------
func TestPayloadGroupsByLabelsAndCarriesNanosecondStamps(t *testing.T) {
ts := time.Date(2026, 10, 12, 20, 45, 12, 123000000, time.UTC)
entries := []Entry{
{Source: "mode", Game: "king_kong_le", TS: ts, Line: "one"},
{Source: "mode", Game: "king_kong_le", TS: ts.Add(time.Second), Line: "two"},
{Source: "switch", Game: "", TS: ts, Line: "three"},
}
payloads := buildPayloads(entries, "stern-spike2", 1<<20)
if len(payloads) != 1 {
t.Fatalf("got %d payloads, want 1 (both sources fit under the cap)", len(payloads))
}
var parsed struct {
Streams []struct {
Stream map[string]string `json:"stream"`
Values [][]string `json:"values"`
} `json:"streams"`
}
if err := json.Unmarshal(payloads[0], &parsed); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
if len(parsed.Streams) != 2 {
t.Fatalf("got %d streams, want 2 (mode and switch have different labels)", len(parsed.Streams))
}
for _, s := range parsed.Streams {
if s.Stream["job"] != "pinball" || s.Stream["machine"] != "stern-spike2" {
t.Fatalf("bad labels: %v", s.Stream)
}
}
// find the mode stream and check ordering, stamps and the game label
var mode struct {
values [][]string
}
for _, s := range parsed.Streams {
if s.Stream["source"] == "mode" {
if s.Stream["game"] != "king_kong_le" {
t.Fatalf("mode stream game label = %q", s.Stream["game"])
}
mode.values = s.Values
}
}
if len(mode.values) != 2 || mode.values[0][1] != "one" || mode.values[1][1] != "two" {
t.Fatalf("mode values wrong: %v", mode.values)
}
if mode.values[0][0] != "1791837912123000000" { // the line's own stamp, nanoseconds, as a string
t.Fatalf("stamp = %q", mode.values[0][0])
}
}
func TestPayloadSplitsPastTheCap(t *testing.T) {
ts := time.Date(2026, 10, 12, 20, 45, 12, 0, time.UTC)
var entries []Entry
for i := 0; i < 100; i++ {
entries = append(entries, Entry{Source: "mode", TS: ts, Line: strings.Repeat("x", 1000)})
}
payloads := buildPayloads(entries, "stern-spike2", 4096)
if len(payloads) < 2 {
t.Fatalf("got %d payloads, want several", len(payloads))
}
for _, p := range payloads {
if len(p) > 4096 {
t.Fatalf("payload of %d bytes exceeds the cap", len(p))
}
}
}
// ---- stamps for unstamped lines ---------------------------------------------------------
func TestUnstampedLinesInheritTheirNeighboursStamps(t *testing.T) {
stamp := func(s string) time.Time {
ts, _ := time.Parse(stampLayout, s)
return ts
}
fallback := stamp("2026-10-22T12:00:00.000Z")
lines := []string{
` 1234 [pad] port /usr/local/padmode/ports/king_kong_le-0.97.port: king_kong_le 0.97, 121 sites, 55 shots`,
` 1234 [pad] [GAMELOG] 2026-10-12T20:45:12.123Z up: game king_kong_le 0.97, 55 named shots`,
` 1250 [pad] [GAMELOG] 2026-10-12T20:45:12.500Z EV game_start - GAME START`,
` 1300 [pad] [GAMELOG] 2026-10-12T20:45:13.000Z EV ball_start player=1 ball=1 score=0`,
` 1400 [pad] gamelog: no stamp on this one`,
}
got := assignTimestamps(lines, fallback)
// the banner comes BEFORE the first stamp: it takes the next line's stamp, not the
// fallback - a stream must not start at "now" and then jump ten days back
if !got[0].Equal(stamp("2026-10-12T20:45:12.123Z")) {
t.Fatalf("banner stamp = %v, want the next line's stamp", got[0])
}
for i, want := range []string{"2026-10-12T20:45:12.123Z", "2026-10-12T20:45:12.123Z",
"2026-10-12T20:45:12.500Z", "2026-10-12T20:45:13.000Z", "2026-10-12T20:45:13.000Z"} {
if !got[i].Equal(stamp(want)) {
t.Fatalf("line %d stamp = %v, want %v", i, got[i], want)
}
}
// a batch with no stamps at all falls back to the shipping time
got = assignTimestamps([]string{"one", "two"}, fallback)
if !got[0].Equal(fallback) || !got[1].Equal(fallback) {
t.Fatalf("fallback stamps wrong: %v", got)
}
}
// ---- the flush after a backfill ---------------------------------------------------------
func TestOldEntriesNeedAFlush(t *testing.T) {
// entries older than the out-of-order window (1h) sit invisible in the ingester's head
// until a flush; live entries never need one
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
fresh := []Entry{{Source: "mode", TS: now.Add(-time.Minute), Line: "x"}}
old := []Entry{{Source: "mode", TS: now.Add(-10 * 24 * time.Hour), Line: "x"}}
if needsFlush(fresh, now) {
t.Fatal("a fresh batch must not trigger a flush")
}
if !needsFlush(old, now) {
t.Fatal("an old batch must trigger a flush")
}
}
// ---- the offset state -------------------------------------------------------------------
func TestStateRoundTrips(t *testing.T) {
dir := t.TempDir()
path := dir + "/state"
st := state{"mode.log": 12345, "switch.log": 67890}
if err := saveState(path, st); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadState(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if got["mode.log"] != 12345 || got["switch.log"] != 67890 {
t.Fatalf("round trip lost offsets: %v", got)
}
if _, err := loadState(path + "-missing"); err != nil {
t.Fatalf("a missing state file is not an error: %v", err)
}
}