Skip to content

Commit e49afa6

Browse files
committed
feat: add cloud.region and transaction_tag in span attributes
1 parent 8818c30 commit e49afa6

15 files changed

+209
-179
lines changed

examples/trace.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
2828

2929
# Setup common variables that'll be used between Spanner and traces.
30-
project_id = os.environ.get('SPANNER_PROJECT_ID', 'test-project')
30+
project_id = os.environ.get('SPANNER_PROJECT_ID', 'span-cloud-testing')
3131

3232
def spanner_with_cloud_trace():
3333
# [START spanner_opentelemetry_traces_cloudtrace_usage]
@@ -62,16 +62,13 @@ def spanner_with_otlp():
6262

6363

6464
def main():
65-
# Setup OpenTelemetry, trace and Cloud Trace exporter.
66-
tracer_provider = TracerProvider(sampler=ALWAYS_ON)
67-
trace_exporter = CloudTraceSpanExporter(project_id=project_id)
68-
tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter))
69-
7065
# Setup the Cloud Spanner Client.
7166
# Change to "spanner_client = spanner_with_otlp" to use OTLP exporter
7267
spanner_client = spanner_with_cloud_trace()
73-
instance = spanner_client.instance('test-instance')
74-
database = instance.database('test-db')
68+
instance = spanner_client.instance('suvham-testing')
69+
instance.reload()
70+
database = instance.database('gildb')
71+
tracer_provider = spanner_client.observability_options["tracer_provider"]
7572

7673
# Set W3C Trace Context as the global propagator for end to end tracing.
7774
set_global_textmap(TraceContextTextMapPropagator())
@@ -93,12 +90,27 @@ def main():
9390
# Purposefully issue a bad SQL statement to examine exceptions
9491
# that get recorded and a ERROR span status.
9592
try:
96-
data = snapshot.execute_sql('SELECT CURRENT_TIMESTAMPx()')
93+
data = snapshot.execute_sql('SELECT CURRENT_TIMESTAMP()')
9794
for row in data:
9895
print(row)
9996
except Exception as e:
10097
print(e)
10198

99+
# Example of a read-write transaction with a transaction tag
100+
with tracer.start_as_current_span('TaggedTransaction'):
101+
def update_singer_name(transaction):
102+
transaction.execute_update(
103+
"UPDATE Singers SET FirstName = 'Timothy' WHERE SingerId = 1",
104+
request_options={
105+
"request_tag": "app=concert,env=dev,action=update"
106+
},
107+
)
108+
print("Updated singer's name.")
109+
110+
database.run_in_transaction(
111+
update_singer_name, transaction_tag="app=concert,env=dev"
112+
)
113+
102114

103115
if __name__ == '__main__':
104116
main()

google/cloud/spanner_v1/_helpers.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import time
2121
import base64
2222
import threading
23+
import logging
2324

2425
from google.protobuf.struct_pb2 import ListValue
2526
from google.protobuf.struct_pb2 import Value
@@ -29,11 +30,16 @@
2930
from google.api_core import datetime_helpers
3031
from google.api_core.exceptions import Aborted
3132
from google.cloud._helpers import _date_from_iso8601_date
32-
from google.cloud.spanner_v1 import TypeCode
33-
from google.cloud.spanner_v1 import ExecuteSqlRequest
34-
from google.cloud.spanner_v1 import JsonObject, Interval
35-
from google.cloud.spanner_v1 import TransactionOptions
33+
from google.cloud.spanner_v1.types import ExecuteSqlRequest
34+
from google.cloud.spanner_v1.types import TransactionOptions
35+
from google.cloud.spanner_v1.data_types import JsonObject, Interval
3636
from google.cloud.spanner_v1.request_id_header import with_request_id
37+
from google.cloud.spanner_v1.types import TypeCode
38+
from opentelemetry.semconv.resource import ResourceAttributes
39+
from opentelemetry.resourcedetector.gcp_resource_detector import (
40+
GoogleCloudResourceDetector,
41+
)
42+
3743
from google.rpc.error_details_pb2 import RetryInfo
3844

3945
try:
@@ -55,6 +61,10 @@
5561
+ "numeric has a whole component with precision {}"
5662
)
5763

64+
GOOGLE_CLOUD_REGION_GLOBAL = "global"
65+
66+
log = logging.getLogger(__name__)
67+
5868

5969
if HAS_OPENTELEMETRY_INSTALLED:
6070

@@ -79,6 +89,28 @@ def set(self, carrier: List[Tuple[str, str]], key: str, value: str) -> None:
7989
carrier.append((key, value))
8090

8191

92+
def _get_cloud_region() -> str:
93+
"""Get the location of the resource.
94+
95+
Returns:
96+
str: The location of the resource. If OpenTelemetry is not installed, returns a global region.
97+
"""
98+
if not HAS_OPENTELEMETRY_INSTALLED:
99+
return GOOGLE_CLOUD_REGION_GLOBAL
100+
try:
101+
detector = GoogleCloudResourceDetector()
102+
resources = detector.detect()
103+
104+
if ResourceAttributes.CLOUD_REGION in resources.attributes:
105+
return resources.attributes[ResourceAttributes.CLOUD_REGION]
106+
except Exception as e:
107+
log.warning(
108+
"Failed to detect GCP resource location for Spanner metrics, defaulting to 'global'. Error: %s",
109+
e,
110+
)
111+
return GOOGLE_CLOUD_REGION_GLOBAL
112+
113+
82114
def _try_to_coerce_bytes(bytestring):
83115
"""Try to coerce a byte string into the right thing based on Python
84116
version and whether or not it is base64 encoded.

google/cloud/spanner_v1/_opentelemetry_tracing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from google.cloud.spanner_v1 import SpannerClient
2222
from google.cloud.spanner_v1 import gapic_version
2323
from google.cloud.spanner_v1._helpers import (
24+
_get_cloud_region,
2425
_metadata_with_span_context,
2526
)
2627

@@ -85,6 +86,7 @@ def trace_call(
8586
enable_end_to_end_tracing = False
8687

8788
db_name = ""
89+
cloud_region = None
8890
if session and getattr(session, "_database", None):
8991
db_name = session._database.name
9092

@@ -97,7 +99,9 @@ def trace_call(
9799
"enable_end_to_end_tracing", enable_end_to_end_tracing
98100
)
99101
db_name = observability_options.get("db_name", db_name)
102+
cloud_region = observability_options.get("cloud_region", cloud_region)
100103

104+
cloud_region = _get_cloud_region()
101105
tracer = get_tracer(tracer_provider)
102106

103107
# Set base attributes that we know for every trace created
@@ -107,6 +111,7 @@ def trace_call(
107111
"db.instance": db_name,
108112
"net.host.name": SpannerClient.DEFAULT_ENDPOINT,
109113
OTEL_SCOPE_NAME: TRACER_NAME,
114+
"cloud.region": cloud_region,
110115
OTEL_SCOPE_VERSION: TRACER_VERSION,
111116
# Standard GCP attributes for OTel, attributes are used for internal purpose and are subjected to change
112117
"gcp.client.service": "spanner",

google/cloud/spanner_v1/database.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,8 +1012,14 @@ def run_in_transaction(self, func, *args, **kw):
10121012
reraises any non-ABORT exceptions raised by ``func``.
10131013
"""
10141014
observability_options = getattr(self, "observability_options", None)
1015+
transaction_tag = kw.get("transaction_tag")
1016+
extra_attributes = {}
1017+
if transaction_tag:
1018+
extra_attributes["transaction.tag"] = transaction_tag
1019+
10151020
with trace_call(
10161021
"CloudSpanner.Database.run_in_transaction",
1022+
extra_attributes=extra_attributes,
10171023
observability_options=observability_options,
10181024
), MetricsCapture():
10191025
# Sanity check: Is there a transaction already running?

google/cloud/spanner_v1/metrics/spanner_metrics_tracer_factory.py

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,9 @@
1818
from .metrics_tracer_factory import MetricsTracerFactory
1919
import os
2020
import logging
21-
from .constants import (
22-
SPANNER_SERVICE_NAME,
23-
GOOGLE_CLOUD_REGION_KEY,
24-
GOOGLE_CLOUD_REGION_GLOBAL,
25-
)
21+
from .constants import SPANNER_SERVICE_NAME
2622

2723
try:
28-
from opentelemetry.resourcedetector import gcp_resource_detector
29-
30-
# Overwrite the requests timeout for the detector.
31-
# This is necessary as the client will wait the full timeout if the
32-
# code is not run in a GCP environment, with the location endpoints available.
33-
gcp_resource_detector._TIMEOUT_SEC = 0.2
34-
3524
import mmh3
3625

3726
logging.getLogger("opentelemetry.resourcedetector.gcp_resource_detector").setLevel(
@@ -44,6 +33,7 @@
4433

4534
from .metrics_tracer import MetricsTracer
4635
from google.cloud.spanner_v1 import __version__
36+
from google.cloud.spanner_v1._helpers import _get_cloud_region
4737
from uuid import uuid4
4838

4939
log = logging.getLogger(__name__)
@@ -86,7 +76,7 @@ def __new__(
8676
cls._metrics_tracer_factory.set_client_hash(
8777
cls._generate_client_hash(client_uid)
8878
)
89-
cls._metrics_tracer_factory.set_location(cls._get_location())
79+
cls._metrics_tracer_factory.set_location(_get_cloud_region())
9080
cls._metrics_tracer_factory.gfe_enabled = gfe_enabled
9181

9282
if cls._metrics_tracer_factory.enabled != enabled:
@@ -153,28 +143,3 @@ def _generate_client_hash(client_uid: str) -> str:
153143

154144
# Return as 6 digit zero padded hex string
155145
return f"{sig_figs:06x}"
156-
157-
@staticmethod
158-
def _get_location() -> str:
159-
"""Get the location of the resource.
160-
161-
In case of any error during detection, this method will log a warning
162-
and default to the "global" location.
163-
164-
Returns:
165-
str: The location of the resource. If OpenTelemetry is not installed, returns a global region.
166-
"""
167-
if not HAS_OPENTELEMETRY_INSTALLED:
168-
return GOOGLE_CLOUD_REGION_GLOBAL
169-
try:
170-
detector = gcp_resource_detector.GoogleCloudResourceDetector()
171-
resources = detector.detect()
172-
173-
if GOOGLE_CLOUD_REGION_KEY in resources.attributes:
174-
return resources.attributes[GOOGLE_CLOUD_REGION_KEY]
175-
except Exception as e:
176-
log.warning(
177-
"Failed to detect GCP resource location for Spanner metrics, defaulting to 'global'. Error: %s",
178-
e,
179-
)
180-
return GOOGLE_CLOUD_REGION_GLOBAL

google/cloud/spanner_v1/session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,9 +531,14 @@ def run_in_transaction(self, func, *args, **kw):
531531
database = self._database
532532
log_commit_stats = database.log_commit_stats
533533

534+
extra_attributes = {}
535+
if transaction_tag:
536+
extra_attributes["transaction.tag"] = transaction_tag
537+
534538
with trace_call(
535539
"CloudSpanner.Session.run_in_transaction",
536540
self,
541+
extra_attributes=extra_attributes,
537542
observability_options=getattr(database, "observability_options", None),
538543
) as span, MetricsCapture():
539544
attempts: int = 0

google/cloud/spanner_v1/transaction.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,10 @@ def execute_update(
479479
request_options = RequestOptions(request_options)
480480
request_options.transaction_tag = self.transaction_tag
481481

482-
trace_attributes = {"db.statement": dml}
482+
trace_attributes = {
483+
"db.statement": dml,
484+
"request_options": request_options,
485+
}
483486

484487
# If this request begins the transaction, we need to lock
485488
# the transaction until the transaction ID is updated.
@@ -629,7 +632,8 @@ def batch_update(
629632

630633
trace_attributes = {
631634
# Get just the queries from the DML statement batch
632-
"db.statement": ";".join([statement.sql for statement in parsed])
635+
"db.statement": ";".join([statement.sql for statement in parsed]),
636+
"request_options": request_options,
633637
}
634638

635639
# If this request begins the transaction, we need to lock

tests/unit/test__helpers.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
import unittest
1717
import mock
1818

19+
from opentelemetry.sdk.resources import Resource
20+
from opentelemetry.semconv.resource import ResourceAttributes
21+
22+
1923
from google.cloud.spanner_v1 import TransactionOptions
2024

2125

@@ -89,6 +93,45 @@ def test_base_object_merge_dict(self):
8993
self.assertEqual(result, expected)
9094

9195

96+
class Test_get_cloud_region(unittest.TestCase):
97+
def _callFUT(self, *args, **kw):
98+
from google.cloud.spanner_v1._helpers import _get_cloud_region
99+
100+
return _get_cloud_region(*args, **kw)
101+
102+
@mock.patch("google.cloud.spanner_v1._helpers.GoogleCloudResourceDetector.detect")
103+
def test_get_location_with_region(self, mock_detect):
104+
"""Test that _get_cloud_region returns the region when detected."""
105+
mock_resource = Resource.create(
106+
{ResourceAttributes.CLOUD_REGION: "us-central1"}
107+
)
108+
mock_detect.return_value = mock_resource
109+
110+
location = self._callFUT()
111+
self.assertEqual(location, "us-central1")
112+
113+
@mock.patch("google.cloud.spanner_v1._helpers.GoogleCloudResourceDetector.detect")
114+
def test_get_location_without_region(self, mock_detect):
115+
"""Test that _get_cloud_region returns 'global' when no region is detected."""
116+
mock_resource = Resource.create({}) # No region attribute
117+
mock_detect.return_value = mock_resource
118+
119+
location = self._callFUT()
120+
self.assertEqual(location, "global")
121+
122+
@mock.patch("google.cloud.spanner_v1._helpers.GoogleCloudResourceDetector.detect")
123+
def test_get_location_with_exception(self, mock_detect):
124+
"""Test that _get_cloud_region returns 'global' and logs a warning on exception."""
125+
mock_detect.side_effect = Exception("detector failed")
126+
127+
with self.assertLogs(
128+
"google.cloud.spanner_v1._helpers", level="WARNING"
129+
) as log:
130+
location = self._callFUT()
131+
self.assertEqual(location, "global")
132+
self.assertIn("Failed to detect GCP resource location", log.output[0])
133+
134+
92135
class Test_make_value_pb(unittest.TestCase):
93136
def _callFUT(self, *args, **kw):
94137
from google.cloud.spanner_v1._helpers import _make_value_pb

tests/unit/test__opentelemetry_tracing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def test_trace_call(self):
6565
"db.type": "spanner",
6666
"db.url": "spanner.googleapis.com",
6767
"net.host.name": "spanner.googleapis.com",
68+
"cloud.region": "global",
6869
"gcp.client.service": "spanner",
6970
"gcp.client.version": LIB_VERSION,
7071
"gcp.client.repo": "googleapis/python-spanner",
@@ -95,6 +96,7 @@ def test_trace_error(self):
9596
"db.type": "spanner",
9697
"db.url": "spanner.googleapis.com",
9798
"net.host.name": "spanner.googleapis.com",
99+
"cloud.region": "global",
98100
"gcp.client.service": "spanner",
99101
"gcp.client.version": LIB_VERSION,
100102
"gcp.client.repo": "googleapis/python-spanner",

tests/unit/test_batch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"gcp.client.service": "spanner",
5858
"gcp.client.version": LIB_VERSION,
5959
"gcp.client.repo": "googleapis/python-spanner",
60+
"cloud.region": "global",
6061
}
6162
enrich_with_otel_scope(BASE_ATTRIBUTES)
6263

0 commit comments

Comments
 (0)