mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-26 11:09:42 +00:00
440 lines
15 KiB
Python
Executable File
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()
|