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/

cloudwatch-metric

The newest value of one CloudWatch metric of an AWS region, and its age, by the CloudWatch API.

  • cloud
  • aws
  • amazon
  • cloudwatch
  • metrics
  • https
  • api

by banshee0 installations1 version

v1Latest

Published on October 27, 2026. First version.

Keen 1.0.0 · 142 lines
// One CloudWatch metric of an AWS region, by the CloudWatch API with an access key: its newest value over the
// last hour, in the statistic chosen, and how old that value is. Any namespace, any metric, any dimensions: the
// metric is set on the probe.
// Reads GET https://monitoring.<region>.amazonaws.com/?Action=GetMetricStatistics, 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 metric, as AWS writes it in an API: eu-west-3, us-east-1.',
));
$namespace = variable('aws:namespace', 'text', array(
    'label' => 'Namespace',
    'help'  => 'The namespace of the metric, for instance AWS/EC2, AWS/RDS or a custom namespace.',
));
$metric = variable('aws:metric', 'text', array(
    'label' => 'Metric',
    'help'  => 'The name of the metric as CloudWatch knows it, for instance CPUUtilization.',
));
$dimensions = variable('aws:dimensions', 'multiline', array(
    'label'    => 'Dimensions',
    'help'     => 'One dimension per line, Name=Value, for instance InstanceId=i-0123456789abcdef0: every dimension the metric is published with.',
    'required' => false,
));
$statistic = variable('aws:statistic', 'choice', array(
    'label'   => 'Statistic',
    'help'    => 'How the values of each period are combined.',
    'choices' => array('Average', 'Minimum', 'Maximum', 'Sum', 'SampleCount'),
    'default' => 'Average',
));
$period = variable('aws:period', 'integer', array(
    'label'   => 'Period',
    'help'    => 'The length of each period, in seconds, a multiple of 60: 300 for the basic monitoring of EC2, 60 for its detailed monitoring.',
    'default' => 300,
));

output('value', 'number', array('label' => 'Value'));
output('age', 'number', array('label' => 'Age of the value', 'unit' => 'min'));

watch.above('high', 'Value too high', 'value', array(
    'help' => 'Raises when the value goes above the thresholds set on the probe, in the unit of the metric.',
));
watch.below('low', 'Value too low', 'value', array(
    'help'    => 'Raises when the value goes below the thresholds set on the probe, in the unit of the metric.',
    'enabled' => false,
));
watch.above('stale', 'Value not fresh', 'age', array('warning' => 30));

// 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];
}

// 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');
}
if ($period < 60 || $period % 60 != 0) {
    fail('misconfigured', 'The period is not a multiple of 60 seconds', 'CloudWatch takes 60, 120, 300… for a metric of standard resolution.');
}

// The last hour, rounded down to the minute as CloudWatch does, the times written in UTC.
$now = number.round(time.now());
$query = array(
    'Action'              => 'GetMetricStatistics',
    'Version'             => '2010-08-01',
    'Namespace'           => $namespace,
    'MetricName'          => $metric,
    'StartTime'           => time.format($now - 3600, 'rfc3339'),
    'EndTime'             => time.format($now, 'rfc3339'),
    'Period'              => $period,
    'Statistics.member.1' => $statistic,
);
$n = 0;
foreach (text.lines($dimensions) as $line) {
    $pair = text.match($line, '^([^=]+)=(.*)$');
    if (list.count($pair) == 0) {
        fail('misconfigured', 'A dimension is not written Name=Value: ' . $line);
    }
    $n = $n + 1;
    $query['Dimensions.member.' . $n . '.Name'] = text.trim($pair[1]);
    $query['Dimensions.member.' . $n . '.Value'] = text.trim($pair[2]);
}

// <GetMetricStatisticsResponse><GetMetricStatisticsResult><Label>CPUUtilization</Label><Datapoints><member>
// <Timestamp>2026-10-28T08:05:00Z</Timestamp><Average>12.5</Average><Unit>Percent</Unit></member></Datapoints>
// </GetMetricStatisticsResult>…</GetMetricStatisticsResponse>. AWS gives the points in no order: the newest is
// looked for.
$result = element(element(aws_get('monitoring', $region, $query, 'the metric ' . $metric), 'GetMetricStatisticsResponse'),
    'GetMetricStatisticsResult');
$last = '';
$at = 0;
foreach (list.get(element($result, 'Datapoints'), 'member', array()) as $point) {
    $time = time.parse(child_text($point, 'Timestamp'), 'rfc3339');
    if ($time > $at) {
        $at = $time;
        $last = child_text($point, $statistic);
    }
}
if ($last == '') {
    fail('unreachable', 'CloudWatch has no value of ' . $metric . ' in the last hour',
        'A name, a namespace or a dimension that CloudWatch does not know gives no value either: check them.');
}
value('value', $last);
value('age', number.round(($now - $at) / 60, 1));