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""" + +
+ + +| Step | Log |
|---|