Add kaya-otel package for OpenTelemetry tracing and metrics
CI / Build Pip package (push) Successful in 4m8s

Instrument HTTP requests and WebSocket connections via Kaya hooks, covering both ASGI and RSGI. Records handler exceptions, WebSocket close codes, optional header capture, exclusions and lifecycle hooks.

Add route-template resolution and exception visibility to kaya-core so trace/metric attributes can use low-cardinality routes and failed spans can record escaped exceptions.
This commit is contained in:
2026-09-19 00:39:15 +00:00
parent 69762d93df
commit 148a35b71c
16 changed files with 1182 additions and 1 deletions
+10
View File
@@ -145,6 +145,9 @@ class KayaApp(AbstractKayaApp):
await handler.handle_request(ctx, captured)
else:
await ctx.send_empty(404)
except Exception as exc:
ctx.exception = exc
raise
finally:
for hook in reversed(self._after_request_hooks):
await hook(ctx)
@@ -161,6 +164,9 @@ class KayaApp(AbstractKayaApp):
await handler.handle_request(ws, captured)
else:
await ws.close(1000)
except Exception as exc:
ws.exception = exc
raise
finally:
for hook in reversed(self._after_websocket_hooks):
await hook(ws)
@@ -173,6 +179,10 @@ class KayaApp(AbstractKayaApp):
for mixin in self._mixins:
mixin.shutdown(loop)
def route_template(self, url: str, method: HttpMethod = HttpMethod.GET) -> Optional[str]:
"""Return the registered route template that would handle ``url``."""
return self._tree.route_template(url, method)
def route(self,
paths: StrOrStrings,
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
@@ -29,6 +29,7 @@ class HttpContext(ABC):
server: Optional[Tuple[str, Optional[int]]]
request_body: AsyncIterator[bytes]
session: Optional[Any] = None
exception: Optional[BaseException] = None
@abstractmethod
async def stream_body(self,
+31
View File
@@ -237,6 +237,37 @@ class Tree:
# return (handler, unmatched)
return None
def route_template(self, url: str, method: HttpMethod = HttpMethod.GET) -> Optional[str]:
"""Return the registered route template that would handle ``url``.
Static segments are returned verbatim, ``${name}``/``${name:int}``
parameter matchers are reconstructed from the matched nodes, and glob
matchers keep their pattern. Returns ``None`` when no route (including
recursive fallback routes) would handle the request.
"""
result = self.find_node((p for p in PathIterator(urlparse(url).path)), method)
if result is None:
return None
node, captured = result
if len(captured.unmatched_paths) > 0 and not any(handler.recursive for handler in node.handlers):
return None
parts: List[str] = []
current: Optional[Node | PathMatcher] = node
while current is not None:
if isinstance(current, Node):
key = current.key
if not isinstance(key, HttpMethod) and key != '/':
parts.append(str(key))
elif isinstance(current, IntMatcher):
parts.append('${%s:int}' % current.name)
elif isinstance(current, StrMatcher):
parts.append('${%s}' % current.name)
elif isinstance(current, GlobMatcher):
parts.append(current.pattern)
current = current.parent
return '/' + '/'.join(reversed(parts)) if parts else '/'
def parse(self, leaf: str, parent: Optional[Node | PathMatcher]) -> Node | PathMatcher:
start = 0
result = index_of_with_escape(leaf, '${', '\\', 0)
@@ -21,6 +21,7 @@ class WebSocket(ABC):
client: Optional[Tuple[str, int]]
server: Optional[Tuple[str, Optional[int]]]
session: Optional[Any] = None
exception: Optional[BaseException] = None
@abstractmethod
async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
+29
View File
@@ -250,3 +250,32 @@ class AsgiTest(unittest.TestCase):
r = await client.put("/restaurants/42/menu")
self.assertEqual(404, r.status_code)
def test_route_template(self):
self.assertEqual('/employee/${employee_id}',
self.app.route_template('/employee/101325', HttpMethod.GET))
self.assertEqual('/square/${x:int}', self.app.route_template('/square/30', HttpMethod.GET))
self.assertIsNone(self.app.route_template('/unknown', HttpMethod.GET))
@async_test
async def test_exception_exposed_to_after_hooks(self):
app = KayaApp()
seen = []
async def after_request(ctx: HttpContext) -> None:
seen.append(ctx.exception)
app.add_after_request_hook(after_request)
@app.GET('/raises')
async def raises(ctx: HttpContext) -> None:
raise RuntimeError('boom')
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
with self.assertRaises(RuntimeError):
await client.get('/raises')
self.assertEqual(1, len(seen))
self.assertIsInstance(seen[0], RuntimeError)
self.assertEqual('boom', str(seen[0]))
+21
View File
@@ -80,6 +80,27 @@ class TreeTest(unittest.TestCase):
self.assertIs(Maybe.of(handler_num).map(self.handlers.__getitem__).or_none(),
Maybe.of_nullable(res).map(lambda it: it[0]).or_none())
def test_route_template(self):
cases: Tuple[Tuple[str, HttpMethod, Optional[str]], ...] = (
('/home/something', HttpMethod.GET, '/home/something'),
('/home/something_else', HttpMethod.POST, '/home/something_else'),
('/home/README.md', HttpMethod.GET, '/home/*.md'),
('/home/something/ciao/blah/README.md', HttpMethod.GET, '/home/something/*/blah/*.md'),
('/home/bar/ciao/blah/README.md', HttpMethod.GET, '/home/bar/*'),
('/unknown', HttpMethod.GET, None),
)
for url, method, expected in cases:
with self.subTest(f'{method} {url}'):
self.assertEqual(expected, self.tree.route_template(url, method))
def test_route_template_with_params(self):
tree = Tree()
tree.add((p for p in ('foo', '${id:int}')), HttpMethod.PUT, self.handlers[0])
tree.add((p for p in ('foo', '${name}')), HttpMethod.GET, self.handlers[1])
self.assertEqual('/foo/${id:int}', tree.route_template('/foo/42', HttpMethod.PUT))
self.assertEqual('/foo/${name}', tree.route_template('/foo/bar', HttpMethod.GET))
self.assertIsNone(tree.route_template('/foo/bar', HttpMethod.DELETE))
def test_two_method_agnostic_matchers_raise(self):
tree = Tree()
tree.add((p for p in ('foo', '*')), None, self.handlers[0])