fastapi-observer 1.4.1__tar.gz → 1.5.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.4.1/src/fastapi_observer.egg-info → fastapi_observer-1.5.0}/PKG-INFO +5 -7
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/README.md +2 -2
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/pyproject.toml +3 -5
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0/src/fastapi_observer.egg-info}/PKG-INFO +5 -7
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/requires.txt +1 -6
- fastapi_observer-1.5.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/fastapi.py +7 -0
- fastapi_observer-1.5.0/src/fastapiobserver/logging/filters.py +61 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/formatter.py +9 -4
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/queueing.py +11 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/metrics.py +6 -2
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/request_logging.py +31 -19
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/tracing.py +0 -14
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/plugins.py +3 -1
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/normalize.py +4 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/proxies.py +15 -10
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/redaction.py +37 -20
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_fastapi_install.py +12 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_logging_schema_contract.py +28 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_log_correlation.py +7 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_trust_boundary.py +22 -1
- fastapi_observer-1.4.1/src/fastapiobserver/_version.py +0 -1
- fastapi_observer-1.4.1/src/fastapiobserver/logging/filters.py +0 -53
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/LICENSE +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/setup.cfg +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/config.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/control_plane.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/setup.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/state.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/builder.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/contracts.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/endpoint.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/noop.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/pathing.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/backend.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/client.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/collector.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/exemplars.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/multiprocess.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/registry.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/context.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/events.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/span_errors.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/types.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/policy.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/discovery.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/factory.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/registry.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_fork_queue_listener.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_logtail_dlq.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_middleware.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_security_audit_fixes.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_sinks.py +0 -0
- {fastapi_observer-1.4.1 → fastapi_observer-1.5.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.
|
|
3
|
+
Version: 1.5.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
|
|
@@ -14,7 +14,6 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -24,7 +23,7 @@ Classifier: Framework :: FastAPI
|
|
|
24
23
|
Classifier: Topic :: System :: Logging
|
|
25
24
|
Classifier: Topic :: System :: Monitoring
|
|
26
25
|
Classifier: Typing :: Typed
|
|
27
|
-
Requires-Python:
|
|
26
|
+
Requires-Python: >=3.11
|
|
28
27
|
Description-Content-Type: text/markdown
|
|
29
28
|
License-File: LICENSE
|
|
30
29
|
Requires-Dist: fastapi>=0.133.0
|
|
@@ -64,8 +63,7 @@ Requires-Dist: opentelemetry-instrumentation-requests>=0.66b1; extra == "all"
|
|
|
64
63
|
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.66b1; extra == "all"
|
|
65
64
|
Requires-Dist: cryptography>=50.0.0; extra == "all"
|
|
66
65
|
Provides-Extra: dev
|
|
67
|
-
Requires-Dist:
|
|
68
|
-
Requires-Dist: httpx2>=2.16.0; python_version >= "3.11" and extra == "dev"
|
|
66
|
+
Requires-Dist: httpx2>=2.16.0; extra == "dev"
|
|
69
67
|
Requires-Dist: loguru>=0.7.3; extra == "dev"
|
|
70
68
|
Requires-Dist: mypy>=2.4.1; extra == "dev"
|
|
71
69
|
Requires-Dist: pip-audit>=2.10.1; extra == "dev"
|
|
@@ -87,7 +85,7 @@ Dynamic: license-file
|
|
|
87
85
|
|
|
88
86
|
**Documentation:** [vitaee.github.io/FastapiObserver](https://vitaee.github.io/FastapiObserver/)
|
|
89
87
|
|
|
90
|
-
**Supported Python versions:** `3.
|
|
88
|
+
**Supported Python versions:** `3.11` and newer (CI-tested on `3.11` to `3.15`)
|
|
91
89
|
|
|
92
90
|
---
|
|
93
91
|
|
|
@@ -96,7 +94,7 @@ Dynamic: license-file
|
|
|
96
94
|
|
|
97
95
|
| Component | Supported / Tested |
|
|
98
96
|
|---|---|
|
|
99
|
-
| Python | `3.
|
|
97
|
+
| Python | `>=3.11` (CI matrix: `3.11` to `3.15`) |
|
|
100
98
|
| FastAPI | `>=0.133.0` |
|
|
101
99
|
| Starlette | `>=1.3.1` |
|
|
102
100
|
| pydantic-settings | `>=2.10.1` |
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**Documentation:** [vitaee.github.io/FastapiObserver](https://vitaee.github.io/FastapiObserver/)
|
|
11
11
|
|
|
12
|
-
**Supported Python versions:** `3.
|
|
12
|
+
**Supported Python versions:** `3.11` and newer (CI-tested on `3.11` to `3.15`)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
| Component | Supported / Tested |
|
|
20
20
|
|---|---|
|
|
21
|
-
| Python | `3.
|
|
21
|
+
| Python | `>=3.11` (CI matrix: `3.11` to `3.15`) |
|
|
22
22
|
| FastAPI | `>=0.133.0` |
|
|
23
23
|
| Starlette | `>=1.3.1` |
|
|
24
24
|
| pydantic-settings | `>=2.10.1` |
|
|
@@ -7,7 +7,7 @@ name = "fastapi-observer"
|
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
description = "Zero-glue FastAPI observability with security presets and runtime controls"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Vitaee", email = "opensource@vitaee.dev" },
|
|
13
13
|
]
|
|
@@ -16,7 +16,6 @@ classifiers = [
|
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Operating System :: OS Independent",
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
|
-
"Programming Language :: Python :: 3.10",
|
|
20
19
|
"Programming Language :: Python :: 3.11",
|
|
21
20
|
"Programming Language :: Python :: 3.12",
|
|
22
21
|
"Programming Language :: Python :: 3.13",
|
|
@@ -87,8 +86,7 @@ all = [
|
|
|
87
86
|
"cryptography>=50.0.0",
|
|
88
87
|
]
|
|
89
88
|
dev = [
|
|
90
|
-
"
|
|
91
|
-
"httpx2>=2.16.0; python_version >= '3.11'",
|
|
89
|
+
"httpx2>=2.16.0",
|
|
92
90
|
"loguru>=0.7.3",
|
|
93
91
|
"mypy>=2.4.1",
|
|
94
92
|
"pip-audit>=2.10.1",
|
|
@@ -109,7 +107,7 @@ version = { attr = "fastapiobserver._version.__version__" }
|
|
|
109
107
|
where = ["src"]
|
|
110
108
|
|
|
111
109
|
[tool.ruff]
|
|
112
|
-
target-version = "
|
|
110
|
+
target-version = "py311"
|
|
113
111
|
line-length = 100
|
|
114
112
|
src = ["src", "tests"]
|
|
115
113
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.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
|
|
@@ -14,7 +14,6 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -24,7 +23,7 @@ Classifier: Framework :: FastAPI
|
|
|
24
23
|
Classifier: Topic :: System :: Logging
|
|
25
24
|
Classifier: Topic :: System :: Monitoring
|
|
26
25
|
Classifier: Typing :: Typed
|
|
27
|
-
Requires-Python:
|
|
26
|
+
Requires-Python: >=3.11
|
|
28
27
|
Description-Content-Type: text/markdown
|
|
29
28
|
License-File: LICENSE
|
|
30
29
|
Requires-Dist: fastapi>=0.133.0
|
|
@@ -64,8 +63,7 @@ Requires-Dist: opentelemetry-instrumentation-requests>=0.66b1; extra == "all"
|
|
|
64
63
|
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.66b1; extra == "all"
|
|
65
64
|
Requires-Dist: cryptography>=50.0.0; extra == "all"
|
|
66
65
|
Provides-Extra: dev
|
|
67
|
-
Requires-Dist:
|
|
68
|
-
Requires-Dist: httpx2>=2.16.0; python_version >= "3.11" and extra == "dev"
|
|
66
|
+
Requires-Dist: httpx2>=2.16.0; extra == "dev"
|
|
69
67
|
Requires-Dist: loguru>=0.7.3; extra == "dev"
|
|
70
68
|
Requires-Dist: mypy>=2.4.1; extra == "dev"
|
|
71
69
|
Requires-Dist: pip-audit>=2.10.1; extra == "dev"
|
|
@@ -87,7 +85,7 @@ Dynamic: license-file
|
|
|
87
85
|
|
|
88
86
|
**Documentation:** [vitaee.github.io/FastapiObserver](https://vitaee.github.io/FastapiObserver/)
|
|
89
87
|
|
|
90
|
-
**Supported Python versions:** `3.
|
|
88
|
+
**Supported Python versions:** `3.11` and newer (CI-tested on `3.11` to `3.15`)
|
|
91
89
|
|
|
92
90
|
---
|
|
93
91
|
|
|
@@ -96,7 +94,7 @@ Dynamic: license-file
|
|
|
96
94
|
|
|
97
95
|
| Component | Supported / Tested |
|
|
98
96
|
|---|---|
|
|
99
|
-
| Python | `3.
|
|
97
|
+
| Python | `>=3.11` (CI matrix: `3.11` to `3.15`) |
|
|
100
98
|
| FastAPI | `>=0.133.0` |
|
|
101
99
|
| Starlette | `>=1.3.1` |
|
|
102
100
|
| pydantic-settings | `>=2.10.1` |
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/requires.txt
RENAMED
|
@@ -20,6 +20,7 @@ cryptography>=50.0.0
|
|
|
20
20
|
cryptography>=50.0.0
|
|
21
21
|
|
|
22
22
|
[dev]
|
|
23
|
+
httpx2>=2.16.0
|
|
23
24
|
loguru>=0.7.3
|
|
24
25
|
mypy>=2.4.1
|
|
25
26
|
pip-audit>=2.10.1
|
|
@@ -29,12 +30,6 @@ ruff>=0.17.3
|
|
|
29
30
|
cyclonedx-bom>=7.6.0
|
|
30
31
|
uvicorn>=0.54.0
|
|
31
32
|
|
|
32
|
-
[dev:python_version < "3.11"]
|
|
33
|
-
httpx>=0.28.1
|
|
34
|
-
|
|
35
|
-
[dev:python_version >= "3.11"]
|
|
36
|
-
httpx2>=2.16.0
|
|
37
|
-
|
|
38
33
|
[fast-json]
|
|
39
34
|
orjson>=3.11.7
|
|
40
35
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.5.0"
|
|
@@ -126,6 +126,13 @@ def install_observability(
|
|
|
126
126
|
metrics_backend,
|
|
127
127
|
)
|
|
128
128
|
|
|
129
|
+
# --- 7. Uvicorn access log ---
|
|
130
|
+
# The middleware's `request.completed` line carries the same fields, so drop
|
|
131
|
+
# uvicorn's duplicate line the same way `uvicorn --no-access-log` does.
|
|
132
|
+
uvicorn_access_logger = logging.getLogger("uvicorn.access")
|
|
133
|
+
uvicorn_access_logger.handlers = []
|
|
134
|
+
uvicorn_access_logger.propagate = False
|
|
135
|
+
|
|
129
136
|
# --- 8. Runtime control plane ---
|
|
130
137
|
if runtime_control_settings and runtime_control_settings.enabled:
|
|
131
138
|
mount_control_plane(app, runtime_control_settings)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from ..plugins import apply_log_filters
|
|
7
|
+
from ..request_context import get_request_id, get_span_id, get_trace_id, get_user_context
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger("fastapiobserver.logging")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestIdFilter(logging.Filter):
|
|
13
|
+
"""Attach request ID and user context from context vars to every log record.
|
|
14
|
+
|
|
15
|
+
Records are formatted on the queue listener thread, where the request's
|
|
16
|
+
context vars are not visible, so they are captured here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
20
|
+
if not getattr(record, "request_id", None):
|
|
21
|
+
record.request_id = get_request_id()
|
|
22
|
+
if not getattr(record, "user_context", None):
|
|
23
|
+
record.user_context = get_user_context()
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TraceContextFilter(logging.Filter):
|
|
28
|
+
"""Attach OTel trace_id / span_id to every log record."""
|
|
29
|
+
|
|
30
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
31
|
+
trace_id = get_trace_id()
|
|
32
|
+
span_id = get_span_id()
|
|
33
|
+
|
|
34
|
+
# No span can be active unless something already imported the OTel API.
|
|
35
|
+
# Looking it up avoids a failing import (a sys.path scan) on every record.
|
|
36
|
+
otel_trace = sys.modules.get("opentelemetry.trace")
|
|
37
|
+
if otel_trace is not None:
|
|
38
|
+
try:
|
|
39
|
+
span_context = otel_trace.get_current_span().get_span_context()
|
|
40
|
+
if span_context and span_context.is_valid:
|
|
41
|
+
trace_id = f"{span_context.trace_id:032x}"
|
|
42
|
+
span_id = f"{span_context.span_id:016x}"
|
|
43
|
+
except Exception:
|
|
44
|
+
_LOGGER.debug(
|
|
45
|
+
"trace_context_filter.otel_lookup_failed",
|
|
46
|
+
exc_info=True,
|
|
47
|
+
extra={"_skip_enrichers": True},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if trace_id:
|
|
51
|
+
record.trace_id = trace_id # type: ignore[attr-defined]
|
|
52
|
+
if span_id:
|
|
53
|
+
record.span_id = span_id # type: ignore[attr-defined]
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PluginLogFilter(logging.Filter):
|
|
58
|
+
"""Apply user-registered log filters with fault isolation."""
|
|
59
|
+
|
|
60
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
61
|
+
return apply_log_filters(record)
|
|
@@ -52,7 +52,7 @@ class StructuredJsonFormatter(logging.Formatter):
|
|
|
52
52
|
|
|
53
53
|
def format(self, record: logging.LogRecord) -> str:
|
|
54
54
|
payload: dict[str, Any] = {
|
|
55
|
-
"timestamp": datetime.
|
|
55
|
+
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
|
|
56
56
|
"level": record.levelname,
|
|
57
57
|
"logger": record.name,
|
|
58
58
|
"message": record.getMessage(),
|
|
@@ -77,7 +77,7 @@ class StructuredJsonFormatter(logging.Formatter):
|
|
|
77
77
|
if span_id:
|
|
78
78
|
payload["span_id"] = span_id
|
|
79
79
|
|
|
80
|
-
user_context = get_user_context()
|
|
80
|
+
user_context = getattr(record, "user_context", None) or get_user_context()
|
|
81
81
|
if user_context:
|
|
82
82
|
payload["user_context"] = user_context
|
|
83
83
|
|
|
@@ -96,17 +96,22 @@ class StructuredJsonFormatter(logging.Formatter):
|
|
|
96
96
|
else:
|
|
97
97
|
candidate_payload = self._enrich_event(dict(payload))
|
|
98
98
|
if isinstance(candidate_payload, Mapping):
|
|
99
|
-
enriched_payload =
|
|
99
|
+
enriched_payload = _as_dict(candidate_payload)
|
|
100
100
|
else:
|
|
101
101
|
enriched_payload = payload
|
|
102
102
|
sanitized_candidate = self._sanitize_payload(enriched_payload, self.security_policy)
|
|
103
103
|
if isinstance(sanitized_candidate, Mapping):
|
|
104
|
-
sanitized_payload =
|
|
104
|
+
sanitized_payload = _as_dict(sanitized_candidate)
|
|
105
105
|
else:
|
|
106
106
|
sanitized_payload = enriched_payload
|
|
107
107
|
return _json_dumps(sanitized_payload)
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def _as_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
111
|
+
# Both steps already return fresh dicts; only copy other mapping types.
|
|
112
|
+
return value if type(value) is dict else dict(value)
|
|
113
|
+
|
|
114
|
+
|
|
110
115
|
def _json_dumps(payload: dict[str, Any]) -> str:
|
|
111
116
|
if orjson is not None:
|
|
112
117
|
return orjson.dumps(payload).decode("utf-8")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import copy
|
|
3
4
|
import logging
|
|
4
5
|
import logging.handlers
|
|
5
6
|
import queue
|
|
@@ -135,6 +136,16 @@ class OverflowPolicyQueueHandler(logging.handlers.QueueHandler):
|
|
|
135
136
|
self.block_timeout_seconds = block_timeout_seconds
|
|
136
137
|
self.telemetry = telemetry
|
|
137
138
|
|
|
139
|
+
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
|
140
|
+
# The stdlib version formats the record here and drops exc_info, which
|
|
141
|
+
# loses the structured `error` field. The queue stays in-process, so keep
|
|
142
|
+
# exc_info and leave formatting to the listener thread.
|
|
143
|
+
record = copy.copy(record)
|
|
144
|
+
record.message = record.getMessage()
|
|
145
|
+
record.msg = record.message
|
|
146
|
+
record.args = None
|
|
147
|
+
return record
|
|
148
|
+
|
|
138
149
|
def enqueue(self, record: logging.LogRecord) -> None:
|
|
139
150
|
try:
|
|
140
151
|
self.queue.put_nowait(record)
|
|
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
5
|
from ..config import ObservabilitySettings
|
|
6
|
-
from ..metrics import MetricsBackend, collapse_dynamic_segments
|
|
7
|
-
from ..plugins import emit_metric_hooks
|
|
6
|
+
from ..metrics import MetricsBackend, NoopMetricsBackend, collapse_dynamic_segments
|
|
7
|
+
from ..plugins import _METRIC_HOOKS, emit_metric_hooks
|
|
8
8
|
from starlette.requests import Request
|
|
9
9
|
from starlette.responses import Response
|
|
10
10
|
from starlette.types import Scope
|
|
@@ -30,6 +30,8 @@ class _MetricsRecorder:
|
|
|
30
30
|
duration_seconds: float,
|
|
31
31
|
scope: Scope | None = None,
|
|
32
32
|
) -> None:
|
|
33
|
+
if isinstance(self.metrics_backend, NoopMetricsBackend):
|
|
34
|
+
return
|
|
33
35
|
collapsed_path = collapse_dynamic_segments(path)
|
|
34
36
|
excluded_urls = self.settings.metrics_exclude_paths
|
|
35
37
|
|
|
@@ -67,6 +69,8 @@ class _MetricsRecorder:
|
|
|
67
69
|
status_code: int,
|
|
68
70
|
duration_seconds: float,
|
|
69
71
|
) -> None:
|
|
72
|
+
if not _METRIC_HOOKS:
|
|
73
|
+
return
|
|
70
74
|
request = Request(scope)
|
|
71
75
|
response = Response(status_code=status_code)
|
|
72
76
|
emit_metric_hooks(request, response, duration_seconds)
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/request_logging.py
RENAMED
|
@@ -60,9 +60,8 @@ class RequestLoggingMiddleware:
|
|
|
60
60
|
method = scope.get("method", "UNKNOWN")
|
|
61
61
|
status_code = 500
|
|
62
62
|
captured_error: Exception | None = None
|
|
63
|
-
request_content_type = _get_header(raw_headers, b"content-type")
|
|
64
63
|
request_body_enabled = self.security_policy.log_request_body and is_body_capturable(
|
|
65
|
-
|
|
64
|
+
_get_header(raw_headers, b"content-type"),
|
|
66
65
|
self.security_policy,
|
|
67
66
|
)
|
|
68
67
|
request_body_capture = _BodyCapture(
|
|
@@ -102,7 +101,11 @@ class RequestLoggingMiddleware:
|
|
|
102
101
|
await send(message)
|
|
103
102
|
|
|
104
103
|
try:
|
|
105
|
-
await self.app(
|
|
104
|
+
await self.app(
|
|
105
|
+
scope,
|
|
106
|
+
receive_wrapper if request_body_enabled else receive,
|
|
107
|
+
send_wrapper,
|
|
108
|
+
)
|
|
106
109
|
except Exception as exc:
|
|
107
110
|
had_error = True
|
|
108
111
|
captured_error = exc
|
|
@@ -137,26 +140,35 @@ class RequestLoggingMiddleware:
|
|
|
137
140
|
had_error: bool,
|
|
138
141
|
) -> None:
|
|
139
142
|
duration_seconds = max(0.0, time.perf_counter() - start)
|
|
140
|
-
error_type = _classify_error(status_code, captured_error)
|
|
141
|
-
safe_event = self.event_builder.build(
|
|
142
|
-
method=method,
|
|
143
|
-
path=path,
|
|
144
|
-
status_code=status_code,
|
|
145
|
-
duration_seconds=duration_seconds,
|
|
146
|
-
client_ip=client_ip,
|
|
147
|
-
request_body=request_body_capture.value,
|
|
148
|
-
response_body=response_body_capture.value,
|
|
149
|
-
error_type=error_type,
|
|
150
|
-
exception=captured_error,
|
|
151
|
-
)
|
|
152
143
|
if had_error:
|
|
153
|
-
|
|
144
|
+
level, message = logging.ERROR, "request.failed"
|
|
154
145
|
elif status_code >= 500:
|
|
155
|
-
|
|
146
|
+
level, message = logging.ERROR, "request.server_error"
|
|
156
147
|
elif status_code >= 400:
|
|
157
|
-
|
|
148
|
+
level, message = logging.WARNING, "request.client_error"
|
|
158
149
|
else:
|
|
159
|
-
|
|
150
|
+
level, message = logging.INFO, "request.completed"
|
|
151
|
+
|
|
152
|
+
# Building and redacting the event is the costliest step; skip it when
|
|
153
|
+
# the record would be dropped by the log level anyway.
|
|
154
|
+
if self.logger.isEnabledFor(level):
|
|
155
|
+
safe_event = self.event_builder.build(
|
|
156
|
+
method=method,
|
|
157
|
+
path=path,
|
|
158
|
+
status_code=status_code,
|
|
159
|
+
duration_seconds=duration_seconds,
|
|
160
|
+
client_ip=client_ip,
|
|
161
|
+
request_body=request_body_capture.value,
|
|
162
|
+
response_body=response_body_capture.value,
|
|
163
|
+
error_type=_classify_error(status_code, captured_error),
|
|
164
|
+
exception=captured_error,
|
|
165
|
+
)
|
|
166
|
+
self.logger.log(
|
|
167
|
+
level,
|
|
168
|
+
message,
|
|
169
|
+
exc_info=captured_error if had_error else None,
|
|
170
|
+
extra={"event": safe_event},
|
|
171
|
+
)
|
|
160
172
|
|
|
161
173
|
# Prefer route template for bounded cardinality
|
|
162
174
|
route_template = _extract_route_template(scope, path)
|
|
@@ -40,9 +40,6 @@ def install_otel(
|
|
|
40
40
|
fastapi_instrumentor_module = import_otel_module(
|
|
41
41
|
"opentelemetry.instrumentation.fastapi"
|
|
42
42
|
)
|
|
43
|
-
logging_instrumentor_module = import_otel_module(
|
|
44
|
-
"opentelemetry.instrumentation.logging"
|
|
45
|
-
)
|
|
46
43
|
|
|
47
44
|
set_trace_sampling_ratio(otel_settings.trace_sampling_ratio)
|
|
48
45
|
current_provider = trace_api.get_tracer_provider()
|
|
@@ -122,17 +119,6 @@ def install_otel(
|
|
|
122
119
|
app, **instrument_kwargs
|
|
123
120
|
)
|
|
124
121
|
|
|
125
|
-
try:
|
|
126
|
-
logging_instrumentor_module.LoggingInstrumentor().instrument(
|
|
127
|
-
set_logging_format=False
|
|
128
|
-
)
|
|
129
|
-
except Exception:
|
|
130
|
-
_LOGGER.debug(
|
|
131
|
-
"otel.logging_instrumentor.failed",
|
|
132
|
-
exc_info=True,
|
|
133
|
-
extra={"_skip_enrichers": True},
|
|
134
|
-
)
|
|
135
|
-
|
|
136
122
|
if provider_owned:
|
|
137
123
|
register_shutdown_hook(
|
|
138
124
|
key=f"otel.tracer_provider.{id(tracer_provider)}",
|
|
@@ -74,6 +74,8 @@ def clear_plugins() -> None:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def apply_log_enrichers(event: dict[str, object]) -> dict[str, object]:
|
|
77
|
+
if not _LOG_ENRICHERS:
|
|
78
|
+
return event
|
|
77
79
|
with _LOCK:
|
|
78
80
|
enrichers = list(_LOG_ENRICHERS.items())
|
|
79
81
|
|
|
@@ -92,7 +94,7 @@ def apply_log_enrichers(event: dict[str, object]) -> dict[str, object]:
|
|
|
92
94
|
|
|
93
95
|
|
|
94
96
|
def apply_log_filters(record: logging.LogRecord) -> bool:
|
|
95
|
-
if getattr(record, "_skip_log_filters", False):
|
|
97
|
+
if not _LOG_FILTERS or getattr(record, "_skip_log_filters", False):
|
|
96
98
|
return True
|
|
97
99
|
|
|
98
100
|
with _LOCK:
|
|
@@ -35,15 +35,20 @@ def resolve_client_ip(
|
|
|
35
35
|
if not is_trusted_client_ip(client_ip, policy):
|
|
36
36
|
return client_ip
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
# Each proxy appends the address it received the request from, so only the
|
|
39
|
+
# right-hand entries can be trusted; the left ones are whatever the client sent.
|
|
40
|
+
# Walk right to left and return the first hop that is not a trusted proxy.
|
|
41
|
+
# Repeated header lines form one list.
|
|
42
|
+
hops = [
|
|
43
|
+
hop.strip()
|
|
44
|
+
for k, v in headers
|
|
45
|
+
if k.lower() == b"x-forwarded-for"
|
|
46
|
+
for hop in v.decode("latin1").split(",")
|
|
47
|
+
if hop.strip()
|
|
48
|
+
]
|
|
49
|
+
for hop in reversed(hops):
|
|
50
|
+
if not is_trusted_client_ip(hop, policy):
|
|
51
|
+
return hop
|
|
52
|
+
return hops[0] if hops else client_ip
|
|
48
53
|
|
|
49
54
|
__all__ = ["is_trusted_client_ip", "resolve_client_ip"]
|
|
@@ -1,10 +1,32 @@
|
|
|
1
|
+
import functools
|
|
1
2
|
import hashlib
|
|
2
|
-
from
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
3
5
|
|
|
4
6
|
from .normalize import _normalize_key, _normalize_media_type
|
|
5
7
|
from .policy import _DROP, SecurityPolicy
|
|
6
8
|
|
|
7
9
|
MAX_SANITIZE_DEPTH = 32
|
|
10
|
+
_SCALAR_TYPES = frozenset({str, int, float, bool})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@functools.lru_cache(maxsize=64)
|
|
14
|
+
def _policy_key_sets(
|
|
15
|
+
policy: SecurityPolicy,
|
|
16
|
+
) -> tuple[frozenset[str], frozenset[str], frozenset[str] | None, frozenset[str] | None]:
|
|
17
|
+
# Policies are frozen, so the normalized lookup sets are built once per policy
|
|
18
|
+
# instead of once per log record.
|
|
19
|
+
def normalized(items: tuple[str, ...]) -> frozenset[str]:
|
|
20
|
+
return frozenset(_normalize_key(item) for item in items)
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
normalized(policy.redacted_fields),
|
|
24
|
+
normalized(policy.redacted_headers),
|
|
25
|
+
normalized(policy.header_allowlist) if policy.header_allowlist is not None else None,
|
|
26
|
+
normalized(policy.event_key_allowlist)
|
|
27
|
+
if policy.event_key_allowlist is not None
|
|
28
|
+
else None,
|
|
29
|
+
)
|
|
8
30
|
|
|
9
31
|
|
|
10
32
|
def _redact_value(value: Any, policy: SecurityPolicy) -> Any:
|
|
@@ -17,17 +39,8 @@ def _redact_value(value: Any, policy: SecurityPolicy) -> Any:
|
|
|
17
39
|
|
|
18
40
|
|
|
19
41
|
def sanitize_event(data: dict[str, Any], policy: SecurityPolicy) -> dict[str, Any]:
|
|
20
|
-
sensitive_fields
|
|
21
|
-
|
|
22
|
-
header_allowlist = (
|
|
23
|
-
{_normalize_key(item) for item in policy.header_allowlist}
|
|
24
|
-
if policy.header_allowlist is not None
|
|
25
|
-
else None
|
|
26
|
-
)
|
|
27
|
-
event_key_allowlist = (
|
|
28
|
-
{_normalize_key(item) for item in policy.event_key_allowlist}
|
|
29
|
-
if policy.event_key_allowlist is not None
|
|
30
|
-
else None
|
|
42
|
+
sensitive_fields, sensitive_headers, header_allowlist, event_key_allowlist = (
|
|
43
|
+
_policy_key_sets(policy)
|
|
31
44
|
)
|
|
32
45
|
return _sanitize_mapping(
|
|
33
46
|
data,
|
|
@@ -44,12 +57,12 @@ def sanitize_event(data: dict[str, Any], policy: SecurityPolicy) -> dict[str, An
|
|
|
44
57
|
def _sanitize_mapping(
|
|
45
58
|
data: Mapping[str, Any],
|
|
46
59
|
policy: SecurityPolicy,
|
|
47
|
-
sensitive_fields:
|
|
48
|
-
sensitive_headers:
|
|
60
|
+
sensitive_fields: frozenset[str],
|
|
61
|
+
sensitive_headers: frozenset[str],
|
|
49
62
|
parent_is_headers: bool,
|
|
50
63
|
depth: int,
|
|
51
|
-
header_allowlist:
|
|
52
|
-
event_key_allowlist:
|
|
64
|
+
header_allowlist: frozenset[str] | None,
|
|
65
|
+
event_key_allowlist: frozenset[str] | None,
|
|
53
66
|
) -> dict[str, Any]:
|
|
54
67
|
out: dict[str, Any] = {}
|
|
55
68
|
for key, value in data.items():
|
|
@@ -72,6 +85,10 @@ def _sanitize_mapping(
|
|
|
72
85
|
out[str(key)] = redacted
|
|
73
86
|
continue
|
|
74
87
|
|
|
88
|
+
if value is None or type(value) in _SCALAR_TYPES:
|
|
89
|
+
out[str(key)] = value
|
|
90
|
+
continue
|
|
91
|
+
|
|
75
92
|
out[str(key)] = _sanitize_value(
|
|
76
93
|
value,
|
|
77
94
|
policy,
|
|
@@ -88,12 +105,12 @@ def _sanitize_mapping(
|
|
|
88
105
|
def _sanitize_value(
|
|
89
106
|
value: Any,
|
|
90
107
|
policy: SecurityPolicy,
|
|
91
|
-
sensitive_fields:
|
|
92
|
-
sensitive_headers:
|
|
108
|
+
sensitive_fields: frozenset[str],
|
|
109
|
+
sensitive_headers: frozenset[str],
|
|
93
110
|
parent_is_headers: bool,
|
|
94
111
|
depth: int,
|
|
95
|
-
header_allowlist:
|
|
96
|
-
event_key_allowlist:
|
|
112
|
+
header_allowlist: frozenset[str] | None,
|
|
113
|
+
event_key_allowlist: frozenset[str] | None,
|
|
97
114
|
) -> Any:
|
|
98
115
|
if depth > MAX_SANITIZE_DEPTH and isinstance(value, (Mapping, list, tuple)):
|
|
99
116
|
# Too deep to inspect: mask rather than pass unchecked data through.
|
|
@@ -32,6 +32,18 @@ def test_install_observability_adds_request_logging_middleware_once() -> None:
|
|
|
32
32
|
assert count == 1
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def test_install_observability_drops_duplicate_uvicorn_access_log() -> None:
|
|
36
|
+
install_observability(
|
|
37
|
+
FastAPI(),
|
|
38
|
+
ObservabilitySettings(app_name="test", service="svc", environment="test"),
|
|
39
|
+
metrics_enabled=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Uvicorn writes access lines only while this logger has somewhere to send them.
|
|
43
|
+
assert not logging.getLogger("uvicorn.access").hasHandlers()
|
|
44
|
+
assert logging.getLogger("uvicorn.error").hasHandlers()
|
|
45
|
+
|
|
46
|
+
|
|
35
47
|
def test_install_observability_with_metrics_enabled_requires_extra() -> None:
|
|
36
48
|
app = FastAPI()
|
|
37
49
|
settings = ObservabilitySettings(
|
|
@@ -116,6 +116,34 @@ def test_structured_formatter_supports_injected_dependencies() -> None:
|
|
|
116
116
|
assert payload["sanitized_by"] == "custom"
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def test_queued_records_keep_exception_and_user_context() -> None:
|
|
120
|
+
class _CollectingHandler(logging.Handler):
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
super().__init__()
|
|
123
|
+
self.messages: list[str] = []
|
|
124
|
+
|
|
125
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
126
|
+
self.messages.append(self.format(record))
|
|
127
|
+
|
|
128
|
+
settings = ObservabilitySettings(app_name="test", service="test", environment="test")
|
|
129
|
+
collector = _CollectingHandler()
|
|
130
|
+
setup_logging(settings, force=True, logs_mode="otlp", extra_handlers=[collector])
|
|
131
|
+
|
|
132
|
+
set_user_context({"user_id": "42"})
|
|
133
|
+
try:
|
|
134
|
+
raise RuntimeError("boom")
|
|
135
|
+
except RuntimeError:
|
|
136
|
+
logging.getLogger("tests.queued").exception("request failed")
|
|
137
|
+
finally:
|
|
138
|
+
clear_user_context()
|
|
139
|
+
shutdown_logging()
|
|
140
|
+
|
|
141
|
+
payload = next(json.loads(m) for m in collector.messages if '"tests.queued"' in m)
|
|
142
|
+
assert payload["message"] == "request failed"
|
|
143
|
+
assert payload["error"]["type"] == "RuntimeError"
|
|
144
|
+
assert payload["user_context"] == {"user_id": "42"}
|
|
145
|
+
|
|
146
|
+
|
|
119
147
|
def test_setup_logging_is_idempotent_with_force_mode() -> None:
|
|
120
148
|
settings = ObservabilitySettings(app_name="test", service="test", environment="test")
|
|
121
149
|
root = logging.getLogger()
|
|
@@ -80,6 +80,13 @@ def test_trace_and_span_ids_are_correlated_in_logs(
|
|
|
80
80
|
),
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
+
# OTel's LoggingInstrumentor would add its own root handler that bypasses
|
|
84
|
+
# the queue and redaction; tracing must not install one.
|
|
85
|
+
assert not any(
|
|
86
|
+
type(root_handler).__module__.startswith("opentelemetry")
|
|
87
|
+
for root_handler in logging.getLogger().handlers
|
|
88
|
+
)
|
|
89
|
+
|
|
83
90
|
provider = trace_api.get_tracer_provider()
|
|
84
91
|
if not hasattr(provider, "add_span_processor"):
|
|
85
92
|
pytest.skip("Active tracer provider does not support span processors")
|
|
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
|
|
|
6
6
|
from fastapiobserver import ObservabilitySettings, install_observability
|
|
7
7
|
from fastapiobserver.middleware import _resolve_request_id
|
|
8
8
|
from fastapiobserver.request_context import get_request_id
|
|
9
|
-
from fastapiobserver.security import TrustedProxyPolicy
|
|
9
|
+
from fastapiobserver.security import TrustedProxyPolicy, resolve_client_ip
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _build_app(trusted_proxy_policy: TrustedProxyPolicy) -> FastAPI:
|
|
@@ -51,3 +51,24 @@ def test_resolve_request_id_accepts_only_trusted_valid_values() -> None:
|
|
|
51
51
|
assert _resolve_request_id("request_123", True) == "request_123"
|
|
52
52
|
assert _resolve_request_id("bad id with spaces", True) != "bad id with spaces"
|
|
53
53
|
assert _resolve_request_id("request_123", False) != "request_123"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_forwarded_for_uses_rightmost_hop_outside_trusted_proxies() -> None:
|
|
57
|
+
policy = TrustedProxyPolicy(honor_forwarded_headers=True)
|
|
58
|
+
proxy = "10.0.0.5"
|
|
59
|
+
|
|
60
|
+
def resolve(*values: bytes) -> str | None:
|
|
61
|
+
return resolve_client_ip(proxy, [(b"x-forwarded-for", v) for v in values], policy)
|
|
62
|
+
|
|
63
|
+
assert resolve(b"203.0.113.7") == "203.0.113.7"
|
|
64
|
+
# Proxies append to whatever the client sent, so leftmost entries are forgeable.
|
|
65
|
+
assert resolve(b"6.6.6.6, 203.0.113.7") == "203.0.113.7"
|
|
66
|
+
assert resolve(b"6.6.6.6", b"203.0.113.7") == "203.0.113.7"
|
|
67
|
+
assert resolve(b"203.0.113.7, 10.1.1.1") == "203.0.113.7"
|
|
68
|
+
# Every hop is a trusted proxy: fall back to the leftmost one.
|
|
69
|
+
assert resolve(b"10.2.2.2, 10.1.1.1") == "10.2.2.2"
|
|
70
|
+
assert resolve() == proxy
|
|
71
|
+
# Untrusted peers never get their forwarded headers honored.
|
|
72
|
+
assert resolve_client_ip("198.51.100.1", [(b"x-forwarded-for", b"6.6.6.6")], policy) == (
|
|
73
|
+
"198.51.100.1"
|
|
74
|
+
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.4.1"
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
|
|
5
|
-
from ..plugins import apply_log_filters
|
|
6
|
-
from ..request_context import get_request_id, get_span_id, get_trace_id
|
|
7
|
-
|
|
8
|
-
_LOGGER = logging.getLogger("fastapiobserver.logging")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class RequestIdFilter(logging.Filter):
|
|
12
|
-
"""Attach request ID from context vars to every log record."""
|
|
13
|
-
|
|
14
|
-
def filter(self, record: logging.LogRecord) -> bool:
|
|
15
|
-
if not getattr(record, "request_id", None):
|
|
16
|
-
record.request_id = get_request_id()
|
|
17
|
-
return True
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class TraceContextFilter(logging.Filter):
|
|
21
|
-
"""Attach OTel trace_id / span_id to every log record."""
|
|
22
|
-
|
|
23
|
-
def filter(self, record: logging.LogRecord) -> bool:
|
|
24
|
-
trace_id = get_trace_id()
|
|
25
|
-
span_id = get_span_id()
|
|
26
|
-
|
|
27
|
-
try:
|
|
28
|
-
from opentelemetry import trace as otel_trace # type: ignore
|
|
29
|
-
|
|
30
|
-
span = otel_trace.get_current_span()
|
|
31
|
-
span_context = span.get_span_context()
|
|
32
|
-
if span_context and span_context.is_valid:
|
|
33
|
-
trace_id = f"{span_context.trace_id:032x}"
|
|
34
|
-
span_id = f"{span_context.span_id:016x}"
|
|
35
|
-
except Exception:
|
|
36
|
-
_LOGGER.debug(
|
|
37
|
-
"trace_context_filter.otel_lookup_failed",
|
|
38
|
-
exc_info=True,
|
|
39
|
-
extra={"_skip_enrichers": True},
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
if trace_id:
|
|
43
|
-
record.trace_id = trace_id # type: ignore[attr-defined]
|
|
44
|
-
if span_id:
|
|
45
|
-
record.span_id = span_id # type: ignore[attr-defined]
|
|
46
|
-
return True
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class PluginLogFilter(logging.Filter):
|
|
50
|
-
"""Apply user-registered log filters with fault isolation."""
|
|
51
|
-
|
|
52
|
-
def filter(self, record: logging.LogRecord) -> bool:
|
|
53
|
-
return apply_log_filters(record)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.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.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/integrations/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/integrations/strawberry.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.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
|
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/backend.py
RENAMED
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/__init__.py
RENAMED
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/body_capture.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/span_errors.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.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/handler.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
|
|
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
|
|
File without changes
|