Declarative Grafana / Prometheus / Loki / Tempo observability stack

Deploys the whole stack into the observability namespace of the local
cluster with Kustomize alone — no helm install, no dashboard created by
hand in the UI. The Makefile carries every command: deploy, dashboards,
diagnostics, PVC flush, teardown.

Dashboards are files, never UI objects. Each carries a stable uid, which
is what keeps URLs, cross-links and alerts alive across redeployments, and
references datasources by stable uid rather than by name. The dashboard
ConfigMap is generated without a name-suffix hash so applying it updates
it in place and Grafana reloads without a restart, while the provisioning
ConfigMaps keep their hash precisely so that a change there does force one.

Prometheus scrapes the kubelet /metrics/resource endpoint rather than
/metrics/cadvisor: on this cluster cadvisor exposes machine_* series only
and no container_* at all, which would leave every CPU and memory panel
empty.

Alloy ships every pod's logs to Loki through the Kubernetes API, so no
/var/log mount is needed. Tempo's metrics_generator feeds span metrics and
the service graph back into Prometheus, and the datasources are wired so a
log line's trace_id opens the matching trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-25 07:27:38 +02:00
commit 3937b4f366
42 changed files with 7597 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
*.tmp
# Real secrets: only secret.example.yaml is versioned
k8s/secret.yaml
k8s/prometheus/secret.yaml
+356
View File
@@ -0,0 +1,356 @@
# =============================================================================
# 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)
$(KUBECTL) apply -k $(K8S)
$(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)
$(KUBECTL) apply -k $(DASHBOARDS)
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)
$(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) --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
+281
View File
@@ -0,0 +1,281 @@
# Infra Observability (Kubernetes / declarative)
**Grafana 13.2 / Prometheus 3.14 / Loki 3.7 / Tempo 3.0** plus **Alloy 1.19**,
deployed fully declaratively (Kustomize) into the `observability` namespace of
the local `orbstack` cluster.
Nothing is imperative: no `helm install`, no dashboard hand-created in the UI.
The entire desired state lives in this repository and `make deploy` reconciles it.
## Repository layout
```text
Makefile # Every command (make = help)
k8s/
├── kustomization.yaml # Root Kustomize manifest
├── namespace.yaml # observability namespace
├── secret.yaml # Grafana admin credentials (GITIGNORED)
├── secret.example.yaml # Secret template
├── prometheus/
│ ├── prometheus.yaml # Scrape config (Kubernetes SD)
│ ├── rbac.yaml # SA + discovery ClusterRole
│ ├── secret.yaml # LibreChat scrape token (GITIGNORED)
│ ├── secret.example.yaml # Token template
│ ├── pvc.yaml # Persistent TSDB (10Gi)
│ ├── deployment.yaml # Prometheus v3.14.0
│ └── service.yaml # :9090
├── loki/
│ ├── loki.yaml # Monolithic config, filesystem storage
│ ├── pvc.yaml # Chunks + index (10Gi)
│ ├── deployment.yaml # Loki 3.7.6 (-target=all)
│ └── service.yaml # :3100 (HTTP) / :9096 (gRPC)
├── tempo/
│ ├── tempo.yaml # Monolithic config + metrics-generator
│ ├── pvc.yaml # WAL + trace blocks (10Gi)
│ ├── deployment.yaml # Tempo 3.0.3 (-target=all)
│ └── service.yaml # :3200, OTLP :4317/:4318, Zipkin :9411
├── alloy/
│ ├── config.alloy # Pod logs -> Loki pipeline
│ ├── rbac.yaml # SA + ClusterRole (including pods/log)
│ ├── daemonset.yaml # Alloy v1.19.0 (one pod per node)
│ └── service.yaml # :12345 (UI + /metrics)
├── grafana/
│ ├── provisioning/
│ │ ├── datasources.yaml # Datasources with stable UIDs
│ │ └── dashboard-providers.yaml # File provider
│ ├── dashboards/
│ │ ├── kustomization.yaml # ConfigMap WITHOUT hash (hot reload)
│ │ ├── obs-stack-health.json # uid: obs-stack-health
│ │ ├── obs-keycloak.json # uid: obs-keycloak
│ │ ├── obs-librechat.json # uid: obs-librechat
│ │ └── obs-classic-models-api.json
│ ├── pvc.yaml # Grafana SQLite database (5Gi)
│ ├── deployment.yaml # Grafana 13.2.0
│ └── service.yaml # :3000
└── ingress/
├── certificate.yaml # cert-manager Certificate (sttlab-local-ca)
└── ingress.yaml # Three NGINX Ingresses
```
## Getting started
```bash
make # Help: every available target
make deploy # Deploy the whole stack
make hosts # /etc/hosts entries to add
make grafana-password # Admin credentials
make smoke # End-to-end verification
```
| URL | Purpose |
|---|---|
| `https://grafana.sttlab.local` | Grafana UI |
| `https://prometheus.sttlab.local` | Prometheus UI (targets, expression browser) |
| `https://alloy.sttlab.local` | Alloy UI (log pipeline state) |
Sending traces from an in-cluster application:
`tempo.observability.svc.cluster.local:4317` (OTLP gRPC) or `:4318` (OTLP HTTP).
## Declarative dashboards with stable UIDs
This is the centrepiece of the repository: **a dashboard only ever exists as a
versioned JSON file**, never as an object created through the UI.
### Rules
1. **One `.json` file per dashboard**, under `k8s/grafana/dashboards/`.
2. **The `uid` field is stable and never changes.** It is what keeps URLs
(`/d/<uid>/…`), cross-dashboard links, annotations and alerts alive across
every redeployment. By convention the filename equals the UID
(`obs-keycloak.json` -> `uid: obs-keycloak`).
3. **The `id` field must stay `null`** (or absent): it is an identifier internal
to a Grafana instance and is not portable.
4. **Datasources are referenced by stable UID**, never by name:
`"datasource": { "type": "prometheus", "uid": "prometheus" }`.
Available UIDs: `prometheus`, `loki`, `tempo`.
5. `allowUiUpdates: false`: any change made in the UI is overwritten on the next
reload. The file is the source of truth.
### Adding a dashboard
```bash
cp my-dashboard.json k8s/grafana/dashboards/obs-my-topic.json
# edit the JSON: "uid": "obs-my-topic", "id": null
make dashboards-sync # update the list in dashboards/kustomization.yaml
make dashboards # apply (without restarting Grafana)
```
`make dashboards-check` (run automatically by `dashboards` and `validate`)
verifies: valid JSON, `uid` present, `uid` unique, `id` null, file referenced in
the kustomization, filename/UID consistency.
### How the hot reload works
The `grafana-dashboards` ConfigMap is generated **without a name-suffix hash**
(`disableNameSuffixHash: true`), so `make dashboards` updates it *in place*: the
Grafana pod is not recreated and the file provider re-reads the directory
(`updateIntervalSeconds: 10`).
Measured end-to-end delay: **about 70 seconds** (kubelet ConfigMap volume sync
~60s + provider poll 10s). For immediate effect: `make restart-grafana`.
Conversely, the *provisioning* ConfigMaps (datasources, providers) **keep** their
hash suffix: those files are only read at startup, so the hash deliberately
forces a pod restart when they change.
## Persistence
| PVC | Component | Size | Contents | Retention |
|---|---|---|---|---|
| `prometheus-data` | Prometheus | 10Gi | TSDB | 15 d / 8 GB |
| `loki-data` | Loki | 10Gi | chunks + TSDB index | 168 h (compactor) |
| `tempo-data` | Tempo | 10Gi | WAL + trace blocks | 168 h |
| `grafana-data` | Grafana | 5Gi | SQLite database (users, prefs) | — |
StorageClass `local-path` (the cluster default), `ReadWriteOnce`. The Deployments
use `strategy: Recreate`: with an RWO volume the old pod must release it before
the new one can start.
Alloy's read positions live on a `hostPath` (`/var/lib/alloy`), so restarting the
DaemonSet does not re-ingest every log.
### Wiping data
```bash
make pvc # PVC state
make flush-loki CONFIRM=yes # wipe the logs only
make flush-prometheus CONFIRM=yes # wipe the TSDB only
make flush-pvc CONFIRM=yes # wipe EVERYTHING
```
The cycle is: `scale 0` -> wait for pod deletion -> `delete pvc` -> reapply the
kustomization (which recreates the PVC) -> `scale 1`. Every destructive target
requires `CONFIRM=yes`.
Dashboards are **not** affected by `flush-grafana`: they are reprovisioned from
the ConfigMaps at startup.
## Wiring the three signals
- **Prometheus** discovers its targets through the Kubernetes API: apiserver,
kubelet, container resource metrics, plus any Service or Pod carrying the
`prometheus.io/scrape: "true"` annotation (`prometheus.io/port`,
`prometheus.io/path`).
- **Alloy** reads every pod's logs through the Kubernetes API (no `/var/log`
mount) and pushes them to Loki with `namespace`, `pod`, `container`, `node`,
`app`, `component` and `job` labels.
- **Tempo** receives OTLP, and its `metrics_generator` pushes span metrics and
service graphs to Prometheus through its remote-write receiver
(`--web.enable-remote-write-receiver`).
- **Grafana correlations**: Loki -> Tempo (`TraceID` derived field),
Tempo -> Loki (`tracesToLogsV2`), Tempo -> Prometheus (`serviceMap`,
`tracesToMetrics`), Prometheus exemplars -> Tempo.
### Container resource metrics
Prometheus scrapes the kubelet's **`/metrics/resource`** endpoint, not
`/metrics/cadvisor`. On this cluster the cadvisor endpoint answers with `machine_*`
series only and exposes no `container_*` at all, so CPU and memory panels would
stay empty. `/metrics/resource` — the source metrics-server itself reads —
provides `container_cpu_usage_seconds_total` and
`container_memory_working_set_bytes` with `namespace`/`pod`/`container` labels.
kube-state-metrics is not deployed, so container restarts are derived from
changes in `container_start_time_seconds`.
## Connected applications
| Application | Traces | Metrics | Logs | Dashboard |
|---|---|---|---|---|
| `keycloak` | OTel Java agent → `tempo:4318` | native `/metrics` (management port 9000) | JSON + MDC `trace_id` | `obs-keycloak` |
| `librechat` | OTel SDK → `tempo:4318` | built-in `/metrics`, Bearer token | stdout | `obs-librechat` |
| `classic-models-api` | `opentelemetry-instrument` → `tempo:4318` | django-prometheus `/metrics` | JSON + `otelTraceID` | `obs-classic-models-api` |
Their manifests live in the `infra-keycloak`, `infra-librechat` and
`classic-models-api` repositories; this repository only holds the platform.
Each application dashboard carries the same four sections: an overview of RED
indicators, HTTP traffic, a runtime section specific to the stack in use, and a
**Logs** row, followed by container resources.
The Logs row leans on Loki's `detected_level`, which is inferred at ingestion
time, so a single query works across all three log formats — Keycloak emits JSON,
LibreChat colour-coded text, and the Django API plain gunicorn access lines. No
per-application parser is needed. The volume panel is scoped to the application
container, while the two error panels cover the whole namespace so the
datastore's own failures surface alongside the application's. Where a log line
carries a `trace_id`, the Loki datasource's derived field turns it into a link
straight to the matching trace in Tempo.
### Connecting an application
1. **Traces** — point OTLP at Tempo:
`OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo.observability.svc.cluster.local:4318`
(`:4317` for gRPC). RED span metrics and the service graph are then derived
automatically by Tempo's `metrics_generator`, with no extra instrumentation.
2. **Metrics** — annotate the **pod** (not the Service):
```yaml
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9000"
prometheus.io/path: "/metrics"
```
3. **Logs** — nothing to do: Alloy collects every pod in the cluster. For Grafana
to jump from a log line to its trace, the line only needs to contain a
`trace_id` (the Loki datasource's derived field).
### Token-protected endpoints
Annotation-based discovery cannot carry credentials, so an authenticated target
gets its own `scrape_config` in `k8s/prometheus/prometheus.yaml`, with the token
mounted from a Secret — see the `librechat` job, which reads
`/etc/prometheus/secrets/librechat/token`.
That token must be **identical** on both sides: `METRICS_SECRET` in the
`librechat-secrets` secret (namespace `librechat`) and the `token` key of the
`librechat-metrics` secret (namespace `observability`, file
`k8s/prometheus/secret.yaml`, gitignored).
A Kubernetes Secret is namespaced and a pod can only mount ones from its own
namespace, so the same value has to exist twice — once for LibreChat, which
**verifies** the token, once for Prometheus, which **presents** it. The risk is
silent drift: regenerating one side without the other sends the scrape back to
401 without breaking anything else. `make check-metrics-token` (included in
`make diag`) compares both copies.
## Diagnostics
```bash
make status # pods, services, PVCs, ingresses
make diag # + endpoints, certificate, failing containers, events
make smoke # health of all five components, datasources, dashboards, targets
make targets # Prometheus targets and their last scrape error
make logs-loki # follow one component's logs
make top # CPU/memory usage
make pf-grafana # port-forward when the ingress is unavailable
make diff # difference between the repository and the cluster
make validate # kustomize build + server dry-run + dashboard checks
```
## Secrets
`k8s/secret.yaml` and `k8s/prometheus/secret.yaml` are **gitignored**.
`make secret` generates the first from `secret.example.yaml` with a random admin
password if it does not exist (and is called automatically by `make deploy`).
The TLS certificate is not versioned either: it is issued by the existing
`sttlab-local-ca` cert-manager ClusterIssuer (`k8s/ingress/certificate.yaml`).
## Coexisting with what was already there
The `observability` namespace already hosts two deployments **not managed by this
repository**, left untouched:
- `jaeger` (installed by Helm) — it also exposes OTLP on `4317`/`4318`, but on its
own Service, so there is no conflict with Tempo. Since all three applications
now point at Tempo, Jaeger no longer receives anything; uninstall the Helm
release whenever you want a single trace backend.
- `audit-log-collector` (Fluent Bit) — writes to its own PVC.
`make undeploy` and `make destroy` only run `kubectl delete -k k8s/`, so they
touch nothing but the resources described here.
+66
View File
@@ -0,0 +1,66 @@
// Grafana Alloy -- ships the logs of every pod in the cluster to Loki.
// Reads through the Kubernetes API (no /var/log mount required).
logging {
level = "info"
format = "logfmt"
}
discovery.kubernetes "pods" {
role = "pod"
}
discovery.relabel "pod_logs" {
targets = discovery.kubernetes.pods.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
target_label = "container"
}
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
target_label = "node"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
target_label = "app"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_component"]
target_label = "component"
}
// job = <namespace>/<container>: the usual Loki convention.
rule {
source_labels = ["__meta_kubernetes_namespace", "__meta_kubernetes_pod_container_name"]
separator = "/"
target_label = "job"
}
// Only collect containers on the current node (DaemonSet).
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
regex = env("HOSTNAME_NODE")
action = "keep"
}
}
loki.source.kubernetes "pods" {
targets = discovery.relabel.pod_logs.output
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
external_labels = {
cluster = "orbstack",
}
}
+73
View File
@@ -0,0 +1,73 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: alloy
namespace: observability
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/component: collector
app.kubernetes.io/part-of: observability
spec:
selector:
matchLabels:
app: alloy
template:
metadata:
labels:
app: alloy
app.kubernetes.io/name: alloy
app.kubernetes.io/component: collector
app.kubernetes.io/part-of: observability
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "12345"
spec:
serviceAccountName: alloy
tolerations:
- operator: Exists
containers:
- name: alloy
image: grafana/alloy:v1.19.0
args:
- run
- /etc/alloy/config.alloy
- --storage.path=/var/lib/alloy/data
- --server.http.listen-addr=0.0.0.0:12345
- --disable-reporting
env:
# Used by config.alloy to tail only the pods on the current node.
- name: HOSTNAME_NODE
valueFrom:
fieldRef:
fieldPath: spec.nodeName
ports:
- name: http
containerPort: 12345
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
readinessProbe:
httpGet:
path: /-/ready
port: http
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /etc/alloy
- name: storage
mountPath: /var/lib/alloy
volumes:
- name: config
configMap:
name: alloy-config
- name: storage
# Read positions persisted on the node: no full log re-ingestion
# when the pod restarts.
hostPath:
path: /var/lib/alloy
type: DirectoryOrCreate
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- rbac.yaml
- daemonset.yaml
- service.yaml
configMapGenerator:
- name: alloy-config
files:
- config.alloy
generatorOptions:
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/part-of: observability
+52
View File
@@ -0,0 +1,52 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: alloy
namespace: observability
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/part-of: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: observability-alloy
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/part-of: observability
rules:
- apiGroups: [""]
resources:
- nodes
- nodes/proxy
- nodes/metrics
- services
- endpoints
- pods
- namespaces
- events
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list", "watch"]
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
- nonResourceURLs: ["/metrics"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: observability-alloy
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/part-of: observability
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: observability-alloy
subjects:
- kind: ServiceAccount
name: alloy
namespace: observability
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: alloy
namespace: observability
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/component: collector
app.kubernetes.io/part-of: observability
spec:
type: ClusterIP
selector:
app: alloy
ports:
- name: http
port: 12345
targetPort: http
+31
View File
@@ -0,0 +1,31 @@
# Grafana dashboards -- declarative provisioning.
#
# Rules:
# * one .json file per dashboard, named after its UID;
# * every JSON carries a STABLE "uid" field (never regenerated): that is what
# keeps URLs, cross-links and alerts alive across redeployments;
# * `disableNameSuffixHash: true` -> the ConfigMap always keeps the same name,
# so `kubectl apply` updates it in place and Grafana reloads WITHOUT
# restarting the pod, in about 70s (see dashboard-providers.yaml).
#
# Adding a dashboard = drop the .json here and add it to `files:` below.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
configMapGenerator:
- name: grafana-dashboards
files:
# >>> dashboards (generated by `make dashboards-sync`) >>>
- obs-classic-models-api.json
- obs-keycloak.json
- obs-librechat.json
- obs-stack-health.json
# <<< dashboards <<<
generatorOptions:
disableNameSuffixHash: true
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/component: dashboards
app.kubernetes.io/part-of: observability
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,437 @@
{
"uid": "obs-stack-health",
"title": "Observability — Stack Health",
"description": "Health of the Prometheus / Loki / Tempo / Alloy stack. Reference dashboard: stable UID, declaratively provisioned.",
"tags": [
"observability",
"stack",
"provisioned"
],
"editable": false,
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"templating": {
"list": [
{
"name": "namespace",
"label": "Namespace",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"query": "label_values(kube_pod_info, namespace)",
"refresh": 2,
"includeAll": true,
"multi": true,
"current": {
"text": "observability",
"value": "observability"
}
}
]
},
"panels": [
{
"id": 1,
"type": "row",
"title": "Collection",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"collapsed": false,
"panels": []
},
{
"id": 2,
"type": "stat",
"title": "Prometheus targets UP",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 1
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(up)",
"legendFormat": "up"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 3,
"type": "stat",
"title": "Prometheus targets DOWN",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 5,
"w": 6,
"x": 6,
"y": 1
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(up == bool 0)",
"legendFormat": "down"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 4,
"type": "stat",
"title": "Log lines ingested (/s)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 5,
"w": 6,
"x": 12,
"y": 1
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(loki_distributor_lines_received_total[5m]))",
"legendFormat": "lines/s"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 1,
"color": {
"mode": "fixed",
"fixedColor": "blue"
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 5,
"type": "stat",
"title": "Spans ingested (/s)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 5,
"w": 6,
"x": 18,
"y": 1
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(tempo_distributor_spans_received_total[5m]))",
"legendFormat": "spans/s"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 1,
"color": {
"mode": "fixed",
"fixedColor": "purple"
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 6,
"type": "row",
"title": "Stack resources",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 6
},
"collapsed": false,
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "CPU per component (observability namespace)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{namespace=\"observability\", container!=\"\"}[$__rate_interval]))",
"legendFormat": "{{pod}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"drawStyle": "line",
"lineWidth": 1,
"fillOpacity": 10,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Memory per component (observability namespace)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (pod) (container_memory_working_set_bytes{namespace=\"observability\", container!=\"\"})",
"legendFormat": "{{pod}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineWidth": 1,
"fillOpacity": 10,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"type": "row",
"title": "Logs",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 15
},
"collapsed": false,
"panels": []
},
{
"id": 10,
"type": "logs",
"title": "Recent stack errors",
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 16
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "loki",
"uid": "loki"
},
"expr": "{namespace=\"observability\"} |~ \"(?i)(error|level=error|fatal)\"",
"queryType": "range"
}
],
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": true,
"sortOrder": "Descending",
"dedupStrategy": "none",
"enableLogDetails": true
}
}
]
}
+112
View File
@@ -0,0 +1,112 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: observability
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/component: ui
app.kubernetes.io/part-of: observability
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: grafana
template:
metadata:
labels:
app: grafana
app.kubernetes.io/name: grafana
app.kubernetes.io/component: ui
app.kubernetes.io/part-of: observability
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3000"
prometheus.io/path: "/metrics"
spec:
securityContext:
runAsUser: 472
runAsGroup: 472
fsGroup: 472
containers:
- name: grafana
image: grafana/grafana:13.2.0
ports:
- name: http
containerPort: 3000
env:
- name: GF_SECURITY_ADMIN_USER
valueFrom:
secretKeyRef:
name: observability-secrets
key: GF_SECURITY_ADMIN_USER
- name: GF_SECURITY_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: observability-secrets
key: GF_SECURITY_ADMIN_PASSWORD
- name: GF_SERVER_ROOT_URL
value: https://grafana.sttlab.local
- name: GF_PATHS_PROVISIONING
value: /etc/grafana/provisioning
# Everything is driven by the provisioning files, so creating
# dashboards or datasources from the UI is disabled.
- name: GF_USERS_ALLOW_SIGN_UP
value: "false"
- name: GF_ANALYTICS_REPORTING_ENABLED
value: "false"
- name: GF_ANALYTICS_CHECK_FOR_UPDATES
value: "false"
- name: GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES
value: "false"
- name: GF_NEWS_NEWS_FEED_ENABLED
value: "false"
- name: GF_LOG_LEVEL
value: info
- name: GF_FEATURE_TOGGLES_ENABLE
value: traceqlEditor,traceQLStreaming,correlations,lokiLogsDataplane
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 60
periodSeconds: 30
volumeMounts:
- name: data
mountPath: /var/lib/grafana
- name: provisioning-datasources
mountPath: /etc/grafana/provisioning/datasources
- name: provisioning-dashboards
mountPath: /etc/grafana/provisioning/dashboards
- name: dashboards
mountPath: /var/lib/grafana/dashboards
volumes:
- name: data
persistentVolumeClaim:
claimName: grafana-data
- name: provisioning-datasources
configMap:
name: grafana-datasources
- name: provisioning-dashboards
configMap:
name: grafana-dashboard-providers
- name: dashboards
# Stable-named ConfigMap: updated in place by `make dashboards` and
# re-read by Grafana without a restart (~70s: kubelet sync + 10s poll).
configMap:
name: grafana-dashboards
+27
View File
@@ -0,0 +1,27 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- pvc.yaml
- deployment.yaml
- service.yaml
# Dashboards live in ./dashboards and are referenced by the root kustomization:
# `k8s/grafana` is the runtime alone, `k8s/grafana/dashboards` the content alone.
# Both are deployable independently.
configMapGenerator:
# Name-suffix hash KEPT here: datasource and provider provisioning is only
# read at startup, so the hash deliberately forces a pod restart whenever
# these files change.
- name: grafana-datasources
files:
- datasources.yaml=provisioning/datasources.yaml
- name: grafana-dashboard-providers
files:
- dashboard-providers.yaml=provisioning/dashboard-providers.yaml
generatorOptions:
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/part-of: observability
@@ -0,0 +1,19 @@
# File-based dashboard provider.
# The JSON files are mounted from the `grafana-dashboards` ConfigMap (generated
# without a name-suffix hash): `make dashboards` updates the ConfigMap in place
# and Grafana reloads WITHOUT restarting the pod, in about 70s
# (kubelet volume sync ~60s + updateIntervalSeconds below).
apiVersion: 1
providers:
- name: infra-observability
orgId: 1
folder: Infra Observability
folderUid: infra-observability
type: file
disableDeletion: false
allowUiUpdates: false
updateIntervalSeconds: 10
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
+82
View File
@@ -0,0 +1,82 @@
# Declarative datasource provisioning.
# The `uid` values are STABLE and are what dashboard JSON files reference:
# "datasource": { "type": "prometheus", "uid": "prometheus" }
apiVersion: 1
deleteDatasources:
- name: Prometheus
orgId: 1
- name: Loki
orgId: 1
- name: Tempo
orgId: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
orgId: 1
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
timeInterval: 30s
manageAlerts: false
exemplarTraceIdDestinations:
- name: trace_id
datasourceUid: tempo
- name: Loki
uid: loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
editable: false
jsonData:
timeout: 60
maxLines: 1000
# Logs -> traces: a trace_id in the log line opens Tempo.
derivedFields:
- name: TraceID
matcherType: regex
matcherRegex: '"?trace[_-]?id"?[:=]"?([a-f0-9]{16,32})'
url: '$${__value.raw}'
datasourceUid: tempo
urlDisplayLabel: View trace
- name: Tempo
uid: tempo
type: tempo
access: proxy
orgId: 1
url: http://tempo:3200
editable: false
jsonData:
httpMethod: GET
streamingEnabled:
search: true
metrics: true
# Service graph fed by the metrics_generator -> Prometheus.
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: -5m
spanEndTimeShift: 5m
filterByTraceID: true
filterBySpanID: false
tags:
- key: service.name
value: app
tracesToMetrics:
datasourceUid: prometheus
spanStartTimeShift: -5m
spanEndTimeShift: 5m
tags:
- key: service.name
value: service
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: grafana-data
namespace: observability
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/component: storage
app.kubernetes.io/part-of: observability
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: observability
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/component: ui
app.kubernetes.io/part-of: observability
spec:
type: ClusterIP
selector:
app: grafana
ports:
- name: http
port: 3000
targetPort: http
+21
View File
@@ -0,0 +1,21 @@
# Certificates issued by the `sttlab-local-ca` cert-manager ClusterIssuer
# (already present on the cluster). No private key is ever versioned.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: observability-tls
namespace: observability
labels:
app.kubernetes.io/part-of: observability
spec:
secretName: observability-tls
issuerRef:
name: sttlab-local-ca
kind: ClusterIssuer
commonName: grafana.sttlab.local
dnsNames:
- grafana.sttlab.local
- prometheus.sttlab.local
- alloy.sttlab.local
- loki.sttlab.local
- tempo.sttlab.local
+88
View File
@@ -0,0 +1,88 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
namespace: observability
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/part-of: observability
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "20m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
# Required for TraceQL streaming and Loki live tail.
nginx.ingress.kubernetes.io/proxy-buffering: "off"
spec:
ingressClassName: nginx
tls:
- hosts:
- grafana.sttlab.local
secretName: observability-tls
rules:
- host: grafana.sttlab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 3000
---
# Exposed for diagnostics (targets, TSDB status, expression browser).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: prometheus
namespace: observability
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/part-of: observability
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
spec:
ingressClassName: nginx
tls:
- hosts:
- prometheus.sttlab.local
secretName: observability-tls
rules:
- host: prometheus.sttlab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus
port:
number: 9090
---
# Alloy UI: state of the log collection pipeline components.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: alloy
namespace: observability
labels:
app.kubernetes.io/name: alloy
app.kubernetes.io/part-of: observability
spec:
ingressClassName: nginx
tls:
- hosts:
- alloy.sttlab.local
secretName: observability-tls
rules:
- host: alloy.sttlab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: alloy
port:
number: 12345
+7
View File
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- certificate.yaml
- ingress.yaml
+20
View File
@@ -0,0 +1,20 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- namespace.yaml
- secret.yaml
- prometheus
- loki
- tempo
- alloy
- grafana
- grafana/dashboards
- ingress
labels:
- includeSelectors: false
pairs:
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: observability
+73
View File
@@ -0,0 +1,73 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: loki
namespace: observability
labels:
app.kubernetes.io/name: loki
app.kubernetes.io/component: logs
app.kubernetes.io/part-of: observability
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: loki
template:
metadata:
labels:
app: loki
app.kubernetes.io/name: loki
app.kubernetes.io/component: logs
app.kubernetes.io/part-of: observability
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3100"
spec:
securityContext:
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
containers:
- name: loki
image: grafana/loki:3.7.6
args:
- -config.file=/etc/loki/loki.yaml
- -target=all
ports:
- name: http
containerPort: 3100
- name: grpc
containerPort: 9096
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 60
periodSeconds: 30
volumeMounts:
- name: config
mountPath: /etc/loki
- name: data
mountPath: /loki
volumes:
- name: config
configMap:
name: loki-config
- name: data
persistentVolumeClaim:
claimName: loki-data
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- pvc.yaml
- deployment.yaml
- service.yaml
configMapGenerator:
- name: loki-config
files:
- loki.yaml
generatorOptions:
labels:
app.kubernetes.io/name: loki
app.kubernetes.io/part-of: observability
+85
View File
@@ -0,0 +1,85 @@
# Loki 3.x in monolithic mode (-target=all), filesystem storage on a PVC.
auth_enabled: false
server:
http_listen_address: 0.0.0.0
http_listen_port: 3100
grpc_listen_address: 0.0.0.0
grpc_listen_port: 9096
log_level: info
grpc_server_max_concurrent_streams: 1000
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/tsdb-index
cache_location: /loki/tsdb-cache
filesystem:
directory: /loki/chunks
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
compaction_interval: 10m
retention_delete_delay: 2h
limits_config:
retention_period: 168h
max_query_series: 5000
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
reject_old_samples: true
reject_old_samples_max_age: 168h
volume_enabled: true
metric_aggregation_enabled: true
allow_structured_metadata: true
pattern_ingester:
enabled: true
metric_aggregation:
loki_address: localhost:3100
query_range:
align_queries_with_step: true
cache_results: true
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
frontend:
encoding: protobuf
ruler:
storage:
type: local
local:
directory: /loki/rules
rule_path: /loki/rules-tmp
enable_api: true
analytics:
reporting_enabled: false
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: loki-data
namespace: observability
labels:
app.kubernetes.io/name: loki
app.kubernetes.io/component: storage
app.kubernetes.io/part-of: observability
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+20
View File
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: loki
namespace: observability
labels:
app.kubernetes.io/name: loki
app.kubernetes.io/component: logs
app.kubernetes.io/part-of: observability
spec:
type: ClusterIP
selector:
app: loki
ports:
- name: http
port: 3100
targetPort: http
- name: grpc
port: 9096
targetPort: grpc
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: observability
labels:
app.kubernetes.io/part-of: observability
+86
View File
@@ -0,0 +1,86 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: observability
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/component: metrics
app.kubernetes.io/part-of: observability
spec:
replicas: 1
strategy:
# ReadWriteOnce PVC: the old pod must release the volume first.
type: Recreate
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
app.kubernetes.io/name: prometheus
app.kubernetes.io/component: metrics
app.kubernetes.io/part-of: observability
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
spec:
serviceAccountName: prometheus
securityContext:
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
containers:
- name: prometheus
image: prom/prometheus:v3.14.0
args:
- --config.file=/etc/prometheus/prometheus.yaml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --storage.tsdb.retention.size=8GB
- --web.enable-lifecycle
# Receives remote_write from Tempo's metrics_generator (span metrics + service graphs).
- --web.enable-remote-write-receiver
- --web.external-url=https://prometheus.sttlab.local
- --web.route-prefix=/
ports:
- name: http
containerPort: 9090
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
readinessProbe:
httpGet:
path: /-/ready
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /-/healthy
port: http
initialDelaySeconds: 30
periodSeconds: 30
volumeMounts:
- name: config
mountPath: /etc/prometheus
- name: data
mountPath: /prometheus
- name: librechat-metrics
mountPath: /etc/prometheus/secrets/librechat
readOnly: true
volumes:
- name: config
configMap:
name: prometheus-config
- name: librechat-metrics
secret:
secretName: librechat-metrics
- name: data
persistentVolumeClaim:
claimName: prometheus-data
+20
View File
@@ -0,0 +1,20 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- secret.yaml
- rbac.yaml
- pvc.yaml
- deployment.yaml
- service.yaml
configMapGenerator:
- name: prometheus-config
files:
- prometheus.yaml
generatorOptions:
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/part-of: observability
+177
View File
@@ -0,0 +1,177 @@
global:
scrape_interval: 30s
evaluation_interval: 30s
external_labels:
cluster: orbstack
source: prometheus
scrape_configs:
# --- Control plane -------------------------------------------------------
- job_name: kubernetes-apiservers
kubernetes_sd_configs:
- role: endpoints
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
insecure_skip_verify: true
authorization:
credentials_file: /var/run/secrets/kubernetes.io/serviceaccount/token
relabel_configs:
- source_labels:
[__meta_kubernetes_namespace, __meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
action: keep
regex: default;kubernetes;https
# --- Kubelet -------------------------------------------------------------
- job_name: kubernetes-nodes
kubernetes_sd_configs:
- role: node
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
insecure_skip_verify: true
authorization:
credentials_file: /var/run/secrets/kubernetes.io/serviceaccount/token
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- target_label: __address__
replacement: kubernetes.default.svc:443
- source_labels: [__meta_kubernetes_node_name]
regex: (.+)
target_label: __metrics_path__
replacement: /api/v1/nodes/$1/proxy/metrics
# --- Container resource metrics -----------------------------------------
# /metrics/resource and NOT /metrics/cadvisor: on this cluster (OrbStack) the
# kubelet's cadvisor endpoint only exposes `machine_*` series and no
# `container_*` at all. /metrics/resource -- the source metrics-server reads --
# provides container_cpu_usage_seconds_total and
# container_memory_working_set_bytes with namespace/pod/container labels.
- job_name: kubernetes-kubelet-resource
kubernetes_sd_configs:
- role: node
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
insecure_skip_verify: true
authorization:
credentials_file: /var/run/secrets/kubernetes.io/serviceaccount/token
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- target_label: __address__
replacement: kubernetes.default.svc:443
- source_labels: [__meta_kubernetes_node_name]
regex: (.+)
target_label: __metrics_path__
replacement: /api/v1/nodes/$1/proxy/metrics/resource
- source_labels: [__meta_kubernetes_node_name]
target_label: node
# --- Services annotated with prometheus.io/scrape -----------------------
- job_name: kubernetes-service-endpoints
kubernetes_sd_configs:
- role: endpoints
relabel_configs:
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape]
action: keep
regex: "true"
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scheme]
action: replace
target_label: __scheme__
regex: (https?)
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_service_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- action: labelmap
regex: __meta_kubernetes_service_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_service_name]
target_label: service
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
# --- Pods annotated with prometheus.io/scrape ---------------------------
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
# A pod yields one target per container: without this filter the init
# containers duplicate the real target.
- source_labels: [__meta_kubernetes_pod_container_init]
action: drop
regex: "true"
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scheme]
action: replace
target_label: __scheme__
regex: (https?)
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
- source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
- source_labels: [__meta_kubernetes_pod_phase]
action: drop
regex: (Succeeded|Failed|Pending)
# --- LibreChat: /metrics behind a Bearer token --------------------------
# Annotation-based discovery cannot carry credentials, so LibreChat gets its
# own job. The token comes from the `librechat-metrics` secret mounted into
# the Prometheus pod and must match METRICS_SECRET on the LibreChat side.
- job_name: librechat
kubernetes_sd_configs:
- role: pod
namespaces:
names: [librechat]
authorization:
type: Bearer
credentials_file: /etc/prometheus/secrets/librechat/token
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape_with_auth]
action: keep
regex: "true"
# A pod yields one target per container: without this filter the init
# containers duplicate the real target.
- source_labels: [__meta_kubernetes_pod_container_init]
action: drop
regex: "true"
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
- target_label: service
replacement: librechat
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prometheus-data
namespace: observability
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/component: storage
app.kubernetes.io/part-of: observability
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+51
View File
@@ -0,0 +1,51 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: observability
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/part-of: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: observability-prometheus
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/part-of: observability
rules:
- apiGroups: [""]
resources:
- nodes
- nodes/metrics
- nodes/proxy
- services
- endpoints
- pods
- namespaces
verbs: ["get", "list", "watch"]
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch"]
- nonResourceURLs: ["/metrics", "/metrics/cadvisor"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: observability-prometheus
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/part-of: observability
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: observability-prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: observability
+11
View File
@@ -0,0 +1,11 @@
# Template. The token must be IDENTICAL to the METRICS_SECRET key of the
# `librechat-secrets` secret in the librechat namespace (infra-librechat repo):
# it is the Bearer token Prometheus uses to scrape LibreChat's /metrics.
apiVersion: v1
kind: Secret
metadata:
name: librechat-metrics
namespace: observability
type: Opaque
stringData:
token: <CHANGE_ME>
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: observability
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/component: metrics
app.kubernetes.io/part-of: observability
spec:
type: ClusterIP
selector:
app: prometheus
ports:
- name: http
port: 9090
targetPort: http
+11
View File
@@ -0,0 +1,11 @@
# Template: copy to secret.yaml (gitignored) and fill in the values.
# make secret
apiVersion: v1
kind: Secret
metadata:
name: observability-secrets
namespace: observability
type: Opaque
stringData:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: <CHANGE_ME>
+79
View File
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: tempo
namespace: observability
labels:
app.kubernetes.io/name: tempo
app.kubernetes.io/component: traces
app.kubernetes.io/part-of: observability
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: tempo
template:
metadata:
labels:
app: tempo
app.kubernetes.io/name: tempo
app.kubernetes.io/component: traces
app.kubernetes.io/part-of: observability
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3200"
spec:
securityContext:
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
containers:
- name: tempo
image: grafana/tempo:3.0.3
args:
- -config.file=/etc/tempo/tempo.yaml
- -target=all
ports:
- name: http
containerPort: 3200
- name: grpc
containerPort: 9095
- name: otlp-grpc
containerPort: 4317
- name: otlp-http
containerPort: 4318
- name: zipkin
containerPort: 9411
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 60
periodSeconds: 30
volumeMounts:
- name: config
mountPath: /etc/tempo
- name: data
mountPath: /var/tempo
volumes:
- name: config
configMap:
name: tempo-config
- name: data
persistentVolumeClaim:
claimName: tempo-data
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- pvc.yaml
- deployment.yaml
- service.yaml
configMapGenerator:
- name: tempo-config
files:
- tempo.yaml
generatorOptions:
labels:
app.kubernetes.io/name: tempo
app.kubernetes.io/part-of: observability
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tempo-data
namespace: observability
labels:
app.kubernetes.io/name: tempo
app.kubernetes.io/component: storage
app.kubernetes.io/part-of: observability
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+29
View File
@@ -0,0 +1,29 @@
apiVersion: v1
kind: Service
metadata:
name: tempo
namespace: observability
labels:
app.kubernetes.io/name: tempo
app.kubernetes.io/component: traces
app.kubernetes.io/part-of: observability
spec:
type: ClusterIP
selector:
app: tempo
ports:
- name: http
port: 3200
targetPort: http
- name: grpc
port: 9095
targetPort: grpc
- name: otlp-grpc
port: 4317
targetPort: otlp-grpc
- name: otlp-http
port: 4318
targetPort: otlp-http
- name: zipkin
port: 9411
targetPort: zipkin
+64
View File
@@ -0,0 +1,64 @@
# Tempo 3.x in monolithic mode (-target=all), local storage on a PVC.
stream_over_http_enabled: true
multitenancy_enabled: false
server:
http_listen_address: 0.0.0.0
http_listen_port: 3200
grpc_listen_address: 0.0.0.0
grpc_listen_port: 9095
log_level: info
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
zipkin:
endpoint: 0.0.0.0:9411
# Generates span metrics and service graphs, pushed to Prometheus through its
# remote-write receiver (--web.enable-remote-write-receiver).
metrics_generator:
registry:
external_labels:
source: tempo
cluster: orbstack
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
query_frontend:
search:
duration_slo: 5s
throughput_bytes_slo: 1073741824
trace_by_id:
duration_slo: 5s
storage:
trace:
backend: local
wal:
path: /var/tempo/wal
local:
path: /var/tempo/blocks
overrides:
defaults:
metrics_generator:
processors:
- service-graphs
- span-metrics
generate_native_histograms: both
# Tempo 3.x: retention is no longer a top-level `compactor:` block; it is
# driven from here (backend_scheduler/backend_worker otherwise).
compaction:
block_retention: 168h
usage_report:
reporting_enabled: false