Docs › Observability Specification

Observability Specification

Overview

SpatiumDDI’s shipped observability consists of: structured NDJSON logging to stdout (§ 1), a built-in /logs viewer over agent-pushed DNS/DHCP logs and on-demand Windows reads (§ 3–4½), an append-only audit log with external forwarding (§ 5), an API-request Prometheus family at /metrics (§ 6), an agent-driven dashboard time-series path (§ 6), and health endpoints (§ 7). All stdout output is compatible with standard enterprise stacks (ELK, Loki, Datadog, Splunk) that you point at the container streams.

What is not bundled. Several pieces below are design intent, not shipped code and are flagged inline as planned: a centralized log store + collector (Loki / Grafana / Vector / Promtail — § 2), a generic control-plane log-explorer API (§ 3), the IPAM / DHCP / DNS / Celery / Database Prometheus metric families (§ 6), and the product Grafana dashboard bundle (§ 8). No Loki / Grafana / Vector / Promtail service ships in any docker-compose*.yml or in charts/.

Native admin surfaces (since 2026.04.26-1)

Two admin surfaces give operators platform visibility without standing up Prometheus / Grafana:

The native surfaces are intentionally tactical — for long-term metrics retention and cross-instance dashboards, fall back to Prometheus scraping /metrics (see § 6).


1. Structured Logging

Library and Format

All services use structlog configured to emit newline-delimited JSON (NDJSON):

{
  "timestamp": "2024-01-15T14:30:00.123Z",
  "level": "info",
  "service": "api",
  "instance": "api-pod-3f8a",
  "request_id": "req-uuid-here",
  "user_id": "usr-abc123",
  "user_ip": "10.0.0.45",
  "method": "POST",
  "path": "/api/v1/ipam/subnets",
  "status": 201,
  "duration_ms": 42,
  "event": "subnet_created",
  "subnet_id": "sub-xyz789",
  "subnet_network": "10.1.5.0/24"
}

Standard Fields (always present)

Field Description
timestamp ISO 8601 UTC with milliseconds
level debug, info, warning, error, critical
service api, worker, beat, agent, dhcp, dns
instance Hostname or pod name
request_id In the api, a UUID generated for every request, never the caller’s (it is stored in the tamper-evident audit_log.request_id, so a caller must not choose it). In the worker, the Celery task id, bound for the length of the task. Absent on lines logged outside a request or task
client_request_id The caller’s own X-Request-ID, when it sent one of 1–64 characters of A-Z a-z 0-9 . _ : -. The response echoes it back as X-Request-ID; without one, the response carries request_id

The worker and beat log through the same pipeline as the api (#1246), including Celery’s own stdlib lines (Task … received / succeeded), so one filter on service / request_id covers all three. The effective level is the more verbose of --loglevel and LOG_LEVEL, and --logfile receives the JSON lines. A worker running an embedded scheduler (-B) logs as service=worker; its scheduler lines carry logger=celery.beat. The one plain-text exception is Celery’s startup banner, printed once before logging is configured.

Sensitive Data Rules (enforced by linting)


2. Centralized Log Management

Status: planned / not yet shipped. No log collector, log store, or “log store query” API ships in the repo today. There is no Loki / Grafana / Vector / Promtail / Fluentd service in any docker-compose*.yml or in charts/, and nothing reads from a central store. What ships instead is described in sections 3–4½: every service logs NDJSON to stdout (section 1), and operators who want centralized retention point their own collector at those streams (Loki / Elasticsearch / Datadog / Splunk via standard Docker / Kubernetes log shipping). The pipeline below is the design target, not a bundled deliverable.

Log Pipeline (planned)

Each service (API, Worker, Agents, DHCP, DNS)
        ↓ stdout (NDJSON)        ← shipped
Log Collector (Vector / Promtail / Fluentd)   ← operator-supplied, not bundled
        ↓
Central Log Store (operator's choice):
  - Grafana Loki
  - Elasticsearch (for existing ELK stacks)
  - External (Datadog, Splunk, Cloudwatch)

The shipped log surface (sections 3–4½) reads from PostgreSQL tables the agents push into (BIND9 query log, Kea activity) and from Windows servers over WinRM — not from a central log store. A store-backed log-viewer API is not implemented.

Bundled Log Store (planned)

If a bundled store lands, the intended shape is Loki (aggregation) + Grafana (optional dashboards / log exploration) + Vector (collector / shipper). None of these ship today; treat this as a design note.

Log Retention

For the shipped surfaces, retention is per-table and short — the agent-pushed query/activity logs are pruned at 24 h by default (see § 4½.3) since they’re operator triage, not analytics. Every retention window is a setting (Settings → Data Retention; table in SYSTEM_ADMIN.md §2.7). The audit log (§ 5) lives in PostgreSQL and is not auto-pruned. Long-term log retention belongs in an external store (Loki / a SIEM) fed from the stdout streams.


3. Built-in Log Viewer (shipped)

The shipped log viewer lives at /logs (frontend/src/pages/LogsPage.tsx), backed by the router at backend/app/api/v1/logs/router.py. It is not a generic “control-plane application log explorer” — there is no /admin/logs page and no generic GET /api/v1/logs?service=&level= query API. The control-plane API / worker / beat processes log NDJSON to stdout (section 1); to browse those, read the container streams (docker compose logs -f api worker) or ship them to an external store of your choice.

What the /logs page actually surfaces is four data-source tabs over two transports — agent-pushed DNS/DHCP logs and on-demand Windows server reads:

Tab Source Transport Detail
DNS Queries BIND9 / PowerDNS query log (not Technitium — #742) agent push → DB § 4½.1
DHCP Activity Kea DHCPv4 activity agent push → DB § 4½.2
Event Log Windows DNS / DHCP Event Log WinRM read-through § 4.1
DHCP Audit Windows DHCP per-lease CSV trail WinRM read-through § 4.2

Each tab does its own server-side filtering, auto-fetches on mount and on filter change, and has an explicit Refresh button (§ 4.3). The agent tabs read from narrow PostgreSQL tables (§ 4½.3); the Windows tabs are on-demand reads with no log-shipping. The endpoints are the POST /api/v1/logs/{query,dhcp-audit,dns-queries,dhcp-activity} family plus GET /api/v1/logs/{sources,agent-sources} — see sections 4 and 4½ for the full surface.

Planned (not shipped): a unified explorer over the control-plane NDJSON streams (service / level / free-text filtering, live tail, NDJSON/CSV export) backed by a log-store query API. Today those streams are stdout-only.


4. Windows Server Log Viewer

Separate from the SpatiumDDI Log Explorer (section 3), there is a Windows Server Log Viewer at /logs that pulls logs directly off any Windows server SpatiumDDI has WinRM credentials for. Zero agent, no log-shipping — it’s a read-through on demand, server-side filtered.

Two tabs today, plumbed through the driver abstraction so future sources (agent logs streamed over the bus, control-plane service logs) can plug in via driver.available_log_names() + driver.get_events() without touching the router.

4.1 Event Log tab

Filtered read of Windows Event Log via Get-WinEvent -FilterHashtable. Every filter runs server-side so the full log never crosses the wire.

Inventory per driver:

Driver Logs exposed
WindowsDNSDriver DNS Server (classic) + Microsoft-Windows-DNSServer/Audit
WindowsDHCPReadOnlyDriver Microsoft-Windows-Dhcp-Server/Operational + Microsoft-Windows-Dhcp-Server/FilterNotifications

The DNS Analytical log is deliberately omitted — it’s per-query, noisy, and better viewed in MMC’s Event Viewer.

Filters (all optional, combined server-side):

Endpoints:

GET  /api/v1/logs/sources
  → [{server_id, name, driver, available_logs: [...]}, ...]

POST /api/v1/logs/query
  body: {server_id, log_name, level?, max_events?, since?, event_id?}
  → [{time_created, level, event_id, provider_name, message, ...}, ...]

Error handling. Get-WinEvent raises System.Diagnostics.Eventing.Reader.EventLogException when the log doesn’t exist, zero events match, or a FilterHashtable key doesn’t apply — it bypasses -ErrorAction SilentlyContinue because the exception is terminating at the .NET provider level. fetch_events() catches that explicitly and falls through to a generic “no data” matcher so those cases return [] cleanly instead of surfacing "The parameter is incorrect" to the UI as a 502.

4.2 DHCP Audit tab

Windows DHCP writes its per-lease trail to CSV-style files at C:\Windows\System32\dhcp\DhcpSrvLog-<Day>.log — one file per weekday (Mon / Tue / … / Sun), rotating, on by default on every DHCP role. These are the grants / renewals / releases / NACKs the Event Log itself does not cover.

Helper — backend/app/drivers/windows_dhcp_audit.py. Reads the day’s file over WinRM via Get-Content -Raw, skips past the header block, feeds the remainder to ConvertFrom-Csv, returns the last N rows. Handles both UTF-16 (Unicode BOM) and ASCII — some Windows builds write one, some the other. Event-code → human label map covers the documented codes; unknown codes come through as Code <n> so new Windows releases don’t drop silently. Access denied / missing file / locked-by-rotation all return [] instead of 500.

Driver surface — WindowsDHCPReadOnlyDriver.get_dhcp_audit_events(day, max_events) — thin wrapper routed through the standard _load_credentials + _run_ps path.

Endpoint:

POST /api/v1/logs/dhcp-audit
  body: {server_id, day?, max_events?}   # day: Mon..Sun, null = today
  → [{time, event_id, event_label, ip, hostname, mac, description}, ...]

UI columns — Time / Event (code + label) / IP / Hostname / MAC / Description. Event-dot colour mirrors Windows severity families:

Colour Event family
green Grant / renew / successful ack
red Failure / conflict / NACK
muted Release / expire / scavenge
blue Auth lifecycle (server started, authorised, etc.)

The event-code picker shows distribution for the current day (e.g. 10 — Lease granted (238)) so an operator can filter down to a specific outcome without knowing codes by heart.

4.3 Dispatch & auto-fetch

The frontend uses useQuery keyed on every filter (server / log / level / max / since), so:

  1. Entering the page fires the first query immediately against the first available server + log.
  2. Changing any filter (including the Since datetime picker) fires a re-fetch automatically.
  3. staleTime: Infinity means switching tabs + back doesn’t hit the DC unless the user actually changes something.
  4. The explicit Refresh button calls refetch() to bypass the cache on demand.

Shared helpers (ServerPicker / MaxEventsPicker / FilterSearch / RefreshButton / QueryErrorBanner / QueryingIndicator / TruncatedNotice) are reused across both tabs.


4½. SpatiumDDI Agent Log Viewer (BIND9 + Kea)

Two additional tabs on the same Logs page, sourced from in-container agents instead of WinRM.

One server, a group, or all of them (#2120). Both tabs read a merged, newest-first list across a set of servers. The picker offers All servers, then per server group a <Group> (all N servers) entry followed by that group’s servers. The three reads behind the tabs — POST /logs/dns-queries, POST /logs/dns-queries/analytics and POST /logs/dhcp-activity — take an optional server_id and an optional group_id: server_id reads that server, group_id every agent server in that DNS or DHCP server group, neither every agent server. Every row carries the server_id and server_name that logged it, and the results grid shows a Server column whenever more than one server is selected. The analytics panels roll up across the whole selection, so a group behind one load-balanced address shows its real top names and top clients. The response echoes the scope plus server_ids, the servers the read actually covered.

The filter is a request, not an authorisation: the set it selects is always intersected with the servers the caller may read (see PERMISSIONS.md — a read grant on server may be scoped to one server or to one server group). A group the caller can read none of answers 403; “all” answers with whatever the caller can read. GET /logs/agent-sources lists only readable servers, with group_id / group_name. The Copilot tools find_dns_queries, query_dns_query_log and query_dhcp_activity_log take the same server_id / group_id and go through the same resolution (app/services/logs/agent_logs.py).

4½.1 DNS Queries tab

Source. BIND9’s query-log channel. The control-plane template (backend/app/drivers/dns/templates/bind9/named.conf.j2) renders a logging { channel queries_channel { file "/var/log/named/queries.log" versions 5 size 50m; ... }; category queries { queries_channel; }; }; block when DNSServerOptions.query_log_enabled is on. PowerDNS feeds the same pipeline, but from a different file: the agent’s start_daemon redirects pdns_server’s stderr into <state_dir>/pdns.log (inside the agent’s own state dir, so the unprivileged spatium user can write it) and passes that explicit path to QueryLogShipper, gated on log-dns-queries=yes + loglevel ≥ 6. That is the file to inspect, mount or override with DNS_QUERY_LOG_PATH on a PowerDNS agent — not /var/log/named/queries.log. The control plane dispatches parsing by server.driver, so the one ingest endpoint accepts either format.

Not Technitium. Technitium’s query logging is API/DB-backed (/api/logs/query*) rather than a tailable text file, so the file-tailing QueryLogShipper cannot reach it — it needs a poll-and-diff shipper instead. Tracked in #742; until it lands, a Technitium group’s queries are visible in Technitium’s own console but not on this tab.

Pipeline.

  1. BIND9 writes lines to /var/log/named/queries.log (path is templated; agents respect DNS_QUERY_LOG_PATH env override).
  2. The DNS agent’s QueryLogShipper thread (agent/dns/spatium_dns_agent/query_log_shipper.py) tails the file like tail -F, batches up to 200 lines or 5 s of activity (whichever first), and POSTs to POST /api/v1/dns/agents/query-log-entries.
  3. The control plane parses each line into DNSQueryLogEntry rows (backend/app/services/logs/bind9_parser.py) — timestamp, client IP+port, qname, qclass, qtype, flags, view. The original raw line is preserved alongside.
  4. UI calls POST /logs/dns-queries with a scope (server_id / group_id / neither) and filters (q substring, qtype, client_ip, since, max_events); newest first.

Filters. Server / group / all picker, qtype (A / AAAA / MX / …), client IP, datetime-since, max events, raw-substring search.

Resilience. File-not-yet-present (operator hasn’t enabled query_log_enabled) is silent — the shipper sleeps + re-checks. Inode-change rotation is detected and the file re-opened. Control-plane errors drop the batch (logs are triage data, not durable). Buffer is capped at 5000 lines; older half is trimmed if the control plane stays unreachable.

4½.2 DHCP Activity tab

Source. Kea’s kea-dhcp4 logger. The agent’s render_kea adds two output_options to the rendered config — stdout (existing docker logs workflow stays intact) and /var/log/kea/kea-dhcp4.log with in-process rotation (maxsize: 50_000_000, maxver: 5, flush: true).

Pipeline. Same shape as the DNS path — LogShipper thread (agent/dhcp/spatium_dhcp_agent/log_shipper.py) → POST /api/v1/dhcp/agents/log-entries → kea_parser.py → DHCPLogEntry rows.

Parser fields. Severity (DEBUG / INFO / WARN / ERROR / FATAL), Kea log code (DHCP4_LEASE_ALLOC, DHCP4_LEASE_DECLINE, DHCP4_PACKET_PROCESS_STARTED, etc), MAC address (after hwtype=N), lease IP, transaction id. Lines that don’t match the regex still get stored with the raw text — Kea has hundreds of log codes and we only structure-parse the most common shape.

Filters. Server / group / all picker, severity, log code (free-form text — operators paste e.g. DHCP4_LEASE_ALLOC), MAC, IP, datetime-since, max events, raw substring.

4½.3 Storage + retention

Two narrow tables (dns_query_log_entry, dhcp_log_entry) with composite indexes on (server_id, ts). Migration d8c5f12a47b9_query_log_entries. FK cascade drops a server’s rows when the server is removed.

Retention is a nightly Celery task (app.tasks.prune_logs.prune_log_entries) that deletes rows older than log_retention_hours (default 24 h, 1–168 h, Settings → Data Retention; the cap stays until #2185 replaces the single DELETE with partition drops). This is operator triage, not analytics — for longer history, ship to Loki / a SIEM.

Outage backlog (#1077). Agents spool the batches the control plane could not accept and replay them on reconnect, with the lines’ original timestamps — so a maintenance window fills in rather than leaving a hole. The ingest endpoints skip lines already older than the log_retention_hours window (reported as expired in the response) instead of inserting rows the next prune deletes, and the agents drop spooled log batches older than AGENT_SPOOL_LOG_MAX_AGE_HOURS themselves. Replays are deduplicated per batch (batch_id → agent_ingest_receipt, 35-day retention, pruned by the same nightly task). While an agent is draining a backlog the DNS / DHCP server row shows an amber Replaying … backlog chip; if its spool hit the cap, a red Spool trimmed chip and the agent_spool_trimmed alert (§9.1). See DNS_AGENT.md §3.

Why so short? Query logs are a firehose. A busy resolver doing 100 qps writes 8.6M rows per day; even 24 h of that is significant. Anyone needing days of history should run Loki alongside and the agent push can coexist with stdout shipping.


5. Audit Log

The audit log is a separate, append-only, structured log of all data mutations in SpatiumDDI. It is stored in the PostgreSQL database (not the log store) for queryability, immutability guarantees, and compliance.

AuditLog
  id (uuid)
  timestamp (timestamptz, indexed)
  user_id (FK → User)
  user_display_name: str    -- denormalized for historical record
  auth_source: str          -- local, a service source (system, acme, ...), or the name of the provider an external sign-in went through (up to 255, #1337)
  source_ip: inet
  user_agent: str
  action: enum(create, update, delete, login, logout, sync, permission_change, ...)
  resource_type: str        -- e.g., "subnet", "ip_address", "dhcp_scope"
  resource_id: str
  resource_display: str     -- human-readable, e.g., "10.1.2.0/24 (Corp LAN)"
  old_value: JSONB          -- full previous state (null for create)
  new_value: JSONB          -- full new state (null for delete)
  changed_fields: str[]     -- list of field names that changed (for updates)
  request_id: str           -- the request_id of the log lines that made the change:
                            --   the API request, or the Celery task id (#1245)
  result: enum(success, denied, error)
  error_detail: str (nullable)

Audit Log UI

Available at /admin/audit:

5.1 External forwarding

Every committed AuditLog row fans out to every enabled AuditForwardTarget. Each target picks its own transport, wire format, and optional severity / resource-type filter, so a single SpatiumDDI instance can feed (for example) a Splunk HTTP collector, a QRadar LEEF syslog relay, and a compliance-only JSON-lines sink at the same time. Configure under Settings → Audit Event Forwarding.

Delivery guarantee. Best-effort. A dedicated asyncio task runs outside the commit path, so the audit write always succeeds before forwarding is attempted. A failing collector never rolls back a DDI mutation. Each target’s failure is isolated — one dead SIEM doesn’t affect the others. Operators who need at-least-once replay should point a webhook at a queue (NATS / Kafka / Redis) and let that be the durable layer.

Implementation. SQLAlchemy after_commit listener in app/services/audit_forward.py. Snapshots new AuditLog rows inside after_flush, then in after_commit schedules one asyncio.create_task per (row, target) pair so slow collectors don’t serialize the queue.

Redaction (#2015). old_value, new_value and error_detail are forwarded with the same redaction the audit API applies on read (app/services/audit_redact.py): a value under a credential-shaped key arrives as [redacted], and a credential-shaped string (a JWT, a Fernet token, a PEM private key) is replaced wherever it appears. Booleans and integers are kept, so a writer’s smtp_password_set flag still arrives. The redaction is applied in _deliver_to_target, the one function every target kind sends through, and on a copy: the stored audit row is unchanged. Typed-event webhook deliveries are redacted the same way.

Transports.

Syslog output formats (per-target). Webhook targets always deliver JSON and ignore this setting.

Format Use case
rfc5424_json Default. Modern SIEMs (Splunk, Elastic, Graylog) auto-parse the JSON body after the RFC 5424 header.
rfc5424_cef ArcSight + many commercial SIEMs. CEF:0\|SpatiumDDI\|SpatiumDDI\|1.0\|<rtype:action>\|<display>\|<sev> header + act=/suser=/cs1=… extensions. CEF severity mapped as info=3, error=6, denied=9 (a failed sign-in is 9).
rfc5424_leef IBM QRadar native format. LEEF 2.0 with ^ field delimiter (safer over UDP than the default tab).
rfc3164 Legacy BSD syslog: <PRI>Mmm dd HH:MM:SS host tag: <JSON>. For collectors that don’t speak RFC 5424.
json_lines Bare JSON per line — no syslog framing. For raw Logstash / Fluentd / Vector inputs.

Severity mapping (RFC 5424): result="success" → 6 info, result="denied" / "forbidden" → 4 warn, result="failed" / "failure" / "error" → 3 err — except that a failed sign-in (action="login", result="failure": bad password, bad MFA code, locked or disabled account) ranks with denied, so 4 warn, CEF / LEEF 9, and it clears every min_severity threshold including denied. A sign-in the system could not evaluate (the LDAP / RADIUS / TACACS+ server timed out or errored) is audited error and stays 3 err. Before #1874, failure and forbidden both fell through to info, so a target filtered above info dropped every failed sign-in. Facility is per-target and configurable 0–23 (default 16 = local0).

Three payload shapes go through one delivery path, and until #1031 only the audit one was handled. Audit rows carry result + timestamp; alert events and the AI digest carry severity + fired_at. Every renderer and the filter gate now resolve both — see audit_forward._payload_severity / _payload_timestamp, the single place the shapes are reconciled.

Alert severities map onto the same RFC 5424 scale: critical → 2 crit, warning → 4 warn, info → 6 info. The audit mappings above are byte-identical to what they always were, because moving them would move every line an existing collector already indexes.

CEF and LEEF carry the same severity on their own numeric scales (3 / 6 / 9), from one shared map so the two renderers cannot disagree about how severe the same event was. LEEF’s sev is new — LEEF 2.0 defines it as an integer 1–10, and a word there is not something QRadar can map, so it would have left every alert at default severity.

Per-target filters.

Behaviour change in this release (#1031). Before it, a target with a non-null min_severity dropped every alert including criticals, because alerts were bucketed against a key they do not carry — and a syslog target on the default rfc5424_json format received no alert at all, since rendering raised KeyError: 'timestamp' and the per-target exception guard logged alert_deliver_failed and moved on. Both are fixed, so an operator who set that field long ago and forgot will start receiving alerts they were never getting. That is the intended behaviour; if the volume is unwanted, raise the threshold rather than clearing it. The “Test target” button never showed this, because it sends an audit-shaped payload with the filter forced off.

JSON payload shape (the body used by rfc5424_json, json_lines, and the webhook):

  {
    "id": "…", "timestamp": "2026-04-21T10:00:00+00:00",
    "action": "create", "resource_type": "dns_zone",
    "resource_id": "…", "resource_display": "example.com.",
    "result": "success", "user_id": "…",
    "user_display_name": "admin", "auth_source": "local",
    "changed_fields": [], "old_value": null, "new_value": {…}
  }

Backward compatibility. The pre-multi-target flat columns on platform_settings (audit_forward_syslog_*, audit_forward_webhook_*) are preserved and migrated into one audit_forward_target row apiece on upgrade. When the targets table is empty the service falls back to those flat columns so existing installs keep forwarding without operator intervention. They are slated for removal in a future release. The legacy webhook’s URL and header get the same write-only treatment as a target’s (#1502): audit_forward_webhook_url_encrypted / audit_forward_webhook_auth_header_encrypted, and GET /settings returns audit_forward_webhook_url_set / _url_display / audit_forward_webhook_auth_header_set instead of the values.

Known gap. Celery-scheduled audits (e.g. the lease-pull housekeeping row) may not forward — Celery wraps the task body in asyncio.run, which closes its loop before the scheduled dispatch task runs. Operator-triggered audit events (the 99% case) forward reliably from the API worker’s long-running loop. If scheduled-task forwarding turns out to matter, the fix is an awaitable drain in each task wrapper.


5b. Typed-event webhooks

Distinct from audit-forward: this is a curated typed-event surface for downstream automation, not raw audit rows. Operators configure subscriptions at Admin → Webhooks (/admin/webhooks).

Vocabulary. A curated set of typed events generated from a resource × verb cross-product (plus a handful of special-cased names) — e.g. space.created, subnet.bulk_allocate, dns.zone.updated, dhcp.scope.deleted, auth.user.created, integration.kubernetes.created. The live list is served by GET /api/v1/webhooks/event-types. Subscriptions with no event-type filter match everything.

Pipeline. Audit-row commit triggers an EventOutbox write per matching EventSubscription. Celery beat (event-outbox-drain, every 10 s) claims rows with SELECT … FOR UPDATE SKIP LOCKED, signs each POST with hmac(secret, ts + "." + body, sha256), and delivers. Failures retry with exponential backoff (2 / 4 / 8 … 600 s capped) up to max_attempts (default 8 ≈ 8.5 min cumulative). Permanent failures flip to state="dead" for operator review.

Wire format.

Delivery semantics. At-least-once modulo the audit-row commit window. The outbox write happens after the audit row commits, so a process crash between the two drops the event. Receivers should de-duplicate on event_id (= AuditLog.id).

Operator controls.


6. Prometheus Metrics

The API service exposes a Prometheus scrape endpoint at /metrics on the main API port (gated by the prometheus_metrics_enabled setting, default on; registered in backend/app/main.py).

Scraping needs a bearer token (#1159). The web port proxies /metrics and Docker Compose publishes the API port, so the endpoint is reachable from outside the deployment, and it answers only Authorization: Bearer <token>. Either token works:

A Prometheus scrape job reads the token from a file:

scrape_configs:
  - job_name: spatiumddi
    metrics_path: /metrics
    authorization:
      type: Bearer
      credentials_file: /etc/prometheus/spatiumddi-metrics-token
    static_configs:
      - targets: ["spatiumddi.example.com:443"]
    scheme: https

The appliance console’s API panel reads the scrape token from the chart’s Secret. PROMETHEUS_METRICS_REQUIRE_AUTH=false serves /metrics to anyone again; it isn’t recommended for any deployment whose web port or API port is reachable.

Implemented today: only the API-request family below, defined and emitted in backend/app/metrics.py via a Starlette middleware on every request. The IPAM / DHCP / DNS / Celery / Database families that follow are planned, not emitted — no collector or exporter populates them, and scraping /metrics returns the request family (plus the default prometheus_client process / GC metrics) only. The two spatiumddi_auth_* counters are declared in metrics.py but not yet incremented by any call site, so they read zero. For the shipped, agent-driven dashboard metrics path, see Built-in Dashboard Time-Series below — that one is real.

API Service Metrics (implemented)

spatiumddi_api_requests_total{method, path_template, status_code}
spatiumddi_api_request_duration_seconds{method, path_template} (histogram)
spatiumddi_api_active_requests (gauge)

The DNS agent config long-poll (#1111) adds three counters, labelled family="dns" (the DHCP long-poll does not count yet):

spatiumddi_agent_bundle_served_total{family, outcome}   # full / not_modified / client_gone
spatiumddi_agent_bundle_inline_renders_total{family}
spatiumddi_agent_bundle_inline_failures_total{family}

Plus two counters declared but not yet wired to a call site (no samples):

spatiumddi_auth_login_total{method, result}   # planned — method=local/ldap/oidc
spatiumddi_auth_token_usage_total{scope}       # planned

IPAM Metrics (planned — not emitted)

spatiumddi_subnet_utilization_percent{subnet_id, subnet_network, space_id}
spatiumddi_ip_addresses_total{subnet_id, status}
spatiumddi_subnets_total{space_id, status}

DHCP Metrics (planned — not emitted)

spatiumddi_dhcp_leases_active{server_id, scope_id}
spatiumddi_dhcp_pool_utilization_percent{server_id, scope_id, pool_id}
spatiumddi_dhcp_sync_last_success_timestamp{server_id}
spatiumddi_dhcp_sync_duration_seconds{server_id}
spatiumddi_dhcp_server_status{server_id}   # 1=online, 0=offline

DNS Metrics (planned — not emitted)

spatiumddi_dns_sync_last_success_timestamp{server_id}
spatiumddi_dns_records_total{server_id, zone_id, record_type}
spatiumddi_dns_zones_total{server_id, view_id}
spatiumddi_dns_blocklist_entries_total{list_id}
spatiumddi_dns_server_status{server_id}

Built-in Dashboard Time-Series (agent-driven) — shipped

For operators who don’t run Prometheus, SpatiumDDI ships a minimal self-contained time-series path used by the built-in dashboard charts. Agents poll their local daemons every 60 s and POST per-bucket counter deltas to the control plane:

Surface Source Endpoint
BIND9 statistics-channels XMLv3 (/xml/v3/server, bound to 127.0.0.1:8053, injected at render time) POST /api/v1/dns/agents/metrics
Kea statistic-get-all over the existing control socket POST /api/v1/dhcp/agents/metrics

The control plane stores deltas in two small tables — dns_metric_sample(server_id, bucket_at, queries_total, noerror, nxdomain, servfail, recursion) and dhcp_metric_sample(server_id, bucket_at, discover, offer, request, ack, nak, decline, release, inform, receive_drop, socket_drop, queue_evicted). Retention is enforced by the prune_metric_samples Celery task (daily), which deletes samples older than metric_retention_days (default 7 days, 1–365, Settings → Data Retention).

Three of those DHCP columns are about packets not handled, and they do not come from the same place or mean the same thing (issues #980, #2889). socket_drop is the kernel’s per-socket sk_drops on the sockets Kea serves from, read by the agent directly. Normally those are the DHCP-port UDP sockets in /proc/net/udp. With dhcp-socket-type: raw, kea-dhcp4 serves from AF_PACKET sockets, relayed traffic included. Their drops are read through sock_diag, in place of its UDP :67 fallback, whose copies Kea discards (#1283). Either way, these are packets the kernel discarded because Kea’s receive buffer was full. Kea never saw them, so no counter it keeps can report them. On a node short of CPU only this one moves, and it is the one the alert and the dashboard treat as loss.

queue_evicted (#2889) is loss as well: DHCPv4 requests Kea read and then evicted from its full multi-threading packet queue during a burst. Kea keeps no statistic for it, so the agent derives it from DHCPv4 arrivals at Kea’s intake (counted by a BPF-filtered AF_PACKET socket of its own), less the DHCPv4 share of socket_drop, less pkt4-received. The alert and the dashboard add it to socket_drop. See DHCP.md §4c.

receive_drop is Kea’s own pkt4/6-receive-drop — packets it read and then discarded — and is context, not a fault: it counts deliberate drops too. Verified against kea-dhcp4 3.0.3, a client matching a DROP client-class increments it once per packet, and a DROP class is exactly what the shipped DHCP MAC blocklist renders. See DHCP.md §4c.

All three are nullable, and NULL means UNMEASURED. queue_evicted is also NULL when the agent cannot open a raw socket, on a DHCPv6-only server, and for the bucket in which its counting socket reopened. (The minute in which the agent starts or Kea restarts has no bucket at all, for any counter.) An agent older than #980, or one whose runtime cannot read /proc/net/udp (or, in raw mode, Kea’s AF_PACKET sockets), reports no socket_drop. Readers must render that as unknown — a 0 would say “this server has dropped nothing”, which is exactly the false reassurance #980 was filed about. The ingest endpoint therefore stores an omitted field as NULL rather than clamping it, the dhcp_packets_dropped alert skips such a server rather than vouching for it, and every “was this measured?” test keys on socket_drop alone (receive_drop always arrives from a #980 agent, so testing the pair would read an unmeasurable server as measured-and-clean). The InfluxDB exporter omits the field entirely rather than writing 0, since line protocol has no null.

One more thing worth knowing about both endpoints: a second report for a bucket that already exists is added to it, not substituted. The agent floors bucket_at to the minute while its own interval is 60 s ± 3 s of jitter, so a tick early in a minute followed by a 57-59 s gap puts two genuinely different deltas in the same bucket — about one bucket in forty. Substituting (the pre-#980 behaviour, and the DNS endpoint’s until #1077) silently discarded a poll, which for a loss counter discards the minute somebody is looking for.

Accumulating is only safe because a retry is not a second delta. Since #1077 agents spool a metric POST that failed and replay it on reconnect, so the backlog of an outage lands on the minutes it happened and the chart fills in. Each replay carries the batch’s batch_id, and a batch already committed is acknowledged as a duplicate without being added again — the batch in flight when the control plane stopped would otherwise be counted twice.

The dashboard queries two read endpoints:

GET /api/v1/metrics/dns/timeseries?window={1h|6h|24h|7d}&server_id={uuid?}
GET /api/v1/metrics/dhcp/timeseries?window={1h|6h|24h|7d}&server_id={uuid?}

Server-side date_bin downsamples transparently — 60 s buckets for windows ≤ 24 h, 5 min buckets for 7 d. That keeps the response under ~2 k points regardless of retention.

Windows DNS / DHCP drivers don’t report through this path yet (they’d need Get-DnsServerStatistics / Get-DhcpServerv4Statistics calls over WinRM). The dashboard card shows “no data yet” rather than an error in that case.

InfluxDB Push Export (issue #889) — shipped

The same two tables feed an outbound push. InfluxDBTarget rows are configured under Settings → Metrics → InfluxDB Export; a Celery beat task (app.tasks.influxdb_push.push_influxdb_metrics, 30 s tick, per-target interval gating) renders line protocol and POSTs it.

This is a push, so it works where the Prometheus scrape does not — a control plane behind NAT, or an operator who already runs InfluxDB and does not want a second TSDB. The two are independent; running both is fine.

Measurements, versions (v1 / v2 / v3), idempotency and the high-water marks (a strictly-forward drain plus a separately-budgeted replay window) are documented in features/SYSTEM_ADMIN.md § 8. The short version:

Grafana with an InfluxDB datasource

Add the datasource (Connections → Add new connection → InfluxDB), pick Flux as the query language, and point it at the same URL, org, bucket and token you gave the target. Then, for the two panels operators want first:

DNS query rate per server, as queries/second:

from(bucket: "ddi")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "spatiumddi_dns_queries")
  |> filter(fn: (r) => r._field == "queries_total")
  |> map(fn: (r) => ({r with _value: float(v: r._value) / 60.0}))
  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)
  |> group(columns: ["server"])

The / 60.0 is the load-bearing part: the stored value is a delta per 60 s bucket, not a rate and not a running counter, so neither derivative() nor a bare plot gives you queries/second.

Subnet utilization, latest sample per subnet, for a table or gauge:

from(bucket: "ddi")
  |> range(start: -1h)
  |> filter(fn: (r) => r._measurement == "spatiumddi_subnet_utilization")
  |> filter(fn: (r) => r._field == "percent")
  |> last()
  |> group()
  |> keep(columns: ["subnet", "space", "_value"])

On a v1 datasource the same series are reachable through InfluxQL, where the deltas are plain values (SELECT sum("queries_total") FROM "spatiumddi_dns_queries" WHERE $timeFilter GROUP BY time($__interval), "server") — again a per-bucket sum, not derivative().

Celery / Worker Metrics (planned — not emitted)

spatiumddi_celery_tasks_total{task_name, state}   # state=success/failure/retry
spatiumddi_celery_task_duration_seconds{task_name} (histogram)
spatiumddi_celery_queue_length{queue_name}
spatiumddi_celery_workers_active (gauge)

Worker / beat health is currently surfaced through the GET /health/platform rollup (§ 7) and the Platform Insights admin surface, not through Prometheus metrics.

Database Metrics (planned — not emitted)

spatiumddi_db_pool_connections{state}   # state=checked_out/idle/overflow
spatiumddi_db_query_duration_seconds{operation} (histogram)
spatiumddi_db_replication_lag_seconds{replica}

Postgres diagnostics (DB size, cache hit, WAL position, slow queries, connection state) ship today through the Platform Insights admin surface (/admin/platform-insights, described in the Overview), which queries Postgres pg_stat_* views directly rather than exporting Prometheus metrics.


7. Health Endpoints

All services expose:

GET /health/live — Liveness probe

GET /health/ready — Readiness probe

GET /health/startup — Startup probe (for Kubernetes slow-start containers)

GET /health/platform — Per-component rollup for the dashboard


8. Bundled Grafana Dashboards

Status: planned / not shipped as a product bundle. No operator-facing Grafana dashboard set ships with the product, and the dashboards below depend on the planned (un-emitted) metric families in § 6. What does exist is a single perf-testing “war room” dashboard at perf/dashboards/grafana/dashboards/warroom.json (with provisioning under perf/dashboards/grafana/provisioning/ and a matching scrape config at perf/dashboards/prometheus/prometheus.yml). That stack is for the load/soak test harness under perf/, not for production observability, and it scrapes the perf exporters rather than the product /metrics endpoint’s request family.

The intended product dashboard bundle, once the § 6 metric families are emitted, would be:

Dashboard Contents
SpatiumDDI Overview API request rate, error rate, p95 latency, active users
IP Utilization Top subnets by utilization, space-wide heatmap, trending
DHCP Health Server status, lease counts, pool utilization, sync lag
DNS Health Server status, zone counts, sync lag, blocklist hit rate
System Health DB connections, replication lag, Redis memory, Celery queues
Audit Activity Actions per hour, top users, failed auth attempts

Until then, the shipped in-product equivalents are the Dashboard sub-tabs and Platform Insights surfaces described in the Overview.


9. Alerting

SpatiumDDI exposes two parallel paths for alerts. Both can run at once — they serve different audiences.

9.1 In-app alerts framework (native)

Operator-authored rules stored in the alert_rule table. The evaluator (backend/app/services/alerts.py:evaluate_all) runs on a 60 s Celery beat tick; POST /api/v1/alerts/evaluate forces an immediate pass. Each match opens an alert_event row (a UNIQUE partial index on (rule_id, subject_type, subject_id) WHERE resolved_at IS NULL keeps dedup O(1) and allows one open event per rule and subject); events resolve automatically when the subject clears. Delivery reuses the Audit Event Forwarding syslog + webhook targets — one SIEM destination for both audit and alerts.

A pass is built to fail safely (#2261). Each rule runs in its own savepoint, so a rule whose query errors or times out is logged as alert_rule_eval_failed and skipped while the rest of the pass completes. Events are delivered only after the pass commits them, so a failed or retried pass never pages for an event that is not in the database. A queued delivery is stamped on the event (delivery_pending_since) and claimed by a committed conditional update before it is sent, so no two senders ever send it. A worker killed between the commit and the send leaves the stamp set; every pass ends by re-sending any delivery stamped more than 10 minutes earlier (alert_redelivering_stranded), unless the event has since resolved or its rule was disabled. Passes are serialised by an advisory lock; a pass that starts while another is running (a slow tick, a Celery retry, or Evaluate now) logs alert_evaluate_skipped_locked and returns zero counts. The conformity evaluator follows the same rules.

Rule types described on this page. The build has 73 (RULE_TYPES in backend/app/services/alerts.py) and 41 of them have a row below. GET /api/v1/alerts/rule-types lists every type whose feature module is enabled, with its parameters, and some are documented on their own feature page instead (for example dhcp_pool_exhaustion in DHCP.md, stale_ip_count in IPAM.md). Among those with no row below are tls_cert_expiring and the other tls_cert_* rules, secret_expiring, new_mac_seen, rogue_dhcp, restore_drill_failed, schema_behind_head and the dns_*_suspected threat-analytics rules.

Type Fires when
subnet_utilization Subnet.utilization_percent ≥ threshold; honours PlatformSettings.utilization_max_prefix_{ipv4,ipv6} so PTP / loopback subnets can’t trip the alarm.
server_unreachable Any DNS / DHCP server whose status is unreachable or error. server_type filters the family.
asn_holder_drift / asn_whois_unreachable / rpki_roa_expiring / rpki_roa_expired ASN / RPKI signals from the WHOIS + ROA refresh tasks (#85).
bgp_prefix_hijack / bgp_more_specific_announced BGP prefix-hijack signals (#527) — an unexpected origin AS announcing a tracked prefix (exact) or a more-specific sub-prefix of it. Backed by the bgp_hijack_detection latch table (see §9.1a). Per-detection severity: critical when RPKI says the announcement is invalid, warning when RPKI coverage is unknown. Seeded disabled (external-signal rules are noisy).
bgp_lg_session_down / bgp_lg_rpki_invalid_route / bgp_lg_unexpected_origin / bgp_lg_more_specific / bgp_lg_route_flap / bgp_lg_missing_advertisement BGP Looking Glass internal-RIB signals (#566) — the companion to bgp_prefix_hijack above that reads the operator’s OWN live table via the receive-only collector (bgp_lg_peer / bgp_lg_route) instead of the public table. session_down fires on an enabled peer that stays out of established past a 2 min grace window anchored on BGPLGPeer.down_since (watermark set on first non-established observation, cleared on re-establish), auto-resolving the moment the session recovers. rpki_invalid_route fires on any active learned route whose ingest-computed rpki_status is invalid, always at critical severity. unexpected_origin / more_specific reuse the same bgp_tracked_prefix owned-prefix list #527 curates, matching an owned prefix exactly (==) or strictly more-specific (<<) against a learned route whose origin ASN is outside the expected set. route_flap fires when a route’s lifetime flap_count crosses threshold_percent (reused as a raw flap-count floor, default 5) AND its last_flap_at is within the trailing 10 min window, so it flags currently-unstable paths rather than permanent scars. missing_advertisement fires on a Subnet flagged bgp_should_advertise with no active learned route covering it (>>=) across any peer. Gated on the network.looking_glass module; all six seeded disabled (need an operator-configured peer, and the tracked-prefix rules need at least one enabled bgp_tracked_prefix).
rogue_ra Unexpected IPv6 Router Advertisement on a monitored segment (#524) — the passive RA sniffer observed an RA whose (group, source_ip, source_mac) identity isn’t an allowlisted router. Seeded disabled (needs DHCP_RA_SNIFFER_ENABLED + the ipv6.router_advertisements module).
ip_blocklisted An IP in scope for DNSBL monitoring appears on a curated RBL (#528) — a latch rule that auto-resolves when the IP is delisted or de-scoped. Gated on the security.dnsbl module + the dnsbl_monitoring_enabled sweep switch.
wol_wake_failed A Wake-on-LAN schedule’s latest finalised run (verify_state='done', started within threshold_days, default 1) left SENT hosts unconfirmed, and a fresh passive re-check still can’t see them (#596). Subject is the schedule, not the run — a 15-minute schedule would otherwise open ~96 events a day; the one event carries the blast-radius rollup. The re-check is what makes it recovery-aware: as stragglers boot and any subsystem stamps IPAddress.last_seen_at, the matcher stops matching and the shared loop auto-resolves — no bespoke resolve path. A clean next run, or the failing run ageing out of the window, resolves it too. Ad-hoc single-host wakes (wol_run.schedule_id IS NULL) never match: they have no schedule subject, and surface through History + the find_wol_wake_failures copilot tool instead. Per-schedule mute via wol_schedule.verify_alert_enabled. Seeded disabled (needs post-wake verify armed on a schedule).
domain_expiring / domain_nameserver_drift / domain_registrar_changed / domain_dnssec_status_changed Registry-side domain signals (#87). The two transition-once rules latch their value into last_observed_value JSONB so a single flip fires exactly one event auto-resolved after 7 d.
circuit_term_expiring / circuit_status_changed WAN circuit alerts (#93). Status-changed only fires on suspended / decom transitions and auto-resolves after 7 d.
service_term_expiring / service_resource_orphaned Service-catalog alerts (#94). Orphan-sweep walks network_service_resource join rows whose target was deleted.
compliance_change Audit-log scanner rule type (#105). One event per mutation against a classification-flagged subnet (or descendant IP / DHCP scope). Params: classification (one of pci_scope / hipaa_scope / internet_facing) and change_scope (one of any_change / create / delete). Watermark column on the rule baselines to now() on first run so historical audit history doesn’t retro-page operators. Resource resolution falls back to audit_log.old_value.subnet_id for delete actions where the live row no longer exists. Three disabled seed rules ship at first boot (PCI / HIPAA / internet-facing). Auto-resolves after 24 h.
node_pressure A cluster node reports sustained resource stalling through PSI (Pressure Stall Information, Kubernetes 1.36+, #983 Phase 2). Subject is the node NAME — cluster nodes have no row in this database. Reads avg300 out of the kubelet Summary API the Cluster screen already fetches, so there is no new collector. Note that alert evaluation runs in the Celery worker, so this needs worker.serviceAccount.enabled (a narrow nodes + nodes/stats grant) or the rule evaluates to nothing while appearing enabled. A cluster the worker cannot read is treated as UNKNOWN and holds open events rather than resolving them — returning “no matches” on a kubeapi blip would close the alarm and re-open it a minute later, flapping notifications through exactly the incident it reports. The kernel’s own 5-minute rolling average is what makes “sustained” need no state on our side. Warning when CPU or memory some stalling holds at or above threshold_percent (default 50); critical when memory full stalling — every runnable task blocked — holds at or above a fixed 1%. The two do not share a knob on purpose: some at 20% is a busy node, full at 20% is a node that did no work for a fifth of five minutes. CPU full is not evaluated at all (the kernel reports it as 0 at node level by definition). A node whose kubelet reports NO PSI never matches — null is UNRECORDED, not zero — so the rule is silent on a pre-1.36 fleet and is seeded enabled because of it. This is the alarm #980 needed: the appliance dropped relayed DHCP under CPU pressure with every dashboard green, because utilisation cannot tell a node at 70% CPU with a run queue from one without.
agent_daemon_degraded A DNS or DHCP server’s daemon is not serving and has not been for 5 minutes (#1067). Subject is the server (dns_server:<id> / dhcp_server:<id>). Three sources feed the same daemon_status columns, and the message names which one spoke (#2962). The agent’s own report (Agent reported: …): a daemon whose start is deferred, or whose control socket is unreachable. The supervisor, from the kubelet’s container state (Supervisor reported: …), for the cases where the agent dies with its container and cannot say so: an OOMKilled termination (oom_killed:, #2018 / #2023); a crash loop whatever the termination reason (crash_loop:, #2961), meaning the container restarted at least 3 times within 15 minutes, is not a healthy pod and has not stayed up 10 minutes, with the in-window count, the lifetime restart count, last termination reason and exit code in the text (a kubelet records an OOM group kill as Error / 137 once the pod is in CrashLoopBackOff, so the OOM verdict alone misses it). The in-window count is measured by the control plane from successive supervisor heartbeats (a backend-owned restart_history on each role_health entry), never from the kubelet’s lifetime restartCount: every node reboot and slot upgrade adds one restart to every container, so a lifetime count says how often the node rebooted, and right after a reboot a container is restarted, recently ended and not yet ready; and an agent silent under a live supervisor (agent_unreachable:, #2961), meaning no agent heartbeat for 3 minutes while the appliance’s supervisor still reports the container, counted only over time the control plane could have heard the agent: from the latest of its last heartbeat, the container’s current start, and the start of the supervisor’s current unbroken run of heartbeats (a backend-owned heard_since on the entry; a gap over 2 minutes starts a new run), so a rebooted node or a control plane that was itself down never counts its own absence as the agent’s. The 5-minute grace runs from the start of the state: the kill, the moment the third restart in the window was seen, or the start of the silence as just defined. One restart, a reboot, or a brief blip never fires. Paused (maintenance) and disabled servers are skipped. A health sweep that reads a server unreachable also sets a stale daemon_status=ok to NULL (unknown) rather than leaving a green the agent can no longer vouch for. Auto-resolves when the agent reports ok after the container has stayed up for 10 minutes. Seeded enabled.
appliance_heartbeat_stale An approved appliance’s supervisor has not heartbeated for 5 minutes (critical after 30) (#2259). Subject is the appliance (its UUID). Fleet and every other appliance rule show the node’s LAST reported roles, service health, firewall and storage state, so a node whose supervisor died keeps looking healthy; this is the alarm for the silence itself, and the only one that covers control-plane and observer-only nodes. The 5 min threshold sits above the Fleet UI’s ~3-heartbeat (90 s) greying so a reboot or slot upgrade does not page. Pending / revoked appliances and rows that never heartbeated are skipped. Auto-resolves on the next heartbeat. Seeded enabled; cannot fire on an install with no appliances.
postgres_instance_unhealthy An instance of the appliance’s CNPG PostgreSQL cluster has not been Ready, or a standby has not been streaming WAL from the current primary, for 5 minutes; critical after 15 (#2856). Subject is the instance (its pod name), and the message names its node. CNPG’s readiness probe passes for a standby that is up but not replicating, so CNPG reports “Cluster in healthy state” 3/3 while a standby loops on removed WAL: in a three-node failover drill that held for more than 10 minutes with every alert quiet. Streaming is read from pg_stat_replication on the primary, which the api and worker already connect to: a standby with no WAL sender there is not streaming. The app’s role is not in pg_monitor, so it sees each sender’s name but not its state; a connected sender whose state is hidden is unverified and not flagged, a visible state other than streaming is. A supervisor looping / stuck verdict (#2765), when a supervisor ships one in cluster_health.postgres_instances, counts too. The verdict is the one the Cluster → Overview database row uses, so a degraded row and this rule describe the same fact. No streaming verdict is made mid-switchover (targetPrimary differs from currentPrimary). The 5-minute clock is kept per instance in Redis, so it survives the finding changing (not Ready, then Ready but looping); a not-Ready pod’s own Ready transition time is used when it is earlier. Pods that cannot be listed, a pg_stat_replication that cannot be read while standbys exist, or an unreadable clock are UNKNOWN and hold open events rather than resolving them. Inert off the appliance and on a Postgres that is not a CNPG cluster; a single-node appliance can only match on its one instance not being Ready. Auto-resolves when the instance recovers. Seeded enabled.
cluster_dns_degraded The k3s cluster’s DNS (CoreDNS) is down, thinner than the appliance targets, co-located on one node, or not answering (#985). Subject is the cluster — CoreDNS is cluster-scoped, and which node a replica sits on is already in the message. Every pod resolves *.svc.cluster.local through it (the api reaches Postgres and Redis that way), so a failure here surfaces later as unrelated components failing to start. Reads the same cluster-health snapshot the Cluster → Overview card renders, so there is no new collector and no new RBAC. Warning when fewer replicas are ready than ensure_coredns_ha targets (min(nodes, 2)), or when the ready replicas share a node — two replicas on one node is not HA, and Kubernetes never rebalances running pods (#633). Critical when no replica is ready, or when the live resolve probe fails. The probe is the load-bearing half: replica counts say the pods exist, the probe says the path works, and ready 2 / spread ok / probe failed is a real and distinct state pointing at kube-proxy or the CNI rather than at CoreDNS. It needs no RBAC (it is a DNS query from the pod) and therefore runs even in the worker, whose ServiceAccount deliberately cannot list pods cluster-wide — so the rule still fires there on a probe failure, and the worker is a genuine second vantage rather than a claimed one. A replica view that cannot be read is UNKNOWN and holds open events rather than resolving them; null is never treated as zero. Seeded enabled, and inert off the appliance.
appliance_storage_degraded An appliance’s software RAID (md) array or multipath map has lost redundancy (#999 Part A). Subject is the appliance. The supervisor reads /proc/mdstat + /sys/block out of the host through its privileged / hostPID window and folds the reading into the same cluster_health JSONB the #402 host partitions ride, so there is no new heartbeat field, no column and no migration. Severity keys off redundancy remaining, never off the state string — 2 of 3 in a three-way mirror and 1 of 2 in a pair both report degraded, and only the second has nothing left to lose. Critical when an array is down to the members it needs to serve at all, when it has failed outright, or when a multipath map has no paths at all. A map with exactly ONE path is deliberately not alarmed on: nothing knows whether it ever had more, and the installer explicitly permits installing to a single-path LUN — so a count-based alarm there would be critical forever, turn the console verdict red forever, and be clearable by no action, which is how an alarm gets muted before the night it matters. The count is on every screen for an operator who knows what it should be. Warning when redundancy is reduced but a further loss is survivable, or when some SCSI paths report a fault while others remain. A scrub or resync on an intact array is deliberately not an event — it is shown on all three screens with its progress, and nothing more. #999 asked for it as “informational, auto-clears”, which would be right if info were quiet BY DEFAULT; it is not, because min_severity is nullable and NULL forwards everything — so on a stock install an info event notifies exactly like a critical one. (#999 originally gave a second reason that no longer holds: delivery filtered min_severity against payload["result"], a key alert payloads never carry, so a target with a threshold received nothing and info could not be muted at all. #1031 fixed that. What has not changed is the default, and a finding that is quiet only for operators who went and configured it is not quiet.) Debian runs checkarray monthly by cron, so that row would mail every operator with an array, every month, about their array working correctly, which is how an alarm gets muted before the night it matters. The rebuild that DOES matter never reaches that branch anyway: an array with a member out of sync reports degraded and is classified on redundancy like any other. An assembled array that will not report its member count is a warning, one that failed to assemble (array_state=inactive) is critical whatever its member count says, and an IMSM/DDF metadata container is skipped entirely — it is permanently inactive on a healthy box and would raise an alert nothing could clear. A reading that disappears is not a recovery either: an appliance with an open event and no current reading is re-matched at its existing severity, so an A/B rollback to a pre-#999 supervisor holds the alarm instead of resolving it. Multipath is under-sensitive by construction and says so: the only per-path signal available without the device-mapper ioctl is the path’s SCSI device/state, which stays running when multipathd’s checker fails a path, so a map that has quietly lost half its paths produces no finding — which is why every surface renders a finding-less multipath map in a NEUTRAL style rather than the green an md array earns. Revoked appliances are skipped; so are supervisors too old to report storage, because no storage key is UNKNOWN and not a clean bill of health. Classified by one function (services/appliance/storage_health.evaluate_storage) shared with the find_appliance_storage copilot tool and both dashboard surfaces, so a chip cannot read “clean” while the alert reads “degraded”. Seeded enabled — it is silent on an appliance with no arrays, which is every appliance until somebody builds one.
appliance_disk_usage An appliance’s /var data partition is filling (#2240). Subject is the appliance. /var holds Postgres, the container images and the logs. Reads the host_disk_partitions every supervisor already reports inside cluster_health (#402), the reading behind the Cluster overview’s node cards and the upgrade pre-flight’s headroom check, so there is no new collector, field or migration. Warning at threshold_percent (default 80); critical at a fixed 87% used, whatever the threshold. The two do not share a knob on purpose: /var is ext4 with the default 5% reserved for root, and the supervisor counts that reserve as free (blocks - bfree), so in those terms the kubelet’s nodefs.available<5% (eviction-hard in the appliance’s k3s config) and image-gc-high-threshold=95 both begin at about 89.8% used, where the kubelet starts evicting pods, the control plane’s among them. The critical sits a few points before that line rather than on it: the evaluator runs every 60 s on 30 s heartbeats, so a critical on the line would arrive after the first evictions. A seven-node cluster loaded towards a million devices filled /var (23 GiB of a 40 GiB disk) to 88 to 91% on every node with no alert raised. Only /var is judged: the root slot is a read-only image and the ESP a fixed boot partition. A supervisor that reports no usable /var never matches (UNKNOWN, not healthy). A reading that disappears, or one older than 10 minutes (the node stopped heartbeating; appliance_heartbeat_stale reports that), opens nothing new and holds an open event at its severity rather than resolving it. Revoked and unapproved appliances are skipped. Auto-resolves when usage drops below the threshold. Seeded enabled at 80% at startup, like the other infrastructure rules: disable it rather than delete it, because a deleted seeded rule is created again at the next restart.
agent_spool_trimmed A DNS or DHCP agent’s durable push spool (#1077) hit its size cap (AGENT_SPOOL_MAX_BYTES, default 256 MiB) during a control-plane outage and discarded its oldest queued batches — part of the outage’s logs, metrics or lease events will never arrive. Subject is agent with a dns_server:<id> / dhcp_server:<id> subject id, the same shape as agent_config_rejected. Reads the spool_status the heartbeat stored; no probing. Fires while the reported last_trim_at is within the last 24 h and auto-resolves after that (trim counters are cumulative, so there is no “recovered” report to key off). Critical when lease_events were among the streams trimmed — Kea lease events are the only way the control plane learns about agent-managed leases, so a trimmed one is a lease with no IPAM mirror and no DDNS record until the client renews or the agent’s lease snapshot reconciles it; warning otherwise. NULL (an agent too old to report a spool, or an agentless driver) never matches. Seeded enabled.
dns_agent_duplicate_instance Two live DNS agent processes share one server identity (#2044) — usually a cloned VM or a copied agent state directory that kept the same agent_id. The control plane serves only the most recent process and answers the other’s config polls with 409; the refused agent keeps serving a configuration that no longer changes while heartbeating normally. Subject is agent with a dns_server:<id> subject id. Reads the duplicate-episode columns the config long-poll writes when it refuses a poll; no probing. Fires once refusals have gone on for 2 minutes (so an overlapping rollout’s old pod, refused once before it exits, does not) and auto-resolves 10 minutes after the last one. A replacement agent, whose predecessor never polls again, is never refused. Warning. Seeded enabled, once, by migration f4c2a8e61b93 (not at startup), so deleting it is final.
dns_blocklist_feed_stale An enabled feed-sourced blocklist whose refresh has not succeeded within 3 x its update_interval_hours (#2313). Judged from last_success_at (last_synced_at also moves on failure); a feed that never succeeded is measured from its creation. Servers keep enforcing the entries the list last had. Subject is dns_blocklist. Warning. Skips manual-only (interval 0) and disabled lists; assignment to a group is not a condition. The message carries a fixed reason, never the stored fetch error or any URL. Auto-resolves on the next good refresh. Seeded enabled.
dns_pool_health_stale An enabled DNS pool with at least one enabled member whose health verdict (last_check_at) is older than 3 x the pool’s check interval (floor 30 s, the health-check task’s own cadence) (#2312). Pools fail open: a member last seen healthy is still rendered, so a stopped dispatcher leaves a member that died since in the answer set. Subject is dns_pool, one event per pool. Warning. Auto-resolves when the pool is checked again. Every alert rule is evaluated by the Celery worker, so a stopped worker stops the health checks AND this alert; a stopped worker is reported by the platform health components celery-workers / celery-beat instead. Seeded enabled.
dnssec_multi_signer A DNSSEC-signed zone whose server group has more than one agent DNS server (BIND9 / PowerDNS / Technitium — disabled servers count, since a disabled agent still receives and serves the bundle) (#1762). Each server signs with keys it generated, so the zone is served with several DNSKEY sets against the one DS published at the parent, and validating resolvers SERVFAIL on whichever server it does not match. Signing and every operator server-add are refused on such a group; this rule covers what cannot be refused — zones signed before the rule, and an agent that auto-registered (AGENT_GROUP) into a signed group. Subject is dns_zone. Critical. Auto-resolves when the zone is unsigned or the group is back to one server. Seeded enabled.
backup_failed The last finished run of an enabled backup target with a schedule failed (#1262). Subject is the backup target. Auto-resolves when its next run succeeds. A run in progress neither opens nor resolves it, so a target failing every night holds one event. The message leaves out the run’s error text, because driver errors can name hosts, paths or buckets and alert payloads leave the install; the error is on the Backup page and in the audit log. Manual-only and disabled targets are not watched. Severity warning. Seeded enabled.
backup_stale An enabled backup target with a schedule has had no successful run within N scheduled runs plus one hour (#1262). N is threshold_percent, used as a count, default 2. Counted from the last success (last_run_at when the last run succeeded, otherwise the newest backup_target_run_success audit row), but never from before the schedule was set or changed, so a new schedule gets its first run first. Catches backups that stopped running at all: worker or beat down, or a run left in_progress by a dead process, which the sweep then skips for good. A run in progress for less than two hours holds the event; an older one counts as no run. Severity critical. Seeded enabled. Same decision as the get_backup_health copilot tool.

Admin UI lives at /admin/alerts — rules CRUD + live events viewer (15 s refetch) + per-event Resolve to manually silence a known- noisy event.

A manual Resolve sticks (#2498): the event is stamped last_observed_value.resolved_by = "manual", and while its subject keeps matching, the evaluator opens no new event and delivers nothing. Once the subject stops matching, the suppression ends, so a later re-match opens a fresh event. Same semantics as the conformity engine below; the evaluator’s own auto-resolve is unchanged. This covers the threshold-style rules evaluated by the shared loop; the rule types with their own evaluator (transition-once, latch and similar) keep their own handling.

Conformity evaluations (#106) also feed this surface: a ConformityPolicy can pin fail_alert_rule_id, and a pass→fail transition on a (policy, resource) pair opens an AlertEvent row against the named alert rule with subject_type="conformity" so operators see drift in the same dashboard. Configure the wiring in the policy edit modal at /admin/conformity.

The conformity engine, not the alert evaluator, closes these events (#2277). An open event resolves when its (policy, resource) next evaluates pass or not_applicable, when the resource is deleted or drops out of the policy’s target filter, when the policy is disabled or deleted, and at its next evaluation once the policy’s alert rule is cleared or switched (a failure still in progress then pages once on the new rule). A warn result or a check that raised holds the event open. The resolved event’s last_observed_value.resolution says why (passed, not_applicable, out_of_scope, policy_disabled, policy_deleted, alert_rule_changed). A later re-failure opens a new event and pages again. An event you resolve by hand while the resource is still failing stays resolved until the resource passes and then fails again.

9.1a BGP prefix-hijack monitoring (#527)

Extends the ASN subsystem with route-origin monitoring: is someone else announcing your prefix? Two tables + a beat task + the two alert rules above.

What’s tracked. bgp_tracked_prefix holds the prefixes SpatiumDDI watches on the public routing table (one row per (asn, prefix)). The poll auto-populates them from each public ASN’s RPKI ROAs + the RIPEstat announced-prefixes feed; operators can also add source="manual" rows (never auto-pruned). expected_origin_asn is denormalised from the AS number for a fast mismatch compare, and allowed_origins is the operator allowlist of additional legitimate origins (intentional multi-origin / anycast / DDoS-scrubbing).

Detection. bgp_hijack_detection is the latch/dedup state — one row per observed hijack, resolved_at NULL while active. On every tracked prefix the poll compares the currently-observed origin ASes (RIPEstat prefix-overview for the exact prefix, related-prefixes for more-specifics) against the expected + allowlisted set. An unexpected origin that is not covered by a valid ROA for that origin opens a detection (prefix_hijack for the exact CIDR, more_specific for a sub-prefix that wins BGP longest-match).

RPKI severity ladder (reuses the ROA data from app.tasks.rpki_roa_refresh):

Latch / auto-resolve. An ongoing announcement bumps last_seen_at on the same row (one stable AlertEvent subject); the poll resolves detections whose announcement has been absent for the delist window (default 12 h), which auto-resolves the mirrored AlertEvent — the same latch-and-auto-resolve shape as the domain_* / circuit_* rules. Operators can acknowledge a detection (mutes the alert now) or allowlist-origin (appends the origin to the tracked prefix’s allowlist so it never fires again).

Worker model — two delivery paths. The periodic RIPEstat poll (app.tasks.bgp_hijack_poll.poll_bgp_hijacks, beat-fired hourly) is the reliable source of truth and the alert-latch owner — it runs on any standard Celery deployment, gated on PlatformSettings.bgp_monitoring_enabled (default OFF) with per-prefix next_check_at pacing (bgp_monitoring_interval_hours, default 6 h). An optional RIS Live WebSocket consumer (python -m app.services.bgp.ris_live, gated by the BGP_RIS_LIVE_ENABLED env flag, default OFF) is the real-time upgrade — it subscribes to RIPE RIS Live for the tracked prefixes and writes into the same detection table via the same app.services.bgp.hijack_monitor helpers, so it never becomes load-bearing. The feature works fully without it.

Surfaces. Per-ASN BGP Monitoring tab on the ASN detail page (tracked prefixes + detections + Check BGP now + acknowledge / allowlist); the two alert rules appear in /admin/alerts automatically. MCP: find_bgp_hijacks / count_bgp_hijacks / find_tracked_prefixes (read, default-on) + propose_allowlist_bgp_origin (write, default-off). Feature-module: folds into the existing network.asn module (no new module).

9.2 Prometheus alerting rules (external)

The control plane’s /metrics registry declares eight spatiumddi_* series (beside the client library’s standard process_* and python_*). Six carry samples today; the two auth counters are declared but nothing increments them yet, so they carry no samples, and the agent-bundle counters only ever see family="dns":

Series Type Labels
spatiumddi_api_requests_total counter method, path_template, status_code
spatiumddi_api_request_duration_seconds histogram method, path_template
spatiumddi_api_active_requests gauge  
spatiumddi_auth_login_total counter (no samples yet) method, result
spatiumddi_auth_token_usage_total counter (no samples yet) scope
spatiumddi_agent_bundle_served_total counter family, outcome
spatiumddi_agent_bundle_inline_renders_total counter family
spatiumddi_agent_bundle_inline_failures_total counter family

For example, an API error-rate alert is sum(rate(spatiumddi_api_requests_total{status_code=~"5.."}[5m])) / sum(rate(spatiumddi_api_requests_total[5m])) > 0.05 (a perf-testing Prometheus config already lives under perf/dashboards/prometheus/).

Planned, not emitted yet. There are no Prometheus series for DHCP / DNS server status, DHCP sync age, pool or subnet utilization, database replication lag, Celery queue length or task failures, so the usual rules over them (server offline, pool near exhaustion, replication lag, queue backlog, failed backups) cannot be written against /metrics today. Use the in-product alert rules above (server_unreachable, subnet_utilization, dhcp_pool_exhaustion, backup_failed, …) for those conditions. Exporting them as series is tracked in #2146 and #1267.

DNS Agent Telemetry

See docs/deployment/DNS_AGENT.md §4 for the full protocol.

Metrics

The control plane emits no per-agent Prometheus series yet (no agent-up, config-lag, zone-serial, failed-op or token-rotation metric). The same facts are on the server row in the console and in the API: heartbeat age and status, the applied config etag and apply status, per-server zone serials (DNSServerZoneState) and queued record ops. Agents are egress-only and do not expose a scrape endpoint; the control plane derives everything from the heartbeat body. Agent-level series are part of #2146.

Logs

Agent logs are structured JSON (non-negotiable #7) and include the canonical service=spatium-dns-agent binding. Notable events: