Grafana Cloud observability
Issue #260, sibling of #99 (Dynatrace).
Context
No observability infrastructure existed in this repo before this work. #99 tracks agentic observability via Dynatrace — application-level, OneAgent-based, requiring an NFR SaaS tenant. This is the infrastructure-level complement: cluster metrics, logs, AAP job telemetry — using Grafana Cloud's free tier.
Why Grafana Cloud, not self-hosted:
- Survives RHDP environment rebuilds. Both RHDP environments expired silently once (#101); anything deployed on the cluster died with them. An external SaaS instance does not.
- Free tier: 10k metrics series, 50 GB logs, 50 GB traces, 3 users, no credit card. Sufficient for two demo environments.
- No infrastructure to deploy, no operator to manage, no database to provision. Compare with Automation Orchestrator (#108/#141), which took CloudNativePG and three databases to get running.
Why an MCP server:
The same thesis as #102: stop paying a manual lookup every time the question is
"is the cluster healthy" or "how long did the last provision take." The official
grafana/mcp-grafana (3.4k stars, Apache-2.0, Grafana Labs) covers dashboards,
Prometheus queries, Loki log queries, alerting, incidents, annotations and more.
It runs as stdio, so it fits the same model as kubernetes-mcp-server.
The MCP server
--scope local, not .mcp.json
Follows the AAP MCP server pattern from
platform-addons-plan.md. Three reasons:
- The service account token is a standalone credential. OpenShift
kubeconfigs are derived artifacts —
make-kubeconfig.shgenerates them from vault contents, so.mcp.jsonreferences a gitignored path and the credential has an obvious refresh. A Grafana Cloud SA token is created in the UI with no vault-backed derivation step. It is the credential, not a cache of one. - The URL is sensitive. It contains the Grafana Cloud org slug. By the Dynatrace precedent (#99 S3), vendor-specific SaaS URLs are treated as credentials — the RHDP exception does not extend to other vendors.
- There is one instance. OpenShift and AAP are per-environment (the
environment is in the server's name, #16). Grafana Cloud is a single external
service that spans both. One server named
grafana, not two.
Credentials
Both go in the vault (playbooks/group_vars/all/secrets.yml) as top-level
keys, not under env_secrets:
grafana_cloud_url: "https://<org>.grafana.net"
grafana_cloud_sa_token: "glsa_..."
Top-level because they span both environments — the same reasoning that puts
rhsm_org_id and vaulted_subscriptions_client_id at the top level.
The service account is created with the Viewer role. Read-only, matching the governance thesis: MCP reads, Ansible writes. Even with no Ansible write path for Grafana yet, constraining the token from the start means the demo can safely show that the agent reads but does not modify dashboards or alerts.
Registration
utilities/make-grafana-mcp.sh reads from the vault and runs:
claude mcp add --transport stdio --scope local \
-e GRAFANA_URL="$GRAFANA_URL" \
-e GRAFANA_SERVICE_ACCOUNT_TOKEN="$GRAFANA_TOKEN" \
grafana -- uvx mcp-grafana
Key differences from make-aap-mcp.sh:
- Takes no arguments (one instance, not per-environment)
- No kubeconfig dependency
- No token creation step (token is pre-created in the Grafana UI)
--transport stdio(not--transport http)
The token exception
Same exception as the AAP MCP token documented in
platform-addons-plan.md: an MCP client needs a
durable credential, so CLAUDE.md's always: cleanup rule does not apply.
The token is created in the Grafana Cloud UI and revoked there.
Allowlist
.claude/settings.json carries mcp__grafana__*, pointing at a server that
does not exist until make-grafana-mcp.sh runs. This is the same expected
state as the AAP servers — documented in the sales-demos-mcp skill.
Grafana Cloud account setup
Manual, in a browser. Cannot be scripted.
- Sign up at grafana.com (free tier, no credit card)
- Note the instance URL (e.g.,
https://<org>.grafana.net) - Administration > Service Accounts > Add Service Account
- Assign the Viewer role
- Generate a service account token
- Add both values to the vault:
ansible-vault edit playbooks/group_vars/all/secrets.yml \ --vault-id sales.demos@~/secrets/.vault_pass_sales_demos
Verification
bash utilities/make-grafana-mcp.shcompletes without errorclaude mcp listshowsgrafanaalongside the existing servers- Restart Claude Code, then call a Grafana MCP tool (e.g.,
list_datasourcesorsearch_dashboards) — it should connect and return data from the free-tier instance (even if empty on a fresh account)
Phase 1 — Feed data in (#265)
Deploy Grafana Alloy on the OpenShift clusters to push metrics (Prometheus
remote-write) and logs (Loki) to Grafana Cloud. Separate playbook
(deploy_alloy.yml) plus a skill — opt-in, not baked into setup.yml.
Sandbox first, demo once proven.
Demo headline: "We instrument the entire platform with Ansible — OCP cluster health, KubeVirt VM metrics, AAP job telemetry — and the AI agent queries it all through Grafana Cloud."
Prometheus federation, not direct scraping
OCP already scrapes kubelet, cAdvisor, kube-state-metrics, node-exporter, and
KubeVirt metrics via built-in ServiceMonitors. Duplicating all of that scrape
configuration in Alloy is fragile and pointless. Instead, Alloy federates from
the Prometheus endpoint (prometheus-k8s.openshift-monitoring.svc:9091/federate)
using the SA bearer token and the existing cluster-monitoring-view ClusterRole.
Thanos Query does not implement /federate, so the federation target is the
underlying Prometheus instance, not the Thanos Querier (verified #273).
The match[] parameters on the federation endpoint are the series budget
control — we select only what we need:
kubevirt_vmi_*— VM metrics (the demo headline)node_cpu_seconds_total,node_memory_*,node_filesystem_*,node_network_*— node healthkube_pod_status_phase,kube_pod_container_resource_requests,kube_node_status_*— namespace-filtered toaap,sales-demos-*,openshift-cnvcontainer_cpu_usage_seconds_total,container_memory_working_set_bytes— namespace-filtered
A relabel rule drops noisy CPU modes (irq, softirq, steal, nice, guest) and pause containers from cAdvisor. Measured total: ~2,098 series (2026-09-06) out of the 10k budget — substantial headroom for Phase 2 additions.
AAP metrics — separate scrape job
AAP's metrics endpoint uses different auth (basic auth, not SA bearer) and
lives on a different service. Alloy scrapes the gateway at aap.aap.svc:80
with path /api/controller/v2/metrics/ using the existing aap_username /
aap_password from the vault — no new token to manage. On AAP 2.7 the
controller service (aap-controller-service) returns 401 on direct basic-auth
requests; authentication routes through the gateway, and the controller path
prefix is required (verified #273).
API-based log collection, not hostPath
loki.source.kubernetes streams logs via the Kubernetes API, avoiding hostPath
volumes and the hostmount-anyuid SCC. The DaemonSet runs under the default
restricted SCC. The tradeoff — slightly higher API server load, possible
missed lines during pod restarts — is irrelevant for a demo environment.
Scoped to four namespaces: aap, sales-demos-{{ aap_env_name }},
openshift-cnv, grafana-alloy. System namespaces (openshift-*) are excluded
to avoid consuming the 50 GB log budget on control-plane noise.
Push credentials — five new vault keys
The existing Viewer SA token (grafana_cloud_sa_token) is for MCP reads. Alloy
needs write credentials — a Cloud Access Policy token with metrics:write and
logs:push scopes, plus the Prometheus and Loki push endpoints and usernames.
Five new top-level keys in playbooks/group_vars/all/secrets.yml (top-level
because Grafana Cloud spans both environments):
grafana_cloud_prom_push_url: "https://prometheus-prod-XX.grafana.net/api/prom/push"
grafana_cloud_prom_username: "<instance-id>"
grafana_cloud_loki_push_url: "https://logs-prod-XX.grafana.net/loki/api/v1/push"
grafana_cloud_loki_username: "<instance-id>"
grafana_cloud_push_api_key: "glc_..."
Created manually in the Grafana Cloud portal: Security → Access Policies →
Create → scopes metrics:write, logs:push → generate token.
DaemonSet sizing
100m/256Mi request, 500m/512Mi limit. On the RHDP single-node cluster (32 vCPU, 128 GiB, ~9% CPU / 36% memory used), this is negligible.
Risks
| Risk | Mitigation |
|---|---|
Prometheus federation 403 with cluster-monitoring-view |
Playbook creates the CRB; verified working (#273). Thanos Query does not implement /federate — target is prometheus-k8s |
| AAP controller service 401 on direct basic-auth | Use the gateway at aap.aap.svc:80 with path /api/controller/v2/metrics/; verified working (#273) |
| RHDP environment expires — Alloy dies | Grafana Cloud retains data; re-run playbook |
Docker Hub rate limit on grafana/alloy image pull |
Mirror to quay.io or PAH container registry |
| Cluster-scoped RBAC resources survive namespace deletion | alloy_state=absent teardown path cleans up everything |
Verification via Grafana MCP
Once data flows, the existing MCP server (Phase 0) can query it:
list_prometheus_metric_nameswithregex: "node_cpu_seconds_total"— confirms metrics arrivingquery_prometheuswithexpr: "up"— confirms scrape targets healthylist_loki_label_names— confirms logs arriving with expected labelsquery_prometheuswithexpr: "count({__name__!=\"\"})"— confirms under 10k series
Files
| File | Action |
|---|---|
playbooks/deploy_alloy.yml |
Create — the playbook |
.claude/skills/sales-demos-alloy/SKILL.md |
Create — the skill |
playbooks/group_vars/all/secrets.yml.example |
Modify — add 5 push credential keys |
utilities/check-secrets-example.py |
Modify — add STAGED entries if needed |
CHANGELOG.md |
Modify |
No new collection dependencies — kubernetes.core covers everything.
Phase 2 + 3 — Dashboard as code (#275)
Phases 2 and 3 are combined: dashboards are defined as committed JSON and pushed to Grafana Cloud by a playbook, so the demo story and the config-as-code thesis ship together.
What shipped
A single "Cluster Health" dashboard (playbooks/files/grafana/cluster-health.json)
that serves as both a pre-demo readiness check and a customer-facing demo artifact.
Five sections:
- Overview — stat panels for nodes ready, VMs running, pods healthy, AAP status, Alloy status, series budget.
- Cluster Nodes — CPU utilization, memory usage, filesystem usage, network throughput.
- KubeVirt VMs — VM status table, per-VM CPU/memory/network.
- AAP Platform — capacity gauge, running/pending jobs, managed hosts, job templates, DB connections, license expiry, pod CPU/memory.
- Logs — Loki log panel with namespace filtering.
A cluster template variable makes one dashboard definition work for both
sandbox and demo environments. A namespace multi-select variable filters logs.
Credential strategy
A second Grafana Cloud service account with Editor role
(grafana_cloud_editor_sa_token). The existing Viewer SA stays for MCP reads —
the governance thesis (MCP reads, Ansible writes) is a demo talking point, so
the tokens stay separate.
Files
| File | Action |
|---|---|
playbooks/files/grafana/cluster-health.json |
Create — the dashboard JSON |
playbooks/deploy_dashboard.yml |
Create — the playbook |
.claude/skills/sales-demos-dashboard/SKILL.md |
Create — the skill |
playbooks/group_vars/all/secrets.yml.example |
Modify — add Editor SA token key |
utilities/check-secrets-example.py |
Modify — remove grafana_cloud_url from STAGED |
CHANGELOG.md |
Modify |
No new collection dependencies — ansible.builtin.uri is core Ansible.
What's next
- More dashboards (VM provisioning timing, AAP job duration histograms)
- Alerting rules (Phase 4, not yet planned)
- Dynatrace pairing (#99) for application-level observability