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.
Files changed (122) hide show
  1. {fastapi_observer-1.4.1/src/fastapi_observer.egg-info → fastapi_observer-1.5.0}/PKG-INFO +5 -7
  2. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/README.md +2 -2
  3. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/pyproject.toml +3 -5
  4. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0/src/fastapi_observer.egg-info}/PKG-INFO +5 -7
  5. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/requires.txt +1 -6
  6. fastapi_observer-1.5.0/src/fastapiobserver/_version.py +1 -0
  7. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/fastapi.py +7 -0
  8. fastapi_observer-1.5.0/src/fastapiobserver/logging/filters.py +61 -0
  9. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/formatter.py +9 -4
  10. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/queueing.py +11 -0
  11. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/metrics.py +6 -2
  12. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/request_logging.py +31 -19
  13. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/tracing.py +0 -14
  14. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/plugins.py +3 -1
  15. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/normalize.py +4 -0
  16. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/proxies.py +15 -10
  17. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/redaction.py +37 -20
  18. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_fastapi_install.py +12 -0
  19. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_logging_schema_contract.py +28 -0
  20. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_log_correlation.py +7 -0
  21. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_trust_boundary.py +22 -1
  22. fastapi_observer-1.4.1/src/fastapiobserver/_version.py +0 -1
  23. fastapi_observer-1.4.1/src/fastapiobserver/logging/filters.py +0 -53
  24. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/LICENSE +0 -0
  25. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/setup.cfg +0 -0
  26. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
  27. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  28. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  29. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/__init__.py +0 -0
  30. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/__init__.py +0 -0
  31. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/formatter.py +0 -0
  32. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/providers.py +0 -0
  33. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/audit/verify.py +0 -0
  34. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/config.py +0 -0
  35. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/control_plane.py +0 -0
  36. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/db_tracing.py +0 -0
  37. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  38. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  39. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/__init__.py +0 -0
  40. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  41. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/setup.py +0 -0
  42. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/logging/state.py +0 -0
  43. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/loguru.py +0 -0
  44. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  45. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/builder.py +0 -0
  46. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/contracts.py +0 -0
  47. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/endpoint.py +0 -0
  48. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/noop.py +0 -0
  49. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/pathing.py +0 -0
  50. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/__init__.py +0 -0
  51. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/backend.py +0 -0
  52. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/client.py +0 -0
  53. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/collector.py +0 -0
  54. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/exemplars.py +0 -0
  55. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/prometheus/multiprocess.py +0 -0
  56. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/metrics/registry.py +0 -0
  57. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  58. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  59. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/context.py +0 -0
  60. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/events.py +0 -0
  61. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/headers.py +0 -0
  62. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/ip.py +0 -0
  63. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/middleware/span_errors.py +0 -0
  64. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/__init__.py +0 -0
  65. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  66. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  67. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/logs.py +0 -0
  68. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/metrics.py +0 -0
  69. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/resource.py +0 -0
  70. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/settings.py +0 -0
  71. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/otel/types.py +0 -0
  72. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/profiles.py +0 -0
  73. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/propagation.py +0 -0
  74. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/py.typed +0 -0
  75. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/request_context.py +0 -0
  76. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/__init__.py +0 -0
  77. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/security/policy.py +0 -0
  78. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  79. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  80. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  81. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/factory.py +0 -0
  82. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  83. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  84. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  85. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  86. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  87. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/registry.py +0 -0
  88. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/sinks/stats.py +0 -0
  89. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/src/fastapiobserver/utils.py +0 -0
  90. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_audit_chain.py +0 -0
  91. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_body_media_allowlist.py +0 -0
  92. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_config_settings.py +0 -0
  93. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_db_tracing.py +0 -0
  94. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_dx_parity.py +0 -0
  95. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_excluded_url_precedence.py +0 -0
  96. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_fork_queue_listener.py +0 -0
  97. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_graphql_integrations.py +0 -0
  98. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_lifespan_teardown.py +0 -0
  99. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_log_queue_controls.py +0 -0
  100. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_logtail_dlq.py +0 -0
  101. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_loguru_bridge.py +0 -0
  102. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_metrics_contracts.py +0 -0
  103. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_metrics_optional_dependency.py +0 -0
  104. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_middleware.py +0 -0
  105. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_negotiate_content_type.py +0 -0
  106. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_integration.py +0 -0
  107. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_lifecycle.py +0 -0
  108. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_logs_from_env.py +0 -0
  109. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_logs_pipeline.py +0 -0
  110. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otel_metrics_from_env.py +0 -0
  111. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_otlp_export_integration.py +0 -0
  112. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_plugin_isolation.py +0 -0
  113. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_profiles.py +0 -0
  114. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_propagation.py +0 -0
  115. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_redaction_presets.py +0 -0
  116. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_route_template_cardinality.py +0 -0
  117. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_runtime_control_auth.py +0 -0
  118. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_security_audit_fixes.py +0 -0
  119. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_security_redaction.py +0 -0
  120. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_sink_circuit_breaker.py +0 -0
  121. {fastapi_observer-1.4.1 → fastapi_observer-1.5.0}/tests/test_sinks.py +0 -0
  122. {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.4.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: <3.16,>=3.10
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: httpx>=0.28.1; python_version < "3.11" and extra == "dev"
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.10` to `3.15`
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.10` to `3.15` (CI matrix) |
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.10` to `3.15`
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.10` to `3.15` (CI matrix) |
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,<3.16"
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
- "httpx>=0.28.1; python_version < '3.11'",
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 = "py310"
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.4.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: <3.16,>=3.10
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: httpx>=0.28.1; python_version < "3.11" and extra == "dev"
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.10` to `3.15`
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.10` to `3.15` (CI matrix) |
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` |
@@ -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.now(timezone.utc).isoformat(),
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 = dict(candidate_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 = dict(sanitized_candidate)
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)
@@ -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
- request_content_type,
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(scope, receive_wrapper, send_wrapper)
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
- self.logger.exception("request.failed", extra={"event": safe_event})
144
+ level, message = logging.ERROR, "request.failed"
154
145
  elif status_code >= 500:
155
- self.logger.error("request.server_error", extra={"event": safe_event})
146
+ level, message = logging.ERROR, "request.server_error"
156
147
  elif status_code >= 400:
157
- self.logger.warning("request.client_error", extra={"event": safe_event})
148
+ level, message = logging.WARNING, "request.client_error"
158
149
  else:
159
- self.logger.info("request.completed", extra={"event": safe_event})
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:
@@ -1,3 +1,7 @@
1
+ import functools
2
+
3
+
4
+ @functools.lru_cache(maxsize=1024)
1
5
  def _normalize_key(key: str) -> str:
2
6
  return key.strip().lower().replace("_", "-")
3
7
 
@@ -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
- forwarded_for = None
39
- for k, v in headers:
40
- if k.lower() == b"x-forwarded-for":
41
- forwarded_for = v.decode("latin1")
42
- break
43
-
44
- if not forwarded_for:
45
- return client_ip
46
- first_hop = forwarded_for.split(",")[0].strip()
47
- return first_hop or client_ip
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 typing import Any, Mapping
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 = {_normalize_key(item) for item in policy.redacted_fields}
21
- sensitive_headers = {_normalize_key(item) for item in policy.redacted_headers}
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: set[str],
48
- sensitive_headers: set[str],
60
+ sensitive_fields: frozenset[str],
61
+ sensitive_headers: frozenset[str],
49
62
  parent_is_headers: bool,
50
63
  depth: int,
51
- header_allowlist: set[str] | None,
52
- event_key_allowlist: set[str] | None,
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: set[str],
92
- sensitive_headers: set[str],
108
+ sensitive_fields: frozenset[str],
109
+ sensitive_headers: frozenset[str],
93
110
  parent_is_headers: bool,
94
111
  depth: int,
95
- header_allowlist: set[str] | None,
96
- event_key_allowlist: set[str] | None,
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)