From 862463e78050842283bab4a4d691ec3fc932c6b3 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Wed, 29 Oct 2025 21:53:44 -0400 Subject: [PATCH 01/15] feat(logging): implement request logging with AOP and access filter - Added LoggingAspect to record API entry and exit (method, URI, duration) - Added AccessLogFilter to classify and log final request results: - INFO for successful 2xx responses - WARN for expected 4xx client errors - ERROR for unexpected 5xx server errors with stack traces --- pom.xml | 5 ++ .../config/AccessLogFilter.java | 56 ++++++++++++++ .../config/ApiExceptionHandler.java | 18 +++++ .../cloudnativeweb/config/LoggingAspect.java | 77 +++++++++++++++++++ src/main/resources/application.yml | 10 +++ 5 files changed, 166 insertions(+) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java diff --git a/pom.xml b/pom.xml index 5a71526..73efbcc 100644 --- a/pom.xml +++ b/pom.xml @@ -126,6 +126,11 @@ s3 2.25.60 + + + org.springframework.boot + spring-boot-starter-aop + 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..5791816 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java @@ -0,0 +1,56 @@ +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.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * @author tisaac + */ +@Component +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(); + String exName = String.valueOf(req.getAttribute("error.exception")); + String msg = String.valueOf(req.getAttribute("error.message")); + + if (status >= 500) { + Throwable t = (Throwable) req.getAttribute("error.throwable"); + + if (t != null) { + log.error("{} {} took={}ms err={} msg={}", method, uri, took, exName, msg, t); // full stack info + } else { + log.error("{} {} took={}ms", method, uri, took); + } + } else if (status >= 400) { + boolean expected = Boolean.TRUE.equals(req.getAttribute("error.expected")); + String code = String.valueOf(req.getAttribute("error.code")); + + if (expected && code != null && msg != null) { + log.warn("{} {} took={}ms code={} err={} msg={}", method, uri, took, code, exName, msg); // no stack + } else { + log.warn("{} {} took={}ms", method, uri, took); + } + } else { + log.info("{} {} took={}ms", method, uri, took); + } + } + } +} 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..7489028 --- /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 ex={}", + httpMethod, uri, className, methodName, file, line, tookMs, ex.toString()); + + throw ex; // rethrow such a normal exception handling still applies + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9bfba22..6add495 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: ${LOG_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: From 5d5f6d19acb78ab3290cb97063a0d61ad02b7558 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Wed, 29 Oct 2025 22:37:59 -0400 Subject: [PATCH 02/15] feat(logging): support detailed error messages in AccessLogFilter - Updated AccessLogFilter to merge general @AccessNote messages with per-request details (e.g., "Health Check failed - Query parameters are not allowed") - Added helper to safely combine messages for 4xx and 5xx cases - Ensured consistent formatting across success, warning, and error logs --- .../config/AccessLogFilter.java | 52 ++++++++++++++----- .../cloudnativeweb/config/WebMvcConfig.java | 22 ++++++++ .../health/HealthController.java | 10 ++++ .../cloudnativeweb/logging/AccessLog.java | 28 ++++++++++ .../cloudnativeweb/logging/AccessNote.java | 16 ++++++ .../logging/AccessNoteInterceptor.java | 31 +++++++++++ 6 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/WebMvcConfig.java create mode 100644 src/main/java/com/isaactai/cloudnativeweb/logging/AccessLog.java create mode 100644 src/main/java/com/isaactai/cloudnativeweb/logging/AccessNote.java create mode 100644 src/main/java/com/isaactai/cloudnativeweb/logging/AccessNoteInterceptor.java diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java index 5791816..68ba156 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java +++ b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java @@ -26,31 +26,59 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, long took = System.currentTimeMillis() - start; int status = res.getStatus(); var log = LoggerFactory.getLogger("ACCESS"); - String method = req.getMethod(); - String uri = req.getRequestURI(); - String exName = String.valueOf(req.getAttribute("error.exception")); - String msg = String.valueOf(req.getAttribute("error.message")); + + String method = req.getMethod(); + String uri = req.getRequestURI(); + String label = String.valueOf(req.getAttribute("access.label")); + String exName = String.valueOf(req.getAttribute("error.exception")); + + String msgSuccess = String.valueOf(req.getAttribute("access.success")); // success (general or override) + String msgWarnGen = String.valueOf(req.getAttribute("access.clientWarn")); // general warn from @AccessNote + String msgErrGen = String.valueOf(req.getAttribute("access.serverError")); // general error from @AccessNote + String msgOverride= String.valueOf(req.getAttribute("error.message")); // detailed reason set per-request if (status >= 500) { + // 5xx → ERROR with stack trace (if available) Throwable t = (Throwable) req.getAttribute("error.throwable"); - + // combine general + detailed + String msg = combine(msgErrGen, msgOverride); if (t != null) { - log.error("{} {} took={}ms err={} msg={}", method, uri, took, exName, msg, t); // full stack info + log.error("{} {} [{}] took={}ms err={} msg={}", method, uri, label, took, exName, msg, t); } else { - log.error("{} {} took={}ms", method, uri, took); + log.error("{} {} [{}] took={}ms err={} msg={}", method, uri, label, took, exName, msg); } + } else if (status >= 400) { + // 4xx → WARN (expected client error; no stack) boolean expected = Boolean.TRUE.equals(req.getAttribute("error.expected")); - String code = String.valueOf(req.getAttribute("error.code")); + String code = String.valueOf(req.getAttribute("error.code")); + // combine general + detailed + String msg = combine(msgWarnGen, msgOverride); - if (expected && code != null && msg != null) { - log.warn("{} {} took={}ms code={} err={} msg={}", method, uri, took, code, exName, msg); // no stack + if (expected && code != null && msg != null && !msg.isBlank()) { + log.warn("{} {} [{}] took={}ms code={} err={} msg={}", method, uri, label, took, code, exName, msg); + } else if (msg != null && !msg.isBlank()) { + log.warn("{} {} [{}] took={}ms msg={}", method, uri, label, took, msg); } else { - log.warn("{} {} took={}ms", method, uri, took); + log.warn("{} {} [{}] took={}ms", method, uri, label, took); } + } else { - log.info("{} {} took={}ms", method, uri, took); + // 2xx → INFO + if (msgSuccess != null && !msgSuccess.isBlank()) { + log.info("{} {} [{}] took={}ms msg={}", method, uri, label, took, msgSuccess); + } else { + log.info("{} {} [{}] took={}ms", method, uri, label, 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 + } + } 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..9ddbddf 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java @@ -1,6 +1,8 @@ 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 jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -26,17 +28,25 @@ 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" + ) @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) { 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; + } +} From 142868aa36426f15e0865800f3c0cc3b24b52edf Mon Sep 17 00:00:00 2001 From: Isaac T Date: Wed, 29 Oct 2025 23:08:37 -0400 Subject: [PATCH 03/15] feat(log): handle security rejections (401 & 403) in AccessLogFilter Add fallback handling in AccessLogFilter for requests blocked by Spring Security. Ensures 401 (Unauthorized) and 403 (Forbidden) responses are logged with meaningful default messages and codes even when Security filters bypass controller logic. - Added safe attribute reader to avoid literal "null" in logs --- .../config/AccessLogFilter.java | 97 +++++++++++++------ .../cloudnativeweb/config/SecurityConfig.java | 17 ++++ 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java index 68ba156..25e509e 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java +++ b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java @@ -5,6 +5,8 @@ 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; @@ -14,6 +16,7 @@ * @author tisaac */ @Component +@Order(Ordered.HIGHEST_PRECEDENCE) public class AccessLogFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) @@ -23,52 +26,69 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, try { chain.doFilter(req, res); } finally { - long took = System.currentTimeMillis() - start; - int status = res.getStatus(); - var log = LoggerFactory.getLogger("ACCESS"); + long took = System.currentTimeMillis() - start; + int status = res.getStatus(); + var log = LoggerFactory.getLogger("ACCESS"); - String method = req.getMethod(); - String uri = req.getRequestURI(); - String label = String.valueOf(req.getAttribute("access.label")); - String exName = String.valueOf(req.getAttribute("error.exception")); + String method = req.getMethod(); + String uri = req.getRequestURI(); - String msgSuccess = String.valueOf(req.getAttribute("access.success")); // success (general or override) - String msgWarnGen = String.valueOf(req.getAttribute("access.clientWarn")); // general warn from @AccessNote - String msgErrGen = String.valueOf(req.getAttribute("access.serverError")); // general error from @AccessNote - String msgOverride= String.valueOf(req.getAttribute("error.message")); // detailed reason set per-request + // --- 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) { - // 5xx → ERROR with stack trace (if available) - Throwable t = (Throwable) req.getAttribute("error.throwable"); - // combine general + detailed String msg = combine(msgErrGen, msgOverride); + Throwable t = (Throwable) req.getAttribute("error.throwable"); if (t != null) { - log.error("{} {} [{}] took={}ms err={} msg={}", method, uri, label, took, exName, msg, t); + log.error("{} {} [{}] took={}ms{}{}", + method, uri, labelFinal, took, + nonBlank(" err=", exName), + nonBlank(" msg=", msg), + t); } else { - log.error("{} {} [{}] took={}ms err={} msg={}", method, uri, label, took, exName, msg); + log.error("{} {} [{}] took={}ms{}{}", + method, uri, labelFinal, took, + nonBlank(" err=", exName), + nonBlank(" msg=", msg)); } } else if (status >= 400) { - // 4xx → WARN (expected client error; no stack) boolean expected = Boolean.TRUE.equals(req.getAttribute("error.expected")); - String code = String.valueOf(req.getAttribute("error.code")); - // combine general + detailed String msg = combine(msgWarnGen, msgOverride); - if (expected && code != null && msg != null && !msg.isBlank()) { - log.warn("{} {} [{}] took={}ms code={} err={} msg={}", method, uri, label, took, code, exName, msg); - } else if (msg != null && !msg.isBlank()) { - log.warn("{} {} [{}] took={}ms msg={}", method, uri, label, took, msg); + 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, label, took); + log.warn("{} {} [{}] took={}ms", method, uri, labelFinal, took); } } else { - // 2xx → INFO - if (msgSuccess != null && !msgSuccess.isBlank()) { - log.info("{} {} [{}] took={}ms msg={}", method, uri, label, took, msgSuccess); + if (isNotBlank(msgSuccess)) { + log.info("{} {} [{}] took={}ms msg={}", method, uri, labelFinal, took, msgSuccess); } else { - log.info("{} {} [{}] took={}ms", method, uri, label, took); + log.info("{} {} [{}] took={}ms", method, uri, labelFinal, took); } } } @@ -81,4 +101,25 @@ private static String combine(String general, String override) { 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; + } } 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(); } From 2ebb35e553ecf2020992a7cf5e50955233f6f9db Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 00:07:18 -0400 Subject: [PATCH 04/15] feat(logging): add detailed messages for each controller endpoint --- .../config/AccessLogFilter.java | 13 +++++++- .../cloudnativeweb/config/LoggingAspect.java | 4 +-- .../health/HealthController.java | 10 ++++++ .../cloudnativeweb/image/ImageController.java | 25 +++++++++++++++ .../product/ProductController.java | 31 +++++++++++++++++++ .../cloudnativeweb/user/UserController.java | 19 ++++++++++++ 6 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java index 25e509e..ee545dd 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java +++ b/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java @@ -57,11 +57,15 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, 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=", msg), + nonBlank(" msg=", inlineMsgShort), t); } else { log.error("{} {} [{}] took={}ms{}{}", @@ -122,4 +126,11 @@ private static String firstNonBlank(String... ss) { 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/LoggingAspect.java b/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java index 7489028..112ef33 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java +++ b/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java @@ -68,8 +68,8 @@ public Object logAround(ProceedingJoinPoint pjp) throws Throwable { String file = (st.length > 0) ? st[0].getFileName() : "unknown"; int line = (st.length > 0) ? st[0].getLineNumber() : -1; - logger.info("[END] {} {} !! {}.{}() at {}:{} took={}ms ex={}", - httpMethod, uri, className, methodName, file, line, tookMs, ex.toString()); + 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/health/HealthController.java b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java index 9ddbddf..8dac3ed 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java @@ -68,6 +68,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(); } @@ -76,6 +80,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..afdcbff 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java @@ -1,6 +1,7 @@ package com.isaactai.cloudnativeweb.image; import com.isaactai.cloudnativeweb.image.dto.ImageResponse; +import com.isaactai.cloudnativeweb.logging.AccessNote; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -21,6 +22,12 @@ 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" + ) public ImageResponse uploadImage( @PathVariable("product_id") Long productId, @RequestParam("file") MultipartFile file, @@ -31,6 +38,12 @@ 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" + ) public void deleteImage( @PathVariable("product_id") Long productId, @PathVariable("image_id") Long imageId, @@ -41,6 +54,12 @@ 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" + ) public List listImages( @PathVariable("product_id") Long productId ) { @@ -49,6 +68,12 @@ 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" + ) public ImageResponse getImage( @PathVariable("product_id") Long productId, @PathVariable("image_id") Long imageId diff --git a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java index d635432..c56ba8a 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java @@ -1,5 +1,6 @@ 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; @@ -22,6 +23,12 @@ 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" + ) public ProductResponse create( @Valid @RequestBody ProductCreateRequest req, Authentication auth @@ -31,6 +38,12 @@ 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" + ) public void updateProduct( @PathVariable Long productId, @Valid @RequestBody ProductUpdateRequest req, @@ -41,6 +54,12 @@ 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" + ) public void patchProduct( @PathVariable Long productId, @Valid @RequestBody ProductPatchRequest req, @@ -51,6 +70,12 @@ 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" + ) public void deleteProduct( @PathVariable Long productId, Authentication auth @@ -60,6 +85,12 @@ 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" + ) 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..ce75e55 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java @@ -1,5 +1,6 @@ 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; @@ -23,12 +24,24 @@ public UserController(UserService userService) { } @PostMapping() + @AccessNote( + label = "User", + success = "User created successfully", + clientWarn = "User create failed", + serverError = "Unexpected error occurred during user creation" + ) 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" + ) public ResponseEntity updateUser( @PathVariable int userId, @Valid @RequestBody UserUpdateRequest req, @@ -39,6 +52,12 @@ public ResponseEntity updateUser( } @GetMapping("/{userId}") + @AccessNote( + label = "User", + success = "User retrieved successfully", + clientWarn = "User retrieval failed", + serverError = "Unexpected error occurred during user retrieval" + ) public ResponseEntity getUser( @PathVariable int userId, Authentication auth) { From 96e6775d352a247eae1483d7afe94ca1688ff622 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 01:17:50 -0400 Subject: [PATCH 05/15] feat(logback): configure JSON and console appenders with separate formats - Added logback-spring.xml to define structured JSON output for file logs - Configured STDOUT appender with human-readable pattern for local debugging - Introduced ACCESS logger for AccessLogFilter to log independently from root logger - Enabled to dynamically read LOG_DIR from Spring environment --- .gitignore | 4 +++ pom.xml | 6 +++++ src/main/resources/logback-spring.xml | 37 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 src/main/resources/logback-spring.xml 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/pom.xml b/pom.xml index 73efbcc..11415dd 100644 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,12 @@ org.springframework.boot spring-boot-starter-aop + + + net.logstash.logback + logstash-logback-encoder + 7.4 + diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..6a02361 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,37 @@ + + + + + + + ${LOG_DIR}/log/app.log + true + + + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %msg%n + + + + + + + + + + + + + + \ No newline at end of file From 7294335f8c1466934bdeffdc595facbbfb5d7daf Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 02:12:49 -0400 Subject: [PATCH 06/15] feat(cloudwatch): install and configure Amazon CloudWatch Agent with custom logging --- packer/amazon-cloudwatch-agent.json | 59 ++++++++++++++++++++++++++ packer/builds.pkr.hcl | 66 +++++++++++++++++++++++++++++ scripts/setup.sh | 2 +- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packer/amazon-cloudwatch-agent.json diff --git a/packer/amazon-cloudwatch-agent.json b/packer/amazon-cloudwatch-agent.json new file mode 100644 index 0000000..7ece4e4 --- /dev/null +++ b/packer/amazon-cloudwatch-agent.json @@ -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": "{{LOG_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": "{{LOG_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..2ef4cb1 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -94,6 +94,72 @@ EOC ] } + # ------------------------------------ + # Install and configure Amazon CloudWatch Unified Agent + # ------------------------------------ + provisioner "shell" { + inline_shebang = "/bin/bash" + inline = [ + "set -euo pipefail", + "echo '[INFO] Installing Amazon CloudWatch Agent...'", + + # Download and install (Ubuntu version) + "curl -fsSL -o /tmp/amazon-cloudwatch-agent.deb https://s3.amazonaws.com/amazoncloudwatch-agent/ubuntu/amd64/latest/amazon-cloudwatch-agent.deb", + "sudo dpkg -i /tmp/amazon-cloudwatch-agent.deb", + + # Create configuration directory + "sudo mkdir -p /opt/aws/amazon-cloudwatch-agent/etc", + ] + } + + # Copy your custom CloudWatch Agent config into the AMI + provisioner "file" { + source = "${path.root}/amazon-cloudwatch-agent.json" + destination = "/tmp/amazon-cloudwatch-agent.json" + } + + provisioner "shell" { + inline_shebang = "/bin/bash" + environment_vars = [ + "B_APP_DIR=${var.shell_env.app_dir}", + "B_SERVICE_NAME=${var.shell_env.service_name}" + ] + inline = [ + "echo '[INFO] Copying CloudWatch Agent config...'", + "sed -e 's|{{LOG_DIR}}|${B_APP_DIR}|g' -e 's|{{SERVICE_NAME}}|${B_SERVICE_NAME}|g' /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", + + # Enable the agent service for auto-start + "sudo systemctl enable amazon-cloudwatch-agent", + "echo '[INFO] CloudWatch Agent installed and enabled successfully.'" + ] + } + + # ------------------------------------ + # 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 = [ + "echo '[INFO] Ensuring log directory exists...'", + "sudo mkdir -p \"${B_APP_DIR}/log\"", + "sudo chown -R \"${B_APP_USER}:${B_APP_GROUP}\" \"${B_APP_DIR}\"", + "sudo chmod 0755 \"${B_APP_DIR}\"", + + "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/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() { From 94c01c9458907b0a22cc676751afdc9fd9db7b89 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 03:39:22 -0400 Subject: [PATCH 07/15] feat(packer): render CloudWatch Agent config using templatefile --- ...json => amazon-cloudwatch-agent.json.tmpl} | 12 +++--- packer/builds.pkr.hcl | 38 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) rename packer/{amazon-cloudwatch-agent.json => amazon-cloudwatch-agent.json.tmpl} (80%) diff --git a/packer/amazon-cloudwatch-agent.json b/packer/amazon-cloudwatch-agent.json.tmpl similarity index 80% rename from packer/amazon-cloudwatch-agent.json rename to packer/amazon-cloudwatch-agent.json.tmpl index 7ece4e4..b5eeb88 100644 --- a/packer/amazon-cloudwatch-agent.json +++ b/packer/amazon-cloudwatch-agent.json.tmpl @@ -9,15 +9,15 @@ "files": { "collect_list": [ { - "file_path": "{{LOG_DIR}}/log/app.log", - "log_group_name": "/{{SERVICE_NAME}}/app", + "file_path": "${LOG_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": "{{LOG_DIR}}/log/access.log", - "log_group_name": "/{{SERVICE_NAME}}/access", + "file_path": "${LOG_DIR}/log/access.log", + "log_group_name": "/${SERVICE_NAME}/access", "log_stream_name": "{instance_id}", "retention_in_days": 14, "multi_line_start_pattern": "^{" @@ -31,8 +31,8 @@ "metrics": { "append_dimensions": { - "InstanceId": "${aws:InstanceId}", - "AutoScalingGroupName": "${aws:AutoScalingGroupName}" + "InstanceId": "$${aws:InstanceId}", + "AutoScalingGroupName": "$${aws:AutoScalingGroupName}" }, "metrics_collected": { "cpu": { diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index 2ef4cb1..8cb3cfc 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", { + LOG_DIR = var.shell_env.app_dir + SERVICE_NAME = var.shell_env.service_name + }) +} + build { name = "csye6225-webapp-image" sources = ["source.amazon-ebs.ubuntu"] @@ -112,27 +120,23 @@ EOC ] } - # Copy your custom CloudWatch Agent config into the AMI - provisioner "file" { - source = "${path.root}/amazon-cloudwatch-agent.json" - destination = "/tmp/amazon-cloudwatch-agent.json" - } + + # Write the rendered JSON content into a temporary file on the target instance provisioner "shell" { inline_shebang = "/bin/bash" - environment_vars = [ - "B_APP_DIR=${var.shell_env.app_dir}", - "B_SERVICE_NAME=${var.shell_env.service_name}" + inline = [ + "cat > /tmp/amazon-cloudwatch-agent.json <<'EOF'\n${local.cwagent_config}\nEOF" ] + } + + # Move the rendered config file to the official CloudWatch Agent directory + provisioner "shell" { inline = [ - "echo '[INFO] Copying CloudWatch Agent config...'", - "sed -e 's|{{LOG_DIR}}|${B_APP_DIR}|g' -e 's|{{SERVICE_NAME}}|${B_SERVICE_NAME}|g' /tmp/amazon-cloudwatch-agent.json > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json", + "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", - - # Enable the agent service for auto-start - "sudo systemctl enable amazon-cloudwatch-agent", - "echo '[INFO] CloudWatch Agent installed and enabled successfully.'" + "sudo systemctl enable amazon-cloudwatch-agent" ] } @@ -148,9 +152,9 @@ EOC ] inline = [ "echo '[INFO] Ensuring log directory exists...'", - "sudo mkdir -p \"${B_APP_DIR}/log\"", - "sudo chown -R \"${B_APP_USER}:${B_APP_GROUP}\" \"${B_APP_DIR}\"", - "sudo chmod 0755 \"${B_APP_DIR}\"", + "sudo mkdir -p \"$B_APP_DIR/log\"", + "sudo chown -R \"$B_APP_USER:$B_APP_GROUP\" \"$B_APP_DIR\"", + "sudo chmod 0755 \"$B_APP_DIR\"", "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", From d652b7749b1ff7f99b4a02d0165d58748dff2edd Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 03:56:26 -0400 Subject: [PATCH 08/15] test(auth): switch to preemptive basic auth to fix 401 Unauthorized during CI - The server no longer includes a WWW-Authenticate challenge header, so RestAssured's passive basic() auth was not retrying after 401. Using preemptive().basic() ensures credentials are sent on the first request --- .../product/ProductControllerTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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() From ac343c18992860e6dbe85c222ac54862e958cb5b Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 03:58:15 -0400 Subject: [PATCH 09/15] feat(logging): update log file paths to use APP_DIR instead of LOG_DIR --- packer/amazon-cloudwatch-agent.json.tmpl | 4 ++-- packer/builds.pkr.hcl | 2 +- src/main/resources/application.yml | 2 +- src/main/resources/logback-spring.xml | 6 +++--- src/test/resources/application-ci.yml | 2 ++ 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packer/amazon-cloudwatch-agent.json.tmpl b/packer/amazon-cloudwatch-agent.json.tmpl index b5eeb88..3f83d14 100644 --- a/packer/amazon-cloudwatch-agent.json.tmpl +++ b/packer/amazon-cloudwatch-agent.json.tmpl @@ -9,14 +9,14 @@ "files": { "collect_list": [ { - "file_path": "${LOG_DIR}/log/app.log", + "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": "${LOG_DIR}/log/access.log", + "file_path": "${APP_DIR}/log/access.log", "log_group_name": "/${SERVICE_NAME}/access", "log_stream_name": "{instance_id}", "retention_in_days": 14, diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index 8cb3cfc..82ad86b 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -1,7 +1,7 @@ # Render the CloudWatch Agent config on the Packer side (before upload) locals { cwagent_config = templatefile("${path.root}/amazon-cloudwatch-agent.json.tmpl", { - LOG_DIR = var.shell_env.app_dir + APP_DIR = var.shell_env.app_dir SERVICE_NAME = var.shell_env.service_name }) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6add495..99a0a22 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,7 +27,7 @@ spring: logging: file: - name: ${LOG_DIR}/log/app.log + name: ${APP_DIR}/log/app.log level: root: INFO org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: OFF diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 6a02361..73db8c6 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,10 +1,10 @@ - - + + - ${LOG_DIR}/log/app.log + ${APP_DIR}/log/app.log true 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 From 4c2fc4c35031e7f8b09e5e51dc9f4501a78be05f Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 10:00:21 -0400 Subject: [PATCH 10/15] feat(packer): add APP_DIR environment variable to app.env configuration --- packer/builds.pkr.hcl | 1 + 1 file changed, 1 insertion(+) diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index 82ad86b..2514811 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -58,6 +58,7 @@ EOC cat > /tmp/app.env < Date: Thu, 30 Oct 2025 13:27:28 -0400 Subject: [PATCH 11/15] feat(metrics): enable actuator metrics and add timed S3 instrumentation - Added Spring Boot Actuator dependency - Exposed `health`, `info`, and `metrics` endpoints in application.yml - Introduced timedS3 wrapper to record S3 operation latency using Micrometer - Enhanced Packer build with step-by-step validation to ensure each provisioner succeeds --- packer/builds.pkr.hcl | 17 ++++++- pom.xml | 12 +++++ .../cloudnativeweb/config/MetricsConfig.java | 18 ++++++++ .../cloudnativeweb/config/TimedS3.java | 44 +++++++++++++++++++ .../health/HealthController.java | 2 + .../cloudnativeweb/image/ImageController.java | 5 +++ .../cloudnativeweb/image/ImageService.java | 8 ++-- .../product/ProductController.java | 6 +++ .../cloudnativeweb/user/UserController.java | 4 ++ src/main/resources/application.yml | 17 +++++++ 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/MetricsConfig.java create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index 2514811..653b2a0 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -127,13 +127,27 @@ EOC provisioner "shell" { inline_shebang = "/bin/bash" inline = [ - "cat > /tmp/amazon-cloudwatch-agent.json <<'EOF'\n${local.cwagent_config}\nEOF" + "set -euo pipefail", + "echo '[INFO] Generating cloudwatch agent file...'", + <<-EOC +cat > /tmp/amazon-cloudwatch-agent.json <logstash-logback-encoder 7.4 + + + io.micrometer + micrometer-registry-statsd + + + + net.ttddyy.observation + datasource-micrometer-spring-boot + 1.2.0 + + 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/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/health/HealthController.java b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java index 8dac3ed..4720a47 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java @@ -3,6 +3,7 @@ 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; @@ -34,6 +35,7 @@ public HealthController(HealthCheckService service, HealthProbeService healthPro 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, diff --git a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java index afdcbff..7500d42 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java @@ -2,6 +2,7 @@ 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; @@ -28,6 +29,7 @@ public class ImageController { 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, @@ -44,6 +46,7 @@ public ImageResponse uploadImage( 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, @@ -60,6 +63,7 @@ public void deleteImage( 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 ) { @@ -74,6 +78,7 @@ public List listImages( 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/product/ProductController.java b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java index c56ba8a..6b13c95 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java @@ -5,6 +5,7 @@ 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; @@ -29,6 +30,7 @@ public class ProductController { 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 @@ -44,6 +46,7 @@ public ProductResponse create( 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, @@ -60,6 +63,7 @@ public void updateProduct( 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, @@ -76,6 +80,7 @@ public void patchProduct( 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 @@ -91,6 +96,7 @@ public void deleteProduct( 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 ce75e55..aba01d5 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java @@ -4,6 +4,7 @@ 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; @@ -30,6 +31,7 @@ public UserController(UserService userService) { 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); @@ -42,6 +44,7 @@ public ResponseEntity create(@Valid @RequestBody UserCreateRequest 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, @@ -58,6 +61,7 @@ public ResponseEntity updateUser( 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 99a0a22..a1ef0d0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,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 From e43bd7abea7d68664a6100c8cdef769876c12ae3 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 13:58:41 -0400 Subject: [PATCH 12/15] fix(packer): use quoted heredoc to prevent variable expansion in CloudWatch config --- packer/builds.pkr.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index 653b2a0..3ed4bd9 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -130,7 +130,7 @@ EOC "set -euo pipefail", "echo '[INFO] Generating cloudwatch agent file...'", <<-EOC -cat > /tmp/amazon-cloudwatch-agent.json < /tmp/amazon-cloudwatch-agent.json <<'EOF' ${local.cwagent_config} EOF EOC From 154b5c8dcfb036c3edfc03ca676a9e3715049a52 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 14:02:24 -0400 Subject: [PATCH 13/15] test(health): increase response time threshold to 1000ms in HealthControllerTest --- .../isaactai/cloudnativeweb/health/HealthControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)); } } From 5302118bbea21ecc99107a15e65438c39502ee57 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 14:12:14 -0400 Subject: [PATCH 14/15] feat(packer): use /bin/bash for all provisioner --- packer/builds.pkr.hcl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index 3ed4bd9..c769082 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -121,8 +121,6 @@ EOC ] } - - # Write the rendered JSON content into a temporary file on the target instance provisioner "shell" { inline_shebang = "/bin/bash" @@ -146,6 +144,7 @@ EOC # 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", From d83aa7922de587bf4fcb279dd2627c327ef594ff Mon Sep 17 00:00:00 2001 From: Isaac T Date: Thu, 30 Oct 2025 14:25:10 -0400 Subject: [PATCH 15/15] fix(packer): only create log folder - Let the script do it at once --- packer/builds.pkr.hcl | 2 -- 1 file changed, 2 deletions(-) diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index c769082..bd7c087 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -168,8 +168,6 @@ EOC "set -euo pipefail", "echo '[INFO] Ensuring log directory exists...'", "sudo mkdir -p \"$B_APP_DIR/log\"", - "sudo chown -R \"$B_APP_USER:$B_APP_GROUP\" \"$B_APP_DIR\"", - "sudo chmod 0755 \"$B_APP_DIR\"", "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",