fastapi-observer 1.13.0__tar.gz → 1.15.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.13.0/src/fastapi_observer.egg-info → fastapi_observer-1.15.0}/PKG-INFO +2 -2
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/README.md +1 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0/src/fastapi_observer.egg-info}/PKG-INFO +2 -2
- fastapi_observer-1.15.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/fastapi.py +24 -26
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/__init__.py +2 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/filters.py +2 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/formatter.py +13 -15
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/queueing.py +1 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/setup.py +36 -13
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/events.py +17 -10
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/request_logging.py +27 -5
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/lifecycle.py +14 -21
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/logs.py +17 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/metrics.py +2 -2
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/settings.py +5 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/tracing.py +2 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/policy.py +6 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/redaction.py +37 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_fastapi_install.py +43 -4
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_fork_queue_listener.py +37 -4
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_logging_schema_contract.py +15 -28
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_middleware.py +82 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_integration.py +12 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_lifecycle.py +10 -18
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_logs_pipeline.py +22 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_redaction_presets.py +21 -3
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_route_template_cardinality.py +44 -9
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_security_audit_fixes.py +2 -2
- fastapi_observer-1.13.0/src/fastapiobserver/_version.py +0 -1
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/LICENSE +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/pyproject.toml +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/setup.cfg +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/config.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/control_plane.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/held.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/state.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/volume.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/loop_watchdog.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/context.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/metrics.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/native.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/pools.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/normalize.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/security/proxies.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/discovery.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/factory.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/registry.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_logtail_dlq.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_loop_watchdog.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_native_telemetry.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_pools.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_request_insights.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_sinks.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-1.13.0 → fastapi_observer-1.15.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.15.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
|
|
@@ -149,7 +149,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
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
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}`), and
|
|
152
|
+
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`). Requests no route matches, such as scanners, CORS preflights and requests an auth middleware rejects, share one `<unmatched>` label, and made-up methods share `_OTHER`, so neither IDs nor anonymous clients explode cardinality.
|
|
153
153
|
- **OpenTelemetry traces**, from FastAPI's own support on 0.142 and later, with `trace_id` and `span_id` in every log line, and optional log export over OTLP, redacted like everything else.
|
|
154
154
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
155
155
|
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. Always on: a warning when the thread pool or a database connection pool is full, the usual cause when latency climbs while CPU stays low. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
@@ -73,7 +73,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
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
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}`), and
|
|
76
|
+
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`). Requests no route matches, such as scanners, CORS preflights and requests an auth middleware rejects, share one `<unmatched>` label, and made-up methods share `_OTHER`, so neither IDs nor anonymous clients explode cardinality.
|
|
77
77
|
- **OpenTelemetry traces**, from FastAPI's own support on 0.142 and later, with `trace_id` and `span_id` in every log line, and optional log export over OTLP, redacted like everything else.
|
|
78
78
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
79
79
|
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. Always on: a warning when the thread pool or a database connection pool is full, the usual cause when latency climbs while CPU stays low. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.15.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
|
|
@@ -149,7 +149,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
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
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}`), and
|
|
152
|
+
- **Prometheus metrics** labelled by route template (`/orders/{order_id}`). Requests no route matches, such as scanners, CORS preflights and requests an auth middleware rejects, share one `<unmatched>` label, and made-up methods share `_OTHER`, so neither IDs nor anonymous clients explode cardinality.
|
|
153
153
|
- **OpenTelemetry traces**, from FastAPI's own support on 0.142 and later, with `trace_id` and `span_id` in every log line, and optional log export over OTLP, redacted like everything else.
|
|
154
154
|
- **Non-blocking log writes.** Lines are written from a background thread, so a slow stdout never stalls the event loop.
|
|
155
155
|
- **Help finding what slows a request down.** Opt-in: a warning with the stack of any sync call that blocks the event loop, and `DEBUG` lines written only for requests that fail. Always on: a warning when the thread pool or a database connection pool is full, the usual cause when latency climbs while CPU stays low. With tracing on, each request line counts its queries and outbound HTTP calls, and a query run once per item (N+1) gets a warning. With metrics on, `/metrics` shows how many bytes of logs each route writes, and `LOG_SAMPLE_RATE` keeps a share of successful requests, chosen by trace ID, while failed and slow requests are always written in full.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.15.0"
|
|
@@ -15,10 +15,9 @@ from fastapi import FastAPI
|
|
|
15
15
|
|
|
16
16
|
from .config import ObservabilitySettings
|
|
17
17
|
from .control_plane import RuntimeControlSettings, mount_control_plane
|
|
18
|
-
from .logging import
|
|
18
|
+
from .logging import flush_logging, setup_logging
|
|
19
19
|
from .loop_watchdog import EventLoopWatchdog
|
|
20
20
|
from .profiles import apply_profile_context
|
|
21
|
-
from .db_tracing import uninstrument_sqlalchemy
|
|
22
21
|
from .metrics import (
|
|
23
22
|
MetricsBackend,
|
|
24
23
|
build_metrics_backend,
|
|
@@ -47,7 +46,6 @@ _ROUTE_PARAM_PATTERN = re.compile(r"\{[^{}]+\}")
|
|
|
47
46
|
@dataclass
|
|
48
47
|
class ObservabilityRuntimeState:
|
|
49
48
|
settings: ObservabilitySettings
|
|
50
|
-
teardown_done: bool = False
|
|
51
49
|
excluded_urls: frozenset[str] = frozenset()
|
|
52
50
|
|
|
53
51
|
|
|
@@ -86,11 +84,7 @@ def install_observability(
|
|
|
86
84
|
otel_metrics_settings = otel_metrics_settings or OTelMetricsSettings.from_env()
|
|
87
85
|
runtime_control_settings = runtime_control_settings or RuntimeControlSettings.from_env()
|
|
88
86
|
|
|
89
|
-
#
|
|
90
|
-
app.state._observability_teardown_done = False
|
|
91
|
-
|
|
92
|
-
# Mark a fresh install cycle so shutdown executes once per lifecycle.
|
|
93
|
-
# Ensure settings are stored on state for lifespan to access
|
|
87
|
+
# Settings for the lifespan to read.
|
|
94
88
|
app.state._observability_state = ObservabilityRuntimeState(settings=settings)
|
|
95
89
|
|
|
96
90
|
# --- 1 & 2. Structured logging + OTLP Logs ---
|
|
@@ -148,6 +142,21 @@ def install_observability(
|
|
|
148
142
|
if runtime_control_settings and runtime_control_settings.enabled:
|
|
149
143
|
mount_control_plane(app, runtime_control_settings, trusted_proxy_policy)
|
|
150
144
|
|
|
145
|
+
if security_policy.redaction_mode == "hash" and security_policy.hash_key is None:
|
|
146
|
+
_LOGGER.warning(
|
|
147
|
+
"observability.redaction.hash_key_missing",
|
|
148
|
+
extra={
|
|
149
|
+
"event": {
|
|
150
|
+
"message": (
|
|
151
|
+
"Hashed values use a random key picked at startup, so they differ "
|
|
152
|
+
"between processes and after a restart. Set OBS_REDACTION_HASH_KEY "
|
|
153
|
+
"to keep them stable."
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
"_skip_enrichers": True,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
151
160
|
# --- 9. One line with the settings that took effect ---
|
|
152
161
|
_LOGGER.info(
|
|
153
162
|
"observability.configured",
|
|
@@ -394,7 +403,7 @@ def _build_exclude_path_variants(path: str) -> set[str]:
|
|
|
394
403
|
|
|
395
404
|
@asynccontextmanager
|
|
396
405
|
async def observability_lifespan(app: FastAPI) -> typing.AsyncGenerator[typing.Any, None]:
|
|
397
|
-
"""A
|
|
406
|
+
"""A FastAPI lifespan context manager that writes out queued logs when the app stops.
|
|
398
407
|
|
|
399
408
|
Developers can yield from this manager directly in their app's lifespan:
|
|
400
409
|
|
|
@@ -432,9 +441,8 @@ def _register_logging_shutdown_hook(app: FastAPI) -> None:
|
|
|
432
441
|
) -> typing.AsyncGenerator[typing.Any, None]:
|
|
433
442
|
async with observability_lifespan(app_inst):
|
|
434
443
|
if original_lifespan:
|
|
435
|
-
# If the user
|
|
436
|
-
#
|
|
437
|
-
# _teardown_observability is idempotent.
|
|
444
|
+
# If the user also enters observability_lifespan in their own
|
|
445
|
+
# lifespan, the queue is just flushed twice.
|
|
438
446
|
async with original_lifespan(app_inst) as state:
|
|
439
447
|
yield state
|
|
440
448
|
else:
|
|
@@ -445,19 +453,9 @@ def _register_logging_shutdown_hook(app: FastAPI) -> None:
|
|
|
445
453
|
|
|
446
454
|
|
|
447
455
|
def _teardown_observability(app: FastAPI) -> None:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
elif getattr(app.state, "_observability_teardown_done", False):
|
|
453
|
-
return
|
|
454
|
-
|
|
455
|
-
shutdown_logging()
|
|
456
|
-
uninstrument_sqlalchemy()
|
|
457
|
-
|
|
458
|
-
if state:
|
|
459
|
-
state.teardown_done = True
|
|
460
|
-
else:
|
|
461
|
-
app.state._observability_teardown_done = True
|
|
456
|
+
# Logging and SQLAlchemy instrumentation are shared by every app in the
|
|
457
|
+
# process, and this app may start again, as a reused TestClient does. So
|
|
458
|
+
# write out what's queued and leave them running; atexit stops logging.
|
|
459
|
+
flush_logging()
|
|
462
460
|
|
|
463
461
|
__all__ = ["install_observability", "observability_lifespan"]
|
|
@@ -15,6 +15,7 @@ from .queueing import (
|
|
|
15
15
|
get_log_queue_stats,
|
|
16
16
|
)
|
|
17
17
|
from .setup import (
|
|
18
|
+
flush_logging,
|
|
18
19
|
get_log_level,
|
|
19
20
|
get_managed_output_handlers,
|
|
20
21
|
set_log_level,
|
|
@@ -35,6 +36,7 @@ __all__ = [
|
|
|
35
36
|
"SinkCircuitBreakerSnapshot",
|
|
36
37
|
"StructuredJsonFormatter",
|
|
37
38
|
"TraceContextFilter",
|
|
39
|
+
"flush_logging",
|
|
38
40
|
"get_log_bytes_by_route",
|
|
39
41
|
"get_log_level",
|
|
40
42
|
"get_log_queue_stats",
|
|
@@ -43,6 +43,8 @@ class TraceContextFilter(logging.Filter):
|
|
|
43
43
|
if span_context and span_context.is_valid:
|
|
44
44
|
trace_id = f"{span_context.trace_id:032x}"
|
|
45
45
|
span_id = f"{span_context.span_id:016x}"
|
|
46
|
+
# For OTLP export, which runs on another thread.
|
|
47
|
+
record._otel_span_context = span_context
|
|
46
48
|
except Exception:
|
|
47
49
|
_LOGGER.debug(
|
|
48
50
|
"trace_context_filter.otel_lookup_failed",
|
{fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/logging/formatter.py
RENAMED
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
-
import
|
|
6
|
+
import traceback
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
|
+
from types import TracebackType
|
|
8
9
|
from typing import Any, Callable, Mapping
|
|
9
10
|
|
|
10
11
|
from .._version import __version__
|
|
@@ -144,11 +145,11 @@ def _build_structured_error(
|
|
|
144
145
|
"stacktrace": "",
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
exc_type, exc_value,
|
|
148
|
+
exc_type, exc_value, exc_tb = exc_info
|
|
148
149
|
error_type = exc_type.__name__ if exc_type is not None else "Exception"
|
|
149
150
|
error_message = str(exc_value) if exc_value is not None else ""
|
|
150
151
|
stacktrace = formatter.formatException(exc_info)
|
|
151
|
-
fingerprint = _generate_error_fingerprint(error_type,
|
|
152
|
+
fingerprint = _generate_error_fingerprint(error_type, exc_tb)
|
|
152
153
|
|
|
153
154
|
return {
|
|
154
155
|
"type": error_type,
|
|
@@ -158,16 +159,13 @@ def _build_structured_error(
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
|
|
161
|
-
def _generate_error_fingerprint(error_type: str,
|
|
162
|
-
"""Hash
|
|
163
|
-
if not stacktrace:
|
|
164
|
-
return hashlib.sha256(error_type.encode("utf-8")).hexdigest()[:32]
|
|
162
|
+
def _generate_error_fingerprint(error_type: str, exc_tb: TracebackType | None) -> str:
|
|
163
|
+
"""Hash the exception type and the file, function and source of each frame.
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
165
|
+
The message is left out, since it often holds IDs that differ each time the
|
|
166
|
+
same bug is hit, and so are line numbers, which shift with unrelated edits.
|
|
167
|
+
"""
|
|
168
|
+
frames = "".join(
|
|
169
|
+
f"{frame.filename}:{frame.name}:{frame.line}\n" for frame in traceback.extract_tb(exc_tb)
|
|
170
|
+
)
|
|
171
|
+
return hashlib.sha256(f"{error_type}\n{frames}".encode("utf-8")).hexdigest()[:32]
|
|
@@ -168,6 +168,7 @@ class OverflowPolicyQueueHandler(logging.handlers.QueueHandler):
|
|
|
168
168
|
log_queue = cast(queue.Queue[logging.LogRecord], self.queue)
|
|
169
169
|
try:
|
|
170
170
|
log_queue.get_nowait()
|
|
171
|
+
log_queue.task_done() # dropped counts as done, or flush_logging() waits for it
|
|
171
172
|
self.telemetry.record_drop_oldest()
|
|
172
173
|
except queue.Empty:
|
|
173
174
|
pass
|
|
@@ -4,6 +4,8 @@ import atexit
|
|
|
4
4
|
import logging
|
|
5
5
|
import logging.handlers
|
|
6
6
|
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
7
9
|
from typing import Literal, cast
|
|
8
10
|
|
|
9
11
|
from ..config import ObservabilitySettings
|
|
@@ -43,6 +45,33 @@ def shutdown_logging() -> None:
|
|
|
43
45
|
)
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
def flush_logging(timeout_seconds: float = 5.0) -> None:
|
|
49
|
+
"""Wait until queued records are written, then flush the outputs.
|
|
50
|
+
|
|
51
|
+
Logging keeps running, for other apps in the process and for this one if it
|
|
52
|
+
starts again. ``atexit`` stops it when the process exits.
|
|
53
|
+
"""
|
|
54
|
+
import fastapiobserver.logging.state as state
|
|
55
|
+
|
|
56
|
+
listener = state._QUEUE_LISTENER
|
|
57
|
+
if listener is None:
|
|
58
|
+
return
|
|
59
|
+
log_queue = cast(queue.Queue[logging.LogRecord], listener.queue)
|
|
60
|
+
deadline = time.monotonic() + timeout_seconds
|
|
61
|
+
# The listener marks each record done once its handlers have run.
|
|
62
|
+
while log_queue.unfinished_tasks and time.monotonic() < deadline:
|
|
63
|
+
time.sleep(0.005)
|
|
64
|
+
for handler in list(state._MANAGED_OUTPUT_HANDLERS):
|
|
65
|
+
try:
|
|
66
|
+
handler.flush()
|
|
67
|
+
except Exception:
|
|
68
|
+
_LOGGER.debug(
|
|
69
|
+
"logging.output_handler.flush_failed",
|
|
70
|
+
exc_info=True,
|
|
71
|
+
extra={"_skip_enrichers": True},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
46
75
|
def _teardown_handlers_locked(suppress_errors: bool = True) -> None:
|
|
47
76
|
"""Safely stop listeners, unregister managed handlers, and close outputs."""
|
|
48
77
|
import fastapiobserver.logging.state as state
|
|
@@ -289,22 +318,16 @@ def _restart_queue_listener_in_child() -> None:
|
|
|
289
318
|
|
|
290
319
|
listener = state._QUEUE_LISTENER
|
|
291
320
|
if listener is not None:
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
321
|
+
# Another thread of the parent may have held any lock at the moment of
|
|
322
|
+
# the fork, and no thread in the child will release it. So don't touch
|
|
323
|
+
# the inherited queue, not even to drain it: its records are the
|
|
324
|
+
# parent's to write. Give the child fresh locks and a fresh queue.
|
|
325
|
+
_LOG_QUEUE_TELEMETRY._lock = threading.Lock()
|
|
326
|
+
for breaker in state._SINK_CIRCUIT_BREAKERS:
|
|
327
|
+
breaker._lock = threading.Lock()
|
|
298
328
|
old_queue = cast(queue.Queue[logging.LogRecord], listener.queue)
|
|
299
329
|
new_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=old_queue.maxsize)
|
|
300
330
|
|
|
301
|
-
while not old_queue.empty():
|
|
302
|
-
try:
|
|
303
|
-
# Discard the record to prevent pre-fork replay duplication
|
|
304
|
-
_ = old_queue.get_nowait()
|
|
305
|
-
except queue.Empty:
|
|
306
|
-
break
|
|
307
|
-
|
|
308
331
|
# Re-attach the new queue to the OverflowPolicyQueueHandler if present
|
|
309
332
|
for handler in state._MANAGED_HANDLERS:
|
|
310
333
|
from .queueing import OverflowPolicyQueueHandler
|
{fastapi_observer-1.13.0 → fastapi_observer-1.15.0}/src/fastapiobserver/middleware/events.py
RENAMED
|
@@ -4,10 +4,12 @@ import re
|
|
|
4
4
|
from collections.abc import Mapping
|
|
5
5
|
from typing import Any
|
|
6
6
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
7
|
+
from starlette.routing import Match
|
|
7
8
|
from starlette.types import Scope
|
|
8
9
|
|
|
9
10
|
from ..request_context import UNMATCHED_ROUTE, route_template
|
|
10
11
|
from ..security import SecurityPolicy, sanitize_event
|
|
12
|
+
from ..security.redaction import redact_body
|
|
11
13
|
|
|
12
14
|
_CREDENTIAL_RE = re.compile(r"://[^:]+:[^@]+@")
|
|
13
15
|
|
|
@@ -46,10 +48,11 @@ class _RequestEventBuilder:
|
|
|
46
48
|
"url.path": path,
|
|
47
49
|
"http.response.status_code": status_code,
|
|
48
50
|
}
|
|
51
|
+
# Redaction below matches keys, and a body is one string value.
|
|
49
52
|
if request_body is not None:
|
|
50
|
-
event["request_body"] = request_body
|
|
53
|
+
event["request_body"] = redact_body(request_body, self.policy)
|
|
51
54
|
if response_body is not None:
|
|
52
|
-
event["response_body"] = response_body
|
|
55
|
+
event["response_body"] = redact_body(response_body, self.policy)
|
|
53
56
|
if exception is not None:
|
|
54
57
|
event["exception_class"] = exception.__class__.__name__
|
|
55
58
|
event["exception_message"] = _sanitize_exception_message(str(exception))
|
|
@@ -73,20 +76,24 @@ def _classify_error(status_code: int, error: Exception | None) -> str:
|
|
|
73
76
|
return "ok"
|
|
74
77
|
|
|
75
78
|
|
|
76
|
-
def _extract_route_template(scope: Scope
|
|
79
|
+
def _extract_route_template(scope: Scope) -> str:
|
|
77
80
|
"""Return the metrics ``path`` label: the matched route template if known.
|
|
78
81
|
|
|
79
|
-
After routing,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
After routing, the scope holds the matched route's template (e.g.
|
|
83
|
+
``/users/{user_id}``). FastAPI doesn't record plain Starlette routes, such
|
|
84
|
+
as ones added with ``app.add_route()``, so those are matched here. Anything
|
|
85
|
+
else reached no route, such as scanners, CORS preflights or requests an auth
|
|
86
|
+
middleware rejected, and shares ``UNMATCHED_ROUTE`` instead of adding a time
|
|
87
|
+
series per URL.
|
|
85
88
|
"""
|
|
86
89
|
template = route_template(scope)
|
|
87
90
|
if template:
|
|
88
91
|
return template
|
|
89
|
-
|
|
92
|
+
for route in getattr(scope.get("app"), "routes", ()):
|
|
93
|
+
path = getattr(route, "path", None)
|
|
94
|
+
if path and route.matches(scope)[0] is not Match.NONE:
|
|
95
|
+
return str(path)
|
|
96
|
+
return UNMATCHED_ROUTE
|
|
90
97
|
|
|
91
98
|
__all__ = [
|
|
92
99
|
"UNMATCHED_ROUTE",
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import re
|
|
5
5
|
import time
|
|
6
6
|
|
|
7
|
+
from anyio import get_cancelled_exc_class
|
|
7
8
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
8
9
|
|
|
9
10
|
from ..config import ObservabilitySettings
|
|
@@ -31,6 +32,11 @@ _MAX_STATEMENT_LENGTH = 1000
|
|
|
31
32
|
# Quoted strings and numbers, so values written into SQL text never reach a log line.
|
|
32
33
|
# ponytail: misses dollar-quoted and E'' strings; parse SQL if those ever show up.
|
|
33
34
|
_SQL_LITERALS = re.compile(r"'(?:[^']|'')*'|\b\d+(?:\.\d+)?\b")
|
|
35
|
+
# Metric labels take the method only from this set, as OpenTelemetry does, so a
|
|
36
|
+
# client can't add a time series per made-up method.
|
|
37
|
+
_HTTP_METHODS = frozenset(
|
|
38
|
+
{"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"}
|
|
39
|
+
)
|
|
34
40
|
|
|
35
41
|
|
|
36
42
|
class RequestLoggingMiddleware:
|
|
@@ -86,6 +92,8 @@ class RequestLoggingMiddleware:
|
|
|
86
92
|
max_length=self.security_policy.max_body_length,
|
|
87
93
|
)
|
|
88
94
|
had_error = False
|
|
95
|
+
cancelled = False
|
|
96
|
+
response_started = False
|
|
89
97
|
|
|
90
98
|
async def receive_wrapper() -> Message:
|
|
91
99
|
message = await receive()
|
|
@@ -93,8 +101,9 @@ class RequestLoggingMiddleware:
|
|
|
93
101
|
return message
|
|
94
102
|
|
|
95
103
|
async def send_wrapper(message: Message) -> None:
|
|
96
|
-
nonlocal status_code
|
|
104
|
+
nonlocal status_code, response_started
|
|
97
105
|
if message["type"] == "http.response.start":
|
|
106
|
+
response_started = True
|
|
98
107
|
status_code = int(message.get("status", 500))
|
|
99
108
|
resp_headers = message.get("headers", [])
|
|
100
109
|
if self.security_policy.log_response_body:
|
|
@@ -124,6 +133,13 @@ class RequestLoggingMiddleware:
|
|
|
124
133
|
captured_error = exc
|
|
125
134
|
status_code = 500
|
|
126
135
|
raise
|
|
136
|
+
except get_cancelled_exc_class():
|
|
137
|
+
# The client left, the server is shutting down or a timeout fired.
|
|
138
|
+
# Not a server error: without a response, use nginx's 499.
|
|
139
|
+
cancelled = True
|
|
140
|
+
if not response_started:
|
|
141
|
+
status_code = 499
|
|
142
|
+
raise
|
|
127
143
|
finally:
|
|
128
144
|
self._finalize_request(
|
|
129
145
|
scope=scope,
|
|
@@ -136,6 +152,7 @@ class RequestLoggingMiddleware:
|
|
|
136
152
|
response_body_capture=response_body_capture,
|
|
137
153
|
captured_error=captured_error,
|
|
138
154
|
had_error=had_error,
|
|
155
|
+
cancelled=cancelled,
|
|
139
156
|
)
|
|
140
157
|
|
|
141
158
|
def _finalize_request(
|
|
@@ -150,10 +167,13 @@ class RequestLoggingMiddleware:
|
|
|
150
167
|
response_body_capture: _BodyCapture,
|
|
151
168
|
captured_error: Exception | None,
|
|
152
169
|
had_error: bool,
|
|
170
|
+
cancelled: bool,
|
|
153
171
|
) -> None:
|
|
154
172
|
duration_seconds = max(0.0, time.perf_counter() - start)
|
|
155
173
|
if had_error:
|
|
156
174
|
level, message = logging.ERROR, "request.failed"
|
|
175
|
+
elif cancelled:
|
|
176
|
+
level, message = logging.WARNING, "request.cancelled"
|
|
157
177
|
elif status_code >= 500:
|
|
158
178
|
level, message = logging.ERROR, "request.server_error"
|
|
159
179
|
elif status_code >= 400:
|
|
@@ -187,13 +207,15 @@ class RequestLoggingMiddleware:
|
|
|
187
207
|
extra={"event": safe_event},
|
|
188
208
|
)
|
|
189
209
|
|
|
190
|
-
|
|
191
|
-
|
|
210
|
+
route_template = _extract_route_template(scope)
|
|
211
|
+
metric_method = method if method in _HTTP_METHODS else "_OTHER"
|
|
192
212
|
if request_state is not None and request_state.statements:
|
|
193
|
-
self._report_repeated_statement(
|
|
213
|
+
self._report_repeated_statement(
|
|
214
|
+
metric_method, route_template, request_state.statements
|
|
215
|
+
)
|
|
194
216
|
|
|
195
217
|
self.metrics_recorder.observe(
|
|
196
|
-
method=
|
|
218
|
+
method=metric_method,
|
|
197
219
|
path=route_template,
|
|
198
220
|
status_code=status_code,
|
|
199
221
|
duration_seconds=duration_seconds,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Flush OTel providers when the FastAPI app shuts down.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
``atexit`` shutdown (``shutdown_on_exit=True``).
|
|
3
|
+
Shutting them down is left to process exit: SDK providers register their own
|
|
4
|
+
``atexit`` shutdown (``shutdown_on_exit=True``). A provider is global, so one
|
|
5
|
+
app's lifespan ending mustn't stop it for other apps, or for this one if it
|
|
6
|
+
starts again.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from __future__ import annotations
|
|
@@ -18,10 +20,8 @@ _LOGGER = logging.getLogger("fastapiobserver.otel")
|
|
|
18
20
|
_HOOKED: weakref.WeakKeyDictionary[FastAPI, set[int]] = weakref.WeakKeyDictionary()
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
def
|
|
22
|
-
"""
|
|
23
|
-
for providers we created) or just flush it (providers owned by someone else).
|
|
24
|
-
"""
|
|
23
|
+
def flush_on_app_shutdown(app: FastAPI | None, provider: Any) -> None:
|
|
24
|
+
"""Each time ``app``'s lifespan ends, export what ``provider`` holds."""
|
|
25
25
|
if app is None:
|
|
26
26
|
return
|
|
27
27
|
hooked = _HOOKED.setdefault(app, set())
|
|
@@ -31,27 +31,20 @@ def close_on_app_shutdown(app: FastAPI | None, provider: Any, *, shutdown: bool)
|
|
|
31
31
|
|
|
32
32
|
# Wrap the lifespan: Starlette 1.0 removed add_event_handler/on_shutdown.
|
|
33
33
|
original_lifespan = app.router.lifespan_context
|
|
34
|
-
done = False
|
|
35
34
|
|
|
36
35
|
@asynccontextmanager
|
|
37
36
|
async def lifespan(app_inst: FastAPI) -> AsyncGenerator[Any, None]:
|
|
38
|
-
nonlocal done
|
|
39
37
|
try:
|
|
40
38
|
async with original_lifespan(app_inst) as state:
|
|
41
39
|
yield state
|
|
42
40
|
finally:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
_LOGGER.debug(
|
|
52
|
-
"otel.provider.lifecycle.failed",
|
|
53
|
-
exc_info=True,
|
|
54
|
-
extra={"_skip_enrichers": True},
|
|
55
|
-
)
|
|
41
|
+
try:
|
|
42
|
+
provider.force_flush()
|
|
43
|
+
except Exception:
|
|
44
|
+
_LOGGER.debug(
|
|
45
|
+
"otel.provider.lifecycle.failed",
|
|
46
|
+
exc_info=True,
|
|
47
|
+
extra={"_skip_enrichers": True},
|
|
48
|
+
)
|
|
56
49
|
|
|
57
50
|
app.router.lifespan_context = lifespan
|
|
@@ -9,7 +9,7 @@ from fastapi import FastAPI
|
|
|
9
9
|
|
|
10
10
|
from ..config import ObservabilitySettings
|
|
11
11
|
from ..security import SecurityPolicy, sanitize_event
|
|
12
|
-
from .lifecycle import
|
|
12
|
+
from .lifecycle import flush_on_app_shutdown
|
|
13
13
|
from .resource import (
|
|
14
14
|
build_otlp_exporter,
|
|
15
15
|
create_otel_resource,
|
|
@@ -39,6 +39,8 @@ class _SanitizingOTLPLogHandler(logging.Handler):
|
|
|
39
39
|
super().__init__(level=delegate.level)
|
|
40
40
|
self._delegate = delegate
|
|
41
41
|
self._security_policy = security_policy
|
|
42
|
+
self._otel_context = import_otel_module("opentelemetry.context")
|
|
43
|
+
self._otel_trace = import_otel_module("opentelemetry.trace")
|
|
42
44
|
|
|
43
45
|
def emit(self, record: logging.LogRecord) -> None:
|
|
44
46
|
try:
|
|
@@ -47,7 +49,19 @@ class _SanitizingOTLPLogHandler(logging.Handler):
|
|
|
47
49
|
sanitized_record,
|
|
48
50
|
self._security_policy,
|
|
49
51
|
)
|
|
50
|
-
|
|
52
|
+
# Records are exported on the queue's listener thread, where no span
|
|
53
|
+
# is current, so OpenTelemetry would give them no trace. Make the
|
|
54
|
+
# span the record was created in current while it's exported.
|
|
55
|
+
span_context = sanitized_record.__dict__.pop("_otel_span_context", None)
|
|
56
|
+
if span_context is None:
|
|
57
|
+
self._delegate.emit(sanitized_record)
|
|
58
|
+
return
|
|
59
|
+
span = self._otel_trace.NonRecordingSpan(span_context)
|
|
60
|
+
token = self._otel_context.attach(self._otel_trace.set_span_in_context(span))
|
|
61
|
+
try:
|
|
62
|
+
self._delegate.emit(sanitized_record)
|
|
63
|
+
finally:
|
|
64
|
+
self._otel_context.detach(token)
|
|
51
65
|
except Exception:
|
|
52
66
|
self.handleError(record)
|
|
53
67
|
|
|
@@ -158,7 +172,7 @@ def install_otel_logs(
|
|
|
158
172
|
raw_handler,
|
|
159
173
|
security_policy=policy,
|
|
160
174
|
)
|
|
161
|
-
|
|
175
|
+
flush_on_app_shutdown(app, logger_provider)
|
|
162
176
|
_LOGGER.info(
|
|
163
177
|
"otel.logs.installed",
|
|
164
178
|
extra={
|
|
@@ -8,7 +8,7 @@ from typing import Any
|
|
|
8
8
|
from fastapi import FastAPI
|
|
9
9
|
|
|
10
10
|
from ..config import ObservabilitySettings
|
|
11
|
-
from .lifecycle import
|
|
11
|
+
from .lifecycle import flush_on_app_shutdown
|
|
12
12
|
from .resource import (
|
|
13
13
|
build_otlp_exporter,
|
|
14
14
|
create_otel_resource,
|
|
@@ -75,7 +75,7 @@ def install_otel_metrics(
|
|
|
75
75
|
},
|
|
76
76
|
)
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
flush_on_app_shutdown(app, meter_provider)
|
|
79
79
|
_LOGGER.info(
|
|
80
80
|
"otel.metrics.installed",
|
|
81
81
|
extra={
|
|
@@ -38,9 +38,11 @@ class _BaseOTelSettings(EnvLoadable, BaseModel):
|
|
|
38
38
|
class OTelSettings(_BaseOTelSettings):
|
|
39
39
|
|
|
40
40
|
enabled: bool = False
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
# None: ObservabilitySettings' service, version and environment, so traces
|
|
42
|
+
# and metrics name the service the same way.
|
|
43
|
+
service_name: str | None = None
|
|
44
|
+
service_version: str | None = None
|
|
45
|
+
environment: str | None = None
|
|
44
46
|
trace_sampling_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
45
47
|
extra_resource_attributes: dict[str, str] = Field(default_factory=dict)
|
|
46
48
|
|
|
@@ -10,7 +10,7 @@ from fastapi import FastAPI
|
|
|
10
10
|
|
|
11
11
|
from ..config import ObservabilitySettings
|
|
12
12
|
from ..request_context import get_request_state
|
|
13
|
-
from .lifecycle import
|
|
13
|
+
from .lifecycle import flush_on_app_shutdown
|
|
14
14
|
from .native import trace_natively
|
|
15
15
|
from .resource import (
|
|
16
16
|
build_excluded_urls_csv,
|
|
@@ -109,8 +109,7 @@ def install_otel(
|
|
|
109
109
|
"opentelemetry.instrumentation.fastapi"
|
|
110
110
|
).FastAPIInstrumentor.instrument_app(app, **instrument_kwargs)
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
close_on_app_shutdown(app, tracer_provider, shutdown=True)
|
|
112
|
+
flush_on_app_shutdown(app, tracer_provider)
|
|
114
113
|
|
|
115
114
|
_OTEL_INSTALLED_APPS.add(app)
|
|
116
115
|
|