Files
infra-observability/Makefile
T
Stéphane Tailland eb35419ad0 Split log search into access, application and audit dashboards
One consolidated search compromised on all three questions at once. It also
had a real defect: the Source selector carried `.+` as its All value, so
choosing "All" widened the query back to every stream in the cluster and the
scope preset only ever populated the picker. MongoDB and cert-manager showed up
in what was meant to be an application view.

Each query now carries both `job=~"${source:regex}"` and a literal job matcher,
so the selector actually restricts rather than widens.

The three kinds are subtracted from each other rather than merely filtered:
application logs exclude Keycloak's access-log and events loggers by name, the
Django container's NCSA lines by shape, and the Kong containers' stray access
lines by requiring the nginx date prefix.

Levels are read from the line rather than taken from Loki's detected_level.
That inference marks 78% of the Kong gateway output `unknown` even though every
one of those lines states [notice] or [warn], so filtering on it hid most of
the gateway's own logs. Each parser extracts the level and lowercases it, which
puts nginx's notice, Keycloak's INFO and LibreChat's info on one scale — and
removes the need for an `unknown` entry in the selector.

Applies move to server-side. Client-side apply stores a full copy of each
object in the last-applied-configuration annotation, capped at 256KB, and the
dashboard ConfigMap passed that at eight dashboards; the failure surfaced as
"metadata.annotations: Too long". Server-side apply tracks field ownership in
metadata instead, moving the ceiling to etcd's ~1MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:48:11 +02:00

362 lines
18 KiB
Makefile

# =============================================================================
# infra-observability -- Grafana / Prometheus / Loki / Tempo stack (+ Alloy)
# Fully declarative: kustomize + kubectl. No imperative `helm install`.
#
# make -> help
# make deploy -> deploy everything
# make diag -> full diagnostics
# =============================================================================
SHELL := /bin/bash
.DEFAULT_GOAL := help
# Every target prints its own output, so commands are not echoed.
.SILENT:
NS ?= observability
CTX ?= orbstack
KUBECTL := kubectl --context=$(CTX)
K8S := k8s
DASHBOARDS := $(K8S)/grafana/dashboards
INGRESS_IP ?= 192.168.139.101
HOSTS := grafana.sttlab.local prometheus.sttlab.local alloy.sttlab.local
# Components backed by a PVC (this is also the flush order)
STATEFUL := prometheus loki tempo grafana
# Restartable workloads
WORKLOADS := deployment/prometheus deployment/loki deployment/tempo deployment/grafana daemonset/alloy
C_OK := \033[0;32m
C_WARN := \033[0;33m
C_ERR := \033[0;31m
C_HEAD := \033[1;36m
C_OFF := \033[0m
# -----------------------------------------------------------------------------
# Help
# -----------------------------------------------------------------------------
.PHONY: help
help: ## Show this help
printf "$(C_HEAD)infra-observability$(C_OFF) -- namespace $(C_OK)$(NS)$(C_OFF) / context $(C_OK)$(CTX)$(C_OFF)\n\n"
awk 'BEGIN {FS = ":.*?## "} \
/^[a-zA-Z0-9_-]+:.*?## / {printf " $(C_OK)%-24s$(C_OFF) %s\n", $$1, $$2} \
/^##@/ {printf "\n$(C_HEAD)%s$(C_OFF)\n", substr($$0, 5)}' $(MAKEFILE_LIST)
printf "\n"
##@ Deployment
.PHONY: deploy
deploy: check-context secret ## Deploy the whole stack (runtimes + dashboards)
# --server-side for the same reason as the dashboards target below.
$(KUBECTL) apply -k $(K8S) --server-side --force-conflicts
$(MAKE) --no-print-directory wait
.PHONY: deploy-runtime
deploy-runtime: check-context secret ## Deploy runtimes only (no dashboards)
$(KUBECTL) apply -f $(K8S)/namespace.yaml -f $(K8S)/secret.yaml
$(KUBECTL) apply -k $(K8S)/prometheus
$(KUBECTL) apply -k $(K8S)/loki
$(KUBECTL) apply -k $(K8S)/tempo
$(KUBECTL) apply -k $(K8S)/alloy
$(KUBECTL) apply -k $(K8S)/grafana
$(KUBECTL) apply -k $(K8S)/ingress
$(MAKE) --no-print-directory wait
.PHONY: prometheus loki tempo grafana alloy ingress
prometheus: check-context ## Deploy/update Prometheus only
$(KUBECTL) apply -k $(K8S)/prometheus
loki: check-context ## Deploy/update Loki only
$(KUBECTL) apply -k $(K8S)/loki
tempo: check-context ## Deploy/update Tempo only
$(KUBECTL) apply -k $(K8S)/tempo
grafana: check-context ## Deploy/update the Grafana runtime only (datasources included)
$(KUBECTL) apply -k $(K8S)/grafana
alloy: check-context ## Deploy/update Alloy only (log collection)
$(KUBECTL) apply -k $(K8S)/alloy
ingress: check-context ## Deploy/update the Ingresses and the certificate
$(KUBECTL) apply -k $(K8S)/ingress
.PHONY: secret
secret: ## Generate k8s/secret.yaml (random admin password) if absent
if [ ! -f $(K8S)/secret.yaml ]; then \
pw=$$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 20); \
sed -e '1s|.*|# Generated by `make secret` -- NOT versioned (see .gitignore).|' \
-e "s|<CHANGE_ME>|$$pw|" $(K8S)/secret.example.yaml > $(K8S)/secret.yaml; \
printf "$(C_OK)secret.yaml generated$(C_OFF) -- admin password: $$pw\n"; \
else \
printf "secret.yaml already present (use make grafana-password to read it)\n"; \
fi
##@ Dashboards (declarative, stable UIDs)
.PHONY: dashboards
dashboards: check-context dashboards-check ## Apply dashboards only (no Grafana restart)
# Server-side apply: client-side apply stores a full copy of the object in the
# kubectl.kubernetes.io/last-applied-configuration annotation, which is capped at
# 256KB. The dashboards passed that in aggregate. Server-side apply tracks field
# ownership in metadata instead, so the ceiling becomes etcd's ~1MB per object.
$(KUBECTL) apply -k $(DASHBOARDS) --server-side --force-conflicts
printf "$(C_OK)grafana-dashboards ConfigMap updated$(C_OFF) -- visible in Grafana within ~70s\n"
printf " (kubelet volume sync ~60s + provisioner poll 10s, no restart).\n"
printf " For immediate effect: $(C_WARN)make restart-grafana$(C_OFF)\n"
.PHONY: dashboards-sync
dashboards-sync: ## Regenerate the .json list in dashboards/kustomization.yaml
cd $(DASHBOARDS) && python3 -c "import glob,re,pathlib; \
p=pathlib.Path('kustomization.yaml'); s=p.read_text(); \
files=sorted(glob.glob('*.json')); \
block=''.join(' - %s\n' % f for f in files); \
s=re.sub(r'(# >>> dashboards.*?>>>\n).*?( # <<< dashboards <<<)', lambda m: m.group(1)+block+m.group(2), s, flags=re.S); \
p.write_text(s); print('%d dashboard(s) referenced' % len(files))"
$(MAKE) --no-print-directory dashboards-check
.PHONY: dashboards-check
dashboards-check: ## Validate the JSON files and UID uniqueness/stability
fail=0; \
for f in $(DASHBOARDS)/*.json; do \
jq -e . "$$f" >/dev/null 2>&1 || { printf "$(C_ERR)invalid JSON$(C_OFF): %s\n" "$$f"; fail=1; continue; }; \
uid=$$(jq -r '.uid // empty' "$$f"); \
[ -n "$$uid" ] || { printf "$(C_ERR)missing uid$(C_OFF): %s\n" "$$f"; fail=1; }; \
base=$$(basename "$$f" .json); \
[ "$$uid" = "$$base" ] || printf "$(C_WARN)filename != uid$(C_OFF): %s (uid=%s)\n" "$$f" "$$uid"; \
[ "$$(jq -r '.id // "null"' "$$f")" = "null" ] || printf "$(C_WARN)non-null id field$(C_OFF): %s (remove it)\n" "$$f"; \
grep -q -- "- $$base.json" $(DASHBOARDS)/kustomization.yaml || { printf "$(C_ERR)not referenced in kustomization.yaml$(C_OFF): %s (run make dashboards-sync)\n" "$$f"; fail=1; }; \
done; \
dup=$$(jq -r '.uid' $(DASHBOARDS)/*.json | sort | uniq -d); \
[ -z "$$dup" ] || { printf "$(C_ERR)duplicate UIDs$(C_OFF): %s\n" "$$dup"; fail=1; }; \
[ $$fail -eq 0 ] && printf "$(C_OK)dashboards OK$(C_OFF) (%s)\n" "$$(ls $(DASHBOARDS)/*.json | wc -l | tr -d ' ')"; \
exit $$fail
.PHONY: dashboards-list
dashboards-list: ## List the dashboards actually loaded by Grafana
auth="$$($(KUBECTL) -n $(NS) get secret observability-secrets -o jsonpath='{.data.GF_SECURITY_ADMIN_USER}' | base64 -d):$$($(KUBECTL) -n $(NS) get secret observability-secrets -o jsonpath='{.data.GF_SECURITY_ADMIN_PASSWORD}' | base64 -d)"; \
$(KUBECTL) -n $(NS) exec deployment/grafana -- curl -s -u "$$auth" 'http://localhost:3000/api/search?type=dash-db' \
| jq -r '.[] | "\(.uid)\t\(.folderTitle // "General")\t\(.title)"'
##@ Diagnostics
.PHONY: status
status: ## Overview (pods, services, PVCs, ingresses)
$(KUBECTL) -n $(NS) get pods -o wide
echo
$(KUBECTL) -n $(NS) get svc,pvc,ingress
.PHONY: diag
diag: ## Full diagnostics: state, PVCs, endpoints, certificate, events
printf "$(C_HEAD)== Pods ==$(C_OFF)\n"; $(KUBECTL) -n $(NS) get pods -o wide
printf "\n$(C_HEAD)== PVCs ==$(C_OFF)\n"; $(KUBECTL) -n $(NS) get pvc
printf "\n$(C_HEAD)== Endpoints ==$(C_OFF)\n"; $(KUBECTL) -n $(NS) get endpoints
printf "\n$(C_HEAD)== Ingresses ==$(C_OFF)\n"; $(KUBECTL) -n $(NS) get ingress
printf "\n$(C_HEAD)== Certificate ==$(C_OFF)\n";$(KUBECTL) -n $(NS) get certificate 2>/dev/null || echo "(cert-manager absent)"
printf "\n$(C_HEAD)== ConfigMaps ==$(C_OFF)\n";$(KUBECTL) -n $(NS) get cm
printf "\n$(C_HEAD)== Containers not ready ==$(C_OFF)\n"
$(KUBECTL) -n $(NS) get pods -o json | jq -r '.items[] | select(.status.containerStatuses != null) | .status.containerStatuses[] | select(.ready == false) | "\(.name)\trestarts=\(.restartCount)\t\(.state | keys[0]): \(.state[keys[0]].reason // "-")"' || true
printf "\n$(C_HEAD)== LibreChat scrape token ==$(C_OFF)\n"
$(MAKE) --no-print-directory check-metrics-token || true
printf "\n$(C_HEAD)== Last 15 events ==$(C_OFF)\n"
$(KUBECTL) -n $(NS) get events --sort-by=.lastTimestamp | tail -15
.PHONY: smoke
smoke: ## End-to-end checks: component health, datasources, dashboards, targets
# The Loki and Tempo images ship neither curl nor wget, so probes run from the
# Grafana pod, which has curl and sees the same Services as the datasources.
probe() { $(KUBECTL) -n $(NS) exec deployment/grafana -- curl -s --max-time 10 "$$1"; }; \
auth="$$($(KUBECTL) -n $(NS) get secret observability-secrets -o jsonpath='{.data.GF_SECURITY_ADMIN_USER}' | base64 -d):$$($(KUBECTL) -n $(NS) get secret observability-secrets -o jsonpath='{.data.GF_SECURITY_ADMIN_PASSWORD}' | base64 -d)"; \
printf "$(C_HEAD)== Component health ==$(C_OFF)\n"; \
printf " %-12s %s\n" "prometheus" "$$(probe http://prometheus:9090/-/ready)"; \
printf " %-12s %s\n" "loki" "$$(probe http://loki:3100/ready)"; \
printf " %-12s %s\n" "tempo" "$$(probe http://tempo:3200/ready)"; \
printf " %-12s %s\n" "grafana" "$$(probe http://localhost:3000/api/health | jq -c .)"; \
printf " %-12s %s\n" "alloy" "$$(probe http://alloy:12345/-/ready)"; \
printf "\n$(C_HEAD)== Grafana datasources ==$(C_OFF)\n"; \
for uid in prometheus loki tempo; do \
r=$$($(KUBECTL) -n $(NS) exec deployment/grafana -- curl -s -u "$$auth" -X POST "http://localhost:3000/api/datasources/uid/$$uid/health" | jq -r '"\(.status) \(.message)"'); \
printf " %-12s %s\n" "$$uid" "$$r"; \
done; \
printf "\n$(C_HEAD)== Provisioned dashboards ==$(C_OFF)\n"; \
$(KUBECTL) -n $(NS) exec deployment/grafana -- curl -s -u "$$auth" 'http://localhost:3000/api/search?type=dash-db' \
| jq -r '.[] | " \(.uid) \(.folderTitle // "General")/\(.title)"'; \
printf "\n$(C_HEAD)== Namespaces seen by Loki ==$(C_OFF)\n"; \
probe 'http://loki:3100/loki/api/v1/label/namespace/values' | jq -r '" " + (.data // [] | join(", "))'; \
printf "\n$(C_HEAD)== Prometheus targets ==$(C_OFF)\n"; \
probe http://prometheus:9090/api/v1/targets | jq -r '.data.activeTargets | group_by(.health)[] | " \(.[0].health)\t\(length)"'
.PHONY: targets
targets: ## List Prometheus scrape targets and their state
$(KUBECTL) -n $(NS) exec deployment/prometheus -- wget -qO- http://localhost:9090/api/v1/targets \
| jq -r '.data.activeTargets[] | "\(.health)\t\(.labels.job)\t\(.scrapeUrl)\t\(.lastError // "")"' | sort
.PHONY: check-metrics-token
check-metrics-token: ## Check the LibreChat token matches across both namespaces
a=$$($(KUBECTL) -n $(NS) get secret librechat-metrics -o jsonpath='{.data.token}' 2>/dev/null | base64 -d); \
b=$$($(KUBECTL) -n librechat get secret librechat-secrets -o jsonpath='{.data.METRICS_SECRET}' 2>/dev/null | base64 -d); \
if [ -z "$$a" ] || [ -z "$$b" ]; then \
printf "$(C_WARN)token not found$(C_OFF) (observability/librechat-metrics=%s, librechat/librechat-secrets=%s)\n" "$${a:+present}$${a:-absent}" "$${b:+present}$${b:-absent}"; \
elif [ "$$a" = "$$b" ]; then \
printf "$(C_OK)LibreChat token matches$(C_OFF) across observability and librechat\n"; \
else \
printf "$(C_ERR)DRIFT$(C_OFF): the two Secrets hold different values.\n"; \
printf " The librechat scrape will fall back to 401. Realign both copies.\n"; \
exit 1; \
fi
.PHONY: logs-prometheus logs-loki logs-tempo logs-grafana logs-alloy
logs-prometheus: ## Follow Prometheus logs
$(KUBECTL) -n $(NS) logs -f deployment/prometheus --tail=100
logs-loki: ## Follow Loki logs
$(KUBECTL) -n $(NS) logs -f deployment/loki --tail=100
logs-tempo: ## Follow Tempo logs
$(KUBECTL) -n $(NS) logs -f deployment/tempo --tail=100
logs-grafana: ## Follow Grafana logs
$(KUBECTL) -n $(NS) logs -f deployment/grafana --tail=100
logs-alloy: ## Follow Alloy logs
$(KUBECTL) -n $(NS) logs -f daemonset/alloy --tail=100
.PHONY: events
events: ## Namespace events, oldest to newest
$(KUBECTL) -n $(NS) get events --sort-by=.lastTimestamp
.PHONY: top
top: ## Pod CPU/memory usage
$(KUBECTL) -n $(NS) top pods 2>/dev/null || echo "metrics-server unavailable"
.PHONY: urls
urls: ## Show access URLs
printf " Grafana https://grafana.sttlab.local\n"
printf " Prometheus https://prometheus.sttlab.local\n"
printf " Alloy https://alloy.sttlab.local\n"
printf "\n In-cluster: http://prometheus:9090 - http://loki:3100 - http://tempo:3200\n"
printf " OTLP : tempo.$(NS).svc.cluster.local:4317 (gRPC) / :4318 (HTTP)\n"
.PHONY: hosts
hosts: ## Show the required /etc/hosts entries
printf "# Add to /etc/hosts:\n"
for h in $(HOSTS); do printf "$(INGRESS_IP)\t%s\n" "$$h"; done
.PHONY: grafana-password
grafana-password: ## Show the Grafana admin credentials
printf "user : %s\n" "$$($(KUBECTL) -n $(NS) get secret observability-secrets -o jsonpath='{.data.GF_SECURITY_ADMIN_USER}' | base64 -d)"
printf "password : %s\n" "$$($(KUBECTL) -n $(NS) get secret observability-secrets -o jsonpath='{.data.GF_SECURITY_ADMIN_PASSWORD}' | base64 -d)"
.PHONY: pf-grafana pf-prometheus pf-loki pf-tempo pf-alloy
pf-grafana: ## Port-forward Grafana on :3000
$(KUBECTL) -n $(NS) port-forward svc/grafana 3000:3000
pf-prometheus: ## Port-forward Prometheus on :9090
$(KUBECTL) -n $(NS) port-forward svc/prometheus 9090:9090
pf-loki: ## Port-forward Loki on :3100
$(KUBECTL) -n $(NS) port-forward svc/loki 3100:3100
pf-tempo: ## Port-forward Tempo on :3200
$(KUBECTL) -n $(NS) port-forward svc/tempo 3200:3200
pf-alloy: ## Port-forward the Alloy UI on :12345
$(KUBECTL) -n $(NS) port-forward svc/alloy 12345:12345
##@ Operations
.PHONY: wait
wait: ## Wait for every workload to become ready
for w in $(WORKLOADS); do \
printf " %-24s " "$$w"; \
$(KUBECTL) -n $(NS) rollout status $$w --timeout=300s 2>&1 | tail -1; \
done
.PHONY: restart
restart: ## Restart every workload
$(KUBECTL) -n $(NS) rollout restart $(WORKLOADS)
$(MAKE) --no-print-directory wait
.PHONY: restart-prometheus restart-loki restart-tempo restart-grafana restart-alloy
restart-prometheus: ## Restart Prometheus
$(KUBECTL) -n $(NS) rollout restart deployment/prometheus && $(KUBECTL) -n $(NS) rollout status deployment/prometheus
restart-loki: ## Restart Loki
$(KUBECTL) -n $(NS) rollout restart deployment/loki && $(KUBECTL) -n $(NS) rollout status deployment/loki
restart-tempo: ## Restart Tempo
$(KUBECTL) -n $(NS) rollout restart deployment/tempo && $(KUBECTL) -n $(NS) rollout status deployment/tempo
restart-grafana: ## Restart Grafana (dashboards picked up immediately)
$(KUBECTL) -n $(NS) rollout restart deployment/grafana && $(KUBECTL) -n $(NS) rollout status deployment/grafana
restart-alloy: ## Restart Alloy
$(KUBECTL) -n $(NS) rollout restart daemonset/alloy && $(KUBECTL) -n $(NS) rollout status daemonset/alloy
.PHONY: reload-prometheus
reload-prometheus: ## Hot-reload the Prometheus config (no restart)
$(KUBECTL) -n $(NS) exec deployment/prometheus -- wget -q --post-data='' -O- http://localhost:9090/-/reload \
&& printf "$(C_OK)Prometheus configuration reloaded$(C_OFF)\n"
##@ Persistence
.PHONY: pvc
pvc: ## PVC state and the volumes behind them
$(KUBECTL) -n $(NS) get pvc -o custom-columns='NAME:.metadata.name,STATUS:.status.phase,CAPACITY:.status.capacity.storage,SC:.spec.storageClassName,VOLUME:.spec.volumeName'
echo
$(KUBECTL) -n $(NS) get pods -o json | jq -r '.items[] | . as $$p | (.spec.volumes[]? | select(.persistentVolumeClaim) | "\($$p.metadata.name)\t-> \(.persistentVolumeClaim.claimName)")'
.PHONY: flush-pvc
flush-pvc: ## DESTRUCTIVE: wipe all persisted data (CONFIRM=yes)
$(call confirm,Wipe ALL data (metrics, logs, traces, Grafana))
for c in $(STATEFUL); do $(MAKE) --no-print-directory _flush COMP=$$c; done
$(MAKE) --no-print-directory wait
.PHONY: flush-prometheus flush-loki flush-tempo flush-grafana
flush-prometheus: ## DESTRUCTIVE: wipe the Prometheus TSDB (CONFIRM=yes)
$(call confirm,Wipe the Prometheus TSDB)
$(MAKE) --no-print-directory _flush COMP=prometheus
flush-loki: ## DESTRUCTIVE: wipe the Loki logs (CONFIRM=yes)
$(call confirm,Wipe the Loki logs)
$(MAKE) --no-print-directory _flush COMP=loki
flush-tempo: ## DESTRUCTIVE: wipe the Tempo traces (CONFIRM=yes)
$(call confirm,Wipe the Tempo traces)
$(MAKE) --no-print-directory _flush COMP=tempo
flush-grafana: ## DESTRUCTIVE: wipe the Grafana database (CONFIRM=yes)
$(call confirm,Wipe the Grafana database (users and prefs; dashboards are reprovisioned))
$(MAKE) --no-print-directory _flush COMP=grafana
# Internal target: scale down -> delete PVC -> recreate -> scale up.
.PHONY: _flush
_flush:
printf "$(C_WARN)flushing %s$(C_OFF)\n" "$(COMP)"
$(KUBECTL) -n $(NS) scale deployment/$(COMP) --replicas=0
$(KUBECTL) -n $(NS) wait --for=delete pod -l app=$(COMP) --timeout=120s 2>/dev/null || true
$(KUBECTL) -n $(NS) delete pvc $(COMP)-data --ignore-not-found --wait=true
$(KUBECTL) apply -k $(K8S)/$(COMP) --server-side --force-conflicts
$(KUBECTL) -n $(NS) scale deployment/$(COMP) --replicas=1
##@ Validation and teardown
.PHONY: build
build: ## Render the generated manifests to stdout
kustomize build $(K8S)
.PHONY: diff
diff: check-context ## Diff the manifests against the cluster state
$(KUBECTL) diff -k $(K8S) || true
.PHONY: validate
validate: dashboards-check ## Validate the manifests (build + server dry-run)
kustomize build $(K8S) >/dev/null && printf "$(C_OK)kustomize build OK$(C_OFF)\n"
$(KUBECTL) apply -k $(K8S) --server-side --force-conflicts --dry-run=server >/dev/null && printf "$(C_OK)server dry-run OK$(C_OFF)\n"
.PHONY: undeploy
undeploy: ## DESTRUCTIVE: delete the stack, PVCs kept (CONFIRM=yes)
$(call confirm,Delete the stack (PVCs are KEPT))
$(KUBECTL) delete -k $(K8S) --ignore-not-found
.PHONY: destroy
destroy: ## DESTRUCTIVE: delete the stack AND the PVCs (CONFIRM=yes)
$(call confirm,Delete the stack AND all data)
$(KUBECTL) delete -k $(K8S) --ignore-not-found
$(KUBECTL) -n $(NS) delete pvc --all --ignore-not-found
.PHONY: check-context
check-context:
cur=$$(kubectl config current-context); \
if [ "$$cur" != "$(CTX)" ]; then \
printf "$(C_ERR)current context '%s' != '$(CTX)'$(C_OFF) (commands explicitly target $(CTX))\n" "$$cur"; \
fi
# Guard rail for destructive targets: requires CONFIRM=yes
define confirm
@if [ "$(CONFIRM)" != "yes" ]; then \
printf "$(C_ERR)Destructive operation$(C_OFF): $(1)\n"; \
printf "Re-run with $(C_WARN)CONFIRM=yes$(C_OFF): make $(C_WARN)$@ CONFIRM=yes$(C_OFF)\n"; \
exit 1; \
fi
endef