fastapi-observer 1.7.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 (111) hide show
  1. {fastapi_observer-1.7.0/src/fastapi_observer.egg-info → fastapi_observer-1.8.0}/PKG-INFO +3 -3
  2. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/README.md +2 -2
  3. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/pyproject.toml +3 -0
  4. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0/src/fastapi_observer.egg-info}/PKG-INFO +3 -3
  5. fastapi_observer-1.8.0/src/fastapiobserver/_version.py +1 -0
  6. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/config.py +14 -3
  7. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/control_plane.py +30 -2
  8. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/fastapi.py +56 -3
  9. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/integrations/strawberry.py +1 -1
  10. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/filters.py +2 -2
  11. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/formatter.py +1 -1
  12. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/events.py +1 -1
  13. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/resource.py +2 -1
  14. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/propagation.py +100 -6
  15. fastapi_observer-1.8.0/src/fastapiobserver/sinks/discovery.py +77 -0
  16. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/factory.py +2 -2
  17. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/dlq.py +27 -9
  18. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/registry.py +2 -2
  19. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/utils.py +7 -3
  20. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_config_settings.py +10 -0
  21. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_fastapi_install.py +20 -0
  22. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_logtail_dlq.py +20 -0
  23. fastapi_observer-1.8.0/tests/test_propagation.py +74 -0
  24. fastapi_observer-1.8.0/tests/test_runtime_control_auth.py +75 -0
  25. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_sinks.py +34 -1
  26. fastapi_observer-1.7.0/src/fastapiobserver/_version.py +0 -1
  27. fastapi_observer-1.7.0/src/fastapiobserver/sinks/discovery.py +0 -68
  28. fastapi_observer-1.7.0/tests/test_propagation.py +0 -18
  29. fastapi_observer-1.7.0/tests/test_runtime_control_auth.py +0 -41
  30. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/LICENSE +0 -0
  31. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/setup.cfg +0 -0
  32. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
  33. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  34. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  35. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  36. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/__init__.py +0 -0
  37. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/__init__.py +0 -0
  38. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/formatter.py +0 -0
  39. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/providers.py +0 -0
  40. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/verify.py +0 -0
  41. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/db_tracing.py +0 -0
  42. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  43. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/__init__.py +0 -0
  44. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  45. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/queueing.py +0 -0
  46. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/setup.py +0 -0
  47. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/state.py +0 -0
  48. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/loguru.py +0 -0
  49. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  50. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
  51. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  52. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  53. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/context.py +0 -0
  54. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/headers.py +0 -0
  55. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/ip.py +0 -0
  56. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  57. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/request_logging.py +0 -0
  58. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/__init__.py +0 -0
  59. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  60. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  61. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/logs.py +0 -0
  62. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/metrics.py +0 -0
  63. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/settings.py +0 -0
  64. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/tracing.py +0 -0
  65. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/plugins.py +0 -0
  66. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/profiles.py +0 -0
  67. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/py.typed +0 -0
  68. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/request_context.py +0 -0
  69. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/__init__.py +0 -0
  70. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/normalize.py +0 -0
  71. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/policy.py +0 -0
  72. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/proxies.py +0 -0
  73. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/redaction.py +0 -0
  74. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  75. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  76. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  77. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  78. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  79. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  80. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/stats.py +0 -0
  81. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_audit_chain.py +0 -0
  82. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_body_media_allowlist.py +0 -0
  83. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_db_tracing.py +0 -0
  84. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_dx_parity.py +0 -0
  85. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_excluded_url_precedence.py +0 -0
  86. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_fork_queue_listener.py +0 -0
  87. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_graphql_integrations.py +0 -0
  88. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_lifespan_teardown.py +0 -0
  89. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_log_queue_controls.py +0 -0
  90. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_logging_schema_contract.py +0 -0
  91. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_loguru_bridge.py +0 -0
  92. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_metrics_contracts.py +0 -0
  93. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_metrics_optional_dependency.py +0 -0
  94. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_middleware.py +0 -0
  95. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_negotiate_content_type.py +0 -0
  96. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_integration.py +0 -0
  97. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_lifecycle.py +0 -0
  98. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_log_correlation.py +0 -0
  99. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_logs_from_env.py +0 -0
  100. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_logs_pipeline.py +0 -0
  101. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_metrics_from_env.py +0 -0
  102. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otlp_export_integration.py +0 -0
  103. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_plugin_isolation.py +0 -0
  104. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_profiles.py +0 -0
  105. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_redaction_presets.py +0 -0
  106. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_route_template_cardinality.py +0 -0
  107. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_security_audit_fixes.py +0 -0
  108. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_security_redaction.py +0 -0
  109. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_sink_circuit_breaker.py +0 -0
  110. {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_trust_boundary.py +0 -0
  111. {fastapi_observer-1.7.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.7.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.7.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).
@@ -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()),
@@ -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(
@@ -262,7 +314,8 @@ def _setup_request_middleware(
262
314
  )
263
315
 
264
316
  for middleware in app.user_middleware:
265
- 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:
266
319
  return
267
320
 
268
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():
@@ -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
 
@@ -82,7 +82,7 @@ def _extract_route_template(scope: Scope, raw_path: str) -> str:
82
82
  route = scope.get("route")
83
83
  if route is not None:
84
84
  template = getattr(route, "path", None)
85
- if template:
85
+ if isinstance(template, str) and template:
86
86
  return template
87
87
  return raw_path
88
88
 
@@ -93,7 +93,8 @@ def build_otlp_exporter(
93
93
 
94
94
  def import_otel_module(name: str) -> ModuleType:
95
95
  try:
96
- return lazy_import(name, package_hint="fastapi-observer[otel]")
96
+ module: ModuleType = lazy_import(name, package_hint="fastapi-observer[otel]")
97
+ return module
97
98
  except RuntimeError as exc:
98
99
  raise RuntimeError(
99
100
  "OpenTelemetry support requires `pip install fastapi-observer[otel]`"
@@ -16,17 +16,98 @@ Or for automatic instrumentation::
16
16
 
17
17
  from fastapiobserver.propagation import instrument_httpx_client
18
18
 
19
- instrument_httpx_client() # all httpx calls get traceparent automatically
19
+ instrument_httpx_client() # all httpx calls get traceparent and the request ID
20
+
21
+ ``install_observability()`` sets the request ID header to forward from
22
+ ``REQUEST_ID_HEADER``.
20
23
  """
21
24
 
22
25
  from __future__ import annotations
23
26
 
27
+ import importlib.util
24
28
  import logging
29
+ from collections.abc import Mapping
25
30
  from typing import Any
26
31
 
32
+ from .request_context import get_request_id
27
33
  from .utils import lazy_import
28
34
 
29
35
  _LOGGER = logging.getLogger("fastapiobserver.propagation")
36
+ # ponytail: one header per process, the last install_observability() wins; key it
37
+ # per app if one process ever runs apps with different REQUEST_ID_HEADER values.
38
+ _request_id_header = "x-request-id"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Request ID forwarding
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def _request_id_to_forward(carrier: Any) -> str | None:
47
+ """The current request ID, unless *carrier* already has a request ID header."""
48
+ request_id = get_request_id()
49
+ if request_id is None:
50
+ return None
51
+ if isinstance(carrier, Mapping) and any(
52
+ str(key).lower() == _request_id_header for key in carrier
53
+ ):
54
+ return None
55
+ return request_id
56
+
57
+
58
+ def install_request_id_propagation(header: str) -> None:
59
+ """Send the current request ID as *header* on outbound calls.
60
+
61
+ Adds a propagator next to OpenTelemetry's global one, so every client
62
+ OpenTelemetry instruments (httpx, requests, ...) forwards the request ID
63
+ alongside ``traceparent``. Without OpenTelemetry installed, only
64
+ ``inject_trace_headers()`` forwards it.
65
+ """
66
+ global _request_id_header
67
+ _request_id_header = header
68
+ try:
69
+ from opentelemetry import propagate
70
+ from opentelemetry.context import Context
71
+ from opentelemetry.propagators.composite import CompositePropagator
72
+ from opentelemetry.propagators.textmap import (
73
+ Getter,
74
+ Setter,
75
+ TextMapPropagator,
76
+ default_getter,
77
+ default_setter,
78
+ )
79
+ except ImportError:
80
+ return
81
+
82
+ current = propagate.get_global_textmap()
83
+ if header in current.fields:
84
+ return
85
+
86
+ class RequestIdPropagator(TextMapPropagator):
87
+ def inject(
88
+ self,
89
+ carrier: Any,
90
+ context: Context | None = None,
91
+ setter: Setter[Any] = default_setter,
92
+ ) -> None:
93
+ request_id = _request_id_to_forward(carrier)
94
+ if request_id is not None:
95
+ setter.set(carrier, _request_id_header, request_id)
96
+
97
+ def extract(
98
+ self,
99
+ carrier: Any,
100
+ context: Context | None = None,
101
+ getter: Getter[Any] = default_getter,
102
+ ) -> Context:
103
+ # The middleware reads incoming request IDs, and only from trusted proxies.
104
+ return context if context is not None else Context()
105
+
106
+ @property
107
+ def fields(self) -> set[str]:
108
+ return {_request_id_header}
109
+
110
+ propagate.set_global_textmap(CompositePropagator([current, RequestIdPropagator()]))
30
111
 
31
112
 
32
113
  # ---------------------------------------------------------------------------
@@ -35,11 +116,12 @@ _LOGGER = logging.getLogger("fastapiobserver.propagation")
35
116
 
36
117
 
37
118
  def inject_trace_headers(headers: dict[str, str] | None = None) -> dict[str, str]:
38
- """Inject the current OTel trace context into *headers*.
119
+ """Inject the current request ID and OTel trace context into *headers*.
39
120
 
40
- Returns the (mutated) *headers* dict with ``traceparent`` and
41
- ``tracestate`` entries added when a valid span exists. If OTel is
42
- not installed, *headers* is returned unchanged.
121
+ Returns the (mutated) *headers* dict with the request ID header, and
122
+ ``traceparent`` and ``tracestate`` entries when a valid span exists.
123
+ A request ID already in *headers* is kept. If OTel is not installed,
124
+ only the request ID is added.
43
125
 
44
126
  Parameters
45
127
  ----------
@@ -48,6 +130,9 @@ def inject_trace_headers(headers: dict[str, str] | None = None) -> dict[str, str
48
130
  """
49
131
  if headers is None:
50
132
  headers = {}
133
+ request_id = _request_id_to_forward(headers)
134
+ if request_id is not None:
135
+ headers[_request_id_header] = request_id
51
136
  try:
52
137
  from opentelemetry.propagate import inject
53
138
 
@@ -78,8 +163,17 @@ def instrument_httpx_client(client: Any | None = None) -> None:
78
163
  instrumented. When ``None``, **all** ``httpx`` clients are
79
164
  instrumented globally.
80
165
 
81
- Requires ``pip install fastapi-observer[otel-httpx]``.
166
+ Requires ``pip install fastapi-observer[otel-httpx]`` and the ``httpx``
167
+ package. Other clients, ``httpx2`` included, need a request hook instead;
168
+ see the OpenTelemetry guide.
82
169
  """
170
+ # OpenTelemetry's instrumentor only logs an error and does nothing without httpx.
171
+ if importlib.util.find_spec("httpx") is None:
172
+ raise RuntimeError(
173
+ "instrument_httpx_client() needs the `httpx` package, which is not installed. "
174
+ "For other clients, such as httpx2, call "
175
+ "`opentelemetry.propagate.inject(request.headers)` from a request hook."
176
+ )
83
177
  try:
84
178
  instrumentor_module = lazy_import(
85
179
  "opentelemetry.instrumentation.httpx",
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import logging
5
+ import threading
6
+ from collections.abc import Collection
7
+
8
+ from .protocol import LogSink
9
+ from .registry import register_sink
10
+
11
+ _LOGGER = logging.getLogger("fastapiobserver.sinks")
12
+ _LOADED: set[str] = set()
13
+ _LOADED_LOCK = threading.Lock()
14
+
15
+
16
+ def _forget_loaded() -> None:
17
+ with _LOADED_LOCK:
18
+ _LOADED.clear()
19
+
20
+
21
+ def discover_entry_point_sinks(enabled: Collection[str] = ()) -> None:
22
+ """Register the ``fastapiobserver.log_sinks`` entry points named in *enabled*.
23
+
24
+ Other entry points are never imported, so an installed package can't
25
+ receive your logs unless you list it in ``OBS_LOG_SINKS``.
26
+ """
27
+ with _LOADED_LOCK:
28
+ try:
29
+ group = importlib.metadata.entry_points(group="fastapiobserver.log_sinks")
30
+ except Exception:
31
+ _LOGGER.debug(
32
+ "sinks.entry_point.discover_failed",
33
+ exc_info=True,
34
+ extra={"_skip_enrichers": True},
35
+ )
36
+ return
37
+
38
+ names = {ep.name for ep in group}
39
+ for message, skipped in (
40
+ ("sinks.entry_point.not_enabled", names - set(enabled)),
41
+ ("sinks.entry_point.missing", set(enabled) - names),
42
+ ):
43
+ if skipped:
44
+ _LOGGER.warning(
45
+ message,
46
+ extra={
47
+ "event": {"entry_points": sorted(skipped), "setting": "OBS_LOG_SINKS"},
48
+ "_skip_enrichers": True,
49
+ },
50
+ )
51
+
52
+ for ep in group:
53
+ if ep.name not in enabled or ep.name in _LOADED:
54
+ continue
55
+ try:
56
+ sink = ep.load()()
57
+ if isinstance(sink, LogSink):
58
+ register_sink(sink)
59
+ _LOADED.add(ep.name)
60
+ _LOGGER.debug(
61
+ "sinks.entry_point.loaded",
62
+ extra={
63
+ "event": {"sink_name": sink.name, "entry_point": ep.name},
64
+ "_skip_enrichers": True,
65
+ },
66
+ )
67
+ except Exception:
68
+ _LOGGER.warning(
69
+ "sinks.entry_point.failed",
70
+ exc_info=True,
71
+ extra={
72
+ "event": {"entry_point": ep.name},
73
+ "_skip_enrichers": True,
74
+ },
75
+ )
76
+
77
+ __all__ = ["discover_entry_point_sinks"]
@@ -53,8 +53,8 @@ def build_sink_handlers(
53
53
  )
54
54
  )
55
55
 
56
- # Add entry-point discovered sinks
57
- discover_entry_point_sinks()
56
+ # Add entry-point sinks named in OBS_LOG_SINKS
57
+ discover_entry_point_sinks(settings.log_sinks)
58
58
  for sink in get_registered_sinks().values():
59
59
  try:
60
60
  handlers.append((sink.create_handler(formatter), sink.name))
@@ -2,17 +2,36 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import gzip
5
+ import io
5
6
  import json
6
7
  import logging
8
+ import logging.handlers
7
9
  import threading
8
- from typing import Literal, Any
10
+ from typing import Any, Literal, cast
9
11
  from datetime import datetime, timezone
10
12
  import shutil
11
13
 
14
+ def _owner_only(path: str, flags: int) -> int:
15
+ """``open()`` opener that creates files readable by their owner only."""
16
+ return os.open(path, flags, 0o600)
17
+
18
+
19
+ class _OwnerOnlyRotatingFileHandler(logging.handlers.RotatingFileHandler):
20
+ def _open(self) -> io.TextIOWrapper:
21
+ stream = open(
22
+ self.baseFilename,
23
+ self.mode,
24
+ encoding=self.encoding,
25
+ errors=self.errors,
26
+ opener=_owner_only,
27
+ )
28
+ return cast(io.TextIOWrapper, stream)
29
+
30
+
12
31
  def _gzip_rotator(source: str, dest: str) -> None:
13
32
  try:
14
- with open(source, "rb") as f_in:
15
- with gzip.open(dest, "wb") as f_out:
33
+ with open(source, "rb") as f_in, open(dest, "wb", opener=_owner_only) as raw_out:
34
+ with gzip.GzipFile(fileobj=raw_out, mode="wb") as f_out:
16
35
  shutil.copyfileobj(f_in, f_out)
17
36
  os.remove(source)
18
37
  except Exception:
@@ -38,8 +57,6 @@ class LogtailDLQ:
38
57
  backup_count: int,
39
58
  compress: bool,
40
59
  ) -> None:
41
- import logging.handlers
42
-
43
60
  self.directory = directory
44
61
  self.filename = filename
45
62
  self.max_bytes = max_bytes
@@ -53,10 +70,11 @@ class LogtailDLQ:
53
70
  # Re-entrant because handler error callbacks can be invoked while submit() holds the lock.
54
71
  self._lock = threading.RLock()
55
72
 
56
- os.makedirs(self.directory, exist_ok=True)
73
+ # Dropped log lines can hold user data, so keep them away from other OS users.
74
+ os.makedirs(self.directory, mode=0o700, exist_ok=True)
57
75
  self.filepath = os.path.join(self.directory, self.filename)
58
76
 
59
- self._handler = logging.handlers.RotatingFileHandler(
77
+ self._handler = _OwnerOnlyRotatingFileHandler(
60
78
  self.filepath,
61
79
  maxBytes=self.max_bytes,
62
80
  backupCount=self.backup_count,
@@ -70,8 +88,8 @@ class LogtailDLQ:
70
88
  self._handler.handleError = _handle_error # type: ignore[method-assign]
71
89
 
72
90
  if self.compress:
73
- self._handler.rotator = _gzip_rotator # type: ignore[assignment]
74
- self._handler.namer = _gzip_namer # type: ignore[assignment]
91
+ self._handler.rotator = _gzip_rotator
92
+ self._handler.namer = _gzip_namer
75
93
 
76
94
  def submit(self, payload: str, reason: Literal["queue_overflow", "send_failed"]) -> None:
77
95
  with self._lock: