Skip to content

Commit f4c5dbe

Browse files
committed
Feat: Added Operation and GFE Metrics
1 parent 5aaeb9f commit f4c5dbe

11 files changed

+273
-29
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 = (
@@ -107,26 +109,27 @@ def trace_call(name, session=None, extra_attributes=None, observability_options=
107109
with tracer.start_as_current_span(
108110
name, kind=trace.SpanKind.CLIENT, attributes=attributes
109111
) as span:
110-
try:
111-
yield span
112-
except Exception as error:
113-
span.set_status(Status(StatusCode.ERROR, str(error)))
114-
# OpenTelemetry-Python imposes invoking span.record_exception on __exit__
115-
# on any exception. We should file a bug later on with them to only
116-
# invoke .record_exception if not already invoked, hence we should not
117-
# invoke .record_exception on our own else we shall have 2 exceptions.
118-
raise
119-
else:
120-
# All spans still have set_status available even if for example
121-
# NonRecordingSpan doesn't have "_status".
122-
absent_span_status = getattr(span, "_status", None) is None
123-
if absent_span_status or span._status.status_code == StatusCode.UNSET:
124-
# OpenTelemetry-Python only allows a status change
125-
# if the current code is UNSET or ERROR. At the end
126-
# of the generator's consumption, only set it to OK
127-
# it wasn't previously set otherwise.
128-
# https://github.com/googleapis/python-spanner/issues/1246
129-
span.set_status(Status(StatusCode.OK))
112+
with MetricsCapture():
113+
try:
114+
yield span
115+
except Exception as error:
116+
span.set_status(Status(StatusCode.ERROR, str(error)))
117+
# OpenTelemetry-Python imposes invoking span.record_exception on __exit__
118+
# on any exception. We should file a bug later on with them to only
119+
# invoke .record_exception if not already invoked, hence we should not
120+
# invoke .record_exception on our own else we shall have 2 exceptions.
121+
raise
122+
else:
123+
# All spans still have set_status available even if for example
124+
# NonRecordingSpan doesn't have "_status".
125+
absent_span_status = getattr(span, "_status", None) is None
126+
if absent_span_status or span._status.status_code == StatusCode.UNSET:
127+
# OpenTelemetry-Python only allows a status change
128+
# if the current code is UNSET or ERROR. At the end
129+
# of the generator's consumption, only set it to OK
130+
# it wasn't previously set otherwise.
131+
# https://github.com/googleapis/python-spanner/issues/1246
132+
span.set_status(Status(StatusCode.OK))
130133

131134

132135
def get_current_span():

google/cloud/spanner_v1/metrics/constants.py

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

google/cloud/spanner_v1/metrics/metrics_exporter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# -*- coding: utf-8 -*-
12
# Copyright 2025 Google LLC
23
#
34
# Licensed under the Apache License, Version 2.0 (the "License");
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""
17+
This module provides the MetricsGfeTracer class for recording GFE (Google Front End) metrics.
18+
It utilizes OpenTelemetry for metrics instrumentation and records latency and missing header counts
19+
for GFE requests.
20+
"""
21+
22+
try:
23+
from opentelemetry.metrics import Counter, Histogram, get_meter_provider
24+
25+
HAS_OPENTELEMETRY_INSTALLED = True
26+
except ImportError: # pragma: NO COVER
27+
HAS_OPENTELEMETRY_INSTALLED = False
28+
from typing import List
29+
import re
30+
from google.cloud.spanner_v1.metrics.constants import (
31+
BUILT_IN_METRICS_METER_NAME,
32+
SPANNER_SERVICE_NAME,
33+
METRIC_NAME_GFE_LATENCY,
34+
METRIC_NAME_GFE_MISSING_HEADER_COUNT,
35+
)
36+
from google.cloud.spanner_v1 import __version__
37+
38+
39+
class MetricsGfeTracer:
40+
"""
41+
A tracer class for recording Google Front End (GFE) metrics using OpenTelemetry.
42+
43+
This class provides methods to record latency and missing header counts for GFE requests.
44+
It uses OpenTelemetry's Histogram and Counter instruments for metrics recording.
45+
"""
46+
47+
_instrument_gfe_latency: "Histogram"
48+
_instrument_gfe_missing_header_count: "Counter"
49+
enabled = False
50+
51+
def __init__(self) -> None:
52+
"""
53+
Initializes the MetricsGfeTracer instance and creates the necessary metric instruments.
54+
"""
55+
self._create_gfe_metric_instruments()
56+
57+
def record_gfe_metrics(self, metadata: List):
58+
"""
59+
Records GFE metrics based on the provided metadata.
60+
61+
If the tracer is enabled, it checks for GFE-related headers in the metadata and records
62+
latency or missing header counts accordingly.
63+
64+
Args:
65+
metadata (List): A list of metadata headers to be checked for GFE information.
66+
"""
67+
if not self.enabled:
68+
return
69+
70+
gfe_headers = [
71+
header.value
72+
for header in metadata
73+
if header.key == "server-timing" and header.value.startswith("gfe")
74+
]
75+
76+
if len(gfe_headers) == 0:
77+
self.record_gfe_missing_header_count()
78+
return
79+
80+
for gfe_header in gfe_headers:
81+
match = re.search(r"dur=(\d+)", gfe_header)
82+
if match:
83+
duration = int(match.group(1))
84+
self.record_gfe_latency(duration)
85+
86+
def record_gfe_latency(self, latency: int) -> None:
87+
"""
88+
Records the GFE latency using the Histogram instrument.
89+
90+
Args:
91+
latency (int): The latency duration to be recorded.
92+
"""
93+
if not self.enabled or not HAS_OPENTELEMETRY_INSTALLED:
94+
return
95+
self._instrument_gfe_latency.record(amount=latency)
96+
97+
def record_gfe_missing_header_count(self) -> None:
98+
"""
99+
Increments the counter for missing GFE headers.
100+
"""
101+
if not self.enabled or not HAS_OPENTELEMETRY_INSTALLED:
102+
return
103+
self._instrument_gfe_missing_header_count.add(amount=1)
104+
105+
def _create_gfe_metric_instruments(self) -> None:
106+
"""
107+
Creates the OpenTelemetry metric instruments for recording GFE metrics.
108+
"""
109+
if not HAS_OPENTELEMETRY_INSTALLED:
110+
return
111+
meter_provider = get_meter_provider()
112+
meter = meter_provider.get_meter(
113+
name=BUILT_IN_METRICS_METER_NAME, version=__version__
114+
)
115+
self._instrument_gfe_latency = meter.create_histogram(
116+
name=f"{SPANNER_SERVICE_NAME}/{METRIC_NAME_GFE_LATENCY}",
117+
unit="ms",
118+
description="GFE Latency.",
119+
)
120+
self._instrument_gfe_missing_header_count = meter.create_counter(
121+
name=f"{SPANNER_SERVICE_NAME}/{METRIC_NAME_GFE_MISSING_HEADER_COUNT}",
122+
unit="1",
123+
description="GFE missing header count.",
124+
)

google/cloud/spanner_v1/metrics/metrics_interceptor.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
# Copyright 2025 Google LLC
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025 Google LLC
23
#
34
# Licensed under the Apache License, Version 2.0 (the "License");
45
# you may not use this file except in compliance with the License.
@@ -19,6 +20,7 @@
1920
GOOGLE_CLOUD_RESOURCE_KEY,
2021
SPANNER_METHOD_PREFIX,
2122
)
23+
2224
from typing import Dict
2325
from .spanner_metrics_tracer_factory import SpannerMetricsTracerFactory
2426
import re
@@ -142,4 +144,10 @@ def intercept(self, invoked_method, request_or_iterator, call_details):
142144
response = invoked_method(request_or_iterator, call_details)
143145
SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_completion()
144146

147+
# Process and send GFE metrics if enabled
148+
if SpannerMetricsTracerFactory.metrics_gfe_tracer.enabled:
149+
metadata = response.initial_metadata()
150+
SpannerMetricsTracerFactory.metrics_gfe_tracer.record_gfe_metrics(metadata)
151+
else:
152+
print("disabled")
145153
return response

google/cloud/spanner_v1/metrics/metrics_tracer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class MetricAttemptTracer:
5656
direct_path_used: bool
5757
status: str
5858

59-
def __init__(self):
59+
def __init__(self) -> None:
6060
"""
6161
Initialize a MetricAttemptTracer instance with default values.
6262

google/cloud/spanner_v1/metrics/spanner_metrics_tracer_factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
HAS_OPENTELEMETRY_INSTALLED = False
3636

3737
from .metrics_tracer import MetricsTracer
38+
from .metrics_gfe_tracer import MetricsGfeTracer
3839
from google.cloud.spanner_v1 import __version__
3940
from uuid import uuid4
4041

@@ -44,6 +45,7 @@ class SpannerMetricsTracerFactory(MetricsTracerFactory):
4445

4546
_metrics_tracer_factory: "SpannerMetricsTracerFactory" = None
4647
current_metrics_tracer: MetricsTracer = None
48+
metrics_gfe_tracer: MetricsGfeTracer = MetricsGfeTracer()
4749

4850
def __new__(cls, enabled: bool = True) -> "SpannerMetricsTracerFactory":
4951
"""Create a new instance of SpannerMetricsTracerFactory if it doesn't already exist."""

tests/unit/test_metrics_capture.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import pytest
2+
from unittest import mock
3+
from google.cloud.spanner_v1.metrics.metrics_capture import MetricsCapture
4+
from google.cloud.spanner_v1.metrics.metrics_tracer_factory import MetricsTracerFactory
5+
6+
7+
@pytest.fixture
8+
def mock_tracer_factory():
9+
with mock.patch.object(
10+
MetricsTracerFactory, "create_metrics_tracer"
11+
) as mock_create:
12+
yield mock_create
13+
14+
15+
def test_metrics_capture_enter(mock_tracer_factory):
16+
mock_tracer = mock.Mock()
17+
mock_tracer_factory.return_value = mock_tracer
18+
19+
with MetricsCapture() as capture:
20+
assert capture is not None
21+
mock_tracer_factory.assert_called_once()
22+
mock_tracer.record_operation_start.assert_called_once()
23+
24+
25+
def test_metrics_capture_exit(mock_tracer_factory):
26+
mock_tracer = mock.Mock()
27+
mock_tracer_factory.return_value = mock_tracer
28+
29+
with MetricsCapture():
30+
pass
31+
32+
mock_tracer.record_operation_completion.assert_called_once()

tests/unit/test_metrics_exporter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016 Google LLC All rights reserved.
1+
# Copyright 2025 Google LLC All rights reserved.
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.

0 commit comments

Comments
 (0)