Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions contrib/grafana/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# Grafana kit

A ready-to-run Prometheus and Grafana pair for Pulse, the exact setup used to produce the
project's dashboard screenshots. Eight panels: tick rate, tick time against the budget with a
p95, worldgen queue and generated columns, world counts, working set, GC collections by
generation, and the engine warning counters.
project's dashboard screenshots. The dashboard covers every metric family the mod serves,
grouped into rows: a glance strip of the numbers you check first, then tick health, players,
world, worldgen, network, pauses and warnings, and a runtime row at the bottom. Panels that
depend on something optional say so in their description, so an empty graph tells you why it
is empty instead of leaving you to guess. The runtime row needs `RuntimeMetrics` left on, and
busy time, the per-second network families and the connection queue all come from the engine
probe, which means they are blank on a server running in degraded mode.

With a Pulse-equipped server running on the same host (default bind, port 9464):

Expand All @@ -28,7 +32,40 @@ The anonymous-admin settings are for a local look, not for anything reachable fr
run Grafana properly if you keep it.

Prometheus scrapes every 2 seconds here, which is pleasant for watching a test server live
and far denser than a production setup needs; 15 seconds is plenty for a real host. If you
already run Prometheus and Grafana, the only things you need are the scrape target from
`prometheus.yml` and `provisioning/dashboards/json/pulse.json` to import; on import, point
the panels at your own Prometheus datasource.
and far denser than a production setup needs; 15 seconds is plenty for a real host. The panels
ask for `$__rate_interval` rather than a fixed window, so they follow whatever scrape interval
you settle on instead of going ragged at 15 seconds and lying at 60.

## Importing it into a Grafana you already run

Use `pulse-overview-shared.json`. In Grafana, go to Dashboards, then Import, upload that file,
and pick your Prometheus datasource when it asks for one. That prompt is the entire difference
between the two dashboard files: the provisioned copy points at the datasource uid `pulse-prom`,
which exists only on a Grafana provisioned from this directory, so importing that one anywhere
else gets you a dashboard wired to nothing.

You still need the scrape target from `prometheus.yml`.

## The files

- `provisioning/` is what the Grafana container reads: the datasource, the dashboard provider,
and the dashboard itself at `provisioning/dashboards/json/pulse.json`, uid `pulse-overview`.
This is the copy to edit.
- `pulse-overview-shared.json` is generated from that one, not maintained beside it. Edit the
provisioned dashboard and regenerate.
- `make-shared.py` does the generating: it swaps the datasource for the `DS_PROMETHEUS` import
prompt and adds the `__inputs` and `__requires` blocks Grafana's import dialog reads.
- `check-dashboard.py` looks for the mistakes Grafana will not report. Overlapping panels, a
panel wider than the 24 column grid and duplicate panel ids all get drawn wrong or dropped
silently, which is a miserable thing to debug by eye.

So after editing the dashboard, run both:

```sh
python3 contrib/grafana/make-shared.py
python3 contrib/grafana/check-dashboard.py \
contrib/grafana/provisioning/dashboards/json/pulse.json \
contrib/grafana/pulse-overview-shared.json
```

Neither script needs anything beyond the Python standard library.
99 changes: 99 additions & 0 deletions contrib/grafana/check-dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Check Grafana dashboard JSON for the mistakes that fail silently.

Grafana does not complain about a dashboard with overlapping panels, a panel
running off the right edge of the grid or two panels sharing an id. It just
draws something wrong, or drops a panel, and leaves you wondering. So these
files get checked here instead.

python3 contrib/grafana/check-dashboard.py contrib/grafana/*.json ...

Exits nonzero and prints one line per violation.
"""

import json
import sys

COLUMNS = 24


def panels(dashboard):
"""Every panel with a flag for whether it sits on the dashboard's own grid.

A collapsed row carries its children in its own "panels" list, and those
are positioned relative to the row, so they are not part of the same grid.
"""
for panel in dashboard.get("panels", []):
yield panel, True
for nested in panel.get("panels", []):
yield nested, False


def describe(panel):
return f"id {panel.get('id', '?')} ({panel.get('title', 'untitled')})"


def check(path):
problems = []
with open(path, encoding="utf-8") as handle:
dashboard = json.load(handle)

seen_ids = {}
boxes = []
for panel, on_grid in panels(dashboard):
pid = panel.get("id")
if pid is None:
problems.append(f"{describe(panel)} has no id")
elif pid in seen_ids:
problems.append(f"panel id {pid} used twice: {seen_ids[pid]} and {panel.get('title')}")
else:
seen_ids[pid] = panel.get("title")

pos = panel.get("gridPos")
if not pos:
problems.append(f"{describe(panel)} has no gridPos")
continue
x, y, w, h = (pos.get(k) for k in ("x", "y", "w", "h"))
if None in (x, y, w, h):
problems.append(f"{describe(panel)} has an incomplete gridPos: {pos}")
continue
if w < 1 or h < 1:
problems.append(f"{describe(panel)} is {w}x{h}, both must be at least 1")
if x < 0 or y < 0:
problems.append(f"{describe(panel)} sits at {x},{y}, neither may be negative")
if x + w > COLUMNS:
problems.append(f"{describe(panel)} runs off the grid: x {x} + w {w} > {COLUMNS}")
if on_grid:
boxes.append((panel, x, y, w, h))

for i, (a, ax, ay, aw, ah) in enumerate(boxes):
for b, bx, by, bw, bh in boxes[i + 1:]:
if ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah:
problems.append(f"{describe(a)} overlaps {describe(b)}")

return problems


def main(paths):
if not paths:
print(__doc__)
return 2

failed = False
for path in paths:
try:
problems = check(path)
except (OSError, json.JSONDecodeError) as e:
print(f"{path}: {e}")
failed = True
continue
for problem in problems:
print(f"{path}: {problem}")
failed = failed or bool(problems)
if not problems:
print(f"{path}: ok")
return 1 if failed else 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
95 changes: 95 additions & 0 deletions contrib/grafana/make-shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Turn the provisioned dashboard into one Grafana's import dialog accepts.

A provisioned dashboard points at a datasource by uid, which only means
anything on a Grafana that was provisioned from the files in this repo.
Anyone else importing it gets panels wired to a datasource that does not
exist. Grafana's answer is the export-for-sharing shape: the datasource
becomes an __inputs placeholder, and the import dialog asks for a real one.

python3 contrib/grafana/make-shared.py

Reads provisioning/dashboards/json/pulse.json, writes
pulse-overview-shared.json beside it. The provisioned file is the source of
truth; this exists so nobody has to keep two copies of every panel in sync.
"""

import json
import pathlib

HERE = pathlib.Path(__file__).parent
SOURCE = HERE / "provisioning" / "dashboards" / "json" / "pulse.json"
TARGET = HERE / "pulse-overview-shared.json"

INPUT_NAME = "DS_PROMETHEUS"

# Panel plugin ids carry no display name in the dashboard, and __requires wants
# one. Anything not listed falls back to the id itself, which is still a usable
# thing to read in an import dialog.
PANEL_NAMES = {
"row": "Row",
"stat": "Stat",
"table": "Table",
"timeseries": "Time series",
}

# The oldest Grafana that reads schemaVersion 39 and the panel options used
# here. Import onto anything older and it warns rather than silently misdraws.
GRAFANA_VERSION = "10.0.0"


def placeholder(node):
"""Replace every Prometheus datasource reference with the import input."""
if isinstance(node, dict):
if node.get("type") == "prometheus" and "uid" in node:
return {"type": "prometheus", "uid": "${" + INPUT_NAME + "}"}
return {k: placeholder(v) for k, v in node.items()}
if isinstance(node, list):
return [placeholder(v) for v in node]
return node


def panel_types(dashboard):
types = set()
for panel in dashboard.get("panels", []):
types.add(panel.get("type"))
for nested in panel.get("panels", []):
types.add(nested.get("type"))
return sorted(t for t in types if t)


def main():
dashboard = placeholder(json.loads(SOURCE.read_text(encoding="utf-8")))

shared = {
"__inputs": [
{
"name": INPUT_NAME,
"label": "Prometheus",
"description": "The Prometheus that scrapes your Pulse endpoint.",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus",
}
],
"__requires": [
{"type": "grafana", "id": "grafana", "name": "Grafana", "version": GRAFANA_VERSION},
{"type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0"},
]
+ [
{"type": "panel", "id": t, "name": PANEL_NAMES.get(t, t), "version": ""}
for t in panel_types(dashboard)
],
# Grafana assigns the imported dashboard a fresh numeric id. Carrying
# one over from the exporting instance is how an import lands on top of
# an unrelated dashboard.
"id": None,
}
shared.update(dashboard)

TARGET.write_text(json.dumps(shared, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"wrote {TARGET.relative_to(HERE)} from {SOURCE.relative_to(HERE)}")


if __name__ == "__main__":
main()
Loading
Loading