Files
artcraft/script/metrics/generate_endpoint_dashboards.py
Brandon Thomas 436d6d18a0 more dashboards
2026-05-23 15:55:20 -04:00

440 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate one Datadog dashboard JSON per (METHOD, route) pair found in
storyteller-web's actix-web route registry.
Output: _metrics/datadog/dashboards/per_endpoint/<METHOD>__<sanitized-path>.json
How:
- Walk the route registry files under
crates/service/web/storyteller_web/src/http_server/routes/ to
enumerate every (METHOD, path) registered with actix.
- Filter out HEAD (every HEAD handler in this codebase is the dummy
`|| HttpResponse::Ok()` boilerplate — 253 identical no-ops) and OPTIONS
(per project convention).
- Translate each actix path template like `/v1/users/{token}/profile` into
the Datadog tag value our middleware actually emits — Datadog tag
normalization converts `{` → `_`, `}` → `_`, and trims any trailing `_`.
- Render a per-endpoint dashboard JSON: top-line query values (rate, p95,
p99, 5xx rate %), latency-percentile timeseries, status-class timeseries,
and a status-code toplist.
Idempotent re-generation: any existing per-endpoint dashboard file is
preserved if its `id` field is set (so dashboards aren't recreated each
run). The script overwrites the JSON body but keeps the `id` so that
`apply_dashboards.sh` issues PUT (update) instead of POST (create).
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Iterable
ROOT = Path(__file__).resolve().parents[2]
ROUTES_DIR = ROOT / "crates/service/web/storyteller_web/src/http_server/routes"
OUT_DIR = ROOT / "_metrics/datadog/dashboards/per_endpoint"
SKIP_METHODS = {"HEAD", "OPTIONS"}
# ============================================================================
# Route extraction (lifted from /tmp/extract_routes.py and dropped here so the
# script is self-contained).
# ============================================================================
SCOPE_RE = re.compile(r'\bweb\s*::\s*scope\s*\(\s*"([^"]+)"')
RESOURCE_RE = re.compile(r'\bweb\s*::\s*resource\s*\(\s*"([^"]+)"')
ROUTE_METHOD_RE = re.compile(
r'\.\s*route\s*\(\s*web\s*::\s*(get|post|put|delete|patch|head|options|trace)\s*\('
)
def strip_strings_and_comments(src: str) -> str:
out = []
i = 0
n = len(src)
while i < n:
c = src[i]
if c == '/' and i + 1 < n and src[i+1] == '/':
while i < n and src[i] != '\n':
out.append(' '); i += 1
continue
if c == '/' and i + 1 < n and src[i+1] == '*':
out.append(' '); i += 2
while i + 1 < n and not (src[i] == '*' and src[i+1] == '/'):
out.append(' ' if src[i] != '\n' else '\n'); i += 1
if i + 1 < n:
out.append(' '); i += 2
continue
if c == '"':
out.append(c); i += 1
while i < n and src[i] != '"':
if src[i] == '\\' and i + 1 < n:
out.append(src[i]); out.append(src[i+1]); i += 2
else:
out.append(src[i]); i += 1
if i < n:
out.append('"'); i += 1
continue
if c == "'":
out.append(c); i += 1
while i < n and src[i] != "'":
if src[i] == '\\' and i + 1 < n:
out.append(src[i]); out.append(src[i+1]); i += 2
else:
out.append(src[i]); i += 1
if i < n:
out.append("'"); i += 1
continue
out.append(c); i += 1
return ''.join(out)
def join_path(parts: Iterable[str]) -> str:
out = ''
for p in parts:
if not p:
continue
if not p.startswith('/'):
out += '/' + p
else:
out += p
while '//' in out:
out = out.replace('//', '/')
return out
def parse_file(path: Path):
src = strip_strings_and_comments(path.read_text())
events = []
for m in re.finditer(r'\(|\)', src):
events.append((m.start(), 'paren', m.group(0)))
for m in SCOPE_RE.finditer(src):
events.append((m.start(), 'scope', m.group(1), m.end()))
for m in RESOURCE_RE.finditer(src):
events.append((m.start(), 'resource', m.group(1), m.end()))
for m in ROUTE_METHOD_RE.finditer(src):
events.append((m.start(), 'route', m.group(1).upper(), m.end()))
events.sort(key=lambda e: (e[0], 0 if e[1] != 'paren' else 1))
# Drop parens that fall inside a matched scope/resource/route span — they
# were already counted (with `depth += 1` below) when we processed the
# matched event.
consumed_until = -1
filtered = []
for e in events:
if e[1] == 'paren' and e[0] < consumed_until:
continue
if e[1] != 'paren':
consumed_until = e[3]
filtered.append(e)
results = []
depth = 0
frames = [] # (depth_at_open, kind, path_fragment)
for e in filtered:
if e[1] == 'paren':
if e[2] == '(':
depth += 1
else:
depth -= 1
while frames and depth < frames[-1][0]:
frames.pop()
continue
if e[1] == 'scope':
_, _, path, _ = e
frames.append((depth, 'scope', path))
depth += 1
continue
if e[1] == 'resource':
_, _, path, _ = e
frames.append((depth, 'resource', path))
depth += 1
continue
if e[1] == 'route':
_, _, method, _ = e
resource = None
scope_prefix = []
for frame in frames:
if frame[1] == 'scope':
scope_prefix.append(frame[2])
elif frame[1] == 'resource':
resource = frame[2]
if resource is not None:
full = join_path(scope_prefix + [resource])
results.append((method, full))
depth += 2 # `.route(web::METHOD(` introduces two opens
continue
return results
def enumerate_routes():
files = sorted(p for p in ROUTES_DIR.rglob('*.rs') if p.name != 'mod.rs')
routes = set()
for f in files:
for method, path in parse_file(f):
routes.add((method, path))
return sorted(routes)
# ============================================================================
# Datadog tag normalization
# ============================================================================
def to_dd_route_tag(actix_path: str) -> str:
"""Mirror Datadog's tag-value normalization for the `route` tag value our
metrics middleware emits.
Datadog replaces `{` and `}` with `_`, then trims trailing `_` from the
whole value. E.g.:
/v1/jobs/job/{token} -> /v1/jobs/job/_token
/v1/users/{username}/profile -> /v1/users/_username_/profile
/v1/comments/list/{etype}/{etoken} -> /v1/comments/list/_etype_/_etoken
"""
s = actix_path.replace('{', '_').replace('}', '_')
return s.rstrip('_')
def safe_filename(method: str, path: str) -> str:
"""Stable, filesystem-safe filename per (method, path)."""
# Replace anything non-[A-Za-z0-9._-] with `_`
base = re.sub(r'[^A-Za-z0-9._-]', '_', path).strip('_')
return f"{method.upper()}__{base}.json"
# ============================================================================
# Dashboard JSON template
# ============================================================================
def build_dashboard(method: str, actix_path: str) -> dict:
route_tag = to_dd_route_tag(actix_path)
method_lc = method.lower()
# Filter fragment for queries
flt = f"service:storyteller-web,route:{route_tag},method:{method_lc}"
title = f"[{method.upper()}] {actix_path}"
description = (
f"Auto-generated per-endpoint dashboard for `{method.upper()} {actix_path}`.\n\n"
f"Filters all metrics on `service:storyteller-web,route:{route_tag},method:{method_lc}`.\n\n"
f"Source: `script/metrics/generate_endpoint_dashboards.py`. Do not hand-edit; "
f"changes will be overwritten on the next regeneration."
)
widgets = [
# ---- Top-line group ----
{
"definition": {
"type": "group",
"layout_type": "ordered",
"title": "Top-line health",
"show_title": True,
"widgets": [
{
"definition": {
"type": "query_value",
"title": "Requests/sec",
"title_size": "16",
"title_align": "left",
"precision": 2,
"autoscale": True,
"requests": [
{
"q": f"sum:http.server.request.count{{{flt}}}.as_rate()",
"aggregator": "avg"
}
]
},
"layout": {"x": 0, "y": 0, "width": 3, "height": 2}
},
{
"definition": {
"type": "query_value",
"title": "5xx error rate (%)",
"title_size": "16",
"title_align": "left",
"precision": 2,
"autoscale": False,
"custom_unit": "%",
"requests": [
{
"q": (
f"(sum:http.server.request.count{{{flt},status_class:5xx}}.as_count() "
f"/ sum:http.server.request.count{{{flt}}}.as_count()) * 100"
),
"aggregator": "avg",
"conditional_formats": [
{"comparator": ">", "value": 5.0, "palette": "white_on_red"},
{"comparator": ">", "value": 1.0, "palette": "white_on_yellow"},
{"comparator": "<=", "value": 1.0, "palette": "white_on_green"}
]
}
]
},
"layout": {"x": 3, "y": 0, "width": 3, "height": 2}
},
{
"definition": {
"type": "query_value",
"title": "Latency p95 (ms)",
"title_size": "16",
"title_align": "left",
"precision": 0,
"autoscale": True,
"custom_unit": "ms",
"requests": [
{
"q": f"p95:http.server.request.duration_ms{{{flt}}}",
"aggregator": "avg",
"conditional_formats": [
{"comparator": ">", "value": 1000, "palette": "white_on_red"},
{"comparator": ">", "value": 250, "palette": "white_on_yellow"},
{"comparator": "<=", "value": 250, "palette": "white_on_green"}
]
}
]
},
"layout": {"x": 6, "y": 0, "width": 3, "height": 2}
},
{
"definition": {
"type": "query_value",
"title": "Latency p99 (ms)",
"title_size": "16",
"title_align": "left",
"precision": 0,
"autoscale": True,
"custom_unit": "ms",
"requests": [
{
"q": f"p99:http.server.request.duration_ms{{{flt}}}",
"aggregator": "avg"
}
]
},
"layout": {"x": 9, "y": 0, "width": 3, "height": 2}
}
]
},
"layout": {"x": 0, "y": 0, "width": 12, "height": 3}
},
# ---- Trends group ----
{
"definition": {
"type": "group",
"layout_type": "ordered",
"title": "Trends",
"show_title": True,
"widgets": [
{
"definition": {
"type": "timeseries",
"title": "Requests/sec by status class",
"title_size": "16",
"title_align": "left",
"show_legend": True,
"legend_layout": "horizontal",
"requests": [
{
"q": f"sum:http.server.request.count{{{flt}}} by {{status_class}}.as_rate()",
"display_type": "bars",
"style": {"palette": "semantic", "line_type": "solid", "line_width": "normal"}
}
],
"yaxis": {"scale": "linear", "include_zero": True}
},
"layout": {"x": 0, "y": 0, "width": 6, "height": 4}
},
{
"definition": {
"type": "timeseries",
"title": "Latency percentiles",
"title_size": "16",
"title_align": "left",
"show_legend": True,
"legend_layout": "horizontal",
"requests": [
{
"q": f"p50:http.server.request.duration_ms{{{flt}}}",
"display_type": "line",
"style": {"palette": "cool", "line_type": "solid", "line_width": "normal"}
},
{
"q": f"p95:http.server.request.duration_ms{{{flt}}}",
"display_type": "line",
"style": {"palette": "warm", "line_type": "solid", "line_width": "normal"}
},
{
"q": f"p99:http.server.request.duration_ms{{{flt}}}",
"display_type": "line",
"style": {"palette": "warm", "line_type": "dashed", "line_width": "normal"}
}
],
"yaxis": {"scale": "linear", "include_zero": True, "label": "ms"}
},
"layout": {"x": 6, "y": 0, "width": 6, "height": 4}
}
]
},
"layout": {"x": 0, "y": 3, "width": 12, "height": 5}
},
# ---- Status codes ----
{
"definition": {
"type": "toplist",
"title": "Status code distribution",
"title_size": "16",
"title_align": "left",
"requests": [
{"q": f"top(sum:http.server.request.count{{{flt}}} by {{status_code}}.as_count(), 25, 'sum', 'desc')"}
]
},
"layout": {"x": 0, "y": 8, "width": 12, "height": 4}
}
]
return {
"title": title,
"description": description,
"layout_type": "ordered",
"notify_list": [],
"reflow_type": "fixed",
"widgets": widgets
}
# ============================================================================
# Driver
# ============================================================================
def main():
routes = enumerate_routes()
if not routes:
print("no routes extracted — check ROUTES_DIR", file=sys.stderr)
sys.exit(1)
OUT_DIR.mkdir(parents=True, exist_ok=True)
written = 0
skipped = 0
preserved_ids = 0
for method, path in routes:
if method in SKIP_METHODS:
skipped += 1
continue
filename = safe_filename(method, path)
out_path = OUT_DIR / filename
body = build_dashboard(method, path)
# Preserve `id` if it exists so the apply script PUTs instead of POSTs.
if out_path.exists():
try:
existing = json.loads(out_path.read_text())
if 'id' in existing:
body['id'] = existing['id']
preserved_ids += 1
except json.JSONDecodeError:
pass
out_path.write_text(json.dumps(body, indent=2) + '\n')
written += 1
print(f"wrote {written} dashboards to {OUT_DIR.relative_to(ROOT)}/")
print(f" skipped {skipped} ({', '.join(sorted(SKIP_METHODS))})")
print(f" preserved {preserved_ids} existing id(s) for in-place updates")
if __name__ == '__main__':
main()