diff --git a/docs-requirements.txt b/docs-requirements.txt index db10f6f9ee4..23a7047e8a3 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -22,3 +22,4 @@ wrapt>=1.0.0,<2.0.0 psutil~=5.7.0 boto~=2.0 google-cloud-trace >=0.23.0 +google-cloud-monitoring>=0.36.0 diff --git a/docs/examples/cloud_monitoring/README.rst b/docs/examples/cloud_monitoring/README.rst new file mode 100644 index 00000000000..5ad54217945 --- /dev/null +++ b/docs/examples/cloud_monitoring/README.rst @@ -0,0 +1,35 @@ +Cloud Monitoring Exporter Example +================================= + +These examples show how to use OpenTelemetry to send metrics data to Cloud Monitoring. + + +Basic Example +------------- + +To use this exporter you first need to: + * `Create a Google Cloud project `_. + * Enable the Cloud Monitoring API (aka Stackdriver Monitoring API) in the project `here `_. + * Enable `Default Application Credentials `_. + +* Installation + +.. code-block:: sh + + pip install opentelemetry-api + pip install opentelemetry-sdk + pip install opentelemetry-exporter-cloud-monitoring + +* Run example + +.. code-block:: sh + + python basic_metrics.py + +Viewing Output +-------------------------- + +After running the example: + * Go to the `Cloud Monitoring Metrics Explorer page `_. + * In "Find resource type and metric" enter "OpenTelemetry/request_counter". + * You can filter by labels and change the graphical output here as well. diff --git a/docs/examples/cloud_monitoring/basic_metrics.py b/docs/examples/cloud_monitoring/basic_metrics.py new file mode 100644 index 00000000000..e0ceb420b62 --- /dev/null +++ b/docs/examples/cloud_monitoring/basic_metrics.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time + +from opentelemetry import metrics +from opentelemetry.exporter.cloud_monitoring import ( + CloudMonitoringMetricsExporter, +) +from opentelemetry.sdk.metrics import Counter, MeterProvider +from opentelemetry.sdk.metrics.export.controller import PushController + +meter = metrics.get_meter(__name__, True) + +# Gather and export metrics every 5 seconds +controller = PushController( + meter=meter, exporter=CloudMonitoringMetricsExporter(), interval=5 +) + +requests_counter = meter.create_metric( + name="request_counter", + description="number of requests", + unit="1", + value_type=int, + metric_type=Counter, + label_keys=("environment"), +) + +staging_labels = {"environment": "staging"} + +for i in range(20): + requests_counter.add(25, staging_labels) + time.sleep(10) diff --git a/docs/ext/cloud_monitoring/cloud_monitoring.rst b/docs/ext/cloud_monitoring/cloud_monitoring.rst new file mode 100644 index 00000000000..a3a4f5660ad --- /dev/null +++ b/docs/ext/cloud_monitoring/cloud_monitoring.rst @@ -0,0 +1,7 @@ +OpenTelemetry Cloud Monitoring Exporter +======================================= + +.. automodule:: opentelemetry.exporter.cloud_monitoring + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/ext/opentelemetry-exporter-cloud-monitoring/README.rst b/ext/opentelemetry-exporter-cloud-monitoring/README.rst new file mode 100644 index 00000000000..f1fd52528c9 --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-monitoring/README.rst @@ -0,0 +1,18 @@ +OpenTelemetry Cloud Monitoring Exporters +======================================== + +This library provides classes for exporting metrics data to Google Cloud Monitoring. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-cloud-monitoring + +References +---------- + +* `OpenTelemetry Cloud Monitoring Exporter `_ +* `Cloud Monitoring `_ +* `OpenTelemetry Project `_ \ No newline at end of file diff --git a/ext/opentelemetry-exporter-cloud-monitoring/setup.cfg b/ext/opentelemetry-exporter-cloud-monitoring/setup.cfg new file mode 100644 index 00000000000..37665ee48bc --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-monitoring/setup.cfg @@ -0,0 +1,48 @@ + +# Copyright OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +[metadata] +name = opentelemetry-exporter-cloud-monitoring +description = Cloud Monitoring integration for OpenTelemetry +long_description = file: README.rst +long_description_content_type = text/x-rst +author = OpenTelemetry Authors +author_email = cncf-opentelemetry-contributors@lists.cncf.io +url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-exporter-cloud-monitoring +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 4 - Beta + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + +[options] +python_requires = >=3.4 +package_dir= + =src +packages=find_namespace: +install_requires = + opentelemetry-api + opentelemetry-sdk + google-cloud-monitoring + +[options.packages.find] +where = src \ No newline at end of file diff --git a/ext/opentelemetry-exporter-cloud-monitoring/setup.py b/ext/opentelemetry-exporter-cloud-monitoring/setup.py new file mode 100644 index 00000000000..0ca88bc330f --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-monitoring/setup.py @@ -0,0 +1,31 @@ +# Copyright OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os + +import setuptools + +BASE_DIR = os.path.dirname(__file__) +VERSION_FILENAME = os.path.join( + BASE_DIR, + "src", + "opentelemetry", + "exporter", + "cloud_monitoring", + "version.py", +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup(version=PACKAGE_INFO["__version__"]) diff --git a/ext/opentelemetry-exporter-cloud-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py b/ext/opentelemetry-exporter-cloud-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py new file mode 100644 index 00000000000..6d6af26677a --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py @@ -0,0 +1,169 @@ +import logging +from typing import Optional, Sequence + +import google.auth +from google.api.label_pb2 import LabelDescriptor +from google.api.metric_pb2 import MetricDescriptor +from google.cloud.monitoring_v3 import MetricServiceClient +from google.cloud.monitoring_v3.proto.metric_pb2 import TimeSeries + +from opentelemetry.sdk.metrics.export import ( + MetricRecord, + MetricsExporter, + MetricsExportResult, +) +from opentelemetry.sdk.metrics.export.aggregate import CounterAggregator + +logger = logging.getLogger(__name__) +MAX_BATCH_WRITE = 200 +WRITE_INTERVAL = 10 + + +# pylint is unable to resolve members of protobuf objects +# pylint: disable=no-member +class CloudMonitoringMetricsExporter(MetricsExporter): + """ Implementation of Metrics Exporter to Google Cloud Monitoring""" + + def __init__(self, project_id=None, client=None): + self.client = client or MetricServiceClient() + if not project_id: + _, self.project_id = google.auth.default() + else: + self.project_id = project_id + self.project_name = self.client.project_path(self.project_id) + self._metric_descriptors = {} + self._last_updated = {} + + def _add_resource_info(self, series: TimeSeries) -> None: + """Add Google resource specific information (e.g. instance id, region). + + Args: + series: ProtoBuf TimeSeries + """ + # TODO: Leverage this better + + def _batch_write(self, series: TimeSeries) -> None: + """ Cloud Monitoring allows writing up to 200 time series at once + + :param series: ProtoBuf TimeSeries + :return: + """ + write_ind = 0 + while write_ind < len(series): + self.client.create_time_series( + self.project_name, + series[write_ind : write_ind + MAX_BATCH_WRITE], + ) + write_ind += MAX_BATCH_WRITE + + def _get_metric_descriptor( + self, record: MetricRecord + ) -> Optional[MetricDescriptor]: + """ We can map Metric to MetricDescriptor using Metric.name or + MetricDescriptor.type. We create the MetricDescriptor if it doesn't + exist already and cache it. Note that recreating MetricDescriptors is + a no-op if it already exists. + + :param record: + :return: + """ + descriptor_type = "custom.googleapis.com/OpenTelemetry/{}".format( + record.metric.name + ) + if descriptor_type in self._metric_descriptors: + return self._metric_descriptors[descriptor_type] + descriptor = { + "name": None, + "type": descriptor_type, + "display_name": record.metric.name, + "description": record.metric.description, + "labels": [], + } + for key, value in record.labels: + if isinstance(value, str): + descriptor["labels"].append( + LabelDescriptor(key=key, value_type="STRING") + ) + elif isinstance(value, bool): + descriptor["labels"].append( + LabelDescriptor(key=key, value_type="BOOL") + ) + elif isinstance(value, int): + descriptor["labels"].append( + LabelDescriptor(key=key, value_type="INT64") + ) + else: + logger.warning( + "Label value %s is not a string, bool or integer", value + ) + if isinstance(record.aggregator, CounterAggregator): + descriptor["metric_kind"] = MetricDescriptor.MetricKind.GAUGE + else: + logger.warning( + "Unsupported aggregation type %s, ignoring it", + type(record.aggregator).__name__, + ) + return None + if record.metric.value_type == int: + descriptor["value_type"] = MetricDescriptor.ValueType.INT64 + elif record.metric.value_type == float: + descriptor["value_type"] = MetricDescriptor.ValueType.DOUBLE + proto_descriptor = MetricDescriptor(**descriptor) + try: + descriptor = self.client.create_metric_descriptor( + self.project_name, proto_descriptor + ) + # pylint: disable=broad-except + except Exception as ex: + logger.error( + "Failed to create metric descriptor %s", + proto_descriptor, + exc_info=ex, + ) + return None + self._metric_descriptors[descriptor_type] = descriptor + return descriptor + + def export( + self, metric_records: Sequence[MetricRecord] + ) -> "MetricsExportResult": + all_series = [] + for record in metric_records: + metric_descriptor = self._get_metric_descriptor(record) + if not metric_descriptor: + continue + + series = TimeSeries() + self._add_resource_info(series) + series.metric.type = metric_descriptor.type + for key, value in record.labels: + series.metric.labels[key] = str(value) + + point = series.points.add() + if record.metric.value_type == int: + point.value.int64_value = record.aggregator.checkpoint + elif record.metric.value_type == float: + point.value.double_value = record.aggregator.checkpoint + seconds, nanos = divmod( + record.aggregator.last_update_timestamp, 1e9 + ) + + # Cloud Monitoring API allows, for any combination of labels and + # metric name, one update per WRITE_INTERVAL seconds + updated_key = (metric_descriptor.type, record.labels) + last_updated_seconds = self._last_updated.get(updated_key, 0) + if seconds <= last_updated_seconds + WRITE_INTERVAL: + continue + self._last_updated[updated_key] = seconds + point.interval.end_time.seconds = int(seconds) + point.interval.end_time.nanos = int(nanos) + all_series.append(series) + try: + self._batch_write(all_series) + # pylint: disable=broad-except + except Exception as ex: + logger.error( + "Error while writing to Cloud Monitoring", exc_info=ex + ) + return MetricsExportResult.FAILURE + return MetricsExportResult.SUCCESS diff --git a/ext/opentelemetry-exporter-cloud-monitoring/src/opentelemetry/exporter/cloud_monitoring/version.py b/ext/opentelemetry-exporter-cloud-monitoring/src/opentelemetry/exporter/cloud_monitoring/version.py new file mode 100644 index 00000000000..f83f20e7bac --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-monitoring/src/opentelemetry/exporter/cloud_monitoring/version.py @@ -0,0 +1,15 @@ +# Copyright OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.9.dev0" diff --git a/ext/opentelemetry-exporter-cloud-monitoring/tests/__init__.py b/ext/opentelemetry-exporter-cloud-monitoring/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ext/opentelemetry-exporter-cloud-monitoring/tests/test_cloud_monitoring.py b/ext/opentelemetry-exporter-cloud-monitoring/tests/test_cloud_monitoring.py new file mode 100644 index 00000000000..d7f98e024a7 --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-monitoring/tests/test_cloud_monitoring.py @@ -0,0 +1,285 @@ +# Copyright OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest import mock + +from google.api.label_pb2 import LabelDescriptor +from google.api.metric_pb2 import MetricDescriptor +from google.cloud.monitoring_v3.proto.metric_pb2 import TimeSeries + +from opentelemetry.exporter.cloud_monitoring import ( + MAX_BATCH_WRITE, + WRITE_INTERVAL, + CloudMonitoringMetricsExporter, +) +from opentelemetry.sdk.metrics.export import MetricRecord +from opentelemetry.sdk.metrics.export.aggregate import CounterAggregator + + +class UnsupportedAggregator: + pass + + +class MockMetric: + def __init__(self, name="name", description="description", value_type=int): + self.name = name + self.description = description + self.value_type = value_type + + +# pylint: disable=protected-access +# pylint can't deal with ProtoBuf object members +# pylint: disable=no-member + + +class TestCloudMonitoringMetricsExporter(unittest.TestCase): + def setUp(self): + self.client_patcher = mock.patch( + "opentelemetry.exporter.cloud_monitoring.MetricServiceClient" + ) + self.client_patcher.start() + self.project_id = "PROJECT" + self.project_name = "PROJECT_NAME" + + def tearDown(self): + self.client_patcher.stop() + + def test_constructor_default(self): + exporter = CloudMonitoringMetricsExporter(self.project_id) + self.assertEqual(exporter.project_id, self.project_id) + + def test_constructor_explicit(self): + client = mock.Mock() + exporter = CloudMonitoringMetricsExporter( + self.project_id, client=client + ) + + self.assertIs(exporter.client, client) + self.assertEqual(exporter.project_id, self.project_id) + + def test_batch_write(self): + client = mock.Mock() + exporter = CloudMonitoringMetricsExporter(client=client) + exporter.project_name = self.project_name + exporter._batch_write(range(2 * MAX_BATCH_WRITE + 1)) + client.create_time_series.assert_has_calls( + [ + mock.call(self.project_name, range(MAX_BATCH_WRITE)), + mock.call( + self.project_name, + range(MAX_BATCH_WRITE, 2 * MAX_BATCH_WRITE), + ), + mock.call( + self.project_name, + range(2 * MAX_BATCH_WRITE, 2 * MAX_BATCH_WRITE + 1), + ), + ] + ) + + exporter._batch_write(range(MAX_BATCH_WRITE)) + client.create_time_series.assert_has_calls( + [mock.call(self.project_name, range(MAX_BATCH_WRITE))] + ) + + exporter._batch_write(range(MAX_BATCH_WRITE - 1)) + client.create_time_series.assert_has_calls( + [mock.call(self.project_name, range(MAX_BATCH_WRITE - 1))] + ) + + def test_get_metric_descriptor(self): + client = mock.Mock() + exporter = CloudMonitoringMetricsExporter(client=client) + exporter.project_name = self.project_name + + self.assertIsNone( + exporter._get_metric_descriptor( + MetricRecord(UnsupportedAggregator(), (), MockMetric()) + ) + ) + + record = MetricRecord( + CounterAggregator(), (("label1", "value1"),), MockMetric() + ) + metric_descriptor = exporter._get_metric_descriptor(record) + client.create_metric_descriptor.assert_called_with( + self.project_name, + MetricDescriptor( + **{ + "name": None, + "type": "custom.googleapis.com/OpenTelemetry/name", + "display_name": "name", + "description": "description", + "labels": [ + LabelDescriptor(key="label1", value_type="STRING") + ], + "metric_kind": "GAUGE", + "value_type": "INT64", + } + ), + ) + + # Getting a cached metric descriptor shouldn't use another call + cached_metric_descriptor = exporter._get_metric_descriptor(record) + client.create_metric_descriptor.assert_called_once() + self.assertEqual(metric_descriptor, cached_metric_descriptor) + + # Drop labels with values that aren't string, int or bool + exporter._get_metric_descriptor( + MetricRecord( + CounterAggregator(), + ( + ("label1", "value1"), + ("label2", dict()), + ("label3", 3), + ("label4", False), + ), + MockMetric(name="name2", value_type=float), + ) + ) + client.create_metric_descriptor.assert_called_with( + self.project_name, + MetricDescriptor( + **{ + "name": None, + "type": "custom.googleapis.com/OpenTelemetry/name2", + "display_name": "name2", + "description": "description", + "labels": [ + LabelDescriptor(key="label1", value_type="STRING"), + LabelDescriptor(key="label3", value_type="INT64"), + LabelDescriptor(key="label4", value_type="BOOL"), + ], + "metric_kind": "GAUGE", + "value_type": "DOUBLE", + } + ), + ) + + def test_export(self): + client = mock.Mock() + exporter = CloudMonitoringMetricsExporter(client=client) + exporter.project_name = self.project_name + + exporter.export( + [ + MetricRecord( + UnsupportedAggregator(), + (("label1", "value1"),), + MockMetric(), + ) + ] + ) + client.create_time_series.assert_not_called() + + client.create_metric_descriptor.return_value = MetricDescriptor( + **{ + "name": None, + "type": "custom.googleapis.com/OpenTelemetry/name", + "display_name": "name", + "description": "description", + "labels": [ + LabelDescriptor(key="label1", value_type="STRING"), + LabelDescriptor(key="label2", value_type="INT64"), + ], + "metric_kind": "GAUGE", + "value_type": "DOUBLE", + } + ) + + counter_one = CounterAggregator() + counter_one.checkpoint = 1 + counter_one.last_update_timestamp = (WRITE_INTERVAL + 1) * 1e9 + exporter.export( + [ + MetricRecord( + counter_one, + (("label1", "value1"), ("label2", 1),), + MockMetric(), + ), + MetricRecord( + counter_one, + (("label1", "value2"), ("label2", 2),), + MockMetric(), + ), + ] + ) + series1 = TimeSeries() + series1.metric.type = "custom.googleapis.com/OpenTelemetry/name" + series1.metric.labels["label1"] = "value1" + series1.metric.labels["label2"] = "1" + point = series1.points.add() + point.value.int64_value = 1 + point.interval.end_time.seconds = WRITE_INTERVAL + 1 + point.interval.end_time.nanos = 0 + + series2 = TimeSeries() + series2.metric.type = "custom.googleapis.com/OpenTelemetry/name" + series2.metric.labels["label1"] = "value2" + series2.metric.labels["label2"] = "2" + point = series2.points.add() + point.value.int64_value = 1 + point.interval.end_time.seconds = WRITE_INTERVAL + 1 + point.interval.end_time.nanos = 0 + client.create_time_series.assert_has_calls( + [mock.call(self.project_name, [series1, series2])] + ) + + # Attempting to export too soon after another export with the exact + # same labels leads to it being dropped + + counter_two = CounterAggregator() + counter_two.checkpoint = 1 + counter_two.last_update_timestamp = (WRITE_INTERVAL + 2) * 1e9 + exporter.export( + [ + MetricRecord( + counter_two, + (("label1", "value1"), ("label2", 1),), + MockMetric(), + ), + MetricRecord( + counter_two, + (("label1", "value2"), ("label2", 2),), + MockMetric(), + ), + ] + ) + self.assertEqual(client.create_time_series.call_count, 1) + + # But exporting with different labels is fine + counter_two.checkpoint = 2 + exporter.export( + [ + MetricRecord( + counter_two, + (("label1", "changed_label"), ("label2", 2),), + MockMetric(), + ), + ] + ) + series3 = TimeSeries() + series3.metric.type = "custom.googleapis.com/OpenTelemetry/name" + series3.metric.labels["label1"] = "changed_label" + series3.metric.labels["label2"] = "2" + point = series3.points.add() + point.value.int64_value = 2 + point.interval.end_time.seconds = WRITE_INTERVAL + 2 + point.interval.end_time.nanos = 0 + client.create_time_series.assert_has_calls( + [ + mock.call(self.project_name, [series1, series2]), + mock.call(self.project_name, [series3]), + ] + )