|
| 1 | +# Copyright 2026 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Tests for trace context propagated from request headers.""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import fastapi |
| 20 | +from google.adk.telemetry._agent_engine import get_propagated_context |
| 21 | +from google.adk.telemetry._agent_engine import TopSpanProcessor |
| 22 | +from opentelemetry import baggage |
| 23 | +from opentelemetry import context |
| 24 | +from opentelemetry.sdk.trace import ReadableSpan |
| 25 | +from opentelemetry.sdk.trace import TracerProvider |
| 26 | +from opentelemetry.sdk.trace.export import SimpleSpanProcessor |
| 27 | +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter |
| 28 | +import pytest |
| 29 | + |
| 30 | +_AE_TRACEPARENT_HEADER = 'Google-Agent-Engine-Traceparent' |
| 31 | +_TRACEPARENT_HEADER = 'traceparent' |
| 32 | +_SUPPORT_ID_ATTRIBUTE = 'supportID' |
| 33 | +_SUPPORT_ID_VALUE = 'support-id-value' |
| 34 | +_TOP_SPAN = 'invocation' |
| 35 | +_CHILD_SPAN = 'child' |
| 36 | + |
| 37 | +_TRACE_ID_HEX = '4bf92f3577b34da6a3ce929d0e0e4736' |
| 38 | +_REMOTE_SPAN_ID_HEX = '00f067aa0ba902b7' |
| 39 | +_WELL_FORMED_TRACEPARENT = f'00-{_TRACE_ID_HEX}-{_REMOTE_SPAN_ID_HEX}-01' |
| 40 | + |
| 41 | +# Values the trace context propagator refuses, either because they do not |
| 42 | +# match the wire format or because the ids they carry are not usable. |
| 43 | +_REJECTED_TRACEPARENT_VALUES = [ |
| 44 | + 'x', |
| 45 | + '00-abc-zz-01', |
| 46 | + '', |
| 47 | + '00', |
| 48 | + '-', |
| 49 | + f'00-{_TRACE_ID_HEX}-{_REMOTE_SPAN_ID_HEX}', |
| 50 | + f'00-{"0" * 32}-{_REMOTE_SPAN_ID_HEX}-01', |
| 51 | + f'ff-{_TRACE_ID_HEX}-{_REMOTE_SPAN_ID_HEX}-01', |
| 52 | +] |
| 53 | + |
| 54 | + |
| 55 | +def _request(**headers: str) -> fastapi.Request: |
| 56 | + """Builds a minimal request carrying the given headers.""" |
| 57 | + return fastapi.Request({ |
| 58 | + 'type': 'http', |
| 59 | + 'method': 'POST', |
| 60 | + 'path': '/', |
| 61 | + 'headers': [ |
| 62 | + (name.lower().encode(), value.encode()) |
| 63 | + for name, value in headers.items() |
| 64 | + ], |
| 65 | + }) |
| 66 | + |
| 67 | + |
| 68 | +def _record_spans(ctx: context.Context) -> dict[str, ReadableSpan]: |
| 69 | + """Traces a child span under a top span with ctx attached, keyed by name.""" |
| 70 | + exporter = InMemorySpanExporter() |
| 71 | + provider = TracerProvider(shutdown_on_exit=False) |
| 72 | + provider.add_span_processor(TopSpanProcessor()) |
| 73 | + provider.add_span_processor(SimpleSpanProcessor(exporter)) |
| 74 | + tracer = provider.get_tracer(__name__) |
| 75 | + |
| 76 | + token = context.attach(ctx) |
| 77 | + try: |
| 78 | + with tracer.start_as_current_span(_TOP_SPAN): |
| 79 | + with tracer.start_as_current_span(_CHILD_SPAN): |
| 80 | + pass |
| 81 | + finally: |
| 82 | + context.detach(token) |
| 83 | + |
| 84 | + return {span.name: span for span in exporter.get_finished_spans()} |
| 85 | + |
| 86 | + |
| 87 | +@pytest.mark.parametrize('header_value', _REJECTED_TRACEPARENT_VALUES) |
| 88 | +def test_rejected_header_still_produces_child_spans(header_value): |
| 89 | + """A caller-supplied header must not be able to break span creation.""" |
| 90 | + spans = _record_spans( |
| 91 | + get_propagated_context(_request(**{_AE_TRACEPARENT_HEADER: header_value})) |
| 92 | + ) |
| 93 | + |
| 94 | + assert set(spans) == {_TOP_SPAN, _CHILD_SPAN} |
| 95 | + |
| 96 | + |
| 97 | +@pytest.mark.parametrize('header_value', _REJECTED_TRACEPARENT_VALUES) |
| 98 | +def test_rejected_header_is_not_stored_in_baggage(header_value): |
| 99 | + """Only a header the propagator accepted is worth carrying in baggage.""" |
| 100 | + ctx = get_propagated_context( |
| 101 | + _request(**{_AE_TRACEPARENT_HEADER: header_value}) |
| 102 | + ) |
| 103 | + |
| 104 | + assert _TRACEPARENT_HEADER not in baggage.get_all(context=ctx) |
| 105 | + |
| 106 | + |
| 107 | +@pytest.mark.parametrize('baggage_value', _REJECTED_TRACEPARENT_VALUES) |
| 108 | +def test_rejected_value_in_baggage_still_produces_child_spans(baggage_value): |
| 109 | + """The processor runs on every span, so it cannot trust baggage contents.""" |
| 110 | + spans = _record_spans(baggage.set_baggage(_TRACEPARENT_HEADER, baggage_value)) |
| 111 | + |
| 112 | + assert set(spans) == {_TOP_SPAN, _CHILD_SPAN} |
| 113 | + |
| 114 | + |
| 115 | +def test_well_formed_header_is_stored_in_baggage(): |
| 116 | + """The top span check reads the accepted header back out of baggage.""" |
| 117 | + ctx = get_propagated_context( |
| 118 | + _request(**{_AE_TRACEPARENT_HEADER: _WELL_FORMED_TRACEPARENT}) |
| 119 | + ) |
| 120 | + |
| 121 | + assert ( |
| 122 | + baggage.get_all(context=ctx)[_TRACEPARENT_HEADER] |
| 123 | + == _WELL_FORMED_TRACEPARENT |
| 124 | + ) |
| 125 | + |
| 126 | + |
| 127 | +def test_well_formed_header_marks_first_span_as_top_span(): |
| 128 | + """This is the propagation the rejected-header guards must not break.""" |
| 129 | + spans = _record_spans( |
| 130 | + get_propagated_context( |
| 131 | + _request(**{ |
| 132 | + _AE_TRACEPARENT_HEADER: _WELL_FORMED_TRACEPARENT, |
| 133 | + _TRACEPARENT_HEADER: _SUPPORT_ID_VALUE, |
| 134 | + }) |
| 135 | + ) |
| 136 | + ) |
| 137 | + |
| 138 | + assert spans[_TOP_SPAN].parent.span_id == int(_REMOTE_SPAN_ID_HEX, 16) |
| 139 | + assert spans[_TOP_SPAN].attributes[_SUPPORT_ID_ATTRIBUTE] == _SUPPORT_ID_VALUE |
| 140 | + assert _SUPPORT_ID_ATTRIBUTE not in spans[_CHILD_SPAN].attributes |
| 141 | + |
| 142 | + |
| 143 | +def test_first_span_is_parentless_when_header_is_rejected(): |
| 144 | + """Rejecting the header leaves the first span parentless, still the top.""" |
| 145 | + spans = _record_spans( |
| 146 | + get_propagated_context( |
| 147 | + _request(**{ |
| 148 | + _AE_TRACEPARENT_HEADER: 'x', |
| 149 | + _TRACEPARENT_HEADER: _SUPPORT_ID_VALUE, |
| 150 | + }) |
| 151 | + ) |
| 152 | + ) |
| 153 | + |
| 154 | + assert spans[_TOP_SPAN].parent is None |
| 155 | + assert spans[_TOP_SPAN].attributes[_SUPPORT_ID_ATTRIBUTE] == _SUPPORT_ID_VALUE |
| 156 | + assert _SUPPORT_ID_ATTRIBUTE not in spans[_CHILD_SPAN].attributes |
0 commit comments