From cbe95d425b66999fb823d1d8515c94e90e390721 Mon Sep 17 00:00:00 2001 From: Subham Sinha Date: Tue, 12 May 2026 14:42:20 +0530 Subject: [PATCH] feat(spanner): log client configuration at startup --- .../google/cloud/spanner_v1/_async/client.py | 36 ++++++ .../google/cloud/spanner_v1/client.py | 34 +++++- .../tests/unit/_async/test_client.py | 110 ++++++++++++++++++ .../tests/unit/test_client.py | 108 +++++++++++++++++ 4 files changed, 283 insertions(+), 5 deletions(-) diff --git a/packages/google-cloud-spanner/google/cloud/spanner_v1/_async/client.py b/packages/google-cloud-spanner/google/cloud/spanner_v1/_async/client.py index 5aacb0697195..e5eb07ee35fa 100644 --- a/packages/google-cloud-spanner/google/cloud/spanner_v1/_async/client.py +++ b/packages/google-cloud-spanner/google/cloud/spanner_v1/_async/client.py @@ -101,6 +101,7 @@ EMULATOR_ENV_VAR = "SPANNER_EMULATOR_HOST" SPANNER_DISABLE_BUILTIN_METRICS_ENV_VAR = "SPANNER_DISABLE_BUILTIN_METRICS" +LOG_CLIENT_OPTIONS_ENV_VAR = "GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS" _EMULATOR_HOST_HTTP_SCHEME = ( "%s contains a http scheme. When used with a scheme it may cause gRPC's " "DNS resolver to endlessly attempt to resolve. %s is intended to be used " @@ -133,6 +134,10 @@ def _get_spanner_enable_builtin_metrics_env(): return os.getenv(SPANNER_DISABLE_BUILTIN_METRICS_ENV_VAR) != "true" +def _get_spanner_log_client_options_env(): + return os.getenv(LOG_CLIENT_OPTIONS_ENV_VAR, "false").lower() == "true" + + def _initialize_metrics(project, credentials): """ Initializes the Spanner built-in metrics. @@ -364,6 +369,37 @@ def __init__( self._nth_client_id = Client.NTH_CLIENT.increment() self._nth_request = AtomicCounter(0) + self.host = "spanner.googleapis.com" + if self._emulator_host: + self.host = self._emulator_host + elif self._experimental_host: + self.host = self._experimental_host + elif self._client_options and self._client_options.api_endpoint: + self.host = self._client_options.api_endpoint + + if _get_spanner_log_client_options_env(): + self._log_spanner_options() + + def _log_spanner_options(self): + """Logs Spanner client options.""" + log.info( + "Spanner options: \n" + " Project ID: %s\n" + " Host: %s\n" + " Route to leader enabled: %s\n" + " Directed read options: %s\n" + " Default transaction options: %s\n" + " Observability options: %s\n" + " Built-in metrics enabled: %s", + self.project, + self.host, + self.route_to_leader_enabled, + self._directed_read_options, + self._default_transaction_options, + self._observability_options, + _get_spanner_enable_builtin_metrics_env(), + ) + @property def _next_nth_request(self): return self._nth_request.increment() diff --git a/packages/google-cloud-spanner/google/cloud/spanner_v1/client.py b/packages/google-cloud-spanner/google/cloud/spanner_v1/client.py index 99f97a18308f..e2b52801c43f 100644 --- a/packages/google-cloud-spanner/google/cloud/spanner_v1/client.py +++ b/packages/google-cloud-spanner/google/cloud/spanner_v1/client.py @@ -32,13 +32,11 @@ import threading import warnings from typing import Optional - import google.api_core.client_options import grpc from google.api_core.gapic_v1 import client_info from google.auth.credentials import AnonymousCredentials from google.cloud.client import ClientWithProject - from google.cloud.spanner_admin_database_v1 import ( DatabaseAdminClient as DatabaseAdminClient, ) @@ -55,6 +53,7 @@ from google.cloud.spanner_admin_instance_v1.services.instance_admin.transports.grpc import ( InstanceAdminGrpcTransport, ) +from google.cloud.spanner_v1.instance import Instance from google.cloud.spanner_v1._helpers import ( AtomicCounter, _merge_query_options, @@ -62,7 +61,6 @@ _validate_client_context, ) from google.cloud.spanner_v1.gapic_version import __version__ -from google.cloud.spanner_v1.instance import Instance from google.cloud.spanner_v1.metrics.constants import METRIC_EXPORT_INTERVAL_MS from google.cloud.spanner_v1.metrics.metrics_exporter import ( CloudMonitoringMetricsExporter, @@ -84,6 +82,7 @@ _CLIENT_INFO = client_info.ClientInfo(client_library_version=__version__) EMULATOR_ENV_VAR = "SPANNER_EMULATOR_HOST" SPANNER_DISABLE_BUILTIN_METRICS_ENV_VAR = "SPANNER_DISABLE_BUILTIN_METRICS" +LOG_CLIENT_OPTIONS_ENV_VAR = "GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS" _EMULATOR_HOST_HTTP_SCHEME = ( "%s contains a http scheme. When used with a scheme it may cause gRPC's DNS resolver to endlessly attempt to resolve. %s is intended to be used without a scheme: ex %s=localhost:8080." % ((EMULATOR_ENV_VAR,) * 3) @@ -114,6 +113,10 @@ def _get_spanner_enable_builtin_metrics_env(): return os.getenv(SPANNER_DISABLE_BUILTIN_METRICS_ENV_VAR) != "true" +def _get_spanner_log_client_options_env(): + return os.getenv(LOG_CLIENT_OPTIONS_ENV_VAR, "false").lower() == "true" + + def _initialize_metrics(project, credentials): """Initializes the Spanner built-in metrics. @@ -226,8 +229,7 @@ class Client(ClientWithProject): the Spanner built-in metrics collection and exporting. :raises: :class:`ValueError ` if both ``read_only`` - and ``admin`` are :data:`True` - """ + and ``admin`` are :data:`True`""" _instance_admin_api = None _database_admin_api = None @@ -316,6 +318,28 @@ def __init__( self._default_transaction_options = default_transaction_options self._nth_client_id = Client.NTH_CLIENT.increment() self._nth_request = AtomicCounter(0) + self.host = "spanner.googleapis.com" + if self._emulator_host: + self.host = self._emulator_host + elif self._experimental_host: + self.host = self._experimental_host + elif self._client_options and self._client_options.api_endpoint: + self.host = self._client_options.api_endpoint + if _get_spanner_log_client_options_env(): + self._log_spanner_options() + + def _log_spanner_options(self): + """Logs Spanner client options.""" + log.info( + "Spanner options: \n Project ID: %s\n Host: %s\n Route to leader enabled: %s\n Directed read options: %s\n Default transaction options: %s\n Observability options: %s\n Built-in metrics enabled: %s", + self.project, + self.host, + self.route_to_leader_enabled, + self._directed_read_options, + self._default_transaction_options, + self._observability_options, + _get_spanner_enable_builtin_metrics_env(), + ) @property def _next_nth_request(self): diff --git a/packages/google-cloud-spanner/tests/unit/_async/test_client.py b/packages/google-cloud-spanner/tests/unit/_async/test_client.py index 1644cb0b4470..23a47b7e045a 100644 --- a/packages/google-cloud-spanner/tests/unit/_async/test_client.py +++ b/packages/google-cloud-spanner/tests/unit/_async/test_client.py @@ -778,3 +778,113 @@ async def __anext__(self): self.assertEqual(li_api.call_count, 1) args, kwargs = li_api.call_args self.assertEqual(kwargs["metadata"], expected_metadata) + + @mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "false"} + ) + @CrossSync.pytest + async def test_constructor_logs_options_disabled_by_default(self): + from google.cloud.spanner_v1._async import client as MUT + + logger = MUT.log + creds = build_scoped_credentials() + + with mock.patch.object(logger, "info") as info_logger: + client = self._make_one( + project=self.PROJECT, + credentials=creds, + ) + self.assertIsNotNone(client) + # Assert that no logs are emitted when the environment variable is explicitly false + info_logger.assert_not_called() + + # Also test when the environment variable is not set at all + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.object(logger, "info") as info_logger: + client = self._make_one(project=self.PROJECT, credentials=creds) + self.assertIsNotNone(client) + # Assert that no logs are emitted when the environment variable is not set + info_logger.assert_not_called() + + @CrossSync.pytest + async def test_constructor_logs_options(self): + from google.cloud.spanner_v1._async import client as MUT + + creds = build_scoped_credentials() + observability_options = {"enable_extended_tracing": True} + with self.assertLogs(MUT.__name__, level="INFO") as cm: + with mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true"} + ): + client = self._make_one( + project=self.PROJECT, + credentials=creds, + route_to_leader_enabled=False, + directed_read_options=self.DIRECTED_READ_OPTIONS, + default_transaction_options=self.DEFAULT_TRANSACTION_OPTIONS, + observability_options=observability_options, + ) + self.assertIsNotNone(client) + + # Assert that logs are emitted when the environment variable is true + # and verify their content. + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn("Spanner options:", log_output) + self.assertIn(f"\n Project ID: {self.PROJECT}", log_output) + self.assertIn("\n Host: spanner.googleapis.com", log_output) + self.assertIn("\n Route to leader enabled: False", log_output) + self.assertIn( + f"\n Directed read options: {self.DIRECTED_READ_OPTIONS}", log_output + ) + self.assertIn( + f"\n Default transaction options: {self.DEFAULT_TRANSACTION_OPTIONS}", + log_output, + ) + self.assertIn(f"\n Observability options: {observability_options}", log_output) + # SPANNER_DISABLE_BUILTIN_METRICS is "true" from class-level patch + self.assertIn("\n Built-in metrics enabled: False", log_output) + # Test with custom host + endpoint = "test.googleapis.com" + with self.assertLogs(MUT.__name__, level="INFO") as cm: + with mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true"} + ): + self._make_one( + project=self.PROJECT, + credentials=creds, + client_options={"api_endpoint": endpoint}, + ) + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn(f"\n Host: {endpoint}", log_output) + + # Test with emulator host + emulator_host = "localhost:9010" + with mock.patch.dict( + os.environ, + { + MUT.EMULATOR_ENV_VAR: emulator_host, + "GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true", + }, + ): + with self.assertLogs(MUT.__name__, level="INFO") as cm: + self._make_one(project=self.PROJECT, credentials=creds) + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn(f"\n Host: {emulator_host}", log_output) + + # Test with experimental host + experimental_host = "exp.googleapis.com" + with mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true"} + ): + with self.assertLogs(MUT.__name__, level="INFO") as cm: + self._make_one( + project=self.PROJECT, + credentials=creds, + experimental_host=experimental_host, + ) + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn(f"\n Host: {experimental_host}", log_output) diff --git a/packages/google-cloud-spanner/tests/unit/test_client.py b/packages/google-cloud-spanner/tests/unit/test_client.py index eefe75616a5c..3f265b0da023 100644 --- a/packages/google-cloud-spanner/tests/unit/test_client.py +++ b/packages/google-cloud-spanner/tests/unit/test_client.py @@ -836,3 +836,111 @@ def test_list_instances_w_options(self): retry=mock.ANY, timeout=mock.ANY, ) + + @mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "false"} + ) + def test_constructor_logs_options_disabled_by_default(self): + from google.cloud.spanner_v1 import client as MUT + + logger = MUT.log + creds = build_scoped_credentials() + + with mock.patch.object(logger, "info") as info_logger: + client = self._make_one( + project=self.PROJECT, + credentials=creds, + ) + self.assertIsNotNone(client) + # Assert that no logs are emitted when the environment variable is explicitly false + info_logger.assert_not_called() + + # Also test when the environment variable is not set at all + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.object(logger, "info") as info_logger: + client = self._make_one(project=self.PROJECT, credentials=creds) + self.assertIsNotNone(client) + # Assert that no logs are emitted when the environment variable is not set + info_logger.assert_not_called() + + def test_constructor_logs_options(self): + from google.cloud.spanner_v1 import client as MUT + + creds = build_scoped_credentials() + observability_options = {"enable_extended_tracing": True} + with self.assertLogs(MUT.__name__, level="INFO") as cm: + with mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true"} + ): + client = self._make_one( + project=self.PROJECT, + credentials=creds, + route_to_leader_enabled=False, + directed_read_options=self.DIRECTED_READ_OPTIONS, + default_transaction_options=self.DEFAULT_TRANSACTION_OPTIONS, + observability_options=observability_options, + ) + self.assertIsNotNone(client) + + # Assert that logs are emitted when the environment variable is true + # and verify their content. + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn("Spanner options:", log_output) + self.assertIn(f"\n Project ID: {self.PROJECT}", log_output) + self.assertIn("\n Host: spanner.googleapis.com", log_output) + self.assertIn("\n Route to leader enabled: False", log_output) + self.assertIn( + f"\n Directed read options: {self.DIRECTED_READ_OPTIONS}", log_output + ) + self.assertIn( + f"\n Default transaction options: {self.DEFAULT_TRANSACTION_OPTIONS}", + log_output, + ) + self.assertIn(f"\n Observability options: {observability_options}", log_output) + # SPANNER_DISABLE_BUILTIN_METRICS is "true" from class-level patch + self.assertIn("\n Built-in metrics enabled: False", log_output) + # Test with custom host + endpoint = "test.googleapis.com" + with self.assertLogs(MUT.__name__, level="INFO") as cm: + with mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true"} + ): + self._make_one( + project=self.PROJECT, + credentials=creds, + client_options={"api_endpoint": endpoint}, + ) + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn(f"\n Host: {endpoint}", log_output) + + # Test with emulator host + emulator_host = "localhost:9010" + with mock.patch.dict( + os.environ, + { + MUT.EMULATOR_ENV_VAR: emulator_host, + "GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true", + }, + ): + with self.assertLogs(MUT.__name__, level="INFO") as cm: + self._make_one(project=self.PROJECT, credentials=creds) + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn(f"\n Host: {emulator_host}", log_output) + + # Test with experimental host + experimental_host = "exp.googleapis.com" + with mock.patch.dict( + os.environ, {"GOOGLE_CLOUD_SPANNER_ENABLE_LOG_CLIENT_OPTIONS": "true"} + ): + with self.assertLogs(MUT.__name__, level="INFO") as cm: + self._make_one( + project=self.PROJECT, + credentials=creds, + experimental_host=experimental_host, + ) + self.assertEqual(len(cm.output), 1) + log_output = cm.output[0] + self.assertIn(f"\n Host: {experimental_host}", log_output)