diff --git a/src/app_runtime/control/base.py b/src/app_runtime/control/base.py index 8ba3eab..4c04045 100644 --- a/src/app_runtime/control/base.py +++ b/src/app_runtime/control/base.py @@ -19,7 +19,7 @@ class ControlActionRequest: ActionResult = str | dict[str, object] ActionHandler = Callable[[ControlActionRequest], Awaitable[ActionResult]] HealthHandler = Callable[[], Awaitable[HealthPayload]] -TraceResponseFormat = Literal["json", "text"] +TraceResponseFormat = Literal["json", "text", "html"] @dataclass(slots=True) diff --git a/src/app_runtime/control/http_app.py b/src/app_runtime/control/http_app.py index 826d03a..15c97d4 100644 --- a/src/app_runtime/control/http_app.py +++ b/src/app_runtime/control/http_app.py @@ -1,22 +1,30 @@ from __future__ import annotations -import json import logging import time from collections.abc import Awaitable, Callable -from typing import cast from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse from app_runtime.control.base import ControlActionRequest, TraceQueryRequest -from app_runtime.contracts.trace import TraceLevel, TraceLogView +from app_runtime.control.trace_presenter import TraceRequestParser, TraceResponseRenderer +from app_runtime.contracts.trace import TraceLogView from app_runtime.core.types import HealthPayload LOGGER = logging.getLogger(__name__) class HttpControlAppFactory: + def __init__( + self, + *, + trace_request_parser: TraceRequestParser | None = None, + trace_response_renderer: TraceResponseRenderer | None = None, + ) -> None: + self._trace_request_parser = trace_request_parser or TraceRequestParser() + self._trace_response_renderer = trace_response_renderer or TraceResponseRenderer() + def create( self, health_provider: Callable[[], Awaitable[HealthPayload]], @@ -55,7 +63,7 @@ class HttpControlAppFactory: if trace_provider is None: return JSONResponse(content={"status": "error", "detail": "trace lookup is not configured"}, status_code=503) try: - trace_request = self._trace_request(request) + trace_request = self._trace_request_parser.parse(request) except ValueError as exc: return JSONResponse(content={"status": "error", "detail": str(exc)}, status_code=400) try: @@ -64,7 +72,7 @@ class HttpControlAppFactory: return JSONResponse(content={"status": "error", "detail": f"trace not found: {traceid}"}, status_code=404) except RuntimeError as exc: return JSONResponse(content={"status": "error", "detail": str(exc)}, status_code=503) - return self._trace_response(payload, trace_request) + return self._trace_response_renderer.render(payload, trace_request) return app @@ -106,65 +114,3 @@ class HttpControlAppFactory: if value < 0: raise ValueError(f"query parameter must be >= 0: {name}={raw_value}") return value - - def _trace_request(self, request: Request) -> TraceQueryRequest: - raw_levels = request.query_params.get("levels") - raw_format = request.query_params.get("format", "text") - response_format = raw_format.strip().lower() - if response_format not in {"json", "text"}: - raise ValueError(f"unsupported trace format: {raw_format}") - return TraceQueryRequest( - levels=self._trace_levels(raw_levels), - include_attrs_json=self._bool_param(request, "attrs_json") or False, - response_format=response_format, - ) - - def _trace_levels(self, raw_levels: str | None) -> tuple[TraceLevel, ...]: - if raw_levels is None: - return ("ERROR", "WARNING", "INFO") - parts = [item.strip().upper() for item in raw_levels.split(",")] - levels = tuple(item for item in parts if item) - if not levels: - raise ValueError("trace levels must not be empty") - unsupported = [level for level in levels if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}] - if unsupported: - raise ValueError(f"unsupported trace levels: {', '.join(unsupported)}") - return cast(tuple[TraceLevel, ...], levels) - - def _trace_response(self, trace_view: TraceLogView, request: TraceQueryRequest) -> JSONResponse | PlainTextResponse: - if request.response_format == "json": - return JSONResponse( - content={ - "trace_id": trace_view.trace_id, - "parent_id": trace_view.parent_id or "", - "child_ids": list(trace_view.child_ids), - "messages": [record.as_dict(include_attrs_json=request.include_attrs_json) for record in trace_view.records], - } - ) - lines = [ - f"trace_id: {trace_view.trace_id}", - f"parent_id: {trace_view.parent_id or ''}", - ] - lines.extend(self._child_id_lines(trace_view.child_ids)) - lines.append("--------------------------------------------------") - previous_step: str | None = None - for record in trace_view.records: - current_step = str(record.step or "") - if previous_step is None: - lines.append(f"step: {current_step}") - elif current_step != previous_step: - lines.append("--------------------------------------------------") - lines.append(f"step: {current_step}") - previous_step = current_step - line = record.message - if request.include_attrs_json: - line = f"{line}, {json.dumps(record.attrs_json, ensure_ascii=False, separators=(',', ':'))}" - lines.append(line) - return PlainTextResponse(content="\n".join(lines)) - - def _child_id_lines(self, child_ids: tuple[str, ...]) -> list[str]: - lines = ["child_ids:"] - if not child_ids: - return lines - lines.extend(f" - {child_id}" for child_id in child_ids) - return lines diff --git a/src/app_runtime/control/trace_presenter.py b/src/app_runtime/control/trace_presenter.py new file mode 100644 index 0000000..cbc6dd3 --- /dev/null +++ b/src/app_runtime/control/trace_presenter.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from html import escape +from urllib.parse import urlencode + +from fastapi import Request +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response + +from app_runtime.control.base import TraceQueryRequest +from app_runtime.contracts.trace import TraceLevel, TraceLogRecord, TraceLogView + + +@dataclass(frozen=True) +class TraceStepGroup: + step: str + records: tuple[TraceLogRecord, ...] + + +class TraceRequestParser: + def parse(self, request: Request) -> TraceQueryRequest: + raw_levels = request.query_params.get("levels") + raw_format = request.query_params.get("format", "text") + response_format = raw_format.strip().lower() + if response_format not in {"json", "text", "html"}: + raise ValueError(f"unsupported trace format: {raw_format}") + return TraceQueryRequest( + levels=self._trace_levels(raw_levels), + include_attrs_json=self._bool_param(request, "attrs_json") or False, + response_format=response_format, + ) + + def _trace_levels(self, raw_levels: str | None) -> tuple[TraceLevel, ...]: + if raw_levels is None: + return ("ERROR", "WARNING", "INFO") + parts = [item.strip().upper() for item in raw_levels.split(",")] + levels = tuple(item for item in parts if item) + if not levels: + raise ValueError("trace levels must not be empty") + unsupported = [level for level in levels if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}] + if unsupported: + raise ValueError(f"unsupported trace levels: {', '.join(unsupported)}") + return levels + + def _bool_param(self, request: Request, name: str) -> bool | None: + raw_value = request.query_params.get(name) + if raw_value is None: + return None + normalized = raw_value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError(f"invalid boolean query parameter: {name}={raw_value}") + + +class TraceResponseRenderer: + def render(self, trace_view: TraceLogView, request: TraceQueryRequest) -> Response: + if request.response_format == "json": + return self._render_json(trace_view, request) + if request.response_format == "html": + return self._render_html(trace_view, request) + return self._render_text(trace_view, request) + + def _render_json(self, trace_view: TraceLogView, request: TraceQueryRequest) -> JSONResponse: + return JSONResponse( + content={ + "trace_id": trace_view.trace_id, + "parent_id": trace_view.parent_id or "", + "child_ids": list(trace_view.child_ids), + "messages": [record.as_dict(include_attrs_json=request.include_attrs_json) for record in trace_view.records], + } + ) + + def _render_text(self, trace_view: TraceLogView, request: TraceQueryRequest) -> PlainTextResponse: + lines = [ + f"trace_id: {trace_view.trace_id}", + f"parent_id: {trace_view.parent_id or ''}", + *self._child_id_lines(trace_view.child_ids), + "--------------------------------------------------", + ] + previous_step: str | None = None + for record in trace_view.records: + current_step = str(record.step or "") + if previous_step is None: + lines.append(f"step: {current_step}") + elif current_step != previous_step: + lines.append("--------------------------------------------------") + lines.append(f"step: {current_step}") + previous_step = current_step + lines.append(self._text_message(record, request.include_attrs_json)) + return PlainTextResponse(content="\n".join(lines)) + + def _render_html(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse: + title = escape(f"Trace {trace_view.trace_id}") + header_links = self._related_trace_links(trace_view, request) + rows = self._step_rows(trace_view, request) + html = f""" + + + + + {title} + + + +
+
+

{title}

+

Parent: {escape(trace_view.parent_id or "")}

+

Children: {len(trace_view.child_ids)}

+
+
+

Related Traces

+ {header_links} +
+
+

Logs By Step

+ + + + + + {rows} + +
StepLog
+
+
+ +""" + return HTMLResponse(content=html) + + def _child_id_lines(self, child_ids: tuple[str, ...]) -> list[str]: + lines = ["child_ids:"] + lines.extend(f" - {child_id}" for child_id in child_ids) + return lines + + def _text_message(self, record: TraceLogRecord, include_attrs_json: bool) -> str: + if not include_attrs_json: + return record.message + return f"{record.message}, {json.dumps(record.attrs_json, ensure_ascii=False, separators=(',', ':'))}" + + def _related_trace_links(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: + links = [self._trace_link_item("current", trace_view.trace_id, request)] + if trace_view.parent_id: + links.append(self._trace_link_item("parent", trace_view.parent_id, request)) + links.extend(self._trace_link_item("child", child_id, request) for child_id in trace_view.child_ids) + return f"" + + def _trace_link_item(self, label: str, trace_id: str, request: TraceQueryRequest) -> str: + href = escape(self._trace_href(trace_id, request), quote=True) + text = escape(f"{label}: {trace_id}") + return f"
  • {text}
  • " + + def _trace_href(self, trace_id: str, request: TraceQueryRequest) -> str: + params = urlencode( + { + "format": "html", + "levels": ",".join(request.levels), + "attrs_json": "true" if request.include_attrs_json else "false", + } + ) + return f"/traces/{trace_id}?{params}" + + def _step_rows(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: + groups = self._step_groups(trace_view.records) + if not groups: + return " No log records for selected filters." + return "".join(self._step_row(group, request.include_attrs_json) for group in groups) + + def _step_groups(self, records: tuple[TraceLogRecord, ...]) -> tuple[TraceStepGroup, ...]: + grouped: dict[str, list[TraceLogRecord]] = {} + order: list[str] = [] + for record in records: + step = str(record.step or "") + if step not in grouped: + grouped[step] = [] + order.append(step) + grouped[step].append(record) + return tuple(TraceStepGroup(step=step, records=tuple(grouped[step])) for step in order) + + def _step_row(self, group: TraceStepGroup, include_attrs_json: bool) -> str: + step_label = escape(group.step) if group.step else " " + entries = "".join(self._log_entry(record, include_attrs_json) for record in group.records) + return f"{step_label}{entries}" + + def _log_entry(self, record: TraceLogRecord, include_attrs_json: bool) -> str: + level = escape(record.level) + status = escape(record.status or "") + timestamp = escape(record.event_time.isoformat()) + message = escape(record.message) + attrs = "" + if include_attrs_json: + attrs_payload = escape(json.dumps(record.attrs_json, ensure_ascii=False, indent=2, sort_keys=True)) + attrs = f"
    {attrs_payload}
    " + return ( + f"
    " + f"
    {timestamp} | {level} | {status}
    " + f"
    {message}
    " + f"{attrs}" + f"
    " + ) diff --git a/tests/test_trace_endpoint.py b/tests/test_trace_endpoint.py index 63fc670..e791d6e 100644 --- a/tests/test_trace_endpoint.py +++ b/tests/test_trace_endpoint.py @@ -181,6 +181,37 @@ def test_trace_endpoint_returns_json_payload() -> None: } +def test_trace_endpoint_returns_html_page_with_related_links() -> None: + async def trace_provider(_trace_id: str, _request: TraceQueryRequest) -> TraceLogView: + return TraceLogView( + trace_id="trace-1", + parent_id="parent-1", + child_ids=("child-1", "child-2"), + records=( + _trace_record(row_id=1, level="INFO", message="loaded prices", step="load_stocks", status="ok"), + _trace_record(row_id=2, level="WARNING", message="filtered suspicious ticker", step="filter_stocks", status="degraded"), + ), + ) + + client = _build_client(trace_provider) + try: + response = client.get("/traces/trace-1?format=html&attrs_json=true") + finally: + client.close() + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert "StepLog" in response.text + assert ">load_stocks<" in response.text + assert ">filter_stocks<" in response.text + assert "loaded prices" in response.text + assert "filtered suspicious ticker" in response.text + assert 'href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text + assert 'href="/traces/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text + assert 'href="/traces/child-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text + assert 'href="/traces/child-2?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text + + def test_trace_endpoint_validates_query_params() -> None: client = _build_client(lambda _trace_id, _request: None) try: