Post

State of DevOps: Operations and Observability

Running State of DevOps: job and data-freshness alerts, health probes, runbooks, and the HPA design that turns the single dashboard replica into two to three.

State of DevOps: Operations and Observability

Operating State of DevOps is mostly about one question asked every morning: did last night’s jobs run, and is today’s data complete? The dashboard can be perfectly healthy while showing yesterday’s numbers, and nobody notices until someone quotes them in a meeting. So observability here starts with data freshness, not CPU graphs. It ends with fixing the one known availability gap: the dashboard runs as a single replica.

Application and infrastructure monitoring

Three layers to watch, in order of how often they cause trouble:

LayerSignals
JobsLast successful run per CronJob, run duration against its schedule window, failed Job pods, BackoffLimitExceeded
DataToday’s folder exists per domain and org, generated_at in metadata.json is today, home-overview.json written after 07:00 UTC
DashboardPod ready, request latency and 5xx rate, Redis hit rate, Azure Files read latency

Kubernetes already exposes the job signals (kube-state-metrics has kube_cronjob_status_last_successful_time and kube_job_status_failed). The data signals need a small check, which fits naturally in the summary generator since it reads everything anyway.

TODO: What monitoring stack covers this cluster today (Azure Monitor Container Insights, Prometheus and Grafana, something else)?

Logging and log retention

Every component logs through the shared setup_logging, which is the right place to enforce structure: JSON lines with component, org, run_date and, for the API, path and status. With that, “show me everything the repo analyzer logged about org X last night” is one query.

Retention follows two needs: short for debugging (30 days of application logs is plenty) and longer for audit-relevant events (who created which Jira story, deployment history), which should follow the organization’s audit log policy.

Metrics and dashboards

An operations dashboard for the tool itself (not to be confused with the tool) needs only a few panels:

  • A grid of CronJobs × last 7 days, green or red per run.
  • Job duration per run, with the schedule window drawn as a line.
  • Data freshness per domain: hours since last generated_at.
  • Dashboard API p95 latency and error rate; replicas over time once the HPA is on.
  • GitHub API rate limit remaining per GitHub App installation.

The last panel is the early warning for most job failures: when an App runs out of budget, every job using it fails at once.

Distributed tracing

Tracing has limited value here. Requests are short (browser → FastAPI → Redis or Azure Files), and the jobs are batch processes, not chains of services. OpenTelemetry auto-instrumentation for FastAPI is cheap to add and would show where slow requests spend their time (Redis miss versus file reads), but I’d do it after the job and data alerts, not before.

Health checks

The dashboard has GET /api/health for its probes. Two refinements make it more useful:

  • Keep the liveness probe dumb (the process responds), so a slow file share doesn’t restart pods.
  • Make readiness check its dependencies lightly: Redis reachable, /data mounted and readable. A pod that can’t read the share shouldn’t receive traffic, and with two or more replicas the others keep serving.

For the jobs, the “health check” is the CronJob status plus the data freshness check above.

Alerting and escalation

AlertConditionSeverityGoes to
Job failedAny daily CronJob’s last run failedWarningDevOps Teams channel
Data stalehome-overview.json not written by 08:00 UTCHighDevOps Teams channel + owner
Dashboard downNo ready pods for 5 minutesHighDevOps on-call during business hours
Rate limit lowAn App below 10% of its hourly budgetWarningDevOps Teams channel
Backup missingNo share snapshot in 26 hoursWarningDevOps Teams channel

Escalation stays inside the DevOps team: this is a business-hours service, and nothing here justifies waking anyone up.

TODO: Which alerts exist today, and where do they go?

Availability and performance targets

The targets agreed with stakeholders:

  • Data freshness: today’s data available by 08:00 UTC on 95% of days.
  • Dashboard availability: 99.5% during business hours.
  • Performance: Home under 1 second (one pre-computed file); analysis tabs under 3 seconds at p95 with a warm cache.

The single replica makes the availability target hard to meet: every node drain, AKS upgrade or pod reschedule is an outage of a minute or two. That’s the main reason for the autoscaling change below, more than load.

Operational runbooks

The three runbooks used most:

1
2
3
4
5
6
7
8
9
10
# 1. Re-run a failed job for today
kubectl create job --from=cronjob/devops-repo-analyzer rerun-$(date +%s) -n cronjobs
kubectl logs -f job/rerun-<id> -n cronjobs

# 2. Regenerate the Home page after a re-run
kubectl create job --from=cronjob/devops-summary-generator resum-$(date +%s) -n cronjobs

# 3. Check what's on the share for today
kubectl exec -n state-of-devops deploy/devops-state-dashboard -- \
  ls /data/devops-state/summaries/$(date -u +%Y/%m/%d)/

Each should become a task (for example task rerun -- devops-repo-analyzer), in line with the project’s rule that every operation goes through the Taskfile. More runbooks are listed in Documentation.

Incident and problem management

Incidents here are rarely outages; they’re wrong or missing numbers. The incident process should treat them that way: when a number is wrong, first put a note on the dashboard (or hide the tab through /api/sidebar-config), then fix, re-run the affected days, and remove the note.

Problem management is where the recurring causes go: rate-limit exhaustion, the clock-based schedule letting trends read stale input, a GitHub API change. Each recurrence should end in a change to the design, not just another re-run.

Capacity and cost monitoring

Dashboard autoscaling. The chart already has the values, but no template uses them:

1
2
3
4
5
6
7
# values.yaml today
replicaCount: 1
autoscaling:
  enabled: false
  minReplicas: 1
  maxReplicas: 3
  targetCPUUtilizationPercentage: 80

The change is a HorizontalPodAutoscaler template, a PodDisruptionBudget, and different defaults:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# devops-state-dashboard/helm/templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "devops-state-dashboard.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "devops-state-dashboard.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "devops-state-dashboard.fullname" . }}
spec:
  minAvailable: 1
  selector:
    matchLabels:
      {{- include "devops-state-dashboard.selectorLabels" . | nindent 6 }}
{{- end }}
1
2
3
4
5
6
# values.yaml (AKS)
autoscaling:
  enabled: true
  minReplicas: 2        # 2, not 1: the point is surviving node drains
  maxReplicas: 3
  targetCPUUtilizationPercentage: 70

The Deployment template should also skip replicas: when autoscaling is enabled (otherwise every ArgoCD sync fights the HPA), and spread pods across nodes with a topologySpreadConstraints on kubernetes.io/hostname. The helper names above follow the usual Helm scaffold; match them to the chart’s _helpers.tpl.

Three things make this safe for this app specifically:

  • Shared cache. Redis is Azure Cache for Redis, outside the pods, so every replica sees the same cache.
  • Shared data. The Azure Files volume is ReadWriteMany, so extra pods mount it with no storage changes. The one write path to watch is the dormant-users upload, which two pods could race on.
  • Cache warmer. It runs on each pod’s startup, so scaling to three warms three times. The work is idempotent and Redis absorbs it, but a short Redis lock (SET warm:<date> NX EX 600) avoids the duplicate load.

Keep values-local.yaml at enabled: false with one replica, since k3d gains nothing from it.

Cost. The cost analyzer already reports GitHub, JFrog and Azure spend for the organization. The project’s own cost (job pods, one to three dashboard pods, the share, Redis) should appear in the same view, tagged as its own line.

Checklist

  • Alerts on job failure, stale data and dashboard down, each fired once on purpose
  • Data freshness check written by the summary generator and shown on Home
  • Structured JSON logs with component, org and run date
  • Readiness probe checks Redis and the /data mount; liveness stays simple
  • HPA and PDB templates added; autoscaling.enabled: true, min 2, max 3 in AKS values
  • Deployment omits replicas: when autoscaling is on; pods spread across nodes
  • Cache warmer guarded with a Redis lock
  • Re-run and regenerate runbooks available as tasks

This post is licensed under CC BY 4.0 by the author.