fastapi-observer 1.6.0__tar.gz → 1.8.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 (132) hide show
  1. {fastapi_observer-1.6.0/src/fastapi_observer.egg-info → fastapi_observer-1.8.0}/PKG-INFO +3 -3
  2. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/README.md +2 -2
  3. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/pyproject.toml +3 -0
  4. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0/src/fastapi_observer.egg-info}/PKG-INFO +3 -3
  5. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/SOURCES.txt +1 -14
  6. fastapi_observer-1.8.0/src/fastapiobserver/_version.py +1 -0
  7. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/config.py +14 -3
  8. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/control_plane.py +30 -2
  9. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/db_tracing.py +35 -23
  10. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/fastapi.py +59 -4
  11. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/integrations/strawberry.py +1 -1
  12. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/circuit_breaker.py +16 -1
  13. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/filters.py +2 -2
  14. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/formatter.py +1 -1
  15. fastapi_observer-1.8.0/src/fastapiobserver/metrics/__init__.py +144 -0
  16. fastapi_observer-1.8.0/src/fastapiobserver/metrics/prometheus.py +325 -0
  17. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/__init__.py +0 -2
  18. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/events.py +1 -1
  19. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/request_logging.py +0 -3
  20. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/__init__.py +0 -19
  21. fastapi_observer-1.8.0/src/fastapiobserver/otel/exclusions.py +29 -0
  22. fastapi_observer-1.8.0/src/fastapiobserver/otel/lifecycle.py +57 -0
  23. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/logs.py +45 -107
  24. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/metrics.py +10 -31
  25. fastapi_observer-1.8.0/src/fastapiobserver/otel/resource.py +151 -0
  26. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/tracing.py +15 -41
  27. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/propagation.py +100 -6
  28. fastapi_observer-1.8.0/src/fastapiobserver/sinks/discovery.py +77 -0
  29. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/factory.py +2 -2
  30. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/dlq.py +27 -9
  31. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/registry.py +2 -2
  32. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/utils.py +7 -3
  33. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_config_settings.py +10 -0
  34. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_db_tracing.py +40 -5
  35. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_excluded_url_precedence.py +8 -8
  36. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_fastapi_install.py +20 -0
  37. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_logtail_dlq.py +20 -0
  38. fastapi_observer-1.8.0/tests/test_metrics_contracts.py +102 -0
  39. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_metrics_optional_dependency.py +9 -21
  40. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_middleware.py +16 -24
  41. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_negotiate_content_type.py +41 -13
  42. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_otel_integration.py +81 -6
  43. fastapi_observer-1.8.0/tests/test_otel_lifecycle.py +67 -0
  44. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_otel_log_correlation.py +1 -2
  45. fastapi_observer-1.8.0/tests/test_otel_logs_pipeline.py +144 -0
  46. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_otlp_export_integration.py +0 -2
  47. fastapi_observer-1.8.0/tests/test_propagation.py +74 -0
  48. fastapi_observer-1.8.0/tests/test_runtime_control_auth.py +75 -0
  49. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_sink_circuit_breaker.py +28 -0
  50. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_sinks.py +34 -1
  51. fastapi_observer-1.6.0/src/fastapiobserver/_version.py +0 -1
  52. fastapi_observer-1.6.0/src/fastapiobserver/metrics/__init__.py +0 -41
  53. fastapi_observer-1.6.0/src/fastapiobserver/metrics/builder.py +0 -48
  54. fastapi_observer-1.6.0/src/fastapiobserver/metrics/contracts.py +0 -39
  55. fastapi_observer-1.6.0/src/fastapiobserver/metrics/endpoint.py +0 -180
  56. fastapi_observer-1.6.0/src/fastapiobserver/metrics/noop.py +0 -15
  57. fastapi_observer-1.6.0/src/fastapiobserver/metrics/pathing.py +0 -18
  58. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/__init__.py +0 -21
  59. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/backend.py +0 -126
  60. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/client.py +0 -19
  61. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/collector.py +0 -170
  62. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/exemplars.py +0 -19
  63. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/multiprocess.py +0 -68
  64. fastapi_observer-1.6.0/src/fastapiobserver/metrics/registry.py +0 -42
  65. fastapi_observer-1.6.0/src/fastapiobserver/middleware/span_errors.py +0 -34
  66. fastapi_observer-1.6.0/src/fastapiobserver/otel/exclusions.py +0 -45
  67. fastapi_observer-1.6.0/src/fastapiobserver/otel/lifecycle.py +0 -159
  68. fastapi_observer-1.6.0/src/fastapiobserver/otel/resource.py +0 -234
  69. fastapi_observer-1.6.0/src/fastapiobserver/otel/types.py +0 -49
  70. fastapi_observer-1.6.0/src/fastapiobserver/sinks/discovery.py +0 -68
  71. fastapi_observer-1.6.0/tests/test_metrics_contracts.py +0 -182
  72. fastapi_observer-1.6.0/tests/test_otel_lifecycle.py +0 -98
  73. fastapi_observer-1.6.0/tests/test_otel_logs_pipeline.py +0 -73
  74. fastapi_observer-1.6.0/tests/test_propagation.py +0 -18
  75. fastapi_observer-1.6.0/tests/test_runtime_control_auth.py +0 -41
  76. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/LICENSE +0 -0
  77. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/setup.cfg +0 -0
  78. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  79. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  80. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  81. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/__init__.py +0 -0
  82. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/__init__.py +0 -0
  83. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/formatter.py +0 -0
  84. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/providers.py +0 -0
  85. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/verify.py +0 -0
  86. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  87. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/__init__.py +0 -0
  88. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/queueing.py +0 -0
  89. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/setup.py +0 -0
  90. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/state.py +0 -0
  91. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/loguru.py +0 -0
  92. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  93. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/context.py +0 -0
  94. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/headers.py +0 -0
  95. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/ip.py +0 -0
  96. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  97. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/settings.py +0 -0
  98. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/plugins.py +0 -0
  99. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/profiles.py +0 -0
  100. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/py.typed +0 -0
  101. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/request_context.py +0 -0
  102. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/__init__.py +0 -0
  103. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/normalize.py +0 -0
  104. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/policy.py +0 -0
  105. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/proxies.py +0 -0
  106. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/redaction.py +0 -0
  107. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  108. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  109. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  110. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  111. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  112. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  113. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/stats.py +0 -0
  114. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_audit_chain.py +0 -0
  115. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_body_media_allowlist.py +0 -0
  116. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_dx_parity.py +0 -0
  117. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_fork_queue_listener.py +0 -0
  118. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_graphql_integrations.py +0 -0
  119. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_lifespan_teardown.py +0 -0
  120. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_log_queue_controls.py +0 -0
  121. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_logging_schema_contract.py +0 -0
  122. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_loguru_bridge.py +0 -0
  123. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_otel_logs_from_env.py +0 -0
  124. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_otel_metrics_from_env.py +0 -0
  125. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_plugin_isolation.py +0 -0
  126. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_profiles.py +0 -0
  127. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_redaction_presets.py +0 -0
  128. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_route_template_cardinality.py +0 -0
  129. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_security_audit_fixes.py +0 -0
  130. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_security_redaction.py +0 -0
  131. {fastapi_observer-1.6.0 → fastapi_observer-1.8.0}/tests/test_trust_boundary.py +0 -0
  132. {fastapi_observer-1.6.0 → fastapi_observer-1.8.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.6.0
3
+ Version: 1.8.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
@@ -147,7 +147,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
147
147
  ## What you get
148
148
 
149
149
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
150
- - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, and returned in the response.
150
+ - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
151
151
  - **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
152
152
  - **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so URLs with IDs don't explode cardinality.
153
153
  - **OpenTelemetry traces** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
@@ -186,7 +186,7 @@ What it costs: with default settings, about 43 µs of CPU per request on an Appl
186
186
 
187
187
  ## Why you can trust it
188
188
 
189
- - Tested in CI on Python 3.11 to 3.15, fully typed, and used in production by its maintainer.
189
+ - Tested in CI on Python 3.11 to 3.15, type-checked with `mypy --strict`, tested nightly against the newest OpenTelemetry releases, and used in production by its maintainer.
190
190
  - Releases are published with PyPI Trusted Publishing and signed attestations. CI runs `pip-audit` and generates an SBOM.
191
191
  - Security fixes are called out in the [changelog](https://github.com/Vitaee/FastapiObserver/blob/main/CHANGELOG.md). To report a vulnerability, see [SECURITY.md](https://github.com/Vitaee/FastapiObserver/blob/main/SECURITY.md).
192
192
  - Benchmarks are reproducible from [`examples/benchmarks`](https://github.com/Vitaee/FastapiObserver/tree/main/examples/benchmarks).
@@ -71,7 +71,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
71
71
  ## What you get
72
72
 
73
73
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
74
- - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, and returned in the response.
74
+ - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
75
75
  - **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
76
76
  - **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so URLs with IDs don't explode cardinality.
77
77
  - **OpenTelemetry traces** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
@@ -110,7 +110,7 @@ What it costs: with default settings, about 43 µs of CPU per request on an Appl
110
110
 
111
111
  ## Why you can trust it
112
112
 
113
- - Tested in CI on Python 3.11 to 3.15, fully typed, and used in production by its maintainer.
113
+ - Tested in CI on Python 3.11 to 3.15, type-checked with `mypy --strict`, tested nightly against the newest OpenTelemetry releases, and used in production by its maintainer.
114
114
  - Releases are published with PyPI Trusted Publishing and signed attestations. CI runs `pip-audit` and generates an SBOM.
115
115
  - Security fixes are called out in the [changelog](https://github.com/Vitaee/FastapiObserver/blob/main/CHANGELOG.md). To report a vulnerability, see [SECURITY.md](https://github.com/Vitaee/FastapiObserver/blob/main/SECURITY.md).
116
116
  - Benchmarks are reproducible from [`examples/benchmarks`](https://github.com/Vitaee/FastapiObserver/tree/main/examples/benchmarks).
@@ -115,7 +115,10 @@ src = ["src", "tests"]
115
115
  select = ["E", "F"]
116
116
 
117
117
  [tool.mypy]
118
+ strict = true
118
119
  ignore_missing_imports = false
120
+ # prometheus_client's multiprocess module has no annotations.
121
+ untyped_calls_exclude = ["prometheus_client.multiprocess"]
119
122
 
120
123
  [[tool.mypy.overrides]]
121
124
  module = ["orjson", "orjson.*"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 1.6.0
3
+ Version: 1.8.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
@@ -147,7 +147,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
147
147
  ## What you get
148
148
 
149
149
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
150
- - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, and returned in the response.
150
+ - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
151
151
  - **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
152
152
  - **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so URLs with IDs don't explode cardinality.
153
153
  - **OpenTelemetry traces** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
@@ -186,7 +186,7 @@ What it costs: with default settings, about 43 µs of CPU per request on an Appl
186
186
 
187
187
  ## Why you can trust it
188
188
 
189
- - Tested in CI on Python 3.11 to 3.15, fully typed, and used in production by its maintainer.
189
+ - Tested in CI on Python 3.11 to 3.15, type-checked with `mypy --strict`, tested nightly against the newest OpenTelemetry releases, and used in production by its maintainer.
190
190
  - Releases are published with PyPI Trusted Publishing and signed attestations. CI runs `pip-audit` and generates an SBOM.
191
191
  - Security fixes are called out in the [changelog](https://github.com/Vitaee/FastapiObserver/blob/main/CHANGELOG.md). To report a vulnerability, see [SECURITY.md](https://github.com/Vitaee/FastapiObserver/blob/main/SECURITY.md).
192
192
  - Benchmarks are reproducible from [`examples/benchmarks`](https://github.com/Vitaee/FastapiObserver/tree/main/examples/benchmarks).
@@ -33,18 +33,7 @@ src/fastapiobserver/logging/queueing.py
33
33
  src/fastapiobserver/logging/setup.py
34
34
  src/fastapiobserver/logging/state.py
35
35
  src/fastapiobserver/metrics/__init__.py
36
- src/fastapiobserver/metrics/builder.py
37
- src/fastapiobserver/metrics/contracts.py
38
- src/fastapiobserver/metrics/endpoint.py
39
- src/fastapiobserver/metrics/noop.py
40
- src/fastapiobserver/metrics/pathing.py
41
- src/fastapiobserver/metrics/registry.py
42
- src/fastapiobserver/metrics/prometheus/__init__.py
43
- src/fastapiobserver/metrics/prometheus/backend.py
44
- src/fastapiobserver/metrics/prometheus/client.py
45
- src/fastapiobserver/metrics/prometheus/collector.py
46
- src/fastapiobserver/metrics/prometheus/exemplars.py
47
- src/fastapiobserver/metrics/prometheus/multiprocess.py
36
+ src/fastapiobserver/metrics/prometheus.py
48
37
  src/fastapiobserver/middleware/__init__.py
49
38
  src/fastapiobserver/middleware/body_capture.py
50
39
  src/fastapiobserver/middleware/context.py
@@ -53,7 +42,6 @@ src/fastapiobserver/middleware/headers.py
53
42
  src/fastapiobserver/middleware/ip.py
54
43
  src/fastapiobserver/middleware/metrics.py
55
44
  src/fastapiobserver/middleware/request_logging.py
56
- src/fastapiobserver/middleware/span_errors.py
57
45
  src/fastapiobserver/otel/__init__.py
58
46
  src/fastapiobserver/otel/exclusions.py
59
47
  src/fastapiobserver/otel/lifecycle.py
@@ -62,7 +50,6 @@ src/fastapiobserver/otel/metrics.py
62
50
  src/fastapiobserver/otel/resource.py
63
51
  src/fastapiobserver/otel/settings.py
64
52
  src/fastapiobserver/otel/tracing.py
65
- src/fastapiobserver/otel/types.py
66
53
  src/fastapiobserver/security/__init__.py
67
54
  src/fastapiobserver/security/normalize.py
68
55
  src/fastapiobserver/security/policy.py
@@ -0,0 +1 @@
1
+ __version__ = "1.8.0"
@@ -2,11 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import re
5
- from typing import Literal
5
+ from typing import Annotated, Literal
6
6
 
7
7
  from pydantic import Field, field_validator
8
+ from pydantic_settings import NoDecode
8
9
 
9
- from .utils import InternalSettingsBase, normalize_path, parse_csv
10
+ from .utils import InternalSettingsBase, normalize_path, parse_csv, parse_csv_tuple
10
11
 
11
12
  _HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9-]+$")
12
13
  DEFAULT_METRICS_EXCLUDE_PATHS = (
@@ -69,7 +70,7 @@ class ObservabilitySettings(InternalSettingsBase):
69
70
  metrics_enabled: bool = Field(default=False, validation_alias="METRICS_ENABLED")
70
71
  metrics_backend: str = Field(default="prometheus", validation_alias="METRICS_BACKEND")
71
72
  metrics_path: str = Field(default="/metrics", validation_alias="METRICS_PATH")
72
- metrics_exclude_paths: tuple[str, ...] = Field(
73
+ metrics_exclude_paths: Annotated[tuple[str, ...], NoDecode] = Field(
73
74
  default=DEFAULT_METRICS_EXCLUDE_PATHS,
74
75
  validation_alias="METRICS_EXCLUDE_PATHS",
75
76
  )
@@ -85,6 +86,11 @@ class ObservabilitySettings(InternalSettingsBase):
85
86
  default=None, validation_alias="OTEL_EXCLUDED_URLS"
86
87
  )
87
88
 
89
+ # --- extra sinks from installed packages ---
90
+ log_sinks: Annotated[tuple[str, ...], NoDecode] = Field(
91
+ default=(), validation_alias="OBS_LOG_SINKS"
92
+ )
93
+
88
94
  # --- logtail (Better Stack) ---
89
95
  logtail_enabled: bool = Field(default=False, validation_alias="LOGTAIL_ENABLED")
90
96
  logtail_source_token: str | None = Field(
@@ -178,6 +184,11 @@ class ObservabilitySettings(InternalSettingsBase):
178
184
  return parsed
179
185
  return DEFAULT_METRICS_EXCLUDE_PATHS
180
186
 
187
+ @field_validator("log_sinks", mode="before")
188
+ @classmethod
189
+ def _parse_log_sinks(cls, value: object) -> tuple[str, ...]:
190
+ return parse_csv_tuple(value, ())
191
+
181
192
  @field_validator("metrics_exclude_paths")
182
193
  @classmethod
183
194
  def _normalize_metrics_exclude_paths(
@@ -6,11 +6,12 @@ import secrets
6
6
  import weakref
7
7
  from typing import Annotated, Literal
8
8
 
9
- from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, status
9
+ from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
10
10
  from pydantic import BaseModel, ConfigDict, Field, field_validator
11
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
12
12
 
13
13
  from .otel import get_trace_sampling_ratio, set_trace_sampling_ratio
14
+ from .security import TrustedProxyPolicy, is_trusted_client_ip, resolve_client_ip
14
15
  from .utils import EnvLoadable, normalize_path
15
16
 
16
17
  __all__ = [
@@ -52,7 +53,15 @@ class ControlPlanePayload(BaseModel):
52
53
  trace_sampling_ratio: float | None = Field(default=None, ge=0.0, le=1.0)
53
54
 
54
55
 
55
- def mount_control_plane(app: FastAPI, settings: RuntimeControlSettings) -> None:
56
+ def mount_control_plane(
57
+ app: FastAPI,
58
+ settings: RuntimeControlSettings,
59
+ trusted_proxy_policy: TrustedProxyPolicy | None = None,
60
+ ) -> None:
61
+ """Mount the runtime control endpoint.
62
+
63
+ Callers must come from ``OBS_TRUSTED_CIDRS`` and send the bearer token.
64
+ """
56
65
  if not settings.enabled:
57
66
  return
58
67
  if settings.auth_mode != "token":
@@ -69,9 +78,15 @@ def mount_control_plane(app: FastAPI, settings: RuntimeControlSettings) -> None:
69
78
  if normalized_path in mounted_paths:
70
79
  return
71
80
 
81
+ policy = trusted_proxy_policy or TrustedProxyPolicy.from_env()
82
+
72
83
  def authorize(
84
+ request: Request,
73
85
  authorization: Annotated[str | None, Header()] = None,
74
86
  ) -> None:
87
+ # Checked before the token, so callers outside the network can't guess it.
88
+ if not _is_trusted_caller(request, policy):
89
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
75
90
  current_token = os.getenv(settings.token_env_var)
76
91
  if not current_token:
77
92
  raise HTTPException(
@@ -107,6 +122,19 @@ def mount_control_plane(app: FastAPI, settings: RuntimeControlSettings) -> None:
107
122
  mounted_paths.add(normalized_path)
108
123
 
109
124
 
125
+ def _is_trusted_caller(request: Request, policy: TrustedProxyPolicy) -> bool:
126
+ headers = request.scope["headers"]
127
+ if (
128
+ policy.enabled
129
+ and not policy.honor_forwarded_headers
130
+ and any(name.lower() == b"x-forwarded-for" for name, _ in headers)
131
+ ):
132
+ # Came through a proxy, so the peer address isn't the real caller's.
133
+ return False
134
+ peer = request.client.host if request.client else None
135
+ return is_trusted_client_ip(resolve_client_ip(peer, headers, policy), policy)
136
+
137
+
110
138
  def _current_runtime_settings() -> dict[str, object]:
111
139
  return {
112
140
  "log_level": logging.getLevelName(logging.getLogger().getEffectiveLevel()),
@@ -12,7 +12,7 @@ Usage::
12
12
 
13
13
  engine = create_engine("postgresql://...")
14
14
  instrument_sqlalchemy(engine)
15
- # SELECT * FROM users /*traceparent='00-abc123...',route='/api/users'*/
15
+ # SELECT * FROM users /*db_driver='psycopg2',traceparent='00-abc123...'*/
16
16
 
17
17
  Or for async engines::
18
18
 
@@ -35,9 +35,12 @@ _DEFAULT_COMMENTER_OPTIONS: dict[str, bool] = {
35
35
  "opentelemetry_values": True, # injects traceparent
36
36
  "db_driver": True,
37
37
  "db_framework": False, # noisy — omit by default
38
- "route": True,
39
38
  }
40
39
 
40
+ # OpenTelemetry's instrumentor is a singleton that ignores a second
41
+ # ``instrument()`` call, so every call re-instruments all engines seen so far.
42
+ _ENGINES: list[Any] = []
43
+
41
44
  _PACKAGE_HINT = "fastapi-observer[otel-sqlalchemy]"
42
45
 
43
46
 
@@ -58,38 +61,46 @@ def instrument_sqlalchemy(
58
61
  context to every query.
59
62
  commenter_options:
60
63
  Key-value pairs controlling which fields appear in the SQL
61
- comment. Defaults to ``traceparent``, ``db_driver``, and
62
- ``route``.
64
+ comment (``opentelemetry_values``, ``db_driver``, ``db_framework``).
65
+ Defaults to the trace context and ``db_driver``.
66
+
67
+ Call it once per engine. The commenter settings of the latest call apply
68
+ to every instrumented engine.
63
69
  """
64
70
  try:
65
71
  instrumentor_module = lazy_import(
66
72
  "opentelemetry.instrumentation.sqlalchemy",
67
73
  package_hint=_PACKAGE_HINT,
68
74
  )
69
- instrumentor = instrumentor_module.SQLAlchemyInstrumentor()
70
-
71
- options = commenter_options or _DEFAULT_COMMENTER_OPTIONS
72
-
73
- instrumentor.instrument(
74
- engine=engine,
75
- enable_commenter=enable_commenter,
76
- commenter_options=options,
77
- )
78
- _LOGGER.info(
79
- "db_tracing.instrumented",
80
- extra={
81
- "event": {
82
- "engine_url": str(engine.url),
83
- "commenter_enabled": enable_commenter,
84
- },
85
- "_skip_enrichers": True,
86
- },
87
- )
88
75
  except RuntimeError as exc:
89
76
  raise RuntimeError(
90
77
  "SQLAlchemy instrumentation requires "
91
78
  f"`pip install {_PACKAGE_HINT}`"
92
79
  ) from exc
80
+ instrumentor = instrumentor_module.SQLAlchemyInstrumentor()
81
+
82
+ if not any(known is engine for known in _ENGINES):
83
+ _ENGINES.append(engine)
84
+ if instrumentor.is_instrumented_by_opentelemetry:
85
+ instrumentor.uninstrument()
86
+ instrumentor.instrument(
87
+ engines=list(_ENGINES),
88
+ enable_commenter=enable_commenter,
89
+ commenter_options=commenter_options or _DEFAULT_COMMENTER_OPTIONS,
90
+ )
91
+
92
+ event = {"engine_url": str(engine.url), "commenter_enabled": enable_commenter}
93
+ if not instrumentor.is_instrumented_by_opentelemetry:
94
+ # OpenTelemetry refuses unsupported SQLAlchemy versions and only logs it.
95
+ _LOGGER.warning(
96
+ "db_tracing.not_instrumented",
97
+ extra={"event": event, "_skip_enrichers": True},
98
+ )
99
+ return
100
+ _LOGGER.info(
101
+ "db_tracing.instrumented",
102
+ extra={"event": event, "_skip_enrichers": True},
103
+ )
93
104
 
94
105
 
95
106
  def instrument_sqlalchemy_async(
@@ -134,6 +145,7 @@ def uninstrument_sqlalchemy() -> None:
134
145
  instrumentor_module.SQLAlchemyInstrumentor().uninstrument()
135
146
  except (ModuleNotFoundError, RuntimeError):
136
147
  pass
148
+ _ENGINES.clear()
137
149
 
138
150
 
139
151
  __all__ = [
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import os
4
5
  import re
5
6
  import weakref
6
7
  import typing
8
+ from urllib.parse import urlsplit, urlunsplit
7
9
  from collections.abc import Sequence
8
10
  from contextlib import asynccontextmanager
9
11
  from dataclasses import dataclass
10
- from typing import Any, Literal
12
+ from typing import Any, Literal, cast
11
13
 
12
14
  from fastapi import FastAPI
13
15
 
@@ -31,6 +33,7 @@ from .otel import (
31
33
  install_otel_logs,
32
34
  install_otel_metrics,
33
35
  )
36
+ from .propagation import install_request_id_propagation
34
37
  from .security import SecurityPolicy, TrustedProxyPolicy
35
38
 
36
39
  _LOGGER = logging.getLogger("fastapiobserver.fastapi")
@@ -125,6 +128,7 @@ def install_observability(
125
128
  trusted_proxy_policy,
126
129
  metrics_backend,
127
130
  )
131
+ install_request_id_propagation(settings.request_id_header)
128
132
 
129
133
  # --- 7. Uvicorn access log ---
130
134
  # The middleware's `request.completed` line carries the same fields, so drop
@@ -135,7 +139,55 @@ def install_observability(
135
139
 
136
140
  # --- 8. Runtime control plane ---
137
141
  if runtime_control_settings and runtime_control_settings.enabled:
138
- mount_control_plane(app, runtime_control_settings)
142
+ mount_control_plane(app, runtime_control_settings, trusted_proxy_policy)
143
+
144
+ # --- 9. One line with the settings that took effect ---
145
+ _LOGGER.info(
146
+ "observability.configured",
147
+ extra={
148
+ "event": {
149
+ "profile": os.getenv("OBS_PROFILE", "").strip().lower() or None,
150
+ "log_level": settings.log_level,
151
+ "metrics_path": (
152
+ settings.metrics_path
153
+ if (settings.metrics_enabled if metrics_enabled is None else metrics_enabled)
154
+ else None
155
+ ),
156
+ "tracing": otel_settings.enabled,
157
+ "otlp_endpoint": _endpoint_for_log(otel_settings.otlp_endpoint),
158
+ "otlp_protocol": otel_settings.protocol,
159
+ "trace_sampling_ratio": otel_settings.trace_sampling_ratio,
160
+ "otel_logs_mode": (
161
+ otel_logs_settings.logs_mode if otel_logs_settings.enabled else None
162
+ ),
163
+ "otel_metrics": otel_metrics_settings.enabled,
164
+ "redaction_mode": security_policy.redaction_mode,
165
+ "body_capture": (
166
+ security_policy.log_request_body or security_policy.log_response_body
167
+ ),
168
+ "trusted_cidrs": (
169
+ list(trusted_proxy_policy.trusted_cidrs)
170
+ if trusted_proxy_policy.enabled
171
+ else None
172
+ ),
173
+ "honor_forwarded_headers": trusted_proxy_policy.honor_forwarded_headers,
174
+ "request_id_header": settings.request_id_header,
175
+ "control_plane": (
176
+ runtime_control_settings.path if runtime_control_settings.enabled else None
177
+ ),
178
+ "log_sinks": list(settings.log_sinks),
179
+ },
180
+ "_skip_enrichers": True,
181
+ },
182
+ )
183
+
184
+
185
+ def _endpoint_for_log(endpoint: str | None) -> str | None:
186
+ """*endpoint* without credentials, query or fragment, which can carry tokens."""
187
+ if not endpoint:
188
+ return None
189
+ parts = urlsplit(endpoint)
190
+ return urlunsplit((parts.scheme, parts.netloc.rpartition("@")[2], parts.path, "", ""))
139
191
 
140
192
 
141
193
  def _setup_structured_logging(
@@ -146,6 +198,9 @@ def _setup_structured_logging(
146
198
  security_policy: SecurityPolicy,
147
199
  audit_key_provider: object | None,
148
200
  ) -> None:
201
+ # Hook the lifespan before install_otel_logs does, so on shutdown the log
202
+ # queue drains into the OTLP handler before its provider is shut down.
203
+ _register_logging_shutdown_hook(app)
149
204
  otel_log_handler = None
150
205
  logs_mode: Literal["local_json", "otlp", "both"] = "local_json"
151
206
  if otel_logs_settings and otel_logs_settings.enabled:
@@ -172,7 +227,6 @@ def _setup_structured_logging(
172
227
  extra_handlers=extra_handlers,
173
228
  audit_key_provider=audit_key_provider,
174
229
  )
175
- _register_logging_shutdown_hook(app)
176
230
 
177
231
 
178
232
  def _setup_metrics_backend(
@@ -260,7 +314,8 @@ def _setup_request_middleware(
260
314
  )
261
315
 
262
316
  for middleware in app.user_middleware:
263
- if middleware.cls is RequestLoggingMiddleware:
317
+ # Starlette types `cls` as a middleware factory protocol, which mypy can't compare.
318
+ if cast(object, middleware.cls) is RequestLoggingMiddleware:
264
319
  return
265
320
 
266
321
  app.add_middleware(
@@ -60,7 +60,7 @@ class StrawberryObservabilityExtension:
60
60
 
61
61
  # 2. Update OpenTelemetry Trace if active
62
62
  try:
63
- from opentelemetry import trace as otel_trace # type: ignore
63
+ from opentelemetry import trace as otel_trace
64
64
 
65
65
  span = otel_trace.get_current_span()
66
66
  if span and span.is_recording():
@@ -69,10 +69,22 @@ class SinkCircuitBreakerHandler(logging.Handler):
69
69
  self._half_open_total = 0
70
70
  self._closes_total = 0
71
71
 
72
+ # Stdlib handlers catch their own write errors and only report them
73
+ # through handleError(), so count those reports as failures too.
74
+ self._delegate_failed = False
75
+ report_error = delegate.handleError
76
+
77
+ def _count_and_report(record: logging.LogRecord) -> None:
78
+ self._delegate_failed = True
79
+ report_error(record)
80
+
81
+ delegate.handleError = _count_and_report # type: ignore[method-assign]
82
+
72
83
  def emit(self, record: logging.LogRecord) -> None:
73
84
  if self._should_skip():
74
85
  return
75
86
 
87
+ self._delegate_failed = False
76
88
  try:
77
89
  self._delegate.handle(record)
78
90
  except Exception:
@@ -80,7 +92,10 @@ class SinkCircuitBreakerHandler(logging.Handler):
80
92
  self.handleError(record)
81
93
  return
82
94
 
83
- self._record_success()
95
+ if self._delegate_failed:
96
+ self._record_failure()
97
+ else:
98
+ self._record_success()
84
99
 
85
100
  def setFormatter(self, fmt: logging.Formatter | None) -> None: # noqa: N802
86
101
  super().setFormatter(fmt)
@@ -48,9 +48,9 @@ class TraceContextFilter(logging.Filter):
48
48
  )
49
49
 
50
50
  if trace_id:
51
- record.trace_id = trace_id # type: ignore[attr-defined]
51
+ record.trace_id = trace_id
52
52
  if span_id:
53
- record.span_id = span_id # type: ignore[attr-defined]
53
+ record.span_id = span_id
54
54
  return True
55
55
 
56
56
 
@@ -122,7 +122,7 @@ def _as_dict(value: Mapping[str, Any]) -> dict[str, Any]:
122
122
 
123
123
  def _json_dumps(payload: dict[str, Any]) -> str:
124
124
  if orjson is not None:
125
- return orjson.dumps(payload).decode("utf-8")
125
+ return str(orjson.dumps(payload), "utf-8")
126
126
  return json.dumps(payload, ensure_ascii=True, default=str)
127
127
 
128
128
 
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Callable, Protocol
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from ..config import MetricsFormat
9
+ from .prometheus import (
10
+ PrometheusMetricsBackend,
11
+ _build_prometheus_metrics_backend,
12
+ mark_prometheus_process_dead,
13
+ mount_metrics_endpoint,
14
+ )
15
+
16
+
17
+ class MetricsBackend(Protocol):
18
+ """Interface for metrics recording backends."""
19
+
20
+ def observe(
21
+ self,
22
+ method: str,
23
+ path: str,
24
+ status_code: int,
25
+ duration_seconds: float,
26
+ ) -> None: ...
27
+
28
+
29
+ class MountableMetricsBackend(Protocol):
30
+ """Optional extension for backends that can mount their endpoint."""
31
+
32
+ def mount_endpoint(
33
+ self,
34
+ app: FastAPI,
35
+ *,
36
+ path: str = "/metrics",
37
+ metrics_format: MetricsFormat = "negotiate",
38
+ ) -> None: ...
39
+
40
+
41
+ MetricsBackendFactory = Callable[..., MetricsBackend]
42
+
43
+
44
+ class NoopMetricsBackend:
45
+ """No-op backend that silently discards all observations."""
46
+
47
+ def observe(
48
+ self,
49
+ method: str,
50
+ path: str,
51
+ status_code: int,
52
+ duration_seconds: float,
53
+ ) -> None:
54
+ return None
55
+
56
+
57
+ _UUID_RE = re.compile(
58
+ r"/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"
59
+ )
60
+ _NUMBER_RE = re.compile(r"/\d+")
61
+ _HEX_RE = re.compile(r"/[0-9a-fA-F]{16,}")
62
+
63
+
64
+ def collapse_dynamic_segments(path: str) -> str:
65
+ """Replace dynamic path segments with ``/:id`` to control label cardinality."""
66
+ normalized = _UUID_RE.sub("/:id", path)
67
+ normalized = _HEX_RE.sub("/:id", normalized)
68
+ return _NUMBER_RE.sub("/:id", normalized)
69
+
70
+
71
+ _BACKEND_FACTORIES: dict[str, MetricsBackendFactory] = {}
72
+
73
+
74
+ def register_metrics_backend(name: str, factory: MetricsBackendFactory) -> None:
75
+ normalized_name = name.strip().lower()
76
+ if not normalized_name:
77
+ raise ValueError("Metrics backend name cannot be empty")
78
+ _BACKEND_FACTORIES[normalized_name] = factory
79
+
80
+
81
+ def unregister_metrics_backend(name: str) -> None:
82
+ _BACKEND_FACTORIES.pop(name.strip().lower(), None)
83
+
84
+
85
+ def get_registered_metrics_backends() -> dict[str, MetricsBackendFactory]:
86
+ return dict(_BACKEND_FACTORIES)
87
+
88
+
89
+ def build_metrics_backend(
90
+ enabled: bool,
91
+ *,
92
+ service: str = "api",
93
+ environment: str = "development",
94
+ exemplars_enabled: bool = False,
95
+ backend: str = "prometheus",
96
+ ) -> MetricsBackend:
97
+ """Factory for selecting the appropriate metrics backend."""
98
+ if not enabled:
99
+ return NoopMetricsBackend()
100
+ factory = _BACKEND_FACTORIES.get(backend.strip().lower())
101
+ if factory is None:
102
+ available_csv = ", ".join(sorted(_BACKEND_FACTORIES)) or "(none)"
103
+ raise ValueError(
104
+ f"Unknown metrics backend: {backend!r}. Available backends: {available_csv}"
105
+ )
106
+ return factory(
107
+ service=service,
108
+ environment=environment,
109
+ exemplars_enabled=exemplars_enabled,
110
+ )
111
+
112
+
113
+ def mount_backend_metrics_endpoint(
114
+ app: FastAPI,
115
+ backend: MetricsBackend,
116
+ *,
117
+ path: str = "/metrics",
118
+ metrics_format: MetricsFormat = "negotiate",
119
+ ) -> bool:
120
+ mount_endpoint = getattr(backend, "mount_endpoint", None)
121
+ if not callable(mount_endpoint):
122
+ return False
123
+ mount_endpoint(app, path=path, metrics_format=metrics_format)
124
+ return True
125
+
126
+
127
+ register_metrics_backend("prometheus", _build_prometheus_metrics_backend)
128
+
129
+ __all__ = [
130
+ "MetricsBackend",
131
+ "MountableMetricsBackend",
132
+ "MetricsBackendFactory",
133
+ "MetricsFormat",
134
+ "NoopMetricsBackend",
135
+ "collapse_dynamic_segments",
136
+ "register_metrics_backend",
137
+ "unregister_metrics_backend",
138
+ "get_registered_metrics_backends",
139
+ "build_metrics_backend",
140
+ "mount_backend_metrics_endpoint",
141
+ "PrometheusMetricsBackend",
142
+ "mark_prometheus_process_dead",
143
+ "mount_metrics_endpoint",
144
+ ]