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.
- {fastapi_observer-1.13.0/src/fastapi_observer.egg-info → fastapi_observer-1.14.0}/PKG-INFO +1 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0/src/fastapi_observer.egg-info}/PKG-INFO +1 -1
- fastapi_observer-1.14.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/fastapi.py +15 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/formatter.py +13 -15
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/setup.py +8 -13
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/request_logging.py +16 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/policy.py +6 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/redaction.py +8 -2
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_fork_queue_listener.py +37 -4
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_logging_schema_contract.py +15 -28
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_middleware.py +38 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_redaction_presets.py +21 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_security_audit_fixes.py +2 -2
- fastapi_observer-1.13.0/src/fastapiobserver/_version.py +0 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/LICENSE +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/README.md +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/pyproject.toml +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/setup.cfg +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/config.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/control_plane.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/filters.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/held.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/queueing.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/state.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/volume.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/loop_watchdog.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/context.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/events.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/metrics.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/native.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/otel/tracing.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/pools.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/normalize.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/proxies.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/discovery.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/factory.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/registry.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_fastapi_install.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_logtail_dlq.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_loop_watchdog.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_native_telemetry.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_pools.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_request_insights.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_sinks.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_utils_refactors.py +0 -0
|
@@ -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",
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/formatter.py
RENAMED
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
-
import
|
|
6
|
+
import traceback
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
|
+
from types import TracebackType
|
|
8
9
|
from typing import Any, Callable, Mapping
|
|
9
10
|
|
|
10
11
|
from .._version import __version__
|
|
@@ -144,11 +145,11 @@ def _build_structured_error(
|
|
|
144
145
|
"stacktrace": "",
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
exc_type, exc_value,
|
|
148
|
+
exc_type, exc_value, exc_tb = exc_info
|
|
148
149
|
error_type = exc_type.__name__ if exc_type is not None else "Exception"
|
|
149
150
|
error_message = str(exc_value) if exc_value is not None else ""
|
|
150
151
|
stacktrace = formatter.formatException(exc_info)
|
|
151
|
-
fingerprint = _generate_error_fingerprint(error_type,
|
|
152
|
+
fingerprint = _generate_error_fingerprint(error_type, exc_tb)
|
|
152
153
|
|
|
153
154
|
return {
|
|
154
155
|
"type": error_type,
|
|
@@ -158,16 +159,13 @@ def _build_structured_error(
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
|
|
161
|
-
def _generate_error_fingerprint(error_type: str,
|
|
162
|
-
"""Hash
|
|
163
|
-
if not stacktrace:
|
|
164
|
-
return hashlib.sha256(error_type.encode("utf-8")).hexdigest()[:32]
|
|
162
|
+
def _generate_error_fingerprint(error_type: str, exc_tb: TracebackType | None) -> str:
|
|
163
|
+
"""Hash the exception type and the file, function and source of each frame.
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
165
|
+
The message is left out, since it often holds IDs that differ each time the
|
|
166
|
+
same bug is hit, and so are line numbers, which shift with unrelated edits.
|
|
167
|
+
"""
|
|
168
|
+
frames = "".join(
|
|
169
|
+
f"{frame.filename}:{frame.name}:{frame.line}\n" for frame in traceback.extract_tb(exc_tb)
|
|
170
|
+
)
|
|
171
|
+
return hashlib.sha256(f"{error_type}\n{frames}".encode("utf-8")).hexdigest()[:32]
|
|
@@ -4,6 +4,7 @@ import atexit
|
|
|
4
4
|
import logging
|
|
5
5
|
import logging.handlers
|
|
6
6
|
import queue
|
|
7
|
+
import threading
|
|
7
8
|
from typing import Literal, cast
|
|
8
9
|
|
|
9
10
|
from ..config import ObservabilitySettings
|
|
@@ -289,22 +290,16 @@ def _restart_queue_listener_in_child() -> None:
|
|
|
289
290
|
|
|
290
291
|
listener = state._QUEUE_LISTENER
|
|
291
292
|
if listener is not None:
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
293
|
+
# Another thread of the parent may have held any lock at the moment of
|
|
294
|
+
# the fork, and no thread in the child will release it. So don't touch
|
|
295
|
+
# the inherited queue, not even to drain it: its records are the
|
|
296
|
+
# parent's to write. Give the child fresh locks and a fresh queue.
|
|
297
|
+
_LOG_QUEUE_TELEMETRY._lock = threading.Lock()
|
|
298
|
+
for breaker in state._SINK_CIRCUIT_BREAKERS:
|
|
299
|
+
breaker._lock = threading.Lock()
|
|
298
300
|
old_queue = cast(queue.Queue[logging.LogRecord], listener.queue)
|
|
299
301
|
new_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=old_queue.maxsize)
|
|
300
302
|
|
|
301
|
-
while not old_queue.empty():
|
|
302
|
-
try:
|
|
303
|
-
# Discard the record to prevent pre-fork replay duplication
|
|
304
|
-
_ = old_queue.get_nowait()
|
|
305
|
-
except queue.Empty:
|
|
306
|
-
break
|
|
307
|
-
|
|
308
303
|
# Re-attach the new queue to the OverflowPolicyQueueHandler if present
|
|
309
304
|
for handler in state._MANAGED_HANDLERS:
|
|
310
305
|
from .queueing import OverflowPolicyQueueHandler
|
|
@@ -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",
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/redaction.py
RENAMED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import secrets
|
|
3
5
|
from collections.abc import Mapping
|
|
4
6
|
from typing import Any
|
|
5
7
|
|
|
@@ -8,6 +10,7 @@ from .policy import _DROP, SecurityPolicy
|
|
|
8
10
|
|
|
9
11
|
MAX_SANITIZE_DEPTH = 32
|
|
10
12
|
_SCALAR_TYPES = frozenset({str, int, float, bool})
|
|
13
|
+
_PROCESS_KEY = secrets.token_bytes(32) # for policies without a hash_key
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
@functools.lru_cache(maxsize=64)
|
|
@@ -33,8 +36,11 @@ def _redact_value(value: Any, policy: SecurityPolicy) -> Any:
|
|
|
33
36
|
if policy.redaction_mode == "drop":
|
|
34
37
|
return _DROP
|
|
35
38
|
if policy.redaction_mode == "hash":
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
# Keyed: a plain hash of an email or phone number is reversed by
|
|
40
|
+
# hashing candidates until one matches.
|
|
41
|
+
key = policy.hash_key.get_secret_value().encode() if policy.hash_key else _PROCESS_KEY
|
|
42
|
+
digest = hmac.new(key, str(value).encode("utf-8"), hashlib.sha256).hexdigest()
|
|
43
|
+
return f"hmac-sha256:{digest}"
|
|
38
44
|
return policy.mask_text
|
|
39
45
|
|
|
40
46
|
|
|
@@ -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.
|
|
7
|
+
from fastapiobserver.logging.circuit_breaker import SinkCircuitBreakerHandler
|
|
8
|
+
from fastapiobserver.logging.queueing import _LOG_QUEUE_TELEMETRY, OverflowPolicyQueueHandler
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
def
|
|
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
|
|
289
|
-
|
|
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
|
|
294
|
+
def fingerprint(user_id: int, other_site: bool = False) -> str:
|
|
292
295
|
try:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
assert
|
|
321
|
-
assert len(
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/requires.txt
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapi_observer.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/integrations/strawberry.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/logging/circuit_breaker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/metrics/prometheus.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/body_capture.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/context.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/events.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/headers.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/middleware/metrics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/security/normalize.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/dlq.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/handler.py
RENAMED
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/src/fastapiobserver/sinks/logtail/sink.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_metrics_optional_dependency.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.13.0 → fastapi_observer-1.14.0}/tests/test_route_template_cardinality.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|