Skip to content

Commit 22ea06a

Browse files
authored
test(metrics): Improve testability of @FlushMetrics annotation (#2112)
* Clarify how to mock the Lambda context in unit tests for Metrics utility. * Revert when() mocking in documentation. * Avoid metrics from raising an exception when using plain mocked Lambda context. * Reset outputstream in unit test docs. * Make stdout stream field static and final. * Fix issue when function name is not set. * Fix hl_lines in docs. * Fix hl_lines in docs. * Remove java8 from sam templates.
1 parent de9ba8b commit 22ea06a

File tree

6 files changed

+203
-22
lines changed

6 files changed

+203
-22
lines changed

docs/core/metrics.md

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ For most use-cases, we recommend using Environment variables and only overwrite
140140
Type: AWS::Serverless::Function
141141
Properties:
142142
...
143-
Runtime: java8
143+
Runtime: java11
144144
Environment:
145145
Variables:
146146
POWERTOOLS_SERVICE_NAME: payment
@@ -558,9 +558,24 @@ If you would like to suppress metrics output during your unit tests, you can use
558558

559559
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.
560560

561+
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:
562+
563+
```xml
564+
<plugin>
565+
<groupId>org.apache.maven.plugins</groupId>
566+
<artifactId>maven-surefire-plugin</artifactId>
567+
<configuration>
568+
<environmentVariables>
569+
<POWERTOOLS_SERVICE_NAME>TestService</POWERTOOLS_SERVICE_NAME>
570+
<POWERTOOLS_METRICS_NAMESPACE>TestNamespace</POWERTOOLS_METRICS_NAMESPACE>
571+
</environmentVariables>
572+
</configuration>
573+
</plugin>
574+
```
575+
561576
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.
562577

563-
```java hl_lines="23 28 33 50-55"
578+
```java hl_lines="35 40 56-72"
564579
import static org.assertj.core.api.Assertions.assertThat;
565580

566581
import java.io.ByteArrayOutputStream;
@@ -571,56 +586,73 @@ import java.util.Map;
571586
import org.junit.jupiter.api.AfterEach;
572587
import org.junit.jupiter.api.BeforeEach;
573588
import org.junit.jupiter.api.Test;
589+
import org.junit.jupiter.api.extension.ExtendWith;
590+
import org.mockito.Mock;
591+
import org.mockito.junit.jupiter.MockitoExtension;
574592

575593
import com.amazonaws.services.lambda.runtime.Context;
576594
import com.amazonaws.services.lambda.runtime.RequestHandler;
577595
import com.fasterxml.jackson.databind.JsonNode;
578596
import com.fasterxml.jackson.databind.ObjectMapper;
579597

580598
import software.amazon.lambda.powertools.metrics.model.MetricUnit;
581-
import software.amazon.lambda.powertools.metrics.testutils.TestContext;
582599

600+
@ExtendWith(MockitoExtension.class)
583601
class MetricsTestExample {
584602

603+
@Mock
604+
Context lambdaContext;
605+
585606
private final PrintStream standardOut = System.out;
586-
private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream();
607+
private ByteArrayOutputStream outputStreamCaptor;
587608
private final ObjectMapper objectMapper = new ObjectMapper();
588609

589610
@BeforeEach
590611
void setUp() {
612+
outputStreamCaptor = new ByteArrayOutputStream();
591613
System.setOut(new PrintStream(outputStreamCaptor));
592614
}
593615

594616
@AfterEach
595-
void tearDown() {
617+
void tearDown() throws Exception {
596618
System.setOut(standardOut);
597619
}
598620

599621
@Test
600622
void shouldCaptureMetricsFromAnnotatedHandler() throws Exception {
601623
// Given
602624
RequestHandler<Map<String, Object>, String> handler = new HandlerWithMetricsAnnotation();
603-
Context context = new TestContext();
604625
Map<String, Object> input = new HashMap<>();
605626

606627
// When
607-
handler.handleRequest(input, context);
628+
handler.handleRequest(input, lambdaContext);
608629

609630
// Then
610631
String emfOutput = outputStreamCaptor.toString().trim();
611-
JsonNode rootNode = objectMapper.readTree(emfOutput);
612-
613-
assertThat(rootNode.has("test-metric")).isTrue();
614-
assertThat(rootNode.get("test-metric").asDouble()).isEqualTo(100.0);
615-
assertThat(rootNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText())
616-
.isEqualTo("CustomNamespace");
617-
assertThat(rootNode.has("Service")).isTrue();
618-
assertThat(rootNode.get("Service").asText()).isEqualTo("CustomService");
632+
String[] jsonLines = emfOutput.split("\n");
633+
634+
// First JSON object should be the cold start metric
635+
JsonNode coldStartNode = objectMapper.readTree(jsonLines[0]);
636+
assertThat(coldStartNode.has("ColdStart")).isTrue();
637+
assertThat(coldStartNode.get("ColdStart").asDouble()).isEqualTo(1.0);
638+
assertThat(coldStartNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText())
639+
.isEqualTo("TestNamespace");
640+
assertThat(coldStartNode.has("Service")).isTrue();
641+
assertThat(coldStartNode.get("Service").asText()).isEqualTo("TestService");
642+
643+
// Second JSON object should be the regular metric
644+
JsonNode regularNode = objectMapper.readTree(jsonLines[1]);
645+
assertThat(regularNode.has("test-metric")).isTrue();
646+
assertThat(regularNode.get("test-metric").asDouble()).isEqualTo(100.0);
647+
assertThat(regularNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText())
648+
.isEqualTo("TestNamespace");
649+
assertThat(regularNode.has("Service")).isTrue();
650+
assertThat(regularNode.get("Service").asText()).isEqualTo("TestService");
619651
}
620652

621653
static class HandlerWithMetricsAnnotation implements RequestHandler<Map<String, Object>, String> {
622654
@Override
623-
@FlushMetrics(namespace = "CustomNamespace", service = "CustomService")
655+
@FlushMetrics(captureColdStart = true)
624656
public String handleRequest(Map<String, Object> input, Context context) {
625657
Metrics metrics = MetricsFactory.getMetricsInstance();
626658
metrics.addMetric("test-metric", 100, MetricUnit.COUNT);

docs/core/tracing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Before your use this utility, your AWS Lambda function [must have permissions](h
105105
Type: AWS::Serverless::Function
106106
Properties:
107107
...
108-
Runtime: java8
108+
Runtime: java11
109109

110110
Tracing: Active
111111
Environment:
@@ -191,7 +191,7 @@ different supported `captureMode` to record response, exception or both.
191191
Type: AWS::Serverless::Function
192192
Properties:
193193
...
194-
Runtime: java8
194+
Runtime: java11
195195

196196
Tracing: Active
197197
Environment:

powertools-metrics/pom.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@
105105
<artifactId>assertj-core</artifactId>
106106
<scope>test</scope>
107107
</dependency>
108+
<dependency>
109+
<groupId>org.mockito</groupId>
110+
<artifactId>mockito-junit-jupiter</artifactId>
111+
<scope>test</scope>
112+
</dependency>
108113
<dependency>
109114
<groupId>software.amazon.lambda</groupId>
110115
<artifactId>powertools-common</artifactId>
@@ -135,6 +140,13 @@
135140
</profile>
136141
<profile>
137142
<id>generate-graalvm-files</id>
143+
<dependencies>
144+
<dependency>
145+
<groupId>org.mockito</groupId>
146+
<artifactId>mockito-subclass</artifactId>
147+
<scope>test</scope>
148+
</dependency>
149+
</dependencies>
138150
<build>
139151
<plugins>
140152
<plugin>
@@ -154,6 +166,13 @@
154166
</profile>
155167
<profile>
156168
<id>graalvm-native</id>
169+
<dependencies>
170+
<dependency>
171+
<groupId>org.mockito</groupId>
172+
<artifactId>mockito-subclass</artifactId>
173+
<scope>test</scope>
174+
</dependency>
175+
</dependencies>
157176
<build>
158177
<plugins>
159178
<plugin>

powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/EmfMetricsLogger.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ public void captureColdStartMetric(Context context,
204204
}
205205

206206
// Add request ID from context if available
207-
if (context != null) {
207+
if (context != null && context.getAwsRequestId() != null) {
208208
coldStartLogger.putProperty(REQUEST_ID_PROPERTY, context.getAwsRequestId());
209209
}
210210

powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/LambdaMetricsAspect.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ private void captureColdStartMetricIfEnabled(Context extractedContext, FlushMetr
113113
}
114114

115115
Metrics metricsInstance = MetricsFactory.getMetricsInstance();
116-
metricsInstance.addMetadata(REQUEST_ID_PROPERTY, extractedContext.getAwsRequestId());
116+
// This can be null e.g. during unit tests when mocking the Lambda context
117+
if (extractedContext.getAwsRequestId() != null) {
118+
metricsInstance.addMetadata(REQUEST_ID_PROPERTY, extractedContext.getAwsRequestId());
119+
}
117120

118121
// Only capture cold start metrics if enabled on annotation
119122
if (metrics.captureColdStart()) {
@@ -133,8 +136,9 @@ private void captureColdStartMetricIfEnabled(Context extractedContext, FlushMetr
133136
}
134137

135138
// Add function name
136-
coldStartDimensions.addDimension("FunctionName",
137-
funcName != null ? funcName : extractedContext.getFunctionName());
139+
if (funcName != null) {
140+
coldStartDimensions.addDimension("FunctionName", funcName);
141+
}
138142

139143
metricsInstance.captureColdStartMetric(extractedContext, coldStartDimensions);
140144
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package software.amazon.lambda.powertools.metrics;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.io.ByteArrayOutputStream;
6+
import java.io.PrintStream;
7+
import java.lang.reflect.Method;
8+
import java.util.HashMap;
9+
import java.util.Map;
10+
11+
import org.junit.jupiter.api.AfterEach;
12+
import org.junit.jupiter.api.BeforeEach;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.extension.ExtendWith;
15+
import org.junitpioneer.jupiter.SetEnvironmentVariable;
16+
import org.mockito.Mock;
17+
import org.mockito.junit.jupiter.MockitoExtension;
18+
19+
import com.amazonaws.services.lambda.runtime.Context;
20+
import com.amazonaws.services.lambda.runtime.RequestHandler;
21+
import com.fasterxml.jackson.databind.JsonNode;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
24+
import software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor;
25+
import software.amazon.lambda.powertools.metrics.model.MetricUnit;
26+
27+
@ExtendWith(MockitoExtension.class)
28+
class RequestHandlerTest {
29+
30+
// For developer convenience, no exceptions should be thrown when using a plain Lambda Context mock
31+
@Mock
32+
Context lambdaContext;
33+
34+
private static final PrintStream STDOUT = System.out;
35+
private ByteArrayOutputStream outputStreamCaptor;
36+
private final ObjectMapper objectMapper = new ObjectMapper();
37+
38+
@BeforeEach
39+
void setUp() throws Exception {
40+
outputStreamCaptor = new ByteArrayOutputStream();
41+
System.setOut(new PrintStream(outputStreamCaptor));
42+
43+
// Reset LambdaHandlerProcessor's SERVICE_NAME
44+
Method resetServiceName = LambdaHandlerProcessor.class.getDeclaredMethod("resetServiceName");
45+
resetServiceName.setAccessible(true);
46+
resetServiceName.invoke(null);
47+
48+
// Reset IS_COLD_START
49+
java.lang.reflect.Field coldStartField = LambdaHandlerProcessor.class.getDeclaredField("IS_COLD_START");
50+
coldStartField.setAccessible(true);
51+
coldStartField.set(null, null);
52+
}
53+
54+
@AfterEach
55+
void tearDown() throws Exception {
56+
System.setOut(STDOUT);
57+
58+
// Reset the singleton state between tests
59+
java.lang.reflect.Field field = MetricsFactory.class.getDeclaredField("metrics");
60+
field.setAccessible(true);
61+
field.set(null, null);
62+
63+
field = MetricsFactory.class.getDeclaredField("provider");
64+
field.setAccessible(true);
65+
field.set(null, new software.amazon.lambda.powertools.metrics.provider.EmfMetricsProvider());
66+
}
67+
68+
@Test
69+
@SetEnvironmentVariable(key = "POWERTOOLS_METRICS_NAMESPACE", value = "TestNamespace")
70+
@SetEnvironmentVariable(key = "POWERTOOLS_SERVICE_NAME", value = "TestService")
71+
void shouldCaptureMetricsFromAnnotatedHandler() throws Exception {
72+
// Given
73+
RequestHandler<Map<String, Object>, String> handler = new HandlerWithMetricsAnnotation();
74+
Map<String, Object> input = new HashMap<>();
75+
76+
// When
77+
handler.handleRequest(input, lambdaContext);
78+
79+
// Then
80+
String emfOutput = outputStreamCaptor.toString().trim();
81+
String[] jsonLines = emfOutput.split("\n");
82+
83+
// First JSON object should be the cold start metric
84+
JsonNode coldStartNode = objectMapper.readTree(jsonLines[0]);
85+
assertThat(coldStartNode.has("ColdStart")).isTrue();
86+
assertThat(coldStartNode.get("ColdStart").asDouble()).isEqualTo(1.0);
87+
assertThat(coldStartNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText())
88+
.isEqualTo("TestNamespace");
89+
assertThat(coldStartNode.has("Service")).isTrue();
90+
assertThat(coldStartNode.get("Service").asText()).isEqualTo("TestService");
91+
92+
// Second JSON object should be the regular metric
93+
JsonNode regularNode = objectMapper.readTree(jsonLines[1]);
94+
assertThat(regularNode.has("test-metric")).isTrue();
95+
assertThat(regularNode.get("test-metric").asDouble()).isEqualTo(100.0);
96+
assertThat(regularNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText())
97+
.isEqualTo("TestNamespace");
98+
assertThat(regularNode.has("Service")).isTrue();
99+
assertThat(regularNode.get("Service").asText()).isEqualTo("TestService");
100+
}
101+
102+
@SetEnvironmentVariable(key = "POWERTOOLS_METRICS_DISABLED", value = "true")
103+
@Test
104+
void shouldNotCaptureMetricsWhenDisabled() {
105+
// Given
106+
RequestHandler<Map<String, Object>, String> handler = new HandlerWithMetricsAnnotation();
107+
Map<String, Object> input = new HashMap<>();
108+
109+
// When
110+
handler.handleRequest(input, lambdaContext);
111+
112+
// Then
113+
String emfOutput = outputStreamCaptor.toString().trim();
114+
assertThat(emfOutput).isEmpty();
115+
}
116+
117+
static class HandlerWithMetricsAnnotation implements RequestHandler<Map<String, Object>, String> {
118+
@Override
119+
@FlushMetrics(captureColdStart = true)
120+
public String handleRequest(Map<String, Object> input, Context context) {
121+
Metrics metrics = MetricsFactory.getMetricsInstance();
122+
metrics.addMetric("test-metric", 100, MetricUnit.COUNT);
123+
return "OK";
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)