Skip to content

feat(observability): add a local Prometheus and Grafana stack fed by a metrics simulator - #189

Merged
sourcehawk merged 11 commits into
feat/dashboards-and-alertsfrom
feat/dashboards-and-alerts--dev-stack
Aug 23, 2026
Merged

feat(observability): add a local Prometheus and Grafana stack fed by a metrics simulator#189
sourcehawk merged 11 commits into
feat/dashboards-and-alertsfrom
feat/dashboards-and-alerts--dev-stack

Conversation

@sourcehawk

Copy link
Copy Markdown
Owner

Towards #185

Summary

Adds a local Prometheus + Grafana stack so the alert rules (and, once #186 lands, the dashboards) can be watched against realistic data.

  • Compose stack (observability/dev/): prom/prometheus:v3.14.0 on :9090 and grafana/grafana:13.1.4 on :3000 (anonymous admin). Prometheus loads the dev-rendered rules and scrapes the simulator on the host with static target labels namespace: operators, pod: demo-operator-0, so the condition gauge's own namespace label arrives as exported_namespace, exactly as a ServiceMonitor scrape does in a cluster. Grafana provisions the Prometheus datasource (uid prometheus) and a file provider over observability/generated/dev/dashboards, which is empty until Add the operator overview and conditions browser Grafana dashboards #186.
  • Dev render (make observability-render-dev): renders dashboards and plain-format rules for metric namespace demo into observability/generated/dev/, then shortens every for: to 2m so alerts fire within minutes. make observability-up renders, starts the containers and runs the simulator in the foreground; make observability-down stops the containers. SIMULATOR_ARGS passes extra flags, e.g. SIMULATOR_ARGS="-leader=false".
  • Simulator (observability/dev/simulator/): a Go binary serving /metrics with the real pkg/metrics recorder and ocm.NewOperatorConditionsGauge("demo") for the framework series, plus hand-built controller-runtime, workqueue, REST client and leader election lookalikes. Flags: -listen (:8080), -metric-namespace (demo), -leader (true).

The scripted world, scenario by scenario:

Scenario Controller What it plays Alert it exercises
healthyWebApps webapp 50 owners across team-a..team-e, all converged, Ready=True none (control group)
hotLoop webapp webapp-01's configmap rewritten 4x/s (operation="updated") ManagedResourceNotConverging
databases database shop/orders-db Ready=False for 8h; shop/users-db Ready=Unknown; analytics/reports-db Ready=False with the reason flipping between Failing and Creating; PVC applies failing 3 of 4; 30% reconcile errors; p99 reconcile duration above 30s CustomResourceConditionStuck, CustomResourceNotReady, CustomResourceConditionUnknown, ManagedResourceApplyFailing, ControllerReconcileErrors, ControllerReconcileLatencyHigh
workqueueBacklog database queue depth 30-50, queue wait observations of 200-600s ControllerWorkqueueBacklog
panics database one panic a minute in, then every 20 minutes ControllerReconcilePanics
-leader=false - leader_election_master_status 0 OperatorLeaderMissing
  • Parity test (runtime_test.go): starts a real unmanaged controller-runtime controller, drives one request through its queue, and asserts every lookalike family exists in controller-runtime's registry with the same type, label names and histogram buckets. rest_client_* and leader_election_* are skipped when absent from the real registry, since client-go and the leader elector create them lazily and neither runs without an API server.

Verification

  • make all and go test ./observability/... pass, including the parity test (TestRuntimeMetricsMatchControllerRuntime PASS).
  • make observability-up, then:
    • curl -s localhost:9090/api/v1/targets | jq '.data.activeTargets[].health' returned "up".

    • curl -s localhost:9090/api/v1/rules | jq '[.data.groups[].name]' returned ["controller-runtime","crd-conditions","managed-resources"].

    • curl -s localhost:9090/api/v1/query --data-urlencode 'query=demo_controller_condition{condition="Ready",status="False"}' returned series with exported_namespace "analytics" and "shop", proving the target-label collision renames the gauge's namespace label the way a ServiceMonitor scrape does.

    • Within ten minutes the firing set was exactly the nine expected alerts:

      ["ControllerReconcileErrors","ControllerReconcileLatencyHigh","ControllerReconcilePanics",
       "ControllerWorkqueueBacklog","CustomResourceConditionStuck","CustomResourceConditionUnknown",
       "CustomResourceNotReady","ManagedResourceApplyFailing","ManagedResourceNotConverging"]
      
    • The only firing alert with controller="webapp" was ManagedResourceNotConverging; everything else carried controller="database".

    • Rerunning the simulator with -leader=false made OperatorLeaderMissing fire ({name="demo-operator", namespace="operators"}) after its shortened for: clock.

  • Grafana answered /api/health with "ok" while the provisioned dashboards directory was still empty.
  • make observability-down removed both containers and the network.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

sourcehawk and others added 4 commits August 23, 2026 22:03
…okalikes with a parity test (#185)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd
…erator world (#185)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd
…k fed by the simulator (#185)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a few verified issues that should be fixed before approval (misleading docs reference, scenario comment mismatch, and safer localhost-only port bindings for an anonymous-admin Grafana dev stack).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a local observability development stack (Prometheus + Grafana) plus a Go-based metrics simulator to generate realistic controller-runtime and framework metrics for validating alert rules (and future dashboards) without a live cluster.

Changes:

  • Added a host-run simulator (observability/dev/simulator/) that emits framework metrics (via pkg/metrics) and controller-runtime/workqueue lookalike families guarded by a parity test.
  • Added a docker-compose based local Prometheus+Grafana setup under observability/dev/ wired to scrape the simulator and load dev-rendered rules/dashboards.
  • Added Makefile targets to render dev assets and run/stop the local stack; updated Go module deps for the new test.
File summaries
File Description
observability/dev/simulator/world.go сценарий-генератор “мира” (webapp/database) для условий, apply-метрик и runtime-метрик
observability/dev/simulator/runtime.go определение lookalike метрик controller-runtime/workqueue/rest_client/leader_election
observability/dev/simulator/runtime_test.go parity-тест, сверяющий семейства метрик с реальным controller-runtime registry
observability/dev/simulator/main.go HTTP /metrics сервер и запуск сценариев симулятора
observability/dev/prometheus/prometheus.yml конфигурация Prometheus для scrape симулятора и загрузки rule_files
observability/dev/grafana/provisioning/datasources/prometheus.yaml provisioning datasource Prometheus (uid prometheus)
observability/dev/grafana/provisioning/dashboards/ocf.yaml provisioning file-provider для dashboards каталога
observability/dev/docker-compose.yaml compose стек Prometheus+Grafana для локальной разработки
Makefile цели observability-render-dev, observability-up, observability-down и dev-render логика
go.mod добавлена прямая зависимость github.com/prometheus/client_model для тестов
Review details

Suppressed comments (1)

observability/dev/docker-compose.yaml:20

  • Grafana is configured for anonymous admin access; publishing it on all interfaces can expose an admin UI beyond localhost. Binding the port to 127.0.0.1 keeps the convenience while reducing risk.
    ports:
      - "3000:3000"
  • Files reviewed: 10/10 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread observability/dev/simulator/main.go
Comment thread observability/dev/simulator/world.go Outdated
Comment thread observability/dev/docker-compose.yaml Outdated
Comment thread observability/dev/simulator/world.go Outdated
@sourcehawk

Copy link
Copy Markdown
Owner Author

Review round 1, disposition of the suppressed finding:

  • observability/dev/docker-compose.yaml:20, anonymous-admin Grafana published on all interfaces: applied in 36f1de0, both Grafana and Prometheus now bind to 127.0.0.1 only.

@sourcehawk
sourcehawk requested a lite review from Copilot August 23, 2026 20:28
@sourcehawk
sourcehawk requested a balanced review from Copilot and removed request for Copilot August 23, 2026 20:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The active-worker metric is effectively unsampleable, and the scripted scenario behavior lacks automated coverage.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

observability/dev/simulator/world.go:74

  • activeWorkers is reset to zero immediately after recording a synthetic duration; no work is actually held for duration. Since Prometheus scrapes every five seconds, this gauge will almost always report zero even while the simulator is generating continuous reconciles, so the active-workers dashboard signal is misleading. Model a stable/concurrent worker count (or keep workers active for simulated intervals) rather than emitting an effectively unsampleable pulse.
	c.rt.activeWorkers.WithLabelValues(c.name).Set(0)

observability/dev/simulator/world.go:144

  • The only new test validates the copied runtime metric schemas; none of the scenario behavior started here is exercised. Regressions in the promised labels, condition ages, error/apply ratios, leader state, or cancellation behavior would therefore pass CI. Add registry-based tests for the scripted scenarios, ideally with injectable clocks/tickers so the success, failure, and shutdown paths run deterministically.
	go w.healthyWebApps(ctx)
	go w.hotLoop(ctx)
	go w.databases(ctx)
	go w.workqueueBacklog(ctx)
	go w.panics(ctx)
  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

sourcehawk and others added 5 commits August 23, 2026 23:29
…he scripted world (#185)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd
…me and drop the seeded rng (#185)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd
@sourcehawk

Copy link
Copy Markdown
Owner Author

Review round 2, disposition of the suppressed findings:

  • world.go:74, activeWorkers pulse unsampleable: applied in 9744296. The per-reconcile Set(1)/Set(0) pair is gone; the workers gauge is now held persistently (database saturated at its max, webapp varying 0 to 2), so scrapes observe the load the other series imply.
  • world.go:144, no automated coverage of the scripted scenarios: applied, scaled to the tool's purpose, in 9744296. A registry-based smoke test (TestWorldScriptedScenarios) starts the world and asserts the promised series: the stuck orders-db Ready=False condition at least seven hours old, pvc apply errors, the hot-loop configmap updates rising, reconcile totals for both controllers, the leader gauge, and clean shutdown on context cancel. Injectable clocks were deliberately not added; the simulator is a dev tool and require.Eventually against the first scenario ticks is enough signal.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Rerendering can disconnect live bind mounts, startup failures are masked, and the simulator test is nondeterministic and incomplete.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread Makefile Outdated
Comment thread Makefile Outdated
Comment thread observability/dev/simulator/world.go Outdated
Comment thread observability/dev/simulator/main.go
Comment thread observability/dev/simulator/world_test.go
…deterministic (#185)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd
@sourcehawk
sourcehawk merged commit 4f67035 into feat/dashboards-and-alerts Aug 23, 2026
7 checks passed
@sourcehawk
sourcehawk deleted the feat/dashboards-and-alerts--dev-stack branch August 23, 2026 21:58
sourcehawk added a commit that referenced this pull request Aug 25, 2026
)

* feat(observability): add the alert render pipeline, alert templates and promtool tests (#188)

* feat(observability): add the alert render pipeline and the condition alerts (#184)

Add an Observability section to the root Makefile with the alerts,
dashboards and test-alerts targets, port the condition alert rules and
their promtool unit tests from go-crd-condition-metrics, and ignore the
rendered output under observability/generated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): alert on managed resources that never converge or fail to apply (#184)

Add ManagedResourceNotConverging and ManagedResourceApplyFailing on the
ocf_resource_apply_total and ocf_resource_apply_errors_total counters.
Both are ratios of the resource's own applies with an absolute floor,
keyed on the operator's static topology, so legitimate churn at scale
and sporadic conflicts stay silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): alert on controller-runtime errors, panics, backlog, latency and leadership (#184)

Add ControllerReconcileErrors, ControllerReconcilePanics,
ControllerWorkqueueBacklog, ControllerReconcileLatencyHigh and
OperatorLeaderMissing on the controller-runtime metrics every operator
exposes, with thresholds expressed as ratios or quantiles so they hold
at any scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* ci: run the alert unit tests with promtool (#184)

Add an observability job to the test workflow that installs a pinned
promtool and runs make test-alerts on every push and pull request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* docs(observability): explain why the workqueue rule aggregates by controller (#184)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): make the latency and backlog thresholds reachable and alert per install (#184)

controller_runtime_reconcile_time_seconds has 60 as its largest finite
bucket and histogram_quantile caps there, so a threshold of 60 could
never fire; lower it to 30, a bucket bound. The workqueue histogram has
one bucket per decade, so move the backlog threshold to 100 seconds and
say in the description that the p99 value is interpolated. Add the
operator's namespace to every aggregation so two installs of one
operator in a cluster are alerted on separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): correct the leader and apply failure quick checks and simplify the failure ratio (#184)

A lease name cannot be combined with the all-namespaces flag, so the
leader quick check uses a field selector. The framework records no event
for a failed apply, so the apply failure quick check lists the owners'
Ready conditions instead. Write the failure ratio as errors over
(errors + applies) or errors, and say that OperatorLeaderMissing is also
silent when no replica is alive to export the gauge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): install the shared rules once per cluster and set PrometheusRule metadata (#184)

The controller-runtime and managed resource rules carry no metric
namespace placeholder, so rendering them per operator installed a
duplicate copy per operator. Keep per-operator templates as
<name>.tpl.yaml, named <metric-namespace>-<name>, and ship the shared
rules as plain <name>.yaml files, copied through unchanged and named
ocf-<name>. Rename ocf_resources to managed_resources (group
managed-resources) so the object is ocf-managed-resources. Add
PROMETHEUSRULE_NAMESPACE and PROMETHEUSRULE_LABELS for ruleSelectors
such as kube-prometheus-stack's release label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): quote PrometheusRule label values (#184)

Kubernetes label values must be strings, so emit them quoted; otherwise
a value such as release=1 would be parsed as an integer.

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>

* 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>

* feat(observability): add the operator overview and conditions browser dashboards (#190)

* feat(observability): add the conditions browser dashboard with stale-series protection (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): add the operator overview dashboard (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* test(observability): check rendered dashboards and alerts reference real metrics (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): make dashboard filters and freshness keys consistent (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): template the dashboard uids per metric namespace (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): browser namespace handling for cluster-scoped owners and instant stats (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* test(observability): tighten the template lint (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* test(observability): exercise a cluster-scoped owner in the simulator world (#186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): correct the status stat descriptions in the browser (#186)

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>

* docs(observability): document the dashboards, alerts and local stack (#191)

* docs(observability): document the dashboards, alerts and local stack (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* docs(component): align the recorder's controller name with controller-runtime (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* chore(plugin): sync docs/observability.md into the building-components skill (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* chore(ai): reference docs/observability.md in the AI instructions (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* docs(observability): correct CI, leader and Unknown-tile statements and dedupe the apply rationale (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* docs(metrics): align GoDoc, e2e recorder names and the plugin skill with the controller-name rule (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* test(e2e): give each primitives controller its own metrics recorder (#187)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* docs(observability): keep maintainer content in observability/README.md (#187)

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>

* fix(observability): join condition alerts on the freshest series before matching status

A former leader pod keeps exporting its last condition values until it restarts, so its stale False or Unknown series
kept CustomResourceNotReady, CustomResourceConditionUnknown and CustomResourceConditionStuck firing even after the
current leader reported the owner as Ready. max() only ever saw the series that survived the status matcher.

Every rule now joins on the freshest series per owner across every status first, the same topk join the dashboards
use, and applies its status matcher after. Adds a promtool test with continuously scraped stale and current series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): key the shared alert rules on the scrape job

The controller-runtime and managed-resource rules aggregated by namespace and controller only, so two operators
scraped as different jobs in one namespace with a matching controller name (and, for the apply counters, matching
topology labels) merged into one ratio, where a healthy operator dilutes a failing one below the threshold.

Every aggregation now keeps job as well. OperatorLeaderMissing is unchanged: it keys on the lease name, which is
unique within a namespace. Adds same-namespace, different-job tests for the reconcile error and both apply ratios.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): reject metric namespaces that render an invalid Grafana uid

The dashboard uids are <METRIC_NAMESPACE>_<file name>, and Grafana limits a uid to 40 characters of [A-Za-z0-9_-].
Rendering accepted any nonempty namespace, so a long one, or one with a colon, rendered cleanly and was rejected by
Grafana at import. require_metric_namespace now enforces metric name characters and a 17 character cap, the room the
longest file name leaves, and observability_test.go pins that arithmetic to the dashboard file names.

Also carries the documentation for the previous two rule changes: the freshest-series join and job keying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): validate NAMESPACE_LABEL, drop the unset sentinel, clean render output

METRIC_NAMESPACE defaulted to the sentinel "unset", which reserved a namespace the documented grammar allows; it now
defaults to empty and the check tests for that. NAMESPACE_LABEL is substituted into PromQL as a label name, so it is
now validated against the Prometheus label name grammar instead of rendering invalid queries on a typo. Both render
targets remove the files they previously wrote before rendering, so a dashboard or rule file dropped or renamed by a
framework upgrade is not installed again from the gitignored output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): give the operator overview room and graph the updated rate

The Overview row squeezed nine instant-value stat tiles into one line, so titles truncated and nothing showed a
trend. It is now two lines: four stats with sparklines over the dashboard range (reconciles/s, error ratio, p99
reconcile time, p99 queue wait), then one owners panel with Ready, not Ready > 2m and Unknown as three coloured tiles,
leader status and workers. Updated rate per resource was a table snapshot of the metric whose point is its trend; it is
now a timeseries with a legend table sorted by last value, threshold lines at 0.05 and 0.5 and a zero floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): draw container requests and limits on the process panels

The CPU and Memory RSS panels join kube_pod_container_resource_requests and _limits to the operator's scrape target
on (namespace, pod) and draw them as dashed lines, absent when kube-state-metrics is not scraped. The simulator serves
lookalikes of both families on /ksm/metrics, scraped by the dev Prometheus as its own job with honor_labels the way
kube-state-metrics' ServiceMonitor is, so the local stack shows the lines. The template lint test now knows both
families and `up`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): order the operator dashboard by priority and rebuild the conditions row

Rows now follow what an on-call reader asks first: Overview, Conditions, Managed resources, Reconciliation,
Workqueue, API client, Process. The conditions row no longer grows with kinds times condition types times statuses:
a stacked timeseries of owners by Ready status over time, a bar gauge of unhealthy conditions only (owners per kind,
condition and status for every condition that is not True, empty when healthy), and the Owners not Ready table at
full width. Updated rate per resource takes the full width with its legend table below the graph, the per-resource
ratio panels sit beneath it, and every ratio panel is pinned to 0 to 100% with a dashed threshold line so the alert
bound is always visible. Timeseries panels grow from 8 to 9 rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): drop the Last calc from the Ready status legend

The Overview tiles already carry the current counts, and the calc turned each legend entry into "Ready Last *: 53".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* refactor(observability): say CRs instead of owners in the operator dashboard

The framework calls the reconciled custom resource the owner, and the term leaked into panel titles and descriptions
where a dashboard reader has no reason to know it. Titles and descriptions now say CRs; the docs gloss the mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* docs(observability): name the panel that joins on condition correctly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): give reconcile rate by result the full dashboard width

It is the reconciliation row's most useful panel, so it now spans the row at 11 units high; the error ratio,
latency and panics sit beneath it, then in-progress age and workers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* feat(observability): scope the operator dashboard by operator namespace and scrape job

Every controller-runtime operator exports the same metric names, so the dashboard selects one operator through the
scrape labels. It keyed on job alone, while the shared alert rules key on (namespace, job); two installs of one
operator in different namespaces, which usually share a job name, merged on the dashboard. A namespace variable now
leads the cascade (All also matches series without the label, so an operator scraped outside a cluster still renders),
every selector carries it, and the job variable is labelled as the operator's scrape job with a description. Docs
explain the variables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): require a leading letter in METRIC_NAMESPACE and key=value rule labels

A leading underscore passed the grammar but rendered the PrometheusRule name -demo-crd-conditions, which Kubernetes
rejects; the namespace must now start with a letter. A PROMETHEUSRULE_LABELS entry without = rendered as key: "key"
instead of failing, so a typo silently produced a rule the operator's selector never matches; it is now an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): say most reconciles, not every, in ManagedResourceNotConverging

The rule fires above a 50% updated share, so "rewritten on every reconcile" overstated a 51% ratio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(simulator): keep lastTransitionTime on reason-only flips and report shutdown errors

A reason-only change keeps lastTransitionTime under meta.SetStatusCondition, so the simulator no longer resets it and
now produces the equal-valued reason series a real operator does. Shutdown errors are returned instead of discarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): keep the scrape job in the condition rules and the browser

Two installs of an operator exporting the same metric namespace and the same CR identity deduped and merged across
each other: the condition rules' freshest-series join and aggregation carried no install identity, and the browser
had no job variable, so the operator dashboard's and the alerts' deep links could land on the other install. The
rules now keep job in every grouping, the browser has a job variable (All by default) that every query and both
link producers carry, and a cross-job test covers the case. The reason-change fixture keeps lastTransitionTime the
way a real reason-only update does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

* fix(observability): scope condition panels by job only and exercise every simulator scenario

With NAMESPACE_LABEL=namespace the namespace label on condition series is the CR's, so the operator-namespace
variable misfiltered every condition panel; those panels are now scoped by job alone, and the docs say why job is
the whole install identity the condition rules need. The simulator's backlog and panic scenario timers are fields
the scripted-world test shortens, and it now asserts the queue gauges, wait histogram, busy workers and the panic
counter. The equal-timestamp reason tie after a leader change is documented as a known limitation.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants