diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3014572 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy this file to .env and fill in real values. +# The .env file is excluded from version control. + +# Database +DB_NAME=jobtracker +DB_USERNAME=jobtracker +DB_PASSWORD=change_me +DB_ROOT_PASSWORD=change_me_root + +# JWT +JWT_SECRET=replace_with_a_256_bit_random_string + +# Tracing (1.0 = 100 %; lower for production) +TRACING_SAMPLING_PROBABILITY=1.0 diff --git a/.gitignore b/.gitignore index e6e4424..021fc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ target/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +# Environment variables – never commit secrets +.env diff --git a/Dockerfile b/Dockerfile index 00c5015..994a491 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,18 @@ # ============================================================ # Stage 1: Build # ============================================================ -FROM eclipse-temurin:21-jdk-alpine AS build - -# Install Maven -RUN apk add --no-cache maven +FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /workspace # Copy POM first to cache dependency layer -COPY backend/pom.xml . +COPY 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 +COPY src src RUN mvn package -DskipTests -B --no-transfer-progress # ============================================================ @@ -29,10 +26,7 @@ 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 +COPY --from=build --chown=appuser:appgroup /workspace/target/*.jar app.jar USER appuser diff --git a/Dockerfile.orig b/Dockerfile.orig deleted file mode 100644 index 06d3ddf..0000000 --- a/Dockerfile.orig +++ /dev/null @@ -1,52 +0,0 @@ -# ============================================================ -# 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/src/test/java/com/jobtracker/JobTrackerApplicationTests.java b/backend/src/test/java/com/jobtracker/JobTrackerApplicationTests.java new file mode 100644 index 0000000..2f16b81 --- /dev/null +++ b/backend/src/test/java/com/jobtracker/JobTrackerApplicationTests.java @@ -0,0 +1,26 @@ +package com.jobtracker; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=TestSecretKeyThatIsAtLeast256BitsLongForTestingPurposesOnly", + "jwt.access-token-expiration-ms=900000", + "jwt.refresh-token-expiration-ms=604800000", + "cors.allowed-origins=http://localhost:3000", + "management.tracing.sampling.probability=0.0" +}) +class JobTrackerApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 037aefc..abb7187 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,15 +11,18 @@ services: ports: - "8080:8080" environment: - SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: root - # WARNING: Replace this secret before any production use. This value is for development only. - JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment - CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" + DB_URL: jdbc:mariadb://mariadb:3306/${DB_NAME:-jobtracker}?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true + DB_USERNAME: ${DB_USERNAME:-jobtracker} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-"http://localhost:3000,http://localhost:5173"} + OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 + TRACING_SAMPLING_PROBABILITY: ${TRACING_SAMPLING_PROBABILITY:-1.0} depends_on: mariadb: condition: service_healthy + jaeger: + condition: service_started healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health"] interval: 30s @@ -37,8 +40,10 @@ services: ports: - "3306:3306" environment: - MARIADB_ROOT_PASSWORD: root - MARIADB_DATABASE: jobtracker + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required} + MARIADB_DATABASE: ${DB_NAME:-jobtracker} + MARIADB_USER: ${DB_USERNAME:-jobtracker} + MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} volumes: - mariadb_data:/var/lib/mysql healthcheck: @@ -51,6 +56,20 @@ services: - job-tracker-net restart: unless-stopped + # ── Jaeger (distributed tracing) ───────────────────────── + jaeger: + image: jaegertracing/all-in-one:1.57.0 + container_name: job-tracker-jaeger + ports: + - "16686:16686" # Jaeger UI + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + environment: + - COLLECTOR_OTLP_ENABLED=true + networks: + - job-tracker-net + restart: unless-stopped + # ── Prometheus ──────────────────────────────────────────── prometheus: image: prom/prometheus:v2.54.1 @@ -82,8 +101,8 @@ services: ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: admin + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} volumes: - grafana_data:/var/lib/grafana - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro @@ -104,3 +123,4 @@ volumes: networks: job-tracker-net: driver: bridge + diff --git a/docker-compose.yml.orig b/docker-compose.yml.orig deleted file mode 100644 index 5600e15..0000000 --- a/docker-compose.yml.orig +++ /dev/null @@ -1,99 +0,0 @@ -version: "3.9" - -services: - - # ── Spring Boot application ─────────────────────────────── - app: - build: - context: . - dockerfile: Dockerfile - container_name: job-tracker-app - ports: - - "8080:8080" - environment: - SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: root - # WARNING: Replace this secret before any production use. This value is for development only. - JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment - CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" - depends_on: - mariadb: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 60s - networks: - - job-tracker-net - restart: unless-stopped - - # ── MariaDB ─────────────────────────────────────────────── - mariadb: - image: mariadb:11 - container_name: job-tracker-mariadb - ports: - - "3306:3306" - environment: - MARIADB_ROOT_PASSWORD: root - MARIADB_DATABASE: jobtracker - volumes: - - mariadb_data:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - job-tracker-net - restart: unless-stopped - - # ── Prometheus ──────────────────────────────────────────── - prometheus: - image: prom/prometheus:latest - container_name: job-tracker-prometheus - ports: - - "9090:9090" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--storage.tsdb.path=/prometheus" - - "--web.console.libraries=/usr/share/prometheus/console_libraries" - - "--web.console.templates=/usr/share/prometheus/consoles" - networks: - - job-tracker-net - restart: unless-stopped - - # ── Grafana ─────────────────────────────────────────────── - grafana: - image: grafana/grafana:latest - container_name: job-tracker-grafana - ports: - - "3000:3000" - environment: - GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: admin - volumes: - - grafana_data:/var/lib/grafana - - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro - - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - - ./grafana/dashboards:/etc/grafana/dashboards:ro - depends_on: - - prometheus - networks: - - job-tracker-net - restart: unless-stopped - -volumes: - mariadb_data: - prometheus_data: - grafana_data: - -networks: - job-tracker-net: - driver: bridge diff --git a/docker-compose_BACKUP_20088.yml b/docker-compose_BACKUP_20088.yml deleted file mode 100644 index 5600e15..0000000 --- a/docker-compose_BACKUP_20088.yml +++ /dev/null @@ -1,99 +0,0 @@ -version: "3.9" - -services: - - # ── Spring Boot application ─────────────────────────────── - app: - build: - context: . - dockerfile: Dockerfile - container_name: job-tracker-app - ports: - - "8080:8080" - environment: - SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: root - # WARNING: Replace this secret before any production use. This value is for development only. - JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment - CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" - depends_on: - mariadb: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 60s - networks: - - job-tracker-net - restart: unless-stopped - - # ── MariaDB ─────────────────────────────────────────────── - mariadb: - image: mariadb:11 - container_name: job-tracker-mariadb - ports: - - "3306:3306" - environment: - MARIADB_ROOT_PASSWORD: root - MARIADB_DATABASE: jobtracker - volumes: - - mariadb_data:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - job-tracker-net - restart: unless-stopped - - # ── Prometheus ──────────────────────────────────────────── - prometheus: - image: prom/prometheus:latest - container_name: job-tracker-prometheus - ports: - - "9090:9090" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--storage.tsdb.path=/prometheus" - - "--web.console.libraries=/usr/share/prometheus/console_libraries" - - "--web.console.templates=/usr/share/prometheus/consoles" - networks: - - job-tracker-net - restart: unless-stopped - - # ── Grafana ─────────────────────────────────────────────── - grafana: - image: grafana/grafana:latest - container_name: job-tracker-grafana - ports: - - "3000:3000" - environment: - GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: admin - volumes: - - grafana_data:/var/lib/grafana - - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro - - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - - ./grafana/dashboards:/etc/grafana/dashboards:ro - depends_on: - - prometheus - networks: - - job-tracker-net - restart: unless-stopped - -volumes: - mariadb_data: - prometheus_data: - grafana_data: - -networks: - job-tracker-net: - driver: bridge diff --git a/docker-compose_BASE_20088.yml b/docker-compose_BASE_20088.yml deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose_LOCAL_20088.yml b/docker-compose_LOCAL_20088.yml deleted file mode 100644 index 5600e15..0000000 --- a/docker-compose_LOCAL_20088.yml +++ /dev/null @@ -1,99 +0,0 @@ -version: "3.9" - -services: - - # ── Spring Boot application ─────────────────────────────── - app: - build: - context: . - dockerfile: Dockerfile - container_name: job-tracker-app - ports: - - "8080:8080" - environment: - SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: root - # WARNING: Replace this secret before any production use. This value is for development only. - JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment - CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" - depends_on: - mariadb: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 60s - networks: - - job-tracker-net - restart: unless-stopped - - # ── MariaDB ─────────────────────────────────────────────── - mariadb: - image: mariadb:11 - container_name: job-tracker-mariadb - ports: - - "3306:3306" - environment: - MARIADB_ROOT_PASSWORD: root - MARIADB_DATABASE: jobtracker - volumes: - - mariadb_data:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - job-tracker-net - restart: unless-stopped - - # ── Prometheus ──────────────────────────────────────────── - prometheus: - image: prom/prometheus:latest - container_name: job-tracker-prometheus - ports: - - "9090:9090" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--storage.tsdb.path=/prometheus" - - "--web.console.libraries=/usr/share/prometheus/console_libraries" - - "--web.console.templates=/usr/share/prometheus/consoles" - networks: - - job-tracker-net - restart: unless-stopped - - # ── Grafana ─────────────────────────────────────────────── - grafana: - image: grafana/grafana:latest - container_name: job-tracker-grafana - ports: - - "3000:3000" - environment: - GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: admin - volumes: - - grafana_data:/var/lib/grafana - - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro - - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - - ./grafana/dashboards:/etc/grafana/dashboards:ro - depends_on: - - prometheus - networks: - - job-tracker-net - restart: unless-stopped - -volumes: - mariadb_data: - prometheus_data: - grafana_data: - -networks: - job-tracker-net: - driver: bridge diff --git a/docker-compose_REMOTE_20088.yml b/docker-compose_REMOTE_20088.yml deleted file mode 100644 index bdf6d43..0000000 --- a/docker-compose_REMOTE_20088.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: '3.8' - -services: - db: - image: mariadb:11.2 - container_name: jobtracker-db - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: jobtracker - MYSQL_USER: jobtracker - MYSQL_PASSWORD: jobtracker - ports: - - "3306:3306" - volumes: - - mariadb_data:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 5 - - app: - build: . - container_name: jobtracker-app - ports: - - "8080:8080" - environment: - DB_URL: jdbc:mariadb://db:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true - DB_USERNAME: jobtracker - DB_PASSWORD: jobtracker - JWT_SECRET: ${JWT_SECRET:-ChangeThisToASecureRandomSecretKeyInProductionAtLeast256BitsLong} - JWT_ACCESS_TOKEN_EXPIRATION_MS: 900000 - JWT_REFRESH_TOKEN_EXPIRATION_MS: 604800000 - CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} - depends_on: - db: - condition: service_healthy - restart: unless-stopped - -volumes: - mariadb_data: diff --git a/pom.xml b/pom.xml index cb7c049..cd5b9bb 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,18 @@ spring-boot-starter-actuator + + + io.micrometer + micrometer-tracing-bridge-otel + + + + + io.opentelemetry + opentelemetry-exporter-otlp + + io.micrometer @@ -104,7 +116,6 @@ logstash-logback-encoder 7.4 - org.springframework.boot diff --git a/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig deleted file mode 100644 index d270a3a..0000000 --- a/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig +++ /dev/null @@ -1,48 +0,0 @@ -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/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java.orig b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java.orig deleted file mode 100644 index 0d4b520..0000000 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java.orig +++ /dev/null @@ -1,81 +0,0 @@ -package com.jobtracker.exception; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.AuthenticationException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); - - @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity> handleNotFound(ResourceNotFoundException ex) { - log.warn("event=RESOURCE_NOT_FOUND message={}", ex.getMessage()); - return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage()); - } - - @ExceptionHandler(BadRequestException.class) - public ResponseEntity> handleBadRequest(BadRequestException ex) { - log.warn("event=BAD_REQUEST message={}", ex.getMessage()); - return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - @ExceptionHandler(UnauthorizedException.class) - public ResponseEntity> handleUnauthorized(UnauthorizedException ex) { - log.warn("event=UNAUTHORIZED message={}", ex.getMessage()); - return buildResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); - } - - @ExceptionHandler(ConflictException.class) - public ResponseEntity> handleConflict(ConflictException ex) { - log.warn("event=CONFLICT message={}", ex.getMessage()); - return buildResponse(HttpStatus.CONFLICT, ex.getMessage()); - } - - @ExceptionHandler({BadCredentialsException.class, AuthenticationException.class}) - public ResponseEntity> handleBadCredentials(RuntimeException ex) { - return buildResponse(HttpStatus.UNAUTHORIZED, "Invalid credentials"); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { - Map fieldErrors = new HashMap<>(); - for (FieldError error : ex.getBindingResult().getFieldErrors()) { - fieldErrors.put(error.getField(), error.getDefaultMessage()); - } - log.warn("event=VALIDATION_FAILURE fieldErrors={}", fieldErrors); - Map body = new HashMap<>(); - body.put("timestamp", LocalDateTime.now().toString()); - body.put("status", HttpStatus.BAD_REQUEST.value()); - body.put("error", "Validation failed"); - body.put("fieldErrors", fieldErrors); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGeneral(Exception ex) { - log.error("event=UNEXPECTED_ERROR message={}", ex.getMessage(), ex); - return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); - } - - private ResponseEntity> buildResponse(HttpStatus status, String message) { - Map body = new HashMap<>(); - body.put("timestamp", LocalDateTime.now().toString()); - body.put("status", status.value()); - body.put("error", status.getReasonPhrase()); - body.put("message", message); - return ResponseEntity.status(status).body(body); - } -} diff --git a/src/main/java/com/jobtracker/service/ApplicationService.java b/src/main/java/com/jobtracker/service/ApplicationService.java index 2bc4094..50e7613 100644 --- a/src/main/java/com/jobtracker/service/ApplicationService.java +++ b/src/main/java/com/jobtracker/service/ApplicationService.java @@ -8,6 +8,8 @@ import com.jobtracker.mapper.ApplicationMapper; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.util.SecurityUtils; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; import jakarta.persistence.criteria.Predicate; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -34,21 +36,32 @@ public class ApplicationService { private final ApplicationRepository applicationRepository; private final ApplicationMapper applicationMapper; private final SecurityUtils securityUtils; + private final Tracer tracer; public ApplicationService(ApplicationRepository applicationRepository, ApplicationMapper applicationMapper, - SecurityUtils securityUtils) { + SecurityUtils securityUtils, + Tracer tracer) { this.applicationRepository = applicationRepository; this.applicationMapper = applicationMapper; this.securityUtils = securityUtils; + this.tracer = tracer; } @Transactional public ApplicationResponse create(ApplicationRequest request) { - JobApplication app = new JobApplication(); - mapRequestToEntity(request, app); - app.setUser(securityUtils.getCurrentUser()); - return applicationMapper.toResponse(applicationRepository.save(app)); + Span span = tracer.nextSpan().name("create-application").start(); + try (Tracer.SpanInScope ignored = tracer.withSpan(span)) { + JobApplication app = new JobApplication(); + mapRequestToEntity(request, app); + app.setUser(securityUtils.getCurrentUser()); + return applicationMapper.toResponse(applicationRepository.save(app)); + } catch (Exception e) { + span.error(e); + throw e; + } finally { + span.end(); + } } @Transactional(readOnly = true) diff --git a/src/main/java/com/jobtracker/service/AuthService.java b/src/main/java/com/jobtracker/service/AuthService.java index 1f2b298..45bffaa 100644 --- a/src/main/java/com/jobtracker/service/AuthService.java +++ b/src/main/java/com/jobtracker/service/AuthService.java @@ -10,6 +10,8 @@ import com.jobtracker.exception.ResourceNotFoundException; import com.jobtracker.mapper.AuthMapper; import com.jobtracker.repository.UserRepository; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.BadCredentialsException; @@ -33,19 +35,22 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final PasswordResetService passwordResetService; private final AuthMapper authMapper; + private final Tracer tracer; public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService, RefreshTokenService refreshTokenService, PasswordResetService passwordResetService, - AuthMapper authMapper) { + AuthMapper authMapper, + Tracer tracer) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.jwtService = jwtService; this.refreshTokenService = refreshTokenService; this.passwordResetService = passwordResetService; this.authMapper = authMapper; + this.tracer = tracer; } @Transactional @@ -68,31 +73,50 @@ public AuthResponse register(RegisterRequest request) { @Transactional public AuthResponse login(LoginRequest request) { - User user = userRepository.findByEmail(request.email()) - .orElseThrow(() -> { - log.warn("event=LOGIN_FAILURE reason=USER_NOT_FOUND email={}", request.email()); - return new BadCredentialsException("Invalid credentials"); - }); - - if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { - log.warn("event=LOGIN_FAILURE reason=WRONG_PASSWORD userId={}", user.getId()); - throw new BadCredentialsException("Invalid credentials"); + Span span = tracer.nextSpan().name("login").start(); + try (Tracer.SpanInScope ignored = tracer.withSpan(span)) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> { + log.warn("event=LOGIN_FAILURE reason=USER_NOT_FOUND email={}", request.email()); + return new BadCredentialsException("Invalid credentials"); + }); + + if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + log.warn("event=LOGIN_FAILURE reason=WRONG_PASSWORD userId={}", user.getId()); + throw new BadCredentialsException("Invalid credentials"); + } + + log.info("event=LOGIN_SUCCESS userId={}", user.getId()); + return buildAuthResponse(user); + } catch (BadCredentialsException e) { + // Expected auth failure – do not mark as span error + throw e; + } catch (Exception e) { + span.error(e); + throw e; + } finally { + span.end(); } - - log.info("event=LOGIN_SUCCESS userId={}", user.getId()); - return buildAuthResponse(user); } @Transactional public RefreshResponse refresh(RefreshTokenRequest request) { - RefreshToken newRefreshToken = refreshTokenService.verifyAndRotate(request.refreshToken()); - User user = newRefreshToken.getUser(); - - UserDetails userDetails = new org.springframework.security.core.userdetails.User( - user.getEmail(), user.getPasswordHash(), Collections.emptyList()); - String accessToken = jwtService.generateToken(userDetails); - - return new RefreshResponse(accessToken, newRefreshToken.getToken()); + Span span = tracer.nextSpan().name("token-refresh").start(); + try (Tracer.SpanInScope ignored = tracer.withSpan(span)) { + RefreshToken newRefreshToken = refreshTokenService.verifyAndRotate(request.refreshToken()); + User user = newRefreshToken.getUser(); + + UserDetails userDetails = new org.springframework.security.core.userdetails.User( + user.getEmail(), user.getPasswordHash(), Collections.emptyList()); + String accessToken = jwtService.generateToken(userDetails); + + return new RefreshResponse(accessToken, newRefreshToken.getToken()); + } catch (Exception e) { + span.error(e); + throw e; + } finally { + span.end(); + } } @Transactional diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 87b9d96..d872ac7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,6 +40,12 @@ management: endpoint: health: show-details: when_authorized + tracing: + sampling: + probability: ${TRACING_SAMPLING_PROBABILITY:1.0} + otlp: + tracing: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} metrics: tags: application: ${spring.application.name} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 562f073..94558c9 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -10,14 +10,14 @@ - + - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] [%X{spanId:-}] %logger{36} - %msg%n - + @@ -27,7 +27,7 @@ - + diff --git a/src/main/resources/logback-spring.xml.orig b/src/main/resources/logback-spring.xml.orig deleted file mode 100644 index 3e3368c..0000000 --- a/src/main/resources/logback-spring.xml.orig +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - {"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ}","level":"%level","app":"${appName}","logger":"%logger{36}","thread":"%thread","message":"%msg"}%n - - - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java b/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java index b35c963..b8ef10c 100644 --- a/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java +++ b/src/test/java/com/jobtracker/unit/ApplicationServiceTest.java @@ -10,6 +10,7 @@ import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.service.ApplicationService; import com.jobtracker.util.SecurityUtils; +import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.mockito.Answers.RETURNS_DEEP_STUBS; @ExtendWith(MockitoExtension.class) class ApplicationServiceTest { @@ -37,6 +39,7 @@ class ApplicationServiceTest { @Mock private ApplicationRepository applicationRepository; @Mock private ApplicationMapper applicationMapper; @Mock private SecurityUtils securityUtils; + @Mock(answer = RETURNS_DEEP_STUBS) private Tracer tracer; @InjectMocks private ApplicationService applicationService; diff --git a/src/test/java/com/jobtracker/unit/AuthServiceTest.java b/src/test/java/com/jobtracker/unit/AuthServiceTest.java index af666df..ffd9bc1 100644 --- a/src/test/java/com/jobtracker/unit/AuthServiceTest.java +++ b/src/test/java/com/jobtracker/unit/AuthServiceTest.java @@ -12,6 +12,7 @@ import com.jobtracker.service.AuthService; import com.jobtracker.service.PasswordResetService; import com.jobtracker.service.RefreshTokenService; +import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -30,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +import static org.mockito.Answers.RETURNS_DEEP_STUBS; @ExtendWith(MockitoExtension.class) class AuthServiceTest { @@ -40,6 +42,7 @@ class AuthServiceTest { @Mock private RefreshTokenService refreshTokenService; @Mock private PasswordResetService passwordResetService; @Mock private AuthMapper authMapper; + @Mock(answer = RETURNS_DEEP_STUBS) private Tracer tracer; @InjectMocks private AuthService authService; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index b755bc5..0f78e78 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -19,6 +19,11 @@ jwt: cors: allowed-origins: http://localhost:3000 +management: + tracing: + sampling: + probability: 0.0 + logging: level: root: WARN