A system health and uptime monitoring platform that tracks endpoint availability, response times, and incidents in real time — with automated alerting when services go down.
Every engineering team needs to know when their services go down — ideally before users start complaining. Uptime monitoring is a solved problem at companies with dedicated SRE teams and expensive tools like Datadog or PagerDuty. But building one from scratch forces you to make every decision yourself: where does the data live, how do you check endpoints without blocking, what happens when the database fills up, and how do you alert the right people at the right time.
I just graduated with my Software Engineering degree and wanted to build something that goes beyond a typical CRUD app. This project pushes me to work across the full stack with technologies I'll actually use on the job:
- Java concurrency — using
CompletableFuturefor parallel HTTP requests instead of checking endpoints one at a time - Spring Boot 3 — dependency injection, JPA, scheduling, and REST API design through a real use case
- AWS services — hands-on experience with RDS, S3, Lambda, and SNS, understanding how they fit together
- System design thinking — decisions like "where does this data live?", "how do we keep the database lean?", and "when should we alert vs stay quiet?"
Each phase is built incrementally so I can understand every layer before adding the next one.
| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4, React Query, Recharts |
| Backend | Java 17, Spring Boot 3 |
| Database | AWS RDS PostgreSQL |
| Storage | AWS S3 (metrics archiving) |
| Alerts | AWS Lambda + SNS |
| Monitoring | AWS CloudWatch |
| Phase | Focus | Description | Status |
|---|---|---|---|
| 1 | Project Setup | Spring Boot 3, Maven, H2 dev database, Actuator health endpoint | ✅ |
| 2 | Health Check Model & Engine | JPA entities, repositories, CompletableFuture parallel health checks |
✅ |
| 3 | RDS Metrics & Persistence | Connect to AWS RDS PostgreSQL, migrate off H2 for production data | ✅ |
| 4 | S3 Metrics Archiving | Archive older metrics to S3, keep RDS lean | ✅ |
| 5 | Lambda Alerts & SNS | AWS Lambda formats alerts, SNS sends email notifications on incidents | ✅ |
| 6 | REST API | Spring Boot endpoints for the React dashboard to consume | ✅ |
| 7 | React Dashboard | Live status cards, response time sparklines, dark mode, auto-refresh | ✅ |
| 8 | Alert Thresholds | Configurable response time and failure thresholds per endpoint | ✅ |
UptraceIQ — System Architecture
+------------------+ every 30s +-------------------+
| Endpoints | <----- ping -----> | HealthCheckService |
| (Google, GitHub, | | (CompletableFuture|
| custom URLs) | | parallel checks) |
+------------------+ +--------+----------+
|
save result to DB
|
+--------v----------+
| AlertService |
| (status transition |
| detection) |
+--------+----------+
|
status changed? (UP->DOWN)
|
+--------v----------+
| AWS Lambda |
| (format alert msg) |
+--------+----------+
|
+--------v----------+
| AWS SNS |
| (deliver email) |
+-------------------+
+-------------------+ every 24h +--------------------+
| AWS RDS | <--- archive --> | MetricsArchiveService|
| PostgreSQL | | (query old records, |
| (recent 7 days) | | upload JSON to S3, |
+-------------------+ | delete from RDS) |
+--------------------+
|
+--------v----------+
| AWS S3 |
| (archives/ |
| 2026-04-02.json) |
+-------------------+
+-------------------+ HTTP/JSON +-----------------------+
| React Dashboard | <------------> | Spring REST API |
| (Vite + React | CORS-enabled | EndpointController |
| Query, auto- | via proxy | HealthCheckResult |
| refresh 5s) | | Controller |
| localhost:3000 | | (DTOs + Mappers + |
+-------------------+ | ResponseEntity) |
+-----------+-----------+
|
read/write
|
+-----------v-----------+
| AWS RDS |
| PostgreSQL |
+-----------------------+
frontend/
src/
|-- main.tsx <-- React entry point, QueryClientProvider
|-- App.tsx <-- App shell: header, layout grid
|-- index.css <-- Tailwind v4 import + dark mode config
|-- api/
| '-- client.ts <-- Typed fetch wrapper for all API calls
|-- types/
| '-- api.ts <-- TypeScript interfaces matching backend DTOs
'-- components/
|-- AddEndpointForm.tsx <-- Create endpoint form (sticky sidebar)
|-- EndpointList.tsx <-- Card list with badges, sparklines, skeletons
|-- EndpointDetail.tsx <-- Expanded view: chart, stats, result table
|-- EndpointSparkline.tsx <-- Mini response time chart (Recharts)
|-- StatusSummary.tsx <-- UP/DOWN/DEGRADED count header
'-- ThemeToggle.tsx <-- Dark mode toggle (localStorage + class strategy)
Initialized the Spring Boot 3 project with Maven. Configured core dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, and Spring Boot Actuator. Added H2 as an in-memory development database so the app runs locally without any external services.
Key implementation details:
- Spring Boot auto-configures an embedded Tomcat server, database connection pool, and JPA/Hibernate from the dependencies alone
- Actuator exposes
/actuator/health— used by load balancers to verify the backend is alive - H2 runs inside the JVM with zero setup — the database exists only in memory and resets every restart
- Maven wrapper (
./mvnw) ensures consistent builds without requiring a global Maven install
H2 in-memory database console — used for local development before connecting to AWS RDS PostgreSQL.
Spring Boot Actuator health endpoint — returns service status for load balancer health checks.
Built the core data model and monitoring engine that pings endpoints in parallel every 30 seconds using CompletableFuture.
Key implementation details:
- Endpoint entity — JPA entity representing a monitored service (URL, name, check interval, enabled toggle). Maps to the
endpointsdatabase table via@Entityand@Tableannotations. - HealthCheckResult entity — stores individual ping results (status code, response time, health status, error messages). Linked to Endpoint via
@ManyToOnewithFetchType.LAZYto avoid unnecessary data loading. - HealthStatus enum —
UP,DOWN, orDEGRADED— stored as strings in the database via@Enumerated(EnumType.STRING). - Repository interfaces — Spring auto-generates SQL from method names (query derivation).
findByEnabledTrue()becomesSELECT * FROM endpoints WHERE enabled = true. - HealthCheckService — uses
CompletableFuture.runAsync()for parallel execution,CompletableFuture.allOf().join()to wait for completion. Java's equivalent ofPromise.all().
Challenge: Checking 3 endpoints sequentially took 6+ seconds (2s each). With 100 endpoints, this would take over 3 minutes per check cycle.
Solution:
CompletableFuture.runAsync()runs each check on its own thread. All endpoints are checked simultaneously — total time equals the slowest single check, not the sum.
Challenge: Needed a way to represent the relationship between endpoints and their check results without writing raw SQL joins.
Solution: JPA's
@ManyToOneannotation with@JoinColumncreates the foreign key automatically. Spring Data's query derivation generates SQL from method names — no manual queries needed.
JPA entities auto-generated the endpoints and health_check_results tables in H2 — no SQL written manually.
Health checks running live — Google (301 redirect), GitHub (200 UP), and a fake endpoint (DOWN). All three checked in parallel via CompletableFuture.
Connected the backend to AWS RDS PostgreSQL for persistent data storage. Set up Spring profiles to support dual environments — H2 for local development and RDS for production — without changing any Java code.
Key implementation details:
- Spring profiles swap database configuration at runtime.
application-dev.propertiesconfigures H2,application-rds.propertiesconfigures PostgreSQL. The-Dspring-boot.run.profiles=rdsflag overrides the default. - Environment variables (
${RDS_PASSWORD}) keep credentials out of the codebase. Spring resolves${}placeholders from OS environment variables at startup. - Hibernate
ddl-auto=updatecompares@Entityclasses to existing tables and adds missing columns/tables — never deletes. Safe for production schema evolution. - Security groups act as a network firewall in front of RDS. Without the inbound rule on port 5432, connections are rejected before the password is even checked.
Challenge:
data.sqlseed data ran automatically on H2 but was silently ignored on RDS, leaving the endpoints table empty. Health checks ran but found nothing to check.
Solution: Spring Boot only auto-runs
data.sqlfor embedded databases. PostgreSQL is external, so Spring skips it by design — prevents re-inserting data on every production restart. Seeded RDS manually viapsql.
Challenge: Needed to keep H2 for fast local development while also supporting RDS for production data — without maintaining two separate codebases or configurations.
Solution: Spring profiles load different properties files based on the active profile. The same Java code connects to H2 or PostgreSQL depending on a single runtime flag. No
ifstatements, no environment checks in code.
AWS RDS PostgreSQL instance running in us-east-1 — the production database for all health check data.
Health check results being written to RDS PostgreSQL in real time — three endpoints checked in parallel every 30 seconds.
Health check results persisted in RDS — data survives app restarts, unlike the H2 in-memory database.
Built an automated archiving system that moves health check data older than 7 days from RDS to AWS S3. Keeps the database lean while preserving all historical data in cheap cloud storage.
Key implementation details:
- MetricsArchiveService runs on a
@Scheduled(fixedRate = 86400000)cycle (24 hours). Queries old records, serializes to JSON via JacksonObjectMapper, uploads to S3, then deletes from RDS. - S3Config.java uses
@Configuration+@Beanto create anS3Clientthat Spring injects via constructor. The AWS SDK reads credentials from environment variables automatically (default credential provider chain). - Upload-first, delete-second pattern ensures no data loss. If the S3 upload fails, the
catchblock runs anddeleteAll()is never reached — records stay safe in RDS. - Date-stamped keys (
archives/2026-04-02.json) give each archive a unique path. S3 is a flat key-value store — slashes in keys render as folders in the console.
Challenge: Health checks write 8,640 rows/day to RDS (3 endpoints x 30s intervals). After a few months, queries slow down and storage costs climb on PostgreSQL ($0.115/GB/month).
Solution: Archive anything older than 7 days to S3 ($0.023/GB/month, first 5GB free). Daily archives are ~3KB each — a full year costs fractions of a penny. RDS stays fast with only recent data.
Challenge: Serializing JPA entities directly with Jackson caused cascading relationship loading —
@ManyToOnepulled in the full Endpoint object for every result.
Solution: Manually mapped each
HealthCheckResultto aHashMap<String, Object>with only the fields needed. Full control over the JSON output, no accidental relationship traversal.
S3 bucket created in us-east-1 — stores archived health check metrics as JSON files.
Archived JSON file stored in S3 — 18 health check records exported as archives/2026-04-02.json.
Archiver queried old records from RDS, uploaded to S3, then deleted them from the database to keep it lean.
Added real-time email alerting when endpoints go down or recover. Spring Boot detects status transitions, invokes an AWS Lambda function to format the alert, and Lambda publishes to SNS which delivers the email.
Key implementation details:
- AlertService compares the current health check status against the most recent previous result. Only fires on actual transitions (UP→DOWN or DOWN→UP) — prevents inbox spam from repeated DOWN checks.
- AWS Lambda (Python 3.12) receives incident data as JSON, formats a human-readable email with endpoint name, URL, timestamp, and error details, then publishes to SNS.
- LambdaConfig.java follows the same
@Configuration+@Beanpattern as S3Config — creates aLambdaClientbean that Spring injects into AlertService. - SNS topic (
uptraceiq-alerts) fans out notifications to all subscribers. Currently delivers email, but the same topic could trigger SMS, Slack webhooks, or other Lambda functions. - IAM role grants the Lambda function
sns:Publishpermission — without it, Lambda can execute but can't send notifications.
Challenge: Health checks run every 30 seconds. A DOWN endpoint generates a new DOWN result every cycle — naive alerting would send an email every 30 seconds for the same outage.
Solution: AlertService queries the previous result with
findTopByEndpointIdOrderByCheckedAtDesc()and compares statuses. Only transitions trigger alerts. An endpoint that's been DOWN for an hour sends exactly one alert — when it first went down — and one recovery email when it comes back.
Challenge: The alert check runs after
resultRepository.save(), so querying the "previous" result returned the one we just saved — making previous status always equal current status, and alerts never fired.
Solution: Moved
alertService.checkAndAlert()to run BEFOREresultRepository.save(). Now the "most recent" query returns the actual previous result, and status transitions are detected correctly.
Challenge: Lambda's default execution role only includes CloudWatch Logs permissions. The function ran successfully but
sns.publish()threw an access denied error.
Solution: Attached the
AmazonSNSFullAccesspolicy to Lambda's execution role via IAM. In production, you'd scope this down to onlysns:Publishon the specific topic ARN.
SNS topic created in us-east-1 — the message channel that delivers alerts to all subscribers.
AWS Lambda function (Python 3.12) — receives incident data from Spring Boot, formats the alert, and publishes to SNS.
SNS email subscription confirmed — alerts will be delivered to this address when endpoints go down or recover.
DOWN alert email — triggered when GitHub's endpoint was changed to an unreachable URL. Shows endpoint name, URL, timestamp, and error details.
Recovery email — triggered when GitHub's endpoint was restored to the correct URL. Confirms the service is back UP.
Exposed the monitoring data through a Spring Boot REST API so the React dashboard (Phase 7) has a data source to consume. Built full CRUD for endpoints plus two metrics queries — raw historical results and a computed uptime percentage.
Key implementation details:
- DTO layer — dedicated Data Transfer Objects (
EndpointDTO,HealthCheckResultDTO,UptimeStatsDTO,CreateEndpointRequest) sit between JPA entities and JSON responses. Prevents leaking internal persistence details to clients, avoids infinite Jackson serialization loops from JPA relationships, and lets the API shape evolve independently from the database schema. - Mapper pattern — dedicated
@Componentclasses (EndpointMapper,HealthCheckResultMapper) handle entity-to-DTO translation. Keeps the mapping logic out of both the controllers and the entities themselves. - Inbound vs outbound DTOs —
CreateEndpointRequestintentionally omits server-owned fields (id,createdAt,currentStatus). Allow-list pattern prevents clients from setting fields they shouldn't control. - REST sub-resource routing — results and uptime are nested under their parent endpoint (
/api/endpoints/{id}/results,/api/endpoints/{id}/uptime) because the data is owned by the endpoint. Multiple controllers share the/api/endpointsbase path cleanly. ResponseEntity— gives precise HTTP status control:200 OKfor successful reads,201 Createdfor POST,204 No Contentfor DELETE,404 Not Foundfor missing resources.- Global CORS config —
CorsConfigimplementsWebMvcConfigurerto allow cross-origin requests fromhttp://localhost:3000(React dev server) to any/api/**endpoint. Centralized over scattered@CrossOriginannotations. - Cascade delete handling — deleting an endpoint first removes its results via
deleteByEndpointId(marked@Modifying+@Transactional) to avoid foreign key violations, then removes the endpoint itself. - Computed metrics endpoint —
GET /api/endpoints/{id}/uptimereads the last N check results, counts how many wereUP, and returns anUptimeStatsDTOwith percentage, total, and up counts. Returnsnullpercentage when there's no data rather than a misleading0%.
Challenge: Returning JPA entities directly from controllers would leak internal structure, trigger lazy-loading explosions during JSON serialization, and couple the API contract tightly to the database schema.
Solution: Introduced a DTO layer with dedicated mapper classes. The API now speaks its own language — clean, flat JSON shapes that don't care how the data is stored. Changing the entity structure no longer breaks the API contract.
Challenge: Deleting an endpoint failed with a foreign key constraint violation because
health_check_resultsrows still referenced it.
Solution: Added
deleteByEndpointIdtoHealthCheckResultRepositorywith@Modifyingand@Transactional. The delete endpoint now removes children first, then the parent. Chose this over JPA cascade because theEndpointentity has no reverse relationship to results (a deliberate design choice to keep the entity clean).
Challenge: Integer division would silently return
0for the uptime percentage —upChecks / totalChecksevaluated aslong / longgives0for any ratio below 1.
Solution: Multiplied by
100.0(adoubleliteral) before dividing —(upChecks * 100.0) / totalChecks. Java promotes the whole expression todouble, and the math works correctly. Classic gotcha worth remembering.
Spring Boot backend booting cleanly with the new REST API code — 17 source files compiled, Tomcat listening on port 8080.
GET /api/endpoints returning the full list of monitored services — each with live status and last-checked timestamps computed at response time.
GET /api/endpoints/1/uptime returning a computed uptime percentage over the last N checks — the first endpoint that actually aggregates data rather than returning raw rows.
GET on a non-existent endpoint returns 404 Not Found with CORS Vary headers visible — proof the error path works and the CORS config is active.
DELETE /api/endpoints/2 returns 204 No Content after cascading through child results — the full CRUD loop is closed.
Built a live monitoring dashboard in React that consumes the Phase 6 REST API. The frontend auto-refreshes every 5 seconds, shows real-time status badges, response time sparklines, and supports full dark mode.
Key implementation details:
- React Query manages all server state with a 5-second
refetchInterval. SharedqueryKeyarrays (['endpoints'],['results', id]) let multiple components read the same cached data without duplicate HTTP requests. - Typed API client — a single
client.tsmodule wrapsfetchwith generics (apiRequest<T>) so every component works with TypeScript interfaces, not raw JSON. Vite's dev proxy forwards/api/*to Spring Boot, avoiding CORS in development. - Tailwind CSS v4 — utility-first styling with zero custom CSS files. Uses the Vite plugin (
@tailwindcss/vite) instead of PostCSS. Dark mode uses a class-based strategy via@custom-variant darkso the toggle is instant. - Recharts sparklines — mini line charts on every collapsed card show response time trends at a glance. Color-coded by last status (green/red/amber).
isAnimationActive={false}prevents re-animation on refetch. - Dark mode — class-based toggle (
<html class="dark">) withlocalStoragepersistence and OS preference detection as fallback. Every component has explicitdark:variants for backgrounds, text, borders, and badges. - Loading skeletons —
animate-pulseplaceholder cards match the real card shape, preventing layout shift during initial load. - Responsive layout — mobile-first single column,
lg:breakpoint (1024px) switches to a 1/3 + 2/3 grid with a sticky sidebar form.
Challenge: Google showed as DOWN despite being reachable. The backend's
HttpClientwasn't following redirects — Google returns a 301 fromgoogle.comtowww.google.com, and 301 isn't a 2xx status.
Solution: Added
.followRedirects(HttpClient.Redirect.NORMAL)to theHttpClientbuilder. UsedNORMAL(notALWAYS) to prevent HTTPS→HTTP downgrade attacks.
Challenge: React Query re-fetches every 5 seconds, which caused Recharts to replay its draw animation on every update — visually distracting on a dashboard.
Solution: Set
isAnimationActive={false}on the<Line>component. Sparklines should update silently — animation is for first paint, not live data.
Polished dashboard in light mode — two-column layout with sticky sidebar form, status summary header, sparklines on collapsed cards, and Google's card expanded showing uptime stats and response time chart.
Full dark mode with class-based toggle — slate backgrounds, explicit dark variants on every component, expanded Google card with chart and stats.
Early single-column layout before polish — endpoint cards with status badges and absolute timestamps. Google shows DOWN due to the redirect bug (fixed later with HttpClient.Redirect.NORMAL).
Four endpoints monitored including a newly added YouTube entry showing UNKNOWN status (no check results yet). Demonstrates the full card list with mixed UP/DOWN/UNKNOWN states.
Add Endpoint form with input fields for name, URL, and check interval. Single-column layout from early development, before the two-column responsive redesign.
Added configurable alerting rules per endpoint — replacing hardcoded thresholds with per-endpoint settings for response time sensitivity and failure tolerance.
Key implementation details:
responseTimeThresholdMs— new column on theEndpointentity (default: 5000ms).HealthCheckService.determineStatus()now reads this value instead of a hardcoded constant — an endpoint with a 200ms threshold will show DEGRADED where one with a 10000ms threshold would show UP for the same response time.failureThreshold— new column on theEndpointentity (default: 1).AlertServicetracks consecutive failure streaks per endpoint in aConcurrentHashMap<Long, Integer>. A DOWN alert only fires when the streak count exactly reaches the threshold — preventing false alarms from a single timeout.ConcurrentHashMap— chosen overHashMapbecauseHealthCheckServiceruns all endpoint checks in parallel on separate threads. A plainHashMaphas no thread-safety guarantees under concurrent writes.merge()provides an atomic increment-or-initialize in one call.- DTO + mapper propagation — both fields flow through
CreateEndpointRequest→EndpointMapper.toEntity()→Endpoint→EndpointMapper.toDTO()→EndpointDTO→ frontend. The mapper appliesOptional.ofNullable(...).orElse(default)so omitting a field from the request body silently applies the default. - Frontend form — two new inputs in
AddEndpointFormlet users configure thresholds at creation time.EndpointDetaildisplays the active thresholds as stat cards alongside uptime and check counts.
Challenge: Consecutive failure tracking needs to survive across multiple check cycles but doesn't need to be persisted — it resets naturally if the app restarts (the first fresh check re-establishes the streak from scratch).
Solution: An in-memory
ConcurrentHashMapkeyed byendpointIdlives on theAlertServicebean (singleton scope by default in Spring). It persists for the lifetime of the app without touching the database.
Challenge: The failure alert should fire exactly once when the streak hits the threshold — not on every subsequent failure after that.
Solution:
merge()returns the new value after increment. Checkingfailures != endpoint.getFailureThreshold()means we return early on every check except the one where the count first equals the threshold. At count 4 with threshold 3,4 != 3is true — silent. Only at the exact crossing point does the alert fire.
Expanded endpoint showing the two new threshold stat cards — "Degraded above 5000ms" and "Alert after 1 failure" — alongside the existing uptime and check count stats.
- Java 17+
- Maven 3.9+
- Node.js 18+
cd backend
./mvnw spring-boot:runcd backend
export RDS_PASSWORD=your_rds_password
./mvnw spring-boot:run -Dspring-boot.run.profiles=rdscd frontend
npm install
npm run devOpen http://localhost:3000. Vite proxies /api/* requests to the backend on port 8080.