Skip to content

Commit 5108c86

Browse files
authored
Merge branch 'main' into snapshot_isolation
2 parents 482e659 + fb21d9a commit 5108c86

33 files changed

+1029
-134
lines changed

google/cloud/spanner_v1/_opentelemetry_tracing.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
except ImportError:
3434
HAS_OPENTELEMETRY_INSTALLED = False
3535

36+
from google.cloud.spanner_v1.metrics.metrics_capture import MetricsCapture
37+
3638
TRACER_NAME = "cloud.google.com/python/spanner"
3739
TRACER_VERSION = gapic_version.__version__
3840
extended_tracing_globally_disabled = (
@@ -111,26 +113,27 @@ def trace_call(name, session=None, extra_attributes=None, observability_options=
111113
with tracer.start_as_current_span(
112114
name, kind=trace.SpanKind.CLIENT, attributes=attributes
113115
) as span:
114-
try:
115-
yield span
116-
except Exception as error:
117-
span.set_status(Status(StatusCode.ERROR, str(error)))
118-
# OpenTelemetry-Python imposes invoking span.record_exception on __exit__
119-
# on any exception. We should file a bug later on with them to only
120-
# invoke .record_exception if not already invoked, hence we should not
121-
# invoke .record_exception on our own else we shall have 2 exceptions.
122-
raise
123-
else:
124-
# All spans still have set_status available even if for example
125-
# NonRecordingSpan doesn't have "_status".
126-
absent_span_status = getattr(span, "_status", None) is None
127-
if absent_span_status or span._status.status_code == StatusCode.UNSET:
128-
# OpenTelemetry-Python only allows a status change
129-
# if the current code is UNSET or ERROR. At the end
130-
# of the generator's consumption, only set it to OK
131-
# it wasn't previously set otherwise.
132-
# https://github.com/googleapis/python-spanner/issues/1246
133-
span.set_status(Status(StatusCode.OK))
116+
with MetricsCapture():
117+
try:
118+
yield span
119+
except Exception as error:
120+
span.set_status(Status(StatusCode.ERROR, str(error)))
121+
# OpenTelemetry-Python imposes invoking span.record_exception on __exit__
122+
# on any exception. We should file a bug later on with them to only
123+
# invoke .record_exception if not already invoked, hence we should not
124+
# invoke .record_exception on our own else we shall have 2 exceptions.
125+
raise
126+
else:
127+
# All spans still have set_status available even if for example
128+
# NonRecordingSpan doesn't have "_status".
129+
absent_span_status = getattr(span, "_status", None) is None
130+
if absent_span_status or span._status.status_code == StatusCode.UNSET:
131+
# OpenTelemetry-Python only allows a status change
132+
# if the current code is UNSET or ERROR. At the end
133+
# of the generator's consumption, only set it to OK
134+
# it wasn't previously set otherwise.
135+
# https://github.com/googleapis/python-spanner/issues/1246
136+
span.set_status(Status(StatusCode.OK))
134137

135138

136139
def get_current_span():

google/cloud/spanner_v1/batch.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from google.cloud.spanner_v1._helpers import _retry_on_aborted_exception
3333
from google.cloud.spanner_v1._helpers import _check_rst_stream_error
3434
from google.api_core.exceptions import InternalServerError
35+
from google.cloud.spanner_v1.metrics.metrics_capture import MetricsCapture
3536
import time
3637

3738
DEFAULT_RETRY_TIMEOUT_SECS = 30
@@ -240,7 +241,7 @@ def commit(
240241
self._session,
241242
trace_attributes,
242243
observability_options=observability_options,
243-
):
244+
), MetricsCapture():
244245
method = functools.partial(
245246
api.commit,
246247
request=request,
@@ -362,7 +363,7 @@ def batch_write(self, request_options=None, exclude_txn_from_change_streams=Fals
362363
self._session,
363364
trace_attributes,
364365
observability_options=observability_options,
365-
):
366+
), MetricsCapture():
366367
method = functools.partial(
367368
api.batch_write,
368369
request=request,

google/cloud/spanner_v1/client.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,30 @@
4949
from google.cloud.spanner_v1._helpers import _merge_query_options
5050
from google.cloud.spanner_v1._helpers import _metadata_with_prefix
5151
from google.cloud.spanner_v1.instance import Instance
52+
from google.cloud.spanner_v1.metrics.constants import (
53+
ENABLE_SPANNER_METRICS_ENV_VAR,
54+
METRIC_EXPORT_INTERVAL_MS,
55+
)
56+
from google.cloud.spanner_v1.metrics.spanner_metrics_tracer_factory import (
57+
SpannerMetricsTracerFactory,
58+
)
59+
from google.cloud.spanner_v1.metrics.metrics_exporter import (
60+
CloudMonitoringMetricsExporter,
61+
)
62+
63+
try:
64+
from opentelemetry import metrics
65+
from opentelemetry.sdk.metrics import MeterProvider
66+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
67+
68+
HAS_GOOGLE_CLOUD_MONITORING_INSTALLED = True
69+
except ImportError: # pragma: NO COVER
70+
HAS_GOOGLE_CLOUD_MONITORING_INSTALLED = False
71+
5272

5373
_CLIENT_INFO = client_info.ClientInfo(client_library_version=__version__)
5474
EMULATOR_ENV_VAR = "SPANNER_EMULATOR_HOST"
75+
ENABLE_BUILTIN_METRICS_ENV_VAR = "SPANNER_ENABLE_BUILTIN_METRICS"
5576
_EMULATOR_HOST_HTTP_SCHEME = (
5677
"%s contains a http scheme. When used with a scheme it may cause gRPC's "
5778
"DNS resolver to endlessly attempt to resolve. %s is intended to be used "
@@ -74,6 +95,10 @@ def _get_spanner_optimizer_statistics_package():
7495
return os.getenv(OPTIMIZER_STATISITCS_PACKAGE_ENV_VAR, "")
7596

7697

98+
def _get_spanner_enable_builtin_metrics():
99+
return os.getenv(ENABLE_SPANNER_METRICS_ENV_VAR) == "true"
100+
101+
77102
class Client(ClientWithProject):
78103
"""Client for interacting with Cloud Spanner API.
79104
@@ -202,6 +227,25 @@ def __init__(
202227
"http://" in self._emulator_host or "https://" in self._emulator_host
203228
):
204229
warnings.warn(_EMULATOR_HOST_HTTP_SCHEME)
230+
# Check flag to enable Spanner builtin metrics
231+
if (
232+
_get_spanner_enable_builtin_metrics()
233+
and HAS_GOOGLE_CLOUD_MONITORING_INSTALLED
234+
):
235+
meter_provider = metrics.NoOpMeterProvider()
236+
if not _get_spanner_emulator_host():
237+
meter_provider = MeterProvider(
238+
metric_readers=[
239+
PeriodicExportingMetricReader(
240+
CloudMonitoringMetricsExporter(),
241+
export_interval_millis=METRIC_EXPORT_INTERVAL_MS,
242+
)
243+
]
244+
)
245+
metrics.set_meter_provider(meter_provider)
246+
SpannerMetricsTracerFactory()
247+
else:
248+
SpannerMetricsTracerFactory(enabled=False)
205249

206250
self._route_to_leader_enabled = route_to_leader_enabled
207251
self._directed_read_options = directed_read_options

google/cloud/spanner_v1/database.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
get_current_span,
7373
trace_call,
7474
)
75+
from google.cloud.spanner_v1.metrics.metrics_capture import MetricsCapture
7576

7677

7778
SPANNER_DATA_SCOPE = "https://www.googleapis.com/auth/spanner.data"
@@ -705,7 +706,7 @@ def execute_pdml():
705706
with trace_call(
706707
"CloudSpanner.Database.execute_partitioned_pdml",
707708
observability_options=self.observability_options,
708-
) as span:
709+
) as span, MetricsCapture():
709710
with SessionCheckout(self._pool) as session:
710711
add_span_event(span, "Starting BeginTransaction")
711712
txn = api.begin_transaction(
@@ -912,7 +913,7 @@ def run_in_transaction(self, func, *args, **kw):
912913
with trace_call(
913914
"CloudSpanner.Database.run_in_transaction",
914915
observability_options=observability_options,
915-
):
916+
), MetricsCapture():
916917
# Sanity check: Is there a transaction already running?
917918
# If there is, then raise a red flag. Otherwise, mark that this one
918919
# is running.
@@ -1524,7 +1525,7 @@ def generate_read_batches(
15241525
f"CloudSpanner.{type(self).__name__}.generate_read_batches",
15251526
extra_attributes=dict(table=table, columns=columns),
15261527
observability_options=self.observability_options,
1527-
):
1528+
), MetricsCapture():
15281529
partitions = self._get_snapshot().partition_read(
15291530
table=table,
15301531
columns=columns,
@@ -1575,7 +1576,7 @@ def process_read_batch(
15751576
with trace_call(
15761577
f"CloudSpanner.{type(self).__name__}.process_read_batch",
15771578
observability_options=observability_options,
1578-
):
1579+
), MetricsCapture():
15791580
kwargs = copy.deepcopy(batch["read"])
15801581
keyset_dict = kwargs.pop("keyset")
15811582
kwargs["keyset"] = KeySet._from_dict(keyset_dict)
@@ -1660,7 +1661,7 @@ def generate_query_batches(
16601661
f"CloudSpanner.{type(self).__name__}.generate_query_batches",
16611662
extra_attributes=dict(sql=sql),
16621663
observability_options=self.observability_options,
1663-
):
1664+
), MetricsCapture():
16641665
partitions = self._get_snapshot().partition_query(
16651666
sql=sql,
16661667
params=params,
@@ -1716,7 +1717,7 @@ def process_query_batch(
17161717
with trace_call(
17171718
f"CloudSpanner.{type(self).__name__}.process_query_batch",
17181719
observability_options=self.observability_options,
1719-
):
1720+
), MetricsCapture():
17201721
return self._get_snapshot().execute_sql(
17211722
partition=batch["partition"],
17221723
**batch["query"],
@@ -1781,7 +1782,7 @@ def run_partitioned_query(
17811782
f"CloudSpanner.${type(self).__name__}.run_partitioned_query",
17821783
extra_attributes=dict(sql=sql),
17831784
observability_options=self.observability_options,
1784-
):
1785+
), MetricsCapture():
17851786
partitions = list(
17861787
self.generate_query_batches(
17871788
sql,

google/cloud/spanner_v1/merged_result_set.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from threading import Lock, Event
1919

2020
from google.cloud.spanner_v1._opentelemetry_tracing import trace_call
21+
from google.cloud.spanner_v1.metrics.metrics_capture import MetricsCapture
2122

2223
if TYPE_CHECKING:
2324
from google.cloud.spanner_v1.database import BatchSnapshot
@@ -45,7 +46,7 @@ def run(self):
4546
with trace_call(
4647
"CloudSpanner.PartitionExecutor.run",
4748
observability_options=observability_options,
48-
):
49+
), MetricsCapture():
4950
self.__run()
5051

5152
def __run(self):

google/cloud/spanner_v1/metrics/constants.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2025 Google LLC
1+
# Copyright 2025 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -15,6 +15,12 @@
1515
BUILT_IN_METRICS_METER_NAME = "gax-python"
1616
NATIVE_METRICS_PREFIX = "spanner.googleapis.com/internal/client"
1717
SPANNER_RESOURCE_TYPE = "spanner_instance_client"
18+
SPANNER_SERVICE_NAME = "spanner-python"
19+
GOOGLE_CLOUD_RESOURCE_KEY = "google-cloud-resource-prefix"
20+
GOOGLE_CLOUD_REGION_KEY = "cloud.region"
21+
GOOGLE_CLOUD_REGION_GLOBAL = "global"
22+
SPANNER_METHOD_PREFIX = "/google.spanner.v1."
23+
ENABLE_SPANNER_METRICS_ENV_VAR = "SPANNER_ENABLE_BUILTIN_METRICS"
1824

1925
# Monitored resource labels
2026
MONITORED_RES_LABEL_KEY_PROJECT = "project_id"
@@ -61,3 +67,5 @@
6167
METRIC_NAME_OPERATION_COUNT,
6268
METRIC_NAME_ATTEMPT_COUNT,
6369
]
70+
71+
METRIC_EXPORT_INTERVAL_MS = 60000 # 1 Minute
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2025 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""
15+
This module provides functionality for capturing metrics in Cloud Spanner operations.
16+
17+
It includes a context manager class, MetricsCapture, which automatically handles the
18+
start and completion of metrics tracing for a given operation. This ensures that metrics
19+
are consistently recorded for Cloud Spanner operations, facilitating observability and
20+
performance monitoring.
21+
"""
22+
23+
from .spanner_metrics_tracer_factory import SpannerMetricsTracerFactory
24+
25+
26+
class MetricsCapture:
27+
"""Context manager for capturing metrics in Cloud Spanner operations.
28+
29+
This class provides a context manager interface to automatically handle
30+
the start and completion of metrics tracing for a given operation.
31+
"""
32+
33+
def __enter__(self):
34+
"""Enter the runtime context related to this object.
35+
36+
This method initializes a new metrics tracer for the operation and
37+
records the start of the operation.
38+
39+
Returns:
40+
MetricsCapture: The instance of the context manager.
41+
"""
42+
# Short circuit out if metrics are disabled
43+
factory = SpannerMetricsTracerFactory()
44+
if not factory.enabled:
45+
return self
46+
47+
# Define a new metrics tracer for the new operation
48+
SpannerMetricsTracerFactory.current_metrics_tracer = (
49+
factory.create_metrics_tracer()
50+
)
51+
if SpannerMetricsTracerFactory.current_metrics_tracer:
52+
SpannerMetricsTracerFactory.current_metrics_tracer.record_operation_start()
53+
return self
54+
55+
def __exit__(self, exc_type, exc_value, traceback):
56+
"""Exit the runtime context related to this object.
57+
58+
This method records the completion of the operation. If an exception
59+
occurred, it will be propagated after the metrics are recorded.
60+
61+
Args:
62+
exc_type (Type[BaseException]): The exception type.
63+
exc_value (BaseException): The exception value.
64+
traceback (TracebackType): The traceback object.
65+
66+
Returns:
67+
bool: False to propagate the exception if any occurred.
68+
"""
69+
# Short circuit out if metrics are disable
70+
if not SpannerMetricsTracerFactory().enabled:
71+
return False
72+
73+
if SpannerMetricsTracerFactory.current_metrics_tracer:
74+
SpannerMetricsTracerFactory.current_metrics_tracer.record_operation_completion()
75+
return False # Propagate the exception if any

0 commit comments

Comments
 (0)