Spring Boot monitoring stack with:
- Backend app (Spring Boot + Actuator + Prometheus metrics)
- Prometheus (scrapes backend metrics and evaluates rules)
- Alertmanager (native Prometheus email alerts)
- Grafana (dashboards + Grafana-managed alerts + email)
- PostgreSQL (additional Grafana datasource for SQL dashboards)
- Python populator (writes live probe results into PostgreSQL)
- Docker Desktop running
- Java 8 (for local backend runs outside Docker)
- Ports available:
8081,9090,9093,3000,5432
spring.application.name=Service_Monitor
server.port=8081
management.endpoints.web.exposure.include=health,prometheus,metrics,info
management.endpoint.prometheus.enabled=true
management.endpoint.health.show-details=alwaysglobal:
scrape_interval: 15s
rule_files:
- /etc/prometheus/alert.rules.yml
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
scrape_configs:
- job_name: 'service_monitor_backend'
metrics_path: /actuator/prometheus
static_configs:
- targets: ['service-monitor-backend:8081']Services:
service-monitor-backendprometheusalertmanagergrafanapostgrespostgres-populator-python
Persisted volumes:
grafana-data(Grafana dashboards/datasources)postgres-data(PostgreSQL data)
PostgreSQL init scripts:
- Mounted from
monitoring/postgres/initto/docker-entrypoint-initdb.d
Before first run:
Copy-Item .env.example .envThen set real SMTP and DB values in .env.
Generate Alertmanager runtime config from template:
powershell -ExecutionPolicy Bypass -File monitoring\alertmanager\render-config.ps1Start all:
docker compose up -d --buildStop all (keep data):
docker compose downStop all and delete all volumes (destructive):
docker compose down -vCheck running containers:
docker compose psBackend:
http://localhost:8081/actuator/healthhttp://localhost:8081/actuator/prometheus
Prometheus:
http://localhost:9090/targets- job
service_monitor_backendshould beUP
Alertmanager:
http://localhost:9093
Grafana:
http://localhost:3000- verify Prometheus datasource
Save & test
PostgreSQL quick check:
docker compose exec -T postgres psql -U service_monitor_user -d service_monitor -c "SELECT now() AS ts, count(*) AS services_count FROM monitoring.services;"- URL:
http://prometheus:9090
Use these values in Grafana (container-to-container):
- Host:
postgres:5432 - Database:
service_monitor - User:
service_monitor_user - Password:
service_monitor_pass - SSL mode:
disable
Note:
localhostis wrong inside Grafana container for PostgreSQL datasource.
For host machine tools (IntelliJ, pgAdmin), use the mapped host port from docker-compose.yml.
- Host:
localhost(or127.0.0.1) - Port:
55432(if your compose mapping is55432:5432) - Database:
service_monitor - User:
service_monitor_user - Password:
service_monitor_pass
Important:
postgres:5432is for container-to-container traffic only.localhost:55432is for host machine tools only.
Initialized by:
monitoring/postgres/init/001_init_schema.sqlmonitoring/postgres/init/002_monitoring_model.sqlmonitoring/postgres/init/003_service_probe_config.sqlmonitoring/postgres/init/004_populator_source_constraints.sql
Created schema:
monitoring
Created tables:
monitoring.servicesmonitoring.service_availability_eventsmonitoring.http_error_eventsmonitoring.alert_eventsmonitoring.service_checksmonitoring.service_statemonitoring.service_incidentsmonitoring.metric_samples
Important:
- Init scripts run only on first DB initialization (empty
postgres-datavolume). 001_init_schema.sqlnow seeds all core services by default on fresh volume creation.
If your DB volume already exists, apply step-1 model migration manually:
docker compose exec -T postgres psql -U service_monitor_user -d service_monitor -f /docker-entrypoint-initdb.d/002_monitoring_model.sqlApply step-2 probe configuration migration manually:
docker compose exec -T postgres psql -U service_monitor_user -d service_monitor -f /docker-entrypoint-initdb.d/003_service_probe_config.sqlApply step-3 deterministic writer hardening migration manually:
docker compose exec -T postgres psql -U service_monitor_user -d service_monitor -f /docker-entrypoint-initdb.d/004_populator_source_constraints.sqlImplemented in src/main/java/com/example/service_monitor/SimulationController.java:
GET /sim/error-> always returns500GET /sim/flaky-> randomly returns200or500
Generate error load:
1..300 | % { Invoke-WebRequest "http://localhost:8081/sim/error" -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null; Start-Sleep -Milliseconds 150 }Downtime simulation:
docker compose stop service-monitor-backend
docker compose start service-monitor-backendup{job="service_monitor_backend",instance="service-monitor-backend:8081"}
sum(rate(http_server_requests_seconds_count{job="service_monitor_backend"}[1m]))
sum(rate(http_server_requests_seconds_count{job="service_monitor_backend",status=~"5.."}[1m]))
sum(jvm_memory_used_bytes{job="service_monitor_backend",area="heap"})
sum(jvm_memory_used_bytes{job="service_monitor_backend",area="nonheap"})
jvm_threads_live_threads{job="service_monitor_backend"}
up{job="service_monitor_backend"}
up{job="service_monitor_backend",instance="service-monitor-backend:8081"}
Settings:
- Calculation:
Last (not null) - Text mode:
Value and name - Value mappings:
0 -> DOWN,1 -> UP - Thresholds:
0 red,1 green
sum(rate(http_server_requests_seconds_count{job="service_monitor_backend"}[1m]))
Settings:
- Unit:
reqps - Min:
Auto(recommended for visibility of small changes)
sum(rate(http_server_requests_seconds_count{job="service_monitor_backend",status=~"5.."}[1m]))
Settings:
- Unit:
reqps - Min:
0(important zero baseline) - Suggested thresholds: yellow
0.2, red1
sum(jvm_memory_used_bytes{job="service_monitor_backend",area="heap"})
Settings:
- Unit:
bytes (IEC) - Min:
Auto
sum(jvm_memory_used_bytes{job="service_monitor_backend",area="nonheap"})
Settings:
- Unit:
bytes (IEC) - Min:
Auto
jvm_threads_live_threads{job="service_monitor_backend"}
Settings:
- Unit:
none - Min:
0
sum(rate(http_server_requests_seconds_count{job="service_monitor_backend"}[1m]))
Settings:
- Calculation:
Last (not null) - Text mode:
Value and name
Service count:
SELECT now() AS ts, count(*) AS services_count
FROM monitoring.services;Recent availability events:
SELECT s.service_key, e.status, e.observed_at
FROM monitoring.service_availability_events e
JOIN monitoring.services s ON s.id = e.service_id
ORDER BY e.id DESC
LIMIT 50;Recent HTTP errors:
SELECT s.service_key, h.status_code, h.endpoint, h.observed_at
FROM monitoring.http_error_events h
JOIN monitoring.services s ON s.id = h.service_id
ORDER BY h.id DESC
LIMIT 50;Recent alerts:
SELECT s.service_key, a.alert_name, a.status, a.severity, a.received_at
FROM monitoring.alert_events a
LEFT JOIN monitoring.services s ON s.id = a.service_id
ORDER BY a.id DESC
LIMIT 50;Registered services:
SELECT COUNT(*)::bigint AS value
FROM monitoring.services
WHERE is_active = true;Service checks by status (time series):
SELECT
date_trunc('minute', observed_at) AS time,
status AS metric,
COUNT(*)::bigint AS value
FROM monitoring.service_checks
WHERE observed_at BETWEEN $__timeFrom() AND $__timeTo()
GROUP BY 1,2
ORDER BY 1;HTTP error events (4xx vs 5xx):
SELECT
date_trunc('minute', observed_at) AS time,
CASE
WHEN status_code BETWEEN 400 AND 499 THEN '4xx'
WHEN status_code BETWEEN 500 AND 599 THEN '5xx'
ELSE 'other'
END AS metric,
sum(error_count)::bigint AS value
FROM monitoring.http_error_events
WHERE observed_at BETWEEN $__timeFrom() AND $__timeTo()
AND source = 'POPULATOR'
GROUP BY 1,2
ORDER BY 1;Alert events (FIRING vs RESOLVED):
SELECT
date_trunc('minute', received_at) AS time,
status AS metric,
count(*)::bigint AS value
FROM monitoring.alert_events
WHERE received_at BETWEEN $__timeFrom() AND $__timeTo()
AND alert_source = 'PROMETHEUS'
GROUP BY 1,2
ORDER BY 1;Latest alert incidents:
SELECT
received_at,
alert_source,
alert_name,
severity,
status,
message
FROM monitoring.alert_events
ORDER BY received_at DESC
LIMIT 50;HTTP error incidents:
SELECT
e.observed_at,
s.display_name AS service,
e.endpoint,
e.method,
e.status_code,
e.error_count,
e.source
FROM monitoring.http_error_events e
JOIN monitoring.services s ON s.id = e.service_id
ORDER BY e.observed_at DESC
LIMIT 50;Alerts currently firing (last 24 hours):
SELECT count(*)::bigint AS value
FROM monitoring.alert_events
WHERE status = 'FIRING'
AND received_at >= now() - interval '24 hours';Current expected service entries:
service-monitor-backend->http://service-monitor-backend:8081postgres-db->postgres:5432prometheus->http://prometheus:9090/-/healthygrafana->http://grafana:3000/api/healthalertmanager->http://alertmanager:9093/-/healthyservice-monitor-backend-actuator->http://service-monitor-backend:8081/actuator/health
Per-service probe config fields (in monitoring.services):
probe_type(HTTPorTCP)probe_path(HTTP only, optional)expected_status_codes(example:200,204,301-304)timeout_secondscheck_interval_seconds
Reseed (idempotent):
INSERT INTO monitoring.services
(service_key, display_name, base_url, is_active, probe_type, probe_path, expected_status_codes, timeout_seconds, check_interval_seconds)
VALUES
('service-monitor-backend', 'Service Monitor Backend', 'http://service-monitor-backend:8081', true, 'HTTP', '/actuator/health', '200-399', 5, 15),
('postgres-db', 'PostgreSQL Database', 'postgres:5432', true, 'TCP', NULL, '200-399', 3, 15),
('prometheus', 'Prometheus', 'http://prometheus:9090/-/healthy', true, 'HTTP', NULL, '200-399', 5, 15),
('grafana', 'Grafana', 'http://grafana:3000/api/health', true, 'HTTP', NULL, '200-399', 5, 15),
('alertmanager', 'Alertmanager', 'http://alertmanager:9093/-/healthy', true, 'HTTP', NULL, '200-399', 5, 15),
('service-monitor-backend-actuator', 'Service Monitor Backend Actuator', 'http://service-monitor-backend:8081/actuator/health', true, 'HTTP', NULL, '200-399', 5, 15)
ON CONFLICT (service_key) DO UPDATE
SET
display_name = EXCLUDED.display_name,
base_url = EXCLUDED.base_url,
is_active = EXCLUDED.is_active,
probe_type = EXCLUDED.probe_type,
probe_path = EXCLUDED.probe_path,
expected_status_codes = EXCLUDED.expected_status_codes,
timeout_seconds = EXCLUDED.timeout_seconds,
check_interval_seconds = EXCLUDED.check_interval_seconds;Run from PowerShell via Docker:
docker compose exec -T postgres psql -U service_monitor_user -d service_monitor -c "INSERT INTO monitoring.services (service_key, display_name, base_url, is_active, probe_type, probe_path, expected_status_codes, timeout_seconds, check_interval_seconds) VALUES ('service-monitor-backend', 'Service Monitor Backend', 'http://service-monitor-backend:8081', true, 'HTTP', '/actuator/health', '200-399', 5, 15), ('postgres-db', 'PostgreSQL Database', 'postgres:5432', true, 'TCP', NULL, '200-399', 3, 15), ('prometheus', 'Prometheus', 'http://prometheus:9090/-/healthy', true, 'HTTP', NULL, '200-399', 5, 15), ('grafana', 'Grafana', 'http://grafana:3000/api/health', true, 'HTTP', NULL, '200-399', 5, 15), ('alertmanager', 'Alertmanager', 'http://alertmanager:9093/-/healthy', true, 'HTTP', NULL, '200-399', 5, 15), ('service-monitor-backend-actuator', 'Service Monitor Backend Actuator', 'http://service-monitor-backend:8081/actuator/health', true, 'HTTP', NULL, '200-399', 5, 15) ON CONFLICT (service_key) DO UPDATE SET display_name = EXCLUDED.display_name, base_url = EXCLUDED.base_url, is_active = EXCLUDED.is_active, probe_type = EXCLUDED.probe_type, probe_path = EXCLUDED.probe_path, expected_status_codes = EXCLUDED.expected_status_codes, timeout_seconds = EXCLUDED.timeout_seconds, check_interval_seconds = EXCLUDED.check_interval_seconds;"Quick verify:
docker compose exec -T postgres psql -U service_monitor_user -d service_monitor -c "SELECT id, service_key, base_url, probe_type, probe_path, expected_status_codes, timeout_seconds, check_interval_seconds, is_active FROM monitoring.services ORDER BY id;"If you run:
docker compose down
docker volume rm service_monitor_postgres-data
docker compose up -dThen PostgreSQL recreates everything automatically:
- schema/tables from init scripts
001to004 - default six service rows in
monitoring.services(from001)
What is not auto-restored by PostgreSQL reset:
- Grafana dashboards/datasources/alert rules if
grafana-datais also removed - any manual UI-only configuration not stored as code
Examples:
- Backend down:
up{job="service_monitor_backend"} < 1for2m - High 5xx:
sum(rate(http_server_requests_seconds_count{job="service_monitor_backend",status=~"5.."}[1m]))above threshold
Rule file:
monitoring/prometheus/alert.rules.yml
Alertmanager config flow:
- Tracked template:
monitoring/alertmanager/alertmanager.yml.tpl - Generated runtime file (ignored):
monitoring/alertmanager/alertmanager.local.yml - Generator script:
monitoring/alertmanager/render-config.ps1 - Email template:
monitoring/alertmanager/templates/email.tmpl
Important:
email.tmplmust define both:service_monitor.subjectservice_monitor.text
Otherwise emails fail with template errors.
Spring Boot endpoint:
POST /api/alerts/alertmanager
Alertmanager is configured to forward alerts to the backend via webhook, and the backend persists alerts into monitoring.alert_events with alert_source='PROMETHEUS'.
After changing .env, regenerate Alertmanager config:
powershell -ExecutionPolicy Bypass -File monitoring\alertmanager\render-config.ps1
docker compose restart alertmanagerFiles:
monitoring/postgres/populator/python/Dockerfilemonitoring/postgres/populator/python/requirements.txtmonitoring/postgres/populator/python/populate.py
Current behavior:
- Reads all active services from
monitoring.services - Uses per-service probe settings from DB (
probe_type,probe_path,expected_status_codes,timeout_seconds,check_interval_seconds) - Writes real events (not random):
monitoring.service_checks(raw probe checks)monitoring.service_state(latest per-service state)monitoring.service_incidents(open/close lifecycle)monitoring.service_availability_events(compatibility history)monitoring.http_error_events(4xx/5xx)monitoring.alert_events(status transition alerts)
- Writer-tagged records use
source='POPULATOR'/alert_source='POPULATOR'for clear provenance
Useful env vars:
PYTHON_POPULATOR_INTERVAL_SECONDS(already in.env.example)POPULATOR_TIMEOUT_SECONDS(optional, default5)
Shell populator status:
monitoring/postgres/populator/shell/populate.shkept for reference- shell service remains disabled/commented in compose to avoid duplicate inserts
/actuator/prometheusgives 404:
- wrong
management.*property keys/typos.
- Prometheus target down:
- use
service-monitor-backend:8081(colon, not dot).
- Grafana data disappears:
- ensure
grafana-data:/var/lib/grafanaexists. - avoid
docker compose down -vunless wiping intentionally.
- PostgreSQL schema not visible:
- check DB is
service_monitor. - schema filter should include
monitoring.
- SQL in PowerShell fails:
- run SQL in Grafana query editor or via
psql.
- Alertmanager env vars not applied:
- regenerate config after
.envedits:powershell -ExecutionPolicy Bypass -File monitoring\alertmanager\render-config.ps1
- Alertmanager emails missing:
- check logs for template errors:
docker compose logs --tail=100 alertmanager
- Spring Boot 2.7.18 + Java 1.8 alignment
- Prometheus metrics pipeline working
- Grafana dashboards from Prometheus working
- Downtime/error simulation endpoints implemented
- Grafana alerting with email working
- Prometheus + Alertmanager dual alerting path configured
- PostgreSQL container + schema/tables initialized
- PostgreSQL datasource connectivity validated
- Python populator converted to active real probing for all active services
- Provision Grafana datasources/dashboards/alerts as code
- Add per-service probe path metadata in DB (for non-root health paths)
- Add dedup/rate-limiting strategy for DB alert event inserts
- Move sensitive runtime secret handling to a dedicated secret manager
Grafana is configured to auto-load dashboards from JSON files at startup.
Files:
monitoring/grafana/dashboards/prometheus_dashboard.jsonmonitoring/grafana/dashboards/postgres_dashboard.jsonmonitoring/grafana/provisioning/dashboards/dashboards.yml
Behavior:
- Provisioning runs on every Grafana start.
- If a dashboard with the same UID exists, it will be updated from the JSON file.
- UI edits are allowed (
allowUiUpdates: true) but will be overwritten if the JSON changes.
To apply:
docker compose up -d --build grafana