mirror of
https://github.com/fastapi/fastapi.git
synced 2026-10-08 19:21:32 -04:00
Merge branch 'master' into clarify-sse-event-terminator
This commit is contained in:
82 files changed
+8000
-601
No files matched your search
@@ -51,7 +51,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
|
||||
@@ -28,14 +28,14 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
- name: Bump pre-commit hooks
|
||||
run: uv run prek auto-update --freeze --cooldown-days 7
|
||||
run: uv run prek update --freeze --cooldown-days 7
|
||||
- name: Get PR Submit token
|
||||
id: pr-submit
|
||||
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$branch" \
|
||||
--title "⬆ Bump pre-commit hooks" \
|
||||
--body "Bump pre-commit hook versions via \`prek auto-update --freeze --cooldown-days 7\`." \
|
||||
--body "Bump pre-commit hook versions via \`prek update --freeze --cooldown-days 7\`." \
|
||||
--label internal \
|
||||
--label dependencies \
|
||||
--label pre-commit
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
- name: Extract release details
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: false
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: fastapitiangolo
|
||||
BRANCH: ${{ ( github.event.workflow_run.head_repository.full_name == github.repository && github.event.workflow_run.head_branch == 'master' && 'main' ) || ( github.event.workflow_run.head_sha ) }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
- name: Prepare release
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: "false"
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
python-version-file: "base/.python-version"
|
||||
- name: Setup uv
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
enable-cache: true
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
cache-dependency-glob: |
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: "latest-known"
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
@@ -15,7 +15,7 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: 8a48f81b6c64dcfea44b3633223084c4be58ac5f # frozen: v1.49.0
|
||||
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # frozen: v1.50.3
|
||||
hooks:
|
||||
- id: typos
|
||||
args: [--force-exclude]
|
||||
|
||||
@@ -53,7 +53,6 @@ The key features are:
|
||||
|
||||
<a href="https://blockbee.io?ref=fastapi" target="_blank" title="BlockBee Cryptocurrency Payment Gateway"><img src="https://fastapi.tiangolo.com/img/sponsors/blockbee.png"></a>
|
||||
<a href="https://www.propelauth.com/?utm_source=fastapi&utm_campaign=1223&utm_medium=mainbadge" target="_blank" title="Auth, user management and more for your B2B product"><img src="https://fastapi.tiangolo.com/img/sponsors/propelauth.png"></a>
|
||||
<a href="https://docs.render.com/deploy-fastapi?utm_source=deploydoc&utm_medium=referral&utm_campaign=fastapi" target="_blank" title="Deploy & scale any full-stack web app on Render. Focus on building apps, not infra."><img src="https://fastapi.tiangolo.com/img/sponsors/render.svg"></a>
|
||||
<a href="https://www.coderabbit.ai/?utm_source=fastapi&utm_medium=badge&utm_campaign=fastapi" target="_blank" title="Cut Code Review Time & Bugs in Half with CodeRabbit"><img src="https://fastapi.tiangolo.com/img/sponsors/coderabbit.png"></a>
|
||||
<a href="https://subtotal.com/?utm_source=fastapi&utm_medium=sponsorship&utm_campaign=open-source" target="_blank" title="The Gold Standard in Retail Account Linking"><img src="https://fastapi.tiangolo.com/img/sponsors/subtotal.svg"></a>
|
||||
<a href="https://docs.railway.com/guides/fastapi?utm_medium=integration&utm_source=docs&utm_campaign=fastapi" target="_blank" title="Deploy enterprise applications at startup speed"><img src="https://fastapi.tiangolo.com/img/sponsors/railway.png"></a>
|
||||
@@ -64,7 +63,6 @@ The key features are:
|
||||
|
||||
<a href="https://databento.com/?utm_source=fastapi&utm_medium=sponsor&utm_content=display" target="_blank" title="Pay as you go for market data"><img src="https://fastapi.tiangolo.com/img/sponsors/databento.svg"></a>
|
||||
<a href="https://www.svix.com/" target="_blank" title="Svix - Webhooks as a service"><img src="https://fastapi.tiangolo.com/img/sponsors/svix.svg"></a>
|
||||
<a href="https://www.permit.io/blog/implement-authorization-in-fastapi?utm_source=github&utm_medium=referral&utm_campaign=fastapi" target="_blank" title="Fine-Grained Authorization for FastAPI"><img src="https://fastapi.tiangolo.com/img/sponsors/permit.png"></a>
|
||||
<a href="https://dribia.com/en/" target="_blank" title="Dribia - Data Science within your reach"><img src="https://fastapi.tiangolo.com/img/sponsors/dribia.png"></a>
|
||||
<a href="https://www.bairesdev.com/" target="_blank" title="BairesDev | Nearshore Software Development & Staff Augmentation Company"><img src="https://fastapi.tiangolo.com/img/sponsors/bairesdev.svg"></a>
|
||||
<a href="https://tutorcruncher.com/?utm_source=fastapi" target="_blank" title="TutorCruncher"><img src="https://fastapi.tiangolo.com/img/sponsors/tutorcruncher.png"></a>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
Wenn Ihre API läuft, möchten Sie vielleicht wissen, wie viel Traffic sie erhält, welche Requests langsam sind und wann Fehler auftreten.
|
||||
|
||||
**Telemetrie** umfasst Daten über das Verhalten Ihrer Anwendung, die Ihnen helfen, diese Fragen zu beantworten. Zu den gängigen Typen gehören:
|
||||
|
||||
- **Metriken**: Messwerte, die Sie über einen Zeitraum zusammenfassen können, etwa Responsezeiten und die Anzahl der verarbeiteten Requests.
|
||||
- **Traces**: Aufzeichnungen einzelner Requests und der Operationen, die zu ihrer Verarbeitung ausgeführt werden. Jede zeitlich erfasste Operation wird als **Span** bezeichnet.
|
||||
- **Logs**: Aufzeichnungen von Events mit Zeitstempel, etwa das Starten einer Anwendung oder das Fehlschlagen einer Operation.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) ist eine Sammlung von Standards und Werkzeugen zum Erfassen von Telemetriedaten und zum Senden dieser Daten an einen Monitoring-Dienst, wo Sie sie in Dashboards untersuchen können.
|
||||
|
||||
**FastAPI bietet standardmäßig OpenTelemetry-Unterstützung** für HTTP-Request-Traces, Metriken und Logs. WebSocket-Verbindungen liefern ebenfalls Traces und Logs. Um diese Daten anzuzeigen, konfigurieren Sie einen Monitoring-Dienst, der sie empfängt.
|
||||
|
||||
## FastAPI installieren { #install-fastapi }
|
||||
|
||||
Installieren Sie FastAPI mit den `standard`-Extras, die die Pakete zum Senden von Telemetriedaten enthalten:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Die Anwendung erstellen { #create-the-app }
|
||||
|
||||
Erstellen Sie eine Datei `main.py`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Beachten Sie, dass alles standardmäßig funktioniert. Sie müssen keinen eigenen Code schreiben, damit die Telemetrie funktioniert.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Wenn Sie mit `fastapi[standard]` auf [FastAPI Cloud](https://fastapicloud.com) deployen, funktionieren Metriken automatisch. Sie müssen nichts weiter konfigurieren.
|
||||
|
||||
Mit einem Pro-Tarif können Sie die Anzahl der Requests, Fehlerraten und Responsezeiten im [Metriken-Dashboard](https://fastapicloud.com/docs/monitoring-and-performance/metrics/) anzeigen.
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="FastAPI Cloud Pro-Metriken-Dashboard mit Beispieldaten">
|
||||
|
||||
## Andere Monitoring-Dienste { #other-monitoring-services }
|
||||
|
||||
Um Telemetriedaten an einen anderen Monitoring-Dienst zu senden, konfigurieren Sie einen Endpunkt, der **OTLP** akzeptiert, das OpenTelemetry-Protokoll zum Senden von Telemetriedaten. Verwenden Sie den HTTP/protobuf-Basisendpunkt des Dienstes.
|
||||
|
||||
Setzen Sie diese Umgebungsvariablen und ersetzen Sie die Beispiel-URL durch Ihren Endpunkt:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` identifiziert Ihre Anwendung im Monitoring-Dienst. Der Endpunkt ist die Basis-URL für den Empfang von Daten. Unter dieser URL werden Traces an `/v1/traces`, Metriken an `/v1/metrics` und Logs an `/v1/logs` gesendet.
|
||||
|
||||
Wenn Ihr Dienst Authentifizierung erfordert, setzen Sie `OTEL_EXPORTER_OTLP_HEADERS` auf die von ihm vorgegebenen Header, zum Beispiel `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Die Anwendung ausführen { #run-the-app }
|
||||
|
||||
Starten Sie die Anwendung im selben Terminal:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Senden Sie in einem anderen Terminal einen Request:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
Öffnen Sie Ihren Monitoring-Dienst und suchen Sie nach `my-api`. Nach dem nächsten Export sehen Sie einen Trace mit einem `GET /items/{item_id}`-Span sowie Metriken zur Anzahl der Requests, zur Responsedauer und zu aktiven Requests.
|
||||
|
||||
## Telemetrie anpassen { #customize-telemetry }
|
||||
|
||||
### Provider und Exporter konfigurieren { #configure-providers-and-exporters }
|
||||
|
||||
Ein **Provider** stellt die Objekte bereit, die Traces, Metriken oder Logs aufzeichnen. Seine Konfiguration steuert, wie diese Daten verarbeitet und exportiert werden.
|
||||
|
||||
Telemetriebibliotheken können die globalen Provider von OpenTelemetry konfigurieren. Konfigurieren Sie die Bibliothek, bevor die Anwendung startet, und FastAPI verwendet diese Provider automatisch.
|
||||
|
||||
Wenn ein OTLP-Endpunkt in der Umgebung gesetzt ist, fügt FastAPI jedem aktivierten Provider einen Exporter für dieses Ziel hinzu. Vorhandene Exporter senden weiterhin Daten an ihre Ziele.
|
||||
|
||||
Konfigurieren Sie jedes Ziel einmal. Wenn eine andere Bibliothek bereits das in der Umgebung angegebene Ziel verwaltet, deaktivieren Sie deren Export über die Umgebung oder schalten Sie FastAPIs automatische Einrichtung aus:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
Sie können auch einen Provider direkt im Dictionary `telemetry` übergeben. Dieser Provider verwendet beispielsweise den Konsolenexporter von OpenTelemetry, um Request-Spans in Ihrem Terminal auszugeben:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
Der **Exporter** sendet die Spans an ihr Ziel. `BatchSpanProcessor` gruppiert Spans und sendet sie im Hintergrund. Ersetzen Sie den Konsolenexporter durch einen Exporter Ihrer Monitoring-Bibliothek, um deren Ziel zu verwenden. Weitere Konfigurationsoptionen finden Sie im [Leitfaden zur Python-Instrumentierung von OpenTelemetry](https://opentelemetry.io/docs/languages/python/instrumentation/).
|
||||
|
||||
Verwenden Sie `meter_provider` oder `logger_provider` im selben Dictionary, um einen Provider für Metriken oder Logs bereitzustellen. Die Anwendung oder Bibliothek, die einen Provider erstellt, verwaltet dessen Shutdown. FastAPI verwaltet die Exportkomponenten, die es hinzufügt.
|
||||
|
||||
/// warning | Achtung
|
||||
|
||||
OpenTelemetry verwendet standardmäßig globale Provider. Eine unabhängige Telemetriekonfiguration für [gemountete Unteranwendungen](sub-applications.md) ist nicht garantiert.
|
||||
|
||||
///
|
||||
|
||||
### Request-Operationen nachverfolgen { #trace-request-operations }
|
||||
|
||||
Standardmäßig enthalten Request-Traces Spans für das Auflösen von Abhängigkeiten, das Ausführen Ihrer Pfadoperation-Funktion, das Serialisieren der Response und das Ausführen jedes Tasks in FastAPIs `BackgroundTasks`. Diese Spans verwenden denselben Provider und dieselben Exporter.
|
||||
|
||||
Die Spans der Hintergrundtasks bleiben Teil des Request-Traces. Sie werden ausgeführt, nachdem der HTTP-Response-Span beendet ist, und erhöhen daher nicht die gemessene Responsezeit.
|
||||
|
||||
Um nur den HTTP-Request-Span aufzuzeichnen, setzen Sie `operation_spans` auf `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### WebSocket-Verbindungen nachverfolgen { #trace-websocket-connections }
|
||||
|
||||
Jede WebSocket-Verbindung hat einen Span wie `WS /ws/{room}`, der den Handler und das Aufräumen der Abhängigkeiten umfasst. Er verwendet dieselben Provider und Einstellungen, einschließlich `operation_spans` für das Auflösen von Abhängigkeiten und das Ausführen des Endpunkts.
|
||||
|
||||
HTTP-Request-Metriken erfassen nur HTTP-Requests. Normale WebSocket-Verbindungsabbrüche mit den Codes `1000` oder `1001` erzeugen keine Fehlerlogs.
|
||||
|
||||
### Fehler untersuchen { #inspect-errors }
|
||||
|
||||
FastAPI zeichnet unbehandelte Exceptions als OpenTelemetry-Logs auf, die mit dem Trace des Requests oder der Verbindung verknüpft sind. Fehlerlogs werden auch dann aufgezeichnet, wenn der Trace nicht durch Sampling erfasst wird.
|
||||
|
||||
Exception-Logs enthalten den Typ, die Nachricht und den Stacktrace der Exception. Nachrichten und Stacktraces können sensible Informationen enthalten. Verwenden Sie die Logprozessoren Ihres Providers, um diese zu filtern oder zu schwärzen, oder setzen Sie `logs` auf `False`, um diese Logs zu deaktivieren.
|
||||
|
||||
FastAPI zeichnet außerdem fehlgeschlagene Request-Validierungen als Warnungslogs mit der Route und der Fehleranzahl auf. Diese Logs enthalten nicht die ungültigen Eingabedaten.
|
||||
|
||||
## Die aufzuzeichnenden Daten auswählen { #choose-what-to-record }
|
||||
|
||||
Das Dictionary `telemetry` akzeptiert außerdem diese Einstellungen:
|
||||
|
||||
| Einstellung | Zweck | Defaultwert |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | Spans für HTTP-Requests und WebSocket-Verbindungen aufzeichnen | `True` |
|
||||
| `metrics` | HTTP-Request-Metriken aufzeichnen | `True` |
|
||||
| `logs` | Fehlgeschlagene Validierungen und unbehandelte Exceptions aufzeichnen | `True` |
|
||||
| `operation_spans` | Spans für Request-Operationen hinzufügen | `True` |
|
||||
| `exclude` | Requests überspringen, wenn eine Funktion, die den ASGI-Scope empfängt, `True` zurückgibt | `None` |
|
||||
| `auto_configure` | Exporter für Endpunkte hinzufügen, die in Umgebungsvariablen gesetzt sind | `True` |
|
||||
|
||||
Um beispielsweise Metriken zu erfassen und dabei Healthchecks auszuschließen:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Setzen Sie `auto_configure` auf `False`, wenn Ihre Anwendung die Einrichtung der Provider selbst übernimmt, etwa innerhalb ihrer Lifespan-Funktion.
|
||||
@@ -1,8 +1,5 @@
|
||||
sponsors:
|
||||
- - login: renderinc
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/36424661?v=4
|
||||
url: https://github.com/renderinc
|
||||
- login: subtotal
|
||||
- - login: subtotal
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/176449348?v=4
|
||||
url: https://github.com/subtotal
|
||||
- login: greptileai
|
||||
@@ -29,9 +26,6 @@ sponsors:
|
||||
- login: svix
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/80175132?v=4
|
||||
url: https://github.com/svix
|
||||
- login: permitio
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/71775833?v=4
|
||||
url: https://github.com/permitio
|
||||
- login: databento
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/64141749?v=4
|
||||
url: https://github.com/databento
|
||||
@@ -50,16 +44,16 @@ sponsors:
|
||||
- - login: manulife-ai
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/195145621?v=4
|
||||
url: https://github.com/manulife-ai
|
||||
- login: ptimizeroracle
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/99191972?u=ee268f7afba9856cbd30859ba33708849bc2ece4&v=4
|
||||
url: https://github.com/ptimizeroracle
|
||||
- login: scalar
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/301879?v=4
|
||||
url: https://github.com/scalar
|
||||
- login: Trivie
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/8161763?v=4
|
||||
url: https://github.com/Trivie
|
||||
- - login: takashi-yoneya
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/33813153?u=2d0522bceba0b8b69adf1f2db866503bd96f944e&v=4
|
||||
url: https://github.com/takashi-yoneya
|
||||
- login: Doist
|
||||
- - login: Doist
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/2565372?v=4
|
||||
url: https://github.com/Doist
|
||||
- - login: alixlahuec
|
||||
@@ -74,9 +68,6 @@ sponsors:
|
||||
- login: ChargeStorm
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/26000165?v=4
|
||||
url: https://github.com/ChargeStorm
|
||||
- login: tltaylor1
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/234697691?u=f9bf860a7a6e1109b35f1eb930920bd37e3bf571&v=4
|
||||
url: https://github.com/tltaylor1
|
||||
- login: justoutofcuriosity
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/57193655?v=4
|
||||
url: https://github.com/justoutofcuriosity
|
||||
@@ -107,6 +98,9 @@ sponsors:
|
||||
- login: ashi-agrawal
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/17105294?u=99c7a854035e5398d8e7b674f2d42baae6c957f8&v=4
|
||||
url: https://github.com/ashi-agrawal
|
||||
- login: mjohnsey
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/16784016?u=38fad2e6b411244560b3af99c5f5a4751bc81865&v=4
|
||||
url: https://github.com/mjohnsey
|
||||
- login: jugeeem
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/116043716?u=e4df530e99a086a1085f3dc125b94783670fb383&v=4
|
||||
url: https://github.com/jugeeem
|
||||
@@ -125,30 +119,18 @@ sponsors:
|
||||
- login: anthonycepeda
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/72019805?u=60bdf46240cff8fca482ff0fc07d963fd5e1a27c&v=4
|
||||
url: https://github.com/anthonycepeda
|
||||
- login: PedroRuizCode
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/69810923?u=26f9b262922b724deacdfa16ccd5785d093ac371&v=4
|
||||
url: https://github.com/PedroRuizCode
|
||||
- login: patsatsia
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/61111267?u=3271b85f7a37b479c8d0ae0a235182e83c166edf&v=4
|
||||
url: https://github.com/patsatsia
|
||||
- login: bradsward
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/60303274?u=6b74120e142c8bd4dae22edcf3ac6bb9c3b427bb&v=4
|
||||
url: https://github.com/bradsward
|
||||
- login: dudikbender
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/53487583?u=3a57542938ebfd57579a0111db2b297e606d9681&v=4
|
||||
url: https://github.com/dudikbender
|
||||
- login: jaredtrog
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/4381365?v=4
|
||||
url: https://github.com/jaredtrog
|
||||
- login: Ryandaydev
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/4292423?u=87b1afc7f4fff933779959270e2d168339e91402&v=4
|
||||
url: https://github.com/Ryandaydev
|
||||
- login: gorhack
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/4141690?u=ec119ebc4bdf00a7bc84657a71aa17834f4f27f3&v=4
|
||||
url: https://github.com/gorhack
|
||||
- login: mj0331
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/3890353?u=1c627ac1a024515b4871de5c3ebbfaa1a57f65d4&v=4
|
||||
url: https://github.com/mj0331
|
||||
- login: aacayaco
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/3634801?u=eaadda178c964178fcb64886f6c732172c8f8219&v=4
|
||||
url: https://github.com/aacayaco
|
||||
@@ -170,6 +152,9 @@ sponsors:
|
||||
- login: netsatan
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/955557?u=cb8fc0ae7f7b06807f0a58e335b1af96c9da0344&v=4
|
||||
url: https://github.com/netsatan
|
||||
- login: KevinTCoughlin
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/706967?u=f00384d8ecf138ebb1f7d6aca34dc2a9d5431dc1&v=4
|
||||
url: https://github.com/KevinTCoughlin
|
||||
- login: koxudaxi
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/630670?u=507d8577b4b3670546b449c4c2ccbc5af40d72f7&v=4
|
||||
url: https://github.com/koxudaxi
|
||||
@@ -179,12 +164,6 @@ sponsors:
|
||||
- login: jstanden
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/63288?u=c3658d57d2862c607a0e19c2101c3c51876e36ad&v=4
|
||||
url: https://github.com/jstanden
|
||||
- login: mjohnsey
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/16784016?u=38fad2e6b411244560b3af99c5f5a4751bc81865&v=4
|
||||
url: https://github.com/mjohnsey
|
||||
- login: cizekmilan
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/15999191?u=df194a9bf279f503bc96af41a35c0027eb94ad4f&v=4
|
||||
url: https://github.com/cizekmilan
|
||||
- login: khadrawy
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/13686061?u=59f25ef42ecf04c22657aac4238ce0e2d3d30304&v=4
|
||||
url: https://github.com/khadrawy
|
||||
@@ -194,9 +173,6 @@ sponsors:
|
||||
- login: jsoques
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/12414216?u=620921d94196546cc8b9eae2cc4cbc3f95bab42f&v=4
|
||||
url: https://github.com/jsoques
|
||||
- login: wdwinslow
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/11562137?u=371272f2c69e680e0559a7b0a57385e83a5dc728&v=4
|
||||
url: https://github.com/wdwinslow
|
||||
- login: hiancdtrsnm
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/7343177?v=4
|
||||
url: https://github.com/hiancdtrsnm
|
||||
@@ -215,13 +191,13 @@ sponsors:
|
||||
- login: ternaus
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/5481618?u=513a26b02a39e7a28d587cd37c6cc877ea368e6e&v=4
|
||||
url: https://github.com/ternaus
|
||||
- login: jaredtrog
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/4381365?v=4
|
||||
url: https://github.com/jaredtrog
|
||||
- - login: jpfyoder
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/7548821?u=1683290ed65dae6987d673da044067577ee71521&v=4
|
||||
url: https://github.com/jpfyoder
|
||||
- - login: manoelpqueiroz
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/23669137?u=b12e84b28a84369ab5b30bd5a79e5788df5a0756&v=4
|
||||
url: https://github.com/manoelpqueiroz
|
||||
- login: Artur-Galstyan
|
||||
- - login: Artur-Galstyan
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/63471891?u=e8691f386037e51a737cc0ba866cd8c89e5cf109&v=4
|
||||
url: https://github.com/Artur-Galstyan
|
||||
- - login: pawamoy
|
||||
@@ -251,9 +227,6 @@ sponsors:
|
||||
- login: joshuatz
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/17817563?u=f1bf05b690d1fc164218f0b420cdd3acb7913e21&v=4
|
||||
url: https://github.com/joshuatz
|
||||
- login: danielunderwood
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/4472301?v=4
|
||||
url: https://github.com/danielunderwood
|
||||
- login: my3
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/1825270?v=4
|
||||
url: https://github.com/my3
|
||||
@@ -305,12 +278,12 @@ sponsors:
|
||||
- login: sdevkota
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/5250987?u=4ed9a120c89805a8aefda1cbdc0cf6512e64d1b4&v=4
|
||||
url: https://github.com/sdevkota
|
||||
- login: rangulvers
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/5235430?u=e254d4af4ace5a05fa58372ae677c7d26f0d5a53&v=4
|
||||
url: https://github.com/rangulvers
|
||||
- - login: mohammadi-hadi
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/50410241?u=1137b5ff9ea8585c0192fe9ddb4ae5e21bc3d2e8&v=4
|
||||
url: https://github.com/mohammadi-hadi
|
||||
- login: danielunderwood
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/4472301?v=4
|
||||
url: https://github.com/danielunderwood
|
||||
- - login: emrahbatigun
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/52425144?u=ddf9a50b6b5e9d32f0bb50650988ef64b09646e8&v=4
|
||||
url: https://github.com/emrahbatigun
|
||||
- login: morzan1001
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/47593005?u=c30ab7230f82a12a9b938dcb54f84a996931409a&v=4
|
||||
url: https://github.com/morzan1001
|
||||
@@ -332,12 +305,12 @@ sponsors:
|
||||
- login: diogotoporcov
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/207575398?u=1fa7cf41b4181faa4d27f38bc37a374c17b5163b&v=4
|
||||
url: https://github.com/diogotoporcov
|
||||
- login: anandakrishnone
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/100110721?u=5d30e6fea1524bf3c8be3547027dbce59cc699af&v=4
|
||||
url: https://github.com/anandakrishnone
|
||||
- login: onestn
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/62360849?u=746dd21c34e7e06eefb11b03e8bb01aaae3c2a4f&v=4
|
||||
url: https://github.com/onestn
|
||||
- login: gabe-santana
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/59267719?u=84b3472a9f82a7ccdf6fbd6788a12661990b66f4&v=4
|
||||
url: https://github.com/gabe-santana
|
||||
- login: Toothwitch
|
||||
avatarUrl: https://avatars.githubusercontent.com/u/1710406?u=5eebb23b46cd26e48643b9e5179536cad491c17a&v=4
|
||||
url: https://github.com/Toothwitch
|
||||
|
||||
@@ -12,10 +12,6 @@ gold:
|
||||
img: /img/sponsors/propelauth.png
|
||||
banner_url: https://www.propelauth.com/?utm_source=fastapi&utm_campaign=1223&utm_medium=topbanner
|
||||
banner_img: /img/sponsors/propelauth-banner.png
|
||||
- url: https://docs.render.com/deploy-fastapi?utm_source=deploydoc&utm_medium=referral&utm_campaign=fastapi
|
||||
title: Deploy & scale any full-stack web app on Render. Focus on building apps, not infra.
|
||||
img: /img/sponsors/render.svg
|
||||
banner_img: /img/sponsors/render-banner.svg
|
||||
- url: https://www.coderabbit.ai/?utm_source=fastapi&utm_medium=badge&utm_campaign=fastapi
|
||||
title: Cut Code Review Time & Bugs in Half with CodeRabbit
|
||||
img: /img/sponsors/coderabbit.png
|
||||
@@ -45,9 +41,6 @@ silver:
|
||||
- url: https://www.svix.com/
|
||||
title: Svix - Webhooks as a service
|
||||
img: /img/sponsors/svix.svg
|
||||
- url: https://www.permit.io/blog/implement-authorization-in-fastapi?utm_source=github&utm_medium=referral&utm_campaign=fastapi
|
||||
title: Fine-Grained Authorization for FastAPI
|
||||
img: /img/sponsors/permit.png
|
||||
- url: https://dribia.com/en/
|
||||
title: Dribia - Data Science within your reach
|
||||
img: /img/sponsors/dribia.png
|
||||
|
||||
+191
-191
@@ -1,242 +1,232 @@
|
||||
repos:
|
||||
- name: headroom
|
||||
html_url: https://github.com/headroomlabs-ai/headroom
|
||||
stars: 68267
|
||||
stars: 74217
|
||||
owner_login: headroomlabs-ai
|
||||
owner_html_url: https://github.com/headroomlabs-ai
|
||||
- name: full-stack-fastapi-template
|
||||
html_url: https://github.com/fastapi/full-stack-fastapi-template
|
||||
stars: 45272
|
||||
stars: 45830
|
||||
owner_login: fastapi
|
||||
owner_html_url: https://github.com/fastapi
|
||||
- name: Hello-Python
|
||||
html_url: https://github.com/mouredev/Hello-Python
|
||||
stars: 37219
|
||||
stars: 37617
|
||||
owner_login: mouredev
|
||||
owner_html_url: https://github.com/mouredev
|
||||
- name: serve
|
||||
html_url: https://github.com/jina-ai/serve
|
||||
stars: 21860
|
||||
stars: 21864
|
||||
owner_login: jina-ai
|
||||
owner_html_url: https://github.com/jina-ai
|
||||
- name: HivisionIDPhotos
|
||||
html_url: https://github.com/Zeyi-Lin/HivisionIDPhotos
|
||||
stars: 21468
|
||||
stars: 21582
|
||||
owner_login: Zeyi-Lin
|
||||
owner_html_url: https://github.com/Zeyi-Lin
|
||||
- name: Douyin_TikTok_Download_API
|
||||
html_url: https://github.com/Evil0ctal/Douyin_TikTok_Download_API
|
||||
stars: 19776
|
||||
stars: 20422
|
||||
owner_login: Evil0ctal
|
||||
owner_html_url: https://github.com/Evil0ctal
|
||||
- name: sqlmodel
|
||||
html_url: https://github.com/fastapi/sqlmodel
|
||||
stars: 18295
|
||||
stars: 18354
|
||||
owner_login: fastapi
|
||||
owner_html_url: https://github.com/fastapi
|
||||
- name: fastapi-best-practices
|
||||
html_url: https://github.com/zhanymkanov/fastapi-best-practices
|
||||
stars: 18006
|
||||
stars: 18138
|
||||
owner_login: zhanymkanov
|
||||
owner_html_url: https://github.com/zhanymkanov
|
||||
- name: SurfSense
|
||||
html_url: https://github.com/MODSetter/SurfSense
|
||||
stars: 16046
|
||||
owner_login: MODSetter
|
||||
owner_html_url: https://github.com/MODSetter
|
||||
- name: machine-learning-zoomcamp
|
||||
html_url: https://github.com/DataTalksClub/machine-learning-zoomcamp
|
||||
stars: 14069
|
||||
stars: 14648
|
||||
owner_login: DataTalksClub
|
||||
owner_html_url: https://github.com/DataTalksClub
|
||||
- name: XHS-Downloader
|
||||
html_url: https://github.com/JoeanAmier/XHS-Downloader
|
||||
stars: 12563
|
||||
stars: 12882
|
||||
owner_login: JoeanAmier
|
||||
owner_html_url: https://github.com/JoeanAmier
|
||||
- name: fastapi_mcp
|
||||
html_url: https://github.com/tadata-org/fastapi_mcp
|
||||
stars: 11994
|
||||
stars: 12014
|
||||
owner_login: tadata-org
|
||||
owner_html_url: https://github.com/tadata-org
|
||||
- name: peewee
|
||||
html_url: https://github.com/coleifer/peewee
|
||||
stars: 11986
|
||||
stars: 11997
|
||||
owner_login: coleifer
|
||||
owner_html_url: https://github.com/coleifer
|
||||
- name: awesome-fastapi
|
||||
html_url: https://github.com/mjhea0/awesome-fastapi
|
||||
stars: 11633
|
||||
stars: 11696
|
||||
owner_login: mjhea0
|
||||
owner_html_url: https://github.com/mjhea0
|
||||
- name: WhisperLiveKit
|
||||
html_url: https://github.com/QuentinFuxa/WhisperLiveKit
|
||||
stars: 10983
|
||||
stars: 11108
|
||||
owner_login: QuentinFuxa
|
||||
owner_html_url: https://github.com/QuentinFuxa
|
||||
- name: polar
|
||||
html_url: https://github.com/polarsource/polar
|
||||
stars: 10225
|
||||
stars: 10300
|
||||
owner_login: polarsource
|
||||
owner_html_url: https://github.com/polarsource
|
||||
- name: pycaret
|
||||
html_url: https://github.com/pycaret/pycaret
|
||||
stars: 9833
|
||||
stars: 9849
|
||||
owner_login: pycaret
|
||||
owner_html_url: https://github.com/pycaret
|
||||
- name: FastUI
|
||||
html_url: https://github.com/pydantic/FastUI
|
||||
stars: 8953
|
||||
stars: 8940
|
||||
owner_login: pydantic
|
||||
owner_html_url: https://github.com/pydantic
|
||||
- name: FileCodeBox
|
||||
html_url: https://github.com/vastsa/FileCodeBox
|
||||
stars: 8498
|
||||
stars: 8559
|
||||
owner_login: vastsa
|
||||
owner_html_url: https://github.com/vastsa
|
||||
- name: hatchet
|
||||
html_url: https://github.com/hatchet-dev/hatchet
|
||||
stars: 7824
|
||||
stars: 8039
|
||||
owner_login: hatchet-dev
|
||||
owner_html_url: https://github.com/hatchet-dev
|
||||
- name: nonebot2
|
||||
html_url: https://github.com/nonebot/nonebot2
|
||||
stars: 7691
|
||||
stars: 7727
|
||||
owner_login: nonebot
|
||||
owner_html_url: https://github.com/nonebot
|
||||
- name: honcho
|
||||
html_url: https://github.com/plastic-labs/honcho
|
||||
stars: 6974
|
||||
owner_login: plastic-labs
|
||||
owner_html_url: https://github.com/plastic-labs
|
||||
- name: Yuxi
|
||||
html_url: https://github.com/xerrors/Yuxi
|
||||
stars: 6604
|
||||
stars: 7251
|
||||
owner_login: xerrors
|
||||
owner_html_url: https://github.com/xerrors
|
||||
- name: dramaclaw
|
||||
html_url: https://github.com/dramaclaw/dramaclaw
|
||||
stars: 6615
|
||||
owner_login: dramaclaw
|
||||
owner_html_url: https://github.com/dramaclaw
|
||||
- name: fastapi-users
|
||||
html_url: https://github.com/fastapi-users/fastapi-users
|
||||
stars: 6236
|
||||
stars: 6248
|
||||
owner_login: fastapi-users
|
||||
owner_html_url: https://github.com/fastapi-users
|
||||
- name: serge
|
||||
html_url: https://github.com/serge-chat/serge
|
||||
stars: 5712
|
||||
stars: 5708
|
||||
owner_login: serge-chat
|
||||
owner_html_url: https://github.com/serge-chat
|
||||
- name: YouDub-webui
|
||||
html_url: https://github.com/liuzhao1225/YouDub-webui
|
||||
stars: 5401
|
||||
stars: 5563
|
||||
owner_login: liuzhao1225
|
||||
owner_html_url: https://github.com/liuzhao1225
|
||||
- name: Kokoro-FastAPI
|
||||
html_url: https://github.com/remsky/Kokoro-FastAPI
|
||||
stars: 5392
|
||||
stars: 5499
|
||||
owner_login: remsky
|
||||
owner_html_url: https://github.com/remsky
|
||||
- name: dramaclaw
|
||||
html_url: https://github.com/dramaclaw/dramaclaw
|
||||
stars: 4892
|
||||
owner_login: dramaclaw
|
||||
owner_html_url: https://github.com/dramaclaw
|
||||
- name: OpenExecutive
|
||||
html_url: https://github.com/SenteLabsAI/OpenExecutive
|
||||
stars: 5434
|
||||
owner_login: SenteLabsAI
|
||||
owner_html_url: https://github.com/SenteLabsAI
|
||||
- name: tick-stock-panel
|
||||
html_url: https://github.com/shy3130/tick-stock-panel
|
||||
stars: 5353
|
||||
owner_login: shy3130
|
||||
owner_html_url: https://github.com/shy3130
|
||||
- name: devpush
|
||||
html_url: https://github.com/hunvreus/devpush
|
||||
stars: 4747
|
||||
stars: 4758
|
||||
owner_login: hunvreus
|
||||
owner_html_url: https://github.com/hunvreus
|
||||
- name: strawberry
|
||||
html_url: https://github.com/strawberry-graphql/strawberry
|
||||
stars: 4710
|
||||
stars: 4722
|
||||
owner_login: strawberry-graphql
|
||||
owner_html_url: https://github.com/strawberry-graphql
|
||||
- name: mcp-context-forge
|
||||
html_url: https://github.com/IBM/mcp-context-forge
|
||||
stars: 4557
|
||||
owner_login: IBM
|
||||
owner_html_url: https://github.com/IBM
|
||||
- name: logfire
|
||||
html_url: https://github.com/pydantic/logfire
|
||||
stars: 4449
|
||||
stars: 4500
|
||||
owner_login: pydantic
|
||||
owner_html_url: https://github.com/pydantic
|
||||
- name: poem
|
||||
html_url: https://github.com/poem-web/poem
|
||||
stars: 4438
|
||||
stars: 4443
|
||||
owner_login: poem-web
|
||||
owner_html_url: https://github.com/poem-web
|
||||
- name: mcp-context-forge
|
||||
html_url: https://github.com/IBM/mcp-context-forge
|
||||
stars: 4400
|
||||
owner_login: IBM
|
||||
owner_html_url: https://github.com/IBM
|
||||
- name: huma
|
||||
html_url: https://github.com/danielgtaylor/huma
|
||||
stars: 4365
|
||||
stars: 4433
|
||||
owner_login: danielgtaylor
|
||||
owner_html_url: https://github.com/danielgtaylor
|
||||
- name: dynaconf
|
||||
html_url: https://github.com/dynaconf/dynaconf
|
||||
stars: 4325
|
||||
stars: 4333
|
||||
owner_login: dynaconf
|
||||
owner_html_url: https://github.com/dynaconf
|
||||
- name: chatgpt-web-share
|
||||
html_url: https://github.com/chatpire/chatgpt-web-share
|
||||
stars: 4273
|
||||
stars: 4276
|
||||
owner_login: chatpire
|
||||
owner_html_url: https://github.com/chatpire
|
||||
- name: tick-stock-panel
|
||||
html_url: https://github.com/shy3130/tick-stock-panel
|
||||
stars: 4101
|
||||
owner_login: shy3130
|
||||
owner_html_url: https://github.com/shy3130
|
||||
- name: RVG
|
||||
html_url: https://github.com/arvin341az-glitch/RVG
|
||||
stars: 4140
|
||||
owner_login: arvin341az-glitch
|
||||
owner_html_url: https://github.com/arvin341az-glitch
|
||||
- name: atrilabs-engine
|
||||
html_url: https://github.com/Atri-Labs/atrilabs-engine
|
||||
stars: 4066
|
||||
stars: 4063
|
||||
owner_login: Atri-Labs
|
||||
owner_html_url: https://github.com/Atri-Labs
|
||||
- name: datamodel-code-generator
|
||||
html_url: https://github.com/koxudaxi/datamodel-code-generator
|
||||
stars: 4008
|
||||
owner_login: koxudaxi
|
||||
owner_html_url: https://github.com/koxudaxi
|
||||
html_url: https://github.com/datamodel-code-generator/datamodel-code-generator
|
||||
stars: 4027
|
||||
owner_login: datamodel-code-generator
|
||||
owner_html_url: https://github.com/datamodel-code-generator
|
||||
- name: LitServe
|
||||
html_url: https://github.com/Lightning-AI/LitServe
|
||||
stars: 3934
|
||||
stars: 3943
|
||||
owner_login: Lightning-AI
|
||||
owner_html_url: https://github.com/Lightning-AI
|
||||
- name: RVG
|
||||
html_url: https://github.com/arvin341az-glitch/RVG
|
||||
stars: 3906
|
||||
owner_login: arvin341az-glitch
|
||||
owner_html_url: https://github.com/arvin341az-glitch
|
||||
- name: fastapi-admin
|
||||
html_url: https://github.com/fastapi-admin/fastapi-admin
|
||||
stars: 3819
|
||||
stars: 3828
|
||||
owner_login: fastapi-admin
|
||||
owner_html_url: https://github.com/fastapi-admin
|
||||
- name: tracecat
|
||||
html_url: https://github.com/TracecatHQ/tracecat
|
||||
stars: 3782
|
||||
stars: 3823
|
||||
owner_login: TracecatHQ
|
||||
owner_html_url: https://github.com/TracecatHQ
|
||||
- name: Rapid-MLX
|
||||
html_url: https://github.com/raullenchai/Rapid-MLX
|
||||
stars: 3630
|
||||
owner_login: raullenchai
|
||||
owner_html_url: https://github.com/raullenchai
|
||||
- name: farfalle
|
||||
html_url: https://github.com/rashadphz/farfalle
|
||||
stars: 3543
|
||||
stars: 3540
|
||||
owner_login: rashadphz
|
||||
owner_html_url: https://github.com/rashadphz
|
||||
- name: OpenExecutive
|
||||
html_url: https://github.com/SenteLabsAI/OpenExecutive
|
||||
stars: 3322
|
||||
owner_login: SenteLabsAI
|
||||
owner_html_url: https://github.com/SenteLabsAI
|
||||
- name: any-auto-register
|
||||
html_url: https://github.com/lxf746/any-auto-register
|
||||
stars: 3221
|
||||
stars: 3322
|
||||
owner_login: lxf746
|
||||
owner_html_url: https://github.com/lxf746
|
||||
- name: codex-lb
|
||||
html_url: https://github.com/Soju06/codex-lb
|
||||
stars: 3284
|
||||
owner_login: Soju06
|
||||
owner_html_url: https://github.com/Soju06
|
||||
- name: opyrator
|
||||
html_url: https://github.com/ml-tooling/opyrator
|
||||
stars: 3131
|
||||
stars: 3132
|
||||
owner_login: ml-tooling
|
||||
owner_html_url: https://github.com/ml-tooling
|
||||
- name: docarray
|
||||
@@ -246,72 +236,72 @@ repos:
|
||||
owner_html_url: https://github.com/docarray
|
||||
- name: fastapi-realworld-example-app
|
||||
html_url: https://github.com/nsidnev/fastapi-realworld-example-app
|
||||
stars: 3107
|
||||
stars: 3104
|
||||
owner_login: nsidnev
|
||||
owner_html_url: https://github.com/nsidnev
|
||||
- name: uvicorn-gunicorn-fastapi-docker
|
||||
html_url: https://github.com/tiangolo/uvicorn-gunicorn-fastapi-docker
|
||||
stars: 2916
|
||||
stars: 2914
|
||||
owner_login: tiangolo
|
||||
owner_html_url: https://github.com/tiangolo
|
||||
- name: codex-lb
|
||||
html_url: https://github.com/Soju06/codex-lb
|
||||
stars: 2904
|
||||
owner_login: Soju06
|
||||
owner_html_url: https://github.com/Soju06
|
||||
- name: FastAPI-template
|
||||
html_url: https://github.com/s3rius/FastAPI-template
|
||||
stars: 2826
|
||||
owner_login: s3rius
|
||||
owner_html_url: https://github.com/s3rius
|
||||
- name: sqladmin
|
||||
html_url: https://github.com/smithyhq/sqladmin
|
||||
stars: 2817
|
||||
stars: 2837
|
||||
owner_login: smithyhq
|
||||
owner_html_url: https://github.com/smithyhq
|
||||
- name: YC-Killer
|
||||
html_url: https://github.com/sahibzada-allahyar/YC-Killer
|
||||
stars: 2805
|
||||
stars: 2834
|
||||
owner_login: sahibzada-allahyar
|
||||
owner_html_url: https://github.com/sahibzada-allahyar
|
||||
- name: FastAPI-template
|
||||
html_url: https://github.com/s3rius/FastAPI-template
|
||||
stars: 2833
|
||||
owner_login: s3rius
|
||||
owner_html_url: https://github.com/s3rius
|
||||
- name: NoteDiscovery
|
||||
html_url: https://github.com/gamosoft/NoteDiscovery
|
||||
stars: 2776
|
||||
stars: 2815
|
||||
owner_login: gamosoft
|
||||
owner_html_url: https://github.com/gamosoft
|
||||
- name: best-of-web-python
|
||||
html_url: https://github.com/ml-tooling/best-of-web-python
|
||||
stars: 2755
|
||||
stars: 2763
|
||||
owner_login: ml-tooling
|
||||
owner_html_url: https://github.com/ml-tooling
|
||||
- name: fastapi-langgraph-agent-production-ready-template
|
||||
html_url: https://github.com/wassim249/fastapi-langgraph-agent-production-ready-template
|
||||
stars: 2630
|
||||
stars: 2686
|
||||
owner_login: wassim249
|
||||
owner_html_url: https://github.com/wassim249
|
||||
- name: fastapi-react
|
||||
html_url: https://github.com/Buuntu/fastapi-react
|
||||
stars: 2584
|
||||
owner_login: Buuntu
|
||||
owner_html_url: https://github.com/Buuntu
|
||||
- name: supabase-py
|
||||
html_url: https://github.com/supabase/supabase-py
|
||||
stars: 2571
|
||||
stars: 2596
|
||||
owner_login: supabase
|
||||
owner_html_url: https://github.com/supabase
|
||||
- name: open-wearables
|
||||
html_url: https://github.com/the-momentum/open-wearables
|
||||
stars: 2591
|
||||
owner_login: the-momentum
|
||||
owner_html_url: https://github.com/the-momentum
|
||||
- name: fastapi-react
|
||||
html_url: https://github.com/Buuntu/fastapi-react
|
||||
stars: 2591
|
||||
owner_login: Buuntu
|
||||
owner_html_url: https://github.com/Buuntu
|
||||
- name: fastapi-best-architecture
|
||||
html_url: https://github.com/fastapi-practices/fastapi-best-architecture
|
||||
stars: 2530
|
||||
stars: 2573
|
||||
owner_login: fastapi-practices
|
||||
owner_html_url: https://github.com/fastapi-practices
|
||||
- name: 30-Days-of-Python
|
||||
html_url: https://github.com/codingforentrepreneurs/30-Days-of-Python
|
||||
stars: 2511
|
||||
stars: 2524
|
||||
owner_login: codingforentrepreneurs
|
||||
owner_html_url: https://github.com/codingforentrepreneurs
|
||||
- name: AIstudioProxyAPI
|
||||
html_url: https://github.com/CJackHwang/AIstudioProxyAPI
|
||||
stars: 2496
|
||||
stars: 2514
|
||||
owner_login: CJackHwang
|
||||
owner_html_url: https://github.com/CJackHwang
|
||||
- name: RasaGPT
|
||||
@@ -319,178 +309,188 @@ repos:
|
||||
stars: 2463
|
||||
owner_login: paulpierre
|
||||
owner_html_url: https://github.com/paulpierre
|
||||
- name: open-wearables
|
||||
html_url: https://github.com/the-momentum/open-wearables
|
||||
stars: 2432
|
||||
owner_login: the-momentum
|
||||
owner_html_url: https://github.com/the-momentum
|
||||
- name: nextpy
|
||||
html_url: https://github.com/dot-agent/nextpy
|
||||
stars: 2349
|
||||
stars: 2346
|
||||
owner_login: dot-agent
|
||||
owner_html_url: https://github.com/dot-agent
|
||||
- name: langserve
|
||||
html_url: https://github.com/langchain-ai/langserve
|
||||
stars: 2329
|
||||
stars: 2327
|
||||
owner_login: langchain-ai
|
||||
owner_html_url: https://github.com/langchain-ai
|
||||
- name: fastapi-utils
|
||||
html_url: https://github.com/fastapiutils/fastapi-utils
|
||||
stars: 2309
|
||||
stars: 2308
|
||||
owner_login: fastapiutils
|
||||
owner_html_url: https://github.com/fastapiutils
|
||||
- name: kiro-gateway
|
||||
html_url: https://github.com/jwadow/kiro-gateway
|
||||
stars: 2251
|
||||
stars: 2299
|
||||
owner_login: jwadow
|
||||
owner_html_url: https://github.com/jwadow
|
||||
- name: vue-fastapi-admin
|
||||
html_url: https://github.com/mizhexiaoxiao/vue-fastapi-admin
|
||||
stars: 2245
|
||||
stars: 2261
|
||||
owner_login: mizhexiaoxiao
|
||||
owner_html_url: https://github.com/mizhexiaoxiao
|
||||
- name: creatorhub
|
||||
html_url: https://github.com/3441293738/creatorhub
|
||||
stars: 2200
|
||||
owner_login: '3441293738'
|
||||
owner_html_url: https://github.com/3441293738
|
||||
- name: solara
|
||||
html_url: https://github.com/widgetti/solara
|
||||
stars: 2172
|
||||
stars: 2179
|
||||
owner_login: widgetti
|
||||
owner_html_url: https://github.com/widgetti
|
||||
- name: mangum
|
||||
html_url: https://github.com/Kludex/mangum
|
||||
stars: 2131
|
||||
stars: 2138
|
||||
owner_login: Kludex
|
||||
owner_html_url: https://github.com/Kludex
|
||||
- name: FastAPI-boilerplate
|
||||
html_url: https://github.com/benavlabs/FastAPI-boilerplate
|
||||
stars: 2070
|
||||
stars: 2092
|
||||
owner_login: benavlabs
|
||||
owner_html_url: https://github.com/benavlabs
|
||||
- name: xhs_ai_publisher
|
||||
html_url: https://github.com/BetaStreetOmnis/xhs_ai_publisher
|
||||
stars: 2068
|
||||
stars: 2089
|
||||
owner_login: BetaStreetOmnis
|
||||
owner_html_url: https://github.com/BetaStreetOmnis
|
||||
- name: slowapi
|
||||
html_url: https://github.com/laurentS/slowapi
|
||||
stars: 2052
|
||||
stars: 2067
|
||||
owner_login: laurentS
|
||||
owner_html_url: https://github.com/laurentS
|
||||
- name: openapi-python-client
|
||||
html_url: https://github.com/openapi-generators/openapi-python-client
|
||||
stars: 1985
|
||||
stars: 1995
|
||||
owner_login: openapi-generators
|
||||
owner_html_url: https://github.com/openapi-generators
|
||||
- name: agentkit
|
||||
html_url: https://github.com/BCG-X-Official/agentkit
|
||||
stars: 1949
|
||||
owner_login: BCG-X-Official
|
||||
owner_html_url: https://github.com/BCG-X-Official
|
||||
- name: piccolo
|
||||
html_url: https://github.com/piccolo-orm/piccolo
|
||||
stars: 1936
|
||||
stars: 1948
|
||||
owner_login: piccolo-orm
|
||||
owner_html_url: https://github.com/piccolo-orm
|
||||
- name: manage-fastapi
|
||||
html_url: https://github.com/ycd/manage-fastapi
|
||||
stars: 1907
|
||||
owner_login: ycd
|
||||
owner_html_url: https://github.com/ycd
|
||||
- name: fastapi-cache
|
||||
html_url: https://github.com/long2ice/fastapi-cache
|
||||
stars: 1866
|
||||
owner_login: long2ice
|
||||
owner_html_url: https://github.com/long2ice
|
||||
- name: WebRPA
|
||||
html_url: https://github.com/pmh1314520/WebRPA
|
||||
stars: 1866
|
||||
owner_login: pmh1314520
|
||||
owner_html_url: https://github.com/pmh1314520
|
||||
- name: agentkit
|
||||
html_url: https://github.com/BCG-X-Official/agentkit
|
||||
stars: 1946
|
||||
owner_login: BCG-X-Official
|
||||
owner_html_url: https://github.com/BCG-X-Official
|
||||
- name: full-stack-ai-agent-template
|
||||
html_url: https://github.com/vstorm-co/full-stack-ai-agent-template
|
||||
stars: 1862
|
||||
stars: 1926
|
||||
owner_login: vstorm-co
|
||||
owner_html_url: https://github.com/vstorm-co
|
||||
- name: creatorhub
|
||||
html_url: https://github.com/3441293738/creatorhub
|
||||
- name: PanWatch
|
||||
html_url: https://github.com/TNT-Likely/PanWatch
|
||||
stars: 1915
|
||||
owner_login: TNT-Likely
|
||||
owner_html_url: https://github.com/TNT-Likely
|
||||
- name: manage-fastapi
|
||||
html_url: https://github.com/ycd/manage-fastapi
|
||||
stars: 1905
|
||||
owner_login: ycd
|
||||
owner_html_url: https://github.com/ycd
|
||||
- name: WebRPA
|
||||
html_url: https://github.com/pmh1314520/WebRPA
|
||||
stars: 1899
|
||||
owner_login: pmh1314520
|
||||
owner_html_url: https://github.com/pmh1314520
|
||||
- name: fastapi-cache
|
||||
html_url: https://github.com/long2ice/fastapi-cache
|
||||
stars: 1867
|
||||
owner_login: long2ice
|
||||
owner_html_url: https://github.com/long2ice
|
||||
- name: FileSync
|
||||
html_url: https://github.com/polius/FileSync
|
||||
stars: 1816
|
||||
owner_login: '3441293738'
|
||||
owner_html_url: https://github.com/3441293738
|
||||
owner_login: polius
|
||||
owner_html_url: https://github.com/polius
|
||||
- name: ormar
|
||||
html_url: https://github.com/ormar-orm/ormar
|
||||
stars: 1802
|
||||
stars: 1803
|
||||
owner_login: ormar-orm
|
||||
owner_html_url: https://github.com/ormar-orm
|
||||
- name: python-week-2022
|
||||
html_url: https://github.com/rochacbruno/python-week-2022
|
||||
stars: 1797
|
||||
stars: 1796
|
||||
owner_login: rochacbruno
|
||||
owner_html_url: https://github.com/rochacbruno
|
||||
- name: termpair
|
||||
html_url: https://github.com/cs01/termpair
|
||||
stars: 1779
|
||||
stars: 1778
|
||||
owner_login: cs01
|
||||
owner_html_url: https://github.com/cs01
|
||||
- name: bracket
|
||||
html_url: https://github.com/evroon/bracket
|
||||
stars: 1728
|
||||
stars: 1740
|
||||
owner_login: evroon
|
||||
owner_html_url: https://github.com/evroon
|
||||
- name: fastapi-crudrouter
|
||||
html_url: https://github.com/awtkns/fastapi-crudrouter
|
||||
stars: 1696
|
||||
stars: 1698
|
||||
owner_login: awtkns
|
||||
owner_html_url: https://github.com/awtkns
|
||||
- name: docling-api
|
||||
html_url: https://github.com/drmingler/docling-api
|
||||
stars: 1688
|
||||
owner_login: drmingler
|
||||
owner_html_url: https://github.com/drmingler
|
||||
- name: fastapi-pagination
|
||||
html_url: https://github.com/uriyyo/fastapi-pagination
|
||||
stars: 1680
|
||||
stars: 1682
|
||||
owner_login: uriyyo
|
||||
owner_html_url: https://github.com/uriyyo
|
||||
- name: langchain-serve
|
||||
html_url: https://github.com/jina-ai/langchain-serve
|
||||
stars: 1641
|
||||
stars: 1637
|
||||
owner_login: jina-ai
|
||||
owner_html_url: https://github.com/jina-ai
|
||||
- name: awesome-fastapi-projects
|
||||
html_url: https://github.com/Kludex/awesome-fastapi-projects
|
||||
stars: 1618
|
||||
stars: 1620
|
||||
owner_login: Kludex
|
||||
owner_html_url: https://github.com/Kludex
|
||||
- name: docling-api
|
||||
html_url: https://github.com/drmingler/docling-api
|
||||
stars: 1597
|
||||
owner_login: drmingler
|
||||
owner_html_url: https://github.com/drmingler
|
||||
- name: fastcrud
|
||||
html_url: https://github.com/benavlabs/fastcrud
|
||||
stars: 1577
|
||||
owner_login: benavlabs
|
||||
owner_html_url: https://github.com/benavlabs
|
||||
- name: coronavirus-tracker-api
|
||||
html_url: https://github.com/ExpDev07/coronavirus-tracker-api
|
||||
stars: 1569
|
||||
owner_login: ExpDev07
|
||||
owner_html_url: https://github.com/ExpDev07
|
||||
- name: fastapi-amis-admin
|
||||
html_url: https://github.com/amisadmin/fastapi-amis-admin
|
||||
stars: 1566
|
||||
owner_login: amisadmin
|
||||
owner_html_url: https://github.com/amisadmin
|
||||
- name: tavily-key-generator
|
||||
html_url: https://github.com/skernelx/tavily-key-generator
|
||||
stars: 1553
|
||||
owner_login: skernelx
|
||||
owner_html_url: https://github.com/skernelx
|
||||
- name: yubal
|
||||
html_url: https://github.com/guillevc/yubal
|
||||
stars: 1529
|
||||
stars: 1597
|
||||
owner_login: guillevc
|
||||
owner_html_url: https://github.com/guillevc
|
||||
- name: fastcrud
|
||||
html_url: https://github.com/benavlabs/fastcrud
|
||||
stars: 1592
|
||||
owner_login: benavlabs
|
||||
owner_html_url: https://github.com/benavlabs
|
||||
- name: fastapi-amis-admin
|
||||
html_url: https://github.com/amisadmin/fastapi-amis-admin
|
||||
stars: 1568
|
||||
owner_login: amisadmin
|
||||
owner_html_url: https://github.com/amisadmin
|
||||
- name: coronavirus-tracker-api
|
||||
html_url: https://github.com/ExpDev07/coronavirus-tracker-api
|
||||
stars: 1568
|
||||
owner_login: ExpDev07
|
||||
owner_html_url: https://github.com/ExpDev07
|
||||
- name: tavily-key-generator
|
||||
html_url: https://github.com/skernelx/tavily-key-generator
|
||||
stars: 1561
|
||||
owner_login: skernelx
|
||||
owner_html_url: https://github.com/skernelx
|
||||
- name: fim-one
|
||||
html_url: https://github.com/fim-ai/fim-one
|
||||
stars: 1550
|
||||
owner_login: fim-ai
|
||||
owner_html_url: https://github.com/fim-ai
|
||||
- name: RuoYi-Vue3-FastAPI
|
||||
html_url: https://github.com/insistence/RuoYi-Vue3-FastAPI
|
||||
stars: 1516
|
||||
stars: 1544
|
||||
owner_login: insistence
|
||||
owner_html_url: https://github.com/insistence
|
||||
- name: FileSync
|
||||
html_url: https://github.com/polius/FileSync
|
||||
stars: 1494
|
||||
owner_login: polius
|
||||
owner_html_url: https://github.com/polius
|
||||
- name: fastapi-boilerplate
|
||||
html_url: https://github.com/teamhide/fastapi-boilerplate
|
||||
stars: 1495
|
||||
owner_login: teamhide
|
||||
owner_html_url: https://github.com/teamhide
|
||||
@@ -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.
|
||||
@@ -549,3 +549,10 @@ Inspired by Termynal's CSS tricks with modifications
|
||||
|
||||
/* Hidden in MkDocs; rendered on GitHub (which doesn't load this stylesheet) */
|
||||
.only-github { display: none; }
|
||||
|
||||
.newsletter-embed {
|
||||
display: block;
|
||||
width: calc(100% + 8px);
|
||||
margin: -4px;
|
||||
border: 0;
|
||||
}
|
||||
@@ -20,5 +20,4 @@ Some other cloud providers ✨ [**sponsor FastAPI**](https://github.com/sponsors
|
||||
|
||||
You might also want to consider them to follow their guides and try their services:
|
||||
|
||||
* [Render](https://docs.render.com/deploy-fastapi?utm_source=deploydoc&utm_medium=referral&utm_campaign=fastapi)
|
||||
* [Railway](https://docs.railway.com/guides/fastapi?utm_medium=integration&utm_source=docs&utm_campaign=fastapi)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
@@ -238,12 +238,45 @@ function setupOpinionsTabs() {
|
||||
});
|
||||
}
|
||||
|
||||
let cleanupNewsletterEmbed = () => {};
|
||||
|
||||
function setupNewsletterEmbed() {
|
||||
cleanupNewsletterEmbed();
|
||||
const frame = document.querySelector('.newsletter-embed');
|
||||
if (!frame) return;
|
||||
const origin = new URL(frame.src).origin;
|
||||
const configure = () => frame.contentWindow.postMessage({
|
||||
type: 'fastapi-newsletter:configure',
|
||||
theme: document.body.dataset.mdColorScheme === 'slate' ? 'dark' : 'light',
|
||||
fontSize: parseFloat(getComputedStyle(frame.parentElement).fontSize),
|
||||
}, origin);
|
||||
const resize = (event) => {
|
||||
if (event.origin !== origin || event.source !== frame.contentWindow) return;
|
||||
const { type, height } = event.data ?? {};
|
||||
if (type !== 'fastapi-newsletter:resize' || !Number.isFinite(height) || height <= 0 || height > 2000) return;
|
||||
frame.style.height = `${Math.ceil(height)}px`;
|
||||
};
|
||||
window.addEventListener('message', resize);
|
||||
frame.addEventListener('load', configure);
|
||||
window.addEventListener('resize', configure);
|
||||
const themeObserver = new MutationObserver(configure);
|
||||
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-md-color-scheme'] });
|
||||
configure();
|
||||
cleanupNewsletterEmbed = () => {
|
||||
window.removeEventListener('message', resize);
|
||||
frame.removeEventListener('load', configure);
|
||||
window.removeEventListener('resize', configure);
|
||||
themeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
setupTermynal();
|
||||
showRandomAnnouncement('announce-left', 5000)
|
||||
handleSponsorImages();
|
||||
openLinksInNewTab();
|
||||
setupOpinionsTabs();
|
||||
setupNewsletterEmbed();
|
||||
}
|
||||
document$.subscribe(() => {
|
||||
main()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# FastAPI and friends newsletter
|
||||
|
||||
<iframe data-w-type="embedded" frameborder="0" scrolling="no" marginheight="0" marginwidth="0" src="https://xr4n4.mjt.lu/wgt/xr4n4/hj5/form?c=40a44fa4" width="100%" style="height: 800px;"></iframe>
|
||||
<iframe class="newsletter-embed" src="https://fastapiandfriends.com/embed/" width="100%" height="280"></iframe>
|
||||
|
||||
<script type="text/javascript" src="https://app.mailjet.com/pas-nc-embedded-v1.js"></script>
|
||||
[Read previous issues](https://fastapiandfriends.com/newsletter) or [subscribe on the FastAPI and friends website](https://fastapiandfriends.com/#subscribe).
|
||||
@@ -7,6 +7,61 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
### Docs
|
||||
|
||||
* 📝 Remove HTML title from newsletter to avoid the tooltip. PR [#16461](https://github.com/fastapi/fastapi/pull/16461) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 📝 Embed the FastAPI and friends newsletter signup form. PR [#16459](https://github.com/fastapi/fastapi/pull/16459) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 📝 Update skill, use Asyncer for blocking code in threads. PR [#16420](https://github.com/fastapi/fastapi/pull/16420) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Translations
|
||||
|
||||
* 🌐 Update translations for fr (add-missing). PR [#16436](https://github.com/fastapi/fastapi/pull/16436) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for hi (add-missing). PR [#16435](https://github.com/fastapi/fastapi/pull/16435) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for tr (add-missing). PR [#16434](https://github.com/fastapi/fastapi/pull/16434) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for zh (add-missing). PR [#16433](https://github.com/fastapi/fastapi/pull/16433) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for zh-hant (add-missing). PR [#16432](https://github.com/fastapi/fastapi/pull/16432) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for pt (add-missing). PR [#16431](https://github.com/fastapi/fastapi/pull/16431) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for de (add-missing). PR [#16430](https://github.com/fastapi/fastapi/pull/16430) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for uk (add-missing). PR [#16429](https://github.com/fastapi/fastapi/pull/16429) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for ja (add-missing). PR [#16428](https://github.com/fastapi/fastapi/pull/16428) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for ko (add-missing). PR [#16425](https://github.com/fastapi/fastapi/pull/16425) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for es (add-missing). PR [#16426](https://github.com/fastapi/fastapi/pull/16426) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update translations for ru (add-missing). PR [#16427](https://github.com/fastapi/fastapi/pull/16427) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 🌐 Update Russian LLM-prompt. PR [#16449](https://github.com/fastapi/fastapi/pull/16449) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
### Internal
|
||||
|
||||
* 👷 Fix deprecated command in `bump-pre-commit-hooks` workflow. PR [#16463](https://github.com/fastapi/fastapi/pull/16463) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
* 🔧 Update sponsors: remove Render. PR [#16457](https://github.com/fastapi/fastapi/pull/16457) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 🔨 Use `gpt-6-astra` model for translations. PR [#16453](https://github.com/fastapi/fastapi/pull/16453) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
* ⬆ Bump the python-packages group across 1 directory with 14 updates. PR [#16447](https://github.com/fastapi/fastapi/pull/16447) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump starlette from 1.6.0 to 1.7.0. PR [#16440](https://github.com/fastapi/fastapi/pull/16440) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump pyjwt from 2.13.0 to 2.15.0. PR [#16423](https://github.com/fastapi/fastapi/pull/16423) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump gitpython from 3.1.60 to 3.1.62. PR [#16444](https://github.com/fastapi/fastapi/pull/16444) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* 👥 Update FastAPI GitHub topic repositories. PR [#16443](https://github.com/fastapi/fastapi/pull/16443) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* 👥 Update FastAPI People - Sponsors. PR [#16437](https://github.com/fastapi/fastapi/pull/16437) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* ⬆ Bump pre-commit hooks. PR [#16442](https://github.com/fastapi/fastapi/pull/16442) by [@pr-submit[bot]](https://github.com/apps/pr-submit).
|
||||
* ⬆ Bump urllib3 from 2.7.0 to 2.8.0. PR [#16445](https://github.com/fastapi/fastapi/pull/16445) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump the github-actions group with 3 updates. PR [#16438](https://github.com/fastapi/fastapi/pull/16438) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
|
||||
## 0.142.2 (2026-09-30)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Allow startup when automatic OpenTelemetry configuration fails. PR [#16418](https://github.com/fastapi/fastapi/pull/16418) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.142.1 (2026-09-29)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix repeated endpoint wrapping in included routers. PR [#16414](https://github.com/fastapi/fastapi/pull/16414) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.142.0 (2026-09-29)
|
||||
|
||||
### Features
|
||||
|
||||
* ✨ Add native OpenTelemetry support. PR [#16403](https://github.com/fastapi/fastapi/pull/16403) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Refactors
|
||||
|
||||
* 📱 Improve mobile responsiveness of conference rail. PR [#16196](https://github.com/fastapi/fastapi/pull/16196) by [@alejsdev](https://github.com/alejsdev).
|
||||
@@ -35,6 +90,9 @@ hide:
|
||||
|
||||
### Internal
|
||||
|
||||
* ✅ Fix frontend test timeout with Starlette Git. PR [#16408](https://github.com/fastapi/fastapi/pull/16408) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
* 🔧 Update sponsors: remove Permit.io. PR [#16406](https://github.com/fastapi/fastapi/pull/16406) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ⬆ Bump anyio from 4.12.1 to 4.14.2. PR [#16375](https://github.com/fastapi/fastapi/pull/16375) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump the python-packages group across 1 directory with 15 updates. PR [#16285](https://github.com/fastapi/fastapi/pull/16285) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump starlette from 1.3.1 to 1.6.0. PR [#16289](https://github.com/fastapi/fastapi/pull/16289) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump annotated-doc from 0.0.4 to 0.0.5. PR [#16288](https://github.com/fastapi/fastapi/pull/16288) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,12 +10,6 @@
|
||||
<img class="sponsor-image" src="/img/sponsors/propelauth-banner.png" alt="Auth, user management and more for your B2B product" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="item">
|
||||
<a title="Deploy & scale any full-stack web app on Render. Focus on building apps, not infra." style="display: block; position: relative;" href="https://docs.render.com/deploy-fastapi?utm_source=deploydoc&utm_medium=referral&utm_campaign=fastapi" target="_blank">
|
||||
<span class="sponsor-badge">sponsor</span>
|
||||
<img class="sponsor-image" src="/img/sponsors/render-banner.svg" alt="Deploy & scale any full-stack web app on Render. Focus on building apps, not infra." />
|
||||
</a>
|
||||
</div>
|
||||
<div class="item">
|
||||
<a title="Cut Code Review Time & Bugs in Half with CodeRabbit" style="display: block; position: relative;" href="https://www.coderabbit.ai/?utm_source=fastapi&utm_medium=banner&utm_campaign=fastapi" target="_blank">
|
||||
<span class="sponsor-badge">sponsor</span>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
Cuando tu API está en funcionamiento, puede que quieras saber cuánto tráfico recibe, qué requests son lentas y cuándo se producen errores.
|
||||
|
||||
La **telemetría** son datos sobre el comportamiento de tu aplicación que te ayudan a responder estas preguntas. Algunos tipos comunes son:
|
||||
|
||||
- **Métricas**: mediciones que puedes resumir a lo largo del tiempo, como los tiempos de response y el número de requests que se están procesando.
|
||||
- **Trazas**: registros de requests individuales y de las operaciones realizadas para procesarlas. Cada operación cronometrada se llama **span**.
|
||||
- **Logs**: registros de eventos con marcas de tiempo, como el inicio de una aplicación o el fallo de una operación.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) es un conjunto de estándares y herramientas para recopilar telemetría y enviarla a un servicio de monitorización, donde puedes explorarla en paneles.
|
||||
|
||||
**FastAPI proporciona soporte para OpenTelemetry por defecto** para trazas, métricas y logs de requests HTTP. Las conexiones WebSocket también proporcionan trazas y logs. Para ver esos datos, configura un servicio de monitorización que los reciba.
|
||||
|
||||
## Instala FastAPI { #install-fastapi }
|
||||
|
||||
Instala FastAPI con los extras `standard`, que incluyen los paquetes para enviar telemetría:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Crea la aplicación { #create-the-app }
|
||||
|
||||
Crea un archivo `main.py`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Ten en cuenta que todo funciona por defecto, no necesitas escribir ningún código personalizado para que funcione la telemetría.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Cuando despliegas en [FastAPI Cloud](https://fastapicloud.com) con `fastapi[standard]`, las métricas funcionan automáticamente. No tienes que configurar nada más.
|
||||
|
||||
En los planes Pro, puedes ver el número de requests, las tasas de error y los tiempos de response en el [panel de métricas](https://fastapicloud.com/docs/monitoring-and-performance/metrics/).
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="Panel de métricas de FastAPI Cloud Pro con datos de ejemplo">
|
||||
|
||||
## Otros servicios de monitorización { #other-monitoring-services }
|
||||
|
||||
Para enviar telemetría a otro servicio de monitorización, configura un endpoint que acepte **OTLP**, el protocolo de OpenTelemetry para enviar telemetría. Usa el endpoint base HTTP/protobuf del servicio.
|
||||
|
||||
Establece estas variables de entorno, reemplazando la URL de ejemplo por tu endpoint:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` identifica tu aplicación en el servicio de monitorización. El endpoint es la URL base para recibir datos. Las trazas se envían a `/v1/traces`, las métricas a `/v1/metrics` y los logs a `/v1/logs` bajo esa URL.
|
||||
|
||||
Si tu servicio requiere autenticación, establece `OTEL_EXPORTER_OTLP_HEADERS` con los headers que especifique, por ejemplo `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Ejecuta la aplicación { #run-the-app }
|
||||
|
||||
Inicia la aplicación en la misma terminal:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
En otra terminal, envía una request:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
Abre tu servicio de monitorización y busca `my-api`. Después de la siguiente exportación, podrás ver una traza con un span `GET /items/{item_id}`, junto con métricas del número de requests, la duración de las responses y las requests activas.
|
||||
|
||||
## Personaliza la telemetría { #customize-telemetry }
|
||||
|
||||
### Configura proveedores y exportadores { #configure-providers-and-exporters }
|
||||
|
||||
Un **proveedor** proporciona los objetos que registran trazas, métricas o logs. Su configuración controla cómo se procesan y exportan esos datos.
|
||||
|
||||
Los paquetes de telemetría pueden configurar los proveedores globales de OpenTelemetry. Configura el paquete antes de que se inicie la aplicación y FastAPI usará esos proveedores automáticamente.
|
||||
|
||||
Cuando se establece un endpoint OTLP en el entorno, FastAPI añade un exportador para ese destino a cada proveedor habilitado. Los exportadores existentes siguen enviando datos a sus destinos.
|
||||
|
||||
Configura cada destino una sola vez. Si otro paquete ya gestiona el destino del entorno, desactiva su exportación al destino del entorno o desactiva la configuración automática de FastAPI:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
También puedes pasar un proveedor directamente en el diccionario `telemetry`. Por ejemplo, este proveedor usa el exportador de consola de OpenTelemetry para imprimir los spans de las requests en tu terminal:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
El **exportador** envía los spans a su destino. `BatchSpanProcessor` agrupa los spans y los envía en segundo plano. Reemplaza el exportador de consola por uno proporcionado por tu paquete de monitorización para usar su destino. Consulta la [guía de instrumentación de Python de OpenTelemetry](https://opentelemetry.io/docs/languages/python/instrumentation/) para ver más opciones de configuración.
|
||||
|
||||
Usa `meter_provider` o `logger_provider` en el mismo diccionario para proporcionar un proveedor de métricas o logs. La aplicación o el paquete que crea un proveedor gestiona su cierre. FastAPI gestiona los componentes de exportación que añade.
|
||||
|
||||
/// warning | Advertencia
|
||||
|
||||
OpenTelemetry usa proveedores globales por defecto. No se garantiza una configuración de telemetría independiente para las [subaplicaciones montadas](sub-applications.md).
|
||||
|
||||
///
|
||||
|
||||
### Traza las operaciones de las requests { #trace-request-operations }
|
||||
|
||||
Por defecto, las trazas de las requests incluyen spans para resolver dependencias, ejecutar tu path operation function, serializar la response y ejecutar cada tarea de `BackgroundTasks` de FastAPI. Estos spans usan el mismo proveedor y los mismos exportadores.
|
||||
|
||||
Los spans de las tareas en segundo plano siguen formando parte de la traza de la request. Se ejecutan después de que termine el span de la response HTTP, por lo que no aumentan el tiempo de response medido.
|
||||
|
||||
Para registrar solo el span de la request HTTP, establece `operation_spans` en `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### Traza las conexiones WebSocket { #trace-websocket-connections }
|
||||
|
||||
Cada conexión WebSocket tiene un span como `WS /ws/{room}`, que abarca el manejador y la limpieza de las dependencias. Usa los mismos proveedores y ajustes, incluido `operation_spans` para la resolución de dependencias y la ejecución del endpoint.
|
||||
|
||||
Las métricas de requests HTTP abarcan únicamente requests HTTP. Las desconexiones normales de WebSocket con los códigos `1000` o `1001` no generan logs de error.
|
||||
|
||||
### Inspecciona los errores { #inspect-errors }
|
||||
|
||||
FastAPI registra las excepciones no controladas como logs de OpenTelemetry, vinculados a la traza de la request o de la conexión. Los logs de error se registran incluso cuando la traza no se incluye en el muestreo.
|
||||
|
||||
Los logs de excepciones incluyen el tipo, el mensaje y la traza de la pila de la excepción. Los mensajes y las trazas de la pila pueden contener información sensible. Usa los procesadores de logs de tu proveedor para filtrarlos u ocultar la información sensible, o establece `logs` en `False` para desactivar estos logs.
|
||||
|
||||
FastAPI también registra los fallos de validación de las requests como logs de advertencia con la ruta y el número de errores. Estos logs no incluyen la entrada no válida.
|
||||
|
||||
## Elige qué registrar { #choose-what-to-record }
|
||||
|
||||
El diccionario `telemetry` también acepta estos ajustes:
|
||||
|
||||
| Ajuste | Propósito | Por defecto |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | Registrar spans de requests HTTP y conexiones WebSocket | `True` |
|
||||
| `metrics` | Registrar métricas de requests HTTP | `True` |
|
||||
| `logs` | Registrar fallos de validación y excepciones no controladas | `True` |
|
||||
| `operation_spans` | Añadir spans para las operaciones de las requests | `True` |
|
||||
| `exclude` | Omitir requests cuando una función que recibe el scope de ASGI devuelve `True` | `None` |
|
||||
| `auto_configure` | Añadir exportadores para los endpoints establecidos en variables de entorno | `True` |
|
||||
|
||||
Por ejemplo, para recopilar métricas excluyendo las verificaciones de estado:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Establece `auto_configure` en `False` cuando tu aplicación gestione por sí misma la configuración de los proveedores, por ejemplo, dentro de su función lifespan.
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
Lorsque votre API est en cours d'exécution, vous pouvez vouloir savoir quel trafic elle reçoit, quelles requêtes sont lentes et quand des erreurs se produisent.
|
||||
|
||||
La **télémétrie** désigne les données sur le comportement de votre application qui vous aident à répondre à ces questions. Les types courants comprennent :
|
||||
|
||||
- **Métriques** : des mesures que vous pouvez agréger au fil du temps, comme les temps de réponse et le nombre de requêtes en cours de traitement.
|
||||
- **Traces** : des enregistrements de requêtes individuelles et des opérations effectuées pour les traiter. Chaque opération chronométrée est appelée un **span**.
|
||||
- **Logs** : des enregistrements horodatés d'événements, comme le démarrage d'une application ou l'échec d'une opération.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) est un ensemble de standards et d'outils permettant de collecter la télémétrie et de l'envoyer à un service de supervision, où vous pouvez l'explorer dans des tableaux de bord.
|
||||
|
||||
**FastAPI prend en charge OpenTelemetry par défaut** pour les traces, les métriques et les logs des requêtes HTTP. Les connexions WebSocket fournissent également des traces et des logs. Pour consulter ces données, configurez un service de supervision pour les recevoir.
|
||||
|
||||
## Installer FastAPI { #install-fastapi }
|
||||
|
||||
Installez FastAPI avec les dépendances optionnelles `standard`, qui incluent les paquets permettant d'envoyer la télémétrie :
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Créer l'application { #create-the-app }
|
||||
|
||||
Créez un fichier `main.py` :
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Remarquez que tout fonctionne par défaut : vous n'avez pas besoin d'écrire de code personnalisé pour que la télémétrie fonctionne.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Lorsque vous déployez sur [FastAPI Cloud](https://fastapicloud.com) avec `fastapi[standard]`, les métriques fonctionnent automatiquement. Vous n'avez rien d'autre à configurer.
|
||||
|
||||
Avec les offres Pro, vous pouvez consulter le nombre de requêtes, les taux d'erreur et les temps de réponse dans le [tableau de bord des métriques](https://fastapicloud.com/docs/monitoring-and-performance/metrics/).
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="Tableau de bord des métriques FastAPI Cloud Pro avec des exemples de données">
|
||||
|
||||
## Autres services de supervision { #other-monitoring-services }
|
||||
|
||||
Pour envoyer la télémétrie à un autre service de supervision, configurez un endpoint qui accepte **OTLP**, le protocole OpenTelemetry pour l'envoi de télémétrie. Utilisez l'endpoint de base HTTP/protobuf du service.
|
||||
|
||||
Définissez ces variables d'environnement en remplaçant l'URL d'exemple par votre endpoint :
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` identifie votre application dans le service de supervision. L'endpoint est l'URL de base pour recevoir les données. Les traces sont envoyées à `/v1/traces`, les métriques à `/v1/metrics` et les logs à `/v1/logs` sous cette URL.
|
||||
|
||||
Si votre service nécessite une authentification, définissez `OTEL_EXPORTER_OTLP_HEADERS` avec les en-têtes qu'il spécifie, par exemple `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Exécuter l'application { #run-the-app }
|
||||
|
||||
Démarrez l'application dans le même terminal :
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Dans un autre terminal, envoyez une requête :
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
Ouvrez votre service de supervision et recherchez `my-api`. Après le prochain export, vous pouvez voir une trace avec un span `GET /items/{item_id}`, ainsi que des métriques sur le nombre de requêtes, la durée des réponses et les requêtes actives.
|
||||
|
||||
## Personnaliser la télémétrie { #customize-telemetry }
|
||||
|
||||
### Configurer les fournisseurs et les exportateurs { #configure-providers-and-exporters }
|
||||
|
||||
Un **fournisseur** fournit les objets qui enregistrent les traces, les métriques ou les logs. Sa configuration contrôle la façon dont ces données sont traitées et exportées.
|
||||
|
||||
Les bibliothèques de télémétrie peuvent configurer les fournisseurs globaux d'OpenTelemetry. Configurez la bibliothèque avant le démarrage de l'application, et FastAPI utilise automatiquement ces fournisseurs.
|
||||
|
||||
Lorsqu'un endpoint OTLP est défini dans l'environnement, FastAPI ajoute un exportateur pour cette destination à chaque fournisseur activé. Les exportateurs existants continuent d'envoyer des données vers leurs destinations.
|
||||
|
||||
Configurez chaque destination une seule fois. Si une autre bibliothèque gère déjà la destination définie dans l'environnement, désactivez son export vers cette destination ou désactivez la configuration automatique de FastAPI :
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
Vous pouvez aussi passer un fournisseur directement dans le dictionnaire `telemetry`. Par exemple, ce fournisseur utilise l'exportateur console d'OpenTelemetry pour afficher les spans des requêtes dans votre terminal :
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
L'**exportateur** envoie les spans vers leur destination. `BatchSpanProcessor` regroupe les spans et les envoie en arrière-plan. Remplacez l'exportateur console par un exportateur fourni par votre bibliothèque de supervision pour utiliser sa destination. Consultez le [guide d'instrumentation Python d'OpenTelemetry](https://opentelemetry.io/docs/languages/python/instrumentation/) pour plus d'options de configuration.
|
||||
|
||||
Utilisez `meter_provider` ou `logger_provider` dans le même dictionnaire pour fournir un fournisseur de métriques ou de logs. L'application ou la bibliothèque qui crée un fournisseur gère son arrêt. FastAPI gère les composants d'export qu'il ajoute.
|
||||
|
||||
/// warning | Alertes
|
||||
|
||||
OpenTelemetry utilise des fournisseurs globaux par défaut. Une configuration indépendante de la télémétrie pour les [sous-applications montées](sub-applications.md) n'est pas garantie.
|
||||
|
||||
///
|
||||
|
||||
### Tracer les opérations des requêtes { #trace-request-operations }
|
||||
|
||||
Par défaut, les traces des requêtes incluent des spans pour la résolution des dépendances, l'exécution de votre fonction de chemin d'accès, la sérialisation de la réponse et l'exécution de chaque tâche dans `BackgroundTasks` de FastAPI. Ces spans utilisent le même fournisseur et les mêmes exportateurs.
|
||||
|
||||
Les spans des tâches en arrière-plan font toujours partie de la trace de la requête. Ils s'exécutent après la fin du span de la réponse HTTP et n'augmentent donc pas le temps de réponse mesuré.
|
||||
|
||||
Pour enregistrer uniquement le span de la requête HTTP, définissez `operation_spans` sur `False` :
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### Tracer les connexions WebSocket { #trace-websocket-connections }
|
||||
|
||||
Chaque connexion WebSocket possède un span tel que `WS /ws/{room}`, qui couvre le gestionnaire et le nettoyage des dépendances. Il utilise les mêmes fournisseurs et paramètres, notamment `operation_spans` pour la résolution des dépendances et l'exécution de l'endpoint.
|
||||
|
||||
Les métriques des requêtes HTTP couvrent uniquement les requêtes HTTP. Les déconnexions WebSocket normales avec les codes `1000` ou `1001` ne produisent pas de logs d'erreur.
|
||||
|
||||
### Examiner les erreurs { #inspect-errors }
|
||||
|
||||
FastAPI enregistre les exceptions non gérées sous forme de logs OpenTelemetry, liés à la trace de la requête ou de la connexion. Les logs d'erreur sont enregistrés même lorsque la trace n'est pas échantillonnée.
|
||||
|
||||
Les logs d'exception incluent le type, le message et la trace de pile de l'exception. Les messages et les traces de pile peuvent contenir des informations sensibles. Utilisez les processeurs de logs de votre fournisseur pour les filtrer ou masquer ces informations, ou définissez `logs` sur `False` pour désactiver ces logs.
|
||||
|
||||
FastAPI enregistre également les échecs de validation des requêtes sous forme de logs d'avertissement avec la route et le nombre d'erreurs. Ces logs n'incluent pas les données d'entrée invalides.
|
||||
|
||||
## Choisir les données à enregistrer { #choose-what-to-record }
|
||||
|
||||
Le dictionnaire `telemetry` accepte également ces paramètres :
|
||||
|
||||
| Paramètre | Fonction | Valeur par défaut |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | Enregistrer les spans des requêtes HTTP et des connexions WebSocket | `True` |
|
||||
| `metrics` | Enregistrer les métriques des requêtes HTTP | `True` |
|
||||
| `logs` | Enregistrer les échecs de validation et les exceptions non gérées | `True` |
|
||||
| `operation_spans` | Ajouter des spans pour les opérations des requêtes | `True` |
|
||||
| `exclude` | Ignorer les requêtes lorsqu'une fonction recevant le scope ASGI renvoie `True` | `None` |
|
||||
| `auto_configure` | Ajouter des exportateurs pour les endpoints définis dans les variables d'environnement | `True` |
|
||||
|
||||
Par exemple, pour collecter des métriques tout en excluant les vérifications de l'état de santé :
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Définissez `auto_configure` sur `False` lorsque votre application gère elle-même la configuration des fournisseurs, par exemple dans sa fonction lifespan.
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
जब आपका API चल रहा होता है, तो आप शायद जानना चाहें कि उसे कितना ट्रैफ़िक मिल रहा है, कौन-से requests धीमे हैं और त्रुटियाँ कब होती हैं।
|
||||
|
||||
**Telemetry** आपके एप्लिकेशन के व्यवहार से जुड़ा data है, जो इन सवालों के जवाब देने में मदद करता है। इसके सामान्य प्रकार हैं:
|
||||
|
||||
- **Metrics**: ऐसे माप जिन्हें आप समय के साथ संक्षेप में देख सकते हैं, जैसे response में लगने वाला समय और संभाले जा रहे requests की संख्या।
|
||||
- **Traces**: अलग-अलग requests और उन्हें संभालने के लिए किए गए ऑपरेशन के रिकॉर्ड। हर ऑपरेशन जिसका समय मापा जाता है, एक **span** कहलाता है।
|
||||
- **Logs**: टाइमस्टैम्प के साथ event के रिकॉर्ड, जैसे किसी एप्लिकेशन का शुरू होना या किसी ऑपरेशन का विफल होना।
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) telemetry एकत्र करने और उसे किसी मॉनिटरिंग सेवा को भेजने के लिए standards और tools का एक समूह है, जहाँ आप उसे डैशबोर्ड में देख और जाँच सकते हैं।
|
||||
|
||||
**FastAPI, HTTP request traces, metrics और logs के लिए default रूप से OpenTelemetry support प्रदान करता है**। WebSocket कनेक्शन भी traces और logs प्रदान करते हैं। उस data को देखने के लिए, उसे प्राप्त करने वाली मॉनिटरिंग सेवा कॉन्फ़िगर करें।
|
||||
|
||||
## FastAPI install करें { #install-fastapi }
|
||||
|
||||
FastAPI को `standard` extras के साथ install करें, जिनमें telemetry भेजने के लिए packages शामिल हैं:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## ऐप बनाएँ { #create-the-app }
|
||||
|
||||
एक file `main.py` बनाएँ:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
ध्यान दें कि यह सब default रूप से काम करता है। Telemetry के काम करने के लिए आपको कोई कस्टम कोड लिखने की ज़रूरत नहीं है।
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
जब आप `fastapi[standard]` के साथ [FastAPI Cloud](https://fastapicloud.com) पर डिप्लॉय करते हैं, तो metrics अपने आप काम करते हैं। आपको कुछ और कॉन्फ़िगर नहीं करना पड़ता।
|
||||
|
||||
Pro प्लान पर, आप [Metrics डैशबोर्ड](https://fastapicloud.com/docs/monitoring-and-performance/metrics/) में requests की संख्या, त्रुटि दर और response में लगने वाला समय देख सकते हैं।
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="उदाहरण data के साथ FastAPI Cloud Pro metrics डैशबोर्ड">
|
||||
|
||||
## अन्य मॉनिटरिंग सेवाएँ { #other-monitoring-services }
|
||||
|
||||
किसी अन्य मॉनिटरिंग सेवा को telemetry भेजने के लिए, ऐसा endpoint कॉन्फ़िगर करें जो **OTLP** स्वीकार करता हो। यह telemetry भेजने के लिए OpenTelemetry का प्रोटोकॉल है। सेवा के HTTP/protobuf बेस endpoint का उपयोग करें।
|
||||
|
||||
उदाहरण URL की जगह अपना endpoint डालकर, ये environment variables सेट करें:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` मॉनिटरिंग सेवा में आपके ऐप की पहचान करता है। Endpoint, data प्राप्त करने के लिए बेस URL है। उस URL के अंतर्गत traces को `/v1/traces`, metrics को `/v1/metrics` और logs को `/v1/logs` पर भेजा जाता है।
|
||||
|
||||
यदि आपकी सेवा को प्रमाणीकरण की ज़रूरत है, तो `OTEL_EXPORTER_OTLP_HEADERS` में उसके बताए गए headers सेट करें, उदाहरण के लिए `api-key=YOUR_API_KEY`।
|
||||
|
||||
## ऐप चलाएँ { #run-the-app }
|
||||
|
||||
उसी टर्मिनल में ऐप शुरू करें:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
दूसरे टर्मिनल में, एक request भेजें:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
अपनी मॉनिटरिंग सेवा खोलें और `my-api` खोजें। अगले एक्सपोर्ट के बाद, आप `GET /items/{item_id}` span वाला एक trace देख सकते हैं, साथ ही requests की संख्या, response की अवधि और सक्रिय requests के metrics भी देख सकते हैं।
|
||||
|
||||
## Telemetry को अपनी ज़रूरत के अनुसार बदलें { #customize-telemetry }
|
||||
|
||||
### Providers और exporters कॉन्फ़िगर करें { #configure-providers-and-exporters }
|
||||
|
||||
एक **provider** ऐसे ऑब्जेक्ट प्रदान करता है जो traces, metrics या logs रिकॉर्ड करते हैं। उसका कॉन्फ़िगरेशन नियंत्रित करता है कि उस data को कैसे प्रोसेस और एक्सपोर्ट किया जाता है।
|
||||
|
||||
Telemetry लाइब्रेरी OpenTelemetry के ग्लोबल providers को कॉन्फ़िगर कर सकती हैं। ऐप शुरू होने से पहले लाइब्रेरी को कॉन्फ़िगर करें, और FastAPI अपने आप उन providers का उपयोग करता है।
|
||||
|
||||
जब environment में कोई OTLP endpoint सेट होता है, तो FastAPI हर सक्षम provider में उस गंतव्य के लिए एक exporter जोड़ता है। मौजूदा exporters अपने गंतव्यों पर data भेजना जारी रखते हैं।
|
||||
|
||||
हर गंतव्य को एक बार कॉन्फ़िगर करें। यदि कोई दूसरी लाइब्रेरी पहले से ही environment में दिए गए गंतव्य को संभाल रही है, तो उसका environment export अक्षम करें या FastAPI का स्वचालित setup बंद करें:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
आप `telemetry` डिक्शनरी में सीधे एक provider भी पास कर सकते हैं। उदाहरण के लिए, यह provider आपके टर्मिनल में request spans प्रिंट करने के लिए OpenTelemetry के console exporter का उपयोग करता है:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**Exporter**, spans को उनके गंतव्य पर भेजता है। `BatchSpanProcessor`, spans को समूहों में बाँटता है और उन्हें बैकग्राउंड में भेजता है। अपनी मॉनिटरिंग लाइब्रेरी के गंतव्य का उपयोग करने के लिए console exporter की जगह उस लाइब्रेरी द्वारा प्रदान किया गया exporter लगाएँ। कॉन्फ़िगरेशन के और विकल्पों के लिए [OpenTelemetry की Python instrumentation गाइड](https://opentelemetry.io/docs/languages/python/instrumentation/) देखें।
|
||||
|
||||
Metrics या logs provider प्रदान करने के लिए उसी डिक्शनरी में `meter_provider` या `logger_provider` का उपयोग करें। Provider बनाने वाला एप्लिकेशन या लाइब्रेरी उसके shutdown को संभालता है। FastAPI अपने जोड़े गए एक्सपोर्ट घटकों को संभालता है।
|
||||
|
||||
/// warning | चेतावनी
|
||||
|
||||
OpenTelemetry default रूप से ग्लोबल providers का उपयोग करता है। [माउंट किए गए उप-एप्लिकेशन](sub-applications.md) के लिए स्वतंत्र telemetry कॉन्फ़िगरेशन की गारंटी नहीं है।
|
||||
|
||||
///
|
||||
|
||||
### Request ऑपरेशन को ट्रेस करें { #trace-request-operations }
|
||||
|
||||
Default रूप से, request traces में dependencies रिज़ॉल्व करने, आपका path operation function चलाने, response को serialize करने और FastAPI के `BackgroundTasks` में हर टास्क को चलाने के spans शामिल होते हैं। ये spans उसी provider और उन्हीं exporters का उपयोग करते हैं।
|
||||
|
||||
बैकग्राउंड टास्क के spans, request के trace का हिस्सा बने रहते हैं। वे HTTP response span समाप्त होने के बाद चलते हैं, इसलिए वे मापे गए response समय को नहीं बढ़ाते।
|
||||
|
||||
केवल HTTP request span रिकॉर्ड करने के लिए, `operation_spans` को `False` पर सेट करें:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### WebSocket कनेक्शन को ट्रेस करें { #trace-websocket-connections }
|
||||
|
||||
हर WebSocket कनेक्शन का एक span होता है, जैसे `WS /ws/{room}`, जो हैंडलर और dependency cleanup को कवर करता है। यह उन्हीं providers और सेटिंग्स का उपयोग करता है, जिनमें dependency resolution और endpoint के निष्पादन के लिए `operation_spans` भी शामिल है।
|
||||
|
||||
HTTP request metrics में केवल HTTP requests शामिल होते हैं। कोड `1000` या `1001` के साथ होने वाले सामान्य WebSocket डिस्कनेक्ट, error logs नहीं बनाते।
|
||||
|
||||
### त्रुटियों की जाँच करें { #inspect-errors }
|
||||
|
||||
FastAPI, संभाले न गए exceptions को OpenTelemetry logs के रूप में रिकॉर्ड करता है, जो request या कनेक्शन के trace से जुड़े होते हैं। Error logs तब भी रिकॉर्ड किए जाते हैं जब trace को सैंपल नहीं किया जाता।
|
||||
|
||||
Exception logs में exception का प्रकार, संदेश और stack trace शामिल होते हैं। संदेशों और stack traces में संवेदनशील जानकारी हो सकती है। उन्हें फ़िल्टर करने या उनमें से संवेदनशील जानकारी हटाने के लिए अपने provider के log processors का उपयोग करें, या इन logs को अक्षम करने के लिए `logs` को `False` पर सेट करें।
|
||||
|
||||
FastAPI, request validation की विफलताओं को भी route और त्रुटियों की संख्या के साथ warning logs के रूप में रिकॉर्ड करता है। इन logs में अमान्य इनपुट शामिल नहीं होता।
|
||||
|
||||
## चुनें कि क्या रिकॉर्ड करना है { #choose-what-to-record }
|
||||
|
||||
`telemetry` डिक्शनरी इन सेटिंग्स को भी स्वीकार करती है:
|
||||
|
||||
| सेटिंग | उद्देश्य | Default |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | HTTP request और WebSocket कनेक्शन के spans रिकॉर्ड करना | `True` |
|
||||
| `metrics` | HTTP request metrics रिकॉर्ड करना | `True` |
|
||||
| `logs` | Validation की विफलताएँ और संभाले न गए exceptions रिकॉर्ड करना | `True` |
|
||||
| `operation_spans` | Request ऑपरेशन के लिए spans जोड़ना | `True` |
|
||||
| `exclude` | जब ASGI scope प्राप्त करने वाला function `True` लौटाए, तो requests छोड़ देना | `None` |
|
||||
| `auto_configure` | Environment variables में सेट endpoints के लिए exporters जोड़ना | `True` |
|
||||
|
||||
उदाहरण के लिए, health checks को छोड़कर metrics एकत्र करने के लिए:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
जब आपका एप्लिकेशन provider setup खुद संभालता हो, जैसे अपने lifespan function के अंदर, तो `auto_configure` को `False` पर सेट करें।
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
APIを実行しているとき、受信するトラフィックの量、遅いリクエスト、エラーが発生するタイミングを把握したくなることがあります。
|
||||
|
||||
**テレメトリー**は、こうした疑問に答えるためのアプリケーションの動作に関するデータです。一般的には、次の種類があります:
|
||||
|
||||
- **メトリクス**: レスポンス時間や処理中のリクエスト数など、時間の経過に沿って集計できる測定値です。
|
||||
- **トレース**: 個々のリクエストと、それを処理するために実行された操作の記録です。時間を計測する各操作を**スパン**と呼びます。
|
||||
- **ログ**: アプリケーションの起動や操作の失敗など、イベントのタイムスタンプ付きの記録です。
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/)は、テレメトリーを収集して監視サービスに送信するための標準とツールのセットです。監視サービスのダッシュボードで、そのデータを確認できます。
|
||||
|
||||
**FastAPIはデフォルトでOpenTelemetryをサポートしており**、HTTPリクエストのトレース、メトリクス、ログを提供します。WebSocket接続でもトレースとログを提供します。このデータを確認するには、受信する監視サービスを設定してください。
|
||||
|
||||
## FastAPIのインストール { #install-fastapi }
|
||||
|
||||
テレメトリーを送信するためのパッケージを含む`standard`オプションを指定してFastAPIをインストールします:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## アプリの作成 { #create-the-app }
|
||||
|
||||
`main.py`ファイルを作成します:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
すべてデフォルトで動作するため、テレメトリーを有効にするための独自のコードを書く必要はありません。
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
`fastapi[standard]`を使用して[FastAPI Cloud](https://fastapicloud.com)にデプロイすると、メトリクスは自動的に機能します。追加の設定は不要です。
|
||||
|
||||
Proプランでは、[メトリクスダッシュボード](https://fastapicloud.com/docs/monitoring-and-performance/metrics/)でリクエスト数、エラー率、レスポンス時間を確認できます。
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="サンプルデータを表示したFastAPI Cloud Proのメトリクスダッシュボード">
|
||||
|
||||
## その他の監視サービス { #other-monitoring-services }
|
||||
|
||||
別の監視サービスにテレメトリーを送信するには、テレメトリー送信用のOpenTelemetryプロトコルである**OTLP**を受け付けるエンドポイントを設定します。サービスのHTTP/protobufベースエンドポイントを使用してください。
|
||||
|
||||
例のURLを自分のエンドポイントに置き換えて、次の環境変数を設定します:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME`は、監視サービス内でアプリを識別するための名前です。エンドポイントはデータを受信するためのベースURLです。そのURL配下の`/v1/traces`にトレース、`/v1/metrics`にメトリクス、`/v1/logs`にログが送信されます。
|
||||
|
||||
サービスで認証が必要な場合は、`OTEL_EXPORTER_OTLP_HEADERS`にサービスが指定するヘッダー(例: `api-key=YOUR_API_KEY`)を設定してください。
|
||||
|
||||
## アプリの実行 { #run-the-app }
|
||||
|
||||
同じターミナルでアプリを起動します:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
別のターミナルでリクエストを送信します:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
監視サービスを開き、`my-api`を探します。次のエクスポート後に、`GET /items/{item_id}`スパンを含むトレースと、リクエスト数、レスポンス時間、処理中のリクエスト数のメトリクスを確認できます。
|
||||
|
||||
## テレメトリーのカスタマイズ { #customize-telemetry }
|
||||
|
||||
### プロバイダーとエクスポーターの設定 { #configure-providers-and-exporters }
|
||||
|
||||
**プロバイダー**は、トレース、メトリクス、ログを記録するオブジェクトを提供します。その設定によって、データの処理方法とエクスポート方法を制御します。
|
||||
|
||||
テレメトリーライブラリは、OpenTelemetryのグローバルプロバイダーを設定できます。アプリの起動前にライブラリを設定すると、FastAPIはそれらのプロバイダーを自動的に使用します。
|
||||
|
||||
環境変数にOTLPエンドポイントが設定されている場合、FastAPIは有効な各プロバイダーに、その送信先のエクスポーターを追加します。既存のエクスポーターは、それぞれの送信先にデータを送信し続けます。
|
||||
|
||||
各送信先は一度だけ設定してください。別のライブラリがすでに環境変数で指定された送信先への送信を処理している場合は、そのライブラリの環境変数に基づくエクスポートを無効にするか、FastAPIの自動設定を無効にします:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
`telemetry`辞書にプロバイダーを直接渡すこともできます。例えば、次のプロバイダーはOpenTelemetryのコンソールエクスポーターを使用して、リクエストのスパンをターミナルに出力します:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**エクスポーター**は、スパンを送信先に送ります。`BatchSpanProcessor`はスパンをまとめ、バックグラウンドで送信します。監視ライブラリの送信先を使用するには、コンソールエクスポーターをそのライブラリが提供するエクスポーターに置き換えてください。その他の設定オプションについては、[OpenTelemetryのPython計装ガイド](https://opentelemetry.io/docs/languages/python/instrumentation/)を参照してください。
|
||||
|
||||
メトリクスやログのプロバイダーを指定するには、同じ辞書内で`meter_provider`または`logger_provider`を使用します。プロバイダーを作成したアプリケーションまたはライブラリが、そのシャットダウンを管理します。FastAPIは、自身が追加したエクスポートコンポーネントを管理します。
|
||||
|
||||
/// warning | 注意
|
||||
|
||||
OpenTelemetryはデフォルトでグローバルプロバイダーを使用します。[マウントされたサブアプリケーション](sub-applications.md)ごとに独立したテレメトリー設定は保証されません。
|
||||
|
||||
///
|
||||
|
||||
### リクエスト操作のトレース { #trace-request-operations }
|
||||
|
||||
デフォルトでは、リクエストのトレースには、依存関係の解決、path operation関数の実行、レスポンスのシリアライズ、FastAPIの`BackgroundTasks`内の各タスクの実行に対応するスパンが含まれます。これらのスパンは同じプロバイダーとエクスポーターを使用します。
|
||||
|
||||
バックグラウンドタスクのスパンも、リクエストのトレースの一部として扱われます。これらはHTTPレスポンスのスパンが終了した後に実行されるため、測定されるレスポンス時間は増加しません。
|
||||
|
||||
HTTPリクエストのスパンのみを記録するには、`operation_spans`を`False`に設定します:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### WebSocket接続のトレース { #trace-websocket-connections }
|
||||
|
||||
各WebSocket接続には、`WS /ws/{room}`のようなスパンがあり、ハンドラーと依存関係のクリーンアップを対象とします。同じプロバイダーと設定を使用し、依存関係の解決とエンドポイントの実行には`operation_spans`の設定も適用されます。
|
||||
|
||||
HTTPリクエストのメトリクスは、HTTPリクエストのみを対象とします。コード`1000`または`1001`による正常なWebSocket切断では、エラーログは生成されません。
|
||||
|
||||
### エラーの確認 { #inspect-errors }
|
||||
|
||||
FastAPIは、未処理の例外をOpenTelemetryのログとして記録し、リクエストまたは接続のトレースに関連付けます。トレースがサンプリングされていない場合でも、エラーログは記録されます。
|
||||
|
||||
例外ログには、例外の型、メッセージ、スタックトレースが含まれます。メッセージやスタックトレースには機密情報が含まれる可能性があります。プロバイダーのログプロセッサーを使用してフィルタリングやマスキングを行うか、`logs`を`False`に設定してこれらのログを無効にしてください。
|
||||
|
||||
FastAPIは、リクエストのバリデーション失敗も、ルートとエラー数を含む警告ログとして記録します。これらのログに無効な入力値は含まれません。
|
||||
|
||||
## 記録対象の選択 { #choose-what-to-record }
|
||||
|
||||
`telemetry`辞書では、次の設定も指定できます:
|
||||
|
||||
| 設定 | 目的 | デフォルト |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | HTTPリクエストとWebSocket接続のスパンを記録します | `True` |
|
||||
| `metrics` | HTTPリクエストのメトリクスを記録します | `True` |
|
||||
| `logs` | バリデーション失敗と未処理の例外を記録します | `True` |
|
||||
| `operation_spans` | リクエスト操作のスパンを追加します | `True` |
|
||||
| `exclude` | ASGIスコープを受け取る関数が`True`を返した場合、そのリクエストを除外します | `None` |
|
||||
| `auto_configure` | 環境変数に設定されたエンドポイントのエクスポーターを追加します | `True` |
|
||||
|
||||
例えば、ヘルスチェックを除外してメトリクスを収集するには、次のようにします:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
lifespan関数内などで、アプリケーション自身がプロバイダーの設定を行う場合は、`auto_configure`を`False`に設定してください。
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
API가 실행 중일 때 얼마나 많은 트래픽을 받는지, 어떤 요청이 느린지, 언제 오류가 발생하는지 알고 싶을 수 있습니다.
|
||||
|
||||
**텔레메트리**는 이러한 질문에 답하는 데 도움이 되는 애플리케이션 동작에 관한 데이터입니다. 일반적인 유형은 다음과 같습니다:
|
||||
|
||||
- **메트릭**: 응답 시간이나 처리 중인 요청 수처럼 시간에 따라 요약할 수 있는 측정값입니다.
|
||||
- **트레이스**: 개별 요청과 해당 요청을 처리하기 위해 수행한 작업의 기록입니다. 소요 시간을 측정하는 각 작업을 **스팬**이라고 합니다.
|
||||
- **로그**: 애플리케이션 시작이나 작업 실패와 같은 이벤트에 타임스탬프를 붙인 기록입니다.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/)는 텔레메트리를 수집하여 모니터링 서비스로 보내기 위한 표준과 도구 모음입니다. 모니터링 서비스에서는 대시보드를 통해 데이터를 살펴볼 수 있습니다.
|
||||
|
||||
**FastAPI는 기본적으로 OpenTelemetry를 지원**하며, HTTP 요청의 트레이스, 메트릭, 로그를 제공합니다. WebSocket 연결도 트레이스와 로그를 제공합니다. 이 데이터를 보려면 모니터링 서비스에서 데이터를 수신하도록 설정하세요.
|
||||
|
||||
## FastAPI 설치하기 { #install-fastapi }
|
||||
|
||||
텔레메트리 전송용 패키지가 포함된 `standard` 추가 옵션으로 FastAPI를 설치하세요:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## 애플리케이션 만들기 { #create-the-app }
|
||||
|
||||
`main.py` 파일을 만드세요:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
이 모든 기능은 기본적으로 작동하며, 텔레메트리를 사용하기 위해 별도의 코드를 작성할 필요가 없습니다.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
`fastapi[standard]`를 사용하여 [FastAPI Cloud](https://fastapicloud.com)에 배포하면 메트릭이 자동으로 작동합니다. 다른 설정은 필요하지 않습니다.
|
||||
|
||||
Pro 요금제에서는 [메트릭 대시보드](https://fastapicloud.com/docs/monitoring-and-performance/metrics/)에서 요청 수, 오류율, 응답 시간을 확인할 수 있습니다.
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="예제 데이터가 표시된 FastAPI Cloud Pro 메트릭 대시보드">
|
||||
|
||||
## 다른 모니터링 서비스 { #other-monitoring-services }
|
||||
|
||||
다른 모니터링 서비스로 텔레메트리를 보내려면 텔레메트리 전송을 위한 OpenTelemetry 프로토콜인 **OTLP**를 지원하는 엔드포인트를 설정하세요. 해당 서비스의 HTTP/protobuf 기본 엔드포인트를 사용하세요.
|
||||
|
||||
예제 URL을 사용할 엔드포인트로 바꾸고 다음 환경 변수를 설정하세요:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME`은 모니터링 서비스에서 애플리케이션을 식별합니다. 엔드포인트는 데이터를 수신하는 기본 URL입니다. 트레이스는 해당 URL 아래의 `/v1/traces`로, 메트릭은 `/v1/metrics`로, 로그는 `/v1/logs`로 전송됩니다.
|
||||
|
||||
서비스에서 인증을 요구하는 경우, `OTEL_EXPORTER_OTLP_HEADERS`를 해당 서비스에서 지정한 헤더로 설정하세요. 예를 들면 `api-key=YOUR_API_KEY`입니다.
|
||||
|
||||
## 애플리케이션 실행하기 { #run-the-app }
|
||||
|
||||
같은 터미널에서 애플리케이션을 시작하세요:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
다른 터미널에서 요청을 보내세요:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
모니터링 서비스를 열고 `my-api`를 찾으세요. 다음 내보내기가 이루어진 후, `GET /items/{item_id}` 스팬이 포함된 트레이스와 함께 요청 수, 응답 소요 시간, 활성 요청에 대한 메트릭을 확인할 수 있습니다.
|
||||
|
||||
## 텔레메트리 사용자 정의하기 { #customize-telemetry }
|
||||
|
||||
### 프로바이더와 익스포터 설정하기 { #configure-providers-and-exporters }
|
||||
|
||||
**프로바이더**는 트레이스, 메트릭 또는 로그를 기록하는 객체를 제공합니다. 프로바이더 설정은 해당 데이터의 처리 및 내보내기 방식을 제어합니다.
|
||||
|
||||
텔레메트리 라이브러리는 OpenTelemetry의 전역 프로바이더를 설정할 수 있습니다. 애플리케이션이 시작되기 전에 라이브러리를 설정하면 FastAPI가 해당 프로바이더를 자동으로 사용합니다.
|
||||
|
||||
환경 변수에 OTLP 엔드포인트가 설정되어 있으면, FastAPI는 활성화된 각 프로바이더에 해당 대상으로 데이터를 보내는 익스포터를 추가합니다. 기존 익스포터는 계속해서 각자의 대상으로 데이터를 보냅니다.
|
||||
|
||||
각 대상은 한 번만 설정하세요. 다른 라이브러리가 이미 환경 변수에 지정된 대상으로의 전송을 처리하고 있다면, 해당 라이브러리의 환경 변수 기반 내보내기를 비활성화하거나 FastAPI의 자동 설정을 끄세요:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
`telemetry` 딕셔너리에 프로바이더를 직접 전달할 수도 있습니다. 예를 들어, 다음 프로바이더는 OpenTelemetry의 콘솔 익스포터를 사용하여 터미널에 요청 스팬을 출력합니다:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**익스포터**는 스팬을 대상으로 보냅니다. `BatchSpanProcessor`는 스팬을 그룹으로 묶어 백그라운드에서 보냅니다. 모니터링 라이브러리의 대상으로 보내려면 콘솔 익스포터를 해당 라이브러리에서 제공하는 익스포터로 바꾸세요. 더 많은 설정 옵션은 [OpenTelemetry의 Python 계측 가이드](https://opentelemetry.io/docs/languages/python/instrumentation/)를 참고하세요.
|
||||
|
||||
같은 딕셔너리에서 `meter_provider` 또는 `logger_provider`를 사용하여 메트릭 또는 로그 프로바이더를 제공하세요. 프로바이더를 생성한 애플리케이션이나 라이브러리가 해당 프로바이더의 종료를 관리합니다. FastAPI는 자신이 추가한 내보내기 구성 요소를 관리합니다.
|
||||
|
||||
/// warning | 경고
|
||||
|
||||
OpenTelemetry는 기본적으로 전역 프로바이더를 사용합니다. [마운트된 하위 애플리케이션](sub-applications.md)에 대한 독립적인 텔레메트리 설정은 보장되지 않습니다.
|
||||
|
||||
///
|
||||
|
||||
### 요청 작업 추적하기 { #trace-request-operations }
|
||||
|
||||
기본적으로 요청 트레이스에는 의존성 해결, 경로 처리 함수 실행, 응답 직렬화, FastAPI의 `BackgroundTasks`에 있는 각 작업 실행에 대한 스팬이 포함됩니다. 이 스팬들은 동일한 프로바이더와 익스포터를 사용합니다.
|
||||
|
||||
백그라운드 작업의 스팬도 해당 요청의 트레이스에 포함됩니다. 백그라운드 작업은 HTTP 응답 스팬이 끝난 후에 실행되므로 측정된 응답 시간을 늘리지 않습니다.
|
||||
|
||||
HTTP 요청 스팬만 기록하려면 `operation_spans`를 `False`로 설정하세요:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### WebSocket 연결 추적하기 { #trace-websocket-connections }
|
||||
|
||||
각 WebSocket 연결에는 `WS /ws/{room}`과 같은 스팬이 있으며, 핸들러 실행과 의존성 정리 작업을 포함합니다. 이 스팬은 동일한 프로바이더와 설정을 사용하며, 여기에는 의존성 해결과 엔드포인트 실행을 위한 `operation_spans`도 포함됩니다.
|
||||
|
||||
HTTP 요청 메트릭은 HTTP 요청만 다룹니다. 코드 `1000` 또는 `1001`로 정상 종료된 WebSocket 연결은 오류 로그를 생성하지 않습니다.
|
||||
|
||||
### 오류 확인하기 { #inspect-errors }
|
||||
|
||||
FastAPI는 처리되지 않은 예외를 OpenTelemetry 로그로 기록하고, 해당 요청이나 연결의 트레이스에 연결합니다. 트레이스가 샘플링되지 않은 경우에도 오류 로그는 기록됩니다.
|
||||
|
||||
예외 로그에는 예외 유형, 메시지, 스택 트레이스가 포함됩니다. 메시지와 스택 트레이스에는 민감한 정보가 포함될 수 있습니다. 프로바이더의 로그 프로세서를 사용하여 이를 필터링하거나 민감한 정보를 가리세요. 또는 `logs`를 `False`로 설정하여 이 로그를 비활성화하세요.
|
||||
|
||||
FastAPI는 요청 유효성 검사 실패도 경로와 오류 수가 포함된 경고 로그로 기록합니다. 이 로그에는 유효하지 않은 입력값이 포함되지 않습니다.
|
||||
|
||||
## 기록할 항목 선택하기 { #choose-what-to-record }
|
||||
|
||||
`telemetry` 딕셔너리는 다음 설정도 지원합니다:
|
||||
|
||||
| 설정 | 용도 | 기본값 |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | HTTP 요청 및 WebSocket 연결 스팬 기록 | `True` |
|
||||
| `metrics` | HTTP 요청 메트릭 기록 | `True` |
|
||||
| `logs` | 유효성 검사 실패 및 처리되지 않은 예외 기록 | `True` |
|
||||
| `operation_spans` | 요청 작업에 대한 스팬 추가 | `True` |
|
||||
| `exclude` | ASGI 스코프를 전달받는 함수가 `True`를 반환하면 해당 요청 제외 | `None` |
|
||||
| `auto_configure` | 환경 변수에 설정된 엔드포인트에 대한 익스포터 추가 | `True` |
|
||||
|
||||
예를 들어, 상태 확인 요청을 제외하고 메트릭을 수집하려면 다음과 같이 설정하세요:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
애플리케이션이 lifespan 함수 내부 등에서 프로바이더 설정을 직접 처리하는 경우 `auto_configure`를 `False`로 설정하세요.
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
Quando sua API está em execução, você pode querer saber quanto tráfego ela recebe, quais requests são lentos e quando ocorrem erros.
|
||||
|
||||
**Telemetria** são dados sobre o comportamento da sua aplicação que ajudam a responder a essas perguntas. Os tipos comuns incluem:
|
||||
|
||||
- **Métricas**: medições que você pode resumir ao longo do tempo, como os tempos de response e o número de requests em processamento.
|
||||
- **Traces**: registros de requests individuais e das operações realizadas para processá-los. Cada operação cronometrada é chamada de **span**.
|
||||
- **Logs**: registros de eventos com marcação de tempo, como a inicialização de uma aplicação ou a falha de uma operação.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) é um conjunto de padrões e ferramentas para coletar telemetria e enviá-la a um serviço de monitoramento, onde você pode explorá-la em painéis.
|
||||
|
||||
**O FastAPI oferece suporte a OpenTelemetry por padrão** para traces de requests HTTP, métricas e logs. As conexões WebSocket também fornecem traces e logs. Para visualizar esses dados, configure um serviço de monitoramento para recebê-los.
|
||||
|
||||
## Instale o FastAPI { #install-fastapi }
|
||||
|
||||
Instale o FastAPI com os extras `standard`, que incluem os pacotes para enviar telemetria:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Crie a aplicação { #create-the-app }
|
||||
|
||||
Crie um arquivo `main.py`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Observe que tudo funciona por padrão, sem precisar escrever código personalizado para a telemetria funcionar.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Ao fazer o deploy no [FastAPI Cloud](https://fastapicloud.com) com `fastapi[standard]`, as métricas funcionam automaticamente. Não é necessário configurar mais nada.
|
||||
|
||||
Nos planos Pro, você pode visualizar contagens de requests, taxas de erro e tempos de response no [painel de métricas](https://fastapicloud.com/docs/monitoring-and-performance/metrics/).
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="Painel de métricas do FastAPI Cloud Pro com dados de exemplo">
|
||||
|
||||
## Outros serviços de monitoramento { #other-monitoring-services }
|
||||
|
||||
Para enviar telemetria a outro serviço de monitoramento, configure um endpoint que aceite **OTLP**, o protocolo do OpenTelemetry para enviar telemetria. Use o endpoint base HTTP/protobuf do serviço.
|
||||
|
||||
Defina estas variáveis de ambiente, substituindo a URL de exemplo pelo seu endpoint:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` identifica sua aplicação no serviço de monitoramento. O endpoint é a URL base para receber dados. Os traces são enviados para `/v1/traces`, as métricas para `/v1/metrics` e os logs para `/v1/logs` nessa URL.
|
||||
|
||||
Se o serviço exigir autenticação, defina `OTEL_EXPORTER_OTLP_HEADERS` com os cabeçalhos especificados por ele, por exemplo, `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Execute a aplicação { #run-the-app }
|
||||
|
||||
Inicie a aplicação no mesmo terminal:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Em outro terminal, envie um request:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
Abra seu serviço de monitoramento e procure `my-api`. Após a próxima exportação, você poderá ver um trace com um span `GET /items/{item_id}`, junto com métricas de contagem de requests, duração de responses e requests ativos.
|
||||
|
||||
## Personalize a telemetria { #customize-telemetry }
|
||||
|
||||
### Configure provedores e exportadores { #configure-providers-and-exporters }
|
||||
|
||||
Um **provedor** fornece os objetos que registram traces, métricas ou logs. Sua configuração controla como esses dados são processados e exportados.
|
||||
|
||||
Bibliotecas de telemetria podem configurar os provedores globais do OpenTelemetry. Configure a biblioteca antes de iniciar a aplicação, e o FastAPI usará esses provedores automaticamente.
|
||||
|
||||
Quando um endpoint OTLP é definido no ambiente, o FastAPI adiciona um exportador para esse destino a cada provedor habilitado. Os exportadores existentes continuam enviando dados aos seus destinos.
|
||||
|
||||
Configure cada destino uma única vez. Se outra biblioteca já cuida do destino definido no ambiente, desabilite a exportação dela para esse destino ou desative a configuração automática do FastAPI:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
Você também pode passar um provedor diretamente no dicionário `telemetry`. Por exemplo, este provedor usa o exportador de console do OpenTelemetry para exibir spans de requests no seu terminal:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
O **exportador** envia os spans ao destino. `BatchSpanProcessor` agrupa spans e os envia em segundo plano. Substitua o exportador de console por um fornecido pela sua biblioteca de monitoramento para usar o destino dela. Consulte o [tutorial de instrumentação Python do OpenTelemetry](https://opentelemetry.io/docs/languages/python/instrumentation/) para mais opções de configuração.
|
||||
|
||||
Use `meter_provider` ou `logger_provider` no mesmo dicionário para fornecer um provedor de métricas ou logs. A aplicação ou biblioteca que cria um provedor gerencia seu encerramento. O FastAPI gerencia os componentes de exportação que adiciona.
|
||||
|
||||
/// warning | Atenção
|
||||
|
||||
O OpenTelemetry usa provedores globais por padrão. A configuração independente de telemetria para [subaplicações montadas](sub-applications.md) não é garantida.
|
||||
|
||||
///
|
||||
|
||||
### Rastreie operações de requests { #trace-request-operations }
|
||||
|
||||
Por padrão, os traces de requests incluem spans para resolver dependências, executar sua função de operação de rota, serializar a response e executar cada tarefa em `BackgroundTasks` do FastAPI. Esses spans usam o mesmo provedor e os mesmos exportadores.
|
||||
|
||||
Os spans de tarefas em segundo plano continuam fazendo parte do trace do request. Eles são executados após o término do span da response HTTP, portanto não aumentam o tempo de response medido.
|
||||
|
||||
Para registrar apenas o span do request HTTP, defina `operation_spans` como `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### Rastreie conexões WebSocket { #trace-websocket-connections }
|
||||
|
||||
Cada conexão WebSocket tem um span como `WS /ws/{room}`, que abrange o manipulador e a limpeza das dependências. Ele usa os mesmos provedores e configurações, incluindo `operation_spans` para a resolução de dependências e a execução do endpoint.
|
||||
|
||||
As métricas de requests HTTP abrangem apenas requests HTTP. Desconexões normais de WebSocket com os códigos `1000` ou `1001` não produzem logs de erro.
|
||||
|
||||
### Inspecione erros { #inspect-errors }
|
||||
|
||||
O FastAPI registra exceções não tratadas como logs do OpenTelemetry, vinculados ao trace do request ou da conexão. Os logs de erro são registrados mesmo quando o trace não é selecionado na amostragem.
|
||||
|
||||
Os logs de exceções incluem o tipo, a mensagem e o stack trace da exceção. Mensagens e stack traces podem conter informações sensíveis. Use os processadores de logs do seu provedor para filtrar ou ocultar essas informações, ou defina `logs` como `False` para desabilitar esses logs.
|
||||
|
||||
O FastAPI também registra falhas de validação de requests como logs de aviso com a rota e a contagem de erros. Esses logs não incluem os dados de entrada inválidos.
|
||||
|
||||
## Escolha o que registrar { #choose-what-to-record }
|
||||
|
||||
O dicionário `telemetry` também aceita estas configurações:
|
||||
|
||||
| Configuração | Finalidade | Padrão |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | Registrar spans de requests HTTP e conexões WebSocket | `True` |
|
||||
| `metrics` | Registrar métricas de requests HTTP | `True` |
|
||||
| `logs` | Registrar falhas de validação e exceções não tratadas | `True` |
|
||||
| `operation_spans` | Adicionar spans para operações de requests | `True` |
|
||||
| `exclude` | Ignorar requests quando uma função que recebe o escopo ASGI retorna `True` | `None` |
|
||||
| `auto_configure` | Adicionar exportadores para endpoints definidos em variáveis de ambiente | `True` |
|
||||
|
||||
Por exemplo, para coletar métricas excluindo verificações de integridade:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Defina `auto_configure` como `False` quando sua aplicação cuidar da configuração dos provedores por conta própria, por exemplo, dentro da função lifespan.
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
Когда ваш API запущен, вам может понадобиться узнать, сколько трафика он получает, какие HTTP-запросы выполняются медленно и когда происходят ошибки.
|
||||
|
||||
**Телеметрия** — это данные о поведении вашего приложения, которые помогают ответить на эти вопросы. Распространённые типы включают:
|
||||
|
||||
- **Метрики**: измерения, которые можно агрегировать за период времени, например время ответа и количество обрабатываемых HTTP-запросов.
|
||||
- **Трейсы**: записи отдельных HTTP-запросов и операций, выполненных для их обработки. Каждая операция с измеренным временем называется **спан**.
|
||||
- **Логи**: записи событий с временными метками, например запуск приложения или сбой операции.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) — это набор стандартов и инструментов для сбора телеметрии и отправки её в сервис мониторинга, где её можно просматривать на дашбордах.
|
||||
|
||||
**FastAPI по умолчанию предоставляет поддержку OpenTelemetry** для трейсов HTTP-запросов, метрик и логов. WebSocket-соединения также предоставляют трейсы и логи. Чтобы увидеть эти данные, настройте сервис мониторинга для их получения.
|
||||
|
||||
## Установите FastAPI { #install-fastapi }
|
||||
|
||||
Установите FastAPI с дополнительными зависимостями `standard`, которые включают пакеты для отправки телеметрии:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Создайте приложение { #create-the-app }
|
||||
|
||||
Создайте файл `main.py`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Обратите внимание, что всё это работает по умолчанию, вам не нужно писать какой-либо пользовательский код, чтобы телеметрия работала.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Когда вы развёртываете приложение в [FastAPI Cloud](https://fastapicloud.com) с `fastapi[standard]`, метрики работают автоматически. Вам не нужно настраивать что-либо ещё.
|
||||
|
||||
На тарифах Pro вы можете просматривать количество HTTP-запросов, долю ошибок и время ответа в [дашборде метрик](https://fastapicloud.com/docs/monitoring-and-performance/metrics/).
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="Дашборд метрик FastAPI Cloud Pro с примером данных">
|
||||
|
||||
## Другие сервисы мониторинга { #other-monitoring-services }
|
||||
|
||||
Чтобы отправлять телеметрию в другой сервис мониторинга, настройте эндпоинт, который принимает **OTLP**, протокол OpenTelemetry для отправки телеметрии. Используйте базовый HTTP/protobuf-эндпоинт сервиса.
|
||||
|
||||
Задайте эти переменные окружения, заменив пример URL на ваш эндпоинт:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` идентифицирует ваше приложение в сервисе мониторинга. Эндпоинт — это базовый URL для получения данных. Трейсы отправляются в `/v1/traces`, метрики — в `/v1/metrics`, а логи — в `/v1/logs` по этому URL.
|
||||
|
||||
Если ваш сервис требует аутентификации, задайте `OTEL_EXPORTER_OTLP_HEADERS` со значением HTTP-заголовков, которые он указывает, например `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Запустите приложение { #run-the-app }
|
||||
|
||||
Запустите приложение в том же терминале:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
В другом терминале отправьте HTTP-запрос:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
Откройте ваш сервис мониторинга и найдите `my-api`. После следующего экспорта вы сможете увидеть трейс со спаном `GET /items/{item_id}`, а также метрики количества HTTP-запросов, длительности ответа и активных HTTP-запросов.
|
||||
|
||||
## Настройте телеметрию { #customize-telemetry }
|
||||
|
||||
### Настройте провайдеры и экспортеры { #configure-providers-and-exporters }
|
||||
|
||||
**Провайдер** предоставляет объекты, которые записывают трейсы, метрики или логи. Его конфигурация управляет тем, как эти данные обрабатываются и экспортируются.
|
||||
|
||||
Библиотеки телеметрии могут настраивать глобальные провайдеры OpenTelemetry. Настройте библиотеку до запуска приложения, и FastAPI будет использовать эти провайдеры автоматически.
|
||||
|
||||
Когда OTLP-эндпоинт задан в окружении, FastAPI добавляет экспортер для этого получателя к каждому включённому провайдеру. Существующие экспортеры продолжают отправлять данные своим получателям.
|
||||
|
||||
Настраивайте каждого получателя один раз. Если другая библиотека уже отправляет данные получателю, заданному в окружении, отключите у неё экспорт по настройкам из окружения или выключите автоматическую настройку FastAPI:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
Вы также можете передать провайдер напрямую в словаре `telemetry`. Например, этот провайдер использует консольный экспортер OpenTelemetry, чтобы печатать спаны HTTP-запросов в вашем терминале:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**Экспортер** отправляет спаны их получателю. `BatchSpanProcessor` группирует спаны и отправляет их в фоне. Замените консольный экспортер на экспортер, предоставленный вашей библиотекой мониторинга, чтобы использовать её получателя. См. [руководство OpenTelemetry по инструментированию Python](https://opentelemetry.io/docs/languages/python/instrumentation/) для дополнительных вариантов настройки.
|
||||
|
||||
Используйте `meter_provider` или `logger_provider` в том же словаре, чтобы предоставить провайдер метрик или логов. Приложение или библиотека, создающая провайдер, управляет его завершением работы. FastAPI управляет компонентами экспорта, которые он добавляет.
|
||||
|
||||
/// warning | Предупреждение
|
||||
|
||||
OpenTelemetry по умолчанию использует глобальные провайдеры. Независимая настройка телеметрии для [монтированных подприложений](sub-applications.md) не гарантируется.
|
||||
|
||||
///
|
||||
|
||||
### Трассировка операций HTTP-запроса { #trace-request-operations }
|
||||
|
||||
По умолчанию трейсы HTTP-запросов включают спаны для разрешения зависимостей, запуска вашей функции-обработчика пути, сериализации HTTP-ответа и запуска каждой задачи в `BackgroundTasks` FastAPI. Эти спаны используют тот же провайдер и экспортеры.
|
||||
|
||||
Спаны фоновых задач остаются частью трейса HTTP-запроса. Они выполняются после завершения спана HTTP-ответа, поэтому не увеличивают измеренное время ответа.
|
||||
|
||||
Чтобы записывать только спан HTTP-запроса, установите `operation_spans` в `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### Трассировка WebSocket-соединений { #trace-websocket-connections }
|
||||
|
||||
У каждого WebSocket-соединения есть спан, например `WS /ws/{room}`, охватывающий обработчик и очистку зависимостей. Он использует тех же провайдеров и те же настройки, включая `operation_spans` для разрешения зависимостей и выполнения эндпоинта.
|
||||
|
||||
Метрики HTTP-запросов охватывают только HTTP-запросы. Обычные отключения WebSocket с кодами `1000` или `1001` не создают логи ошибок.
|
||||
|
||||
### Анализ ошибок { #inspect-errors }
|
||||
|
||||
FastAPI записывает необработанные исключения как логи OpenTelemetry, связанные с трейсом HTTP-запроса или соединения. Логи ошибок записываются даже тогда, когда трейс не попал в выборку.
|
||||
|
||||
Логи исключений включают тип исключения, сообщение и трассировку стека. Сообщения и трассировки стека могут содержать конфиденциальную информацию. Используйте процессоры логов вашего провайдера, чтобы фильтровать или редактировать их, либо установите `logs` в `False`, чтобы отключить эти логи.
|
||||
|
||||
FastAPI также записывает ошибки валидации HTTP-запросов как логи уровня предупреждения с маршрутом и количеством ошибок. Эти логи не включают невалидные входные данные.
|
||||
|
||||
## Выберите, что записывать { #choose-what-to-record }
|
||||
|
||||
Словарь `telemetry` также принимает следующие настройки:
|
||||
|
||||
| Настройка | Назначение | По умолчанию |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | Записывать спаны HTTP-запросов и WebSocket-соединений | `True` |
|
||||
| `metrics` | Записывать метрики HTTP-запросов | `True` |
|
||||
| `logs` | Записывать ошибки валидации и необработанные исключения | `True` |
|
||||
| `operation_spans` | Добавлять спаны для операций HTTP-запроса | `True` |
|
||||
| `exclude` | Пропускать HTTP-запросы, когда функция, получающая ASGI scope, возвращает `True` | `None` |
|
||||
| `auto_configure` | Добавлять экспортеры для эндпоинтов, заданных в переменных окружения | `True` |
|
||||
|
||||
Например, чтобы собирать метрики, исключая проверки работоспособности:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Установите `auto_configure` в `False`, когда ваше приложение самостоятельно выполняет настройку провайдера, например внутри своей lifespan-функции.
|
||||
@@ -8,6 +8,8 @@ Use a neutral tone (not overly formal or informal).
|
||||
|
||||
Use correct Russian grammar — appropriate cases, suffixes, and endings depending on context.
|
||||
|
||||
Translate technical terms in prose unless explicitly instructed otherwise below. If there is no commonly used Russian equivalent, use a Russified English term, optionally adding a short Russian explanation. Preserve code identifiers, API names, and protocol names.
|
||||
|
||||
For the following technical terms, use these specific translations to ensure consistency and clarity across the documentation:
|
||||
|
||||
* production (meaning production software or environment): продакшн (do not change the ending, for example, translate `in production` as `в продакшн` (not `в продакшене`))
|
||||
@@ -48,6 +50,15 @@ For the following technical terms, use these specific translations to ensure con
|
||||
* media type: тип содержимого (or `медиа-тип`)
|
||||
* request: HTTP-запрос
|
||||
* response: HTTP-ответ
|
||||
* endpoint: эндпоинт (for example, `OTLP-эндпоинт`)
|
||||
* handler: обработчик
|
||||
* span (in tracing): спан (add `отрезок трассировки` if clarification is needed)
|
||||
* stack trace: трассировка стека
|
||||
* warning logs: логи уровня предупреждения
|
||||
* destination (meaning a telemetry destination): получатель (for example, `отправлять данные получателю`, not `отправлять данные в назначение`)
|
||||
* trace is not sampled: трейс не попал в выборку
|
||||
* sensitive information: конфиденциальная информация
|
||||
* health check: проверка работоспособности (or `проверка состояния`)
|
||||
* type hints: аннотации типов
|
||||
* type annotations: аннотации типов
|
||||
* context manager: менеджер контекста
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
API'niz çalışırken ne kadar trafik aldığını, hangi request'lerin yavaş olduğunu ve hataların ne zaman oluştuğunu bilmek isteyebilirsiniz.
|
||||
|
||||
**Telemetri**, uygulamanızın davranışı hakkında bu soruları yanıtlamanıza yardımcı olan verilerdir. Yaygın türleri şunlardır:
|
||||
|
||||
- **Metrikler**: Response süreleri ve işlenen request sayısı gibi zaman içinde özetleyebileceğiniz ölçümler.
|
||||
- **Trace'ler**: Tek tek request'lerin ve bunları işlemek için gerçekleştirilen işlemlerin kayıtları. Süresi ölçülen her işleme **span** denir.
|
||||
- **Log'lar**: Uygulamanın başlatılması veya bir işlemin başarısız olması gibi olayların zaman damgalı kayıtları.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/), telemetri verilerini toplamak ve bunları panolarda inceleyebileceğiniz bir izleme hizmetine göndermek için kullanılan standartlar ve araçlar bütünüdür.
|
||||
|
||||
**FastAPI, HTTP request trace'leri, metrikleri ve log'ları için varsayılan olarak OpenTelemetry desteği sunar**. WebSocket bağlantıları da trace ve log üretir. Bu verileri görmek için onları alacak bir izleme hizmeti yapılandırın.
|
||||
|
||||
## FastAPI'yi Yükleyin { #install-fastapi }
|
||||
|
||||
FastAPI'yi, telemetri göndermek için gereken paketleri içeren `standard` ek bağımlılıklarıyla yükleyin:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Uygulamayı Oluşturun { #create-the-app }
|
||||
|
||||
`main.py` adlı bir dosya oluşturun:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Her şeyin varsayılan olarak çalıştığına dikkat edin; telemetrinin çalışması için özel bir kod yazmanız gerekmez.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Uygulamanızı `fastapi[standard]` ile [FastAPI Cloud](https://fastapicloud.com)'a dağıttığınızda metrikler otomatik olarak çalışır. Başka bir yapılandırma yapmanız gerekmez.
|
||||
|
||||
Pro planlarında request sayılarını, hata oranlarını ve response sürelerini [Metrikler panosunda](https://fastapicloud.com/docs/monitoring-and-performance/metrics/) görüntüleyebilirsiniz.
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="Örnek veriler içeren FastAPI Cloud Pro metrikler panosu">
|
||||
|
||||
## Diğer İzleme Hizmetleri { #other-monitoring-services }
|
||||
|
||||
Telemetri verilerini başka bir izleme hizmetine göndermek için OpenTelemetry'nin telemetri gönderme protokolü olan **OTLP**'yi kabul eden bir endpoint yapılandırın. Hizmetin HTTP/protobuf temel endpoint'ini kullanın.
|
||||
|
||||
Örnek URL'yi kendi endpoint'inizle değiştirerek şu ortam değişkenlerini ayarlayın:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME`, izleme hizmetinde uygulamanızı tanımlar. Endpoint, verilerin alınacağı temel URL'dir. Trace'ler bu URL altındaki `/v1/traces` adresine, metrikler `/v1/metrics` adresine ve log'lar `/v1/logs` adresine gönderilir.
|
||||
|
||||
Hizmetiniz kimlik doğrulaması gerektiriyorsa `OTEL_EXPORTER_OTLP_HEADERS` değerini, hizmetin belirttiği header'lara göre ayarlayın. Örneğin: `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Uygulamayı Çalıştırın { #run-the-app }
|
||||
|
||||
Uygulamayı aynı terminalde başlatın:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Başka bir terminalden bir request gönderin:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
İzleme hizmetinizi açıp `my-api` uygulamasını bulun. Bir sonraki veri aktarımından sonra `GET /items/{item_id}` span'i içeren bir trace ile birlikte request sayısı, response süresi ve etkin request metriklerini görebilirsiniz.
|
||||
|
||||
## Telemetriyi Özelleştirin { #customize-telemetry }
|
||||
|
||||
### Provider ve Exporter'ları Yapılandırın { #configure-providers-and-exporters }
|
||||
|
||||
**Provider** (sağlayıcı), trace, metrik veya log kaydeden nesneleri sağlar. Yapılandırması, bu verilerin nasıl işleneceğini ve dışa aktarılacağını belirler.
|
||||
|
||||
Telemetri kütüphaneleri, OpenTelemetry'nin global provider'larını yapılandırabilir. Kütüphaneyi uygulama başlamadan önce yapılandırın; FastAPI bu provider'ları otomatik olarak kullanır.
|
||||
|
||||
Ortam değişkenlerinde bir OTLP endpoint'i ayarlandığında FastAPI, etkin olan her provider'a bu hedef için bir exporter ekler. Mevcut exporter'lar kendi hedeflerine veri göndermeye devam eder.
|
||||
|
||||
Her hedefi yalnızca bir kez yapılandırın. Ortam değişkenlerinde belirtilen hedefe gönderimi zaten başka bir kütüphane yönetiyorsa o kütüphanenin bu hedefe veri aktarımını devre dışı bırakın veya FastAPI'nin otomatik yapılandırmasını kapatın:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
Ayrıca `telemetry` sözlüğünde doğrudan bir provider da iletebilirsiniz. Örneğin aşağıdaki provider, request span'lerini terminalinize yazdırmak için OpenTelemetry'nin konsol exporter'ını kullanır:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**Exporter** (dışa aktarıcı), span'leri hedeflerine gönderir. `BatchSpanProcessor`, span'leri gruplandırıp arka planda gönderir. İzleme kütüphanenizin hedefini kullanmak için konsol exporter'ını, o kütüphanenin sağladığı bir exporter ile değiştirin. Daha fazla yapılandırma seçeneği için [OpenTelemetry'nin Python enstrümantasyon rehberine](https://opentelemetry.io/docs/languages/python/instrumentation/) bakın.
|
||||
|
||||
Bir metrik veya log provider'ı sağlamak için aynı sözlükte `meter_provider` ya da `logger_provider` kullanın. Provider'ın kapatılmasını, onu oluşturan uygulama veya kütüphane yönetir. FastAPI ise kendisinin eklediği dışa aktarma bileşenlerini yönetir.
|
||||
|
||||
/// warning | Uyarı
|
||||
|
||||
OpenTelemetry varsayılan olarak global provider'ları kullanır. [Bağlanan alt uygulamalar](sub-applications.md) için bağımsız telemetri yapılandırması garanti edilmez.
|
||||
|
||||
///
|
||||
|
||||
### Request İşlemlerini İzleyin { #trace-request-operations }
|
||||
|
||||
Varsayılan olarak request trace'leri; bağımlılıkların çözümlenmesi, path işlemi fonksiyonunuzun çalıştırılması, response'un serileştirilmesi ve FastAPI'nin `BackgroundTasks` sınıfındaki her görevin çalıştırılması için span'ler içerir. Bu span'ler aynı provider ve exporter'ları kullanır.
|
||||
|
||||
Arka plan görevlerinin span'leri, request'in trace'inin bir parçası olmaya devam eder. HTTP response span'i bittikten sonra çalıştıkları için ölçülen response süresini artırmazlar.
|
||||
|
||||
Yalnızca HTTP request span'ini kaydetmek için `operation_spans` değerini `False` olarak ayarlayın:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### WebSocket Bağlantılarını İzleyin { #trace-websocket-connections }
|
||||
|
||||
Her WebSocket bağlantısının, handler'ı ve bağımlılık temizliğini kapsayan `WS /ws/{room}` gibi bir span'i vardır. Bağımlılık çözümleme ve endpoint'in çalıştırılması için kullanılan `operation_spans` dahil olmak üzere aynı provider'ları ve ayarları kullanır.
|
||||
|
||||
HTTP request metrikleri yalnızca HTTP request'lerini kapsar. `1000` veya `1001` kodlarıyla normal şekilde sonlanan WebSocket bağlantıları hata log'u üretmez.
|
||||
|
||||
### Hataları İnceleyin { #inspect-errors }
|
||||
|
||||
FastAPI, yakalanmamış istisnaları request'in veya bağlantının trace'iyle ilişkilendirilmiş OpenTelemetry log'ları olarak kaydeder. Trace örneklemeye dahil edilmese bile hata log'ları kaydedilir.
|
||||
|
||||
İstisna log'ları; istisnanın türünü, mesajını ve stack trace'ini içerir. Mesajlar ve stack trace'ler hassas bilgiler içerebilir. Bunları filtrelemek veya hassas bilgileri maskelemek için provider'ınızın log işlemcilerini kullanın ya da bu log'ları devre dışı bırakmak için `logs` değerini `False` olarak ayarlayın.
|
||||
|
||||
FastAPI, request doğrulama hatalarını da route ve hata sayısını içeren uyarı log'ları olarak kaydeder. Bu log'lar geçersiz girdiyi içermez.
|
||||
|
||||
## Nelerin Kaydedileceğini Seçin { #choose-what-to-record }
|
||||
|
||||
`telemetry` sözlüğü şu ayarları da kabul eder:
|
||||
|
||||
| Ayar | Amaç | Varsayılan |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | HTTP request ve WebSocket bağlantı span'lerini kaydetmek | `True` |
|
||||
| `metrics` | HTTP request metriklerini kaydetmek | `True` |
|
||||
| `logs` | Doğrulama hatalarını ve yakalanmamış istisnaları kaydetmek | `True` |
|
||||
| `operation_spans` | Request işlemleri için span eklemek | `True` |
|
||||
| `exclude` | ASGI scope'unu alan bir fonksiyon `True` döndürdüğünde request'leri atlamak | `None` |
|
||||
| `auto_configure` | Ortam değişkenlerinde ayarlanan endpoint'ler için exporter eklemek | `True` |
|
||||
|
||||
Örneğin, sağlık kontrollerini hariç tutarak metrik toplamak için:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Uygulamanız provider kurulumunu kendisi yönetiyorsa, örneğin lifespan fonksiyonu içinde yapıyorsa, `auto_configure` değerini `False` olarak ayarlayın.
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
Коли ваш API працює, вам може бути потрібно знати, скільки трафіку він отримує, які запити повільні та коли виникають помилки.
|
||||
|
||||
**Телеметрія** - це дані про поведінку вашого застосунку, які допомагають відповісти на ці запитання. Поширені типи:
|
||||
|
||||
- **Метрики**: вимірювання, які можна узагальнювати за певний час, як-от час відповіді та кількість оброблюваних запитів.
|
||||
- **Трасування**: записи окремих запитів і операцій, виконаних для їх обробки. Кожна операція з виміряною тривалістю називається **span**.
|
||||
- **Журнали**: записи подій із часовими мітками, як-от запуск застосунку або збій операції.
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) - це набір стандартів та інструментів для збирання телеметрії та надсилання її до сервісу моніторингу, де ви можете переглядати її на інформаційних панелях.
|
||||
|
||||
**FastAPI надає підтримку OpenTelemetry за замовчуванням** для трасувань, метрик і журналів HTTP-запитів. З'єднання WebSocket також надають трасування та журнали. Щоб переглядати ці дані, налаштуйте сервіс моніторингу для їх отримання.
|
||||
|
||||
## Встановіть FastAPI { #install-fastapi }
|
||||
|
||||
Встановіть FastAPI з додатковими залежностями `standard`, які включають пакети для надсилання телеметрії:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Створіть застосунок { #create-the-app }
|
||||
|
||||
Створіть файл `main.py`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
Зверніть увагу: усе працює за замовчуванням, вам не потрібно писати власний код для роботи телеметрії.
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
Коли ви розгортаєте застосунок у [FastAPI Cloud](https://fastapicloud.com) із `fastapi[standard]`, метрики працюють автоматично. Вам не потрібно нічого додатково налаштовувати.
|
||||
|
||||
На тарифних планах Pro ви можете переглядати кількість запитів, частоту помилок і час відповіді на [панелі метрик](https://fastapicloud.com/docs/monitoring-and-performance/metrics/).
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="Панель метрик FastAPI Cloud Pro з прикладами даних">
|
||||
|
||||
## Інші сервіси моніторингу { #other-monitoring-services }
|
||||
|
||||
Щоб надсилати телеметрію до іншого сервісу моніторингу, налаштуйте кінцеву точку, яка приймає **OTLP**, протокол OpenTelemetry для надсилання телеметрії. Використовуйте базову кінцеву точку HTTP/protobuf цього сервісу.
|
||||
|
||||
Задайте ці змінні оточення, замінивши URL із прикладу на вашу кінцеву точку:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` ідентифікує ваш застосунок у сервісі моніторингу. Кінцева точка - це базовий URL для отримання даних. Трасування надсилаються до `/v1/traces`, метрики - до `/v1/metrics`, а журнали - до `/v1/logs` за цим URL.
|
||||
|
||||
Якщо ваш сервіс вимагає автентифікації, задайте в `OTEL_EXPORTER_OTLP_HEADERS` заголовки, які він визначає, наприклад `api-key=YOUR_API_KEY`.
|
||||
|
||||
## Запустіть застосунок { #run-the-app }
|
||||
|
||||
Запустіть застосунок у тому самому терміналі:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
В іншому терміналі надішліть запит:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
Відкрийте ваш сервіс моніторингу та знайдіть `my-api`. Після наступного експорту ви зможете побачити трасування зі span `GET /items/{item_id}`, а також метрики кількості запитів, тривалості відповіді та активних запитів.
|
||||
|
||||
## Налаштуйте телеметрію { #customize-telemetry }
|
||||
|
||||
### Налаштуйте провайдери та експортери { #configure-providers-and-exporters }
|
||||
|
||||
**Провайдер** надає об'єкти, які записують трасування, метрики або журнали. Його конфігурація визначає, як ці дані обробляються та експортуються.
|
||||
|
||||
Бібліотеки телеметрії можуть налаштовувати глобальні провайдери OpenTelemetry. Налаштуйте бібліотеку перед запуском застосунку, і FastAPI автоматично використовуватиме ці провайдери.
|
||||
|
||||
Коли кінцеву точку OTLP задано в оточенні, FastAPI додає експортер для цього місця призначення до кожного ввімкненого провайдера. Наявні експортери продовжують надсилати дані до своїх місць призначення.
|
||||
|
||||
Налаштовуйте кожне місце призначення лише один раз. Якщо інша бібліотека вже обробляє місце призначення, задане в оточенні, вимкніть у ній експорт за налаштуваннями оточення або вимкніть автоматичне налаштування FastAPI:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
Ви також можете передати провайдер безпосередньо у словнику `telemetry`. Наприклад, цей провайдер використовує консольний експортер OpenTelemetry для виведення span запитів у вашому терміналі:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**Експортер** надсилає span до їхнього місця призначення. `BatchSpanProcessor` групує span і надсилає їх у фоновому режимі. Замініть консольний експортер на той, який надає ваша бібліотека моніторингу, щоб використовувати її місце призначення. Додаткові параметри налаштування наведено в [посібнику з інструментування Python для OpenTelemetry](https://opentelemetry.io/docs/languages/python/instrumentation/).
|
||||
|
||||
Використовуйте `meter_provider` або `logger_provider` у тому самому словнику, щоб надати провайдер метрик або журналів. Застосунок або бібліотека, що створює провайдер, керує його вимкненням. FastAPI керує компонентами експорту, які додає.
|
||||
|
||||
/// warning | Попередження
|
||||
|
||||
OpenTelemetry за замовчуванням використовує глобальні провайдери. Незалежне налаштування телеметрії для [змонтованих підзастосунків](sub-applications.md) не гарантоване.
|
||||
|
||||
///
|
||||
|
||||
### Трасуйте операції запитів { #trace-request-operations }
|
||||
|
||||
За замовчуванням трасування запитів включають span для розв'язання залежностей, виконання вашої функції операції шляху, серіалізації відповіді та виконання кожного завдання у `BackgroundTasks` FastAPI. Ці span використовують ті самі провайдер і експортери.
|
||||
|
||||
Span фонових завдань залишаються частиною трасування запиту. Вони виконуються після завершення span HTTP-відповіді, тому не збільшують виміряний час відповіді.
|
||||
|
||||
Щоб записувати лише span HTTP-запиту, задайте `operation_spans` значення `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### Трасуйте з'єднання WebSocket { #trace-websocket-connections }
|
||||
|
||||
Кожне з'єднання WebSocket має span, наприклад `WS /ws/{room}`, що охоплює обробник і очищення залежностей. Він використовує ті самі провайдери й налаштування, включно з `operation_spans` для розв'язання залежностей і виконання кінцевої точки.
|
||||
|
||||
Метрики HTTP-запитів охоплюють лише HTTP-запити. Звичайні від'єднання WebSocket із кодами `1000` або `1001` не створюють записів про помилки в журналах.
|
||||
|
||||
### Переглядайте помилки { #inspect-errors }
|
||||
|
||||
FastAPI записує необроблені винятки як журнали OpenTelemetry, пов'язані з трасуванням запиту або з'єднання. Помилки записуються в журнали, навіть коли трасування не потрапляє до вибірки.
|
||||
|
||||
Записи винятків у журналах містять тип винятку, повідомлення та трасування стека. Повідомлення та трасування стека можуть містити конфіденційну інформацію. Використовуйте процесори журналів вашого провайдера для фільтрування або приховування такої інформації або задайте `logs` значення `False`, щоб вимкнути ці журнали.
|
||||
|
||||
FastAPI також записує помилки валідації запитів як попередження в журналах із маршрутом і кількістю помилок. Ці записи не містять некоректних вхідних даних.
|
||||
|
||||
## Виберіть, що записувати { #choose-what-to-record }
|
||||
|
||||
Словник `telemetry` також приймає такі налаштування:
|
||||
|
||||
| Налаштування | Призначення | За замовчуванням |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | Записувати span HTTP-запитів і з'єднань WebSocket | `True` |
|
||||
| `metrics` | Записувати метрики HTTP-запитів | `True` |
|
||||
| `logs` | Записувати помилки валідації та необроблені винятки | `True` |
|
||||
| `operation_spans` | Додавати span для операцій запитів | `True` |
|
||||
| `exclude` | Пропускати запити, коли функція, що отримує ASGI scope, повертає `True` | `None` |
|
||||
| `auto_configure` | Додавати експортери для кінцевих точок, заданих у змінних оточення | `True` |
|
||||
|
||||
Наприклад, щоб збирати метрики, виключаючи перевірки працездатності:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Задайте `auto_configure` значення `False`, коли ваш застосунок самостійно налаштовує провайдери, наприклад у своїй функції тривалості життя.
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
當你的 API 運作時,你可能會想知道它收到多少流量、哪些請求較慢,以及何時發生錯誤。
|
||||
|
||||
**遙測(Telemetry)**是關於應用程式行為的資料,能協助你回答這些問題。常見類型包括:
|
||||
|
||||
- **指標(Metrics)**:可隨時間彙整的測量值,例如回應時間和正在處理的請求數量。
|
||||
- **追蹤(Traces)**:個別請求及其處理過程中所執行操作的記錄。每個計時的操作稱為一個 **span**。
|
||||
- **日誌(Logs)**:附有時間戳記的事件記錄,例如應用程式啟動或操作失敗。
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) 是一套標準與工具,用於收集遙測資料並將其傳送到監控服務,讓你能在儀表板中查看這些資料。
|
||||
|
||||
**FastAPI 預設提供 OpenTelemetry 支援**,涵蓋 HTTP 請求的追蹤、指標和日誌。WebSocket 連線也提供追蹤和日誌。若要查看這些資料,請設定監控服務來接收它們。
|
||||
|
||||
## 安裝 FastAPI { #install-fastapi }
|
||||
|
||||
安裝 FastAPI 時加上 `standard` 額外選項,其中包含傳送遙測資料所需的套件:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## 建立應用程式 { #create-the-app }
|
||||
|
||||
建立一個 `main.py` 檔案:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
請注意,這些功能預設就能運作,你不需要撰寫任何自訂程式碼來啟用遙測。
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
當你使用 `fastapi[standard]` 部署到 [FastAPI Cloud](https://fastapicloud.com) 時,指標功能會自動運作。你不需要做任何額外設定。
|
||||
|
||||
使用 Pro 方案時,你可以在[指標儀表板](https://fastapicloud.com/docs/monitoring-and-performance/metrics/)中查看請求數量、錯誤率和回應時間。
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="含有範例資料的 FastAPI Cloud Pro 指標儀表板">
|
||||
|
||||
## 其他監控服務 { #other-monitoring-services }
|
||||
|
||||
若要將遙測資料傳送到其他監控服務,請設定一個接受 **OTLP** 的端點。OTLP 是 OpenTelemetry 用於傳送遙測資料的協定。請使用該服務的 HTTP/protobuf 基礎端點。
|
||||
|
||||
設定以下環境變數,並將範例 URL 替換為你的端點:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` 用於在監控服務中識別你的應用程式。端點是接收資料的基礎 URL。追蹤會傳送到該 URL 下的 `/v1/traces`,指標傳送到 `/v1/metrics`,日誌則傳送到 `/v1/logs`。
|
||||
|
||||
如果你的服務需要驗證身分,請將 `OTEL_EXPORTER_OTLP_HEADERS` 設定為該服務指定的標頭,例如 `api-key=YOUR_API_KEY`。
|
||||
|
||||
## 執行應用程式 { #run-the-app }
|
||||
|
||||
在同一個終端機中啟動應用程式:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
在另一個終端機中傳送請求:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
開啟你的監控服務並找到 `my-api`。下一次匯出後,你就能看到包含 `GET /items/{item_id}` span 的追蹤,以及請求數量、回應時間和處理中請求的指標。
|
||||
|
||||
## 自訂遙測 { #customize-telemetry }
|
||||
|
||||
### 設定提供者和匯出器 { #configure-providers-and-exporters }
|
||||
|
||||
**提供者(provider)**提供用於記錄追蹤、指標或日誌的物件。其設定控制這些資料的處理與匯出方式。
|
||||
|
||||
遙測函式庫可以設定 OpenTelemetry 的全域提供者。在應用程式啟動前設定好函式庫,FastAPI 就會自動使用這些提供者。
|
||||
|
||||
當環境中設定了 OTLP 端點時,FastAPI 會為每個已啟用的提供者新增一個指向該目的地的匯出器。現有的匯出器會繼續將資料傳送到各自的目的地。
|
||||
|
||||
每個目的地只需設定一次。如果另一個函式庫已經負責處理環境變數指定的目的地,請停用該函式庫依環境變數進行匯出的功能,或關閉 FastAPI 的自動設定:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
你也可以直接在 `telemetry` 字典中傳入提供者。例如,這個提供者使用 OpenTelemetry 的主控台匯出器,在終端機中印出請求的 span:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**匯出器(exporter)**會將 span 傳送到目的地。`BatchSpanProcessor` 會將 span 分組,並在背景傳送。將主控台匯出器替換成監控函式庫提供的匯出器,即可使用該函式庫的目的地。更多設定選項請參閱 [OpenTelemetry 的 Python instrumentation 指南](https://opentelemetry.io/docs/languages/python/instrumentation/)。
|
||||
|
||||
在同一個字典中使用 `meter_provider` 或 `logger_provider`,即可提供指標或日誌的提供者。建立提供者的應用程式或函式庫負責管理其關閉流程。FastAPI 則管理它所新增的匯出元件。
|
||||
|
||||
/// warning | 警告
|
||||
|
||||
OpenTelemetry 預設使用全域提供者。不保證[掛載的子應用程式](sub-applications.md)能使用獨立的遙測設定。
|
||||
|
||||
///
|
||||
|
||||
### 追蹤請求操作 { #trace-request-operations }
|
||||
|
||||
預設情況下,請求追蹤會包含解析相依性、執行路徑操作函式、序列化回應,以及執行 FastAPI `BackgroundTasks` 中各個任務的 span。這些 span 使用相同的提供者和匯出器。
|
||||
|
||||
背景任務的 span 仍屬於該請求的追蹤。它們在 HTTP 回應 span 結束後才執行,因此不會增加測得的回應時間。
|
||||
|
||||
若只想記錄 HTTP 請求 span,請將 `operation_spans` 設為 `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### 追蹤 WebSocket 連線 { #trace-websocket-connections }
|
||||
|
||||
每個 WebSocket 連線都有一個 span,例如 `WS /ws/{room}`,涵蓋處理函式及相依性清理。它使用相同的提供者和設定,包括用於相依性解析和端點執行的 `operation_spans`。
|
||||
|
||||
HTTP 請求指標僅涵蓋 HTTP 請求。使用代碼 `1000` 或 `1001` 正常中斷的 WebSocket 連線不會產生錯誤日誌。
|
||||
|
||||
### 檢查錯誤 { #inspect-errors }
|
||||
|
||||
FastAPI 會將未處理的例外記錄為 OpenTelemetry 日誌,並連結到該請求或連線的追蹤。即使追蹤未被取樣,仍會記錄錯誤日誌。
|
||||
|
||||
例外日誌包含例外的類型、訊息和堆疊追蹤。訊息與堆疊追蹤可能包含敏感資訊。請使用提供者的日誌處理器來篩選或遮蔽這些資訊,或將 `logs` 設為 `False` 以停用這些日誌。
|
||||
|
||||
FastAPI 也會將請求驗證失敗記錄為警告日誌,其中包含路由和錯誤數量。這些日誌不會包含無效的輸入。
|
||||
|
||||
## 選擇要記錄的內容 { #choose-what-to-record }
|
||||
|
||||
`telemetry` 字典也接受以下設定:
|
||||
|
||||
| 設定 | 用途 | 預設值 |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | 記錄 HTTP 請求和 WebSocket 連線的 span | `True` |
|
||||
| `metrics` | 記錄 HTTP 請求指標 | `True` |
|
||||
| `logs` | 記錄驗證失敗和未處理的例外 | `True` |
|
||||
| `operation_spans` | 為請求操作新增 span | `True` |
|
||||
| `exclude` | 當接收 ASGI scope 的函式回傳 `True` 時,略過該請求 | `None` |
|
||||
| `auto_configure` | 為環境變數中設定的端點新增匯出器 | `True` |
|
||||
|
||||
例如,若要收集指標,但排除健康檢查:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
當你的應用程式自行處理提供者設定時,例如在 lifespan 函式中設定,請將 `auto_configure` 設為 `False`。
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenTelemetry { #opentelemetry }
|
||||
|
||||
当你的 API 运行时,你可能想知道它接收了多少流量、哪些请求较慢,以及何时发生错误。
|
||||
|
||||
**遥测**是反映应用行为的数据,可以帮助你回答这些问题。常见类型包括:
|
||||
|
||||
- **指标(Metrics)**:可以按时间汇总的测量值,例如响应时间和正在处理的请求数。
|
||||
- **追踪(Traces)**:记录单个请求及处理该请求所执行的操作。每个计时的操作称为一个 **span**。
|
||||
- **日志(Logs)**:带有时间戳的事件记录,例如应用启动或操作失败。
|
||||
|
||||
[**OpenTelemetry**](https://opentelemetry.io/) 是一套用于收集遥测数据并将其发送到监控服务的标准和工具,你可以在监控服务的仪表板中查看这些数据。
|
||||
|
||||
**FastAPI 默认提供 OpenTelemetry 支持**,用于 HTTP 请求的追踪、指标和日志。WebSocket 连接也提供追踪和日志。要查看这些数据,请配置监控服务来接收它们。
|
||||
|
||||
## 安装 FastAPI { #install-fastapi }
|
||||
|
||||
安装 FastAPI 时启用 `standard` 扩展依赖,其中包含发送遥测数据所需的包:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv add "fastapi[standard]"
|
||||
---> 100%
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## 创建应用 { #create-the-app }
|
||||
|
||||
创建文件 `main.py`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial001_py310.py *}
|
||||
|
||||
注意,这一切默认就能工作,你无需编写任何自定义代码即可使用遥测功能。
|
||||
|
||||
## FastAPI Cloud { #fastapi-cloud }
|
||||
|
||||
使用 `fastapi[standard]` 部署到 [FastAPI Cloud](https://fastapicloud.com) 时,指标功能会自动工作。你无需进行其他配置。
|
||||
|
||||
使用 Pro 套餐时,你可以在[指标仪表板](https://fastapicloud.com/docs/monitoring-and-performance/metrics/)中查看请求数、错误率和响应时间。
|
||||
|
||||
<img src="/img/tutorial/opentelemetry/image01.png" alt="显示示例数据的 FastAPI Cloud Pro 指标仪表板">
|
||||
|
||||
## 其他监控服务 { #other-monitoring-services }
|
||||
|
||||
要将遥测数据发送到其他监控服务,请配置一个接受 **OTLP** 的端点,OTLP 是 OpenTelemetry 用于发送遥测数据的协议。请使用该服务的 HTTP/protobuf 基础端点。
|
||||
|
||||
设置以下环境变量,将示例 URL 替换为你的端点:
|
||||
|
||||
```bash
|
||||
export OTEL_SERVICE_NAME=my-api
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
```
|
||||
|
||||
`OTEL_SERVICE_NAME` 用于在监控服务中标识你的应用。端点是接收数据的基础 URL。追踪会发送到该 URL 下的 `/v1/traces`,指标发送到 `/v1/metrics`,日志发送到 `/v1/logs`。
|
||||
|
||||
如果你的服务需要身份验证,请将 `OTEL_EXPORTER_OTLP_HEADERS` 设置为该服务指定的请求头,例如 `api-key=YOUR_API_KEY`。
|
||||
|
||||
## 运行应用 { #run-the-app }
|
||||
|
||||
在同一个终端中启动应用:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ uv run fastapi run
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
在另一个终端中发送请求:
|
||||
|
||||
```console
|
||||
$ curl http://127.0.0.1:8000/items/1
|
||||
{"item_id":1}
|
||||
```
|
||||
|
||||
打开你的监控服务,找到 `my-api`。下一次导出后,你就能看到一条包含 `GET /items/{item_id}` span 的追踪,以及请求数、响应耗时和活跃请求数的指标。
|
||||
|
||||
## 自定义遥测 { #customize-telemetry }
|
||||
|
||||
### 配置 provider 和 exporter { #configure-providers-and-exporters }
|
||||
|
||||
**provider** 提供用于记录追踪、指标或日志的对象。其配置决定了这些数据的处理和导出方式。
|
||||
|
||||
遥测库可以配置 OpenTelemetry 的全局 provider。在应用启动之前配置好该库,FastAPI 就会自动使用这些 provider。
|
||||
|
||||
当环境中设置了 OTLP 端点时,FastAPI 会为每个已启用的 provider 添加一个向该目标发送数据的 exporter。现有的 exporter 会继续向各自的目标发送数据。
|
||||
|
||||
每个目标只需配置一次。如果另一个库已经负责向环境变量指定的目标导出数据,请禁用该库基于环境变量的导出功能,或关闭 FastAPI 的自动配置:
|
||||
|
||||
```python
|
||||
app = FastAPI(telemetry={"auto_configure": False})
|
||||
```
|
||||
|
||||
你也可以直接在 `telemetry` 字典中传入 provider。例如,下面的 provider 使用 OpenTelemetry 的控制台 exporter,在终端中打印请求 span:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial002_py310.py hl[2:8] *}
|
||||
|
||||
**exporter** 将 span 发送到目标位置。`BatchSpanProcessor` 将 span 分组,并在后台发送。要使用监控库的目标位置,请将控制台 exporter 替换为该库提供的 exporter。更多配置选项请参阅 [OpenTelemetry 的 Python 插桩指南](https://opentelemetry.io/docs/languages/python/instrumentation/)。
|
||||
|
||||
在同一个字典中使用 `meter_provider` 或 `logger_provider` 来提供指标或日志 provider。创建 provider 的应用或库负责管理其关闭过程。FastAPI 负责管理它所添加的导出组件。
|
||||
|
||||
/// warning | 警告
|
||||
|
||||
OpenTelemetry 默认使用全局 provider。不保证[挂载的子应用](sub-applications.md)可以拥有独立的遥测配置。
|
||||
|
||||
///
|
||||
|
||||
### 追踪请求操作 { #trace-request-operations }
|
||||
|
||||
默认情况下,请求追踪包含以下操作的 span:解析依赖项、运行路径操作函数、序列化响应,以及运行 FastAPI 的 `BackgroundTasks` 中的每个任务。这些 span 使用相同的 provider 和 exporter。
|
||||
|
||||
后台任务的 span 仍属于该请求的追踪。它们在 HTTP 响应 span 结束后运行,因此不会增加测得的响应时间。
|
||||
|
||||
要仅记录 HTTP 请求 span,请将 `operation_spans` 设置为 `False`:
|
||||
|
||||
{* ../../docs_src/opentelemetry/tutorial003_py310.py hl[3] *}
|
||||
|
||||
|
||||
### 追踪 WebSocket 连接 { #trace-websocket-connections }
|
||||
|
||||
每个 WebSocket 连接都有一个类似 `WS /ws/{room}` 的 span,涵盖处理函数和依赖项清理过程。它使用相同的 provider 和设置,包括用于依赖项解析和端点执行的 `operation_spans`。
|
||||
|
||||
HTTP 请求指标仅涵盖 HTTP 请求。使用代码 `1000` 或 `1001` 正常断开的 WebSocket 连接不会产生日志错误。
|
||||
|
||||
### 检查错误 { #inspect-errors }
|
||||
|
||||
FastAPI 将未处理的异常记录为 OpenTelemetry 日志,并关联到请求或连接的追踪。即使追踪未被采样,也会记录错误日志。
|
||||
|
||||
异常日志包含异常类型、消息和堆栈追踪。消息和堆栈追踪可能包含敏感信息。请使用 provider 的日志处理器对其进行过滤或脱敏,或将 `logs` 设置为 `False` 来禁用这些日志。
|
||||
|
||||
FastAPI 还会将请求验证失败记录为警告日志,其中包含路由和错误数量。这些日志不包含无效的输入。
|
||||
|
||||
## 选择要记录的内容 { #choose-what-to-record }
|
||||
|
||||
`telemetry` 字典还接受以下设置:
|
||||
|
||||
| 设置 | 用途 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `tracing` | 记录 HTTP 请求和 WebSocket 连接的 span | `True` |
|
||||
| `metrics` | 记录 HTTP 请求指标 | `True` |
|
||||
| `logs` | 记录验证失败和未处理的异常 | `True` |
|
||||
| `operation_spans` | 为请求操作添加 span | `True` |
|
||||
| `exclude` | 当接收 ASGI scope 的函数返回 `True` 时跳过请求 | `None` |
|
||||
| `auto_configure` | 为环境变量中设置的端点添加 exporter | `True` |
|
||||
|
||||
例如,要收集指标,同时排除健康检查:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
telemetry={
|
||||
"tracing": False,
|
||||
"exclude": lambda scope: scope["path"] == "/health",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
当应用自行配置 provider 时,例如在其 lifespan 函数中配置,请将 `auto_configure` 设置为 `False`。
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
|
||||
@@ -55,6 +55,7 @@ def get_username():
|
||||
finally:
|
||||
print("Clean up before response is sent")
|
||||
|
||||
|
||||
UserNameDep = Annotated[str, Depends(get_username, scope="function")]
|
||||
|
||||
@app.get("/users/me")
|
||||
|
||||
@@ -18,6 +18,8 @@ When needing to run blocking code inside of async functions, or async code insid
|
||||
|
||||
Prefer it over AnyIO or asyncio.
|
||||
|
||||
Prefer it over `run_in_threadpool()`.
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -76,6 +76,7 @@ app = FastAPI()
|
||||
class PNGStreamingResponse(StreamingResponse):
|
||||
media_type = "image/png"
|
||||
|
||||
|
||||
@app.get("/image", response_class=PNGStreamingResponse)
|
||||
def stream_image_no_async_no_annotation():
|
||||
with read_image() as image_file:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.141.1"
|
||||
__version__ = "0.142.2"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
+65
-2
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
+118
-38
@@ -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
|
||||
|
||||
@@ -1271,12 +1304,8 @@ class APIRoute(routing.Route):
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
token = _effective_route_context_var.set(effective_context)
|
||||
try:
|
||||
app = request_response(self.get_route_handler())
|
||||
finally:
|
||||
_effective_route_context_var.reset(token)
|
||||
await app(scope, receive, send)
|
||||
assert effective_context.app is not None
|
||||
await effective_context.app(scope, receive, send)
|
||||
return
|
||||
await super().handle(scope, receive, send)
|
||||
|
||||
@@ -1374,6 +1403,7 @@ class _RouterIncludeContext:
|
||||
class _EffectiveRouteContext:
|
||||
original_route: BaseRoute
|
||||
starlette_route: BaseRoute | None = None
|
||||
app: ASGIApp | None = field(default=None, repr=False, compare=False)
|
||||
frontend_prefix: str = ""
|
||||
path: str = ""
|
||||
endpoint: Callable[..., Any] | None = None
|
||||
@@ -1475,6 +1505,13 @@ class _EffectiveRouteContext:
|
||||
),
|
||||
stream_item_type=route.stream_item_type,
|
||||
)
|
||||
# Build once per inclusion context, just as APIRoute does for direct routes.
|
||||
# Integrations may wrap dependant.call while constructing the handler.
|
||||
token = _effective_route_context_var.set(context)
|
||||
try:
|
||||
context.app = request_response(original_route.get_route_handler())
|
||||
finally:
|
||||
_effective_route_context_var.reset(token)
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
@@ -1791,6 +1828,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 +2232,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 +2778,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 +2791,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 +2810,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 +2840,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,238 @@
|
||||
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:
|
||||
async def wrapped_receive() -> Message:
|
||||
message = await receive()
|
||||
if message["type"] == "lifespan.startup":
|
||||
try:
|
||||
_configure_from_environment(config)
|
||||
except Exception as exc:
|
||||
# Optional telemetry setup must not prevent application startup.
|
||||
logger.warning(
|
||||
"FastAPI automatic telemetry configuration failed: %s", exc
|
||||
)
|
||||
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)
|
||||
+19
-1
@@ -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",
|
||||
@@ -147,7 +158,7 @@ docs = [
|
||||
docs-tests = [
|
||||
"httpx >=0.23.0,<1.0.0",
|
||||
"httpx2>=2.0.0",
|
||||
"ruff >=0.14.14,<0.16.0",
|
||||
"ruff>=0.14.14,<0.17.0",
|
||||
]
|
||||
github-actions = [
|
||||
"httpx >=0.27.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 = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
@@ -37,6 +38,8 @@ general_prompt = general_prompt_path.read_text(encoding="utf-8")
|
||||
app = typer.Typer()
|
||||
repository_path = Path(__file__).absolute().parent.parent
|
||||
|
||||
os.environ["PYDANTIC_AI_NO_BANNER"] = "1"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_langs() -> dict[str, str]:
|
||||
@@ -137,7 +140,7 @@ def translate_page(
|
||||
print(f"Found existing translation: {out_path}")
|
||||
old_translation = out_path.read_text(encoding="utf-8")
|
||||
print(f"Translating {en_path} to {language} ({language_name})")
|
||||
agent = Agent("openai-chat:gpt-5.5")
|
||||
agent = Agent("openai-chat:gpt-6-astra")
|
||||
|
||||
MAX_ATTEMPTS = 3
|
||||
additional_instructions = ""
|
||||
|
||||
@@ -578,11 +578,8 @@ async def test_frontend_dependency_restores_existing_dependency_stacks(
|
||||
messages = []
|
||||
|
||||
async def receive():
|
||||
return { # pragma: no cover
|
||||
"type": "http.request",
|
||||
"body": b"",
|
||||
"more_body": False,
|
||||
}
|
||||
# Keep the connection open until the response finishes.
|
||||
await anyio.sleep_forever()
|
||||
|
||||
async def send(message):
|
||||
messages.append(message)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from functools import wraps
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, Depends, FastAPI, routing
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_router", [False, True], ids=["direct", "included"])
|
||||
def test_endpoint_wrapper_does_not_accumulate_across_requests(
|
||||
monkeypatch, include_router
|
||||
):
|
||||
original_get_request_handler = routing.get_request_handler
|
||||
wrapper_calls = 0
|
||||
|
||||
def get_request_handler_with_endpoint_wrapper(*args, **kwargs):
|
||||
# Older Sentry SDKs wrap dependant.call each time a handler is built.
|
||||
# Accumulating these wrappers eventually exhausts the recursion limit.
|
||||
dependant = kwargs["dependant"]
|
||||
original_call = dependant.call
|
||||
|
||||
@wraps(original_call)
|
||||
def wrapped_endpoint(*args, **kwargs):
|
||||
nonlocal wrapper_calls
|
||||
wrapper_calls += 1
|
||||
return original_call(*args, **kwargs)
|
||||
|
||||
dependant.call = wrapped_endpoint
|
||||
return original_get_request_handler(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
routing, "get_request_handler", get_request_handler_with_endpoint_wrapper
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
router = APIRouter() if include_router else app.router
|
||||
|
||||
@router.get("/items/{item_id}")
|
||||
def read_item(item_id: str):
|
||||
return {"item_id": item_id}
|
||||
|
||||
if include_router:
|
||||
app.include_router(router)
|
||||
|
||||
calls_per_request = []
|
||||
with TestClient(app) as client:
|
||||
for item_id in ("first", "second", "third"):
|
||||
wrapper_calls = 0
|
||||
response = client.get(f"/items/{item_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"item_id": item_id}
|
||||
calls_per_request.append(wrapper_calls)
|
||||
|
||||
assert calls_per_request == [1, 1, 1]
|
||||
|
||||
|
||||
def test_custom_handlers_are_cached_separately_for_nested_inclusions():
|
||||
built_handlers = []
|
||||
|
||||
class CustomRoute(routing.APIRoute):
|
||||
def get_route_handler(self):
|
||||
handler = super().get_route_handler()
|
||||
built_handlers.append(handler)
|
||||
handler_id = str(len(built_handlers))
|
||||
|
||||
async def custom_handler(request):
|
||||
response = await handler(request)
|
||||
response.headers["x-handler-id"] = handler_id
|
||||
return response
|
||||
|
||||
return custom_handler
|
||||
|
||||
router = APIRouter(route_class=CustomRoute)
|
||||
|
||||
@router.get("/{item_id}")
|
||||
def read_item(item_id: str):
|
||||
return item_id
|
||||
|
||||
parent = APIRouter()
|
||||
parent.include_router(router, prefix="/json")
|
||||
parent.include_router(
|
||||
router, prefix="/text", default_response_class=PlainTextResponse
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(parent, prefix="/api")
|
||||
|
||||
with TestClient(app) as client:
|
||||
first_json = client.get("/api/json/first")
|
||||
first_text = client.get("/api/text/first")
|
||||
json_handler_id = first_json.headers["x-handler-id"]
|
||||
text_handler_id = first_text.headers["x-handler-id"]
|
||||
assert json_handler_id != text_handler_id
|
||||
built_count = len(built_handlers)
|
||||
|
||||
for item_id in ("second", "third"):
|
||||
json_response = client.get(f"/api/json/{item_id}")
|
||||
assert json_response.status_code == 200
|
||||
assert json_response.json() == item_id
|
||||
assert json_response.headers["x-handler-id"] == json_handler_id
|
||||
text_response = client.get(f"/api/text/{item_id}")
|
||||
assert text_response.status_code == 200
|
||||
assert text_response.text == item_id
|
||||
assert text_response.headers["x-handler-id"] == text_handler_id
|
||||
|
||||
assert len(built_handlers) == built_count
|
||||
|
||||
|
||||
def test_cached_handler_uses_live_dependency_overrides_and_route_additions():
|
||||
router = APIRouter()
|
||||
|
||||
def dependency():
|
||||
return "original"
|
||||
|
||||
@router.get("/items")
|
||||
def read_items(value: str = Depends(dependency)):
|
||||
return value
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/items").json() == "original"
|
||||
app.dependency_overrides[dependency] = lambda: "overridden"
|
||||
assert client.get("/api/items").json() == "overridden"
|
||||
|
||||
@router.get("/later")
|
||||
def read_later(value: str = Depends(dependency)):
|
||||
return value
|
||||
|
||||
assert client.get("/api/items").json() == "overridden"
|
||||
assert client.get("/api/later").json() == "overridden"
|
||||
app.dependency_overrides.clear()
|
||||
assert client.get("/api/items").json() == "original"
|
||||
assert client.get("/api/later").json() == "original"
|
||||
|
||||
|
||||
def test_failed_handler_construction_restores_context_and_can_retry():
|
||||
fail = False
|
||||
|
||||
class CustomRoute(routing.APIRoute):
|
||||
def get_route_handler(self):
|
||||
if fail:
|
||||
raise RuntimeError("handler construction failed")
|
||||
return super().get_route_handler()
|
||||
|
||||
router = APIRouter(route_class=CustomRoute)
|
||||
|
||||
@router.get("/items")
|
||||
def read_items():
|
||||
return ["item"]
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
fail = True
|
||||
assert routing._effective_route_context_var.get() is None
|
||||
with pytest.raises(RuntimeError, match="handler construction failed"):
|
||||
app.openapi()
|
||||
assert routing._effective_route_context_var.get() is None
|
||||
|
||||
fail = False
|
||||
assert "/api/items" in app.openapi()["paths"]
|
||||
assert routing._effective_route_context_var.get() is None
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/items").json() == ["item"]
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,648 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
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",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_configuration_warns_without_disabling_providers(
|
||||
monkeypatch, caplog, telemetry, env, message
|
||||
):
|
||||
for name, value in env.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
config, exporter, _ = telemetry
|
||||
events = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
events.append("startup")
|
||||
yield
|
||||
events.append("shutdown")
|
||||
|
||||
app = FastAPI(telemetry=config, lifespan=lifespan)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
assert events == ["startup", "shutdown"]
|
||||
assert exporter.get_finished_spans()
|
||||
assert not runtime._owned
|
||||
assert len(caplog.records) == 1
|
||||
assert caplog.records[0].levelno == logging.WARNING
|
||||
assert message in caplog.messages[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["startup", "shutdown"])
|
||||
def test_application_lifespan_errors_still_propagate(monkeypatch, stage):
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "not-a-url")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
if stage == "startup":
|
||||
raise RuntimeError("application startup failed")
|
||||
yield
|
||||
raise RuntimeError("application shutdown failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match=f"application {stage} failed"):
|
||||
with TestClient(FastAPI(lifespan=lifespan)) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
|
||||
|
||||
@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_warns_without_preventing_startup():
|
||||
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 unittest import TestCase
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestCase().assertLogs("fastapi", level="WARNING") as logs:
|
||||
with TestClient(FastAPI()) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
assert len(logs.records) == 1
|
||||
assert logs.records[0].levelno == logging.WARNING
|
||||
assert "fastapi[opentelemetry]" in logs.output[0]
|
||||
assert not runtime._owned
|
||||
|
||||
|
||||
@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_warns_without_preventing_startup(
|
||||
monkeypatch, caplog, name
|
||||
):
|
||||
from opentelemetry._logs import NoOpLoggerProvider
|
||||
from opentelemetry.metrics import NoOpMeterProvider
|
||||
from opentelemetry.trace import NoOpTracerProvider
|
||||
|
||||
signal, provider = {
|
||||
"tracer": ("TRACES", NoOpTracerProvider),
|
||||
"meter": ("METRICS", NoOpMeterProvider),
|
||||
"logger": ("LOGS", NoOpLoggerProvider),
|
||||
}[name]
|
||||
monkeypatch.setenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT", "http://127.0.0.1:1")
|
||||
app = FastAPI(telemetry={f"{name}_provider": provider()})
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
assert "does not support adding an OTLP exporter" in caplog.text
|
||||
assert "auto_configure" in caplog.text
|
||||
assert not runtime._owned
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_type", [ValueError, AttributeError])
|
||||
def test_failed_registration_closes_new_exporter(monkeypatch, caplog, 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 TestClient(app) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
assert "registration failed" in caplog.text
|
||||
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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user