fastapi-observer 1.13.0__tar.gz → 1.15.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.13.0/src/fastapi_observer.egg-info → fastapi_observer-1.15.0}/PKG-INFO +2 -2
  2. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/README.md +1 -1
  3. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0/src/fastapi_observer.egg-info}/PKG-INFO +2 -2
  4. fastapi_observer-1.15.0/src/fastapiobserver/_version.py +1 -0
  5. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/fastapi.py +24 -26
  6. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/__init__.py +2 -0
  7. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/filters.py +2 -0
  8. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/formatter.py +13 -15
  9. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/queueing.py +1 -0
  10. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/setup.py +36 -13
  11. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/events.py +17 -10
  12. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/request_logging.py +27 -5
  13. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/lifecycle.py +14 -21
  14. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/logs.py +17 -3
  15. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/metrics.py +2 -2
  16. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/settings.py +5 -3
  17. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/tracing.py +2 -3
  18. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/policy.py +6 -1
  19. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/redaction.py +37 -3
  20. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_fastapi_install.py +43 -4
  21. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_fork_queue_listener.py +37 -4
  22. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_logging_schema_contract.py +15 -28
  23. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_middleware.py +82 -3
  24. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_integration.py +12 -0
  25. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_lifecycle.py +10 -18
  26. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_logs_pipeline.py +22 -0
  27. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_redaction_presets.py +21 -3
  28. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_route_template_cardinality.py +44 -9
  29. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_security_audit_fixes.py +2 -2
  30. fastapi_observer-1.13.0/src/fastapiobserver/_version.py +0 -1
  31. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/LICENSE +0 -0
  32. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/pyproject.toml +0 -0
  33. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/setup.cfg +0 -0
  34. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
  35. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  36. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  37. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  38. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/__init__.py +0 -0
  39. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/__init__.py +0 -0
  40. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/formatter.py +0 -0
  41. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/providers.py +0 -0
  42. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/verify.py +0 -0
  43. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/config.py +0 -0
  44. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/control_plane.py +0 -0
  45. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/db_tracing.py +0 -0
  46. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  47. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  48. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  49. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/held.py +0 -0
  50. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/state.py +0 -0
  51. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/volume.py +0 -0
  52. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/loguru.py +0 -0
  53. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/loop_watchdog.py +0 -0
  54. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  55. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
  56. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  57. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  58. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/context.py +0 -0
  59. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/headers.py +0 -0
  60. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/ip.py +0 -0
  61. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  62. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/__init__.py +0 -0
  63. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  64. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/native.py +0 -0
  65. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/resource.py +0 -0
  66. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/plugins.py +0 -0
  67. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/pools.py +0 -0
  68. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/profiles.py +0 -0
  69. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/propagation.py +0 -0
  70. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/py.typed +0 -0
  71. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/request_context.py +0 -0
  72. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/__init__.py +0 -0
  73. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/normalize.py +0 -0
  74. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/proxies.py +0 -0
  75. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  76. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  77. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  78. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/factory.py +0 -0
  79. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  80. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  81. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  82. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  83. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  84. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/registry.py +0 -0
  85. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/stats.py +0 -0
  86. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/utils.py +0 -0
  87. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_audit_chain.py +0 -0
  88. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_body_media_allowlist.py +0 -0
  89. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_config_settings.py +0 -0
  90. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_db_tracing.py +0 -0
  91. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_dx_parity.py +0 -0
  92. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_excluded_url_precedence.py +0 -0
  93. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_graphql_integrations.py +0 -0
  94. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_lifespan_teardown.py +0 -0
  95. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_log_queue_controls.py +0 -0
  96. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_logtail_dlq.py +0 -0
  97. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_loguru_bridge.py +0 -0
  98. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_loop_watchdog.py +0 -0
  99. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_metrics_contracts.py +0 -0
  100. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_metrics_optional_dependency.py +0 -0
  101. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_native_telemetry.py +0 -0
  102. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_negotiate_content_type.py +0 -0
  103. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_log_correlation.py +0 -0
  104. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_logs_from_env.py +0 -0
  105. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_metrics_from_env.py +0 -0
  106. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otlp_export_integration.py +0 -0
  107. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_plugin_isolation.py +0 -0
  108. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_pools.py +0 -0
  109. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_profiles.py +0 -0
  110. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_propagation.py +0 -0
  111. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_request_insights.py +0 -0
  112. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_runtime_control_auth.py +0 -0
  113. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_security_redaction.py +0 -0
  114. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_sink_circuit_breaker.py +0 -0
  115. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_sinks.py +0 -0
  116. {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_trust_boundary.py +0 -0
  117. {fastapi_observer-1.13.0 → fastapi_observer-1.15.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.13.0
3
+ Version: 1.15.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
@@ -149,7 +149,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
149
149
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
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
- - **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.
152
+ - **Prometheus metrics** labelled by route template (`/orders/{order_id}`). Requests no route matches, such as scanners, CORS preflights and requests an auth middleware rejects, share one `<unmatched>` label, and made-up methods share `_OTHER`, so neither IDs nor anonymous clients explode cardinality.
153
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
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.
@@ -73,7 +73,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
73
73
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
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
- - **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.
76
+ - **Prometheus metrics** labelled by route template (`/orders/{order_id}`). Requests no route matches, such as scanners, CORS preflights and requests an auth middleware rejects, share one `<unmatched>` label, and made-up methods share `_OTHER`, so neither IDs nor anonymous clients explode cardinality.
77
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
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 1.13.0
3
+ Version: 1.15.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
@@ -149,7 +149,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
149
149
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
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
- - **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.
152
+ - **Prometheus metrics** labelled by route template (`/orders/{order_id}`). Requests no route matches, such as scanners, CORS preflights and requests an auth middleware rejects, share one `<unmatched>` label, and made-up methods share `_OTHER`, so neither IDs nor anonymous clients explode cardinality.
153
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
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.
@@ -0,0 +1 @@
1
+ __version__ = "1.15.0"
@@ -15,10 +15,9 @@ from fastapi import FastAPI
15
15
 
16
16
  from .config import ObservabilitySettings
17
17
  from .control_plane import RuntimeControlSettings, mount_control_plane
18
- from .logging import setup_logging, shutdown_logging
18
+ from .logging import flush_logging, setup_logging
19
19
  from .loop_watchdog import EventLoopWatchdog
20
20
  from .profiles import apply_profile_context
21
- from .db_tracing import uninstrument_sqlalchemy
22
21
  from .metrics import (
23
22
  MetricsBackend,
24
23
  build_metrics_backend,
@@ -47,7 +46,6 @@ _ROUTE_PARAM_PATTERN = re.compile(r"\{[^{}]+\}")
47
46
  @dataclass
48
47
  class ObservabilityRuntimeState:
49
48
  settings: ObservabilitySettings
50
- teardown_done: bool = False
51
49
  excluded_urls: frozenset[str] = frozenset()
52
50
 
53
51
 
@@ -86,11 +84,7 @@ def install_observability(
86
84
  otel_metrics_settings = otel_metrics_settings or OTelMetricsSettings.from_env()
87
85
  runtime_control_settings = runtime_control_settings or RuntimeControlSettings.from_env()
88
86
 
89
- # Legacy fallback guard
90
- app.state._observability_teardown_done = False
91
-
92
- # Mark a fresh install cycle so shutdown executes once per lifecycle.
93
- # Ensure settings are stored on state for lifespan to access
87
+ # Settings for the lifespan to read.
94
88
  app.state._observability_state = ObservabilityRuntimeState(settings=settings)
95
89
 
96
90
  # --- 1 & 2. Structured logging + OTLP Logs ---
@@ -148,6 +142,21 @@ def install_observability(
148
142
  if runtime_control_settings and runtime_control_settings.enabled:
149
143
  mount_control_plane(app, runtime_control_settings, trusted_proxy_policy)
150
144
 
145
+ if security_policy.redaction_mode == "hash" and security_policy.hash_key is None:
146
+ _LOGGER.warning(
147
+ "observability.redaction.hash_key_missing",
148
+ extra={
149
+ "event": {
150
+ "message": (
151
+ "Hashed values use a random key picked at startup, so they differ "
152
+ "between processes and after a restart. Set OBS_REDACTION_HASH_KEY "
153
+ "to keep them stable."
154
+ ),
155
+ },
156
+ "_skip_enrichers": True,
157
+ },
158
+ )
159
+
151
160
  # --- 9. One line with the settings that took effect ---
152
161
  _LOGGER.info(
153
162
  "observability.configured",
@@ -394,7 +403,7 @@ def _build_exclude_path_variants(path: str) -> set[str]:
394
403
 
395
404
  @asynccontextmanager
396
405
  async def observability_lifespan(app: FastAPI) -> typing.AsyncGenerator[typing.Any, None]:
397
- """A native FastAPI lifespan context manager for graceful observability teardown.
406
+ """A FastAPI lifespan context manager that writes out queued logs when the app stops.
398
407
 
399
408
  Developers can yield from this manager directly in their app's lifespan:
400
409
 
@@ -432,9 +441,8 @@ def _register_logging_shutdown_hook(app: FastAPI) -> None:
432
441
  ) -> typing.AsyncGenerator[typing.Any, None]:
433
442
  async with observability_lifespan(app_inst):
434
443
  if original_lifespan:
435
- # If the user is explicitly using observability_lifespan inside their
436
- # original_lifespan, this will safely no-op during cleanup because
437
- # _teardown_observability is idempotent.
444
+ # If the user also enters observability_lifespan in their own
445
+ # lifespan, the queue is just flushed twice.
438
446
  async with original_lifespan(app_inst) as state:
439
447
  yield state
440
448
  else:
@@ -445,19 +453,9 @@ def _register_logging_shutdown_hook(app: FastAPI) -> None:
445
453
 
446
454
 
447
455
  def _teardown_observability(app: FastAPI) -> None:
448
- state = _get_runtime_state(app)
449
- if state:
450
- if state.teardown_done:
451
- return
452
- elif getattr(app.state, "_observability_teardown_done", False):
453
- return
454
-
455
- shutdown_logging()
456
- uninstrument_sqlalchemy()
457
-
458
- if state:
459
- state.teardown_done = True
460
- else:
461
- app.state._observability_teardown_done = True
456
+ # Logging and SQLAlchemy instrumentation are shared by every app in the
457
+ # process, and this app may start again, as a reused TestClient does. So
458
+ # write out what's queued and leave them running; atexit stops logging.
459
+ flush_logging()
462
460
 
463
461
  __all__ = ["install_observability", "observability_lifespan"]
@@ -15,6 +15,7 @@ from .queueing import (
15
15
  get_log_queue_stats,
16
16
  )
17
17
  from .setup import (
18
+ flush_logging,
18
19
  get_log_level,
19
20
  get_managed_output_handlers,
20
21
  set_log_level,
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "SinkCircuitBreakerSnapshot",
36
37
  "StructuredJsonFormatter",
37
38
  "TraceContextFilter",
39
+ "flush_logging",
38
40
  "get_log_bytes_by_route",
39
41
  "get_log_level",
40
42
  "get_log_queue_stats",
@@ -43,6 +43,8 @@ class TraceContextFilter(logging.Filter):
43
43
  if span_context and span_context.is_valid:
44
44
  trace_id = f"{span_context.trace_id:032x}"
45
45
  span_id = f"{span_context.span_id:016x}"
46
+ # For OTLP export, which runs on another thread.
47
+ record._otel_span_context = span_context
46
48
  except Exception:
47
49
  _LOGGER.debug(
48
50
  "trace_context_filter.otel_lookup_failed",
@@ -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]
@@ -168,6 +168,7 @@ class OverflowPolicyQueueHandler(logging.handlers.QueueHandler):
168
168
  log_queue = cast(queue.Queue[logging.LogRecord], self.queue)
169
169
  try:
170
170
  log_queue.get_nowait()
171
+ log_queue.task_done() # dropped counts as done, or flush_logging() waits for it
171
172
  self.telemetry.record_drop_oldest()
172
173
  except queue.Empty:
173
174
  pass
@@ -4,6 +4,8 @@ import atexit
4
4
  import logging
5
5
  import logging.handlers
6
6
  import queue
7
+ import threading
8
+ import time
7
9
  from typing import Literal, cast
8
10
 
9
11
  from ..config import ObservabilitySettings
@@ -43,6 +45,33 @@ def shutdown_logging() -> None:
43
45
  )
44
46
 
45
47
 
48
+ def flush_logging(timeout_seconds: float = 5.0) -> None:
49
+ """Wait until queued records are written, then flush the outputs.
50
+
51
+ Logging keeps running, for other apps in the process and for this one if it
52
+ starts again. ``atexit`` stops it when the process exits.
53
+ """
54
+ import fastapiobserver.logging.state as state
55
+
56
+ listener = state._QUEUE_LISTENER
57
+ if listener is None:
58
+ return
59
+ log_queue = cast(queue.Queue[logging.LogRecord], listener.queue)
60
+ deadline = time.monotonic() + timeout_seconds
61
+ # The listener marks each record done once its handlers have run.
62
+ while log_queue.unfinished_tasks and time.monotonic() < deadline:
63
+ time.sleep(0.005)
64
+ for handler in list(state._MANAGED_OUTPUT_HANDLERS):
65
+ try:
66
+ handler.flush()
67
+ except Exception:
68
+ _LOGGER.debug(
69
+ "logging.output_handler.flush_failed",
70
+ exc_info=True,
71
+ extra={"_skip_enrichers": True},
72
+ )
73
+
74
+
46
75
  def _teardown_handlers_locked(suppress_errors: bool = True) -> None:
47
76
  """Safely stop listeners, unregister managed handlers, and close outputs."""
48
77
  import fastapiobserver.logging.state as state
@@ -289,22 +318,16 @@ def _restart_queue_listener_in_child() -> None:
289
318
 
290
319
  listener = state._QUEUE_LISTENER
291
320
  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.
321
+ # Another thread of the parent may have held any lock at the moment of
322
+ # the fork, and no thread in the child will release it. So don't touch
323
+ # the inherited queue, not even to drain it: its records are the
324
+ # parent's to write. Give the child fresh locks and a fresh queue.
325
+ _LOG_QUEUE_TELEMETRY._lock = threading.Lock()
326
+ for breaker in state._SINK_CIRCUIT_BREAKERS:
327
+ breaker._lock = threading.Lock()
298
328
  old_queue = cast(queue.Queue[logging.LogRecord], listener.queue)
299
329
  new_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=old_queue.maxsize)
300
330
 
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
331
  # Re-attach the new queue to the OverflowPolicyQueueHandler if present
309
332
  for handler in state._MANAGED_HANDLERS:
310
333
  from .queueing import OverflowPolicyQueueHandler
@@ -4,10 +4,12 @@ import re
4
4
  from collections.abc import Mapping
5
5
  from typing import Any
6
6
  from starlette.exceptions import HTTPException as StarletteHTTPException
7
+ from starlette.routing import Match
7
8
  from starlette.types import Scope
8
9
 
9
10
  from ..request_context import UNMATCHED_ROUTE, route_template
10
11
  from ..security import SecurityPolicy, sanitize_event
12
+ from ..security.redaction import redact_body
11
13
 
12
14
  _CREDENTIAL_RE = re.compile(r"://[^:]+:[^@]+@")
13
15
 
@@ -46,10 +48,11 @@ class _RequestEventBuilder:
46
48
  "url.path": path,
47
49
  "http.response.status_code": status_code,
48
50
  }
51
+ # Redaction below matches keys, and a body is one string value.
49
52
  if request_body is not None:
50
- event["request_body"] = request_body
53
+ event["request_body"] = redact_body(request_body, self.policy)
51
54
  if response_body is not None:
52
- event["response_body"] = response_body
55
+ event["response_body"] = redact_body(response_body, self.policy)
53
56
  if exception is not None:
54
57
  event["exception_class"] = exception.__class__.__name__
55
58
  event["exception_message"] = _sanitize_exception_message(str(exception))
@@ -73,20 +76,24 @@ def _classify_error(status_code: int, error: Exception | None) -> str:
73
76
  return "ok"
74
77
 
75
78
 
76
- def _extract_route_template(scope: Scope, raw_path: str, status_code: int) -> str:
79
+ def _extract_route_template(scope: Scope) -> str:
77
80
  """Return the metrics ``path`` label: the matched route template if known.
78
81
 
79
- After routing, ``scope["route"]`` holds the matched route, whose ``.path``
80
- is the template (e.g. ``/users/{user_id}``). Without one, a 404 means no
81
- route matched, mostly scanners, so those share ``UNMATCHED_ROUTE`` instead
82
- of adding a time series per URL. Other requests without a template keep
83
- *raw_path*: FastAPI doesn't record plain Starlette routes, such as ones
84
- added with ``app.add_route()``, in the scope.
82
+ After routing, the scope holds the matched route's template (e.g.
83
+ ``/users/{user_id}``). FastAPI doesn't record plain Starlette routes, such
84
+ as ones added with ``app.add_route()``, so those are matched here. Anything
85
+ else reached no route, such as scanners, CORS preflights or requests an auth
86
+ middleware rejected, and shares ``UNMATCHED_ROUTE`` instead of adding a time
87
+ series per URL.
85
88
  """
86
89
  template = route_template(scope)
87
90
  if template:
88
91
  return template
89
- return UNMATCHED_ROUTE if status_code == 404 else raw_path
92
+ for route in getattr(scope.get("app"), "routes", ()):
93
+ path = getattr(route, "path", None)
94
+ if path and route.matches(scope)[0] is not Match.NONE:
95
+ return str(path)
96
+ return UNMATCHED_ROUTE
90
97
 
91
98
  __all__ = [
92
99
  "UNMATCHED_ROUTE",
@@ -4,6 +4,7 @@ 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
@@ -31,6 +32,11 @@ _MAX_STATEMENT_LENGTH = 1000
31
32
  # Quoted strings and numbers, so values written into SQL text never reach a log line.
32
33
  # ponytail: misses dollar-quoted and E'' strings; parse SQL if those ever show up.
33
34
  _SQL_LITERALS = re.compile(r"'(?:[^']|'')*'|\b\d+(?:\.\d+)?\b")
35
+ # Metric labels take the method only from this set, as OpenTelemetry does, so a
36
+ # client can't add a time series per made-up method.
37
+ _HTTP_METHODS = frozenset(
38
+ {"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"}
39
+ )
34
40
 
35
41
 
36
42
  class RequestLoggingMiddleware:
@@ -86,6 +92,8 @@ class RequestLoggingMiddleware:
86
92
  max_length=self.security_policy.max_body_length,
87
93
  )
88
94
  had_error = False
95
+ cancelled = False
96
+ response_started = False
89
97
 
90
98
  async def receive_wrapper() -> Message:
91
99
  message = await receive()
@@ -93,8 +101,9 @@ class RequestLoggingMiddleware:
93
101
  return message
94
102
 
95
103
  async def send_wrapper(message: Message) -> None:
96
- nonlocal status_code
104
+ nonlocal status_code, response_started
97
105
  if message["type"] == "http.response.start":
106
+ response_started = True
98
107
  status_code = int(message.get("status", 500))
99
108
  resp_headers = message.get("headers", [])
100
109
  if self.security_policy.log_response_body:
@@ -124,6 +133,13 @@ class RequestLoggingMiddleware:
124
133
  captured_error = exc
125
134
  status_code = 500
126
135
  raise
136
+ except get_cancelled_exc_class():
137
+ # The client left, the server is shutting down or a timeout fired.
138
+ # Not a server error: without a response, use nginx's 499.
139
+ cancelled = True
140
+ if not response_started:
141
+ status_code = 499
142
+ raise
127
143
  finally:
128
144
  self._finalize_request(
129
145
  scope=scope,
@@ -136,6 +152,7 @@ class RequestLoggingMiddleware:
136
152
  response_body_capture=response_body_capture,
137
153
  captured_error=captured_error,
138
154
  had_error=had_error,
155
+ cancelled=cancelled,
139
156
  )
140
157
 
141
158
  def _finalize_request(
@@ -150,10 +167,13 @@ class RequestLoggingMiddleware:
150
167
  response_body_capture: _BodyCapture,
151
168
  captured_error: Exception | None,
152
169
  had_error: bool,
170
+ cancelled: bool,
153
171
  ) -> None:
154
172
  duration_seconds = max(0.0, time.perf_counter() - start)
155
173
  if had_error:
156
174
  level, message = logging.ERROR, "request.failed"
175
+ elif cancelled:
176
+ level, message = logging.WARNING, "request.cancelled"
157
177
  elif status_code >= 500:
158
178
  level, message = logging.ERROR, "request.server_error"
159
179
  elif status_code >= 400:
@@ -187,13 +207,15 @@ class RequestLoggingMiddleware:
187
207
  extra={"event": safe_event},
188
208
  )
189
209
 
190
- # Prefer route template for bounded cardinality
191
- route_template = _extract_route_template(scope, path, status_code)
210
+ route_template = _extract_route_template(scope)
211
+ metric_method = method if method in _HTTP_METHODS else "_OTHER"
192
212
  if request_state is not None and request_state.statements:
193
- self._report_repeated_statement(method, route_template, request_state.statements)
213
+ self._report_repeated_statement(
214
+ metric_method, route_template, request_state.statements
215
+ )
194
216
 
195
217
  self.metrics_recorder.observe(
196
- method=method,
218
+ method=metric_method,
197
219
  path=route_template,
198
220
  status_code=status_code,
199
221
  duration_seconds=duration_seconds,
@@ -1,7 +1,9 @@
1
1
  """Flush OTel providers when the FastAPI app shuts down.
2
2
 
3
- Process exit needs nothing from us: SDK providers register their own
4
- ``atexit`` shutdown (``shutdown_on_exit=True``).
3
+ Shutting them down is left to process exit: SDK providers register their own
4
+ ``atexit`` shutdown (``shutdown_on_exit=True``). A provider is global, so one
5
+ app's lifespan ending mustn't stop it for other apps, or for this one if it
6
+ starts again.
5
7
  """
6
8
 
7
9
  from __future__ import annotations
@@ -18,10 +20,8 @@ _LOGGER = logging.getLogger("fastapiobserver.otel")
18
20
  _HOOKED: weakref.WeakKeyDictionary[FastAPI, set[int]] = weakref.WeakKeyDictionary()
19
21
 
20
22
 
21
- def close_on_app_shutdown(app: FastAPI | None, provider: Any, *, shutdown: bool) -> None:
22
- """Once ``app``'s lifespan ends, shut ``provider`` down (``shutdown=True``,
23
- for providers we created) or just flush it (providers owned by someone else).
24
- """
23
+ def flush_on_app_shutdown(app: FastAPI | None, provider: Any) -> None:
24
+ """Each time ``app``'s lifespan ends, export what ``provider`` holds."""
25
25
  if app is None:
26
26
  return
27
27
  hooked = _HOOKED.setdefault(app, set())
@@ -31,27 +31,20 @@ def close_on_app_shutdown(app: FastAPI | None, provider: Any, *, shutdown: bool)
31
31
 
32
32
  # Wrap the lifespan: Starlette 1.0 removed add_event_handler/on_shutdown.
33
33
  original_lifespan = app.router.lifespan_context
34
- done = False
35
34
 
36
35
  @asynccontextmanager
37
36
  async def lifespan(app_inst: FastAPI) -> AsyncGenerator[Any, None]:
38
- nonlocal done
39
37
  try:
40
38
  async with original_lifespan(app_inst) as state:
41
39
  yield state
42
40
  finally:
43
- if not done:
44
- done = True
45
- try:
46
- if shutdown: # SDK shutdown() exports pending data first
47
- provider.shutdown()
48
- else:
49
- provider.force_flush()
50
- except Exception:
51
- _LOGGER.debug(
52
- "otel.provider.lifecycle.failed",
53
- exc_info=True,
54
- extra={"_skip_enrichers": True},
55
- )
41
+ try:
42
+ provider.force_flush()
43
+ except Exception:
44
+ _LOGGER.debug(
45
+ "otel.provider.lifecycle.failed",
46
+ exc_info=True,
47
+ extra={"_skip_enrichers": True},
48
+ )
56
49
 
57
50
  app.router.lifespan_context = lifespan
@@ -9,7 +9,7 @@ from fastapi import FastAPI
9
9
 
10
10
  from ..config import ObservabilitySettings
11
11
  from ..security import SecurityPolicy, sanitize_event
12
- from .lifecycle import close_on_app_shutdown
12
+ from .lifecycle import flush_on_app_shutdown
13
13
  from .resource import (
14
14
  build_otlp_exporter,
15
15
  create_otel_resource,
@@ -39,6 +39,8 @@ class _SanitizingOTLPLogHandler(logging.Handler):
39
39
  super().__init__(level=delegate.level)
40
40
  self._delegate = delegate
41
41
  self._security_policy = security_policy
42
+ self._otel_context = import_otel_module("opentelemetry.context")
43
+ self._otel_trace = import_otel_module("opentelemetry.trace")
42
44
 
43
45
  def emit(self, record: logging.LogRecord) -> None:
44
46
  try:
@@ -47,7 +49,19 @@ class _SanitizingOTLPLogHandler(logging.Handler):
47
49
  sanitized_record,
48
50
  self._security_policy,
49
51
  )
50
- self._delegate.emit(sanitized_record)
52
+ # Records are exported on the queue's listener thread, where no span
53
+ # is current, so OpenTelemetry would give them no trace. Make the
54
+ # span the record was created in current while it's exported.
55
+ span_context = sanitized_record.__dict__.pop("_otel_span_context", None)
56
+ if span_context is None:
57
+ self._delegate.emit(sanitized_record)
58
+ return
59
+ span = self._otel_trace.NonRecordingSpan(span_context)
60
+ token = self._otel_context.attach(self._otel_trace.set_span_in_context(span))
61
+ try:
62
+ self._delegate.emit(sanitized_record)
63
+ finally:
64
+ self._otel_context.detach(token)
51
65
  except Exception:
52
66
  self.handleError(record)
53
67
 
@@ -158,7 +172,7 @@ def install_otel_logs(
158
172
  raw_handler,
159
173
  security_policy=policy,
160
174
  )
161
- close_on_app_shutdown(app, logger_provider, shutdown=provider_owned)
175
+ flush_on_app_shutdown(app, logger_provider)
162
176
  _LOGGER.info(
163
177
  "otel.logs.installed",
164
178
  extra={
@@ -8,7 +8,7 @@ from typing import Any
8
8
  from fastapi import FastAPI
9
9
 
10
10
  from ..config import ObservabilitySettings
11
- from .lifecycle import close_on_app_shutdown
11
+ from .lifecycle import flush_on_app_shutdown
12
12
  from .resource import (
13
13
  build_otlp_exporter,
14
14
  create_otel_resource,
@@ -75,7 +75,7 @@ def install_otel_metrics(
75
75
  },
76
76
  )
77
77
 
78
- close_on_app_shutdown(app, meter_provider, shutdown=provider_owned)
78
+ flush_on_app_shutdown(app, meter_provider)
79
79
  _LOGGER.info(
80
80
  "otel.metrics.installed",
81
81
  extra={
@@ -38,9 +38,11 @@ class _BaseOTelSettings(EnvLoadable, BaseModel):
38
38
  class OTelSettings(_BaseOTelSettings):
39
39
 
40
40
  enabled: bool = False
41
- service_name: str = "api"
42
- service_version: str = "0.0.0"
43
- environment: str = "development"
41
+ # None: ObservabilitySettings' service, version and environment, so traces
42
+ # and metrics name the service the same way.
43
+ service_name: str | None = None
44
+ service_version: str | None = None
45
+ environment: str | None = None
44
46
  trace_sampling_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
45
47
  extra_resource_attributes: dict[str, str] = Field(default_factory=dict)
46
48
 
@@ -10,7 +10,7 @@ from fastapi import FastAPI
10
10
 
11
11
  from ..config import ObservabilitySettings
12
12
  from ..request_context import get_request_state
13
- from .lifecycle import close_on_app_shutdown
13
+ from .lifecycle import flush_on_app_shutdown
14
14
  from .native import trace_natively
15
15
  from .resource import (
16
16
  build_excluded_urls_csv,
@@ -109,8 +109,7 @@ def install_otel(
109
109
  "opentelemetry.instrumentation.fastapi"
110
110
  ).FastAPIInstrumentor.instrument_app(app, **instrument_kwargs)
111
111
 
112
- if provider_owned:
113
- close_on_app_shutdown(app, tracer_provider, shutdown=True)
112
+ flush_on_app_shutdown(app, tracer_provider)
114
113
 
115
114
  _OTEL_INSTALLED_APPS.add(app)
116
115