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.
Files changed (114) hide show
  1. {fastapi_observer-1.9.0/src/fastapi_observer.egg-info → fastapi_observer-1.11.0}/PKG-INFO +2 -1
  2. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/README.md +1 -0
  3. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0/src/fastapi_observer.egg-info}/PKG-INFO +2 -1
  4. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/SOURCES.txt +5 -0
  5. fastapi_observer-1.11.0/src/fastapiobserver/_version.py +1 -0
  6. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/config.py +19 -2
  7. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/control_plane.py +3 -4
  8. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/fastapi.py +11 -0
  9. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/__init__.py +11 -1
  10. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/filters.py +4 -1
  11. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/formatter.py +4 -1
  12. fastapi_observer-1.11.0/src/fastapiobserver/logging/held.py +111 -0
  13. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/setup.py +47 -4
  14. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/state.py +2 -0
  15. fastapi_observer-1.11.0/src/fastapiobserver/logging/volume.py +55 -0
  16. fastapi_observer-1.11.0/src/fastapiobserver/loop_watchdog.py +77 -0
  17. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/metrics/prometheus.py +19 -2
  18. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/context.py +12 -1
  19. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/events.py +7 -5
  20. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/request_logging.py +42 -1
  21. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/tracing.py +36 -0
  22. fastapi_observer-1.11.0/src/fastapiobserver/request_context.py +139 -0
  23. fastapi_observer-1.11.0/tests/test_loop_watchdog.py +52 -0
  24. fastapi_observer-1.11.0/tests/test_request_insights.py +276 -0
  25. fastapi_observer-1.9.0/src/fastapiobserver/_version.py +0 -1
  26. fastapi_observer-1.9.0/src/fastapiobserver/request_context.py +0 -74
  27. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/LICENSE +0 -0
  28. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/pyproject.toml +0 -0
  29. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/setup.cfg +0 -0
  30. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  31. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  32. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  33. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/__init__.py +0 -0
  34. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/__init__.py +0 -0
  35. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/formatter.py +0 -0
  36. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/providers.py +0 -0
  37. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/audit/verify.py +0 -0
  38. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/db_tracing.py +0 -0
  39. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  40. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  41. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  42. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/logging/queueing.py +0 -0
  43. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/loguru.py +0 -0
  44. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  45. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  46. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  47. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/headers.py +0 -0
  48. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/ip.py +0 -0
  49. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  50. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/__init__.py +0 -0
  51. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  52. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  53. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/logs.py +0 -0
  54. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/metrics.py +0 -0
  55. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/resource.py +0 -0
  56. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/otel/settings.py +0 -0
  57. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/plugins.py +0 -0
  58. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/profiles.py +0 -0
  59. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/propagation.py +0 -0
  60. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/py.typed +0 -0
  61. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/__init__.py +0 -0
  62. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/normalize.py +0 -0
  63. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/policy.py +0 -0
  64. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/proxies.py +0 -0
  65. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/security/redaction.py +0 -0
  66. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  67. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  68. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  69. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/factory.py +0 -0
  70. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  71. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  72. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  73. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  74. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  75. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/registry.py +0 -0
  76. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/sinks/stats.py +0 -0
  77. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/src/fastapiobserver/utils.py +0 -0
  78. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_audit_chain.py +0 -0
  79. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_body_media_allowlist.py +0 -0
  80. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_config_settings.py +0 -0
  81. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_db_tracing.py +0 -0
  82. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_dx_parity.py +0 -0
  83. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_excluded_url_precedence.py +0 -0
  84. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_fastapi_install.py +0 -0
  85. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_fork_queue_listener.py +0 -0
  86. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_graphql_integrations.py +0 -0
  87. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_lifespan_teardown.py +0 -0
  88. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_log_queue_controls.py +0 -0
  89. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_logging_schema_contract.py +0 -0
  90. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_logtail_dlq.py +0 -0
  91. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_loguru_bridge.py +0 -0
  92. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_metrics_contracts.py +0 -0
  93. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_metrics_optional_dependency.py +0 -0
  94. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_middleware.py +0 -0
  95. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_negotiate_content_type.py +0 -0
  96. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_integration.py +0 -0
  97. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_lifecycle.py +0 -0
  98. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_log_correlation.py +0 -0
  99. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_logs_from_env.py +0 -0
  100. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_logs_pipeline.py +0 -0
  101. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otel_metrics_from_env.py +0 -0
  102. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_otlp_export_integration.py +0 -0
  103. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_plugin_isolation.py +0 -0
  104. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_profiles.py +0 -0
  105. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_propagation.py +0 -0
  106. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_redaction_presets.py +0 -0
  107. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_route_template_cardinality.py +0 -0
  108. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_runtime_control_auth.py +0 -0
  109. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_security_audit_fixes.py +0 -0
  110. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_security_redaction.py +0 -0
  111. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_sink_circuit_breaker.py +0 -0
  112. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_sinks.py +0 -0
  113. {fastapi_observer-1.9.0 → fastapi_observer-1.11.0}/tests/test_trust_boundary.py +0 -0
  114. {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.9.0
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.9.0
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
 
@@ -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": logging.getLevelName(logging.getLogger().getEffectiveLevel()),
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
- logging.getLogger().setLevel(level)
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 get_managed_output_handlers, setup_logging, shutdown_logging
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 and user context from context vars to every log record.
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
- return _json_dumps(sanitized_payload)
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 ("uvicorn", "uvicorn.error", "uvicorn.access"):
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
- uvicorn_logger.setLevel(settings.log_level.upper())
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"]
@@ -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 scrape time."""
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 get_log_queue_stats, get_sink_circuit_breaker_stats
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: