diff --git a/google/api_core/metrics_tracer.py b/google/api_core/metrics_tracer.py new file mode 100644 index 00000000..e65e45db --- /dev/null +++ b/google/api_core/metrics_tracer.py @@ -0,0 +1,418 @@ +# Copyright 2025 Google LLC +# +# 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. + +"""This module contains the MetricTracer class and its related helper classes. +The MetricTracer class is responsible for collecting and tracing metrics, +while the helper classes provide additional functionality and context for the metrics being traced.""" + +from datetime import datetime +from typing import Dict + +try: + from opentelemetry.metrics import Counter, Histogram + + HAS_OPENTELEMETRY_INSTALLED = True +except ImportError: # pragma: NO COVER + HAS_OPENTELEMETRY_INSTALLED = False + +import http.client + +# Monitored Resource Labels +MONITORED_RES_LABEL_KEY_PROJECT = "project_id" +MONITORED_RES_LABEL_KEY_INSTANCE = "instance_id" +MONITORED_RES_LABEL_KEY_INSTANCE_CONFIG = "instance_config" +MONITORED_RES_LABEL_KEY_LOCATION = "location" +MONITORED_RES_LABEL_KEY_CLIENT_HASH = "client_hash" +# Metric Labels +METRIC_LABEL_KEY_CLIENT_UID = "client_uid" +METRIC_LABEL_KEY_CLIENT_NAME = "client_name" +METRIC_LABEL_KEY_DATABASE = "database" +METRIC_LABEL_KEY_METHOD = "method" +METRIC_LABEL_KEY_STATUS = "status" +METRIC_LABEL_KEY_DIRECT_PATH_ENABLED = "directpath_enabled" +METRIC_LABEL_KEY_DIRECT_PATH_USED = "directpath_used" + + +class MetricAttemptTracer: + """This class is designed to hold information related to a metric attempt. + It captures the start time of the attempt, whether the direct path was used, and the status of the attempt.""" + + _start_time: datetime + direct_path_used: bool + status: str + + def __init__(self): + """ + Initializes a MetricAttemptTracer instance with default values. + + This constructor sets the start time of the metric attempt to the current datetime, initializes the status as an empty string, and sets direct path used flag to False by default. + """ + self._start_time = datetime.now() + self.status = "" + self.direct_path_used = False + + @property + def start_time(self): + """Getter method for the start_time property. + + This method returns the start time of the metric attempt. + + Returns: + datetime: The start time of the metric attempt. + """ + return self._start_time + + +class MetricOpTracer: + """ + This class is designed to store and manage information related to metric operations. + It captures the method name, start time, attempt count, current attempt, status, and direct path enabled status of a metric operation. + """ + + _attempt_count: int + _start_time: datetime + _current_attempt: MetricAttemptTracer + status: str + direct_path_enabled: bool + + def __init__(self, is_direct_path_enabled: bool = False): + """ + Initializes a MetricOpTracer instance with the given parameters. + + This constructor sets up a MetricOpTracer instance with the provided metric name, instrumentations for attempt latency, + attempt counter, operation latency, and operation counter, and a flag indicating whether the direct path is enabled. + It initializes the method name, start time, attempt count, current attempt, direct path enabled status, and status of the metric operation. + + Args: + metric_name (str): The name of the metric operation. + instrument_attempt_latency (Histogram): The instrumentation for measuring attempt latency. + instrument_attempt_counter (Counter): The instrumentation for counting attempts. + instrument_operation_latency (Histogram): The instrumentation for measuring operation latency. + instrument_operation_counter (Counter): The instrumentation for counting operations. + is_direct_path_enabled (bool, optional): A flag indicating whether the direct path is enabled. Defaults to False. + """ + self._attempt_count = 0 + self._start_time = datetime.now() + self._current_attempt = None + self.direct_path_enabled = is_direct_path_enabled + self.status = "" + + @property + def attempt_count(self): + return self._attempt_count + + @property + def current_attempt(self): + return self._current_attempt + + @property + def start_time(self): + return self._start_time + + def increment_attempt_count(self): + self._attempt_count += 1 + + def start(self): + self._start_time = datetime.now() + + def new_attempt(self): + self._current_attempt = MetricAttemptTracer() + + +class MetricsTracer: + """ + This class computes generic metrics that can be observed in the lifecycle of an RPC operation. + + The responsibility of recording metrics should delegate to MetricsRecorder, hence this + class should not have any knowledge about the observability framework used for metrics recording. + """ + + _client_attributes: Dict[str, str] + _instrument_attempt_counter: Counter + _instrument_attempt_latency: Histogram + _instrument_operation_counter: Counter + _instrument_operation_latency: Histogram + current_op: MetricOpTracer + enabled: bool + method: str + + def __init__( + self, + method: str, + enabled: bool, + is_direct_path_enabled: bool, + instrument_attempt_latency: Histogram, + instrument_attempt_counter: Counter, + instrument_operation_latency: Histogram, + instrument_operation_counter: Counter, + client_attributes: Dict[str, str], + ): + """ + Initializes a MetricsTracer instance with the given parameters. + + This constructor initializes a MetricsTracer instance with the provided method name, enabled status, direct path enabled status, + instrumented metrics for attempt latency, attempt counter, operation latency, operation counter, and client attributes. + It sets up the necessary metrics tracing infrastructure for recording metrics related to RPC operations. + + Args: + method (str): The name of the method for which metrics are being traced. + enabled (bool): A flag indicating if metrics tracing is enabled. + is_direct_path_enabled (bool): A flag indicating if the direct path is enabled for metrics tracing. + instrument_attempt_latency (Histogram): The instrument for measuring attempt latency. + instrument_attempt_counter (Counter): The instrument for counting attempts. + instrument_operation_latency (Histogram): The instrument for measuring operation latency. + instrument_operation_counter (Counter): The instrument for counting operations. + client_attributes (dict[str, str]): A dictionary of client attributes used for metrics tracing. + """ + self.method = method + self.current_op = MetricOpTracer(is_direct_path_enabled=is_direct_path_enabled) + self._client_attributes = client_attributes + self._instrument_attempt_latency = instrument_attempt_latency + self._instrument_attempt_counter = instrument_attempt_counter + self._instrument_operation_latency = instrument_operation_latency + self._instrument_operation_counter = instrument_operation_counter + self.enabled = enabled + + @property + def client_attributes(self) -> Dict[str, str]: + """ + Returns a dictionary of client attributes used for metrics tracing. + + This property returns a dictionary containing client attributes such as project, instance, + instance configuration, location, client hash, client UID, client name, and database. + These attributes are used to provide context to the metrics being traced. + + Returns: + dict[str, str]: A dictionary of client attributes. + """ + return self._client_attributes + + @property + def instrument_attempt_counter(self) -> Counter: + """ + Returns the instrument for counting attempts. + + This property returns the Counter instrument used to count the number of attempts made during RPC operations. + This metric is useful for tracking the frequency of attempts and can help identify patterns or issues in the operation flow. + + Returns: + Counter: The instrument for counting attempts. + """ + return self._instrument_attempt_counter + + @property + def instrument_attempt_latency(self) -> Histogram: + """ + Returns the instrument for measuring attempt latency. + + This property returns the Histogram instrument used to measure the latency of individual attempts. + This metric is useful for tracking the performance of attempts and can help identify bottlenecks or issues in the operation flow. + + Returns: + Histogram: The instrument for measuring attempt latency. + """ + return self._instrument_attempt_latency + + @property + def instrument_operation_counter(self) -> Counter: + """ + Returns the instrument for counting operations. + + This property returns the Counter instrument used to count the number of operations made during RPC operations. + This metric is useful for tracking the frequency of operations and can help identify patterns or issues in the operation flow. + + Returns: + Counter: The instrument for counting operations. + """ + return self._instrument_operation_counter + + @property + def instrument_operation_latency(self) -> Histogram: + """ + Returns the instrument for measuring operation latency. + + This property returns the Histogram instrument used to measure the latency of operations. + This metric is useful for tracking the performance of operations and can help identify bottlenecks or issues in the operation flow. + + Returns: + Histogram: The instrument for measuring operation latency. + """ + return self._instrument_operation_latency + + def record_attempt_start(self) -> None: + """ + Records the start of a new attempt within the current operation. + + This method increments the attempt count for the current operation and marks the start of a new attempt. + It is used to track the number of attempts made during an operation and to identify the start of each attempt for metrics and tracing purposes. + """ + self.current_op.increment_attempt_count() + self.current_op.new_attempt() + + def record_attempt_completion(self) -> None: + """ + Records the completion of an attempt within the current operation. + + This method updates the status of the current attempt to indicate its completion and records the latency of the attempt. + It calculates the elapsed time since the attempt started and uses this value to record the attempt latency metric. + This metric is useful for tracking the performance of individual attempts and can help identify bottlenecks or issues in the operation flow. + + If metrics tracing is not enabled, this method does not perform any operations. + """ + if not self.enabled: + return + self.current_op.current_attempt.status = http.client.OK.phrase + + # Build Attributes + attempt_attributes = self._create_attempt_otel_attributes() + + # Calculate elapsed time + attempt_latency_ms = self._get_ms_time_diff( + start=self.current_op.current_attempt.start_time, end=datetime.now() + ) + + # Record attempt latency + self.instrument_attempt_latency.record( + amount=attempt_latency_ms, attributes=attempt_attributes + ) + + def record_operation_start(self) -> None: + """ + Records the start of a new operation. + + This method marks the beginning of a new operation and initializes the operation's metrics tracking. + It is used to track the start time of an operation, which is essential for calculating operation latency and other metrics. + If metrics tracing is not enabled, this method does not perform any operations. + """ + if not self.enabled: + return + self.current_op.start() + + def record_operation_completion(self) -> None: + """ + Records the completion of an operation. + + This method marks the end of an operation and updates the metrics accordingly. + It calculates the operation latency by measuring the time elapsed since the operation started and records this metric. + Additionally, it increments the operation count and records the attempt count for the operation. + If metrics tracing is not enabled, this method does not perform any operations. + """ + if not self.enabled: + return + end_time = datetime.now() + + # Build Attributes + operation_attributes = self._create_operation_otel_attributes() + attempt_attributes = self._create_attempt_otel_attributes() + + # Calculate elapsed time + operation_latency_ms = self._get_ms_time_diff( + start=self.current_op.start_time, end=end_time + ) + + # Increase operation count + self.instrument_operation_counter.add(amount=1, attributes=operation_attributes) + + # Record operation latency + self.instrument_operation_latency.record( + amount=operation_latency_ms, attributes=operation_attributes + ) + + # Record Attempt Count + self.instrument_attempt_counter.add( + self.current_op.attempt_count, attributes=attempt_attributes + ) + + def _create_otel_attributes(self) -> Dict[str, str]: + """ + Creates a dictionary of attributes for OpenTelemetry metrics tracing. + + This method initializes a copy of the client attributes and adds specific operation attributes + such as the method name, direct path enabled status, and direct path used status. + These attributes are used to provide context to the metrics being traced. + If metrics tracing is not enabled, this method does not perform any operations. + Returns: + dict[str, str]: A dictionary of attributes for OpenTelemetry metrics tracing. + """ + if not self.enabled: + return + + attributes = self.client_attributes.copy() + attributes[METRIC_LABEL_KEY_METHOD] = self.method + attributes[METRIC_LABEL_KEY_DIRECT_PATH_ENABLED] = str( + self.current_op.direct_path_enabled + ) + if self.current_op.current_attempt is not None: + attributes[METRIC_LABEL_KEY_DIRECT_PATH_USED] = str( + self.current_op.current_attempt.direct_path_used + ) + + return attributes + + def _create_operation_otel_attributes(self) -> Dict[str, str]: + """ + Creates a dictionary of attributes specific to an operation for OpenTelemetry metrics tracing. + + This method builds upon the base attributes created by `_create_otel_attributes` and adds the operation status. + These attributes are used to provide context to the metrics being traced for a specific operation. + If metrics tracing is not enabled, this method does not perform any operations. + + Returns: + dict[str, str]: A dictionary of attributes specific to an operation for OpenTelemetry metrics tracing. + """ + if not self.enabled: + return + + attributes = self._create_otel_attributes() + attributes[METRIC_LABEL_KEY_STATUS] = self.current_op.status + return attributes + + def _create_attempt_otel_attributes(self) -> Dict[str, str]: + """ + Creates a dictionary of attributes specific to an attempt within an operation for OpenTelemetry metrics tracing. + + This method builds upon the operation attributes created by `_create_operation_otel_attributes` and adds the attempt status. + These attributes are used to provide context to the metrics being traced for a specific attempt within an operation. + If metrics tracing is not enabled, this method does not perform any operations. + Returns: + dict[str, str]: A dictionary of attributes specific to an attempt within an operation for OpenTelemetry metrics tracing. + """ + if not self.enabled: + return + + attributes = self._create_operation_otel_attributes() + # Short circuit out if we don't have an attempt + if self.current_op.current_attempt is not None: + attributes[METRIC_LABEL_KEY_STATUS] = self.current_op.current_attempt.status + + return attributes + + @staticmethod + def _get_ms_time_diff(start: datetime, end: datetime) -> float: + """ + Calculates the time difference in milliseconds between two datetime objects. + + This method calculates the time difference between two datetime objects and returns the result in milliseconds. + This is useful for measuring the duration of operations or attempts for metrics tracing. + Note: total_seconds() returns a float value of seconds. + + Args: + start (datetime): The start datetime. + end (datetime): The end datetime. + + Returns: + float: The time difference in milliseconds. + """ + time_delta = end - start + return time_delta.total_seconds() * 1000 diff --git a/google/api_core/metrics_tracer_factory.py b/google/api_core/metrics_tracer_factory.py new file mode 100644 index 00000000..6fe1dc69 --- /dev/null +++ b/google/api_core/metrics_tracer_factory.py @@ -0,0 +1,191 @@ +# Copyright 2025 Google LLC +# +# 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. + +"""Factory for creating MetricTracer instances, facilitating metrics collection and tracing.""" + +from google.api_core.metrics_tracer import ( + MetricsTracer, + # Monitored Resource Labels + MONITORED_RES_LABEL_KEY_PROJECT, + MONITORED_RES_LABEL_KEY_INSTANCE, + MONITORED_RES_LABEL_KEY_INSTANCE_CONFIG, + MONITORED_RES_LABEL_KEY_LOCATION, + MONITORED_RES_LABEL_KEY_CLIENT_HASH, + # Metric Labels, + METRIC_LABEL_KEY_CLIENT_UID, + METRIC_LABEL_KEY_CLIENT_NAME, + METRIC_LABEL_KEY_DATABASE, +) +from typing import Dict + +try: + from opentelemetry.metrics import Counter, Histogram, get_meter_provider + + HAS_OPENTELEMETRY_INSTALLED = True +except ImportError: # pragma: NO COVER + HAS_OPENTELEMETRY_INSTALLED = False + +from google.api_core import version as api_core_version + +# Constants +BUILT_IN_METRICS_METER_NAME = "gax-python" + + +class MetricsTracerFactory: + """Factory class for creating MetricTracer instances. This class facilitates the creation of MetricTracer objects, which are responsible for collecting and tracing metrics.""" + + enabled: bool + _instrument_attempt_latency: Histogram + _instrument_attempt_counter: Counter + _instrument_operation_latency: Histogram + _instrument_operation_counter: Counter + _client_attributes: Dict[str, str] + + @property + def instrument_attempt_latency(self) -> Histogram: + return self._instrument_attempt_latency + + @property + def instrument_attempt_counter(self) -> Counter: + return self._instrument_attempt_counter + + @property + def instrument_operation_latency(self) -> Histogram: + return self._instrument_operation_latency + + @property + def instrument_operation_counter(self) -> Counter: + return self._instrument_operation_counter + + def __init__( + self, + enabled: bool, + service_name: str, + project: str, + instance: str, + instance_config: str, + location: str, + client_hash: str, + client_uid: str, + client_name: str, + database: str, + ): + """Initializes a MetricsTracerFactory instance with the given parameters. + + This constructor initializes a MetricsTracerFactory instance with the provided service name, project, instance, instance configuration, location, client hash, client UID, client name, and database. It sets up the necessary metric instruments and client attributes for metrics tracing. + + Args: + service_name (str): The name of the service for which metrics are being traced. + project (str): The project ID for the monitored resource. + instance (str): The instance ID for the monitored resource. + instance_config (str): The instance configuration for the monitored resource. + location (str): The location of the monitored resource. + client_hash (str): A unique hash for the client. + client_uid (str): The unique identifier for the client. + client_name (str): The name of the client. + database (str): The database name associated with the client. + """ + self.enabled = enabled + self._create_metric_instruments(service_name) + self._client_attributes = { + MONITORED_RES_LABEL_KEY_PROJECT: project, + MONITORED_RES_LABEL_KEY_INSTANCE: instance, + MONITORED_RES_LABEL_KEY_INSTANCE_CONFIG: instance_config, + MONITORED_RES_LABEL_KEY_LOCATION: location, + MONITORED_RES_LABEL_KEY_CLIENT_HASH: client_hash, + METRIC_LABEL_KEY_CLIENT_UID: client_uid, + METRIC_LABEL_KEY_CLIENT_NAME: client_name, + METRIC_LABEL_KEY_DATABASE: database, + } + + @property + def client_attributes(self) -> Dict[str, str]: + """Returns a dictionary of client attributes used for metrics tracing. + + This property returns a dictionary containing client attributes such as project, instance, + instance configuration, location, client hash, client UID, client name, and database. + These attributes are used to provide context to the metrics being traced. + + Returns: + dict[str, str]: A dictionary of client attributes. + """ + return self._client_attributes + + def create_metrics_tracer(self, method: str) -> MetricsTracer: + """Creates and returns a MetricsTracer instance with default settings and client attributes. + + This method initializes a MetricsTracer instance with default settings for metrics tracing, + with disabled metrics tracing and no direct path enabled by default. + It also sets the client attributes based on the factory's configuration. + + Args: + method (str): The name of the method for which metrics are being traced. + + Returns: + MetricsTracer: A MetricsTracer instance with default settings and client attributes. + """ + metrics_tracer = MetricsTracer( + enabled=self.enabled and HAS_OPENTELEMETRY_INSTALLED, + method=method, + is_direct_path_enabled=False, # Direct path is disabled by default + instrument_attempt_latency=self.instrument_attempt_latency, + instrument_attempt_counter=self.instrument_attempt_counter, + instrument_operation_latency=self.instrument_operation_latency, + instrument_operation_counter=self.instrument_operation_counter, + client_attributes=self.client_attributes, + ) + return metrics_tracer + + def _create_metric_instruments(self, service_name: str) -> None: + """ + Creates and sets up metric instruments for the given service name. + + This method initializes and configures metric instruments for attempt latency, attempt counter, + operation latency, and operation counter. These instruments are used to measure and track + metrics related to attempts and operations within the service. + + Args: + service_name (str): The name of the service for which metric instruments are being created. + """ + if not HAS_OPENTELEMETRY_INSTALLED: # pragma: NO COVER + return + + meter_provider = get_meter_provider() + meter = meter_provider.get_meter( + name=BUILT_IN_METRICS_METER_NAME, version=api_core_version + ) + + self._instrument_attempt_latency = meter.create_histogram( + name=f"{service_name}/attempt_latency", + unit="ms", + description="Time an individual attempt took.", + ) + + self._instrument_attempt_counter = meter.create_counter( + name=f"{service_name}/attempt_count", + unit="1", + description="Number of attempts.", + ) + + self._instrument_operation_latency = meter.create_histogram( + name=f"{service_name}/operation_latency", + unit="ms", + description="Total time until final operation success or failure, including retries and backoff.", + ) + + self._instrument_operation_counter = meter.create_counter( + name=f"{service_name}/operation_count", + unit="1", + description="Number of operations.", + ) diff --git a/noxfile.py b/noxfile.py index ada6f330..a513235e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -38,6 +38,7 @@ "unit", "unit_grpc_gcp", "unit_wo_grpc", + "unit_wo_tracing", "unit_w_prerelease_deps", "unit_w_async_rest_extra", "cover", @@ -112,7 +113,13 @@ def install_prerelease_dependencies(session, constraints_path): session.install(*other_deps) -def default(session, install_grpc=True, prerelease=False, install_async_rest=False): +def default( + session, + install_grpc=True, + prerelease=False, + install_async_rest=False, + install_tracing=True, +): """Default unit test session. This is intended to be run **without** an interpreter set, so @@ -136,6 +143,9 @@ def default(session, install_grpc=True, prerelease=False, install_async_rest=Fal # Note: The extra is called `grpc` and not `grpcio`. install_extras.append("grpc") + if install_tracing: + install_extras.append("tracing") + constraints_dir = str(CURRENT_DIRECTORY / "testing") if install_async_rest: install_extras.append("async_rest") @@ -250,6 +260,10 @@ def unit_wo_grpc(session): """Run the unit test suite w/o grpcio installed""" default(session, install_grpc=False) +@nox.session(python=PYTHON_VERSIONS) +def unit_wo_tracing(session): + """Run the unit test suite w/o tracing installed""" + default(session, install_tracing=False) @nox.session(python=PYTHON_VERSIONS) def unit_w_async_rest_extra(session): diff --git a/pyproject.toml b/pyproject.toml index fda8f01b..745e8c70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ grpc = [ ] grpcgcp = ["grpcio-gcp >= 0.2.2, < 1.0.dev0"] grpcio-gcp = ["grpcio-gcp >= 0.2.2, < 1.0.dev0"] +tracing = ["opentelemetry-sdk >= 1.22.0"] [tool.setuptools.dynamic] version = { attr = "google.api_core.version.__version__" } diff --git a/tests/unit/test_metrics_tracer.py b/tests/unit/test_metrics_tracer.py new file mode 100644 index 00000000..8a686965 --- /dev/null +++ b/tests/unit/test_metrics_tracer.py @@ -0,0 +1,140 @@ +# Copyright 2025 Google LLC +# +# 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 pytest +pytest.importorskip("opentelemetry") +from google.api_core.metrics_tracer import MetricsTracer, MetricOpTracer +import mock +from opentelemetry.metrics import Counter, Histogram +from datetime import datetime + +@pytest.fixture +def metrics_tracer(): + mock_attempt_counter = mock.create_autospec(Counter, instance=True) + mock_attempt_latency = mock.create_autospec(Histogram, instance=True) + mock_operation_counter = mock.create_autospec(Counter, instance=True) + mock_operation_latency = mock.create_autospec(Histogram, instance=True) + return MetricsTracer( + method="test_method", + enabled=True, + is_direct_path_enabled=False, + instrument_attempt_latency=mock_attempt_latency, + instrument_attempt_counter=mock_attempt_counter, + instrument_operation_latency=mock_operation_latency, + instrument_operation_counter=mock_operation_counter, + client_attributes={"project_id": "test_project"}, + ) + + +def test_initialization(metrics_tracer): + assert metrics_tracer.method == "test_method" + assert metrics_tracer.enabled is True + + +def test_record_attempt_start(metrics_tracer): + metrics_tracer.record_attempt_start() + assert metrics_tracer.current_op.current_attempt is not None + assert metrics_tracer.current_op.current_attempt.start_time is not None + assert metrics_tracer.current_op.attempt_count == 1 + + +def test_record_operation_start(metrics_tracer): + metrics_tracer.record_operation_start() + assert metrics_tracer.current_op.start_time is not None + + +def test_record_attempt_completion(metrics_tracer): + metrics_tracer.record_attempt_start() + metrics_tracer.record_attempt_completion() + assert metrics_tracer.current_op.current_attempt.status == "OK" + + +def test_record_operation_completion(metrics_tracer): + metrics_tracer.record_operation_start() + metrics_tracer.record_attempt_start() + metrics_tracer.record_attempt_completion() + metrics_tracer.record_operation_completion() + assert metrics_tracer.instrument_attempt_counter.add.call_count == 1 + assert metrics_tracer.instrument_attempt_latency.record.call_count == 1 + assert metrics_tracer.instrument_operation_latency.record.call_count == 1 + assert metrics_tracer.instrument_operation_counter.add.call_count == 1 + + +def test_atempt_otel_attributes(metrics_tracer): + from google.api_core.metrics_tracer import ( + METRIC_LABEL_KEY_DIRECT_PATH_USED, + METRIC_LABEL_KEY_STATUS, + ) + + metrics_tracer.current_op._current_attempt = None + attributes = metrics_tracer._create_attempt_otel_attributes() + assert METRIC_LABEL_KEY_DIRECT_PATH_USED not in attributes + + +def test_disabled(metrics_tracer): + mock_operation = mock.create_autospec(MetricOpTracer, instance=True) + metrics_tracer.enabled = False + metrics_tracer._current_op = mock_operation + + # Attempt start should be skipped + metrics_tracer.record_attempt_start() + assert mock_operation.new_attempt.call_count == 0 + + # Attempt completion should also be skipped + metrics_tracer.record_attempt_completion() + assert metrics_tracer.instrument_attempt_latency.record.call_count == 0 + + # Operation start should be skipped + metrics_tracer.record_operation_start() + assert mock_operation.start.call_count == 0 + + # Operation completion should also skip all metric logic + metrics_tracer.record_operation_completion() + assert metrics_tracer.instrument_attempt_counter.add.call_count == 0 + assert metrics_tracer.instrument_operation_latency.record.call_count == 0 + assert metrics_tracer.instrument_operation_counter.add.call_count == 0 + + assert metrics_tracer._create_otel_attributes() is None + assert metrics_tracer._create_operation_otel_attributes() is None + assert metrics_tracer._create_attempt_otel_attributes() is None + + +def test_get_ms_time_diff(): + # Create two datetime objects + start_time = datetime(2025, 1, 1, 12, 0, 0) + end_time = datetime(2025, 1, 1, 12, 0, 1) # 1 second later + + # Calculate expected milliseconds difference + expected_diff = 1000.0 # 1 second in milliseconds + + # Call the static method + actual_diff = MetricsTracer._get_ms_time_diff(start_time, end_time) + + # Assert the expected and actual values are equal + assert actual_diff == expected_diff + + +def test_get_ms_time_diff_negative(): + # Create two datetime objects where end is before start + start_time = datetime(2025, 1, 1, 12, 0, 1) + end_time = datetime(2025, 1, 1, 12, 0, 0) # 1 second earlier + + # Calculate expected milliseconds difference + expected_diff = -1000.0 # -1 second in milliseconds + + # Call the static method + actual_diff = MetricsTracer._get_ms_time_diff(start_time, end_time) + + # Assert the expected and actual values are equal + assert actual_diff == expected_diff diff --git a/tests/unit/test_metrics_tracer_factory.py b/tests/unit/test_metrics_tracer_factory.py new file mode 100644 index 00000000..2db25f93 --- /dev/null +++ b/tests/unit/test_metrics_tracer_factory.py @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC +# +# 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 pytest +pytest.importorskip("opentelemetry") +from google.api_core.metrics_tracer_factory import MetricsTracerFactory +from google.api_core.metrics_tracer import MetricsTracer + + +@pytest.fixture +def metrics_tracer_factory(): + return MetricsTracerFactory( + enabled=True, + service_name="test_service", + project="test_project", + instance="test_instance", + instance_config="test_config", + location="test_location", + client_hash="test_hash", + client_uid="test_uid", + client_name="test_name", + database="test_db", + ) + + +def test_initialization(metrics_tracer_factory): + assert metrics_tracer_factory.enabled is True + assert metrics_tracer_factory.client_attributes["project_id"] == "test_project" + + +def test_create_metrics_tracer(metrics_tracer_factory): + tracer = metrics_tracer_factory.create_metrics_tracer("test_method") + assert isinstance(tracer, MetricsTracer) + assert tracer.method == "test_method" + assert tracer.enabled is True + + +def test_client_attributes(metrics_tracer_factory): + attributes = metrics_tracer_factory.client_attributes + assert attributes["project_id"] == "test_project" + assert attributes["instance_id"] == "test_instance"