Monitoring & Observability
Metrics, logs, traces, dashboards and alerting for the whole stack, in one
docker compose command. Every component is free and open source — Prometheus
and OpenTelemetry are CNCF projects, the rest is Grafana Labs' OSS tier. There
is no paid tier anywhere in this document and nothing phones home.
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
| UI | URL | Login |
|---|---|---|
| Grafana | http://127.0.0.1:3000 | admin / ${GRAFANA_ADMIN_PASSWORD} (default admin) |
| Prometheus | http://127.0.0.1:9090 | none |
| Alertmanager | http://127.0.0.1:9093 | none |
Everything binds to 127.0.0.1 by default. That is not paranoia: Docker
publishes ports by writing its own iptables rules, which bypass UFW entirely
— a 0.0.0.0 binding puts an unauthenticated Prometheus on the public internet
no matter what the firewall says. Reach the UIs over SSH instead:
ssh -L 3000:localhost:3000 -L 9090:localhost:9090 deploy@your-vps
To stop just the monitoring half, use the same two -f flags with stop; a
plain docker compose down (single -f) leaves the containers orphaned.
What is in the stack
┌──────────────────────────── Grafana ───────────────────────────┐
│ dashboards · Explore · alert view · trace↔log↔metric links │
└───────┬──────────────────┬───────────────────┬─────────────────┘
│ │ │
Prometheus Loki Tempo
(metrics) (logs) (traces)
▲ ▲ ▲
┌───────────────────┼──────────┐ │ │
│ │ │ │ │ │ promtail OpenTelemetry Collector
backend node cAdvisor pg nginx blackbox ▲ ▲
/actuator/ exporter exporter exporter │ │
prometheus └── docker API ─────┤
│ │
Alertmanager ◄── alert rules Spring Boot (OTLP)
│
Slack · Discord · email
| Container | Image | Job |
|---|---|---|
motorph_prometheus | prom/prometheus:v2.53.0 | scrapes metrics, evaluates 44 alert + 12 recording rules |
motorph_alertmanager | prom/alertmanager:v0.27.0 | groups, deduplicates and routes alerts |
motorph_grafana | grafana/grafana:11.1.0 | 9 provisioned dashboards, 4 provisioned datasources |
motorph_loki | grafana/loki:3.1.1 | log storage |
motorph_promtail | grafana/promtail:3.1.1 | ships every container's stdout to Loki |
motorph_tempo | grafana/tempo:2.5.0 | trace storage + span-derived RED metrics |
motorph_otel_collector | otel/opentelemetry-collector-contrib:0.104.0 | the single OTLP endpoint the app talks to |
motorph_node_exporter | prom/node-exporter:v1.8.2 | host CPU / RAM / disk / network |
motorph_cadvisor | gcr.io/cadvisor/cadvisor:v0.49.1 | per-container CPU / RAM / restarts |
motorph_postgres_exporter | prometheuscommunity/postgres-exporter:v0.15.0 | connections, locks, cache ratio, table sizes |
motorph_blackbox_exporter | prom/blackbox-exporter:v0.25.0 | synthetic uptime + TLS expiry |
motorph_nginx_exporter | nginx/nginx-prometheus-exporter:1.3.0 | nginx connections and requests |
Dashboards
Provisioned from infra/monitoring/grafana/dashboards/, one folder per
subdirectory, re-read every 30 s. Edit a JSON on disk and Grafana picks it up —
there is no import step, and no dashboard that exists only in one person's
browser. UI edits are refused (allowUiUpdates: false) because the next reload
would silently discard them; use Save As for scratch work.
| Folder | Dashboard | Answers |
|---|---|---|
| Infrastructure | Server Health | Is the box healthy? CPU by mode, memory, disk, load, network |
| Infrastructure | Docker Containers | Which container is eating the box? Per-container CPU/RAM/net, restart counts |
| Application | Spring Boot API | Traffic, error rate, p50/p95/p99, slowest and busiest endpoints, 24 h availability |
| Application | JVM & Connection Pool | Heap, GC, threads, Tomcat, HikariCP |
| Database | PostgreSQL | Connections, TPS, cache hit ratio, locks, deadlocks, largest tables, dead rows |
| Business | Payroll KPIs | Payroll runs, payslips generated, failed runs, generation time, leave/overtime/attendance |
| Security | Auth Security | Login outcomes, lockouts, rate-limit rejections, ALTCHA, signup codes, mail |
| Logs | Log Explorer | Every container's logs, filterable by level and free text |
| Uptime | Uptime & SLO | Probe success, availability, error budget, certificate expiry |
The three signals, and how they connect
The point of running all three is not having three tools. It is that an error on a dashboard is two clicks from the log line that produced it and the trace that shows which call was slow. That only works if one identifier survives the whole journey, and it does:
Micrometer Tracing writes traceId/spanId into the MDC
→ logback-spring.xml puts them in the JSON log line
→ promtail lifts them into Loki structured metadata
→ the Loki datasource's derivedFields links them to Tempo
→ Tempo's tracesToLogsV2 links back the other way
Metrics — Prometheus
The backend exposes /actuator/prometheus; Prometheus scrapes it every 15 s.
Two things were added to make it useful beyond counters:
- Histogram buckets for
http.server.requestsandmotorph.payroll.payslip.generation, which is what makes p95/p99 possible at all. Deliberately not enabled globally — every histogram is roughly 15 extra series per tag combination, andhttp.server.requestsalready carries uri × method × status. - A common
envtag on every metric, fromMETRICS_ENV. Dashboards and alerts group by it. It comes from the application rather than from a Prometheus target label because Tempo's span metrics arrive by remote write and never pass through a scrape config where a label could be attached.
Scrape targets live in infra/monitoring/prometheus/targets/*.yml, not in
prometheus.yml. Prometheus re-reads those files on a timer, so adding an
environment is an edit to a data file — no restart, no container recreate.
Scrape the backend by its hyphenated alias, never its container name. Tomcat validates the
Hostheader against RFC 9110, underscores are not legal in a hostname, andmotorph_payroll_backend:8080therefore returns a bare Tomcat 400 before Spring sees the request. The compose files give the backend the network aliasmotorph-payroll-backendfor exactly this. The application never noticed the problem because nginx and Caddy both forward the browser'sHostrather than the upstream's.
Logs — Loki + Promtail
Promtail discovers containers through the Docker API, so a new service is
collected by existing — no per-container configuration. Backend logs are shipped
as JSON (LOG_FORMAT=json, the default in every containerised environment) and
parsed into:
- labels —
container,service,stack,stream,level, andstatus_classfor nginx. All bounded sets. - structured metadata —
trace_id,span_id,logger,thread, and nginx'smethod/path/status. Stored but not indexed.
That split is the whole discipline of running Loki. Labels are an index; a label with unbounded values (a trace id, a user, a tenant) creates a stream per value and is the single most common way to make Loki unusable.
Set LOG_FORMAT=console when you would rather read docker logs directly —
./dev.sh host mode already does, since those logs are read by a person.
Traces — Tempo + OpenTelemetry Collector
Off by default. Turn it on with one line in .env:
TRACING_ENABLED=true
The application sends spans to the collector and knows nothing else about where
traces go, which is the point of the collector being there: sampling harder,
dropping a noisy span, or replacing Tempo becomes a change to
infra/monitoring/otel/config.yml rather than a redeploy.
The collector drops /actuator/* spans before storage — Prometheus alone hits
that path four times a minute forever, and keeping it would fill Tempo's
retention window with the monitoring watching itself.
Tempo's metrics_generator derives RED metrics and a service graph from spans
and remote-writes them to Prometheus as traces_spanmetrics_* and
traces_service_graph_*. This is why Prometheus runs with
--web.enable-remote-write-receiver.
Two traps worth knowing, both of which fail silently:
- The Boot 3 property
management.otlp.tracing.endpointstill appears in Boot 4's configuration metadata but no longer binds. Boot 4 wantsmanagement.opentelemetry.tracing.export.otlp.endpoint. Set the old one and the exporter quietly falls back tolocalhost:4318, which inside a container is the container itself. micrometer-tracing-bridge-otelalone is not enough on Boot 4. Boot 4 split autoconfiguration into per-technology modules, so the bridge puts the libraries on the classpath and configures nothing. The dependency that actually wires it up isspring-boot-starter-opentelemetry(withmicrometer-registry-otlpexcluded, since metrics here are pulled, not pushed).
Alerting
44 rules in infra/monitoring/prometheus/rules/alerts.yml, across availability,
host, containers, application, database, security, probes, business, and the
monitoring stack watching itself. Two severities, and the distinction is the
whole point:
- critical — someone is paged now; the product is down or about to be.
- warning — goes to the chat channel; look at it during working hours.
Every for: duration is deliberate. An alert with no delay fires on a single
bad scrape, and an alert that fires on noise gets muted — which is worse than
not having it.
Routing lives in alertmanager.yml; the receivers are generated at container
start by entrypoint.sh from environment variables, because Alertmanager does
not interpolate env vars into its config and a Slack webhook URL is a bearer
token that has no business in git.
ALERT_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
ALERT_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... # plain URL; /slack is appended for you
With none of them set, alerts still fire, still group, and are still visible in the Alertmanager UI and in Grafana → Alerting. Nothing breaks; you just have to go and look.
Staging is routed to chat only and never pages. inhibit_rules suppress the
downstream noise — a BackendDown mutes every other alert for that environment,
because a dead backend explains all of them.
Business alerts
The ones a non-engineer would recognise, and the reason business metrics exist:
| Alert | Fires when |
|---|---|
PayrollGenerationFailed | any payslip generation run threw, in the last 15 min |
PayrollGenerationSlow | p95 generation time above 2 minutes |
MailDeliveryFailing | more than 20 % of outbound email failing |
Application metrics
Three registries, all under backend/src/main/java/com/motorph/payroll/metrics/.
AuthMetrics
— login outcomes, lockouts, rate-limit rejections, ALTCHA verifications, signup
codes, password rehashes, portal auth. These are the only durable record of
anonymous rejections: rate-limit and ALTCHA refusals happen before any user is
resolved, and user_log.user_id is a NOT NULL FK to users, so they cannot
be audited there.
BusinessMetrics
— what the product did, instrumented at the service layer rather than the
controller so the numbers reflect work that was actually committed:
| Prometheus metric | Tags |
|---|---|
motorph_payroll_runs_total | action: created | payslips_generated | approved | rejected | processed | cancelled |
motorph_payroll_payslip_generation_seconds_* | outcome: success | failure — timer with buckets |
motorph_payroll_payslips_generated_total | — |
motorph_leave_requests_total | action: created | approved | rejected | cancelled |
motorph_overtime_requests_total | action: created | cancelled |
motorph_timesheet_events_total | action: clock_in | clock_out | submitted | status_changed |
BuildInfoMetrics
— motorph_build_info, a constant-1 gauge whose labels carry the deployed
version and commit. It answers the question that comes up in every incident and
that no other metric can: which build is actually running? Overlay it on a
latency graph and "it got slow at 14:20" becomes "it got slow when we shipped
abc123".
Cardinality doctrine, which applies to anything added later: every tag value comes from a small fixed set defined in the source. Never tag with an employee number, a payroll id, or a tenant slug. One series per employee is how a Prometheus dies, and per-tenant series would put customer shape into a store with no meaningful access control.
All counters are in-memory and reset when the backend restarts — dashboards and
alerts read them through rate() / increase(), never as running totals.
Running it in production
Set one line in the PROD_ENV_FILE secret:
MONITORING_ENABLED=true
Every production deploy from then on brings the stack up and keeps it managed —
including the automated ones. That is deliberately a value in the env file
rather than a flag on the command line: the pipeline invokes deploy.sh itself
and passes no shell variables, so an invocation-only switch would mean the stack
came up once, by hand, and silently stopped being managed on the next deploy
from CI.
For a one-off trial without committing to it, the override still exists:
WITH_MONITORING=1 deploy/deploy.sh prod deploy <tag>
Turning it back off stops the overlay being managed but does not remove the containers — compose leaves unreferenced services running rather than guessing. Stop them explicitly if that is what you want.
Production only — deploy.sh refuses WITH_MONITORING=1 for staging. The
overlay's containers have fixed names (motorph_prometheus, motorph_grafana,
…), so a staging stack bringing them up would not get its own copy: compose
would adopt production's containers and repoint them at staging's config and
volumes. Production's monitoring quietly becoming staging's is a worse outcome
than having none, so the refusal is a hard failure rather than a warning.
Staging is still observed. Production's Prometheus reaches it across the shared
network — uncomment the motorph-stage-backend entry in
infra/monitoring/prometheus/targets/apps.yml. One stack, both environments,
and staging runs nothing extra.
Staging's backend therefore keeps METRICS_ENV=stage (so its series are
distinguishable once scraped) but logs plain text rather than JSON, and has no
tracing variables at all — there is no Loki or collector on that side to receive
either.
Configuration goes in the GitHub Secrets, not on the box
deploy.sh rewrites /srv/motorph/.env and /srv/motorph/.env.stage from the
PROD_ENV_FILE / STAGE_ENV_FILE GitHub Secrets on every run. A hand-edit
on the VPS survives exactly until the next deploy. Add these to the secret:
GRAFANA_ADMIN_PASSWORD=<a real password> # set BEFORE first start
MONITORING_BIND=127.0.0.1
PROMETHEUS_RETENTION=30d
PROMETHEUS_RETENTION_SIZE=8GB
LOKI_RETENTION=720h
TEMPO_RETENTION=168h
TRACING_ENABLED=false
TRACING_SAMPLE_RATE=0.1
ALERT_SLACK_WEBHOOK_URL=
GRAFANA_ADMIN_PASSWORD applies only when Grafana initialises its database
for the first time. On every later start it is ignored — the password lives in
grafana.db in the grafana_data volume from then on. Changing the variable and
redeploying does not rotate it. To actually reset it, see the credential-reset
runbook in docs/deployment/monitoring.md (operator-only, not published).
Disk
This stack writes continuously, and the retention settings above are the only
thing standing between it and a full disk. Check df -h before raising them.
Rough shape on a small deployment: Prometheus is the largest and the most
useful historically; Loki is next; Tempo is the most disposable — a two-week-old
trace answers no question the metrics and logs cannot.
Public probes
infra/monitoring/prometheus/targets/probes-http.yml and probes-tls.yml ship
with the public entries commented out, since local dev has no public hostname.
Uncomment them on the VPS with the real domain — the TLS probe is what powers
CertificateExpiringSoon, and Caddy renews at two thirds of a certificate's
life, so that alert firing means renewal is broken (usually port 80 no longer
reaching Caddy for the ACME challenge).
Troubleshooting
A dashboard is empty. Check Prometheus → Status → Targets first, or the "Scrape targets" panel on Uptime & SLO. A missing exporter makes its dashboard lie by omission rather than break.
The backend target is down with a 400. It is being scraped by its
underscored container name. Use motorph-payroll-backend:8080 — see the box
under Metrics above.
Logs arrive as unparsed text. LOG_FORMAT is not json for that container.
Tempo is empty. TRACING_ENABLED is false, or the OTLP endpoint property is
the Boot 3 spelling — see Traces above. Confirm spans are moving with
otelcol_receiver_accepted_spans and otelcol_exporter_send_failed_spans in
Prometheus.
Alerts fire but nobody is told. AlertmanagerNotificationsFailing covers
the case where a webhook is configured and broken. If no ALERT_* variable is
set at all, that is by design — check the Alertmanager UI.
Promtail logs "timestamp too old" on first start. It is replaying the
history of long-running containers and Loki rejects entries older than
reject_old_samples_max_age (7 days). It stops once promtail catches up.
A deploy fails with "contain '$' and are NOT single-quoted". That is the
generic guard, and it applies to every secret, not just Grafana's. Docker compose
expands env-file values, so a JWT_SECRET of sec$RETvalue123 reaches the container as
sec — the app then signs and verifies tokens perfectly with a three-character
key, and nothing anywhere reports a problem. Wrap the value in single quotes
in the PROD_ENV_FILE / STAGE_ENV_FILE secret; double quotes do not help. If a
credential has already been deployed truncated, rotate it.
Reloading config without a restart. Prometheus runs with
--web.enable-lifecycle, so curl -X POST http://127.0.0.1:9090/-/reload
applies rule and scrape-config edits in place. Target files and Grafana
dashboards need nothing at all — both are re-read on a timer.