Skip to content

Commit 4f67035

Browse files
sourcehawkclaude
andauthored
feat(observability): add a local Prometheus and Grafana stack fed by a metrics simulator (#189)
* feat(observability): add the simulator's controller-runtime metric lookalikes with a parity test (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * feat(observability): add a metrics simulator that plays a scripted operator world (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * feat(observability): add a docker compose Prometheus and Grafana stack fed by the simulator (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * chore(observability): promote client_model to a direct dependency (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * fix(observability): bind the dev stack to localhost and tidy simulator wording (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * fix(observability): satisfy goconst in the simulator (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * feat(observability): model persistent active workers and smoke test the scripted world (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * fix(observability): harden the dev render and stack startup in the Makefile (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * fix(observability): align the simulator with real controller-runtime emissions (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * refactor(observability): source workqueue names from controller-runtime and drop the seeded rng (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd * fix(observability): harden dev stack startup and make the world test deterministic (#185) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7cba942 commit 4f67035

11 files changed

Lines changed: 884 additions & 1 deletion

File tree

Makefile

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,36 @@ test-alerts: ## Lint and unit test the alert rules with promtool.
327327
echo "Running unit tests..."; \
328328
promtool test rules --diff "$$tmpdir"/tests/*.yaml
329329

330+
OBS_DEV_NAMESPACE := demo
331+
OBS_DEV_OUT := $(OBS_DIR)/generated/dev
332+
# Extra flags for the simulator, e.g. SIMULATOR_ARGS="-leader=false".
333+
SIMULATOR_ARGS ?=
334+
335+
.PHONY: observability-render-dev
336+
observability-render-dev: ## Render dashboards and plain rules for the dev stack, with every `for:` shortened to 2m.
337+
@rm -rf $(OBS_DEV_OUT)/alerts/* $(OBS_DEV_OUT)/dashboards/*
338+
@$(MAKE) --no-print-directory dashboards METRIC_NAMESPACE=$(OBS_DEV_NAMESPACE) OBS_OUT=$(OBS_DEV_OUT)
339+
@$(MAKE) --no-print-directory alerts METRIC_NAMESPACE=$(OBS_DEV_NAMESPACE) OBS_OUT=$(OBS_DEV_OUT) ALERT_FORMAT=rules
340+
@for file in $(OBS_DEV_OUT)/alerts/*.yaml; do \
341+
sed -E 's/^( *for: ).*/\12m/' "$$file" > "$$file.tmp" && mv "$$file.tmp" "$$file"; \
342+
done
343+
344+
.PHONY: observability-up
345+
observability-up: observability-render-dev ## Start Prometheus (:9090) and Grafana (:3000) and run the simulator on the host.
346+
docker compose -f $(OBS_DIR)/dev/docker-compose.yaml up -d
347+
@ready=0; for i in $$(seq 1 30); do \
348+
curl -fsS 127.0.0.1:9090/-/ready >/dev/null 2>&1 && { ready=1; break; }; \
349+
sleep 1; \
350+
done; \
351+
[ "$$ready" = "1" ] || { echo "Error: prometheus did not become ready"; exit 1; }; \
352+
curl -fsS -X POST 127.0.0.1:9090/-/reload
353+
@echo "Grafana: http://localhost:3000 Prometheus: http://localhost:9090/alerts"
354+
go run ./$(OBS_DIR)/dev/simulator -metric-namespace=$(OBS_DEV_NAMESPACE) $(SIMULATOR_ARGS)
355+
356+
.PHONY: observability-down
357+
observability-down: ## Stop the local Prometheus and Grafana.
358+
docker compose -f $(OBS_DIR)/dev/docker-compose.yaml down
359+
330360

331361
# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist
332362
# $1 - target path with name of binary

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/onsi/gomega v1.42.1
99
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
1010
github.com/prometheus/client_golang v1.23.2
11+
github.com/prometheus/client_model v0.6.2
1112
github.com/sourcehawk/go-crd-condition-metrics v1.1.0
1213
github.com/spf13/cobra v1.10.2
1314
github.com/stretchr/testify v1.11.1
@@ -46,7 +47,6 @@ require (
4647
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4748
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
4849
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
49-
github.com/prometheus/client_model v0.6.2 // indirect
5050
github.com/prometheus/common v0.67.5 // indirect
5151
github.com/prometheus/procfs v0.19.2 // indirect
5252
github.com/sourcehawk/go-prometheus-gaugevecset v1.1.0 // indirect
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Local Prometheus + Grafana for looking at the dashboards and alerts with the
2+
# simulator's data behind them. Start with `make observability-up`.
3+
services:
4+
prometheus:
5+
image: prom/prometheus:v3.14.0
6+
command:
7+
- --config.file=/etc/prometheus/prometheus.yml
8+
- --web.enable-lifecycle
9+
ports:
10+
- "127.0.0.1:9090:9090"
11+
volumes:
12+
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
13+
- ../generated/dev/alerts:/etc/prometheus/rules:ro
14+
extra_hosts:
15+
- "host.docker.internal:host-gateway"
16+
17+
grafana:
18+
image: grafana/grafana:13.1.4
19+
ports:
20+
- "127.0.0.1:3000:3000"
21+
environment:
22+
GF_AUTH_ANONYMOUS_ENABLED: "true"
23+
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
24+
GF_AUTH_DISABLE_LOGIN_FORM: "true"
25+
volumes:
26+
- ./grafana/provisioning:/etc/grafana/provisioning:ro
27+
- ../generated/dev/dashboards:/var/lib/grafana/dashboards:ro
28+
depends_on:
29+
- prometheus
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: 1
2+
providers:
3+
- name: ocf
4+
folder: OCF
5+
type: file
6+
disableDeletion: true
7+
options:
8+
path: /var/lib/grafana/dashboards
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: 1
2+
datasources:
3+
- name: Prometheus
4+
type: prometheus
5+
uid: prometheus
6+
access: proxy
7+
url: http://prometheus:9090
8+
isDefault: true
9+
editable: false
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Scrapes the simulator running on the host. The static `namespace` and `pod`
2+
# target labels collide with the namespace label the condition gauge exports,
3+
# which Prometheus resolves by renaming the exported one to
4+
# `exported_namespace`, exactly as a ServiceMonitor scrape does in a cluster.
5+
global:
6+
scrape_interval: 5s
7+
evaluation_interval: 10s
8+
9+
rule_files:
10+
- /etc/prometheus/rules/*.yaml
11+
12+
scrape_configs:
13+
- job_name: demo-operator
14+
static_configs:
15+
- targets: ["host.docker.internal:8080"]
16+
labels:
17+
namespace: operators
18+
pod: demo-operator-0
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Command simulator exposes a synthetic operator's metrics on /metrics for the
2+
// local observability stack under observability/dev.
3+
//
4+
// The framework's own series (resource applies, conditions) are recorded
5+
// through the real pkg/metrics recorder; controller-runtime, workqueue, REST
6+
// client and leader election series are lookalikes guarded by runtime_test.go.
7+
// The world it plays is described in docs/observability.md.
8+
package main
9+
10+
import (
11+
"context"
12+
"errors"
13+
"flag"
14+
"fmt"
15+
"log"
16+
"net/http"
17+
"os"
18+
"os/signal"
19+
"syscall"
20+
"time"
21+
22+
"github.com/prometheus/client_golang/prometheus"
23+
"github.com/prometheus/client_golang/prometheus/promhttp"
24+
ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics"
25+
26+
"github.com/sourcehawk/operator-component-framework/pkg/metrics"
27+
)
28+
29+
func main() {
30+
if err := run(); err != nil {
31+
log.Fatal(err)
32+
}
33+
}
34+
35+
// run wires the registry the way an operator would, serves /metrics and plays
36+
// the world until the process is signalled. It returns an error when the
37+
// metrics endpoint failed to serve, so main exits non-zero and
38+
// `make observability-up` fails visibly instead of idling without metrics.
39+
func run() error {
40+
listen := flag.String("listen", ":8080", "address to serve /metrics on")
41+
metricNamespace := flag.String("metric-namespace", "demo", "metric namespace of the condition gauge")
42+
leader := flag.Bool("leader", true, "report this replica as the leader; false shows OperatorLeaderMissing")
43+
flag.Parse()
44+
45+
reg := prometheus.NewRegistry()
46+
conditions := ocm.NewOperatorConditionsGauge(*metricNamespace)
47+
collectors := metrics.NewCollectors()
48+
reg.MustRegister(conditions, collectors)
49+
rt := newRuntimeMetrics(reg)
50+
51+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
52+
defer stop()
53+
54+
mux := http.NewServeMux()
55+
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
56+
srv := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
57+
serveErr := make(chan error, 1)
58+
go func() {
59+
log.Printf("serving metrics on %s/metrics", *listen)
60+
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
61+
serveErr <- err
62+
stop()
63+
}
64+
}()
65+
66+
newWorld(conditions, collectors, rt, *leader).run(ctx)
67+
68+
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
69+
defer cancel()
70+
_ = srv.Shutdown(shutdown)
71+
select {
72+
case err := <-serveErr:
73+
return fmt.Errorf("serving metrics: %w", err)
74+
default:
75+
return nil
76+
}
77+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package main
2+
3+
import (
4+
"github.com/prometheus/client_golang/prometheus"
5+
"github.com/prometheus/client_golang/prometheus/collectors"
6+
ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
7+
)
8+
9+
// runtimeMetrics defines the controller-runtime, workqueue, REST client and
10+
// leader election series an operator exposes, with the same names, label sets
11+
// and bucket layouts as the real ones. The real vectors live in
12+
// controller-runtime's internal packages; runtime_test.go guards this copy
13+
// against drift.
14+
type runtimeMetrics struct {
15+
reconcileTotal *prometheus.CounterVec
16+
reconcileErrors *prometheus.CounterVec
17+
reconcilePanics *prometheus.CounterVec
18+
reconcileTime *prometheus.HistogramVec
19+
maxConcurrent *prometheus.GaugeVec
20+
activeWorkers *prometheus.GaugeVec
21+
queueDepth *prometheus.GaugeVec
22+
queueAdds *prometheus.CounterVec
23+
queueDuration *prometheus.HistogramVec
24+
workDuration *prometheus.HistogramVec
25+
queueUnfinished *prometheus.GaugeVec
26+
queueLongestRunning *prometheus.GaugeVec
27+
queueRetries *prometheus.CounterVec
28+
restRequests *prometheus.CounterVec
29+
leader *prometheus.GaugeVec
30+
}
31+
32+
// Label names and the reconcile result value the lookalike series share with
33+
// controller-runtime.
34+
const (
35+
labelController = "controller"
36+
labelName = "name"
37+
resultError = "error"
38+
)
39+
40+
// reconcileTimeBuckets mirrors controller-runtime's ReconcileTime histogram.
41+
var reconcileTimeBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0,
42+
1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, 40, 50, 60}
43+
44+
func newRuntimeMetrics(reg prometheus.Registerer) *runtimeMetrics {
45+
m := &runtimeMetrics{
46+
reconcileTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
47+
Name: "controller_runtime_reconcile_total",
48+
Help: "Total number of reconciliations per controller",
49+
}, []string{labelController, "result"}),
50+
reconcileErrors: prometheus.NewCounterVec(prometheus.CounterOpts{
51+
Name: "controller_runtime_reconcile_errors_total",
52+
Help: "Total number of reconciliation errors per controller",
53+
}, []string{labelController}),
54+
reconcilePanics: prometheus.NewCounterVec(prometheus.CounterOpts{
55+
Name: "controller_runtime_reconcile_panics_total",
56+
Help: "Total number of reconciliation panics per controller",
57+
}, []string{labelController}),
58+
reconcileTime: prometheus.NewHistogramVec(prometheus.HistogramOpts{
59+
Name: "controller_runtime_reconcile_time_seconds",
60+
Help: "Length of time per reconciliation per controller",
61+
Buckets: reconcileTimeBuckets,
62+
}, []string{labelController}),
63+
maxConcurrent: prometheus.NewGaugeVec(prometheus.GaugeOpts{
64+
Name: "controller_runtime_max_concurrent_reconciles",
65+
Help: "Maximum number of concurrent reconciles per controller",
66+
}, []string{labelController}),
67+
activeWorkers: prometheus.NewGaugeVec(prometheus.GaugeOpts{
68+
Name: "controller_runtime_active_workers",
69+
Help: "Number of currently used workers per controller",
70+
}, []string{labelController}),
71+
queueDepth: prometheus.NewGaugeVec(prometheus.GaugeOpts{
72+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.DepthKey,
73+
Help: "Current depth of workqueue by workqueue and priority",
74+
}, []string{labelName, labelController, "priority"}),
75+
queueAdds: prometheus.NewCounterVec(prometheus.CounterOpts{
76+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.AddsKey,
77+
Help: "Total number of adds handled by workqueue",
78+
}, []string{labelName, labelController}),
79+
queueDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
80+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.QueueLatencyKey,
81+
Help: "How long in seconds an item stays in workqueue before being requested",
82+
Buckets: prometheus.ExponentialBuckets(10e-9, 10, 12),
83+
}, []string{labelName, labelController}),
84+
workDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
85+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.WorkDurationKey,
86+
Help: "How long in seconds processing an item from workqueue takes.",
87+
Buckets: prometheus.ExponentialBuckets(10e-9, 10, 12),
88+
}, []string{labelName, labelController}),
89+
queueUnfinished: prometheus.NewGaugeVec(prometheus.GaugeOpts{
90+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.UnfinishedWorkKey,
91+
Help: "How many seconds of work has been done that " +
92+
"is in progress and hasn't been observed by work_duration. Large " +
93+
"values indicate stuck threads. One can deduce the number of stuck " +
94+
"threads by observing the rate at which this increases.",
95+
}, []string{labelName, labelController}),
96+
queueLongestRunning: prometheus.NewGaugeVec(prometheus.GaugeOpts{
97+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.LongestRunningProcessorKey,
98+
Help: "How many seconds has the longest running " +
99+
"processor for workqueue been running.",
100+
}, []string{labelName, labelController}),
101+
queueRetries: prometheus.NewCounterVec(prometheus.CounterOpts{
102+
Subsystem: ctrlmetrics.WorkQueueSubsystem, Name: ctrlmetrics.RetriesKey,
103+
Help: "Total number of items added to the workqueue with a non-zero delay (rate-limited requeues, explicit RequeueAfter or AddAfter calls)",
104+
}, []string{labelName, labelController}),
105+
restRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
106+
Name: "rest_client_requests_total",
107+
Help: "Number of HTTP requests, partitioned by status code, method, and host.",
108+
}, []string{"code", "method", "host"}),
109+
leader: prometheus.NewGaugeVec(prometheus.GaugeOpts{
110+
Name: "leader_election_master_status",
111+
Help: "Gauge of if the reporting system is master of the relevant lease, 0 indicates backup, 1 indicates master. 'name' is the string used to identify the lease. Please make sure to group by name.",
112+
}, []string{labelName}),
113+
}
114+
reg.MustRegister(
115+
m.reconcileTotal, m.reconcileErrors, m.reconcilePanics, m.reconcileTime, m.maxConcurrent, m.activeWorkers,
116+
m.queueDepth, m.queueAdds, m.queueDuration, m.workDuration, m.queueUnfinished, m.queueLongestRunning, m.queueRetries,
117+
m.restRequests, m.leader,
118+
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
119+
collectors.NewGoCollector(),
120+
)
121+
return m
122+
}
123+
124+
// initController creates the zero-valued series controller-runtime creates
125+
// when a controller starts, so panels show a flat line instead of no data.
126+
func (m *runtimeMetrics) initController(name string, maxConcurrent int) {
127+
for _, result := range []string{resultError, "requeue_after", "requeue", "success"} {
128+
m.reconcileTotal.WithLabelValues(name, result).Add(0)
129+
}
130+
m.reconcileErrors.WithLabelValues(name).Add(0)
131+
m.reconcilePanics.WithLabelValues(name).Add(0)
132+
m.reconcileTime.WithLabelValues(name)
133+
m.maxConcurrent.WithLabelValues(name).Set(float64(maxConcurrent))
134+
m.activeWorkers.WithLabelValues(name).Set(0)
135+
m.queueDepth.WithLabelValues(name, name, "0").Set(0)
136+
m.queueAdds.WithLabelValues(name, name).Add(0)
137+
m.queueDuration.WithLabelValues(name, name)
138+
m.workDuration.WithLabelValues(name, name)
139+
m.queueUnfinished.WithLabelValues(name, name).Set(0)
140+
m.queueLongestRunning.WithLabelValues(name, name).Set(0)
141+
m.queueRetries.WithLabelValues(name, name).Add(0)
142+
}

0 commit comments

Comments
 (0)