fastapi-observer 1.8.0__tar.gz → 1.9.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 (109) hide show
  1. {fastapi_observer-1.8.0/src/fastapi_observer.egg-info → fastapi_observer-1.9.0}/PKG-INFO +2 -2
  2. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/README.md +1 -1
  3. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0/src/fastapi_observer.egg-info}/PKG-INFO +2 -2
  4. fastapi_observer-1.9.0/src/fastapiobserver/_version.py +1 -0
  5. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/events.py +21 -16
  6. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/metrics.py +5 -1
  7. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/request_logging.py +1 -1
  8. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_middleware.py +3 -2
  9. fastapi_observer-1.9.0/tests/test_route_template_cardinality.py +90 -0
  10. fastapi_observer-1.8.0/src/fastapiobserver/_version.py +0 -1
  11. fastapi_observer-1.8.0/tests/test_route_template_cardinality.py +0 -51
  12. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/LICENSE +0 -0
  13. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/pyproject.toml +0 -0
  14. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/setup.cfg +0 -0
  15. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
  16. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  17. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  18. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  19. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/__init__.py +0 -0
  20. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/__init__.py +0 -0
  21. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/formatter.py +0 -0
  22. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/providers.py +0 -0
  23. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/audit/verify.py +0 -0
  24. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/config.py +0 -0
  25. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/control_plane.py +0 -0
  26. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/db_tracing.py +0 -0
  27. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/fastapi.py +0 -0
  28. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  29. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  30. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/__init__.py +0 -0
  31. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  32. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/filters.py +0 -0
  33. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/formatter.py +0 -0
  34. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/queueing.py +0 -0
  35. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/setup.py +0 -0
  36. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/logging/state.py +0 -0
  37. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/loguru.py +0 -0
  38. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/metrics/__init__.py +0 -0
  39. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/metrics/prometheus.py +0 -0
  40. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/__init__.py +0 -0
  41. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  42. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/context.py +0 -0
  43. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/headers.py +0 -0
  44. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/middleware/ip.py +0 -0
  45. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/__init__.py +0 -0
  46. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/exclusions.py +0 -0
  47. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/lifecycle.py +0 -0
  48. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/logs.py +0 -0
  49. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/metrics.py +0 -0
  50. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/resource.py +0 -0
  51. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/settings.py +0 -0
  52. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/otel/tracing.py +0 -0
  53. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/plugins.py +0 -0
  54. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/profiles.py +0 -0
  55. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/propagation.py +0 -0
  56. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/py.typed +0 -0
  57. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/request_context.py +0 -0
  58. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/__init__.py +0 -0
  59. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/normalize.py +0 -0
  60. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/policy.py +0 -0
  61. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/proxies.py +0 -0
  62. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/security/redaction.py +0 -0
  63. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  64. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  65. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  66. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/factory.py +0 -0
  67. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  68. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  69. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  70. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  71. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  72. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/registry.py +0 -0
  73. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/sinks/stats.py +0 -0
  74. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/src/fastapiobserver/utils.py +0 -0
  75. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_audit_chain.py +0 -0
  76. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_body_media_allowlist.py +0 -0
  77. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_config_settings.py +0 -0
  78. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_db_tracing.py +0 -0
  79. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_dx_parity.py +0 -0
  80. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_excluded_url_precedence.py +0 -0
  81. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_fastapi_install.py +0 -0
  82. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_fork_queue_listener.py +0 -0
  83. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_graphql_integrations.py +0 -0
  84. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_lifespan_teardown.py +0 -0
  85. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_log_queue_controls.py +0 -0
  86. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_logging_schema_contract.py +0 -0
  87. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_logtail_dlq.py +0 -0
  88. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_loguru_bridge.py +0 -0
  89. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_metrics_contracts.py +0 -0
  90. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_metrics_optional_dependency.py +0 -0
  91. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_negotiate_content_type.py +0 -0
  92. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otel_integration.py +0 -0
  93. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otel_lifecycle.py +0 -0
  94. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otel_log_correlation.py +0 -0
  95. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otel_logs_from_env.py +0 -0
  96. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otel_logs_pipeline.py +0 -0
  97. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otel_metrics_from_env.py +0 -0
  98. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_otlp_export_integration.py +0 -0
  99. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_plugin_isolation.py +0 -0
  100. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_profiles.py +0 -0
  101. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_propagation.py +0 -0
  102. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_redaction_presets.py +0 -0
  103. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_runtime_control_auth.py +0 -0
  104. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_security_audit_fixes.py +0 -0
  105. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_security_redaction.py +0 -0
  106. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_sink_circuit_breaker.py +0 -0
  107. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_sinks.py +0 -0
  108. {fastapi_observer-1.8.0 → fastapi_observer-1.9.0}/tests/test_trust_boundary.py +0 -0
  109. {fastapi_observer-1.8.0 → fastapi_observer-1.9.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.8.0
3
+ Version: 1.9.0
4
4
  Summary: Zero-glue FastAPI observability with security presets and runtime controls
5
5
  Author-email: Vitaee <opensource@vitaee.dev>
6
6
  License-Expression: MIT
@@ -149,7 +149,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
149
149
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
150
150
  - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
151
151
  - **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
152
- - **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so URLs with IDs don't explode cardinality.
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
155
 
@@ -73,7 +73,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
73
73
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
74
74
  - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
75
75
  - **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
76
- - **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so URLs with IDs don't explode cardinality.
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
79
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 1.8.0
3
+ Version: 1.9.0
4
4
  Summary: Zero-glue FastAPI observability with security presets and runtime controls
5
5
  Author-email: Vitaee <opensource@vitaee.dev>
6
6
  License-Expression: MIT
@@ -149,7 +149,7 @@ Explicit variables and Python arguments always win over profile defaults. Every
149
149
  - **One JSON line per request** with method, path, status, duration and client IP, and your service's name, environment and version on every line, including your own `logging` calls.
150
150
  - **Request IDs** taken from `X-Request-ID` when the caller is a trusted proxy, generated otherwise, returned in the response and passed on to the services you call, so one ID finds a request's logs in every service.
151
151
  - **Redaction on by default.** Passwords, tokens, API keys, `Authorization` and cookies are masked before anything is written. Presets cover stricter needs (`strict`, `pci`, `gdpr`).
152
- - **Prometheus metrics** labelled by route template (`/orders/{order_id}`), so URLs with IDs don't explode cardinality.
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
155
 
@@ -0,0 +1 @@
1
+ __version__ = "1.9.0"
@@ -68,23 +68,28 @@ def _classify_error(status_code: int, error: Exception | None) -> str:
68
68
  return "ok"
69
69
 
70
70
 
71
- def _extract_route_template(scope: Scope, raw_path: str) -> str:
72
- """Return the matched route template for bounded metric cardinality.
71
+ UNMATCHED_ROUTE = "<unmatched>"
73
72
 
74
- After Starlette's router runs, ``scope["route"]`` contains the matched
75
- ``Route`` object whose ``.path`` is the template string
76
- (e.g. ``/users/{user_id}``).
77
73
 
78
- Falls back to *raw_path* when:
79
- * No route matched (404 / unmatched sub-apps)
80
- * ``scope["route"]`` is not a Starlette ``Route`` with a ``path`` attr
74
+ def _extract_route_template(scope: Scope, raw_path: str, status_code: int) -> str:
75
+ """Return the metrics ``path`` label: the matched route template if known.
76
+
77
+ After routing, ``scope["route"]`` holds the matched route, whose ``.path``
78
+ is the template (e.g. ``/users/{user_id}``). Without one, a 404 means no
79
+ route matched, mostly scanners, so those share ``UNMATCHED_ROUTE`` instead
80
+ of adding a time series per URL. Other requests without a template keep
81
+ *raw_path*: FastAPI doesn't record plain Starlette routes, such as ones
82
+ added with ``app.add_route()``, in the scope.
81
83
  """
82
- route = scope.get("route")
83
- if route is not None:
84
- template = getattr(route, "path", None)
85
- if isinstance(template, str) and template:
86
- return template
87
- return raw_path
84
+ template = getattr(scope.get("route"), "path", None)
85
+ if isinstance(template, str) and template:
86
+ return template
87
+ return UNMATCHED_ROUTE if status_code == 404 else raw_path
88
88
 
89
- __all__ = ["_RequestEventBuilder", "_classify_error", "_extract_route_template",
90
- "_sanitize_exception_message"]
89
+ __all__ = [
90
+ "UNMATCHED_ROUTE",
91
+ "_RequestEventBuilder",
92
+ "_classify_error",
93
+ "_extract_route_template",
94
+ "_sanitize_exception_message",
95
+ ]
@@ -38,12 +38,16 @@ class _MetricsRecorder:
38
38
  if scope and "app" in scope:
39
39
  app = scope["app"]
40
40
  state = getattr(app.state, "_observability_state", None)
41
- if state is not None:
41
+ # Filled when the app starts; without a lifespan, keep the settings' list.
42
+ if state is not None and state.excluded_urls:
42
43
  excluded_urls = state.excluded_urls
43
44
 
45
+ # Also check the request's own path: unmatched requests share one label.
46
+ raw_path = scope.get("path") if scope else None
44
47
  if (
45
48
  path in excluded_urls
46
49
  or collapsed_path in excluded_urls
50
+ or raw_path in excluded_urls
47
51
  ):
48
52
  return
49
53
  try:
@@ -168,7 +168,7 @@ class RequestLoggingMiddleware:
168
168
  )
169
169
 
170
170
  # Prefer route template for bounded cardinality
171
- route_template = _extract_route_template(scope, path)
171
+ route_template = _extract_route_template(scope, path, status_code)
172
172
 
173
173
  self.metrics_recorder.observe(
174
174
  method=method,
@@ -183,10 +183,11 @@ def test_resolve_request_id_contract() -> None:
183
183
  def test_extract_route_template_contract() -> None:
184
184
  from fastapiobserver.middleware import _extract_route_template
185
185
 
186
- assert _extract_route_template({}, "/raw") == "/raw"
186
+ assert _extract_route_template({}, "/raw", 200) == "/raw"
187
+ assert _extract_route_template({}, "/raw", 404) == "<unmatched>"
187
188
 
188
189
  scope = {
189
190
  "endpoint": lambda: None,
190
191
  "route": type("Route", (), {"path": "/users/{user_id}"})(),
191
192
  }
192
- assert _extract_route_template(scope, "/raw") == "/users/{user_id}"
193
+ assert _extract_route_template(scope, "/raw", 404) == "/users/{user_id}"
@@ -0,0 +1,90 @@
1
+ """Tests for route template extraction to prevent metric cardinality explosion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.testclient import TestClient
9
+
10
+ from fastapiobserver import ObservabilitySettings, install_observability
11
+ from fastapiobserver.middleware import _extract_route_template # type: ignore[attr-defined]
12
+ from fastapiobserver.middleware.events import UNMATCHED_ROUTE
13
+
14
+
15
+ class _FakeRoute:
16
+ """Minimal route stub matching Starlette's Route interface."""
17
+
18
+ def __init__(self, path: str) -> None:
19
+ self.path = path
20
+
21
+
22
+ def test_route_template_preferred_over_raw_path() -> None:
23
+ """When scope has a matched route, use its template."""
24
+ scope = {"route": _FakeRoute("/users/{user_id}")}
25
+ result = _extract_route_template(scope, "/users/alice", 200)
26
+ assert result == "/users/{user_id}"
27
+
28
+
29
+ def test_unmatched_404_gets_the_shared_label() -> None:
30
+ """No route matched: a 404 must not put its raw path in the label."""
31
+ scope: dict = {} # no "route" key
32
+ assert _extract_route_template(scope, "/nonexistent/path", 404) == UNMATCHED_ROUTE
33
+
34
+
35
+ def test_plain_route_without_template_keeps_raw_path() -> None:
36
+ """FastAPI records no route for plain Starlette routes, such as `/metrics`."""
37
+ scope: dict = {}
38
+ assert _extract_route_template(scope, "/metrics", 200) == "/metrics"
39
+
40
+
41
+ def test_fallback_when_route_has_no_path_attr() -> None:
42
+ """Mounted sub-apps may have routes without .path attribute."""
43
+
44
+ class _MountedRoute:
45
+ pass
46
+
47
+ scope = {"route": _MountedRoute()}
48
+ assert _extract_route_template(scope, "/mounted/raw", 200) == "/mounted/raw"
49
+
50
+
51
+ def test_route_is_none() -> None:
52
+ """scope["route"] explicitly set to None."""
53
+ scope = {"route": None}
54
+ assert _extract_route_template(scope, "/somewhere", 404) == UNMATCHED_ROUTE
55
+
56
+
57
+ def test_nested_route_template() -> None:
58
+ """Deeply nested route with multiple parameters."""
59
+ scope = {"route": _FakeRoute("/orgs/{org_id}/teams/{team_id}/members")}
60
+ result = _extract_route_template(scope, "/orgs/acme-corp/teams/eng/members", 200)
61
+ assert result == "/orgs/{org_id}/teams/{team_id}/members"
62
+
63
+
64
+ def test_scanner_404s_share_one_metrics_label() -> None:
65
+ app = FastAPI()
66
+
67
+ @app.get("/orders/{order_id}")
68
+ def order(order_id: int) -> dict[str, int]:
69
+ return {"order_id": order_id}
70
+
71
+ install_observability(app, ObservabilitySettings(metrics_enabled=True))
72
+ client = TestClient(app) # no lifespan: exclusions must still apply
73
+
74
+ def unmatched_requests() -> float:
75
+ return sum(
76
+ float(line.rsplit(" ", 1)[1])
77
+ for line in client.get("/metrics").text.splitlines()
78
+ if line.startswith("http_requests_total{") and f'path="{UNMATCHED_ROUTE}"' in line
79
+ )
80
+
81
+ before = unmatched_requests()
82
+ probes = [f"/probe-{uuid.uuid4().hex[:8]}.php" for _ in range(20)]
83
+ for path in probes:
84
+ assert client.get(path).status_code == 404
85
+ after_probes = unmatched_requests()
86
+ client.get("/healthz") # excluded by default, though no route serves it
87
+
88
+ assert after_probes - before == 20
89
+ assert unmatched_requests() == after_probes
90
+ assert not any(path in client.get("/metrics").text for path in probes)
@@ -1 +0,0 @@
1
- __version__ = "1.8.0"
@@ -1,51 +0,0 @@
1
- """Tests for route template extraction to prevent metric cardinality explosion."""
2
-
3
- from __future__ import annotations
4
-
5
- from fastapiobserver.middleware import _extract_route_template # type: ignore[attr-defined]
6
-
7
-
8
- class _FakeRoute:
9
- """Minimal route stub matching Starlette's Route interface."""
10
-
11
- def __init__(self, path: str) -> None:
12
- self.path = path
13
-
14
-
15
- def test_route_template_preferred_over_raw_path() -> None:
16
- """When scope has a matched route, use its template."""
17
- scope = {"route": _FakeRoute("/users/{user_id}")}
18
- result = _extract_route_template(scope, "/users/alice")
19
- assert result == "/users/{user_id}"
20
-
21
-
22
- def test_fallback_to_raw_path_when_no_route() -> None:
23
- """404 / unmatched — no route in scope → raw path used."""
24
- scope: dict = {} # no "route" key
25
- result = _extract_route_template(scope, "/nonexistent/path")
26
- assert result == "/nonexistent/path"
27
-
28
-
29
- def test_fallback_when_route_has_no_path_attr() -> None:
30
- """Mounted sub-apps may have routes without .path attribute."""
31
-
32
- class _MountedRoute:
33
- pass
34
-
35
- scope = {"route": _MountedRoute()}
36
- result = _extract_route_template(scope, "/mounted/raw")
37
- assert result == "/mounted/raw"
38
-
39
-
40
- def test_route_is_none() -> None:
41
- """scope["route"] explicitly set to None."""
42
- scope = {"route": None}
43
- result = _extract_route_template(scope, "/somewhere")
44
- assert result == "/somewhere"
45
-
46
-
47
- def test_nested_route_template() -> None:
48
- """Deeply nested route with multiple parameters."""
49
- scope = {"route": _FakeRoute("/orgs/{org_id}/teams/{team_id}/members")}
50
- result = _extract_route_template(scope, "/orgs/acme-corp/teams/eng/members")
51
- assert result == "/orgs/{org_id}/teams/{team_id}/members"