fastapi-observer 1.12.0__tar.gz → 1.13.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.
Files changed (117) hide show
  1. {fastapi_observer-1.12.0/src/fastapi_observer.egg-info → fastapi_observer-1.13.0}/PKG-INFO +13 -11
  2. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/README.md +12 -10
  3. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0/src/fastapi_observer.egg-info}/PKG-INFO +13 -11
  4. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapi_observer.egg-info/SOURCES.txt +2 -0
  5. fastapi_observer-1.13.0/src/fastapiobserver/_version.py +1 -0
  6. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/fastapi.py +19 -11
  7. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/metrics/prometheus.py +55 -7
  8. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/request_logging.py +2 -0
  9. fastapi_observer-1.13.0/src/fastapiobserver/pools.py +99 -0
  10. fastapi_observer-1.13.0/tests/test_pools.py +116 -0
  11. fastapi_observer-1.12.0/src/fastapiobserver/_version.py +0 -1
  12. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/LICENSE +0 -0
  13. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/pyproject.toml +0 -0
  14. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/setup.cfg +0 -0
  15. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  16. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  17. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  18. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/__init__.py +0 -0
  19. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/audit/__init__.py +0 -0
  20. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/audit/formatter.py +0 -0
  21. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/audit/providers.py +0 -0
  22. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/audit/verify.py +0 -0
  23. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/config.py +0 -0
  24. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/control_plane.py +0 -0
  25. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/db_tracing.py +0 -0
  26. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  27. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  28. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/__init__.py +0 -0
  29. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  30. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/filters.py +0 -0
  31. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/formatter.py +0 -0
  32. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/held.py +0 -0
  33. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/queueing.py +0 -0
  34. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/setup.py +0 -0
  35. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/state.py +0 -0
  36. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/logging/volume.py +0 -0
  37. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/loguru.py +0 -0
  38. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/loop_watchdog.py +0 -0
  39. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  40. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  41. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  42. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/context.py +0 -0
  43. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/events.py +0 -0
  44. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/headers.py +0 -0
  45. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/ip.py +0 -0
  46. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  47. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/__init__.py +0 -0
  48. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  49. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  50. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/logs.py +0 -0
  51. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/metrics.py +0 -0
  52. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/native.py +0 -0
  53. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/resource.py +0 -0
  54. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/settings.py +0 -0
  55. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/otel/tracing.py +0 -0
  56. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/plugins.py +0 -0
  57. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/profiles.py +0 -0
  58. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/propagation.py +0 -0
  59. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/py.typed +0 -0
  60. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/request_context.py +0 -0
  61. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/security/__init__.py +0 -0
  62. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/security/normalize.py +0 -0
  63. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/security/policy.py +0 -0
  64. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/security/proxies.py +0 -0
  65. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/security/redaction.py +0 -0
  66. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  67. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  68. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  69. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/factory.py +0 -0
  70. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  71. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  72. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  73. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  74. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  75. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/registry.py +0 -0
  76. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/sinks/stats.py +0 -0
  77. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/src/fastapiobserver/utils.py +0 -0
  78. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_audit_chain.py +0 -0
  79. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_body_media_allowlist.py +0 -0
  80. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_config_settings.py +0 -0
  81. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_db_tracing.py +0 -0
  82. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_dx_parity.py +0 -0
  83. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_excluded_url_precedence.py +0 -0
  84. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_fastapi_install.py +0 -0
  85. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_fork_queue_listener.py +0 -0
  86. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_graphql_integrations.py +0 -0
  87. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_lifespan_teardown.py +0 -0
  88. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_log_queue_controls.py +0 -0
  89. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_logging_schema_contract.py +0 -0
  90. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_logtail_dlq.py +0 -0
  91. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_loguru_bridge.py +0 -0
  92. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_loop_watchdog.py +0 -0
  93. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_metrics_contracts.py +0 -0
  94. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_metrics_optional_dependency.py +0 -0
  95. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_middleware.py +0 -0
  96. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_native_telemetry.py +0 -0
  97. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_negotiate_content_type.py +0 -0
  98. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otel_integration.py +0 -0
  99. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otel_lifecycle.py +0 -0
  100. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otel_log_correlation.py +0 -0
  101. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otel_logs_from_env.py +0 -0
  102. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otel_logs_pipeline.py +0 -0
  103. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otel_metrics_from_env.py +0 -0
  104. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_otlp_export_integration.py +0 -0
  105. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_plugin_isolation.py +0 -0
  106. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_profiles.py +0 -0
  107. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_propagation.py +0 -0
  108. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_redaction_presets.py +0 -0
  109. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_request_insights.py +0 -0
  110. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_route_template_cardinality.py +0 -0
  111. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_runtime_control_auth.py +0 -0
  112. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_security_audit_fixes.py +0 -0
  113. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_security_redaction.py +0 -0
  114. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_sink_circuit_breaker.py +0 -0
  115. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_sinks.py +0 -0
  116. {fastapi_observer-1.12.0 → fastapi_observer-1.13.0}/tests/test_trust_boundary.py +0 -0
  117. {fastapi_observer-1.12.0 → fastapi_observer-1.13.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.12.0
3
+ Version: 1.13.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** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
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** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
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.12.0
3
+ Version: 1.13.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** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
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
 
@@ -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.13.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 and otel_settings and otel_settings.enabled:
114
- _setup_database_tracing(db_engine, db_commenter_enabled, db_commenter_options)
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:
@@ -262,8 +267,17 @@ def _setup_metrics_backend(
262
267
  return metrics_backend
263
268
 
264
269
 
270
+ def _as_engine_list(db_engine: Any | Sequence[Any]) -> list[Any]:
271
+ if isinstance(db_engine, (str, bytes, bytearray)):
272
+ raise TypeError(
273
+ f"db_engine must be an SQLAlchemy Engine or sequence of Engines, "
274
+ f"not {type(db_engine).__name__}"
275
+ )
276
+ return list(db_engine) if isinstance(db_engine, Sequence) else [db_engine]
277
+
278
+
265
279
  def _setup_database_tracing(
266
- db_engine: Any | Sequence[Any],
280
+ engines: list[Any],
267
281
  db_commenter_enabled: bool,
268
282
  db_commenter_options: dict[str, bool] | None,
269
283
  ) -> None:
@@ -272,13 +286,6 @@ def _setup_database_tracing(
272
286
  instrument_sqlalchemy_async,
273
287
  )
274
288
 
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
289
  for engine in engines:
283
290
  if hasattr(engine, "sync_engine"):
284
291
  instrument_sqlalchemy_async(
@@ -400,6 +407,7 @@ async def observability_lifespan(app: FastAPI) -> typing.AsyncGenerator[typing.A
400
407
  watchdog = None
401
408
  if state:
402
409
  _auto_discover_excluded_routes(app, state.settings)
410
+ watch_thread_pool()
403
411
  if state.settings.loop_block_threshold_ms:
404
412
  watchdog = EventLoopWatchdog(state.settings.loop_block_threshold_ms / 1000)
405
413
  watchdog.start()
@@ -186,17 +186,65 @@ class _LogQueueMetricsCollector:
186
186
  yield counter
187
187
 
188
188
 
189
- def _register_log_queue_metrics_collector() -> None:
190
- # Per-worker queue stats would be meaningless once aggregated across processes.
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
- try:
197
- _import_prometheus_client().REGISTRY.register(_LogQueueMetricsCollector())
198
- except ValueError:
199
- pass # Already registered by an earlier backend build in this process.
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
- _register_log_queue_metrics_collector()
339
+ _register_collectors()
292
340
  return backend
293
341
 
294
342
 
@@ -9,6 +9,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
9
9
  from ..config import ObservabilitySettings
10
10
  from ..logging.held import keep_request_logs
11
11
  from ..metrics import MetricsBackend, NoopMetricsBackend
12
+ from ..pools import check_pools
12
13
  from ..request_context import get_request_state
13
14
  from ..security import (
14
15
  SecurityPolicy,
@@ -62,6 +63,7 @@ class RequestLoggingMiddleware:
62
63
  await self.app(scope, receive, send)
63
64
  return
64
65
 
66
+ check_pools()
65
67
  start = time.perf_counter()
66
68
  raw_headers = scope.get("headers", [])
67
69
  client_ip, trusted_source = self.ip_resolver.resolve(scope, raw_headers)
@@ -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
+ ]
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ from types import SimpleNamespace
7
+
8
+ import anyio.to_thread
9
+ from fastapi import FastAPI
10
+ from fastapi.testclient import TestClient
11
+ import httpx2
12
+ import pytest
13
+
14
+ from fastapiobserver import ObservabilitySettings, install_observability
15
+ from fastapiobserver import pools
16
+
17
+ prometheus_client = pytest.importorskip("prometheus_client")
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def _fresh_warnings() -> None:
22
+ pools._last_warned.clear()
23
+
24
+
25
+ def _warnings(caplog: pytest.LogCaptureFixture, message: str) -> list[logging.LogRecord]:
26
+ return [r for r in caplog.records if r.getMessage() == message]
27
+
28
+
29
+ def test_requests_waiting_for_a_thread_are_reported(caplog: pytest.LogCaptureFixture) -> None:
30
+ app = FastAPI()
31
+ release = threading.Event()
32
+
33
+ @app.get("/sync")
34
+ def sync_endpoint() -> dict[str, str]: # runs in AnyIO's thread pool
35
+ release.wait(5)
36
+ return {}
37
+
38
+ @app.get("/ping")
39
+ async def ping() -> dict[str, str]:
40
+ return {}
41
+
42
+ install_observability(app, ObservabilitySettings(), metrics_enabled=True)
43
+
44
+ async def main() -> str:
45
+ async with app.router.lifespan_context(app):
46
+ anyio.to_thread.current_default_thread_limiter().total_tokens = 1
47
+ transport = httpx2.ASGITransport(app=app)
48
+ async with httpx2.AsyncClient(transport=transport, base_url="http://test") as client:
49
+ blocked = [asyncio.create_task(client.get("/sync")) for _ in range(2)]
50
+ await asyncio.sleep(0.1) # one call holds the only thread, one waits
51
+ await client.get("/ping") # starts while the pool is full
52
+ await client.get("/ping") # within a minute: no second warning
53
+ metrics = prometheus_client.generate_latest().decode()
54
+ release.set()
55
+ await asyncio.gather(*blocked)
56
+ return metrics
57
+
58
+ with caplog.at_level(logging.WARNING, logger="fastapiobserver.pools"):
59
+ metrics = asyncio.run(main())
60
+
61
+ [warning] = _warnings(caplog, "threadpool.saturated")
62
+ assert warning.event == {"busy": 1, "limit": 1, "waiting": 1} # type: ignore[attr-defined]
63
+ assert "fastapiobserver_threadpool_busy 1.0" in metrics
64
+ assert "fastapiobserver_threadpool_limit 1.0" in metrics
65
+ assert "fastapiobserver_threadpool_waiting 1.0" in metrics
66
+
67
+
68
+ class _QueuePool:
69
+ """The part of SQLAlchemy's QueuePool that fastapi-observer reads."""
70
+
71
+ def __init__(self, size: int, max_overflow: int) -> None:
72
+ self._size = size
73
+ self._max_overflow = max_overflow
74
+ self.checked_out = 0
75
+
76
+ def size(self) -> int:
77
+ return self._size
78
+
79
+ def checkedout(self) -> int:
80
+ return self.checked_out
81
+
82
+
83
+ class _Engine:
84
+ """A SQLAlchemy engine: weakly referenceable, with a pool and a URL."""
85
+
86
+ def __init__(self, pool: _QueuePool) -> None:
87
+ self.pool = pool
88
+ self.url = SimpleNamespace(render_as_string=lambda hide_password: "postgresql://app@db/orders")
89
+
90
+
91
+ def test_a_full_database_pool_is_reported(caplog: pytest.LogCaptureFixture) -> None:
92
+ pool = _QueuePool(size=2, max_overflow=1)
93
+ engine = _Engine(pool)
94
+ app = FastAPI()
95
+
96
+ @app.get("/ping")
97
+ async def ping() -> dict[str, str]:
98
+ return {}
99
+
100
+ install_observability(app, ObservabilitySettings(), metrics_enabled=True, db_engine=engine)
101
+ client = TestClient(app)
102
+ with caplog.at_level(logging.WARNING, logger="fastapiobserver.pools"):
103
+ client.get("/ping") # 0 of 3 checked out
104
+ pool.checked_out = 3
105
+ client.get("/ping")
106
+ client.get("/ping") # within a minute: no second warning
107
+
108
+ [warning] = _warnings(caplog, "db.pool_exhausted")
109
+ assert warning.event == { # type: ignore[attr-defined]
110
+ "engine": "postgresql://app@db/orders",
111
+ "checked_out": 3,
112
+ "limit": 3,
113
+ }
114
+ metrics = prometheus_client.generate_latest().decode()
115
+ assert 'fastapiobserver_db_pool_checked_out{engine="postgresql://app@db/orders"} 3.0' in metrics
116
+ assert 'fastapiobserver_db_pool_limit{engine="postgresql://app@db/orders"} 3.0' in metrics
@@ -1 +0,0 @@
1
- __version__ = "1.12.0"