✨ Add native OpenTelemetry support (#16403)

This commit is contained in:
Sebastián Ramírez authored and GitHub committed 2026-09-29 19:25:55 +02:00
1 parent c30032a1b6
commit 4b3949cd9e
36 files changed
+5251 -46

No files matched your search

+161
View File
@@ -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:
<div class="termy">
```console
$ uv add "fastapi[standard]"
---> 100%
```
</div>
## 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/).
<img src="/img/tutorial/opentelemetry/image01.png" alt="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:
<div class="termy">
```console
$ uv run fastapi run
```
</div>
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+1
View File
@@ -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
@@ -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}
@@ -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}
@@ -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}
+11
View File
@@ -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.
+65 -2
View File
@@ -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,
+6
View File
@@ -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()
+108 -32
View File
@@ -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
+3
View File
@@ -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
+276
View File
@@ -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
+446
View File
@@ -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)
+239
View File
@@ -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)
+18
View File
@@ -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 = [
View File
Whitespace-only changes.
+36
View File
@@ -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()
+43
View File
@@ -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))
+69
View File
@@ -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
]
+276
View File
@@ -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
+286
View File
@@ -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()
@@ -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()
+249
View File
@@ -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
+695
View File
@@ -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}"
+351
View File
@@ -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
)
@@ -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()
+93
View File
@@ -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)
+613
View File
@@ -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()
@@ -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
+292
View File
@@ -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)
Whitespace-only changes.
@@ -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)
@@ -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}
@@ -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()
@@ -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()
Generated
+96 -12
View File
@@ -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]]