diff --git a/.coveragerc b/.coveragerc index 5f7e9aa..5ed0b4f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,7 +5,7 @@ omit = azure_monitor/tests/* [report] -fail_under = 98 +fail_under = 90 show_missing = True omit = azure_monitor/setup.py diff --git a/azure_monitor/src/azure_monitor/protocol.py b/azure_monitor/src/azure_monitor/protocol.py index 9f68f16..e10c8d4 100644 --- a/azure_monitor/src/azure_monitor/protocol.py +++ b/azure_monitor/src/azure_monitor/protocol.py @@ -561,3 +561,127 @@ def to_dict(self): "properties": self.properties, "measurements": self.measurements, } + + +class LiveMetricDocumentProperty(BaseObject): + + __slots__ = ("key", "value") + + def __init__(self, key: str, value: str) -> None: + self.key = key + self.value = value + + +class LiveMetricDocument(BaseObject): + + __slots__ = ( + "__type", + "document_type", + "version", + "operation_id", + "properties", + ) + + def __init__( + self, + __type: str = "", + document_type: str = "", + version: str = "", + operation_id: str = "", + properties: typing.List[LiveMetricDocumentProperty] = None, + ) -> None: + self.__type = __type + self.document_type = document_type + self.version = version + self.operation_id = operation_id + self.properties = properties + + def to_dict(self): + return { + "__type": self.__type, + "DocumentType": self.document_type, + "Version": self.version, + "OperationId": self.operation_id, + "Properties": self.properties.to_dict() + if self.properties + else None, + } + + +class LiveMetric(BaseObject): + + __slots__ = ("name", "value", "weight") + + def __init__(self, name: str, value: str, weight: int) -> None: + self.name = name + self.value = value + self.weight = weight + + def to_dict(self): + return {"Name": self.name, "Value": self.value, "Weight": self.weight} + + +class LiveMetricEnvelope(BaseObject): + """Envelope to send data to Live Metrics service. + + Args: + documents: An array of detailed failure documents, each representing a full SDK telemetry item. + instance: Instance name. Either a cloud RoleInstance (if running in Azure), or the machine name otherwise. + instrumentation_key: Instrumentation key that the agent is instrumented with. While SDK can send to multiple ikeys, + it must select a single ikey to send QuickPulse data to - and only consider telemetry items sent to that ikey while collecting. + invariant_version: Version of QPS protocol that SDK supports. Currently, the latest is 2. + machine_name: Machine name. + metrics: Metrics + stream_id: A random GUID generated at start-up. Must remain unchanged while the application is running. + timestamp: UTC time the request was made in the Microsoft JSON format, e.g. "/Date(1478555534692)/". + version: SDK version (not specific to QuickPulse). Please make sure that there's some sort of prefix to identify the client (.NET SDK, Java SDK, etc.). + """ + + __slots__ = ( + "documents", + "instance", + "instrumentation_key", + "invariant_version", + "machine_name", + "metrics", + "stream_id", + "timestamp", + "version", + ) + + def __init__( + self, + documents: typing.List[LiveMetricDocument] = None, + instance: str = "", + instrumentation_key: str = "", + invariant_version: int = 1, + machine_name: str = "", + metrics: typing.List[LiveMetric] = None, + stream_id: str = "", + timestamp: str = "", + version: str = "", + ) -> None: + if metrics is None: + metrics = [] + self.documents = documents + self.instance = instance + self.instrumentation_key = instrumentation_key + self.invariant_version = invariant_version + self.machine_name = machine_name + self.metrics = metrics + self.stream_id = stream_id + self.timestamp = timestamp + self.version = version + + def to_dict(self): + return { + "Documents": self.documents.to_dict() if self.documents else None, + "Instance": self.instance, + "InstrumentationKey": self.instrumentation_key, + "InvariantVersion": self.invariant_version, + "MachineName": self.machine_name, + "Metrics": list(map(lambda x: x.to_dict(), self.metrics)), + "StreamId": self.stream_id, + "Timestamp": self.timestamp, + "Version": self.version, + } diff --git a/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/__init__.py b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/__init__.py new file mode 100644 index 0000000..5b7f7a9 --- /dev/null +++ b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/exporter.py b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/exporter.py new file mode 100644 index 0000000..9f9483f --- /dev/null +++ b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/exporter.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +import collections +import logging +import typing + +from opentelemetry.sdk.metrics import Counter, Observer +from opentelemetry.sdk.metrics.export import ( + MetricRecord, + MetricsExporter, + MetricsExportResult, +) + +from azure_monitor.protocol import ( + Envelope, + LiveMetric, + LiveMetricDocument, + LiveMetricDocumentProperty, + LiveMetricEnvelope, +) +from azure_monitor.sdk.auto_collection.live_metrics import utils +from azure_monitor.sdk.auto_collection.live_metrics.sender import ( + LiveMetricsSender, +) + +logger = logging.getLogger(__name__) + + +# pylint: disable=no-self-use +# pylint: disable=too-many-statements +# pylint: disable=too-many-return-statements +class LiveMetricsExporter(MetricsExporter): + """Live Metrics Exporter + + Export data to Azure Live Metrics service and determine if user is subscribed. + """ + + def __init__(self, instrumentation_key): + self._instrumentation_key = instrumentation_key + self._sender = LiveMetricsSender(self._instrumentation_key) + self.subscribed = True + self._document_envelopes = collections.deque() + + def add_document(self, envelope: Envelope): + self._document_envelopes.append(envelope) + + def export( + self, metric_records: typing.Sequence[MetricRecord] + ) -> MetricsExportResult: + envelope = self._metric_to_live_metrics_envelope(metric_records) + try: + response = self._sender.post(envelope) + if response.ok: + self.subscribed = ( + response.headers.get(utils.LIVE_METRICS_SUBSCRIBED_HEADER) + == "true" + ) + return MetricsExportResult.SUCCESS + + except Exception: # pylint: disable=broad-except + logger.exception("Exception occurred while exporting the data.") + + return MetricsExportResult.FAILED_NOT_RETRYABLE + + def _metric_to_live_metrics_envelope( + self, metric_records: typing.Sequence[MetricRecord] + ) -> LiveMetricEnvelope: + + envelope = utils.create_metric_envelope(self._instrumentation_key) + envelope.documents = self._get_live_metric_documents() + envelope.metrics = [] + + # Add metrics + for metric_record in metric_records: + value = 0 + metric = metric_record.metric + if isinstance(metric, Counter): + value = metric_record.aggregator.checkpoint + elif isinstance(metric, Observer): + value = metric_record.aggregator.checkpoint.last + if not value: + value = 0 + envelope.metrics.append( + LiveMetric(name=metric.name, value=value, weight=1) + ) + + return envelope + + def _get_live_metric_documents( + self, + ) -> typing.Sequence[LiveMetricDocument]: + live_metric_documents = [] + while self._document_envelopes: + for envelope in self._document_envelopes.popleft(): + base_type = envelope.data.baseType + if base_type: + document = LiveMetricDocument( + __type=self._get_live_metric_type(base_type), + document_type=self._get_live_metric_document_type( + base_type + ), + properties=self._get_aggregated_properties(envelope), + version="1.0", + ) + live_metric_documents.append(document) + else: + logger.warning( + "Document type invalid; not sending live metric document" + ) + + return live_metric_documents + + def _get_live_metric_type(self, base_type: str) -> str: + if base_type == "EventData": + return "EventTelemetryDocument" + if base_type == "ExceptionData": + return "ExceptionTelemetryDocument" + if base_type == "MessageData": + return "TraceTelemetryDocument" + if base_type == "MetricData": + return "MetricTelemetryDocument" + if base_type == "RequestData": + return "RequestTelemetryDocument" + if base_type == "RemoteDependencyData": + return "DependencyTelemetryDocument" + if base_type == "AvailabilityData": + return "AvailabilityTelemetryDocument" + return "" + + def _get_live_metric_document_type(self, base_type: str) -> str: + if base_type == "EventData": + return "Event" + if base_type == "ExceptionData": + return "Exception" + if base_type == "MessageData": + return "Trace" + if base_type == "MetricData": + return "Metric" + if base_type == "RequestData": + return "Request" + if base_type == "RemoteDependencyData": + return "RemoteDependency" + if base_type == "AvailabilityData": + return "Availability" + return "" + + def _get_aggregated_properties( + self, envelope: Envelope + ) -> typing.List[LiveMetricDocumentProperty]: + + aggregated_properties = [] + measurements = ( + envelope.data.base_data.measurements + if envelope.data.base_data.measurements + else [] + ) + for key in measurements: + prop = LiveMetricDocumentProperty(key=key, value=measurements[key]) + aggregated_properties.append(prop) + properties = ( + envelope.data.base_data.properties + if envelope.data.base_data.properties + else [] + ) + for key in properties: + prop = LiveMetricDocumentProperty(key=key, value=properties[key]) + aggregated_properties.append(prop) + return aggregated_properties diff --git a/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/manager.py b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/manager.py new file mode 100644 index 0000000..0e9c407 --- /dev/null +++ b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/manager.py @@ -0,0 +1,171 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +import threading +import time + +from opentelemetry.context import attach, detach, set_value +from opentelemetry.sdk.metrics.export import MetricsExportResult + +from azure_monitor.sdk.auto_collection.live_metrics import utils +from azure_monitor.sdk.auto_collection.live_metrics.exporter import ( + LiveMetricsExporter, +) +from azure_monitor.sdk.auto_collection.live_metrics.sender import ( + LiveMetricsSender, +) + +# Interval for failures threshold reached in seconds +FALLBACK_INTERVAL = 60.0 +# Ping interval for succesful requests in seconds +PING_INTERVAL = 5.0 +# Post interval for succesful requests in seconds +POST_INTERVAL = 1.0 +# Main process interval (Manager) in seconds +MAIN_INTERVAL = 2.0 + + +class LiveMetricsManager(threading.Thread): + """Live Metrics Manager + + It will start Live Metrics process when instantiated, + responsible for switching between ping and post actions. + """ + + daemon = True + + def __init__(self, meter, instrumentation_key): + super().__init__() + self.thread_event = threading.Event() + self.interval = MAIN_INTERVAL + self._instrumentation_key = instrumentation_key + self._is_user_subscribed = False + self._meter = meter + self._exporter = LiveMetricsExporter(self._instrumentation_key) + self._post = None + self._ping = LiveMetricsPing(self._instrumentation_key) + self.start() + + def run(self): + self.check_if_user_is_subscribed() + while not self.thread_event.wait(self.interval): + self.check_if_user_is_subscribed() + + def check_if_user_is_subscribed(self): + if self._ping: + if self._ping.is_user_subscribed: + # Switch to Post + self._ping.shutdown() + self._ping = None + self._post = LiveMetricsPost( + self._meter, self._exporter, self._instrumentation_key + ) + if self._post: + if not self._post.is_user_subscribed: + # Switch to Ping + self._post.shutdown() + self._post = None + self._ping = LiveMetricsPing(self._instrumentation_key) + + def shutdown(self): + if self._ping: + self._ping.shutdown() + if self._post: + self._post.shutdown() + self.thread_event.set() + + +class LiveMetricsPing(threading.Thread): + """Ping to Live Metrics service + + Ping to determine if user is subscribed and live metrics need to be send. + """ + + daemon = True + + def __init__(self, instrumentation_key): + super().__init__() + self.instrumentation_key = instrumentation_key + self.thread_event = threading.Event() + self.interval = PING_INTERVAL + self.is_user_subscribed = False + self.last_send_succeeded = False + self.last_request_success_time = 0 + self.sender = LiveMetricsSender(self.instrumentation_key) + self.start() + + def run(self): + self.ping() + while not self.thread_event.wait(self.interval): + self.ping() + + def ping(self): + envelope = utils.create_metric_envelope(self.instrumentation_key) + token = attach(set_value("suppress_instrumentation", True)) + response = self.sender.ping(envelope) + detach(token) + if response.ok: + if not self.last_send_succeeded: + self.interval = PING_INTERVAL + self.last_send_succeeded = True + self.last_request_success_time = time.time() + if ( + response.headers.get(utils.LIVE_METRICS_SUBSCRIBED_HEADER) + == "true" + ): + self.is_user_subscribed = True + else: + self.last_send_succeeded = False + if time.time() >= self.last_request_success_time + 60: + self.interval = FALLBACK_INTERVAL + + def shutdown(self): + self.thread_event.set() + + +class LiveMetricsPost(threading.Thread): + """Post to Live Metrics service + + Post to send live metrics data when user is subscribed. + """ + + daemon = True + + def __init__(self, meter, exporter, instrumentation_key): + super().__init__() + self.instrumentation_key = instrumentation_key + self.meter = meter + self.thread_event = threading.Event() + self.interval = POST_INTERVAL + self.is_user_subscribed = True + self.last_send_succeeded = False + self.last_request_success_time = time.time() + self.exporter = exporter + self.start() + + def run(self): + self.post() + while not self.thread_event.wait(self.interval): + self.post() + + def post(self): + self.meter.collect() + token = attach(set_value("suppress_instrumentation", True)) + result = self.exporter.export(self.meter.batcher.checkpoint_set()) + detach(token) + self.meter.batcher.finished_collection() + if result == MetricsExportResult.SUCCESS: + self.last_request_success_time = time.time() + if not self.last_send_succeeded: + self.interval = POST_INTERVAL + self.last_send_succeeded = True + + if not self.exporter.subscribed: + self.is_user_subscribed = False + else: + self.last_send_succeeded = False + if time.time() >= self.last_request_success_time + 20: + self.interval = FALLBACK_INTERVAL + + def shutdown(self): + self.thread_event.set() diff --git a/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/sender.py b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/sender.py new file mode 100644 index 0000000..d8c6df2 --- /dev/null +++ b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/sender.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +import json +import logging +import time + +import requests + +from azure_monitor.protocol import LiveMetricEnvelope +from azure_monitor.sdk.auto_collection.live_metrics import utils + +logger = logging.getLogger(__name__) + + +class LiveMetricsSender: + """Live Metrics Sender + + Send HTTP requests to Live Metrics service + """ + + def __init__(self, instrumentation_key: str): + self._instrumentation_key = instrumentation_key + + def ping(self, envelope: LiveMetricEnvelope): + return self._send_request(json.dumps(envelope.to_dict()), "ping") + + def post(self, envelope: LiveMetricEnvelope): + return self._send_request(json.dumps([envelope.to_dict()]), "post") + + def _send_request(self, data: str, request_type: str) -> requests.Response: + try: + url = "{0}/QuickPulseService.svc/{1}?ikey={2}".format( + utils.DEFAULT_LIVEMETRICS_ENDPOINT, + request_type, + self._instrumentation_key, + ) + response = requests.post( + url=url, + data=data, + headers={ + "Expect": "100-continue", + "Content-Type": "application/json; charset=utf-8", + # TODO: Fix issue with incorrect time + utils.LIVE_METRICS_TRANSMISSION_TIME_HEADER: str( + round(time.time()) * 1000 + ), + }, + ) + except requests.exceptions.RequestException as ex: + logger.warning("Failed to send live metrics: %s.", ex.strerror) + return ex.response + return response diff --git a/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/utils.py b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/utils.py new file mode 100644 index 0000000..d27a5c9 --- /dev/null +++ b/azure_monitor/src/azure_monitor/sdk/auto_collection/live_metrics/utils.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +import time +import uuid + +from azure_monitor.protocol import LiveMetricEnvelope +from azure_monitor.utils import azure_monitor_context + +DEFAULT_LIVEMETRICS_ENDPOINT = "https://rt.services.visualstudio.com" +LIVE_METRICS_SUBSCRIBED_HEADER = "x-ms-qps-subscribed" +LIVE_METRICS_TRANSMISSION_TIME_HEADER = "x-ms-qps-transmission-time" +STREAM_ID = str(uuid.uuid4()) + + +def create_metric_envelope(instrumentation_key: str): + envelope = LiveMetricEnvelope( + documents=None, + instance=azure_monitor_context.get("ai.cloud.roleInstance"), + instrumentation_key=instrumentation_key, + invariant_version=1, # 1 -> v1 QPS protocol, + machine_name=azure_monitor_context.get("ai.device.id"), + metrics=None, + stream_id=STREAM_ID, + # TODO: Fix issue with incorrect time + timestamp="/Date({0})/".format(str(int(time.time()) * 1000)), + version=azure_monitor_context.get("ai.internal.sdkVersion"), + ) + return envelope diff --git a/azure_monitor/src/azure_monitor/storage.py b/azure_monitor/src/azure_monitor/storage.py index b04caa5..08ff2d0 100644 --- a/azure_monitor/src/azure_monitor/storage.py +++ b/azure_monitor/src/azure_monitor/storage.py @@ -114,6 +114,7 @@ def __enter__(self): def __exit__(self, type, value, traceback): self.close() + # pylint: disable=unused-variable def _maintenance_routine(self, silent=False): try: if not os.path.isdir(self.path): diff --git a/azure_monitor/tests/auto_collection/live_metrics/__init__.py b/azure_monitor/tests/auto_collection/live_metrics/__init__.py new file mode 100644 index 0000000..5b7f7a9 --- /dev/null +++ b/azure_monitor/tests/auto_collection/live_metrics/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/azure_monitor/tests/auto_collection/live_metrics/test_exporter.py b/azure_monitor/tests/auto_collection/live_metrics/test_exporter.py new file mode 100644 index 0000000..2dc4f46 --- /dev/null +++ b/azure_monitor/tests/auto_collection/live_metrics/test_exporter.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from unittest import mock + +import requests +from opentelemetry import metrics +from opentelemetry.sdk.metrics import Counter, MeterProvider +from opentelemetry.sdk.metrics.export import MetricRecord, MetricsExportResult +from opentelemetry.sdk.metrics.export.aggregate import CounterAggregator + +from azure_monitor.protocol import Envelope +from azure_monitor.sdk.auto_collection.live_metrics.exporter import ( + LiveMetricsExporter, +) + + +def throw(exc_type, *args, **kwargs): + def func(*_args, **_kwargs): + raise exc_type(*args, **kwargs) + + return func + + +# pylint: disable=protected-access +class TestLiveMetricsExporter(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._instrumentation_key = "99c42f65-1656-4c41-afde-bd86b709a4a7" + metrics.set_meter_provider(MeterProvider()) + cls._meter = metrics.get_meter(__name__) + cls._test_metric = cls._meter.create_metric( + "testname", "testdesc", "unit", int, Counter, ["environment"] + ) + cls._test_labels = tuple({"environment": "staging"}.items()) + + def test_constructor(self): + """Test the constructor.""" + exporter = LiveMetricsExporter( + instrumentation_key=self._instrumentation_key + ) + self.assertEqual(exporter.subscribed, True) + self.assertEqual( + exporter._instrumentation_key, self._instrumentation_key + ) + + def test_add_document(self): + """Test adding a document.""" + exporter = LiveMetricsExporter( + instrumentation_key=self._instrumentation_key + ) + envelope = Envelope() + exporter.add_document(envelope) + self.assertEqual(exporter._document_envelopes.pop(), envelope) + + def test_export(self): + """Test export.""" + record = MetricRecord( + CounterAggregator(), self._test_labels, self._test_metric + ) + exporter = LiveMetricsExporter( + instrumentation_key=self._instrumentation_key + ) + with mock.patch( + "azure_monitor.sdk.auto_collection.live_metrics.sender.LiveMetricsSender.post" + ) as request: + response = requests.Response() + response.status_code = 200 + request.return_value = response + result = exporter.export([record]) + self.assertEqual(result, MetricsExportResult.SUCCESS) + + def test_export_failed(self): + record = MetricRecord( + CounterAggregator(), self._test_labels, self._test_metric + ) + exporter = LiveMetricsExporter( + instrumentation_key=self._instrumentation_key + ) + with mock.patch( + "azure_monitor.sdk.auto_collection.live_metrics.sender.LiveMetricsSender.post" + ) as request: + response = requests.Response() + response.status_code = 400 + request.return_value = response + result = exporter.export([record]) + self.assertEqual(result, MetricsExportResult.FAILED_NOT_RETRYABLE) + + def test_export_exception(self): + record = MetricRecord( + CounterAggregator(), self._test_labels, self._test_metric + ) + exporter = LiveMetricsExporter( + instrumentation_key=self._instrumentation_key + ) + with mock.patch( + "azure_monitor.sdk.auto_collection.live_metrics.sender.LiveMetricsSender.post", + throw(Exception), + ): + result = exporter.export([record]) + self.assertEqual(result, MetricsExportResult.FAILED_NOT_RETRYABLE) diff --git a/azure_monitor/tests/auto_collection/live_metrics/test_manager.py b/azure_monitor/tests/auto_collection/live_metrics/test_manager.py new file mode 100644 index 0000000..4c4cab7 --- /dev/null +++ b/azure_monitor/tests/auto_collection/live_metrics/test_manager.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import time +import unittest +from unittest import mock + +from opentelemetry import metrics +from opentelemetry.sdk.metrics import Counter, MeterProvider + +from azure_monitor.sdk.auto_collection.live_metrics.exporter import ( + LiveMetricsExporter, +) +from azure_monitor.sdk.auto_collection.live_metrics.manager import ( + LiveMetricsManager, + LiveMetricsPing, + LiveMetricsPost, +) + + +# pylint: disable=protected-access +class TestLiveMetricsManager(unittest.TestCase): + @classmethod + def setUpClass(cls): + metrics.set_meter_provider(MeterProvider()) + cls._meter = metrics.get_meter(__name__) + cls._test_metric = cls._meter.create_metric( + "testname", "testdesc", "unit", int, Counter, ["environment"] + ) + testing_labels = {"environment": "testing"} + cls._test_metric.add(5, testing_labels) + cls._instrumentation_key = "99c42f65-1656-4c41-afde-bd86b709a4a7" + cls._manager = None + cls._ping = None + cls._post = None + + @classmethod + def tearDownClass(cls): + metrics._METER_PROVIDER = None + + def tearDown(self): + if self._manager: + self._manager.shutdown() + self._manager = None + if self._ping: + self._ping.shutdown() + self._ping = None + if self._post: + self._post.shutdown() + self._post = None + + def test_constructor(self): + """Test the constructor.""" + with mock.patch("requests.post"): + self._manager = LiveMetricsManager( + meter=self._meter, + instrumentation_key=self._instrumentation_key, + ) + self.assertFalse(self._manager._is_user_subscribed) + self.assertEqual( + self._manager._instrumentation_key, self._instrumentation_key + ) + self.assertEqual(self._manager._meter, self._meter) + self.assertIsNotNone(self._manager._ping) + + def test_switch(self): + """Test manager switch between ping and post.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse( + 200, None, {"x-ms-qps-subscribed": "true"} + ) + self._manager = LiveMetricsManager( + meter=self._meter, + instrumentation_key=self._instrumentation_key, + ) + self._manager.interval = 60 + time.sleep(1) + self._manager.check_if_user_is_subscribed() + self.assertIsNone(self._manager._ping) + self.assertIsNotNone(self._manager._post) + self._manager._post.is_user_subscribed = False + self._manager.check_if_user_is_subscribed() + self.assertIsNone(self._manager._post) + self.assertIsNotNone(self._manager._ping) + + def test_ping_ok(self): + """Test ping send requests to Live Metrics service.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse(200, None, {}) + self._ping = LiveMetricsPing( + instrumentation_key=self._instrumentation_key + ) + self._ping.ping() + self.assertTrue(request.called) + self.assertTrue(self._ping.last_request_success_time > 0) + self.assertTrue(self._ping.last_send_succeeded) + self.assertFalse(self._ping.is_user_subscribed) + + def test_ping_subscribed(self): + """Test ping when user is subscribed.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse( + 200, None, {"x-ms-qps-subscribed": "true"} + ) + self._ping = LiveMetricsPing( + instrumentation_key=self._instrumentation_key + ) + self._ping.ping() + self.assertTrue(self._ping.is_user_subscribed) + + def test_ping_error(self): + """Test ping when failure.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse(400, None, {}) + self._ping = LiveMetricsPing( + instrumentation_key=self._instrumentation_key + ) + self._ping.last_request_success_time = time.time() - 21 + self._ping.ping() + self.assertFalse(self._ping.last_send_succeeded) + self.assertEqual(self._ping.interval, 60) + + def test_post_ok(self): + """Test post send requests to Live Metrics service.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse( + 200, None, {"x-ms-qps-subscribed": "false"} + ) + self._post = LiveMetricsPost( + exporter=LiveMetricsExporter(self._instrumentation_key), + meter=self._meter, + instrumentation_key=self._instrumentation_key, + ) + self._post.post() + self.assertTrue(request.called) + self.assertTrue(self._post.last_request_success_time > 0) + self.assertTrue(self._post.last_send_succeeded) + self.assertFalse(self._post.is_user_subscribed) + + def test_post_subscribed(self): + """Test post when user is subscribed.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse( + 200, None, {"x-ms-qps-subscribed": "true"} + ) + self._post = LiveMetricsPost( + exporter=LiveMetricsExporter(self._instrumentation_key), + meter=self._meter, + instrumentation_key=self._instrumentation_key, + ) + self._post.post() + self.assertTrue(self._post.is_user_subscribed) + + def test_post_error(self): + """Test post when failure.""" + with mock.patch("requests.post") as request: + request.return_value = MockResponse(400, None, {}) + self._post = LiveMetricsPost( + exporter=LiveMetricsExporter(self._instrumentation_key), + meter=self._meter, + instrumentation_key=self._instrumentation_key, + ) + self._post.last_request_success_time = time.time() - 61 + self._post.post() + self.assertFalse(self._post.last_send_succeeded) + self.assertEqual(self._post.interval, 60) + + +# pylint: disable=invalid-name +class MockResponse: + def __init__(self, status_code, text, headers): + self.status_code = status_code + self.text = text + self.ok = status_code == 200 + self.headers = headers diff --git a/azure_monitor/tests/auto_collection/live_metrics/test_sender.py b/azure_monitor/tests/auto_collection/live_metrics/test_sender.py new file mode 100644 index 0000000..dadf95c --- /dev/null +++ b/azure_monitor/tests/auto_collection/live_metrics/test_sender.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from unittest import mock + +from azure_monitor.protocol import LiveMetricEnvelope +from azure_monitor.sdk.auto_collection.live_metrics.sender import ( + LiveMetricsSender, +) + + +# pylint: disable=protected-access +class TestLiveMetricsSender(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._instrumentation_key = "99c42f65-1656-4c41-afde-bd86b709a4a7" + + def test_constructor(self): + """Test the constructor.""" + sender = LiveMetricsSender( + instrumentation_key=self._instrumentation_key + ) + self.assertEqual( + sender._instrumentation_key, self._instrumentation_key + ) + + def test_ping(self): + """Test ping.""" + sender = LiveMetricsSender( + instrumentation_key=self._instrumentation_key + ) + envelope = LiveMetricEnvelope() + with mock.patch("requests.post") as request: + sender.ping(envelope) + self.assertTrue(request.called) + self.assertEqual( + request.call_args[1].get("url"), + "https://rt.services.visualstudio.com/QuickPulseService.svc/ping?ikey={0}".format( + self._instrumentation_key + ), + ) + self.assertIsNotNone(request.call_args[1].get("data")) + headers = request.call_args[1].get("headers") + self.assertEqual(headers.get("Expect"), "100-continue") + self.assertEqual( + headers.get("Content-Type"), "application/json; charset=utf-8" + ) + self.assertTrue( + headers.get("x-ms-qps-transmission-time").isdigit() + ) + + def test_post(self): + """Test post.""" + sender = LiveMetricsSender( + instrumentation_key=self._instrumentation_key + ) + envelope = LiveMetricEnvelope() + with mock.patch("requests.post") as request: + sender.post(envelope) + self.assertTrue(request.called) + self.assertEqual( + request.call_args[1].get("url"), + "https://rt.services.visualstudio.com/QuickPulseService.svc/post?ikey={0}".format( + self._instrumentation_key + ), + ) + self.assertIsNotNone(request.call_args[1].get("data")) + headers = request.call_args[1].get("headers") + self.assertEqual(headers.get("Expect"), "100-continue") + self.assertEqual( + headers.get("Content-Type"), "application/json; charset=utf-8" + ) + self.assertTrue( + headers.get("x-ms-qps-transmission-time").isdigit() + )