Observability Stack
Breeze includes an optional observability stack as a separate Docker Compose overlay (docker-compose.monitoring.yml). Enable it alongside the core stack to get full metrics, dashboards, log aggregation, and infrastructure alerting.
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -dComponents
Section titled “Components”| Component | Port | Purpose |
|---|---|---|
| Prometheus | 9090 (localhost) | Time-series metrics collection and alerting rules |
| Grafana | 3000 (localhost) | Dashboards and visualization |
| Alertmanager | 9093 (localhost) | Alert routing and notifications |
| Loki | 3100 (localhost) | Log aggregation and querying |
| Promtail | 9080 (localhost) | Log shipping from Docker containers to Loki |
| Redis Exporter | 9121 (internal) | Exports Redis metrics for Prometheus |
| Postgres Exporter | 9187 (internal) | Exports PostgreSQL metrics for Prometheus |
Accessing Grafana
Section titled “Accessing Grafana”# Via SSH tunnelssh -L 3000:127.0.0.1:3000 user@your-server
# Then open http://localhost:3000# Username: admin# Password: (your GRAFANA_ADMIN_PASSWORD from .env)Pre-Built Dashboards
Section titled “Pre-Built Dashboards”Breeze ships with a Grafana dashboard (monitoring/grafana/dashboards/breeze-overview.json) that is automatically provisioned. It includes these panels:
| Panel | What It Shows |
|---|---|
| Service Status | Up/down status of API, Redis, PostgreSQL, and other services |
| Request Rate | HTTP requests per second with breakdown by method |
| Response Times | P50, P95, and P99 latency over time |
| Error Rate | 4xx and 5xx response rates as percentages |
| HTTP Status Distribution | Breakdown of responses by status code |
| Top Endpoints | Most-used API endpoints by request volume |
| Active Devices | Count of agents with recent heartbeats |
| Organizations | Number of active tenants |
| Redis Memory | Memory usage, evictions, and hit rate |
| PostgreSQL Connections | Active connection count vs. max pool size |
Four panel groups shipped after the table above was originally written and aren’t reflected in it:
| Panel | What It Shows |
|---|---|
| Requests by Response Class | Request rate broken down by status class over time, alongside the status-code pie chart |
| Redis Operations | Redis commands processed per second |
| Active Alerts | Count of currently open in-app alerts |
| Agent Heartbeats | Agent heartbeat rate over time |
| Script Executions | Script execution rate over time |
| Backup Operations — Start Failures (24h) | Backup, restore, or verification start failures in the last 24 hours |
| Backup Operations — Restore Timeouts (24h) | Restore command timeouts in the last 24 hours |
| Backup Operations — Verification Skips (24h) | Scheduled backup verification runs skipped in the last 24 hours |
Adding Custom Dashboards
Section titled “Adding Custom Dashboards”To add your own dashboards:
- Create or import a dashboard in the Grafana UI.
- Export it as JSON from the Grafana dashboard settings.
- Save the JSON file to
monitoring/grafana/dashboards/. - The dashboard provisioner (
monitoring/grafana/dashboards.yml) rescans that directory every 30 seconds and picks up new files automatically — no Grafana restart needed.
Data Sources
Section titled “Data Sources”Configured automatically via monitoring/grafana/datasources.yml:
| Source | Type | URL |
|---|---|---|
| Prometheus | Time-series | http://prometheus:9090 |
| Loki | Logs | http://loki:3100 |
| PostgreSQL | SQL | postgres:5432 |
| Redis | Key-value | redis://redis:6379 |
Prometheus Configuration
Section titled “Prometheus Configuration”Located at monitoring/prometheus.yml. Key settings:
global: scrape_interval: 15s evaluation_interval: 15s scrape_timeout: 10s external_labels: monitor: 'breeze-rmm' environment: 'production'Scrape Targets
Section titled “Scrape Targets”| Job | Target | Interval | Auth |
|---|---|---|---|
prometheus |
localhost:9090 |
15s (default) | None |
breeze-api |
api:3001 at /metrics/scrape |
10s | Bearer token |
breeze-worker |
worker:3001 at /metrics |
10s | Bearer token |
redis |
redis-exporter:9121 |
15s (default) | None |
postgres |
postgres-exporter:9187 |
15s (default) | None |
node |
node-exporter:9100 (optional) |
15s (default) | None |
The API metrics endpoint is protected by a bearer token. Store the token in monitoring/secrets/metrics_scrape_token and reference it in prometheus.yml:
- job_name: 'breeze-api' metrics_path: /metrics/scrape authorization: type: Bearer credentials_file: /run/secrets/metrics_scrape_tokenRule Files
Section titled “Rule Files”Prometheus loads all .yml files from monitoring/rules/. Breeze ships with breeze-rules.yml containing API, infrastructure, and business alert rules plus recording rules for common aggregations. See Infrastructure Alerts for the full list.
Rules are evaluated every 30 seconds (API and infrastructure groups) or every 60 seconds (business alerts). Recording rules pre-compute expensive queries:
| Recording Rule | Description |
|---|---|
breeze:http_requests:rate5m |
Request rate by status, method, and route |
breeze:http_error_rate:ratio5m |
5xx error rate as a ratio |
breeze:http_request_duration:avg5m |
Average request duration by route |
breeze:http_request_duration:p95_5m |
95th percentile request duration |
breeze:http_request_duration:p99_5m |
99th percentile request duration |
breeze:devices:active_count |
Total active device count |
breeze:redis_ops:rate5m |
Redis operations per second |
breeze:postgres_query_duration:avg5m |
Average PostgreSQL query duration |
breeze:backup_dispatch_failures:increase15m |
Backup/restore/verification start failures over the last 15 minutes, by operation and reason |
breeze:restore_timeouts:increase15m |
Restore command timeouts over the last 15 minutes, by command type |
breeze:backup_verification_skips:increase1h |
Scheduled backup verification skips over the last hour, by verification type and reason |
breeze:failed_logins:increase5m |
Failed logins over the last 5 minutes, by reason and tenant |
breeze:agent_enrollments:increase10m |
Agent enrollments over the last 10 minutes, by result and tenant |
breeze:commands_dispatched:increase5m |
Commands dispatched over the last 5 minutes, by type, actor, and tenant |
Key Metrics
Section titled “Key Metrics”HTTP Metrics (from the API)
Section titled “HTTP Metrics (from the API)”| Metric | Type | Description |
|---|---|---|
http_requests_total |
Counter | Total HTTP requests by method, path, status |
http_request_duration_seconds |
Histogram | Request latency distribution |
http_requests_in_flight |
Gauge | Currently processing requests |
agent_heartbeat_total |
Counter | Agent heartbeats received |
Business Metrics
Section titled “Business Metrics”| Metric | Type | Description |
|---|---|---|
breeze_active_devices |
Gauge | Devices with a recent heartbeat |
breeze_active_organizations |
Gauge | Organizations with active devices |
breeze_commands_total |
Counter | Commands executed, labeled by type |
breeze_alerts_total |
Counter | Alerts fired, labeled by severity |
Infrastructure Metrics
Section titled “Infrastructure Metrics”| Metric | Type | Description |
|---|---|---|
redis_memory_used_bytes |
Gauge | Redis memory consumption |
redis_commands_processed_total |
Counter | Total Redis commands processed |
pg_stat_activity_count |
Gauge | PostgreSQL active connections |
pg_database_size_bytes |
Gauge | Database size in bytes |
pg_settings_max_connections |
Gauge | PostgreSQL max allowed connections |
Since v0.117.0 the API also publishes its own view of Redis memory —
breeze_redis_used_memory_bytes, breeze_redis_maxmemory_bytes and
breeze_redis_memory_used_ratio — and warns once usage crosses 80% of
maxmemory. Redis runs noeviction, so a full instance fails writes; alert on
the ratio well before 1. See
Redis memory watchdog for the full
series list and tuning variables.
Event-Loop Metrics
Section titled “Event-Loop Metrics”The API exports its own event-loop delay so a stalled main thread is visible as such, rather than only as the downstream errors it causes (see Event-loop lag monitor for the tuning variables). All five series are published from process start, so an alert rule referencing them never queries a metric that does not exist yet.
| Metric | Type | Description |
|---|---|---|
breeze_nodejs_eventloop_monitored |
Gauge | 1 when the instrumentation is running, 0 when it is disabled or not yet started |
breeze_nodejs_eventloop_lag_max_seconds |
Gauge | Delay over the last completed sampling interval, including any stall still in flight (instantaneous) |
breeze_nodejs_eventloop_lag_window_max_seconds |
Gauge | Worst delay retained across the whole sampling window (high-water mark) |
breeze_nodejs_eventloop_lag_window_mean_seconds |
Gauge | Mean delay across the retained sampling window |
breeze_nodejs_eventloop_starved |
Gauge | 1 while the current lag is at or above EVENT_LOOP_STARVATION_WARN_MS, else 0 |
Two things to know before writing alert rules against these:
- Alert on
breeze_nodejs_eventloop_monitored == 0. When the monitor is off, the other four gauges sit at0— which is indistinguishable from a perfectly healthy loop. This series is what separates the two cases. _lag_max_secondsis instantaneous,_lag_window_max_secondsis not. Use the instantaneous series (orbreeze_nodejs_eventloop_starved) for “is it stalled right now”; the window max stays elevated for the retention window, so amax_over_timeon it counts one stall many times.
Admission Metrics
Section titled “Admission Metrics”Agent admission control (see Agent admission control) publishes its state and every refusal.
| Metric | Type | Description |
|---|---|---|
breeze_admission_mode{mode} |
Gauge | 1 for the active mode (off, observe, enforce) |
breeze_admission_level |
Gauge | 0 normal, 1 inventory-type uploads refused, 2 status uploads refused too, 3 critical |
breeze_admission_decisions_total{class,route,outcome,reason} |
Counter | Requests refused (outcome="shed"), or that would have been in observe mode (would_shed). reason is level, bulk_in_flight, ws_bucket or ws_setups |
breeze_admission_transitions_total{from,to,trigger} |
Counter | Level changes. trigger is lag, db, db_timeout or emergency for escalations; recovery steps carry the last escalation’s trigger, and none once back at level 0. A fail-open reset after repeated tick failures reports tick_failure |
breeze_admission_signal_lag_seconds |
Gauge | Median event-loop lag the controller used (-1 when the lag monitor is off) |
breeze_admission_signal_db_wait_seconds |
Gauge | How long requests wait for a database connection (the pool’s own series are breeze_db_pool_admission_*) |
breeze_admission_db_saturation_responses_total{error} |
Counter | Requests answered 503 because the database pool (pool_acquire_timeout) or the RLS prologue (prologue_timeout) timed out |
breeze_admission_ws_bucket_tokens / breeze_admission_ws_setups_in_flight |
Gauge | WebSocket pacing state |
breeze_admission_bulk_in_flight |
Gauge | Inventory-type uploads being processed |
breeze_admission_tick_failures_total |
Counter | Admission controller ticks that threw. Five in a row fail open: the level is forced to 0 so shedding cannot persist on a stuck controller |
breeze_admission_offline_deferred_total{event} |
Counter | WebSocket disconnects whose offline flip was left to the offline detector while under load |
breeze_nodejs_eventloop_utilization |
Gauge | Event-loop utilization over the last second |
Alert on breeze_admission_level == 3 for 5 minutes: the instance is down to
protected traffic and needs capacity. In observe mode, compare
would_shed against incidents before switching to enforce.
Log Aggregation with Loki
Section titled “Log Aggregation with Loki”Promtail scrapes Docker container logs and ships them to Loki. Loki stores logs for 14 days by default (configurable via retention_period in monitoring/loki-config.yml).
Querying Logs in Grafana
Section titled “Querying Logs in Grafana”Open the Explore page in Grafana, select the Loki data source, and enter LogQL queries.
Basic Queries
Section titled “Basic Queries”# All API logs{container="breeze-api"}
# API errors only{container="breeze-api"} |= "error"
# Structured JSON logs — filter by level{container="breeze-api"} | json | level = "error"
# Logs from a specific container{container="breeze-web"}Filtering and Searching
Section titled “Filtering and Searching”# Search for a specific device ID{container="breeze-api"} |= "device_id=abc123"
# Exclude health check noise{container="breeze-api"} != "/health"
# Filter by HTTP status code in structured logs{container="breeze-api"} | json | status >= 500
# Rate of errors over time (useful for dashboards)rate({container="breeze-api"} |= "error" [5m])Time-Based Queries
Section titled “Time-Based Queries”# Logs from the last hour containing "timeout"{container="breeze-api"} |= "timeout"
# Count log lines per minutesum(rate({container="breeze-api"} [1m])) by (container)Adding Custom Prometheus Alert Rules
Section titled “Adding Custom Prometheus Alert Rules”-
Create a new YAML file in
monitoring/rules/(e.g.,monitoring/rules/custom-rules.yml). -
Define your alert rules following the Prometheus format:
groups:- name: custom-alertsrules:- alert: HighAgentChurnexpr: rate(breeze_device_enrollments_total[1h]) > 10for: 30mlabels:severity: warningannotations:summary: "High agent enrollment rate"description: "More than 10 new enrollments per hour for 30 minutes" -
Reload the Prometheus configuration (no restart required):
Terminal window curl -X POST http://localhost:9090/-/reload -
Verify the rule loaded successfully by checking
http://localhost:9090/rulesin the Prometheus UI.
Data Retention
Section titled “Data Retention”| Component | Default Retention | Configuration |
|---|---|---|
| Prometheus | 15 days | --storage.tsdb.retention.time=15d in compose file |
| Loki | 14 days (336h) | retention_period in monitoring/loki-config.yml |
| Grafana | Unlimited (dashboards only) | N/A |
| Alertmanager | Silences and notification log only | --storage.path in compose file |
To change retention, edit the relevant configuration and restart the container.
Troubleshooting
Section titled “Troubleshooting”Prometheus Is Not Scraping the API
Section titled “Prometheus Is Not Scraping the API”Symptom: The breeze-api target shows as DOWN in http://localhost:9090/targets.
- Verify the API is running and healthy:
curl http://localhost:3001/health - Check the scrape token is correct. Compare
monitoring/secrets/metrics_scrape_tokenwith theMETRICS_SCRAPE_TOKENenvironment variable on the API container. - Verify network connectivity. Both Prometheus and the API must be on the same Docker network (
breeze). - Check Prometheus logs:
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml logs prometheus --tail 50
Grafana Shows “No Data”
Section titled “Grafana Shows “No Data””Symptom: Dashboard panels show “No data” instead of charts.
- Confirm Prometheus is running and scraping: visit
http://localhost:9090/targetsand verify all targets areUP. - In Grafana, go to Configuration > Data Sources > Prometheus and click Test. It should say “Data source is working.”
- Check the time range selector in Grafana. If metrics collection just started, narrow the range to “Last 15 minutes.”
- If using a custom dashboard, verify the metric names match what Prometheus is collecting. Test a simple query like
upin Grafana Explore.
Loki Queries Are Slow
Section titled “Loki Queries Are Slow”Symptom: Log queries in Grafana take more than 10 seconds or time out.
- Narrow the time range. Loki performs best with shorter ranges (last 1 hour vs. last 7 days).
- Add label matchers.
{container="breeze-api"} |= "error"is much faster than{job="varlogs"} |= "error"because the label narrows the search before the text filter runs. - Check Loki’s compactor. If it has fallen behind, compaction can slow queries:
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml logs loki --tail 50 - Increase Loki resources if needed. In the compose file, add memory limits and CPU limits that match your server capacity.
Alertmanager Is Not Sending Notifications
Section titled “Alertmanager Is Not Sending Notifications”Symptom: Alerts fire in Prometheus but no notifications arrive.
- Confirm Alertmanager is receiving alerts: visit
http://localhost:9093/#/alertsand check for active alerts. - If no alerts appear, verify Prometheus is configured to send to Alertmanager. Check
alerting.alertmanagersinmonitoring/prometheus.yml. - If alerts appear but notifications are not sent, check the receiver configuration in
monitoring/alertmanager.yml. Look for commented-out sections that need to be enabled. - Check Alertmanager logs for delivery errors:
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml logs alertmanager --tail 50 - Verify webhook URLs, API keys, and SMTP credentials are correct. Test Slack webhooks with
curlto rule out network issues.
Containers Not Starting
Section titled “Containers Not Starting”Symptom: One or more monitoring containers fail to start or keep restarting.
- Check which containers are failing:
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml ps - Read the logs:
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml logs <container> --tail 100 - Common causes:
- Grafana:
GRAFANA_ADMIN_PASSWORDnot set in.env. The compose file requires this variable. (This is separate from.env.prod, which onlyscripts/prod/deploy.sh’s digest-pinned production flow reads.) - Postgres Exporter:
POSTGRES_PASSWORDnot set or incorrect. The exporter needs the same credentials as the database. - Prometheus: Invalid YAML in
prometheus.ymlor rule files. Validate withpromtool check config monitoring/prometheus.yml. - Loki: Permissions issue on the data volume. Loki runs as a non-root user and needs write access to
/loki.
- Grafana:
Disk Space Growing
Section titled “Disk Space Growing”Monitoring data can accumulate over time, especially on busy systems.
- Check volume sizes:
docker system df -v | grep -E 'prometheus|grafana|loki' - Reduce Prometheus retention: lower
--storage.tsdb.retention.timefrom15dto7din the compose file. - Reduce Loki retention: lower
retention_periodinmonitoring/loki-config.yml(e.g., from336hto168h). - Prune old Docker volumes if containers were previously removed without cleaning up:
docker volume prune - Restart the affected containers after configuration changes.
Hot-path cache
Section titled “Hot-path cache”The agent heartbeat and UniFi collector poll serve a few per-org and global values from small in-process caches (agent update config, topology flags, the helper flag, the policy probe, the PAM fallback, the manifest trust keyset and delegations, and UniFi collector absence). When one API instance writes a change, it announces the change over Redis and every other instance drops its entry within about a second. The message carries the cache name and an id (or none, for the whole cache), never a value.
| Metric | Type | Description |
|---|---|---|
breeze_hot_path_cache_lookups_total{cache,result} |
Counter | Lookups by result: hit, miss, or bypass (a read inside a database transaction, which never uses the cache) |
breeze_hot_path_cache_entries{cache} |
Gauge | Entries currently held, read at scrape time |
breeze_cluster_cache_invalidations_published_total{cache,outcome} |
Counter | Invalidations this instance announced. outcome is published, unavailable (no Redis) or failed (error, or no reply within 1 s) |
breeze_cluster_cache_invalidations_received_total{cache,result} |
Counter | Messages handled: applied, self (this instance’s own echo), unknown_cache, malformed, or error (the cache failed to drop the entry; it keeps it until the TTL) |
breeze_cluster_cache_invalidation_lag_seconds{cache} |
Histogram | Time from publish to apply for applied messages |
breeze_cluster_cache_subscriber_up |
Gauge | 1 while this instance is subscribed |
breeze_cluster_cache_resyncs_total |
Counter | Full local cache clears after the subscriber reconnected (messages sent during the gap are lost, so every cache is dropped once) |
A healthy instance shows breeze_cluster_cache_subscriber_up 1, a lag p99 well
under one second, and a flat published_total{outcome="failed"}. Without
Redis the caches still work: each entry simply lives until its TTL (60 seconds,
or 5 minutes for collector absence), which was the behaviour before
cross-instance invalidation existed. The subscriber waits at most one second at
boot and keeps retrying in the background.
Agent capacity KPIs
Section titled “Agent capacity KPIs”The breeze-capacity-kpis rule group in monitoring/rules/breeze-rules.yml and the Breeze RMM - Agent Capacity dashboard (monitoring/grafana/dashboards/breeze-agent-capacity.json, uid breeze-agent-capacity) report what an agent costs the API, per region and per API instance. The number of online agents is the count of agent WebSockets owned by each API process.
| KPI | Definition | Recording rule | M1 / M2 / M3 target |
|---|---|---|---|
| Agent cost | API process CPU-ms per agent-minute | breeze:agent_cost_cpu_ms_per_agent_minute:region and :instance |
60 / 25 / 15 |
| Heartbeat latency | p50, p95, p99 of POST /agents/:id/heartbeat, server histogram |
breeze:heartbeat_latency_seconds:p50_region, :p95_region, :p99_region, :p95_instance |
none set |
| DB intensity | Committed transactions per agent-minute | breeze:db_tx_per_agent_minute:region |
8 at M1, 4 at M3 |
| Request mix | Agent requests per agent-minute | breeze:agent_requests_per_agent_minute:region |
none set |
| Online agents | Agent sockets owned by API processes | breeze:agent_ws_connections:instance and :region |
n/a |
Event-loop utilization and lag, pool wait, prologue-deadline expiries and the admission level are on the dashboard’s Saturation row and have no targets.
New series
Section titled “New series”| Metric | Type | Labels | Description |
|---|---|---|---|
breeze_process_cpu_seconds_total |
Counter | mode = user or system |
CPU time of the process, read from process.cpuUsage() at scrape time |
breeze_agent_ws_connections |
Gauge | none | Agent WebSockets owned by this process. The worker role publishes 0 |
Requirements
Section titled “Requirements”regionlabel. The rules aggregateby (region). Every scrape job on the Prometheus that evaluates them needs aregionlabel, includingpostgres-exporter: the DB intensity rule joins the exporter’spg_stat_database_xact_committo the API socket count onregion.deploy/prometheus-remote-scrape.example.ymlshows the API jobs; the placeholder values areeuandus.- Single-host stacks. The dashboard’s
regionandinstancevariables default to All (allValue: ".*"), and that matcher also matches an absent label, so a stack without aregionlabel still renders. The per-instance rules also work without it; the region DB rule does not. - Database name. The DB rule filters
datname="breeze", the Compose default. Edit it inbreeze-rules.ymlif you renamed the database. - Empty fleet. The rules divide by
(... > 0), so zero online agents gives “no data”, not+Inf.
Caveats
Section titled “Caveats”- Per-instance cost is approximate with more than one API instance. HTTP requests spread across instances; sockets do not. The region figure is the one to trust.
- Event-loop “lag p99” is approximated.
breeze_nodejs_eventloop_lag_max_secondsis a gauge, not a histogram. The Event-loop lag panel plots the raw gauge,max_over_timeover 5 minutes andquantile_over_time(0.99)over 15 minutes. - Two heartbeat p95 figures exist. The dashboard uses the server histogram, which starts at the handler. The perf gate uses the simulator’s client-side p95, which also includes queueing before the handler, so it is higher under load.
- The lab baseline overstates production cost. The perf gate runs one
role: all, dev-mode API container that also runs the BullMQ workers, so its CPU-ms per agent-minute is above what the productionapirole costs. It is a regression reference for that profile, not a production figure.
Perf regression gate
Section titled “Perf regression gate”A nightly or manually dispatched workflow measures these KPIs on fixed hardware and fails on a regression of more than 15% against a committed baseline. See load-tests/perf/README.md for what it gates, how to run it and how to read a failure.