stuff I figured out the hard way

Defaults:

  • Use the standard library only (client-go for the Kubernetes snippet), Go 1.22 or newer.
  • Every Python snippet has a Go equivalent under the same heading.
  • Keep main tiny; put the work in a run that returns an error.
  • Set a timeout on every http.Client and http.Server.
  • Pass a context everywhere so SIGTERM cancels work promptly.
  • Log JSON to stderr with log/slog.

Script skeleton

signal.NotifyContext cancels the context on Ctrl+C or SIGTERM - what Kubernetes sends a pod before killing it.

package main

import (
	"context"
	"flag"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

func run() error {
	timeout := flag.Duration("timeout", 5*time.Second, "per-request timeout")
	workers := flag.Int("workers", 10, "max parallel checks")
	flag.Parse()
	if flag.NArg() == 0 {
		fmt.Fprintln(os.Stderr, "usage: check [flags] URL...")
		os.Exit(2)
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// ... do the work with ctx, *timeout and *workers
	return nil
}

Structured JSON logs

log/slog is standard library since Go 1.21. Collectors like Loki and VictoriaLogs want JSON on stderr.

logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
slog.SetDefault(logger)

slog.Info("check ok", "url", url)
slog.Error("check failed", "url", url, "err", err)

Parse key=value log lines

Return an error; never panic on input. strings.Cut splits at the first = only, so values containing = survive.

// parseLine turns "ts k=v k=v" into a map. Returns an error on bad input.
func parseLine(line string) (map[string]string, error) {
	parts := strings.Fields(line)
	if len(parts) < 2 {
		return nil, fmt.Errorf("too few fields: %q", line)
	}
	fields := map[string]string{"ts": parts[0]}
	for _, p := range parts[1:] {
		k, v, ok := strings.Cut(p, "=") // splits at the first "=" only
		if !ok {
			return nil, fmt.Errorf("bad pair %q", p)
		}
		fields[k] = v
	}
	return fields, nil
}

Skip bad lines by counting errors, so one malformed line never stops a run:

bad := 0
for sc.Scan() {
	line := sc.Text()
	if strings.TrimSpace(line) == "" {
		continue
	}
	fields, err := parseLine(line)
	if err != nil {
		bad++
		continue
	}
	_ = fields["status"] // use the fields
}

Count and group

A missing map key reads as the zero value, so counters need no setup. Maps iterate in random order - sort before printing.

counts := map[string]int{}
counts["payments"]++ // a missing key reads as 0
counts["auth"] += 2

type kv struct {
	Key   string
	Count int
}
var top []kv
for k, v := range counts {
	top = append(top, kv{k, v})
}
sort.Slice(top, func(i, j int) bool { return top[i].Count > top[j].Count }) // maps have no order
fmt.Println(top[:min(3, len(top))])

latencies := map[string][]float64{}
latencies["payments"] = append(latencies["payments"], 134) // nil slice: no init needed

A small record type

Store pointers in the map. Map values aren’t addressable, so stats[k].Count++ on a map[string]Stats doesn’t compile, and a copied struct silently drops updates.

type Stats struct {
	Count     int
	Errors    int
	Latencies []float64 // nil is a valid empty slice; append works on it
}

func (s *Stats) ErrorRate() float64 {
	if s.Count == 0 {
		return 0
	}
	return float64(s.Errors) / float64(s.Count)
}
stats := map[string]*Stats{} // pointers, so updates stick
s := stats["payments"]
if s == nil {
	s = &Stats{}
	stats["payments"] = s
}
s.Count++
s.Errors++
stats["auth"] = &Stats{Count: 4, Errors: 1}
VerbEffect
%-12sLeft-align to 12 characters
%7dRight-align to 7 characters
%6.1f%%0.5 × 100 becomes 50.0%; %% is a literal percent sign
names := make([]string, 0, len(stats))
for name := range stats {
	names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
	return stats[names[i]].ErrorRate() > stats[names[j]].ErrorRate()
})
fmt.Printf("%-12s %7s %7s\n", "service", "count", "err%")
for _, name := range names {
	st := stats[name]
	fmt.Printf("%-12s %7d %6.1f%%\n", name, st.Count, st.ErrorRate()*100)
}

For columns whose width depends on the data, use text/tabwriter.

Latency percentiles

The standard library has no quantile function. Sort first, then interpolate between the closest ranks - this matches Python’s method="inclusive" exactly, so both languages report the same p99.

// percentile interpolates linearly between the closest ranks of sorted,
// staying within the observed values (Python's method="inclusive").
func percentile(sorted []float64, pct float64) float64 {
	if len(sorted) == 0 {
		return math.NaN()
	}
	if len(sorted) == 1 {
		return sorted[0]
	}
	pos := pct / 100 * float64(len(sorted)-1)
	lo := int(pos)
	if lo >= len(sorted)-1 {
		return sorted[len(sorted)-1]
	}
	return sorted[lo] + (sorted[lo+1]-sorted[lo])*(pos-float64(lo))
}
values := []float64{400, 134}
sort.Float64s(values) // percentile needs sorted input
fmt.Println(percentile(values, 50), percentile(values, 99))

HTTP request with a timeout

The default http.Client has no timeout. Pass the context so cancellation works; always close the body or connections leak.

client := &http.Client{Timeout: 5 * time.Second}

func fetchStatus(ctx context.Context, client *http.Client, url string) (int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return 0, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return 0, err // timeout, DNS, connection refused...
	}
	defer resp.Body.Close()
	return resp.StatusCode, nil
}

Retry with exponential backoff and jitter

Retry network errors, 5xx and 429; never other 4xx. The select stops waiting as soon as the context is cancelled, so a sleeping retry never holds up shutdown.

func retryable(status int, err error) bool {
	if err != nil {
		return !errors.Is(err, context.Canceled)
	}
	return status >= 500 || status == http.StatusTooManyRequests
}

func check(ctx context.Context, client *http.Client, url string, retries int) error {
	delay := time.Second
	for attempt := 0; ; attempt++ {
		status, err := fetchStatus(ctx, client, url)
		if err == nil && status >= 200 && status < 400 {
			return nil
		}
		if err == nil {
			err = fmt.Errorf("status %d", status)
		}
		if attempt == retries || !retryable(status, err) {
			return err
		}
		jitter := time.Duration(rand.Int64N(int64(delay / 2))) // math/rand/v2
		select {
		case <-time.After(delay + jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
		delay *= 2 // 1s, 2s, 4s
	}
}

Run checks in parallel

Cap parallelism with a buffered channel as a semaphore.

  • Each goroutine writes to its own slice index, so no mutex is needed.
  • Capturing i and url in the closure is safe from Go 1.22 - each iteration gets fresh variables.
type Result struct {
	URL string
	Err error
}

func checkAll(ctx context.Context, client *http.Client, urls []string, workers int) []Result {
	results := make([]Result, len(urls))
	sem := make(chan struct{}, workers)
	var wg sync.WaitGroup
	for i, url := range urls {
		wg.Add(1)
		go func() {
			defer wg.Done()
			sem <- struct{}{}        // acquire
			defer func() { <-sem }() // release
			results[i] = Result{URL: url, Err: check(ctx, client, url, 3)}
		}()
	}
	wg.Wait()
	return results
}

HTTP server with graceful shutdown

On SIGTERM, stop accepting new connections and let in-flight requests finish.

  • Set ReadHeaderTimeout - without it a slow client can hold connections open forever.
  • Method patterns like "GET /healthz" need Go 1.22.
  • Keep the shutdown timeout below the pod’s terminationGracePeriodSeconds (30 seconds by default), or Kubernetes kills the process mid-drain.
func serve(ctx context.Context) error {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() { errCh <- srv.ListenAndServe() }()

	select {
	case err := <-errCh:
		return err // failed to start, e.g. port in use
	case <-ctx.Done():
	}
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}

Find OOMKilled containers

The one snippet outside the standard library: it uses the official client (go get k8s.io/client-go). Runs in a pod or on a laptop; needs get and list on pods. Built against client-go v0.37.

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/clientcmd"
)

func main() {
	cfg, err := rest.InClusterConfig() // inside a pod
	if err != nil {
		// on a laptop: honours $KUBECONFIG, then ~/.kube/config
		rules := clientcmd.NewDefaultClientConfigLoadingRules()
		cfg, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
			rules, &clientcmd.ConfigOverrides{}).ClientConfig()
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "no cluster config:", err)
		os.Exit(2)
	}
	client, err := kubernetes.NewForConfig(cfg)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(2)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	pods, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) // "" = all namespaces
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(2)
	}
	for _, pod := range pods.Items {
		for _, cs := range pod.Status.ContainerStatuses {
			if t := cs.LastTerminationState.Terminated; t != nil && t.Reason == "OOMKilled" {
				fmt.Println(pod.Namespace, pod.Name, cs.Name, "restarts="+fmt.Sprint(cs.RestartCount))
			}
		}
	}
}
  • Set a timeout on the context. A hung API server otherwise hangs the tool.
  • Page large clusters. Set ListOptions.Limit and follow Continue instead of listing every pod at once.

Why this matters: the app that was starved of memory its whole life.

Read files: plain, gzip, stdin, JSON lines, CSV

Stream with bufio.Scanner and route every input through one opener: plain files, .gz, and - for stdin.

// openText opens a plain file, a .gz file, or stdin ("-").
func openText(path string) (io.ReadCloser, error) {
	if path == "-" {
		return io.NopCloser(os.Stdin), nil
	}
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	if !strings.HasSuffix(path, ".gz") {
		return f, nil
	}
	gz, err := gzip.NewReader(f)
	if err != nil {
		f.Close()
		return nil, err
	}
	return struct {
		io.Reader
		io.Closer
	}{gz, f}, nil // Close closes the file underneath
}

// eachLine calls fn for every line; long lines up to 1 MiB are fine.
func eachLine(path string, fn func(line string)) error {
	r, err := openText(path)
	if err != nil {
		return err
	}
	defer r.Close()
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for sc.Scan() {
		fn(sc.Text())
	}
	return sc.Err() // ErrTooLong, truncated gzip, read errors
}

type Entry struct {
	Service   string  `json:"service"`
	Status    int     `json:"status"`
	LatencyMS float64 `json:"latency_ms"`
}
n := 0
if err := eachLine("app.log", func(string) { n++ }); err != nil {
	fmt.Fprintln(os.Stderr, "error:", err)
	os.Exit(2)
}
fmt.Println("lines:", n)

// JSON lines into a struct; skip what doesn't parse
var entries []Entry
bad := 0
err := eachLine("events.jsonl.gz", func(line string) {
	if strings.TrimSpace(line) == "" {
		return
	}
	var e Entry
	if err := json.Unmarshal([]byte(line), &e); err != nil {
		bad++
		return
	}
	entries = append(entries, e)
})
fmt.Println(len(entries), bad, err)

// CSV with a header row
f, err := os.Open("report.csv")
if err != nil {
	fmt.Fprintln(os.Stderr, "error:", err)
	os.Exit(2)
}
defer f.Close()
cr := csv.NewReader(f)
header, err := cr.Read()
if err != nil {
	fmt.Fprintln(os.Stderr, "error:", err)
	os.Exit(2)
}
col := map[string]int{}
for i, name := range header {
	col[name] = i
}
for {
	rec, err := cr.Read()
	if errors.Is(err, io.EOF) {
		break
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(2)
	}
	fmt.Println(rec[col["service"]], rec[col["status"]])
}
  • Raise the Scanner buffer. Its default maximum line is 64 KiB; a longer line stops the scan with token too long.
  • Always check sc.Err(). The loop ends the same way on EOF, a truncated .gz and a too-long line - only Err tells them apart.
  • Unmarshalling into a struct zero-fills missing fields. A line without status decodes as status 0 with no error. Validate required fields after decoding.

Full program: log analyzer

The Go version of the Python log analyzer, with identical output on the same input.

go build -o loganalyzer .
./loganalyzer app.log app.log.1.gz
kubectl logs deploy/api | ./loganalyzer -service payments
./loganalyzer -json /var/log/app/*.gz | jq '.services[0]'
// loganalyzer summarises access logs per service: requests, error rate,
// p50 and p99 latency. Reads key=value or JSON lines, plain or gzipped,
// from files or stdin. Exit codes: 0 ok, 2 input could not be read.
package main

import (
	"bufio"
	"compress/gzip"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"math"
	"os"
	"sort"
	"strconv"
	"strings"
)

type Stats struct {
	Count     int
	Errors    int
	Latencies []float64
}

func (s *Stats) ErrorRate() float64 {
	if s.Count == 0 {
		return 0
	}
	return float64(s.Errors) / float64(s.Count)
}

type Report struct {
	Services       []Row `json:"services"`
	MalformedLines int   `json:"malformed_lines"`
}

type Row struct {
	Service   string  `json:"service"`
	Count     int     `json:"count"`
	ErrorRate float64 `json:"error_rate"`
	P50       float64 `json:"p50_ms"`
	P99       float64 `json:"p99_ms"`
}

func main() {
	service := flag.String("service", "", "only this service")
	top := flag.Int("top", 20, "max rows")
	asJSON := flag.Bool("json", false, "JSON output for scripts")
	flag.Parse()

	paths := flag.Args()
	if len(paths) == 0 {
		paths = []string{"-"}
	}

	stats := map[string]*Stats{}
	bad := 0
	for _, path := range paths {
		n, err := analyzeFile(path, *service, stats)
		bad += n
		if err != nil {
			fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)
			os.Exit(2)
		}
	}

	rows := summarize(stats, *top)
	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		_ = enc.Encode(Report{Services: rows, MalformedLines: bad})
	} else {
		printTable(rows)
	}
	if bad > 0 {
		fmt.Fprintf(os.Stderr, "skipped %d malformed line(s)\n", bad)
	}
}

// openText opens a plain file, a .gz file, or stdin ("-").
func openText(path string) (io.ReadCloser, error) {
	if path == "-" {
		return io.NopCloser(os.Stdin), nil
	}
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	if !strings.HasSuffix(path, ".gz") {
		return f, nil
	}
	gz, err := gzip.NewReader(f)
	if err != nil {
		f.Close()
		return nil, err
	}
	return struct {
		io.Reader
		io.Closer
	}{gz, f}, nil
}

func analyzeFile(path, only string, stats map[string]*Stats) (bad int, err error) {
	r, err := openText(path)
	if err != nil {
		return 0, err
	}
	defer r.Close()

	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // default max line is 64 KiB
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		svc, status, latency, err := parseLine(line)
		if err != nil {
			bad++
			continue
		}
		if only != "" && svc != only {
			continue
		}
		s := stats[svc]
		if s == nil {
			s = &Stats{}
			stats[svc] = s
		}
		s.Count++
		if status >= 500 {
			s.Errors++
		}
		s.Latencies = append(s.Latencies, latency)
	}
	return bad, sc.Err() // a too-long line or truncated .gz surfaces here
}

// parseLine accepts a JSON object or "ts k=v k=v".
func parseLine(line string) (service string, status int, latency float64, err error) {
	fields := map[string]string{}
	if strings.HasPrefix(line, "{") {
		var obj map[string]any
		if err := json.Unmarshal([]byte(line), &obj); err != nil {
			return "", 0, 0, err
		}
		for k, v := range obj {
			if v != nil {
				fields[k] = fmt.Sprint(v)
			}
		}
	} else {
		parts := strings.Fields(line)
		for _, p := range parts[1:] { // parts[0] is the timestamp
			k, v, ok := strings.Cut(p, "=")
			if !ok {
				return "", 0, 0, fmt.Errorf("bad pair %q", p)
			}
			fields[k] = v
		}
	}

	service, ok := fields["service"]
	if !ok {
		return "", 0, 0, errors.New("missing service")
	}
	if status, err = strconv.Atoi(fields["status"]); err != nil {
		return "", 0, 0, err
	}
	if latency, err = strconv.ParseFloat(fields["latency_ms"], 64); err != nil {
		return "", 0, 0, err
	}
	return service, status, latency, nil
}

// percentile interpolates linearly between the closest ranks, staying
// within the observed values (same as Python's method="inclusive").
func percentile(sorted []float64, pct float64) float64 {
	if len(sorted) == 1 {
		return sorted[0]
	}
	pos := pct / 100 * float64(len(sorted)-1)
	lo := int(math.Floor(pos))
	if lo >= len(sorted)-1 {
		return sorted[len(sorted)-1]
	}
	return sorted[lo] + (sorted[lo+1]-sorted[lo])*(pos-float64(lo))
}

func summarize(stats map[string]*Stats, top int) []Row {
	rows := make([]Row, 0, len(stats))
	for name, s := range stats {
		sort.Float64s(s.Latencies)
		rows = append(rows, Row{
			Service:   name,
			Count:     s.Count,
			ErrorRate: math.Round(s.ErrorRate()*10000) / 10000,
			P50:       math.Round(percentile(s.Latencies, 50)*10) / 10,
			P99:       math.Round(percentile(s.Latencies, 99)*10) / 10,
		})
	}
	sort.Slice(rows, func(i, j int) bool {
		a, b := rows[i], rows[j]
		if a.ErrorRate != b.ErrorRate {
			return a.ErrorRate > b.ErrorRate
		}
		if a.Count != b.Count {
			return a.Count > b.Count
		}
		return a.Service < b.Service
	})
	if len(rows) > top {
		rows = rows[:top]
	}
	return rows
}

func printTable(rows []Row) {
	fmt.Printf("%-12s %7s %7s %8s %8s\n", "service", "count", "err%", "p50", "p99")
	for _, r := range rows {
		fmt.Printf("%-12s %7d %6.1f%% %8.0f %8.0f\n",
			r.Service, r.Count, r.ErrorRate*100, r.P50, r.P99)
	}
}
  • Flags go before file arguments. Go’s flag package stops parsing at the first non-flag argument, so ./loganalyzer app.log -json treats -json as a file name.
  • Exit codes: 0 ran, 2 couldn’t read input - including lines over the 1 MiB buffer limit.
  • Encode JSON from a struct, not a map, to control field order; maps are always marshalled in alphabetical key order.

Unit-test the parser

Use table-driven tests: one slice of cases, one loop, t.Run per case so failures name the case. Run with go test ./.... These cover the parseLine and percentile snippets above.

package main

import (
	"math"
	"testing"
)

func TestParseLine(t *testing.T) {
	tests := []struct {
		name    string
		line    string
		want    map[string]string
		wantErr bool
	}{
		{
			name: "good line",
			line: "2026-10-13T14:02:11Z service=auth status=200",
			want: map[string]string{"ts": "2026-10-13T14:02:11Z", "service": "auth", "status": "200"},
		},
		{name: "value keeps extra equals", line: "t q=a=b", want: map[string]string{"ts": "t", "q": "a=b"}},
		{name: "pair without equals", line: "garbage line here", wantErr: true},
		{name: "timestamp only", line: "2026-10-13T14:02:11Z", wantErr: true},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := parseLine(tc.line)
			if tc.wantErr {
				if err == nil {
					t.Fatalf("want error, got %v", got)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			for k, v := range tc.want {
				if got[k] != v {
					t.Errorf("%s = %q, want %q", k, got[k], v)
				}
			}
		})
	}
}

func TestPercentile(t *testing.T) {
	sorted := []float64{134, 400}
	if got := percentile(sorted, 50); got != 267 {
		t.Errorf("p50 = %v, want 267", got)
	}
	if got := percentile(sorted, 99); math.Abs(got-397.34) > 1e-9 {
		t.Errorf("p99 = %v, want 397.34", got)
	}
	if got := percentile([]float64{55}, 99); got != 55 {
		t.Errorf("single value p99 = %v, want 55", got)
	}
}