tutorial: GopherCon UK 2024 tutorial resources.

Signed-off-by: bwplotka <[email protected]>
diff --git a/tutorials/runtime/wheelofmisfortune/Dockerfile b/tutorials/runtime/wheelofmisfortune/Dockerfile
new file mode 100644
index 0000000..d112445
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/Dockerfile
@@ -0,0 +1,6 @@
+# Use amd64 sha.
+FROM curlimages/curl@sha256:8addc281f0ea517409209f76832b6ddc2cabc3264feb1ebbec2a2521ffad24e4
+
+COPY brokenapp /brokenapp
+CMD ["/brokenapp"]
+ENTRYPOINT ["/brokenapp"]
diff --git a/tutorials/runtime/wheelofmisfortune/Makefile b/tutorials/runtime/wheelofmisfortune/Makefile
new file mode 100644
index 0000000..d5c86af
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/Makefile
@@ -0,0 +1,34 @@
+.PHONY: help
+help: ## Displays help.
+	@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-z0-9A-Z_-]+:.*?##/ { printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
+
+.PHONY: deploy
+deploy: ## init
+	@bash setup-gke.sh bwplotka-stdtest
+
+.PHONY: run
+run: ## run brokenapp
+	@go run brokenapp.go -listen-address=":99"
+
+THE_CHOSEN_POD=$(shell kubectl get pods -l 'app=brokenapp' -o jsonpath='{.items[*].metadata.name}' | sort | awk '{print $$3}')
+
+.PHONY: port-forward
+port-forward: ## forward traffic to one pod
+	@kubectl port-forward pod/$(THE_CHOSEN_POD) 9999
+
+.PHONY: metrics
+metrics: ## get metrics from one pod
+	@kubectl exec $(THE_CHOSEN_POD) -- curl -s http://localhost:9999/metrics
+
+.PHONY: open-metrics
+open-metrics: ## get metrics from one pod
+	@kubectl exec $(THE_CHOSEN_POD) -- curl -s -H 'Accept: application/openmetrics-text' http://localhost:9999/metrics
+
+CASE=0
+.PHONY: break
+break: ## break one pod
+	@kubectl exec $(THE_CHOSEN_POD) -- curl -s http://localhost:9999/break/$(CASE)
+
+.PHONY: fix
+fix: ## fix one pod
+	@kubectl exec $(THE_CHOSEN_POD) -- curl -s http://localhost:9999/fix/$(CASE)
diff --git a/tutorials/runtime/wheelofmisfortune/README.md b/tutorials/runtime/wheelofmisfortune/README.md
new file mode 100644
index 0000000..00921f4
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/README.md
@@ -0,0 +1,3 @@
+# client_golang Tutorial: runtime wheel of misfortune
+
+[Slides](https://docs.google.com/presentation/d/1blGS0rey7-nDWHAJrZnX_0kwbDuS0M3cnzDt3_ct9Ss/edit#slide=id.g2f3249de911_4_146)
diff --git a/tutorials/runtime/wheelofmisfortune/brokenapp.go b/tutorials/runtime/wheelofmisfortune/brokenapp.go
new file mode 100644
index 0000000..c75ca2d
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/brokenapp.go
@@ -0,0 +1,132 @@
+// Copyright 2023 The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"context"
+	"flag"
+	"log"
+	"net/http"
+	httppprof "net/http/pprof"
+	"regexp"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"syscall"
+
+	"github.com/efficientgo/core/errors"
+	"github.com/oklog/run"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/collectors"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+var (
+	addr = flag.String("listen-address", ":9999", "The address to listen on for HTTP requests.")
+)
+
+func main() {
+	flag.Parse()
+
+	if err := runMain(*addr); err != nil {
+		// Use %+v for github.com/efficientgo/core/errors error to print with stack.
+		log.Fatalf("Error: %+v", errors.Wrapf(err, "%s", flag.Arg(0)))
+	}
+}
+
+func runMain(addr string) (err error) {
+	// Create registry for Prometheus metrics.
+	reg := prometheus.NewRegistry()
+	reg.MustRegister(
+		collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics( // Metrics from Go runtime.
+			collectors.GoRuntimeMetricsRule{
+				Matcher: regexp.MustCompile("/sched/latencies:seconds"), // One more recommended metric on top of the default.
+			},
+		)),
+		collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), // Metrics about the current UNIX process.
+	)
+
+	m := http.NewServeMux()
+
+	// Create HTTP handler for Prometheus metrics.
+	m.Handle("/metrics", promhttp.HandlerFor(
+		reg,
+		promhttp.HandlerOpts{EnableOpenMetrics: true},
+	))
+
+	// Debug profiling endpoints.
+	m.HandleFunc("/debug/pprof/", httppprof.Index)
+	m.HandleFunc("/debug/pprof/cmdline", httppprof.Cmdline)
+	m.HandleFunc("/debug/pprof/profile", httppprof.Profile)
+	m.HandleFunc("/debug/pprof/symbol", httppprof.Symbol)
+
+	s := &scenarios{}
+	m.HandleFunc("/break/", func(w http.ResponseWriter, r *http.Request) {
+		if err := s.SetFromParam(strings.TrimPrefix(r.URL.Path, "/break/"), true); err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+		}
+	})
+	m.HandleFunc("/fix/", func(w http.ResponseWriter, r *http.Request) {
+		if err := s.SetFromParam(strings.TrimPrefix(r.URL.Path, "/fix/"), false); err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+		}
+	})
+
+	srv := http.Server{Addr: addr, Handler: m}
+	g := &run.Group{}
+	{
+		g.Add(func() error {
+			log.Println("Starting HTTP server", "addr", addr)
+			if err := srv.ListenAndServe(); err != nil {
+				return errors.Wrap(err, "starting web server")
+			}
+			return nil
+		}, func(error) {
+			if err := srv.Close(); err != nil {
+				log.Println("Error: Failed to stop web server", "err", err)
+			}
+		})
+	}
+	g.Add(run.SignalHandler(context.Background(), syscall.SIGINT, syscall.SIGTERM))
+	addContextNotCanceledGroup(g, reg, func() bool { return s.IsEnabled(contextNotCanceled) })
+	addGoroutineJumpGroup(g, func() bool { return s.IsEnabled(goroutineJump) })
+	return g.Run()
+}
+
+func doOp(ctx context.Context) int64 {
+	wg := sync.WaitGroup{}
+	wg.Add(10)
+	var sum int64
+	for i := 0; i < 10; i++ {
+		atomic.StoreInt64(&sum, int64(fib(ctx, 1e5)))
+		wg.Done()
+	}
+	wg.Wait()
+	return sum
+}
+
+func fib(ctx context.Context, n int) int {
+	if n <= 1 {
+		return n
+	}
+	var n2, n1 = 0, 1
+	for i := 2; i <= n; i++ {
+		if ctx.Err() != nil {
+			return -1
+		}
+		n2, n1 = n1, n1+n2
+	}
+	return n1
+}
diff --git a/tutorials/runtime/wheelofmisfortune/brokenapp.yaml b/tutorials/runtime/wheelofmisfortune/brokenapp.yaml
new file mode 100644
index 0000000..6463b34
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/brokenapp.yaml
@@ -0,0 +1,44 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: brokenapp
+  labels:
+    app: brokenapp
+spec:
+  selector:
+    matchLabels:
+      app: brokenapp
+  replicas: 5
+  template:
+    metadata:
+      labels:
+        app: brokenapp
+    spec:
+      containers:
+      - name: brokenapp
+        imagePullPolicy: Always
+        image: gcr.io/gpe-test-1/bwplotka-brokenapp:latest
+        args:
+        - "/app"
+        - "-listen-address=:9999"
+        ports:
+        - containerPort: 9999
+          name: metrics
+        resources:
+          limits:
+            memory: 1G
+---
+apiVersion: monitoring.googleapis.com/v1
+kind: PodMonitoring
+metadata:
+  name: brokenapp
+  labels:
+    app: brokenapp
+spec:
+  endpoints:
+  - port: metrics
+    interval: 15s
+    path: /metrics
+  selector:
+    matchLabels:
+      app: brokenapp
diff --git a/tutorials/runtime/wheelofmisfortune/go.mod b/tutorials/runtime/wheelofmisfortune/go.mod
new file mode 100644
index 0000000..61fca9b
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/go.mod
@@ -0,0 +1,23 @@
+module github.com/prometheus/client_golang/tutorials/runtime/wheelofmisfortune
+
+go 1.21.4
+
+require (
+	github.com/efficientgo/core v1.0.0-rc.2
+	github.com/oklog/run v1.1.0
+	github.com/prometheus/client_golang v1.19.1
+)
+
+require (
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/klauspost/compress v1.17.9 // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/prometheus/client_model v0.6.1 // indirect
+	github.com/prometheus/common v0.55.0 // indirect
+	github.com/prometheus/procfs v0.15.1 // indirect
+	golang.org/x/sys v0.22.0 // indirect
+	google.golang.org/protobuf v1.34.2 // indirect
+)
+
+replace github.com/prometheus/client_golang => github.com/prometheus/client_golang v1.19.1-0.20240811171649-aa3c00d2ee32
diff --git a/tutorials/runtime/wheelofmisfortune/go.sum b/tutorials/runtime/wheelofmisfortune/go.sum
new file mode 100644
index 0000000..8da6643
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/go.sum
@@ -0,0 +1,30 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/efficientgo/core v1.0.0-rc.2 h1:7j62qHLnrZqO3V3UA0AqOGd5d5aXV3AX6m/NZBHp78I=
+github.com/efficientgo/core v1.0.0-rc.2/go.mod h1:FfGdkzWarkuzOlY04VY+bGfb1lWrjaL6x/GLcQ4vJps=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/prometheus/client_golang v1.19.1-0.20240811171649-aa3c00d2ee32 h1:HKADPlynLA8q5KEF/f7KYo8pnyRFuvKz9f/QPfXs2nk=
+github.com/prometheus/client_golang v1.19.1-0.20240811171649-aa3c00d2ee32/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
diff --git a/tutorials/runtime/wheelofmisfortune/scenarios.go b/tutorials/runtime/wheelofmisfortune/scenarios.go
new file mode 100644
index 0000000..17971f5
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/scenarios.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/oklog/run"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+type scenario int
+
+const (
+	contextNotCanceled scenario = 0
+	goroutineJump      scenario = 1
+)
+
+type scenarios struct {
+	enabled [2]bool
+	mu      sync.RWMutex
+}
+
+func (s *scenarios) SetFromParam(c string, v bool) error {
+	if c == "" {
+		return errors.New("no {case} parameter in path")
+	}
+	cN, err := strconv.Atoi(c)
+	if err != nil {
+		return errors.New("{case} is not a number")
+	}
+	if cN < 0 || cN >= len(s.enabled) {
+		return fmt.Errorf("{case} should be a number from 0 to %d", len(s.enabled)-1)
+	}
+	s.set(scenario(cN), v)
+	return nil
+}
+
+func (s *scenarios) set(choice scenario, v bool) {
+	s.mu.Lock()
+	s.enabled[choice] = v
+	s.mu.Unlock()
+}
+
+func (s *scenarios) IsEnabled(choice scenario) bool {
+	s.mu.RLock()
+	ret := s.enabled[choice]
+	s.mu.RUnlock()
+	return ret
+}
+
+func addContextNotCanceledGroup(g *run.Group, reg *prometheus.Registry, shouldBreak func() bool) {
+	// Create latency metric for our app operation.
+	opLatency := promauto.With(reg).NewHistogram(
+		prometheus.HistogramOpts{
+			Name:    "brokenapp_operation_latency_seconds",
+			Help:    "Tracks the latencies for calls.",
+			Buckets: []float64{0.01, 0.05, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20},
+		},
+	)
+
+	ctx, cancel := context.WithCancel(context.Background())
+
+	// Custom contexts can happen...
+	// Without it, Go has many clever tricks to avoid extra goroutines per context
+	// cancel setup or timers.
+	ctx = withCustomContext(ctx)
+	g.Add(func() error {
+		for {
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case <-time.After(100 * time.Millisecond):
+			}
+			broken := shouldBreak()
+
+			// Do an operation.
+			ctx, cancel := context.WithTimeout(ctx, 1*time.Hour)
+			if broken {
+				// Bug: Cancel will run until the end of this function... so until program
+				// exit of timeout. This means we are leaking goroutines here with
+				// all their allocated memory (and a bit of memory for defer).
+				defer cancel()
+			}
+
+			start := time.Now()
+			ret := doOp(ctx)
+			since := time.Since(start)
+			opLatency.Observe(float64(since.Nanoseconds()) * 1e-9)
+
+			fmt.Println("10 * 1e5th fibonacci number is", ret, "; elapsed", since.String())
+
+			if !broken {
+				cancel()
+			}
+		}
+	}, func(err error) {
+		cancel()
+	})
+}
+
+func addGoroutineJumpGroup(g *run.Group, shouldBreak func() bool) {
+	ctx, cancel := context.WithCancel(context.Background())
+	g.Add(func() error {
+		for {
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case <-time.After(30 * time.Second):
+			}
+
+			if !shouldBreak() {
+				continue
+			}
+
+			var wg sync.WaitGroup
+			done := make(chan struct{})
+
+			for i := 0; i < 300; i++ {
+				time.Sleep(1 * time.Second)
+				wg.Add(1)
+				go func() {
+					<-done
+					wg.Done()
+				}()
+			}
+			time.Sleep(30 * time.Second)
+			close(done)
+			wg.Wait()
+		}
+	}, func(err error) {
+		cancel()
+	})
+}
+
+type customCtx struct {
+	context.Context
+}
+
+func withCustomContext(ctx context.Context) context.Context {
+	return customCtx{Context: ctx}
+}
+
+func (c customCtx) Value(any) any {
+	return nil // Noop to avoid optimizations to highlight the negative effect.
+}
diff --git a/tutorials/runtime/wheelofmisfortune/setup-gke.sh b/tutorials/runtime/wheelofmisfortune/setup-gke.sh
new file mode 100644
index 0000000..c102284
--- /dev/null
+++ b/tutorials/runtime/wheelofmisfortune/setup-gke.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+set -efo pipefail
+export SHELLOPTS	# propagate set to children by default
+IFS=$'\t\n'
+
+CLUSTER_NAME=$1
+if [ -z "${CLUSTER_NAME}" ]; then
+    echo "cluster name is required as the first parameter!"
+fi
+
+ZONE="us-central1-a"
+PROJECT_ID=$(gcloud config get project)
+
+# Do nothing if cluster already exists.
+if gcloud container clusters list --filter="name: ${CLUSTER_NAME}" 2>&1 | grep -q "^${CLUSTER_NAME} "
+then
+  echo "WARN: Cluster ${CLUSTER_NAME} already exists, skipping creation"
+  gcloud container clusters get-credentials ${CLUSTER_NAME} --zone ${ZONE} --project ${PROJECT_ID}
+else
+  # Start a new one-node cluster.
+  # https://cloud.google.com/sdk/gcloud/reference/container/clusters/create
+  # n2-standard-4 -- 4 vCPUs 16 GB
+  gcloud container clusters create ${CLUSTER_NAME} \
+    --project=${PROJECT_ID} \
+    --location=${ZONE} \
+    --workload-pool=${PROJECT_ID}.svc.id.goog \
+    --release-channel=rapid \
+    --num-nodes=1 \
+    --machine-type="n2-standard-4"
+fi
+
+CLUSTER_API_URL=$(kubectl config view --minify -o jsonpath="{.clusters[?(@.name == \"kind-${CLUSTER_NAME}\")].cluster.server}")
+echo "## Cluster is now running, kubectl should point to the new cluster at ${CLUSTER_API_URL}"
+kubectl cluster-info
+
+CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./brokenapp
+CGO_ENABLED=0 go build -o ./brokenapp-mac
+
+# Build image.
+docker buildx build --platform linux/amd64 --tag gcr.io/gpe-test-1/bwplotka-brokenapp:latest --load .
+
+# Push image.
+docker push gcr.io/gpe-test-1/bwplotka-brokenapp:latest
+kubectl delete deployment brokenapp
+# Deploy.
+kubectl apply -f brokenapp.yaml