feat: export Prometheus metrics for access request usage - #461
Draft
dnguy078 wants to merge 1 commit into
Draft
Conversation
Oz previously exported only controller-runtime's built-in metrics, which describe how hard the operator is working but say nothing about how it is being used. Add metrics that answer "who is requesting access, through which template, and how long did it take to become usable". Attributing usage to a person requires persisting the requester: the identity is only visible inside an admission webhook, and by the time a request reaches the reconciler the "who" has been lost. The mutating defaulter now stamps it as the crds.wizardofoz.co/requested-by annotation. It is set from authoritative API server data on CREATE only (the reconciler itself issues Updates to set owner references, which would otherwise rewrite it to the controller's service account), always overwrites any client-supplied value so it cannot be spoofed, and is rejected from being modified on update so it stays trustworthy. This also makes access auditable with plain kubectl. Counters and histograms are incremented on the code path where the event happens. Point-in-time gauges cannot work that way - a controller never reliably observes a resource disappearing, so an incrementally maintained GaugeVec accumulates stale label sets forever. Those are instead computed by a prometheus.Collector that lists from the manager's informer cache at scrape time, which costs no API calls and cannot go stale. The `user` label is opt-in via --metrics-include-user-label, reported as "redacted" otherwise. Usernames are frequently email addresses, and metrics tend to be far more widely readable than the cluster itself; they also multiply the series count of the affected metrics by the number of distinct people using Oz. The annotation is populated regardless. Dry-run admissions are not counted. The API server runs the full webhook chain for `kubectl apply --dry-run=server` but persists nothing, so counting those would report access that was never granted. Also add the ServiceMonitor to the Helm chart. It existed only under config/prometheus, so chart installs had a live metrics endpoint with nothing scraping it. Two existing specs replaced the annotations map wholesale during an update, which the new immutability check rejects. They now add to the existing map, and assert that wiping the annotation is refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage Report for CI Build 30595958740Coverage increased (+5.0%) to 40.873%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Oz exports only controller-runtime's built-in metrics today. Those tell you how hard the operator is working (reconcile counts, webhook latencies, workqueue depths) but nothing about how it is being used — there is no way to answer "who is requesting access, through which template, and how long did it take to become usable".
Metrics
oz_access_request_created_totalkind,namespace,template,useroz_access_request_requested_duration_secondskind,namespace,templateoz_access_request_ready_secondskind,namespace,templateoz_access_request_terminated_totalkind,namespace,template,reasonoz_access_request_condition_errors_totalkind,namespace,template,conditionoz_access_requestskind,namespace,template,user,readyoz_access_templateskind,namespace,name,readyoz_pod_exec_totalnamespace,subresource,user,interactiveoz_access_request_ready_secondsmeasures creation →Status.Ready, so for aPodAccessRequestit covers the whole spin-up: reading the target workload's PodSpec, applying the mutation config, creating the Pod/Role/RoleBinding, and waiting for the Pod to reachRunning+PodReady.AccessResourcesAreReadypolls the Pod every second inside a single reconcile, so resolution is ~1–2s (1s poll, plusCreationTimestampbeing second-truncated) rather than the 5s requeue interval. Past 30s the blocking loop times out and resolution degrades to roughly ±5s.oz_access_request_condition_errors_totalis per reconcile attempt, keyed on the existingRequestConditionTypes. A request wedged on a missing template keeps incrementing it — deliberately, since it turns an otherwise silent requeue loop into a rate you can alert on.Design notes
Persisting the requester. The identity is only visible inside an admission webhook; by the time a request reaches the reconciler the "who" is gone. The mutating defaulter now stamps
crds.wizardofoz.co/requested-by:Updates to set owner references. Stamping on update would rewrite every request to the controller's own service account.ValidateUpdate. Otherwise anyone with update access could disown or misattribute their request, making the annotation worthless for reporting.This also makes access auditable without Prometheus at all:
kubectl get podaccessrequests -A \ -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,TEMPLATE:.spec.templateName,USER:'.metadata.annotations.crds\.wizardofoz\.co/requested-by'Gauges are collected, not maintained. A controller never reliably observes a resource disappearing, so an incrementally-updated
GaugeVecaccumulates stale label sets forever.internal/metrics/collectorinstead implementsprometheus.Collectorand lists from the manager's informer cache at scrape time — no API calls, and the series cannot go stale. A listing failure surfaces as a scrape error rather than a silent zero, which would otherwise look identical to "nobody is using Oz". It is a separate package purely to break an import cycle (v1alpha1 imports metrics; the collector imports v1alpha1).Dry-run is not counted. The API server runs the full webhook chain for
kubectl apply --dry-run=serverbut persists nothing, so counting those would inflate usage with grants that never happened.The
userlabel is opt-in (--metrics-include-user-label, chartmetrics.includeUserLabel), reported asredactedotherwise. Usernames are frequently email addresses, and metrics are typically far more widely readable than the cluster itself; they also multiply the series count of the affected metrics by the number of distinct people using Oz. The annotation is populated regardless of this setting, so nothing durable is lost by leaving it off.Chart
Adds the ServiceMonitor, which previously existed only under
config/prometheus/— chart installs had a live metrics endpoint with nothing scraping it. Off by default (metrics.serviceMonitor.create) since it needs the Prometheus Operator CRDs. The scraping ServiceAccount needs the-metrics-readerClusterRole bound to it or every scrape returns 403; that is called out in the template and the README.Behavior change worth reviewing
Two existing specs did
request.SetAnnotations(map[string]string{"foo": "bar"}), replacing the map wholesale and wipingrequested-by. The new immutability check rejects that, so they now add to the existing map, plus new specs assert the wipe is refused.Normal read-modify-write flows (
kubectl edit,kubectl annotate, controller-runtime patches) preserve annotations and are unaffected. Pre-existing requests have no annotation, soold == new == ""and they update fine. But tooling that reconstructs metadata wholesale will now get a 403.Verification
golangci-lint,revive,gofumpt,go vetandhelm lintall clean. New specs cover the label gate,Kind(), annotation set/get/immutability, the dry-run guard, and the collector (aggregation, per-label separation, redaction collapsing distinct users into one series, both kinds, and read-failure surfacing as a scrape error).A real HTTP scrape of the registry after driving the webhook and collector paths returns 200 / 157 metric families, 8 of them
oz_*:Two gaps I would rather name than paper over:
ready_seconds,terminated_total,condition_errors_total) are wired by inspection and the scrape above, not by assertion. Covering them properly means driving envtest through a full grant-then-expire cycle. The webhook and collector paths are asserted.internal/testing/e2ewas not run — it needsbin/kustomizeand a live cluster, and fails identically on unmodifiedmainin this environment. The TLS/authn layer and the ServiceMonitor actually reaching the pod are consequently unverified.Note for operators
Both gauges are computed per replica, so with
controllerManager.replicas > 1aggregate them withmax by (...)rather thansum by (...). Counters and histograms are unaffected — only the leader reconciles, and each webhook call is handled once. Documented in the README.🤖 Generated with Claude Code