fastapi-observer 1.13.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.13.0/src/fastapi_observer.egg-info → fastapi_observer-1.14.0}/PKG-INFO +1 -1
  2. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0/src/fastapi_observer.egg-info}/PKG-INFO +1 -1
  3. fastapi_observer-1.14.0/src/fastapiobserver/_version.py +1 -0
  4. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/fastapi.py +15 -0
  5. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/formatter.py +13 -15
  6. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/setup.py +8 -13
  7. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/request_logging.py +16 -1
  8. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/policy.py +6 -1
  9. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/redaction.py +8 -2
  10. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_fork_queue_listener.py +37 -4
  11. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_logging_schema_contract.py +15 -28
  12. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_middleware.py +38 -0
  13. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_redaction_presets.py +21 -3
  14. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_security_audit_fixes.py +2 -2
  15. fastapi_observer-1.13.0/src/fastapiobserver/_version.py +0 -1
  16. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/LICENSE +0 -0
  17. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/README.md +0 -0
  18. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/pyproject.toml +0 -0
  19. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/setup.cfg +0 -0
  20. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
  21. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  22. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  23. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  24. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/__init__.py +0 -0
  25. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/__init__.py +0 -0
  26. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/formatter.py +0 -0
  27. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/providers.py +0 -0
  28. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/verify.py +0 -0
  29. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/config.py +0 -0
  30. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/control_plane.py +0 -0
  31. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/db_tracing.py +0 -0
  32. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  33. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  34. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/__init__.py +0 -0
  35. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  36. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/filters.py +0 -0
  37. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/held.py +0 -0
  38. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/queueing.py +0 -0
  39. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/state.py +0 -0
  40. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/volume.py +0 -0
  41. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loguru.py +0 -0
  42. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loop_watchdog.py +0 -0
  43. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  44. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
  45. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  46. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  47. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/context.py +0 -0
  48. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/events.py +0 -0
  49. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/headers.py +0 -0
  50. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/ip.py +0 -0
  51. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  52. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/__init__.py +0 -0
  53. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  54. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  55. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/logs.py +0 -0
  56. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/metrics.py +0 -0
  57. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/native.py +0 -0
  58. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/resource.py +0 -0
  59. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/settings.py +0 -0
  60. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/tracing.py +0 -0
  61. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/plugins.py +0 -0
  62. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/pools.py +0 -0
  63. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/profiles.py +0 -0
  64. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/propagation.py +0 -0
  65. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/py.typed +0 -0
  66. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/request_context.py +0 -0
  67. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/__init__.py +0 -0
  68. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/normalize.py +0 -0
  69. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/proxies.py +0 -0
  70. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  71. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  72. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  73. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/factory.py +0 -0
  74. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  75. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  76. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  77. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  78. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  79. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/registry.py +0 -0
  80. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/stats.py +0 -0
  81. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/utils.py +0 -0
  82. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_audit_chain.py +0 -0
  83. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_body_media_allowlist.py +0 -0
  84. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_config_settings.py +0 -0
  85. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_db_tracing.py +0 -0
  86. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_dx_parity.py +0 -0
  87. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_excluded_url_precedence.py +0 -0
  88. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_fastapi_install.py +0 -0
  89. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_graphql_integrations.py +0 -0
  90. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_lifespan_teardown.py +0 -0
  91. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_log_queue_controls.py +0 -0
  92. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_logtail_dlq.py +0 -0
  93. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_loguru_bridge.py +0 -0
  94. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_loop_watchdog.py +0 -0
  95. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_metrics_contracts.py +0 -0
  96. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_metrics_optional_dependency.py +0 -0
  97. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_native_telemetry.py +0 -0
  98. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_negotiate_content_type.py +0 -0
  99. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_integration.py +0 -0
  100. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_lifecycle.py +0 -0
  101. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_log_correlation.py +0 -0
  102. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_from_env.py +0 -0
  103. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_pipeline.py +0 -0
  104. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_metrics_from_env.py +0 -0
  105. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otlp_export_integration.py +0 -0
  106. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_plugin_isolation.py +0 -0
  107. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_pools.py +0 -0
  108. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_profiles.py +0 -0
  109. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_propagation.py +0 -0
  110. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_request_insights.py +0 -0
  111. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_route_template_cardinality.py +0 -0
  112. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_runtime_control_auth.py +0 -0
  113. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_security_redaction.py +0 -0
  114. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_sink_circuit_breaker.py +0 -0
  115. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_sinks.py +0 -0
  116. {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_trust_boundary.py +0 -0
  117. {fastapi_observer-1.13.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.13.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 1.13.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
@@ -0,0 +1 @@
1
+ __version__ = "1.14.0"
@@ -148,6 +148,21 @@ def install_observability(
148
148
  if runtime_control_settings and runtime_control_settings.enabled:
149
149
  mount_control_plane(app, runtime_control_settings, trusted_proxy_policy)
150
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
+
151
166
  # --- 9. One line with the settings that took effect ---
152
167
  _LOGGER.info(
153
168
  "observability.configured",
@@ -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
@@ -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
@@ -86,6 +87,8 @@ class RequestLoggingMiddleware:
86
87
  max_length=self.security_policy.max_body_length,
87
88
  )
88
89
  had_error = False
90
+ cancelled = False
91
+ response_started = False
89
92
 
90
93
  async def receive_wrapper() -> Message:
91
94
  message = await receive()
@@ -93,8 +96,9 @@ class RequestLoggingMiddleware:
93
96
  return message
94
97
 
95
98
  async def send_wrapper(message: Message) -> None:
96
- nonlocal status_code
99
+ nonlocal status_code, response_started
97
100
  if message["type"] == "http.response.start":
101
+ response_started = True
98
102
  status_code = int(message.get("status", 500))
99
103
  resp_headers = message.get("headers", [])
100
104
  if self.security_policy.log_response_body:
@@ -124,6 +128,13 @@ class RequestLoggingMiddleware:
124
128
  captured_error = exc
125
129
  status_code = 500
126
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
127
138
  finally:
128
139
  self._finalize_request(
129
140
  scope=scope,
@@ -136,6 +147,7 @@ class RequestLoggingMiddleware:
136
147
  response_body_capture=response_body_capture,
137
148
  captured_error=captured_error,
138
149
  had_error=had_error,
150
+ cancelled=cancelled,
139
151
  )
140
152
 
141
153
  def _finalize_request(
@@ -150,10 +162,13 @@ class RequestLoggingMiddleware:
150
162
  response_body_capture: _BodyCapture,
151
163
  captured_error: Exception | None,
152
164
  had_error: bool,
165
+ cancelled: bool,
153
166
  ) -> None:
154
167
  duration_seconds = max(0.0, time.perf_counter() - start)
155
168
  if had_error:
156
169
  level, message = logging.ERROR, "request.failed"
170
+ elif cancelled:
171
+ level, message = logging.WARNING, "request.cancelled"
157
172
  elif status_code >= 500:
158
173
  level, message = logging.ERROR, "request.server_error"
159
174
  elif status_code >= 400:
@@ -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
 
@@ -1,12 +1,14 @@
1
1
  import logging
2
2
  import queue
3
+ import threading
3
4
  from unittest.mock import Mock
4
5
  from fastapiobserver.logging.setup import _restart_queue_listener_in_child
5
6
  import fastapiobserver.logging.state as state
6
- from fastapiobserver.logging.queueing import OverflowPolicyQueueHandler
7
+ from fastapiobserver.logging.circuit_breaker import SinkCircuitBreakerHandler
8
+ from fastapiobserver.logging.queueing import _LOG_QUEUE_TELEMETRY, OverflowPolicyQueueHandler
7
9
 
8
10
 
9
- def test_restart_queue_listener_in_child_drains_and_recreates_queue():
11
+ def test_restart_queue_listener_in_child_recreates_queue():
10
12
  # Setup mock state
11
13
  old_queue = queue.Queue(maxsize=10)
12
14
  old_queue.put_nowait(
@@ -36,8 +38,6 @@ def test_restart_queue_listener_in_child_drains_and_recreates_queue():
36
38
  _restart_queue_listener_in_child()
37
39
 
38
40
  # Assertions
39
- assert old_queue.empty()
40
-
41
41
  # Verify the new listener was created and started
42
42
  new_listener = state._QUEUE_LISTENER
43
43
  assert new_listener is not mock_listener
@@ -58,3 +58,36 @@ def test_restart_queue_listener_in_child_drains_and_recreates_queue():
58
58
 
59
59
  state._QUEUE_LISTENER = original_listener
60
60
  state._MANAGED_HANDLERS = original_handlers
61
+
62
+
63
+ def test_restart_in_child_ignores_locks_held_at_fork():
64
+ # In the parent, the listener or a request thread can hold these locks at the
65
+ # moment of the fork. Nothing in the child releases them.
66
+ old_queue = queue.Queue(maxsize=10)
67
+ breaker = SinkCircuitBreakerHandler(
68
+ sink_name="stdout",
69
+ delegate=logging.NullHandler(),
70
+ failure_threshold=1,
71
+ recovery_timeout_seconds=1,
72
+ )
73
+ held = [old_queue.mutex, _LOG_QUEUE_TELEMETRY._lock, breaker._lock]
74
+ listener = Mock(queue=old_queue, handlers=[], respect_handler_level=True)
75
+ saved = (state._QUEUE_LISTENER, state._SINK_CIRCUIT_BREAKERS, _LOG_QUEUE_TELEMETRY._lock)
76
+ state._QUEUE_LISTENER = listener
77
+ state._SINK_CIRCUIT_BREAKERS = [breaker]
78
+ for lock in held:
79
+ lock.acquire()
80
+ try:
81
+ child = threading.Thread(target=_restart_queue_listener_in_child, daemon=True)
82
+ child.start()
83
+ child.join(timeout=2)
84
+
85
+ assert not child.is_alive()
86
+ assert not _LOG_QUEUE_TELEMETRY._lock.locked()
87
+ assert not breaker._lock.locked()
88
+ finally:
89
+ for lock in held:
90
+ lock.release()
91
+ if isinstance(state._QUEUE_LISTENER, logging.handlers.QueueListener):
92
+ state._QUEUE_LISTENER.stop()
93
+ state._QUEUE_LISTENER, state._SINK_CIRCUIT_BREAKERS, _LOG_QUEUE_TELEMETRY._lock = saved
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import logging
5
5
  import logging.config
6
+ import sys
6
7
  import time
7
8
 
8
9
  import pytest
@@ -285,40 +286,26 @@ def test_structured_formatter_emits_structured_error_payload() -> None:
285
286
  assert "fingerprint" in payload["error"]
286
287
 
287
288
 
288
- def test_error_fingerprint_is_stable_across_transient_noise() -> None:
289
- settings = ObservabilitySettings(app_name="test", service="test", environment="test")
289
+ def test_error_fingerprint_groups_by_where_it_was_raised_not_by_message() -> None:
290
+ formatter = StructuredJsonFormatter(
291
+ ObservabilitySettings(app_name="test", service="test", environment="test")
292
+ )
290
293
 
291
- def _simulated_error(noise: str) -> logging.LogRecord:
294
+ def fingerprint(user_id: int, other_site: bool = False) -> str:
292
295
  try:
293
- # The line number and this arbitrary noise will be stripped by the fingerprinter
294
- raise ValueError(f"Some business logic failed {noise}")
295
- except ValueError as e:
296
- # We purposely monkeypatch the exception string to inject fake memory addresses
297
- # to simulate two different object lifetimes.
298
- fake_tb = f'File "app.py", line 42, in <module>\n <MyObject object at {noise}>'
299
- exc_info = (e.__class__, e, e.__traceback__)
296
+ if other_site:
297
+ raise LookupError(f"user {user_id} not found")
298
+ raise LookupError(f"user {user_id} not found at 0x{id(object()):x}")
299
+ except LookupError:
300
300
  record = logging.LogRecord(
301
301
  name="test", level=logging.ERROR, pathname="", lineno=0,
302
- msg="error", args=(), exc_info=exc_info
302
+ msg="error", args=(), exc_info=sys.exc_info(),
303
303
  )
304
- # Override the traceback formatting via a mock class for the test
305
- class MockFormatter(StructuredJsonFormatter):
306
- def formatException(self, exc_info):
307
- return fake_tb
308
-
309
- mock_formatter = MockFormatter(settings)
310
-
311
- payload = json.loads(mock_formatter.format(record))
312
- return payload["error"]["fingerprint"]
313
-
314
- # Even though the simulated memory addresses differ completely, the fingerprint
315
- # must remain identical since it's the same error type and file location.
316
- fingerprint_a = _simulated_error("0x10a2b3c4d")
317
- fingerprint_b = _simulated_error("0x7f8e9d0c1b2a")
304
+ return str(json.loads(formatter.format(record))["error"]["fingerprint"])
318
305
 
319
- assert fingerprint_a == fingerprint_b
320
- assert isinstance(fingerprint_a, str)
321
- assert len(fingerprint_a) == 32 # MD5 hex length
306
+ assert fingerprint(1) == fingerprint(2)
307
+ assert fingerprint(1) != fingerprint(1, other_site=True)
308
+ assert len(fingerprint(1)) == 32
322
309
 
323
310
  def test_request_id_filter_standalone() -> None:
324
311
  from fastapiobserver.logging import RequestIdFilter
@@ -202,3 +202,41 @@ def test_extract_route_template_contract() -> None:
202
202
  "route": type("Route", (), {"path": "/users/{user_id}"})(),
203
203
  }
204
204
  assert _extract_route_template(scope, "/raw", 404) == "/users/{user_id}"
205
+
206
+
207
+ def test_cancelled_request_is_logged_as_cancelled_not_as_a_server_error(caplog) -> None:
208
+ import asyncio
209
+
210
+ from fastapiobserver.middleware import RequestLoggingMiddleware
211
+
212
+ async def cancelled_before_response(scope, receive, send) -> None:
213
+ raise asyncio.CancelledError()
214
+
215
+ async def cancelled_mid_stream(scope, receive, send) -> None:
216
+ await send({"type": "http.response.start", "status": 200, "headers": []})
217
+ raise asyncio.CancelledError()
218
+
219
+ async def no_op_send(message) -> None:
220
+ pass
221
+
222
+ async def run(app) -> None:
223
+ middleware = RequestLoggingMiddleware(
224
+ app, ObservabilitySettings(app_name="test", service="svc", environment="test")
225
+ )
226
+ scope = {"type": "http", "method": "GET", "path": "/slow", "headers": []}
227
+ with pytest.raises(asyncio.CancelledError):
228
+ await middleware(scope, None, no_op_send)
229
+
230
+ with caplog.at_level(logging.INFO, logger="fastapiobserver.middleware"):
231
+ asyncio.run(run(cancelled_before_response))
232
+ asyncio.run(run(cancelled_mid_stream))
233
+
234
+ lines = [
235
+ (record.getMessage(), record.levelno, record.event["status_code"])
236
+ for record in caplog.records
237
+ if record.name == "fastapiobserver.middleware"
238
+ ]
239
+ assert lines == [
240
+ ("request.cancelled", logging.WARNING, 499),
241
+ ("request.cancelled", logging.WARNING, 200),
242
+ ]
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+ import hmac
5
+
3
6
  import pytest
4
7
 
5
8
  import fastapiobserver
@@ -49,9 +52,9 @@ def test_gdpr_preset_hashes_pii_fields() -> None:
49
52
 
50
53
  sanitized = sanitize_event(payload, policy)
51
54
 
52
- assert sanitized["email"].startswith("sha256:")
53
- assert sanitized["phone"].startswith("sha256:")
54
- assert sanitized["address"].startswith("sha256:")
55
+ assert sanitized["email"].startswith("hmac-sha256:")
56
+ assert sanitized["phone"].startswith("hmac-sha256:")
57
+ assert sanitized["address"].startswith("hmac-sha256:")
55
58
 
56
59
 
57
60
  def test_preset_can_be_overridden_by_env(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -69,3 +72,18 @@ def test_preset_constants_are_available_in_public_api() -> None:
69
72
  assert "strict" in fastapiobserver.SECURITY_POLICY_PRESETS
70
73
  assert fastapiobserver.PCI_REDACTED_FIELDS
71
74
  assert fastapiobserver.GDPR_REDACTED_FIELDS
75
+
76
+
77
+ def test_hash_mode_is_keyed_so_values_cannot_be_recovered_by_guessing(
78
+ monkeypatch: pytest.MonkeyPatch,
79
+ ) -> None:
80
+ phone = "+1234567890"
81
+ monkeypatch.setenv("OBS_REDACTION_PRESET", "gdpr")
82
+ monkeypatch.setenv("OBS_REDACTION_HASH_KEY", "k1")
83
+ from_env = sanitize_event({"phone": phone}, SecurityPolicy.from_env())["phone"]
84
+ keyed = hmac.new(b"k1", phone.encode(), hashlib.sha256).hexdigest()
85
+
86
+ assert from_env == f"hmac-sha256:{keyed}"
87
+ assert hashlib.sha256(phone.encode()).hexdigest() not in from_env
88
+ other_key = SecurityPolicy(redaction_mode="hash", redacted_fields=("phone",), hash_key="k2")
89
+ assert sanitize_event({"phone": phone}, other_key)["phone"] != from_env
@@ -28,7 +28,7 @@ def test_gdpr_preset_redacts_client_ip() -> None:
28
28
  policy = SecurityPolicy.from_preset("gdpr")
29
29
  event = {"client_ip": "203.0.113.42", "method": "GET"}
30
30
  sanitized = sanitize_event(event, policy)
31
- assert sanitized["client_ip"].startswith("sha256:")
31
+ assert sanitized["client_ip"].startswith("hmac-sha256:")
32
32
  assert sanitized["method"] == "GET"
33
33
 
34
34
 
@@ -36,7 +36,7 @@ def test_gdpr_preset_redacts_client_ip_inside_nested_dict() -> None:
36
36
  policy = SecurityPolicy.from_preset("gdpr")
37
37
  event = {"nested": {"client_ip": "10.0.0.1"}}
38
38
  sanitized = sanitize_event(event, policy)
39
- assert sanitized["nested"]["client_ip"].startswith("sha256:")
39
+ assert sanitized["nested"]["client_ip"].startswith("hmac-sha256:")
40
40
 
41
41
 
42
42
  # ---------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- __version__ = "1.13.0"