Application Patterns
Every homegrown app follows the same shape so monitoring, ingress, and TLS come for free.
The base/web-app contract
A web app drops into base/web-app/ and gets:
base/web-app/
├── deployment.yaml # one container at port 8080 named `http`
├── service.yaml # ClusterIP, port 80 → targetPort http
├── ingress.yaml # nginx, with cert-manager annotation
├── hpa.yaml # HorizontalPodAutoscaler
├── servicemonitor.yaml # ← auto-scrapes /metrics on port http
└── kustomization.yamlThe base has no namespace, no image tag, no environment-specific values. Overlays patch all three. A new app inherits the metric scrape config and the TLS-terminated ingress without writing either file.
ServiceMonitor convention
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: web-app
labels:
release: kube-prometheus-stack # required — operator selects on this
spec:
selector:
matchLabels:
app.kubernetes.io/name: web-app
endpoints:
- port: http # matches the Service's named port
path: /metrics
interval: 30sTwo rules that have bitten us:
release: kube-prometheus-stackis mandatory. The Prometheus Operator selects ServiceMonitors by this label; without it the SM is silently ignored.- The
selectormatches the Service’s labels, not its name. See the argocd selector gotcha in the Observability chapter.
components/observability
A Kustomize Component that annotates every Deployment with
prometheus.io/{scrape,port,path} so legacy bare-Prometheus or future
scrape-discovery agents (Alloy in prometheus.scrape mode) also pick the
app up.
# Include in an overlay:
components:
- ../../components/observabilityBelt-and-suspenders next to the ServiceMonitor — the operator picks up the SM, but anything scrape-config-based reads the pod annotations.
Instrumenting an app
Use the /instrument Claude Code slash command in any app repo. It:
- Detects the language (Node, Python, Go, Rust).
- Adds the Prometheus client library.
- Wires up the standard metrics:
http_requests_total(counter; labels: method, path, status_code)http_request_duration_seconds(histogram; buckets 0.01–5s)app_info(gauge=1; version, commit, service)
- Replaces unstructured logging with structured JSON.
- Adds OTel auto-instrumentation with stdout exporter (so traces are already shaped for Tempo if/when it’s deployed).
The metric names are load-bearing — the generic Grafana dashboard
(infrastructure/monitoring/kube-prometheus-stack/dashboards/app-template.yaml)
queries those strings literally. Don’t rename them per app.
Path label cardinality
Use the route template (/users/:id), not the raw URL
(/users/12345), as the path label on metrics. Raw URLs explode
cardinality and force Prometheus into series-count panic mode.
Logging convention
{"timestamp":"2026-05-21T03:00:00.123Z","level":"error",
"message":"DB connection failed","service":"user-api",
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}Required keys: timestamp (ISO-8601 UTC), level, message,
service. Optional but recommended: trace_id for log-to-trace
correlation once Tempo lands.
Alloy auto-detects JSON and parses it into Loki labels. The
{namespace="…", app="…"} | json | level="error" LogQL pattern Just
Works.
Migration off the homelab
Because every app uses Prometheus client libraries (which remote_write
to AMP, GCP managed Prometheus, or Azure Monitor managed Prometheus
unchanged), and structured JSON logs (which any log aggregator can
ingest), moving an app to a cloud-managed observability stack is a
config change — not a re-instrumentation. See
docs/cloud-provider-migration.md.