1212from contextlib import asynccontextmanager
1313from pathlib import Path
1414
15- from fastapi import FastAPI , Request
15+ from fastapi import FastAPI , Request , Response
1616from fastapi .middleware .cors import CORSMiddleware
1717from fastapi .responses import JSONResponse
1818from fastapi .staticfiles import StaticFiles
2222from .infra .config import get_settings
2323from .infra .db import get_engine
2424from .infra .logging import configure_logging , get_logger , reset_request_id , set_request_id
25+ from .infra .metrics import get_registry , normalize_method
2526from .routes import account , auth , billing , conversations , secrets
2627
2728_log = get_logger ("request" )
@@ -34,6 +35,10 @@ async def lifespan(app: FastAPI):
3435 controller .reconcile_orphaned_runs () # fail runs stranded by a restart
3536 controller .reap_idle_sandboxes ()
3637 yield
38+ # graceful shutdown: stop taking new runs and let in-flight ones
39+ # finish (bounded), so a deploy does not kill short runs mid-flight.
40+ # Whatever does not finish in time is reconciled on the next start.
41+ controller .drain (timeout = get_settings ().shutdown_drain_seconds )
3742
3843
3944def create_app () -> FastAPI :
@@ -66,6 +71,22 @@ async def _request_context(request: Request, call_next):
6671 )
6772 dur_ms = (time .perf_counter () - start ) * 1000
6873 response .headers ["X-Request-ID" ] = rid
74+ # metrics: count by method+status and record latency. The
75+ # method is normalized to a bounded set so arbitrary/garbage
76+ # verbs cannot create unbounded metric series.
77+ registry = get_registry ()
78+ method = normalize_method (request .method )
79+ registry .counter (
80+ "paw_http_requests_total" ,
81+ labels = {"method" : method , "status" : str (response .status_code )},
82+ help = "HTTP requests by method and status." ,
83+ )
84+ registry .observe (
85+ "paw_http_request_duration_seconds" ,
86+ dur_ms / 1000.0 ,
87+ labels = {"method" : method },
88+ help = "HTTP request latency in seconds." ,
89+ )
6990 _log .info (
7091 "%s %s -> %s %.1fms" ,
7192 request .method ,
@@ -112,6 +133,12 @@ def _domain_error(_request: Request, exc: DomainError) -> JSONResponse:
112133 def healthz () -> dict :
113134 return {"ok" : True , "runner" : settings .runner }
114135
136+ @app .get ("/metrics" )
137+ def metrics () -> Response :
138+ """Prometheus text exposition of process metrics (runs, tokens,
139+ HTTP, span durations). Scrape target for a monitoring stack."""
140+ return Response (get_registry ().render (), media_type = "text/plain; version=0.0.4" )
141+
115142 static_dir = Path (__file__ ).parent / "views"
116143 if static_dir .is_dir ():
117144 app .mount ("/" , StaticFiles (directory = static_dir , html = True ), name = "ui" )
0 commit comments