Charting Grafana metrics
Overview
Renders a dark-themed, Grafana-styled PNG line chart from a PromQL query, fetched through the Grafana datasource proxy (so no direct Prometheus network access is needed, the Grafana SA token is enough). Reproducible and scriptable, unlike a manual screenshot. Can attach the result straight to a Jira issue.
Snapshot first when a panel already shows it. Grafana Cloud stacks ship server-side rendering, so gcx dashboards snapshot returns the real panel as a PNG there. This skill is for what a snapshot cannot give: a side-by-side comparison across queries or time ranges that no single panel encodes, and self-hosted instances without the Image Renderer plugin, where the snapshot errors out.
Pre-flight
Every Grafana call here goes through gcx. Check it once per session:
command -v gcx >/dev/null || { echo "install: brew install gcx"; exit 1; }
gcx config check || { echo "not ready: gcx login"; exit 1; }
Get the query right before you draw it. This skill renders whatever PromQL you hand it, and a wrong query is worse here than in a dashboard: the PNG lands in a ticket as frozen evidence nobody replays, and it looks official. REQUIRED BACKGROUND for the query itself: use the grafana-dashboards skill, sections PromQL essentials, Aggregation traps and Reading distributions and tails. The ones that bite on a comparison chart: aggregate after rate and never before, wrap both sides in max by (...) when a rollout leaves two series per pod, never histogram_quantile a base-2 exponential histogram, and give every subquery an explicit step.
When to use
- Comparing 2+ series: version A vs B, canary vs control, before/after a change.
- Attaching evidence to a Jira ticket, MR, or incident writeup.
- Grafana panel render fails (
gcx dashboards snapshoterrors: no Image Renderer on that self-hosted context).
Not for: interactive exploration (use Grafana), single-value/table data, or a screenshot of an existing panel on Grafana Cloud (gcx dashboards snapshot).
Setup (once)
matplotlib is required. If missing, use an isolated venv (don't pollute system Python):
python3 -m venv ${CLAUDE_SKILL_DIR}/.venv
${CLAUDE_SKILL_DIR}/.venv/bin/pip install -q matplotlib
Quick reference
| Need | Flag |
|---|---|
| Which datasource | --datasource-uid (find via gcx datasources list or panel queries) |
| The query | --expr 'PromQL' |
| Time window | --start now-6h --end now --step 60 |
| Series label source | --legend-key instance (default; any metric label) |
| Prettier names | --rename '{"raw-value":"Nice Name"}' |
| Per-series mean/min/max | printed to stdout after saving (no extra query needed) |
| Callout on the peak | --annotate-max "text" |
| Which Grafana | --grafana-url https://<host>: uses the gcx context whose server has that host (default: gcx's current context) |
| Which gcx context | --gcx-context <name> (default $GCX_CONTEXT); for an instance named without a URL, pick it from gcx config list-contexts |
| Attach to Jira | --attach-jira ABC-123 (see the env vars below, and the approval gate in Workflow) |
Environment
Nothing is hardcoded: no host, no address. Set these in your shell profile.
| Variable | Needed for | Notes |
|---|---|---|
GRAFANA_URL + GRAFANA_TOKEN | static token | the token is only sent to the host of GRAFANA_URL (then GRAFANA_SERVICE_ACCOUNT_TOKEN, GTOK) |
GCX_CONTEXT | gcx | gcx context to force, same as --gcx-context |
JIRA_API_TOKEN JIRA_EMAIL JIRA_BASE | --attach-jira only | all three, no default |
Resolution order, identical across every Grafana skill: --token; --gcx-context / $GCX_CONTEXT; --grafana-url (the env token for the host of $GRAFANA_URL, else the gcx context serving that host, else exit with the gcx login to run); gcx's current context; the env token when gcx is missing. Through gcx, its own OAuth refresh applies and this script never reads or caches a credential itself. The resolver is duplicated in each skill on purpose, so either works installed alone.
Workflow
- Get the datasource UID + PromQL from the dashboard (
gcx api /api/dashboards/uid/<uid>, readpanels[*].targets[*].expr) or write the query yourself. - Run the script (via the venv python). Colors auto-assign from the Grafana palette in series order; legend shows mean/max per series.
- Read the PNG back to eyeball it before sharing.
- Optionally attach to Jira with
--attach-jira. It is a tracker write visible to the whole team: follow the approval gate of theticket-conventionsskill first (show the ticket id and the PNG, wait for an explicit yes). A failed upload exits non-zero.
Example (the canonical one: HAProxy 3.2 vs 2.7 node memory)
VENV=${CLAUDE_SKILL_DIR}/.venv/bin/python
SKILL=${CLAUDE_SKILL_DIR}
export JIRA_API_TOKEN=$(zsh -l -c 'echo $JIRA_API_TOKEN') # only if attaching
export [email protected] JIRA_BASE=https://example.atlassian.net
DS_UID=abc123XYZ # from `gcx datasources list`
$VENV $SKILL/plot_grafana.py \
--datasource-uid $DS_UID \
--expr '(1 - (node_memory_MemAvailable_bytes{instance=~"lb-edge-dc1-(1|4)"} / node_memory_MemTotal_bytes{instance=~"lb-edge-dc1-(1|4)"})) * 100' \
--start now-6h --step 60 \
--title "lb-edge - node memory used % · 3.2 vs 2.7 (dc1, last 6h)" \
--ylabel "Memory used %" --ymin 0 --ymax 45 \
--rename '{"lb-edge-dc1-1":"HAProxy 3.2.20 (dc1-1)","lb-edge-dc1-4":"HAProxy 2.7.11 (dc1-4, control)"}' \
--annotate-max "reload: old+new worker coexist → node mem ~2x" \
--grafana-url https://grafana.example.com \
--out ./mem-3.2-vs-2.7.png \
--attach-jira ABC-123
Common mistakes
- Chart drawn from a query nobody checked → see the PromQL pointer above; a plausible wrong number is the failure mode, not an error.
- 401 from the proxy → with a static token, it does not belong to that host; through gcx, the refresh token expired (about monthly), run
gcx login <context>. - Using system
python3→ModuleNotFoundError: matplotlib. Use the venv python. - Empty/one flat line where you expect several → the
--exprregex matched a single series; widen the label matcher. - Series unnamed / all "series" →
--legend-keypoints at a label the metric doesn't have; pick one it does (check the raw query result). :9100(or any:port) in legend names → auto-stripped from the derived series name, so--renametargets the clean host (e.g.lb-edge-dc1-1, notlb-edge-dc1-1:9100). No need to list both forms.- Relative times take one number and one unit, s/m/h/d (
now-90m,now-6h,now-2d). Compound (now-1h30m), weeks (now-1w) and rounding (now/d) are not supported: convert them (now-90m,now-7d).