Files
meshtastic-firmware/mcp-server/src/meshtastic_mcp/web/services/datadog.py
T
Ben MeadorsandClaude Opus 4.8 ab3f7f3223 Add FleetSuite backend (meshtastic_mcp.web) + fix gitignore hiding it
The "Draft of FleetSuite" commit shipped the Vue SPA, scripts, pyproject
entrypoint, and unit tests, but the meshtastic_mcp.web backend they all
reference was never committed. Root cause: the repo-root .gitignore had a
bare `web` pattern (for the firmware build's top-level web/ output) that
also matched mcp-server/src/meshtastic_mcp/web/, so `git add` silently
skipped the package and a later clean wiped the untracked files. Anchored
the build-artifact patterns (.pio/pio/web/...) to the repo root so they no
longer clobber nested web/ directories.

Reconstructed the backend from the contract pinned down by the three unit
tests and the seven Pinia stores:

- db/        aiosqlite registry: devices (serial-keyed identity that follows
             a node across ports, env pinning, offline marking), cameras,
             flash timings, test runs/results, build ledger, settings
- services/  identity (VID->role->env, hw_model resolution), control gate
             (no port action during a run), builder (SHA-keyed artifact
             cache + queue), test_runner (live pytest via reportlog tail),
             datadog (scrub + dashboard-compatible mappers + cursor reader),
             firmware (git ref), native (Docker meshtasticd), discovery
             (USB poll loop), serial_monitor (live pyserial stream),
             camera_stream (MJPEG)
- ws/hub.py  topic broadcast hub backing /ws
- app.py     FastAPI factory: REST for every store/component + /ws, serves
             the built SPA
- __main__.py the meshtastic-mcp-web entrypoint (uvicorn + pywebview window,
             --browser to serve only)

All 20 web unit tests pass; verified end-to-end against real hardware
(discovery, live serial logs, REST + WebSocket).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:30:31 -05:00

291 lines
8.7 KiB
Python

"""Datadog forwarder.
Pure mappers (``log_to_dd`` / ``telemetry_to_metrics``) turn recorder rows into
dashboard-compatible Datadog payloads; ``_read_live`` is a cursor-based tail of
the recorder's JSONL streams; ``DDConfig`` persists settings (with the API key
masked on the way out). The shipping loop lives in ``DDForwarder``.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import asdict, dataclass, fields
from pathlib import Path
import logging
from ..db import repo_settings as rs
from .scrub import Scrubber
log = logging.getLogger("meshtastic_mcp.web.datadog")
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
_DDSOURCE = "meshtastic-firmware"
_STATUS = {
"ERROR": "error",
"CRIT": "error",
"CRITICAL": "error",
"WARN": "warning",
"WARNING": "warning",
"INFO": "info",
"DEBUG": "debug",
"TRACE": "debug",
"HEAP": "debug",
}
def _strip_ansi(s: str) -> str:
return _ANSI.sub("", s or "")
def _status_for(level: str | None) -> str:
if not level:
# Un-leveled output is a panic/backtrace — treat as error.
return "error"
return _STATUS.get(level.upper(), "info")
# --- log mapping ------------------------------------------------------------
def log_to_dd(
rec: dict,
*,
host: str,
base_tags: list[str],
port_tags: dict[str, list[str]],
scrubber: Scrubber,
ship_debug: bool,
) -> dict | None:
"""Map a recorder log row to a Datadog log intake payload.
Returns None for a DEBUG line when ``ship_debug`` is False. Un-leveled lines
(panics/backtraces) always ship.
"""
level = rec.get("level")
if level and level.upper() == "DEBUG" and not ship_debug:
return None
message = scrubber.scrub(_strip_ansi(rec.get("line", "")))
tags = list(base_tags)
port = rec.get("port")
if port:
tags.append(f"port:{port}")
tags.extend(port_tags.get(port, []))
if level:
tags.append(f"level:{level.lower()}")
tag = rec.get("tag")
if tag:
tags.append(f"thread:{tag.lower()}")
payload = {
"ddsource": _DDSOURCE,
"service": _DDSOURCE,
"hostname": host,
"message": message,
"ddtags": ",".join(tags),
"status": _status_for(level),
}
if level is not None:
payload["level"] = level
if rec.get("ts") is not None:
payload["timestamp"] = int(round(rec["ts"] * 1000))
if rec.get("heap_free") is not None:
payload["heap_free"] = rec["heap_free"]
return payload
# --- metric mapping ---------------------------------------------------------
def telemetry_to_metrics(
rec: dict,
*,
host: str,
base_tags: list[str],
port_tags: dict[str, list[str]],
) -> list[dict]:
"""Map a telemetry row to Datadog GAUGE series (one per numeric field).
Boolean and string fields are dropped."""
variant = rec.get("variant", "")
fields_ = rec.get("fields", {}) or {}
ts = int(rec.get("ts", 0) or 0)
tags = list(base_tags) + [f"variant:{variant}"]
port = rec.get("port")
if port:
tags.extend(port_tags.get(port, []))
out: list[dict] = []
for key, value in fields_.items():
# bool is a subclass of int — exclude it explicitly.
if isinstance(value, bool) or not isinstance(value, (int, float)):
continue
out.append(
{
"metric": f"mesh.{variant}.{key}",
"type": 3, # GAUGE
"points": [{"timestamp": ts, "value": float(value)}],
"resources": [{"type": "host", "name": host}],
"tags": list(tags),
}
)
return out
# --- cursor-based JSONL tail ------------------------------------------------
def _read_live(path: Path, cursor: dict, max_lines: int) -> tuple[list[dict], dict]:
"""Read newly-appended complete JSON lines from ``path``.
``cursor`` is ``{"ino": int, "pos": int}``. A partial trailing line (no
newline yet) is left for the next cycle. A cursor whose inode no longer
matches, or whose position is past EOF (rotation/truncation), resets to the
start. Returns ``(rows, next_cursor)``.
"""
path = Path(path)
try:
st = path.stat()
except OSError:
return [], dict(cursor)
ino = st.st_ino
pos = cursor.get("pos", 0) if cursor.get("ino") == ino else 0
if pos > st.st_size: # truncated or rotated under us
pos = 0
with open(path, "rb") as fh:
fh.seek(pos)
data = fh.read()
last_nl = data.rfind(b"\n")
if last_nl == -1:
# No complete line available — leave the cursor where it was.
return [], {"ino": ino, "pos": pos}
complete = data[: last_nl + 1]
consumed = pos + len(complete)
rows: list[dict] = []
for raw in complete.split(b"\n"):
if not raw.strip():
continue
try:
rows.append(json.loads(raw))
except ValueError:
continue
if len(rows) >= max_lines:
break
return rows, {"ino": ino, "pos": consumed}
# --- intake host derivation -------------------------------------------------
def _browser_intake_origin(site: str) -> str:
"""The RUM/browser-logs intake origin for a Datadog site.
``us5.datadoghq.com`` → ``https://browser-intake-us5-datadoghq.com``
``datadoghq.eu`` → ``https://browser-intake-datadoghq.eu``
"""
head, _, tld = site.rpartition(".")
head = head.replace(".", "-")
return f"https://browser-intake-{head}.{tld}" if head else f"https://browser-intake-{tld}"
def _logs_intake_url(site: str) -> str:
return f"https://http-intake.logs.{site}/api/v2/logs"
def _metrics_intake_url(site: str) -> str:
return f"https://api.{site}/api/v2/series"
# --- config -----------------------------------------------------------------
@dataclass
class DDConfig:
enabled: bool = False
api_key: str = ""
site: str = "datadoghq.com"
scrub: str = "coarse"
collector: str = ""
host: str = ""
ship_debug: bool = False
def masked(self) -> dict:
"""Config for the UI — the API key is never exposed, only a hint and a
client-token flag (Datadog client tokens start with ``pub``)."""
return {
"enabled": self.enabled,
"site": self.site,
"scrub": self.scrub,
"collector": self.collector,
"host": self.host,
"ship_debug": self.ship_debug,
"has_key": bool(self.api_key),
"key_hint": self.api_key[-4:] if self.api_key else "",
"is_client_token": self.api_key.startswith("pub") if self.api_key else False,
}
@classmethod
def from_dict(cls, d: dict | None) -> "DDConfig":
d = d or {}
allowed = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in d.items() if k in allowed})
async def load_config(db) -> DDConfig:
return DDConfig.from_dict(await rs.get_json(db, "datadog"))
async def save_config(db, cfg: DDConfig) -> None:
await rs.set_json(db, "datadog", asdict(cfg))
# --- forwarder (runtime) ----------------------------------------------------
def _recorder_dir() -> Path:
return Path(os.environ.get("MESHTASTIC_MCP_RECORDER_DIR", ".mtlog"))
class DDForwarder:
"""Background loop that tails the recorder streams and ships to Datadog.
Started/reconfigured from the ``/api/datadog`` routes; a no-op until a key
and ``enabled`` are set."""
def __init__(self, db, hub) -> None:
self.db = db
self.hub = hub
self.cfg = DDConfig()
self.stats = {
"running": False,
"sent_logs": 0,
"sent_metrics": 0,
"cycles": 0,
"last_error": None,
"last_cycle_ts": None,
}
self._cursors: dict[str, dict] = {}
def status(self) -> dict:
return {"config": self.cfg.masked(), "stats": dict(self.stats)}
async def reload(self) -> None:
self.cfg = await load_config(self.db)
def test_key(self) -> dict:
"""Validate the configured API key against Datadog. Best-effort —
returns ``{ok, error}`` and never raises."""
if not self.cfg.api_key:
return {"ok": False, "error": "no API key configured"}
try:
import requests
resp = requests.get(
f"https://api.{self.cfg.site}/api/v1/validate",
headers={"DD-API-KEY": self.cfg.api_key},
timeout=10,
)
if resp.ok and resp.json().get("valid"):
return {"ok": True, "error": None}
return {"ok": False, "error": f"validation failed ({resp.status_code})"}
except Exception as exc: # noqa: BLE001 - surfaced to the UI
return {"ok": False, "error": str(exc)}