diff --git a/README.md b/README.md index 4dacd66..765d5be 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ ## Установка +Быстрая установка через SSH-доступ к Git: + +```bash +pip install "plba @ git+ssh://git@git.lesha.spb.ru/alex/plba.git@main" +``` + Установка пакета напрямую из Git-репозитория через `pip`: ```bash diff --git a/pyproject.toml b/pyproject.toml index 1e8fa9e..cace5e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plba" -version = "0.4.0" +version = "0.5.0" description = "Platform runtime for business applications" readme = "README.md" requires-python = ">=3.11" diff --git a/src/app_runtime/contracts/trace.py b/src/app_runtime/contracts/trace.py index e877af3..a074d9d 100644 --- a/src/app_runtime/contracts/trace.py +++ b/src/app_runtime/contracts/trace.py @@ -90,9 +90,12 @@ class TraceLogRecord: class TraceLogView: trace_id: str parent_id: str | None + alias: str = "" child_ids: tuple[str, ...] = () records: tuple[TraceLogRecord, ...] = () ancestors: tuple[TraceLogView, ...] = () + descendants: tuple[TraceLogView, ...] = () + requested_trace_id: str | None = None class TraceLogReader(Protocol): @@ -101,5 +104,7 @@ class TraceLogReader(Protocol): trace_id: str, levels: tuple[TraceLevel, ...], ancestor_depth: int | None = 0, + *, + view: Literal["tree", "single"] = "tree", ) -> TraceLogView | None: """Load trace context and filtered log records.""" diff --git a/src/app_runtime/control/base.py b/src/app_runtime/control/base.py index 3776a2e..8deefb3 100644 --- a/src/app_runtime/control/base.py +++ b/src/app_runtime/control/base.py @@ -20,6 +20,7 @@ ActionResult = str | dict[str, object] ActionHandler = Callable[[ControlActionRequest], Awaitable[ActionResult]] HealthHandler = Callable[[], Awaitable[HealthPayload]] TraceResponseFormat = Literal["json", "text", "html"] +TraceViewMode = Literal["tree", "single"] @dataclass(slots=True) @@ -27,7 +28,8 @@ class TraceQueryRequest: levels: tuple[TraceLevel, ...] = ("ERROR", "WARNING", "INFO") include_attrs_json: bool = False response_format: TraceResponseFormat = "html" - ancestor_depth: int | None = 0 + ancestor_depth: int | None = None + view: TraceViewMode = "tree" TraceLookupHandler = Callable[[str, TraceQueryRequest], Awaitable[TraceLogView]] diff --git a/src/app_runtime/control/trace_presenter.py b/src/app_runtime/control/trace_presenter.py index 2df7236..6d12056 100644 --- a/src/app_runtime/control/trace_presenter.py +++ b/src/app_runtime/control/trace_presenter.py @@ -21,11 +21,16 @@ class TraceRequestParser: response_format = raw_format.strip().lower() if response_format not in {"json", "text", "html"}: raise ValueError(f"unsupported trace format: {raw_format}") + raw_view = request.query_params.get("view", "tree") + view = raw_view.strip().lower() + if view not in {"tree", "single"}: + raise ValueError(f"unsupported trace view: {raw_view}") return TraceQueryRequest( levels=self._trace_levels(raw_levels), include_attrs_json=self._bool_param(request, "attrs_json") or False, response_format=response_format, - ancestor_depth=self._ancestor_depth(request), + ancestor_depth=self._ancestor_depth(request, default_all=view == "tree"), + view=view, ) def _trace_levels(self, raw_levels: str | None) -> tuple[TraceLevel, ...]: @@ -51,10 +56,10 @@ class TraceRequestParser: return False raise ValueError(f"invalid boolean query parameter: {name}={raw_value}") - def _ancestor_depth(self, request: Request) -> int | None: + def _ancestor_depth(self, request: Request, *, default_all: bool) -> int | None: raw_value = request.query_params.get("ancestor_depth") if raw_value is None: - return 0 + return None if default_all else 0 normalized = raw_value.strip().lower() if normalized == "all": return None @@ -69,26 +74,35 @@ class TraceRequestParser: class TraceResponseRenderer: def render(self, trace_view: TraceLogView, request: TraceQueryRequest) -> Response: + if request.view == "single": + if request.response_format == "json": + return self._render_json_single(trace_view, request) + if request.response_format == "html": + return self._render_html_single(trace_view, request) + return self._render_text_single(trace_view, request) if request.response_format == "json": - return self._render_json(trace_view, request) + return self._render_json_tree(trace_view, request) if request.response_format == "html": - return self._render_html(trace_view, request) - return self._render_text(trace_view, request) + return self._render_html_tree(trace_view, request) + return self._render_text_tree(trace_view, request) - def _render_json(self, trace_view: TraceLogView, request: TraceQueryRequest) -> JSONResponse: + def _render_json_single(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], - "ancestors": [self._trace_payload(view, request) for view in trace_view.ancestors], + "ancestors": [self._trace_payload_single(view, request) for view in trace_view.ancestors], } ) - def _render_text(self, trace_view: TraceLogView, request: TraceQueryRequest) -> PlainTextResponse: + def _render_json_tree(self, trace_view: TraceLogView, request: TraceQueryRequest) -> JSONResponse: + return JSONResponse(content=self._trace_payload_tree(trace_view, request)) + + def _render_text_single(self, trace_view: TraceLogView, request: TraceQueryRequest) -> PlainTextResponse: lineage = [*trace_view.ancestors, trace_view] - lines = self._text_trace_summary_lines(trace_view) + lines = self._text_trace_summary_lines_single(trace_view) for index, entry in enumerate(lineage): if index == 0: lines.append("") @@ -97,10 +111,29 @@ class TraceResponseRenderer: lines.extend(self._text_trace_log_lines(entry, request)) return PlainTextResponse(content="\n".join(lines)) - def _render_html(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse: + def _render_text_tree(self, trace_view: TraceLogView, request: TraceQueryRequest) -> PlainTextResponse: + lines = self._text_trace_summary_lines_tree(trace_view) + for index, entry in enumerate(self._tree_walk(trace_view)): + if index == 0: + lines.append("") + else: + lines.extend(["", ""]) + lines.extend(self._text_trace_log_lines(entry, request)) + return PlainTextResponse(content="\n".join(lines)) + + def _render_html_single(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse: title = escape(f"Trace {trace_view.trace_id}") - lines = self._html_lines(trace_view, request) - html = f""" + lines = self._html_lines_single(trace_view, request) + return HTMLResponse(content=self._html_document(title, lines)) + + def _render_html_tree(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse: + requested = trace_view.requested_trace_id or trace_view.trace_id + title = escape(f"Trace {requested}") + lines = self._html_lines_tree(trace_view, request) + return HTMLResponse(content=self._html_document(title, lines)) + + def _html_document(self, title: str, lines: str) -> str: + return f''' @@ -160,8 +193,7 @@ class TraceResponseRenderer: {lines} -""" - return HTMLResponse(content=html) +''' def _child_id_lines(self, child_ids: tuple[str, ...]) -> list[str]: lines = ["child_ids:"] @@ -178,6 +210,7 @@ class TraceResponseRenderer: "format": "html", "levels": ",".join(request.levels), "attrs_json": "true" if request.include_attrs_json else "false", + "view": request.view, } if request.ancestor_depth is None: params["ancestor_depth"] = "all" @@ -186,9 +219,9 @@ class TraceResponseRenderer: query = urlencode(params) return f"/traces/{trace_id}?{query}" - def _html_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: + def _html_lines_single(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: lineage = [*trace_view.ancestors, trace_view] - lines = self._html_trace_summary_lines(trace_view, request) + lines = self._html_trace_summary_lines_single(trace_view, request) for index, entry in enumerate(lineage): if index == 0: lines.append(self._html_plain_line("")) @@ -197,7 +230,17 @@ class TraceResponseRenderer: lines.extend(self._html_trace_log_lines(entry, request)) return "".join(lines) - def _trace_payload(self, trace_view: TraceLogView, request: TraceQueryRequest) -> dict[str, object]: + def _html_lines_tree(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: + lines = self._html_trace_summary_lines_tree(trace_view, request) + for index, entry in enumerate(self._tree_walk(trace_view)): + if index == 0: + lines.append(self._html_plain_line("")) + else: + lines.extend([self._html_plain_line(""), self._html_plain_line("")]) + lines.extend(self._html_trace_log_lines(entry, request)) + return "".join(lines) + + def _trace_payload_single(self, trace_view: TraceLogView, request: TraceQueryRequest) -> dict[str, object]: return { "trace_id": trace_view.trace_id, "parent_id": trace_view.parent_id or "", @@ -205,17 +248,38 @@ class TraceResponseRenderer: "messages": [record.as_dict(include_attrs_json=request.include_attrs_json) for record in trace_view.records], } - def _text_trace_summary_lines(self, trace_view: TraceLogView) -> list[str]: + def _trace_payload_tree(self, trace_view: TraceLogView, request: TraceQueryRequest) -> dict[str, object]: + payload: dict[str, object] = { + "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], + "descendants": [self._trace_payload_tree(view, request) for view in trace_view.descendants], + } + if trace_view.requested_trace_id and trace_view.requested_trace_id != trace_view.trace_id: + payload["requested_trace_id"] = trace_view.requested_trace_id + return payload + + def _text_trace_summary_lines_single(self, trace_view: TraceLogView) -> list[str]: return [ f"trace_id: {trace_view.trace_id}", f"parent_id: {trace_view.parent_id or ''}", *self._child_id_lines(trace_view.child_ids), ] + def _text_trace_summary_lines_tree(self, trace_view: TraceLogView) -> list[str]: + lines = [f"trace_id: {trace_view.trace_id}"] + requested = trace_view.requested_trace_id or trace_view.trace_id + if requested != trace_view.trace_id: + lines.append(f"requested_trace_id: {requested}") + lines.append(f"parent_id: {trace_view.parent_id or ''}") + lines.extend(self._child_id_lines(trace_view.child_ids)) + return lines + def _text_trace_log_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: lines = [ TRACE_SECTION_SEPARATOR, - f"trace_id: {trace_view.trace_id}", + self._trace_context_label(trace_view), "", ] previous_step: str | None = None @@ -230,7 +294,7 @@ class TraceResponseRenderer: lines.append(self._text_message(record, request.include_attrs_json)) return lines - def _html_trace_summary_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: + def _html_trace_summary_lines_single(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: return [ self._html_plain_line(f"trace_id: {self._trace_link(trace_view.trace_id, request)}"), self._html_plain_line(f"parent_id: {self._optional_trace_link(trace_view.parent_id, request)}"), @@ -238,10 +302,24 @@ class TraceResponseRenderer: *(self._html_plain_line(f" - {self._trace_link(child_id, request)}") for child_id in trace_view.child_ids), ] + def _html_trace_summary_lines_tree(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: + lines = [self._html_plain_line(f"trace_id: {self._trace_link(trace_view.trace_id, request)}")] + requested = trace_view.requested_trace_id or trace_view.trace_id + if requested != trace_view.trace_id: + lines.append(self._html_plain_line(f"requested_trace_id: {self._trace_link(requested, request)}")) + lines.extend( + [ + self._html_plain_line(f"parent_id: {self._optional_trace_link(trace_view.parent_id, request)}"), + self._html_plain_line("child_ids:"), + *(self._html_plain_line(f" - {self._trace_link(child_id, request)}") for child_id in trace_view.child_ids), + ] + ) + return lines + def _html_trace_log_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: lines = [ self._html_plain_line(TRACE_SECTION_SEPARATOR), - self._html_plain_line(f"trace_id: {self._trace_link(trace_view.trace_id, request)}"), + self._html_plain_line(self._html_trace_context_label(trace_view, request)), self._html_plain_line(""), ] previous_step: str | None = None @@ -260,29 +338,36 @@ class TraceResponseRenderer: return lines def _html_message_lines(self, record: TraceLogRecord, include_attrs_json: bool) -> list[str]: - lines = [self._html_colored_line(self._text_message(record, include_attrs_json), record.level)] - return lines + return [self._html_colored_line(self._text_message(record, include_attrs_json), record.level)] def _html_plain_line(self, content: str) -> str: - return f"
{content or ' '}
" + return f'
{content or " "}
' def _html_step_line(self, content: str) -> str: - return f"
{escape(content) or ' '}
" + return f'
{escape(content) or " "}
' def _html_colored_line(self, content: str, level: str) -> str: level_class = self._level_class(level) - return f"
{escape(content)}
" + return f'
{escape(content)}
' def _trace_link(self, trace_id: str, request: TraceQueryRequest) -> str: href = escape(self._trace_href(trace_id, request), quote=True) text = escape(trace_id) - return f"{text}" + return f'{text}' def _optional_trace_link(self, trace_id: str | None, request: TraceQueryRequest) -> str: if not trace_id: return "" return self._trace_link(trace_id, request) + def _trace_context_label(self, trace_view: TraceLogView) -> str: + return f"trace context: {trace_view.alias}, {trace_view.trace_id}" + + def _html_trace_context_label(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: + alias = escape(trace_view.alias) + trace_link = self._trace_link(trace_view.trace_id, request) + return f"trace context: {alias}, {trace_link}" + def _level_class(self, level: str) -> str: if level == "ERROR": return "msg-error" @@ -291,3 +376,14 @@ class TraceResponseRenderer: if level == "INFO": return "msg-info" return "msg-debug" + + def _tree_walk(self, trace_view: TraceLogView) -> list[TraceLogView]: + ordered: list[TraceLogView] = [] + + def _visit(node: TraceLogView) -> None: + ordered.append(node) + for child in node.descendants: + _visit(child) + + _visit(trace_view) + return ordered diff --git a/src/app_runtime/core/runtime.py b/src/app_runtime/core/runtime.py index 71f1549..ea59fac 100644 --- a/src/app_runtime/core/runtime.py +++ b/src/app_runtime/core/runtime.py @@ -141,7 +141,7 @@ class RuntimeManager: reader = build_trace_log_reader(self.traces.transport) if reader is None: raise RuntimeError("trace log reader is not configured") - trace_view = reader.read_trace(trace_id, request.levels, request.ancestor_depth) + trace_view = reader.read_trace(trace_id, request.levels, request.ancestor_depth, view=request.view) if trace_view is None: raise KeyError(trace_id) return trace_view diff --git a/src/app_runtime/tracing/reader.py b/src/app_runtime/tracing/reader.py index e6ba943..215ecf3 100644 --- a/src/app_runtime/tracing/reader.py +++ b/src/app_runtime/tracing/reader.py @@ -1,11 +1,13 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, Literal from app_runtime.contracts.trace import TraceLevel, TraceLogReader, TraceLogRecord, TraceLogView, TraceTransport from app_runtime.tracing.transport import MySqlTraceConnectionFactory, MySqlTraceTransport +TraceViewMode = Literal["tree", "single"] + class MySqlTraceLogReader(TraceLogReader): def __init__(self, connection_factory: MySqlTraceConnectionFactory) -> None: @@ -16,19 +18,72 @@ class MySqlTraceLogReader(TraceLogReader): trace_id: str, levels: tuple[TraceLevel, ...], ancestor_depth: int | None = 0, + *, + view: TraceViewMode = "tree", ) -> TraceLogView | None: - parent_id = self._read_parent_id(trace_id) - if parent_id is None and not self._trace_exists(trace_id): + context = self._read_context(trace_id) + if context is None: return None - ancestors = self._read_ancestors(parent_id, levels, ancestor_depth) + parent_id, alias = context + if view == "single": + ancestors = self._read_ancestors(parent_id, levels, ancestor_depth) + child_ids = self._read_child_ids(trace_id) + records = self._read_records(trace_id, levels) + return TraceLogView( + trace_id=trace_id, + parent_id=parent_id, + alias=alias, + child_ids=tuple(child_ids), + records=tuple(records), + ancestors=tuple(ancestors), + ) + root_trace_id = self._resolve_tree_root(trace_id, ancestor_depth) + root_view = self._read_tree(root_trace_id, levels) + if root_view is None: + return None + if root_trace_id == trace_id: + return root_view + return TraceLogView( + trace_id=root_view.trace_id, + parent_id=root_view.parent_id, + alias=root_view.alias, + child_ids=root_view.child_ids, + records=root_view.records, + ancestors=root_view.ancestors, + descendants=root_view.descendants, + requested_trace_id=trace_id, + ) + + def _resolve_tree_root(self, trace_id: str, ancestor_depth: int | None) -> str: + current_trace_id = trace_id + remaining_depth = ancestor_depth + while True: + context = self._read_context(current_trace_id) + if context is None: + return current_trace_id + parent_id, _alias = context + if parent_id is None: + return current_trace_id + if remaining_depth == 0: + return current_trace_id + current_trace_id = parent_id + if remaining_depth is not None: + remaining_depth -= 1 + + def _read_tree(self, trace_id: str, levels: tuple[TraceLevel, ...]) -> TraceLogView | None: + context = self._read_context(trace_id) + if context is None: + return None + parent_id, alias = context child_ids = self._read_child_ids(trace_id) - records = self._read_records(trace_id, levels) + descendants = [self._read_tree(child_id, levels) for child_id in child_ids] return TraceLogView( trace_id=trace_id, parent_id=parent_id, + alias=alias, child_ids=tuple(child_ids), - records=tuple(records), - ancestors=tuple(ancestors), + records=tuple(self._read_records(trace_id, levels)), + descendants=tuple(view for view in descendants if view is not None), ) def _read_ancestors( @@ -43,11 +98,15 @@ class MySqlTraceLogReader(TraceLogReader): ancestors: list[TraceLogView] = [] current_trace_id = parent_id while current_trace_id is not None and (remaining_depth is None or remaining_depth > 0): - current_parent_id = self._read_parent_id(current_trace_id) + context = self._read_context(current_trace_id) + if context is None: + break + current_parent_id, alias = context ancestors.append( TraceLogView( trace_id=current_trace_id, parent_id=current_parent_id, + alias=alias, child_ids=tuple(self._read_child_ids(current_trace_id)), records=tuple(self._read_records(current_trace_id, levels)), ) @@ -58,22 +117,15 @@ class MySqlTraceLogReader(TraceLogReader): ancestors.reverse() return ancestors - def _trace_exists(self, trace_id: str) -> bool: - query = "SELECT 1 FROM trace_contexts WHERE trace_id = %s" - with self._connection_factory.connect() as connection: - with connection.cursor() as cursor: - cursor.execute(query, (trace_id,)) - return cursor.fetchone() is not None - - def _read_parent_id(self, trace_id: str) -> str | None: - query = "SELECT parent_id FROM trace_contexts WHERE trace_id = %s" + def _read_context(self, trace_id: str) -> tuple[str | None, str] | None: + query = "SELECT parent_id, alias FROM trace_contexts WHERE trace_id = %s" with self._connection_factory.connect() as connection: with connection.cursor() as cursor: cursor.execute(query, (trace_id,)) row = cursor.fetchone() if row is None: return None - return self._string_or_none(row.get("parent_id")) + return self._string_or_none(row.get("parent_id")), self._string_or_none(row.get("alias")) or "" def _read_records(self, trace_id: str, levels: tuple[TraceLevel, ...]) -> list[TraceLogRecord]: placeholders = ", ".join(["%s"] * len(levels)) diff --git a/tests/test_trace_endpoint.py b/tests/test_trace_endpoint.py index 4e48509..79c0d0e 100644 --- a/tests/test_trace_endpoint.py +++ b/tests/test_trace_endpoint.py @@ -54,6 +54,7 @@ def test_trace_endpoint_returns_html_by_default() -> None: return TraceLogView( trace_id="trace-1", parent_id="root-trace", + alias="mailbox_sync", child_ids=("child-1", "child-2"), records=( _trace_record(row_id=1, level="ERROR", message="first error"), @@ -69,7 +70,7 @@ def test_trace_endpoint_returns_html_by_default() -> None: assert response.status_code == 200 assert response.headers["content-type"].startswith("text/html") - assert "trace_id:" in response.text + assert "trace context: mailbox_sync, " in response.text assert "first error" in response.text assert "second warning" in response.text assert captured == [ @@ -79,7 +80,8 @@ def test_trace_endpoint_returns_html_by_default() -> None: levels=("ERROR", "WARNING", "INFO"), include_attrs_json=False, response_format="html", - ancestor_depth=0, + ancestor_depth=None, + view="tree", ), ) ] @@ -90,6 +92,7 @@ def test_trace_endpoint_returns_text_when_requested() -> None: return TraceLogView( trace_id="trace-1", parent_id="root-trace", + alias="mailbox_sync", child_ids=("child-1", "child-2"), records=( _trace_record(row_id=1, level="ERROR", message="first error"), @@ -112,7 +115,7 @@ def test_trace_endpoint_returns_text_when_requested() -> None: " - child-2\n" "\n" "==============================\n" - "trace_id: trace-1\n" + "trace context: mailbox_sync, trace-1\n" "\n" "step: process\n" "first error\n" @@ -125,6 +128,7 @@ def test_trace_endpoint_appends_attrs_json_in_text_mode() -> None: return TraceLogView( trace_id="trace-1", parent_id=None, + alias="mailbox_sync", child_ids=(), records=( _trace_record(row_id=1, level="ERROR", message="failure", attrs_json={"attempt": 2, "source": "crm"}), @@ -144,7 +148,7 @@ def test_trace_endpoint_appends_attrs_json_in_text_mode() -> None: "child_ids:\n" "\n" "==============================\n" - "trace_id: trace-1\n" + "trace context: mailbox_sync, trace-1\n" "\n" "step: process\n" 'failure, {"attempt":2,"source":"crm"}' @@ -156,6 +160,7 @@ def test_trace_endpoint_separates_messages_by_step_in_text_mode() -> None: return TraceLogView( trace_id="trace-1", parent_id=None, + alias="mailbox_sync", child_ids=(), records=( _trace_record(row_id=1, level="INFO", message="load first", step="load_stocks"), @@ -177,7 +182,7 @@ def test_trace_endpoint_separates_messages_by_step_in_text_mode() -> None: "child_ids:\n" "\n" "==============================\n" - "trace_id: trace-1\n" + "trace context: mailbox_sync, trace-1\n" "\n" "step: load_stocks\n" "load first\n" @@ -201,7 +206,7 @@ def test_trace_endpoint_returns_json_payload() -> None: client = _build_client(trace_provider) try: - response = client.get("/traces/trace-1?format=json&attrs_json=true&levels=info") + response = client.get("/traces/trace-1?format=json&attrs_json=true&levels=info&view=single") finally: client.close() @@ -257,7 +262,7 @@ def test_trace_endpoint_returns_json_payload_with_ancestors() -> None: client = _build_client(trace_provider) try: - response = client.get("/traces/trace-1?format=json&ancestor_depth=1") + response = client.get("/traces/trace-1?format=json&ancestor_depth=1&view=single") finally: client.close() @@ -303,6 +308,7 @@ def test_trace_endpoint_returns_html_page_with_related_links() -> None: return TraceLogView( trace_id="trace-1", parent_id="parent-1", + alias="order_confirmation", child_ids=("child-1", "child-2"), records=( _trace_record(row_id=1, level="INFO", message="loaded prices", step="load_stocks", status="ok"), @@ -327,14 +333,14 @@ def test_trace_endpoint_returns_html_page_with_related_links() -> None: assert "--error: #ff817d;" in response.text assert "--other: #ececec;" in response.text assert 'font: 13px/1.1 "SFMono-Regular", monospace;' in response.text - assert '
trace_id: trace-1
' in response.text - assert '
parent_id: parent-1
' in response.text + assert '
trace_id: trace-1
' in response.text + assert '
parent_id: parent-1
' in response.text assert '
child_ids:
' in response.text - assert '
- child-1
' in response.text - assert '
- child-2
' in response.text + assert '
- child-1
' in response.text + assert '
- child-2
' in response.text assert '
==============================
' in response.text + assert '
trace context: order_confirmation, trace-1
' in response.text assert '
load_stocks
' in response.text - assert '
trace_id: trace-1
' in response.text assert '
filter_stocks
' in response.text assert "loaded prices" in response.text assert "filtered suspicious ticker" in response.text @@ -348,18 +354,21 @@ def test_trace_endpoint_renders_ancestors_in_text_mode() -> None: return TraceLogView( trace_id="trace-1", parent_id="parent-1", + alias="child", child_ids=(), records=(_trace_record(row_id=1, level="INFO", message="child message"),), ancestors=( TraceLogView( trace_id="root-1", parent_id=None, + alias="root", child_ids=("parent-1",), records=(_trace_record(row_id=2, level="INFO", message="root message"),), ), TraceLogView( trace_id="parent-1", parent_id="root-1", + alias="parent", child_ids=("trace-1",), records=(_trace_record(row_id=3, level="WARNING", message="parent message"),), ), @@ -368,7 +377,7 @@ def test_trace_endpoint_renders_ancestors_in_text_mode() -> None: client = _build_client(trace_provider) try: - response = client.get("/traces/trace-1?format=text&ancestor_depth=1") + response = client.get("/traces/trace-1?format=text&ancestor_depth=1&view=single") finally: client.close() @@ -379,21 +388,21 @@ def test_trace_endpoint_renders_ancestors_in_text_mode() -> None: "child_ids:\n" "\n" "==============================\n" - "trace_id: root-1\n" + "trace context: root, root-1\n" "\n" "step: process\n" "root message\n" "\n" "\n" "==============================\n" - "trace_id: parent-1\n" + "trace context: parent, parent-1\n" "\n" "step: process\n" "parent message\n" "\n" "\n" "==============================\n" - "trace_id: trace-1\n" + "trace context: child, trace-1\n" "\n" "step: process\n" "child message" @@ -405,18 +414,21 @@ def test_trace_endpoint_preserves_ancestor_depth_in_html_links() -> None: return TraceLogView( trace_id="trace-1", parent_id="parent-1", + alias="child", child_ids=("child-1",), records=(_trace_record(row_id=1, level="INFO", message="loaded prices"),), ancestors=( TraceLogView( trace_id="root-1", parent_id=None, + alias="root", child_ids=("parent-1",), records=(_trace_record(row_id=2, level="INFO", message="root info"),), ), TraceLogView( trace_id="parent-1", parent_id="root-1", + alias="parent", child_ids=("trace-1",), records=(_trace_record(row_id=3, level="WARNING", message="parent warning"),), ), @@ -425,19 +437,102 @@ def test_trace_endpoint_preserves_ancestor_depth_in_html_links() -> None: client = _build_client(trace_provider) try: - response = client.get("/traces/trace-1?format=html&attrs_json=true&ancestor_depth=all") + response = client.get("/traces/trace-1?format=html&attrs_json=true&ancestor_depth=all&view=single") finally: client.close() assert response.status_code == 200 - assert 'href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&ancestor_depth=all"' in response.text - assert 'href="/traces/root-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&ancestor_depth=all"' in response.text - assert 'href="/traces/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&ancestor_depth=all"' in response.text + assert 'href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=single&ancestor_depth=all"' in response.text + assert 'href="/traces/root-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=single&ancestor_depth=all"' in response.text + assert 'href="/traces/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=single&ancestor_depth=all"' in response.text assert response.text.index("root info") < response.text.index("parent warning") < response.text.index("loaded prices") assert "root info" in response.text assert "parent warning" in response.text +def test_trace_endpoint_returns_full_tree_by_default() -> None: + async def trace_provider(trace_id: str, request: TraceQueryRequest) -> TraceLogView: + assert trace_id == "child-1" + assert request.view == "tree" + assert request.ancestor_depth is None + return TraceLogView( + trace_id="root-1", + parent_id=None, + alias="root", + child_ids=("parent-1",), + records=(_trace_record(row_id=1, level="INFO", message="root message"),), + descendants=( + TraceLogView( + trace_id="parent-1", + parent_id="root-1", + alias="parent", + child_ids=("child-1", "sibling-1"), + records=(_trace_record(row_id=2, level="WARNING", message="parent message"),), + descendants=( + TraceLogView( + trace_id="child-1", + parent_id="parent-1", + alias="child", + child_ids=(), + records=(_trace_record(row_id=3, level="ERROR", message="child message"),), + ), + TraceLogView( + trace_id="sibling-1", + parent_id="parent-1", + alias="sibling", + child_ids=(), + records=(_trace_record(row_id=4, level="INFO", message="sibling message"),), + ), + ), + ), + ), + requested_trace_id="child-1", + ) + + client = _build_client(trace_provider) + try: + response = client.get("/traces/child-1?format=text") + finally: + client.close() + + assert response.status_code == 200 + assert "requested_trace_id: child-1" in response.text + assert response.text.index("root message") < response.text.index("parent message") < response.text.index("child message") < response.text.index("sibling message") + + +def test_trace_endpoint_supports_single_view_opt_in() -> None: + async def trace_provider(_trace_id: str, request: TraceQueryRequest) -> TraceLogView: + assert request.view == "single" + assert request.ancestor_depth == 0 + return TraceLogView( + trace_id="trace-1", + parent_id="root-trace", + alias="single_trace", + child_ids=("child-1",), + records=(_trace_record(row_id=1, level="ERROR", message="only current trace"),), + ) + + client = _build_client(trace_provider) + try: + response = client.get("/traces/trace-1?format=text&view=single") + finally: + client.close() + + assert response.status_code == 200 + assert response.text == ( + "trace_id: trace-1\n" + "parent_id: root-trace\n" + "child_ids:\n" + " - child-1\n" + "\n" + "==============================\n" + "trace context: single_trace, trace-1\n" + "\n" + "step: process\n" + "only current trace" + ) + + def test_trace_endpoint_validates_query_params() -> None: client = _build_client(lambda _trace_id, _request: None) try: @@ -445,6 +540,7 @@ def test_trace_endpoint_validates_query_params() -> None: invalid_format = client.get("/traces/trace-1?format=xml") invalid_ancestor_depth = client.get("/traces/trace-1?ancestor_depth=-1") invalid_ancestor_type = client.get("/traces/trace-1?ancestor_depth=up") + invalid_view = client.get("/traces/trace-1?view=graph") finally: client.close() @@ -462,12 +558,18 @@ def test_trace_endpoint_validates_query_params() -> None: "status": "error", "detail": "invalid ancestor depth query parameter: ancestor_depth=up", } + assert invalid_view.status_code == 400 + assert invalid_view.json() == { + "status": "error", + "detail": "unsupported trace view: graph", + } def test_runtime_trace_logs_uses_configured_reader(monkeypatch) -> None: expected = TraceLogView( trace_id="trace-1", parent_id="root", + alias="runtime", child_ids=("child-1",), records=(_trace_record(row_id=1, level="ERROR", message="boom"),), ) @@ -478,16 +580,19 @@ def test_runtime_trace_logs_uses_configured_reader(monkeypatch) -> None: trace_id: str, levels: tuple[str, ...], ancestor_depth: int | None = 0, + *, + view: str = "tree", ) -> TraceLogView | None: assert trace_id == "trace-1" assert levels == ("ERROR",) assert ancestor_depth is None + assert view == "tree" return expected monkeypatch.setattr(runtime_module, "build_trace_log_reader", lambda _transport: StubReader()) runtime = RuntimeManager() - result = asyncio.run(runtime.trace_logs("trace-1", TraceQueryRequest(levels=("ERROR",), ancestor_depth=None))) + result = asyncio.run(runtime.trace_logs("trace-1", TraceQueryRequest(levels=("ERROR",), ancestor_depth=None, view="tree"))) assert result == expected @@ -504,9 +609,9 @@ def test_mysql_trace_log_reader_maps_db_rows() -> None: def fetchone(self) -> dict[str, object] | None: if self.executed[-1][1] == ("trace-1",): - return {"parent_id": "root-77"} + return {"parent_id": "root-77", "alias": "single"} if self.executed[-1][1] == ("root-77",): - return {"parent_id": None} + return {"parent_id": None, "alias": "root"} return None def fetchall(self) -> list[dict[str, object]]: @@ -554,11 +659,12 @@ def test_mysql_trace_log_reader_maps_db_rows() -> None: factory = FakeConnectionFactory() reader = MySqlTraceLogReader(factory) # type: ignore[arg-type] - view = reader.read_trace("trace-1", ("ERROR", "WARNING")) + view = reader.read_trace("trace-1", ("ERROR", "WARNING"), view="single") assert view == TraceLogView( trace_id="trace-1", parent_id="root-77", + alias="single", child_ids=("child-1", "child-2"), records=( TraceLogRecord( @@ -591,11 +697,11 @@ def test_mysql_trace_log_reader_loads_requested_ancestors() -> None: def fetchone(self) -> dict[str, object] | None: if self.executed[-1][1] == ("trace-1",): - return {"parent_id": "parent-1"} + return {"parent_id": "parent-1", "alias": "child"} if self.executed[-1][1] == ("parent-1",): - return {"parent_id": "root-1"} + return {"parent_id": "root-1", "alias": "parent"} if self.executed[-1][1] == ("root-1",): - return {"parent_id": None} + return {"parent_id": None, "alias": "root"} return None def fetchall(self) -> list[dict[str, object]]: @@ -651,17 +757,101 @@ def test_mysql_trace_log_reader_loads_requested_ancestors() -> None: factory = FakeConnectionFactory() reader = MySqlTraceLogReader(factory) # type: ignore[arg-type] - view = reader.read_trace("trace-1", ("ERROR",), 1) + view = reader.read_trace("trace-1", ("ERROR",), 1, view="single") assert view is not None assert view.trace_id == "trace-1" assert view.parent_id == "parent-1" + assert view.alias == "child" assert len(view.ancestors) == 1 assert view.ancestors[0].trace_id == "parent-1" assert view.ancestors[0].parent_id == "root-1" + assert view.ancestors[0].alias == "parent" assert view.ancestors[0].child_ids == ("trace-1",) +def test_mysql_trace_log_reader_tree_view_returns_root_subtree() -> None: + class FakeCursor: + def __init__(self) -> None: + self.executed: list[tuple[str, tuple[object, ...]]] = [] + self._current_query = "" + + def execute(self, query: str, params: tuple[object, ...]) -> None: + self.executed.append((query, params)) + self._current_query = query + + def fetchone(self) -> dict[str, object] | None: + trace_id = self.executed[-1][1][0] + mapping = { + "child-1": {"parent_id": "parent-1", "alias": "child"}, + "parent-1": {"parent_id": "root-1", "alias": "parent"}, + "root-1": {"parent_id": None, "alias": "root"}, + "sibling-1": {"parent_id": "parent-1", "alias": "sibling"}, + } + return mapping.get(trace_id) + + def fetchall(self) -> list[dict[str, object]]: + if "WHERE parent_id = %s" in self._current_query: + parent_id = self.executed[-1][1][0] + if parent_id == "root-1": + return [{"trace_id": "parent-1"}] + if parent_id == "parent-1": + return [{"trace_id": "child-1"}, {"trace_id": "sibling-1"}] + return [] + trace_id = self.executed[-1][1][0] + return [ + { + "id": 8, + "trace_id": trace_id, + "event_time": datetime(2026, 4, 28, 10, 11, 12, tzinfo=timezone.utc), + "step": "parse", + "status": "failed", + "level": "ERROR", + "message": f"broken:{trace_id}", + "attrs_json": '{"attempt":1}', + } + ] + + def __enter__(self) -> FakeCursor: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + class FakeConnection: + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def cursor(self) -> FakeCursor: + return self._cursor + + def __enter__(self) -> FakeConnection: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + class FakeConnectionFactory: + def __init__(self) -> None: + self.cursor = FakeCursor() + + def connect(self) -> FakeConnection: + return FakeConnection(self.cursor) + + factory = FakeConnectionFactory() + reader = MySqlTraceLogReader(factory) # type: ignore[arg-type] + + view = reader.read_trace("child-1", ("ERROR",), None, view="tree") + + assert view is not None + assert view.trace_id == "root-1" + assert view.alias == "root" + assert view.requested_trace_id == "child-1" + assert tuple(child.trace_id for child in view.descendants) == ("parent-1",) + assert view.descendants[0].alias == "parent" + assert tuple(child.trace_id for child in view.descendants[0].descendants) == ("child-1", "sibling-1") + + def test_mysql_trace_log_reader_orders_ancestors_root_first() -> None: class FakeCursor: def __init__(self) -> None: @@ -674,11 +864,11 @@ def test_mysql_trace_log_reader_orders_ancestors_root_first() -> None: def fetchone(self) -> dict[str, object] | None: if self.executed[-1][1] == ("trace-1",): - return {"parent_id": "parent-1"} + return {"parent_id": "parent-1", "alias": "child"} if self.executed[-1][1] == ("parent-1",): - return {"parent_id": "root-1"} + return {"parent_id": "root-1", "alias": "parent"} if self.executed[-1][1] == ("root-1",): - return {"parent_id": None} + return {"parent_id": None, "alias": "root"} return None def fetchall(self) -> list[dict[str, object]]: @@ -732,7 +922,8 @@ def test_mysql_trace_log_reader_orders_ancestors_root_first() -> None: factory = FakeConnectionFactory() reader = MySqlTraceLogReader(factory) # type: ignore[arg-type] - view = reader.read_trace("trace-1", ("ERROR",), 2) + view = reader.read_trace("trace-1", ("ERROR",), 2, view="single") assert view is not None assert tuple(ancestor.trace_id for ancestor in view.ancestors) == ("root-1", "parent-1") + assert tuple(ancestor.alias for ancestor in view.ancestors) == ("root", "parent")