diff --git a/.gitignore b/.gitignore index 07179e0..c418178 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ scripts/*vars.sh ### Packer *.pkrvars.hcl + +### Logging + +log/* \ No newline at end of file diff --git a/packer/amazon-cloudwatch-agent.json.tmpl b/packer/amazon-cloudwatch-agent.json.tmpl new file mode 100644 index 0000000..3f83d14 --- /dev/null +++ b/packer/amazon-cloudwatch-agent.json.tmpl @@ -0,0 +1,59 @@ +{ + "agent": { + "metrics_collection_interval": 60, + "logfile": "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log" + }, + + "logs": { + "logs_collected": { + "files": { + "collect_list": [ + { + "file_path": "${APP_DIR}/log/app.log", + "log_group_name": "/${SERVICE_NAME}/app", + "log_stream_name": "{instance_id}", + "retention_in_days": 14, + "multi_line_start_pattern": "^{" + }, + { + "file_path": "${APP_DIR}/log/access.log", + "log_group_name": "/${SERVICE_NAME}/access", + "log_stream_name": "{instance_id}", + "retention_in_days": 14, + "multi_line_start_pattern": "^{" + } + ] + } + }, + "log_stream_name": "{instance_id}", + "force_flush_interval": 15 + }, + + "metrics": { + "append_dimensions": { + "InstanceId": "$${aws:InstanceId}", + "AutoScalingGroupName": "$${aws:AutoScalingGroupName}" + }, + "metrics_collected": { + "cpu": { + "measurement": [ + {"name": "cpu_usage_active", "rename": "CPUUsage", "unit": "Percent"} + ], + "metrics_collection_interval": 60 + }, + "mem": { + "measurement": [ + {"name": "mem_used_percent", "rename": "MemoryUsage", "unit": "Percent"} + ], + "metrics_collection_interval": 60 + }, + "disk": { + "resources": ["/"], + "measurement": [ + {"name": "disk_used_percent", "rename": "DiskUsage", "unit": "Percent"} + ], + "metrics_collection_interval": 300 + } + } + } +} \ No newline at end of file diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index d8f07ec..bd7c087 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -1,3 +1,11 @@ +# Render the CloudWatch Agent config on the Packer side (before upload) +locals { + cwagent_config = templatefile("${path.root}/amazon-cloudwatch-agent.json.tmpl", { + APP_DIR = var.shell_env.app_dir + SERVICE_NAME = var.shell_env.service_name + }) +} + build { name = "csye6225-webapp-image" sources = ["source.amazon-ebs.ubuntu"] @@ -50,6 +58,7 @@ EOC cat > /tmp/app.env < /tmp/amazon-cloudwatch-agent.json <<'EOF' +${local.cwagent_config} +EOF +EOC + , + "if [ -f /tmp/amazon-cloudwatch-agent.json ]; then", + " echo '[SUCCESS] Config file created successfully:'", + " head -n 10 /tmp/amazon-cloudwatch-agent.json", + "else", + " echo '[ERROR] Failed to generate /tmp/amazon-cloudwatch-agent.json'; exit 1;", + "fi" + ] + } + + # Move the rendered config file to the official CloudWatch Agent directory + provisioner "shell" { + inline_shebang = "/bin/bash" # use bash + inline = [ + "set -euo pipefail", + "sudo mv /tmp/amazon-cloudwatch-agent.json /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json", + "sudo chown root:root /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json", + "sudo chmod 0644 /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json", + "sudo systemctl enable amazon-cloudwatch-agent" + ] + } + + # ------------------------------------ + # Verify CloudWatch Agent config & ensure log dirs + # ------------------------------------ + provisioner "shell" { + inline_shebang = "/bin/bash" + environment_vars = [ + "B_APP_DIR=${var.shell_env.app_dir}", + "B_APP_USER=${var.shell_env.app_user}", + "B_APP_GROUP=${var.shell_env.app_group}" + ] + inline = [ + "set -euo pipefail", + "echo '[INFO] Ensuring log directory exists...'", + "sudo mkdir -p \"$B_APP_DIR/log\"", + + "echo '[INFO] Starting CloudWatch Agent for config validation...'", + "sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s || true", + "sudo systemctl enable amazon-cloudwatch-agent", + "sudo systemctl status amazon-cloudwatch-agent --no-pager || true", + "echo '[INFO] CloudWatch Agent validated successfully.'" + ] + } + provisioner "shell" { inline_shebang = "/bin/bash" # use bash execute_command = "sudo bash '{{ .Path }}'" diff --git a/pom.xml b/pom.xml index 5a71526..eebe035 100644 --- a/pom.xml +++ b/pom.xml @@ -126,6 +126,29 @@ s3 2.25.60 + + + org.springframework.boot + spring-boot-starter-aop + + + + net.logstash.logback + logstash-logback-encoder + 7.4 + + + + io.micrometer + micrometer-registry-statsd + + + + net.ttddyy.observation + datasource-micrometer-spring-boot + 1.2.0 + + diff --git a/scripts/setup.sh b/scripts/setup.sh index 8987add..c76b7c4 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -126,7 +126,7 @@ update_system() { install_common_tools() { info "Installing common tools (unzip, tar, curl)..." - apt-get install -y unzip tar curl sudo vim iproute2 >/dev/null + apt-get install -y unzip tar curl sudo sed vim iproute2 >/dev/null } install_java() { diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java new file mode 100644 index 0000000..ee545dd --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java @@ -0,0 +1,136 @@ +package com.isaactai.cloudnativeweb.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * @author tisaac + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class AccessLogFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + long start = System.currentTimeMillis(); + + try { + chain.doFilter(req, res); + } finally { + long took = System.currentTimeMillis() - start; + int status = res.getStatus(); + var log = LoggerFactory.getLogger("ACCESS"); + + String method = req.getMethod(); + String uri = req.getRequestURI(); + + // --- SAFE getters (no "null" literal) --- + String labelRaw = attr(req, "access.label"); + String exName = attr(req, "error.exception"); + + String msgSuccess = attr(req, "access.success"); // 2xx general + String msgWarnGen = attr(req, "access.clientWarn"); // 4xx general + String msgErrGen = attr(req, "access.serverError"); // 5xx general + String msgOverride = attr(req, "error.message"); // per-request detail + String code = attr(req, "error.code"); + + // --- fallbacks for missing attributes --- + String labelFinal = firstNonBlank(labelRaw, "Security"); + + if (status == 401) { + code = firstNonBlank(code, "UNAUTHORIZED"); + msgOverride = firstNonBlank(msgOverride, "Missing or invalid credentials"); + } else if (status == 403) { + code = firstNonBlank(code, "FORBIDDEN"); + msgOverride = firstNonBlank(msgOverride, "Insufficient permissions"); + } + + if (status >= 500) { + String msg = combine(msgErrGen, msgOverride); + Throwable t = (Throwable) req.getAttribute("error.throwable"); + + // shortened the msg to prevent too long + String inlineMsgShort = abbrev(msg, 220); + + if (t != null) { + log.error("{} {} [{}] took={}ms{}{}", + method, uri, labelFinal, took, + nonBlank(" err=", exName), + nonBlank(" msg=", inlineMsgShort), + t); + } else { + log.error("{} {} [{}] took={}ms{}{}", + method, uri, labelFinal, took, + nonBlank(" err=", exName), + nonBlank(" msg=", msg)); + } + + } else if (status >= 400) { + boolean expected = Boolean.TRUE.equals(req.getAttribute("error.expected")); + String msg = combine(msgWarnGen, msgOverride); + + if (expected && isNotBlank(code) && isNotBlank(msg)) { + log.warn("{} {} [{}] took={}ms code={}{}", + method, uri, labelFinal, took, code, + nonBlank(" msg=", msg)); + } else if (isNotBlank(msg)) { + log.warn("{} {} [{}] took={}ms msg={}", method, uri, labelFinal, took, msg); + } else { + log.warn("{} {} [{}] took={}ms", method, uri, labelFinal, took); + } + + } else { + if (isNotBlank(msgSuccess)) { + log.info("{} {} [{}] took={}ms msg={}", method, uri, labelFinal, took, msgSuccess); + } else { + log.info("{} {} [{}] took={}ms", method, uri, labelFinal, took); + } + } + } + } + + private static String combine(String general, String override) { + String g = (general == null || general.isBlank()) ? null : general; + String o = (override == null || override.isBlank()) ? null : override; + if (g != null && o != null) return g + " - " + o; // e.g., "Health Check failed - Query parameters are not allowed" + return (o != null) ? o : g; // only one exists + } + + // Safely read a request attribute as String; treat missing/"null"/blank as null + private static String attr(HttpServletRequest req, String name) { + Object o = req.getAttribute(name); + if (o == null) return null; + String s = (o instanceof String) ? (String) o : String.valueOf(o); + return (s == null || s.isBlank() || "null".equalsIgnoreCase(s)) ? null : s; + } + + private static boolean isNotBlank(String s) { + return s != null && !s.isBlank(); + } + + private static String nonBlank(String prefix, String val) { + return isNotBlank(val) ? (prefix + val) : ""; + } + + private static String firstNonBlank(String... ss) { + if (ss == null) return null; + for (String s : ss) if (isNotBlank(s)) return s; + return null; + } + + private static String abbrev(String s, int max) { + if (s == null) return null; + s = s.trim(); + if (s.length() <= max) return s; + return s.substring(0, Math.max(0, max - 1)) + "…"; + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/ApiExceptionHandler.java b/src/main/java/com/isaactai/cloudnativeweb/config/ApiExceptionHandler.java index 3037c10..9b98083 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/ApiExceptionHandler.java +++ b/src/main/java/com/isaactai/cloudnativeweb/config/ApiExceptionHandler.java @@ -23,8 +23,14 @@ public class ApiExceptionHandler { // For my custom BaseApiException + // Expected exception @ExceptionHandler(BaseApiException.class) public ResponseEntity handleBase(BaseApiException ex, HttpServletRequest req) { + req.setAttribute("error.expected", true); + req.setAttribute("error.code", ex.getCode().name()); + req.setAttribute("error.message", ex.getMessage()); + req.setAttribute("error.exception", ex.getClass().getSimpleName()); + HttpStatus status = ex.getStatus(); ApiErrorResponse body = ApiErrorResponse.of( status.value(), @@ -35,6 +41,16 @@ public ResponseEntity handleBase(BaseApiException ex, HttpServ return ResponseEntity.status(status).body(body); } + @ExceptionHandler(Exception.class) // Unexpected 5xx + public ResponseEntity handleAny(Exception ex, HttpServletRequest req) { + req.setAttribute("error.unexpected", true); + req.setAttribute("error.throwable", ex); // for AccessLogFilter to print stack + req.setAttribute("error.message", ex.getMessage()); + req.setAttribute("error.exception", ex.getClass().getSimpleName()); + var st = HttpStatus.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(st).body(ApiErrorResponse.of(st.value(), "INTERNAL_ERROR", "Internal server error", req.getRequestURI())); + } + // @Valid validation failed @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation( @@ -76,5 +92,7 @@ public ResponseEntity handleBadJson( ); } + + // TODO: Fallback handler to avoid returning raw 500 errors } diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java b/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java new file mode 100644 index 0000000..112ef33 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java @@ -0,0 +1,77 @@ +package com.isaactai.cloudnativeweb.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * @author tisaac + */ +@Aspect +@Component +public class LoggingAspect { + private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class); + + // Pointcut: match any method inside classes annotated with @RestController + @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)") + public void controllerMethods() {} + + /** + * Single around advice to log: + * - START: before controller method execution (prints HTTP method + URI + args) + * - SUCCESS: after normal return (prints duration) + * - ERROR: on exception (prints duration + exception) + */ + @Around("controllerMethods()") + public Object logAround(ProceedingJoinPoint pjp) throws Throwable { + long start = System.currentTimeMillis(); + + // Try to get current HttpServletRequest (may be null in non-web threads) + HttpServletRequest req = null; + RequestAttributes ra = RequestContextHolder.getRequestAttributes(); + if (ra instanceof ServletRequestAttributes sra) { + req = sra.getRequest(); + } + + // Build safe fields + final String httpMethod = (req != null) ? req.getMethod() : "N/A"; + final String uri = (req != null) ? req.getRequestURI() : "N/A"; + final String query = (req != null && req.getQueryString() != null) ? "?" + req.getQueryString() : ""; + final String className = pjp.getSignature().getDeclaringTypeName(); + final String methodName = pjp.getSignature().getName(); + + // START + logger.info("[START] {} {}{} -> {}.{}()", + httpMethod, uri, query, className, methodName); + + try { + Object result = pjp.proceed(); + + // SUCCESS + long tookMs = System.currentTimeMillis() - start; + logger.info("[END] {} {} <- {}.{}() took={}ms", + httpMethod, uri, className, methodName, tookMs); + + return result; + } catch (Throwable ex) { + long tookMs = System.currentTimeMillis() - start; + + StackTraceElement[] st = ex.getStackTrace(); + String file = (st.length > 0) ? st[0].getFileName() : "unknown"; + int line = (st.length > 0) ? st[0].getLineNumber() : -1; + + logger.info("[END] {} {} !! {}.{}() at {}:{} took={}ms", + httpMethod, uri, className, methodName, file, line, tookMs); + + throw ex; // rethrow such a normal exception handling still applies + } + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/MetricsConfig.java b/src/main/java/com/isaactai/cloudnativeweb/config/MetricsConfig.java new file mode 100644 index 0000000..11b9990 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/MetricsConfig.java @@ -0,0 +1,18 @@ +package com.isaactai.cloudnativeweb.config; + +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author tisaac + */ +@Configuration +public class MetricsConfig { + + @Bean + public TimedAspect timedAspect(MeterRegistry registry) { + return new TimedAspect(registry); + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/SecurityConfig.java b/src/main/java/com/isaactai/cloudnativeweb/config/SecurityConfig.java index a68c58b..702c6d7 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/SecurityConfig.java +++ b/src/main/java/com/isaactai/cloudnativeweb/config/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -39,6 +40,22 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .headers(h -> h .frameOptions(fo -> fo.disable()) .xssProtection(x -> x.disable()) + ) + .exceptionHandling(e -> e + .authenticationEntryPoint((req, res, ex) -> { + req.setAttribute("access.label", "Security"); + req.setAttribute("error.expected", true); + req.setAttribute("error.code", "UNAUTHORIZED"); + req.setAttribute("error.message", "Missing or invalid credentials"); + res.sendError(HttpStatus.UNAUTHORIZED.value()); + }) + .accessDeniedHandler((req, res, ex) -> { + req.setAttribute("access.label", "Security"); + req.setAttribute("error.expected", true); + req.setAttribute("error.code", "FORBIDDEN"); + req.setAttribute("error.message", "Insufficient permissions"); + res.sendError(HttpStatus.FORBIDDEN.value()); + }) ); return http.build(); } diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java b/src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java new file mode 100644 index 0000000..dfe7345 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java @@ -0,0 +1,44 @@ +package com.isaactai.cloudnativeweb.config; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.util.function.Supplier; + +/** + * @author tisaac + */ +@Component +@RequiredArgsConstructor +public class TimedS3 { + private final S3Client s3; + private final MeterRegistry reg; + + private T timeS3Call(String metric, String bucket, Supplier call) { + Timer.Sample sample = Timer.start(reg); + try { + return call.get(); + } finally { + sample.stop(Timer.builder(metric) + .tag("bucket", bucket) + .register(reg)); + } + } + + public PutObjectResponse putObject(PutObjectRequest req, RequestBody body) { + return timeS3Call("s3.put.time", req.bucket(), () -> s3.putObject(req, body)); + } + + public DeleteObjectResponse deleteObject(DeleteObjectRequest req) { + return timeS3Call("s3.delete.time", req.bucket(), () -> s3.deleteObject(req)); + } + + public GetObjectResponse getObject(GetObjectRequest req, java.nio.file.Path dest) { + return timeS3Call("s3.get.time", req.bucket(), () -> s3.getObject(req, dest)); + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/WebMvcConfig.java b/src/main/java/com/isaactai/cloudnativeweb/config/WebMvcConfig.java new file mode 100644 index 0000000..85d2e8e --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.isaactai.cloudnativeweb.config; + +import com.isaactai.cloudnativeweb.logging.AccessNoteInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * @author tisaac + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final AccessNoteInterceptor accessNoteInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(accessNoteInterceptor) + .addPathPatterns("/**"); + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java index 2b15dea..4720a47 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java @@ -1,6 +1,9 @@ package com.isaactai.cloudnativeweb.health; import com.isaactai.cloudnativeweb.common.exception.BadRequestException; +import com.isaactai.cloudnativeweb.logging.AccessLog; +import com.isaactai.cloudnativeweb.logging.AccessNote; +import io.micrometer.core.annotation.Timed; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -26,17 +29,26 @@ public HealthController(HealthCheckService service, HealthProbeService healthPro // For general-purpose lightweight health checks under heavy load. // Inserts a new record into the database each time. // Does not return any response body. + @AccessNote( + label = "Health", + success = "Health check successful", + clientWarn = "Health Check failed", + serverError = "Unexpected error occurred" + ) + @Timed(value = "api.healthz", description = "Time taken to respond to /healthz requests") @GetMapping("/healthz") public ResponseEntity healthz( HttpServletRequest request, @RequestParam Map queryParams ) { if (!queryParams.isEmpty()) { + AccessLog.clientWarn(request, "Query parameters are not allowed"); return ResponseEntity.badRequest().build(); } try { if (request.getInputStream().read() != -1) { + AccessLog.clientWarn(request, "Request body is not allowed"); return ResponseEntity.badRequest().build(); } } catch (IOException e) { @@ -58,6 +70,10 @@ public ResponseEntity healthz( value = "/healthz", method = {RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.PATCH} ) + @AccessNote( + label = "Health", + clientWarn = "Health Check failed - Method not allowed" + ) public ResponseEntity healthzWrongMethod() { return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).build(); } @@ -66,6 +82,12 @@ public ResponseEntity healthzWrongMethod() { // Returns a JSON response with service and dependency status. // Does not insert a new record into the database. @GetMapping(value = "/api/health", produces = MediaType.APPLICATION_JSON_VALUE) + @AccessNote( + label = "Health", + success = "Health probe successful", + clientWarn = "Health Probe failed", + serverError = "Unexpected error occurred" + ) public ResponseEntity probe(HttpServletRequest request) { try { if (request.getInputStream().read() != -1) { diff --git a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java index d3c9d5d..7500d42 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java @@ -1,6 +1,8 @@ package com.isaactai.cloudnativeweb.image; import com.isaactai.cloudnativeweb.image.dto.ImageResponse; +import com.isaactai.cloudnativeweb.logging.AccessNote; +import io.micrometer.core.annotation.Timed; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -21,6 +23,13 @@ public class ImageController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) + @AccessNote( + label = "Image", + success = "Image uploaded successfully", + clientWarn = "Image upload failed", + serverError = "Unexpected error occurred during image upload" + ) + @Timed(value = "api.image.upload", description = "Time taken to upload an image") public ImageResponse uploadImage( @PathVariable("product_id") Long productId, @RequestParam("file") MultipartFile file, @@ -31,6 +40,13 @@ public ImageResponse uploadImage( @DeleteMapping("/{image_id}") @ResponseStatus(HttpStatus.NO_CONTENT) + @AccessNote( + label = "Image", + success = "Image deleted successfully", + clientWarn = "Image deletion failed", + serverError = "Unexpected error occurred during image deletion" + ) + @Timed(value = "api.image.delete", description = "Time taken to delete an image") public void deleteImage( @PathVariable("product_id") Long productId, @PathVariable("image_id") Long imageId, @@ -41,6 +57,13 @@ public void deleteImage( @GetMapping @ResponseStatus(HttpStatus.OK) + @AccessNote( + label = "Image", + success = "Images listed successfully", + clientWarn = "Image listing failed", + serverError = "Unexpected error occurred during image listing" + ) + @Timed(value = "api.image.list", description = "Time taken to list images for a product") public List listImages( @PathVariable("product_id") Long productId ) { @@ -49,6 +72,13 @@ public List listImages( @GetMapping("/{image_id}") @ResponseStatus(HttpStatus.OK) + @AccessNote( + label = "Image", + success = "Image retrieved successfully", + clientWarn = "Image retrieval failed", + serverError = "Unexpected error occurred during image retrieval" + ) + @Timed(value = "api.image.get", description = "Time taken to get image details") public ImageResponse getImage( @PathVariable("product_id") Long productId, @PathVariable("image_id") Long imageId diff --git a/src/main/java/com/isaactai/cloudnativeweb/image/ImageService.java b/src/main/java/com/isaactai/cloudnativeweb/image/ImageService.java index 6618fd3..e4b1eab 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageService.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageService.java @@ -2,6 +2,7 @@ import com.isaactai.cloudnativeweb.common.exception.BadRequestException; import com.isaactai.cloudnativeweb.common.exception.NotFoundException; +import com.isaactai.cloudnativeweb.config.TimedS3; import com.isaactai.cloudnativeweb.image.dto.ImageResponse; import com.isaactai.cloudnativeweb.image.exception.S3UploadException; import com.isaactai.cloudnativeweb.product.Product; @@ -15,7 +16,6 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; import java.io.IOException; @@ -33,8 +33,8 @@ public class ImageService { private final ImageRepository repo; private final UserService userService; private final ProductService prodService; + private final TimedS3 timedS3; - private final S3Client s3Client; // AWS SDK Client side (inject by Spring) @Value("${aws.s3.bucket}") // read bucket name from .env private String bucketName; @@ -59,7 +59,7 @@ public ImageResponse uploadProdImg(String name, Long productId, MultipartFile fi user.getId(), productId, UUID.randomUUID(), safeName); try { - s3Client.putObject( + timedS3.putObject( PutObjectRequest.builder() .bucket(bucketName) .key(key) @@ -103,7 +103,7 @@ public void deleteForUser(String username, Long productId, Long imageId) { } try { - s3Client.deleteObject(DeleteObjectRequest.builder() + timedS3.deleteObject(DeleteObjectRequest.builder() .bucket(bucketName) .key(img.getS3BucketPath()) .build()); diff --git a/src/main/java/com/isaactai/cloudnativeweb/logging/AccessLog.java b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessLog.java new file mode 100644 index 0000000..401a91c --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessLog.java @@ -0,0 +1,28 @@ +package com.isaactai.cloudnativeweb.logging; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * @author tisaac + */ +public class AccessLog { + private AccessLog() {} + + public static void label(HttpServletRequest req, String label) { + req.setAttribute("access.label", label); + } + public static void success(HttpServletRequest req, String msg) { + req.setAttribute("access.success", msg); + } + public static void clientWarn(HttpServletRequest req, String msg) { + // mark as expected client error with a custom message + req.setAttribute("error.expected", true); + req.setAttribute("error.message", msg); + } + public static void serverError(HttpServletRequest req, String msg, Throwable t) { + req.setAttribute("error.unexpected", true); + req.setAttribute("error.message", msg); + req.setAttribute("error.throwable", t); + if (t != null) req.setAttribute("error.exception", t.getClass().getSimpleName()); + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/logging/AccessNote.java b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessNote.java new file mode 100644 index 0000000..080f63a --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessNote.java @@ -0,0 +1,16 @@ +package com.isaactai.cloudnativeweb.logging; + +import java.lang.annotation.*; + +/** + * @author tisaac + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AccessNote { + String label() default ""; // e.g., ex. ("User", "Product", "Image", etc) + String success() default ""; // e.g., "User created" + String clientWarn() default ""; // e.g., "User action failed" (general) + String serverError() default ""; // e.g., "User service failed" +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/logging/AccessNoteInterceptor.java b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessNoteInterceptor.java new file mode 100644 index 0000000..98ea34d --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessNoteInterceptor.java @@ -0,0 +1,31 @@ +package com.isaactai.cloudnativeweb.logging; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * @author tisaac + */ +@Component +public class AccessNoteInterceptor implements HandlerInterceptor { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (handler instanceof HandlerMethod hm) { + AccessNote note = hm.getMethodAnnotation(AccessNote.class); + if (note != null) { + // Persist notes to request so AccessLogFilter can read later + request.setAttribute("access.label", note.label()); + request.setAttribute("access.success", note.success()); + request.setAttribute("access.clientWarn", note.clientWarn()); + request.setAttribute("access.serverError", note.serverError()); + } else { + // Reasonable defaults (optional) + request.setAttribute("access.label", hm.getMethod().getName()); + } + } + return true; + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java index d635432..6b13c95 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java @@ -1,9 +1,11 @@ package com.isaactai.cloudnativeweb.product; +import com.isaactai.cloudnativeweb.logging.AccessNote; import com.isaactai.cloudnativeweb.product.dto.ProductCreateRequest; import com.isaactai.cloudnativeweb.product.dto.ProductPatchRequest; import com.isaactai.cloudnativeweb.product.dto.ProductResponse; import com.isaactai.cloudnativeweb.product.dto.ProductUpdateRequest; +import io.micrometer.core.annotation.Timed; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -22,6 +24,13 @@ public class ProductController { @PostMapping @ResponseStatus(HttpStatus.CREATED) + @AccessNote( + label = "Product", + success = "Product created successfully", + clientWarn = "Product creation failed", + serverError = "Unexpected error occurred during product creation" + ) + @Timed(value = "api.product.create", description = "Time taken to create a new product") public ProductResponse create( @Valid @RequestBody ProductCreateRequest req, Authentication auth @@ -31,6 +40,13 @@ public ProductResponse create( @PutMapping("/{productId}") @ResponseStatus(HttpStatus.NO_CONTENT) + @AccessNote( + label = "Product", + success = "Product updated successfully", + clientWarn = "Product update failed", + serverError = "Unexpected error occurred during product update" + ) + @Timed(value = "api.product.update", description = "Time taken to update a product") public void updateProduct( @PathVariable Long productId, @Valid @RequestBody ProductUpdateRequest req, @@ -41,6 +57,13 @@ public void updateProduct( @PatchMapping("/{productId}") @ResponseStatus(HttpStatus.NO_CONTENT) + @AccessNote( + label = "Product", + success = "Product patched successfully", + clientWarn = "Product patch failed", + serverError = "Unexpected error occurred during product patch" + ) + @Timed(value = "api.product.patch", description = "Time taken to patch a product") public void patchProduct( @PathVariable Long productId, @Valid @RequestBody ProductPatchRequest req, @@ -51,6 +74,13 @@ public void patchProduct( @DeleteMapping("/{productId}") @ResponseStatus(HttpStatus.NO_CONTENT) + @AccessNote( + label = "Product", + success = "Product deleted successfully", + clientWarn = "Product deletion failed", + serverError = "Unexpected error occurred during product deletion" + ) + @Timed(value = "api.product.delete", description = "Time taken to delete a product") public void deleteProduct( @PathVariable Long productId, Authentication auth @@ -60,6 +90,13 @@ public void deleteProduct( @GetMapping("/{productId}") @ResponseStatus(HttpStatus.OK) + @AccessNote( + label = "Product", + success = "Product retrieved successfully", + clientWarn = "Product retrieval failed", + serverError = "Unexpected error occurred during product retrieval" + ) + @Timed(value = "api.product.get", description = "Time taken to retrieve a product") public ProductResponse getProduct(@PathVariable Long productId) { return service.getProduct(productId); } diff --git a/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java b/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java index bd7bda0..aba01d5 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java @@ -1,8 +1,10 @@ package com.isaactai.cloudnativeweb.user; +import com.isaactai.cloudnativeweb.logging.AccessNote; import com.isaactai.cloudnativeweb.user.dto.UserCreateRequest; import com.isaactai.cloudnativeweb.user.dto.UserResponse; import com.isaactai.cloudnativeweb.user.dto.UserUpdateRequest; +import io.micrometer.core.annotation.Timed; import jakarta.validation.Valid; import org.apache.coyote.Response; import org.springframework.http.HttpStatus; @@ -23,12 +25,26 @@ public UserController(UserService userService) { } @PostMapping() + @AccessNote( + label = "User", + success = "User created successfully", + clientWarn = "User create failed", + serverError = "Unexpected error occurred during user creation" + ) + @Timed(value = "api.user.create", description = "Time taken to create a new user") public ResponseEntity create(@Valid @RequestBody UserCreateRequest req) { UserResponse created = userService.createUser(req); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @PutMapping("/{userId}") + @AccessNote( + label = "User", + success = "User updated successfully", + clientWarn = "User update failed", + serverError = "Unexpected error occurred during user update" + ) + @Timed(value = "api.user.update", description = "Time taken to update a user") public ResponseEntity updateUser( @PathVariable int userId, @Valid @RequestBody UserUpdateRequest req, @@ -39,6 +55,13 @@ public ResponseEntity updateUser( } @GetMapping("/{userId}") + @AccessNote( + label = "User", + success = "User retrieved successfully", + clientWarn = "User retrieval failed", + serverError = "Unexpected error occurred during user retrieval" + ) + @Timed(value = "api.user.get", description = "Time taken to retrieve a user") public ResponseEntity getUser( @PathVariable int userId, Authentication auth) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9bfba22..a1ef0d0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,6 +25,16 @@ spring: deserialization: fail-on-unknown-properties: true +logging: + file: + name: ${APP_DIR}/log/app.log + level: + root: INFO + org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: OFF + org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver: OFF + pattern: + file: "%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger - %msg%n" + aws: region: ${AWS_REGION} s3: @@ -36,3 +46,20 @@ server: include-message: never include-binding-errors: never include-stacktrace: never + +management: + metrics: + export: + statsd: + enabled: true + flavor: etsy + host: 127.0.0.1 + port: 8125 + polling-frequency: 10s + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..73db8c6 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,37 @@ + + + + + + + ${APP_DIR}/log/app.log + true + + + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %msg%n + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/isaactai/cloudnativeweb/health/HealthControllerTest.java b/src/test/java/com/isaactai/cloudnativeweb/health/HealthControllerTest.java index 9a34fdc..a9c2631 100644 --- a/src/test/java/com/isaactai/cloudnativeweb/health/HealthControllerTest.java +++ b/src/test/java/com/isaactai/cloudnativeweb/health/HealthControllerTest.java @@ -29,6 +29,6 @@ void healthz_shouldRespondUnder200ms() { .when() .get("/healthz") .then() - .time(lessThan(600L)); + .time(lessThan(1000L)); } } diff --git a/src/test/java/com/isaactai/cloudnativeweb/product/ProductControllerTest.java b/src/test/java/com/isaactai/cloudnativeweb/product/ProductControllerTest.java index 41a3782..3971bc2 100644 --- a/src/test/java/com/isaactai/cloudnativeweb/product/ProductControllerTest.java +++ b/src/test/java/com/isaactai/cloudnativeweb/product/ProductControllerTest.java @@ -50,7 +50,7 @@ void setUpUser() { null, "Apple", 2); res = given() - .auth().basic(username, pwd) + .auth().preemptive().basic(username, pwd) .contentType("application/json") .body(bodyJson) .when() @@ -73,7 +73,7 @@ void createProduct_success() { null, "Apple", 5); given() - .auth().basic(username, pwd) + .auth().preemptive().basic(username, pwd) .contentType("application/json") .body(bodyJson) .when() @@ -93,7 +93,7 @@ void createProduct_invalidQuantity_returns400() { String json = productJson("BadProduct", "Invalid Quantity", null, "Microsoft", 111); given() - .auth().basic(username, pwd) + .auth().preemptive().basic(username, pwd) .contentType("application/json") .body(json) .when() @@ -106,13 +106,13 @@ void createProduct_invalidQuantity_returns400() { void createProduct_duplicateSku_returns400() { String dupSku = "sku-" + System.currentTimeMillis(); String body1 = productJson("BadProduct", "Invalid Quantity", dupSku, "Microsoft", 11); - given().auth().basic(username, pwd) + given().auth().preemptive().basic(username, pwd) .contentType("application/json").body(body1) .when().post("/v1/product") .then().statusCode(201); String body2 = productJson("iPhone2", "Another", dupSku, "Apple", 2); - given().auth().basic(username, pwd) + given().auth().preemptive().basic(username, pwd) .contentType("application/json").body(body2) .when().post("/v1/product") .then().statusCode(400); @@ -211,7 +211,7 @@ void updateProduct_fullPut_success() { null, "Apple", 10); given() - .auth().basic(username, pwd) + .auth().preemptive().basic(username, pwd) .contentType("application/json") .body(newProductJson) .when() @@ -240,7 +240,7 @@ void updateProduct_partialPatch_quantity_only_success() { }""".formatted(quantity); given() - .auth().basic(username, pwd) + .auth().preemptive().basic(username, pwd) .contentType("application/json") .body(patchJson) .when() diff --git a/src/test/resources/application-ci.yml b/src/test/resources/application-ci.yml index e1425ce..f13cb80 100644 --- a/src/test/resources/application-ci.yml +++ b/src/test/resources/application-ci.yml @@ -4,6 +4,8 @@ spring: maximum-pool-size: 4 connection-timeout: ${DB_CONN_TIMEOUT_MS:5000} +APP_DIR: ${java.io.tmpdir}/csye6225 + aws: s3: bucket: dummy-bucket