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.
Files changed (117) hide show
  1. {fastapi_observer-1.12.0/src/fastapi_observer.egg-info → fastapi_observer-1.14.0}/PKG-INFO +13 -11
  2. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/README.md +12 -10
  3. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0/src/fastapi_observer.egg-info}/PKG-INFO +13 -11
  4. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/SOURCES.txt +2 -0
  5. fastapi_observer-1.14.0/src/fastapiobserver/_version.py +1 -0
  6. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/fastapi.py +34 -11
  7. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/formatter.py +13 -15
  8. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/setup.py +8 -13
  9. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/prometheus.py +55 -7
  10. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/request_logging.py +18 -1
  11. fastapi_observer-1.14.0/src/fastapiobserver/pools.py +99 -0
  12. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/policy.py +6 -1
  13. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/redaction.py +8 -2
  14. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_fork_queue_listener.py +37 -4
  15. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_logging_schema_contract.py +15 -28
  16. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_middleware.py +38 -0
  17. fastapi_observer-1.14.0/tests/test_pools.py +116 -0
  18. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_redaction_presets.py +21 -3
  19. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_security_audit_fixes.py +2 -2
  20. fastapi_observer-1.12.0/src/fastapiobserver/_version.py +0 -1
  21. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/LICENSE +0 -0
  22. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/pyproject.toml +0 -0
  23. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/setup.cfg +0 -0
  24. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  25. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  26. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  27. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/__init__.py +0 -0
  28. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/__init__.py +0 -0
  29. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/formatter.py +0 -0
  30. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/providers.py +0 -0
  31. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/verify.py +0 -0
  32. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/config.py +0 -0
  33. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/control_plane.py +0 -0
  34. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/db_tracing.py +0 -0
  35. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  36. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  37. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/__init__.py +0 -0
  38. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  39. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/filters.py +0 -0
  40. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/held.py +0 -0
  41. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/queueing.py +0 -0
  42. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/state.py +0 -0
  43. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/volume.py +0 -0
  44. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loguru.py +0 -0
  45. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loop_watchdog.py +0 -0
  46. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  47. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  48. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  49. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/context.py +0 -0
  50. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/events.py +0 -0
  51. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/headers.py +0 -0
  52. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/ip.py +0 -0
  53. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  54. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/__init__.py +0 -0
  55. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  56. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  57. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/logs.py +0 -0
  58. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/metrics.py +0 -0
  59. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/native.py +0 -0
  60. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/resource.py +0 -0
  61. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/settings.py +0 -0
  62. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/tracing.py +0 -0
  63. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/plugins.py +0 -0
  64. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/profiles.py +0 -0
  65. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/propagation.py +0 -0
  66. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/py.typed +0 -0
  67. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/request_context.py +0 -0
  68. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/__init__.py +0 -0
  69. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/normalize.py +0 -0
  70. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/proxies.py +0 -0
  71. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  72. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  73. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  74. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/factory.py +0 -0
  75. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  76. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  77. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  78. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  79. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  80. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/registry.py +0 -0
  81. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/stats.py +0 -0
  82. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/src/fastapiobserver/utils.py +0 -0
  83. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_audit_chain.py +0 -0
  84. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_body_media_allowlist.py +0 -0
  85. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_config_settings.py +0 -0
  86. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_db_tracing.py +0 -0
  87. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_dx_parity.py +0 -0
  88. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_excluded_url_precedence.py +0 -0
  89. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_fastapi_install.py +0 -0
  90. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_graphql_integrations.py +0 -0
  91. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_lifespan_teardown.py +0 -0
  92. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_log_queue_controls.py +0 -0
  93. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_logtail_dlq.py +0 -0
  94. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_loguru_bridge.py +0 -0
  95. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_loop_watchdog.py +0 -0
  96. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_metrics_contracts.py +0 -0
  97. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_metrics_optional_dependency.py +0 -0
  98. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_native_telemetry.py +0 -0
  99. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_negotiate_content_type.py +0 -0
  100. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_integration.py +0 -0
  101. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_lifecycle.py +0 -0
  102. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_log_correlation.py +0 -0
  103. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_from_env.py +0 -0
  104. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_pipeline.py +0 -0
  105. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otel_metrics_from_env.py +0 -0
  106. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_otlp_export_integration.py +0 -0
  107. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_plugin_isolation.py +0 -0
  108. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_profiles.py +0 -0
  109. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_propagation.py +0 -0
  110. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_request_insights.py +0 -0
  111. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_route_template_cardinality.py +0 -0
  112. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_runtime_control_auth.py +0 -0
  113. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_security_redaction.py +0 -0
  114. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_sink_circuit_breaker.py +0 -0
  115. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_sinks.py +0 -0
  116. {fastapi_observer-1.12.0 → fastapi_observer-1.14.0}/tests/test_trust_boundary.py +0 -0
  117. {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.12.0
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** 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.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** 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.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 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:
@@ -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
- db_engine: Any | Sequence[Any],
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()
@@ -3,8 +3,9 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import json
5
5
  import logging
6
- import re
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, _exc_tb = exc_info
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, stacktrace)
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, stacktrace: str) -> str:
162
- """Hash stack trace after stripping out environment-specific noise."""
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
- # Strip hexadecimal memory addresses
167
- sanitized = re.sub(r"0x[0-9a-fA-F]+", "0x<ptr>", stacktrace)
168
- # Strip exact line numbers to survive minor file refactoring
169
- sanitized = re.sub(r"line \d+", "line <N>", sanitized)
170
-
171
- # Combine error type and sanitized trace to ensure distinct groupings
172
- payload = f"{error_type}:{sanitized}"
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
- # The inherited queue's Condition lock might be held by the parent
293
- # or left in an inconsistent state across the fork. To reliably resume
294
- # processing, we must create a fresh queue. We explicitly drain and
295
- # DROP inherited records because the parent process's listener thread
296
- # is already processing those same records in the parent process.
297
- # Re-queuing them here causes deterministic duplicate logs.
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
@@ -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
 
@@ -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",
@@ -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
- digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()
37
- return f"sha256:{digest}"
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