diff --git a/docs/core/metrics.md b/docs/core/metrics.md index 31079a76e..61d4c38f0 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -140,7 +140,7 @@ For most use-cases, we recommend using Environment variables and only overwrite Type: AWS::Serverless::Function Properties: ... - Runtime: java8 + Runtime: java11 Environment: Variables: POWERTOOLS_SERVICE_NAME: payment @@ -558,9 +558,24 @@ If you would like to suppress metrics output during your unit tests, you can use When unit testing your code, you can run assertions against the output generated by the `Metrics` Singleton. For the `EmfMetricsLogger`, you can assert the generated JSON blob following the [CloudWatch EMF specification](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) against your expected output. +Make sure to set a test metrics namespace and service name to run assertions against metrics. For example, by setting the following environment variables in your tests: + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + + TestService + TestNamespace + + + +``` + Consider the following example where we redirect the standard output to a custom `PrintStream`. We use the Jackson library to parse the EMF output into a `JsonNode` and run assertions against that. -```java hl_lines="23 28 33 50-55" +```java hl_lines="35 40 56-72" import static org.assertj.core.api.Assertions.assertThat; import java.io.ByteArrayOutputStream; @@ -571,6 +586,9 @@ import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -578,21 +596,25 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.lambda.powertools.metrics.model.MetricUnit; -import software.amazon.lambda.powertools.metrics.testutils.TestContext; +@ExtendWith(MockitoExtension.class) class MetricsTestExample { + @Mock + Context lambdaContext; + private final PrintStream standardOut = System.out; - private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); + private ByteArrayOutputStream outputStreamCaptor; private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { + outputStreamCaptor = new ByteArrayOutputStream(); System.setOut(new PrintStream(outputStreamCaptor)); } @AfterEach - void tearDown() { + void tearDown() throws Exception { System.setOut(standardOut); } @@ -600,27 +622,37 @@ class MetricsTestExample { void shouldCaptureMetricsFromAnnotatedHandler() throws Exception { // Given RequestHandler, String> handler = new HandlerWithMetricsAnnotation(); - Context context = new TestContext(); Map input = new HashMap<>(); // When - handler.handleRequest(input, context); + handler.handleRequest(input, lambdaContext); // Then String emfOutput = outputStreamCaptor.toString().trim(); - JsonNode rootNode = objectMapper.readTree(emfOutput); - - assertThat(rootNode.has("test-metric")).isTrue(); - assertThat(rootNode.get("test-metric").asDouble()).isEqualTo(100.0); - assertThat(rootNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText()) - .isEqualTo("CustomNamespace"); - assertThat(rootNode.has("Service")).isTrue(); - assertThat(rootNode.get("Service").asText()).isEqualTo("CustomService"); + String[] jsonLines = emfOutput.split("\n"); + + // First JSON object should be the cold start metric + JsonNode coldStartNode = objectMapper.readTree(jsonLines[0]); + assertThat(coldStartNode.has("ColdStart")).isTrue(); + assertThat(coldStartNode.get("ColdStart").asDouble()).isEqualTo(1.0); + assertThat(coldStartNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText()) + .isEqualTo("TestNamespace"); + assertThat(coldStartNode.has("Service")).isTrue(); + assertThat(coldStartNode.get("Service").asText()).isEqualTo("TestService"); + + // Second JSON object should be the regular metric + JsonNode regularNode = objectMapper.readTree(jsonLines[1]); + assertThat(regularNode.has("test-metric")).isTrue(); + assertThat(regularNode.get("test-metric").asDouble()).isEqualTo(100.0); + assertThat(regularNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText()) + .isEqualTo("TestNamespace"); + assertThat(regularNode.has("Service")).isTrue(); + assertThat(regularNode.get("Service").asText()).isEqualTo("TestService"); } static class HandlerWithMetricsAnnotation implements RequestHandler, String> { @Override - @FlushMetrics(namespace = "CustomNamespace", service = "CustomService") + @FlushMetrics(captureColdStart = true) public String handleRequest(Map input, Context context) { Metrics metrics = MetricsFactory.getMetricsInstance(); metrics.addMetric("test-metric", 100, MetricUnit.COUNT); diff --git a/docs/core/tracing.md b/docs/core/tracing.md index 883f8db86..8129d45ba 100644 --- a/docs/core/tracing.md +++ b/docs/core/tracing.md @@ -105,7 +105,7 @@ Before your use this utility, your AWS Lambda function [must have permissions](h Type: AWS::Serverless::Function Properties: ... - Runtime: java8 + Runtime: java11 Tracing: Active Environment: @@ -191,7 +191,7 @@ different supported `captureMode` to record response, exception or both. Type: AWS::Serverless::Function Properties: ... - Runtime: java8 + Runtime: java11 Tracing: Active Environment: diff --git a/powertools-metrics/pom.xml b/powertools-metrics/pom.xml index 4bccab505..b90506c25 100644 --- a/powertools-metrics/pom.xml +++ b/powertools-metrics/pom.xml @@ -105,6 +105,11 @@ assertj-core test + + org.mockito + mockito-junit-jupiter + test + software.amazon.lambda powertools-common @@ -135,6 +140,13 @@ generate-graalvm-files + + + org.mockito + mockito-subclass + test + + @@ -154,6 +166,13 @@ graalvm-native + + + org.mockito + mockito-subclass + test + + diff --git a/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/EmfMetricsLogger.java b/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/EmfMetricsLogger.java index 2f6f9e689..c9651585f 100644 --- a/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/EmfMetricsLogger.java +++ b/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/EmfMetricsLogger.java @@ -204,7 +204,7 @@ public void captureColdStartMetric(Context context, } // Add request ID from context if available - if (context != null) { + if (context != null && context.getAwsRequestId() != null) { coldStartLogger.putProperty(REQUEST_ID_PROPERTY, context.getAwsRequestId()); } diff --git a/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/LambdaMetricsAspect.java b/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/LambdaMetricsAspect.java index b214d7c52..32824e24f 100644 --- a/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/LambdaMetricsAspect.java +++ b/powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/LambdaMetricsAspect.java @@ -113,7 +113,10 @@ private void captureColdStartMetricIfEnabled(Context extractedContext, FlushMetr } Metrics metricsInstance = MetricsFactory.getMetricsInstance(); - metricsInstance.addMetadata(REQUEST_ID_PROPERTY, extractedContext.getAwsRequestId()); + // This can be null e.g. during unit tests when mocking the Lambda context + if (extractedContext.getAwsRequestId() != null) { + metricsInstance.addMetadata(REQUEST_ID_PROPERTY, extractedContext.getAwsRequestId()); + } // Only capture cold start metrics if enabled on annotation if (metrics.captureColdStart()) { @@ -133,8 +136,9 @@ private void captureColdStartMetricIfEnabled(Context extractedContext, FlushMetr } // Add function name - coldStartDimensions.addDimension("FunctionName", - funcName != null ? funcName : extractedContext.getFunctionName()); + if (funcName != null) { + coldStartDimensions.addDimension("FunctionName", funcName); + } metricsInstance.captureColdStartMetric(extractedContext, coldStartDimensions); } diff --git a/powertools-metrics/src/test/java/software/amazon/lambda/powertools/metrics/RequestHandlerTest.java b/powertools-metrics/src/test/java/software/amazon/lambda/powertools/metrics/RequestHandlerTest.java new file mode 100644 index 000000000..d94a6bbe8 --- /dev/null +++ b/powertools-metrics/src/test/java/software/amazon/lambda/powertools/metrics/RequestHandlerTest.java @@ -0,0 +1,126 @@ +package software.amazon.lambda.powertools.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.SetEnvironmentVariable; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor; +import software.amazon.lambda.powertools.metrics.model.MetricUnit; + +@ExtendWith(MockitoExtension.class) +class RequestHandlerTest { + + // For developer convenience, no exceptions should be thrown when using a plain Lambda Context mock + @Mock + Context lambdaContext; + + private static final PrintStream STDOUT = System.out; + private ByteArrayOutputStream outputStreamCaptor; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() throws Exception { + outputStreamCaptor = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outputStreamCaptor)); + + // Reset LambdaHandlerProcessor's SERVICE_NAME + Method resetServiceName = LambdaHandlerProcessor.class.getDeclaredMethod("resetServiceName"); + resetServiceName.setAccessible(true); + resetServiceName.invoke(null); + + // Reset IS_COLD_START + java.lang.reflect.Field coldStartField = LambdaHandlerProcessor.class.getDeclaredField("IS_COLD_START"); + coldStartField.setAccessible(true); + coldStartField.set(null, null); + } + + @AfterEach + void tearDown() throws Exception { + System.setOut(STDOUT); + + // Reset the singleton state between tests + java.lang.reflect.Field field = MetricsFactory.class.getDeclaredField("metrics"); + field.setAccessible(true); + field.set(null, null); + + field = MetricsFactory.class.getDeclaredField("provider"); + field.setAccessible(true); + field.set(null, new software.amazon.lambda.powertools.metrics.provider.EmfMetricsProvider()); + } + + @Test + @SetEnvironmentVariable(key = "POWERTOOLS_METRICS_NAMESPACE", value = "TestNamespace") + @SetEnvironmentVariable(key = "POWERTOOLS_SERVICE_NAME", value = "TestService") + void shouldCaptureMetricsFromAnnotatedHandler() throws Exception { + // Given + RequestHandler, String> handler = new HandlerWithMetricsAnnotation(); + Map input = new HashMap<>(); + + // When + handler.handleRequest(input, lambdaContext); + + // Then + String emfOutput = outputStreamCaptor.toString().trim(); + String[] jsonLines = emfOutput.split("\n"); + + // First JSON object should be the cold start metric + JsonNode coldStartNode = objectMapper.readTree(jsonLines[0]); + assertThat(coldStartNode.has("ColdStart")).isTrue(); + assertThat(coldStartNode.get("ColdStart").asDouble()).isEqualTo(1.0); + assertThat(coldStartNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText()) + .isEqualTo("TestNamespace"); + assertThat(coldStartNode.has("Service")).isTrue(); + assertThat(coldStartNode.get("Service").asText()).isEqualTo("TestService"); + + // Second JSON object should be the regular metric + JsonNode regularNode = objectMapper.readTree(jsonLines[1]); + assertThat(regularNode.has("test-metric")).isTrue(); + assertThat(regularNode.get("test-metric").asDouble()).isEqualTo(100.0); + assertThat(regularNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText()) + .isEqualTo("TestNamespace"); + assertThat(regularNode.has("Service")).isTrue(); + assertThat(regularNode.get("Service").asText()).isEqualTo("TestService"); + } + + @SetEnvironmentVariable(key = "POWERTOOLS_METRICS_DISABLED", value = "true") + @Test + void shouldNotCaptureMetricsWhenDisabled() { + // Given + RequestHandler, String> handler = new HandlerWithMetricsAnnotation(); + Map input = new HashMap<>(); + + // When + handler.handleRequest(input, lambdaContext); + + // Then + String emfOutput = outputStreamCaptor.toString().trim(); + assertThat(emfOutput).isEmpty(); + } + + static class HandlerWithMetricsAnnotation implements RequestHandler, String> { + @Override + @FlushMetrics(captureColdStart = true) + public String handleRequest(Map input, Context context) { + Metrics metrics = MetricsFactory.getMetricsInstance(); + metrics.addMetric("test-metric", 100, MetricUnit.COUNT); + return "OK"; + } + } +}