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/azure/https/

metrics

The newest value of one Azure Monitor metric of one Azure resource, and its age, by Azure Resource Manager.

  • cloud
  • azure
  • azure-monitor
  • metrics
  • insights
  • https
  • api

by banshee1 installation2 versions

Keen 1.0.0 · 225 lines
// One metric of one Azure resource, by Azure Monitor, logged in as a service principal: its newest value over
// the last thirty minutes, in the aggregation chosen, and how old that value is. Any resource, any metric
// Azure Monitor keeps for it: the resource and the metric are set on the probe.
// Reads POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token, then GET
// <resource>/providers/Microsoft.Insights/metrics on management.azure.com.
need('protocol:http');
$client = need('protocol:http:user');
$secret = need('protocol:http:password');
$tenant = variable('azure:tenant', 'text', array(
    'label' => 'Tenant',
    'help'  => 'The directory (tenant) ID of the service principal, or one of the tenant\'s domains.',
));
$resource = variable('azure:resource', 'text', array(
    'label' => 'Resource ID',
    'help'  => 'The full ID of the resource, as its page "JSON View" gives it: /subscriptions/…/resourceGroups/…/providers/….',
));
$metric = variable('azure:metric', 'text', array(
    'label' => 'Metric',
    'help'  => 'The name of the metric as Azure Monitor knows it, for instance Percentage CPU for a virtual machine.',
));
$aggregation = variable('azure:aggregation', 'choice', array(
    'label'   => 'Aggregation',
    'help'    => 'How the values of each minute are combined: the one the metric supports, its default aggregation at best.',
    'choices' => array('average', 'minimum', 'maximum', 'total', 'count'),
    'default' => 'average',
));
$namespace = variable('azure:metric-namespace', 'text', array(
    'label'    => 'Metric namespace',
    'help'     => 'The namespace of the metric, only when it is not the resource\'s own, for instance a guest metric.',
    'required' => false,
));

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' => 15));

// Log in as the service principal, by the client credentials grant. The secret goes in the form, which makes
// the answer secret, and its access token with it. The token is kept in the cache, encrypted on the poller,
// thirty minutes, under the hour or so Microsoft gives it.
function login($tenant, $client, $secret, $scope) {
    $body = url.query(array('grant_type' => 'client_credentials', 'client_id' => $client,
        'client_secret' => $secret, 'scope' => $scope));
    $answer = http.post('https://login.microsoftonline.com/' . url.encode($tenant) . '/oauth2/v2.0/token', $body,
        headers=array('Content-Type' => 'application/x-www-form-urlencoded'));
    if ($answer['status'] == 400 || $answer['status'] == 401) {
        fail('misconfigured', 'Microsoft Entra ID refuses the service principal, code ' . $answer['status']);
    }
    if ($answer['status'] != 200) {
        fail('unreachable', 'Microsoft Entra ID answers the login with code ' . $answer['status']);
    }
    $session = json.read($answer['body']);
    cache.set('token', $session['access_token'], 1800);
    return $session['access_token'];
}

// One GET on Azure Resource Manager with the token kept in the cache; a token Azure no longer takes answers
// 401, and the collector logs in again, once.
function arm_get($url, $what, $tenant, $client, $secret) {
    if (cache.has('token')) {
        $token = cache.get('token');
    } else {
        $token = login($tenant, $client, $secret, 'https://management.azure.com/.default');
    }
    $answer = http.get($url, headers=array('Authorization' => 'Bearer ' . $token));
    if ($answer['status'] == 401) {
        $token = login($tenant, $client, $secret, 'https://management.azure.com/.default');
        $answer = http.get($url, headers=array('Authorization' => 'Bearer ' . $token));
    }
    if ($answer['status'] == 403) {
        fail('misconfigured', 'The service principal may not read ' . $what . ', code 403');
    }
    if ($answer['status'] == 400 || $answer['status'] == 404) {
        fail('misconfigured', 'Azure refuses to read ' . $what . ', code ' . $answer['status'],
            'Check the IDs and names set on the probe.');
    }
    if ($answer['status'] != 200) {
        fail('unreachable', 'Azure answers the reading of ' . $what . ' with code ' . $answer['status']);
    }
    return json.read($answer['body']);
}

function is_leap($year) {
    return $year % 4 == 0 && ($year % 100 != 0 || $year % 400 == 0);
}

function two_digits($n) {
    if ($n < 10) {
        return '0' . $n;
    }
    return '' . $n;
}

// The time written 2026-10-27T08:05:00Z of a whole number of seconds since 1 January 1970 UTC: the form of the
// timespan Azure Monitor reads.
function utc_text($epoch) {
    $second = $epoch % 60;
    $minute = ($epoch % 3600 - $second) / 60;
    $hour = ($epoch % 86400 - $epoch % 3600) / 3600;
    $days = ($epoch - $epoch % 86400) / 86400;
    $year = 1970;
    while (true) {
        $length = 365;
        if (is_leap($year)) {
            $length = 366;
        }
        if ($days < $length) {
            break;
        }
        $days = $days - $length;
        $year = $year + 1;
    }
    $months = array(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31);
    if (is_leap($year)) {
        $months[1] = 29;
    }
    $month = 1;
    foreach ($months as $length) {
        if ($days < $length) {
            break;
        }
        $days = $days - $length;
        $month = $month + 1;
    }
    return $year . '-' . two_digits($month) . '-' . two_digits($days + 1) . 'T' . two_digits($hour) . ':'
        . two_digits($minute) . ':' . two_digits($second) . 'Z';
}

// The seconds from 1 January 2000 to a date and time, all in the same time.
function local_seconds($year, $month, $day, $hour, $minute, $second) {
    $before = array(0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334);
    $days = $before[$month - 1] + $day - 1;
    if ($month > 2 && is_leap($year)) {
        $days = $days + 1;
    }
    for ($y = 2000; $y < $year; $y = $y + 1) {
        $days = $days + 365;
        if (is_leap($y)) {
            $days = $days + 1;
        }
    }
    return $days * 86400 + $hour * 3600 + $minute * 60 + $second;
}

// The seconds since 1 January 1970 UTC of a time written 2026-10-27T08:05:00Z (or with a fraction of a
// second, or an offset +01:00); '' when the text is not such a time.
function epoch_of($time) {
    $m = text.match($time, '^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|([+-])([0-9]{2}):?([0-9]{2}))$');
    if (list.count($m) == 0) {
        return '';
    }
    // 946684800: the seconds from 1970 to 2000. Each part is turned into a number, so that it compares as one.
    $seconds = local_seconds($m[1] + 0, $m[2] + 0, $m[3] + 0, $m[4] + 0, $m[5] + 0, $m[6] + 0) + 946684800;
    if ($m[8] != 'Z') {
        $shift = $m[10] * 3600 + $m[11] * 60;
        if ($m[9] == '+') {
            $seconds = $seconds - $shift;
        } else {
            $seconds = $seconds + $shift;
        }
    }
    return $seconds;
}

// The resource ID goes into the path as it is written: it must be one.
if (list.count(text.match($resource, '^/subscriptions/[^?#]+$')) == 0) {
    fail('misconfigured', 'The resource ID does not start with /subscriptions/', 'Copy it from the page "JSON View" of the resource.');
}

// The last thirty minutes, a value a minute, or the finest grain the metric has (AutoAdjustTimegrain).
$now = number.round(time.now());
$query = array(
    'api-version'         => '2023-10-01',
    'metricnames'         => $metric,
    'aggregation'         => $aggregation,
    'timespan'            => utc_text($now - 1800) . '/' . utc_text($now),
    'interval'            => 'PT1M',
    'AutoAdjustTimegrain' => 'true',
);
if ($namespace != '') {
    $query['metricnamespace'] = $namespace;
}
$answer = arm_get('https://management.azure.com' . $resource . '/providers/Microsoft.Insights/metrics?' . url.query($query),
    'the metric ' . $metric, $tenant, $client, $secret);

// {"value": [{"name": {"value": "Percentage CPU"}, "unit": "Percent", "errorCode": "Success", "timeseries":
// [{"data": [{"timeStamp": "2026-10-27T08:04:00Z", "average": 12.5}, {"timeStamp": "2026-10-27T08:05:00Z"}]}]}]}.
// A minute not yet computed comes without the aggregation, or with null: the newest value is that of the last
// minute that carries it.
$found = list.get($answer, 'value', array());
if (list.count($found) == 0) {
    fail('misconfigured', 'Azure Monitor returns no metric ' . $metric);
}
$result = $found[0];
if (list.get($result, 'errorCode', 'Success') != 'Success') {
    fail('misconfigured', 'Azure Monitor cannot read the metric ' . $metric . ': ' . $result['errorCode'],
        list.get($result, 'errorMessage', ''));
}
$last = '';
$at = '';
foreach (list.get($result, 'timeseries', array()) as $series) {
    foreach (list.get($series, 'data', array()) as $point) {
        if (list.get($point, $aggregation, '') != '') {
            $last = $point[$aggregation];
            $at = $point['timeStamp'];
        }
    }
    break;
}
if ($last == '') {
    fail('unreachable', 'Azure Monitor has no value of ' . $metric . ' in the last thirty minutes');
}
$epoch = epoch_of($at);
if ($epoch == '') {
    fail('unreachable', 'Azure Monitor writes a time it does not read: ' . $at);
}
value('value', $last);
value('age', number.round(($now - $epoch) / 60, 1));