diff --git a/README.md b/README.md index 5ab45e40..4d104cd1 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ aws_tag_select | Optional. A tag configuration to filter on, based on mapping fr tag_selections | Optional, under `aws_tag_select`. Specify a map from a tag key to a list of tag values to apply [tag filtering](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/API_GetResources.html#resourcegrouptagging-GetResources-request-TagFilters) on resources from which metrics will be gathered. resource_type_selection | Required, under `aws_tag_select`. Specify the [resource type](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#genref-aws-service-namesspaces) to filter on. `resource_type_selection` should be comprised as `service:resource_type`, as per the [resource group tagging API](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/API_GetResources.html#resourcegrouptagging-GetResources-request-TagFilters). Where `resource_type` could be an empty string, like in S3 case: `resource_type_selection: "s3:"`. resource_id_dimension | Required, under `aws_tag_select`. For the current metric, specify which CloudWatch dimension maps to the ARN [resource ID](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#arns-syntax). + arn_resource_id_regexp | If the Cloudwatch dimension specified in `resource_id_dimension` doesn't conform to the convention for resource ID an alternative regular expression to extract the resource ID from the ARN can be given here. The default is `(?:([^:/]+)|[^:/]+/([^:]+))$`. The first non empty match group will be used. aws_statistics | Optional. A list of statistics to retrieve, values can include Sum, SampleCount, Minimum, Maximum, Average. Defaults to all statistics unless extended statistics are requested. aws_extended_statistics | Optional. A list of extended statistics to retrieve. Extended statistics currently include percentiles in the form `pN` or `pN.N`. delay_seconds | Optional. The newest data to request. Used to avoid collecting data that has not fully converged. Defaults to 600s. Can be set globally and per metric. diff --git a/examples/ApplicationELB.yml b/examples/ApplicationELB.yml index 7443e797..ec0f76a5 100644 --- a/examples/ApplicationELB.yml +++ b/examples/ApplicationELB.yml @@ -4,8 +4,20 @@ metrics: aws_metric_name: HealthyHostCount aws_dimensions: - LoadBalancer - aws_statistics: - - Sum + - TargetGroup + aws_statistics: + - Minimum + # In case you want to use some tag to select target group to monitor, or to have additional `info` metric + # with all target group tags as labels, use `aws_tag_select`. + # Since the TargetGroup dimension doesn't follow the convention for how to extract resource ids from ARN + # `arn_resource_id_regexp` is specified with an alternative regular expression. + aws_tag_select: + resource_type_selection: elasticloadbalancing:targetgroup + resource_id_dimension: TargetGroup + arn_resource_id_regexp: "(targetgroup/.*)$" + tag_selections: + Environment: + - production - aws_namespace: AWS/ApplicationELB aws_metric_name: UnHealthyHostCount aws_dimensions: diff --git a/examples/WAFV2.yml b/examples/WAFV2.yml index 126d1ee2..00aed43f 100644 --- a/examples/WAFV2.yml +++ b/examples/WAFV2.yml @@ -9,6 +9,17 @@ metrics: aws_namespace: AWS/WAFV2 aws_statistics: - Sum + # In case you want to use some tag to select web acls to monitor, or to have additional `info` metric + # with all web acl tags as labels, use `aws_tag_select`. + # Since the WebACL dimension doesn't follow the convention for how to extract resource ids from ARN + # `arn_resource_id_regexp` is specified with an alternative regular expression. + aws_tag_select: + resource_type_selection: wafv2:regional/webacl + resource_id_dimension: WebACL + arn_resource_id_regexp: "([^/]+)/[^/]+$" + tag_selections: + Environment: + - production - aws_dimensions: - Region - Rule diff --git a/src/main/java/io/prometheus/cloudwatch/CloudWatchCollector.java b/src/main/java/io/prometheus/cloudwatch/CloudWatchCollector.java index cb34407f..71ff755c 100644 --- a/src/main/java/io/prometheus/cloudwatch/CloudWatchCollector.java +++ b/src/main/java/io/prometheus/cloudwatch/CloudWatchCollector.java @@ -21,6 +21,8 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -44,6 +46,11 @@ public class CloudWatchCollector extends Collector implements Describable { private static final Logger LOGGER = Logger.getLogger(CloudWatchCollector.class.getName()); + // ARN parsing is based on + // https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html + private static final Pattern DEFAULT_ARN_RESOURCE_ID_REGEXP = + Pattern.compile("(?:([^:/]+)|[^:/]+/([^:]+))$"); + static class ActiveConfig { ArrayList rules; CloudWatchClient cloudWatchClient; @@ -64,6 +71,7 @@ static class AWSTagSelect { String resourceTypeSelection; String resourceIdDimension; Map> tagSelections; + Pattern arnResourceIdRegexp; } ActiveConfig activeConfig = new ActiveConfig(); @@ -314,6 +322,10 @@ private void loadConfig( awsTagSelect.tagSelections = (Map>) yamlAwsTagSelect.get("tag_selections"); } + if (yamlAwsTagSelect.containsKey("arn_resource_id_regexp")) { + awsTagSelect.arnResourceIdRegexp = + Pattern.compile((String) yamlAwsTagSelect.get("arn_resource_id_regexp")); + } } if (yamlMetricRule.containsKey("list_metrics_cache_ttl")) { @@ -394,14 +406,23 @@ private List getResourceTagMappings( return resourceTagMappings; } - private List extractResourceIds(List resourceTagMappings) { + private List extractResourceIds( + Pattern arnResourceIdRegexp, List resourceTagMappings) { List resourceIds = new ArrayList<>(); for (ResourceTagMapping resourceTagMapping : resourceTagMappings) { - resourceIds.add(extractResourceIdFromArn(resourceTagMapping.resourceARN())); + resourceIds.add( + extractResourceIdFromArn(resourceTagMapping.resourceARN(), arnResourceIdRegexp)); } return resourceIds; } + private static Pattern getArnResourceIdRegexp(MetricRule rule) { + if (rule.awsTagSelect != null && rule.awsTagSelect.arnResourceIdRegexp != null) { + return rule.awsTagSelect.arnResourceIdRegexp; + } + return DEFAULT_ARN_RESOURCE_ID_REGEXP; + } + private String toSnakeCase(String str) { return str.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); } @@ -477,7 +498,9 @@ private void scrape(List mfs) { List resourceTagMappings = getResourceTagMappings(rule, config.taggingClient); - List tagBasedResourceIds = extractResourceIds(resourceTagMappings); + Pattern arnResourceIdRegexp = getArnResourceIdRegexp(rule); + List tagBasedResourceIds = + extractResourceIds(arnResourceIdRegexp, resourceTagMappings); List> dimensionList = config.dimensionSource.getDimensions(rule, tagBasedResourceIds).getDimensions(); @@ -609,7 +632,8 @@ private void scrape(List mfs) { labelNames.add("arn"); labelValues.add(resourceTagMapping.resourceARN()); labelNames.add(safeLabelName(toSnakeCase(rule.awsTagSelect.resourceIdDimension))); - labelValues.add(extractResourceIdFromArn(resourceTagMapping.resourceARN())); + labelValues.add( + extractResourceIdFromArn(resourceTagMapping.resourceARN(), arnResourceIdRegexp)); for (Tag tag : resourceTagMapping.tags()) { // Avoid potential collision between resource tags and other metric labels by adding the // "tag_" prefix @@ -671,16 +695,17 @@ public List collect() { return mfs; } - private String extractResourceIdFromArn(String arn) { - // ARN parsing is based on - // https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html - String[] arnArray = arn.split(":"); - String resourceId = arnArray[arnArray.length - 1]; - if (resourceId.contains("/")) { - String[] resourceArray = resourceId.split("/", 2); - resourceId = resourceArray[resourceArray.length - 1]; + private String extractResourceIdFromArn(String arn, Pattern arnResourceIdRegexp) { + final Matcher matcher = arnResourceIdRegexp.matcher(arn); + if (matcher.find()) { + for (int i = 1; i <= matcher.groupCount(); i++) { + final String group = matcher.group(i); + if (group != null && !group.isEmpty()) { + return group; + } + } } - return resourceId; + return ""; } /** Convenience function to run standalone. */ diff --git a/src/test/java/io/prometheus/cloudwatch/CloudWatchCollectorTest.java b/src/test/java/io/prometheus/cloudwatch/CloudWatchCollectorTest.java index 1ae8f286..796e32e6 100644 --- a/src/test/java/io/prometheus/cloudwatch/CloudWatchCollectorTest.java +++ b/src/test/java/io/prometheus/cloudwatch/CloudWatchCollectorTest.java @@ -1136,6 +1136,206 @@ public void testTagSelectEC2() throws Exception { .01); } + @Test + public void testTagSelectWebACL() { + // Testing "aws_tag_select" with an WAF WebAcl, which have a non-standard resource id in + // metrics. + // The regexp to get the resource id from the arn is specified in the rule + new CloudWatchCollector( + "---\nregion: eu-west-1\nmetrics:\n- aws_namespace: AWS/WAFV2\n aws_metric_name: CountedRequests\n aws_dimensions: [Region, Rule, WebACL]\n aws_tag_select:\n resource_type_selection: \"wafv2:regional/webacl\"\n resource_id_dimension: WebACL\n arn_resource_id_regexp: \"([^/]+)/[^/]+$\"\n", + cloudWatchClient, + taggingClient) + .register(registry); + + Mockito.when( + taggingClient.getResources( + argThat( + new GetResourcesRequestMatcher().ResourceTypeFilter("wafv2:regional/webacl")))) + .thenReturn( + GetResourcesResponse.builder() + .resourceTagMappingList( + ResourceTagMapping.builder() + .tags(Tag.builder().key("Monitoring").value("enabled").build()) + .resourceARN( + "arn:aws:wafv2:eu-west-1:123456789:regional/webacl/svc-integration-xxxx/d177aaf1-b18f-4f84-aa8e-f1c5c40fc426") + .build()) + .build()); + + Mockito.when( + cloudWatchClient.listMetrics( + argThat( + new ListMetricsRequestMatcher() + .Namespace("AWS/WAFV2") + .MetricName("CountedRequests") + .Dimensions("Region", "Rule", "WebACL")))) + .thenReturn( + ListMetricsResponse.builder() + .metrics( + Metric.builder() + .dimensions( + Dimension.builder().name("Region").value("eu-west-1").build(), + Dimension.builder().name("Rule").value("WebAclLog").build(), + Dimension.builder() + .name("WebACL") + .value("svc-integration-xxxx") + .build()) + .build()) + .build()); + + Mockito.when( + cloudWatchClient.getMetricStatistics( + argThat( + new GetMetricStatisticsRequestMatcher() + .Namespace("AWS/WAFV2") + .MetricName("CountedRequests") + .Dimension("Region", "eu-west-1") + .Dimension("Rule", "WebAclLog") + .Dimension("WebACL", "svc-integration-xxxx")))) + .thenReturn( + GetMetricStatisticsResponse.builder() + .datapoints( + Datapoint.builder().timestamp(new Date().toInstant()).sum(200.0).build()) + .build()); + + assertEquals( + 200.0, + registry.getSampleValue( + "aws_wafv2_counted_requests_sum", + new String[] {"job", "instance", "region", "rule", "web_acl"}, + new String[] {"aws_wafv2", "", "eu-west-1", "WebAclLog", "svc-integration-xxxx"}), + .01); + assertEquals( + 1.0, + registry.getSampleValue( + "aws_resource_info", + new String[] {"job", "instance", "arn", "web_acl", "tag_Monitoring"}, + new String[] { + "aws_wafv2", + "", + "arn:aws:wafv2:eu-west-1:123456789:regional/webacl/svc-integration-xxxx/d177aaf1-b18f-4f84-aa8e-f1c5c40fc426", + "svc-integration-xxxx", + "enabled" + }), + .01); + } + + @Test + public void testTagSelectTargetGroup() { + // Testing "aws_tag_select" with an ALB target group, which have a non-standard resource id in + // metrics + // The regexp to get the resource id from the arn is specified in the rule + new CloudWatchCollector( + "---\nregion: reg\nmetrics:\n- aws_namespace: AWS/ApplicationELB\n aws_metric_name: UnHealthyHostCount\n aws_dimensions:\n - TargetGroup\n - LoadBalancer\n aws_tag_select:\n resource_type_selection: \"elasticloadbalancing:targetgroup\"\n resource_id_dimension: TargetGroup\n tag_selections:\n Monitoring: [enabled]\n arn_resource_id_regexp: \"(targetgroup/.*)$\"\n", + cloudWatchClient, + taggingClient) + .register(registry); + + Mockito.when( + taggingClient.getResources( + argThat( + new GetResourcesRequestMatcher() + .ResourceTypeFilter("elasticloadbalancing:targetgroup") + .TagFilter("Monitoring", List.of("enabled"))))) + .thenReturn( + GetResourcesResponse.builder() + .resourceTagMappingList( + ResourceTagMapping.builder() + .tags(Tag.builder().key("Monitoring").value("enabled").build()) + .resourceARN( + "arn:aws:elasticloadbalancing:us-east-1:121212121212:targetgroup/abc-123") + .build(), + ResourceTagMapping.builder() + .tags(Tag.builder().key("Monitoring").value("enabled").build()) + .resourceARN( + "arn:aws:elasticloadbalancing:us-east-1:121212121212:targetgroup/abc-234") + .build()) + .build()); + + Mockito.when( + cloudWatchClient.listMetrics( + argThat( + new ListMetricsRequestMatcher() + .Namespace("AWS/ApplicationELB") + .MetricName("UnHealthyHostCount") + .Dimensions("TargetGroup", "LoadBalancer")))) + .thenReturn( + ListMetricsResponse.builder() + .metrics( + Metric.builder() + .dimensions( + Dimension.builder() + .name("TargetGroup") + .value("targetgroup/abc-123") + .build(), + Dimension.builder().name("LoadBalancer").value("app/myLB/123").build()) + .build(), + Metric.builder() + .dimensions( + Dimension.builder() + .name("TargetGroup") + .value("targetgroup/abc-234") + .build(), + Dimension.builder().name("LoadBalancer").value("app/myLB/123").build()) + .build()) + .build()); + + Mockito.when( + cloudWatchClient.getMetricStatistics( + argThat( + new GetMetricStatisticsRequestMatcher() + .Namespace("AWS/ApplicationELB") + .MetricName("UnHealthyHostCount") + .Dimension("TargetGroup", "targetgroup/abc-123") + .Dimension("LoadBalancer", "app/myLB/123")))) + .thenReturn( + GetMetricStatisticsResponse.builder() + .datapoints( + Datapoint.builder().timestamp(new Date().toInstant()).average(2.0).build()) + .build()); + + Mockito.when( + cloudWatchClient.getMetricStatistics( + argThat( + new GetMetricStatisticsRequestMatcher() + .Namespace("AWS/ApplicationELB") + .MetricName("UnHealthyHostCount") + .Dimension("TargetGroup", "targetgroup/abc-234") + .Dimension("LoadBalancer", "app/myLB/123")))) + .thenReturn( + GetMetricStatisticsResponse.builder() + .datapoints( + Datapoint.builder().timestamp(new Date().toInstant()).average(3.0).build()) + .build()); + + assertEquals( + 2.0, + registry.getSampleValue( + "aws_applicationelb_un_healthy_host_count_average", + new String[] {"job", "instance", "target_group", "load_balancer"}, + new String[] {"aws_applicationelb", "", "targetgroup/abc-123", "app/myLB/123"}), + .01); + assertEquals( + 3.0, + registry.getSampleValue( + "aws_applicationelb_un_healthy_host_count_average", + new String[] {"job", "instance", "target_group", "load_balancer"}, + new String[] {"aws_applicationelb", "", "targetgroup/abc-234", "app/myLB/123"}), + .01); + assertEquals( + 1.0, + registry.getSampleValue( + "aws_resource_info", + new String[] {"job", "instance", "arn", "target_group", "tag_Monitoring"}, + new String[] { + "aws_applicationelb", + "", + "arn:aws:elasticloadbalancing:us-east-1:121212121212:targetgroup/abc-123", + "targetgroup/abc-123", + "enabled" + }), + .01); + } + @Test public void testTagSelectALB() throws Exception { // Testing "aws_tag_select" with an ALB, which have a fairly complex ARN