Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/clients/test_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
176 changes: 149 additions & 27 deletions tests/frameworks/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Comment thread
pvital marked this conversation as resolved.
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]:
Expand Down Expand Up @@ -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, "/")
Comment thread
CagriYonca marked this conversation as resolved.

def test_basic_get(self) -> None:
result = None
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -305,6 +403,8 @@ def test_secret_scrubbing(self) -> None:
assert not asgi_span.data["http"]["error"]
assert asgi_span.data["http"]["params"] == "secret=<redacted>"

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,
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading