-
Notifications
You must be signed in to change notification settings - Fork 0
Observability
Centralized logging and metrics stack -- Loki for log storage, Alloy for journal collection and host metrics, Prometheus for metrics storage, podman-exporter for container metrics, and Grafana for dashboards.
All five containers are managed by the logging role. The role also installs a
policy-driven log inspection timer that queries Loki on a schedule and writes the
latest JSON report to disk. Grafana state, Prometheus TSDB, and Loki data are
disposable -- Grafana is fully provisioned from Ansible templates, while
Prometheus and Loki will repopulate from live data. None require backup.
| Container | Image | Port | Purpose |
|---|---|---|---|
loki |
docker.io/grafana/loki |
3100 | Log storage |
alloy |
docker.io/grafana/alloy |
12345 | Journal collector + host metrics exporter |
prometheus |
docker.io/prom/prometheus |
9090 | Metrics storage |
podman-exporter |
quay.io/navidys/prometheus-podman-exporter |
9882 | Container metrics |
grafana |
docker.io/grafana/grafana |
3000 | Dashboard UI |
| Property | Value |
|---|---|
| Traefik subdomain |
grafana.media.example.com (Grafana only) |
| Config directory | /home/mms/config/logging |
| Loki data | /home/mms/config/logging/loki-data |
| Grafana data | /home/mms/config/logging/grafana-data |
| Prometheus data | /home/mms/config/logging/prometheus-data |
| Prometheus retention | 30 days |
| Loki retention | 30 days (720h) |
| Log inspection timer | Every 15 minutes (configurable via logging_inspection_schedule) |
| Log inspection policies | /home/mms/config/logging/inspection/policies |
| Latest inspection report | /home/mms/config/logging/inspection/latest-report.json |
| Notification env file | /home/mms/config/logging/inspection/notifications.env |
| Autodeploy group |
interactive (daily at 02:00) |
Alloy ships user journal entries to Loki with the job="mms" label. The logging
role publishes Loki on 127.0.0.1:3100 so host-side tools can query it without
exposing Loki to the LAN. mms-log-inspect.timer starts
mms-log-inspect.service every 15 minutes by default.
The service runs:
~/config/logging/bin/mms-log-inspect \
--loki-url http://localhost:3100 \
--lookback-minutes 60 \
--query-limit 5000 \
--policy ~/config/logging/inspection/policies \
--output-json ~/config/logging/inspection/latest-report.json \
--fail-on criticalWhen notifications are enabled, systemd also loads
~/config/logging/inspection/notifications.env and appends --notify.
| Variable | Default | Purpose |
|---|---|---|
logging_inspection_enabled |
true |
Enables and starts mms-log-inspect.timer. |
logging_inspection_schedule |
*-*-* *:00/15:00 |
User systemd calendar schedule. |
logging_inspection_lookback_minutes |
60 |
Loki lookback window per scheduled run. |
logging_inspection_query_limit |
5000 |
Maximum Loki entries requested per run. |
logging_inspection_fail_on |
critical |
Severity threshold that makes the service exit 1. |
logging_inspection_policy_dir |
{{ logging_config_dir }}/inspection/policies |
Active policy directory. |
logging_inspection_output_file |
{{ logging_config_dir }}/inspection/latest-report.json |
Latest report path. |
logging_inspection_notifications_enabled |
false |
Adds --notify and loads the env file. |
logging_inspection_environment_file |
{{ logging_config_dir }}/inspection/notifications.env |
Private secret file. |
systemctl --user start loki.service
systemctl --user stop loki.service
systemctl --user restart loki.service
systemctl --user status loki.servicesystemctl --user start alloy.service
systemctl --user stop alloy.service
systemctl --user restart alloy.service
systemctl --user status alloy.servicesystemctl --user start prometheus.service
systemctl --user stop prometheus.service
systemctl --user restart prometheus.service
systemctl --user status prometheus.servicesystemctl --user start podman-exporter.service
systemctl --user stop podman-exporter.service
systemctl --user restart podman-exporter.service
systemctl --user status podman-exporter.servicesystemctl --user start grafana.service
systemctl --user stop grafana.service
systemctl --user restart grafana.service
systemctl --user status grafana.servicesystemctl --user start mms-log-inspect.service
systemctl --user status mms-log-inspect.service
systemctl --user status mms-log-inspect.timer
systemctl --user list-timers mms-log-inspect.timer# Each container
podman logs --tail 50 loki
podman logs --tail 50 alloy
podman logs --tail 50 prometheus
podman logs --tail 50 podman-exporter
podman logs --tail 50 grafana
# Systemd unit logs
journalctl --user -u loki --since today
journalctl --user -u alloy --since today
journalctl --user -u prometheus --since today
journalctl --user -u grafana --since today
# Latest policy inspection report
python3 -m json.tool ~/config/logging/inspection/latest-report.jsonThe logging role installs mms-log-inspect, a dependency-free Python CLI that
queries Loki's local API and evaluates JSON policy files. Loki is published on
127.0.0.1:3100 only so the host-side timer can query it without exposing Loki
on the LAN.
Default example policies are deployed from examples/log-policies/ to
~/config/logging/inspection/policies/. Add custom *.json files to that
directory and restart the timer if the schedule changed:
systemctl --user restart mms-log-inspect.timerPolicy files use this shape:
{
"name": "storage-pressure",
"description": "Detect log messages that indicate storage exhaustion.",
"window_minutes": 60,
"rules": [
{
"id": "disk-full",
"severity": "critical",
"description": "A service reports that disk space is exhausted.",
"service": "*",
"patterns": ["no space left on device", "ENOSPC", "disk full"],
"threshold": 1
}
]
}Supported severities are info, warning, and critical. service can be
*, a service name such as radarr, or a systemd unit such as
radarr.service. patterns are Python regular expressions evaluated
case-insensitively against each log line. A rule becomes a finding when the
number of matching log entries is greater than or equal to threshold.
| Field | Required | Description |
|---|---|---|
name |
yes | Stable policy name shown in reports and generic webhook payloads. |
description |
yes | Operator-facing description of the policy intent. |
window_minutes |
yes | Intended review window; keep it at or below the configured lookback. |
notifications |
no | Webhook targets evaluated only when --notify is set. |
rules |
yes | Non-empty array of rule objects. |
| Field | Required | Description |
|---|---|---|
id |
yes | Stable rule identifier shown in reports and notification text. |
severity |
yes |
info, warning, or critical. |
description |
yes | Finding description shown in reports. |
service |
yes |
*, a service label such as radarr, or a unit such as radarr.service. |
patterns |
yes | Non-empty array of Python regular expressions, matched case-insensitively. |
threshold |
yes | Positive integer match count required to create a finding. |
Run an inspection manually against Loki:
~/config/logging/bin/mms-log-inspect \
--loki-url http://localhost:3100 \
--lookback-minutes 60 \
--query-limit 5000 \
--policy ~/config/logging/inspection/policies \
--output-json ~/config/logging/inspection/latest-report.json \
--fail-on criticalThe command exits 1 when findings at or above --fail-on exist. The systemd
service uses that exit code so critical policy matches are visible through
systemctl --user status mms-log-inspect.service and
journalctl --user -u mms-log-inspect.service.
The report is always written before notification delivery. It has this shape:
{
"generated_at": "2026-05-10T12:00:00Z",
"summary": {
"critical": 1,
"warning": 2,
"info": 0
},
"findings": [
{
"policy": "storage-pressure",
"rule_id": "disk-full",
"severity": "critical",
"description": "A service reports that disk space is exhausted.",
"match_count": 1,
"threshold": 1,
"matches": [
{
"timestamp": "2026-05-10T12:00:00Z",
"service": "sabnzbd",
"unit": "sabnzbd.service",
"message": "write failed: no space left on device"
}
]
}
]
}| Exit code | Meaning |
|---|---|
0 |
Inspection completed and no findings met --fail-on. |
1 |
Inspection completed and findings met or exceeded --fail-on. |
2 |
Input, policy, Loki query, report write, or notification delivery failed. |
Only the first 10 matching log entries are included per finding.
Policies can notify webhook-compatible services when findings are present. Notifications are disabled by default. Enable scheduled notifications in inventory or role defaults:
logging_inspection_notifications_enabled: trueWhen enabled, the logging role creates
~/config/logging/inspection/notifications.env with mode 0600 if it does not
already exist. Add webhook URLs there using the environment-variable names from
your policy files:
MMS_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
MMS_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
MMS_GENERIC_WEBHOOK_URL=https://example.invalid/mms-alert-bridgeDo not commit real webhook URLs. Keep them in Ansible Vault or edit the
deployed notifications.env on the host.
Add a notifications array to any policy file:
{
"name": "storage-pressure",
"description": "Detect log messages that indicate storage exhaustion.",
"window_minutes": 60,
"notifications": [
{
"id": "ops-discord",
"provider": "discord",
"webhook_url_env": "MMS_DISCORD_WEBHOOK_URL",
"min_severity": "warning"
}
],
"rules": [
{
"id": "disk-full",
"severity": "critical",
"description": "A service reports that disk space is exhausted.",
"service": "*",
"patterns": ["no space left on device", "ENOSPC", "disk full"],
"threshold": 1
}
]
}Supported providers are:
| Provider | Payload |
|---|---|
discord |
{"content": "..."} |
slack |
{"text": "..."} |
generic |
Structured JSON with summary, findings, and generated_at
|
Use generic for automation bridges, including services that forward webhook
payloads to WhatsApp.
| Field | Required | Description |
|---|---|---|
id |
yes | Stable notification target identifier. |
provider |
yes |
discord, slack, or generic. |
webhook_url_env |
yes | Environment variable containing the webhook URL. |
min_severity |
yes | Lowest severity sent to this target. |
Create a Discord incoming webhook for a specific text channel:
- In Discord, open the target server.
- Open Server Settings.
- Open Integrations.
- Open Webhooks.
- Create a webhook.
- Choose the channel that should receive MMS alerts.
- Name the webhook, for example
MMS Log Inspection. - Copy the webhook URL.
Store the URL on the MMS host:
mkdir -p ~/config/logging/inspection
touch ~/config/logging/inspection/notifications.env
chmod 0600 ~/config/logging/inspection/notifications.env
$EDITOR ~/config/logging/inspection/notifications.envAdd:
MMS_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...Then reference that variable from a policy:
{
"id": "ops-discord",
"provider": "discord",
"webhook_url_env": "MMS_DISCORD_WEBHOOK_URL",
"min_severity": "warning"
}See the Discord webhook docs at https://docs.discord.com/developers/platform/webhooks and the Discord support guide at https://support.discord.com/hc/en-us/articles/228383668.
Create a Slack incoming webhook:
- Create or open a Slack app for the workspace.
- Open Incoming Webhooks in the app configuration.
- Turn on Activate Incoming Webhooks.
- Select Add New Webhook to Workspace.
- Choose the channel that should receive MMS alerts.
- Copy the generated webhook URL.
Store the URL on the MMS host:
mkdir -p ~/config/logging/inspection
touch ~/config/logging/inspection/notifications.env
chmod 0600 ~/config/logging/inspection/notifications.env
$EDITOR ~/config/logging/inspection/notifications.envAdd:
MMS_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...Then reference that variable from a policy:
{
"id": "ops-slack",
"provider": "slack",
"webhook_url_env": "MMS_SLACK_WEBHOOK_URL",
"min_severity": "critical"
}See the Slack incoming webhook docs at https://api.slack.com/messaging/webhooks.
Use generic for services that accept arbitrary JSON over HTTP, including
automation bridges and notification relays.
- Create the destination webhook in the receiving service.
- Confirm it accepts
POSTrequests withContent-Type: application/json. - Copy the destination URL.
- Store it in
~/config/logging/inspection/notifications.env.
MMS_GENERIC_WEBHOOK_URL=https://example.invalid/mms-alert-bridgeThen reference that variable from a policy:
{
"id": "automation-bridge",
"provider": "generic",
"webhook_url_env": "MMS_GENERIC_WEBHOOK_URL",
"min_severity": "critical"
}The generic provider sends structured JSON with notification_id, summary,
findings, and generated_at.
A complete example is available at
examples/log-notification-policies/notification-webhooks.json. Copy it into
~/config/logging/inspection/policies/ only after setting the referenced
environment variables.
Run a notification-enabled inspection manually:
set -a
source ~/config/logging/inspection/notifications.env
set +a
~/config/logging/bin/mms-log-inspect \
--loki-url http://localhost:3100 \
--lookback-minutes 60 \
--query-limit 5000 \
--policy ~/config/logging/inspection/policies \
--output-json ~/config/logging/inspection/latest-report.json \
--fail-on critical \
--notifyNotification failures exit 2 after writing the JSON report. Missing webhook
environment variables are reported by variable name only.
- Edit or add a policy under
examples/log-policies/in the repository. - Validate it locally with
scripts/generate-test-corpusandscripts/mms-log-inspect. - Deploy the logging service:
ansible-playbook playbooks/deploy-service.yml -e service_name=logging- On the host, confirm the policy exists:
ls -l ~/config/logging/inspection/policies/- Run one inspection immediately:
systemctl --user start mms-log-inspect.service
systemctl --user status mms-log-inspect.service
python3 -m json.tool ~/config/logging/inspection/latest-report.jsonUse scripts/generate-test-corpus to create deterministic JSONL logs, then run
the same inspector against those files before deploying policy changes:
scripts/generate-test-corpus \
--scenario faulty \
--entries 40 \
--output /tmp/mms-faulty.jsonl
scripts/mms-log-inspect \
--input-jsonl /tmp/mms-faulty.jsonl \
--policy examples/log-policies \
--output-json /tmp/mms-log-report.json \
--fail-on critical
python3 -m json.tool /tmp/mms-log-report.jsonAvailable scenarios are clean, faulty, and adversarial. Use clean to
check false positives, faulty to confirm expected detections, and
adversarial to review near-miss log lines that should not trigger policies.
For notification tests, point policy webhook_url_env values at a local webhook
receiver or a disposable test channel before using production destinations,
then add --notify to the inspection command.
# Loki readiness
podman exec grafana curl -s http://loki:3100/ready
curl -sf http://127.0.0.1:3100/ready
# Prometheus targets
curl -sf http://localhost:9090/api/v1/targets | python3 -m json.tool
# Grafana UI
curl -sf http://grafana.media.example.com/api/healthNo backup needed. All observability data is disposable:
-
Grafana: Dashboards and datasources are fully provisioned from Ansible templates. Destroying and recreating
grafana-data/loses nothing. - Prometheus: TSDB data is retention-managed (30 days). It repopulates from live scrapes.
- Loki: Log data is retention-managed (30 days). It repopulates from the journal.
| Direction | Target | URL | Purpose |
|---|---|---|---|
| Alloy -> Loki | loki |
http://loki:3100/loki/api/v1/push |
Ship journal logs |
| Prometheus -> Alloy | alloy |
http://alloy:12345/metrics |
Scrape host metrics |
| Prometheus -> podman-exporter | podman-exporter |
http://podman-exporter:9882/metrics |
Scrape container metrics |
| Grafana -> Loki | loki |
http://loki:3100 |
Query logs |
| Grafana -> Prometheus | prometheus |
http://prometheus:9090 |
Query metrics |
Status 1 means the inspector ran successfully and found policy matches at or
above logging_inspection_fail_on. Read the report first:
python3 -m json.tool ~/config/logging/inspection/latest-report.jsonStatus 2 means execution failed. Check stderr in the user journal:
journalctl --user -u mms-log-inspect.service --since todayCommon causes are invalid JSON, unsupported severity or provider names, missing policy files, Loki query failures, and missing webhook environment variables.
Verify scheduled notifications are enabled, the environment file is loaded, and
the policy has findings at or above the target's min_severity:
systemctl --user cat mms-log-inspect.service
grep -E '^[A-Z0-9_]+=' ~/config/logging/inspection/notifications.env | cut -d= -f1
python3 -m json.tool ~/config/logging/inspection/latest-report.jsonAlloy reads the systemd journal via the systemd-journal group. Verify the mms user is in the group:
groups mms | grep systemd-journalIf the group membership was just added, the Alloy container needs a restart:
systemctl --user restart alloy.serviceAlso verify persistent journald is configured (the base_system role handles this). If journal data is only in-memory, Alloy won't find historical entries:
ls /var/log/journal/Check Loki is running and healthy first:
systemctl --user status loki.service
podman logs --tail 20 loki
podman exec grafana curl -s http://loki:3100/readyIf Loki is healthy but Grafana still shows no data, check the Grafana datasource configuration:
cat ~/config/logging/grafana/provisioning/datasources/*.ymlCheck that Alloy and podman-exporter are running:
podman ps --filter name=alloy --filter name=podman-exporterVerify Prometheus can reach them:
podman exec prometheus wget -q -O- http://alloy:12345/metrics | head -5
podman exec prometheus wget -q -O- http://podman-exporter:9882/metrics | head -5podman-exporter requires podman.socket to be enabled for the mms user:
systemctl --user status podman.socket
systemctl --user enable --now podman.socketLoki retains logs based on logging_loki_retention_period (default: 30 days / 720h). Check usage:
du -sh ~/config/logging/loki-data/To reduce retention, update logging_loki_retention_period in roles/logging/defaults/main.yml and redeploy.
Since Grafana is fully provisioned, you can safely destroy and recreate its state:
systemctl --user stop grafana.service
rm -rf ~/config/logging/grafana-data/*
systemctl --user start grafana.serviceDashboards, datasources, and alert rules will be reprovisioned automatically on startup.