Skip to content

Commit ff15ee4

Browse files
committed
feat(gapic): add gcp.client.* attributes to T3 method span
1 parent 662de80 commit ff15ee4

4 files changed

Lines changed: 176 additions & 4 deletions

File tree

packages/google-api-core/google/api_core/gapic_v1/method.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from google.api_core import _observability, grpc_helpers
2727
from google.api_core.gapic_v1 import client_info
28+
from google.api_core.gapic_v1.client_info import METRICS_METADATA_KEY
2829
from google.api_core.timeout import TimeToDeadlineTimeout
2930

3031
USE_DEFAULT_METADATA = object()
@@ -93,7 +94,7 @@ def _extract_metrics_header(metadata) -> Tuple[str, List[Tuple[str, str]]]:
9394
if not metadata:
9495
return "", []
9596

96-
key_to_find = client_info.METRICS_METADATA_KEY
97+
key_to_find = METRICS_METADATA_KEY
9798

9899
metric_str = _deduplicate_metadata_tokens(
99100
" ".join([v for k, v in metadata if k == key_to_find])
@@ -150,6 +151,9 @@ class _GapicCallable(object):
150151
(e.g. "/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion").
151152
is_streaming (bool): Whether the RPC method is streaming. Defaults to False.
152153
Note: Streaming methods do not currently generate Tier 3 observability spans.
154+
client_info (Optional[google.api_core.gapic_v1.client_info.ClientInfo]):
155+
Client information used to extract client library metadata (e.g. version, repo)
156+
for observability attributes. Defaults to None.
153157
"""
154158

155159
def __init__(
@@ -162,6 +166,7 @@ def __init__(
162166
client_options=None,
163167
method_name=None,
164168
is_streaming=False,
169+
client_info=None,
165170
):
166171
self._target = target
167172
self._retry = retry
@@ -176,7 +181,7 @@ def __init__(
176181
self._static_metadata = tuple(remaining)
177182
if self._x_goog_api_client:
178183
self._default_metadata = (
179-
(client_info.METRICS_METADATA_KEY, self._x_goog_api_client),
184+
(METRICS_METADATA_KEY, self._x_goog_api_client),
180185
*self._static_metadata,
181186
)
182187
else:
@@ -213,6 +218,23 @@ def __init__(
213218
"rpc.service": self._rpc_service,
214219
"rpc.method": self._rpc_method,
215220
}
221+
if self._rpc_service:
222+
self._span_attributes["gcp.client.service"] = (
223+
self._rpc_service.rpartition(".")[-1]
224+
)
225+
if client_info is not None:
226+
client_version = getattr(
227+
client_info, "client_library_version", None
228+
) or getattr(client_info, "gapic_version", None)
229+
if client_version:
230+
self._span_attributes["gcp.client.version"] = client_version
231+
self._span_attributes["gcp.client.repo"] = (
232+
getattr(client_info, "client_repo", None)
233+
or "googleapis/google-cloud-python"
234+
)
235+
client_artifact = getattr(client_info, "client_artifact", None)
236+
if client_artifact:
237+
self._span_attributes["gcp.client.artifact"] = client_artifact
216238
except (ImportError, AttributeError, TypeError):
217239
# Gracefully disable tracing if OpenTelemetry or custom provider fails
218240
self._tracer = None
@@ -248,7 +270,7 @@ def __call__(
248270
self._x_goog_api_client, user_x_goog
249271
)
250272
if merged_header:
251-
final_metadata.append((client_info.METRICS_METADATA_KEY, merged_header))
273+
final_metadata.append((METRICS_METADATA_KEY, merged_header))
252274
final_metadata.extend(remaining)
253275
kwargs["metadata"] = final_metadata
254276
elif self._default_metadata:
@@ -416,5 +438,6 @@ def get_topic(name, timeout=None):
416438
client_options=client_options,
417439
method_name=method_name,
418440
is_streaming=is_streaming,
441+
client_info=client_info,
419442
)
420443
)

packages/google-api-core/google/api_core/gapic_v1/method_async.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,6 @@ def wrap_method(
6464
client_options=client_options,
6565
method_name=method_name,
6666
is_streaming=is_streaming,
67+
client_info=client_info,
6768
)
6869
)

packages/google-api-core/tests/asyncio/gapic/test_method_async.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,5 +319,7 @@ async def test_wrap_method_async_with_otel_tracing(monkeypatch):
319319
"rpc.system": "grpc",
320320
"rpc.service": "google.test.AsyncService",
321321
"rpc.method": "AsyncMethod",
322+
"gcp.client.service": "AsyncService",
323+
"gcp.client.repo": "googleapis/google-cloud-python",
322324
},
323325
)

packages/google-api-core/tests/unit/gapic/test_method.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
pytest.skip("No GRPC", allow_module_level=True)
2626

2727

28-
import google.api_core.gapic_v1.client_info
2928
import google.api_core.gapic_v1.method
3029
import google.api_core.page_iterator
3130
from google.api_core import client_options as client_options_lib
3231
from google.api_core import exceptions, retry, timeout
32+
from google.api_core.gapic_v1 import client_info
3333

3434

3535
def _utcnow_monotonic():
@@ -477,6 +477,8 @@ def test_wrap_method_otel_tracing_enabled_success(mock_otel, method_name):
477477
"rpc.system": "grpc",
478478
"rpc.service": "google.cloud.secretmanager.v1.SecretManagerService",
479479
"rpc.method": "ListSecrets",
480+
"gcp.client.service": "SecretManagerService",
481+
"gcp.client.repo": "googleapis/google-cloud-python",
480482
},
481483
)
482484

@@ -506,6 +508,8 @@ def test_wrap_method_otel_tracing_custom_client_options(mock_otel):
506508
"rpc.system": "grpc",
507509
"rpc.service": "google.test.Service",
508510
"rpc.method": "TestMethod",
511+
"gcp.client.service": "Service",
512+
"gcp.client.repo": "googleapis/google-cloud-python",
509513
},
510514
)
511515

@@ -630,6 +634,8 @@ def test_wrap_method_async_otel_tracing(mock_otel):
630634
"rpc.system": "grpc",
631635
"rpc.service": "google.test.AsyncService",
632636
"rpc.method": "AsyncMethod",
637+
"gcp.client.service": "AsyncService",
638+
"gcp.client.repo": "googleapis/google-cloud-python",
633639
},
634640
)
635641

@@ -655,3 +661,143 @@ def test_wrap_method_async_otel_tracing_streaming_skips_span(mock_otel):
655661
mock_trace=mock_otel.trace,
656662
expected_result="async_success",
657663
)
664+
665+
666+
def test_wrap_method_otel_tracing_attributes_with_client_info(mock_otel):
667+
"""Proves that client_info version, repo, and artifact attributes are included in the T3 span."""
668+
mock_target = mock.Mock(return_value="success")
669+
670+
info = client_info.ClientInfo(
671+
client_library_version="2.16.0",
672+
)
673+
info.client_repo = "googleapis/google-cloud-python-test"
674+
info.client_artifact = "google-cloud-secretmanager"
675+
676+
wrapped = google.api_core.gapic_v1.method.wrap_method(
677+
mock_target,
678+
client_info=info,
679+
method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
680+
)
681+
result = wrapped()
682+
683+
assert result == "success"
684+
mock_otel.tracer.start_as_current_span.assert_called_once_with(
685+
"google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
686+
kind="CLIENT",
687+
attributes={
688+
"rpc.system": "grpc",
689+
"rpc.service": "google.cloud.secretmanager.v1.SecretManagerService",
690+
"rpc.method": "ListSecrets",
691+
"gcp.client.service": "SecretManagerService",
692+
"gcp.client.repo": "googleapis/google-cloud-python-test",
693+
"gcp.client.version": "2.16.0",
694+
"gcp.client.artifact": "google-cloud-secretmanager",
695+
},
696+
)
697+
698+
699+
def test_wrap_method_otel_tracing_attributes_fallback_gapic_version(mock_otel):
700+
"""Proves that gapic_version is used when client_library_version is not set."""
701+
mock_target = mock.Mock(return_value="success")
702+
703+
info = client_info.ClientInfo(
704+
gapic_version="1.5.0",
705+
)
706+
707+
wrapped = google.api_core.gapic_v1.method.wrap_method(
708+
mock_target,
709+
client_info=info,
710+
method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
711+
)
712+
result = wrapped()
713+
714+
assert result == "success"
715+
mock_otel.tracer.start_as_current_span.assert_called_once_with(
716+
"google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
717+
kind="CLIENT",
718+
attributes={
719+
"rpc.system": "grpc",
720+
"rpc.service": "google.cloud.secretmanager.v1.SecretManagerService",
721+
"rpc.method": "ListSecrets",
722+
"gcp.client.service": "SecretManagerService",
723+
"gcp.client.repo": "googleapis/google-cloud-python",
724+
"gcp.client.version": "1.5.0",
725+
},
726+
)
727+
728+
729+
def test_wrap_method_otel_tracing_attributes_no_client_info(mock_otel):
730+
"""Proves that when client_info is None, gcp.client repo, version, and artifact are omitted."""
731+
mock_target = mock.Mock(return_value="success")
732+
733+
wrapped = google.api_core.gapic_v1.method.wrap_method(
734+
mock_target,
735+
client_info=None,
736+
method_name="/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
737+
)
738+
result = wrapped()
739+
740+
assert result == "success"
741+
mock_otel.tracer.start_as_current_span.assert_called_once_with(
742+
"google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
743+
kind="CLIENT",
744+
attributes={
745+
"rpc.system": "grpc",
746+
"rpc.service": "google.cloud.secretmanager.v1.SecretManagerService",
747+
"rpc.method": "ListSecrets",
748+
"gcp.client.service": "SecretManagerService",
749+
},
750+
)
751+
752+
753+
def test_wrap_method_otel_tracing_attributes_no_service(mock_otel):
754+
"""Proves that when method_name has no service prefix, gcp.client.service is omitted."""
755+
mock_target = mock.Mock(return_value="success")
756+
757+
wrapped = google.api_core.gapic_v1.method.wrap_method(
758+
mock_target,
759+
client_info=None,
760+
method_name="ListSecrets",
761+
)
762+
result = wrapped()
763+
764+
assert result == "success"
765+
mock_otel.tracer.start_as_current_span.assert_called_once_with(
766+
"ListSecrets",
767+
kind="CLIENT",
768+
attributes={
769+
"rpc.system": "grpc",
770+
"rpc.service": "",
771+
"rpc.method": "ListSecrets",
772+
},
773+
)
774+
775+
776+
def test_wrap_method_async_otel_tracing_with_client_info(mock_otel):
777+
"""Proves that method_async.wrap_method passes client_info to _GapicCallable."""
778+
from google.api_core.gapic_v1 import method_async
779+
780+
mock_target = mock.Mock(return_value="async_success")
781+
info = client_info.ClientInfo(client_library_version="3.0.0")
782+
783+
wrapped = method_async.wrap_method(
784+
mock_target,
785+
kind=None,
786+
client_info=info,
787+
method_name="google.test.AsyncService/AsyncMethod",
788+
)
789+
result = wrapped()
790+
791+
assert result == "async_success"
792+
mock_otel.tracer.start_as_current_span.assert_called_once_with(
793+
"google.test.AsyncService/AsyncMethod",
794+
kind="CLIENT",
795+
attributes={
796+
"rpc.system": "grpc",
797+
"rpc.service": "google.test.AsyncService",
798+
"rpc.method": "AsyncMethod",
799+
"gcp.client.service": "AsyncService",
800+
"gcp.client.repo": "googleapis/google-cloud-python",
801+
"gcp.client.version": "3.0.0",
802+
},
803+
)

0 commit comments

Comments
 (0)