Rules and workflow for the "Org Monitoring by sfdx-hardis" Grafana dashboards v2 (docs/grafana/dashboards-v2). Use when creating or modifying v2 dashboards or alert rules, AND whenever a monitoring indicator (notification type, metric, logElements shape) is created, updated, or deleted - every indicator evolution must handle its impact on the dashboards.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Rules and workflow for the "Org Monitoring by sfdx-hardis" Grafana dashboards v2 (docs/grafana/dashboards-v2). Use when creating or modifying v2 dashboards or alert rules, AND whenever a monitoring indicator (notification type, metric, logElements shape) is created, updated, or deleted - every indicator evolution must handle its impact on the dashboards.
user-invocable
false
Grafana Dashboards v2 (Org Monitoring by sfdx-hardis)
Everything needed to build, modify and validate the v2 Grafana dashboards without rediscovering the constraints. The generic Grafana skills (dashboarding, promql, loki, alerting-irm in .claude/skills/) cover Grafana itself; THIS skill covers the sfdx-hardis-specific rules.
Source of truth
Dashboards: docs/grafana/dashboards-v2/*.json - GENERATED FILES, never edit them directly.
Generator: docs/grafana/dashboards-v2/generator.mjs. Edit it, then run node generator.mjs from that folder to rewrite all JSONs.
Panel id lock: docs/grafana/panel-ids-v2.json - generated, commit it with the dashboards, never hand-edit. Keeps every panel id stable across insertions and reorderings (see the DON'Ts).
Alert pack: docs/grafana/alerts-v2/sfdx-hardis-alerts.yaml (hand-written YAML, all rules isPaused: true).
Lint suite: test/grafana-dashboards-v2.test.ts - enforces most rules below; run with npx mocha "test/grafana-dashboards-v2.test.ts".
Installer command: hardis:org:configure:grafana-dashboards (src/common/grafana/grafanaDashboardsInstaller.ts) fetches the dashboards from GitHub raw at runtime and imports them via the Grafana HTTP API. When adding or removing a dashboard JSON, update GRAFANA_V2_FALLBACK_DASHBOARD_FILES there (enforced by the lint suite).
v1 dashboards (docs/grafana/dashboards/) are frozen: never modify them.
Data model reminder
sfdx-hardis pushes to two backends (see src/common/notifProvider/apiProvider.ts):
Loki (logs): stream labels source="sfdx-hardis", type (notification type key), orgIdentifier, gitIdentifier, severity. The log line is a JSON body: metric, _metrics, _metricsKeys, _logElements (detail rows), _title, _logBodyText, _dateTime, _jobUrl.
Prometheus/Mimir (metrics): each metrics key of a notification becomes <Key>_metric (plus _max / _percent variants for object-form values), labeled with source, type, orgIdentifier, gitIdentifier.
PII: in CI, sensitive fields in _logElements are pseudonymized (user_xxxxxxxxxx, id_xxxxxxxxxx, ip_xxxxxxxxxx) by src/common/utils/anonymizeUtils.ts (levels: standard / strict, actor fields readable at standard). Dashboards must never rely on readable usernames.
Two emission rules decide whether a panel can show a number at all:
A value of exactly 0 IS emitted.apiProvider used to test min/max/percent/value for truthiness, so a metric sitting at zero produced no field and the series disappeared: a limit at 0% read as "no data" instead of charting a zero. Fixed in 7.24; older CLI versions still drop it, which is what RECENT_CLI_NOTE warns about.
A feature that is not provisioned emits NO metric, rather than a zero (no AiUsageCreditsTotal on an org without a Data 360 consumption model). A confident 0 would claim "nothing consumed" when the truth is "not available here". Such panels need a noValue empty state ("Requires Data 360"), never a threshold that treats 0 as good news.
DOs
DO wrap every Prometheus selector in a lookback window: last_over_time(M{...}[2d]), avg_over_time(M{...}[7d]), etc. Metrics arrive ONCE PER DAY; a bare selector returns "no data" (5-minute default lookback). Enforced by lint.
DO aggregate per org: max by (orgIdentifier) (...) on every stat/gauge query (min by for time-to-exhaustion forecasts). Orgs can expose several series for one metric (two gitIdentifier values after a monitoring repo/branch rename); without aggregation, stat panels show two confusing values. For per-limit series use max by (__name__).
DO use the datasource variables${ds_prom} / ${ds_loki} for every panel and target (helpers DS_PROM/DS_LOKI in the generator). They are hidden (hide: 2) - there is always exactly one Prometheus and one Loki receiving sfdx-hardis data.
DO put a detail link on every number: each stat/gauge/bargauge must be clickable via fieldConfig.defaults.links. Helpers: indicatorLink('<TYPE>') (generic Indicator Detail dashboard filtered on the notification type), detailLink('<dash-slug>') (another org dashboard), viewPanelLink(...) / linkStatToPanel(...) (full-screen view of a table on the same dashboard, used on fleet for silent orgs / backup failures). Enforced by lint (exempt: "Stats generation date", "Latest value", the dtl-indicator dashboard).
DO propagate variables AND the time range in links: append ${ds_prom:queryparam}&${ds_loki:queryparam}&${__url_time_range} (already in the helpers) or ${__all_variables}&${__url_time_range} for same-dashboard viewPanel links. The time range is not optional: a stat viewed over 180d shows the last non-null value in that window, so its detail link must open the same window or a stale value lands on an empty page.
DO show averages and trends: new numeric indicators should get avg/day (7d/30d) context where relevant (avgStat helper), not just the latest value.
DO respect the fleet Environment filter: every fleet-dashboard query must include the $env matcher ({source="sfdx-hardis", $env, orgIdentifier=~"$org"}). It injects orgIdentifier!~".*sandbox.*" for "Production only" (RE2 has no lookbehind, hence matcher-as-variable-value).
DON'Ts
DON'T edit the generated JSONs - always the generator.
DON'T hardcode datasource UIDs or any stack-specific string (grafanacloud-, cloudity) anywhere in dashboards - they must import on any Grafana instance (OSS/Enterprise/Cloud). Enforced by lint.
DON'T use Grafana Cloud-only features: no ML forecasting (use PromQL predict_linear/deriv), no Cloud-only datasources or panel plugins. Core panels only: stat, gauge, timeseries, table, bargauge, text, row.
DON'T display usernames or user lists on dashboards - counts, aggregates and pseudonymized IDs only.
DON'T create per-user or per-flow Prometheus label cardinality - per-item detail belongs in Loki _logElements, not in metric labels.
DON'T write a {__name__=~"..."} selector without a type="<NOTIF_TYPE>" matcher. A name regex matches across every indicator, so any metric shipped later whose name happens to fit silently joins the panel. The limits dashboard queried {__name__=~".+_percent", source="sfdx-hardis"} with no type, and the day usage entitlements shipped their percent fields they landed in the "All limits" table and timeseries. Scoping each name-regex query to its own notification type also documents which indicator a panel belongs to. When adding an indicator that emits percent/max fields, grep the generator for __name__=~ and check whether an existing panel would swallow it.
DON'T do binary operations across {__name__=~...} multi-metric selectors - vector matching collides (same label sets after name is ignored). Use one query per metric, or label_replace tricks, or per-metric explicit queries (see days-until-limit).
DON'T touch the v1 dashboards or their Grafana folder (cdklj9xhp8074d).
DON'T activate alert rules by default - the alert pack ships isPaused: true (Grafana Cloud free-tier cost), with ${DS_PROMETHEUS}/${DS_LOKI} placeholders documented for replacement at import.
DON'T mint new dashboard UIDs for existing dashboards - uids are sfdx-hardis-v2-<slug> and stable; changing one breaks bookmarks and cross-dashboard links.
Panel ids are frozen in that lock () and the generator reuses them whatever the construction order; only a panel missing from the lock gets a fresh id above the high-water mark, and the generator rewrites the lock so it stays frozen. Ids are public surface on an installed dashboard - links, iframe embeds and user-authored alert annotations all reference them, and a renumber silently points them at a panel rather than failing. Before this lock existed the generator used one global counter, so inserting a panel into an early dashboard renumbered 67 panels across 7 otherwise-untouched dashboards. After regenerating, always confirm shows no change in a dashboard you did not intend to touch. Renaming a panel's title changes its key and therefore allocates a new id: rename deliberately, and if the id must survive, rename the key in the lock in the same commit.
When a monitoring indicator changes (create / update / delete)
Any change to a notification type, its metrics keys, or its logElements shape (in src/commands/** or src/common/notifProvider/**) MUST evaluate dashboard impact:
New indicator / new metric key: decide where it surfaces - existing dashboard row, new panel, fleet column, or only the generic Indicator Detail dashboard (which picks up any type automatically). Add panels via the generator, with lookback wrapper, per-org aggregation, detail link, and RECENT_CLI_NOTE description (older CLIs won't send it yet).
Renamed/removed metric key: grep the generator for the old <Key>_metric name and update every query, and check docs/grafana/alerts-v2/ for alert rules using it. Old series keep their data in Prometheus under the old name; mention the rename in the dashboard panel description if history matters.
Changed logElements fields: check Loki table panels extracting those fields (jsonArrayToRows, extractJson paths) and the anonymizer's field rules (src/common/utils/anonymizeUtils.ts) if user-identifying fields are involved.
New notification type: it appears automatically in the Indicator Detail dashboard $type variable (Loki label values) - explicit panels are only needed if the indicator deserves dedicated visibility.
Regenerate (node generator.mjs), run the lint suite, re-import to the sfdx-hardis-v2 folder, and update docs/salesforce-monitoring-grafana-v2.md if the dashboard list or prerequisites changed.
Alert pack rules
One YAML file, provisioning format, apiVersion: 1, group sfdx-hardis-org-monitoring, evaluation interval 6h.
Every rule: isPaused: true, noDataState: OK, execErrState: OK (daily data = "no data" is normal), shape A (query) -> B (reduce last) -> C (threshold).
Health-score rules use an 8-day lookback ([8d]) because the score is weekly.
Freshness/silent-org logic uses LogQL unless between a 7d and a 36h count_over_time (per-org absence cannot be expressed with absent_over_time alone).
DO use whitelist organize transformations on Loki tables (includeByName) so backend noise columns (labels, traceID, detected_level) can never appear. __proxy_source__ is excluded centrally by the organize() helper.
DO reuse the v1-proven jsonArrayToRows('<field>') chain to render a JSON array field of the latest Loki entry as table rows (_logElements, topFailingFlows, ...).
DO keep tables vertical: one row per item, 2-4 columns. Wide "one column per item" layouts cause horizontal scrollbars (the days-until-limit table uses reduce seriesToRows + legendFormat per query for this).
DO keep stat titles short (fits a 4-unit-wide box, ~15 chars): "Apex avg/day (30d)", not "Apex errors average per day (30 days)". Longer context goes in the panel description. The ~15 char budget is tied to the box width, so scale it with gridPos.w: on a wider panel a longer title is fine, and when a title reads as clipped mid-word the fix is to widen the panel or shorten the words, never to let Grafana truncate it.
DO handle no-data: noValue text on stats (e.g. "Schedule dora-report"), range mappings for degenerate values ("Limit exceeded" for negative forecast days, "No growth" above 3650), and a description mentioning "Requires a recent sfdx-hardis version" for panels fed by newly added metrics (RECENT_CLI_NOTE).
DO validate live before delivering: .env contains GRAFANA_TOKEN (service account for cloudity.grafana.net). Test queries through the datasource proxy (/api/datasources/proxy/uid/grafanacloud-prom/api/v1/query, .../grafanacloud-logs/loki/api/v1/query), then import into folder uid sfdx-hardis-v2 via POST /api/dashboards/db with overwrite: true.
DO run the lint suite after regenerating - it checks JSON validity, v2 uids/tags, ds variables, no hardcoded stack references, *_over_time wrappers, unique panel ids, and detail links on every number.
DON'T let an existing panel id change, and DON'T hand-edit docs/grafana/panel-ids-v2.json.
<dashboard uid> -> <panel type>::<panel title> -> id
?viewPanel=<id>
/d-solo/...?panelId=<id>
different
git diff
"id"
DON'T put anything but a dashboard in docs/grafana/dashboards-v2/. The lint suite treats every *.json there as a dashboard and asserts the set matches GRAFANA_V2_FALLBACK_DASHBOARD_FILES. That is why the id lock lives one level up.
DON'T write to the Salesforce test org, and when running commands that push test data, set SFDX_HARDIS_MONITORING_KEY=claude-test so real org series stay clean.