From c521d19b7e7286c68d4cabd2b25f24805d4753dc Mon Sep 17 00:00:00 2001 From: Leighton Chen Date: Wed, 8 Jun 2022 14:20:59 -0700 Subject: [PATCH] Update to OpenTelemetry api/sdk 1.12.0rc1 (#24619) --- .../CHANGELOG.md | 2 + .../exporter/export/metrics/_exporter.py | 96 +++++--- .../samples/metrics/sample_instruments.py | 35 +-- .../samples/metrics/sample_metrics.py | 10 +- .../setup.py | 4 +- .../tests/metrics/test_metrics.py | 213 ++++++++---------- shared_requirements.txt | 4 +- 7 files changed, 184 insertions(+), 180 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index 4db8328ba988..94032c3b058f 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -13,6 +13,8 @@ ([#23960](https://github.com/Azure/azure-sdk-for-python/pull/23960)) ### Breaking Changes +- Update to OpenTelemetry api/sdk 1.12.0rc1 + ([#24619](https://github.com/Azure/azure-sdk-for-python/pull/24619)) ### Bugs Fixed diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py index b87f683af65b..9219702971f1 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py @@ -1,15 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import logging -from typing import Sequence, Any - -from opentelemetry.sdk._metrics.export import MetricExporter, MetricExportResult -from opentelemetry.sdk._metrics.point import ( - Gauge, - Histogram, - Metric, - Sum, + +from typing import Optional, Any + +from opentelemetry.sdk.metrics.export import ( + DataPointT, + HistogramDataPoint, + MetricExporter, + MetricExportResult, + MetricsData as OTMetricsData, + NumberDataPoint, ) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope from azure.monitor.opentelemetry.exporter import _utils from azure.monitor.opentelemetry.exporter._generated.models import ( @@ -32,14 +36,32 @@ class AzureMonitorMetricExporter(BaseExporter, MetricExporter): """Azure Monitor Metric exporter for OpenTelemetry.""" def export( - self, metrics: Sequence[Metric], **kwargs: Any # pylint: disable=unused-argument + self, + metrics_data: OTMetricsData, + timeout_millis: float = 10_000, # pylint: disable=unused-argument + **kwargs: Any, # pylint: disable=unused-argument ) -> MetricExportResult: """Exports a batch of metric data :param metrics: Open Telemetry Metric(s) to export. - :type metrics: Sequence[~opentelemetry._metrics.point.Metric] - :rtype: ~opentelemetry.sdk._metrics.export.MetricExportResult + :type metrics_data: Sequence[~opentelemetry.sdk.metrics._internal.point.MetricsData] + :rtype: ~opentelemetry.sdk.metrics.export.MetricExportResult """ - envelopes = [self._metric_to_envelope(metric) for metric in metrics] + envelopes = [] + if metrics_data is None: + return MetricExportResult.SUCCESS + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + for point in metric.data.data_points: + if point is not None: + envelopes.append( + self._point_to_envelope( + point, + metric.name, + resource_metric.resource, + scope_metric.scope + ) + ) try: result = self._transmit(envelopes) if result == ExportResult.FAILED_RETRYABLE: @@ -53,17 +75,25 @@ def export( _logger.exception("Exception occurred while exporting the data.") return _get_metric_export_result(ExportResult.FAILED_NOT_RETRYABLE) - def shutdown(self) -> None: + def shutdown( + self, + timeout_millis: float = 30_000, # pylint: disable=unused-argument + **kwargs: Any, # pylint: disable=unused-argument + ) -> None: """Shuts down the exporter. Called when the SDK is shut down. """ self.storage.close() - def _metric_to_envelope(self, metric: Metric) -> TelemetryItem: - if not metric: - return None - envelope = _convert_metric_to_envelope(metric) + def _point_to_envelope( + self, + point: DataPointT, + name: str, + resource: Optional[Resource] = None, + scope: Optional[InstrumentationScope] = None + ) -> TelemetryItem: + envelope = _convert_point_to_envelope(point, name, resource, scope) envelope.instrumentation_key = self._instrumentation_key return envelope @@ -86,33 +116,39 @@ def from_connection_string( # pylint: disable=protected-access -def _convert_metric_to_envelope(metric: Metric) -> TelemetryItem: - point = metric.point +def _convert_point_to_envelope( + point: DataPointT, + name: str, + resource: Optional[Resource] = None, + scope: Optional[InstrumentationScope] = None # pylint: disable=unused-argument +) -> TelemetryItem: envelope = _utils._create_telemetry_item(point.time_unix_nano) envelope.name = "Microsoft.ApplicationInsights.Metric" - envelope.tags.update(_utils._populate_part_a_fields(metric.resource)) - properties = metric.attributes + envelope.tags.update(_utils._populate_part_a_fields(resource)) value = 0 - # TODO count = 1 - # min = None - # max = None + min_ = None + max_ = None # std_dev = None - if isinstance(point, (Gauge, Sum)): + if isinstance(point, NumberDataPoint): value = point.value - elif isinstance(point, Histogram): - value = sum(point.bucket_counts) - count = sum(point.bucket_counts) + elif isinstance(point, HistogramDataPoint): + value = point.sum + count = point.count + min_ = point.min + max_ = point.max data_point = MetricDataPoint( - name=metric.name, + name=name, value=value, data_point_type="Aggregation", count=count, + min=min_, + max=max_, ) data = MetricsData( - properties=properties, + properties=dict(point.attributes), metrics=[data_point], ) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_instruments.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_instruments.py index 2c6eb32c6f36..396e72e4bc40 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_instruments.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_instruments.py @@ -6,11 +6,12 @@ AzureMonitorMetricsExporter. """ import os +from typing import Iterable -from opentelemetry import _metrics -from opentelemetry._metrics.measurement import Measurement -from opentelemetry.sdk._metrics import MeterProvider -from opentelemetry.sdk._metrics.export import PeriodicExportingMetricReader +from opentelemetry import metrics +from opentelemetry.metrics import CallbackOptions, Observation +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader from azure.monitor.opentelemetry.exporter import AzureMonitorMetricExporter @@ -18,22 +19,24 @@ os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"] ) reader = PeriodicExportingMetricReader(exporter, export_interval_millis=5000) -_metrics.set_meter_provider(MeterProvider(metric_readers=[reader])) +metrics.set_meter_provider(MeterProvider(metric_readers=[reader])) # Create a namespaced meter -meter = _metrics.get_meter_provider().get_meter("sample") +meter = metrics.get_meter_provider().get_meter("sample") # Callback functions for observable instruments -def observable_counter_func(): - yield Measurement(1, {}) +def observable_counter_func(options: CallbackOptions) -> Iterable[Observation]: + yield Observation(1, {}) -def observable_up_down_counter_func(): - yield Measurement(-10, {}) +def observable_up_down_counter_func( + options: CallbackOptions, +) -> Iterable[Observation]: + yield Observation(-10, {}) -def observable_gauge_func(): - yield Measurement(9, {}) +def observable_gauge_func(options: CallbackOptions) -> Iterable[Observation]: + yield Observation(9, {}) # Counter counter = meter.create_counter("counter") @@ -41,7 +44,7 @@ def observable_gauge_func(): # Async Counter observable_counter = meter.create_observable_counter( - "observable_counter", observable_counter_func + "observable_counter", [observable_counter_func] ) # UpDownCounter @@ -51,7 +54,7 @@ def observable_gauge_func(): # Async UpDownCounter observable_updown_counter = meter.create_observable_up_down_counter( - "observable_updown_counter", observable_up_down_counter_func + "observable_updown_counter", [observable_up_down_counter_func] ) # Histogram @@ -59,6 +62,4 @@ def observable_gauge_func(): histogram.record(99.9) # Async Gauge -gauge = meter.create_observable_gauge("gauge", observable_gauge_func) - -input(...) \ No newline at end of file +gauge = meter.create_observable_gauge("gauge", [observable_gauge_func]) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_metrics.py index b910fc8b1b55..58435fa128f1 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/samples/metrics/sample_metrics.py @@ -6,9 +6,9 @@ """ import os -from opentelemetry import _metrics -from opentelemetry.sdk._metrics import MeterProvider -from opentelemetry.sdk._metrics.export import PeriodicExportingMetricReader +from opentelemetry import metrics +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader from azure.monitor.opentelemetry.exporter import AzureMonitorMetricExporter @@ -16,10 +16,10 @@ os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"] ) reader = PeriodicExportingMetricReader(exporter, export_interval_millis=5000) -_metrics.set_meter_provider(MeterProvider(metric_readers=[reader])) +metrics.set_meter_provider(MeterProvider(metric_readers=[reader])) # Create a namespaced meter -meter = _metrics.get_meter_provider().get_meter("sample") +meter = metrics.get_meter_provider().get_meter("sample") # Create Counter instrument with the meter counter = meter.create_counter("counter") diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/setup.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/setup.py index cbbf22b98bbe..f0188cb61257 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/setup.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/setup.py @@ -82,8 +82,8 @@ install_requires=[ "azure-core<2.0.0,>=1.23.0", "msrest>=0.6.10", - "opentelemetry-api<2.0.0,>=1.11.1,!=1.10a0", - "opentelemetry-sdk<2.0.0,>=1.11.1,!=1.10a0", + "opentelemetry-api<2.0.0,>=1.12.0rc1,!=1.10a0", + "opentelemetry-sdk<2.0.0,>=1.12.0rc1,!=1.10a0", ], ) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py index 68933ebe441b..f1f728a45e82 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py @@ -9,12 +9,17 @@ # pylint: disable=import-error from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk._metrics.export import MetricExportResult -from opentelemetry.sdk._metrics.point import ( +from opentelemetry.sdk.metrics.export import ( AggregationTemporality, Gauge, Histogram, + HistogramDataPoint, Metric, + MetricExportResult, + MetricsData, + NumberDataPoint, + ResourceMetrics, + ScopeMetrics, Sum, ) @@ -47,24 +52,42 @@ def setUpClass(cls): "APPINSIGHTS_INSTRUMENTATIONKEY" ] = "1234abcd-5678-4efa-8abc-1234567890ab" cls._exporter = AzureMonitorMetricExporter() - cls._metric = Metric( - attributes={ - "test": "attribute" - }, - description="test description", - instrumentation_scope=InstrumentationScope("test_name"), - name="test name", - resource = Resource.create( - attributes={"asd":"test_resource"} - ), - unit="ms", - point=Sum( - start_time_unix_nano=1646865018558419456, - time_unix_nano=1646865018558419457, - value=10, - aggregation_temporality=AggregationTemporality.CUMULATIVE, - is_monotonic=False, - ) + cls._metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource = Resource.create( + attributes={"asd":"test_resource"} + ), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("test_name"), + metrics=[ + Metric( + name="test name", + description="test description", + unit="ms", + data=Sum( + data_points=[ + NumberDataPoint( + attributes={ + "test": "attribute", + }, + start_time_unix_nano=1646865018558419456, + time_unix_nano=1646865018558419457, + value=10, + ) + ], + aggregation_temporality=AggregationTemporality.CUMULATIVE, + is_monotonic=False, + ) + ) + ], + schema_url="test url", + ) + ], + schema_url="test url", + ) + ] ) @classmethod @@ -91,9 +114,9 @@ def test_from_connection_string(self): "4321abcd-5678-4efa-8abc-1234567890ab", ) - def test_export_empty(self): + def test_export_none(self): exporter = self._exporter - result = exporter.export([]) + result = exporter.export(None) self.assertEqual(result, MetricExportResult.SUCCESS) def test_export_failure(self): @@ -104,7 +127,7 @@ def test_export_failure(self): transmit.return_value = ExportResult.FAILED_RETRYABLE storage_mock = mock.Mock() exporter.storage.put = storage_mock - result = exporter.export([self._metric]) + result = exporter.export(self._metrics_data) self.assertEqual(result, MetricExportResult.FAILURE) self.assertEqual(storage_mock.call_count, 1) @@ -116,7 +139,7 @@ def test_export_success(self): transmit.return_value = ExportResult.SUCCESS storage_mock = mock.Mock() exporter._transmit_from_storage = storage_mock - result = exporter.export([self._metric]) + result = exporter.export(self._metrics_data) self.assertEqual(result, MetricExportResult.SUCCESS) self.assertEqual(storage_mock.call_count, 1) @@ -127,7 +150,7 @@ def test_export_exception(self, logger_mock): "azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter._transmit", throw(Exception), ): # noqa: E501 - result = exporter.export([self._metric]) + result = exporter.export(self._metrics_data) self.assertEqual(result, MetricExportResult.FAILURE) self.assertEqual(logger_mock.exception.called, True) @@ -137,33 +160,24 @@ def test_export_not_retryable(self): "azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter._transmit" ) as transmit: # noqa: E501 transmit.return_value = ExportResult.FAILED_NOT_RETRYABLE - result = exporter.export([self._metric]) + result = exporter.export(self._metrics_data) self.assertEqual(result, MetricExportResult.FAILURE) - def test_metric_to_envelope_partA(self): + def test_point_to_envelope_partA(self): exporter = self._exporter resource = Resource( {"service.name": "testServiceName", "service.namespace": "testServiceNamespace", "service.instance.id": "testServiceInstanceId"}) - _metric = Metric( + point=NumberDataPoint( attributes={ - "test": "attribute" + "test": "attribute", }, - description="test description", - instrumentation_scope=InstrumentationScope("test_name"), - name="test name", - resource = resource, - unit="ms", - point=Sum( - start_time_unix_nano=1646865018558419456, - time_unix_nano=1646865018558419457, - value=10, - aggregation_temporality=AggregationTemporality.CUMULATIVE, - is_monotonic=False, - ) + start_time_unix_nano=1646865018558419456, + time_unix_nano=1646865018558419457, + value=10, ) - envelope = exporter._metric_to_envelope(_metric) + envelope = exporter._point_to_envelope(point, "test name", resource) self.assertEqual(envelope.instrumentation_key, "1234abcd-5678-4efa-8abc-1234567890ab") @@ -178,54 +192,38 @@ def test_metric_to_envelope_partA(self): self.assertEqual(envelope.tags.get("ai.cloud.roleInstance"), "testServiceInstanceId") self.assertEqual(envelope.tags.get("ai.internal.nodeName"), "testServiceInstanceId") - def test_metric_to_envelope_partA_default(self): + def test_point_to_envelope_partA_default(self): exporter = self._exporter resource = Resource( {"service.name": "testServiceName"}) - _metric = Metric( + point=NumberDataPoint( attributes={ - "test": "attribute" + "test": "attribute", }, - description="test description", - instrumentation_scope=InstrumentationScope("test_name"), - name="test name", - resource = resource, - unit="ms", - point=Sum( - start_time_unix_nano=1646865018558419456, - time_unix_nano=1646865018558419457, - value=10, - aggregation_temporality=AggregationTemporality.CUMULATIVE, - is_monotonic=False, - ) + start_time_unix_nano=1646865018558419456, + time_unix_nano=1646865018558419457, + value=10, ) - envelope = exporter._metric_to_envelope(_metric) + envelope = exporter._point_to_envelope(point, "test name", resource) self.assertEqual(envelope.tags.get("ai.cloud.role"), "testServiceName") self.assertEqual(envelope.tags.get("ai.cloud.roleInstance"), platform.node()) self.assertEqual(envelope.tags.get("ai.internal.nodeName"), envelope.tags.get("ai.cloud.roleInstance")) - def test_metric_to_envelope_sum(self): + def test_point_to_envelope_number(self): exporter = self._exporter - _metric = Metric( + resource = Resource.create(attributes={"asd":"test_resource"}) + scope = InstrumentationScope("test_scope"), + point=NumberDataPoint( attributes={ - "test": "attribute" + "test": "attribute", }, - description="test description", - instrumentation_scope=InstrumentationScope("test_name"), - name="test name", - resource=None, - unit="ms", - point=Sum( - start_time_unix_nano=1646865018558419456, - time_unix_nano=1646865018558419457, - value=10, - aggregation_temporality=AggregationTemporality.CUMULATIVE, - is_monotonic=False, - ) + start_time_unix_nano=1646865018558419456, + time_unix_nano=1646865018558419457, + value=10, ) - envelope = exporter._metric_to_envelope(_metric) + envelope = exporter._point_to_envelope(point, "test name", resource, scope) self.assertEqual(envelope.name, 'Microsoft.ApplicationInsights.Metric') - self.assertEqual(envelope.time, ns_to_iso_str(_metric.point.time_unix_nano)) + self.assertEqual(envelope.time, ns_to_iso_str(point.time_unix_nano)) self.assertEqual(envelope.data.base_type, 'MetricData') self.assertEqual(len(envelope.data.base_data.properties), 1) self.assertEqual(envelope.data.base_data.properties['test'], 'attribute') @@ -235,65 +233,32 @@ def test_metric_to_envelope_sum(self): self.assertEqual(envelope.data.base_data.metrics[0].data_point_type, "Aggregation") self.assertEqual(envelope.data.base_data.metrics[0].count, 1) - def test_metric_to_envelope_gauge(self): - exporter = self._exporter - _metric = Metric( - attributes={ - "test": "attribute" - }, - description="test description", - instrumentation_scope=InstrumentationScope("test_name"), - name="test name", - resource=None, - unit="ms", - point=Gauge( - time_unix_nano=1646865018558419457, - value=100, - ) - ) - envelope = exporter._metric_to_envelope(_metric) - self.assertEqual(envelope.name, 'Microsoft.ApplicationInsights.Metric') - self.assertEqual(envelope.time, ns_to_iso_str(_metric.point.time_unix_nano)) - self.assertEqual(envelope.data.base_type, 'MetricData') - self.assertEqual(len(envelope.data.base_data.properties), 1) - self.assertEqual(envelope.data.base_data.properties['test'], 'attribute') - self.assertEqual(len(envelope.data.base_data.metrics), 1) - self.assertEqual(envelope.data.base_data.metrics[0].name, "test name") - self.assertEqual(envelope.data.base_data.metrics[0].value, 100) - self.assertEqual(envelope.data.base_data.metrics[0].data_point_type, "Aggregation") - self.assertEqual(envelope.data.base_data.metrics[0].count, 1) - - def test_metric_to_envelope_histogram(self): + def test_point_to_envelope_histogram(self): exporter = self._exporter - _metric = Metric( + resource = Resource.create(attributes={"asd":"test_resource"}) + scope = InstrumentationScope("test_scope"), + point=HistogramDataPoint( attributes={ - "test": "attribute" + "test": "attribute", }, - description="test description", - instrumentation_scope=InstrumentationScope("test_name"), - name="test name", - resource=None, - unit="ms", - point=Histogram( - aggregation_temporality=AggregationTemporality.DELTA, - bucket_counts=[0,3,4], - explicit_bounds=[0,5,10,0], - max=18, - min=1, - start_time_unix_nano=1646865018558419456, - time_unix_nano=1646865018558419457, - sum=31, - ) + bucket_counts=[0,3,4], + count=7, + explicit_bounds=[0,5,10,0], + max=18, + min=1, + start_time_unix_nano=1646865018558419456, + time_unix_nano=1646865018558419457, + sum=31, ) - envelope = exporter._metric_to_envelope(_metric) + envelope = exporter._point_to_envelope(point, "test name", resource, scope) self.assertEqual(envelope.name, 'Microsoft.ApplicationInsights.Metric') - self.assertEqual(envelope.time, ns_to_iso_str(_metric.point.time_unix_nano)) + self.assertEqual(envelope.time, ns_to_iso_str(point.time_unix_nano)) self.assertEqual(envelope.data.base_type, 'MetricData') self.assertEqual(len(envelope.data.base_data.properties), 1) self.assertEqual(envelope.data.base_data.properties['test'], 'attribute') self.assertEqual(len(envelope.data.base_data.metrics), 1) self.assertEqual(envelope.data.base_data.metrics[0].name, "test name") - self.assertEqual(envelope.data.base_data.metrics[0].value, 7) + self.assertEqual(envelope.data.base_data.metrics[0].value, 31) self.assertEqual(envelope.data.base_data.metrics[0].data_point_type, "Aggregation") self.assertEqual(envelope.data.base_data.metrics[0].count, 7) diff --git a/shared_requirements.txt b/shared_requirements.txt index 92969d41a886..a873493cb3e1 100644 --- a/shared_requirements.txt +++ b/shared_requirements.txt @@ -201,8 +201,8 @@ opentelemetry-sdk<2.0.0,>=1.5.0,!=1.10a0 #override azure-ai-translation-document azure-core<2.0.0,>=1.14.0 #override azure-monitor-opentelemetry-exporter azure-core<2.0.0,>=1.23.0 #override azure-monitor-opentelemetry-exporter msrest>=0.6.10 -#override azure-monitor-opentelemetry-exporter opentelemetry-api<2.0.0,>=1.11.1,!=1.10a0 -#override azure-monitor-opentelemetry-exporter opentelemetry-sdk<2.0.0,>=1.11.1,!=1.10a0 +#override azure-monitor-opentelemetry-exporter opentelemetry-api<2.0.0,>=1.12.0rc1,!=1.10a0 +#override azure-monitor-opentelemetry-exporter opentelemetry-sdk<2.0.0,>=1.12.0rc1,!=1.10a0 #override azure-core-tracing-opentelemetry opentelemetry-api<2.0.0,>=1.0.0 #override azure-identity six>=1.12.0 #override azure-keyvault-keys six>=1.12.0