Skip to content

feat(observability): ship Grafana dashboards and Prometheus alerts - #192

Merged
sourcehawk merged 21 commits into
mainfrom
feat/dashboards-and-alerts
Aug 25, 2026
Merged

feat(observability): ship Grafana dashboards and Prometheus alerts#192
sourcehawk merged 21 commits into
mainfrom
feat/dashboards-and-alerts

Conversation

@sourcehawk

Copy link
Copy Markdown
Owner

Description

Closes #183

Operators built on the framework now get Grafana dashboards and Prometheus alert rules for the metrics the framework records (#182) and for controller-runtime's own series, including the alert the apply counters were built for: a managed resource rewritten on every reconcile. Everything lives under observability/ as templates rendered with make dashboards|alerts METRIC_NAMESPACE=<ns>, is unit tested with promtool and a Go template lint, and can be inspected on a local Prometheus + Grafana stack fed by a simulator that drives the real pkg/metrics recorder. One expectation is placed on existing operators: the controller name passed to metrics.NewRecorder must equal the controller-runtime controller name (lower-cased kind unless Named overrides it), or the dashboards cannot correlate the framework's series with controller-runtime's; the docs, GoDoc and the repository's own e2e suites now say and do so.

Changes

  • Alert rules: CustomResourceNotReady, CustomResourceConditionUnknown, CustomResourceConditionStuck (per operator, rendered per metric namespace); ManagedResourceNotConverging, ManagedResourceApplyFailing, ControllerReconcileErrors, ControllerReconcilePanics, ControllerWorkqueueBacklog, ControllerReconcileLatencyHigh, OperatorLeaderMissing (shared, installed once per cluster as ocf-managed-resources and ocf-controller-runtime). Ratio thresholds with floors so legitimate churn at scale stays silent; quantile thresholds on real histogram bucket bounds; aggregation by the operator's namespace so two installs alert separately.
  • Two dashboards with metric-namespace-templated uids: an operator overview (reconciliation, workqueue, managed resource applies, condition summary, API client, process, all filtered by one controller variable) and the per-owner conditions browser ported from go-crd-condition-metrics with stale-series protection (topk by (kind, id)), consistent filters, a wired resource variable that the alerts' dashboard_url deep links use, and wildcard All values so cluster-scoped owners stay visible.
  • Render pipeline in the root Makefile (dashboards, alerts, test-alerts, lint-dashboards, observability-up, observability-down) with NAMESPACE_LABEL, ALERT_FORMAT=prometheusrule|rules, PROMETHEUSRULE_NAMESPACE and PROMETHEUSRULE_LABELS; output under the gitignored observability/generated/.
  • Local stack: docker compose Prometheus and Grafana bound to localhost, a Go simulator playing a scripted world (healthy owners, a hot-loop resource, stuck, Unknown, reason-flipping and cluster-scoped owners, failing applies, erroring and slow reconciles, a backlogged workqueue, leader election), and a parity test that starts a real unmanaged controller-runtime controller to assert the simulator's lookalike series match names, labels and buckets.
  • CI: an observability job runs the promtool tests and the template lint.
  • Docs: consumer-facing docs/observability.md (site nav, index, README table, AI instructions, plugin skill reference); observability/README.md as the maintainer guide; docs/component.md and pkg/metrics GoDoc state the controller-name rule.

Related

Testing

make all, make test-alerts (promtool lint under both namespace label variants plus 15 unit test cases including the negative cases: churn at scale and a single edit do not fire ManagedResourceNotConverging, sporadic conflicts do not fire ManagedResourceApplyFailing, a second healthy install does not mask an erroring one, a reason change does not reset the for: clock), make lint-dashboards, go test ./observability/... (parity and scripted-world tests), make docs-build in strict mode, and make sync-plugin plus make ai-instructions leaving the tree clean, all pass on this branch. Every panel of both dashboards and every alert was exercised against live data on the local stack (make observability-up): all 42 panel targets return series, the expected nine alerts fire within minutes with OperatorLeaderMissing firing under -leader=false, the browser's filters and the alert deep links narrow to one owner, and the cluster-scoped owner is visible under All. To poke at it yourself: make observability-up, then open http://localhost:3000 and http://localhost:9090/alerts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JPihvXVfS997iGmGabTGsd

sourcehawk and others added 4 commits August 23, 2026 21:57
…nd 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>
…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>
… 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>
…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>
Copilot AI lite review requested due to automatic review settings August 23, 2026 23:42
@sourcehawk
sourcehawk requested a balanced review from Copilot and removed request for Copilot August 23, 2026 23:43

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

Stale condition series can trigger false alerts, shared rules can merge unrelated operators, and some valid metric namespaces produce rejected Grafana dashboards.

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

Pull request overview

Adds a complete observability package for OCF operators, including dashboards, alerts, rendering, validation, local simulation, and documentation.

Changes:

  • Adds Grafana dashboards and Prometheus alert rules with tests.
  • Adds a local Prometheus/Grafana stack and metrics simulator.
  • Documents rendering, installation, controller naming, and maintenance.
File summaries
File Description
README.md Links observability documentation.
plugin/skills/building-components/SKILL.md Adds observability guidance.
plugin/skills/building-components/references/observability.md Syncs observability documentation.
plugin/skills/building-components/references/component.md Documents controller-name matching.
pkg/metrics/metrics.go Updates recorder GoDoc.
observability/README.md Adds maintainer instructions.
observability/observability_test.go Validates templates and metrics.
observability/dev/simulator/world.go Implements simulated scenarios.
observability/dev/simulator/world_test.go Tests simulator output.
observability/dev/simulator/runtime.go Defines runtime metric lookalikes.
observability/dev/simulator/runtime_test.go Verifies metric parity.
observability/dev/simulator/main.go Runs the simulator endpoint.
observability/dev/prometheus/prometheus.yml Configures local Prometheus.
observability/dev/grafana/provisioning/datasources/prometheus.yaml Provisions Prometheus in Grafana.
observability/dev/grafana/provisioning/dashboards/ocf.yaml Provisions dashboards.
observability/dev/docker-compose.yaml Defines the local stack.
observability/dashboards/crd_conditions_browser.tpl.json Adds the conditions browser.
observability/dashboards/ocf_operator.tpl.json Adds the operator overview.
observability/alerts/tests/managed_resources_test.yaml Tests resource alerts.
observability/alerts/tests/crd_conditions_test.yaml Tests condition alerts.
observability/alerts/tests/controller_runtime_test.yaml Tests runtime alerts.
observability/alerts/managed_resources.yaml Adds managed-resource alerts.
observability/alerts/crd_conditions.tpl.yaml Adds condition alerts.
observability/alerts/controller_runtime.yaml Adds controller-runtime alerts.
mkdocs.yml Adds observability navigation.
Makefile Adds rendering and local-stack targets.
go.mod Promotes the Prometheus model dependency.
e2e/primitives/suite_test.go Aligns recorder controller names.
e2e/component/suite_test.go Aligns recorder controller name.
docs/observability.md Documents the observability package.
docs/index.md Adds an observability card.
docs/component.md Documents controller-name correlation.
.gitignore Ignores rendered artifacts.
.github/workflows/test.yml Adds observability CI checks.
.github/copilot-instructions.md Registers observability guidance.
.ai/base.md Updates generated AI guidance.
Review details

Suppressed comments (2)

observability/alerts/crd_conditions.tpl.yaml:75

  • Filtering to Unknown before max() means an Unknown series from a former leader remains eligible forever, even when the active leader now exports True or False. First choose the freshest series per controller/kind/id/condition across every status, then filter to Unknown; cover recovery while the stale pod continues to be scraped.
          max (
            {{operator_namespace}}controller_condition{status="Unknown"}
          ) by (controller, kind, name, condition, {{namespace_label}})

observability/alerts/crd_conditions.tpl.yaml:132

  • This also filters out the current Ready=True series before stale-series selection. Consequently, any old non-True series still exported by a former leader can make the owner look stuck indefinitely. Select the freshest Ready series across statuses first, and only then test status!="True"; add a stale-non-True/current-True test case.
            max (
              {{operator_namespace}}controller_condition{condition="Ready", status!="True"}
            ) by (controller, kind, name, condition, {{namespace_label}})
  • Files reviewed: 34/36 changed files
  • Comments generated: 4
  • Review effort level: Balanced

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

Comment thread observability/alerts/crd_conditions.tpl.yaml Outdated
Comment thread Makefile Outdated
Comment thread observability/alerts/managed_resources.yaml Outdated
Comment thread observability/alerts/controller_runtime.yaml Outdated
sourcehawk and others added 3 commits August 24, 2026 02:13
…re 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
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
…rafana 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
Copilot AI review requested due to automatic review settings August 24, 2026 00:13
@sourcehawk

Copy link
Copy Markdown
Owner Author

Disposition of the two findings Copilot's review of cf3287c suppressed (no thread to reply in):

  • observability/alerts/crd_conditions.tpl.yaml:75 (CustomResourceConditionUnknown filters to Unknown before max(), so a former leader's stale Unknown series stays eligible): applied in cca1e25. The rule joins on the freshest series per owner and condition across every status first.
  • observability/alerts/crd_conditions.tpl.yaml:132 (CustomResourceConditionStuck filters out Ready=True before selecting the freshest series): applied in cca1e25, same join keyed on (controller, kind, name, condition, <namespace label>). TestStaleFormerLeaderSeriesDoNotFire covers the stale-non-True / current-True case for this rule at the 7h mark.

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

The renderer can retain obsolete artifacts and accepts an unvalidated namespace-label identifier.

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

Review details

Suppressed comments (3)

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

Makefile:249

  • NAMESPACE_LABEL is substituted directly into PromQL as a label identifier, but it is never validated. A typo such as NAMESPACE_LABEL=exported-namespace therefore makes both render targets succeed while producing invalid queries. Validate it against the Prometheus label-name grammar before rendering.
sed -e 's/{{operator_namespace}}/$(2)_/g' -e 's/{{namespace_label}}/$(3)/g' $(1)

Makefile:273

  • The alert output directory is not cleaned before rendering. After a rule file is removed or renamed, its old generated YAML remains and the documented kubectl apply -f observability/generated/alerts/ command continues installing obsolete alerts. Remove generated YAML files before writing the current rule set.
	@mkdir -p $(OBS_OUT)/alerts

Makefile:262

  • This directory is only created, so dashboards removed or renamed by a framework upgrade remain in the gitignored output. The documented --from-file=observability/generated/dashboards/ install then republishes that stale dashboard. Clear generated JSON files before rendering the current template set.
	@mkdir -p $(OBS_OUT)/dashboards
  • Files reviewed: 34/36 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread Makefile Outdated
…, 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
Copilot AI review requested due to automatic review settings August 24, 2026 00:20
@sourcehawk

Copy link
Copy Markdown
Owner Author

Disposition of the three findings Copilot's review of ef05bb8 suppressed (no thread to reply in), all applied in 272c982:

  • Makefile:249 (NAMESPACE_LABEL substituted into PromQL unvalidated): require_metric_namespace now rejects a label that does not match the Prometheus label name grammar ^[A-Za-z_][A-Za-z0-9_]*$, with a message naming it. Documented in the NAMESPACE_LABEL row of docs/observability.md.
  • Makefile:273 (stale rule files survive in generated/alerts/): make alerts removes generated/alerts/*.yaml before rendering.
  • Makefile:262 (stale dashboards survive in generated/dashboards/): make dashboards removes generated/dashboards/*.json before rendering. observability-render-dev relies on the same cleanup instead of its own rm. Documented next to the output layout in docs/observability.md.

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

Equal LastTransitionTime values can leave dashboard reason filtering stale, and the simulator currently masks that behavior.

Review details

Suppressed comments (6)

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

observability/dev/simulator/world.go:242

  • A reason-only update does not change a Kubernetes condition's LastTransitionTime; the framework's meta.SetStatusCondition call preserves it while the status remains False. Resetting flipSince here makes the simulator's Since value and stale-series behavior unlike a real operator, and hides equal-timestamp old/new reason series from local validation. Keep the original transition time when only the reason flips.
			flipSince = time.Now()

observability/dashboards/crd_conditions_browser.tpl.json:115

  • The reason filter is not stale-safe when old and current pods export different reasons with the same LastTransitionTime (the normal case for a reason-only update). topk(1) may select the old reason on the right, causing the current reason's series on the left not to match and making this total incorrect. Use an independent per-pod freshness/leadership signal and cover equal-valued reason series in a dashboard query test.

This issue also appears in the following locations of the same file:

  • line 247
  • line 397
  • line 468
  • line 543
          "expr": "count(\n    {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", status=~\"$status\", reason=~\"$reason\", id=~\"$resource_id\", {{namespace_label}}=~\"$namespace\"} > 0\n  and\n    topk by (id, condition) (1, {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", {{namespace_label}}=~\"$namespace\"})\n)",

observability/dashboards/crd_conditions_browser.tpl.json:247

  • This stale-series join cannot reliably select the current reason. The metric value is LastTransitionTime, and the framework uses meta.SetStatusCondition (pkg/component/conditions.go:161), which preserves that timestamp when only Reason changes. After a leader change, the old and current reason series can therefore tie, so topk(1) may make the table show or filter on the stale reason indefinitely. The dashboard needs an independent freshness/leadership signal, plus a regression case where the two pods have equal metric values.
          "expr": "sum(\n  {{operator_namespace}}controller_condition{\n    kind=\"$kind\",\n    condition=~\"$condition\",\n    status=~\"$status\",\n    reason=~\"$reason\",\n    id=~\"$resource_id\",\n    {{namespace_label}}=~\"$namespace\"\n  }\n  and\n  topk by (id, condition) (1, {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", {{namespace_label}}=~\"$namespace\"})\n) by (name, {{namespace_label}}, condition, status, reason)",

observability/dashboards/crd_conditions_browser.tpl.json:397

  • This reason-filtered count can choose a stale pod's reason because reason-only changes retain LastTransitionTime, leaving topk(1) tied. Selecting a specific current reason can therefore show zero (or the stale reason can remain selected) after failover. Join against an independent freshness/leader signal and test equal-valued old/new reason series.
              "expr": "count(\n    {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", status=\"False\", reason=~\"$reason\", id=~\"$resource_id\", {{namespace_label}}=~\"$namespace\"} > 0\n  and\n    topk by (id, condition) (1, {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", {{namespace_label}}=~\"$namespace\"})\n)",

observability/dashboards/crd_conditions_browser.tpl.json:468

  • This reason-filtered count has the same equal-timestamp failure: a reason-only update does not advance LastTransitionTime, so topk(1) can retain the former leader's reason and reject the current series. Use a separate freshness/leadership signal rather than the transition timestamp for pod deduplication.
              "expr": "count(\n    {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", status=\"Unknown\", reason=~\"$reason\", id=~\"$resource_id\", {{namespace_label}}=~\"$namespace\"} > 0\n  and\n    topk by (id, condition) (1, {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", {{namespace_label}}=~\"$namespace\"})\n)",

observability/dashboards/crd_conditions_browser.tpl.json:543

  • Because reason-only changes preserve LastTransitionTime, the old and current reason series can tie here and topk(1) may select the stale one. A specific reason filter can consequently report the wrong True count after leader failover. Deduplicate using an independent freshness/leader signal and add an equal-timestamp regression.
              "expr": "count(\n    {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", status=\"True\", reason=~\"$reason\", id=~\"$resource_id\", {{namespace_label}}=~\"$namespace\"} > 0\n  and\n    topk by (id, condition) (1, {{operator_namespace}}controller_condition{kind=\"$kind\", condition=~\"$condition\", {{namespace_label}}=~\"$namespace\"})\n)",
  • Files reviewed: 34/36 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@sourcehawk

Copy link
Copy Markdown
Owner Author

Copilot's review of 272c982 posted no comments and suppressed six, all in code unchanged since the first review. Review loop cap (three rounds) reached; these are left open for a design decision rather than addressed here.

  • observability/dashboards/crd_conditions_browser.tpl.json lines 115, 247, 397, 468, 543 (one finding): the topk by (id, condition) (1, ...) stale-series join keys on LastTransitionTime, and meta.SetStatusCondition (which applyStatusCondition uses) preserves that timestamp when only reason changes. After a leader change, a former leader still being scraped can therefore export the previous reason with the same value as the current leader's series, and topk(1) picks one of them arbitrarily, so a reason-filtered panel may show or hide the stale reason. Verified as real, but narrow: it needs a former leader still scraped and a reason-only change since failover. The alert rules are not affected: a tie implies equal status, and every rule drops reason before max(). The suggested fix (dedupe on a leadership signal such as leader_election_master_status joined on the scrape target) needs a fallback for operators that run without leader election, so it is a design choice for a follow-up.
  • observability/dev/simulator/world.go:242: the simulator resets flipSince on a reason-only flip, so its series do not tie the way a real operator's would. Verified; belongs with the same follow-up so the local stack can reproduce the case above.

sourcehawk and others added 3 commits August 25, 2026 12:29
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
…shboard

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

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

Alert wording, metadata validation, and simulator shutdown error handling need correction.

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

Review details

Suppressed comments (2)

observability/alerts/managed_resources.yaml:57

  • The expression fires once the updated share is merely greater than 50%, but this notification says the resource is rewritten on every reconcile. A sustained 51% ratio satisfies the rule and would receive a materially stronger diagnosis than the data supports; describe it as repeatedly/mostly rewritten and update the exact annotation fixtures accordingly.
            {{ $labels.kind }} `{{ $labels.resource }}` of component `{{ $labels.component }}` ({{ $labels.owner_kind }}) is rewritten on every reconcile
          description: |
            Over the last 15 minutes, {{ $value | humanizePercentage }} of the applies of the `{{ $labels.kind }}` resource `{{ $labels.resource }}` in component `{{ $labels.component }}` of owner kind `{{ $labels.owner_kind }}` (controller `{{ $labels.controller }}`) updated the object. In steady state a converged resource applies as `none` on every pass, so a resource whose every apply is an update is being rewritten on every reconcile even though nothing changed.

Makefile:309

  • A malformed label token without = is silently turned into a different valid label: for example, PROMETHEUSRULE_LABELS=release renders release: "release" instead of failing, so the Prometheus Operator may never select the rule. Validate each documented key=value entry before emitting it.
	          for kv in $$(echo "$(PROMETHEUSRULE_LABELS)" | tr ',' ' '); do \
	            echo "    $${kv%%=*}: \"$${kv#*=}\""; \
	          done; \
  • Files reviewed: 36/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread observability/dev/simulator/main.go
Copilot AI review requested due to automatic review settings August 25, 2026 10:32

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

Render validation and condition-simulation accuracy still have unresolved correctness issues.

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

Review details
  • Files reviewed: 36/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

… 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
Copilot AI review requested due to automatic review settings August 25, 2026 10:42

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

Condition alerts and the browser merge separate operator installations, and one accepted metric namespace produces an invalid PrometheusRule name.

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

Review details

Suppressed comments (4)

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

observability/dashboards/crd_conditions_browser.tpl.json:590

  • The browser has no job variable, so all of its variables and panels query every installation exposing this metric namespace, unlike the job-scoped operator overview. When two installs have the same kind/id, the stale-series topk can select the other install, causing alert and overview deep links to show the wrong owner state. Add a job selector to the browser, scope all queries by it, and pass var-job from both link producers.
        "definition": "label_values({{operator_namespace}}controller_condition,kind)",

Makefile:250

  • METRIC_NAMESPACE=_demo passes this validation, but the default alert render turns its rule name into -demo-crd-conditions; Kubernetes rejects that metadata.name because it starts with -. Either derive a DNS-safe PrometheusRule name for leading-underscore metric namespaces or reject that input, and keep the documented grammar/tests in sync.
@echo "$(METRIC_NAMESPACE)" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]{0,16}$$' || { \
	echo "Error: METRIC_NAMESPACE '$(METRIC_NAMESPACE)' is not renderable."; \
	echo "It must match ^[A-Za-z_][A-Za-z0-9_]*$$ and be at most 17 characters, so that the"; \
	echo "dashboard uid <namespace>_crd_conditions_browser fits Grafana's 40 character limit."; \
	exit 1; \

observability/alerts/crd_conditions.tpl.yaml:91

  • This topk and final aggregation merge identical condition identities across jobs. A newer non-Unknown series from another installation can therefore suppress CustomResourceConditionUnknown, and simultaneous alerts lose the installation identity. Retain a stable install label such as job in both grouping clauses and cover two jobs with conflicting statuses.
            topk by (controller, kind, name, condition, {{namespace_label}}) (
              1, {{operator_namespace}}controller_condition
            )
          ) by (controller, kind, name, condition, {{namespace_label}})

observability/alerts/crd_conditions.tpl.yaml:152

  • The stuck-condition selection also omits the scrape job/install identity. For the same owner identity in two installations, a newer Ready=True series can win topk and suppress the genuinely stuck series, or two stuck installs can collapse into one alert. Keep a stable install label in both aggregations and add a cross-job test.
              topk by (controller, kind, name, condition, {{namespace_label}}) (
                1, {{operator_namespace}}controller_condition{condition="Ready"}
              )
            ) by (controller, kind, name, condition, {{namespace_label}})
  • Files reviewed: 36/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread observability/alerts/crd_conditions.tpl.yaml Outdated
…ce 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
Copilot AI review requested due to automatic review settings August 25, 2026 11:17
sourcehawk and others added 4 commits August 25, 2026 13:22
…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
…eNotConverging

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
…rt 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
…e 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
@sourcehawk

Copy link
Copy Markdown
Owner Author

Disposition of the findings the reviews of 02156c5 through 0843ddb suppressed (no thread to reply in):

  • observability/alerts/managed_resources.yaml:57 (summary overstates a 51% ratio): applied in 681f260, same change as the posted comment on line 55.
  • Makefile:309 (label entry without = rendered as key: "key"): applied in aeedca7, same change as the posted comment.
  • Makefile:250 (_demo renders an invalid PrometheusRule name): applied in aeedca7, same change as the posted comment on line 249.
  • observability/alerts/crd_conditions.tpl.yaml:91 and :152 (Unknown and Stuck rules merge installs): applied in c6c1257 together with the posted comment on line 42; job is in every grouping of all three rules.
  • observability/dashboards/crd_conditions_browser.tpl.json:590 (browser has no job selector): applied in c6c1257. The browser gained an Operator (scrape job) variable, All by default so existing links keep working, every query is scoped by it, and both link producers (the operator dashboard's CRs not Ready table and the condition alerts' dashboard_url) pass var-job.

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

Stale reason selection remains nondeterministic, simulator scenarios lack coverage, and shutdown failures are hidden.

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

Review details

Suppressed comments (1)

observability/dev/simulator/main.go:72

  • Shutdown can return an error (notably when its timeout expires), but the simulator currently reports success and hides that failure. Preserve the existing serve-error precedence, then return a wrapped shutdown error so make observability-up fails visibly when the server did not shut down cleanly.
	shutdownErr := srv.Shutdown(shutdown)
  • Files reviewed: 36/38 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread observability/dashboards/crd_conditions_browser.tpl.json Outdated
Comment thread observability/dashboards/ocf_operator.tpl.json Outdated
Comment thread observability/dev/simulator/world.go
Copilot AI review requested due to automatic review settings August 25, 2026 11:24

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

Namespace-label handling can misfilter dashboard conditions and merge alerts across operator installations.

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

Review details
  • Files reviewed: 36/38 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread observability/dashboards/ocf_operator.tpl.json Outdated
Comment thread observability/alerts/crd_conditions.tpl.yaml
…very 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
Copilot AI review requested due to automatic review settings August 25, 2026 12:23
@sourcehawk

Copy link
Copy Markdown
Owner Author

The review of 3608130 (stale head) suppressed one finding, observability/dev/simulator/main.go:72 (Shutdown error discarded): already applied in 09490e5.

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 public Make-based render pipeline lacks automated end-to-end coverage for its validation and output branches.

Review details

Suppressed comments (1)

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

Makefile:241

  • The public render pipeline is not exercised by an automated test. observability_test.go reimplements only the two sed substitutions, so regressions in the required-value/grammar checks, ALERT_FORMAT branches, PrometheusRule wrapping, or metadata options can still pass CI. Add integration coverage that invokes make dashboards and make alerts for valid and invalid inputs, both namespace-label modes, both alert formats, and optional metadata.
define require_metric_namespace
  • Files reviewed: 36/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@sourcehawk
sourcehawk merged commit b1e8bbc into main Aug 25, 2026
8 checks passed
@sourcehawk
sourcehawk deleted the feat/dashboards-and-alerts branch August 25, 2026 12:49
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.

Ship Grafana dashboards and Prometheus alerts for operators built on the framework

2 participants