fastapi-observer 1.9.0__tar.gz → 1.11.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.9.0/src/fastapi_observer.egg-info → fastapi_observer-1.11.0}/PKG-INFO +2 -1
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/README.md +1 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0/src/fastapi_observer.egg-info}/PKG-INFO +2 -1
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/SOURCES.txt +5 -0
- fastapi_observer-1.11.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/config.py +19 -2
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/control_plane.py +3 -4
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/fastapi.py +11 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/__init__.py +11 -1
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/filters.py +4 -1
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/formatter.py +4 -1
- fastapi_observer-1.11.0/src/fastapiobserver/logging/held.py +111 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/setup.py +47 -4
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/state.py +2 -0
- fastapi_observer-1.11.0/src/fastapiobserver/logging/volume.py +55 -0
- fastapi_observer-1.11.0/src/fastapiobserver/loop_watchdog.py +77 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/metrics/prometheus.py +19 -2
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/context.py +12 -1
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/events.py +7 -5
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/request_logging.py +42 -1
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/tracing.py +36 -0
- fastapi_observer-1.11.0/src/fastapiobserver/request_context.py +139 -0
- fastapi_observer-1.11.0/tests/test_loop_watchdog.py +52 -0
- fastapi_observer-1.11.0/tests/test_request_insights.py +276 -0
- fastapi_observer-1.9.0/src/fastapiobserver/_version.py +0 -1
- fastapi_observer-1.9.0/src/fastapiobserver/request_context.py +0 -74
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/LICENSE +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/pyproject.toml +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/setup.cfg +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/formatter.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/providers.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/verify.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/db_tracing.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/integrations/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/queueing.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/metrics/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/headers.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/ip.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/metrics.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/exclusions.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/profiles.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/normalize.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/policy.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/proxies.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/redaction.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/builtin.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/discovery.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/factory.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/protocol.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/registry.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/stats.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_audit_chain.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_db_tracing.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_dx_parity.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_fastapi_install.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_fork_queue_listener.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_graphql_integrations.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_lifespan_teardown.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_logging_schema_contract.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_logtail_dlq.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_metrics_contracts.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_middleware.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_profiles.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_security_audit_fixes.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_sinks.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-1.9.0 → fastapi_observer-1.11.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.11.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
|
|
@@ -152,6 +152,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
152
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
|
+
- **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. 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.
|
|
155
156
|
|
|
156
157
|
Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlchemy, `httpx` and `requests` tracing, Strawberry GraphQL and a Loguru bridge. See the [documentation](https://vitaee.github.io/FastapiObserver/).
|
|
157
158
|
|
|
@@ -76,6 +76,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
76
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
|
+
- **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. 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.
|
|
79
80
|
|
|
80
81
|
Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlchemy, `httpx` and `requests` tracing, Strawberry GraphQL and a Loguru bridge. See the [documentation](https://vitaee.github.io/FastapiObserver/).
|
|
81
82
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.11.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
|
|
@@ -152,6 +152,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
|
|
|
152
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
|
+
- **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. 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.
|
|
155
156
|
|
|
156
157
|
Optional extras: runtime log-level control, a tamper-evident audit log, SQLAlchemy, `httpx` and `requests` tracing, Strawberry GraphQL and a Loguru bridge. See the [documentation](https://vitaee.github.io/FastapiObserver/).
|
|
157
158
|
|
{fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/SOURCES.txt
RENAMED
|
@@ -13,6 +13,7 @@ src/fastapiobserver/control_plane.py
|
|
|
13
13
|
src/fastapiobserver/db_tracing.py
|
|
14
14
|
src/fastapiobserver/fastapi.py
|
|
15
15
|
src/fastapiobserver/loguru.py
|
|
16
|
+
src/fastapiobserver/loop_watchdog.py
|
|
16
17
|
src/fastapiobserver/plugins.py
|
|
17
18
|
src/fastapiobserver/profiles.py
|
|
18
19
|
src/fastapiobserver/propagation.py
|
|
@@ -29,9 +30,11 @@ src/fastapiobserver/logging/__init__.py
|
|
|
29
30
|
src/fastapiobserver/logging/circuit_breaker.py
|
|
30
31
|
src/fastapiobserver/logging/filters.py
|
|
31
32
|
src/fastapiobserver/logging/formatter.py
|
|
33
|
+
src/fastapiobserver/logging/held.py
|
|
32
34
|
src/fastapiobserver/logging/queueing.py
|
|
33
35
|
src/fastapiobserver/logging/setup.py
|
|
34
36
|
src/fastapiobserver/logging/state.py
|
|
37
|
+
src/fastapiobserver/logging/volume.py
|
|
35
38
|
src/fastapiobserver/metrics/__init__.py
|
|
36
39
|
src/fastapiobserver/metrics/prometheus.py
|
|
37
40
|
src/fastapiobserver/middleware/__init__.py
|
|
@@ -80,6 +83,7 @@ tests/test_log_queue_controls.py
|
|
|
80
83
|
tests/test_logging_schema_contract.py
|
|
81
84
|
tests/test_logtail_dlq.py
|
|
82
85
|
tests/test_loguru_bridge.py
|
|
86
|
+
tests/test_loop_watchdog.py
|
|
83
87
|
tests/test_metrics_contracts.py
|
|
84
88
|
tests/test_metrics_optional_dependency.py
|
|
85
89
|
tests/test_middleware.py
|
|
@@ -95,6 +99,7 @@ tests/test_plugin_isolation.py
|
|
|
95
99
|
tests/test_profiles.py
|
|
96
100
|
tests/test_propagation.py
|
|
97
101
|
tests/test_redaction_presets.py
|
|
102
|
+
tests/test_request_insights.py
|
|
98
103
|
tests/test_route_template_cardinality.py
|
|
99
104
|
tests/test_runtime_control_auth.py
|
|
100
105
|
tests/test_security_audit_fixes.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.11.0"
|
|
@@ -32,6 +32,15 @@ class ObservabilitySettings(InternalSettingsBase):
|
|
|
32
32
|
|
|
33
33
|
# --- logging ---
|
|
34
34
|
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
|
35
|
+
# Records from this level up to log_level are held per request and written
|
|
36
|
+
# only if the request fails.
|
|
37
|
+
log_level_on_error: str | None = Field(default=None, validation_alias="LOG_LEVEL_ON_ERROR")
|
|
38
|
+
# Share of requests whose INFO and lower lines are written, chosen by trace ID.
|
|
39
|
+
log_sample_rate: float = Field(default=1.0, ge=0.0, le=1.0, validation_alias="LOG_SAMPLE_RATE")
|
|
40
|
+
# Requests at least this slow are written in full, sampled or not.
|
|
41
|
+
log_slow_request_ms: float = Field(
|
|
42
|
+
default=1000.0, gt=0, validation_alias="LOG_SLOW_REQUEST_MS"
|
|
43
|
+
)
|
|
35
44
|
log_dir: str | None = Field(default=None, validation_alias="LOG_DIR")
|
|
36
45
|
log_queue_max_size: int = Field(default=10_000, gt=0, validation_alias="LOG_QUEUE_MAX_SIZE")
|
|
37
46
|
log_queue_overflow_policy: LogQueueOverflowPolicy = Field(
|
|
@@ -81,6 +90,12 @@ class ObservabilitySettings(InternalSettingsBase):
|
|
|
81
90
|
default="negotiate", validation_alias="METRICS_FORMAT"
|
|
82
91
|
)
|
|
83
92
|
|
|
93
|
+
# --- event loop ---
|
|
94
|
+
# Log the blocking call's stack when the loop doesn't run for this long.
|
|
95
|
+
loop_block_threshold_ms: float | None = Field(
|
|
96
|
+
default=None, gt=0, validation_alias="OBS_LOOP_BLOCK_THRESHOLD_MS"
|
|
97
|
+
)
|
|
98
|
+
|
|
84
99
|
# --- otel trace noise control ---
|
|
85
100
|
otel_excluded_urls: tuple[str, ...] | None = Field(
|
|
86
101
|
default=None, validation_alias="OTEL_EXCLUDED_URLS"
|
|
@@ -135,9 +150,11 @@ class ObservabilitySettings(InternalSettingsBase):
|
|
|
135
150
|
|
|
136
151
|
# ---------- validators ----------
|
|
137
152
|
|
|
138
|
-
@field_validator("log_level")
|
|
153
|
+
@field_validator("log_level", "log_level_on_error")
|
|
139
154
|
@classmethod
|
|
140
|
-
def _validate_log_level(cls, value: str) -> str:
|
|
155
|
+
def _validate_log_level(cls, value: str | None) -> str | None:
|
|
156
|
+
if value is None:
|
|
157
|
+
return None
|
|
141
158
|
normalized_log_level = value.upper().strip()
|
|
142
159
|
level = logging.getLevelName(normalized_log_level)
|
|
143
160
|
if not isinstance(level, int):
|
|
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request,
|
|
|
10
10
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
11
11
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
12
|
|
|
13
|
+
from .logging import get_log_level, set_log_level
|
|
13
14
|
from .otel import get_trace_sampling_ratio, set_trace_sampling_ratio
|
|
14
15
|
from .security import TrustedProxyPolicy, is_trusted_client_ip, resolve_client_ip
|
|
15
16
|
from .utils import EnvLoadable, normalize_path
|
|
@@ -137,7 +138,7 @@ def _is_trusted_caller(request: Request, policy: TrustedProxyPolicy) -> bool:
|
|
|
137
138
|
|
|
138
139
|
def _current_runtime_settings() -> dict[str, object]:
|
|
139
140
|
return {
|
|
140
|
-
"log_level":
|
|
141
|
+
"log_level": get_log_level(),
|
|
141
142
|
"trace_sampling_ratio": get_trace_sampling_ratio(),
|
|
142
143
|
}
|
|
143
144
|
|
|
@@ -151,9 +152,7 @@ def _set_log_level(value: str) -> None:
|
|
|
151
152
|
detail=f"Invalid log level: {value}",
|
|
152
153
|
)
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
156
|
-
logging.getLogger(logger_name).setLevel(level)
|
|
155
|
+
set_log_level(level)
|
|
157
156
|
|
|
158
157
|
|
|
159
158
|
class _RuntimeControlEnvSettings(BaseSettings):
|
|
@@ -16,6 +16,7 @@ from fastapi import FastAPI
|
|
|
16
16
|
from .config import ObservabilitySettings
|
|
17
17
|
from .control_plane import RuntimeControlSettings, mount_control_plane
|
|
18
18
|
from .logging import setup_logging, shutdown_logging
|
|
19
|
+
from .loop_watchdog import EventLoopWatchdog
|
|
19
20
|
from .profiles import apply_profile_context
|
|
20
21
|
from .db_tracing import uninstrument_sqlalchemy
|
|
21
22
|
from .metrics import (
|
|
@@ -148,6 +149,10 @@ def install_observability(
|
|
|
148
149
|
"event": {
|
|
149
150
|
"profile": os.getenv("OBS_PROFILE", "").strip().lower() or None,
|
|
150
151
|
"log_level": settings.log_level,
|
|
152
|
+
"log_level_on_error": settings.log_level_on_error,
|
|
153
|
+
"log_sample_rate": settings.log_sample_rate,
|
|
154
|
+
"log_slow_request_ms": settings.log_slow_request_ms,
|
|
155
|
+
"loop_block_threshold_ms": settings.loop_block_threshold_ms,
|
|
151
156
|
"metrics_path": (
|
|
152
157
|
settings.metrics_path
|
|
153
158
|
if (settings.metrics_enabled if metrics_enabled is None else metrics_enabled)
|
|
@@ -389,12 +394,18 @@ async def observability_lifespan(app: FastAPI) -> typing.AsyncGenerator[typing.A
|
|
|
389
394
|
yield
|
|
390
395
|
"""
|
|
391
396
|
state = _get_runtime_state(app)
|
|
397
|
+
watchdog = None
|
|
392
398
|
if state:
|
|
393
399
|
_auto_discover_excluded_routes(app, state.settings)
|
|
400
|
+
if state.settings.loop_block_threshold_ms:
|
|
401
|
+
watchdog = EventLoopWatchdog(state.settings.loop_block_threshold_ms / 1000)
|
|
402
|
+
watchdog.start()
|
|
394
403
|
|
|
395
404
|
try:
|
|
396
405
|
yield {}
|
|
397
406
|
finally:
|
|
407
|
+
if watchdog:
|
|
408
|
+
watchdog.stop()
|
|
398
409
|
_teardown_observability(app)
|
|
399
410
|
|
|
400
411
|
|
|
@@ -14,7 +14,14 @@ from .queueing import (
|
|
|
14
14
|
OverflowPolicyQueueHandler,
|
|
15
15
|
get_log_queue_stats,
|
|
16
16
|
)
|
|
17
|
-
from .setup import
|
|
17
|
+
from .setup import (
|
|
18
|
+
get_log_level,
|
|
19
|
+
get_managed_output_handlers,
|
|
20
|
+
set_log_level,
|
|
21
|
+
setup_logging,
|
|
22
|
+
shutdown_logging,
|
|
23
|
+
)
|
|
24
|
+
from .volume import get_log_bytes_by_route
|
|
18
25
|
|
|
19
26
|
__all__ = [
|
|
20
27
|
"LOG_SCHEMA_VERSION",
|
|
@@ -28,9 +35,12 @@ __all__ = [
|
|
|
28
35
|
"SinkCircuitBreakerSnapshot",
|
|
29
36
|
"StructuredJsonFormatter",
|
|
30
37
|
"TraceContextFilter",
|
|
38
|
+
"get_log_bytes_by_route",
|
|
39
|
+
"get_log_level",
|
|
31
40
|
"get_log_queue_stats",
|
|
32
41
|
"get_managed_output_handlers",
|
|
33
42
|
"get_sink_circuit_breaker_stats",
|
|
43
|
+
"set_log_level",
|
|
34
44
|
"setup_logging",
|
|
35
45
|
"shutdown_logging",
|
|
36
46
|
]
|
|
@@ -5,12 +5,13 @@ import sys
|
|
|
5
5
|
|
|
6
6
|
from ..plugins import apply_log_filters
|
|
7
7
|
from ..request_context import get_request_id, get_span_id, get_trace_id, get_user_context
|
|
8
|
+
from . import volume
|
|
8
9
|
|
|
9
10
|
_LOGGER = logging.getLogger("fastapiobserver.logging")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class RequestIdFilter(logging.Filter):
|
|
13
|
-
"""Attach request ID
|
|
14
|
+
"""Attach request ID, user context and route from context vars to every log record.
|
|
14
15
|
|
|
15
16
|
Records are formatted on the queue listener thread, where the request's
|
|
16
17
|
context vars are not visible, so they are captured here.
|
|
@@ -21,6 +22,8 @@ class RequestIdFilter(logging.Filter):
|
|
|
21
22
|
record.request_id = get_request_id()
|
|
22
23
|
if not getattr(record, "user_context", None):
|
|
23
24
|
record.user_context = get_user_context()
|
|
25
|
+
if volume.COUNTING:
|
|
26
|
+
record._obs_route = volume.current_route()
|
|
24
27
|
return True
|
|
25
28
|
|
|
26
29
|
|
|
@@ -13,6 +13,7 @@ from ..plugins import apply_log_enrichers
|
|
|
13
13
|
from ..request_context import get_request_id, get_span_id, get_trace_id, get_user_context
|
|
14
14
|
from ..security import SecurityPolicy, sanitize_event
|
|
15
15
|
from ..utils import lazy_import
|
|
16
|
+
from .volume import count_log_bytes
|
|
16
17
|
|
|
17
18
|
orjson: Any
|
|
18
19
|
try:
|
|
@@ -112,7 +113,9 @@ class StructuredJsonFormatter(logging.Formatter):
|
|
|
112
113
|
sanitized_payload = _as_dict(sanitized_candidate)
|
|
113
114
|
else:
|
|
114
115
|
sanitized_payload = enriched_payload
|
|
115
|
-
|
|
116
|
+
line = _json_dumps(sanitized_payload)
|
|
117
|
+
count_log_bytes(record, line)
|
|
118
|
+
return line
|
|
116
119
|
|
|
117
120
|
|
|
118
121
|
def _as_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""A request's log records, held until it ends and then written or dropped.
|
|
2
|
+
|
|
3
|
+
Two settings hold records:
|
|
4
|
+
|
|
5
|
+
- ``LOG_LEVEL_ON_ERROR``: a request's records below ``LOG_LEVEL`` are written
|
|
6
|
+
only if it ends in a 5xx or an unhandled exception.
|
|
7
|
+
- ``LOG_SAMPLE_RATE``: a request that isn't sampled has its ``INFO`` and lower
|
|
8
|
+
records written only if it fails or takes ``LOG_SLOW_REQUEST_MS`` or longer.
|
|
9
|
+
``WARNING`` and higher are always written.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
from collections import deque
|
|
17
|
+
|
|
18
|
+
from ..request_context import RequestState, get_request_state
|
|
19
|
+
from . import state
|
|
20
|
+
|
|
21
|
+
# ponytail: fixed cap per request, newest kept; make it a setting if 200 isn't enough.
|
|
22
|
+
_MAX_HELD_RECORDS = 200
|
|
23
|
+
_LOW_64_BITS = (1 << 64) - 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HeldLogFilter(logging.Filter):
|
|
27
|
+
"""Holds a request's records instead of passing them on, until it ends.
|
|
28
|
+
|
|
29
|
+
It is the queue handler's last filter, so held records already carry their
|
|
30
|
+
request ID and trace context. Outside a request, records below *level* are
|
|
31
|
+
dropped, as the log level would have dropped them. *lowest_level* is
|
|
32
|
+
``LOG_LEVEL_ON_ERROR``, or *level* without it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
handler: logging.Handler,
|
|
38
|
+
level: int,
|
|
39
|
+
lowest_level: int,
|
|
40
|
+
sample_rate: float = 1.0,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.handler = handler
|
|
44
|
+
self.level = level
|
|
45
|
+
self.lowest_level = lowest_level
|
|
46
|
+
# The bound OpenTelemetry's TraceIdRatioBased sampler uses for the same ratio.
|
|
47
|
+
self.sample_bound = round(sample_rate * (_LOW_64_BITS + 1))
|
|
48
|
+
|
|
49
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
50
|
+
request_state = get_request_state()
|
|
51
|
+
if record.levelno >= self.level and (
|
|
52
|
+
request_state is None
|
|
53
|
+
or record.levelno >= logging.WARNING
|
|
54
|
+
or self._sampled(request_state, record)
|
|
55
|
+
):
|
|
56
|
+
return True
|
|
57
|
+
if request_state is not None:
|
|
58
|
+
if request_state.held_logs is None:
|
|
59
|
+
request_state.held_logs = deque(maxlen=_MAX_HELD_RECORDS)
|
|
60
|
+
request_state.held_logs.append(record)
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def _sampled(self, request_state: RequestState, record: logging.LogRecord) -> bool:
|
|
64
|
+
if self.sample_bound > _LOW_64_BITS:
|
|
65
|
+
return True
|
|
66
|
+
if request_state.sampled is None:
|
|
67
|
+
bits = _sampling_bits(record)
|
|
68
|
+
request_state.sampled = bits is None or bits < self.sample_bound
|
|
69
|
+
return request_state.sampled
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sampling_bits(record: logging.LogRecord) -> int | None:
|
|
73
|
+
"""The 64 bits that decide whether a record's request is sampled.
|
|
74
|
+
|
|
75
|
+
The low half of its trace ID, which OpenTelemetry's TraceIdRatioBased sampler
|
|
76
|
+
compares, so every service that samples logs and traces at the same ratio
|
|
77
|
+
keeps the same requests. Without a trace ID, a hash of the request ID, which
|
|
78
|
+
services forward to each other.
|
|
79
|
+
"""
|
|
80
|
+
trace_id = getattr(record, "trace_id", None)
|
|
81
|
+
if isinstance(trace_id, str):
|
|
82
|
+
try:
|
|
83
|
+
return int(trace_id, 16) & _LOW_64_BITS
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
request_id = getattr(record, "request_id", None)
|
|
87
|
+
if not request_id:
|
|
88
|
+
return None
|
|
89
|
+
digest = hashlib.blake2b(str(request_id).encode(), digest_size=8).digest()
|
|
90
|
+
return int.from_bytes(digest, "big")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def keep_request_logs(*, failed: bool) -> None:
|
|
94
|
+
"""Write the current request's held records and pass its later ones.
|
|
95
|
+
|
|
96
|
+
Called when a request fails or is slow. Records held only because they are
|
|
97
|
+
below the log level are written only if it *failed*.
|
|
98
|
+
"""
|
|
99
|
+
held_filter = state._HELD_LOG_FILTER
|
|
100
|
+
request_state = get_request_state()
|
|
101
|
+
if held_filter is None or request_state is None:
|
|
102
|
+
return
|
|
103
|
+
request_state.sampled = True
|
|
104
|
+
held, request_state.held_logs = request_state.held_logs, None
|
|
105
|
+
for record in held or ():
|
|
106
|
+
if failed or record.levelno >= held_filter.level:
|
|
107
|
+
# emit(), not handle(): the filters already ran when the record was held.
|
|
108
|
+
held_filter.handler.emit(record)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["HeldLogFilter", "keep_request_logs"]
|
|
@@ -12,9 +12,11 @@ from ..sinks import build_sink_handlers
|
|
|
12
12
|
from .circuit_breaker import SinkCircuitBreakerHandler
|
|
13
13
|
from .filters import PluginLogFilter, RequestIdFilter, TraceContextFilter
|
|
14
14
|
from .formatter import StructuredJsonFormatter
|
|
15
|
+
from .held import HeldLogFilter
|
|
15
16
|
from .queueing import OverflowPolicyQueueHandler, _LOG_QUEUE_TELEMETRY
|
|
16
17
|
|
|
17
18
|
_LOGGER = logging.getLogger("fastapiobserver.logging")
|
|
19
|
+
_UVICORN_LOGGERS = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
def get_managed_output_handlers() -> list[logging.Handler]:
|
|
@@ -94,6 +96,7 @@ def _teardown_handlers_locked(suppress_errors: bool = True) -> None:
|
|
|
94
96
|
|
|
95
97
|
state._MANAGED_OUTPUT_HANDLERS = []
|
|
96
98
|
state._SINK_CIRCUIT_BREAKERS = []
|
|
99
|
+
state._HELD_LOG_FILTER = None
|
|
97
100
|
state._MANAGED_HANDLERS.clear()
|
|
98
101
|
|
|
99
102
|
if errors and not suppress_errors:
|
|
@@ -150,8 +153,6 @@ def setup_logging(
|
|
|
150
153
|
delegate=formatter, key_provider=cast(AuditKeyProvider, provider),
|
|
151
154
|
)
|
|
152
155
|
|
|
153
|
-
root_logger.setLevel(settings.log_level.upper())
|
|
154
|
-
|
|
155
156
|
# Build output handlers based on logs_mode
|
|
156
157
|
output_handlers: list[tuple[logging.Handler, str]] = []
|
|
157
158
|
if logs_mode in ("local_json", "both"):
|
|
@@ -195,6 +196,17 @@ def setup_logging(
|
|
|
195
196
|
telemetry=_LOG_QUEUE_TELEMETRY,
|
|
196
197
|
)
|
|
197
198
|
_configure_queue_handler(queue_handler)
|
|
199
|
+
level = logging.getLevelName(settings.log_level)
|
|
200
|
+
lowest_level = level
|
|
201
|
+
if settings.log_level_on_error is not None:
|
|
202
|
+
lowest_level = min(level, logging.getLevelName(settings.log_level_on_error))
|
|
203
|
+
if lowest_level < level or settings.log_sample_rate < 1:
|
|
204
|
+
held_filter = HeldLogFilter(
|
|
205
|
+
queue_handler, level, lowest_level, settings.log_sample_rate
|
|
206
|
+
)
|
|
207
|
+
queue_handler.addFilter(held_filter)
|
|
208
|
+
state._HELD_LOG_FILTER = held_filter
|
|
209
|
+
set_log_level(level)
|
|
198
210
|
root_logger.addHandler(queue_handler)
|
|
199
211
|
|
|
200
212
|
listener = logging.handlers.QueueListener(
|
|
@@ -208,11 +220,42 @@ def setup_logging(
|
|
|
208
220
|
_ensure_atexit_shutdown_hook_locked()
|
|
209
221
|
_ensure_fork_reinitialization_locked()
|
|
210
222
|
|
|
211
|
-
for name in
|
|
223
|
+
for name in _UVICORN_LOGGERS:
|
|
212
224
|
uvicorn_logger = logging.getLogger(name)
|
|
213
225
|
uvicorn_logger.handlers = []
|
|
214
226
|
uvicorn_logger.propagate = True
|
|
215
|
-
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def set_log_level(level: int) -> None:
|
|
230
|
+
"""Set the log level, at startup and from the runtime control plane.
|
|
231
|
+
|
|
232
|
+
With ``LOG_LEVEL_ON_ERROR`` below *level*, the root logger stays at that
|
|
233
|
+
lower level so the records are created, and the held-log filter applies
|
|
234
|
+
*level* instead.
|
|
235
|
+
"""
|
|
236
|
+
import fastapiobserver.logging.state as state
|
|
237
|
+
|
|
238
|
+
root_level = level
|
|
239
|
+
held_filter = state._HELD_LOG_FILTER
|
|
240
|
+
if held_filter is not None:
|
|
241
|
+
held_filter.level = level
|
|
242
|
+
root_level = min(level, held_filter.lowest_level)
|
|
243
|
+
logging.getLogger().setLevel(root_level)
|
|
244
|
+
for name in _UVICORN_LOGGERS:
|
|
245
|
+
logging.getLogger(name).setLevel(level)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_log_level() -> str:
|
|
249
|
+
"""The level records are written at, as set by :func:`set_log_level`."""
|
|
250
|
+
import fastapiobserver.logging.state as state
|
|
251
|
+
|
|
252
|
+
held_filter = state._HELD_LOG_FILTER
|
|
253
|
+
level = (
|
|
254
|
+
held_filter.level
|
|
255
|
+
if held_filter is not None
|
|
256
|
+
else logging.getLogger().getEffectiveLevel()
|
|
257
|
+
)
|
|
258
|
+
return logging.getLevelName(level)
|
|
216
259
|
|
|
217
260
|
|
|
218
261
|
def _configure_queue_handler(handler: logging.Handler) -> None:
|
|
@@ -8,10 +8,12 @@ from typing import TYPE_CHECKING
|
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from .circuit_breaker import SinkCircuitBreakerHandler
|
|
11
|
+
from .held import HeldLogFilter
|
|
11
12
|
|
|
12
13
|
_LOGGING_LOCK = threading.Lock()
|
|
13
14
|
_QUEUE_LISTENER: logging.handlers.QueueListener | None = None
|
|
14
15
|
_MANAGED_OUTPUT_HANDLERS: list[logging.Handler] = []
|
|
15
16
|
_SINK_CIRCUIT_BREAKERS: list["SinkCircuitBreakerHandler"] = []
|
|
16
17
|
_MANAGED_HANDLERS: weakref.WeakSet[logging.Handler] = weakref.WeakSet()
|
|
18
|
+
_HELD_LOG_FILTER: "HeldLogFilter | None" = None
|
|
17
19
|
_ATEXIT_SHUTDOWN_REGISTERED = False
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""JSON log volume by route, exported as ``fastapiobserver_log_bytes_total``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
from ..request_context import UNMATCHED_ROUTE, get_request_state, route_template
|
|
9
|
+
|
|
10
|
+
# Label for lines written outside a request, such as startup and background work.
|
|
11
|
+
NO_REQUEST = "<none>"
|
|
12
|
+
|
|
13
|
+
_LOCK = threading.Lock()
|
|
14
|
+
_BYTES_BY_ROUTE: dict[str, int] = {}
|
|
15
|
+
# Off until the Prometheus collector that exports the counts is registered.
|
|
16
|
+
COUNTING = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def start_counting() -> None:
|
|
20
|
+
global COUNTING
|
|
21
|
+
COUNTING = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current_route() -> str:
|
|
25
|
+
"""The route label for a record created now, read on the caller's thread."""
|
|
26
|
+
request_state = get_request_state()
|
|
27
|
+
if request_state is None:
|
|
28
|
+
return NO_REQUEST
|
|
29
|
+
return route_template(request_state.scope) or UNMATCHED_ROUTE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def count_log_bytes(record: logging.LogRecord, line: str) -> None:
|
|
33
|
+
route = getattr(record, "_obs_route", None)
|
|
34
|
+
# Not counting, or every sink formats the same record and it's counted once.
|
|
35
|
+
if route is None or getattr(record, "_obs_bytes_counted", False):
|
|
36
|
+
return
|
|
37
|
+
record._obs_bytes_counted = True
|
|
38
|
+
size = len(line) if line.isascii() else len(line.encode())
|
|
39
|
+
with _LOCK:
|
|
40
|
+
_BYTES_BY_ROUTE[route] = _BYTES_BY_ROUTE.get(route, 0) + size
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_log_bytes_by_route() -> dict[str, int]:
|
|
44
|
+
"""Bytes of JSON log lines written so far, by the route that wrote them."""
|
|
45
|
+
with _LOCK:
|
|
46
|
+
return dict(_BYTES_BY_ROUTE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"NO_REQUEST",
|
|
51
|
+
"count_log_bytes",
|
|
52
|
+
"current_route",
|
|
53
|
+
"get_log_bytes_by_route",
|
|
54
|
+
"start_counting",
|
|
55
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Log the blocking call when something stops the event loop.
|
|
2
|
+
|
|
3
|
+
A sync call inside ``async def``, such as ``requests.get``, ``time.sleep`` or a
|
|
4
|
+
sync database driver, stops every request on the worker until it returns.
|
|
5
|
+
asyncio's debug mode reports the slow callback afterwards, without the line that
|
|
6
|
+
blocked. This thread reads the loop thread's stack while it is still blocked.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import traceback
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger("fastapiobserver.event_loop")
|
|
19
|
+
# Innermost frames only; the outer ones are the server and the middleware.
|
|
20
|
+
_STACK_LIMIT = 30
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EventLoopWatchdog:
|
|
24
|
+
"""Sends the running loop a callback and logs ``event_loop.blocked`` if it
|
|
25
|
+
hasn't run after *threshold_seconds*, once per block.
|
|
26
|
+
|
|
27
|
+
Create it on the loop's thread, as the app's lifespan does.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, threshold_seconds: float) -> None:
|
|
31
|
+
self._loop = asyncio.get_running_loop()
|
|
32
|
+
self._loop_thread_id = threading.get_ident()
|
|
33
|
+
self._threshold = threshold_seconds
|
|
34
|
+
self._sent_at: float | None = None # when the unanswered callback was sent
|
|
35
|
+
self._stopped = threading.Event()
|
|
36
|
+
self._thread = threading.Thread(
|
|
37
|
+
target=self._watch, name="fastapiobserver-loop-watchdog", daemon=True
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def start(self) -> None:
|
|
41
|
+
self._thread.start()
|
|
42
|
+
|
|
43
|
+
def stop(self) -> None:
|
|
44
|
+
self._stopped.set()
|
|
45
|
+
self._thread.join(timeout=1.0)
|
|
46
|
+
|
|
47
|
+
def _answer(self) -> None:
|
|
48
|
+
self._sent_at = None
|
|
49
|
+
|
|
50
|
+
def _watch(self) -> None:
|
|
51
|
+
reported = False
|
|
52
|
+
while not self._stopped.wait(self._threshold / 2):
|
|
53
|
+
sent_at = self._sent_at
|
|
54
|
+
if sent_at is None:
|
|
55
|
+
reported = False
|
|
56
|
+
self._sent_at = time.monotonic()
|
|
57
|
+
try:
|
|
58
|
+
self._loop.call_soon_threadsafe(self._answer)
|
|
59
|
+
except RuntimeError: # the loop is closed
|
|
60
|
+
return
|
|
61
|
+
elif not reported and time.monotonic() - sent_at >= self._threshold:
|
|
62
|
+
reported = True
|
|
63
|
+
self._report(time.monotonic() - sent_at)
|
|
64
|
+
|
|
65
|
+
def _report(self, blocked_seconds: float) -> None:
|
|
66
|
+
frame = sys._current_frames().get(self._loop_thread_id)
|
|
67
|
+
stack = "".join(traceback.format_stack(frame, limit=_STACK_LIMIT)) if frame else None
|
|
68
|
+
_LOGGER.warning(
|
|
69
|
+
"event_loop.blocked",
|
|
70
|
+
extra={
|
|
71
|
+
"event": {"blocked_ms": round(blocked_seconds * 1000), "stack": stack},
|
|
72
|
+
"_skip_enrichers": True,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["EventLoopWatchdog"]
|
{fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/metrics/prometheus.py
RENAMED
|
@@ -82,12 +82,17 @@ def _get_trace_exemplar() -> dict[str, str] | None:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
class _LogQueueMetricsCollector:
|
|
85
|
-
"""Exposes log-queue, Logtail DLQ and sink circuit-breaker stats at
|
|
85
|
+
"""Exposes log-queue, log volume, Logtail DLQ and sink circuit-breaker stats at
|
|
86
|
+
scrape time."""
|
|
86
87
|
|
|
87
88
|
def collect(self) -> Iterator[Any]:
|
|
88
89
|
from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
|
|
89
90
|
|
|
90
|
-
from ..logging import
|
|
91
|
+
from ..logging import (
|
|
92
|
+
get_log_bytes_by_route,
|
|
93
|
+
get_log_queue_stats,
|
|
94
|
+
get_sink_circuit_breaker_stats,
|
|
95
|
+
)
|
|
91
96
|
from ..sinks import get_logtail_dlq_stats
|
|
92
97
|
|
|
93
98
|
queue = get_log_queue_stats()
|
|
@@ -132,6 +137,15 @@ class _LogQueueMetricsCollector:
|
|
|
132
137
|
value=float(queue["block_timeout_total"]),
|
|
133
138
|
)
|
|
134
139
|
|
|
140
|
+
log_bytes = CounterMetricFamily(
|
|
141
|
+
"fastapiobserver_log_bytes_total",
|
|
142
|
+
"Bytes of JSON log lines written, by the route of the request that wrote them.",
|
|
143
|
+
labels=["route"],
|
|
144
|
+
)
|
|
145
|
+
for route, size in sorted(get_log_bytes_by_route().items()):
|
|
146
|
+
log_bytes.add_metric([route], float(size))
|
|
147
|
+
yield log_bytes
|
|
148
|
+
|
|
135
149
|
dlq = get_logtail_dlq_stats()
|
|
136
150
|
written = CounterMetricFamily(
|
|
137
151
|
"fastapiobserver_dlq_written_total",
|
|
@@ -176,6 +190,9 @@ def _register_log_queue_metrics_collector() -> None:
|
|
|
176
190
|
# Per-worker queue stats would be meaningless once aggregated across processes.
|
|
177
191
|
if _multiprocess_dir():
|
|
178
192
|
return
|
|
193
|
+
from ..logging.volume import start_counting
|
|
194
|
+
|
|
195
|
+
start_counting()
|
|
179
196
|
try:
|
|
180
197
|
_import_prometheus_client().REGISTRY.register(_LogQueueMetricsCollector())
|
|
181
198
|
except ValueError:
|