stac-fastapi.api 6.4.1__tar.gz → 6.6.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: stac-fastapi-api
3
- Version: 6.4.1
3
+ Version: 6.6.0
4
4
  Summary: An implementation of STAC API based on the FastAPI framework.
5
5
  Project-URL: Homepage, https://github.com/stac-utils/stac-fastapi
6
6
  Author-email: ArturoAI <engineering@arturo.ai>
@@ -17,4 +17,6 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Python: >=3.11
19
19
  Requires-Dist: brotli-asgi
20
- Requires-Dist: stac-fastapi-types~=6.4
20
+ Requires-Dist: stac-fastapi-types~=6.6
21
+ Provides-Extra: metrics
22
+ Requires-Dist: prometheus-fastapi-instrumentator>=8.1.0; extra == 'metrics'
@@ -32,7 +32,12 @@ classifiers = [
32
32
  dynamic = ["version"]
33
33
  dependencies = [
34
34
  "brotli_asgi",
35
- "stac-fastapi.types~=6.4",
35
+ "stac-fastapi.types~=6.6",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ metrics = [
40
+ "prometheus-fastapi-instrumentator>=8.1.0",
36
41
  ]
37
42
 
38
43
  [dependency-groups]
@@ -74,6 +74,10 @@ class StacApi:
74
74
  health_check:
75
75
  A Callable which return application's `health` information.
76
76
  Defaults to `def health: return {"status": "UP"}`
77
+ add_metrics:
78
+ When ``True``, instrument the app and expose Prometheus metrics at
79
+ ``{router_prefix}/_mgmt/metrics``. Requires
80
+ ``stac-fastapi-api[metrics]`` to be installed.
77
81
 
78
82
  """
79
83
 
@@ -142,6 +146,7 @@ class StacApi:
142
146
  health_check: Callable[[], dict] | Callable[[], Awaitable[dict]] = attr.ib(
143
147
  default=lambda: {"status": "UP"}
144
148
  )
149
+ add_metrics: bool = attr.ib(default=False)
145
150
 
146
151
  def get_extension(self, extension: type[ApiExtension]) -> ApiExtension | None:
147
152
  """Get an extension.
@@ -477,6 +482,14 @@ class StacApi:
477
482
  for middleware in self.middlewares:
478
483
  self.app.user_middleware.insert(0, middleware)
479
484
 
485
+ if self.add_metrics:
486
+ from stac_fastapi.api.metrics import instrument_app
487
+
488
+ instrument_app(
489
+ self.app,
490
+ endpoint=f"{self.app.state.router_prefix}/_mgmt/metrics",
491
+ )
492
+
480
493
  # customize route dependencies
481
494
  for scopes, dependencies in self.route_dependencies:
482
495
  self.add_route_dependencies(scopes=scopes, dependencies=dependencies)
@@ -27,7 +27,6 @@ DEFAULT_STATUS_CODES = {
27
27
  ConflictError: status.HTTP_409_CONFLICT,
28
28
  ForeignKeyError: status.HTTP_424_FAILED_DEPENDENCY,
29
29
  DatabaseError: status.HTTP_424_FAILED_DEPENDENCY,
30
- Exception: status.HTTP_500_INTERNAL_SERVER_ERROR,
31
30
  InvalidQueryParameter: status.HTTP_400_BAD_REQUEST,
32
31
  ResponseValidationError: status.HTTP_500_INTERNAL_SERVER_ERROR,
33
32
  }
@@ -0,0 +1,131 @@
1
+ """Prometheus metrics with low-cardinality STAC operation labels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from fastapi import FastAPI
9
+
10
+ try:
11
+ from prometheus_client import REGISTRY, Counter, Histogram
12
+ from prometheus_fastapi_instrumentator import Instrumentator
13
+ from prometheus_fastapi_instrumentator.metrics import Info
14
+
15
+ REQUESTS: Any = Counter(
16
+ "http_requests_total",
17
+ "Total HTTP requests by STAC operation.",
18
+ labelnames=("operation", "method", "status"),
19
+ registry=REGISTRY,
20
+ )
21
+ LATENCY: Any = Histogram(
22
+ "http_request_duration_seconds",
23
+ "HTTP request latency by STAC operation.",
24
+ labelnames=("operation", "method"),
25
+ buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, float("inf")),
26
+ registry=REGISTRY,
27
+ )
28
+ except ImportError: # pragma: no cover
29
+ REGISTRY = None # type: ignore
30
+ Counter = None # type: ignore
31
+ Histogram = None # type: ignore
32
+ Instrumentator = None # type: ignore
33
+ Info = None # type: ignore
34
+ REQUESTS = None
35
+ LATENCY = None
36
+
37
+ OPERATIONS: dict[tuple[str, str], str] = {
38
+ ("GET", "/"): "landing",
39
+ ("GET", "/conformance"): "conformance",
40
+ ("GET", "/collections"): "list_collections",
41
+ ("GET", "/collections/{collection_id}"): "get_collection",
42
+ ("GET", "/collections/{collection_id}/items"): "list_items",
43
+ ("GET", "/collections/{collection_id}/items/{item_id}"): "get_item",
44
+ ("GET", "/search"): "search",
45
+ ("POST", "/search"): "search",
46
+ ("POST", "/collections"): "create_collection",
47
+ ("PUT", "/collections/{collection_id}"): "edit_collection",
48
+ ("PATCH", "/collections/{collection_id}"): "edit_collection",
49
+ ("DELETE", "/collections/{collection_id}"): "delete_collection",
50
+ ("POST", "/collections/{collection_id}/items"): "create_item",
51
+ ("PUT", "/collections/{collection_id}/items/{item_id}"): "edit_item",
52
+ ("PATCH", "/collections/{collection_id}/items/{item_id}"): "edit_item",
53
+ ("DELETE", "/collections/{collection_id}/items/{item_id}"): "delete_item",
54
+ ("POST", "/collections/{collection_id}/bulk_items"): "bulk",
55
+ ("GET", "/queryables"): "queryables",
56
+ ("GET", "/collections/{collection_id}/queryables"): "queryables",
57
+ ("GET", "/aggregations"): "aggregations",
58
+ ("POST", "/aggregations"): "aggregations",
59
+ ("GET", "/collections/{collection_id}/aggregations"): "aggregations",
60
+ ("POST", "/collections/{collection_id}/aggregations"): "aggregations",
61
+ ("GET", "/aggregate"): "aggregate",
62
+ ("POST", "/aggregate"): "aggregate",
63
+ ("GET", "/collections/{collection_id}/aggregate"): "aggregate",
64
+ ("POST", "/collections/{collection_id}/aggregate"): "aggregate",
65
+ }
66
+
67
+
68
+ def register_operations(mapping: dict[tuple[str, str], str]) -> None:
69
+ """Register or override operation labels for route templates."""
70
+ OPERATIONS.update(mapping)
71
+
72
+
73
+ def resolve_operation(method: str, route: str | None, router_prefix: str = "") -> str:
74
+ """Map a request method and route template to a STAC operation label."""
75
+ if not route or route == "none":
76
+ return "unknown"
77
+
78
+ path = route
79
+ prefix = (router_prefix or "").rstrip("/")
80
+ if prefix and path.startswith(prefix):
81
+ path = path[len(prefix) :] or "/"
82
+
83
+ operation = OPERATIONS.get((method.upper(), path))
84
+ if operation:
85
+ return operation
86
+
87
+ if path.startswith("/catalogs"):
88
+ return "catalog"
89
+ if "bulk" in path:
90
+ return "bulk"
91
+
92
+ return "unknown"
93
+
94
+
95
+ def record_stac_metrics(info: Info) -> None:
96
+ """Record request count and latency using STAC operation labels."""
97
+ route = info.request.scope.get("route")
98
+ route_path = getattr(route, "path", None)
99
+ router_prefix = getattr(info.request.app.state, "router_prefix", "") or ""
100
+ operation = resolve_operation(info.method, route_path, router_prefix)
101
+
102
+ REQUESTS.labels(operation, info.method, info.modified_status).inc()
103
+ LATENCY.labels(operation, info.method).observe(info.modified_duration)
104
+
105
+
106
+ def instrument_app(app: FastAPI, endpoint: str) -> None:
107
+ """Instrument a FastAPI app and expose Prometheus metrics.
108
+
109
+ Must be called during app construction (e.g. from ``StacApi``), before any
110
+ requests. Adding middleware after the app has started is not supported.
111
+
112
+ Raises:
113
+ ImportError: If ``stac-fastapi-api[metrics]`` is not installed.
114
+ """
115
+ if Instrumentator is None:
116
+ raise ImportError(
117
+ "Prometheus metrics require the optional dependency "
118
+ "`stac-fastapi-api[metrics]`. Install with: "
119
+ "pip install 'stac-fastapi-api[metrics]'"
120
+ )
121
+
122
+ (
123
+ Instrumentator(
124
+ should_group_status_codes=True,
125
+ should_ignore_untemplated=True,
126
+ excluded_handlers=[".*/_mgmt/.*"],
127
+ )
128
+ .add(record_stac_metrics)
129
+ .instrument(app)
130
+ .expose(app, endpoint=endpoint, include_in_schema=False)
131
+ )
@@ -4,6 +4,7 @@ from typing import Annotated, Literal, cast
4
4
 
5
5
  import attr
6
6
  from fastapi import Path, Query
7
+ from fastapi.responses import JSONResponse
7
8
  from pydantic import BaseModel, create_model
8
9
  from stac_pydantic.shared import BBox
9
10
 
@@ -19,12 +20,6 @@ from stac_fastapi.types.search import (
19
20
  _validate_datetime,
20
21
  )
21
22
 
22
- try:
23
- import orjson # noqa
24
- from fastapi.responses import ORJSONResponse as JSONResponse
25
- except ImportError: # pragma: nocover
26
- from starlette.responses import JSONResponse # type: ignore [assignment]
27
-
28
23
 
29
24
  def create_request_model(
30
25
  model_name="SearchGetRequest",
@@ -1,3 +1,3 @@
1
1
  """Library version."""
2
2
 
3
- __version__ = "6.4.1"
3
+ __version__ = "6.6.0"