Skip to content

Commit 9af35a8

Browse files
authored
feat(gapic): add OpenTelemetry T3 client method span wrapping
1 parent 7028eea commit 9af35a8

2 files changed

Lines changed: 138 additions & 1 deletion

File tree

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import functools
2323
from typing import List, Tuple
2424

25-
from google.api_core import grpc_helpers
25+
from google.api_core import _observability, grpc_helpers
2626
from google.api_core.gapic_v1 import client_info
2727
from google.api_core.timeout import TimeToDeadlineTimeout
2828

@@ -125,6 +125,8 @@ class _GapicCallable(object):
125125
additional metadata will be passed to the RPC method.
126126
"""
127127

128+
_is_tracing_supported = True
129+
128130
def __init__(
129131
self,
130132
target,
@@ -186,6 +188,41 @@ def __call__(
186188
if self._compression is not None:
187189
kwargs["compression"] = compression
188190

191+
if _observability.is_otel_capabilities_enabled():
192+
try:
193+
from opentelemetry import trace
194+
195+
tracer = trace.get_tracer("google.api_core")
196+
raw_method = getattr(self._target, "_method", None)
197+
if raw_method and isinstance(raw_method, (str, bytes)):
198+
if isinstance(raw_method, bytes):
199+
raw_method = raw_method.decode("utf-8")
200+
method_str = raw_method.lstrip("/")
201+
service, _, method = method_str.rpartition("/")
202+
span_name = method_str
203+
else:
204+
service = "google.api_core"
205+
method = getattr(self._target, "__name__", "call")
206+
span_name = f"{service}/{method}"
207+
208+
with tracer.start_as_current_span(
209+
span_name,
210+
kind=trace.SpanKind.CLIENT,
211+
attributes={
212+
"rpc.system": "grpc",
213+
"rpc.service": service,
214+
"rpc.method": method,
215+
},
216+
) as span:
217+
try:
218+
return wrapped_func(*args, **kwargs)
219+
except Exception as exc:
220+
span.record_exception(exc)
221+
span.set_status(trace.StatusCode.ERROR, str(exc))
222+
raise
223+
except ImportError:
224+
pass
225+
189226
return wrapped_func(*args, **kwargs)
190227

191228

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import datetime
16+
import sys
1617
from unittest import mock
1718

1819
import pytest
@@ -346,3 +347,102 @@ def test_wrap_method_with_call_not_supported():
346347
def test__deduplicate_metadata_tokens(headers, expected):
347348
dedup = google.api_core.gapic_v1.method._deduplicate_metadata_tokens
348349
assert dedup(*headers) == expected
350+
351+
352+
def test_wrap_method_otel_tracing_disabled(monkeypatch):
353+
"""Proves that when OpenTelemetry tracing is disabled, no span is created."""
354+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false")
355+
mock_target = mock.Mock(return_value="success")
356+
wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target)
357+
358+
with mock.patch(
359+
"google.api_core._observability.is_otel_capabilities_enabled",
360+
return_value=False,
361+
):
362+
assert wrapped() == "success"
363+
mock_target.assert_called_once()
364+
365+
366+
def test_wrap_method_otel_tracing_enabled_success(monkeypatch):
367+
"""Proves that when OpenTelemetry tracing is enabled, a T3 client span is started."""
368+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
369+
mock_target = mock.Mock(return_value="success")
370+
mock_target._method = (
371+
"/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets"
372+
)
373+
374+
mock_span = mock.MagicMock()
375+
mock_tracer = mock.MagicMock()
376+
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
377+
378+
mock_trace = mock.Mock()
379+
mock_trace.get_tracer.return_value = mock_tracer
380+
mock_trace.SpanKind.CLIENT = "CLIENT"
381+
382+
wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target)
383+
384+
with (
385+
mock.patch(
386+
"google.api_core._observability.is_otel_capabilities_enabled",
387+
return_value=True,
388+
),
389+
mock.patch.dict(
390+
sys.modules,
391+
{
392+
"opentelemetry": mock.Mock(trace=mock_trace),
393+
"opentelemetry.trace": mock_trace,
394+
},
395+
),
396+
):
397+
result = wrapped()
398+
399+
assert result == "success"
400+
mock_tracer.start_as_current_span.assert_called_once_with(
401+
"google.cloud.secretmanager.v1.SecretManagerService/ListSecrets",
402+
kind="CLIENT",
403+
attributes={
404+
"rpc.system": "grpc",
405+
"rpc.service": "google.cloud.secretmanager.v1.SecretManagerService",
406+
"rpc.method": "ListSecrets",
407+
},
408+
)
409+
410+
411+
def test_wrap_method_otel_tracing_enabled_error(monkeypatch):
412+
"""Proves that when an RPC fails, the T3 client span records the exception and error status."""
413+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
414+
err = RuntimeError("gRPC connection reset")
415+
mock_target = mock.Mock(side_effect=err)
416+
mock_target._method = (
417+
"/google.cloud.secretmanager.v1.SecretManagerService/ListSecrets"
418+
)
419+
420+
mock_span = mock.MagicMock()
421+
mock_tracer = mock.MagicMock()
422+
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
423+
424+
mock_trace = mock.Mock()
425+
mock_trace.get_tracer.return_value = mock_tracer
426+
mock_trace.SpanKind.CLIENT = "CLIENT"
427+
mock_trace.StatusCode.ERROR = "ERROR"
428+
429+
wrapped = google.api_core.gapic_v1.method.wrap_method(mock_target)
430+
431+
with (
432+
mock.patch(
433+
"google.api_core._observability.is_otel_capabilities_enabled",
434+
return_value=True,
435+
),
436+
mock.patch.dict(
437+
sys.modules,
438+
{
439+
"opentelemetry": mock.Mock(trace=mock_trace),
440+
"opentelemetry.trace": mock_trace,
441+
},
442+
),
443+
):
444+
with pytest.raises(RuntimeError):
445+
wrapped()
446+
447+
mock_span.record_exception.assert_called_once_with(err)
448+
mock_span.set_status.assert_called_once_with("ERROR", str(err))

0 commit comments

Comments
 (0)