diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdbc60..8d1a00c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Change Log All notable changes to this project are noted in this file. This project adheres to [Semantic Versioning](http://semver.org/). -0.3.2 +0.4.1 ----- - Fixed issue (https://github.com/blue-yonder/azure-cost-mon/issues/12) @@ -13,6 +13,14 @@ Versioning](http://semver.org/). resets within Prometheus than necessary, so that `increase` gave wrong results! + +0.4.0 +----- + +- Use the `X-Prometheus-Scrape-Timeout-Seconds` header sent by + prometheus to overwrite the internal request timeout default. + + 0.3.1 ----- diff --git a/azure_costs_exporter/main.py b/azure_costs_exporter/main.py index b82950f..555ba2f 100644 --- a/azure_costs_exporter/main.py +++ b/azure_costs_exporter/main.py @@ -1,31 +1,15 @@ from os import getcwd from flask import Flask -from prometheus_client import REGISTRY -from azure_costs_exporter.prometheus_collector import AzureEABillingCollector def create_app(): from azure_costs_exporter.views import bp - app = Flask(__name__) app.config.from_pyfile(getcwd()+"/application.cfg") app.register_blueprint(bp) - - collector = AzureEABillingCollector( - app.config['PROMETHEUS_METRIC_NAME'], - app.config['ENROLLMENT_NUMBER'], - app.config['BILLING_API_ACCESS_KEY'] - ) - try: - REGISTRY.register(collector) - except ValueError: - #don't register multiple times - pass - return app - if __name__ == "__main__": app = create_app() app.run(debug=True, host='0.0.0.0') diff --git a/azure_costs_exporter/prometheus_collector.py b/azure_costs_exporter/prometheus_collector.py index 618ddba..c728c82 100644 --- a/azure_costs_exporter/prometheus_collector.py +++ b/azure_costs_exporter/prometheus_collector.py @@ -1,7 +1,7 @@ import requests import datetime from pandas import DataFrame -from prometheus_client.core import CounterMetricFamily, Metric +from prometheus_client.core import CounterMetricFamily base_columns = ['DepartmentName', 'AccountName', 'SubscriptionName', 'MeterCategory', 'MeterSubCategory', 'MeterName', 'ResourceGroup'] @@ -38,17 +38,19 @@ class AzureEABillingCollector(object): in Prometheus compatible format. """ - def __init__(self, metric_name, enrollment, token): + def __init__(self, metric_name, enrollment, token, timeout): """ Constructor. :param metric_name: Name of the timeseries :param enrollment: ID of the enterprise agreement (EA) :param token: Access Key generated via the EA portal + :param timeout: Timeout to use for the request against the EA portal """ self._metric_name = metric_name self._enrollment = enrollment self._token = token + self._timeout = timeout def _get_azure_data(self, month=None): """ @@ -65,7 +67,7 @@ def _get_azure_data(self, month=None): url = "https://ea.azure.com/rest/{0}/usage-report?month={1}&type=detail&fmt=Json".format(self._enrollment, month) - rsp = requests.get(url, headers=headers, timeout=10) + rsp = requests.get(url, headers=headers, timeout=self._timeout) rsp.raise_for_status() if rsp.text.startswith('"Usage Data Extract"'): @@ -106,4 +108,4 @@ def collect(self): for name, value in groups.iterrows(): c.add_metric(name, int(round(value.ExtendedCost))) - yield c \ No newline at end of file + yield c diff --git a/azure_costs_exporter/views.py b/azure_costs_exporter/views.py index 6ee1379..9827f5c 100644 --- a/azure_costs_exporter/views.py +++ b/azure_costs_exporter/views.py @@ -1,7 +1,19 @@ -from flask import Blueprint, Response, abort -from prometheus_client import generate_latest, CONTENT_TYPE_LATEST +from flask import Blueprint, Response, abort, current_app, request +from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, generate_latest + +from .prometheus_collector import AzureEABillingCollector + bp = Blueprint('views', __name__) +DEFAULT_SCRAPE_TIMEOUT = 10 + + +def _get_timeout(): + try: + return float(request.headers.get('X-Prometheus-Scrape-Timeout-Seconds')) + except Exception: + return DEFAULT_SCRAPE_TIMEOUT + @bp.route("/health") def health(): @@ -10,8 +22,17 @@ def health(): @bp.route("/metrics") def metrics(): + timeout = _get_timeout() + collector = AzureEABillingCollector( + current_app.config['PROMETHEUS_METRIC_NAME'], + current_app.config['ENROLLMENT_NUMBER'], + current_app.config['BILLING_API_ACCESS_KEY'], + timeout + ) + registry = CollectorRegistry() + registry.register(collector) try: - content = generate_latest() + content = generate_latest(registry) return content, 200, {'Content-Type': CONTENT_TYPE_LATEST} except Exception as e: abort(Response("Scrape failed: {}".format(e), status=502)) diff --git a/tests/test_app.py b/tests/test_app.py index 11221ca..3b0559b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,8 +1,10 @@ +import datetime + import pytest import responses -import datetime from azure_costs_exporter.main import create_app +from azure_costs_exporter.views import DEFAULT_SCRAPE_TIMEOUT from .data import sample_data, api_output_for_empty_months @@ -46,19 +48,30 @@ def test_token(client, now, enrollment, access_key): assert responses.calls[-1].request.headers['Authorization'] == "Bearer {}".format(access_key) -@responses.activate -def test_metrics(app, now, enrollment): - - responses.add( - method='GET', - url="https://ea.azure.com/rest/{0}/usage-report?month={1}&type=detail&fmt=Json".format(enrollment, now), - match_querystring=True, - json=sample_data - ) +@pytest.mark.parametrize('timeout,expected', [('42.3', 42.3), (None, DEFAULT_SCRAPE_TIMEOUT)]) +def test_metrics(app, access_key, now, enrollment, timeout, expected): + class RequestsMock(responses.RequestsMock): + def get(self, *args, **kwargs): + assert kwargs['timeout'] == expected + return super(RequestsMock, self).get(*args, **kwargs) - rsp = app.test_client().get('/metrics') - assert rsp.status_code == 200 - assert rsp.data.count(b'azure_costs_eur') == 4 + with RequestsMock() as resp: + resp.add( + method='GET', + url="https://ea.azure.com/rest/{0}/usage-report?month={1}&type=detail&fmt=Json".format(enrollment, now), + match_querystring=True, + json=sample_data + ) + + headers = {} + if timeout is not None: + headers = {'X-Prometheus-Scrape-Timeout-Seconds': timeout} + + rsp = app.test_client().get('/metrics', headers=headers) + url = 'https://ea.azure.com/rest/{enrollment}/usage-report?month={month}&type=detail&fmt=Json' + url = url.format(enrollment=enrollment, month=now) + assert rsp.status_code == 200 + assert rsp.data.count(b'azure_costs_eur') == 4 @responses.activate @@ -80,24 +93,13 @@ def test_metrics_no_usage(app, now, enrollment): @responses.activate -def test_failing_target(client, now): - responses.add( - method='GET', - url="https://ea.azure.com/rest/{0}/usage-report?month={1}&type=detail&fmt=Json".format(enrollment, now), - match_querystring=True, - status=500 - ) - - rsp = client.get('/metrics') - - assert rsp.status_code == 502 - assert rsp.data.startswith(b'Scrape failed') - +@pytest.mark.parametrize('status', [500, 400]) +def test_failing_target(client, now, status): responses.add( method='GET', url="https://ea.azure.com/rest/{0}/usage-report?month={1}&type=detail&fmt=Json".format(enrollment, now), match_querystring=True, - status=400 + status=status ) rsp = client.get('/metrics') diff --git a/tests/test_collector.py b/tests/test_collector.py index 1bf4775..166e829 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -13,6 +13,18 @@ def current_month(): return now.strftime("%Y-%m") +@pytest.fixture() +def enrollment(): + return '1234567' + + +@pytest.fixture() +def api_url(): + base_url = "https://ea.azure.com/rest/{}/usage-report".format(enrollment()) + params = "?month={}&type=detail&fmt=Json".format(current_month()) + return base_url + params + + def test_df_conversion(): df = convert_json_df(sample_data) @@ -29,21 +41,17 @@ def test_df_missing_column(): @responses.activate -def test_extract_metrics(): - - enrollment = '123' - base_url = "https://ea.azure.com/rest/{}/usage-report".format(enrollment) - params = "?month={}&type=detail&fmt=Json".format(current_month()) +def test_extract_metrics(api_url, enrollment): responses.add( method='GET', - url=base_url+params, + url=api_url, match_querystring=True, json=sample_data ) registry = CollectorRegistry() - c = AzureEABillingCollector('costs', enrollment, 'ab123xy') + c = AzureEABillingCollector('costs', enrollment, 'ab123xy', 10) registry.register(c) result = generate_latest(registry).decode('utf8').split('\n') @@ -57,17 +65,13 @@ def test_extract_metrics(): @responses.activate -def test_get_azure_data(): - - enrollment = '123' - base_url = "https://ea.azure.com/rest/{}/usage-report".format(enrollment) - params = "?month={}&type=detail&fmt=Json".format(current_month()) +def test_get_azure_data(api_url, enrollment): - c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz') + c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz', 42.3) responses.add( method='GET', - url=base_url+params, + url=api_url, match_querystring=True, json=sample_data ) @@ -77,19 +81,15 @@ def test_get_azure_data(): @responses.activate -def test_empty_month(): +def test_empty_month(api_url, enrollment): """ If no usage details have are available for a given month the API does not return a JSON document. """ - enrollment = '123' - base_url = "https://ea.azure.com/rest/{}/usage-report".format(enrollment) - params = "?month={}&type=detail&fmt=Json".format(current_month()) - - c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz') + c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz', 11) responses.add( method='GET', - url=base_url+params, + url=api_url, match_querystring=True, body=api_output_for_empty_months )