Go
Standard-library Go for ops tools: parsing, counting, percentiles, HTTP with timeouts, retries, concurrency, file reading, a full log analyzer and tests.
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
maintiny; put the work in arunthat returns an error. - Set a timeout on every
http.Clientandhttp.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}
Print an aligned table
| Verb | Effect |
|---|---|
%-12s | Left-align to 12 characters |
%7d | Right-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
iandurlin 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.Limitand followContinueinstead 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.gzand a too-long line - onlyErrtells them apart. - Unmarshalling into a struct zero-fills missing fields. A line without
statusdecodes 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
flagpackage stops parsing at the first non-flag argument, so./loganalyzer app.log -jsontreats-jsonas 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)
}
}