From 8acd7152db540fec714071f246a3b91def52809a Mon Sep 17 00:00:00 2001 From: Isaac T Date: Mon, 3 Nov 2025 18:47:03 -0500 Subject: [PATCH 1/3] feat(cloudwatch): enable statsd listener and backup agent config - Added "statsd" block to CloudWatch agent config to open UDP port 8125 for Micrometer metrics - Enabled metrics collection interval for StatsD listener - Created backup copy of /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json as .bk --- ...udwatch-agent.json.tmpl => amazon-cloudwatch-agent.json} | 6 +++++- packer/builds.pkr.hcl | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) rename packer/{amazon-cloudwatch-agent.json.tmpl => amazon-cloudwatch-agent.json} (91%) diff --git a/packer/amazon-cloudwatch-agent.json.tmpl b/packer/amazon-cloudwatch-agent.json similarity index 91% rename from packer/amazon-cloudwatch-agent.json.tmpl rename to packer/amazon-cloudwatch-agent.json index 3f83d14..44adcd9 100644 --- a/packer/amazon-cloudwatch-agent.json.tmpl +++ b/packer/amazon-cloudwatch-agent.json @@ -3,7 +3,6 @@ "metrics_collection_interval": 60, "logfile": "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log" }, - "logs": { "logs_collected": { "files": { @@ -30,6 +29,7 @@ }, "metrics": { + "namespace": "CSYE6225_WebApp", "append_dimensions": { "InstanceId": "$${aws:InstanceId}", "AutoScalingGroupName": "$${aws:AutoScalingGroupName}" @@ -53,6 +53,10 @@ {"name": "disk_used_percent", "rename": "DiskUsage", "unit": "Percent"} ], "metrics_collection_interval": 300 + }, + "statsd": { + "service_address": ":8125", + "metrics_collection_interval": 60 } } } diff --git a/packer/builds.pkr.hcl b/packer/builds.pkr.hcl index bd7c087..2b7f0dc 100644 --- a/packer/builds.pkr.hcl +++ b/packer/builds.pkr.hcl @@ -148,6 +148,7 @@ EOC inline = [ "set -euo pipefail", "sudo mv /tmp/amazon-cloudwatch-agent.json /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json", + "sudo cp /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json.bk", "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" From 35581c92eab4f48385e9b8af32a210e4ac5658d4 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Mon, 3 Nov 2025 21:54:57 -0500 Subject: [PATCH 2/3] feat(metrics): add custom annotation for API resource tagging and remove redundant @Timed - Introduced @ApiResourceTag annotation to define custom metric tags - Implemented CustomObservationFilter to inject "resource" and "custom_tag" - Removed existing @Timed annotations to avoid duplicate metrics with different names --- ...json => amazon-cloudwatch-agent.json.tmpl} | 0 .../cloudnativeweb/config/ApiResourceTag.java | 16 ++++++ .../config/CustomObservationFilter.java | 53 +++++++++++++++++++ .../health/HealthController.java | 1 - .../cloudnativeweb/image/ImageController.java | 6 +-- .../product/ProductController.java | 7 +-- .../cloudnativeweb/user/UserController.java | 5 +- 7 files changed, 75 insertions(+), 13 deletions(-) rename packer/{amazon-cloudwatch-agent.json => amazon-cloudwatch-agent.json.tmpl} (100%) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java create mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java diff --git a/packer/amazon-cloudwatch-agent.json b/packer/amazon-cloudwatch-agent.json.tmpl similarity index 100% rename from packer/amazon-cloudwatch-agent.json rename to packer/amazon-cloudwatch-agent.json.tmpl diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java b/src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java new file mode 100644 index 0000000..7fa99f0 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java @@ -0,0 +1,16 @@ +package com.isaactai.cloudnativeweb.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author tisaac + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiResourceTag { + String resource() default ""; + String tag() default ""; +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java b/src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java new file mode 100644 index 0000000..8823375 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java @@ -0,0 +1,53 @@ +package com.isaactai.cloudnativeweb.config; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerMapping; + +/** + * @author tisaac + */ +@Component +public class CustomObservationFilter implements ObservationFilter { + + @Override + public Observation.Context map(Observation.Context context) { + // Customize the observation context if needed + // only handle http request + if (context.getName().equals("http.server.requests")) { + // get request from context + Object request = context.get(HttpServletRequest.class); + + if (request instanceof HttpServletRequest httpRequest) { + Object handler = httpRequest.getAttribute( + HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE + ); + + if (handler instanceof HandlerMethod handlerMethod) { + ApiResourceTag annotation = handlerMethod.getBeanType() + .getAnnotation(ApiResourceTag.class); + + if (annotation != null) { + if (!annotation.resource().isEmpty()) { + context.addLowCardinalityKeyValue( + KeyValue.of("resource", annotation.resource()) + ); + } + + if (!annotation.tag().isEmpty()) { + context.addLowCardinalityKeyValue( + KeyValue.of("custom_tag", annotation.tag()) + ); + } + } + } + } + } + + return context; + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java index 4720a47..82c57a2 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/health/HealthController.java @@ -35,7 +35,6 @@ 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 7500d42..a34a3ac 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java @@ -1,5 +1,6 @@ package com.isaactai.cloudnativeweb.image; +import com.isaactai.cloudnativeweb.config.ApiResourceTag; import com.isaactai.cloudnativeweb.image.dto.ImageResponse; import com.isaactai.cloudnativeweb.logging.AccessNote; import io.micrometer.core.annotation.Timed; @@ -18,6 +19,7 @@ @RestController @RequestMapping("/v1/product/{product_id}/image") @RequiredArgsConstructor +@ApiResourceTag(resource = "Image") public class ImageController { private final ImageService service; @@ -29,7 +31,6 @@ 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, @@ -46,7 +47,6 @@ 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, @@ -63,7 +63,6 @@ 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 ) { @@ -78,7 +77,6 @@ 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/product/ProductController.java b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java index 6b13c95..f244bb8 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.config.ApiResourceTag; import com.isaactai.cloudnativeweb.logging.AccessNote; import com.isaactai.cloudnativeweb.product.dto.ProductCreateRequest; import com.isaactai.cloudnativeweb.product.dto.ProductPatchRequest; @@ -18,6 +19,7 @@ @RestController @RequestMapping("/v1/product") @RequiredArgsConstructor +@ApiResourceTag(resource = "Product") public class ProductController { private final ProductService service; @@ -30,7 +32,6 @@ 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 @@ -46,7 +47,6 @@ 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, @@ -63,7 +63,6 @@ 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, @@ -80,7 +79,6 @@ 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 @@ -96,7 +94,6 @@ 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 aba01d5..a4773da 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.config.ApiResourceTag; import com.isaactai.cloudnativeweb.logging.AccessNote; import com.isaactai.cloudnativeweb.user.dto.UserCreateRequest; import com.isaactai.cloudnativeweb.user.dto.UserResponse; @@ -17,6 +18,7 @@ */ @RestController @RequestMapping("/v1/user") +@ApiResourceTag(resource = "User") public class UserController { private final UserService userService; @@ -31,7 +33,6 @@ 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); @@ -44,7 +45,6 @@ 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, @@ -61,7 +61,6 @@ 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) { From 8417bcfa5e63b3ffc09d813b44eea6aabaf16542 Mon Sep 17 00:00:00 2001 From: Isaac T Date: Tue, 4 Nov 2025 00:32:58 -0500 Subject: [PATCH 3/3] refactor(metrics): replace Micrometer defaults and TimedS3 with unified AOP-based custom metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disabled Micrometer’s default HTTP metrics (e.g., http.server.requests autotime) to avoid duplicate/ noisy series - Removed TimedS3 helper; switched to AOP-driven collection - Added MetricsAspect with: • @ApiObserved — per-endpoint metrics: • @S3Observed — S3 operation latency • DB interception via pointcut - All metrics emitted via Micrometer → StatsD → CloudWatch --- .../{config => aop}/LoggingAspect.java | 0 .../cloudnativeweb/aop/MetricsAspect.java | 124 ++++++++++++++++++ .../config/CustomObservationFilter.java | 53 -------- .../cloudnativeweb/config/TimedS3.java | 44 ------- .../health/HealthController.java | 4 + .../cloudnativeweb/image/ImageController.java | 8 +- .../cloudnativeweb/image/ImageService.java | 8 +- .../{config => logging}/AccessLogFilter.java | 2 +- .../cloudnativeweb/metrics/ApiObserved.java | 12 ++ .../{config => metrics}/ApiResourceTag.java | 2 +- .../cloudnativeweb/metrics/S3Observed.java | 12 ++ .../product/ProductController.java | 9 +- .../cloudnativeweb/user/UserController.java | 8 +- src/main/resources/application.yml | 6 + 14 files changed, 182 insertions(+), 110 deletions(-) rename src/main/java/com/isaactai/cloudnativeweb/{config => aop}/LoggingAspect.java (100%) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/aop/MetricsAspect.java delete mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java delete mode 100644 src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java rename src/main/java/com/isaactai/cloudnativeweb/{config => logging}/AccessLogFilter.java (99%) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/metrics/ApiObserved.java rename src/main/java/com/isaactai/cloudnativeweb/{config => metrics}/ApiResourceTag.java (89%) create mode 100644 src/main/java/com/isaactai/cloudnativeweb/metrics/S3Observed.java diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java b/src/main/java/com/isaactai/cloudnativeweb/aop/LoggingAspect.java similarity index 100% rename from src/main/java/com/isaactai/cloudnativeweb/config/LoggingAspect.java rename to src/main/java/com/isaactai/cloudnativeweb/aop/LoggingAspect.java diff --git a/src/main/java/com/isaactai/cloudnativeweb/aop/MetricsAspect.java b/src/main/java/com/isaactai/cloudnativeweb/aop/MetricsAspect.java new file mode 100644 index 0000000..70e0627 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/aop/MetricsAspect.java @@ -0,0 +1,124 @@ +package com.isaactai.cloudnativeweb.config; + +import com.isaactai.cloudnativeweb.metrics.ApiObserved; +import com.isaactai.cloudnativeweb.metrics.ApiResourceTag; +import com.isaactai.cloudnativeweb.metrics.S3Observed; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerMapping; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author tisaac + */ +@Aspect +@Component +@RequiredArgsConstructor +public class MetricsAspect { + private final MeterRegistry registry; + + @Around("@annotation(apiObs)") + public Object apiAround(ProceedingJoinPoint pjp, ApiObserved apiObs) throws Throwable { + long start = System.nanoTime(); + boolean success = true; + try { + return pjp.proceed(); // Proceed with the original method call (Controller handler) + } catch (Throwable t) { + success = false; + throw t; + } finally { + long ns = System.nanoTime() - start; + double ms = ns / 1_000_000.0; + + // Extract HTTP method and URI pattern from the current request + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + String method = (attrs != null && attrs.getRequest() != null) + ? attrs.getRequest().getMethod() + : "UNKNOWN"; + + String uriPattern = (attrs != null && attrs.getRequest() != null) + ? String.valueOf(attrs.getRequest().getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)) + : "UNKNOWN"; + + // read @ApiResourceTag + MethodSignature sig = (MethodSignature) pjp.getSignature(); + Class cls = sig.getDeclaringType(); + ApiResourceTag tag = cls.getAnnotation(ApiResourceTag.class); + if (tag == null) tag = sig.getMethod().getAnnotation(ApiResourceTag.class); + + List tags = new ArrayList<>(); + tags.add(Tag.of("method", method)); + tags.add(Tag.of("uri", uriPattern)); + tags.add(Tag.of("outcome", success ? "success" : "error")); + if (tag != null) { + if (!tag.resource().isBlank()) tags.add(Tag.of("resource", tag.resource())); + if (!tag.tag().isBlank()) tags.add(Tag.of("custom_tag", tag.tag())); + } + + Counter.builder(apiObs.name() + ".count") + .tags(tags) + .register(registry) + .increment(); + + // Latency: use DistributionSummary to record the value directly in milliseconds + // (Timer reports in seconds and uploads as sum/count/max; using Summary makes the CloudWatch chart show ms directly) + DistributionSummary.builder(apiObs.name() + ".time.ms") + .tags(tags) + .register(registry) + .record(ms); + } + } + + @Around("execution(* org.springframework.data.repository.Repository+.*(..)) " + + "|| within(@org.springframework.stereotype.Repository *)") + public Object dbAround(ProceedingJoinPoint pjp) throws Throwable { + long start = System.nanoTime(); + boolean success = true; + try { + return pjp.proceed(); + } catch (Throwable t) { + success = false; + throw t; + }finally { + double ms = (System.nanoTime() - start) / 1_000_000.0; + String op = pjp.getSignature().getName(); + DistributionSummary.builder("app.db.query.time.ms") + .baseUnit("milliseconds") + .tag("operation", op) + .tag("success", String.valueOf(success)) + .register(registry) + .record(ms); + } + } + + @Around("@annotation(s3Obs)") + public Object s3Around(ProceedingJoinPoint pjp, S3Observed s3Obs) throws Throwable { + long start = System.nanoTime(); + boolean success = true; + try { + return pjp.proceed(); + } catch (Throwable t) { + success = false; + throw t; + } finally { + double ms = (System.nanoTime() - start) / 1_000_000.0; + DistributionSummary.builder(s3Obs.name() + ".time.ms") + .baseUnit("milliseconds") + .tag("success", String.valueOf(success)) + .register(registry) + .record(ms); + } + } +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java b/src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java deleted file mode 100644 index 8823375..0000000 --- a/src/main/java/com/isaactai/cloudnativeweb/config/CustomObservationFilter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.isaactai.cloudnativeweb.config; - -import io.micrometer.common.KeyValue; -import io.micrometer.observation.Observation; -import io.micrometer.observation.ObservationFilter; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.stereotype.Component; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerMapping; - -/** - * @author tisaac - */ -@Component -public class CustomObservationFilter implements ObservationFilter { - - @Override - public Observation.Context map(Observation.Context context) { - // Customize the observation context if needed - // only handle http request - if (context.getName().equals("http.server.requests")) { - // get request from context - Object request = context.get(HttpServletRequest.class); - - if (request instanceof HttpServletRequest httpRequest) { - Object handler = httpRequest.getAttribute( - HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE - ); - - if (handler instanceof HandlerMethod handlerMethod) { - ApiResourceTag annotation = handlerMethod.getBeanType() - .getAnnotation(ApiResourceTag.class); - - if (annotation != null) { - if (!annotation.resource().isEmpty()) { - context.addLowCardinalityKeyValue( - KeyValue.of("resource", annotation.resource()) - ); - } - - if (!annotation.tag().isEmpty()) { - context.addLowCardinalityKeyValue( - KeyValue.of("custom_tag", annotation.tag()) - ); - } - } - } - } - } - - return context; - } -} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java b/src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java deleted file mode 100644 index dfe7345..0000000 --- a/src/main/java/com/isaactai/cloudnativeweb/config/TimedS3.java +++ /dev/null @@ -1,44 +0,0 @@ -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 82c57a2..d762027 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 com.isaactai.cloudnativeweb.metrics.ApiObserved; import io.micrometer.core.annotation.Timed; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; @@ -36,6 +37,7 @@ public HealthController(HealthCheckService service, HealthProbeService healthPro serverError = "Unexpected error occurred" ) @GetMapping("/healthz") + @ApiObserved public ResponseEntity healthz( HttpServletRequest request, @RequestParam Map queryParams @@ -73,6 +75,7 @@ public ResponseEntity healthz( label = "Health", clientWarn = "Health Check failed - Method not allowed" ) + @ApiObserved public ResponseEntity healthzWrongMethod() { return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).build(); } @@ -87,6 +90,7 @@ public ResponseEntity healthzWrongMethod() { clientWarn = "Health Probe failed", serverError = "Unexpected error occurred" ) + @ApiObserved 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 a34a3ac..46e8f6f 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java @@ -1,9 +1,9 @@ package com.isaactai.cloudnativeweb.image; -import com.isaactai.cloudnativeweb.config.ApiResourceTag; +import com.isaactai.cloudnativeweb.metrics.ApiResourceTag; import com.isaactai.cloudnativeweb.image.dto.ImageResponse; import com.isaactai.cloudnativeweb.logging.AccessNote; -import io.micrometer.core.annotation.Timed; +import com.isaactai.cloudnativeweb.metrics.S3Observed; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -31,6 +31,7 @@ public class ImageController { clientWarn = "Image upload failed", serverError = "Unexpected error occurred during image upload" ) + @S3Observed public ImageResponse uploadImage( @PathVariable("product_id") Long productId, @RequestParam("file") MultipartFile file, @@ -47,6 +48,7 @@ public ImageResponse uploadImage( clientWarn = "Image deletion failed", serverError = "Unexpected error occurred during image deletion" ) + @S3Observed public void deleteImage( @PathVariable("product_id") Long productId, @PathVariable("image_id") Long imageId, @@ -63,6 +65,7 @@ public void deleteImage( clientWarn = "Image listing failed", serverError = "Unexpected error occurred during image listing" ) + @S3Observed public List listImages( @PathVariable("product_id") Long productId ) { @@ -77,6 +80,7 @@ public List listImages( clientWarn = "Image retrieval failed", serverError = "Unexpected error occurred during image retrieval" ) + @S3Observed 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 e4b1eab..c496cd0 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageService.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageService.java @@ -2,7 +2,6 @@ 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; @@ -16,6 +15,7 @@ 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,7 +33,7 @@ public class ImageService { private final ImageRepository repo; private final UserService userService; private final ProductService prodService; - private final TimedS3 timedS3; + private final S3Client s3; @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 { - timedS3.putObject( + s3.putObject( PutObjectRequest.builder() .bucket(bucketName) .key(key) @@ -103,7 +103,7 @@ public void deleteForUser(String username, Long productId, Long imageId) { } try { - timedS3.deleteObject(DeleteObjectRequest.builder() + s3.deleteObject(DeleteObjectRequest.builder() .bucket(bucketName) .key(img.getS3BucketPath()) .build()); diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessLogFilter.java similarity index 99% rename from src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java rename to src/main/java/com/isaactai/cloudnativeweb/logging/AccessLogFilter.java index ee545dd..6ae6323 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java +++ b/src/main/java/com/isaactai/cloudnativeweb/logging/AccessLogFilter.java @@ -1,4 +1,4 @@ -package com.isaactai.cloudnativeweb.config; +package com.isaactai.cloudnativeweb.logging; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiObserved.java b/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiObserved.java new file mode 100644 index 0000000..95b0579 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiObserved.java @@ -0,0 +1,12 @@ +package com.isaactai.cloudnativeweb.metrics; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiObserved { + String name() default "app.api"; +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java b/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiResourceTag.java similarity index 89% rename from src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java rename to src/main/java/com/isaactai/cloudnativeweb/metrics/ApiResourceTag.java index 7fa99f0..440db30 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/config/ApiResourceTag.java +++ b/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiResourceTag.java @@ -1,4 +1,4 @@ -package com.isaactai.cloudnativeweb.config; +package com.isaactai.cloudnativeweb.metrics; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/isaactai/cloudnativeweb/metrics/S3Observed.java b/src/main/java/com/isaactai/cloudnativeweb/metrics/S3Observed.java new file mode 100644 index 0000000..deaaafd --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/metrics/S3Observed.java @@ -0,0 +1,12 @@ +package com.isaactai.cloudnativeweb.metrics; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface S3Observed { + String name() default "app.s3"; +} diff --git a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java index f244bb8..fce3528 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java @@ -1,12 +1,12 @@ package com.isaactai.cloudnativeweb.product; -import com.isaactai.cloudnativeweb.config.ApiResourceTag; +import com.isaactai.cloudnativeweb.metrics.ApiObserved; +import com.isaactai.cloudnativeweb.metrics.ApiResourceTag; 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; @@ -32,6 +32,7 @@ public class ProductController { clientWarn = "Product creation failed", serverError = "Unexpected error occurred during product creation" ) + @ApiObserved public ProductResponse create( @Valid @RequestBody ProductCreateRequest req, Authentication auth @@ -47,6 +48,7 @@ public ProductResponse create( clientWarn = "Product update failed", serverError = "Unexpected error occurred during product update" ) + @ApiObserved public void updateProduct( @PathVariable Long productId, @Valid @RequestBody ProductUpdateRequest req, @@ -63,6 +65,7 @@ public void updateProduct( clientWarn = "Product patch failed", serverError = "Unexpected error occurred during product patch" ) + @ApiObserved public void patchProduct( @PathVariable Long productId, @Valid @RequestBody ProductPatchRequest req, @@ -79,6 +82,7 @@ public void patchProduct( clientWarn = "Product deletion failed", serverError = "Unexpected error occurred during product deletion" ) + @ApiObserved public void deleteProduct( @PathVariable Long productId, Authentication auth @@ -94,6 +98,7 @@ public void deleteProduct( clientWarn = "Product retrieval failed", serverError = "Unexpected error occurred during product retrieval" ) + @ApiObserved 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 a4773da..2359c85 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java @@ -1,13 +1,12 @@ package com.isaactai.cloudnativeweb.user; -import com.isaactai.cloudnativeweb.config.ApiResourceTag; +import com.isaactai.cloudnativeweb.metrics.ApiObserved; +import com.isaactai.cloudnativeweb.metrics.ApiResourceTag; 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; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -33,6 +32,7 @@ public UserController(UserService userService) { clientWarn = "User create failed", serverError = "Unexpected error occurred during user creation" ) + @ApiObserved public ResponseEntity create(@Valid @RequestBody UserCreateRequest req) { UserResponse created = userService.createUser(req); return ResponseEntity.status(HttpStatus.CREATED).body(created); @@ -45,6 +45,7 @@ public ResponseEntity create(@Valid @RequestBody UserCreateRequest clientWarn = "User update failed", serverError = "Unexpected error occurred during user update" ) + @ApiObserved public ResponseEntity updateUser( @PathVariable int userId, @Valid @RequestBody UserUpdateRequest req, @@ -61,6 +62,7 @@ public ResponseEntity updateUser( clientWarn = "User retrieval failed", serverError = "Unexpected error occurred during user retrieval" ) + @ApiObserved public ResponseEntity getUser( @PathVariable int userId, Authentication auth) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a1ef0d0..704520f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -47,8 +47,14 @@ server: include-binding-errors: never include-stacktrace: never + management: metrics: + web: + server: + request: + autotime: + enabled: false export: statsd: enabled: true