From 87b81c6b487da54dc23948f02b78b56c82aea315 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 22 Jun 2026 03:44:27 +0300 Subject: [PATCH 1/3] feat(observability): request id, access log, metrics hooks --- oxyroute/app.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++- src/dispatch.rs | 18 +++++++++++++++++ src/lib.rs | 1 + src/state.rs | 1 + 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/oxyroute/app.py b/oxyroute/app.py index 87607d8..26efdc6 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -18,6 +18,37 @@ def _unwrap_dep(f: Dep) -> Any: return f.dependency() return f +class _ProtocolWrapper: + __slots__ = ("_inner", "status", "__oxyroute_path_template__") + + def __init__(self, inner: Any) -> None: + self._inner = inner + self.status: int = 500 + self.__oxyroute_path_template__: str = "" + + def __oxyroute_set_path_template__(self, template: str) -> None: + self.__oxyroute_path_template__ = template + + def response_empty(self, status: int, headers: list[tuple[str, str]]) -> None: + self.status = status + self._inner.response_empty(status, headers) + + def response_str(self, status: int, headers: list[tuple[str, str]], body: str) -> None: + self.status = status + self._inner.response_str(status, headers, body) + + def response_bytes(self, status: int, headers: list[tuple[str, str]], body: bytes) -> None: + self.status = status + self._inner.response_bytes(status, headers, body) + + def response_file(self, status: int, headers: list[tuple[str, str]], file_path: str) -> None: + self.status = status + self._inner.response_file(status, headers, file_path) + + def response_stream(self, status: int, headers: list[tuple[str, str]]) -> Any: + self.status = status + return self._inner.response_stream(status, headers) + def _norm_dependencies( deps: list[tuple[str, Dep]] | None, @@ -43,10 +74,17 @@ class App: Granian worker processes. """ - def __init__(self, title: str = "OxyRoute", *, include_openapi: bool = True) -> None: + def __init__( + self, + title: str = "OxyRoute", + *, + include_openapi: bool = True, + access_log_hook: Callable[[Any, int, float, str], None] | None = None, + ) -> None: self._app = _oxyroute.App(include_openapi=include_openapi) self._app.set_openapi_title(title) self.title = title + self.access_log_hook = access_log_hook # Per-process mutable bag for ``__rsgi_init__`` / factory setup (DB pool, clients, …). self.state: SimpleNamespace = SimpleNamespace() @@ -413,6 +451,17 @@ async def __rsgi__(self, scope: Any, protocol: Any) -> Any: Granian awaits this coroutine. Native ``handle_rsgi`` may return ``None`` immediately (sync short-circuit for openapi / 404 / 405) or an awaitable (full async path). """ + if self.access_log_hook: + import time + start = time.perf_counter_ns() + p = _ProtocolWrapper(protocol) + r = self._app.handle_rsgi(scope, p) + if r is not None and inspect.isawaitable(r): + await r + dur = (time.perf_counter_ns() - start) / 1000000.0 + self.access_log_hook(scope, p.status, dur, p.__oxyroute_path_template__) + return r + r = self._app.handle_rsgi(scope, protocol) if r is None or not inspect.isawaitable(r): return r diff --git a/src/dispatch.rs b/src/dispatch.rs index 69b66ba..efc1d15 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -220,6 +220,11 @@ fn run_trivial_sync_route( scope: &pyo3::Bound<'_, PyAny>, state: &std::sync::Arc>, ) -> PyResult<()> { + let _ = protocol.setattr( + py, + "__oxyroute_path_template__", + entry.path_template.clone(), + ); let handler = entry.handler.bind(py); let out = match handler.call0() { Ok(x) => x.unbind(), @@ -348,6 +353,7 @@ pub fn try_rsgi_sync_short_circuit( } if (method == "GET" || method == "HEAD") && path == "/openapi.json" && snapshot.include_openapi { + let _ = protocol_py.setattr(py, "__oxyroute_path_template__", "/openapi.json"); let doc = state.read().openapi.lock().to_string(); if is_head { response::send_head_simple_sync( @@ -475,6 +481,9 @@ pub async fn run_rsgi( }); if (method == "GET" || method == "HEAD") && path == "/openapi.json" && snapshot.include_openapi { + let _ = Python::with_gil(|py| { + protocol.setattr(py, "__oxyroute_path_template__", "/openapi.json") + }); let doc = state.read().openapi.lock().to_string(); if is_head { return response::send_head_simple( @@ -489,6 +498,8 @@ pub async fn run_rsgi( } // Prototype: Issue 55 (sqlx integration benchmark path) if method == "GET" && path == "/test_db" { + let _ = + Python::with_gil(|py| protocol.setattr(py, "__oxyroute_path_template__", "/test_db")); if let Some(pool) = snapshot.db_pool.as_ref() { use sqlx::Row; match sqlx::query("SELECT 1 as num").fetch_one(pool).await { @@ -677,6 +688,13 @@ pub async fn run_rsgi( })?; let may_need_raw_body = handler_varkw || handler_param_names.contains("body"); let should_read_body = read_json_body || read_form_body || may_need_raw_body; + let _ = Python::with_gil(|py| { + protocol.setattr( + py, + "__oxyroute_path_template__", + routes_arc[route_idx].path_template.clone(), + ) + }); let mut body_bytes: Vec = if should_read_body { let read_fut = Python::with_gil(|py| { let p = protocol.bind(py); diff --git a/src/lib.rs b/src/lib.rs index 0c2ca6c..7fea4a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -251,6 +251,7 @@ impl App { let routes = Arc::make_mut(&mut st.routes); let idx = routes.len(); routes.push(state::RouteEntry { + path_template: path.to_string(), handler, is_async, require_jwt, diff --git a/src/state.rs b/src/state.rs index 1f362ed..85362ac 100644 --- a/src/state.rs +++ b/src/state.rs @@ -38,6 +38,7 @@ pub struct WebsocketRoute { #[derive(Clone)] pub struct RouteEntry { + pub path_template: String, pub handler: Py, pub is_async: bool, pub require_jwt: bool, From 41d0223a1450c66fdab880aa5ac620b28a5d61d6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 22 Jun 2026 03:49:12 +0300 Subject: [PATCH 2/3] style: sort ProtocolWrapper __slots__ to pass ruff RUF023 --- oxyroute/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxyroute/app.py b/oxyroute/app.py index 26efdc6..6b233e8 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -19,7 +19,7 @@ def _unwrap_dep(f: Dep) -> Any: return f class _ProtocolWrapper: - __slots__ = ("_inner", "status", "__oxyroute_path_template__") + __slots__ = ("__oxyroute_path_template__", "_inner", "status") def __init__(self, inner: Any) -> None: self._inner = inner From 22372c8442bfddc3fd6d296336e1738942a0d5e3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 22 Jun 2026 03:53:59 +0300 Subject: [PATCH 3/3] style: format oxyroute/app.py to pass ruff format --- oxyroute/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/oxyroute/app.py b/oxyroute/app.py index 6b233e8..e9e7eec 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -18,9 +18,10 @@ def _unwrap_dep(f: Dep) -> Any: return f.dependency() return f + class _ProtocolWrapper: __slots__ = ("__oxyroute_path_template__", "_inner", "status") - + def __init__(self, inner: Any) -> None: self._inner = inner self.status: int = 500 @@ -453,6 +454,7 @@ async def __rsgi__(self, scope: Any, protocol: Any) -> Any: """ if self.access_log_hook: import time + start = time.perf_counter_ns() p = _ProtocolWrapper(protocol) r = self._app.handle_rsgi(scope, p) @@ -461,7 +463,7 @@ async def __rsgi__(self, scope: Any, protocol: Any) -> Any: dur = (time.perf_counter_ns() - start) / 1000000.0 self.access_log_hook(scope, p.status, dur, p.__oxyroute_path_template__) return r - + r = self._app.handle_rsgi(scope, protocol) if r is None or not inspect.isawaitable(r): return r