Skip to content

Commit 8f964b5

Browse files
committed
fix(tracing): instrument FastAPI ASGI layer for inbound traceparent propagation
Fixes #4767. When get_fast_api_app() is used in production behind an OTel-instrumented caller (e.g. a Next.js service using @opentelemetry/sdk-node), every inbound request carried a W3C traceparent header that ADK silently discarded. The TracerProvider and W3C propagator were already wired up correctly by _setup_telemetry(), but without an ASGI-level hook to extract the header, each request spawned a new trace root instead of continuing the caller's trace. The fix calls FastAPIInstrumentor.instrument_app(app) immediately after the FastAPI instance is created, but only when an OTel export pipeline is actually active (OTLP env vars or otel_to_cloud=True). The instrumentation is applied before the CORS and origin-check middleware are registered so that Starlette's reverse-registration order leaves the security wrappers outermost in the stack. The call is best-effort: if opentelemetry-instrumentation-fastapi is absent a debug-level message is emitted and the server starts normally. The package is added to the otel-gcp extras so users who install google-adk[otel-gcp] get end-to-end distributed tracing out of the box without any manual post-instrumentation step. opentelemetry-instrumentation-fastapi is also added to the test extras so the new unit tests can import it directly. #4767
1 parent b3fcd8a commit 8f964b5

File tree

3 files changed

+214
-1
lines changed

3 files changed

+214
-1
lines changed

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ test = [
129129
"litellm>=1.75.5, <2.0.0", # For LiteLLM tests
130130
"llama-index-readers-file>=0.4.0", # For retrieval tests
131131
"openai>=1.100.2", # For LiteLLM
132+
"opentelemetry-instrumentation-fastapi>=0.48b0, <1.0.0",
132133
"opentelemetry-instrumentation-google-genai>=0.3b0, <1.0.0",
133134
"pypika>=0.50.0", # For crewai->chromadb dependency
134135
"pytest-asyncio>=0.25.0",
@@ -168,7 +169,12 @@ extensions = [
168169
"toolbox-adk>=0.7.0, <0.8.0", # For tools.toolbox_toolset.ToolboxToolset
169170
]
170171

171-
otel-gcp = ["opentelemetry-instrumentation-google-genai>=0.6b0, <1.0.0"]
172+
otel-gcp = [
173+
# go/keep-sorted start
174+
"opentelemetry-instrumentation-fastapi>=0.48b0, <1.0.0",
175+
"opentelemetry-instrumentation-google-genai>=0.6b0, <1.0.0",
176+
# go/keep-sorted end
177+
]
172178

173179
toolbox = ["toolbox-adk>=0.7.0, <0.8.0"]
174180

src/google/adk/cli/adk_web_server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,27 @@ async def internal_lifespan(app: FastAPI):
910910
# Run the FastAPI server.
911911
app = FastAPI(lifespan=internal_lifespan)
912912

913+
# When an OTel pipeline is active, instrument the ASGI layer so that
914+
# inbound W3C traceparent headers are extracted and agent spans are
915+
# correctly parented to the caller's trace. Without this, every request
916+
# starts a new trace root even though the TracerProvider and W3C propagator
917+
# are already configured. Applied before other middleware so that
918+
# security/CORS wrappers are outermost in the stack (Starlette applies
919+
# middleware in reverse-registration order).
920+
if otel_to_cloud or _otel_env_vars_enabled():
921+
try:
922+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor # pylint: disable=g-import-not-at-top
923+
924+
FastAPIInstrumentor.instrument_app(app)
925+
logger.debug("FastAPI OpenTelemetry instrumentation enabled.")
926+
except ImportError:
927+
logger.debug(
928+
"opentelemetry-instrumentation-fastapi is not installed; inbound"
929+
" traceparent headers will not be propagated into agent spans."
930+
" Install it alongside the OTel extras: pip install"
931+
" opentelemetry-instrumentation-fastapi"
932+
)
933+
913934
has_configured_allowed_origins = bool(allow_origins)
914935
if allow_origins:
915936
literal_origins, combined_regex = _parse_cors_origins(allow_origins)

tests/unittests/cli/test_fast_api.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,5 +1808,191 @@ async def run_async_session_not_found(self, **kwargs):
18081808
assert "Session not found" in response.json()["detail"]
18091809

18101810

1811+
# ---------------------------------------------------------------------------
1812+
# OpenTelemetry FastAPI instrumentation tests
1813+
# ---------------------------------------------------------------------------
1814+
1815+
_OTEL_ENV_ENABLED = "google.adk.cli.adk_web_server._otel_env_vars_enabled"
1816+
_SETUP_TELEMETRY = "google.adk.cli.adk_web_server._setup_telemetry"
1817+
_FASTAPI_INSTRUMENTOR = "opentelemetry.instrumentation.fastapi.FastAPIInstrumentor"
1818+
1819+
1820+
def _make_otel_test_client(
1821+
mock_session_service,
1822+
mock_artifact_service,
1823+
mock_memory_service,
1824+
mock_agent_loader,
1825+
mock_eval_sets_manager,
1826+
mock_eval_set_results_manager,
1827+
**app_kwargs,
1828+
):
1829+
"""Like _create_test_client but suppresses real OTel provider setup."""
1830+
defaults = dict(
1831+
agents_dir=".",
1832+
web=False,
1833+
session_service_uri="",
1834+
artifact_service_uri="",
1835+
memory_service_uri="",
1836+
a2a=False,
1837+
host="127.0.0.1",
1838+
port=8000,
1839+
)
1840+
defaults.update(app_kwargs)
1841+
with (
1842+
patch.object(signal, "signal", autospec=True, return_value=None),
1843+
patch.object(
1844+
fast_api_module,
1845+
"create_session_service_from_options",
1846+
autospec=True,
1847+
return_value=mock_session_service,
1848+
),
1849+
patch.object(
1850+
fast_api_module,
1851+
"create_artifact_service_from_options",
1852+
autospec=True,
1853+
return_value=mock_artifact_service,
1854+
),
1855+
patch.object(
1856+
fast_api_module,
1857+
"create_memory_service_from_options",
1858+
autospec=True,
1859+
return_value=mock_memory_service,
1860+
),
1861+
patch.object(
1862+
fast_api_module,
1863+
"AgentLoader",
1864+
autospec=True,
1865+
return_value=mock_agent_loader,
1866+
),
1867+
patch.object(
1868+
fast_api_module,
1869+
"LocalEvalSetsManager",
1870+
autospec=True,
1871+
return_value=mock_eval_sets_manager,
1872+
),
1873+
patch.object(
1874+
fast_api_module,
1875+
"LocalEvalSetResultsManager",
1876+
autospec=True,
1877+
return_value=mock_eval_set_results_manager,
1878+
),
1879+
# Suppress real OTel provider / exporter setup so tests stay isolated.
1880+
patch(_SETUP_TELEMETRY),
1881+
):
1882+
app = get_fast_api_app(**defaults)
1883+
return TestClient(app)
1884+
1885+
1886+
def test_fastapi_instrumented_when_otlp_env_var_set(
1887+
mock_session_service,
1888+
mock_artifact_service,
1889+
mock_memory_service,
1890+
mock_agent_loader,
1891+
mock_eval_sets_manager,
1892+
mock_eval_set_results_manager,
1893+
):
1894+
"""FastAPIInstrumentor.instrument_app is called when an OTLP env var is set."""
1895+
with (
1896+
patch(_OTEL_ENV_ENABLED, return_value=True),
1897+
patch(_FASTAPI_INSTRUMENTOR) as mock_instrumentor_cls,
1898+
):
1899+
_make_otel_test_client(
1900+
mock_session_service,
1901+
mock_artifact_service,
1902+
mock_memory_service,
1903+
mock_agent_loader,
1904+
mock_eval_sets_manager,
1905+
mock_eval_set_results_manager,
1906+
)
1907+
1908+
mock_instrumentor_cls.instrument_app.assert_called_once()
1909+
1910+
1911+
def test_fastapi_instrumented_when_otel_to_cloud_enabled(
1912+
mock_session_service,
1913+
mock_artifact_service,
1914+
mock_memory_service,
1915+
mock_agent_loader,
1916+
mock_eval_sets_manager,
1917+
mock_eval_set_results_manager,
1918+
):
1919+
"""FastAPIInstrumentor.instrument_app is called when otel_to_cloud=True."""
1920+
with (
1921+
# otel_to_cloud=True triggers the instrumentation regardless of env vars.
1922+
patch(_OTEL_ENV_ENABLED, return_value=False),
1923+
patch(_FASTAPI_INSTRUMENTOR) as mock_instrumentor_cls,
1924+
):
1925+
_make_otel_test_client(
1926+
mock_session_service,
1927+
mock_artifact_service,
1928+
mock_memory_service,
1929+
mock_agent_loader,
1930+
mock_eval_sets_manager,
1931+
mock_eval_set_results_manager,
1932+
otel_to_cloud=True,
1933+
)
1934+
1935+
mock_instrumentor_cls.instrument_app.assert_called_once()
1936+
1937+
1938+
def test_fastapi_not_instrumented_without_otel_config(
1939+
mock_session_service,
1940+
mock_artifact_service,
1941+
mock_memory_service,
1942+
mock_agent_loader,
1943+
mock_eval_sets_manager,
1944+
mock_eval_set_results_manager,
1945+
):
1946+
"""FastAPIInstrumentor.instrument_app is NOT called when OTel is not configured."""
1947+
with (
1948+
patch(_OTEL_ENV_ENABLED, return_value=False),
1949+
patch(_FASTAPI_INSTRUMENTOR) as mock_instrumentor_cls,
1950+
):
1951+
_make_otel_test_client(
1952+
mock_session_service,
1953+
mock_artifact_service,
1954+
mock_memory_service,
1955+
mock_agent_loader,
1956+
mock_eval_sets_manager,
1957+
mock_eval_set_results_manager,
1958+
otel_to_cloud=False,
1959+
)
1960+
1961+
mock_instrumentor_cls.instrument_app.assert_not_called()
1962+
1963+
1964+
def test_missing_fastapi_instrumentor_does_not_prevent_startup(
1965+
mock_session_service,
1966+
mock_artifact_service,
1967+
mock_memory_service,
1968+
mock_agent_loader,
1969+
mock_eval_sets_manager,
1970+
mock_eval_set_results_manager,
1971+
):
1972+
"""App starts normally when opentelemetry-instrumentation-fastapi is absent."""
1973+
import sys
1974+
1975+
# Simulate the package not being installed by removing it from sys.modules
1976+
# and making the import raise ImportError.
1977+
with (
1978+
patch(_OTEL_ENV_ENABLED, return_value=True),
1979+
patch.dict(
1980+
sys.modules,
1981+
{"opentelemetry.instrumentation.fastapi": None},
1982+
),
1983+
):
1984+
client = _make_otel_test_client(
1985+
mock_session_service,
1986+
mock_artifact_service,
1987+
mock_memory_service,
1988+
mock_agent_loader,
1989+
mock_eval_sets_manager,
1990+
mock_eval_set_results_manager,
1991+
)
1992+
1993+
response = client.get("/health")
1994+
assert response.status_code == 200
1995+
1996+
18111997
if __name__ == "__main__":
18121998
pytest.main(["-xvs", __file__])

0 commit comments

Comments
 (0)