fastapi-observer 1.7.0__tar.gz → 1.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_observer-1.7.0/src/fastapi_observer.egg-info → fastapi_observer-1.9.0}/PKG-INFO +4 -4
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/README.md +3 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/pyproject.toml +3 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0/src/fastapi_observer.egg-info}/PKG-INFO +4 -4
- fastapi_observer-1.9.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/config.py +14 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/control_plane.py +30 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/fastapi.py +56 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/integrations/strawberry.py +1 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/filters.py +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/formatter.py +1 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/events.py +21 -16
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/metrics.py +5 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/request_logging.py +1 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/resource.py +2 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/propagation.py +100 -6
- fastapi_observer-1.9.0/src/fastapiobserver/sinks/discovery.py +77 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/factory.py +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/dlq.py +27 -9
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/registry.py +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/utils.py +7 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_config_settings.py +10 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_fastapi_install.py +20 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_logtail_dlq.py +20 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_middleware.py +3 -2
- fastapi_observer-1.9.0/tests/test_propagation.py +74 -0
- fastapi_observer-1.9.0/tests/test_route_template_cardinality.py +90 -0
- fastapi_observer-1.9.0/tests/test_runtime_control_auth.py +75 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_sinks.py +34 -1
- fastapi_observer-1.7.0/src/fastapiobserver/_version.py +0 -1
- fastapi_observer-1.7.0/src/fastapiobserver/sinks/discovery.py +0 -68
- fastapi_observer-1.7.0/tests/test_propagation.py +0 -18
- fastapi_observer-1.7.0/tests/test_route_template_cardinality.py +0 -51
- fastapi_observer-1.7.0/tests/test_runtime_control_auth.py +0 -41
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/LICENSE +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/setup.cfg +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/queueing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/setup.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/state.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/context.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/tracing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/normalize.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/policy.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/proxies.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/redaction.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_fork_queue_listener.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_logging_schema_contract.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_security_audit_fixes.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/tests/test_utils_refactors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.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,9 +147,9 @@ 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,
|
|
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
|
-
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so
|
|
152
|
+
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), and 404s for paths no route matches share one `<unmatched>` label, so neither IDs nor scanners explode cardinality.
|
|
153
153
|
- **OpenTelemetry traces** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
|
|
154
154
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
155
155
|
|
|
@@ -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,
|
|
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,9 +71,9 @@ 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,
|
|
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
|
-
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so
|
|
76
|
+
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), and 404s for paths no route matches share one `<unmatched>` label, so neither IDs nor scanners explode cardinality.
|
|
77
77
|
- **OpenTelemetry traces** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
|
|
78
78
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
79
79
|
|
|
@@ -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,
|
|
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.
|
|
3
|
+
Version: 1.9.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,9 +147,9 @@ 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,
|
|
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
|
-
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so
|
|
152
|
+
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`), and 404s for paths no route matches share one `<unmatched>` label, so neither IDs nor scanners explode cardinality.
|
|
153
153
|
- **OpenTelemetry traces** with `trace_id` and `span_id` in every log line, and optional log export over OTLP.
|
|
154
154
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
155
155
|
|
|
@@ -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,
|
|
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.9.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(
|
|
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
|
-
|
|
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(
|
{fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/integrations/strawberry.py
RENAMED
|
@@ -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
|
|
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
|
|
51
|
+
record.trace_id = trace_id
|
|
52
52
|
if span_id:
|
|
53
|
-
record.span_id = span_id
|
|
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)
|
|
125
|
+
return str(orjson.dumps(payload), "utf-8")
|
|
126
126
|
return json.dumps(payload, ensure_ascii=True, default=str)
|
|
127
127
|
|
|
128
128
|
|
|
@@ -68,23 +68,28 @@ def _classify_error(status_code: int, error: Exception | None) -> str:
|
|
|
68
68
|
return "ok"
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
"""Return the matched route template for bounded metric cardinality.
|
|
71
|
+
UNMATCHED_ROUTE = "<unmatched>"
|
|
73
72
|
|
|
74
|
-
After Starlette's router runs, ``scope["route"]`` contains the matched
|
|
75
|
-
``Route`` object whose ``.path`` is the template string
|
|
76
|
-
(e.g. ``/users/{user_id}``).
|
|
77
73
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
def _extract_route_template(scope: Scope, raw_path: str, status_code: int) -> str:
|
|
75
|
+
"""Return the metrics ``path`` label: the matched route template if known.
|
|
76
|
+
|
|
77
|
+
After routing, ``scope["route"]`` holds the matched route, whose ``.path``
|
|
78
|
+
is the template (e.g. ``/users/{user_id}``). Without one, a 404 means no
|
|
79
|
+
route matched, mostly scanners, so those share ``UNMATCHED_ROUTE`` instead
|
|
80
|
+
of adding a time series per URL. Other requests without a template keep
|
|
81
|
+
*raw_path*: FastAPI doesn't record plain Starlette routes, such as ones
|
|
82
|
+
added with ``app.add_route()``, in the scope.
|
|
81
83
|
"""
|
|
82
|
-
|
|
83
|
-
if
|
|
84
|
-
template
|
|
85
|
-
|
|
86
|
-
return template
|
|
87
|
-
return raw_path
|
|
84
|
+
template = getattr(scope.get("route"), "path", None)
|
|
85
|
+
if isinstance(template, str) and template:
|
|
86
|
+
return template
|
|
87
|
+
return UNMATCHED_ROUTE if status_code == 404 else raw_path
|
|
88
88
|
|
|
89
|
-
__all__ = [
|
|
90
|
-
|
|
89
|
+
__all__ = [
|
|
90
|
+
"UNMATCHED_ROUTE",
|
|
91
|
+
"_RequestEventBuilder",
|
|
92
|
+
"_classify_error",
|
|
93
|
+
"_extract_route_template",
|
|
94
|
+
"_sanitize_exception_message",
|
|
95
|
+
]
|
|
@@ -38,12 +38,16 @@ class _MetricsRecorder:
|
|
|
38
38
|
if scope and "app" in scope:
|
|
39
39
|
app = scope["app"]
|
|
40
40
|
state = getattr(app.state, "_observability_state", None)
|
|
41
|
-
|
|
41
|
+
# Filled when the app starts; without a lifespan, keep the settings' list.
|
|
42
|
+
if state is not None and state.excluded_urls:
|
|
42
43
|
excluded_urls = state.excluded_urls
|
|
43
44
|
|
|
45
|
+
# Also check the request's own path: unmatched requests share one label.
|
|
46
|
+
raw_path = scope.get("path") if scope else None
|
|
44
47
|
if (
|
|
45
48
|
path in excluded_urls
|
|
46
49
|
or collapsed_path in excluded_urls
|
|
50
|
+
or raw_path in excluded_urls
|
|
47
51
|
):
|
|
48
52
|
return
|
|
49
53
|
try:
|
{fastapi_observer-1.7.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/request_logging.py
RENAMED
|
@@ -168,7 +168,7 @@ class RequestLoggingMiddleware:
|
|
|
168
168
|
)
|
|
169
169
|
|
|
170
170
|
# Prefer route template for bounded cardinality
|
|
171
|
-
route_template = _extract_route_template(scope, path)
|
|
171
|
+
route_template = _extract_route_template(scope, path, status_code)
|
|
172
172
|
|
|
173
173
|
self.metrics_recorder.observe(
|
|
174
174
|
method=method,
|
|
@@ -93,7 +93,8 @@ def build_otlp_exporter(
|
|
|
93
93
|
|
|
94
94
|
def import_otel_module(name: str) -> ModuleType:
|
|
95
95
|
try:
|
|
96
|
-
|
|
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
|
|
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
|
|
41
|
-
``tracestate`` entries
|
|
42
|
-
|
|
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"]
|