Skip to content

feat(gapic): add OpenTelemetry T3 client method span wrapping in gapic_v1.method (D) - #18274

Open
chalmerlowe wants to merge 3 commits into
feat/otel-tracing-transport-logicfrom
feat/otel-tracing-t3-method-spans
Open

feat(gapic): add OpenTelemetry T3 client method span wrapping in gapic_v1.method (D)#18274
chalmerlowe wants to merge 3 commits into
feat/otel-tracing-transport-logicfrom
feat/otel-tracing-t3-method-spans

Conversation

@chalmerlowe

@chalmerlowe chalmerlowe commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR implements Tier 3 (T3) Client Method Spans in google-api-core:

  • In google.api_core.gapic_v1.method._GapicCallable.__call__:
    • Starts an OpenTelemetry SpanKind.CLIENT span representing the high-level GAPIC SDK method call (e.g. google.cloud.secretmanager.v1.SecretManagerService/ListSecrets).
    • Sets standard T3 attributes (rpc.system = "grpc", rpc.service, rpc.method).
    • Encompasses client preparation, retry loops, timeouts, and error handling.
    • Automatically establishes context propagation so underlying wire-level transport (T4) spans attach as children under this parent span.
    • Records exceptions and sets span error status if the method terminates with an unhandled exception.
  • Adds comprehensive unit tests in packages/google-api-core/tests/unit/gapic/test_method.py.

Reference:

  [Parent T3 Span] google.cloud.secretmanager.v1.SecretManagerService/ListSecrets (330.37 ms)
     └── [Child T4 Wire Span] /google.cloud.secretmanager.v1.SecretManagerService/ListSecrets (330.08 ms)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds OpenTelemetry tracing support to _GapicCallable in google/api_core/gapic_v1/method.py and includes unit tests to verify the tracing behavior. The review feedback identifies a critical reliability issue: catching only ImportError during OTel setup could allow other exceptions to crash the API call, violating the self-contained fallback rule. Additionally, broadening the exception handling introduces a double execution risk for the wrapped function if it fails. A robust solution using a state flag is proposed to safely handle setup errors while ensuring wrapped function exceptions propagate correctly.

Comment on lines +191 to +224
if _observability.is_otel_capabilities_enabled():
try:
from opentelemetry import trace

tracer = trace.get_tracer("google.api_core")
raw_method = getattr(self._target, "_method", None)
if raw_method and isinstance(raw_method, (str, bytes)):
if isinstance(raw_method, bytes):
raw_method = raw_method.decode("utf-8")
method_str = raw_method.lstrip("/")
service, _, method = method_str.rpartition("/")
span_name = method_str
else:
service = "google.api_core"
method = getattr(self._target, "__name__", "call")
span_name = f"{service}/{method}"

with tracer.start_as_current_span(
span_name,
kind=trace.SpanKind.CLIENT,
attributes={
"rpc.system": "grpc",
"rpc.service": service,
"rpc.method": method,
},
) as span:
try:
return wrapped_func(*args, **kwargs)
except Exception as exc:
span.record_exception(exc)
span.set_status(trace.StatusCode.ERROR, str(exc))
raise
except ImportError:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Reliability & Correctness Issues

  1. Resilience of Fallback Logic: Currently, only ImportError is caught. If any other exception occurs during OpenTelemetry setup, tracer retrieval, or span creation (e.g., AttributeError, TypeError, UnicodeDecodeError when decoding _method, or OTel initialization/configuration errors), it will propagate and crash the user's API call. Per the Repository Style Guide (Rule 3: Self-Contained Fallbacks), fallback logic must be resilient and self-contained, bypassing failures gracefully.
  2. Double Execution Risk: If we simply broaden the exception handler to except Exception:, any exception raised by wrapped_func (which is caught and re-raised by the inner except Exception as exc) would be caught by the outer except Exception and trigger a second execution of wrapped_func(*args, **kwargs). This is a critical bug that could cause non-idempotent RPCs to be executed twice.

Solution

We can use a state flag (func_called) to track whether wrapped_func has been invoked. This allows us to catch all exceptions during OTel setup/span creation and fallback gracefully, while ensuring that any exception raised by wrapped_func itself is propagated immediately without triggering a double execution.

        if _observability.is_otel_capabilities_enabled():
            func_called = False
            try:
                from opentelemetry import trace

                tracer = trace.get_tracer("google.api_core")
                raw_method = getattr(self._target, "_method", None)
                if raw_method and isinstance(raw_method, (str, bytes)):
                    if isinstance(raw_method, bytes):
                        raw_method = raw_method.decode("utf-8")
                    method_str = raw_method.lstrip("/")
                    service, _, method = method_str.rpartition("/")
                    span_name = method_str
                else:
                    service = "google.api_core"
                    method = getattr(self._target, "__name__", "call")
                    span_name = f"{service}/{method}"

                with tracer.start_as_current_span(
                    span_name,
                    kind=trace.SpanKind.CLIENT,
                    attributes={
                        "rpc.system": "grpc",
                        "rpc.service": service,
                        "rpc.method": method,
                    },
                ) as span:
                    try:
                        func_called = True
                        return wrapped_func(*args, **kwargs)
                    except Exception as exc:
                        span.record_exception(exc)
                        span.set_status(trace.StatusCode.ERROR, str(exc))
                        raise
            except Exception:
                if func_called:
                    raise
References
  1. Rule 3: Self-Contained Fallbacks - Fallback logic must be resilient and self-contained. Always wrap fallback configuration loading in try-except blocks to catch expected exceptions and bypass failures gracefully. (link)

@chalmerlowe chalmerlowe added this to the [o11y] Tracing milestone Sep 3, 2026
@chalmerlowe chalmerlowe changed the title feat(gapic): add OpenTelemetry T3 client method span wrapping to gapic_v1.method feat(gapic): add OpenTelemetry T3 client method span wrapping to gapic_v1.method (D) Sep 3, 2026
@chalmerlowe chalmerlowe self-assigned this Sep 3, 2026
span.record_exception(exc)
span.set_status(trace.StatusCode.ERROR, str(exc))
raise
except ImportError:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This is purposeful minimal error handling to allow us to discuss the overall approach, without getting bogged down in the minutiae.
With approval of the approach, I will update the error handling and tests.

@chalmerlowe chalmerlowe changed the title feat(gapic): add OpenTelemetry T3 client method span wrapping to gapic_v1.method (D) feat(gapic): add OpenTelemetry T3 client method span wrapping in gapic_v1.method (D) Sep 3, 2026
@chalmerlowe chalmerlowe added the do not merge Indicates a pull request not ready for merge, due to either quality or timing. label Sep 3, 2026
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-t3-method-spans branch 3 times, most recently from bf82825 to bc94977 Compare September 4, 2026 09:13
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-t3-method-spans branch from bc94977 to 67e3879 Compare September 4, 2026 11:42
@chalmerlowe chalmerlowe removed the do not merge Indicates a pull request not ready for merge, due to either quality or timing. label Sep 4, 2026
@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-t3-method-spans branch from 67e3879 to 6b9bfab Compare September 4, 2026 14:55
@chalmerlowe
chalmerlowe marked this pull request as ready for review September 4, 2026 14:58
@chalmerlowe
chalmerlowe requested a review from a team as a code owner September 4, 2026 14:58
else:
service = "google.api_core"
method = getattr(self._target, "__name__", "call")
span_name = f"{service}/{method}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the hot path that is called on every rpc. It seems like some of this would be doing the same (possibly slow) calculation on each invocation, right? Can we move that logic into the one-time init call?

service = "google.api_core"
method = getattr(self._target, "__name__", "call")
span_name = f"{service}/{method}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

helper methods would be useful here

from opentelemetry import trace

tracer = trace.get_tracer("google.api_core")
raw_method = getattr(self._target, "_method", None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have you tested this against a real client yet? IIRC, there are multiple layers of wrapping, so this may not be exposed the way you expect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it would be possible to pass down a method name, instead of trying to extract it? The generator already knows it when calling _prep_wrapped_messages

},
) as span:
try:
return wrapped_func(*args, **kwargs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two places this function can call into wrapped_function. If errors line up the wrong way, it could hit both. We need to be extra careful to avoid double invocation here, because that would be a very serious bug

It might be better to call wrapped_func a single time at the end of the method, but use a no-op context manager instead of the tracer if we can't get one

try:
from opentelemetry import trace

tracer = trace.get_tracer("google.api_core")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to create this on every invocation? Can we cache it for each request? Or even use a singleton shared across all instances?

span_name,
kind=trace.SpanKind.CLIENT,
attributes={
"rpc.system": "grpc",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, this wrapper is also used by HTTP. So we should try to gate this for now

try:
from opentelemetry import trace

tracer = trace.get_tracer("google.api_core")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does this connect with the client's tracer provider? Is that coming later?

},
) as span:
try:
return wrapped_func(*args, **kwargs)

@daniel-sanche daniel-sanche Sep 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this span won't be very meaningful for streaming rpcs, because it just tracks the stream set-up, not any of the data flow. I remember asking Wes about streaming, and he said it's out of scope.

We should check with Blake if he wants to track stream init like this, or if we should avoid recording any data for streaming rpcs

@chalmerlowe
chalmerlowe force-pushed the feat/otel-tracing-t3-method-spans branch from fb16695 to 9b46ef7 Compare September 4, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants