diff --git a/packer/amazon-cloudwatch-agent.json.tmpl b/packer/amazon-cloudwatch-agent.json.tmpl index 3f83d14..44adcd9 100644 --- a/packer/amazon-cloudwatch-agent.json.tmpl +++ b/packer/amazon-cloudwatch-agent.json.tmpl @@ -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" 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/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 4720a47..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; @@ -35,8 +36,8 @@ 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") + @ApiObserved public ResponseEntity healthz( HttpServletRequest request, @RequestParam Map queryParams @@ -74,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(); } @@ -88,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 7500d42..46e8f6f 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/image/ImageController.java @@ -1,8 +1,9 @@ package com.isaactai.cloudnativeweb.image; +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; @@ -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,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") + @S3Observed public ImageResponse uploadImage( @PathVariable("product_id") Long productId, @RequestParam("file") MultipartFile file, @@ -46,7 +48,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") + @S3Observed public void deleteImage( @PathVariable("product_id") Long productId, @PathVariable("image_id") Long imageId, @@ -63,7 +65,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") + @S3Observed public List listImages( @PathVariable("product_id") Long productId ) { @@ -78,7 +80,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") + @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/metrics/ApiResourceTag.java b/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiResourceTag.java new file mode 100644 index 0000000..440db30 --- /dev/null +++ b/src/main/java/com/isaactai/cloudnativeweb/metrics/ApiResourceTag.java @@ -0,0 +1,16 @@ +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; + +/** + * @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/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 6b13c95..fce3528 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/product/ProductController.java @@ -1,11 +1,12 @@ package com.isaactai.cloudnativeweb.product; +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; @@ -18,6 +19,7 @@ @RestController @RequestMapping("/v1/product") @RequiredArgsConstructor +@ApiResourceTag(resource = "Product") public class ProductController { private final ProductService service; @@ -30,7 +32,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") + @ApiObserved public ProductResponse create( @Valid @RequestBody ProductCreateRequest req, Authentication auth @@ -46,7 +48,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") + @ApiObserved public void updateProduct( @PathVariable Long productId, @Valid @RequestBody ProductUpdateRequest req, @@ -63,7 +65,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") + @ApiObserved public void patchProduct( @PathVariable Long productId, @Valid @RequestBody ProductPatchRequest req, @@ -80,7 +82,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") + @ApiObserved public void deleteProduct( @PathVariable Long productId, Authentication auth @@ -96,7 +98,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") + @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 aba01d5..2359c85 100644 --- a/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java +++ b/src/main/java/com/isaactai/cloudnativeweb/user/UserController.java @@ -1,12 +1,12 @@ package com.isaactai.cloudnativeweb.user; +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; @@ -17,6 +17,7 @@ */ @RestController @RequestMapping("/v1/user") +@ApiResourceTag(resource = "User") public class UserController { private final UserService userService; @@ -31,7 +32,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") + @ApiObserved public ResponseEntity create(@Valid @RequestBody UserCreateRequest req) { UserResponse created = userService.createUser(req); return ResponseEntity.status(HttpStatus.CREATED).body(created); @@ -44,7 +45,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") + @ApiObserved public ResponseEntity updateUser( @PathVariable int userId, @Valid @RequestBody UserUpdateRequest req, @@ -61,7 +62,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") + @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