diff --git a/CHANGELOG.md b/CHANGELOG.md index c009a1836d..91b5be8309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Decode URL-encoded headers in environment variables ([#2312](https://github.com/open-telemetry/opentelemetry-python/pull/2312)) +- [exporter/opentelemetry-exporter-otlp-proto-grpc] Add OTLPMetricExporter + ([#2323](https://github.com/open-telemetry/opentelemetry-python/pull/2323)) ## [1.8.0-0.27b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.8.0-0.27b0) - 2021-12-17 diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/metric_exporter.py b/docs/getting_started/metrics_example.py similarity index 94% rename from opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/metric_exporter.py rename to docs/getting_started/metrics_example.py index e9c6d6aae0..d0bb758306 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/metric_exporter.py +++ b/docs/getting_started/metrics_example.py @@ -12,6 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. - -class MetricExporter: - pass +# metrics.py +# TODO diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_metric_exporter/__init__.py new file mode 100644 index 0000000000..5e76e5c035 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_metric_exporter/__init__.py @@ -0,0 +1,125 @@ +# 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. + +from typing import Optional, Sequence +from grpc import ChannelCredentials, Compression +from opentelemetry.exporter.otlp.proto.grpc.exporter import ( + OTLPExporterMixin, + get_resource_data, +) +from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( + ExportMetricsServiceRequest, +) +from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2_grpc import ( + MetricsServiceStub, +) +from opentelemetry.proto.common.v1.common_pb2 import InstrumentationLibrary +from opentelemetry.proto.metrics.v1.metrics_pb2 import ( + InstrumentationLibraryMetrics, + ResourceMetrics, +) +from opentelemetry.proto.metrics.v1.metrics_pb2 import Metric as PB2Metric +from opentelemetry.sdk._metrics.data import ( + MetricData, +) + +from opentelemetry.sdk._metrics.export import ( + MetricExporter, + MetricExportResult, +) + + +class OTLPMetricExporter( + MetricExporter, + OTLPExporterMixin[ + MetricData, ExportMetricsServiceRequest, MetricExportResult + ], +): + _result = MetricExportResult + _stub = MetricsServiceStub + + def __init__( + self, + endpoint: Optional[str] = None, + insecure: Optional[bool] = None, + credentials: Optional[ChannelCredentials] = None, + headers: Optional[Sequence] = None, + timeout: Optional[int] = None, + compression: Optional[Compression] = None, + ): + super().__init__( + **{ + "endpoint": endpoint, + "insecure": insecure, + "credentials": credentials, + "headers": headers, + "timeout": timeout, + "compression": compression, + } + ) + + def _translate_data( + self, data: Sequence[MetricData] + ) -> ExportMetricsServiceRequest: + sdk_resource_instrumentation_library_metrics = {} + self._collector_metric_kwargs = {} + + for metric_data in data: + resource = metric_data.metric.resource + instrumentation_library_map = ( + sdk_resource_instrumentation_library_metrics.get(resource, {}) + ) + if not instrumentation_library_map: + sdk_resource_instrumentation_library_metrics[ + resource + ] = instrumentation_library_map + + instrumentation_library_metrics = instrumentation_library_map.get( + metric_data.instrumentation_info + ) + + if not instrumentation_library_metrics: + if metric_data.instrumentation_info is not None: + instrumentation_library_map[ + metric_data.instrumentation_info + ] = InstrumentationLibraryMetrics( + instrumentation_library=InstrumentationLibrary( + name=metric_data.instrumentation_info.name, + version=metric_data.instrumentation_info.version, + ) + ) + else: + instrumentation_library_map[ + metric_data.instrumentation_info + ] = InstrumentationLibraryMetrics() + + instrumentation_library_metrics = instrumentation_library_map.get( + metric_data.instrumentation_info + ) + + instrumentation_library_metrics.metrics.append( + PB2Metric(**self._collector_metric_kwargs) + ) + return ExportMetricsServiceRequest( + resource_metrics=get_resource_data( + sdk_resource_instrumentation_library_metrics, + ResourceMetrics, + "metrics", + ) + ) + + def export(self, metrics: Sequence[MetricData]) -> MetricExportResult: + return self._export(metrics) + + def shutdown(self): + pass diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py index b9c33786e3..01fe424835 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py @@ -107,7 +107,7 @@ def setUp(self): self.server = server(ThreadPoolExecutor(max_workers=10)) - self.server.add_insecure_port("[::]:4317") + self.server.add_insecure_port("127.0.0.1:4317") self.server.start() diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/metrics/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/metrics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/metrics/test_otlp_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/metrics/test_otlp_metrics_exporter.py new file mode 100644 index 0000000000..f7cf8ec94d --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/metrics/test_otlp_metrics_exporter.py @@ -0,0 +1,219 @@ +# 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. + +from concurrent.futures import ThreadPoolExecutor +from unittest import TestCase +from unittest.mock import patch + +from google.protobuf.duration_pb2 import Duration +from google.rpc.error_details_pb2 import RetryInfo +from grpc import StatusCode, server + +from opentelemetry.exporter.otlp.proto.grpc._metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( + ExportMetricsServiceResponse, +) +from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2_grpc import ( + MetricsServiceServicer, + add_MetricsServiceServicer_to_server, +) +from opentelemetry.sdk._metrics.data import Metric, MetricData +from opentelemetry.sdk._metrics.export import MetricExportResult +from opentelemetry.sdk.resources import Resource as SDKResource +from opentelemetry.sdk.util.instrumentation import InstrumentationInfo + + +class MetricsServiceServicerUNAVAILABLEDelay(MetricsServiceServicer): + # pylint: disable=invalid-name,unused-argument,no-self-use + def Export(self, request, context): + context.set_code(StatusCode.UNAVAILABLE) + + context.send_initial_metadata( + (("google.rpc.retryinfo-bin", RetryInfo().SerializeToString()),) + ) + context.set_trailing_metadata( + ( + ( + "google.rpc.retryinfo-bin", + RetryInfo( + retry_delay=Duration(seconds=4) + ).SerializeToString(), + ), + ) + ) + + return ExportMetricsServiceResponse() + + +class MetricsServiceServicerUNAVAILABLE(MetricsServiceServicer): + # pylint: disable=invalid-name,unused-argument,no-self-use + def Export(self, request, context): + context.set_code(StatusCode.UNAVAILABLE) + + return ExportMetricsServiceResponse() + + +class MetricsServiceServicerSUCCESS(MetricsServiceServicer): + # pylint: disable=invalid-name,unused-argument,no-self-use + def Export(self, request, context): + context.set_code(StatusCode.OK) + + return ExportMetricsServiceResponse() + + +class MetricsServiceServicerALREADY_EXISTS(MetricsServiceServicer): + # pylint: disable=invalid-name,unused-argument,no-self-use + def Export(self, request, context): + context.set_code(StatusCode.ALREADY_EXISTS) + + return ExportMetricsServiceResponse() + + +class TestOTLPMetricExporter(TestCase): + def setUp(self): + + self.exporter = OTLPMetricExporter() + + self.server = server(ThreadPoolExecutor(max_workers=10)) + + self.server.add_insecure_port("127.0.0.1:4317") + + self.server.start() + + self.metric_data_1 = MetricData( + metric=Metric( + resource=SDKResource({"key": "value"}), + ), + instrumentation_info=InstrumentationInfo( + "first_name", "first_version" + ), + ) + + def tearDown(self): + self.server.stop(None) + + @patch( + "opentelemetry.exporter.otlp.proto.grpc.exporter.ssl_channel_credentials" + ) + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel") + @patch( + "opentelemetry.exporter.otlp.proto.grpc._metric_exporter.OTLPMetricExporter._stub" + ) + # pylint: disable=unused-argument + def test_no_credentials_error( + self, mock_ssl_channel, mock_secure, mock_stub + ): + OTLPMetricExporter(insecure=False) + self.assertTrue(mock_ssl_channel.called) + + # pylint: disable=no-self-use + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel") + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel") + def test_otlp_exporter_endpoint(self, mock_secure, mock_insecure): + expected_endpoint = "localhost:4317" + endpoints = [ + ( + "http://localhost:4317", + None, + mock_insecure, + ), + ( + "localhost:4317", + None, + mock_insecure, + ), + ( + "localhost:4317", + False, + mock_secure, + ), + ( + "https://localhost:4317", + None, + mock_secure, + ), + ( + "https://localhost:4317", + True, + mock_insecure, + ), + ] + # pylint: disable=C0209 + for endpoint, insecure, mock_method in endpoints: + OTLPMetricExporter(endpoint=endpoint, insecure=insecure) + self.assertEqual( + 1, + mock_method.call_count, + "expected {} to be called for {} {}".format( + mock_method, endpoint, insecure + ), + ) + self.assertEqual( + expected_endpoint, + mock_method.call_args[0][0], + "expected {} got {} {}".format( + expected_endpoint, mock_method.call_args[0][0], endpoint + ), + ) + mock_method.reset_mock() + + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.expo") + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep") + def test_unavailable(self, mock_sleep, mock_expo): + + mock_expo.configure_mock(**{"return_value": [1]}) + + add_MetricsServiceServicer_to_server( + MetricsServiceServicerUNAVAILABLE(), self.server + ) + self.assertEqual( + self.exporter.export([self.metric_data_1]), + MetricExportResult.FAILURE, + ) + mock_sleep.assert_called_with(1) + + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.expo") + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep") + def test_unavailable_delay(self, mock_sleep, mock_expo): + + mock_expo.configure_mock(**{"return_value": [1]}) + + add_MetricsServiceServicer_to_server( + MetricsServiceServicerUNAVAILABLEDelay(), self.server + ) + self.assertEqual( + self.exporter.export([self.metric_data_1]), + MetricExportResult.FAILURE, + ) + mock_sleep.assert_called_with(4) + + def test_success(self): + add_MetricsServiceServicer_to_server( + MetricsServiceServicerSUCCESS(), self.server + ) + self.assertEqual( + self.exporter.export([self.metric_data_1]), + MetricExportResult.SUCCESS, + ) + + def test_failure(self): + add_MetricsServiceServicer_to_server( + MetricsServiceServicerALREADY_EXISTS(), self.server + ) + self.assertEqual( + self.exporter.export([self.metric_data_1]), + MetricExportResult.FAILURE, + ) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py index fa3c24e0f9..252d2f7b93 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py @@ -131,7 +131,7 @@ def setUp(self): self.server = server(ThreadPoolExecutor(max_workers=10)) - self.server.add_insecure_port("[::]:4317") + self.server.add_insecure_port("127.0.0.1:4317") self.server.start() diff --git a/exporter/opentelemetry-exporter-otlp/tests/test_otlp.py b/exporter/opentelemetry-exporter-otlp/tests/test_otlp.py index 5b1a7d7fde..57b3d7b1b1 100644 --- a/exporter/opentelemetry-exporter-otlp/tests/test_otlp.py +++ b/exporter/opentelemetry-exporter-otlp/tests/test_otlp.py @@ -17,6 +17,9 @@ from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( OTLPLogExporter, ) +from opentelemetry.exporter.otlp.proto.grpc._metric_exporter import ( + OTLPMetricExporter, +) from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( OTLPSpanExporter, ) @@ -27,7 +30,12 @@ class TestOTLPExporters(unittest.TestCase): def test_constructors(self): - for exporter in [OTLPSpanExporter, HTTPSpanExporter, OTLPLogExporter]: + for exporter in [ + OTLPSpanExporter, + HTTPSpanExporter, + OTLPLogExporter, + OTLPMetricExporter, + ]: try: exporter() except Exception: # pylint: disable=broad-except diff --git a/opentelemetry-sdk/setup.cfg b/opentelemetry-sdk/setup.cfg index d1fe563d66..6b4281cc2e 100644 --- a/opentelemetry-sdk/setup.cfg +++ b/opentelemetry-sdk/setup.cfg @@ -59,6 +59,8 @@ opentelemetry_log_emitter_provider = sdk_log_emitter_provider = opentelemetry.sdk._logs:LogEmitterProvider opentelemetry_logs_exporter = console = opentelemetry.sdk._logs.export:ConsoleLogExporter +opentelemetry_metrics_exporter = + console = opentelemetry.sdk._metrics.export:ConsoleMetricExporter opentelemetry_id_generator = random = opentelemetry.sdk.trace.id_generator:RandomIdGenerator opentelemetry_environment_variables = diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py index bf04d8fe1f..8a87c22ff9 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py @@ -32,7 +32,7 @@ ObservableUpDownCounter as APIObservableUpDownCounter, ) from opentelemetry._metrics.instrument import UpDownCounter as APIUpDownCounter -from opentelemetry.sdk._metrics.export.metric_exporter import MetricExporter +from opentelemetry.sdk._metrics.export import MetricExporter from opentelemetry.sdk._metrics.instrument import ( Counter, Histogram, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/data.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/data.py new file mode 100644 index 0000000000..e582d66684 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/data.py @@ -0,0 +1,35 @@ +# 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. + +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationInfo + + +class Metric: + """TODO fill this in""" + + def __init__(self, resource: Resource) -> None: + self.resource = resource + + +class MetricData: + """Readable Metric data plus associated InstrumentationLibrary.""" + + def __init__( + self, + metric: Metric, + instrumentation_info: InstrumentationInfo, + ): + self.metric = metric + self.instrumentation_info = instrumentation_info diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/__init__.py index b0a6f42841..e9a83861fe 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/export/__init__.py @@ -11,3 +11,68 @@ # 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. + +from abc import ABC, abstractmethod +from enum import Enum +from os import linesep +from sys import stdout +from typing import IO, Callable, Sequence + +from opentelemetry.sdk._metrics.data import Metric, MetricData + + +class MetricExportResult(Enum): + SUCCESS = 0 + FAILURE = 1 + + +class MetricExporter(ABC): + """Interface for exporting metrics. + + Interface to be implemented by services that want to export metrics received + in their own format. + """ + + def export(self, metrics: Sequence[MetricData]) -> "MetricExportResult": + """Exports a batch of telemetry data. + + Args: + metrics: The list of `opentelemetry.sdk._metrics.data.MetricData` objects to be exported + + Returns: + The result of the export + """ + + @abstractmethod + def shutdown(self) -> None: + """Shuts down the exporter. + + Called when the SDK is shut down. + """ + + +class ConsoleMetricExporter(MetricExporter): + """Implementation of :class:`MetricExporter` that prints metrics to the + console. + + This class can be used for diagnostic purposes. It prints the exported + metrics to the console STDOUT. + """ + + def __init__( + self, + out: IO = stdout, + formatter: Callable[[Metric], str] = lambda metric: metric.to_json() + + linesep, + ): + self.out = out + self.formatter = formatter + + def export(self, metrics: Sequence[MetricData]) -> MetricExportResult: + for data in metrics: + self.out.write(self.formatter(data.metric)) + self.out.flush() + return MetricExportResult.SUCCESS + + def shutdown(self) -> None: + pass diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index ca755544b7..32c939df14 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -29,6 +29,7 @@ _init_tracing, ) from opentelemetry.sdk._logs.export import ConsoleLogExporter +from opentelemetry.sdk._metrics.export import ConsoleMetricExporter from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.sdk.trace.export import ConsoleSpanExporter from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator @@ -195,3 +196,7 @@ def test_console_exporters(self): self.assertEqual( logs_exporters["console"].__class__, ConsoleLogExporter.__class__ ) + self.assertEqual( + logs_exporters["console"].__class__, + ConsoleMetricExporter.__class__, + )