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.
- {fastapi_observer-1.7.0/src/fastapi_observer.egg-info → fastapi_observer-1.8.0}/PKG-INFO +3 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/README.md +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/pyproject.toml +3 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0/src/fastapi_observer.egg-info}/PKG-INFO +3 -3
- fastapi_observer-1.8.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/config.py +14 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/control_plane.py +30 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/fastapi.py +56 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/integrations/strawberry.py +1 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/filters.py +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/formatter.py +1 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/events.py +1 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/resource.py +2 -1
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/propagation.py +100 -6
- fastapi_observer-1.8.0/src/fastapiobserver/sinks/discovery.py +77 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/factory.py +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/dlq.py +27 -9
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/registry.py +2 -2
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/utils.py +7 -3
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_config_settings.py +10 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_fastapi_install.py +20 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_logtail_dlq.py +20 -0
- fastapi_observer-1.8.0/tests/test_propagation.py +74 -0
- fastapi_observer-1.8.0/tests/test_runtime_control_auth.py +75 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.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_runtime_control_auth.py +0 -41
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/LICENSE +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/setup.cfg +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/queueing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/setup.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/logging/state.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/context.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/metrics.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/middleware/request_logging.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/otel/tracing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/normalize.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/policy.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/proxies.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/security/redaction.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_fork_queue_listener.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_logging_schema_contract.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_middleware.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_security_audit_fixes.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.7.0 → fastapi_observer-1.8.0}/tests/test_trust_boundary.py +0 -0
- {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.
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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.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,
|
|
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,
|
|
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(
|
|
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.8.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
|
|
|
@@ -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
|
-
|
|
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"]
|
|
@@ -53,8 +53,8 @@ def build_sink_handlers(
|
|
|
53
53
|
)
|
|
54
54
|
)
|
|
55
55
|
|
|
56
|
-
# Add entry-point
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
74
|
-
self._handler.namer = _gzip_namer
|
|
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:
|