From 4b3949cd9e5932f76217131fceabc74145c3db24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 29 Sep 2026 19:25:55 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20native=20OpenTelemetry=20supp?= =?UTF-8?q?ort=20(#16403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/advanced/opentelemetry.md | 161 ++++ .../img/tutorial/opentelemetry/image01.png | Bin 0 -> 225326 bytes docs/en/mkdocs.yml | 1 + docs_src/opentelemetry/tutorial001_py310.py | 8 + docs_src/opentelemetry/tutorial002_py310.py | 13 + docs_src/opentelemetry/tutorial003_py310.py | 8 + fastapi/.agents/skills/fastapi/SKILL.md | 11 + fastapi/applications.py | 67 +- fastapi/background.py | 6 + fastapi/routing.py | 140 +++- fastapi/telemetry/__init__.py | 3 + fastapi/telemetry/_api.py | 276 +++++++ fastapi/telemetry/_asgi.py | 446 +++++++++++ fastapi/telemetry/_runtime.py | 239 ++++++ pyproject.toml | 18 + tests/test_telemetry/__init__.py | 0 tests/test_telemetry/_otlp.py | 36 + tests/test_telemetry/_subprocess.py | 43 ++ tests/test_telemetry/conftest.py | 69 ++ tests/test_telemetry/test_background.py | 276 +++++++ tests/test_telemetry/test_data.py | 286 +++++++ .../test_environment_integrations.py | 244 ++++++ tests/test_telemetry/test_exceptions.py | 249 +++++++ tests/test_telemetry/test_http.py | 695 ++++++++++++++++++ tests/test_telemetry/test_integrations.py | 351 +++++++++ .../test_native_integrations.py | 316 ++++++++ tests/test_telemetry/test_operations.py | 93 +++ tests/test_telemetry/test_runtime.py | 613 +++++++++++++++ .../test_semantic_conventions.py | 163 ++++ tests/test_telemetry/test_websockets.py | 292 ++++++++ .../test_opentelemetry/__init__.py | 0 .../test_opentelemetry/conftest.py | 8 + .../test_opentelemetry/test_tutorial001.py | 8 + .../test_opentelemetry/test_tutorial002.py | 28 + .../test_opentelemetry/test_tutorial003.py | 22 + uv.lock | 108 ++- 36 files changed, 5251 insertions(+), 46 deletions(-) create mode 100644 docs/en/docs/advanced/opentelemetry.md create mode 100644 docs/en/docs/img/tutorial/opentelemetry/image01.png create mode 100644 docs_src/opentelemetry/tutorial001_py310.py create mode 100644 docs_src/opentelemetry/tutorial002_py310.py create mode 100644 docs_src/opentelemetry/tutorial003_py310.py create mode 100644 fastapi/telemetry/__init__.py create mode 100644 fastapi/telemetry/_api.py create mode 100644 fastapi/telemetry/_asgi.py create mode 100644 fastapi/telemetry/_runtime.py create mode 100644 tests/test_telemetry/__init__.py create mode 100644 tests/test_telemetry/_otlp.py create mode 100644 tests/test_telemetry/_subprocess.py create mode 100644 tests/test_telemetry/conftest.py create mode 100644 tests/test_telemetry/test_background.py create mode 100644 tests/test_telemetry/test_data.py create mode 100644 tests/test_telemetry/test_environment_integrations.py create mode 100644 tests/test_telemetry/test_exceptions.py create mode 100644 tests/test_telemetry/test_http.py create mode 100644 tests/test_telemetry/test_integrations.py create mode 100644 tests/test_telemetry/test_native_integrations.py create mode 100644 tests/test_telemetry/test_operations.py create mode 100644 tests/test_telemetry/test_runtime.py create mode 100644 tests/test_telemetry/test_semantic_conventions.py create mode 100644 tests/test_telemetry/test_websockets.py create mode 100644 tests/test_tutorial/test_opentelemetry/__init__.py create mode 100644 tests/test_tutorial/test_opentelemetry/conftest.py create mode 100644 tests/test_tutorial/test_opentelemetry/test_tutorial001.py create mode 100644 tests/test_tutorial/test_opentelemetry/test_tutorial002.py create mode 100644 tests/test_tutorial/test_opentelemetry/test_tutorial003.py diff --git a/docs/en/docs/advanced/opentelemetry.md b/docs/en/docs/advanced/opentelemetry.md new file mode 100644 index 0000000000..67310909cc --- /dev/null +++ b/docs/en/docs/advanced/opentelemetry.md @@ -0,0 +1,161 @@ +# OpenTelemetry { #opentelemetry } + +When your API is running, you might want to know how much traffic it receives, which requests are slow, and when errors happen. + +**Telemetry** is data about your application's behavior that helps you answer these questions. Common types include: + +- **Metrics**: measurements you can summarize over time, such as response times and the number of requests being handled. +- **Traces**: records of individual requests and the operations performed to handle them. Each timed operation is called a **span**. +- **Logs**: timestamped records of events, such as an application starting or an operation failing. + +[**OpenTelemetry**](https://opentelemetry.io/) is a set of standards and tools for collecting telemetry and sending it to a monitoring service, where you can explore it in dashboards. + +**FastAPI provides OpenTelemetry support by default** for HTTP request traces, metrics, and logs. WebSocket connections also provide traces and logs. To see that data, configure a monitoring service to receive it. + +## Install FastAPI { #install-fastapi } + +Install FastAPI with the `standard` extras, which include the packages for sending telemetry: + +
+ +```console +$ uv add "fastapi[standard]" +---> 100% +``` + +
+ +## Create the app { #create-the-app } + +Create a file `main.py`: + +{* ../../docs_src/opentelemetry/tutorial001_py310.py *} + +Notice that it all works by default, you don't need to write any custom code for telemetry to work. + +## FastAPI Cloud { #fastapi-cloud } + +When you deploy to [FastAPI Cloud](https://fastapicloud.com) with `fastapi[standard]`, metrics work automatically. You don't have to configure anything else. + +On Pro plans, you can view request counts, error rates, and response times in the [Metrics dashboard](https://fastapicloud.com/docs/monitoring-and-performance/metrics/). + +FastAPI Cloud Pro metrics dashboard with example data + +## Other monitoring services { #other-monitoring-services } + +To send telemetry to another monitoring service, configure an endpoint that accepts **OTLP**, the OpenTelemetry protocol for sending telemetry. Use the service's HTTP/protobuf base endpoint. + +Set these environment variables, replacing the example URL with your endpoint: + +```bash +export OTEL_SERVICE_NAME=my-api +export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com +``` + +`OTEL_SERVICE_NAME` identifies your app in the monitoring service. The endpoint is the base URL for receiving data. Traces are sent to `/v1/traces`, metrics to `/v1/metrics`, and logs to `/v1/logs` under that URL. + +If your service requires authentication, set `OTEL_EXPORTER_OTLP_HEADERS` to the headers it specifies, for example `api-key=YOUR_API_KEY`. + +## Run the app { #run-the-app } + +Start the app in the same terminal: + +
+ +```console +$ uv run fastapi run +``` + +
+ +In another terminal, send a request: + +```console +$ curl http://127.0.0.1:8000/items/1 +{"item_id":1} +``` + +Open your monitoring service and find `my-api`. After the next export, you can see a trace with a `GET /items/{item_id}` span, along with metrics for request counts, response duration, and active requests. + +## Customize telemetry { #customize-telemetry } + +### Configure providers and exporters { #configure-providers-and-exporters } + +A **provider** supplies the objects that record traces, metrics, or logs. Its configuration controls how that data is processed and exported. + +Telemetry libraries can configure OpenTelemetry's global providers. Configure the library before the app starts, and FastAPI uses those providers automatically. + +When an OTLP endpoint is set in the environment, FastAPI adds an exporter for that destination to each enabled provider. Existing exporters continue sending data to their destinations. + +Configure each destination once. If another library already handles the environment destination, disable its environment export or turn off FastAPI's automatic setup: + +```python +app = FastAPI(telemetry={"auto_configure": False}) +``` + +You can also pass a provider directly in the `telemetry` dictionary. For example, this provider uses OpenTelemetry's console exporter to print request spans in your terminal: + +{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *} + +The **exporter** sends the spans to their destination. `BatchSpanProcessor` groups spans and sends them in the background. Replace the console exporter with one supplied by your monitoring library to use its destination. See [OpenTelemetry's Python instrumentation guide](https://opentelemetry.io/docs/languages/python/instrumentation/) for more configuration options. + +Use `meter_provider` or `logger_provider` in the same dictionary to supply a metrics or logs provider. The application or library creating a provider manages its shutdown. FastAPI manages the export components it adds. + +/// warning + +OpenTelemetry uses global providers by default. Independent telemetry configuration for [mounted sub-applications](sub-applications.md) is not guaranteed. + +/// + +### Trace request operations { #trace-request-operations } + +By default, request traces include spans for resolving dependencies, running your path operation function, serializing the response, and running each task in FastAPI's `BackgroundTasks`. These spans use the same provider and exporters. + +Background task spans remain part of the request's trace. They run after the HTTP response span ends, so they do not increase the measured response time. + +To record only the HTTP request span, set `operation_spans` to `False`: + +{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *} + + +### Trace WebSocket connections { #trace-websocket-connections } + +Each WebSocket connection has a span such as `WS /ws/{room}`, covering the handler and dependency cleanup. It uses the same providers and settings, including `operation_spans` for dependency resolution and endpoint execution. + +HTTP request metrics cover HTTP requests only. Normal WebSocket disconnects with codes `1000` or `1001` do not produce error logs. + +### Inspect errors { #inspect-errors } + +FastAPI records unhandled exceptions as OpenTelemetry logs, linked to the request's or connection's trace. Error logs are recorded even when the trace is not sampled. + +Exception logs include the exception's type, message, and stack trace. Messages and stack traces can contain sensitive information. Use your provider's log processors to filter or redact them, or set `logs` to `False` to disable these logs. + +FastAPI also records request validation failures as warning logs with the route and error count. These logs do not include the invalid input. + +## Choose what to record { #choose-what-to-record } + +The `telemetry` dictionary also accepts these settings: + +| Setting | Purpose | Default | +| --- | --- | --- | +| `tracing` | Record HTTP request and WebSocket connection spans | `True` | +| `metrics` | Record HTTP request metrics | `True` | +| `logs` | Record validation failures and unhandled exceptions | `True` | +| `operation_spans` | Add spans for request operations | `True` | +| `exclude` | Skip requests when a function receiving the ASGI scope returns `True` | `None` | +| `auto_configure` | Add exporters for endpoints set in environment variables | `True` | + +For example, to collect metrics while excluding health checks: + +```python +from fastapi import FastAPI + +app = FastAPI( + telemetry={ + "tracing": False, + "exclude": lambda scope: scope["path"] == "/health", + } +) +``` + +Set `auto_configure` to `False` when your application handles provider setup itself, such as inside its lifespan function. diff --git a/docs/en/docs/img/tutorial/opentelemetry/image01.png b/docs/en/docs/img/tutorial/opentelemetry/image01.png new file mode 100644 index 0000000000000000000000000000000000000000..1ce30bea36a13611d25a50e95ba0bdf00541e30c GIT binary patch literal 225326 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

<1AQO{c*vadSkTrKN4?dYB-gp{($S;ppA$Sf` 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

W>xQo%D-*$L2KUs<%9BV%I)uI85TtThh=f3 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 akdgXvJ5*ynW|-bLl8l6+c-gxTpZ^zP_s(ko literal 0 HcmV?d00001 diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 9bcdbd652d..3ec657ac8f 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -159,6 +159,7 @@ nav: - advanced/templates.md - advanced/websockets.md - advanced/events.md + - advanced/opentelemetry.md - advanced/testing-websockets.md - advanced/testing-events.md - advanced/testing-dependencies.md diff --git a/docs_src/opentelemetry/tutorial001_py310.py b/docs_src/opentelemetry/tutorial001_py310.py new file mode 100644 index 0000000000..8272ad70d4 --- /dev/null +++ b/docs_src/opentelemetry/tutorial001_py310.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/items/{item_id}") +async def read_item(item_id: int): + return {"item_id": item_id} diff --git a/docs_src/opentelemetry/tutorial002_py310.py b/docs_src/opentelemetry/tutorial002_py310.py new file mode 100644 index 0000000000..b820cefb28 --- /dev/null +++ b/docs_src/opentelemetry/tutorial002_py310.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter + +tracer_provider = TracerProvider() +tracer_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) + +app = FastAPI(telemetry={"tracer_provider": tracer_provider}) + + +@app.get("/items/{item_id}") +async def read_item(item_id: int): + return {"item_id": item_id} diff --git a/docs_src/opentelemetry/tutorial003_py310.py b/docs_src/opentelemetry/tutorial003_py310.py new file mode 100644 index 0000000000..a5e65016e9 --- /dev/null +++ b/docs_src/opentelemetry/tutorial003_py310.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(telemetry={"operation_spans": False}) + + +@app.get("/items/{item_id}") +async def read_item(item_id: int): + return {"item_id": item_id} diff --git a/fastapi/.agents/skills/fastapi/SKILL.md b/fastapi/.agents/skills/fastapi/SKILL.md index fc35b97ed9..164d645007 100644 --- a/fastapi/.agents/skills/fastapi/SKILL.md +++ b/fastapi/.agents/skills/fastapi/SKILL.md @@ -12,6 +12,7 @@ Official FastAPI skill to write code with best practices, keeping up to date wit * Serve frontend apps: use `app.frontend()` or `router.frontend()` for built frontend assets; see [Serve Frontend Apps](#serve-frontend-apps). * Server-Sent Events (SSE): use `response_class=EventSourceResponse` and `yield`; see [Streaming](#streaming-json-lines-sse-bytes) and [the streaming reference](references/streaming.md). * JSON Lines and byte streaming: see [the streaming reference](references/streaming.md). +* OpenTelemetry: use FastAPI's native traces, metrics, and logs. See [OpenTelemetry](#opentelemetry). * Dependencies: use `Annotated[..., Depends(...)]`; see [Dependency Injection](#dependency-injection) and [the dependency injection reference](references/dependencies.md) for `yield`, scopes, and class dependencies. * Response models: prefer return types; use `response_model` when the public response schema differs from the internal return value; see [the response reference](references/responses.md). * Pydantic models: do not use ellipsis or `RootModel`; see [the Pydantic reference](references/pydantic.md). @@ -203,6 +204,16 @@ app.include_router(router) `app.frontend()` and `router.frontend()` are low-priority routes: regular API routes are matched first, then frontend files and client-side routing fallbacks. Use this for single-page apps and built frontend assets instead of mounting `StaticFiles` manually. +## OpenTelemetry + +Prefer FastAPI's native OpenTelemetry support for request traces, metrics, and logs. + +Install `fastapi[standard]` to include the SDK and HTTP/protobuf exporters. Set `OTEL_SERVICE_NAME` to identify the app and `OTEL_EXPORTER_OTLP_ENDPOINT` to the collector's HTTP/protobuf base URL. Use `OTEL_EXPORTER_OTLP_HEADERS` when authentication is required. + +Use `FastAPI(telemetry={...})` for custom configuration, such as choosing signals or supplying providers. + +See the [OpenTelemetry tutorial](https://fastapi.tiangolo.com/advanced/opentelemetry/) for configuration details. + ## Dependency Injection Use dependencies when the logic can't be declared in Pydantic validation, depends on external resources, needs cleanup with `yield`, or is shared across endpoints. diff --git a/fastapi/applications.py b/fastapi/applications.py index b5fc76d160..689a828f3d 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -21,6 +21,12 @@ from fastapi.openapi.docs import ( ) from fastapi.openapi.utils import get_openapi from fastapi.params import Depends +from fastapi.telemetry import TelemetryConfig +from fastapi.telemetry._asgi import ( + ExceptionTelemetryMiddleware, + NativeTelemetry, + _legacy_otel, +) from fastapi.types import DecoratedCallable, IncEx from fastapi.utils import generate_unique_id from starlette.applications import Starlette @@ -58,6 +64,19 @@ class FastAPI(Starlette): def __init__( self: AppType, *, + telemetry: Annotated[ + TelemetryConfig | None, + Doc( + """ + Native OpenTelemetry configuration as a dictionary. Uses global + providers by default. Omitted options keep their defaults. + + ```python + app = FastAPI(telemetry={"tracing": False}) + ``` + """ + ), + ] = None, debug: Annotated[ bool, Doc( @@ -1011,6 +1030,20 @@ class FastAPI(Starlette): websocket_request_validation_exception_handler, # type: ignore[arg-type] ) # ty: ignore[no-matching-overload] + self._telemetry: TelemetryConfig = { + "tracer_provider": None, + "meter_provider": None, + "logger_provider": None, + "tracing": True, + "metrics": True, + "logs": True, + "operation_spans": True, + "auto_configure": True, + "exclude": None, + **(telemetry if telemetry is not None else {}), + } + self._native_telemetry = NativeTelemetry(self._telemetry) + self.user_middleware: list[Middleware] = ( [] if middleware is None else list(middleware) ) @@ -1031,7 +1064,10 @@ class FastAPI(Starlette): exception_handlers[key] = value middleware = ( - [Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)] + [ + Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug), + Middleware(ExceptionTelemetryMiddleware), + ] + self.user_middleware + [ Middleware( @@ -1160,7 +1196,34 @@ class FastAPI(Starlette): async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if self.root_path: scope["root_path"] = self.root_path - await super().__call__(scope, receive, send) + if scope["type"] == "lifespan": + from fastapi.telemetry._runtime import lifespan + + await lifespan( + config=self._telemetry, + app=super().__call__, + scope=scope, + receive=receive, + send=send, + ) + return + if ( + scope["type"] not in ("http", "websocket") + or "fastapi.telemetry" in scope + or not self._native_telemetry.enabled() + ): + await super().__call__(scope, receive, send) + return + if self.middleware_stack is None: + self.middleware_stack = self.build_middleware_stack() + scope["app"] = self + await self._native_telemetry( + app=super().__call__, + scope=scope, + receive=receive, + send=send, + legacy_otel=_legacy_otel(self.middleware_stack), + ) def add_api_route( self, diff --git a/fastapi/background.py b/fastapi/background.py index 7677058c43..37932a49e2 100644 --- a/fastapi/background.py +++ b/fastapi/background.py @@ -2,6 +2,7 @@ from collections.abc import Callable from typing import Annotated, Any from annotated_doc import Doc +from fastapi.telemetry._api import _operation from starlette.background import BackgroundTasks as StarletteBackgroundTasks from typing_extensions import ParamSpec @@ -59,3 +60,8 @@ class BackgroundTasks(StarletteBackgroundTasks): [FastAPI docs for Background Tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/). """ return super().add_task(func, *args, **kwargs) + + async def __call__(self) -> None: + for task in self.tasks: + with _operation(name="background_task", function=task.func): + await task() diff --git a/fastapi/routing.py b/fastapi/routing.py index dc48a33d63..4ed655aa28 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -81,6 +81,13 @@ from fastapi.sse import ( ServerSentEvent, format_sse_event, ) +from fastapi.telemetry._api import ( + _operation, + _route_selected, + _run_sync_endpoint, + _validation_failed, + get_telemetry_data, +) from fastapi.types import DecoratedCallable, IncEx from fastapi.utils import ( create_model_field, @@ -349,9 +356,12 @@ async def run_endpoint_function( assert dependant.call is not None, "dependant.call must be a function" if is_coroutine: - return await dependant.call(**values) + with _operation(name="endpoint", function=dependant.call): + return await dependant.call(**values) else: - return await run_in_threadpool(dependant.call, **values) + return await run_in_threadpool( + _run_sync_endpoint, function=dependant.call, arguments=values + ) def _build_response_args( @@ -404,6 +414,9 @@ def get_request_handler( actual_strict_content_type = strict_content_type async def app(request: Request) -> Response: + telemetry_data = get_telemetry_data() + if telemetry_data is not None: + telemetry_data.request = request response: Response | None = None file_stack = request.scope.get("fastapi_middleware_astack") assert isinstance(file_stack, AsyncExitStack), ( @@ -462,6 +475,7 @@ def get_request_handler( body=e.doc, endpoint_ctx=endpoint_ctx, ) + _validation_failed(validation_error) raise validation_error from e except HTTPException: # If a middleware raises an HTTPException, it should be raised again @@ -472,20 +486,27 @@ def get_request_handler( ) raise http_error from e + if telemetry_data is not None: + telemetry_data.body = body + # Solve dependencies and run path operation function, auto-closing dependencies errors: list[Any] = [] async_exit_stack = request.scope.get("fastapi_inner_astack") assert isinstance(async_exit_stack, AsyncExitStack), ( "fastapi_inner_astack not found in request scope" ) - solved_result = await solve_dependencies( - request=request, - dependant=dependant, - body=cast(dict[str, Any] | FormData | bytes | None, body), - dependency_overrides_provider=dependency_overrides_provider, - async_exit_stack=async_exit_stack, - embed_body_fields=embed_body_fields, - ) + with _operation(name="dependencies", function=dependant.call): + solved_result = await solve_dependencies( + request=request, + dependant=dependant, + body=cast(dict[str, Any] | FormData | bytes | None, body), + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, + ) + if telemetry_data is not None: + telemetry_data.values = solved_result.values + telemetry_data.errors = solved_result.errors errors = solved_result.errors assert dependant.call # For types if not errors: @@ -724,19 +745,20 @@ def get_request_handler( use_dump_json = response_field is not None and isinstance( response_class, DefaultPlaceholder ) - content = await serialize_response( - field=response_field, - response_content=raw_response, - include=response_model_include, - exclude=response_model_exclude, - by_alias=response_model_by_alias, - exclude_unset=response_model_exclude_unset, - exclude_defaults=response_model_exclude_defaults, - exclude_none=response_model_exclude_none, - is_coroutine=is_coroutine, - endpoint_ctx=endpoint_ctx, - dump_json=use_dump_json, - ) + with _operation(name="serialization", function=dependant.call): + content = await serialize_response( + field=response_field, + response_content=raw_response, + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + exclude_unset=response_model_exclude_unset, + exclude_defaults=response_model_exclude_defaults, + exclude_none=response_model_exclude_none, + is_coroutine=is_coroutine, + endpoint_ctx=endpoint_ctx, + dump_json=use_dump_json, + ) if use_dump_json: response = Response( content=content, @@ -752,6 +774,7 @@ def get_request_handler( validation_error = RequestValidationError( errors, body=body, endpoint_ctx=endpoint_ctx ) + _validation_failed(validation_error) raise validation_error # Return response @@ -767,6 +790,9 @@ def get_websocket_app( embed_body_fields: bool = False, ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: async def app(websocket: WebSocket) -> None: + telemetry_data = get_telemetry_data() + if telemetry_data is not None: + telemetry_data.websocket = websocket endpoint_ctx = ( _extract_endpoint_context(dependant.call) if dependant.call @@ -780,20 +806,27 @@ def get_websocket_app( assert isinstance(async_exit_stack, AsyncExitStack), ( "fastapi_inner_astack not found in request scope" ) - solved_result = await solve_dependencies( - request=websocket, - dependant=dependant, - dependency_overrides_provider=dependency_overrides_provider, - async_exit_stack=async_exit_stack, - embed_body_fields=embed_body_fields, - ) + with _operation(name="dependencies", function=dependant.call): + solved_result = await solve_dependencies( + request=websocket, + dependant=dependant, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, + ) + if telemetry_data is not None: + telemetry_data.values = solved_result.values + telemetry_data.errors = solved_result.errors if solved_result.errors: - raise WebSocketRequestValidationError( + validation_error = WebSocketRequestValidationError( solved_result.errors, endpoint_ctx=endpoint_ctx, ) + _validation_failed(validation_error) + raise validation_error assert dependant.call is not None, "dependant.call must be a function" - await dependant.call(**solved_result.values) + with _operation(name="endpoint", function=dependant.call): + await dependant.call(**solved_result.values) return app @@ -1791,6 +1824,12 @@ class _IncludedRouter(BaseRoute): await route.handle(scope, receive, send) return if effective_context is not None: + _route_selected( + scope=scope, + path=getattr(effective_context.starlette_route, "path_format", None) + or effective_context.path_format, + mount=isinstance(route, routing.Mount), + ) _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = ( effective_context ) @@ -2189,6 +2228,10 @@ class _FrontendRouteGroup(BaseRoute): if match == Match.NONE or route is None: raise HTTPException(status_code=404) _update_scope(scope, child_scope) + _route_selected( + scope=scope, + path=_join_frontend_paths(prefix, route.path).rstrip("/") + "/{path}", + ) if match == Match.FULL and dependant and dependant.dependencies: async with self._solve_dependencies( scope, @@ -2731,6 +2774,11 @@ class APIRouter(routing.Router): match, child_scope = route.matches(scope) if match == Match.FULL: scope.update(child_scope) + _route_selected( + scope=scope, + path=getattr(route, "path_format", None), + mount=isinstance(route, routing.Mount), + ) await route.handle(scope, receive, send) return if match == Match.PARTIAL and partial is None: @@ -2739,6 +2787,11 @@ class APIRouter(routing.Router): if partial is not None: route, child_scope = partial scope.update(child_scope) + _route_selected( + scope=scope, + path=getattr(route, "path_format", None), + mount=isinstance(route, routing.Mount), + ) await route.handle(scope, receive, send) return @@ -2753,6 +2806,23 @@ class APIRouter(routing.Router): for route in self.routes: match, _ = route.matches(redirect_scope) if match != Match.NONE: + if scope.get("fastapi.telemetry") is not None: + telemetry_route: BaseRoute | _EffectiveRouteContext | None = ( + route + ) + while isinstance(telemetry_route, _IncludedRouter): + _, _, matched_route, matched_context = ( + telemetry_route._match(redirect_scope) + ) + telemetry_route = ( + matched_context.starlette_route or matched_context + if matched_context is not None + else matched_route + ) + _route_selected( + scope=scope, + path=getattr(telemetry_route, "path_format", None), + ) redirect_url = URL(scope=redirect_scope) response = RedirectResponse(url=str(redirect_url)) await response(scope, receive, send) @@ -2766,6 +2836,12 @@ class APIRouter(routing.Router): ) = self._match_low_priority(scope) if low_priority_match != Match.NONE and low_priority_route is not None: _update_scope(scope, low_priority_scope) + _route_selected( + scope=scope, + path=getattr( + low_priority_context or low_priority_route, "path_format", None + ), + ) if low_priority_context is not None: _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = ( low_priority_context diff --git a/fastapi/telemetry/__init__.py b/fastapi/telemetry/__init__.py new file mode 100644 index 0000000000..d31e7d1638 --- /dev/null +++ b/fastapi/telemetry/__init__.py @@ -0,0 +1,3 @@ +from ._api import TelemetryConfig as TelemetryConfig +from ._api import TelemetryData as TelemetryData +from ._api import get_telemetry_data as get_telemetry_data diff --git a/fastapi/telemetry/_api.py b/fastapi/telemetry/_api.py new file mode 100644 index 0000000000..d9b581e285 --- /dev/null +++ b/fastapi/telemetry/_api.py @@ -0,0 +1,276 @@ +from collections.abc import Callable, Iterator, MutableMapping, Sequence +from contextlib import AbstractContextManager, contextmanager, nullcontext +from dataclasses import dataclass, field +from time import time_ns +from typing import Annotated, Any + +from annotated_doc import Doc +from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from opentelemetry import context as otel_context +from opentelemetry._logs import Logger, LoggerProvider, SeverityNumber +from opentelemetry.context import Context +from opentelemetry.metrics import MeterProvider +from opentelemetry.trace import Span, StatusCode, Tracer, TracerProvider +from starlette.exceptions import HTTPException, WebSocketException +from starlette.requests import Request +from starlette.websockets import WebSocket, WebSocketDisconnect +from typing_extensions import TypedDict + +_HTTP_METHODS = frozenset( + { + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "QUERY", + "TRACE", + } +) + + +class TelemetryConfig(TypedDict, total=False): + """Optional settings for `FastAPI(telemetry={...})`. + + Omitted settings keep their defaults. FastAPI preserves existing providers + and their exporters. Environment setup can add exporters unless `auto_configure` + is `False`. Providers supplied by the application are never shut down by FastAPI. + """ + + tracer_provider: Annotated[ + TracerProvider | None, + Doc("Use this provider instead of the global tracer provider."), + ] + meter_provider: Annotated[ + MeterProvider | None, + Doc("Use this provider instead of the global meter provider."), + ] + logger_provider: Annotated[ + LoggerProvider | None, + Doc("Use this provider instead of the global logger provider."), + ] + tracing: Annotated[ + bool, + Doc("Enable HTTP request and WebSocket connection spans. Defaults to `True`."), + ] + metrics: Annotated[bool, Doc("Enable HTTP request metrics. Defaults to `True`.")] + logs: Annotated[ + bool, + Doc( + "Record validation failures and unhandled exceptions, including exception messages and stack traces. Defaults to `True`." + ), + ] + operation_spans: Annotated[ + bool, + Doc( + "Trace dependency resolution, endpoints, serialization, and background tasks. Defaults to `True`." + ), + ] + auto_configure: Annotated[ + bool, + Doc("Add OTLP exporters from environment variables. Defaults to `True`."), + ] + exclude: Annotated[ + Callable[[MutableMapping[str, Any]], bool] | None, + Doc( + "A function that receives the ASGI scope. Return `True` to skip FastAPI telemetry for that request or connection." + ), + ] + + +@dataclass(kw_only=True) +class TelemetryData: + """Local request or connection data for synchronous OpenTelemetry processors. + + FastAPI populates these fields as it handles the request or connection. + Integrations should treat the data as read-only and apply their capture and + redaction settings before exporting any of it. + """ + + request: Annotated[ + Request | None, Doc("The request, available when its route handler starts.") + ] = None + websocket: Annotated[ + WebSocket | None, + Doc("The WebSocket, available when its route handler starts."), + ] = None + body: Annotated[ + Any, Doc("The body read by FastAPI, before parameter validation.") + ] = None + values: Annotated[ + dict[str, Any] | None, + Doc("The original parsed arguments, available after dependency resolution."), + ] = None + errors: Annotated[ + Sequence[Any] | None, + Doc("Request validation errors, including the original input values."), + ] = None + + +@dataclass(kw_only=True) +class _RequestTelemetry: + span: Span | None + span_method: str + tracer: Tracer | None = None + logger: Logger | None = None + route: str | None = None + status_code: int | None = None + data: TelemetryData | None = field(default_factory=TelemetryData) + _mount_prefix: str = "" + _exceptions: list[BaseException] = field(default_factory=list, repr=False) + + +_REQUEST_TELEMETRY_KEY = otel_context.create_key("fastapi.request") +_NO_OPERATION = nullcontext() + + +def _get_request_telemetry(context: Context | None = None) -> _RequestTelemetry | None: + value = otel_context.get_value(_REQUEST_TELEMETRY_KEY, context) + return value if isinstance(value, _RequestTelemetry) else None + + +def get_telemetry_data(context: Context | None = None) -> TelemetryData | None: + """Read local FastAPI data from an OpenTelemetry context. + + Defaults to the current context. Log processors can pass `record.context`. + Read the data synchronously while FastAPI handles the request or connection. + After it finishes, this returns `None`, including for retained contexts. + This data is not automatically added to exported spans or logs. + """ + request_telemetry = _get_request_telemetry(context) + return request_telemetry.data if request_telemetry is not None else None + + +def _validation_failed( + exc: RequestValidationError | WebSocketRequestValidationError, +) -> None: + request_telemetry = _get_request_telemetry() + if request_telemetry is None: + return + if request_telemetry.data is not None: + if isinstance(exc, RequestValidationError): + request_telemetry.data.body = exc.body + request_telemetry.data.errors = exc.errors() + if request_telemetry.logger is not None: + attributes: dict[str, Any] = { + "fastapi.validation.error_count": len(exc.errors()) + } + if request_telemetry.route is not None: + attributes["http.route"] = request_telemetry.route + request_telemetry.logger.emit( + event_name="fastapi.validation.failed", + timestamp=time_ns(), + severity_number=SeverityNumber.WARN, + severity_text="WARN", + body="Request validation failed", + attributes=attributes, + ) + + +def _exception_type(exc: BaseException) -> str: + cls = type(exc) + return ( + f"{cls.__module__}.{cls.__qualname__}" + if cls.__module__ != "builtins" + else cls.__qualname__ + ) + + +def _normal_websocket_disconnect(exc: BaseException) -> bool: + return isinstance(exc, WebSocketDisconnect) and exc.code in (1000, 1001) + + +def _operation( + *, name: str, function: Callable[..., Any] | None = None +) -> AbstractContextManager[None]: + request_telemetry = _get_request_telemetry() + if request_telemetry is None or request_telemetry.tracer is None: + return _NO_OPERATION + return _traced_operation( + request_telemetry=request_telemetry, name=name, function=function + ) + + +@contextmanager +def _traced_operation( + *, + request_telemetry: _RequestTelemetry, + name: str, + function: Callable[..., Any] | None, +) -> Iterator[None]: + assert request_telemetry.tracer is not None + attributes = {} + if function is not None: + module = getattr(function, "__module__", type(function).__module__) + qualname = getattr(function, "__qualname__", type(function).__qualname__) + attributes["code.function.name"] = f"{module}.{qualname}" + with request_telemetry.tracer.start_as_current_span( + f"fastapi.{name}", + attributes=attributes, + record_exception=False, + set_status_on_exception=False, + ) as span: + try: + yield + except Exception as exc: + if name == "background_task" or ( + not isinstance( + exc, + ( + HTTPException, + RequestValidationError, + WebSocketException, + WebSocketRequestValidationError, + ), + ) + and not _normal_websocket_disconnect(exc) + ): + span.set_attribute("error.type", _exception_type(exc)) + span.set_status(StatusCode.ERROR) + raise + + +def _run_sync_endpoint( + *, function: Callable[..., Any], arguments: dict[str, Any] +) -> Any: + with _operation(name="endpoint", function=function): + return function(**arguments) + + +def _route_selected( + *, scope: MutableMapping[str, Any], path: str | None, mount: bool = False +) -> None: + request_telemetry = scope.get("fastapi.telemetry") + if not isinstance(request_telemetry, _RequestTelemetry) or path is None: + return + # Mount's path_format ends in /{path}. The child contributes its own path. + if mount: + path = path.removesuffix("/{path}") + request_telemetry._mount_prefix += path + request_telemetry.route = request_telemetry._mount_prefix + "/{path}" + else: + request_telemetry.route = request_telemetry._mount_prefix + path + if request_telemetry.span is not None: + request_telemetry.span.set_attribute("http.route", request_telemetry.route) + request_telemetry.span.update_name( + f"{request_telemetry.span_method} {request_telemetry.route}" + ) + + +_DEFERRED_PROVIDERS = frozenset( + { + ("opentelemetry.trace", "ProxyTracerProvider"), + ("opentelemetry.metrics._internal", "_ProxyMeterProvider"), + ("opentelemetry._logs._internal", "ProxyLoggerProvider"), + } +) + + +def _unconfigured(provider: Any) -> bool: + # Python's API has no public "is configured" query. Limit this bridge to its + # known deferred providers, never SDK implementations or vendor proxies. + cls = type(provider) + return (cls.__module__, cls.__name__) in _DEFERRED_PROVIDERS diff --git a/fastapi/telemetry/_asgi.py b/fastapi/telemetry/_asgi.py new file mode 100644 index 0000000000..758e3e3e61 --- /dev/null +++ b/fastapi/telemetry/_asgi.py @@ -0,0 +1,446 @@ +import os +from contextlib import nullcontext +from time import perf_counter, time_ns +from typing import Any +from urllib.parse import parse_qsl, urlencode, urlsplit + +from fastapi import __version__ +from fastapi.telemetry._api import ( + _HTTP_METHODS, + _REQUEST_TELEMETRY_KEY, + TelemetryConfig, + _exception_type, + _normal_websocket_disconnect, + _RequestTelemetry, + _unconfigured, +) +from opentelemetry import _logs, metrics, propagate, trace +from opentelemetry import context as otel_context +from opentelemetry._logs import SeverityNumber +from opentelemetry.metrics import Histogram, UpDownCounter +from opentelemetry.propagators.textmap import Getter +from opentelemetry.trace import SpanKind, StatusCode +from starlette.datastructures import Headers +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +_DURATION_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, +) +_SCHEMA_URL = "https://opentelemetry.io/schemas/1.44.0" +# https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server-span +_SENSITIVE_QUERY_PARAMETERS = frozenset( + { + "X-Amz-Signature", + "X-Amz-Credential", + "X-Amz-Security-Token", + "sig", + "X-Goog-Signature", + } +) + + +class _HeadersGetter(Getter[Headers]): + def get(self, carrier: Headers, key: str) -> list[str] | None: + values = carrier.getlist(key) + if not values: + return None + if key.lower() == "baggage": + # The baggage propagator reads only the first value. + return [",".join(values)] + return values + + def keys(self, carrier: Headers) -> list[str]: + return carrier.keys() + + +_HEADERS_GETTER = _HeadersGetter() + + +def _legacy_otel(stack: Any) -> bool: + # Test the built stack, not contrib's flag: that flag also exists when its + # stack patch failed, or when instrument_app was called after stack creation. + seen: set[int] = set() + while stack is not None and id(stack) not in seen: + seen.add(id(stack)) + if ( + type(stack).__module__ == "opentelemetry.instrumentation.asgi" + and type(stack).__name__ == "OpenTelemetryMiddleware" + ): + return True + stack = getattr(stack, "app", None) + return False + + +def _exception(*, scope: Scope, exc: BaseException) -> None: + if scope["type"] == "websocket" and _normal_websocket_disconnect(exc): + return + request_telemetry = scope.get("fastapi.telemetry") + if isinstance(request_telemetry, _RequestTelemetry): + seen = request_telemetry._exceptions + if not any(previous is exc for previous in seen): + seen.append(exc) + if request_telemetry.logger is not None and isinstance(exc, Exception): + attributes = ( + {"http.route": request_telemetry.route} + if request_telemetry.route + else None + ) + request_telemetry.logger.emit( + exception=exc, + event_name="fastapi.websocket.exception" + if scope["type"] == "websocket" + else "http.server.request.exception", + timestamp=time_ns(), + severity_number=SeverityNumber.ERROR, + severity_text="ERROR", + body="Unhandled exception in FastAPI WebSocket connection" + if scope["type"] == "websocket" + else "Unhandled exception in FastAPI request", + attributes=attributes, + ) + + +def _server_attributes(scope: Scope) -> dict[str, Any]: + # ASGI exposes HTTP/2 and HTTP/3 authority as Host. Proxy normalization is + # handled by the server or middleware, as it is for the request URL. + authority = Headers(scope=scope).get("host") + if authority is not None: + try: + url = urlsplit("//" + authority) + address, port = url.hostname, url.port + except ValueError: + # Malformed authority must not prevent handling the request. + return {} + if url.username is not None or url.path or url.query or url.fragment: + return {} + if port is None: + port = {"http": 80, "https": 443, "ws": 80, "wss": 443}.get( + scope.get("scheme", "http") + ) + else: + address, port = scope.get("server") or (None, None) + if address is None: + return {} + attributes: dict[str, Any] = {"server.address": address} + if port is not None: + attributes["server.port"] = port + return attributes + + +class ExceptionTelemetryMiddleware: + """Observe exceptions before error handlers send and complete a response.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + try: + await self.app(scope, receive, send) + except Exception as exc: + _exception(scope=scope, exc=exc) + raise + + +class NativeTelemetry: + def __init__(self, config: TelemetryConfig) -> None: + self.config = config + known_methods = os.getenv("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS", "").strip() + self._known_methods = ( + frozenset( + method.strip() for method in known_methods.split(",") if method.strip() + ) + if known_methods + else _HTTP_METHODS + ) + self._provider: metrics.MeterProvider | None = None + self._duration: Histogram | None = None + self._active: UpDownCounter | None = None + + def enabled(self) -> bool: + config = self.config + return bool( + ( + config["tracing"] + and ( + config["tracer_provider"] is not None + or not _unconfigured(trace.get_tracer_provider()) + ) + ) + or ( + config["metrics"] + and ( + config["meter_provider"] is not None + or not _unconfigured(metrics.get_meter_provider()) + ) + ) + or ( + config["logs"] + and ( + config["logger_provider"] is not None + or not _unconfigured(_logs.get_logger_provider()) + ) + ) + ) + + def _instruments(self) -> tuple[Histogram, UpDownCounter]: + provider = self.config["meter_provider"] or metrics.get_meter_provider() + if provider is not self._provider: + meter = provider.get_meter("fastapi", __version__, schema_url=_SCHEMA_URL) + self._duration = meter.create_histogram( + "http.server.request.duration", + unit="s", + description="Duration of HTTP server requests.", + explicit_bucket_boundaries_advisory=_DURATION_BUCKETS, + ) + self._active = meter.create_up_down_counter( + "http.server.active_requests", + unit="{request}", + description="Number of active HTTP server requests.", + ) + self._provider = provider + assert self._duration is not None and self._active is not None + return self._duration, self._active + + async def __call__( + self, + *, + app: ASGIApp, + scope: Scope, + receive: Receive, + send: Send, + legacy_otel: bool = False, + ) -> None: + config = self.config + if config["exclude"] is not None and config["exclude"](scope): + # Keep mounted FastAPI apps from observing this excluded request. + scope["fastapi.telemetry"] = None + try: + await app(scope, receive, send) + finally: + scope.pop("fastapi.telemetry", None) + return + tracing = config["tracing"] and not legacy_otel + logging = config["logs"] and not legacy_otel + if logging: + logger_provider = config["logger_provider"] or _logs.get_logger_provider() + logging = not ( + (config["logger_provider"] is None and _unconfigured(logger_provider)) + or isinstance(logger_provider, _logs.NoOpLoggerProvider) + ) + is_websocket = scope["type"] == "websocket" + metering = config["metrics"] and not legacy_otel and not is_websocket + if tracing: + provider = config["tracer_provider"] or trace.get_tracer_provider() + tracing = not ( + (config["tracer_provider"] is None and _unconfigured(provider)) + or isinstance(provider, trace.NoOpTracerProvider) + ) + if metering: + meter_provider = config["meter_provider"] or metrics.get_meter_provider() + metering = not ( + (config["meter_provider"] is None and _unconfigured(meter_provider)) + or isinstance(meter_provider, metrics.NoOpMeterProvider) + ) + if not tracing and not metering and not logging: + await app(scope, receive, send) + return + original_method = scope.get("method", "") + method = original_method if original_method in self._known_methods else "_OTHER" + span_method = "WS" if is_websocket else "HTTP" if method == "_OTHER" else method + attributes: dict[str, Any] = { + "url.scheme": scope.get("scheme", "ws" if is_websocket else "http"), + } + if is_websocket: + attributes["network.protocol.name"] = "websocket" + else: + attributes["http.request.method"] = method + if scope.get("http_version"): + attributes["network.protocol.version"] = scope["http_version"] + duration, active = self._instruments() if metering else (None, None) + active_attributes = { + k: v for k, v in attributes.items() if k != "network.protocol.version" + } + if active is not None: + active.add(1, active_attributes) + started = perf_counter() + span = None + tracer = None + parent_token = None + if tracing: + parent = propagate.extract(Headers(scope=scope), getter=_HEADERS_GETTER) + parent_token = otel_context.attach(parent) + tracer = trace.get_tracer( + "fastapi", + __version__, + config["tracer_provider"], + schema_url=_SCHEMA_URL, + ) + span_attributes = {**attributes, **_server_attributes(scope)} + if not is_websocket: + span_attributes["url.path"] = scope["path"] + if scope.get("query_string"): + span_attributes["url.query"] = urlencode( + [ + ( + key, + "REDACTED" + if key in _SENSITIVE_QUERY_PARAMETERS + else value, + ) + for key, value in parse_qsl( + scope["query_string"].decode("latin-1"), + keep_blank_values=True, + ) + ] + ) + if original_method != method: + span_attributes["http.request.method_original"] = original_method + span = tracer.start_span( + span_method, + context=parent, + kind=SpanKind.SERVER, + attributes=span_attributes, + ) + request_telemetry = _RequestTelemetry( + span=span, + span_method=span_method, + tracer=tracer if config["operation_spans"] else None, + logger=_logs.get_logger( + "fastapi", + __version__, + config["logger_provider"], + schema_url=_SCHEMA_URL, + ) + if logging + else None, + _mount_prefix=scope.get("app_root_path", scope.get("root_path", "")).rstrip( + "/" + ), + ) + scope["fastapi.telemetry"] = request_telemetry + token = otel_context.attach( + otel_context.set_value(_REQUEST_TELEMETRY_KEY, request_telemetry) + ) + finished = False + trailers = False + disconnected = False + + def finish(error: BaseException | None = None) -> None: + nonlocal finished + if finished: + return + finished = True + if request_telemetry.route is not None: + attributes["http.route"] = request_telemetry.route + if request_telemetry.status_code is not None: + attributes["http.response.status_code"] = request_telemetry.status_code + failed = ( + error is not None + or not is_websocket + and ( + request_telemetry.status_code is None + or request_telemetry.status_code >= 500 + ) + ) + if failed: + errors = request_telemetry._exceptions + failure = error or (errors[-1] if errors else None) + attributes["error.type"] = ( + _exception_type(failure) + if failure + else str(request_telemetry.status_code or "incomplete_response") + ) + if span is not None: + span.set_attributes(attributes) + if failed: + span.set_status(StatusCode.ERROR) + # Record while the span is current, so exemplars can correlate it. + if duration is not None: + duration.record(max(0, perf_counter() - started), attributes) + if active is not None: + active.add(-1, active_attributes) + if span is not None: + span.end() + + async def wrapped_send(message: Message) -> None: + nonlocal trailers + if message["type"] == "http.response.start": + request_telemetry.status_code = message["status"] + trailers = message.get("trailers", False) + await send(message) + if ( + ( + message["type"] == "http.response.body" + and not message.get("more_body", False) + and not trailers + ) + or ( + message["type"] == "http.response.trailers" + and not message.get("more_trailers", False) + ) + or message["type"] == "http.response.pathsend" + ): + finish() + + async def wrapped_receive() -> Message: + nonlocal disconnected + message = await receive() + if message["type"] == "http.disconnect": + disconnected = True + return message + + context = ( + trace.use_span( + span, + end_on_exit=False, + record_exception=False, + set_status_on_exception=False, + ) + if span is not None + else nullcontext() + ) + try: + with context: + try: + await app( + scope, + receive if is_websocket else wrapped_receive, + send if is_websocket else wrapped_send, + ) + except BaseException as exc: + _exception(scope=scope, exc=exc) + finish( + None + if is_websocket and _normal_websocket_disconnect(exc) + else exc + ) + raise + finally: + if not finished: + finish( + None + if is_websocket + else ConnectionError("Client disconnected") + if disconnected + else RuntimeError("Incomplete ASGI response") + ) + finally: + request_telemetry.data = None + request_telemetry._exceptions.clear() + otel_context.detach(token) + scope.pop("fastapi.telemetry", None) + if parent_token is not None: + otel_context.detach(parent_token) diff --git a/fastapi/telemetry/_runtime.py b/fastapi/telemetry/_runtime.py new file mode 100644 index 0000000000..641aea43c0 --- /dev/null +++ b/fastapi/telemetry/_runtime.py @@ -0,0 +1,239 @@ +import atexit +import os +import threading +from collections.abc import Callable +from typing import Any, cast +from urllib.parse import urlsplit + +from anyio.to_thread import run_sync +from fastapi.exceptions import FastAPIError +from fastapi.logger import logger +from fastapi.telemetry._api import TelemetryConfig, _unconfigured +from opentelemetry import _logs, metrics, trace +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +_lock = threading.RLock() +_owned: list[Any] = [] +_configured: list[tuple[str, Any]] = [] + + +def _flush() -> None: + for component in tuple(_owned): + try: + component.force_flush() + except Exception: + logger.exception("FastAPI telemetry cleanup failed") + + +def _shutdown() -> None: + with _lock: + owned = tuple(_owned) + _owned.clear() + for component in owned: + try: + component.shutdown() + except Exception: + logger.exception("FastAPI telemetry cleanup failed") + + +atexit.register(_shutdown) + + +def _export_endpoint(signal: str) -> str | None: + exporter = (os.getenv(f"OTEL_{signal}_EXPORTER") or "otlp").strip().lower() + endpoint = os.getenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT") + if not endpoint: + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + if endpoint: + endpoint = f"{endpoint.removesuffix('/')}/v1/{signal.lower()}" + if not endpoint or exporter == "none": + return None + if exporter != "otlp": + raise FastAPIError( + f"FastAPI automatic telemetry supports OTEL_{signal}_EXPORTER=otlp or none. " + "Configure other exporters explicitly and pass telemetry={'auto_configure': False} to FastAPI()." + ) + protocol = ( + os.getenv(f"OTEL_EXPORTER_OTLP_{signal}_PROTOCOL") + or os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL") + or "http/protobuf" + ) + if protocol != "http/protobuf": + raise FastAPIError( + "FastAPI automatic telemetry requires the OTLP http/protobuf protocol. Configure other transports explicitly and pass telemetry={'auto_configure': False} to FastAPI()." + ) + parsed = urlsplit(endpoint) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise FastAPIError( + f"Invalid OTLP {signal.lower()} endpoint. Use an absolute HTTP or HTTPS URL." + ) + return endpoint + + +def _registration_provider(provider: Any) -> Any: + # Older Logfire meter wrappers do not expose add_metric_reader(). Attach + # the reader to their SDK provider. Measurements still use the original wrapper. + if ( + type(provider).__module__ == "logfire._internal.metrics" + and type(provider).__name__ == "ProxyMeterProvider" + and not hasattr(provider, "add_metric_reader") + ): + return provider.provider + return provider + + +def _configure_from_environment(config: TelemetryConfig) -> None: + """Add OTLP exporters from the environment to the selected providers. + + Called automatically before ASGI lifespan startup. Existing providers and + their exporters are preserved. FastAPI registers its own export components once + per provider. It does not inspect or deduplicate other components' exporters. + Pass `telemetry={"auto_configure": False}` to `FastAPI()` when another component + manages environment export. + """ + if ( + not config["auto_configure"] + or os.getenv("OTEL_SDK_DISABLED", "").lower() == "true" + ): + return + with _lock: + signals = [ + ("TRACES", config["tracing"], config["tracer_provider"]), + ("METRICS", config["metrics"], config["meter_provider"]), + ("LOGS", config["logs"], config["logger_provider"]), + ] + requested = [ + (signal, provider, endpoint) + for signal, enabled, provider in signals + if enabled and (endpoint := _export_endpoint(signal)) is not None + ] + if not requested: + return + try: + from opentelemetry.exporter.otlp.proto.http._log_exporter import ( + OTLPLogExporter, + ) + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter, + ) + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, + ) + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError as exc: + raise FastAPIError( + "Automatic OpenTelemetry export requires fastapi[opentelemetry] or fastapi[standard]. Install the extra, configure providers yourself, or pass telemetry={'auto_configure': False} to FastAPI()." + ) from exc + # The SDK's registration methods also allow vendor wrappers to forward + # additions. Keep a record of our own registrations, without examining + # another component's exporters or their destinations. + providers = { + "TRACES": ("tracer", trace.get_tracer_provider), + "METRICS": ("meter", metrics.get_meter_provider), + "LOGS": ("logger", _logs.get_logger_provider), + } + + def create_component(*, signal: str, endpoint: str) -> Any: + if signal == "TRACES": + return BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + if signal == "METRICS": + return PeriodicExportingMetricReader( + OTLPMetricExporter(endpoint=endpoint) + ) + return BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint)) + + for signal, explicit, endpoint in requested: + name, get_provider = providers[signal] + provider = _registration_provider( + explicit if explicit is not None else get_provider() + ) + if any(s == signal and p is provider for s, p in _configured): + continue + if explicit is None and _unconfigured(provider): + component = create_component(signal=signal, endpoint=endpoint) + if signal == "TRACES": + provider = TracerProvider(shutdown_on_exit=False) + provider.add_span_processor(component) + trace.set_tracer_provider(provider) + elif signal == "METRICS": + provider = MeterProvider( + metric_readers=[component], shutdown_on_exit=False + ) + metrics.set_meter_provider(provider) + else: + provider = LoggerProvider(shutdown_on_exit=False) + provider.add_log_record_processor(component) + _logs.set_logger_provider(provider) + current = get_provider() + if current is provider: + _configured.append((signal, provider)) + _owned.append(provider) + continue + # Another component configured the global provider concurrently. + # Close the unused pipeline and attach a new component to theirs. + provider.shutdown() + provider = _registration_provider(current) + # Vendor wrappers can expose the SDK registration methods without + # inheriting its provider classes. Check each method against its SDK + # class statically and its availability on the provider at runtime. + register: Callable[[Any], None] | None + try: + if signal == "TRACES": + register = cast(TracerProvider, provider).add_span_processor + elif signal == "METRICS": + register = cast(MeterProvider, provider).add_metric_reader + else: + register = cast(LoggerProvider, provider).add_log_record_processor + except AttributeError: + register = None + if not callable(register): + raise FastAPIError( + f"The configured OpenTelemetry {name} provider does not support " + "adding an OTLP exporter. Configure environment export through " + "that provider and pass telemetry={'auto_configure': False} to FastAPI()." + ) + component = create_component(signal=signal, endpoint=endpoint) + try: + register(component) + except Exception: + component.shutdown() + raise + _configured.append((signal, provider)) + _owned.append(component) + + +async def lifespan( + *, config: TelemetryConfig, app: ASGIApp, scope: Scope, receive: Receive, send: Send +) -> None: + # Consume startup only inside the wrapper, and preserve ASGI failure events. + # Initialization exceptions before app(scope) would otherwise look like a + # server's unsupported-lifespan fallback and silently disable telemetry. + async def wrapped_receive() -> Message: + message = await receive() + if message["type"] == "lifespan.startup": + try: + _configure_from_environment(config) + except Exception as exc: + await send({"type": "lifespan.startup.failed", "message": str(exc)}) + raise + return message + + async def wrapped_send(message: Message) -> None: + if ( + message["type"] + in { + "lifespan.shutdown.complete", + "lifespan.shutdown.failed", + "lifespan.startup.failed", + } + and _owned + ): + await run_sync(_flush) + await send(message) + + await app(scope, wrapped_receive, wrapped_send) diff --git a/pyproject.toml b/pyproject.toml index 43cde965c0..75d9d02675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "typing-extensions>=4.8.0", "typing-inspection>=0.4.2", "annotated-doc>=0.0.2", + "opentelemetry-api>=1.44.0", ] [project.urls] @@ -57,7 +58,13 @@ Issues = "https://github.com/fastapi/fastapi/issues" Changelog = "https://fastapi.tiangolo.com/release-notes/" [project.optional-dependencies] +opentelemetry = [ + "opentelemetry-sdk>=1.44.0", + "opentelemetry-exporter-otlp-proto-http>=1.44.0", +] standard = [ + "opentelemetry-sdk>=1.44.0", + "opentelemetry-exporter-otlp-proto-http>=1.44.0", "fastapi-cli[standard] >=0.0.32", "fastar >= 0.9.0", # For the test client @@ -77,6 +84,8 @@ standard = [ ] standard-no-fastapi-cloud-cli = [ + "opentelemetry-sdk>=1.44.0", + "opentelemetry-exporter-otlp-proto-http>=1.44.0", "fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.32", # For the test client "httpx >=0.23.0,<1.0.0", @@ -95,6 +104,8 @@ standard-no-fastapi-cloud-cli = [ ] all = [ + "opentelemetry-sdk>=1.44.0", + "opentelemetry-exporter-otlp-proto-http>=1.44.0", "fastapi-cli[standard] >=0.0.32", # # For the test client "httpx >=0.23.0,<1.0.0", @@ -158,6 +169,12 @@ github-actions = [ "smokeshow >=0.5.0", ] tests = [ + "opentelemetry-instrumentation-fastapi>=0.65b0", + "logfire>=5.1.0", + "sentry-sdk>=2.70.0", + "opentelemetry-sdk>=1.44.0", + "opentelemetry-exporter-otlp-proto-http>=1.44.0", + { include-group = "docs-tests" }, "anyio[trio] >=3.2.1,<5.0.0", "coverage[toml] >=7.13,<8.0", @@ -233,6 +250,7 @@ filterwarnings = [ timeout = "20" [tool.coverage.run] +patch = ["subprocess", "_exit"] parallel = true data_file = "coverage/.coverage" source = [ diff --git a/tests/test_telemetry/__init__.py b/tests/test_telemetry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_telemetry/_otlp.py b/tests/test_telemetry/_otlp.py new file mode 100644 index 0000000000..a537068a74 --- /dev/null +++ b/tests/test_telemetry/_otlp.py @@ -0,0 +1,36 @@ +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from socketserver import TCPServer + + +class _CollectorServer(ThreadingHTTPServer): + def server_bind(self): + TCPServer.server_bind(self) + self.server_name = str(self.server_address[0]) + self.server_port = self.server_address[1] + + +@contextmanager +def otlp_collector(): + received = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + received.append((self.path, body, self.headers)) + self.send_response(200) + self.send_header("Content-Type", "application/x-protobuf") + self.end_headers() + + def log_message(self, format, *args): + pass + + with _CollectorServer(("127.0.0.1", 0), Handler) as server: + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + yield f"http://127.0.0.1:{server.server_port}", received + finally: + server.shutdown() + worker.join() diff --git a/tests/test_telemetry/_subprocess.py b/tests/test_telemetry/_subprocess.py new file mode 100644 index 0000000000..59675d5748 --- /dev/null +++ b/tests/test_telemetry/_subprocess.py @@ -0,0 +1,43 @@ +import json +import os +import subprocess +import sys +from functools import wraps +from importlib import import_module +from inspect import unwrap + + +def run_in_subprocess(function): + """Run a test in a fresh interpreter to isolate SDK globals and monkeypatches.""" + + @wraps(function) + def wrapper(**kwargs): + env = { + name: value + for name, value in os.environ.items() + if not name.startswith(("OTEL_", "LOGFIRE_", "SENTRY_")) + } + env["PYDANTIC_DISABLE_PLUGINS"] = "__all__" + result = subprocess.run( + [ + sys.executable, + "-m", + "tests.test_telemetry._subprocess", + function.__module__, + function.__name__, + json.dumps(kwargs), + ], + env=env, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stdout + result.stderr + + return wrapper + + +if __name__ == "__main__": + module_name, function_name, arguments = sys.argv[1:] + function = getattr(import_module(module_name), function_name) + unwrap(function)(**json.loads(arguments)) diff --git a/tests/test_telemetry/conftest.py b/tests/test_telemetry/conftest.py new file mode 100644 index 0000000000..f08ae0f273 --- /dev/null +++ b/tests/test_telemetry/conftest.py @@ -0,0 +1,69 @@ +import os + +import pytest +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +@pytest.fixture(autouse=True) +def clean_environment(monkeypatch): + remove_export_environment(monkeypatch) + + +def remove_export_environment(monkeypatch): + for name in os.environ: + if name.startswith(("OTEL_", "LOGFIRE_", "SENTRY_")): + monkeypatch.delenv(name) + + +@pytest.fixture +def telemetry(): + exporter = InMemorySpanExporter() + tracer = TracerProvider(shutdown_on_exit=False) + tracer.add_span_processor(SimpleSpanProcessor(exporter)) + reader = InMemoryMetricReader() + meter = MeterProvider(metric_readers=[reader], shutdown_on_exit=False) + yield {"tracer_provider": tracer, "meter_provider": meter}, exporter, reader + tracer.shutdown() + meter.shutdown() + + +@pytest.fixture +def logs(telemetry): + config, _, _ = telemetry + exporter = InMemoryLogRecordExporter() + provider = LoggerProvider(shutdown_on_exit=False) + provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) + config["logger_provider"] = provider + yield exporter + provider.shutdown() + + +def metric_points(*, reader, name="http.server.request.duration"): + data = reader.get_metrics_data() + if data is None: + return [] + return [ + point + for resource in data.resource_metrics + for scope in resource.scope_metrics + for metric in scope.metrics + if metric.name == name + for point in metric.data.data_points + ] + + +def server_spans(exporter): + from opentelemetry.trace import SpanKind + + return [ + span for span in exporter.get_finished_spans() if span.kind == SpanKind.SERVER + ] diff --git a/tests/test_telemetry/test_background.py b/tests/test_telemetry/test_background.py new file mode 100644 index 0000000000..99ff21d358 --- /dev/null +++ b/tests/test_telemetry/test_background.py @@ -0,0 +1,276 @@ +import threading +from functools import partial + +import anyio +import pytest +from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException +from fastapi.responses import JSONResponse +from fastapi.telemetry import get_telemetry_data +from fastapi.testclient import TestClient +from opentelemetry import trace +from opentelemetry.sdk.trace import Span +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF +from opentelemetry.trace import SpanKind, StatusCode +from starlette.background import BackgroundTask + +from ._subprocess import run_in_subprocess +from .conftest import metric_points, server_spans + + +@pytest.mark.parametrize("backend", ["asyncio", "trio"]) +@pytest.mark.parametrize("sync", [False, True]) +def test_background_task_spans(telemetry, backend, sync): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + executed = [] + endpoint_thread = [] + + def execute(number, *, value): + span = trace.get_current_span() + assert isinstance(span, Span) + assert span.is_recording() + assert span.name == "fastapi.background_task" + data = get_telemetry_data() + assert data is not None + assert data.request is not None + assert data.request.url.path == "/items/42" + assert len(server_spans(exporter)) == 1 + executed.append((number, value, threading.get_ident(), span.get_span_context())) + + async def async_task(number, *, value): + execute(number, value=value) + + task = execute if sync else async_task + + async def dependency(background: BackgroundTasks): + background.add_task(task, 1, value="private dependency value") + + @app.get("/items/{item_id}", dependencies=[Depends(dependency)]) + async def endpoint(background: BackgroundTasks, item_id: int): + endpoint_thread.append(threading.get_ident()) + background.add_task(task, 2, value="private endpoint value") + return {"item_id": item_id} + + assert TestClient(app, backend=backend).get("/items/42").json() == {"item_id": 42} + assert [(number, value) for number, value, _, _ in executed] == [ + (1, "private dependency value"), + (2, "private endpoint value"), + ] + spans = exporter.get_finished_spans() + (server,) = server_spans(exporter) + tasks = [span for span in spans if span.name == "fastapi.background_task"] + assert len(tasks) == 2 + for span, (_, _, thread_id, context) in zip(tasks, executed, strict=True): + assert span.kind == SpanKind.INTERNAL + assert span.parent.span_id == server.context.span_id + assert context.trace_id == server.context.trace_id + assert context.span_id == span.context.span_id + assert span.start_time >= server.end_time + assert span.status.status_code == StatusCode.UNSET + assert span.attributes == { + "code.function.name": f"{task.__module__}.{task.__qualname__}" + } + assert (thread_id != endpoint_thread[0]) == sync + assert metric_points(reader=reader)[0].count == 1 + assert get_telemetry_data() is None + + +def test_background_task_objects(telemetry): + config, exporter, _ = telemetry + app = FastAPI(telemetry=config) + executed = [] + + def record(value): + span = trace.get_current_span() + assert isinstance(span, Span) + executed.append((value, span.name)) + + class AsyncTask: + async def __call__(self, *, value): + record(value) + + class CustomTask(BackgroundTask): + async def __call__(self): + record("before") + await super().__call__() + record("after") + + original = CustomTask(record, "custom") + background = BackgroundTasks([original]) + background.add_task(partial(record, "partial")) + background.add_task(AsyncTask(), value="async callable") + + @app.get("/") + async def endpoint(): + return JSONResponse("ok", background=background) + + assert TestClient(app).get("/").json() == "ok" + assert background.tasks[0] is original + assert executed == [ + (value, "fastapi.background_task") + for value in ("before", "custom", "after", "partial", "async callable") + ] + assert ( + len( + [ + s + for s in exporter.get_finished_spans() + if s.name == "fastapi.background_task" + ] + ) + == 3 + ) + + +@pytest.mark.parametrize("sync", [False, True]) +@pytest.mark.parametrize("http_error", [False, True]) +def test_background_task_failure(telemetry, logs, sync, http_error): + config, exporter, _ = telemetry + app = FastAPI(telemetry=config) + error = HTTPException(503) if http_error else ValueError("background failure") + executed = [] + + def fail(): + executed.append("first") + raise error + + async def async_fail(): + fail() + + @app.get("/") + async def endpoint(background: BackgroundTasks): + background.add_task(fail if sync else async_fail) + background.add_task(executed.append, "second") + return "ok" + + assert TestClient(app, raise_server_exceptions=False).get("/").status_code == 200 + assert executed == ["first"] + (task,) = [ + s for s in exporter.get_finished_spans() if s.name == "fastapi.background_task" + ] + assert task.status.status_code == StatusCode.ERROR + assert task.attributes["error.type"] == ( + "fastapi.exceptions.HTTPException" if http_error else "ValueError" + ) + assert not task.events + (server,) = server_spans(exporter) + assert server.attributes["http.response.status_code"] == 200 + assert server.status.status_code == StatusCode.UNSET + (record,) = logs.get_finished_logs() + assert ( + record.log_record.trace_id == task.context.trace_id == server.context.trace_id + ) + assert "raise error" in record.log_record.attributes["exception.stacktrace"] + assert record.log_record.exception is not None + if http_error: + assert record.log_record.exception.__cause__ is error + else: + assert record.log_record.exception is error + + +@pytest.mark.parametrize( + "mode", ["operations_disabled", "tracing_disabled", "excluded", "unsampled"] +) +def test_background_task_settings(telemetry, mode): + config, exporter, _ = telemetry + if mode == "operations_disabled": + config["operation_spans"] = False + elif mode == "tracing_disabled": + config["tracing"] = False + elif mode == "excluded": + config["exclude"] = lambda scope: True + else: + config["tracer_provider"].sampler = ALWAYS_OFF + app = FastAPI(telemetry=config) + executed = [] + + @app.get("/") + async def endpoint(background: BackgroundTasks): + background.add_task(executed.append, "done") + return "ok" + + assert TestClient(app).get("/").json() == "ok" + assert executed == ["done"] + assert not any( + s.name == "fastapi.background_task" for s in exporter.get_finished_spans() + ) + + +def test_background_tasks_outside_request(): + executed = [] + background = BackgroundTasks() + background.add_task(executed.append, "done") + anyio.run(background) + assert executed == ["done"] + assert get_telemetry_data() is None + + +@pytest.mark.parametrize("integration", ["contrib", "logfire", "native_logfire"]) +@run_in_subprocess +def test_background_task_integrations(integration): + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + native = InMemorySpanExporter() + legacy = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(native)) + app = FastAPI( + telemetry={ + "tracer_provider": None if integration == "native_logfire" else provider + } + ) + executed = [] + + def sync_task(): + executed.append(trace.get_current_span().get_span_context()) + + async def async_task(): + executed.append(trace.get_current_span().get_span_context()) + + @app.get("/") + async def endpoint(background: BackgroundTasks): + background.add_task(sync_task) + background.add_task(async_task) + return "ok" + + if integration == "contrib": + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + + legacy_provider = TracerProvider() + legacy_provider.add_span_processor(SimpleSpanProcessor(legacy)) + FastAPIInstrumentor.instrument_app(app, tracer_provider=legacy_provider) + else: + import logfire + + logfire.configure( + send_to_logfire=False, + console=False, + metrics=False, + additional_span_processors=[SimpleSpanProcessor(legacy)], + ) + if integration == "logfire": + logfire.instrument_fastapi(app) + assert TestClient(app).get("/").json() == "ok" + assert not native.get_finished_spans() + tasks = [ + span + for span in legacy.get_finished_spans() + if ( + span.name.startswith("BackgroundTask ") + or span.name == "fastapi.background_task" + ) + and not ( + span.attributes + and span.attributes.get("logfire.span_type") == "pending_span" + ) + ] + assert [span.name for span in tasks] == ( + ["fastapi.background_task", "fastapi.background_task"] + if integration == "native_logfire" + else ["BackgroundTask sync_task", "BackgroundTask async_task"] + ) + assert [span.context for span in tasks] == executed diff --git a/tests/test_telemetry/test_data.py b/tests/test_telemetry/test_data.py new file mode 100644 index 0000000000..c6174ea3e6 --- /dev/null +++ b/tests/test_telemetry/test_data.py @@ -0,0 +1,286 @@ +import gc +import weakref + +import anyio +import pytest +from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket +from fastapi.telemetry import get_telemetry_data +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient +from opentelemetry import context +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk._logs import LoggerProvider, LogRecordProcessor +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, +) +from pydantic import BaseModel +from starlette.websockets import WebSocketDisconnect + +from ._otlp import otlp_collector + + +@pytest.mark.anyio +async def test_data_is_local_to_each_request_and_cleared_afterwards(telemetry): + config, _, _ = telemetry + child = FastAPI(telemetry=config) + app = FastAPI(telemetry=config) + app.mount("/child", child) + saved_contexts = [] + saved_data = [] + entered = 0 + ready = anyio.Event() + service = object() + + class Item(BaseModel): + name: str + + def dependency(request: Request): + data = get_telemetry_data() + assert data is not None + assert data.request is request + assert data.values is None + assert data.body == {"name": request.path_params["name"]} + return service + + @child.post("/{name}") + async def endpoint( + *, name: str, item: Item, request: Request, value=Depends(dependency) + ): + nonlocal entered + data = get_telemetry_data() + assert data is not None + assert data.request is request + assert data.values is not None + assert data.values["item"] is item + assert data.values["value"] is service + entered += 1 + if entered == 2: + ready.set() + await ready.wait() + assert get_telemetry_data() is data + assert data.values["name"] == name + assert data.errors == [] + saved_contexts.append(context.get_current()) + saved_data.append(data) + return name + + assert get_telemetry_data() is None + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + results = {} + + async def send(name): + results[name] = await client.post(f"/child/{name}", json={"name": name}) + + async with anyio.create_task_group() as tasks: + tasks.start_soon(send, "first") + tasks.start_soon(send, "second") + assert {name: response.json() for name, response in results.items()} == { + "first": "first", + "second": "second", + } + assert saved_data[0] is not saved_data[1] + assert all(get_telemetry_data(ctx) is None for ctx in saved_contexts) + assert get_telemetry_data() is None + + +def test_retained_context_does_not_keep_failed_request_alive(telemetry): + config, _, _ = telemetry + app = FastAPI(telemetry={**config, "tracing": False, "logs": False}) + saved_contexts = [] + request_refs = [] + + @app.post("/") + async def endpoint(request: Request): + await request.body() + saved_contexts.append(context.get_current()) + request_refs.append(weakref.ref(request)) + raise ValueError("endpoint failed") + + with TestClient(app, raise_server_exceptions=False) as client: + assert client.post("/", json={"name": "request data"}).status_code == 500 + + gc.collect() + assert get_telemetry_data(saved_contexts[0]) is None + assert request_refs[0]() is None + + +@pytest.mark.parametrize("invalid_json", [False, True]) +@pytest.mark.parametrize("logging", [False, True]) +def test_validation_data_without_tracing(invalid_json, logging): + exporter = InMemoryLogRecordExporter() + provider = LoggerProvider(shutdown_on_exit=False) + observed = [] + + class Validation(LogRecordProcessor): + def on_emit(self, log_record): + record = log_record.log_record + data = get_telemetry_data(record.context) + assert data is not None + assert data.request is not None + assert data.request.url.path == "/items" + assert data.errors is not None + assert data.errors[0]["type"] == ( + "json_invalid" if invalid_json else "int_parsing" + ) + assert data.body == ("{" if invalid_json else {"amount": "private-input"}) + observed.append(record.context) + + def shutdown(self): + pass + + def force_flush(self, timeout_millis=30000): + return True + + provider.add_log_record_processor(Validation()) + provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) + app = FastAPI( + telemetry={"logger_provider": provider, "tracing": False, "logs": logging} + ) + + @app.post("/items") + def endpoint(item: dict[str, int]): + return item # pragma: no cover + + try: + with TestClient(app) as client: + response = ( + client.post( + "/items", content="{", headers={"content-type": "application/json"} + ) + if invalid_json + else client.post("/items", json={"amount": "private-input"}) + ) + assert provider.force_flush() + assert response.status_code == 422 + if not logging: + assert not exporter.get_finished_logs() + assert observed == [] + return + (data,) = exporter.get_finished_logs() + record = data.log_record + assert record.severity_number == SeverityNumber.WARN + assert record.event_name == "fastapi.validation.failed" + assert record.exception is None + assert record.attributes == { + "http.route": "/items", + "fastapi.validation.error_count": 1, + } + assert record.body == "Request validation failed" + assert len(observed) == 1 + assert get_telemetry_data(observed[0]) is None + assert get_telemetry_data(record.context) is None + finally: + provider.shutdown() + + +def test_request_objects_are_not_exported(): + from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import ( + ExportLogsServiceRequest, + ) + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + + with otlp_collector() as (base, received): + logger = LoggerProvider(shutdown_on_exit=False) + logger.add_log_record_processor( + SimpleLogRecordProcessor(OTLPLogExporter(endpoint=base + "/v1/logs")) + ) + tracer = TracerProvider(shutdown_on_exit=False) + tracer.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint=base + "/v1/traces")) + ) + app = FastAPI(telemetry={"tracer_provider": tracer, "logger_provider": logger}) + service = object() + + def dependency(): + return service + + @app.post("/items") + def endpoint(*, item: dict[str, str], count: int, value=Depends(dependency)): + data = get_telemetry_data() + assert data is not None + assert data.values is not None + assert data.values["value"] is service + raise ValueError("endpoint failed") + + @app.get("/handled") + def handled(): + raise HTTPException(503, "private-handled-detail") + + @app.websocket("/ws") + async def websocket_endpoint(*, websocket: WebSocket, count: int): + data = get_telemetry_data() + assert data is not None + assert data.websocket is websocket + assert data.body is None + await websocket.accept() + await websocket.send_text(await websocket.receive_text()) + await websocket.close() + + try: + with TestClient(app, raise_server_exceptions=False) as client: + with client.websocket_connect( + "/ws?count=1&query=websocket-search", + headers={ + "authorization": "Bearer authorization-secret", + "cookie": "session=session-secret", + }, + ) as websocket: + websocket.send_text("private-websocket-message") + assert websocket.receive_text() == "private-websocket-message" + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/ws?count=invalid-websocket-count"): + pass # pragma: no cover + assert client.get("/handled").status_code == 503 + for count, status in [("1", 500), ("invalid-count", 422)]: + response = client.post( + "/items", + params={"count": count, "query": "search-term"}, + headers={ + "authorization": "Bearer authorization-secret", + "cookie": "session=session-secret", + }, + json={"name": "private-body"}, + ) + assert response.status_code == status + assert {path for path, _, _ in received} == {"/v1/logs", "/v1/traces"} + for path, payload, _ in received: + if path == "/v1/logs": + assert b"invalid-count" not in payload + assert b"search-term" not in payload + for uncaptured_value in [ + b"websocket-search", + b"invalid-websocket-count", + b"authorization-secret", + b"session-secret", + b"private-body", + b"private-handled-detail", + b"private-websocket-message", + ]: + assert uncaptured_value not in payload + traces = b"".join( + payload for path, payload, _ in received if path == "/v1/traces" + ) + assert b"invalid-count" in traces + assert b"search-term" in traces + records = [ + record + for path, payload, _ in received + if path == "/v1/logs" + for resource in ExportLogsServiceRequest.FromString( + payload + ).resource_logs + for scope in resource.scope_logs + for record in scope.log_records + ] + assert len(records) == 3 + assert records[0].event_name == "fastapi.validation.failed" + assert records[2].event_name == "fastapi.validation.failed" + finally: + tracer.shutdown() + logger.shutdown() diff --git a/tests/test_telemetry/test_environment_integrations.py b/tests/test_telemetry/test_environment_integrations.py new file mode 100644 index 0000000000..7ba7e01879 --- /dev/null +++ b/tests/test_telemetry/test_environment_integrations.py @@ -0,0 +1,244 @@ +"""Environment export remains active alongside independently configured SDKs.""" + +import pytest + +from ._otlp import otlp_collector +from ._subprocess import run_in_subprocess + + +@pytest.mark.parametrize( + "mode", + [ + "global", + "explicit", + "sentry-first", + "fastapi-first", + "sentry-classic", + "logfire", + "logfire-opt-out", + ], +) +@run_in_subprocess +def test_environment_export_with_existing_integrations(mode): + import os + + from fastapi import FastAPI + from fastapi.telemetry import TelemetryConfig, _runtime + from fastapi.testclient import TestClient + from opentelemetry import _logs, metrics, trace + from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import ( + ExportLogsServiceRequest, + ) + from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( + ExportMetricsServiceRequest, + ) + from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( + ExportTraceServiceRequest, + ) + from opentelemetry.proto.trace.v1.trace_pb2 import Span + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ) + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import InMemoryMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + with otlp_collector() as (base, received): + os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = base + "/environment" + os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "x-cloud=preserved" + os.environ["OTEL_BSP_SCHEDULE_DELAY"] = "60000" + os.environ["OTEL_BLRP_SCHEDULE_DELAY"] = "60000" + os.environ["OTEL_METRIC_EXPORT_INTERVAL"] = "60000" + settings: TelemetryConfig = {} + original_spans = InMemorySpanExporter() + original_logs = InMemoryLogRecordExporter() + original_metrics = InMemoryMetricReader() + if mode in ("global", "explicit"): + tp = TracerProvider(shutdown_on_exit=False) + tp.add_span_processor(SimpleSpanProcessor(original_spans)) + mp = MeterProvider( + metric_readers=[original_metrics], shutdown_on_exit=False + ) + lp = LoggerProvider(shutdown_on_exit=False) + lp.add_log_record_processor(SimpleLogRecordProcessor(original_logs)) + if mode == "global": + trace.set_tracer_provider(tp) + metrics.set_meter_provider(mp) + _logs.set_logger_provider(lp) + else: + settings = { + "tracer_provider": tp, + "meter_provider": mp, + "logger_provider": lp, + } + elif mode.startswith("logfire"): + import logfire + + logfire.configure( + send_to_logfire=False, + console=False, + additional_span_processors=[SimpleSpanProcessor(original_spans)], + metrics=logfire.MetricsOptions(additional_readers=[original_metrics]), + advanced=logfire.AdvancedOptions( + log_record_processors=[SimpleLogRecordProcessor(original_logs)] + ), + ) + settings["auto_configure"] = mode != "logfire-opt-out" + else: + import sentry_sdk + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.otlp import OTLPIntegration + from sentry_sdk.integrations.starlette import StarletteIntegration + from sentry_sdk.transport import Transport + + items = [] + + class LocalTransport(Transport): + def capture_envelope(self, envelope): + items.extend(item.type for item in envelope.items) + + if mode == "fastapi-first": + with TestClient(FastAPI()): + pass + integrations = ( + [StarletteIntegration(), FastApiIntegration()] + if mode == "sentry-classic" + else [ + OTLPIntegration( + collector_url=base + "/sentry/traces", setup_propagator=False + ) + ] + ) + sentry_sdk.init( + dsn="https://public@example.invalid/1", + transport=LocalTransport, + default_integrations=False, + auto_enabling_integrations=False, + integrations=integrations, + traces_sample_rate=1.0, + send_client_reports=False, + ) + + app = FastAPI(telemetry=settings) + + @app.get("/items/{item_id}") + def endpoint(item_id: int): + raise ValueError("environment export") + + try: + for _ in range(2): + with TestClient(app, raise_server_exceptions=False) as client: + assert client.get("/items/1").status_code == 500 + if mode.startswith("logfire"): + logfire.force_flush() + elif mode.startswith("sentry") or mode == "fastapi-first": + tracer_provider = trace.get_tracer_provider() + assert isinstance(tracer_provider, TracerProvider) + tracer_provider.force_flush() + sentry_sdk.flush() + + def spans_at(path): + requests = [ + ExportTraceServiceRequest.FromString(body) + for url, body, _ in received + if url == path + ] + return [ + span + for request in requests + for resource in request.resource_spans + for scope in resource.scope_spans + for span in scope.spans + ] + + environment_spans = spans_at("/environment/v1/traces") + multiplier = 2 if mode == "logfire" else 1 + servers = [ + span for span in environment_spans if span.kind == Span.SPAN_KIND_SERVER + ] + assert len(servers) == 2 * multiplier, (mode, len(servers)) + assert len({span.span_id for span in servers}) == 2 + assert all(span.name == "GET /items/{item_id}" for span in servers) + if mode in ("global", "explicit"): + assert sorted( + (span.context.trace_id, span.context.span_id, span.name) + for span in original_spans.get_finished_spans() + ) == sorted( + ( + int.from_bytes(span.trace_id, "big"), + int.from_bytes(span.span_id, "big"), + span.name, + ) + for span in environment_spans + ) + if mode in ("sentry-first", "fastapi-first"): + sentry_spans = spans_at("/sentry/traces") + assert {span.span_id for span in sentry_spans} == { + span.span_id for span in environment_spans + } + if mode == "sentry-classic": + assert items.count("event") == 2, items + assert items.count("transaction") == 2, items + + log_requests = [ + ExportLogsServiceRequest.FromString(body) + for url, body, _ in received + if url == "/environment/v1/logs" + ] + logs = [ + record + for request in log_requests + for resource in request.resource_logs + for scope in resource.scope_logs + for record in scope.log_records + ] + assert len(logs) == 2 * multiplier, (mode, len(logs)) + assert all( + record.trace_id in {span.trace_id for span in servers} + for record in logs + ) + metric_requests = [ + ExportMetricsServiceRequest.FromString(body) + for url, body, _ in received + if url == "/environment/v1/metrics" + ] + counts = [ + point.count + for request in metric_requests + for resource in request.resource_metrics + for scope in resource.scope_metrics + for metric in scope.metrics + if metric.name == "http.server.request.duration" + for point in getattr(metric, metric.WhichOneof("data")).data_points + ] + assert counts and counts[-1] == 2, counts + assert all( + headers.get("x-cloud") == "preserved" + for url, _, headers in received + if url.startswith("/environment/") + ) + assert len(_runtime._configured) == (0 if mode == "logfire-opt-out" else 3) + _runtime._shutdown() + if mode in ("global", "explicit"): + # FastAPI's cleanup closes its own exporters, leaving the provider and + # previously installed vendor components usable. + count = len(original_spans.get_finished_spans()) + with tp.get_tracer("vendor").start_as_current_span("still-running"): + pass + assert len(original_spans.get_finished_spans()) == count + 1 + lp.get_logger("vendor").emit(body="still-running") + assert len(original_logs.get_finished_logs()) == 3 + assert original_metrics.get_metrics_data() is not None + tp.shutdown() + mp.shutdown() + lp.shutdown() + finally: + _runtime._shutdown() + if mode.startswith("logfire"): + logfire.shutdown() diff --git a/tests/test_telemetry/test_exceptions.py b/tests/test_telemetry/test_exceptions.py new file mode 100644 index 0000000000..14f15a32ca --- /dev/null +++ b/tests/test_telemetry/test_exceptions.py @@ -0,0 +1,249 @@ +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.testclient import TestClient +from opentelemetry import trace +from opentelemetry._logs import NoOpLoggerProvider, SeverityNumber +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF + +from .conftest import metric_points, server_spans + + +@pytest.mark.parametrize("sampled", [True, False]) +def test_exception_is_logged_once_with_trace_context(telemetry, logs, sampled): + config, spans, reader = telemetry + if not sampled: + config["tracer_provider"].sampler = ALWAYS_OFF + app = FastAPI(telemetry=config) + contexts = [] + error = ValueError("test failure") + + @app.get("/items/{item_id}") + def endpoint(item_id: int): + contexts.append(trace.get_current_span().get_span_context()) + raise error + + assert ( + TestClient(app, raise_server_exceptions=False).get("/items/1").status_code + == 500 + ) + (data,) = logs.get_finished_logs() + record = data.log_record + assert record.event_name == "http.server.request.exception" + assert record.timestamp is not None + assert record.timestamp <= record.observed_timestamp + assert ( + data.instrumentation_scope.schema_url + == "https://opentelemetry.io/schemas/1.44.0" + ) + assert record.exception is error + assert record.trace_id == contexts[0].trace_id + assert record.span_id != 0 + if sampled: + (server,) = server_spans(spans) + assert record.span_id == server.context.span_id + assert record.severity_number == SeverityNumber.ERROR + assert record.attributes["http.route"] == "/items/{item_id}" + assert record.attributes["exception.type"] == "ValueError" + assert record.attributes["exception.message"] == "test failure" + assert "raise error" in record.attributes["exception.stacktrace"] + assert len(server_spans(spans)) == int(sampled) + + +@pytest.mark.parametrize("raises", [False, True]) +def test_exclusion_applies_to_mounted_apps(telemetry, logs, raises): + config, spans, reader = telemetry + scopes = [] + child = FastAPI(telemetry=config) + + @child.get("/{name}") + def endpoint(*, request: Request, name: str): + scopes.append(request.scope) + if name == "excluded" and raises: + raise ValueError("excluded error") + return name + + parent = FastAPI( + telemetry={ + **config, + "exclude": lambda scope: scope["path"] == "/child/excluded", + } + ) + parent.mount("/child", child) + client = TestClient(parent, raise_server_exceptions=False) + assert client.get("/child/excluded").status_code == (500 if raises else 200) + assert not spans.get_finished_spans() + assert not logs.get_finished_logs() + assert not metric_points(reader=reader) + assert "fastapi.telemetry" not in scopes[0] + assert client.get("/child/included").json() == "included" + (span,) = server_spans(spans) + assert span.attributes["http.route"] == "/child/{name}" + assert metric_points(reader=reader)[0].count == 1 + + +def test_handled_exceptions_and_validation_are_not_error_logs(telemetry, logs): + config, spans, reader = telemetry + app = FastAPI(telemetry=config) + + @app.exception_handler(ValueError) + async def handled(request, exc): + return JSONResponse({"detail": "handled"}, status_code=400) + + @app.get("/items/{value}") + def endpoint(value: int): + if value == 1: + raise HTTPException(404) + raise ValueError("handled by application") + + client = TestClient(app) + assert client.get("/items/no").status_code == 422 + assert client.get("/items/1").status_code == 404 + assert client.get("/items/2").status_code == 400 + assert client.get("/missing").status_code == 404 + (validation,) = logs.get_finished_logs() + assert validation.log_record.severity_number == SeverityNumber.WARN + assert validation.log_record.event_name == "fastapi.validation.failed" + assert validation.log_record.timestamp is not None + assert validation.log_record.timestamp <= validation.log_record.observed_timestamp + assert validation.log_record.exception is None + + +@pytest.mark.parametrize("stage", ["stream", "cleanup"]) +def test_errors_after_response_started_are_logged(telemetry, logs, stage): + from fastapi import Depends + + config, spans, reader = telemetry + app = FastAPI(telemetry=config) + + def fail(): + raise ValueError("after response started") + + async def dependency(): + yield + if stage == "cleanup": + fail() + + @app.get("/", dependencies=[Depends(dependency)]) + async def endpoint(): + if stage == "stream": + + async def stream(): + yield "start" + fail() + + return StreamingResponse(stream()) + return "ok" + + assert TestClient(app, raise_server_exceptions=False).get("/").status_code == 200 + (data,) = logs.get_finished_logs() + (span,) = server_spans(spans) + assert data.log_record.trace_id == span.context.trace_id + assert ( + "after response started" in data.log_record.attributes["exception.stacktrace"] + ) + + +@pytest.mark.parametrize("mode", ["logs_only", "disabled", "noop", "excluded"]) +def test_independent_log_configuration(telemetry, logs, mode): + config, spans, reader = telemetry + if mode == "logs_only": + config.update(tracing=False, metrics=False) + elif mode == "disabled": + config["logs"] = False + elif mode == "noop": + config["logger_provider"] = NoOpLoggerProvider() + else: + config["exclude"] = lambda scope: True + app = FastAPI(telemetry=config) + + @app.get("/") + def endpoint(): + raise ValueError("test") + + assert TestClient(app, raise_server_exceptions=False).get("/").status_code == 500 + records = logs.get_finished_logs() + assert len(records) == int(mode == "logs_only") + if records: + assert records[0].log_record.trace_id == 0 + + +@pytest.mark.parametrize("status", [404, 503]) +@pytest.mark.parametrize("sampled", [False, True]) +def test_http_exception_records_status_without_exception_telemetry( + telemetry, logs, status, sampled +): + from opentelemetry.trace import StatusCode + + config, spans, reader = telemetry + if not sampled: + config["tracer_provider"].sampler = ALWAYS_OFF + app = FastAPI(telemetry=config) + + @app.get("/items/{item_id}") + def endpoint(item_id: int): + raise HTTPException(status, "private exception detail") + + response = TestClient(app).get("/items/1") + assert response.status_code == status + assert response.json() == {"detail": "private exception detail"} + assert not logs.get_finished_logs() + (point,) = metric_points(reader=reader) + assert point.count == 1 + assert point.attributes["http.response.status_code"] == status + assert point.attributes["http.route"] == "/items/{item_id}" + assert point.attributes.get("error.type") == ("503" if status == 503 else None) + finished = spans.get_finished_spans() + assert all(not span.events for span in finished) + if sampled: + (server,) = server_spans(spans) + assert server.attributes["http.response.status_code"] == status + assert server.status.status_code == ( + StatusCode.ERROR if status == 503 else StatusCode.UNSET + ) + else: + assert not finished + + +def test_exception_handler_failure_is_logged_once_as_unhandled(telemetry, logs): + config, _, _ = telemetry + failure = RuntimeError("handler failed") + app = FastAPI(telemetry=config) + + @app.exception_handler(HTTPException) + async def handler(request, exc): + raise failure + + @app.get("/") + async def endpoint(): + raise HTTPException(404, "original") + + assert TestClient(app, raise_server_exceptions=False).get("/").status_code == 500 + (data,) = logs.get_finished_logs() + assert data.log_record.exception is failure + assert data.log_record.body == "Unhandled exception in FastAPI request" + + +class ApplicationError(Exception): + pass + + +def test_exception_type_matches_spans_metrics_and_logs(telemetry, logs): + config, spans, reader = telemetry + app = FastAPI(telemetry=config) + + @app.get("/") + async def endpoint(): + raise ApplicationError("failed") + + assert TestClient(app, raise_server_exceptions=False).get("/").status_code == 500 + (record,) = logs.get_finished_logs() + expected = f"{__name__}.ApplicationError" + assert record.log_record.attributes["exception.type"] == expected + failed_spans = [ + span for span in spans.get_finished_spans() if span.status.is_ok is False + ] + assert {span.name for span in failed_spans} == {"GET /", "fastapi.endpoint"} + assert all(span.attributes["error.type"] == expected for span in failed_spans) + (point,) = metric_points(reader=reader) + assert point.attributes["error.type"] == expected diff --git a/tests/test_telemetry/test_http.py b/tests/test_telemetry/test_http.py new file mode 100644 index 0000000000..3abeb06e24 --- /dev/null +++ b/tests/test_telemetry/test_http.py @@ -0,0 +1,695 @@ +import asyncio +from contextlib import asynccontextmanager + +import anyio +import pytest +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + FastAPI, + HTTPException, + Request, +) +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse +from fastapi.testclient import TestClient +from opentelemetry import baggage, propagate, trace +from opentelemetry.propagators import textmap +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF +from opentelemetry.trace import SpanKind, StatusCode +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from starlette.middleware import Middleware +from starlette.types import Scope + +from ._subprocess import run_in_subprocess +from .conftest import metric_points, server_spans + + +def test_request_context_and_span_attributes(telemetry): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + seen = [] + + @app.get("/items/{item_id}") + async def endpoint(item_id: int): + seen.append(trace.get_current_span().get_span_context()) + return item_id + + client = TestClient(app) + parent_trace = "0123456789abcdef0123456789abcdef" + parent_span = "0123456789abcdef" + response = client.get( + "/items/123?page=2&sig=signature-secret", + headers={ + "traceparent": f"00-{parent_trace}-{parent_span}-01", + "authorization": "Bearer authorization-secret", + }, + ) + assert response.json() == 123 + (span,) = server_spans(exporter) + assert span.kind == SpanKind.SERVER + assert span.name == "GET /items/{item_id}" + endpoint_span = next( + s for s in exporter.get_finished_spans() if s.name == "fastapi.endpoint" + ) + assert endpoint_span.context == seen[0] + assert endpoint_span.parent.span_id == span.context.span_id + assert span.context.trace_id == int(parent_trace, 16) + assert span.parent.span_id == int(parent_span, 16) + assert span.status.status_code == StatusCode.UNSET + assert span.attributes["http.route"] == "/items/{item_id}" + assert span.attributes["http.response.status_code"] == 200 + assert "signature-secret" not in repr(span.attributes) + assert "authorization-secret" not in repr(span.attributes) + assert span.attributes["url.path"] == "/items/123" + assert span.attributes["url.query"] == "page=2&sig=REDACTED" + points = metric_points(reader=reader) + assert len(points) == 1 + assert points[0].count == 1 + assert points[0].sum > 0 + assert points[0].attributes["http.route"] == "/items/{item_id}" + assert points[0].explicit_bounds == ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 7.5, + 10, + ) + assert ( + metric_points(reader=reader, name="http.server.active_requests")[0].value == 0 + ) + assert not trace.get_current_span().get_span_context().is_valid + + +@pytest.mark.parametrize("split_headers", [False, True]) +def test_propagation_headers(telemetry, split_headers): + config, exporter, _ = telemetry + app = FastAPI(telemetry=config) + + @app.get("/") + async def endpoint(): + return dict(baggage.get_all()) + + trace_id = "0123456789abcdef0123456789abcdef" + parent_id = "0123456789abcdef" + headers = [("traceparent", f"00-{trace_id}-{parent_id}-01")] + for name, values in [ + ("tracestate", ["vendora=one", "vendorb=two"]), + ("baggage", ["first=one", "second=two"]), + ]: + headers.extend( + (name, value) for value in (values if split_headers else [",".join(values)]) + ) + response = TestClient(app).get("/", headers=headers) + assert response.json() == {"first": "one", "second": "two"} + (span,) = server_spans(exporter) + assert span.context.trace_id == int(trace_id, 16) + assert span.parent.span_id == int(parent_id, 16) + assert list(span.context.trace_state.items()) == [ + ("vendora", "one"), + ("vendorb", "two"), + ] + assert not baggage.get_all() + + +def test_custom_propagator_can_read_repeated_headers(telemetry, monkeypatch): + class HeaderPropagator(TraceContextTextMapPropagator): + def extract(self, carrier, context=None, getter=textmap.default_getter): + assert "x-custom-context" in getter.keys(carrier) + values = getter.get(carrier, "X-Custom-Context") + assert values is not None + assert values == ["one, two", "three"] + assert getter.get(carrier, "missing-header") is None + context = super().extract(carrier, context=context, getter=getter) + return baggage.set_baggage("custom", "|".join(values), context=context) + + monkeypatch.setattr(propagate, "get_global_textmap", lambda: HeaderPropagator()) + config, _, _ = telemetry + app = FastAPI(telemetry=config) + + @app.get("/") + async def endpoint(): + return baggage.get_baggage("custom") + + response = TestClient(app).get( + "/", headers=[("x-custom-context", "one, two"), ("x-custom-context", "three")] + ) + assert response.json() == "one, two|three" + assert not baggage.get_all() + + +@pytest.mark.parametrize( + "path,status,route,error", + [ + ("/ok", 200, "/ok", False), + ("/missing", 404, None, False), + ("/validation/no", 422, "/validation/{value}", False), + ("/validation/1", 200, "/validation/{value}", False), + ("/fail", 500, "/fail", True), + ("/handled", 503, "/handled", True), + ("/ok/", 307, "/ok", False), + ], +) +def test_http_statuses(telemetry, path, status, route, error): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.get("/ok") + def ok(): + return "ok" + + @app.get("/validation/{value}") + def validation(value: int): + return value + + @app.get("/fail") + def fail(): + raise ValueError("endpoint failed") + + @app.get("/handled") + async def handled(): + raise HTTPException(503, "Unavailable") + + assert ( + TestClient(app, raise_server_exceptions=False) + .get(path, follow_redirects=False) + .status_code + == status + ) + (span,) = server_spans(exporter) + assert span.attributes["http.response.status_code"] == status + assert span.attributes.get("http.route") == route + assert (span.status.status_code == StatusCode.ERROR) == error + assert "endpoint failed" not in repr(span.attributes) + assert span.events == () + assert sum(p.count for p in metric_points(reader=reader)) == 1 + + +def test_custom_error_handler_sees_span(telemetry): + config, exporter, reader = telemetry + seen = [] + + async def error_handler(request, exc): + span = trace.get_current_span() + seen.append(span.is_recording()) + span.set_attribute("handled", True) + return JSONResponse({"error": "handled"}, status_code=500) + + app = FastAPI(telemetry=config, exception_handlers={500: error_handler}) + + @app.get("/") + def endpoint(): + raise ValueError("endpoint failed") + + assert TestClient(app, raise_server_exceptions=False).get("/").json() == { + "error": "handled" + } + (span,) = server_spans(exporter) + assert seen == [True] + assert span.attributes["handled"] is True + assert span.attributes["error.type"] == "ValueError" + + +def test_route_prefix_and_dynamic_mount(telemetry): + config, exporter, reader = telemetry + parent = FastAPI(telemetry=config, root_path="/proxy") + child = FastAPI() + router = APIRouter(prefix="/v1") + + @router.get("/items/{item_id}") + def endpoint(item_id: int): + return item_id + + child.include_router(router, prefix="/api") + parent.mount("/tenants/{tenant}", child) + response = TestClient(parent).get("/proxy/tenants/acme/api/v1/items/4") + assert response.json() == 4 + (span,) = server_spans(exporter) + assert ( + span.attributes["http.route"] + == "/proxy/tenants/{tenant}/api/v1/items/{item_id}" + ) + assert span.name == "GET /proxy/tenants/{tenant}/api/v1/items/{item_id}" + assert span.attributes["url.path"] == "/proxy/tenants/acme/api/v1/items/4" + assert "acme" not in repr(metric_points(reader=reader)[0].attributes) + assert sum(p.count for p in metric_points(reader=reader)) == 1 + + +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("route_type", ["fastapi", "starlette"]) +def test_included_router_redirect_template(telemetry, nested, route_type): + config, exporter, reader = telemetry + router = APIRouter() + + def endpoint(request: Request): + return PlainTextResponse("ok") + + if route_type == "fastapi": + router.add_api_route("/items/{item_id}/", endpoint) + else: + router.add_route("/items/{item_id}/", endpoint) + if nested: + outer = APIRouter() + outer.include_router(router, prefix="/v1") + router = outer + child = FastAPI() + child.include_router(router, prefix="/api") + app = FastAPI(telemetry=config) + app.mount("/tenants/{tenant}", child) + prefix = "/api" + ("/v1" if nested else "") + path = "/tenants/acme" + prefix + "/items/1" + template = "/tenants/{tenant}" + prefix + "/items/{item_id}/" + client = TestClient(app) + response = client.get(path, follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "http://testserver" + path + "/" + (span,) = server_spans(exporter) + assert span.name == "GET " + template + assert span.attributes["http.route"] == template + (point,) = metric_points(reader=reader) + assert point.count == 1 + assert point.attributes["http.route"] == template + assert client.get(path + "/").text == "ok" + + +def test_method_not_allowed_and_docs(telemetry): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.get("/items/{value}") + def endpoint(value: str): + return value + + client = TestClient(app) + assert client.post("/items/x").status_code == 405 + assert client.get("/docs").status_code == 200 + assert [s.attributes["http.route"] for s in exporter.get_finished_spans()] == [ + "/items/{value}", + "/docs", + ] + assert client.get("/items/x").json() == "x" + + +def test_stream_completes_before_background_and_cleanup(telemetry): + config, exporter, reader = telemetry + seen = [] + app = FastAPI(telemetry=config) + + async def dependency(): + yield + seen.append(("cleanup", len(server_spans(exporter)))) + + async def background(): + seen.append(("background", len(server_spans(exporter)))) + + @app.get("/", dependencies=[Depends(dependency)]) + async def endpoint(tasks: BackgroundTasks): + tasks.add_task(background) + + async def stream(): + yield "one" + assert not server_spans(exporter) + await anyio.sleep(0) + yield "two" + + return StreamingResponse(stream()) + + assert TestClient(app).get("/").text == "onetwo" + assert seen == [("background", 1), ("cleanup", 1)] + assert sum(p.count for p in metric_points(reader=reader)) == 1 + + +@pytest.mark.parametrize( + "tracing,metering", [(True, False), (False, True), (False, False)] +) +def test_independent_signals(telemetry, tracing, metering): + config, exporter, reader = telemetry + config["tracing"], config["metrics"] = tracing, metering + app = FastAPI(telemetry=config) + TestClient(app).get("/missing") + assert len(exporter.get_finished_spans()) == int(tracing) + assert sum(p.count for p in metric_points(reader=reader)) == int(metering) + + +def test_configuration_is_copied_for_each_app(telemetry): + config, exporter, reader = telemetry + original = config.copy() + enabled = FastAPI(telemetry=config) + assert config == original + + config["tracing"] = False + disabled = FastAPI(telemetry=config) + config["tracing"] = True + + assert TestClient(enabled).get("/").status_code == 404 + assert TestClient(disabled).get("/").status_code == 404 + assert len(server_spans(exporter)) == 1 + assert sum(point.count for point in metric_points(reader=reader)) == 2 + + +def test_unsampled_requests_still_record_metrics(telemetry): + config, exporter, reader = telemetry + config["tracer_provider"].sampler = ALWAYS_OFF + TestClient(FastAPI(telemetry=config)).get("/missing") + assert not exporter.get_finished_spans() + assert metric_points(reader=reader)[0].count == 1 + + +def test_exclusion(telemetry): + config, exporter, reader = telemetry + config["exclude"] = lambda scope: scope["path"] == "/health" + client = TestClient(FastAPI(telemetry=config)) + client.get("/health") + assert not exporter.get_finished_spans() + assert not metric_points(reader=reader) + client.get("/other") + assert len(exporter.get_finished_spans()) == 1 + + +@pytest.mark.parametrize("raises", [False, True]) +def test_middleware_response_or_error(telemetry, raises): + config, exporter, reader = telemetry + + class CustomMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if raises: + raise RuntimeError("middleware failed") + await PlainTextResponse("middleware")(scope, receive, send) + + app = FastAPI(telemetry=config, middleware=[Middleware(CustomMiddleware)]) + response = TestClient(app, raise_server_exceptions=False).get("/") + assert response.status_code == (500 if raises else 200) + (span,) = server_spans(exporter) + assert "http.route" not in span.attributes + if raises: + assert span.attributes["error.type"] == "RuntimeError" + + +@pytest.mark.parametrize( + "kind", ["trailers", "disconnect", "cancel", "pathsend", "incomplete", "send_error"] +) +def test_asgi_lifecycle(telemetry, kind): + from fastapi.telemetry._asgi import NativeTelemetry + + config, exporter, reader = telemetry + sent = [] + scope: Scope = { + "type": "http", + "method": "GET", + "scheme": "http", + "path": "/", + "headers": [], + } + + async def receive(): + return {"type": "http.disconnect"} + + async def send(message): + if kind == "send_error": + raise OSError("closed") + sent.append(message) + + async def app(scope, receive, send): + if kind == "disconnect": + await receive() + return + if kind == "cancel": + raise asyncio.CancelledError() + if kind == "incomplete": + return + await send( + { + "type": "http.response.start", + "status": 200, + "trailers": kind == "trailers", + } + ) + if kind == "pathsend": + await send({"type": "http.response.pathsend", "path": "/tmp/example"}) + else: + await send({"type": "http.response.body", "body": b"ok"}) + if kind == "trailers": + assert not exporter.get_finished_spans() + await send( + {"type": "http.response.trailers", "headers": [], "more_trailers": True} + ) + assert not exporter.get_finished_spans() + await send({"type": "http.response.trailers", "headers": []}) + + async def run(): + await NativeTelemetry(FastAPI(telemetry=config)._telemetry)( + app=app, scope=scope, receive=receive, send=send + ) + + if kind == "cancel": + with pytest.raises(asyncio.CancelledError): + asyncio.run(run()) + elif kind == "send_error": + with pytest.raises(OSError): + asyncio.run(run()) + else: + asyncio.run(run()) + (span,) = server_spans(exporter) + assert (span.status.status_code == StatusCode.ERROR) == ( + kind in {"cancel", "disconnect", "incomplete", "send_error"} + ) + assert metric_points(reader=reader)[0].count == 1 + assert ( + metric_points(reader=reader, name="http.server.active_requests")[0].value == 0 + ) + assert "fastapi.telemetry" not in scope + + +def test_multiple_lifespans_borrowed_providers(telemetry): + config, exporter, reader = telemetry + lifecycle = [] + + @asynccontextmanager + async def lifespan(app): + lifecycle.append("start") + yield {"state": True} + lifecycle.append("stop") + + first = FastAPI(telemetry=config, lifespan=lifespan) + second = FastAPI(telemetry=config) + for app in [first, second, first]: + with TestClient(app) as client: + assert client.get("/").status_code == 404 + assert lifecycle == ["start", "stop", "start", "stop"] + assert len(exporter.get_finished_spans()) == 3 + assert sum(p.count for p in metric_points(reader=reader)) == 3 + + +@pytest.mark.parametrize( + "method", + [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "QUERY", + "TRACE", + ], +) +def test_known_http_methods(telemetry, method): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.api_route("/items/{value}", methods=[method]) + def endpoint(value: str): + return value + + assert TestClient(app).request(method, "/items/1").status_code == 200 + (span,) = server_spans(exporter) + assert span.name == f"{method} /items/{{value}}" + assert span.attributes["http.request.method"] == method + assert "http.request.method_original" not in span.attributes + assert metric_points(reader=reader)[0].attributes["http.request.method"] == method + assert ( + metric_points(reader=reader, name="http.server.active_requests")[0].attributes[ + "http.request.method" + ] + == method + ) + + +@pytest.mark.parametrize( + "path,status,span_name", + [("/items/1", 405, "HTTP /items/{value}"), ("/missing", 404, "HTTP")], +) +def test_unknown_method_has_bounded_span_name(telemetry, path, status, span_name): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.get("/items/{value}") + def endpoint(value: str): + return value + + assert TestClient(app).request("PRIVATE_METHOD", path).status_code == status + (span,) = server_spans(exporter) + assert span.name == span_name + assert span.attributes["http.request.method"] == "_OTHER" + assert span.attributes["http.request.method_original"] == "PRIVATE_METHOD" + for name in ("http.server.request.duration", "http.server.active_requests"): + attributes = metric_points(reader=reader, name=name)[0].attributes + assert attributes["http.request.method"] == "_OTHER" + assert "http.request.method_original" not in attributes + assert TestClient(app).get("/items/1").json() == "1" + + +@pytest.mark.parametrize("matched", [True, False]) +@pytest.mark.parametrize( + "known_methods,method,expected", + [ + (" QUERY , PROPFIND , ", "QUERY", "QUERY"), + (" QUERY , PROPFIND , ", "PROPFIND", "PROPFIND"), + ("QUERY,PROPFIND", "GET", "_OTHER"), + ("query", "QUERY", "_OTHER"), + ("CUSTOM", "CUSTOM", "CUSTOM"), + ("", "QUERY", "QUERY"), + (" ", "QUERY", "QUERY"), + ], +) +def test_known_http_methods_override( + telemetry, monkeypatch, known_methods, method, expected, matched +): + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS", known_methods) + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.api_route("/items/{value}", methods=[method]) + def endpoint(value: str): + return value + + path = "/items/1" if matched else "/missing" + assert TestClient(app).request(method, path).status_code == ( + 200 if matched else 404 + ) + (span,) = server_spans(exporter) + span_method = "HTTP" if expected == "_OTHER" else method + assert span.name == span_method + (" /items/{value}" if matched else "") + assert span.attributes["http.request.method"] == expected + if expected == "_OTHER": + assert span.attributes["http.request.method_original"] == method + else: + assert "http.request.method_original" not in span.attributes + for name in ("http.server.request.duration", "http.server.active_requests"): + attributes = metric_points(reader=reader, name=name)[0].attributes + assert attributes["http.request.method"] == expected + assert "http.request.method_original" not in attributes + + +def test_frontend_and_static_templates(telemetry, tmp_path): + from fastapi.staticfiles import StaticFiles + + config, exporter, reader = telemetry + (tmp_path / "index.html").write_text("index") + (tmp_path / "script.js").write_text("script") + app = FastAPI(telemetry=config) + app.mount("/static", StaticFiles(directory=tmp_path)) + router = APIRouter() + router.frontend("/ui", directory=tmp_path, fallback="index.html") + app.include_router(router, prefix="/app") + client = TestClient(app) + assert client.get("/static/script.js").text == "script" + assert ( + client.get("/app/ui/arbitrary/path", headers={"accept": "text/html"}).text + == "index" + ) + assert [s.attributes["http.route"] for s in exporter.get_finished_spans()] == [ + "/static/{path}", + "/app/ui/{path}", + ] + + +def test_included_mount_template(telemetry): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + child = FastAPI() + + @child.get("/items/{value}") + def endpoint(value: str): + return value + + router = APIRouter() + router.mount("/tenants/{tenant}", child) + app.include_router(router, prefix="/api") + assert TestClient(app).get("/api/tenants/acme/items/1").json() == "1" + (span,) = server_spans(exporter) + assert span.attributes["http.route"] == "/api/tenants/{tenant}/items/{value}" + + +@pytest.mark.parametrize("explicit_proxy", [False, True]) +@run_in_subprocess +def test_late_global_providers_enable_existing_app(explicit_proxy): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from opentelemetry import metrics, trace + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import InMemoryMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + app = FastAPI( + telemetry={ + "tracer_provider": trace.get_tracer_provider(), + "meter_provider": metrics.get_meter_provider(), + } + if explicit_proxy + else None + ) + client = TestClient(app) + assert client.get("/").status_code == 404 + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + reader = InMemoryMetricReader() + trace.set_tracer_provider(provider) + metrics.set_meter_provider(MeterProvider(metric_readers=[reader])) + assert client.get("/").status_code == 404 + assert len(exporter.get_finished_spans()) == 1 + assert reader.get_metrics_data() is not None + + +def test_base_http_middleware_preserves_context_and_route(telemetry): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + seen = [] + + @app.middleware("http") + async def middleware(request, call_next): + seen.append(trace.get_current_span().get_span_context().span_id) + return await call_next(request) + + @app.get("/items/{item_id}") + async def endpoint(item_id: int): + seen.append(trace.get_current_span().get_span_context().span_id) + return item_id + + assert TestClient(app).get("/items/1").json() == 1 + (span,) = server_spans(exporter) + endpoint_span = next( + s for s in exporter.get_finished_spans() if s.name == "fastapi.endpoint" + ) + assert seen == [span.context.span_id, endpoint_span.context.span_id] + assert endpoint_span.parent.span_id == span.context.span_id + assert span.attributes["http.route"] == "/items/{item_id}" diff --git a/tests/test_telemetry/test_integrations.py b/tests/test_telemetry/test_integrations.py new file mode 100644 index 0000000000..ee37db0b34 --- /dev/null +++ b/tests/test_telemetry/test_integrations.py @@ -0,0 +1,351 @@ +"""Published integration compatibility, isolated because SDKs patch globals.""" + +import os + +import pytest + +from ._subprocess import run_in_subprocess + + +@pytest.mark.parametrize("mode", ["app", "global", "late", "uninstrument"]) +@run_in_subprocess +def test_current_contrib(mode): + import os + + os.environ.update({"OTEL_SEMCONV_STABILITY_OPT_IN": "http"}) + + import fastapi + from fastapi.testclient import TestClient + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import InMemoryMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + from opentelemetry.trace import SpanKind + + native = InMemorySpanExporter() + legacy = InMemorySpanExporter() + p1, p2 = TracerProvider(), TracerProvider() + p1.add_span_processor(SimpleSpanProcessor(native)) + p2.add_span_processor(SimpleSpanProcessor(legacy)) + r1, r2 = InMemoryMetricReader(), InMemoryMetricReader() + m1, m2 = MeterProvider(metric_readers=[r1]), MeterProvider(metric_readers=[r2]) + hooks = [] + + def server_request_hook(span, scope): + hooks.append(scope["path"]) + + if mode == "global": + FastAPIInstrumentor().instrument( + tracer_provider=p2, + meter_provider=m2, + server_request_hook=server_request_hook, + exclude_spans=["send", "receive"], + ) + app = fastapi.FastAPI(telemetry={"tracer_provider": p1, "meter_provider": m1}) + + @app.get("/items/{value}") + def endpoint(value: int): + return value + + client = TestClient(app) + if mode == "late": + assert client.get("/items/1").json() == 1 + if mode != "global": + FastAPIInstrumentor.instrument_app( + app, + tracer_provider=p2, + meter_provider=m2, + server_request_hook=server_request_hook, + exclude_spans=["send", "receive"], + ) + assert client.get("/items/2").json() == 2 + if mode == "late": + # Current contrib cannot replace an already-built stack. Native remains live. + assert len(native.get_finished_spans()) == 8 + assert not legacy.get_finished_spans() + else: + assert not native.get_finished_spans() + assert ( + len([s for s in legacy.get_finished_spans() if s.kind == SpanKind.SERVER]) + == 1 + ) + assert hooks == ["/items/2"] + assert r1.get_metrics_data() is None + if mode == "uninstrument": + FastAPIInstrumentor.uninstrument_app(app) + assert client.get("/items/3").json() == 3 + assert len(native.get_finished_spans()) == 4 + + @app.websocket("/ws") + async def websocket_endpoint(websocket: fastapi.WebSocket): + await websocket.accept() + await websocket.close() + + native.clear() + legacy.clear() + with client.websocket_connect("/ws"): + pass + if mode in ("late", "uninstrument"): + assert len(native.get_finished_spans()) == 3 + assert not legacy.get_finished_spans() + else: + assert not native.get_finished_spans() + assert ( + len( + [ + span + for span in legacy.get_finished_spans() + if span.kind == SpanKind.SERVER + ] + ) + == 1 + ) + assert hooks[-1] == "/ws" + + +@pytest.mark.parametrize("order", ["before", "after"]) +@run_in_subprocess +def test_current_logfire(order): + import logfire + from fastapi import FastAPI, WebSocket + from fastapi.testclient import TestClient + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + from opentelemetry.trace import SpanKind + + legacy, native = InMemorySpanExporter(), InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(native)) + + def configure(): + logfire.configure( + send_to_logfire=False, + console=False, + metrics=False, + additional_span_processors=[SimpleSpanProcessor(legacy)], + ) + + if order == "before": + configure() + app = FastAPI(telemetry={"tracer_provider": provider}) + if order == "after": + configure() + + @app.get("/items/{value}") + def endpoint(value: int): + return value + + mapped = [] + + def mapper(request, attributes): + mapped.append(request.url.path) + return attributes + + logfire.instrument_fastapi(app, request_attributes_mapper=mapper, extra_spans=True) + assert TestClient(app).get("/items/5").json() == 5 + spans = legacy.get_finished_spans() + assert len([s for s in spans if s.kind == SpanKind.SERVER]) == 1, spans + assert any("arguments" in s.name for s in spans) + assert any( + s.attributes is not None + and s.attributes.get("code.function") + in (endpoint.__name__, endpoint.__qualname__) + for s in spans + ) + assert mapped == ["/items/5"] + assert not native.get_finished_spans() + + @app.websocket("/ws/{value}") + async def websocket_endpoint(*, websocket: WebSocket, value: int): + await websocket.accept() + await websocket.close() + + legacy.clear() + with TestClient(app).websocket_connect("/ws/5"): + pass + assert not native.get_finished_spans() + assert ( + len( + [ + span + for span in legacy.get_finished_spans() + if span.kind == SpanKind.SERVER + ] + ) + == 1 + ) + assert mapped[-1] == "/ws/5" + + +@pytest.mark.parametrize("sampling", [0.0, 1.0]) +@run_in_subprocess +def test_current_sentry(sampling): + import sentry_sdk + from fastapi import FastAPI, HTTPException, WebSocket + from fastapi.testclient import TestClient + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ) + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.starlette import StarletteIntegration + from sentry_sdk.transport import Transport + + items = [] + + class LocalTransport(Transport): + def capture_envelope(self, envelope): + items.extend(item.type for item in envelope.items) + + sentry_sdk.init( + dsn="https://public@example.invalid/1", + transport=LocalTransport, + default_integrations=False, + auto_enabling_integrations=False, + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=sampling, + send_client_reports=False, + ) + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + logs = InMemoryLogRecordExporter() + logger = LoggerProvider() + logger.add_log_record_processor(SimpleLogRecordProcessor(logs)) + app = FastAPI(telemetry={"tracer_provider": provider, "logger_provider": logger}) + + @app.get("/items/{value}") + def endpoint(value: int): + if value == 2: + raise ValueError("test") + if value == 3: + raise HTTPException(503, "handled") + return value + + client = TestClient(app, raise_server_exceptions=False) + assert client.get("/items/1").status_code == 200 + assert client.get("/items/2").status_code == 500 + assert client.get("/items/3").status_code == 503 + sentry_sdk.flush() + assert items.count("event") == 2, items + (unhandled,) = logs.get_finished_logs() + assert isinstance(unhandled.log_record.exception, ValueError) + assert items.count("transaction") == (3 if sampling else 0), items + assert len(exporter.get_finished_spans()) == 10 + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + raise ValueError("websocket failed") + + with pytest.raises(ValueError, match="websocket failed"): + with client.websocket_connect("/ws"): + pass # pragma: no cover + sentry_sdk.flush() + assert items.count("event") == 3, items + assert items.count("transaction") == (4 if sampling else 0), items + assert len(exporter.get_finished_spans()) == 13 + assert len(logs.get_finished_logs()) == 2 + + +@run_in_subprocess +def test_api_only_and_no_implicit_sdk_import(): + import sys + from importlib.abc import MetaPathFinder + + class BlockSDK(MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.startswith(("opentelemetry.sdk", "opentelemetry.exporter")): + raise AssertionError( + f"Unexpected optional import: {fullname}" + ) # pragma: no cover + + sys.meta_path.insert(0, BlockSDK()) + from fastapi import FastAPI, WebSocket + from fastapi.testclient import TestClient + + app = FastAPI() + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + await websocket.close() + + with TestClient(app) as client: + assert client.get("/").status_code == 404 + with client.websocket_connect("/ws"): + pass + assert not any(name.startswith("opentelemetry.sdk") for name in sys.modules) + + +def test_inactive_sentry_does_not_disable_native(telemetry): + import sentry_sdk + from fastapi import FastAPI + from fastapi.testclient import TestClient + + config, exporter, reader = telemetry + assert sentry_sdk.get_client().get_integration("fastapi") is None + assert TestClient(FastAPI(telemetry=config)).get("/").status_code == 404 + assert len(exporter.get_finished_spans()) == 1 + + +@run_in_subprocess +def test_logfire_global_provider_without_fastapi_instrumentor(): + import logfire + from fastapi import FastAPI + from fastapi.testclient import TestClient + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + exporter = InMemorySpanExporter() + logfire.configure( + send_to_logfire=False, + console=False, + metrics=False, + additional_span_processors=[SimpleSpanProcessor(exporter)], + ) + app = FastAPI() + + @app.get("/") + def endpoint(): + return "ok" + + assert TestClient(app).get("/").json() == "ok" + spans = exporter.get_finished_spans() + assert {span.name for span in spans} == { + "GET /", + "fastapi.dependencies", + "fastapi.endpoint", + "fastapi.serialization", + } + assert all( + span.instrumentation_scope is not None + and span.instrumentation_scope.name == "fastapi" + for span in spans + ) + + +def test_environment_isolation_removes_export_credentials(monkeypatch): + from .conftest import remove_export_environment + + for name in ["OTEL_EXPORTER_OTLP_HEADERS", "LOGFIRE_TOKEN", "SENTRY_DSN"]: + monkeypatch.setenv(name, "test-only") + remove_export_environment(monkeypatch) + assert not any( + name.startswith(("OTEL_", "LOGFIRE_", "SENTRY_")) for name in os.environ + ) diff --git a/tests/test_telemetry/test_native_integrations.py b/tests/test_telemetry/test_native_integrations.py new file mode 100644 index 0000000000..e54d0d859d --- /dev/null +++ b/tests/test_telemetry/test_native_integrations.py @@ -0,0 +1,316 @@ +"""Integration prototypes using standard OpenTelemetry SDK extension points.""" + +import pytest + +from ._subprocess import run_in_subprocess + + +@run_in_subprocess +def test_logfire_native_spans_and_exception_logs(): + import logfire + from fastapi import Depends, FastAPI, WebSocket, routing + from fastapi.telemetry import get_telemetry_data + from fastapi.testclient import TestClient + from opentelemetry import trace + from opentelemetry.sdk._logs import LogRecordProcessor + from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ) + from opentelemetry.sdk.trace import SpanProcessor + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + original = ( + routing.get_websocket_app, + routing.get_request_handler, + routing.run_endpoint_function, + routing.solve_dependencies, + ) + spans = InMemorySpanExporter() + logs = InMemoryLogRecordExporter() + dependency_value = object() + observed = [] + + class Arguments(SpanProcessor): + def on_start(self, span, parent_context=None): + if span.name != "fastapi.endpoint": + return + data = get_telemetry_data(parent_context) + assert data is not None + connection = data.request or data.websocket + assert connection is not None + assert data.values is not None + if connection.url.path in ("/error", "/ws/error"): + return + assert data.values["service"] is dependency_value + assert data.values["item_id"] == 42 + observed.append(connection.url.path) + span.set_attribute("test.item_id", data.values["item_id"]) + + class Validation(LogRecordProcessor): + def on_emit(self, log_record): + record = log_record.log_record + if record.event_name != "fastapi.validation.failed": + return + data = get_telemetry_data(record.context) + assert data is not None + connection = data.request or data.websocket + assert connection is not None + assert data.errors is not None + assert data.errors[0]["input"] == "invalid-item-id" + observed.append(connection.url.path) + record.attributes["test.error_types"] = tuple( + error["type"] for error in data.errors + ) + + def shutdown(self): + pass + + def force_flush(self, timeout_millis=30000): + return True + + logfire.configure( + send_to_logfire=False, + console=False, + metrics=False, + additional_span_processors=[Arguments(), SimpleSpanProcessor(spans)], + advanced=logfire.AdvancedOptions( + log_record_processors=[Validation(), SimpleLogRecordProcessor(logs)] + ), + ) + app = FastAPI() + contexts = [] + + def dependency(): + assert trace.get_current_span().get_span_context().is_valid + return dependency_value + + @app.get("/sync") + def sync_endpoint(*, item_id: int, service=Depends(dependency)): + return "ok" + + @app.get("/async") + async def async_endpoint(*, item_id: int, service=Depends(dependency)): + return "ok" + + @app.get("/error") + async def error_endpoint(): + contexts.append(trace.get_current_span().get_span_context()) + raise ValueError("native error") + + @app.websocket("/ws") + async def websocket_endpoint( + *, websocket: WebSocket, item_id: int, service=Depends(dependency) + ): + await websocket.accept() + await websocket.send_text("ok") + await websocket.close() + + @app.websocket("/ws/error") + async def websocket_error(websocket: WebSocket): + contexts.append(trace.get_current_span().get_span_context()) + await websocket.accept() + raise ValueError("native error") + + client = TestClient(app, raise_server_exceptions=False) + assert client.get("/sync?item_id=42").json() == "ok" + assert client.get("/async?item_id=42").json() == "ok" + assert client.get("/error").status_code == 500 + assert client.get("/async?item_id=invalid-item-id").status_code == 422 + from starlette.websockets import WebSocketDisconnect + + with client.websocket_connect("/ws?item_id=42") as websocket: + assert websocket.receive_text() == "ok" + with pytest.raises(ValueError, match="native error"): + with client.websocket_connect("/ws/error"): + pass # pragma: no cover + with pytest.raises(WebSocketDisconnect) as caught: + with client.websocket_connect("/ws?item_id=invalid-item-id"): + pass # pragma: no cover + assert caught.value.code == 1008 + logfire.force_flush() + finished = spans.get_finished_spans() + assert len([s for s in finished if s.kind == trace.SpanKind.SERVER]) == 7 + assert ( + len( + [ + s + for s in finished + if s.name == "fastapi.endpoint" + and s.attributes is not None + and s.attributes.get("logfire.span_type") != "pending_span" + ] + ) + == 5 + ) + data, validation, websocket_error_log, websocket_validation = ( + logs.get_finished_logs() + ) + assert websocket_error_log.log_record.exception is not None + assert websocket_error_log.log_record.trace_id == contexts[1].trace_id + assert websocket_validation.log_record.event_name == "fastapi.validation.failed" + assert websocket_validation.log_record.attributes is not None + assert "test.error_types" in websocket_validation.log_record.attributes + assert len([span for span in finished if span.name == "WS /ws"]) == 2 + assert validation.log_record.event_name == "fastapi.validation.failed" + assert validation.log_record.attributes is not None + assert "test.error_types" in validation.log_record.attributes + assert observed == ["/sync", "/async", "/async", "/ws", "/ws"] + arguments = [s for s in finished if s.attributes and "test.item_id" in s.attributes] + assert arguments + assert all(s.attributes and s.attributes["test.item_id"] == 42 for s in arguments) + assert get_telemetry_data() is None + + assert data.log_record.attributes is not None + assert data.log_record.attributes["exception.message"] == "native error" + assert data.log_record.trace_id == contexts[0].trace_id + assert original == ( + routing.get_websocket_app, + routing.get_request_handler, + routing.run_endpoint_function, + routing.solve_dependencies, + ) + + +@pytest.mark.parametrize("sampled", [True, False]) +@run_in_subprocess +def test_sentry_standard_log_processor(sampled): + import sentry_sdk + from fastapi import FastAPI, WebSocket, routing + from fastapi.telemetry import get_telemetry_data + from fastapi.testclient import TestClient + from opentelemetry import trace + from opentelemetry.sdk._logs import LoggerProvider, LogRecordProcessor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + from opentelemetry.sdk.trace.sampling import ALWAYS_OFF, ALWAYS_ON + from sentry_sdk.integrations.otlp import OTLPIntegration + from sentry_sdk.transport import Transport + + original = ( + routing.get_websocket_app, + routing.get_request_handler, + routing.run_endpoint_function, + routing.solve_dependencies, + ) + items = [] + + class LocalTransport(Transport): + def capture_envelope(self, envelope): + items.extend( + item.payload.json for item in envelope.items if item.type == "event" + ) + + class SentryErrors(LogRecordProcessor): + def on_emit(self, log_record): + if ( + log_record.instrumentation_scope.name == "fastapi" + and log_record.log_record.exception is not None + ): + data = get_telemetry_data(log_record.log_record.context) + assert data is not None + connection = data.request or data.websocket + assert connection is not None + assert data.values is not None + + def enrich(event, hint): + event["request"] = { + "url": str(connection.url.replace(query=None)), + "query_string": str(connection.query_params), + "data": data.body, + } + if data.request is not None: + event["request"]["method"] = data.request.method + return event + + with sentry_sdk.new_scope() as scope: + scope.add_event_processor(enrich) + sentry_sdk.capture_exception(log_record.log_record.exception) + + def shutdown(self): + pass + + def force_flush(self, timeout_millis=30000): + return True + + sentry_sdk.init( + dsn="https://public@example.invalid/1", + transport=LocalTransport, + default_integrations=False, + auto_enabling_integrations=False, + integrations=[ + OTLPIntegration(setup_otlp_traces_exporter=False, setup_propagator=False) + ], + send_client_reports=False, + ) + logger = LoggerProvider() + logger.add_log_record_processor(SentryErrors()) + tracer = TracerProvider(sampler=ALWAYS_ON if sampled else ALWAYS_OFF) + exporter = InMemorySpanExporter() + tracer.add_span_processor(SimpleSpanProcessor(exporter)) + app = FastAPI(telemetry={"tracer_provider": tracer, "logger_provider": logger}) + contexts = [] + + @app.post("/sync") + def sync_endpoint(payload: dict): + contexts.append(trace.get_current_span().get_span_context()) + raise ValueError("sync error") + + @app.post("/async") + async def async_endpoint(payload: dict): + contexts.append(trace.get_current_span().get_span_context()) + raise ValueError("async error") + + @app.websocket("/ws") + async def websocket_endpoint(*, websocket: WebSocket, item_id: int): + contexts.append(trace.get_current_span().get_span_context()) + await websocket.accept() + raise ValueError("websocket error") + + client = TestClient(app, raise_server_exceptions=False) + assert client.post("/sync?item_id=1", json={"value": "sync"}).status_code == 500 + assert client.post("/async?item_id=2", json={"value": "async"}).status_code == 500 + with pytest.raises(ValueError, match="websocket error"): + with client.websocket_connect("/ws?item_id=3"): + pass # pragma: no cover + assert logger.force_flush() + sentry_sdk.flush() + assert len(items) == 3 + assert [item["request"] for item in items] == [ + { + "method": "POST", + "url": "http://testserver/sync", + "query_string": "item_id=1", + "data": {"value": "sync"}, + }, + { + "method": "POST", + "url": "http://testserver/async", + "query_string": "item_id=2", + "data": {"value": "async"}, + }, + { + "url": "ws://testserver/ws", + "query_string": "item_id=3", + "data": None, + }, + ] + assert [item["contexts"]["trace"]["trace_id"] for item in items] == [ + format(ctx.trace_id, "032x") for ctx in contexts + ] + assert all(item["exception"]["values"][0]["stacktrace"]["frames"] for item in items) + assert bool(exporter.get_finished_spans()) == sampled + assert original == ( + routing.get_websocket_app, + routing.get_request_handler, + routing.run_endpoint_function, + routing.solve_dependencies, + ) + logger.shutdown() + tracer.shutdown() diff --git a/tests/test_telemetry/test_operations.py b/tests/test_telemetry/test_operations.py new file mode 100644 index 0000000000..a9112ca22e --- /dev/null +++ b/tests/test_telemetry/test_operations.py @@ -0,0 +1,93 @@ +import threading +from typing import Annotated + +import pytest +from fastapi import Depends, FastAPI, HTTPException +from fastapi.testclient import TestClient +from opentelemetry import baggage, trace +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.trace import SpanKind, StatusCode + + +@pytest.mark.parametrize("sync", [False, True]) +def test_native_operations_and_worker_context(telemetry, sync): + config, exporter, reader = telemetry + started = {} + executed = {} + + class Processor(SpanProcessor): + def on_start(self, span, parent_context=None): + started[span.name] = (threading.get_ident(), span.get_span_context()) + + config["tracer_provider"].add_span_processor(Processor()) + app = FastAPI(telemetry=config) + + def dependency(): + executed["dependency"] = trace.get_current_span().get_span_context() + return "private argument" + + def implementation(value: Annotated[str, Depends(dependency)]): + executed["endpoint"] = ( + threading.get_ident(), + trace.get_current_span().get_span_context(), + ) + assert value == "private argument" + return baggage.get_baggage("example") + + async def async_endpoint(value: Annotated[str, Depends(dependency)]): + return implementation(value) + + app.get("/")(implementation if sync else async_endpoint) + assert ( + TestClient(app).get("/", headers={"baggage": "example=value"}).json() == "value" + ) + assert started["fastapi.endpoint"] == executed["endpoint"] + assert started["fastapi.dependencies"][1] == executed["dependency"] + assert ( + started["fastapi.endpoint"][0] != started["fastapi.dependencies"][0] + ) == sync + spans = exporter.get_finished_spans() + server = next(s for s in spans if s.kind == SpanKind.SERVER) + children = [s for s in spans if s is not server] + assert {s.name for s in children} == { + "fastapi.dependencies", + "fastapi.endpoint", + "fastapi.serialization", + } + assert all(s.parent.span_id == server.context.span_id for s in children) + assert all(s.context.trace_id == server.context.trace_id for s in children) + assert all("code.function.name" in s.attributes for s in children) + assert "private argument" not in repr([s.attributes for s in spans]) + assert baggage.get_baggage("example") is None + assert not trace.get_current_span().get_span_context().is_valid + + +@pytest.mark.parametrize( + "failure", ["dependency", "endpoint", "serialization", "handled"] +) +def test_operation_errors(telemetry, failure): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + async def dependency(): + if failure == "dependency": + raise ValueError("dependency failure") + + @app.get("/", dependencies=[Depends(dependency)], response_model=int) + async def endpoint(): + if failure == "endpoint": + raise ValueError("endpoint failure") + if failure == "handled": + raise HTTPException(400) + return "not an integer" + + response = TestClient(app, raise_server_exceptions=False).get("/") + assert response.status_code == (400 if failure == "handled" else 500) + spans = exporter.get_finished_spans() + if failure == "handled": + assert all(s.status.status_code == StatusCode.UNSET for s in spans) + else: + name = "dependencies" if failure == "dependency" else failure + failed = next(s for s in spans if s.name == f"fastapi.{name}") + assert failed.status.status_code == StatusCode.ERROR + assert all(not s.events for s in spans) diff --git a/tests/test_telemetry/test_runtime.py b/tests/test_telemetry/test_runtime.py new file mode 100644 index 0000000000..5adccf0bfe --- /dev/null +++ b/tests/test_telemetry/test_runtime.py @@ -0,0 +1,613 @@ +import asyncio +import threading +from unittest.mock import Mock + +import pytest +from fastapi import FastAPI +from fastapi.exceptions import FastAPIError +from fastapi.telemetry import _runtime as runtime +from fastapi.testclient import TestClient + +from ._otlp import otlp_collector +from ._subprocess import run_in_subprocess + + +def test_otlp_collector_does_not_resolve_hostname(monkeypatch): + # Reverse DNS during HTTPServer binding caused macOS CI timeouts. + lookup = Mock() + monkeypatch.setattr("socket.getfqdn", lookup) + with otlp_collector(): + pass + lookup.assert_not_called() + + +@pytest.mark.parametrize("base_path", ["", "/collector/"]) +@run_in_subprocess +def test_real_otlp_export_and_repeated_lifespans(base_path): + import os + from contextlib import asynccontextmanager + + from fastapi import FastAPI + from fastapi.telemetry import _runtime + from fastapi.testclient import TestClient + from opentelemetry import _logs, trace + from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import ( + ExportLogsServiceRequest, + ) + from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( + ExportMetricsServiceRequest, + ) + from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( + ExportTraceServiceRequest, + ) + from opentelemetry.proto.trace.v1.trace_pb2 import Span + + with otlp_collector() as (base, received): + prefix = base_path.rstrip("/") + os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = base + base_path + os.environ["OTEL_SERVICE_NAME"] = "native-test" + os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "x-test=value" + os.environ["OTEL_RESOURCE_ATTRIBUTES"] = "test.resource=example" + os.environ["OTEL_BSP_SCHEDULE_DELAY"] = "60000" + os.environ["OTEL_BLRP_SCHEDULE_DELAY"] = "60000" + os.environ["OTEL_METRIC_EXPORT_INTERVAL"] = "60000" + seen = [] + + @asynccontextmanager + async def lifespan(app): + seen.append(trace.get_tracer_provider()) + yield + + app = FastAPI(lifespan=lifespan) + + @app.get("/items/{value}") + def endpoint(value: int): + _logs.get_logger("test").emit(body="request processed") + return value + + try: + for _ in range(2): + with TestClient(app) as client: + assert client.get("/items/3").json() == 3 + assert len(_runtime._owned) == 3 + assert seen[0] is seen[1] + assert all(headers.get("x-test") == "value" for _, _, headers in received) + traces = [ + ExportTraceServiceRequest.FromString(body) + for path, body, _ in received + if path == prefix + "/v1/traces" + ] + spans = [ + span + for request in traces + for resource in request.resource_spans + for scope in resource.scope_spans + for span in scope.spans + ] + assert len(spans) == 8 + servers = [span for span in spans if span.kind == Span.SPAN_KIND_SERVER] + assert len(servers) == 2 + assert all(span.name == "GET /items/{value}" for span in servers) + metric_requests = [ + ExportMetricsServiceRequest.FromString(body) + for path, body, _ in received + if path == prefix + "/v1/metrics" + ] + assert metric_requests + resources = metric_requests[-1].resource_metrics + resource_attributes = { + a.key: a.value.string_value for a in resources[0].resource.attributes + } + assert resource_attributes["service.name"] == "native-test" + assert resource_attributes["test.resource"] == "example" + histograms = [ + metric.histogram + for resource in resources + for scope in resource.scope_metrics + for metric in scope.metrics + if metric.name == "http.server.request.duration" + ] + assert histograms[0].data_points[0].count == 2 + log_requests = [ + ExportLogsServiceRequest.FromString(body) + for path, body, _ in received + if path == prefix + "/v1/logs" + ] + records = [ + record + for request in log_requests + for resource in request.resource_logs + for scope in resource.scope_logs + for record in scope.log_records + ] + assert len(records) == 2 + assert all( + record.body.string_value == "request processed" for record in records + ) + finally: + _runtime._shutdown() + + +@pytest.mark.parametrize( + "env", + [ + { + "OTEL_TRACES_EXPORTER": "", + "OTEL_METRICS_EXPORTER": "", + "OTEL_LOGS_EXPORTER": "", + "OTEL_EXPORTER_OTLP_PROTOCOL": "", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL": "", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": "", + }, + { + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "", + }, + ], +) +def test_empty_environment_uses_defaults(monkeypatch, env): + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318/collector/" + ) + for name, value in env.items(): + monkeypatch.setenv(name, value) + for signal in ("TRACES", "METRICS", "LOGS"): + assert runtime._export_endpoint(signal) == ( + f"http://127.0.0.1:4318/collector/v1/{signal.lower()}" + ) + + +@pytest.mark.parametrize("protocol", ["http/protobuf", "grpc"]) +def test_empty_signal_protocol_uses_general_protocol(monkeypatch, protocol): + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://127.0.0.1:4318/v1/traces" + ) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", protocol) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "") + if protocol == "http/protobuf": + assert runtime._export_endpoint("TRACES") == "http://127.0.0.1:4318/v1/traces" + else: + with pytest.raises(FastAPIError, match="http/protobuf"): + runtime._export_endpoint("TRACES") + + +@pytest.mark.parametrize("signal", ["TRACES", "METRICS", "LOGS"]) +def test_signal_endpoint_overrides_general_endpoint(monkeypatch, signal): + endpoint = "http://127.0.0.1:4318/custom/" + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "invalid") + monkeypatch.setenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT", endpoint) + assert runtime._export_endpoint(signal) == endpoint + + +@pytest.mark.parametrize( + "env,message", + [ + ({"OTEL_EXPORTER_OTLP_ENDPOINT": "not-a-url"}, "absolute HTTP"), + ( + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + }, + "http/protobuf", + ), + ( + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1", + "OTEL_TRACES_EXPORTER": "console", + }, + "otlp or none", + ), + ], +) +@run_in_subprocess +def test_invalid_configuration_reports_startup_failure(env, message): + import os + + os.environ.update(env) + + import asyncio + + from fastapi import FastAPI + from fastapi.exceptions import FastAPIError + + messages = [] + + async def receive(): + return {"type": "lifespan.startup"} + + async def send(message): + messages.append(message) + + async def run(): + with pytest.raises(FastAPIError) as exc_info: + await FastAPI()({"type": "lifespan", "state": {}}, receive, send) + assert message in str(exc_info.value) + + asyncio.run(run()) + assert len(messages) == 1, messages + assert messages[0]["type"] == "lifespan.startup.failed" + + +@pytest.mark.parametrize( + "env", + [ + {}, + {"OTEL_SERVICE_NAME": "no-endpoint"}, + {"OTEL_TRACES_EXPORTER": "otlp", "OTEL_METRICS_EXPORTER": "otlp"}, + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1", + "OTEL_TRACES_EXPORTER": "none", + "OTEL_METRICS_EXPORTER": "none", + "OTEL_LOGS_EXPORTER": "none", + }, + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1", + "OTEL_SDK_DISABLED": "true", + }, + ], +) +@run_in_subprocess +def test_no_implicit_export(env): + import os + + os.environ.update(env) + + from fastapi import FastAPI + from fastapi.telemetry import _runtime + from fastapi.testclient import TestClient + + with TestClient(FastAPI()) as client: + assert client.get("/").status_code == 404 + assert _runtime._owned == [] + + +@run_in_subprocess +def test_missing_sdk_diagnostic(): + import os + + os.environ.update( + {"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "http://127.0.0.1:1/metrics"} + ) + + import sys + from importlib.abc import MetaPathFinder + + class BlockSDK(MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.startswith(("opentelemetry.sdk", "opentelemetry.exporter")): + raise ImportError("SDK absent") + + sys.meta_path.insert(0, BlockSDK()) + from fastapi import FastAPI + from fastapi.exceptions import FastAPIError + from fastapi.testclient import TestClient + + with pytest.raises(FastAPIError) as exc_info: + with TestClient(FastAPI()): + pass # pragma: no cover + assert "fastapi[opentelemetry]" in str(exc_info.value) + + +@run_in_subprocess +def test_external_globals_are_unchanged_when_auto_configuration_is_disabled(): + import os + + os.environ.update({"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1"}) + + from fastapi import FastAPI + from fastapi.telemetry import _runtime + from fastapi.testclient import TestClient + from opentelemetry import _logs, metrics, trace + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + exporter = InMemorySpanExporter() + tp = TracerProvider() + tp.add_span_processor(SimpleSpanProcessor(exporter)) + mp = MeterProvider() + lp = LoggerProvider() + _logs.set_logger_provider(lp) + trace.set_tracer_provider(tp) + metrics.set_meter_provider(mp) + for _ in range(2): + with TestClient(FastAPI(telemetry={"auto_configure": False})) as client: + client.get("/") + assert trace.get_tracer_provider() is tp + assert metrics.get_meter_provider() is mp + assert _logs.get_logger_provider() is lp + assert not _runtime._owned + assert len(exporter.get_finished_spans()) == 2 + + +def test_auto_configuration_opt_out(monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "invalid") + with TestClient(FastAPI(telemetry={"auto_configure": False})) as client: + assert client.get("/").status_code == 404 + + +def test_owned_flush_failure_does_not_break_shutdown(monkeypatch, caplog): + calls = [] + + class FailingProvider: + def force_flush(self): + raise RuntimeError("flush failed") + + def shutdown(self): + raise RuntimeError("shutdown failed") + + class Provider: + def force_flush(self): + calls.append("flush") + + def shutdown(self): + calls.append("shutdown") + + monkeypatch.setattr(runtime, "_owned", [FailingProvider(), Provider()]) + with TestClient(FastAPI()) as client: + assert client.get("/").status_code == 404 + runtime._shutdown() + runtime._shutdown() + assert calls == ["flush", "shutdown"] + assert caplog.text.count("FastAPI telemetry cleanup failed") == 2 + + +@pytest.mark.parametrize( + "message_type", + [ + "lifespan.startup.failed", + "lifespan.shutdown.failed", + "lifespan.shutdown.complete", + ], +) +def test_lifespan_flush_runs_off_event_loop(monkeypatch, message_type): + calls = [] + flush_threads = [] + event_loop_thread = threading.get_ident() + + class Provider: + def force_flush(self): + flush_threads.append(threading.get_ident()) + calls.append("flush") + + def shutdown(self): + calls.append("shutdown") + + monkeypatch.setattr(runtime, "_owned", [Provider()]) + + async def app(scope, receive, send): + assert await receive() == {"type": "lifespan.startup"} + await send({"type": message_type}) + + async def receive(): + return {"type": "lifespan.startup"} + + async def send(message): + calls.append(message["type"]) + + asyncio.run( + runtime.lifespan( + config=FastAPI()._telemetry, app=app, scope={}, receive=receive, send=send + ) + ) + assert calls == ["flush", message_type] + assert len(flush_threads) == 1 + assert flush_threads[0] != event_loop_thread + runtime._shutdown() + runtime._shutdown() + assert calls == ["flush", message_type, "shutdown"] + + +def test_registration_provider_prefers_public_metric_reader(monkeypatch): + from logfire._internal.metrics import ProxyMeterProvider + from opentelemetry.metrics import NoOpMeterProvider + + provider = NoOpMeterProvider() + proxy = ProxyMeterProvider(provider=provider) + assert runtime._registration_provider(proxy) is provider + + monkeypatch.setattr(proxy, "add_metric_reader", lambda reader: None, raising=False) + assert runtime._registration_provider(proxy) is proxy + + +@pytest.mark.parametrize("wrapped_meter", [False, True]) +def test_concurrent_provider_owner_wins(monkeypatch, wrapped_meter): + from opentelemetry import _logs, metrics, trace + from opentelemetry.sdk import _logs as sdk_logs + from opentelemetry.sdk import metrics as sdk_metrics + from opentelemetry.sdk import trace as sdk_trace + + stopped = [] + winners = [] + for name, api, sdk, cls_name in [ + ("tracer", trace, sdk_trace, "TracerProvider"), + ("meter", metrics, sdk_metrics, "MeterProvider"), + ("logger", _logs, sdk_logs, "LoggerProvider"), + ]: + original = getattr(sdk, cls_name) + winner = original(shutdown_on_exit=False) + winners.append(winner) + + class Provider(original): + def shutdown(self, _name=name): + stopped.append(_name) + super().shutdown() + + if wrapped_meter and name == "meter": + from logfire._internal.metrics import ProxyMeterProvider + + winner = ProxyMeterProvider(winner) + current = [getattr(api, f"get_{name}_provider")()] + monkeypatch.setattr(sdk, cls_name, Provider) + monkeypatch.setattr(api, f"get_{name}_provider", lambda c=current: c[0]) + monkeypatch.setattr( + api, + f"set_{name}_provider", + lambda provider, c=current, w=winner: c.__setitem__(0, w), + ) + monkeypatch.setattr(runtime, "_owned", []) + monkeypatch.setattr(runtime, "_configured", []) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1") + runtime._configure_from_environment(FastAPI()._telemetry) + assert stopped == ["tracer", "meter", "logger"] + assert [provider for _, provider in runtime._configured] == winners + assert len(runtime._owned) == 3 + assert all(component not in winners for component in runtime._owned) + runtime._shutdown() + for winner in winners: + winner.shutdown() + + +@pytest.mark.skipif( + not hasattr(__import__("os"), "fork"), reason="POSIX worker lifecycle" +) +@run_in_subprocess +def test_environment_export_initializes_after_fork(): + import os + import threading + from http.server import BaseHTTPRequestHandler + + from fastapi import FastAPI + from fastapi.telemetry import _runtime + from fastapi.testclient import TestClient + + from tests.test_telemetry._otlp import _CollectorServer + + received = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers["Content-Length"])) + received.append(self.path) + self.send_response(200) + self.end_headers() + + def log_message(self, format, *args): + pass + + server = _CollectorServer(("127.0.0.1", 0), Handler) + os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = ( + f"http://127.0.0.1:{server.server_port}/traces" + ) + # Importing and constructing before fork must not create providers. + app = FastAPI() + assert not _runtime._owned + pid = os.fork() + if pid == 0: + try: + with TestClient(app) as client: + assert client.get("/").status_code == 404 + assert len(_runtime._owned) == 1 + _runtime._shutdown() + except BaseException: # pragma: no cover + os._exit(1) + os._exit(0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + _, status = os.waitpid(pid, 0) + assert status == 0, status + assert received == ["/traces"], received + assert not _runtime._owned + server.shutdown() + server.server_close() + + +@run_in_subprocess +def test_real_otlp_exception_export_without_traces_or_metrics(): + import os + + from fastapi import FastAPI + from fastapi.telemetry import _runtime + from fastapi.testclient import TestClient + from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import ( + ExportLogsServiceRequest, + ) + + with otlp_collector() as (base, received): + os.environ["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"] = base + "/logs" + os.environ["OTEL_SERVICE_NAME"] = "errors-only" + app = FastAPI() + + @app.get("/items/{item_id}") + def endpoint(item_id: int): + raise ValueError("exported exception") + + try: + for _ in range(2): + with TestClient(app, raise_server_exceptions=False) as client: + assert client.get("/items/1").status_code == 500 + assert len(_runtime._owned) == 1 + assert all(path == "/logs" for path, _, _ in received) + requests = [ + ExportLogsServiceRequest.FromString(body) for _, body, _ in received + ] + records = [ + record + for request in requests + for resource in request.resource_logs + for scope in resource.scope_logs + for record in scope.log_records + ] + assert len(records) == 2 + for record in records: + attributes = {a.key: a.value.string_value for a in record.attributes} + assert attributes["exception.type"] == "ValueError" + assert attributes["exception.message"] == "exported exception" + assert attributes["http.route"] == "/items/{item_id}" + assert "in endpoint" in attributes["exception.stacktrace"] + assert ( + "ValueError: exported exception" + in attributes["exception.stacktrace"] + ) + finally: + _runtime._shutdown() + + +@pytest.mark.parametrize("name", ["tracer", "meter", "logger"]) +def test_unsupported_provider_reports_configuration_error(monkeypatch, name): + signal = {"tracer": "TRACES", "meter": "METRICS", "logger": "LOGS"}[name] + monkeypatch.setenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT", "http://127.0.0.1:1") + app = FastAPI(telemetry={f"{name}_provider": object()}) + with pytest.raises(FastAPIError, match="does not support.*auto_configure"): + with TestClient(app): + pass # pragma: no cover + + +@pytest.mark.parametrize("error_type", [ValueError, AttributeError]) +def test_failed_registration_closes_new_exporter(monkeypatch, error_type): + from opentelemetry.exporter.otlp.proto.http import trace_exporter + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + stopped = [] + + class Exporter(InMemorySpanExporter): + def __init__(self, *, endpoint): + super().__init__() + + def shutdown(self): + stopped.append(True) + super().shutdown() + + provider = TracerProvider(shutdown_on_exit=False) + + def fail(component): + raise error_type("registration failed") + + monkeypatch.setattr(provider, "add_span_processor", fail) + monkeypatch.setattr(trace_exporter, "OTLPSpanExporter", Exporter) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://127.0.0.1:1") + app = FastAPI(telemetry={"tracer_provider": provider}) + with pytest.raises(error_type, match="registration failed"): + with TestClient(app): + pass # pragma: no cover + assert stopped == [True] + provider.shutdown() diff --git a/tests/test_telemetry/test_semantic_conventions.py b/tests/test_telemetry/test_semantic_conventions.py new file mode 100644 index 0000000000..a687e4c725 --- /dev/null +++ b/tests/test_telemetry/test_semantic_conventions.py @@ -0,0 +1,163 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from .conftest import metric_points, server_spans + + +@pytest.mark.parametrize( + "scheme,host,expected", + [ + ( + "http", + "public.example", + {"server.address": "public.example", "server.port": 80}, + ), + ( + "https", + "public.example", + {"server.address": "public.example", "server.port": 443}, + ), + ( + "http", + "public.example:8443", + {"server.address": "public.example", "server.port": 8443}, + ), + ( + "http", + "[2001:db8::1]:8443", + {"server.address": "2001:db8::1", "server.port": 8443}, + ), + ("http", "[2001:db8::1]", {"server.address": "2001:db8::1", "server.port": 80}), + ("http", "public.example:invalid", {}), + ("http", "[invalid", {}), + ("http", "public.example:99999", {}), + ("http", "user:password@public.example", {}), + ("http", "public.example/path", {}), + ("http", "", {}), + ("custom", "public.example", {"server.address": "public.example"}), + ], +) +def test_request_authority_is_only_on_spans(telemetry, scheme, host, expected): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.get("/") + async def endpoint(): + return "ok" + + async def asgi(scope, receive, send): + scope["scheme"] = scheme + await app(scope, receive, send) + + client = TestClient(asgi, base_url="http://listener:8000") + assert client.get("/", headers={"host": host}).json() == "ok" + (span,) = server_spans(exporter) + assert { + key: value + for key, value in span.attributes.items() + if key.startswith("server.") + } == expected + for name in ("http.server.request.duration", "http.server.active_requests"): + (point,) = metric_points(reader=reader, name=name) + assert not any(key.startswith("server.") for key in point.attributes) + + +def test_server_fallback_without_host_header(telemetry): + config, exporter, _ = telemetry + app = FastAPI(telemetry=config) + + async def asgi(scope, receive, send): + scope["headers"] = [ + (name, value) for name, value in scope["headers"] if name != b"host" + ] + await app(scope, receive, send) + + assert TestClient(asgi, base_url="http://listener:8000").get("/").status_code == 404 + (span,) = server_spans(exporter) + assert span.attributes["server.address"] == "listener" + assert span.attributes["server.port"] == 8000 + + +@pytest.mark.parametrize( + "query,expected", + [ + ("", None), + ( + "?tag=one&tag=two&empty=&flag", + "tag=one&tag=two&empty=&flag=", + ), + ( + "?X-Amz-Signature=secret&X-Amz-Credential=secret&X-Amz-Security-Token=secret&sig=secret&X-Goog-Signature=secret", + "X-Amz-Signature=REDACTED&X-Amz-Credential=REDACTED&X-Amz-Security-Token=REDACTED&sig=REDACTED&X-Goog-Signature=REDACTED", + ), + ( + "?%70age=2&q=fastapi%20tutorial&sort%20by=name", + "page=2&q=fastapi+tutorial&sort+by=name", + ), + ( + "?%73ig=secret&q=hello%26world", + "sig=REDACTED&q=hello%26world", + ), + ( + "?x-amz-signature=keep&x-amz-credential=keep&x-amz-security-token=keep&SIG=keep&x-goog-signature=keep", + "x-amz-signature=keep&x-amz-credential=keep&x-amz-security-token=keep&SIG=keep&x-goog-signature=keep", + ), + ( + "?sig=one&sig=two&sig=&q=hello+world", + "sig=REDACTED&sig=REDACTED&sig=REDACTED&q=hello+world", + ), + ], +) +def test_url_attributes_do_not_split_metrics(telemetry, query, expected): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.get("/items/{item_id}") + async def endpoint(item_id: int): + return item_id + + client = TestClient(app) + for item_id in (1, 2): + assert ( + client.get( + f"/items/{item_id}{query}", headers={"host": f"host-{item_id}.example"} + ).json() + == item_id + ) + first, second = server_spans(exporter) + assert first.attributes["url.path"] == "/items/1" + assert second.attributes["url.path"] == "/items/2" + assert first.attributes.get("url.query") == expected + assert second.attributes.get("url.query") == expected + (point,) = metric_points(reader=reader) + assert point.count == 2 + assert "url.path" not in point.attributes + assert "url.query" not in point.attributes + assert point.attributes["http.route"] == "/items/{item_id}" + + +@pytest.mark.parametrize("metrics", [None, False, True]) +def test_active_requests_follow_metrics_setting(telemetry, metrics): + config, _, reader = telemetry + if metrics is not None: + config["metrics"] = metrics + app = FastAPI(telemetry=config) + seen = [] + + @app.get("/") + async def endpoint(): + seen.extend( + point.value + for point in metric_points( + reader=reader, name="http.server.active_requests" + ) + ) + return "ok" + + assert TestClient(app).get("/").json() == "ok" + active = metrics is not False + assert seen == ([1] if active else []) + points = metric_points(reader=reader, name="http.server.active_requests") + assert [point.value for point in points] == ([0] if active else []) + assert bool(metric_points(reader=reader)) == active diff --git a/tests/test_telemetry/test_websockets.py b/tests/test_telemetry/test_websockets.py new file mode 100644 index 0000000000..8b92b7e085 --- /dev/null +++ b/tests/test_telemetry/test_websockets.py @@ -0,0 +1,292 @@ +import pytest +from fastapi import APIRouter, Depends, FastAPI, WebSocket, WebSocketException +from fastapi.telemetry import get_telemetry_data +from fastapi.testclient import TestClient +from opentelemetry import context +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk._logs import LogRecordProcessor +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF +from opentelemetry.trace import StatusCode +from starlette.websockets import WebSocketDisconnect + +from .conftest import server_spans + + +@pytest.mark.parametrize("operation_spans", [False, True]) +def test_connection_context_route_and_data(telemetry, operation_spans): + config, exporter, reader = telemetry + config["operation_spans"] = operation_spans + app = FastAPI(telemetry=config) + child = FastAPI(telemetry=config) + router = APIRouter() + service = object() + saved = [] + + def dependency(websocket: WebSocket): + data = get_telemetry_data() + assert data is not None + assert data.websocket is websocket + assert data.request is None + assert data.body is None + assert data.values is None + return service + + @router.websocket("/rooms/{room}") + async def endpoint(*, websocket: WebSocket, room: int, value=Depends(dependency)): + data = get_telemetry_data() + assert data is not None + assert data.values is not None + assert data.values["value"] is service + assert data.values["room"] == room + assert data.errors == [] + saved.append(context.get_current()) + await websocket.accept() + for _ in range(2): + await websocket.send_text(await websocket.receive_text()) + await websocket.close() + + child.include_router(router, prefix="/api") + app.mount("/tenants/{tenant}", child) + with TestClient(app).websocket_connect( + "/tenants/acme/api/rooms/42", + headers={"traceparent": "00-" + "1" * 32 + "-" + "2" * 16 + "-01"}, + ) as websocket: + websocket.send_text("private payload") + assert websocket.receive_text() == "private payload" + assert not server_spans(exporter) + websocket.send_text("second message") + assert websocket.receive_text() == "second message" + (span,) = server_spans(exporter) + assert span.name == "WS /tenants/{tenant}/api/rooms/{room}" + assert span.parent.span_id == int("2" * 16, 16) + assert span.context.trace_id == int("1" * 32, 16) + assert span.attributes["network.protocol.name"] == "websocket" + assert span.attributes["url.scheme"] == "ws" + assert span.status.status_code == StatusCode.UNSET + assert not any(key.startswith("http.request.") for key in span.attributes) + assert "http.response.status_code" not in span.attributes + assert "network.protocol.version" not in span.attributes + assert reader.get_metrics_data() is None + assert len(exporter.get_finished_spans()) == (3 if operation_spans else 1) + assert "private payload" not in repr(exporter.get_finished_spans()) + assert all(get_telemetry_data(saved_context) is None for saved_context in saved) + assert get_telemetry_data() is None + + +@pytest.mark.parametrize("code", [1000, 1001, 1006]) +def test_client_disconnect(telemetry, logs, code): + config, exporter, reader = telemetry + app = FastAPI(telemetry=config) + + @app.websocket("/ws") + async def endpoint(websocket: WebSocket): + await websocket.accept() + await websocket.receive_text() + + with pytest.raises(WebSocketDisconnect) as caught: + with TestClient(app).websocket_connect("/ws") as websocket: + websocket.close(code=code) + assert caught.value.code == code + (server,) = server_spans(exporter) + assert server.status.status_code == ( + StatusCode.ERROR if code == 1006 else StatusCode.UNSET + ) + assert len(logs.get_finished_logs()) == int(code == 1006) + for span in exporter.get_finished_spans(): + assert not span.events + assert span.status.status_code == ( + StatusCode.ERROR + if code == 1006 and span.name != "fastapi.dependencies" + else StatusCode.UNSET + ) + assert reader.get_metrics_data() is None + + +@pytest.mark.parametrize("mode", ["sampled", "unsampled", "logs_only", "logs_disabled"]) +@pytest.mark.parametrize("stage", ["dependency", "endpoint", "cleanup"]) +def test_unexpected_exception(telemetry, logs, mode, stage): + config, exporter, reader = telemetry + if mode == "unsampled": + config["tracer_provider"].sampler = ALWAYS_OFF + elif mode == "logs_only": + config["tracing"] = False + elif mode == "logs_disabled": + config["logs"] = False + app = FastAPI(telemetry=config) + failure = ValueError("websocket failed") + saved = [] + + async def dependency(): + saved.append(context.get_current()) + if stage == "dependency": + raise failure + yield + if stage == "cleanup": + raise failure + + @app.websocket("/ws", dependencies=[Depends(dependency)]) + async def endpoint(websocket: WebSocket): + await websocket.accept() + if stage == "endpoint": + raise failure + await websocket.close() + + with pytest.raises(ValueError, match="websocket failed"): + with TestClient(app).websocket_connect("/ws"): + pass # pragma: no cover + records = logs.get_finished_logs() + assert len(records) == int(mode != "logs_disabled") + if records: + record = records[0].log_record + assert record.event_name == "fastapi.websocket.exception" + assert record.timestamp is not None + assert record.timestamp <= record.observed_timestamp + assert record.exception is failure + assert record.severity_number == SeverityNumber.ERROR + assert record.body == "Unhandled exception in FastAPI WebSocket connection" + assert record.attributes["http.route"] == "/ws" + assert bool(record.trace_id) == (mode != "logs_only") + assert get_telemetry_data(record.context) is None + if mode in ("sampled", "logs_disabled"): + (server,) = server_spans(exporter) + assert server.status.status_code == StatusCode.ERROR + assert server.attributes["error.type"] == "ValueError" + if records: + assert records[0].log_record.span_id == server.context.span_id + else: + assert not exporter.get_finished_spans() + assert reader.get_metrics_data() is None + assert all(get_telemetry_data(saved_context) is None for saved_context in saved) + + +@pytest.mark.parametrize("tracing", [False, True]) +def test_validation_data(telemetry, logs, tracing): + config, exporter, reader = telemetry + config["tracing"] = tracing + app = FastAPI(telemetry=config) + observed = [] + + class Observe(LogRecordProcessor): + def on_emit(self, log_record): + data = get_telemetry_data(log_record.log_record.context) + assert data is not None + assert data.websocket is not None + assert data.request is None + assert data.body is None + assert data.errors is not None + assert data.errors[0]["input"] == "private-invalid-input" + observed.append(log_record.log_record.context) + + def shutdown(self): + pass + + def force_flush(self, timeout_millis=30000): + return True + + config["logger_provider"].add_log_record_processor(Observe()) + + @app.websocket("/ws/{value}") + async def endpoint(*, websocket: WebSocket, value: int): + pytest.fail( + "Validation should fail before the endpoint runs" + ) # pragma: no cover + + with pytest.raises(WebSocketDisconnect) as caught: + with TestClient(app).websocket_connect("/ws/private-invalid-input"): + pass # pragma: no cover + assert caught.value.code == 1008 + assert config["logger_provider"].force_flush() + (record,) = logs.get_finished_logs() + assert record.log_record.event_name == "fastapi.validation.failed" + assert record.log_record.severity_number == SeverityNumber.WARN + assert record.log_record.attributes == { + "http.route": "/ws/{value}", + "fastapi.validation.error_count": 1, + } + assert record.log_record.exception is None + assert "private-invalid-input" not in repr(record.log_record.attributes) + assert len(observed) == 1 + assert get_telemetry_data(observed[0]) is None + if tracing: + assert all( + span.status.status_code == StatusCode.UNSET + for span in exporter.get_finished_spans() + ) + assert reader.get_metrics_data() is None + + +@pytest.mark.parametrize("excluded", [False, True]) +def test_disabled_and_excluded_connections(telemetry, logs, excluded): + config, exporter, reader = telemetry + if excluded: + config["exclude"] = lambda scope: scope["type"] == "websocket" + else: + config.update(tracing=False, logs=False) + app = FastAPI(telemetry=config) + child = FastAPI(telemetry=config) + app.mount("/child", child) + + @child.websocket("/ws") + async def endpoint(websocket: WebSocket): + assert get_telemetry_data() is None + await websocket.accept() + await websocket.close() + + with TestClient(app).websocket_connect("/child/ws"): + pass + assert not exporter.get_finished_spans() + assert not logs.get_finished_logs() + assert reader.get_metrics_data() is None + + +def test_handled_websocket_exception(telemetry, logs): + config, exporter, _ = telemetry + app = FastAPI(telemetry=config) + + @app.websocket("/ws") + async def endpoint(websocket: WebSocket): + raise WebSocketException(code=1008) + + with pytest.raises(WebSocketDisconnect) as caught: + with TestClient(app).websocket_connect("/ws"): + pass # pragma: no cover + assert caught.value.code == 1008 + assert len(server_spans(exporter)) == 1 + assert not logs.get_finished_logs() + assert all( + span.status.status_code == StatusCode.UNSET + for span in exporter.get_finished_spans() + ) + + +def test_concurrent_connections_keep_local_data_separate(telemetry): + config, exporter, _ = telemetry + app = FastAPI(telemetry=config) + saved = [] + observed = [] + + @app.websocket("/ws/{name}") + async def endpoint(*, websocket: WebSocket, name: str): + data = get_telemetry_data() + assert data is not None + await websocket.accept() + await websocket.receive_text() + assert get_telemetry_data() is data + assert data.values is not None + assert data.values["name"] == name + assert data.websocket is websocket + saved.append(context.get_current()) + observed.append(data) + await websocket.send_text(name) + await websocket.close() + + with TestClient(app) as client: + with client.websocket_connect("/ws/first") as first: + with client.websocket_connect("/ws/second") as second: + second.send_text("go") + assert second.receive_text() == "second" + first.send_text("go") + assert first.receive_text() == "first" + assert observed[0] is not observed[1] + assert len(server_spans(exporter)) == 2 + assert all(get_telemetry_data(saved_context) is None for saved_context in saved) diff --git a/tests/test_tutorial/test_opentelemetry/__init__.py b/tests/test_tutorial/test_opentelemetry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_opentelemetry/conftest.py b/tests/test_tutorial/test_opentelemetry/conftest.py new file mode 100644 index 0000000000..5cbd06ff3c --- /dev/null +++ b/tests/test_tutorial/test_opentelemetry/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from tests.test_telemetry.conftest import remove_export_environment + + +@pytest.fixture(autouse=True) +def clean_environment(monkeypatch): + remove_export_environment(monkeypatch) diff --git a/tests/test_tutorial/test_opentelemetry/test_tutorial001.py b/tests/test_tutorial/test_opentelemetry/test_tutorial001.py new file mode 100644 index 0000000000..724220f5e3 --- /dev/null +++ b/tests/test_tutorial/test_opentelemetry/test_tutorial001.py @@ -0,0 +1,8 @@ +from fastapi.testclient import TestClient + + +def test_default_example(): + from docs_src.opentelemetry.tutorial001_py310 import app + + with TestClient(app) as client: + assert client.get("/items/1").json() == {"item_id": 1} diff --git a/tests/test_tutorial/test_opentelemetry/test_tutorial002.py b/tests/test_tutorial/test_opentelemetry/test_tutorial002.py new file mode 100644 index 0000000000..6bcbf62559 --- /dev/null +++ b/tests/test_tutorial/test_opentelemetry/test_tutorial002.py @@ -0,0 +1,28 @@ +import json +from io import StringIO + +from fastapi.testclient import TestClient + + +def test_console_provider_example(monkeypatch): + from opentelemetry.sdk.trace import export + + output = StringIO() + exporter = export.ConsoleSpanExporter( + out=output, formatter=lambda span: span.to_json(indent=None) + "\n" + ) + monkeypatch.setattr(export, "ConsoleSpanExporter", lambda: exporter) + from docs_src.opentelemetry.tutorial002_py310 import app, tracer_provider + + try: + with TestClient(app) as client: + assert client.get("/items/2").json() == {"item_id": 2} + assert tracer_provider.force_flush() + spans = [json.loads(line) for line in output.getvalue().splitlines()] + assert len(spans) == 4 + span = next(span for span in spans if span["kind"] == "SpanKind.SERVER") + assert span["name"] == "GET /items/{item_id}" + assert span["kind"] == "SpanKind.SERVER" + assert span["attributes"]["http.route"] == "/items/{item_id}" + finally: + tracer_provider.shutdown() diff --git a/tests/test_tutorial/test_opentelemetry/test_tutorial003.py b/tests/test_tutorial/test_opentelemetry/test_tutorial003.py new file mode 100644 index 0000000000..dd161fa27b --- /dev/null +++ b/tests/test_tutorial/test_opentelemetry/test_tutorial003.py @@ -0,0 +1,22 @@ +from fastapi.testclient import TestClient +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +def test_disable_operation_spans_example(monkeypatch): + exporter = InMemorySpanExporter() + provider = TracerProvider(shutdown_on_exit=False) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider) + from docs_src.opentelemetry.tutorial003_py310 import app + + try: + with TestClient(app) as client: + assert client.get("/items/3").json() == {"item_id": 3} + (span,) = exporter.get_finished_spans() + assert span.kind == trace.SpanKind.SERVER + assert span.name == "GET /items/{item_id}" + finally: + provider.shutdown() diff --git a/uv.lock b/uv.lock index 2feffec443..75792782aa 100644 --- a/uv.lock +++ b/uv.lock @@ -144,6 +144,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" }, ] +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + [[package]] name = "ast-serialize" version = "0.6.0" @@ -851,6 +863,7 @@ name = "fastapi" source = { editable = "." } dependencies = [ { name = "annotated-doc" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, @@ -864,18 +877,26 @@ all = [ { name = "httpx" }, { name = "itsdangerous" }, { name = "jinja2" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "uvicorn", extra = ["standard"] }, ] +opentelemetry = [ + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, +] standard = [ { name = "email-validator" }, { name = "fastapi-cli", extra = ["standard"] }, { name = "fastar" }, { name = "httpx" }, { name = "jinja2" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -886,6 +907,8 @@ standard-no-fastapi-cloud-cli = [ { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -908,10 +931,14 @@ dev = [ { name = "httpx2" }, { name = "inline-snapshot" }, { name = "jieba" }, + { name = "logfire" }, { name = "markdown-include-variants" }, { name = "mdx-include" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-sdk" }, { name = "pillow" }, { name = "playwright" }, { name = "prek" }, @@ -928,6 +955,7 @@ dev = [ { name = "python-slugify" }, { name = "pyyaml" }, { name = "ruff" }, + { name = "sentry-sdk" }, { name = "sqlmodel" }, { name = "strawberry-graphql" }, { name = "ty" }, @@ -975,7 +1003,11 @@ tests = [ { name = "httpx" }, { name = "httpx2" }, { name = "inline-snapshot" }, + { name = "logfire" }, { name = "mypy" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-sdk" }, { name = "pwdlib", extra = ["argon2"] }, { name = "pyjwt" }, { name = "pytest" }, @@ -986,6 +1018,7 @@ tests = [ { name = "pytest-xdist", extra = ["psutil"] }, { name = "pyyaml" }, { name = "ruff" }, + { name = "sentry-sdk" }, { name = "sqlmodel" }, { name = "strawberry-graphql" }, { name = "ty" }, @@ -1014,6 +1047,15 @@ requires-dist = [ { name = "jinja2", marker = "extra == 'all'", specifier = ">=3.1.5" }, { name = "jinja2", marker = "extra == 'standard'", specifier = ">=3.1.5" }, { name = "jinja2", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=3.1.5" }, + { name = "opentelemetry-api", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'all'", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'opentelemetry'", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'standard'", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=1.44.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'all'", specifier = ">=1.44.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'opentelemetry'", specifier = ">=1.44.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'standard'", specifier = ">=1.44.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=1.44.0" }, { name = "pydantic", specifier = ">=2.9.0" }, { name = "pydantic-extra-types", marker = "extra == 'all'", specifier = ">=2.0.0" }, { name = "pydantic-extra-types", marker = "extra == 'standard'", specifier = ">=2.0.0" }, @@ -1032,7 +1074,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.12.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.12.0" }, ] -provides-extras = ["standard", "standard-no-fastapi-cloud-cli", "all"] +provides-extras = ["opentelemetry", "standard", "standard-no-fastapi-cloud-cli", "all"] [package.metadata.requires-dev] dev = [ @@ -1050,10 +1092,14 @@ dev = [ { name = "httpx2", specifier = ">=2.0.0" }, { name = "inline-snapshot", specifier = ">=0.21.1" }, { name = "jieba", specifier = ">=0.42.1" }, + { name = "logfire", specifier = ">=5.1.0" }, { name = "markdown-include-variants", specifier = ">=0.0.8" }, { name = "mdx-include", specifier = ">=1.4.1,<2.0.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "mypy", specifier = ">=1.14.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.44.0" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.65b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.44.0" }, { name = "pillow", specifier = ">=11.3.0" }, { name = "playwright", specifier = ">=1.57.0" }, { name = "prek", specifier = ">=0.2.22" }, @@ -1070,6 +1116,7 @@ dev = [ { name = "python-slugify", specifier = ">=8.0.4" }, { name = "pyyaml", specifier = ">=5.3.1,<7.0.0" }, { name = "ruff", specifier = ">=0.14.14,<0.16.0" }, + { name = "sentry-sdk", specifier = ">=2.70.0" }, { name = "sqlmodel", specifier = ">=0.0.31" }, { name = "strawberry-graphql", specifier = ">=0.200.0,<1.0.0" }, { name = "ty", specifier = ">=0.0.25" }, @@ -1118,7 +1165,11 @@ tests = [ { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, { name = "httpx2", specifier = ">=2.0.0" }, { name = "inline-snapshot", specifier = ">=0.21.1" }, + { name = "logfire", specifier = ">=5.1.0" }, { name = "mypy", specifier = ">=1.14.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.44.0" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.65b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.44.0" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.2.1" }, { name = "pyjwt", specifier = ">=2.9.0" }, { name = "pytest", specifier = ">=9.0.0" }, @@ -1129,6 +1180,7 @@ tests = [ { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.0.2" }, { name = "pyyaml", specifier = ">=5.3.1,<7.0.0" }, { name = "ruff", specifier = ">=0.14.14,<0.16.0" }, + { name = "sentry-sdk", specifier = ">=2.70.0" }, { name = "sqlmodel", specifier = ">=0.0.31" }, { name = "strawberry-graphql", specifier = ">=0.200.0,<1.0.0" }, { name = "ty", specifier = ">=0.0.25" }, @@ -1605,8 +1657,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -1720,7 +1772,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -2070,7 +2122,7 @@ wheels = [ [[package]] name = "logfire" -version = "4.41.0" +version = "5.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "executing" }, @@ -2082,9 +2134,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/1a/529f5fd3d0b72eca62737e07b290d38737f104f31891d23e5ed47a8ec7a0/logfire-4.41.0.tar.gz", hash = "sha256:3806fba60389d57c38a12a88135a7c7bf9d0fca09325094517e976b29b5b9d33", size = 1302531, upload-time = "2026-08-20T17:42:23.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/da/0c73a4447dffe01e1737580556f5f061ffda8f85e46fbf35ac36f5b64d27/logfire-5.1.0.tar.gz", hash = "sha256:edf6c7d33c00214e11c2cdc567eb53051be36289747091e8bb910527e5970bea", size = 1359738, upload-time = "2026-09-11T10:25:11.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/e1/ee33bf0e3f85c00a4235c8c9c4e23f3955154d842f15f0377936461ec6a3/logfire-4.41.0-py3-none-any.whl", hash = "sha256:5bae36637aef81eeee6bfa5d764bf3cff0755af613a4888fd0ae4a656cd2451e", size = 426654, upload-time = "2026-08-20T17:42:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/b32c3d5c298e4a9010694d261fb82c2c672747c0bf2b47bffda484aef349/logfire-5.1.0-py3-none-any.whl", hash = "sha256:c1d54eabf39f4c8f5e18e71f35da13375c8adb144c263f3076e650ba85929108", size = 474841, upload-time = "2026-09-11T10:25:08.131Z" }, ] [package.optional-dependencies] @@ -2524,6 +2576,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/83/8e8e83b7ac285281687c7be2fd305213ccccbb8c0a2dd4fb45a8ccaf12c7/opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426", size = 26140, upload-time = "2026-07-16T15:25:54.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/9c/376962840b619d2d55fe8ee2285f8c70971c090e5fff614516fc654a6f3a/opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65", size = 15903, upload-time = "2026-07-16T15:24:57.198Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/23/b057f8196d06efdc1b50e3ff11fbc499a7d96b35c87f217eb7885542f4ea/opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97", size = 26236, upload-time = "2026-07-16T15:26:05.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b0/c9b0300d33349ecc3dfd2362516eaffc44877e90970e6a52178ff953fec3/opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943", size = 13261, upload-time = "2026-07-16T15:25:12.429Z" }, +] + [[package]] name = "opentelemetry-instrumentation-httpx" version = "0.65b0" @@ -4027,8 +4111,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "jeepney", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -4037,15 +4121,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/0a/e37553f3106d0f7f0d4b8ad2c7b92410d131929786ffd116833d3b8164df/sentry_sdk-2.70.0.tar.gz", hash = "sha256:27e4b512f47be93136645dbe8f02d430473ee73a389ce430b2b7104baa66cf75", size = 1047234, upload-time = "2026-09-22T09:40:11.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/b1e56f009bfb9b8023910b533d5ebf4ec5650da49891f2b8c7f3d4815401/sentry_sdk-2.70.0-py3-none-any.whl", hash = "sha256:89600e8bb5fc5239cad5c4434217f453204f2207ad29399ea58947460b37a62e", size = 531752, upload-time = "2026-09-22T09:40:10.063Z" }, ] [[package]]

4zJ5K)7>toZ!{%HqZ3^8qbPl^+7mz;DNB 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