From 3cac2f0505c7e7c0e7cf0af223bee52635a9566e Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Tue, 27 Oct 2026 11:00:38 -0400 Subject: [PATCH] fix(google-api-core): align HTTP T3 and T4 OpenTelemetry span status and attributes --- .../system/telemetry_requirements_matrix.csv | 28 ++-- .../google/api_core/_observability.py | 60 +++++++- .../tests/unit/test_observability.py | 132 +++++++++++++++++- 3 files changed, 199 insertions(+), 21 deletions(-) diff --git a/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv b/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv index 524010fc2244..10e7e7d359cc 100644 --- a/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv +++ b/packages/gapic-generator/tests/system/telemetry_requirements_matrix.csv @@ -1,10 +1,10 @@ Feature ID,Feature Name,Tier,Attempt,Transport,Scenario,Span Count,Span Kind,Span Name,Span Status,rpc.system.name,rpc.method,rpc.response.status_code,http.request.method,http.response.status_code,rpc.grpc.status_code,url.domain,url.template,server.address,server.port,error.type,status.message,resend_count,parent_span_id,Extra / Permitted Attributes F1.1,Tracing Off (HTTP),T4,N/A,HTTP/REST,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted -F1.2,Unary Success (HTTP),T4,0,HTTP/REST,Happy Path,1,CLIENT,POST /v1beta1/echo:echo,OK,NOT SET,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"url.full, http.request.body.size" -F1.3,Server Error (HTTP),T4,0,HTTP/REST,Server Failure,1,CLIENT,POST /v1beta1/echo:echo,ERROR,NOT SET,NOT SET,NOT SET,POST,400,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"url.full, http.request.body.size" -F1.4,Client Timeout (HTTP),T4,0,HTTP/REST,Client Timeout,1,CLIENT,POST /v1beta1/echo:block,ERROR,NOT SET,NOT SET,NOT SET,POST,NOT SET,NOT SET,googleapis.com,/v1beta1/echo:block,localhost,7469,ReadTimeout,Read timed out,NOT SET,T3.span_id,url.full -F1.5a,Retry Recovery - Attempt 1 (HTTP),T4,0,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,NOT SET,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,url.full -F1.5b,Retry Recovery - Attempt 2 (HTTP),T4,1,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},OK,NOT SET,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,url.full +F1.2,Unary Success (HTTP),T4,0,HTTP/REST,Happy Path,1,CLIENT,POST /v1beta1/echo:echo,UNSET,http,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,"url.full, http.request.body.size" +F1.3,Server Error (HTTP),T4,0,HTTP/REST,Server Failure,1,CLIENT,POST /v1beta1/echo:echo,ERROR,http,NOT SET,NOT SET,POST,400,NOT SET,googleapis.com,/v1beta1/echo:echo,localhost,7469,400,Simulated unretryable invalid argument error.,NOT SET,T3.span_id,"url.full, http.request.body.size" +F1.4,Client Timeout (HTTP),T4,0,HTTP/REST,Client Timeout,1,CLIENT,POST /v1beta1/echo:block,ERROR,http,NOT SET,NOT SET,POST,NOT SET,NOT SET,googleapis.com,/v1beta1/echo:block,localhost,7469,ReadTimeout,Read timed out,NOT SET,T3.span_id,url.full +F1.5a,Retry Recovery - Attempt 1 (HTTP),T4,0,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Temporary glitch,NOT SET,T3.span_id,url.full +F1.5b,Retry Recovery - Attempt 2 (HTTP),T4,1,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},UNSET,http,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,url.full F1.6,Tracing Off (gRPC),T4,N/A,gRPC,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted F1.7,Unary Success (gRPC),T4,0,gRPC,Happy Path,1,CLIENT,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" F1.8,Server Error (gRPC),T4,0,gRPC,Server Failure,1,CLIENT,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,NOT SET,NOT SET,NOT SET,3,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" @@ -12,19 +12,19 @@ F1.9,Client Timeout (gRPC),T4,0,gRPC,Client Timeout,1,CLIENT,google.showcase.v1b F1.10a,Retry Recovery - Attempt 1 (gRPC),T4,0,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" F1.10b,Retry Recovery - Attempt 2 (gRPC),T4,1,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,"rpc.system, rpc.service" F2.1,Tracing Off (HTTP),T3,N/A,HTTP/REST,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted -F2.2,Unary Success (HTTP),T3,0,HTTP/REST,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,http,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) -F2.3,Server Error (HTTP),T3,0,HTTP/REST,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,http,google.showcase.v1beta1.Echo/Echo,BAD_REQUEST,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,BAD_REQUEST,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" -F2.4,Client Timeout (HTTP),T3,0,HTTP/REST,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,http,google.showcase.v1beta1.Echo/Block,ReadTimeout,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,ReadTimeout,Read timed out,NOT SET,None (Root),gcp.errors.domain +F2.2,Unary Success (HTTP),T3,0,HTTP/REST,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,http,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,200,NOT SET,googleapis.com,/v1beta1/echo:echo,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) +F2.3,Server Error (HTTP),T3,0,HTTP/REST,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,http,google.showcase.v1beta1.Echo/Echo,BAD_REQUEST,NOT SET,400,NOT SET,googleapis.com,/v1beta1/echo:echo,NOT SET,NOT SET,BAD_REQUEST,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" +F2.4,Client Timeout (HTTP),T3,0,HTTP/REST,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,http,google.showcase.v1beta1.Echo/Block,ReadTimeout,NOT SET,NOT SET,NOT SET,googleapis.com,/v1beta1/echo:block,NOT SET,NOT SET,ReadTimeout,Read timed out,NOT SET,None (Root),gcp.errors.domain F2.5,Tracing Off (gRPC),T3,N/A,gRPC,Tracing Off,0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,No spans emitted F2.6,Unary Success (gRPC),T3,0,gRPC,Happy Path,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,UNSET,grpc,google.showcase.v1beta1.Echo/Echo,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Strict ceiling (no unknown attrs) F2.7,Server Error (gRPC),T3,0,gRPC,Server Failure,1,INTERNAL,google.showcase.v1beta1.Echo/Echo,ERROR,grpc,google.showcase.v1beta1.Echo/Echo,INVALID_ARGUMENT,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,INVALID_ARGUMENT,Simulated unretryable invalid argument error.,NOT SET,None (Root),"gcp.errors.domain, gcp.errors.metadata.*" F2.8,Client Timeout (gRPC),T3,0,gRPC,Client Timeout,1,INTERNAL,google.showcase.v1beta1.Echo/Block,ERROR,grpc,google.showcase.v1beta1.Echo/Block,DEADLINE_EXCEEDED,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,DEADLINE_EXCEEDED,Deadline Exceeded,NOT SET,None (Root),gcp.errors.domain -F3.1a,Retry Succeeds - Root (HTTP),T3,0,HTTP/REST,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,http,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 -F3.1b,Retry Succeeds - Attempt 1 (HTTP),T4,0,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,NOT SET,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 -F3.1c,Retry Succeeds - Attempt 2 (HTTP),T4,1,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},OK,NOT SET,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 -F3.2a,Retries Exhausted - Root (HTTP),T3,0,HTTP/REST,Retries Exhausted,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,http,google.showcase.v1beta1.SequenceService/AttemptSequence,UNAVAILABLE,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,UNAVAILABLE,Persistent outage,NOT SET,None (Root),Parent aggregates UNAVAILABLE; all T4 children nested under T3 -F3.2b,Retries Exhausted - Attempt 1 (HTTP),T4,0,HTTP/REST,Retries Exhausted,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,NOT SET,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 -F3.2c,Retries Exhausted - Attempt 2 (HTTP),T4,1,HTTP/REST,Retries Exhausted,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,NOT SET,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.1a,Retry Succeeds - Root (HTTP),T3,0,HTTP/REST,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,http,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 +F3.1b,Retry Succeeds - Attempt 1 (HTTP),T4,0,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Temporary glitch,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 +F3.1c,Retry Succeeds - Attempt 2 (HTTP),T4,1,HTTP/REST,Retry Recovery,2,CLIENT,POST /v1beta1/{name=sequences/*},UNSET,http,NOT SET,NOT SET,POST,200,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 +F3.2a,Retries Exhausted - Root (HTTP),T3,0,HTTP/REST,Retries Exhausted,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,http,google.showcase.v1beta1.SequenceService/AttemptSequence,UNAVAILABLE,NOT SET,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},NOT SET,NOT SET,UNAVAILABLE,Persistent outage,NOT SET,None (Root),Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.2b,Retries Exhausted - Attempt 1 (HTTP),T4,0,HTTP/REST,Retries Exhausted,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Persistent outage,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 +F3.2c,Retries Exhausted - Attempt 2 (HTTP),T4,1,HTTP/REST,Retries Exhausted,2,CLIENT,POST /v1beta1/{name=sequences/*},ERROR,http,NOT SET,NOT SET,POST,503,NOT SET,googleapis.com,/v1beta1/{name=sequences/*},localhost,7469,503,Persistent outage,NOT SET,T3.span_id,Parent aggregates UNAVAILABLE; all T4 children nested under T3 F3.3a,Retry Succeeds - Root (gRPC),T3,0,gRPC,Retry Recovery,1,INTERNAL,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,NOT SET,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,None (Root),Parent aggregates OK; T4 children nested under T3 F3.3b,Retry Succeeds - Attempt 1 (gRPC),T4,0,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,ERROR,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,NOT SET,NOT SET,NOT SET,14,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 F3.3c,Retry Succeeds - Attempt 2 (gRPC),T4,1,gRPC,Retry Recovery,2,CLIENT,google.showcase.v1beta1.SequenceService/AttemptSequence,UNSET,grpc,google.showcase.v1beta1.SequenceService/AttemptSequence,OK,NOT SET,NOT SET,0,googleapis.com,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,NOT SET,T3.span_id,Parent aggregates OK; T4 children nested under T3 diff --git a/packages/google-api-core/google/api_core/_observability.py b/packages/google-api-core/google/api_core/_observability.py index 6ad4c9604f1e..53d8185c01eb 100644 --- a/packages/google-api-core/google/api_core/_observability.py +++ b/packages/google-api-core/google/api_core/_observability.py @@ -598,6 +598,7 @@ def _build_http_span_attributes( f"{resolved_method} {url_template}" if url_template else resolved_method ) span_attributes: dict[str, Any] = { + "rpc.system.name": "http", "http.request.method": resolved_method, "server.address": server_address or "", "server.port": server_port or 443, @@ -613,6 +614,32 @@ def _build_http_span_attributes( return span_name, span_attributes, resolved_headers + @staticmethod + def _extract_http_error_message(response: Any) -> str | None: + """Extract a human-readable error message from an HTTP error response.""" + json_fn = getattr(response, "json", None) + if callable(json_fn): + try: + payload = json_fn() + if isinstance(payload, dict): + err_obj = payload.get("error") + if isinstance(err_obj, dict): + msg = err_obj.get("message") + if isinstance(msg, str) and msg: + return msg + elif isinstance(err_obj, str) and err_obj: + return err_obj + except Exception: + pass + for attr in ("text", "reason"): + try: + val = getattr(response, attr, None) + if isinstance(val, str) and val.strip(): + return val.strip() + except Exception: + continue + return None + def __init__( self, request: Any = None, @@ -632,6 +659,7 @@ def __init__( self._body = body self._client_options = client_options self._span: Any = None + self._parent_span: Any = None self._cm: Any = None def record_response(self, response: Any) -> None: @@ -654,11 +682,18 @@ def record_response(self, response: Any) -> None: response, "status_code", getattr(response, "status", None) ) if status_code is not None: - span.set_attribute("http.response.status_code", int(status_code)) - if int(status_code) >= 400: + code_int = int(status_code) + span.set_attribute("http.response.status_code", code_int) + if self._parent_span is not None: + self._parent_span.set_attribute( + "http.response.status_code", code_int + ) + if code_int >= 400: span.set_status(Status(StatusCode.ERROR)) - else: - span.set_status(Status(StatusCode.OK)) + span.set_attribute("error.type", str(code_int)) + error_msg = self._extract_http_error_message(response) + if error_msg: + span.set_attribute("status.message", error_msg) headers = getattr(response, "headers", None) if headers and "Content-Length" in headers: @@ -745,6 +780,23 @@ def __enter__(self) -> "_TraceContext": ) ) + parent_span = trace.get_current_span() + if ( + parent_span is not None + and getattr(parent_span, "is_recording", lambda: False)() is True + and hasattr(parent_span, "set_attribute") + ): + self._parent_span = parent_span + if self._url_template: + try: + parent_attrs = getattr(parent_span, "attributes", None) + if parent_attrs is None or "url.template" not in parent_attrs: + parent_span.set_attribute( + "url.template", self._url_template + ) + except Exception: + pass + self._cm = tracer.start_as_current_span( span_name, kind=trace.SpanKind.CLIENT, diff --git a/packages/google-api-core/tests/unit/test_observability.py b/packages/google-api-core/tests/unit/test_observability.py index 68cd35e1f2a5..35febc269bbd 100644 --- a/packages/google-api-core/tests/unit/test_observability.py +++ b/packages/google-api-core/tests/unit/test_observability.py @@ -747,7 +747,7 @@ def test_trace_http_request_active(monkeypatch): def test_trace_context_record_response_success(monkeypatch): - """Proves that _TraceContext.record_response records status code and size attributes.""" + """Proves that _TraceContext.record_response records status code and size attributes while leaving status UNSET.""" mock_span = mock.Mock() response = mock.Mock(status_code=200, headers={"Content-Length": "42"}) @@ -759,22 +759,148 @@ def test_trace_context_record_response_success(monkeypatch): ctx.record_response(response) mock_span.set_attribute.assert_any_call("http.response.status_code", 200) mock_span.set_attribute.assert_any_call("http.response.body.size", 42) + mock_span.set_status.assert_not_called() def test_trace_context_record_response_error_status(monkeypatch): - """Proves that _TraceContext.record_response sets error status on 4xx/5xx responses.""" + """Proves that _TraceContext.record_response sets error status, error.type, and status.message on 4xx/5xx responses.""" mock_span = mock.Mock() - response = mock.Mock(status_code=503, headers={}) + mock_parent_span = mock.Mock() + response = mock.Mock( + status_code=503, + headers={}, + json=mock.Mock(return_value={"error": {"message": "Temporary glitch"}}), + ) mock_status_mod = mock.Mock() monkeypatch.setitem(sys.modules, "opentelemetry.trace.status", mock_status_mod) ctx = _observability._TraceContext() ctx._span = mock_span + ctx._parent_span = mock_parent_span ctx.record_response(response) mock_span.set_attribute.assert_any_call("http.response.status_code", 503) + mock_span.set_attribute.assert_any_call("error.type", "503") + mock_span.set_attribute.assert_any_call("status.message", "Temporary glitch") + mock_parent_span.set_attribute.assert_called_once_with( + "http.response.status_code", 503 + ) mock_span.set_status.assert_called_once() + # Also test 503 response with no extractable message + mock_span.reset_mock() + ctx.record_response(mock.Mock(status_code=503, headers={})) + mock_span.set_attribute.assert_any_call("error.type", "503") + + +def test_extract_http_error_message_variants(): + """Proves that _extract_http_error_message handles JSON dicts, string errors, text fallbacks, and empty responses.""" + extract = _observability._TraceContext._extract_http_error_message + + # 1. Nested dict error message + resp_dict = mock.Mock( + json=mock.Mock(return_value={"error": {"message": "Invalid argument"}}) + ) + assert extract(resp_dict) == "Invalid argument" + + # 2. String error field in JSON dict + resp_str_err = mock.Mock(json=mock.Mock(return_value={"error": "Simple error"})) + assert extract(resp_str_err) == "Simple error" + + # 3. Empty message in JSON dict + fallback to text + resp_fallback_text = mock.Mock( + json=mock.Mock(return_value={"error": {"message": ""}}), + text=" Gateway timeout ", + reason=None, + ) + assert extract(resp_fallback_text) == "Gateway timeout" + + # 4. Non-dict JSON payload + json() raising ValueError + fallback to reason + resp_json_exc = mock.Mock( + json=mock.Mock(side_effect=ValueError("No JSON")), + text="", + reason="Service Unavailable", + ) + assert extract(resp_json_exc) == "Service Unavailable" + + # 5. Non-callable json attribute and no text/reason + resp_empty = mock.Mock(json=None, text=None, reason=None) + assert extract(resp_empty) is None + + # 6. JSON returns a list instead of a dict and error is neither dict nor non-empty str + resp_list = mock.Mock( + json=mock.Mock(return_value=["not-a-dict"]), text=None, reason=None + ) + assert extract(resp_list) is None + resp_int_err = mock.Mock( + json=mock.Mock(return_value={"error": 123}), text=None, reason=None + ) + assert extract(resp_int_err) is None + + +def test_trace_http_request_propagates_to_parent_span(monkeypatch): + """Proves that trace_http_request propagates url.template (first attempt) and http.response.status_code to parent T3 span.""" + monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true") + + mock_tracer = mock.MagicMock() + mock_span = mock.MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + + parent_attrs = {} + mock_parent_span = mock.MagicMock() + mock_parent_span.is_recording.return_value = True + mock_parent_span.attributes = parent_attrs + + def _set_parent_attr(k, v): + parent_attrs[k] = v + + mock_parent_span.set_attribute.side_effect = _set_parent_attr + + mock_otel = mock.MagicMock() + mock_otel.trace.get_tracer.return_value = mock_tracer + mock_otel.trace.get_current_span.return_value = mock_parent_span + monkeypatch.setitem(sys.modules, "opentelemetry", mock_otel) + monkeypatch.setitem(sys.modules, "opentelemetry.trace", mock_otel.trace) + monkeypatch.setitem( + sys.modules, + "opentelemetry.trace.propagation.tracecontext", + mock_otel.trace.propagation.tracecontext, + ) + monkeypatch.setitem( + sys.modules, + "opentelemetry.instrumentation.grpc", + mock.Mock(), + ) + + # First attempt: sets url.template on parent_span + with _observability.trace_http_request( + method="POST", + url="https://example.com/v1/first", + url_template="/v1/first", + client_options=ClientOptions(), + ) as ctx: + assert ctx._parent_span is mock_parent_span + assert parent_attrs["url.template"] == "/v1/first" + + # Second attempt with a different url_template: does NOT overwrite first attempt's url.template + with _observability.trace_http_request( + method="POST", + url="https://example.com/v1/second", + url_template="/v1/second", + client_options=ClientOptions(), + ): + pass + assert parent_attrs["url.template"] == "/v1/first" + + # Third attempt with url_template=None + with _observability.trace_http_request( + method="POST", + url="https://example.com/v1/none", + url_template=None, + client_options=ClientOptions(), + ): + pass + def test_trace_context_record_error(monkeypatch): """Proves that _TraceContext.record_error records supplemental error attributes."""