From 8deddc1739ae11290de6fa3838be54ee86d5cdd3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:26:20 +0000
Subject: [PATCH 1/4] feat: add Docker, OpenAPI docs, logging and Prometheus
observability
Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/5f1775da-aadb-4d0a-8e9b-d95b9333e860
Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
---
Dockerfile | 52 ++++++++
backend/pom.xml | 12 ++
.../com/jobtracker/config/OpenApiConfig.java | 4 +-
.../config/RequestLoggingFilter.java | 48 +++++++
.../com/jobtracker/config/SecurityConfig.java | 9 +-
.../controller/ApplicationController.java | 121 +++++++++++++++---
.../jobtracker/controller/AuthController.java | 68 ++++++++++
.../controller/DashboardController.java | 15 +++
.../application/ApplicationPageResponse.java | 8 ++
.../dto/application/ApplicationRequest.java | 13 ++
.../dto/application/ApplicationResponse.java | 15 +++
.../application/UpdateReminderRequest.java | 3 +
.../dto/application/UpdateStatusRequest.java | 4 +
.../com/jobtracker/dto/auth/AuthResponse.java | 6 +
.../dto/auth/ForgotPasswordRequest.java | 3 +
.../com/jobtracker/dto/auth/LoginRequest.java | 4 +
.../jobtracker/dto/auth/LogoutRequest.java | 3 +
.../jobtracker/dto/auth/MessageResponse.java | 4 +
.../jobtracker/dto/auth/RefreshResponse.java | 5 +
.../dto/auth/RefreshTokenRequest.java | 3 +
.../jobtracker/dto/auth/RegisterRequest.java | 6 +
.../dto/auth/ResetPasswordRequest.java | 5 +
.../com/jobtracker/dto/auth/UserResponse.java | 6 +
.../dashboard/DashboardSummaryResponse.java | 8 ++
.../exception/GlobalExceptionHandler.java | 10 ++
.../com/jobtracker/service/AuthService.java | 14 +-
backend/src/main/resources/application.yml | 22 ++++
backend/src/main/resources/logback-spring.xml | 41 ++++++
docker-compose.prod.yml | 52 ++++++++
docker-compose.yml | 98 ++++++++++++++
grafana/dashboards/job-tracker-dashboard.json | 82 ++++++++++++
grafana/provisioning/dashboards/dashboard.yml | 11 ++
.../provisioning/datasources/datasource.yml | 9 ++
prometheus.yml | 9 ++
34 files changed, 750 insertions(+), 23 deletions(-)
create mode 100644 Dockerfile
create mode 100644 backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
create mode 100644 backend/src/main/resources/logback-spring.xml
create mode 100644 docker-compose.prod.yml
create mode 100644 docker-compose.yml
create mode 100644 grafana/dashboards/job-tracker-dashboard.json
create mode 100644 grafana/provisioning/dashboards/dashboard.yml
create mode 100644 grafana/provisioning/datasources/datasource.yml
create mode 100644 prometheus.yml
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..06d3ddf
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,52 @@
+# ============================================================
+# Stage 1: Build
+# ============================================================
+FROM eclipse-temurin:21-jdk-alpine AS build
+
+# Install Maven
+RUN apk add --no-cache maven
+
+WORKDIR /workspace
+
+# Copy POM first to cache dependency layer
+COPY backend/pom.xml .
+
+# Download dependencies (cached unless pom.xml changes)
+RUN mvn dependency:go-offline -B --no-transfer-progress
+
+# Copy source code and build the fat JAR, skipping tests
+COPY backend/src src
+RUN mvn package -DskipTests -B --no-transfer-progress
+
+# ============================================================
+# Stage 2: Runtime
+# ============================================================
+FROM eclipse-temurin:21-jre-alpine AS runtime
+
+# Create a non-root user for security
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+WORKDIR /app
+
+# Copy the built JAR from the build stage
+COPY --from=build /workspace/target/*.jar app.jar
+
+# Ensure the app directory is owned by the non-root user
+RUN chown -R appuser:appgroup /app
+
+USER appuser
+
+# Expose application port
+EXPOSE 8080
+
+# Health check – relies on Spring Actuator
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD wget -qO- http://localhost:8080/actuator/health || exit 1
+
+# JVM tuning flags for containers (respects cgroup memory limits)
+ENV JAVA_OPTS="-XX:+UseContainerSupport \
+ -XX:MaxRAMPercentage=75.0 \
+ -XX:+ExitOnOutOfMemoryError \
+ -Djava.security.egd=file:/dev/./urandom"
+
+ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
diff --git a/backend/pom.xml b/backend/pom.xml
index 73940b7..a062b9d 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -84,6 +84,18 @@
2.5.0
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
org.springframework.boot
diff --git a/backend/src/main/java/com/jobtracker/config/OpenApiConfig.java b/backend/src/main/java/com/jobtracker/config/OpenApiConfig.java
index eeeb5a5..3d8b1c1 100644
--- a/backend/src/main/java/com/jobtracker/config/OpenApiConfig.java
+++ b/backend/src/main/java/com/jobtracker/config/OpenApiConfig.java
@@ -16,8 +16,8 @@ public OpenAPI openAPI() {
final String securitySchemeName = "bearerAuth";
return new OpenAPI()
.info(new Info()
- .title("Job Tracker API")
- .description("REST API for Job Application Tracker PWA")
+ .title("Job Apply Tracker API")
+ .description("API for tracking job applications")
.version("1.0.0"))
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
.components(new Components()
diff --git a/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java b/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
new file mode 100644
index 0000000..d270a3a
--- /dev/null
+++ b/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
@@ -0,0 +1,48 @@
+package com.jobtracker.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.lang.NonNull;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+public class RequestLoggingFilter extends OncePerRequestFilter {
+
+ private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain) throws ServletException, IOException {
+ long start = System.currentTimeMillis();
+ try {
+ filterChain.doFilter(request, response);
+ } finally {
+ long duration = System.currentTimeMillis() - start;
+ String userId = resolveUserId();
+ log.info("method={} path={} status={} duration={}ms userId={}",
+ request.getMethod(),
+ request.getRequestURI(),
+ response.getStatus(),
+ duration,
+ userId);
+ }
+ }
+
+ private String resolveUserId() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
+ return auth.getName();
+ }
+ return "anonymous";
+ }
+}
diff --git a/backend/src/main/java/com/jobtracker/config/SecurityConfig.java b/backend/src/main/java/com/jobtracker/config/SecurityConfig.java
index d85d6b7..f256aa0 100644
--- a/backend/src/main/java/com/jobtracker/config/SecurityConfig.java
+++ b/backend/src/main/java/com/jobtracker/config/SecurityConfig.java
@@ -18,10 +18,13 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CorsConfig corsConfig;
+ private final RequestLoggingFilter requestLoggingFilter;
- public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig) {
+ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig,
+ RequestLoggingFilter requestLoggingFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.corsConfig = corsConfig;
+ this.requestLoggingFilter = requestLoggingFilter;
}
@Bean
@@ -36,9 +39,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/api/auth/refresh", "/api/auth/forgot-password",
"/api/auth/reset-password").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
+ .requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus").permitAll()
.anyRequest().authenticated()
)
- .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .addFilterBefore(requestLoggingFilter, JwtAuthenticationFilter.class);
return http.build();
}
diff --git a/backend/src/main/java/com/jobtracker/controller/ApplicationController.java b/backend/src/main/java/com/jobtracker/controller/ApplicationController.java
index 1022ef0..dd8531c 100644
--- a/backend/src/main/java/com/jobtracker/controller/ApplicationController.java
+++ b/backend/src/main/java/com/jobtracker/controller/ApplicationController.java
@@ -2,6 +2,12 @@
import com.jobtracker.dto.application.*;
import com.jobtracker.service.ApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
@@ -12,6 +18,7 @@
import java.util.List;
import java.util.Map;
+@Tag(name = "Applications", description = "Job application management endpoints")
@RestController
@RequestMapping("/api/applications")
public class ApplicationController {
@@ -22,60 +29,140 @@ public ApplicationController(ApplicationService applicationService) {
this.applicationService = applicationService;
}
+ @Operation(
+ summary = "Create a job application",
+ description = "Creates a new job application for the authenticated user",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "Application created",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "400", description = "Validation error")
+ }
+ )
@PostMapping
public ResponseEntity create(@Valid @RequestBody ApplicationRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request));
}
+ @Operation(
+ summary = "Get application by ID",
+ description = "Returns a single job application owned by the authenticated user",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Application found",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@GetMapping("/{id}")
- public ResponseEntity getById(@PathVariable Long id) {
+ public ResponseEntity getById(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(applicationService.getById(id));
}
+ @Operation(
+ summary = "Update a job application",
+ description = "Replaces all fields of an existing job application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Application updated",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "400", description = "Validation error"),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@PutMapping("/{id}")
- public ResponseEntity update(@PathVariable Long id,
- @Valid @RequestBody ApplicationRequest request) {
+ public ResponseEntity update(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id,
+ @Valid @RequestBody ApplicationRequest request) {
return ResponseEntity.ok(applicationService.update(id, request));
}
+ @Operation(
+ summary = "Update application status",
+ description = "Partially updates only the status field of an application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Status updated",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@PatchMapping("/{id}/status")
- public ResponseEntity updateStatus(@PathVariable Long id,
- @Valid @RequestBody UpdateStatusRequest request) {
+ public ResponseEntity updateStatus(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id,
+ @Valid @RequestBody UpdateStatusRequest request) {
return ResponseEntity.ok(applicationService.updateStatus(id, request));
}
+ @Operation(
+ summary = "Update recruiter DM reminder",
+ description = "Enables or disables the recruiter DM reminder for an application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Reminder updated",
+ content = @Content(schema = @Schema(implementation = ApplicationResponse.class))),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@PatchMapping("/{id}/reminder")
- public ResponseEntity updateReminder(@PathVariable Long id,
- @Valid @RequestBody UpdateReminderRequest request) {
+ public ResponseEntity updateReminder(
+ @Parameter(description = "Application ID", required = true) @PathVariable Long id,
+ @Valid @RequestBody UpdateReminderRequest request) {
return ResponseEntity.ok(applicationService.updateReminder(id, request));
}
+ @Operation(
+ summary = "Delete a job application",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Application deleted"),
+ @ApiResponse(responseCode = "404", description = "Application not found")
+ }
+ )
@DeleteMapping("/{id}")
- public ResponseEntity
+
+
+ net.logstash.logback
+ logstash-logback-encoder
+ 7.4
+
+
org.springframework.boot
diff --git a/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java b/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
index d270a3a..bedc5d1 100644
--- a/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
+++ b/backend/src/main/java/com/jobtracker/config/RequestLoggingFilter.java
@@ -19,6 +19,12 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
+ @Override
+ protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
+ // Skip logging for actuator endpoints to avoid log spam from health probes and Prometheus scrapes
+ return request.getRequestURI().startsWith("/actuator");
+ }
+
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@@ -28,17 +34,17 @@ protected void doFilterInternal(@NonNull HttpServletRequest request,
filterChain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - start;
- String userId = resolveUserId();
- log.info("method={} path={} status={} duration={}ms userId={}",
+ String userEmail = resolveUserEmail();
+ log.info("method={} path={} status={} duration={}ms userEmail={}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
duration,
- userId);
+ userEmail);
}
}
- private String resolveUserId() {
+ private String resolveUserEmail() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
return auth.getName();
diff --git a/backend/src/main/java/com/jobtracker/config/SecurityConfig.java b/backend/src/main/java/com/jobtracker/config/SecurityConfig.java
index f256aa0..108a5a5 100644
--- a/backend/src/main/java/com/jobtracker/config/SecurityConfig.java
+++ b/backend/src/main/java/com/jobtracker/config/SecurityConfig.java
@@ -39,7 +39,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/api/auth/refresh", "/api/auth/forgot-password",
"/api/auth/reset-password").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
- .requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
diff --git a/backend/src/main/java/com/jobtracker/service/AuthService.java b/backend/src/main/java/com/jobtracker/service/AuthService.java
index 1c29299..1f2b298 100644
--- a/backend/src/main/java/com/jobtracker/service/AuthService.java
+++ b/backend/src/main/java/com/jobtracker/service/AuthService.java
@@ -128,8 +128,8 @@ public MessageResponse resetPassword(ResetPasswordRequest request) {
public MessageResponse logout(LogoutRequest request) {
refreshTokenService.revokeToken(request.refreshToken());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- String userId = (auth != null && auth.isAuthenticated()) ? auth.getName() : "unknown";
- log.info("event=LOGOUT_SUCCESS userId={}", userId);
+ String userEmail = (auth != null && auth.isAuthenticated()) ? auth.getName() : "unknown";
+ log.info("event=LOGOUT_SUCCESS userEmail={}", userEmail);
return new MessageResponse("Logged out successfully");
}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 0fc1a3a..87b9d96 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -31,6 +31,8 @@ cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173}
management:
+ server:
+ port: ${MANAGEMENT_PORT:8081}
endpoints:
web:
exposure:
@@ -41,6 +43,9 @@ management:
metrics:
tags:
application: ${spring.application.name}
+ distribution:
+ percentiles-histogram:
+ http.server.requests: true
springdoc:
api-docs:
diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml
index 3e3368c..562f073 100644
--- a/backend/src/main/resources/logback-spring.xml
+++ b/backend/src/main/resources/logback-spring.xml
@@ -3,10 +3,10 @@
-
+
-
- {"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ}","level":"%level","app":"${appName}","logger":"%logger{36}","thread":"%thread","message":"%msg"}%n
+
+ {"app":"${appName}"}
diff --git a/docker-compose.yml b/docker-compose.yml
index 5600e15..037aefc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,7 +21,7 @@ services:
mariadb:
condition: service_healthy
healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
+ test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health"]
interval: 30s
timeout: 10s
retries: 5
@@ -53,7 +53,7 @@ services:
# ── Prometheus ────────────────────────────────────────────
prometheus:
- image: prom/prometheus:latest
+ image: prom/prometheus:v2.54.1
container_name: job-tracker-prometheus
ports:
- "9090:9090"
@@ -65,13 +65,19 @@ services:
- "--storage.tsdb.path=/prometheus"
- "--web.console.libraries=/usr/share/prometheus/console_libraries"
- "--web.console.templates=/usr/share/prometheus/consoles"
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 15s
networks:
- job-tracker-net
restart: unless-stopped
# ── Grafana ───────────────────────────────────────────────
grafana:
- image: grafana/grafana:latest
+ image: grafana/grafana:10.4.8
container_name: job-tracker-grafana
ports:
- "3000:3000"
@@ -84,7 +90,8 @@ services:
- ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./grafana/dashboards:/etc/grafana/dashboards:ro
depends_on:
- - prometheus
+ prometheus:
+ condition: service_healthy
networks:
- job-tracker-net
restart: unless-stopped
diff --git a/prometheus.yml b/prometheus.yml
index 3cc642e..7d5765e 100644
--- a/prometheus.yml
+++ b/prometheus.yml
@@ -6,4 +6,4 @@ scrape_configs:
- job_name: 'spring-app'
metrics_path: '/actuator/prometheus'
static_configs:
- - targets: ['app:8080']
+ - targets: ['app:8081']
From 50b36551a46489a4196a1b98d655081982064979 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:41:49 +0000
Subject: [PATCH 4/4] fix: expose management port 8081 in Dockerfile
Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/9b792434-2e5e-4bb8-a9bd-e729d1a13085
Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
---
Dockerfile | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index f96250b..00c5015 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,8 +36,9 @@ RUN chown -R appuser:appgroup /app
USER appuser
-# Expose application port
+# Expose application port and management port
EXPOSE 8080
+EXPOSE 8081
# Health check – relies on Spring Actuator management port
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \