Free lesson · GenAI Platform Engineering
Deploy cost dashboards with Grafana
Build Grafana dashboards showing real-time spend, budget burn rate, team comparisons, and cost optimization opportunities. Configure alerting for budget overruns.
Course: AI Developer Platform Engineering · Chapter 9 · Cost Allocation & Chargeback
Free to read — no subscription required.
Introduction
Engineers often have LLM cost data sitting in a PostgreSQL events table and a Prometheus scrape endpoint with no unified view that shows which teams are over budget or which models are driving spend. Without a live dashboard, operators resort to ad-hoc SQL queries and miss budget breaches until the billing cycle closes. This lesson shows you how to deploy a Grafana dashboard that pulls daily cost trends from PostgreSQL and real-time utilization metrics from Prometheus, filtered by department and team variables. By the end, you'll be able to define a dashboard JSON with variable templates and budget thresholds, then provision it into Kubernetes as a ConfigMap so Grafana loads the view automatically.
Key Terminology
- Grafana provisioning sidecar — a Kubernetes sidecar container that watches a mounted volume in the
monitoringnamespace and automatically loads any.jsonfile it finds, so a ConfigMap-delivered dashboard appears in Grafana's UI without a manualimportstep. - Variable template — a Grafana dashboard object of
type: querythat populates a dropdown filter by executing a SQL query against a data source; the lesson defines three —department,team, andmodel— that cascade so selecting a department automatically narrows the team list and filters all panel queries. - Dual data source dashboard — a Grafana pattern that routes different panels to different backends; here the
timeseriespanel targets PostgreSQL for historicalcost_eventsaggregations while thegaugepanel targets Prometheus for the liveplatform_budget_utilization_ratiometric. - Budget utilization threshold — a
fieldConfig.defaults.thresholds.stepsarray on the gauge panel that maps ratio values to colors: green below 0.7, yellow from 0.7, and red from 0.9, giving operators an at-a-glance signal of budget pressure without reading raw numbers. - Kubernetes ConfigMap — a
v1Kubernetes resource whosedatafield carries the Grafana dashboard JSON under a.json-suffixed key; applied to themonitoringnamespace, it delivers the dashboard to Grafana's provisioning sidecar without any manual cluster login or UI action. - Dashboard serialization — the step of calling
json.dumps(configmap, indent=2)to convert the Python dict produced byprovision_as_configmapinto the JSON text thatkubectl apply -fcan consume, completing the pipeline from Python function to cluster resource.
Concepts
Why Two Data Sources Instead of One
A cost dashboard needs to answer two fundamentally different questions: how was money spent over time, and how close is a team to its budget right now? PostgreSQL and Prometheus serve these roles without overlap.
PostgreSQL holds the cost_events table — an accumulating record of every model invocation with its total_cost, tenant_id, and timestamp. Aggregating with date_trunc('day', timestamp) and GROUP BY produces the stable daily trend that engineering leads and finance teams actually review: what was spent, by whom, on which days. Prometheus, by contrast, continuously scrapes platform_budget_utilization_ratio as a live ratio. It answers "is this team burning through budget faster than expected right now?" rather than "what did they spend last Tuesday?" Its short retention window makes it unsuitable for month-to-date trend queries, but it is exactly right for a real-time gauge.
Routing the timeseries panel to PostgreSQL and the gauge panel to Prometheus (see Code Walkthrough) avoids the failure modes of each system used alone: PostgreSQL cannot produce a continuously updated gauge without expensive streaming infrastructure, and Prometheus cannot reconstruct a billing-cycle trend from a 15-day scrape window.
Variable Templates as Cascading Drill-Down
Dashboard variable templates let a single JSON definition serve every team and department without duplicating panels. The three templates — department, team, and model — are all declared as type: query, meaning Grafana re-executes the underlying SQL each time an operator changes a selection. The team query filters on '$department', creating a cascade: choosing "Platform Engineering" in the department dropdown automatically restricts the team list to teams within that department. The same $department token appears verbatim inside the timeseries panel's rawSql subquery, so chart data narrows to match without any additional panel configuration.
This pattern eliminates the N-panels-per-team antipattern — a dashboard with one panel hardcoded per team that becomes unmaintainable past a dozen teams. With variable templates, operators drill from an org-wide view down to a single team's daily spend in two dropdown clicks, and the dashboard JSON stays a fixed size regardless of how many teams exist.
ConfigMap Provisioning and the Sidecar Pattern
Grafana supports file-based dashboard provisioning: any .json file placed in a watched directory — either at startup or via a volume mount into a running pod — is loaded automatically. In Kubernetes, a ConfigMap is the standard mechanism for injecting configuration files into a pod's filesystem. The provision_as_configmap function (see Code Walkthrough) constructs the required apiVersion: v1 / kind: ConfigMap manifest, places the dashboard JSON under a .json-keyed data entry, and targets the monitoring namespace where Grafana's sidecar watches for new files.
Once kubectl apply -f processes the serialized output, the sidecar detects the new ConfigMap on its next refresh cycle and registers the dashboard — no Grafana UI login, no manual import, no API call required. This matters operationally: dashboard definitions become code changes tracked in version control and deployed through the same pipeline as any other Kubernetes manifest, rather than configuration that lives only inside Grafana's internal database.
The Serialization Step: From Python Dict to kubectl Manifest
The final step converts a nested Python dict into text that kubectl apply accepts. json.dumps(configmap, indent=2) produces indented JSON, which kubectl treats identically to YAML. The nesting structure is deliberate: the outer dict carries the Kubernetes object fields (apiVersion, kind, metadata, data), while data["{name}.json"] holds the Grafana dashboard JSON as a plain string value — produced by an inner json.dumps(dashboard["dashboard"]) call without indentation, since Grafana's sidecar parses it programmatically. Keeping these two serialization passes separate is important: a single json.dumps of the entire structure would embed Kubernetes metadata inside the Grafana JSON object, which the sidecar would reject, while double-encoding data as a nested JSON-in-JSON string produces a ConfigMap that kubectl itself will refuse.
Code Walkthrough
Now that you understand how PostgreSQL cost queries, Prometheus real-time metrics, and Kubernetes ConfigMap provisioning fit together as a unified monitoring stack, the implementation has two moving parts: the dashboard JSON definition and the ConfigMap that delivers it to Grafana.
The function below defines three variable templates — department, team, and model — so operators can drill from organization-wide totals down to a single team's spending. Two panel types cover the core views: a timeseries panel queries PostgreSQL for daily cost trends filtered by the selected department, and a gauge panel reads the Prometheus metric platform_budget_utilization_ratio and applies green/yellow/red thresholds so budget pressure is visible at a glance. Budget threshold alerts from Alertmanager annotate the time-series panel automatically once the Grafana data source connection is live.
Code snippetpython
1import json 2 3def generate_cost_overview_dashboard() -> dict: 4 return { 5 "dashboard": { 6 "title": "LLM Cost Overview", 7 "tags": ["cost", "llm", "platform"], 8 "templating": { 9 "list": [ 10 { 11 "name": "department", 12 "type": "query", 13 "query": "SELECT DISTINCT department FROM tenants", 14 }, 15 { 16 "name": "team", 17 "type": "query", 18 "query": "SELECT team_id FROM tenants WHERE department='$department'", 19 }, 20 { 21 "name": "model", 22 "type": "query", 23 "query": "SELECT DISTINCT model_id FROM cost_events", 24 }, 25 ] 26 }, 27 "panels": [ 28 { 29 "title": "Daily Cost Trend", 30 "type": "timeseries", 31 "datasource": "PostgreSQL", 32 "targets": [{ 33 "rawSql": ( 34 "SELECT date_trunc('day', timestamp) AS time, " 35 "SUM(total_cost) AS cost " 36 "FROM cost_events " 37 "WHERE tenant_id IN (" 38 " SELECT team_id FROM tenants " 39 " WHERE department='$department') " 40 "GROUP BY 1 ORDER BY 1" 41 ), 42 }], 43 "fieldConfig": {"defaults": {"unit": "currencyUSD"}}, 44 }, 45 { 46 "title": "Budget Utilization", 47 "type": "gauge", 48 "datasource": "Prometheus", 49 "targets": [{"expr": 'platform_budget_utilization_ratio{department="$department"}'}], 50 "fieldConfig": { 51 "defaults": { 52 "thresholds": { 53 "steps": [ 54 {"value": 0, "color": "green"}, 55 {"value": 0.7, "color": "yellow"}, 56 {"value": 0.9, "color": "red"}, 57 ] 58 } 59 } 60 }, 61 }, 62 ], 63 } 64 }
To deploy the dashboard, wrap it in a Kubernetes ConfigMap. Grafana's provisioning sidecar watches the monitoring namespace and loads any .json file it finds in the mounted volume, so the dashboard appears in the Grafana UI without a manual import step.
Code snippetpython
1def provision_as_configmap(dashboard: dict, name: str = "cost-overview") -> dict: 2 return { 3 "apiVersion": "v1", 4 "kind": "ConfigMap", 5 "metadata": { 6 "name": f"grafana-{name}", 7 "namespace": "monitoring", 8 }, 9 "data": {f"{name}.json": json.dumps(dashboard["dashboard"])}, 10 } 11 12# Serialize the ConfigMap for kubectl apply 13configmap = provision_as_configmap(generate_cost_overview_dashboard()) 14print(json.dumps(configmap, indent=2))
After running kubectl apply -f with the printed output, Grafana's provisioning sidecar picks up the file on its next refresh cycle and the dashboard becomes available under the cost tag. Confirm that the Daily Cost Trend panel returns data rows and the Budget Utilization gauge shows a non-zero value for at least one department before moving on.
Do's and Don'ts
Having just walked through the dashboard JSON, the ConfigMap wrapper, and the provisioning sidecar pipeline, these rules keep that pipeline from silently failing once it lands in the monitoring namespace.
Do's
- ✓Do serialize only the inner
dashboard["dashboard"]dict into the ConfigMap's data field —provision_as_configmapcallsjson.dumps(dashboard["dashboard"]), notjson.dumps(dashboard), because Grafana's provisioning sidecar expects the raw dashboard object as the.jsonfile content; including the outer{"dashboard": …}wrapper produces a file the sidecar silently rejects. - ✓Do place the ConfigMap in the
monitoringnamespace with a.json-suffixed data key — Grafana's provisioning sidecar watches the mounted volume in that namespace and only auto-loads files whose keys end in.json; using the wrong namespace or a different extension means the dashboard never appears in the UI without a manualimportstep. - ✓Do thread the
$departmentvariable through both the PostgreSQL subquery and the Prometheus label selector — thetimeseriespanel filterscost_eventsviaWHERE department='$department'through thetenantssubquery, and thegaugepanel uses{department="$department"}onplatform_budget_utilization_ratio; both must reference the same variable so the two panels stay in sync when an operator changes the department filter.
Don'ts
- ✗Don't pass the full
generate_cost_overview_dashboard()returnvalue directly tojson.dumpsin the ConfigMap — the function returns{"dashboard": {…}}as a Python convention, but only the inner dict is the valid Grafana provisioning payload; wrapping the wrapper produces a top-level"dashboard"key inside the.jsonfile that the sidecar cannot parse as a dashboard definition. - ✗Don't omit the
teamvariable template that cascades off$department— the querySELECT team_id FROM tenants WHERE department='$department'is what lets operators drill from organization-wide cost totals down to per-team spend; without it the dashboard can only filter at department granularity and the chargeback reporting use case collapses to a single aggregation level. - ✗Don't skip confirming that the
platform_budget_utilization_ratiogauge returns a non-zero value afterkubectl apply— a gauge stuck at zero means the Prometheus data source is misconfigured or thedepartmentlabel on the metric doesn't match the variable value, and the green/yellow/red threshold steps (0.7 and 0.9) will never fire regardless of actual budget pressure.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Platform Engineering subscription.
From · cancel anytime
More free lessons in AI Developer Platform Engineering
- Ch 6Implement K8s namespace provisioning with quota enforcement
- Ch 6Deploy multi-tenant infrastructure with Helm overrides
- Ch 9Deploy cost dashboards with GrafanaYou are here
- Ch 10Deploy onboarding system with ArgoCD integration
- Ch 12Design tool registry model with MCP server metadata
- Ch 12Deploy MCP hub with Helm and agent integration
- Ch 13Deploy managed pgvector with Helm StatefulSet