From 2fc1785bfcb9f9856c6755a7f18e092aa8971e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BChlbauer?= Date: Sat, 10 Jun 2017 15:11:43 +0200 Subject: [PATCH 1/3] Use 'X-Prometheus-Scrape-Timeout-Seconds' header instead of hardcoded timeout. #7 (#11) * Use 'Scrape-Timeout-Seconds' header instead of hardcoded timeout. #7 * Create collector instance in view function to use scrape timeout --- CHANGELOG.md | 5 ++ azure_costs_exporter/main.py | 16 ------ azure_costs_exporter/prometheus_collector.py | 10 ++-- azure_costs_exporter/views.py | 27 ++++++++-- tests/test_app.py | 56 ++++++++++---------- tests/test_collector.py | 15 +++--- 6 files changed, 70 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76dc425..69d479e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Change Log All notable changes to this project are noted in this file. This project adheres to [Semantic Versioning](http://semver.org/). +Next Version +------------ + +- Use `X-Prometheus-Scrape-Timeout-Seconds` when querying the billing API + 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 fae34b2..927c48e 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, 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 0309313..b9f9f97 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -43,7 +43,7 @@ def test_extract_metrics(): ) registry = CollectorRegistry() - c = AzureEABillingCollector('costs', enrollment, 'ab123xy') + c = AzureEABillingCollector('costs', enrollment, 'ab123xy', 10) registry.register(c) result = generate_latest(registry).decode('utf8').split('\n') @@ -59,20 +59,17 @@ 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()) - - c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz') + enrollment='12345' + c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz', 42.3) responses.add( method='GET', - url=base_url+params, + url="https://ea.azure.com/rest/{}/usage-report?month=2017-03&type=detail&fmt=Json".format(enrollment), match_querystring=True, json=sample_data ) - data = c._get_azure_data(current_month()) + data = c._get_azure_data('2017-03') assert data == sample_data @@ -85,7 +82,7 @@ def test_empty_month(): 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', From 7aae6bbe570da0273f41b3237989c29a0eeb5dd4 Mon Sep 17 00:00:00 2001 From: ManuelBahr Date: Sat, 10 Jun 2017 15:21:44 +0200 Subject: [PATCH 2/3] cleanup tests extract magic values into fixtures --- tests/test_collector.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/test_collector.py b/tests/test_collector.py index b9f9f97..6b4439f 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,15 +41,11 @@ 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 ) @@ -57,36 +65,31 @@ def test_extract_metrics(): @responses.activate -def test_get_azure_data(): +def test_get_azure_data(api_url, enrollment): - enrollment='12345' c = AzureEABillingCollector('cloud_costs', enrollment, 'abc123xyz', 42.3) responses.add( method='GET', - url="https://ea.azure.com/rest/{}/usage-report?month=2017-03&type=detail&fmt=Json".format(enrollment), + url=api_url, match_querystring=True, json=sample_data ) - data = c._get_azure_data('2017-03') + data = c._get_azure_data(current_month()) assert data == sample_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', 11) responses.add( method='GET', - url=base_url+params, + url=api_url, match_querystring=True, body=api_output_for_empty_months ) From 325416f21294a02eed87bcd1a9bb153f51a8a9e7 Mon Sep 17 00:00:00 2001 From: ManuelBahr Date: Sat, 10 Jun 2017 15:24:42 +0200 Subject: [PATCH 3/3] changelog ready for release --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d479e..47e091f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,11 @@ Change Log All notable changes to this project are noted in this file. This project adheres to [Semantic Versioning](http://semver.org/). -Next Version ------------- +0.4.0 +----- -- Use `X-Prometheus-Scrape-Timeout-Seconds` when querying the billing API +- Use the `X-Prometheus-Scrape-Timeout-Seconds` header sent by + prometheus to overwrite the internal request timeout default. 0.3.1 ----- @@ -15,7 +16,6 @@ Next Version - Fixed the exporter to cope with the non-standard response for months without usage details. - 0.3.0 -----