fastapi-observer 1.12.0__tar.gz → 1.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_observer-1.12.0/src/fastapi_observer.egg-info → fastapi_observer-1.14.0}/PKG-INFO +13 -11
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/README.md +12 -10
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0/src/fastapi_observer.egg-info}/PKG-INFO +13 -11
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/SOURCES.txt +2 -0
- fastapi_observer-1.14.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/fastapi.py +34 -11
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/formatter.py +13 -15
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/setup.py +8 -13
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/prometheus.py +55 -7
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/request_logging.py +18 -1
- fastapi_observer-1.14.0/src/fastapiobserver/pools.py +99 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/policy.py +6 -1
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/redaction.py +8 -2
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_fork_queue_listener.py +37 -4
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_logging_schema_contract.py +15 -28
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_middleware.py +38 -0
- fastapi_observer-1.14.0/tests/test_pools.py +116 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_redaction_presets.py +21 -3
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_security_audit_fixes.py +2 -2
- fastapi_observer-1.12.0/src/fastapiobserver/_version.py +0 -1
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/LICENSE +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/pyproject.toml +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/setup.cfg +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/config.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/control_plane.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/filters.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/held.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/queueing.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/state.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/volume.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loop_watchdog.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/context.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/events.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/metrics.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/native.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/tracing.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/normalize.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/proxies.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/discovery.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/factory.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/registry.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_fastapi_install.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_logtail_dlq.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_loop_watchdog.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_native_telemetry.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_request_insights.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_sinks.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_utils_refactors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.14.0
|
|
4
4
|
Summary: Zero-glue FastAPI observability with security presets and runtime controls
|
|
5
5
|
Author-email: Vitaee <opensource@vitaee.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -150,9 +150,9 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
150
150
|
- **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
|
|
151
151
|
- **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
|
|
152
152
|
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), and 404s for paths no route matches share one `<unmatched>` label, so neither IDs nor scanners explode cardinality.
|
|
153
|
-
- **OpenTelemetry traces
|
|
153
|
+
- **OpenTelemetry traces**, from FastAPI's own support on 0.142 and later, with `trace_id` and `span_id` in every log line, and optional log export over OTLP, redacted like everything else.
|
|
154
154
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
155
|
-
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
155
|
+
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. Always on: a warning when the thread pool or a database connection pool is full, the usual cause when latency climbs while CPU stays low. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
156
156
|
|
|
157
157
|
Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlchemy, `httpx` and `requests` tracing, Strawberry GraphQL and a Loguru bridge. See the [documentation](https://vitaee.github.io/FastapiObserver/).
|
|
158
158
|
|
|
@@ -162,14 +162,16 @@ Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlche
|
|
|
162
162
|
|
|
163
163
|
## How it compares
|
|
164
164
|
|
|
165
|
-
| | fastapi-observer | structlog or loguru | OpenTelemetry auto-instrumentation | Logfire |
|
|
166
|
-
|
|
167
|
-
| JSON log line per request | Built in | You write the middleware | No | A span per request |
|
|
168
|
-
| Request ID on every log line | Built in | You wire it up | No | Trace IDs |
|
|
169
|
-
| Redaction by default | Yes, with presets | You write a processor | No | Yes |
|
|
170
|
-
| Prometheus `/metrics` | Built in | No | Through an exporter | No |
|
|
171
|
-
| Traces | OpenTelemetry | No | Yes | Yes |
|
|
172
|
-
| Where data goes | stdout, Prometheus and any OTLP backend | stdout or files | Any OTLP backend | Logfire's platform |
|
|
165
|
+
| | fastapi-observer | FastAPI 0.142+ built in | structlog or loguru | OpenTelemetry auto-instrumentation | Logfire |
|
|
166
|
+
|---|---|---|---|---|---|
|
|
167
|
+
| JSON log line per request | Built in | No, only errors as OTLP log records | You write the middleware | No | A span per request |
|
|
168
|
+
| Request ID on every log line | Built in | No | You wire it up | No | Trace IDs |
|
|
169
|
+
| Redaction by default | Yes, with presets | Signed-URL query parameters only | You write a processor | No | Yes |
|
|
170
|
+
| Prometheus `/metrics` | Built in | Through an exporter | No | Through an exporter | No |
|
|
171
|
+
| Traces | FastAPI's own on 0.142+, OpenTelemetry's instrumentation before | Yes, with spans for dependencies, the endpoint and serialization | No | Yes | Yes |
|
|
172
|
+
| Where data goes | stdout, Prometheus and any OTLP backend | Any OTLP backend | stdout or files | Any OTLP backend | Logfire's platform |
|
|
173
|
+
|
|
174
|
+
Since 0.142, FastAPI traces requests on its own once an OpenTelemetry provider is set up. fastapi-observer uses that for traces and adds the rest of the first column.
|
|
173
175
|
|
|
174
176
|
**When not to use it:**
|
|
175
177
|
|
|
@@ -74,9 +74,9 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
74
74
|
- **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
|
|
75
75
|
- **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
|
|
76
76
|
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), and 404s for paths no route matches share one `<unmatched>` label, so neither IDs nor scanners explode cardinality.
|
|
77
|
-
- **OpenTelemetry traces
|
|
77
|
+
- **OpenTelemetry traces**, from FastAPI's own support on 0.142 and later, with `trace_id` and `span_id` in every log line, and optional log export over OTLP, redacted like everything else.
|
|
78
78
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
79
|
-
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
79
|
+
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. Always on: a warning when the thread pool or a database connection pool is full, the usual cause when latency climbs while CPU stays low. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
80
80
|
|
|
81
81
|
Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlchemy, `httpx` and `requests` tracing, Strawberry GraphQL and a Loguru bridge. See the [documentation](https://vitaee.github.io/FastapiObserver/).
|
|
82
82
|
|
|
@@ -86,14 +86,16 @@ Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlche
|
|
|
86
86
|
|
|
87
87
|
## How it compares
|
|
88
88
|
|
|
89
|
-
| | fastapi-observer | structlog or loguru | OpenTelemetry auto-instrumentation | Logfire |
|
|
90
|
-
|
|
91
|
-
| JSON log line per request | Built in | You write the middleware | No | A span per request |
|
|
92
|
-
| Request ID on every log line | Built in | You wire it up | No | Trace IDs |
|
|
93
|
-
| Redaction by default | Yes, with presets | You write a processor | No | Yes |
|
|
94
|
-
| Prometheus `/metrics` | Built in | No | Through an exporter | No |
|
|
95
|
-
| Traces | OpenTelemetry | No | Yes | Yes |
|
|
96
|
-
| Where data goes | stdout, Prometheus and any OTLP backend | stdout or files | Any OTLP backend | Logfire's platform |
|
|
89
|
+
| | fastapi-observer | FastAPI 0.142+ built in | structlog or loguru | OpenTelemetry auto-instrumentation | Logfire |
|
|
90
|
+
|---|---|---|---|---|---|
|
|
91
|
+
| JSON log line per request | Built in | No, only errors as OTLP log records | You write the middleware | No | A span per request |
|
|
92
|
+
| Request ID on every log line | Built in | No | You wire it up | No | Trace IDs |
|
|
93
|
+
| Redaction by default | Yes, with presets | Signed-URL query parameters only | You write a processor | No | Yes |
|
|
94
|
+
| Prometheus `/metrics` | Built in | Through an exporter | No | Through an exporter | No |
|
|
95
|
+
| Traces | FastAPI's own on 0.142+, OpenTelemetry's instrumentation before | Yes, with spans for dependencies, the endpoint and serialization | No | Yes | Yes |
|
|
96
|
+
| Where data goes | stdout, Prometheus and any OTLP backend | Any OTLP backend | stdout or files | Any OTLP backend | Logfire's platform |
|
|
97
|
+
|
|
98
|
+
Since 0.142, FastAPI traces requests on its own once an OpenTelemetry provider is set up. fastapi-observer uses that for traces and adds the rest of the first column.
|
|
97
99
|
|
|
98
100
|
**When not to use it:**
|
|
99
101
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.14.0
|
|
4
4
|
Summary: Zero-glue FastAPI observability with security presets and runtime controls
|
|
5
5
|
Author-email: Vitaee <opensource@vitaee.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -150,9 +150,9 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
150
150
|
- **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
|
|
151
151
|
- **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
|
|
152
152
|
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), and 404s for paths no route matches share one `<unmatched>` label, so neither IDs nor scanners explode cardinality.
|
|
153
|
-
- **OpenTelemetry traces
|
|
153
|
+
- **OpenTelemetry traces**, from FastAPI's own support on 0.142 and later, with `trace_id` and `span_id` in every log line, and optional log export over OTLP, redacted like everything else.
|
|
154
154
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
155
|
-
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
155
|
+
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. Always on: a warning when the thread pool or a database connection pool is full, the usual cause when latency climbs while CPU stays low. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
156
156
|
|
|
157
157
|
Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlchemy, `httpx` and `requests` tracing, Strawberry GraphQL and a Loguru bridge. See the [documentation](https://vitaee.github.io/FastapiObserver/).
|
|
158
158
|
|
|
@@ -162,14 +162,16 @@ Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlche
|
|
|
162
162
|
|
|
163
163
|
## How it compares
|
|
164
164
|
|
|
165
|
-
| | fastapi-observer | structlog or loguru | OpenTelemetry auto-instrumentation | Logfire |
|
|
166
|
-
|
|
167
|
-
| JSON log line per request | Built in | You write the middleware | No | A span per request |
|
|
168
|
-
| Request ID on every log line | Built in | You wire it up | No | Trace IDs |
|
|
169
|
-
| Redaction by default | Yes, with presets | You write a processor | No | Yes |
|
|
170
|
-
| Prometheus `/metrics` | Built in | No | Through an exporter | No |
|
|
171
|
-
| Traces | OpenTelemetry | No | Yes | Yes |
|
|
172
|
-
| Where data goes | stdout, Prometheus and any OTLP backend | stdout or files | Any OTLP backend | Logfire's platform |
|
|
165
|
+
| | fastapi-observer | FastAPI 0.142+ built in | structlog or loguru | OpenTelemetry auto-instrumentation | Logfire |
|
|
166
|
+
|---|---|---|---|---|---|
|
|
167
|
+
| JSON log line per request | Built in | No, only errors as OTLP log records | You write the middleware | No | A span per request |
|
|
168
|
+
| Request ID on every log line | Built in | No | You wire it up | No | Trace IDs |
|
|
169
|
+
| Redaction by default | Yes, with presets | Signed-URL query parameters only | You write a processor | No | Yes |
|
|
170
|
+
| Prometheus `/metrics` | Built in | Through an exporter | No | Through an exporter | No |
|
|
171
|
+
| Traces | FastAPI's own on 0.142+, OpenTelemetry's instrumentation before | Yes, with spans for dependencies, the endpoint and serialization | No | Yes | Yes |
|
|
172
|
+
| Where data goes | stdout, Prometheus and any OTLP backend | Any OTLP backend | stdout or files | Any OTLP backend | Logfire's platform |
|
|
173
|
+
|
|
174
|
+
Since 0.142, FastAPI traces requests on its own once an OpenTelemetry provider is set up. fastapi-observer uses that for traces and adds the rest of the first column.
|
|
173
175
|
|
|
174
176
|
**When not to use it:**
|
|
175
177
|
|
{fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/SOURCES.txt
RENAMED
|
@@ -15,6 +15,7 @@ src/fastapiobserver/fastapi.py
|
|
|
15
15
|
src/fastapiobserver/loguru.py
|
|
16
16
|
src/fastapiobserver/loop_watchdog.py
|
|
17
17
|
src/fastapiobserver/plugins.py
|
|
18
|
+
src/fastapiobserver/pools.py
|
|
18
19
|
src/fastapiobserver/profiles.py
|
|
19
20
|
src/fastapiobserver/propagation.py
|
|
20
21
|
src/fastapiobserver/py.typed
|
|
@@ -98,6 +99,7 @@ tests/test_otel_logs_pipeline.py
|
|
|
98
99
|
tests/test_otel_metrics_from_env.py
|
|
99
100
|
tests/test_otlp_export_integration.py
|
|
100
101
|
tests/test_plugin_isolation.py
|
|
102
|
+
tests/test_pools.py
|
|
101
103
|
tests/test_profiles.py
|
|
102
104
|
tests/test_propagation.py
|
|
103
105
|
tests/test_redaction_presets.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.14.0"
|
|
@@ -35,6 +35,7 @@ from .otel import (
|
|
|
35
35
|
install_otel_metrics,
|
|
36
36
|
)
|
|
37
37
|
from .otel.native import keep_native_logs_out
|
|
38
|
+
from .pools import watch_db_pool, watch_thread_pool
|
|
38
39
|
from .propagation import install_request_id_propagation
|
|
39
40
|
from .security import SecurityPolicy, TrustedProxyPolicy
|
|
40
41
|
|
|
@@ -109,9 +110,13 @@ def install_observability(
|
|
|
109
110
|
if otel_settings and otel_settings.enabled:
|
|
110
111
|
install_otel(app, settings, otel_settings)
|
|
111
112
|
|
|
112
|
-
# --- 4b. Database tracing + SQLCommenter ---
|
|
113
|
-
if db_engine is not None
|
|
114
|
-
|
|
113
|
+
# --- 4b. Database pools, tracing + SQLCommenter ---
|
|
114
|
+
if db_engine is not None:
|
|
115
|
+
engines = _as_engine_list(db_engine)
|
|
116
|
+
for engine in engines:
|
|
117
|
+
watch_db_pool(getattr(engine, "sync_engine", engine))
|
|
118
|
+
if otel_settings and otel_settings.enabled:
|
|
119
|
+
_setup_database_tracing(engines, db_commenter_enabled, db_commenter_options)
|
|
115
120
|
|
|
116
121
|
# --- 5. OTel metrics ---
|
|
117
122
|
if otel_metrics_settings and otel_metrics_settings.enabled:
|
|
@@ -143,6 +148,21 @@ def install_observability(
|
|
|
143
148
|
if runtime_control_settings and runtime_control_settings.enabled:
|
|
144
149
|
mount_control_plane(app, runtime_control_settings, trusted_proxy_policy)
|
|
145
150
|
|
|
151
|
+
if security_policy.redaction_mode == "hash" and security_policy.hash_key is None:
|
|
152
|
+
_LOGGER.warning(
|
|
153
|
+
"observability.redaction.hash_key_missing",
|
|
154
|
+
extra={
|
|
155
|
+
"event": {
|
|
156
|
+
"message": (
|
|
157
|
+
"Hashed values use a random key picked at startup, so they differ "
|
|
158
|
+
"between processes and after a restart. Set OBS_REDACTION_HASH_KEY "
|
|
159
|
+
"to keep them stable."
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
"_skip_enrichers": True,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
146
166
|
# --- 9. One line with the settings that took effect ---
|
|
147
167
|
_LOGGER.info(
|
|
148
168
|
"observability.configured",
|
|
@@ -262,8 +282,17 @@ def _setup_metrics_backend(
|
|
|
262
282
|
return metrics_backend
|
|
263
283
|
|
|
264
284
|
|
|
285
|
+
def _as_engine_list(db_engine: Any | Sequence[Any]) -> list[Any]:
|
|
286
|
+
if isinstance(db_engine, (str, bytes, bytearray)):
|
|
287
|
+
raise TypeError(
|
|
288
|
+
f"db_engine must be an SQLAlchemy Engine or sequence of Engines, "
|
|
289
|
+
f"not {type(db_engine).__name__}"
|
|
290
|
+
)
|
|
291
|
+
return list(db_engine) if isinstance(db_engine, Sequence) else [db_engine]
|
|
292
|
+
|
|
293
|
+
|
|
265
294
|
def _setup_database_tracing(
|
|
266
|
-
|
|
295
|
+
engines: list[Any],
|
|
267
296
|
db_commenter_enabled: bool,
|
|
268
297
|
db_commenter_options: dict[str, bool] | None,
|
|
269
298
|
) -> None:
|
|
@@ -272,13 +301,6 @@ def _setup_database_tracing(
|
|
|
272
301
|
instrument_sqlalchemy_async,
|
|
273
302
|
)
|
|
274
303
|
|
|
275
|
-
if isinstance(db_engine, (str, bytes, bytearray)):
|
|
276
|
-
raise TypeError(
|
|
277
|
-
f"db_engine must be an SQLAlchemy Engine or sequence of Engines, "
|
|
278
|
-
f"not {type(db_engine).__name__}"
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
engines = db_engine if isinstance(db_engine, Sequence) else [db_engine]
|
|
282
304
|
for engine in engines:
|
|
283
305
|
if hasattr(engine, "sync_engine"):
|
|
284
306
|
instrument_sqlalchemy_async(
|
|
@@ -400,6 +422,7 @@ async def observability_lifespan(app: FastAPI) -> typing.AsyncGenerator[typing.A
|
|
|
400
422
|
watchdog = None
|
|
401
423
|
if state:
|
|
402
424
|
_auto_discover_excluded_routes(app, state.settings)
|
|
425
|
+
watch_thread_pool()
|
|
403
426
|
if state.settings.loop_block_threshold_ms:
|
|
404
427
|
watchdog = EventLoopWatchdog(state.settings.loop_block_threshold_ms / 1000)
|
|
405
428
|
watchdog.start()
|
{fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/formatter.py
RENAMED
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
-
import
|
|
6
|
+
import traceback
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
|
+
from types import TracebackType
|
|
8
9
|
from typing import Any, Callable, Mapping
|
|
9
10
|
|
|
10
11
|
from .._version import __version__
|
|
@@ -144,11 +145,11 @@ def _build_structured_error(
|
|
|
144
145
|
"stacktrace": "",
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
exc_type, exc_value,
|
|
148
|
+
exc_type, exc_value, exc_tb = exc_info
|
|
148
149
|
error_type = exc_type.__name__ if exc_type is not None else "Exception"
|
|
149
150
|
error_message = str(exc_value) if exc_value is not None else ""
|
|
150
151
|
stacktrace = formatter.formatException(exc_info)
|
|
151
|
-
fingerprint = _generate_error_fingerprint(error_type,
|
|
152
|
+
fingerprint = _generate_error_fingerprint(error_type, exc_tb)
|
|
152
153
|
|
|
153
154
|
return {
|
|
154
155
|
"type": error_type,
|
|
@@ -158,16 +159,13 @@ def _build_structured_error(
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
|
|
161
|
-
def _generate_error_fingerprint(error_type: str,
|
|
162
|
-
"""Hash
|
|
163
|
-
if not stacktrace:
|
|
164
|
-
return hashlib.sha256(error_type.encode("utf-8")).hexdigest()[:32]
|
|
162
|
+
def _generate_error_fingerprint(error_type: str, exc_tb: TracebackType | None) -> str:
|
|
163
|
+
"""Hash the exception type and the file, function and source of each frame.
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
165
|
+
The message is left out, since it often holds IDs that differ each time the
|
|
166
|
+
same bug is hit, and so are line numbers, which shift with unrelated edits.
|
|
167
|
+
"""
|
|
168
|
+
frames = "".join(
|
|
169
|
+
f"{frame.filename}:{frame.name}:{frame.line}\n" for frame in traceback.extract_tb(exc_tb)
|
|
170
|
+
)
|
|
171
|
+
return hashlib.sha256(f"{error_type}\n{frames}".encode("utf-8")).hexdigest()[:32]
|
|
@@ -4,6 +4,7 @@ import atexit
|
|
|
4
4
|
import logging
|
|
5
5
|
import logging.handlers
|
|
6
6
|
import queue
|
|
7
|
+
import threading
|
|
7
8
|
from typing import Literal, cast
|
|
8
9
|
|
|
9
10
|
from ..config import ObservabilitySettings
|
|
@@ -289,22 +290,16 @@ def _restart_queue_listener_in_child() -> None:
|
|
|
289
290
|
|
|
290
291
|
listener = state._QUEUE_LISTENER
|
|
291
292
|
if listener is not None:
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
293
|
+
# Another thread of the parent may have held any lock at the moment of
|
|
294
|
+
# the fork, and no thread in the child will release it. So don't touch
|
|
295
|
+
# the inherited queue, not even to drain it: its records are the
|
|
296
|
+
# parent's to write. Give the child fresh locks and a fresh queue.
|
|
297
|
+
_LOG_QUEUE_TELEMETRY._lock = threading.Lock()
|
|
298
|
+
for breaker in state._SINK_CIRCUIT_BREAKERS:
|
|
299
|
+
breaker._lock = threading.Lock()
|
|
298
300
|
old_queue = cast(queue.Queue[logging.LogRecord], listener.queue)
|
|
299
301
|
new_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=old_queue.maxsize)
|
|
300
302
|
|
|
301
|
-
while not old_queue.empty():
|
|
302
|
-
try:
|
|
303
|
-
# Discard the record to prevent pre-fork replay duplication
|
|
304
|
-
_ = old_queue.get_nowait()
|
|
305
|
-
except queue.Empty:
|
|
306
|
-
break
|
|
307
|
-
|
|
308
303
|
# Re-attach the new queue to the OverflowPolicyQueueHandler if present
|
|
309
304
|
for handler in state._MANAGED_HANDLERS:
|
|
310
305
|
from .queueing import OverflowPolicyQueueHandler
|
{fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/prometheus.py
RENAMED
|
@@ -186,17 +186,65 @@ class _LogQueueMetricsCollector:
|
|
|
186
186
|
yield counter
|
|
187
187
|
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
class _PoolMetricsCollector:
|
|
190
|
+
"""Exposes thread pool and SQLAlchemy connection pool usage at scrape time."""
|
|
191
|
+
|
|
192
|
+
def collect(self) -> Iterator[Any]:
|
|
193
|
+
from prometheus_client.core import GaugeMetricFamily
|
|
194
|
+
|
|
195
|
+
from ..pools import db_pool_usage, thread_pool_usage
|
|
196
|
+
|
|
197
|
+
threads = thread_pool_usage()
|
|
198
|
+
if threads is not None:
|
|
199
|
+
busy, limit, waiting = threads
|
|
200
|
+
yield GaugeMetricFamily(
|
|
201
|
+
"fastapiobserver_threadpool_busy",
|
|
202
|
+
"Threads running sync endpoints and dependencies.",
|
|
203
|
+
value=busy,
|
|
204
|
+
)
|
|
205
|
+
yield GaugeMetricFamily(
|
|
206
|
+
"fastapiobserver_threadpool_limit",
|
|
207
|
+
"Threads AnyIO's default pool may run at once.",
|
|
208
|
+
value=limit,
|
|
209
|
+
)
|
|
210
|
+
yield GaugeMetricFamily(
|
|
211
|
+
"fastapiobserver_threadpool_waiting",
|
|
212
|
+
"Calls waiting for a thread.",
|
|
213
|
+
value=waiting,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
db_pools = db_pool_usage()
|
|
217
|
+
if not db_pools:
|
|
218
|
+
return
|
|
219
|
+
checked_out = GaugeMetricFamily(
|
|
220
|
+
"fastapiobserver_db_pool_checked_out",
|
|
221
|
+
"Connections checked out of each SQLAlchemy pool.",
|
|
222
|
+
labels=["engine"],
|
|
223
|
+
)
|
|
224
|
+
limits = GaugeMetricFamily(
|
|
225
|
+
"fastapiobserver_db_pool_limit",
|
|
226
|
+
"Connections each SQLAlchemy pool may hand out: pool_size plus max_overflow.",
|
|
227
|
+
labels=["engine"],
|
|
228
|
+
)
|
|
229
|
+
for label, out, limit in db_pools:
|
|
230
|
+
checked_out.add_metric([label], out)
|
|
231
|
+
limits.add_metric([label], limit)
|
|
232
|
+
yield checked_out
|
|
233
|
+
yield limits
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _register_collectors() -> None:
|
|
237
|
+
# Per-worker stats would be meaningless once aggregated across processes.
|
|
191
238
|
if _multiprocess_dir():
|
|
192
239
|
return
|
|
193
240
|
from ..logging.volume import start_counting
|
|
194
241
|
|
|
195
242
|
start_counting()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
243
|
+
for collector in (_LogQueueMetricsCollector(), _PoolMetricsCollector()):
|
|
244
|
+
try:
|
|
245
|
+
_import_prometheus_client().REGISTRY.register(collector)
|
|
246
|
+
except ValueError:
|
|
247
|
+
pass # Already registered by an earlier backend build in this process.
|
|
200
248
|
|
|
201
249
|
|
|
202
250
|
class PrometheusMetricsBackend:
|
|
@@ -288,7 +336,7 @@ def _build_prometheus_metrics_backend(
|
|
|
288
336
|
environment=environment,
|
|
289
337
|
exemplars_enabled=exemplars_enabled,
|
|
290
338
|
)
|
|
291
|
-
|
|
339
|
+
_register_collectors()
|
|
292
340
|
return backend
|
|
293
341
|
|
|
294
342
|
|
|
@@ -4,11 +4,13 @@ import logging
|
|
|
4
4
|
import re
|
|
5
5
|
import time
|
|
6
6
|
|
|
7
|
+
from anyio import get_cancelled_exc_class
|
|
7
8
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
8
9
|
|
|
9
10
|
from ..config import ObservabilitySettings
|
|
10
11
|
from ..logging.held import keep_request_logs
|
|
11
12
|
from ..metrics import MetricsBackend, NoopMetricsBackend
|
|
13
|
+
from ..pools import check_pools
|
|
12
14
|
from ..request_context import get_request_state
|
|
13
15
|
from ..security import (
|
|
14
16
|
SecurityPolicy,
|
|
@@ -62,6 +64,7 @@ class RequestLoggingMiddleware:
|
|
|
62
64
|
await self.app(scope, receive, send)
|
|
63
65
|
return
|
|
64
66
|
|
|
67
|
+
check_pools()
|
|
65
68
|
start = time.perf_counter()
|
|
66
69
|
raw_headers = scope.get("headers", [])
|
|
67
70
|
client_ip, trusted_source = self.ip_resolver.resolve(scope, raw_headers)
|
|
@@ -84,6 +87,8 @@ class RequestLoggingMiddleware:
|
|
|
84
87
|
max_length=self.security_policy.max_body_length,
|
|
85
88
|
)
|
|
86
89
|
had_error = False
|
|
90
|
+
cancelled = False
|
|
91
|
+
response_started = False
|
|
87
92
|
|
|
88
93
|
async def receive_wrapper() -> Message:
|
|
89
94
|
message = await receive()
|
|
@@ -91,8 +96,9 @@ class RequestLoggingMiddleware:
|
|
|
91
96
|
return message
|
|
92
97
|
|
|
93
98
|
async def send_wrapper(message: Message) -> None:
|
|
94
|
-
nonlocal status_code
|
|
99
|
+
nonlocal status_code, response_started
|
|
95
100
|
if message["type"] == "http.response.start":
|
|
101
|
+
response_started = True
|
|
96
102
|
status_code = int(message.get("status", 500))
|
|
97
103
|
resp_headers = message.get("headers", [])
|
|
98
104
|
if self.security_policy.log_response_body:
|
|
@@ -122,6 +128,13 @@ class RequestLoggingMiddleware:
|
|
|
122
128
|
captured_error = exc
|
|
123
129
|
status_code = 500
|
|
124
130
|
raise
|
|
131
|
+
except get_cancelled_exc_class():
|
|
132
|
+
# The client left, the server is shutting down or a timeout fired.
|
|
133
|
+
# Not a server error: without a response, use nginx's 499.
|
|
134
|
+
cancelled = True
|
|
135
|
+
if not response_started:
|
|
136
|
+
status_code = 499
|
|
137
|
+
raise
|
|
125
138
|
finally:
|
|
126
139
|
self._finalize_request(
|
|
127
140
|
scope=scope,
|
|
@@ -134,6 +147,7 @@ class RequestLoggingMiddleware:
|
|
|
134
147
|
response_body_capture=response_body_capture,
|
|
135
148
|
captured_error=captured_error,
|
|
136
149
|
had_error=had_error,
|
|
150
|
+
cancelled=cancelled,
|
|
137
151
|
)
|
|
138
152
|
|
|
139
153
|
def _finalize_request(
|
|
@@ -148,10 +162,13 @@ class RequestLoggingMiddleware:
|
|
|
148
162
|
response_body_capture: _BodyCapture,
|
|
149
163
|
captured_error: Exception | None,
|
|
150
164
|
had_error: bool,
|
|
165
|
+
cancelled: bool,
|
|
151
166
|
) -> None:
|
|
152
167
|
duration_seconds = max(0.0, time.perf_counter() - start)
|
|
153
168
|
if had_error:
|
|
154
169
|
level, message = logging.ERROR, "request.failed"
|
|
170
|
+
elif cancelled:
|
|
171
|
+
level, message = logging.WARNING, "request.cancelled"
|
|
155
172
|
elif status_code >= 500:
|
|
156
173
|
level, message = logging.ERROR, "request.server_error"
|
|
157
174
|
elif status_code >= 400:
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Full pools: requests wait, latency climbs, CPU stays low.
|
|
2
|
+
|
|
3
|
+
Two pools make requests wait without using CPU: AnyIO's thread pool, which runs
|
|
4
|
+
sync endpoints and dependencies, and SQLAlchemy connection pools. Neither AnyIO
|
|
5
|
+
nor SQLAlchemy has a hook for how long one call waited, so this reports when a
|
|
6
|
+
pool is full: a warning at most once a minute per pool, checked as each request
|
|
7
|
+
starts, and gauges on ``/metrics``. Requests still start when every thread and
|
|
8
|
+
connection is stuck, so a deadlock is reported too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import math
|
|
15
|
+
import time
|
|
16
|
+
import weakref
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger("fastapiobserver.pools")
|
|
20
|
+
# ponytail: fixed; make it a setting if once a minute is too noisy or too quiet.
|
|
21
|
+
_WARN_INTERVAL_SECONDS = 60.0
|
|
22
|
+
|
|
23
|
+
_thread_limiter: Any = None # AnyIO's default limiter, captured on the event loop
|
|
24
|
+
_db_pools: weakref.WeakKeyDictionary[Any, str] = weakref.WeakKeyDictionary() # engine: label
|
|
25
|
+
_last_warned: dict[str, float] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def watch_thread_pool() -> None:
|
|
29
|
+
"""Remember AnyIO's default thread limiter. Call it on the event loop."""
|
|
30
|
+
global _thread_limiter
|
|
31
|
+
from anyio.to_thread import current_default_thread_limiter
|
|
32
|
+
|
|
33
|
+
_thread_limiter = current_default_thread_limiter()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def watch_db_pool(engine: Any) -> None:
|
|
37
|
+
"""Watch a sync SQLAlchemy engine's pool, if it has a limit (``QueuePool``)."""
|
|
38
|
+
if callable(getattr(engine.pool, "checkedout", None)):
|
|
39
|
+
_db_pools[engine] = engine.url.render_as_string(hide_password=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def thread_pool_usage() -> tuple[int, float, int] | None:
|
|
43
|
+
"""Busy threads, the limit and calls waiting; None until the app has started."""
|
|
44
|
+
if _thread_limiter is None:
|
|
45
|
+
return None
|
|
46
|
+
stats = _thread_limiter.statistics()
|
|
47
|
+
return stats.borrowed_tokens, stats.total_tokens, stats.tasks_waiting
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def db_pool_usage() -> list[tuple[str, int, float]]:
|
|
51
|
+
"""Each watched engine's label, checked-out connections and limit."""
|
|
52
|
+
return [
|
|
53
|
+
(label, engine.pool.checkedout(), _db_pool_limit(engine.pool))
|
|
54
|
+
for engine, label in list(_db_pools.items())
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_pools() -> None:
|
|
59
|
+
"""Warn about full pools. Called as each request starts."""
|
|
60
|
+
limiter = _thread_limiter
|
|
61
|
+
# Cheap test first; statistics() copies the borrower list.
|
|
62
|
+
if limiter is not None and limiter.borrowed_tokens >= limiter.total_tokens:
|
|
63
|
+
busy, limit, waiting = thread_pool_usage() or (0, 0, 0)
|
|
64
|
+
if waiting:
|
|
65
|
+
_warn(
|
|
66
|
+
"threadpool.saturated",
|
|
67
|
+
"threadpool",
|
|
68
|
+
{"busy": busy, "limit": limit, "waiting": waiting},
|
|
69
|
+
)
|
|
70
|
+
for label, checked_out, limit in db_pool_usage():
|
|
71
|
+
if checked_out >= limit:
|
|
72
|
+
_warn(
|
|
73
|
+
"db.pool_exhausted",
|
|
74
|
+
label,
|
|
75
|
+
{"engine": label, "checked_out": checked_out, "limit": limit},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _db_pool_limit(pool: Any) -> float:
|
|
80
|
+
# max_overflow has no public accessor; without it, or at -1, there's no limit.
|
|
81
|
+
max_overflow = getattr(pool, "_max_overflow", -1)
|
|
82
|
+
return math.inf if max_overflow < 0 else pool.size() + max_overflow
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _warn(message: str, pool: str, event: dict[str, Any]) -> None:
|
|
86
|
+
now = time.monotonic()
|
|
87
|
+
if now - _last_warned.get(pool, -math.inf) < _WARN_INTERVAL_SECONDS:
|
|
88
|
+
return
|
|
89
|
+
_last_warned[pool] = now
|
|
90
|
+
_LOGGER.warning(message, extra={"event": event, "_skip_enrichers": True})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"check_pools",
|
|
95
|
+
"db_pool_usage",
|
|
96
|
+
"thread_pool_usage",
|
|
97
|
+
"watch_db_pool",
|
|
98
|
+
"watch_thread_pool",
|
|
99
|
+
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from typing import Any, Literal
|
|
3
3
|
|
|
4
|
-
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationInfo, field_validator
|
|
5
5
|
from pydantic_settings import BaseSettings
|
|
6
6
|
|
|
7
7
|
from ..utils import EnvLoadable, InternalSettingsBase, parse_csv
|
|
@@ -88,6 +88,7 @@ _SECURITY_POLICY_ENV_MAP = {
|
|
|
88
88
|
"redacted_headers": "OBS_REDACTED_HEADERS",
|
|
89
89
|
"redaction_mode": "OBS_REDACTION_MODE",
|
|
90
90
|
"mask_text": "OBS_MASK_TEXT",
|
|
91
|
+
"hash_key": "OBS_REDACTION_HASH_KEY",
|
|
91
92
|
"log_request_body": "OBS_LOG_REQUEST_BODY",
|
|
92
93
|
"log_response_body": "OBS_LOG_RESPONSE_BODY",
|
|
93
94
|
"max_body_length": "OBS_MAX_BODY_LENGTH",
|
|
@@ -112,6 +113,9 @@ class SecurityPolicy(EnvLoadable, BaseModel):
|
|
|
112
113
|
redacted_headers: tuple[str, ...] = DEFAULT_REDACTED_HEADERS
|
|
113
114
|
redaction_mode: RedactionMode = "mask"
|
|
114
115
|
mask_text: str = "***"
|
|
116
|
+
# HMAC key for the hash mode. Without one, a random key is picked at import,
|
|
117
|
+
# so the same value hashes differently in other processes and after a restart.
|
|
118
|
+
hash_key: SecretStr | None = None
|
|
115
119
|
log_request_body: bool = False
|
|
116
120
|
log_response_body: bool = False
|
|
117
121
|
max_body_length: int = Field(default=256, gt=0)
|
|
@@ -233,6 +237,7 @@ class _SecurityPolicySettings(InternalSettingsBase):
|
|
|
233
237
|
validation_alias="OBS_REDACTION_MODE",
|
|
234
238
|
)
|
|
235
239
|
mask_text: str | None = Field(default=None, validation_alias="OBS_MASK_TEXT")
|
|
240
|
+
hash_key: SecretStr | None = Field(default=None, validation_alias="OBS_REDACTION_HASH_KEY")
|
|
236
241
|
log_request_body: bool | None = Field(
|
|
237
242
|
default=None,
|
|
238
243
|
validation_alias="OBS_LOG_REQUEST_BODY",
|
{fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/redaction.py
RENAMED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import secrets
|
|
3
5
|
from collections.abc import Mapping
|
|
4
6
|
from typing import Any
|
|
5
7
|
|
|
@@ -8,6 +10,7 @@ from .policy import _DROP, SecurityPolicy
|
|
|
8
10
|
|
|
9
11
|
MAX_SANITIZE_DEPTH = 32
|
|
10
12
|
_SCALAR_TYPES = frozenset({str, int, float, bool})
|
|
13
|
+
_PROCESS_KEY = secrets.token_bytes(32) # for policies without a hash_key
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
@functools.lru_cache(maxsize=64)
|
|
@@ -33,8 +36,11 @@ def _redact_value(value: Any, policy: SecurityPolicy) -> Any:
|
|
|
33
36
|
if policy.redaction_mode == "drop":
|
|
34
37
|
return _DROP
|
|
35
38
|
if policy.redaction_mode == "hash":
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
# Keyed: a plain hash of an email or phone number is reversed by
|
|
40
|
+
# hashing candidates until one matches.
|
|
41
|
+
key = policy.hash_key.get_secret_value().encode() if policy.hash_key else _PROCESS_KEY
|
|
42
|
+
digest = hmac.new(key, str(value).encode("utf-8"), hashlib.sha256).hexdigest()
|
|
43
|
+
return f"hmac-sha256:{digest}"
|
|
38
44
|
return policy.mask_text
|
|
39
45
|
|
|
40
46
|
|