Observability was running but could not report anything. This closes that end to end and makes every piece of it reproducible from the installers instead of from a shell history. Logs - Filebeat 9 REMOVED the container input; it refuses the config per container and then runs with zero harvesters and no index. Container logs now use filestream + the container parser with a stable id each, and add_docker_metadata is told where the id is. - Every line gets event.dataset, or Kibana's Logs rate chart is one "unknown" bucket. - Beats pin replicas to 0: a single node cannot place one, so the cluster sat yellow forever. Security - xpack.security.enabled=true, because Kibana's Observability apps ask Elasticsearch whether the user holds a privilege and that API does not exist when security is off (the Infrastructure page failed on every view). Nobody sees a login: Kibana's anonymous provider signs every visitor in as one native user, and Caddy + Authentik + DIAG_REQUIRE_GROUP stays the only gate. configure.py generates the service passwords, creates the accounts and writes a root-only netrc for the deadman; --es-security retries just that step. Alerting - A basic licence has no webhook connector (403, Gold+), so Kibana's rules write documents into a vanguard-alerts index through the free .index connector and vanguard-alerts.timer delivers them to Discord. Alerts are therefore searchable beside the logs that caused them. - Six rules in observability/kibana-alarms.py: disk and memory below 15% free, bot not running, bot unhealthy, recordings stuck over an hour, normalise failures parked. Each repeats every 12 h while unresolved. - The deadman watches both halves of that pipeline, since nothing inside the stack can report that the stack's own alerting died. Our own metrics - bin/vgmetrics.sh counters from the sweep and vgdeband, plus live gauges on dumpd /metrics (loopback only, unrouted by Caddy), scraped by Metricbeat. vanguard_media_pending is the measured answer to whether normalising keeps up, which was previously only a benchmark guess. - The sweep now captures ffmpeg's stderr: rc= alone was not diagnosable. - Its file loop reads a process substitution, not a pipe, or the subshell discards every counter. Housekeeping - configure.py --apply installs the media scripts (it never did, so applies left stale binaries), creates the alert index, injects an ILM delete phase and runs Kibana's first-run setup. - The discord-bot container asks the bot about itself via /health rather than probing a port. - Removed the dead diag landing page: Kibana occupies the root of that host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
7.7 KiB
Python
Executable File
151 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""The real alarm rules. Idempotent: creates what is missing, leaves what exists.
|
|
|
|
Every rule is an Elasticsearch query rule that counts matching documents in a window and writes one document
|
|
into the alert bus through the "Vanguard alert index" connector, which bin/vanguard-alerts.sh delivers to
|
|
Discord. Run observability/kibana-setup.py first — it creates that connector.
|
|
|
|
FIRING CADENCE: notify_when=onThrottleInterval with throttle=12h, so an alarm that stays true writes again
|
|
every 12 hours and stops when the condition clears. The delivery script's own 15-minute per-rule suppression
|
|
sits well inside that and never interferes.
|
|
|
|
WHAT IS DELIBERATELY NOT HERE
|
|
Elasticsearch, Kibana and Beat liveness, and the disks, are bin/vanguard-deadman.sh's job. Nothing that
|
|
depends on Kibana being alive can report that Kibana is dead, so those checks stay outside the stack and
|
|
duplicating them here would be worse than useless: they would go quiet in exactly the case that matters.
|
|
|
|
pw=$(grep -m1 '^ES_ELASTIC_PASSWORD=' .env | cut -d= -f2-)
|
|
KEY=$(curl -s -XPOST -u "elastic:$pw" localhost:9200/_security/api_key \
|
|
-H 'Content-Type: application/json' -d '{"name":"vanguard-cli"}' \
|
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["encoded"])')
|
|
KEY="$KEY" python3 observability/kibana-alarms.py
|
|
|
|
To change a rule, edit it in Kibana or delete it there and re-run this. Thresholds here are the defaults:
|
|
FREE_PCT 15 alarm when a real filesystem or memory has less than this percent free
|
|
STUCK_SECONDS 3600 alarm when a finished recording has waited this long to be normalised
|
|
THROTTLE 12h how often an unresolved alarm repeats
|
|
"""
|
|
import json, os, urllib.error, urllib.request
|
|
|
|
KIBANA = "http://127.0.0.1:5601"
|
|
HEADERS = {"Content-Type": "application/json", "kbn-xsrf": "1",
|
|
"Authorization": "ApiKey " + os.environ["KEY"]}
|
|
CONNECTOR_NAME = "Vanguard alert index"
|
|
|
|
FREE_PCT = float(os.environ.get("FREE_PCT", "15"))
|
|
STUCK_SECONDS = int(os.environ.get("STUCK_SECONDS", "3600"))
|
|
THROTTLE = os.environ.get("THROTTLE", "12h")
|
|
USED_MAX = round(1 - FREE_PCT / 100, 4) # 15% free -> used.pct >= 0.85
|
|
|
|
# Filesystems that are not disks. Without this, every tmpfs and overlay mount reports ~100% used and the
|
|
# disk alarm would fire permanently on something that cannot fill up.
|
|
PSEUDO_FS = ["tmpfs", "devtmpfs", "overlay", "squashfs", "ramfs", "autofs", "nsfs", "tracefs", "debugfs",
|
|
"configfs", "mqueue", "hugetlbfs", "pstore", "bpf", "devpts", "proc", "sysfs", "cgroup",
|
|
"cgroup2", "fusectl", "securityfs", "efivarfs", "binfmt_misc", "fuse.lxcfs", "iso9660"]
|
|
|
|
def rule(name, level, why, query, *, index="metricbeat-*", window=10, comparator=">", threshold=0):
|
|
return {"name": name, "level": level, "why": why, "query": query,
|
|
"index": index, "window": window, "comparator": comparator, "threshold": threshold}
|
|
|
|
ALARMS = [
|
|
rule("Disk free below %g%%" % FREE_PCT, "error",
|
|
"A filesystem is nearly full. Elasticsearch turns its indices READ-ONLY at the flood watermark and "
|
|
"does not undo that by itself, and recordings stop being published.",
|
|
{"query": {"bool": {
|
|
"filter": [{"range": {"system.filesystem.used.pct": {"gte": USED_MAX}}}],
|
|
"must_not": [{"terms": {"system.filesystem.type": PSEUDO_FS}}]}}}),
|
|
|
|
rule("Memory free below %g%%" % FREE_PCT, "warn",
|
|
"Memory is nearly exhausted. Elasticsearch has a pinned 2 GB heap, so the pressure is usually "
|
|
"ffmpeg or Jellyfin, and the kernel will start killing processes rather than queueing.",
|
|
{"query": {"range": {"system.memory.actual.used.pct": {"gte": USED_MAX}}}}),
|
|
|
|
rule("Discord bot not running", "error",
|
|
"No metrics from the discord-bot container for 5 minutes: it is stopped, removed, or restarting too "
|
|
"fast to be sampled. Nothing it does is happening.",
|
|
{"query": {"term": {"container.name": "discord-bot"}}},
|
|
window=5, comparator="<", threshold=1),
|
|
|
|
rule("Discord bot unhealthy", "error",
|
|
"The bot is running but reports itself degraded: its /health says the Discord gateway is "
|
|
"disconnected or the database does not answer. It is up and doing nothing.",
|
|
{"query": {"bool": {"filter": [{"term": {"container.name": "discord-bot"}},
|
|
{"term": {"docker.healthcheck.status": "unhealthy"}}]}}},
|
|
window=5),
|
|
|
|
rule("Recordings stuck waiting to be normalised", "warn",
|
|
"A finished recording has waited over %d minutes in .live/. It is not in Jellyfin, not in copyparty "
|
|
"and not announced, so to everyone else it does not exist." % (STUCK_SECONDS // 60),
|
|
{"query": {"range": {"prometheus.vanguard_media_pending_oldest_seconds.value": {"gt": STUCK_SECONDS}}}}),
|
|
|
|
rule("Normalise failures parked", "warn",
|
|
"One or more files have a normalize-failed log, so the sweep skips them forever until it is deleted. "
|
|
"The log now contains ffmpeg's stderr, which says why.",
|
|
{"query": {"range": {"prometheus.vanguard_media_failed_files.value": {"gt": 0}}}}),
|
|
]
|
|
|
|
def req(method, path, body=None):
|
|
request = urllib.request.Request(KIBANA + path, method=method,
|
|
data=None if body is None else json.dumps(body).encode())
|
|
for key, value in HEADERS.items():
|
|
request.add_header(key, value)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
raw = response.read()
|
|
return json.loads(raw) if raw else {}
|
|
except urllib.error.HTTPError as error:
|
|
print(f" {method} {path} -> HTTP {error.code}")
|
|
print(" " + error.read().decode(errors="replace")[:600])
|
|
raise SystemExit(1)
|
|
|
|
connectors = {c["name"]: c["id"] for c in req("GET", "/api/actions/connectors")}
|
|
if CONNECTOR_NAME not in connectors:
|
|
raise SystemExit(f"connector {CONNECTOR_NAME!r} is missing — run observability/kibana-setup.py first")
|
|
connector_id = connectors[CONNECTOR_NAME]
|
|
|
|
present = {r["name"] for r in req("GET", "/api/alerting/rules/_find?per_page=100").get("data", [])}
|
|
created = skipped = 0
|
|
for alarm in ALARMS:
|
|
if alarm["name"] in present:
|
|
print("already present:", alarm["name"])
|
|
skipped += 1
|
|
continue
|
|
made = req("POST", "/api/alerting/rule", {
|
|
"name": alarm["name"],
|
|
"rule_type_id": ".es-query",
|
|
"consumer": "stackAlerts",
|
|
"enabled": True,
|
|
"schedule": {"interval": "1m"},
|
|
"params": {
|
|
"searchType": "esQuery",
|
|
"index": [alarm["index"]],
|
|
"timeField": "@timestamp",
|
|
"esQuery": json.dumps(alarm["query"]),
|
|
"size": 1,
|
|
"aggType": "count",
|
|
"groupBy": "all",
|
|
"thresholdComparator": alarm["comparator"],
|
|
"threshold": [alarm["threshold"]],
|
|
"timeWindowSize": alarm["window"],
|
|
"timeWindowUnit": "m",
|
|
"excludeHitsFromPreviousRun": False,
|
|
},
|
|
"actions": [{
|
|
"group": "query matched",
|
|
"id": connector_id,
|
|
"params": {"documents": [{
|
|
"kind": "alarm",
|
|
"rule": "{{rule.name}}",
|
|
"level": alarm["level"],
|
|
"message": alarm["why"] + " [{{context.message}}]",
|
|
}]},
|
|
# every 12 h for as long as it stays true, then silence when it clears
|
|
"frequency": {"summary": False, "notify_when": "onThrottleInterval", "throttle": THROTTLE},
|
|
}],
|
|
})
|
|
print("created:", made["name"], "|", alarm["level"], "| repeats every", THROTTLE)
|
|
created += 1
|
|
|
|
print(f"\n{created} created, {skipped} already there. Disk/memory threshold {FREE_PCT:g}% free; "
|
|
f"stuck after {STUCK_SECONDS}s.")
|