Add kaya-otel package for OpenTelemetry tracing and metrics
CI / Build Pip package (push) Successful in 4m8s
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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user