banshee is under active development: collectors will keep changing, and a version you publish will likely need adjustments. For stable use, wait for version 1.0.0.

Cloud

banshee/cloud-collection/cloud/aws/https/

service-quotas

The usage of the AWS service quotas of a region against their limit, by the usage metrics of CloudWatch and SERVICE_QUOTA().

  • cloud
  • aws
  • amazon
  • service-quotas
  • quotas
  • limits
  • cloudwatch
  • https
  • api

by banshee0 installations1 version

v1Latest

Published on October 27, 2026. First version.

Keen 1.0.0 · 196 lines
// The service quotas of an AWS region near their limit, by the usage metrics AWS sends to CloudWatch, with an
// access key: for each resource AWS counts against a quota (the vCPUs of a family of EC2 instances, the
// Elastic IPs, the tables of DynamoDB…), its usage, its quota and the part of the quota in use. A quota reached
// refuses the next resource.
// Reads GET https://monitoring.<region>.amazonaws.com/?Action=ListMetrics on the namespace AWS/Usage, then
// ?Action=GetMetricData by twenty resources at a time, each usage beside SERVICE_QUOTA() of it, signed by
// Signature Version 4.
need('protocol:http');
need('protocol:aws');
$region = variable('aws:region', 'text', array(
    'label' => 'Region',
    'help'  => 'The AWS region of the quotas, as AWS writes it in an API: eu-west-3, us-east-1.',
));
$service = variable('aws:service', 'text', array(
    'label'    => 'Service',
    'help'     => 'Only the quotas of this service, as the usage metrics name it: EC2, DynamoDB, Logs. Empty, every service that sends its usage.',
    'required' => false,
));

per('quota', 'Quotas');
output('used', 'number', array('label' => 'Part of the quota in use', 'unit' => '%', 'per' => 'quota'));
output('usage', 'number', array('label' => 'In use', 'per' => 'quota'));
output('limit', 'number', array('label' => 'Quota', 'per' => 'quota'));

watch.above('near-limit', 'Quota near its limit', 'used', array('warning' => 80, 'critical' => 95));

// One signed GET on an AWS API of the Query protocol (EC2, RDS, CloudWatch): the parameters in the address, the
// answer in XML. aws.sign gives the headers that carry the signature: the keys never leave. A refusal is told
// by the error code AWS writes in its answer; a throttled request or a fault of AWS is not one.
function aws_get($service, $region, $query, $what) {
    $url = 'https://' . $service . '.' . $region . '.amazonaws.com/?' . url.query($query);
    $answer = http.get($url, headers=aws.sign('GET', $url, array(), '', $region, $service));
    if ($answer['status'] != 200) {
        $code = '';
        $found = text.match($answer['body'], '<Code>([^<]+)</Code>');
        if (list.count($found) > 0) {
            $code = $found[1];
        }
        if ($code == 'Throttling' || $code == 'RequestLimitExceeded' || $answer['status'] >= 500) {
            fail('unreachable', 'AWS answers the reading of ' . $what . ' with code ' . $answer['status'] . ' ' . $code);
        }
        if ($answer['status'] == 400 || $answer['status'] == 401 || $answer['status'] == 403) {
            fail('misconfigured', 'AWS refuses to read ' . $what . ', code ' . $answer['status'] . ' ' . $code,
                'Check the access key, the permissions of its user or role, and the region.');
        }
        fail('unreachable', 'AWS answers the reading of ' . $what . ' with code ' . $answer['status']);
    }
    return xml.read($answer['body']);
}

// The first child of that name, array() when it is absent or empty. An empty element, such as a list without
// members written <name/>, reads as the text '' and not as an array.
function element($parent, $name) {
    $children = list.get($parent, $name, array());
    if (list.count($children) == 0 || !list.is($children[0])) {
        return array();
    }
    return $children[0];
}

// The text of the first child of that name, '' when it is absent.
function child_text($parent, $name) {
    $children = list.get($parent, $name, array());
    if (list.count($children) == 0) {
        return '';
    }
    return $children[0];
}

// Reads the usage and the quota of a batch of usage metrics, by one GetMetricData: for the metric n, the query
// u<n> reads its maximum over five minutes, and the query q<n> is SERVICE_QUOTA(u<n>). Each value read is
// rendered under the name of its metric; a metric without a quota (a usage AWS counts but does not limit) is
// left out. The values of a result come newest first.
function read_batch($region, $batch, $now) {
    $query = array(
        'Action'    => 'GetMetricData',
        'Version'   => '2010-08-01',
        'StartTime' => time.format($now - 3600, 'rfc3339'),
        'EndTime'   => time.format($now, 'rfc3339'),
    );
    $n = 0;
    foreach ($batch as $metric) {
        $n = $n + 1;
        $usage = 'MetricDataQueries.member.' . (2 * $n - 1);
        $query[$usage . '.Id'] = 'u' . $n;
        $query[$usage . '.MetricStat.Metric.Namespace'] = 'AWS/Usage';
        $query[$usage . '.MetricStat.Metric.MetricName'] = 'ResourceCount';
        $d = 0;
        foreach ($metric['dimensions'] as $name => $value) {
            $d = $d + 1;
            $query[$usage . '.MetricStat.Metric.Dimensions.member.' . $d . '.Name'] = $name;
            $query[$usage . '.MetricStat.Metric.Dimensions.member.' . $d . '.Value'] = $value;
        }
        $query[$usage . '.MetricStat.Period'] = 300;
        $query[$usage . '.MetricStat.Stat'] = 'Maximum';
        $limit = 'MetricDataQueries.member.' . (2 * $n);
        $query[$limit . '.Id'] = 'q' . $n;
        $query[$limit . '.Expression'] = 'SERVICE_QUOTA(u' . $n . ')';
    }
    // <GetMetricDataResponse><GetMetricDataResult><MetricDataResults><member><Id>u1</Id><Label>…</Label>
    // <StatusCode>Complete</StatusCode><Timestamps><member>2026-10-28T08:00:00Z</member>…</Timestamps><Values>
    // <member>17</member>…</Values></member>…</MetricDataResults></GetMetricDataResult>…</GetMetricDataResponse>
    $result = element(element(aws_get('monitoring', $region, $query, 'the usage and the quotas'), 'GetMetricDataResponse'),
        'GetMetricDataResult');
    $newest = array();
    foreach (list.get(element($result, 'MetricDataResults'), 'member', array()) as $series) {
        $values = list.get(element($series, 'Values'), 'member', array());
        if (list.count($values) > 0) {
            $newest[child_text($series, 'Id')] = $values[0];
        }
    }
    $n = 0;
    $read = 0;
    foreach ($batch as $metric) {
        $n = $n + 1;
        $usage = list.get($newest, 'u' . $n, '');
        $limit = list.get($newest, 'q' . $n, '');
        if ($usage != '' && $limit != '') {
            value('usage', $usage, $metric['name']);
            value('limit', $limit, $metric['name']);
            $read = $read + 1;
            // A quota of 0 is a resource the account may not use at all: nothing is in use of nothing.
            if ($limit > 0) {
                value('used', number.round($usage * 100 / $limit, 1), $metric['name']);
            }
        }
    }
    return $read;
}

// The region goes into the name of the host: it must be a name.
if (list.count(text.match($region, '^[a-z0-9-]+$')) == 0) {
    fail('misconfigured', 'The region holds other characters than lower-case letters, digits and hyphens');
}

// The usage metrics of the region: <ListMetricsResponse><ListMetricsResult><Metrics><member><Dimensions><member>
// <Name>Service</Name><Value>EC2</Value></member><member><Name>Type</Name><Value>Resource</Value></member>
// <member><Name>Resource</Name><Value>vCPU</Value></member><member><Name>Class</Name><Value>Standard/OnDemand
// </Value></member></Dimensions><MetricName>ResourceCount</MetricName><Namespace>AWS/Usage</Namespace></member>
// </Metrics><NextToken>…</NextToken></ListMetricsResult>…</ListMetricsResponse>. A quota is named by its
// Service, Resource and Class, joined by /: EC2/vCPU/Standard/OnDemand.
$metrics = array();
$token = '';
while (true) {
    $query = array('Action' => 'ListMetrics', 'Version' => '2010-08-01', 'Namespace' => 'AWS/Usage', 'MetricName' => 'ResourceCount');
    if ($service != '') {
        $query['Dimensions.member.1.Name'] = 'Service';
        $query['Dimensions.member.1.Value'] = $service;
    }
    if ($token != '') {
        $query['NextToken'] = $token;
    }
    $result = element(element(aws_get('monitoring', $region, $query, 'the usage metrics'), 'ListMetricsResponse'),
        'ListMetricsResult');
    foreach (list.get(element($result, 'Metrics'), 'member', array()) as $metric) {
        $dimensions = array();
        foreach (list.get(element($metric, 'Dimensions'), 'member', array()) as $dimension) {
            $dimensions[child_text($dimension, 'Name')] = child_text($dimension, 'Value');
        }
        $parts = array();
        foreach (array('Service', 'Resource', 'Class') as $key) {
            if (list.get($dimensions, $key, '') != '' && $dimensions[$key] != 'None') {
                $parts = list.add($parts, $dimensions[$key]);
            }
        }
        $metrics = list.add($metrics, array('name' => text.join($parts, '/'), 'dimensions' => $dimensions));
    }
    $token = child_text($result, 'NextToken');
    if ($token == '') {
        break;
    }
}
if (list.count($metrics) == 0) {
    fail('misconfigured', 'CloudWatch has no usage metric in ' . $region,
        'Check the region and the service: AWS sends the usage of some services only.');
}

// Twenty metrics a request, forty queries: the address stays short, far under the 500 queries GetMetricData takes.
$now = number.round(time.now());
$read = 0;
$batch = array();
foreach ($metrics as $metric) {
    $batch = list.add($batch, $metric);
    if (list.count($batch) == 20) {
        $read = $read + read_batch($region, $batch, $now);
        $batch = array();
    }
}
if (list.count($batch) > 0) {
    $read = $read + read_batch($region, $batch, $now);
}
// Usage metrics but not one quota read: the watch would never raise, and the probe would show green.
if ($read == 0) {
    fail('misconfigured', 'CloudWatch gives no quota for the ' . list.count($metrics) . ' usage metrics of ' . $region,
        'SERVICE_QUOTA returns nothing: the account may lack a permission of Service Quotas, servicequotas:GetServiceQuota.');
}