diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java index 8f4ecca..e48b58a 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java @@ -6,6 +6,7 @@ import software.amazon.awssdk.services.cloudwatch.model.*; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Map; import static org.assertj.core.api.Assertions.*; @@ -185,4 +186,51 @@ class CloudWatchTest { ); assertThat(noMatch.datapoints()).isEmpty(); } + + @Test + @DisplayName("PutMetricData stores datums nested under EntityMetricData") + void testPutMetricDataWithEntityMetricData() { + String namespace = "JavaTestEntityMetricData"; + Instant now = Instant.now(); + + cloudWatchClient.putMetricData(request -> request + .namespace(namespace) + .metricData(datum -> datum + .metricName("TopLevel") + .value(1.0) + .timestamp(now)) + .entityMetricData(entityMetricData -> entityMetricData + .entity(entity -> entity + .keyAttributes(Map.of( + "Type", "AWS::Resource", + "ResourceType", "AWS::EC2::Instance", + "Identifier", "i-0123456789abcdef0"))) + .metricData(MetricDatum.builder() + .metricName("mem_used_percent") + .value(6.5) + .unit(StandardUnit.PERCENT) + .storageResolution(60) + .timestamp(now) + .dimensions(Dimension.builder().name("host").value("h1").build()) + .build())) + .strictEntityValidation(false) + ); + + ListMetricsResponse listed = cloudWatchClient.listMetrics(request -> request.namespace(namespace)); + assertThat(listed.metrics()) + .extracting(Metric::metricName) + .containsExactlyInAnyOrder("TopLevel", "mem_used_percent"); + + GetMetricStatisticsResponse stats = cloudWatchClient.getMetricStatistics(request -> request + .namespace(namespace) + .metricName("mem_used_percent") + .dimensions(Dimension.builder().name("host").value("h1").build()) + .startTime(now.minus(1, ChronoUnit.HOURS)) + .endTime(now.plus(1, ChronoUnit.MINUTES)) + .period(3600) + .statistics(Statistic.SUM) + ); + assertThat(stats.datapoints()).hasSize(1); + assertThat(stats.datapoints().get(0).sum()).isEqualTo(6.5); + } } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java index 5aa036d..acba84d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java @@ -82,10 +82,11 @@ public class CloudWatchMetricsJsonHandler { private Response handlePutMetricData(JsonNode request, String region) { String namespace = request.path("Namespace").asText(); - LOG.infov("JSON PutMetricData raw: {0}", request); List datums = parseMetricDataJson(request.path("MetricData")); - LOG.infov("JSON PutMetricData parsed {0} datums, sums={1}", datums.size(), - datums.stream().map(d -> d.getMetricName() + "=" + d.getSum()).toList()); + for (JsonNode entityMetricData : request.path("EntityMetricData")) { + datums.addAll(parseMetricDataJson(entityMetricData.path("MetricData"))); + } + LOG.debugv("JSON PutMetricData parsed {0} datums for namespace {1}", datums.size(), namespace); metricsService.putMetricData(namespace, datums, region); return Response.ok(objectMapper.createObjectNode()).build(); } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java index 6d67684..4865b89 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java @@ -26,11 +26,16 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.jboss.logging.Logger; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @ApplicationScoped public class CloudWatchMetricsQueryHandler { private static final Logger LOG = Logger.getLogger(CloudWatchMetricsQueryHandler.class); + private static final Pattern ENTITY_METRIC_DATA_MEMBER = Pattern.compile("EntityMetricData\\.member\\.(\\d{1,9})\\."); private CloudWatchMetricsService metricsService; private final CloudWatchDashboardsService dashboardsService; @@ -591,42 +596,59 @@ public class CloudWatchMetricsQueryHandler { // ──────────────────────────── Parsing Helpers ──────────────────────────── private List parseMetricData(MultivaluedMap params) { - LOG.infov("Query PutMetricData params: {0}", params); + List datums = parseMetricData(params, "MetricData"); + // An empty EntityMetricData entry serializes to no keys, so its index is absent + // while later entries keep theirs: iterate the indexes present, not 1..first gap. + SortedSet entityIndexes = new TreeSet<>(); + for (String key : params.keySet()) { + Matcher matcher = ENTITY_METRIC_DATA_MEMBER.matcher(key); + if (matcher.lookingAt()) { + entityIndexes.add(Integer.parseInt(matcher.group(1))); + } + } + for (int index : entityIndexes) { + datums.addAll(parseMetricData(params, "EntityMetricData.member." + index + ".MetricData")); + } + LOG.debugv("Query PutMetricData parsed {0} datums", datums.size()); + return datums; + } + + private List parseMetricData(MultivaluedMap params, String listPrefix) { List datums = new ArrayList<>(); for (int i = 1; ; i++) { - String metricName = params.getFirst("MetricData.member." + i + ".MetricName"); + String metricName = params.getFirst(listPrefix + ".member." + i + ".MetricName"); if (metricName == null) { break; } MetricDatum datum = new MetricDatum(); datum.setMetricName(metricName); - datum.setUnit(params.getFirst("MetricData.member." + i + ".Unit")); + datum.setUnit(params.getFirst(listPrefix + ".member." + i + ".Unit")); - String valueStr = params.getFirst("MetricData.member." + i + ".Value"); + String valueStr = params.getFirst(listPrefix + ".member." + i + ".Value"); if (valueStr != null) { datum.setValue(parseDouble(valueStr, 0)); } - String tsStr = params.getFirst("MetricData.member." + i + ".Timestamp"); + String tsStr = params.getFirst(listPrefix + ".member." + i + ".Timestamp"); if (tsStr != null) { Instant ts = parseInstant(tsStr); datum.setTimestamp(ts != null ? ts.getEpochSecond() : 0); } // StatisticValues - String sc = params.getFirst("MetricData.member." + i + ".StatisticValues.SampleCount"); + String sc = params.getFirst(listPrefix + ".member." + i + ".StatisticValues.SampleCount"); if (sc != null) { datum.setSampleCount(parseDouble(sc, 0)); - datum.setSum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Sum"), 0)); - datum.setMinimum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Minimum"), 0)); - datum.setMaximum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Maximum"), 0)); + datum.setSum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Sum"), 0)); + datum.setMinimum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Minimum"), 0)); + datum.setMaximum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Maximum"), 0)); } // Dimensions List dims = new ArrayList<>(); for (int j = 1; ; j++) { - String dimName = params.getFirst("MetricData.member." + i + ".Dimensions.member." + j + ".Name"); - String dimValue = params.getFirst("MetricData.member." + i + ".Dimensions.member." + j + ".Value"); + String dimName = params.getFirst(listPrefix + ".member." + i + ".Dimensions.member." + j + ".Name"); + String dimValue = params.getFirst(listPrefix + ".member." + i + ".Dimensions.member." + j + ".Value"); if (dimName == null) { break; } diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsEntityMetricDataIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsEntityMetricDataIntegrationTest.java new file mode 100644 index 0000000..890bceb --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsEntityMetricDataIntegrationTest.java @@ -0,0 +1,156 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.config.EncoderConfig; +import io.restassured.http.ContentType; +import io.restassured.parsing.Parser; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static io.restassured.RestAssured.config; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; + +/** + * PutMetricData datums nested under EntityMetricData, as the CloudWatch Agent sends them, + * must be stored under the request namespace on every protocol path. floci-io/floci#6066. + */ +@QuarkusTest +class CloudWatchMetricsEntityMetricDataIntegrationTest { + + private static final String CW_AUTH = + "AWS4-HMAC-SHA256 Credential=test/20261020/us-east-1/monitoring/aws4_request"; + private static final String JSON_1_0 = "application/x-amz-json-1.0"; + + @BeforeAll + static void registerJsonParser() { + RestAssured.registerParser(JSON_1_0, Parser.JSON); + } + + private static RequestSpecification json(String operation) { + return given() + .header("Authorization", CW_AUTH) + .contentType(JSON_1_0) + .config(config().encoderConfig(EncoderConfig.encoderConfig() + .encodeContentTypeAs(JSON_1_0, ContentType.TEXT))) + .header("X-Amz-Target", "GraniteServiceVersion20100801." + operation); + } + + private static RequestSpecification query(String action) { + return given() + .header("Authorization", CW_AUTH) + .contentType("application/x-www-form-urlencoded") + .formParam("Action", action) + .formParam("Version", "2010-08-01"); + } + + @Test + void jsonEntityMetricDataOnlyIsListedAndAggregated() { + long now = Instant.now().getEpochSecond(); + json("PutMetricData") + .body(""" + {"Namespace":"CWAgent/Json","MetricData":[], + "EntityMetricData":[{ + "Entity":{"KeyAttributes":{"Type":"AWS::Resource", + "ResourceType":"AWS::EC2::Instance","Identifier":"i-0123456789abcdef0"}, + "Attributes":{}}, + "MetricData":[ + {"MetricName":"mem_used_percent","Value":6.5,"Unit":"Percent", + "StorageResolution":60,"Timestamp":%d, + "Dimensions":[{"Name":"host","Value":"h1"}]}, + {"MetricName":"disk_used_percent","Value":0.75,"Unit":"Percent", + "StorageResolution":60,"Timestamp":%d, + "Dimensions":[{"Name":"host","Value":"h1"}]}]}], + "StrictEntityValidation":false} + """.formatted(now, now)) + .when().post("/") + .then().statusCode(200); + + json("ListMetrics") + .body("{\"Namespace\":\"CWAgent/Json\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Metrics.MetricName", containsInAnyOrder("mem_used_percent", "disk_used_percent")); + + json("GetMetricStatistics") + .body(""" + {"Namespace":"CWAgent/Json","MetricName":"mem_used_percent", + "Dimensions":[{"Name":"host","Value":"h1"}], + "StartTime":%d,"EndTime":%d,"Period":60,"Statistics":["Sum"]} + """.formatted(now - 120, now + 120)) + .when().post("/") + .then() + .statusCode(200) + .body("Datapoints.Sum", hasItem(6.5f)); + } + + @Test + void jsonTopLevelAndEntityMetricDataAreBothStored() { + json("PutMetricData") + .body(""" + {"Namespace":"CWAgent/JsonMixed", + "MetricData":[{"MetricName":"TopLevel","Value":1}], + "EntityMetricData":[{"Entity":{"KeyAttributes":{"Type":"Service", + "Name":"checkout","Environment":"prod"}}, + "MetricData":[{"MetricName":"FromEntity","Value":2}]}]} + """) + .when().post("/") + .then().statusCode(200); + + json("ListMetrics") + .body("{\"Namespace\":\"CWAgent/JsonMixed\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Metrics.MetricName", containsInAnyOrder("TopLevel", "FromEntity")); + } + + @Test + void queryTopLevelAndEntityMetricDataAreListedAndAggregated() { + Instant now = Instant.now(); + query("PutMetricData") + .formParam("Namespace", "CWAgent/Query") + .formParam("MetricData.member.1.MetricName", "TopLevel") + .formParam("MetricData.member.1.Value", "1") + .formParam("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.key", "Type") + .formParam("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.value", "AWS::Resource") + .formParam("EntityMetricData.member.1.MetricData.member.1.MetricName", "mem_used_percent") + .formParam("EntityMetricData.member.1.MetricData.member.1.Value", "6.5") + .formParam("EntityMetricData.member.1.MetricData.member.1.Timestamp", now.toString()) + .formParam("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Name", "host") + .formParam("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Value", "h1") + .formParam("StrictEntityValidation", "false") + .when().post("/") + .then().statusCode(200); + + query("ListMetrics") + .formParam("Namespace", "CWAgent/Query") + .when().post("/") + .then() + .statusCode(200) + .body("ListMetricsResponse.ListMetricsResult.Metrics.member.MetricName", + containsInAnyOrder("TopLevel", "mem_used_percent")); + + query("GetMetricStatistics") + .formParam("Namespace", "CWAgent/Query") + .formParam("MetricName", "mem_used_percent") + .formParam("Dimensions.member.1.Name", "host") + .formParam("Dimensions.member.1.Value", "h1") + .formParam("StartTime", now.minusSeconds(120).toString()) + .formParam("EndTime", now.plusSeconds(120).toString()) + .formParam("Period", "60") + .formParam("Statistics.member.1", "Sum") + .when().post("/") + .then() + .statusCode(200) + .body("GetMetricStatisticsResponse.GetMetricStatisticsResult.Datapoints.member.Sum", + equalTo("6.5")); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java index 89628e6..9b57294 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java @@ -17,6 +17,12 @@ import java.math.BigDecimal; import java.time.Instant; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Handler-level tests for CloudWatchMetricsJsonHandler. @@ -146,6 +152,59 @@ class CloudWatchMetricsJsonHandlerTest { "metric stored with 24h-ago timestamp must not appear in a 20-second window around now"); } + // The CloudWatch Agent sends an empty top-level MetricData and nests every datum under + // EntityMetricData, floci-io/floci#6066. + @Test + void putMetricData_entityMetricDataOnly_storesNestedDatumsUnderTheRequestNamespace() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "CWAgent"); + req.putArray("MetricData"); + ObjectNode entityMetricData = req.putArray("EntityMetricData").addObject(); + ObjectNode entity = entityMetricData.putObject("Entity"); + entity.putObject("KeyAttributes") + .put("Type", "AWS::Resource") + .put("ResourceType", "AWS::EC2::Instance") + .put("Identifier", "i-0123456789abcdef0"); + entity.putObject("Attributes"); + ObjectNode datum = entityMetricData.putArray("MetricData").addObject(); + datum.put("MetricName", "mem_used_percent"); + datum.put("Value", 6.43); + datum.put("Unit", "Percent"); + datum.put("StorageResolution", 60); + datum.put("Timestamp", sdkTimestamp(EPOCH_NOW)); + datum.putArray("Dimensions").addObject().put("Name", "host").put("Value", "h1"); + req.put("StrictEntityValidation", false); + + assertEquals(200, handler.handle("PutMetricData", req, REGION).getStatus()); + + ObjectNode stats = getStats("CWAgent", "mem_used_percent", "host", "h1", + EPOCH_NOW.minusSeconds(60), EPOCH_NOW.plusSeconds(60), 60); + assertEquals(1, stats.get("Datapoints").size()); + assertEquals(6.43, stats.get("Datapoints").get(0).get("Sum").asDouble()); + } + + @Test + void putMetricData_topLevelAndEntityMetricData_storesBoth() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "Mixed"); + req.putArray("MetricData").addObject() + .put("MetricName", "TopLevel").put("Value", 1.0); + ArrayNode entityMetricData = req.putArray("EntityMetricData"); + entityMetricData.addObject().putArray("MetricData").addObject() + .put("MetricName", "FirstEntity").put("Value", 2.0); + entityMetricData.addObject().putArray("MetricData").addObject() + .put("MetricName", "SecondEntity").put("Value", 3.0); + + assertEquals(200, handler.handle("PutMetricData", req, REGION).getStatus()); + + ObjectNode listReq = MAPPER.createObjectNode(); + listReq.put("Namespace", "Mixed"); + ObjectNode listed = (ObjectNode) handler.handle("ListMetrics", listReq, REGION).getEntity(); + List names = new ArrayList<>(); + listed.get("Metrics").forEach(m -> names.add(m.get("MetricName").asText())); + assertEquals(List.of("FirstEntity", "SecondEntity", "TopLevel"), names.stream().sorted().toList()); + } + @Test void setAlarmState_updatesFieldsCorrectly() { putMetric("NS", "M", "type", "current", 100.0, EPOCH_NOW); diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java index 6f465c2..94e7e3f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java @@ -1,6 +1,8 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics; import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; import jakarta.ws.rs.core.MultivaluedHashMap; @@ -17,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -123,4 +126,59 @@ class CloudWatchMetricsQueryHandlerTest { anyInt(), any(), unitCaptor.capture(), any()); assertEquals("Count", unitCaptor.getValue()); } + + // The Query serialization of EntityMetricData nests each datum two lists deep; + // floci-io/floci#6066 is the JSON-side report of the same dropped datums. + @Test + void putMetricDataStoresTopLevelAndEntityMetricDataUnderTheRequestNamespace() { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("Namespace", "CWAgent"); + params.putSingle("MetricData.member.1.MetricName", "TopLevel"); + params.putSingle("MetricData.member.1.Value", "1"); + params.putSingle("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.key", "Type"); + params.putSingle("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.value", "AWS::Resource"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.MetricName", "mem_used_percent"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Value", "6.5"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Unit", "Percent"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Name", "host"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Value", "h1"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.MetricName", "disk_used_percent"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.Value", "0.75"); + params.putSingle("StrictEntityValidation", "false"); + + handler.handle("PutMetricData", params, REGION); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metricsService).putMetricData(eq("CWAgent"), captor.capture(), eq(REGION)); + List datums = captor.getValue(); + assertEquals(List.of("TopLevel", "mem_used_percent", "disk_used_percent"), + datums.stream().map(MetricDatum::getMetricName).toList()); + MetricDatum entityDatum = datums.get(1); + assertEquals(6.5, entityDatum.getValue()); + assertEquals("Percent", entityDatum.getUnit()); + assertEquals("host", entityDatum.getDimensions().get(0).name()); + assertEquals("h1", entityDatum.getDimensions().get(0).value()); + } + + @Test + void putMetricDataStoresEntityMetricDataAfterAnEmptyEntityEntry() { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("Namespace", "CWAgent"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.MetricName", "M"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.Value", "1"); + params.putSingle("EntityMetricData.member.10.MetricData.member.1.MetricName", "N"); + params.putSingle("EntityMetricData.member.10.MetricData.member.1.Value", "2"); + params.putSingle("StrictEntityValidation", "false"); + + handler.handle("PutMetricData", params, REGION); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metricsService).putMetricData(eq("CWAgent"), captor.capture(), eq(REGION)); + List datums = captor.getValue(); + assertEquals(List.of("M", "N"), datums.stream().map(MetricDatum::getMetricName).toList()); + assertEquals(1.0, datums.get(0).getValue()); + assertEquals(2.0, datums.get(1).getValue()); + } }