fastapi-observer 1.11.0__tar.gz → 1.12.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 (116) hide show
  1. {fastapi_observer-1.11.0/src/fastapi_observer.egg-info → fastapi_observer-1.12.0}/PKG-INFO +1 -1
  2. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0/src/fastapi_observer.egg-info}/PKG-INFO +1 -1
  3. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapi_observer.egg-info/SOURCES.txt +2 -0
  4. fastapi_observer-1.12.0/src/fastapiobserver/_version.py +1 -0
  5. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/fastapi.py +3 -0
  6. fastapi_observer-1.12.0/src/fastapiobserver/otel/exclusions.py +54 -0
  7. fastapi_observer-1.12.0/src/fastapiobserver/otel/native.py +44 -0
  8. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/tracing.py +10 -13
  9. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/request_context.py +11 -2
  10. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_excluded_url_precedence.py +3 -2
  11. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_middleware.py +14 -3
  12. fastapi_observer-1.12.0/tests/test_native_telemetry.py +130 -0
  13. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_route_template_cardinality.py +36 -1
  14. fastapi_observer-1.11.0/src/fastapiobserver/_version.py +0 -1
  15. fastapi_observer-1.11.0/src/fastapiobserver/otel/exclusions.py +0 -29
  16. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/LICENSE +0 -0
  17. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/README.md +0 -0
  18. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/pyproject.toml +0 -0
  19. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/setup.cfg +0 -0
  20. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  21. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  22. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  23. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/__init__.py +0 -0
  24. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/audit/__init__.py +0 -0
  25. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/audit/formatter.py +0 -0
  26. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/audit/providers.py +0 -0
  27. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/audit/verify.py +0 -0
  28. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/config.py +0 -0
  29. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/control_plane.py +0 -0
  30. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/db_tracing.py +0 -0
  31. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  32. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  33. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/__init__.py +0 -0
  34. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  35. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/filters.py +0 -0
  36. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/formatter.py +0 -0
  37. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/held.py +0 -0
  38. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/queueing.py +0 -0
  39. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/setup.py +0 -0
  40. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/state.py +0 -0
  41. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/logging/volume.py +0 -0
  42. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/loguru.py +0 -0
  43. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/loop_watchdog.py +0 -0
  44. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  45. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
  46. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  47. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  48. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/context.py +0 -0
  49. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/events.py +0 -0
  50. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/headers.py +0 -0
  51. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/ip.py +0 -0
  52. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  53. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/middleware/request_logging.py +0 -0
  54. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/__init__.py +0 -0
  55. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  56. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/logs.py +0 -0
  57. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/metrics.py +0 -0
  58. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/resource.py +0 -0
  59. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/otel/settings.py +0 -0
  60. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/plugins.py +0 -0
  61. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/profiles.py +0 -0
  62. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/propagation.py +0 -0
  63. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/py.typed +0 -0
  64. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/security/__init__.py +0 -0
  65. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/security/normalize.py +0 -0
  66. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/security/policy.py +0 -0
  67. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/security/proxies.py +0 -0
  68. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/security/redaction.py +0 -0
  69. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  70. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  71. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  72. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/factory.py +0 -0
  73. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  74. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  75. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  76. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  77. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  78. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/registry.py +0 -0
  79. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/sinks/stats.py +0 -0
  80. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/src/fastapiobserver/utils.py +0 -0
  81. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_audit_chain.py +0 -0
  82. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_body_media_allowlist.py +0 -0
  83. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_config_settings.py +0 -0
  84. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_db_tracing.py +0 -0
  85. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_dx_parity.py +0 -0
  86. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_fastapi_install.py +0 -0
  87. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_fork_queue_listener.py +0 -0
  88. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_graphql_integrations.py +0 -0
  89. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_lifespan_teardown.py +0 -0
  90. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_log_queue_controls.py +0 -0
  91. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_logging_schema_contract.py +0 -0
  92. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_logtail_dlq.py +0 -0
  93. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_loguru_bridge.py +0 -0
  94. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_loop_watchdog.py +0 -0
  95. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_metrics_contracts.py +0 -0
  96. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_metrics_optional_dependency.py +0 -0
  97. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_negotiate_content_type.py +0 -0
  98. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otel_integration.py +0 -0
  99. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otel_lifecycle.py +0 -0
  100. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otel_log_correlation.py +0 -0
  101. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otel_logs_from_env.py +0 -0
  102. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otel_logs_pipeline.py +0 -0
  103. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otel_metrics_from_env.py +0 -0
  104. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_otlp_export_integration.py +0 -0
  105. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_plugin_isolation.py +0 -0
  106. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_profiles.py +0 -0
  107. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_propagation.py +0 -0
  108. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_redaction_presets.py +0 -0
  109. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_request_insights.py +0 -0
  110. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_runtime_control_auth.py +0 -0
  111. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_security_audit_fixes.py +0 -0
  112. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_security_redaction.py +0 -0
  113. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_sink_circuit_breaker.py +0 -0
  114. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_sinks.py +0 -0
  115. {fastapi_observer-1.11.0 → fastapi_observer-1.12.0}/tests/test_trust_boundary.py +0 -0
  116. {fastapi_observer-1.11.0 → fastapi_observer-1.12.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.11.0
3
+ Version: 1.12.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 1.11.0
3
+ Version: 1.12.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
@@ -50,6 +50,7 @@ src/fastapiobserver/otel/exclusions.py
50
50
  src/fastapiobserver/otel/lifecycle.py
51
51
  src/fastapiobserver/otel/logs.py
52
52
  src/fastapiobserver/otel/metrics.py
53
+ src/fastapiobserver/otel/native.py
53
54
  src/fastapiobserver/otel/resource.py
54
55
  src/fastapiobserver/otel/settings.py
55
56
  src/fastapiobserver/otel/tracing.py
@@ -87,6 +88,7 @@ tests/test_loop_watchdog.py
87
88
  tests/test_metrics_contracts.py
88
89
  tests/test_metrics_optional_dependency.py
89
90
  tests/test_middleware.py
91
+ tests/test_native_telemetry.py
90
92
  tests/test_negotiate_content_type.py
91
93
  tests/test_otel_integration.py
92
94
  tests/test_otel_lifecycle.py
@@ -0,0 +1 @@
1
+ __version__ = "1.12.0"
@@ -34,6 +34,7 @@ from .otel import (
34
34
  install_otel_logs,
35
35
  install_otel_metrics,
36
36
  )
37
+ from .otel.native import keep_native_logs_out
37
38
  from .propagation import install_request_id_propagation
38
39
  from .security import SecurityPolicy, TrustedProxyPolicy
39
40
 
@@ -217,6 +218,8 @@ def _setup_structured_logging(
217
218
  otel_settings=otel_settings,
218
219
  security_policy=security_policy,
219
220
  )
221
+ if otel_log_handler is not None:
222
+ keep_native_logs_out(app)
220
223
  if logs_mode == "otlp" and otel_log_handler is None:
221
224
  raise RuntimeError(
222
225
  "OTLP log mode requires a configured OTLP log handler. "
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, MutableMapping
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from .native import native_telemetry_config
7
+
8
+ if TYPE_CHECKING:
9
+ from fastapi import FastAPI
10
+
11
+
12
+ class NativeExclusions:
13
+ """FastAPI's native ``exclude`` hook, with the URL patterns OpenTelemetry's
14
+ instrumentation uses: *csv*, or its environment variables when *csv* is None.
15
+ """
16
+
17
+ def __init__(self, csv: str | None) -> None:
18
+ from opentelemetry.util.http import get_excluded_urls, parse_excluded_urls
19
+
20
+ self.excluded_urls = (
21
+ parse_excluded_urls(csv) if csv is not None else get_excluded_urls("FASTAPI")
22
+ )
23
+
24
+ def __call__(self, scope: MutableMapping[str, Any]) -> bool:
25
+ return bool(self.excluded_urls.url_disabled(scope.get("path", "")))
26
+
27
+
28
+ def update_otel_middleware_exclusions(
29
+ app: FastAPI, new_exclusions: frozenset[str] | set[str]
30
+ ) -> None:
31
+ """Merge ``new_exclusions`` into the excluded URLs of whichever tracing is active.
32
+
33
+ Both FastAPI's native tracing and ``FastAPIInstrumentor.instrument_app()`` take
34
+ exclusions up front, but routes are usually added after
35
+ ``install_observability()``, so this updates them in place.
36
+ """
37
+ native_exclude = (native_telemetry_config(app) or {}).get("exclude")
38
+ if isinstance(native_exclude, NativeExclusions):
39
+ native_exclude.excluded_urls = _merged(native_exclude.excluded_urls, new_exclusions)
40
+
41
+ current = getattr(app, "middleware_stack", None)
42
+ while current is not None:
43
+ # Match by name so apps without OTel never import the ASGI instrumentation.
44
+ if type(current).__name__ == "OpenTelemetryMiddleware":
45
+ current.excluded_urls = _merged(current.excluded_urls, new_exclusions)
46
+ return
47
+ current = getattr(current, "app", None)
48
+
49
+
50
+ def _merged(excluded_urls: Any, new_exclusions: Iterable[str]) -> Any:
51
+ from opentelemetry.util.http import parse_excluded_urls
52
+
53
+ existing = getattr(excluded_urls, "_excluded_urls", None) or ()
54
+ return parse_excluded_urls(",".join(sorted({*existing, *new_exclusions})))
@@ -0,0 +1,44 @@
1
+ """FastAPI's native OpenTelemetry (FastAPI 0.142+), set up to fit fastapi-observer.
2
+
3
+ FastAPI takes these settings only as ``FastAPI(telemetry=...)`` and keeps them in
4
+ ``app._telemetry``, which it reads on every request. That attribute isn't public
5
+ API. Without it, as before 0.142, nothing here changes anything, and tracing
6
+ falls back to OpenTelemetry's FastAPI instrumentation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import MutableMapping
12
+ from typing import Any
13
+
14
+
15
+ def native_telemetry_config(app: Any) -> MutableMapping[str, Any] | None:
16
+ config = getattr(app, "_telemetry", None)
17
+ return config if isinstance(config, MutableMapping) else None
18
+
19
+
20
+ def trace_natively(app: Any, excluded_urls_csv: str | None) -> bool:
21
+ """Let FastAPI trace requests itself, skipping the excluded URLs.
22
+
23
+ False when it can't: FastAPI before 0.142, or tracing turned off in
24
+ ``FastAPI(telemetry=...)``.
25
+ """
26
+ config = native_telemetry_config(app)
27
+ if config is None or not config.get("tracing", True):
28
+ return False
29
+ from .exclusions import NativeExclusions
30
+
31
+ config["exclude"] = NativeExclusions(excluded_urls_csv)
32
+ return True
33
+
34
+
35
+ def keep_native_logs_out(app: Any) -> None:
36
+ """Stop FastAPI exporting its own logs around fastapi-observer's redaction.
37
+
38
+ FastAPI writes validation failures and unhandled exceptions, with messages
39
+ and stack traces, straight to the global logger provider, the one OTLP log
40
+ export sets up. fastapi-observer already logs both, redacted.
41
+ """
42
+ config = native_telemetry_config(app)
43
+ if config is not None:
44
+ config["logs"] = False
@@ -11,6 +11,7 @@ from fastapi import FastAPI
11
11
  from ..config import ObservabilitySettings
12
12
  from ..request_context import get_request_state
13
13
  from .lifecycle import close_on_app_shutdown
14
+ from .native import trace_natively
14
15
  from .resource import (
15
16
  build_excluded_urls_csv,
16
17
  build_otlp_exporter,
@@ -39,9 +40,6 @@ def install_otel(
39
40
  trace_sdk = import_otel_module("opentelemetry.sdk.trace")
40
41
  trace_export = import_otel_module("opentelemetry.sdk.trace.export")
41
42
  sampling = import_otel_module("opentelemetry.sdk.trace.sampling")
42
- fastapi_instrumentor_module = import_otel_module(
43
- "opentelemetry.instrumentation.fastapi"
44
- )
45
43
 
46
44
  set_trace_sampling_ratio(otel_settings.trace_sampling_ratio)
47
45
  tracer_provider = trace_api.get_tracer_provider()
@@ -100,17 +98,16 @@ def install_otel(
100
98
  tracer_provider.add_span_processor(_request_call_totals(trace_api, trace_sdk))
101
99
  _CALL_TOTALS_PROVIDERS.add(tracer_provider)
102
100
 
103
- # --- Wire excluded URLs for noise control ---
101
+ # FastAPI 0.142+ traces requests itself, with spans for dependencies, the
102
+ # endpoint and serialization; older versions need OpenTelemetry's instrumentation.
104
103
  excluded_urls_str = build_excluded_urls_csv(settings)
105
- instrument_kwargs: dict[str, Any] = {
106
- "tracer_provider": tracer_provider,
107
- }
108
- if excluded_urls_str is not None:
109
- instrument_kwargs["excluded_urls"] = excluded_urls_str
110
-
111
- fastapi_instrumentor_module.FastAPIInstrumentor.instrument_app(
112
- app, **instrument_kwargs
113
- )
104
+ if not trace_natively(app, excluded_urls_str):
105
+ instrument_kwargs: dict[str, Any] = {"tracer_provider": tracer_provider}
106
+ if excluded_urls_str is not None:
107
+ instrument_kwargs["excluded_urls"] = excluded_urls_str
108
+ import_otel_module(
109
+ "opentelemetry.instrumentation.fastapi"
110
+ ).FastAPIInstrumentor.instrument_app(app, **instrument_kwargs)
114
111
 
115
112
  if provider_owned:
116
113
  close_on_app_shutdown(app, tracer_provider, shutdown=True)
@@ -69,8 +69,17 @@ class RequestState:
69
69
 
70
70
 
71
71
  def route_template(scope: Mapping[str, Any]) -> str | None:
72
- """The matched route's template, such as ``/users/{user_id}``, once routing has run."""
73
- template = getattr(scope.get("route"), "path", None)
72
+ """The matched route's template, such as ``/users/{user_id}``, once routing has run.
73
+
74
+ Since FastAPI 0.137, ``scope["route"].path`` leaves out prefixes given to
75
+ ``include_router()`` and those of outer routers. FastAPI keeps the full
76
+ template in its effective route context, set for routes in included routers.
77
+ """
78
+ fastapi_scope = scope.get("fastapi")
79
+ context = (
80
+ fastapi_scope.get("effective_route_context") if isinstance(fastapi_scope, dict) else None
81
+ )
82
+ template = getattr(context, "path", None) or getattr(scope.get("route"), "path", None)
74
83
  return template if isinstance(template, str) and template else None
75
84
 
76
85
 
@@ -72,7 +72,8 @@ def test_install_otel_passes_empty_excluded_urls(
72
72
  monkeypatch: pytest.MonkeyPatch,
73
73
  ) -> None:
74
74
  """Integration: install_otel actually passes excluded_urls='' to instrument_app
75
- when otel_excluded_urls is explicitly set to empty."""
75
+ when otel_excluded_urls is explicitly set to empty. FastAPI's own tracing is
76
+ off, so the instrumentation is used, as on FastAPI before 0.142."""
76
77
  pytest.importorskip("opentelemetry.instrumentation.fastapi")
77
78
 
78
79
  from fastapi import FastAPI
@@ -87,7 +88,7 @@ def test_install_otel_passes_empty_excluded_urls(
87
88
 
88
89
  monkeypatch.setattr(FastAPIInstrumentor, "instrument_app", spy_instrument_app)
89
90
 
90
- app = FastAPI()
91
+ app = FastAPI(telemetry={"tracing": False})
91
92
  settings = ObservabilitySettings(otel_excluded_urls="") # type: ignore[arg-type]
92
93
  otel_settings = OTelSettings(enabled=True, service_name="test-svc")
93
94
 
@@ -122,8 +122,18 @@ def test_middleware_classifies_unhandled_exception(caplog) -> None:
122
122
  assert request_logs[-1].event["http.response.status_code"] == 500
123
123
 
124
124
 
125
- def test_unhandled_exception_recorded_once_on_server_span(
125
+ @pytest.mark.parametrize(
126
+ ("telemetry", "events", "error_type"),
127
+ [
128
+ ({}, [], "RuntimeError"), # FastAPI's own tracing: no exception text in the trace
129
+ ({"tracing": False}, ["exception"], None), # the instrumentation records it once
130
+ ],
131
+ )
132
+ def test_unhandled_exception_marks_the_server_span_once(
126
133
  monkeypatch: pytest.MonkeyPatch,
134
+ telemetry: dict[str, bool],
135
+ events: list[str],
136
+ error_type: str | None,
127
137
  ) -> None:
128
138
  in_memory = pytest.importorskip("opentelemetry.sdk.trace.export.in_memory_span_exporter")
129
139
  import fastapiobserver.otel.tracing as otel_tracing_module
@@ -132,7 +142,7 @@ def test_unhandled_exception_recorded_once_on_server_span(
132
142
  exporter = in_memory.InMemorySpanExporter()
133
143
  monkeypatch.setattr(otel_tracing_module, "build_otlp_exporter", lambda *_: exporter)
134
144
 
135
- app = FastAPI()
145
+ app = FastAPI(telemetry=telemetry) # type: ignore[arg-type]
136
146
 
137
147
  @app.get("/boom-span")
138
148
  def boom() -> None:
@@ -153,8 +163,9 @@ def test_unhandled_exception_recorded_once_on_server_span(
153
163
  [server_span] = [
154
164
  span for span in exporter.get_finished_spans() if span.kind.name == "SERVER"
155
165
  ]
156
- assert [event.name for event in server_span.events] == ["exception"]
166
+ assert [event.name for event in server_span.events] == events
157
167
  assert server_span.status.status_code.name == "ERROR"
168
+ assert server_span.attributes.get("error.type") == error_type
158
169
 
159
170
 
160
171
  def test_extract_scope_client_ip_contract() -> None:
@@ -0,0 +1,130 @@
1
+ """FastAPI 0.142+ traces requests itself; fastapi-observer uses that and keeps
2
+ FastAPI's own log export out of the way of redaction."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ from fastapi import FastAPI
11
+ from fastapi.testclient import TestClient
12
+ import pytest
13
+
14
+ from fastapiobserver import (
15
+ ObservabilitySettings,
16
+ OTelLogsSettings,
17
+ OTelSettings,
18
+ install_observability,
19
+ )
20
+ from fastapiobserver.logging import setup_logging, shutdown_logging
21
+ import fastapiobserver.otel.logs as otel_logs_module
22
+ import fastapiobserver.otel.tracing as otel_tracing_module
23
+
24
+ pytest.importorskip("fastapi.telemetry")
25
+ trace_api = pytest.importorskip("opentelemetry.trace")
26
+ in_memory_spans = pytest.importorskip("opentelemetry.sdk.trace.export.in_memory_span_exporter")
27
+
28
+
29
+ class _Collector(logging.Handler):
30
+ def __init__(self) -> None:
31
+ super().__init__()
32
+ self.payloads: list[dict[str, Any]] = []
33
+
34
+ def emit(self, record: logging.LogRecord) -> None:
35
+ self.payloads.append(json.loads(self.format(record)))
36
+
37
+
38
+ def _server_spans(exporter: Any) -> list[Any]:
39
+ trace_api.get_tracer_provider().force_flush()
40
+ return [s for s in exporter.get_finished_spans() if s.kind == trace_api.SpanKind.SERVER]
41
+
42
+
43
+ def test_requests_are_traced_by_fastapi_itself(monkeypatch: pytest.MonkeyPatch) -> None:
44
+ exporter = in_memory_spans.InMemorySpanExporter()
45
+ monkeypatch.setattr(otel_tracing_module, "build_otlp_exporter", lambda *_: exporter)
46
+ settings = ObservabilitySettings()
47
+ app = FastAPI()
48
+
49
+ @app.get("/orders/{order_id}")
50
+ def order(order_id: str) -> dict[str, str]:
51
+ return {}
52
+
53
+ @app.get("/healthz")
54
+ def healthz() -> dict[str, str]:
55
+ return {}
56
+
57
+ @app.get("/internal", include_in_schema=False)
58
+ def internal() -> dict[str, str]:
59
+ return {}
60
+
61
+ install_observability(app, settings, otel_settings=OTelSettings(enabled=True))
62
+ collector = _Collector()
63
+ setup_logging(settings, logs_mode="otlp", extra_handlers=[collector])
64
+ with TestClient(app) as client: # the lifespan adds hidden routes to the exclusions
65
+ for path in ("/orders/1", "/healthz", "/internal"):
66
+ client.get(path)
67
+ shutdown_logging()
68
+
69
+ [span] = _server_spans(exporter) # excluded and hidden routes aren't traced
70
+ assert span.instrumentation_scope.name == "fastapi"
71
+ assert span.name == "GET /orders/{order_id}"
72
+ [completed] = [
73
+ p
74
+ for p in collector.payloads
75
+ if p["message"] == "request.completed" and p["event"]["path"] == "/orders/1"
76
+ ]
77
+ assert completed["trace_id"] == f"{span.context.trace_id:032x}"
78
+
79
+
80
+ def test_instrumentation_traces_when_fastapi_tracing_is_off(
81
+ monkeypatch: pytest.MonkeyPatch,
82
+ ) -> None:
83
+ exporter = in_memory_spans.InMemorySpanExporter()
84
+ monkeypatch.setattr(otel_tracing_module, "build_otlp_exporter", lambda *_: exporter)
85
+ app = FastAPI(telemetry={"tracing": False}) # as on FastAPI before 0.142
86
+
87
+ @app.get("/orders/{order_id}")
88
+ def order(order_id: str) -> dict[str, str]:
89
+ return {}
90
+
91
+ install_observability(app, ObservabilitySettings(), otel_settings=OTelSettings(enabled=True))
92
+ TestClient(app).get("/orders/1")
93
+ shutdown_logging()
94
+
95
+ [span] = _server_spans(exporter)
96
+ assert span.instrumentation_scope.name == "opentelemetry.instrumentation.fastapi"
97
+
98
+
99
+ @pytest.mark.parametrize("tracing", [False, True])
100
+ def test_fastapi_does_not_export_its_own_logs_past_redaction(
101
+ monkeypatch: pytest.MonkeyPatch, tracing: bool
102
+ ) -> None:
103
+ in_memory_logs = pytest.importorskip("opentelemetry.sdk._logs.export")
104
+ logs_api = pytest.importorskip("opentelemetry._logs")
105
+ exported = in_memory_logs.InMemoryLogRecordExporter()
106
+ monkeypatch.setattr(otel_logs_module, "build_otlp_exporter", lambda *_: exported)
107
+ monkeypatch.setattr(
108
+ otel_tracing_module,
109
+ "build_otlp_exporter",
110
+ lambda *_: in_memory_spans.InMemorySpanExporter(),
111
+ )
112
+ app = FastAPI()
113
+
114
+ @app.get("/boom")
115
+ def boom() -> dict[str, str]:
116
+ raise RuntimeError("upstream rejected token=sk_live_secret")
117
+
118
+ install_observability(
119
+ app,
120
+ ObservabilitySettings(),
121
+ otel_settings=OTelSettings(enabled=tracing),
122
+ otel_logs_settings=OTelLogsSettings(enabled=True, logs_mode="otlp"),
123
+ )
124
+ TestClient(app, raise_server_exceptions=False).get("/boom")
125
+ shutdown_logging()
126
+ logs_api.get_logger_provider().force_flush()
127
+
128
+ scopes = {record.instrumentation_scope.name for record in exported.get_finished_logs()}
129
+ assert "fastapiobserver.middleware" in scopes # ours, redacted, still exported
130
+ assert "fastapi" not in scopes
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import uuid
6
6
 
7
- from fastapi import FastAPI
7
+ from fastapi import APIRouter, FastAPI
8
8
  from fastapi.testclient import TestClient
9
9
 
10
10
  from fastapiobserver import ObservabilitySettings, install_observability
@@ -88,3 +88,38 @@ def test_scanner_404s_share_one_metrics_label() -> None:
88
88
  assert after_probes - before == 20
89
89
  assert unmatched_requests() == after_probes
90
90
  assert not any(path in client.get("/metrics").text for path in probes)
91
+
92
+
93
+ def test_routers_included_with_a_prefix_keep_it_in_the_label() -> None:
94
+ # Since FastAPI 0.137, scope["route"].path leaves out prefixes given to
95
+ # include_router() and those of outer routers (fastapi/fastapi#16051).
96
+ app = FastAPI()
97
+ v1, v2, inner = APIRouter(), APIRouter(), APIRouter()
98
+
99
+ @v1.get("/items/{item_id}")
100
+ def v1_item(item_id: str) -> dict[str, str]:
101
+ return {}
102
+
103
+ @v2.get("/items/{item_id}")
104
+ def v2_item(item_id: str) -> dict[str, str]:
105
+ return {}
106
+
107
+ @inner.get("/ping")
108
+ def ping() -> dict[str, str]:
109
+ return {}
110
+
111
+ v2.include_router(inner, prefix="/admin")
112
+ app.include_router(v1, prefix="/v1")
113
+ app.include_router(v2, prefix="/v2")
114
+ install_observability(app, ObservabilitySettings(metrics_enabled=True))
115
+ client = TestClient(app)
116
+ for path in ("/v1/items/1", "/v2/items/1", "/v2/admin/ping"):
117
+ assert client.get(path).status_code == 200
118
+
119
+ labels = {
120
+ line.split('path="', 1)[1].split('"', 1)[0]
121
+ for line in client.get("/metrics").text.splitlines()
122
+ if line.startswith("http_requests_total{")
123
+ }
124
+ assert {"/v1/items/{item_id}", "/v2/items/{item_id}", "/v2/admin/ping"} <= labels
125
+ assert "/items/{item_id}" not in labels
@@ -1 +0,0 @@
1
- __version__ = "1.11.0"
@@ -1,29 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING
4
-
5
- if TYPE_CHECKING:
6
- from fastapi import FastAPI
7
-
8
-
9
- def update_otel_middleware_exclusions(
10
- app: FastAPI, new_exclusions: frozenset[str] | set[str]
11
- ) -> None:
12
- """Merge ``new_exclusions`` into the built OTel middleware's excluded URLs.
13
-
14
- ``FastAPIInstrumentor.instrument_app(excluded_urls=...)`` only takes
15
- exclusions up front, but routes are usually added after
16
- ``install_observability()`` and OTel offers no supported way to extend
17
- them later, so this updates the middleware in place.
18
- """
19
- current = getattr(app, "middleware_stack", None)
20
- while current is not None:
21
- # Match by name so apps without OTel never import the ASGI instrumentation.
22
- if type(current).__name__ == "OpenTelemetryMiddleware":
23
- from opentelemetry.util.http import parse_excluded_urls
24
-
25
- existing = getattr(current.excluded_urls, "_excluded_urls", None) or ()
26
- merged = sorted({*existing, *new_exclusions})
27
- current.excluded_urls = parse_excluded_urls(",".join(merged))
28
- return
29
- current = getattr(current, "app", None)