diff --git a/tests/clients/test_sqlalchemy.py b/tests/clients/test_sqlalchemy.py index 20e1fadc..a2963059 100644 --- a/tests/clients/test_sqlalchemy.py +++ b/tests/clients/test_sqlalchemy.py @@ -305,7 +305,7 @@ def test_context_restored_after_query(self) -> None: find the correct parent. This validates the fix for the bug where after_cursor_execute used get_current_span() instead of conn.span, which could corrupt the context stack and cause child spans to be dropped.""" - with self.tracer.start_as_current_span("test") as parent_span: + with self.tracer.start_as_current_span("test"): with engine.begin() as connection: connection.execute(text("select 1")) diff --git a/tests/frameworks/test_fastapi.py b/tests/frameworks/test_fastapi.py index 80213971..5707716e 100644 --- a/tests/frameworks/test_fastapi.py +++ b/tests/frameworks/test_fastapi.py @@ -2,17 +2,72 @@ # (c) Copyright Instana Inc. 2020 +import sys from typing import Generator -from fastapi.testclient import TestClient import pytest -from instana.singletons import agent, get_tracer +from fastapi.testclient import TestClient +from instana.singletons import agent, get_tracer from instana.util.ids import hex_id from tests.apps.fastapi_app.app import fastapi_server -from tests.helpers import get_first_span_by_filter - - +from tests.helpers import get_first_span_by_filter, get_spans_by_filter + + +def assert_fastapi_otel_spans( + spans: list, + http_route: str, + *, + expect_serialization: bool = True, + http_status_code: int = 200, +) -> None: + """Assert the OTel spans emitted by FastAPI >= 0.115.0. + + FastAPI registers itself against the global OTel TracerProvider (which + Instana provides), so every request produces extra sdk spans that land in + Instana's recorder. + + Per request FastAPI emits: + - fastapi.dependencies (always) + - fastapi.endpoint (always) + - fastapi.serialization (only on non-exception responses) + - GET /path (always, but orphan: p == 0) + + Args: + spans: full list returned by recorder.queued_spans() + http_route: the OTel http.route value, e.g. "/" or "/users/{user_id}" + expect_serialization: False for exception responses (4xx/5xx via HTTPException + or Response() objects that bypass JSON serialization) + http_status_code: expected http.response.status_code on the HTTP entry span + """ + otel_sdk_spans = get_spans_by_filter( + spans, + lambda s: s.n == "sdk" and s.data["sdk"]["name"].startswith("fastapi."), + ) + otel_names = {s.data["sdk"]["name"] for s in otel_sdk_spans} + assert "fastapi.dependencies" in otel_names + assert "fastapi.endpoint" in otel_names + if expect_serialization: + assert "fastapi.serialization" in otel_names + else: + assert "fastapi.serialization" not in otel_names + + http_entry_span = get_first_span_by_filter( + spans, + lambda s: s.n == "sdk" and s.data["sdk"]["name"] == f"GET {http_route}", + ) + assert http_entry_span, f"FastAPI HTTP entry span 'GET {http_route}' not found" + assert http_entry_span.p == 0, "FastAPI HTTP entry span must be orphan (p == 0)" + tags = http_entry_span.data["sdk"]["custom"]["tags"] + assert tags["http.request.method"] == "GET" + assert tags["http.route"] == http_route + assert tags["http.response.status_code"] == http_status_code + + +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="FastAPI >= 0.115.0 (required for OTel span assertions) only supports Python 3.10+", +) class TestFastAPI: @pytest.fixture(autouse=True) def _resource(self) -> Generator[None, None, None]: @@ -49,8 +104,15 @@ def test_vanilla_get(self) -> None: # unless told otherwise spans = self.recorder.queued_spans() - assert len(spans) == 1 - assert spans[0].n == "asgi" + # FastAPI >= 0.115.0 emits 4 extra OTel spans per request via the global + # OTel provider that Instana registers: fastapi.dependencies, + # fastapi.endpoint, fastapi.serialization, GET / + assert len(spans) == 5 + span_filter = lambda span: span.n == "asgi" # noqa: E731 + asgi_span = get_first_span_by_filter(spans, span_filter) + assert asgi_span + + assert_fastapi_otel_spans(spans, "/") def test_basic_get(self) -> None: result = None @@ -73,8 +135,10 @@ def test_basic_get(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, fastapi.serialization, GET / + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -103,6 +167,8 @@ def test_basic_get(self) -> None: assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] + assert_fastapi_otel_spans(spans, "/") + def test_400(self) -> None: result = None with self.tracer.start_as_current_span("test") as span: @@ -124,8 +190,11 @@ def test_400(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 3 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, GET /400 (orphan). + # fastapi.serialization is absent on exception responses. + # TODO: after support httpx, the expected value will be 6. + assert len(spans) == 5 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -154,6 +223,10 @@ def test_400(self) -> None: assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] + assert_fastapi_otel_spans( + spans, "/400", expect_serialization=False, http_status_code=400 + ) + def test_500(self) -> None: result = None with self.tracer.start_as_current_span("test") as span: @@ -175,8 +248,11 @@ def test_500(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 3 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, GET /500 (orphan). + # fastapi.serialization is absent on exception responses. + # TODO: after support httpx, the expected value will be 6. + assert len(spans) == 5 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -204,6 +280,16 @@ def test_500(self) -> None: assert asgi_span.data["http"]["error"] == "500 response" assert not asgi_span.data["http"]["params"] + assert_fastapi_otel_spans( + spans, "/500", expect_serialization=False, http_status_code=500 + ) + # error.type is set by FastAPI on 5xx responses + http_entry_span = get_first_span_by_filter( + spans, + lambda s: s.n == "sdk" and s.data["sdk"]["name"] == "GET /500", + ) + assert "error.type" in http_entry_span.data["sdk"]["custom"]["tags"] + def test_path_templates(self) -> None: result = None with self.tracer.start_as_current_span("test") as span: @@ -225,8 +311,10 @@ def test_path_templates(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, fastapi.serialization, GET /users/{user_id} + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -254,6 +342,14 @@ def test_path_templates(self) -> None: assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] + assert_fastapi_otel_spans(spans, "/users/{user_id}") + # url.path carries the resolved path (with actual param value) + http_entry_span = get_first_span_by_filter( + spans, + lambda s: s.n == "sdk" and s.data["sdk"]["name"] == "GET /users/{user_id}", + ) + assert http_entry_span.data["sdk"]["custom"]["tags"]["url.path"] == "/users/1" + def test_secret_scrubbing(self) -> None: result = None with self.tracer.start_as_current_span("test") as span: @@ -275,8 +371,10 @@ def test_secret_scrubbing(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, fastapi.serialization, GET / + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -305,6 +403,8 @@ def test_secret_scrubbing(self) -> None: assert not asgi_span.data["http"]["error"] assert asgi_span.data["http"]["params"] == "secret=" + assert_fastapi_otel_spans(spans, "/") + def test_synthetic_request(self) -> None: with self.tracer.start_as_current_span("test") as span: # As TestClient() is based on httpx, and we don't support it yet, @@ -326,8 +426,10 @@ def test_synthetic_request(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, fastapi.serialization, GET / + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -359,6 +461,8 @@ def test_synthetic_request(self) -> None: assert asgi_span.sy assert not test_span.sy + assert_fastapi_otel_spans(spans, "/") + def test_request_header_capture(self) -> None: with self.tracer.start_as_current_span("test") as span: # As TestClient() is based on httpx, and we don't support it yet, @@ -381,8 +485,10 @@ def test_request_header_capture(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, fastapi.serialization, GET / + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -416,6 +522,8 @@ def test_request_header_capture(self) -> None: assert "X-Capture-That" in asgi_span.data["http"]["header"] assert asgi_span.data["http"]["header"]["X-Capture-That"] == "that" + assert_fastapi_otel_spans(spans, "/") + def test_response_header_capture(self) -> None: # The background FastAPI server is pre-configured with custom headers # to capture. @@ -439,8 +547,11 @@ def test_response_header_capture(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 3 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, GET /response_headers (orphan). + # fastapi.serialization is absent — Response() objects bypass JSON serialization. + # TODO: after support httpx, the expected value will be 6. + assert len(spans) == 5 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -474,6 +585,11 @@ def test_response_header_capture(self) -> None: assert "X-Capture-That-Too" in asgi_span.data["http"]["header"] assert asgi_span.data["http"]["header"]["X-Capture-That-Too"] == "that too" + # Response() objects bypass FastAPI's JSON serialization pipeline + assert_fastapi_otel_spans( + spans, "/response_headers", expect_serialization=False + ) + def test_non_async_simple(self) -> None: with self.tracer.start_as_current_span("test") as span: # As TestClient() is based on httpx, and we don't support it yet, @@ -494,7 +610,9 @@ def test_non_async_simple(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - assert len(spans) == 3 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request (8 total for 2 requests): + # fastapi.dependencies, fastapi.endpoint, GET /..., and one internal span each. + assert len(spans) == 11 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -506,7 +624,9 @@ def test_non_async_simple(self) -> None: asgi_span1 = get_first_span_by_filter(spans, span_filter) assert asgi_span1 - span_filter = lambda span: span.n == "asgi" and span.p == asgi_span1.s # noqa: E731 + # asgi_span2 is a child of the fastapi.endpoint span which is itself a + # child of asgi_span1; find it by excluding the first asgi span. + span_filter = lambda span: span.n == "asgi" and span.s != asgi_span1.s # noqa: E731 asgi_span2 = get_first_span_by_filter(spans, span_filter) assert asgi_span2 @@ -557,8 +677,10 @@ def test_non_async_threadpool(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, GET /non_async_threadpool, and one internal span. + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" diff --git a/tests/frameworks/test_fastapi_middleware.py b/tests/frameworks/test_fastapi_middleware.py index 8dd0c4cd..f8ba2a13 100644 --- a/tests/frameworks/test_fastapi_middleware.py +++ b/tests/frameworks/test_fastapi_middleware.py @@ -2,16 +2,64 @@ # (c) Copyright Instana Inc. 2020 -from typing import Generator +import sys +from collections.abc import Generator import pytest -from instana.singletons import get_tracer from fastapi.testclient import TestClient +from instana.singletons import get_tracer from instana.util.ids import hex_id -from tests.helpers import get_first_span_by_filter - - +from tests.helpers import get_first_span_by_filter, get_spans_by_filter + + +def _assert_fastapi_otel_spans( + spans: list, + http_route: str, + *, + expect_serialization: bool = True, + http_status_code: int = 200, +) -> None: + """Assert the OTel spans emitted by FastAPI >= 0.115.0. + + FastAPI registers itself against the global OTel TracerProvider (which + Instana provides), so every request produces extra sdk spans that land in + Instana's recorder. + + Per request FastAPI emits: + - fastapi.dependencies (always) + - fastapi.endpoint (always) + - fastapi.serialization (only on non-exception responses) + - GET /path (always, but orphan: p == 0) + """ + otel_sdk_spans = get_spans_by_filter( + spans, + lambda s: s.n == "sdk" and s.data["sdk"]["name"].startswith("fastapi."), + ) + otel_names = {s.data["sdk"]["name"] for s in otel_sdk_spans} + assert "fastapi.dependencies" in otel_names + assert "fastapi.endpoint" in otel_names + if expect_serialization: + assert "fastapi.serialization" in otel_names + else: + assert "fastapi.serialization" not in otel_names + + http_entry_span = get_first_span_by_filter( + spans, + lambda s: s.n == "sdk" and s.data["sdk"]["name"] == f"GET {http_route}", + ) + assert http_entry_span, f"FastAPI HTTP entry span 'GET {http_route}' not found" + assert http_entry_span.p == 0, "FastAPI HTTP entry span must be orphan (p == 0)" + tags = http_entry_span.data["sdk"]["custom"]["tags"] + assert tags["http.request.method"] == "GET" + assert tags["http.route"] == http_route + assert tags["http.response.status_code"] == http_status_code + + +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="FastAPI >= 0.115.0 (required for OTel span assertions) only supports Python 3.10+", +) class TestFastAPIMiddleware: """ Tests FastAPI with provided Middleware. @@ -46,8 +94,14 @@ def test_vanilla_get(self) -> None: # unless told otherwise spans = self.recorder.queued_spans() - assert len(spans) == 1 - assert spans[0].n == "asgi" + # FastAPI >= 0.115.0 emits 4 extra OTel spans per request via the global + # OTel provider that Instana registers: fastapi.dependencies, + # fastapi.endpoint, fastapi.serialization, GET / + assert len(spans) == 5 + span_filter = lambda span: span.n == "asgi" # noqa: E731 + asgi_span = get_first_span_by_filter(spans, span_filter) + assert asgi_span + _assert_fastapi_otel_spans(spans, "/") def test_basic_get(self) -> None: result = None @@ -69,8 +123,10 @@ def test_basic_get(self) -> None: assert result.headers["X-INSTANA-L"] == "1" spans = self.recorder.queued_spans() - # TODO: after support httpx, the expected value will be 3. - assert len(spans) == 2 + # FastAPI >= 0.115.0 adds 4 extra OTel spans per request: + # fastapi.dependencies, fastapi.endpoint, fastapi.serialization, GET / + # TODO: after support httpx, the expected value will be 7. + assert len(spans) == 6 span_filter = ( # noqa: E731 lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" @@ -97,3 +153,4 @@ def test_basic_get(self) -> None: assert asgi_span.data["http"]["host"] == "testserver" assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] + _assert_fastapi_otel_spans(spans, "/") diff --git a/tests/requirements.txt b/tests/requirements.txt index 0df51830..57b47522 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -7,7 +7,8 @@ boto3>=1.17.74 bottle>=0.12.25 celery>=5.2.7 Django>=4.2.16 -fastapi>=0.92.0; python_version < "3.13" +fastapi>=0.92.0,<0.129.0; python_version == "3.9" +fastapi>=0.92.0; python_version >= "3.10" and python_version < "3.13" fastapi>=0.115.0; python_version >= "3.13" flask>=2.3.2 grpcio>=1.14.1