Back to skills
SKILL.md
Loom Grafana
ASecurityObservability visualization with Grafana and the LGTM stack.
- 56 stars
- 0 votes
- 0 copies
- 1 view
- Added September 5, 2026
Works with
Security analysis
100/100npx -y skills add cosmix/loom --skill loom-grafana --agent claude-codeAre you the author of Loom Grafana?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-grafana)---
name: loom-grafana
description: Observability visualization with Grafana and the LGTM stack.
allowed-tools:
- Read
- Grep
- Glob
- Edit
- Write
- Bash
triggers:
- grafana
- dashboard
- panel
- visualization
- logql
- traceql
- loki
- tempo
- mimir
- data source
- annotation
- variable
- template
- row
- stat
- graph
- table
- heatmap
- gauge
- bar chart
- pie chart
- time series
- logs panel
- traces panel
- LGTM stack
---
# Grafana and the LGTM Stack
Visualization, dashboards, alerting, and exploration. Scope: dashboards/panels, LogQL, TraceQL, data sources, templating, Grafana alerting. **PromQL query semantics → loom-prometheus.**
## Overview
| Component | Role | Query lang |
| --------- | ---- | ---------- |
| **L**oki | Log aggregation; indexes labels only, not content | LogQL |
| **G**rafana | Visualization, dashboards, unified alerting, Explore | — |
| **T**empo | Distributed tracing; columnar Parquet, OTel-native | TraceQL |
| **M**imir | Long-term, multi-tenant Prometheus storage (`remote_write`) | PromQL |
Collector: **Grafana Alloy** (OTel Collector distribution). Grafana Agent is EOL (2025-11-01) — migrate.
## Dashboard Design
**Hierarchy:** Overview → Service → Component → Deep Dive. **Signals:** RED (Rate/Errors/Duration) for services, USE (Utilization/Saturation/Errors) for resources. Variable-driven, 24-column grid, `< 15` panels/dashboard.
### Panel types
| Panel | Best for |
| ----- | -------- |
| Time Series | trends: rates, latency, resource usage (default; replaces removed Graph) |
| Stat | single current value / sparkline |
| Gauge / Bar Gauge | progress toward a limit / Top-N comparison |
| Table | structured rows: service lists, error details |
| Heatmap | distribution over time (latency buckets); renders native histograms |
| Pie | proportions (use sparingly) |
| Logs / Traces | Loki streams / Tempo trace view |
### Panel config
- **Titles** include the unit + dimension ("P95 Latency (s) by Endpoint", not "Latency"); add a description tooltip.
- **Units:** always set `fieldConfig.defaults.unit` (`s`, `bytes`, `reqps`, `percentunit` for 0–1 ratios, `percent` for 0–100). Wrong unit is the most common panel bug.
- **Legends:** `{{label}}` templating; hide for single series; sort by value for Top-N.
- **Thresholds:** green (normal) / yellow (warning) / red (critical); drive Stat/Gauge color and Time Series bands.
- **Data links:** drill Overview→Service→Component; link to logs/traces/runbooks.
## Variables & Templating
| Type | Purpose |
| ---- | ------- |
| Query | populate from a data source (`label_values(...)`) |
| Custom | static option list (envs) |
| Interval | selectable resolution step |
| Datasource | switch data sources |
| Constant / Textbox | hidden value / free-form input |
```json
{ "templating": { "list": [
{ "name": "datasource", "type": "datasource", "query": "prometheus" },
{ "name": "namespace", "type": "query", "datasource": "${datasource}",
"query": "label_values(kube_pod_info, namespace)", "multi": true, "includeAll": true },
{ "name": "app", "type": "query", "datasource": "${datasource}",
"query": "label_values(kube_pod_info{namespace=~\"$namespace\"}, app)",
"multi": true, "includeAll": true },
{ "name": "interval", "type": "interval", "auto": true,
"options": ["1m","5m","15m","1h","6h","1d"] }
] } }
```
Reference with `$var` / `${var}`. **Dependent chain:** `$datasource → $cluster → $namespace → $app → $pod`, each query filtering on the previous. Regex-filter a variable's values with `"regex": "/^$app-.*/"`.
> **⚠️ Multi-value / Include-All variables interpolate as a regex alternation — use `=~`, never `=`.** With `multi: true`/`includeAll`, Grafana renders the selection as `(prod|staging)`; the `=` matcher treats that whole string as a *literal* value and silently matches nothing (no error, blank panel). Both `$namespace` and `$app` above are `multi: true` → MUST use `{namespace=~"$namespace"}`. A Custom `allValue` like `.*` is injected verbatim (not escaped) — intentional, but a footgun in a query string.
>
> **⚠️ `label_values()` ignores the dashboard time range.** It scans ALL series matching the metric — slow on high-series backends and returns stale values (pods gone months ago). For only entities active in the window use `query_result()`, which is time-aware:
```promql
query_result(count by (service) (rate(http_requests_total[$__range]))) # only services seen in window
# vs label_values(http_requests_total, service) — all-time, slow, stale
```
## Annotations
Event markers on time-series panels, from any data source:
```json
{ "annotations": { "list": [
{ "name": "Deployments", "datasource": "Prometheus",
"expr": "changes(kube_deployment_spec_replicas{namespace=\"$namespace\"}[5m])",
"tagKeys": "deployment,namespace", "iconColor": "blue" },
{ "name": "Alerts", "datasource": "Loki",
"expr": "{app=\"alertmanager\"} | json | alertname!=\"\"", "iconColor": "red" }
] } }
```
## Dashboard as Code & Provisioning
> **Generate JSON from typed models; do not commit hand-edited UI exports.** Grafana 12 ships the **Foundation SDK** (Go/TS/Python/Java/PHP) + the **`gcx` CLI** (REST push; Git Sync = bidirectional). Typed models prevent schema drift (deprecated panel types, wrong field names) that UI exports re-introduce. For production use Foundation SDK + `gcx`, or **Terraform with the Grafana provider**.
```typescript
const dashboard = new DashboardBuilder("App Observability")
.withPanel(new TimeseriesPanelBuilder().title("Request Rate").unit("reqps"));
// gcx push
```
> **⚠️ `allowUiUpdates: true` silently discards UI edits on the next sync.** UI edits persist across refreshes but the next provisioning sync (`updateIntervalSeconds`, default 10s) overwrites the DB version with the file version, ignoring the DB `version` — no warning. Production: `allowUiUpdates: false`, `editable: false`, `disableDeletion: true`. To keep a UI edit, export the JSON back into the provisioning file.
```yaml
# /etc/grafana/provisioning/dashboards/dashboards.yaml
apiVersion: 1
providers:
- name: application
orgId: 1
folder: Applications
type: file
disableDeletion: true
editable: false
updateIntervalSeconds: 10
options: { path: /var/lib/grafana/dashboards/application }
```
> **Folder addressing:** `folderId` (numeric) was removed from API paths in Grafana 10 — use `folderUid` (string; omit/empty = default folder). `overwrite: true` always wins over the DB dashboard regardless of `version`.
**K8s delivery:** a ConfigMap with label `grafana_dashboard: "1"` (sidecar auto-import), or the Grafana Operator `GrafanaDashboard` CRD (`spec.instanceSelector` + inline `json`).
## Data Sources
> **⚠️ Provisioned data sources need an explicit, stable `uid`.** Without one Grafana generates a random UID each startup; dashboard panels embed that UID, so on redeploy/recreate every panel shows "Datasource not found" — silent at provision time (Grafana loads fine, panels broken). Declare a pinned human-readable `uid` and reference it from dashboards via the object form `{type, uid}`, not the legacy name string (name refs break on rename).
```yaml
# datasources.yaml (apiVersion: 1)
datasources:
- name: Mimir
type: prometheus
uid: prometheus_uid # pinned, stable
url: http://mimir:8080/prometheus
jsonData:
httpMethod: POST
prometheusType: Mimir
exemplarTraceIdDestinations: [{ datasourceUid: tempo_uid, name: trace_id }]
cacheLevel: High
- name: Loki
type: loki
uid: loki_uid
url: http://loki:3100
jsonData:
maxLines: 1000
derivedFields: # link a log field to a trace
- datasourceUid: tempo_uid
matcherRegex: "trace_id=(\\w+)"
name: TraceID
url: "$${__value.raw}"
- name: Tempo
type: tempo
uid: tempo_uid
url: http://tempo:3200
jsonData:
# tracesToLogsV2 supersedes legacy tracesToLogs (tags/mappedTags); adds customQuery
# with ${__tags}/${__span.traceId} interpolation.
tracesToLogsV2:
datasourceUid: loki_uid
spanStartTimeShift: "-1h"
spanEndTimeShift: "1h"
customQuery: true
query: '{${__tags}} |= "${__span.traceId}"'
tracesToMetrics: { datasourceUid: prometheus_uid }
serviceMap: { datasourceUid: prometheus_uid }
nodeGraph: { enabled: true }
```
## LogQL (Loki)
**Pipeline order = stream selector → line filters → parsers → label filters.** Loki evaluates left-to-right; ordering controls how many lines reach each expensive stage (documented perf rule). Line filters `|=`/`!=` (string, cheapest) before `|~`/`!~` (regex) before parsers (`json`/`logfmt`/`pattern`/`regexp`, most expensive).
```logql
# Stream selection (indexed labels — keep low-cardinality)
{namespace="production", app=~"api|web", level!="debug"}
# Line filters
{app="api"} |= "error" != "timeout" |~ "(?i)fatal"
# Parse + label filter (filter cheaply FIRST, then parse)
{app="api"} |= "error" | json | level="error" | duration > 500ms
{app="api"} | logfmt level, caller, msg
{app="nginx"} | pattern `<ip> - - <_> "<method> <uri> <_>" <status> <_>` | status >= 400
# Metric queries
sum(rate({app="api"} |= "error" [$__rate_interval])) by (namespace)
sum(rate({app="api"} |= "error" [5m])) / sum(rate({app="api"}[5m])) # error ratio
quantile_over_time(0.95, {app="api"} | logfmt | unwrap duration_seconds(duration) [5m]) by (endpoint)
topk(10, sum by (msg) (count_over_time({app="api"} | json | level="error" [1h])))
```
## TraceQL (Tempo)
**Use scoped attributes (`span.` / `resource.`) and prefer `&&`.** Tempo stores traces in columnar Parquet; unscoped `.attr` checks multiple columns (slower). All-`&&` queries push filtering into Parquet (predicate pushdown — fastest path); `||` and structural operators bypass it.
```traceql
{ span.http.status_code = 500 && resource.service.name = "api" && span.http.method = "POST" } # fast
{ trace:rootService = "gateway" && trace:duration > 1s } # end-to-end slow traces
{ .service.name = "frontend" } >> { .service.name = "backend" && status = error } # structural
```
**TraceQL metrics = RED metrics from traces, no metrics generator** (Tempo 2.4+, query type `traceql_metrics`). Append `rate`/`quantile_over_time`/`count_over_time`/`histogram_over_time`/`compare`/`topk` to a span filter. Enables retroactive dimensional slicing Prometheus can't (p99 of DB spans by `db.system`). Avoid structural operators here — full trace processing, slow.
```traceql
{ span:name = "GET /:endpoint" } | quantile_over_time(duration, .99) by (span.http.target)
{ status = error } | rate() by (resource.service.name)
```
## Grafana Alerting
Unified alerting: multi-datasource rules, provisioned as YAML. Alert *expressions* use PromQL/LogQL (see loom-prometheus / LogQL above); the structure is Grafana-specific.
```yaml
# /etc/grafana/provisioning/alerting/rules.yaml (apiVersion: 1)
groups:
- name: application_alerts
interval: 1m
rules:
- uid: error_rate_high
title: High Error Rate
condition: A
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus_uid
model:
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 0.05
for: 5m # pending period before firing
keep_firing_for: 2m # anti-flap on exit
noDataState: NoData
execErrState: Error
labels: { severity: critical, team: platform }
annotations:
description: 'Error rate {{ printf "%.2f" $values.A.Value }}%'
runbook_url: https://wiki.company.com/runbooks/high-error-rate
# Loki-backed rule: model.expr = sum(rate({app="api"} | json | level="error" [5m])) > 10
```
Contact points + notification policies (routing tree, matchers, grouping):
```yaml
# contactpoints.yaml / policies
contactPoints:
- orgId: 1
name: pagerduty-oncall
receivers: [{ uid: pd, type: pagerduty, settings: { integrationKey: KEY, severity: critical } }]
notificationPolicies:
- orgId: 1
receiver: slack-critical
group_by: [alertname, namespace]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- receiver: pagerduty-oncall
matchers: [severity = critical, page = true]
group_wait: 10s
continue: true
```
## Complete Dashboard Example
RED dashboard: request rate, P95 latency, error ratio, error logs. Note `$__rate_interval` with `rate()`, `percentunit` on the 0–1 ratio, `{type, uid}` datasource refs, and Explore data links.
```json
{
"dashboard": {
"title": "Application Observability - ${app}",
"tags": ["observability"], "graphTooltip": 1, "refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"templating": { "list": [
{ "name": "datasource", "type": "datasource", "query": "prometheus" },
{ "name": "app", "type": "query", "datasource": {"type":"prometheus","uid":"${datasource}"},
"query": "label_values(up, app)" },
{ "name": "namespace", "type": "query", "datasource": {"type":"prometheus","uid":"${datasource}"},
"query": "label_values(up{app=\"$app\"}, namespace)", "multi": true, "includeAll": true }
] },
"panels": [
{ "id": 1, "title": "Request Rate", "type": "timeseries",
"datasource": {"type":"prometheus","uid":"${datasource}"},
"gridPos": {"h":8,"w":12,"x":0,"y":0},
"fieldConfig": {"defaults": {"unit": "reqps"}},
"targets": [{ "expr": "sum(rate(http_requests_total{app=\"$app\", namespace=~\"$namespace\"}[$__rate_interval])) by (method, status)",
"legendFormat": "{{method}} - {{status}}" }] },
{ "id": 2, "title": "P95 Latency", "type": "timeseries",
"datasource": {"type":"prometheus","uid":"${datasource}"},
"gridPos": {"h":8,"w":12,"x":12,"y":0},
"fieldConfig": {"defaults": {"unit": "s",
"thresholds": {"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":1}]}}},
"targets": [{ "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{app=\"$app\", namespace=~\"$namespace\"}[$__rate_interval])) by (le, endpoint))",
"legendFormat": "{{endpoint}}" }] },
{ "id": 3, "title": "Error Rate", "type": "timeseries",
"datasource": {"type":"prometheus","uid":"${datasource}"},
"gridPos": {"h":8,"w":12,"x":0,"y":8},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1}},
"targets": [{ "expr": "sum(rate(http_requests_total{app=\"$app\", namespace=~\"$namespace\", status=~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{app=\"$app\", namespace=~\"$namespace\"}[$__rate_interval]))",
"legendFormat": "Error %" }] },
{ "id": 4, "title": "Recent Error Logs", "type": "logs",
"datasource": {"type":"loki","uid":"loki_uid"},
"gridPos": {"h":8,"w":12,"x":12,"y":8},
"options": {"showTime": true, "wrapLogMessage": true, "enableLogDetails": true},
"targets": [{ "expr": "{app=\"$app\", namespace=~\"$namespace\"} | json | level=\"error\"", "refId": "A" }] }
],
"links": [
{ "title": "Explore Traces", "type": "link", "icon": "gf-traces",
"url": "/explore?left={\"datasource\":\"Tempo\",\"queries\":[{\"query\":\"{resource.service.name=\\\"$app\\\"}\",\"queryType\":\"traceql\"}]}" }
]
},
"overwrite": true, "folderUid": null
}
```
## LGTM Backend Config
```yaml
# loki.yaml — new installs: schema v13 + store tsdb (required for structured metadata, OTLP, bloom)
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: s3
schema: v13
index: { prefix: index_, period: 24h }
compactor:
retention_enabled: true
delete_request_store: s3 # REQUIRED in Loki 3.0 when retention_enabled
limits_config:
retention_period: 744h
# ⚠ shared_store was REMOVED in Loki 3.0 (boltdb_shipper/tsdb_shipper/compactor). Storage is
# selected per-period via object_store above; a lingering shared_store fails to start.
# Existing cluster: add a NEW period_config with a future from:, don't mutate the old one.
```
```yaml
# tempo.yaml — OTLP + metrics generator remote_write to Mimir
distributor:
receivers: { otlp: { protocols: { http: {}, grpc: {} } } }
metrics_generator:
storage: { path: /var/tempo/generator/wal,
remote_write: [{ url: http://mimir:9009/api/v1/push, send_exemplars: true }] }
storage:
trace: { backend: s3, s3: { bucket: tempo-traces, region: us-east-1 }, wal: { path: /var/tempo/wal } }
```
## Expert Practices: Idioms, Anti-Patterns & Gotchas
Mechanism-level rules. Many are *silent* — no error, just wrong or empty panels.
### Query Idioms
**Use `$__rate_interval` (never `$__interval` or fixed `[5m]`) with `rate()`/`increase()`.** These need ≥2 samples in the range vector. `$__interval` is the display step and shrinks below the scrape interval on zoom → empty/NaN gaps. `$__rate_interval = max($__interval + scrape_interval, 4 * scrape_interval)`, always ≥4× scrape. Reserve `$__interval` for `avg_over_time`-style aggregations. Silent correctness bug at narrow zoom.
**Order Grafana transformations: normalize → combine(join) → filter → reduce.** A pipeline; each step feeds the next. Classic mistake: **Reduce** (collapses each series to one value) *before* a **Join** destroys the shared Time field the join keys on → nothing matches. Correct: convert types; Outer join on Time (or Merge); filter; reduce last only if a scalar is needed. **Merge joins on ALL matching fields** — queries sharing more fields than intended multiply rows; use Outer join on Time explicitly. The transformation debug icon shows each step's output shape.
### LogQL Gotchas (silent wrong/empty data)
**Parsers add an `__error__` label rather than dropping lines.** A failed `json`/`logfmt`/`pattern` line is NOT filtered — it gets `__error__` and still counts, inflating aggregations. Filter `| __error__=""`. Also: bare `| json` silently skips array values (`| json first="servers[0]"`); an extracted label colliding with a stream label is suffixed `_extracted` — reconcile with `| label_format level=level_extracted`.
```logql
{app="api"} | json | __error__="" | level="error" # excludes parse failures
{app="api"} | json | level="error" # silently includes failed-parse lines
```
**`unwrap` defaults to raw float64 — use `duration_seconds()` / `bytes()`.** `150ms` or `5 MiB` fails raw conversion; instead of erroring, the line gets `__error__` and drops out, so `quantile_over_time`/`avg_over_time` compute over only the parseable subset — silently. `duration_seconds()` expects Go format (`1s`,`100ms`); a field holding integer `1000` (ms) must be unwrapped raw and divided by 1000.
**LogQL label-filter boolean precedence is strictly left-to-right, NOT AND-before-OR.** `| duration >= 20ms or method="GET" and size <= 20KB` parses as `((duration>=20ms or method="GET") and size<=20KB)`. Always parenthesize multi-predicate filters.
**Loki labels are streams — keep high-cardinality fields in structured metadata, not labels.** Each unique label set is a separate stream with its own chunks; high-card labels (`trace_id`, `request_id`, `pod`, `user_id`) explode the index and force tiny-chunk write amplification. Loki 3.0 (v13+TSDB) adds **structured metadata**: per-line KV, not indexed but queryable; the OTLP endpoint maps non-default OTel attributes there. Critical: structured metadata CANNOT go in the `{}` selector — it's a pipeline filter (`| trace_id="..."`) with no index pre-filter. Keep dynamic labels to single-/low-tens cardinality.
```logql
{namespace="prod", app="api"} | trace_id="0242ac12" | pod="svc-abc" # low-card labels, rest filtered
{namespace="prod", app="api", trace_id="0242ac12", pod="svc-abc"} # stream explosion
```
### TraceQL Gotcha
**Structural operators return the RIGHT-side spans; `span:duration` ≠ `trace:duration`.** `{ .service.name="gateway" } >> { status=error }` returns the error *descendant* spans, not the gateway parent. `span:duration` is one span's `end-start`; `trace:duration` is whole-request wall time (`max(end)-min(start)`) and trace-level intrinsics are far more performant — use `trace:duration` for end-to-end latency.
### Alerting Gotchas
**NoData / Error create synthetic `DatasourceNoData` / `DatasourceError` instances that bypass existing silences.** They carry different labels (`alertname=DatasourceNoData`, plus `datasource_uid`, `rulename`) from the parent, so silences/mute-timings/policies keyed to the parent do NOT apply — the #1 surprise-page source. Options: Set NoData (default), Set Alerting, Set Normal, Keep last state; `for` applies (Normal→Pending→NoData); `grafana_state_reason` explains divergence. To suppress: silence/route on the synthetic labels, or `noDataState: KeepLast` for transient hiccups.
**Symptom-based alerting + `for`/`keep_firing_for`** — page on user-visible latency/errors/availability, not causes (see loom-prometheus for the SRE rationale). Grafana-specific: pair `for` (pending) with `keep_firing_for` (anti-flap on exit).
### Currency: removed mechanisms (Grafana 12 / Loki 3.0 / Tempo 2.4+)
**Graph (old) panel and in-panel `alert` blocks are gone.** AngularJS was removed in Grafana 12; `"type": "graph"` (with `yaxes[]` + root-level `thresholds`) is force-migrated to `timeseries` on load, dropping that config — emit `"type": "timeseries"` with `fieldConfig.defaults`. The panel-level `"alert"` block was removed with legacy alerting in Grafana 11 — completely inert (no rule, no firing). Define alerts under `/etc/grafana/provisioning/alerting/`.
**Prometheus native histograms are GA.** `histogram_quantile()` works on the native metric directly — no `_bucket`, no `by(le)` (details → loom-prometheus). **Grafana caveat:** the Time Series panel can't render a native histogram; use Histogram or Heatmap.
## Verification Checklist
- [ ] Every `rate()`/`increase()` in a panel/alert uses `[$__rate_interval]`, not `[$__interval]` or a fixed window
- [ ] Multi-value / Include-All variables are matched with `=~` (never `=`)
- [ ] Panels reference data sources by `{type, uid}`; provisioned data sources have a pinned `uid`
- [ ] Every panel sets an explicit `unit` (`percentunit` for 0–1 ratios); titles include units
- [ ] `"type": "timeseries"` (no `"graph"`); no in-panel `"alert"` blocks
- [ ] LogQL pipeline is selector → line filter → parser → label filter; parsers followed by `| __error__=""`; `unwrap` uses `duration_seconds()`/`bytes()`
- [ ] TraceQL uses scoped `span.`/`resource.` attributes and `trace:duration` for end-to-end latency
- [ ] Loki: schema v13 + tsdb; no `shared_store`; `delete_request_store` set when retention enabled
- [ ] Alert rules account for NoData/Error routing on synthetic labels
- [ ] Provisioning: production dashboards `allowUiUpdates: false`; generated from typed models, not UI exports
## Resources
- Grafana: <https://grafana.com/docs/grafana/latest/> · LogQL: /docs/loki/latest/query/
- Tempo/TraceQL: <https://grafana.com/docs/tempo/latest/traceql/>
- Foundation SDK + `gcx`, Terraform Grafana provider, Grafana Operator
Attribution
Comments
Loading comments…