Skip to content

observability: delete SLO rules that query a metric nothing emits; make the canary gate fail closed - #1117

Open
mdheller wants to merge 2 commits into
mainfrom
fix/observability-delivery-plane
Open

observability: delete SLO rules that query a metric nothing emits; make the canary gate fail closed#1117
mdheller wants to merge 2 commits into
mainfrom
fix/observability-delivery-plane

Conversation

@mdheller

Copy link
Copy Markdown
Member

What

Three controls in the observability/delivery plane reported healthy and could not fire, all because they read http_server_request_duration_seconds — a metric with zero emitters estate-wide. This deletes them, re-expresses each over metrics that actually have series, and adds the absent() meta-guard that was missing everywhere. Every expression here was run against the live observability Prometheus before commit, and the canary gate was proved to reject a bad rollout on the live cluster.

Companion to #1105 (storage lane). Same defect class — a control that is health=ok over an empty vector — in a different plane.

The finding, verified not assumed

count(http_server_request_duration_seconds_count)   -> 0 series
HighErrorRatio                                       -> health=ok state=inactive
GET /metrics on compute-gateway, gateway, dashboard-bff,
  hellgraph-service, health-twin, eval-fabric-api    -> HTTP 404  (api: conn refused)

No OpenTelemetry SDK, prometheus_client, promhttp or client_golang in any requirements.txt or any of the four go.mod files. The metric was never emitted, so job:request_error_ratio:rate5m and job:request_latency_p99:5m recorded empty forever, and HighErrorRatio sat inactive — indistinguishable on every dashboard from "services healthy". The recording rule's clamp_min(denominator, 1) made it worse: no-data resolved to a numerically healthy 0.0 rather than a gap.

Decision: delete, not enable

Picked deliberately, not for expedience. metrics.enabled in charts/socioprophet-service gates only the ServiceMonitor — it is scrape-side and never reaches application code. Turning it on aims Prometheus at that confirmed 404 and fires TargetDown: a new false signal, not a real one. A genuine emitter means HTTP middleware in both Python and Go, an OTel SDK added to ~47 services, and every image rebuilt — none of which can be built or verified while Actions is at its spend cap. Shipping enabled: true unverified would rebuild the exact defect being removed.

Coverage lost: none that ever existed. These rules produced no signal from the day they were written. This PR cannot give you a 5xx ratio — that requires the emitter work above, tracked separately — but it replaces "cannot fire" with alerts that do.

What replaces them — all over series confirmed present live

Rule Expression basis Live check
HellGraphDown kube_deployment_status_replicas_available{deployment="hellgraph-service"}==0 was doubly dead: up{job=~".*hellgraph.*"} had no members because the hellgraph ServiceMonitor selected app: hellgraph, a label no Service carries — it produced no target at all, not even a failing one
WorkloadUnavailable 0 available replicas while spec > 0 fired live on a real pre-existing break (arcticdb-gateway) the old plane was blind to
WorkloadCrashLooping restart increase > 3 over 15m fired on the deliberate probe
AlertInputSeriesMissing absent(up) or absent(kube_deployment_status_replicas_available{…}) or absent(kube_pod_container_status_restarts_total{…}) the meta-guard. absent() was used nowhere in the estate

AlertInputSeriesMissing is the primitive that was missing: a threshold alert whose data stops arriving goes quiet and reads as healthy. Verified both directions live — absent() returns nothing over a live metric and 1 over a dead one.

Also unwired, same defect:

  • hellgraph ServiceMonitor removed — selector matches no Service, endpoint 404s. Inert twice over.
  • keda-scaledobject-vllm.yaml.example.yaml. Its trigger read the same dead metric; an empty result sits below activationThreshold, so it could only ever hold the GPU Deployment at 0 replicas — a cost control whose only reachable output is "off". Query corrected to vllm:num_requests_running, three blocking preconditions written down, left unwired because KEDA is not installed and nothing scrapes mesh-vllm.

The canary gate — proved both ways on the live cluster

The Argo Rollouts AnalysisTemplate gated on the same dead recording rules, and had only a failureCondition — so in Rollouts semantics a measurement that isn't a failure scores Successful, i.e. an empty result promoted. Every canary passed, three ways over (no data; wrong polarity; clamp_min).

Rewritten so every metric requires the series to exist before it can pass:

successCondition: len(result) == 1 && result[0] >= 1

&& short-circuits, so an empty result never reaches the threshold term — it fails the condition and fails the analysis. Missing metric aborts the rollout instead of promoting it. Queries read kube-state-metrics (canary-scoped by pod-template-hash), which has series today.

Argo Rollouts was not installed at all when I started (kubectl get rolloutsthe server doesn't have a resource type), so this gate had never been evaluable — it was YAML nobody read while READMEs called it live. I installed the controller via the relocated Application (below), then:

Test B — healthy canary → PROMOTED. AnalysisRun Successful, real measurements canary-available=[1] ×5, canary-not-restarting=[0] ×5. Rollout advanced to 100%, stable revision became 7885b6f6f4.

Test C — deliberately-bad canary (exit 1) → REJECTED and ROLLED BACK. The load-bearing proof:

AnalysisRun zz-canary-proof-6cd9f9bb4c-3-1:  phase=Failed
  canary-available:      Failed  value=[0] (x2)   -> failed(2) > failureLimit(1)
  canary-not-restarting: Failed  value=[8] (x2)
Rollout zz-canary-proof:  phase=Degraded  abort=true
  message: RolloutAborted: Rollout aborted update to revision 3
  stableRS: 7885b6f6f4    (HEALTHY revision — traffic reverted here)
  currentPodHash: 6cd9f9bb4c    (bad revision — abandoned)

Not just a breached threshold in a query: the AnalysisRun Failed, the Rollout aborted, and traffic reverted to the previous healthy ReplicaSet.

Fail-closed on a missing metric (item 3, distinct from Test C's present-but-zero). An isolated AnalysisRun with the same guard shape querying a nonexistent metric:

measurement phase=Failed  value=[]    (empty vector -> len(result)==0 -> successCondition false)
overall AnalysisRun phase=Failed

The old template scored this Successful; the new one fails closed.

All probe objects (rollout, analysis runs, broken deployment, proof PrometheusRule) and the temporarily-installed Rollouts controller/CRDs/namespace were deleted afterwards — the cluster is back to matching main (observability app Synced on main, live socioprophet-slos unchanged, postgres PV/PVC untouched at 50Gi).

Also here: progressive-delivery relocated into the watched tree

argo-rollouts + progressive-delivery-base Applications were authored in infra/argocd/, which the root app-of-apps (infra/tofu/.../argocd.tf, gitops_path=deploy/argocd, recurse) does not watch — the reason there was no controller. Moved to deploy/argocd/progressive-delivery.yaml with socioprophet.io/tier: reference (required by tools/preflight_deploy_contract.py check 5). Cilium deliberately stays in infra/argocd/ — GKE Autopilot already runs Cilium as its dataplane; relocating it would install a second CNI.

Also fixed, caught by server-side apply against the real CRD the moment the controller existed: canary-hash is a Rollout-supplied arg (valueFrom.podTemplateHashValue), invalid on an AnalysisTemplate arg — wrong this whole time, uncatchable because the CRD was never installed.

Not verified / not addressed (deliberate)

mdheller added 2 commits July 30, 2026 01:54
`http_server_request_duration_seconds` has zero emitters and always has. The
two recording rules over it, the HighErrorRatio alert, the Argo Rollouts canary
gate and a KEDA scale-to-zero trigger all consumed it. Every one of them
reported healthy while reading an empty vector.

Verified, not assumed:

  count(http_server_request_duration_seconds_count)  -> 0 series
  HighErrorRatio                                     -> health=ok state=inactive
  GET /metrics on compute-gateway, gateway, dashboard-bff, hellgraph-service,
    health-twin, eval-fabric-api                     -> HTTP 404 (api: refused)

and no OpenTelemetry SDK, prometheus_client, promhttp or client_golang appears
in any requirements.txt or any of the four go.mod files.

DECIDED: delete, not enable.

`metrics.enabled` in charts/socioprophet-service gates only the ServiceMonitor.
It is scrape-side and never reaches application code, so turning it on aims
Prometheus at that 404 and fires TargetDown — a new false signal, not a real
one. A genuine emitter means HTTP middleware in both Python and Go, an OTel SDK
added to ~47 services, and every image rebuilt; Actions is at its spend cap, so
none of that can be built or verified now. Shipping "enabled" unverified
rebuilds the defect being removed.

Coverage lost: none that ever existed. These rules produced no signal from the
day they were written.

What replaces them, all over series confirmed present on the live server:

  HellGraphDown          re-expressed over kube-state-metrics. It was also dead,
                         for a second reason: `up{job=~".*hellgraph.*"}` has no
                         members because the ServiceMonitor meant to create that
                         job selected `app: hellgraph`, a label no Service
                         carries. It produced no target at all, not even a
                         failing one.
  WorkloadUnavailable    deployment at 0 available replicas while spec > 0.
  WorkloadCrashLooping   restart increase over 15m.
  AlertInputSeriesMissing  the meta-guard. absent() over each input every other
                         rule depends on. This is the primitive that was missing
                         estate-wide: a threshold alert whose data stops arriving
                         goes quiet and reads as healthy. Verified both ways —
                         absent() returns nothing over a live metric and 1 over
                         the dead one.

Also removed or unwired, same defect:

  * the `hellgraph` ServiceMonitor — selector matches no Service, and the
    endpoint it names 404s. Inert twice over.
  * keda-scaledobject-vllm.yaml -> .example.yaml. Its trigger read the same dead
    metric; an empty result sits below activationThreshold, so it could only ever
    hold the GPU Deployment at zero replicas. A cost control whose only reachable
    output is "off" is indistinguishable from one that works. Query corrected to
    vllm:num_requests_running, preconditions written down, left unwired because
    KEDA is not installed and nothing scrapes mesh-vllm.
…From

Caught by server-side apply against the real CRD the moment the controller
existed: .spec.args[2].valueFrom.podTemplateHashValue is not in the
AnalysisTemplate schema. It is only valid on a Rollout's analysis args. The
template declares the arg bare; the Rollout sources it. Example Rollout updated
to pass it, since without it the AnalysisRun cannot start.

Worth noting the class: this file sat in the repo for weeks describing itself as
the live canary gate, and no validation anywhere caught the invalid field —
because the CRD was never installed, so nothing could ever have applied it.
Copilot AI review requested due to automatic review settings July 30, 2026 07:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Updates the observability and progressive-delivery controls to avoid “healthy over empty vector” failures by removing SLO rules that queried non-emitted metrics, adding absent-series guards, and making the canary analysis fail closed when metrics are missing.

Changes:

  • Replace request-metric-based SLO recording/alerting with kube-state-metrics-based workload health alerts + an absent() meta-guard.
  • Rewrite the Argo Rollouts AnalysisTemplate to require result existence (len(result) == 1) so canary gating fails closed on missing series.
  • Relocate progressive delivery Argo Applications into the watched deploy/argocd/ tree; strand autoscaling/KEDA resources as examples only.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
infra/k8s/rollouts/base/rollout-hellgraph-service.example.yaml Passes canary pod-template-hash into analysis args to scope Prometheus queries to canary pods.
infra/k8s/rollouts/base/analysistemplate-slo.yaml Replaces dead request-SLO gates with kube-state-metrics-based gates and fail-closed success conditions.
infra/k8s/observability/base/servicemonitor.yaml Removes inert HellGraph ServiceMonitor and documents why it cannot work yet.
infra/k8s/observability/base/prometheusrule-slos.yaml Deletes dead request-SLO rules; adds workload health alerts plus an absent() input-series guard.
infra/k8s/autoscaling/base/kustomization.yaml Unwires autoscaling resources (now explicitly empty) and documents why.
infra/k8s/autoscaling/base/keda-scaledobject-vllm.yaml Removes the previously-wired ScaledObject that depended on a non-existent metric.
infra/k8s/autoscaling/base/keda-scaledobject-vllm.example.yaml Adds an example ScaledObject with corrected vLLM metrics and documented preconditions.
infra/argocd/progressive-delivery.yaml Removes stranded rollouts apps from unwatched tree; leaves mesh app with warning.
deploy/argocd/progressive-delivery.yaml Adds watched-tree Argo Applications for argo-rollouts and rollouts base resources.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +84 to +100
- name: canary-not-restarting
# Catches a canary that comes up, serves, then crashes — readiness alone can
# flap too slowly to catch that inside one analysis window.
initialDelay: 30s
interval: 20s
count: 5
# Fail if p99 latency exceeds 1.5s.
failureCondition: result[0] >= 1.5
failureLimit: 1
consecutiveErrorLimit: 2
successCondition: len(result) == 1 && result[0] <= 2
provider:
prometheus:
address: http://kube-prometheus-stack-prometheus.observability.svc.cluster.local:9090
query: |
job:request_latency_p99:5m{job="{{args.service}}"}
sum(kube_pod_container_status_restarts_total{
namespace="{{args.namespace}}",
pod=~"{{args.service}}-{{args.canary-hash}}-.*"
})
Comment on lines +77 to +85
- alert: WorkloadUnavailable
expr: |
sum by (job) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]))
/
clamp_min(sum by (job) (rate(http_server_request_duration_seconds_count[5m])), 1)
# p99 latency per service — the second canary gate metric.
- record: job:request_latency_p99:5m
expr: |
histogram_quantile(0.99, sum by (job, le) (rate(http_server_request_duration_seconds_bucket[5m])))
- alert: HighErrorRatio
expr: job:request_error_ratio:rate5m > 0.05
kube_deployment_status_replicas_available{namespace="socioprophet"} == 0
and on (namespace, deployment)
kube_deployment_spec_replicas{namespace="socioprophet"} > 0
for: 10m
labels: { severity: critical }
annotations:
summary: "{{ $labels.deployment }} has 0/{{ $labels.spec_replicas }} available replicas for 10m"
Comment on lines +86 to +88
- alert: WorkloadCrashLooping
expr: increase(kube_pod_container_status_restarts_total{namespace=~"socioprophet|observability|serving"}[15m]) > 3
for: 5m
Comment on lines +108 to +112
- alert: AlertInputSeriesMissing
expr: |
absent(up)
or absent(kube_deployment_status_replicas_available{namespace="socioprophet"})
or absent(kube_pod_container_status_restarts_total{namespace="socioprophet"})
Comment on lines +48 to +49
# The Rollout's name — canary pods are named <rollout>-<hash>-<suffix>.
- name: service
@mdheller

Copy link
Copy Markdown
Member Author

Merge-gate disposition (Copilot 3-channel + adversarial pass)

Canary fails closed on a missing metric (verified): successCondition: len(result) == 1 && result[0] >= 1 with only a success condition means an empty/absent series evaluates false → measurement Failed → failureLimit: 1 exceeded → the rollout aborts. sum() over an empty vector yields no series (len == 0), so a metric nothing emits aborts rather than promotes. This is the intended fix.

Acknowledged, non-blocking: {{ $labels.spec_replicas }} renders blank (it's the sample value, not a label — the alert still fires, only the summary number is missing); the AlertInputSeriesMissing meta-guard covers only namespace="socioprophet" while WorkloadCrashLooping spans socioprophet|observability|serving — a narrow blind spot, mitigated since both come from the one kube-state-metrics exporter; the lifetime-restart-counter concern is mitigated by canary-hash pod scoping.

Verdict: MERGE-READY. Ordering: land before #1116 (the other lane owns #1116).

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