From aed12c9c4e9d530c24eae842ad171536a82972d8 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Mon, 4 May 2026 11:12:19 +0300 Subject: [PATCH] =?UTF-8?q?INF-3=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=87=D0=B5=D1=80=D0=BD=D0=B8=D1=85=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D1=85=20=D1=82=D1=80=D0=B5=D0=B9=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app_runtime/contracts/trace.py | 8 +- src/app_runtime/control/base.py | 1 + src/app_runtime/control/trace_presenter.py | 96 +++++--- src/app_runtime/core/runtime.py | 2 +- src/app_runtime/tracing/reader.py | 35 ++- tests/test_trace_endpoint.py | 246 ++++++++++++++++++++- 6 files changed, 356 insertions(+), 32 deletions(-) diff --git a/src/app_runtime/contracts/trace.py b/src/app_runtime/contracts/trace.py index 3c7a64b..e877af3 100644 --- a/src/app_runtime/contracts/trace.py +++ b/src/app_runtime/contracts/trace.py @@ -92,8 +92,14 @@ class TraceLogView: parent_id: str | None child_ids: tuple[str, ...] = () records: tuple[TraceLogRecord, ...] = () + ancestors: tuple[TraceLogView, ...] = () class TraceLogReader(Protocol): - def read_trace(self, trace_id: str, levels: tuple[TraceLevel, ...]) -> TraceLogView | None: + def read_trace( + self, + trace_id: str, + levels: tuple[TraceLevel, ...], + ancestor_depth: int | None = 0, + ) -> 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 70eafd7..3776a2e 100644 --- a/src/app_runtime/control/base.py +++ b/src/app_runtime/control/base.py @@ -27,6 +27,7 @@ class TraceQueryRequest: levels: tuple[TraceLevel, ...] = ("ERROR", "WARNING", "INFO") include_attrs_json: bool = False response_format: TraceResponseFormat = "html" + ancestor_depth: int | None = 0 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 d7e47d5..239a9c2 100644 --- a/src/app_runtime/control/trace_presenter.py +++ b/src/app_runtime/control/trace_presenter.py @@ -22,6 +22,7 @@ class TraceRequestParser: 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), ) def _trace_levels(self, raw_levels: str | None) -> tuple[TraceLevel, ...]: @@ -47,6 +48,21 @@ class TraceRequestParser: return False raise ValueError(f"invalid boolean query parameter: {name}={raw_value}") + def _ancestor_depth(self, request: Request) -> int | None: + raw_value = request.query_params.get("ancestor_depth") + if raw_value is None: + return 0 + normalized = raw_value.strip().lower() + if normalized == "all": + return None + try: + value = int(normalized) + except ValueError as exc: + raise ValueError(f"invalid ancestor depth query parameter: ancestor_depth={raw_value}") from exc + if value < 0: + raise ValueError(f"query parameter must be >= 0: ancestor_depth={raw_value}") + return value + class TraceResponseRenderer: def render(self, trace_view: TraceLogView, request: TraceQueryRequest) -> Response: @@ -63,26 +79,14 @@ class TraceResponseRenderer: "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], } ) 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)) + lines = self._text_trace_lines(trace_view, request) + for index, ancestor in enumerate(trace_view.ancestors, start=1): + lines.extend(["", f"ancestor[{index}]:", *self._text_trace_lines(ancestor, request)]) return PlainTextResponse(content="\n".join(lines)) def _render_html(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse: @@ -162,16 +166,58 @@ class TraceResponseRenderer: return f"{record.message}, {json.dumps(record.attrs_json, ensure_ascii=False, separators=(',', ':'))}" 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}" + params = { + "format": "html", + "levels": ",".join(request.levels), + "attrs_json": "true" if request.include_attrs_json else "false", + } + if request.ancestor_depth is None: + params["ancestor_depth"] = "all" + elif request.ancestor_depth > 0: + params["ancestor_depth"] = str(request.ancestor_depth) + query = urlencode(params) + return f"/traces/{trace_id}?{query}" def _html_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str: + lines = self._html_trace_lines(trace_view, request) + for index, ancestor in enumerate(trace_view.ancestors, start=1): + lines.extend( + [ + self._html_plain_line(""), + self._html_plain_line(f"ancestor[{index}]:"), + *self._html_trace_lines(ancestor, request), + ] + ) + return "".join(lines) + + def _trace_payload(self, trace_view: TraceLogView, request: TraceQueryRequest) -> dict[str, object]: + return { + "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 _text_trace_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: + 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 lines + + def _html_trace_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]: lines = [ 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)}"), @@ -192,7 +238,7 @@ class TraceResponseRenderer: lines.append(self._html_plain_line("")) previous_step = current_step lines.extend(self._html_message_lines(record, request.include_attrs_json)) - return "".join(lines) + 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)] diff --git a/src/app_runtime/core/runtime.py b/src/app_runtime/core/runtime.py index c9b002e..71f1549 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) + trace_view = reader.read_trace(trace_id, request.levels, request.ancestor_depth) 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 ea8f261..2f90fea 100644 --- a/src/app_runtime/tracing/reader.py +++ b/src/app_runtime/tracing/reader.py @@ -11,10 +11,16 @@ class MySqlTraceLogReader(TraceLogReader): def __init__(self, connection_factory: MySqlTraceConnectionFactory) -> None: self._connection_factory = connection_factory - def read_trace(self, trace_id: str, levels: tuple[TraceLevel, ...]) -> TraceLogView | None: + def read_trace( + self, + trace_id: str, + levels: tuple[TraceLevel, ...], + ancestor_depth: int | None = 0, + ) -> TraceLogView | None: parent_id = self._read_parent_id(trace_id) if parent_id is None and not self._trace_exists(trace_id): return None + 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( @@ -22,8 +28,35 @@ class MySqlTraceLogReader(TraceLogReader): parent_id=parent_id, child_ids=tuple(child_ids), records=tuple(records), + ancestors=tuple(ancestors), ) + def _read_ancestors( + self, + parent_id: str | None, + levels: tuple[TraceLevel, ...], + ancestor_depth: int | None, + ) -> list[TraceLogView]: + if parent_id is None or ancestor_depth == 0: + return [] + remaining_depth = ancestor_depth + 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) + ancestors.append( + TraceLogView( + trace_id=current_trace_id, + parent_id=current_parent_id, + child_ids=tuple(self._read_child_ids(current_trace_id)), + records=tuple(self._read_records(current_trace_id, levels)), + ) + ) + current_trace_id = current_parent_id + if remaining_depth is not None: + remaining_depth -= 1 + 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: diff --git a/tests/test_trace_endpoint.py b/tests/test_trace_endpoint.py index 66560a6..c0cf531 100644 --- a/tests/test_trace_endpoint.py +++ b/tests/test_trace_endpoint.py @@ -72,7 +72,17 @@ def test_trace_endpoint_returns_html_by_default() -> None: assert "trace_id:" in response.text assert "first error" in response.text assert "second warning" in response.text - assert captured == [("trace-1", TraceQueryRequest(levels=("ERROR", "WARNING", "INFO"), include_attrs_json=False, response_format="html"))] + assert captured == [ + ( + "trace-1", + TraceQueryRequest( + levels=("ERROR", "WARNING", "INFO"), + include_attrs_json=False, + response_format="html", + ancestor_depth=0, + ), + ) + ] def test_trace_endpoint_returns_text_when_requested() -> None: @@ -203,9 +213,58 @@ def test_trace_endpoint_returns_json_payload() -> None: "attrs_json": {"batch": 7}, } ], + "ancestors": [], } +def test_trace_endpoint_returns_json_payload_with_ancestors() -> 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",), + records=( + _trace_record(row_id=3, level="INFO", message="done", attrs_json={"batch": 7}), + ), + ancestors=( + TraceLogView( + trace_id="parent-1", + parent_id="root-1", + child_ids=("trace-1", "sibling-1"), + records=( + _trace_record(row_id=4, level="WARNING", message="parent warning"), + ), + ), + ), + ) + + client = _build_client(trace_provider) + try: + response = client.get("/traces/trace-1?format=json&ancestor_depth=1") + finally: + client.close() + + assert response.status_code == 200 + assert response.json()["ancestors"] == [ + { + "trace_id": "parent-1", + "parent_id": "root-1", + "child_ids": ["trace-1", "sibling-1"], + "messages": [ + { + "id": 4, + "trace_id": "trace-1", + "event_time": "2026-04-28T10:11:12+00:00", + "step": "process", + "status": "failed", + "level": "WARNING", + "message": "parent warning", + } + ], + } + ] + + def test_trace_endpoint_returns_html_page_with_related_links() -> None: async def trace_provider(_trace_id: str, _request: TraceQueryRequest) -> TraceLogView: return TraceLogView( @@ -250,11 +309,86 @@ def test_trace_endpoint_returns_html_page_with_related_links() -> None: assert "Related Traces" not in response.text +def test_trace_endpoint_renders_ancestors_in_text_mode() -> None: + async def trace_provider(_trace_id: str, _request: TraceQueryRequest) -> TraceLogView: + return TraceLogView( + trace_id="trace-1", + parent_id="parent-1", + child_ids=(), + records=(_trace_record(row_id=1, level="INFO", message="child message"),), + ancestors=( + TraceLogView( + trace_id="parent-1", + parent_id="root-1", + child_ids=("trace-1",), + records=(_trace_record(row_id=2, level="WARNING", message="parent message"),), + ), + ), + ) + + client = _build_client(trace_provider) + try: + response = client.get("/traces/trace-1?format=text&ancestor_depth=1") + finally: + client.close() + + assert response.status_code == 200 + assert response.text == ( + "trace_id: trace-1\n" + "parent_id: parent-1\n" + "child_ids:\n" + "------------------------------\n" + "step: process\n" + "child message\n" + "\n" + "ancestor[1]:\n" + "trace_id: parent-1\n" + "parent_id: root-1\n" + "child_ids:\n" + " - trace-1\n" + "------------------------------\n" + "step: process\n" + "parent message" + ) + + +def test_trace_endpoint_preserves_ancestor_depth_in_html_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",), + records=(_trace_record(row_id=1, level="INFO", message="loaded prices"),), + ancestors=( + TraceLogView( + trace_id="parent-1", + parent_id="root-1", + child_ids=("trace-1",), + records=(_trace_record(row_id=2, level="WARNING", message="parent warning"),), + ), + ), + ) + + client = _build_client(trace_provider) + try: + response = client.get("/traces/trace-1?format=html&attrs_json=true&ancestor_depth=all") + 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/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&ancestor_depth=all"' in response.text + assert '
ancestor[1]:
' in response.text + assert "parent warning" in response.text + + def test_trace_endpoint_validates_query_params() -> None: client = _build_client(lambda _trace_id, _request: None) try: invalid_level = client.get("/traces/trace-1?levels=error,fatal") 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") finally: client.close() @@ -262,6 +396,16 @@ def test_trace_endpoint_validates_query_params() -> None: assert invalid_level.json() == {"status": "error", "detail": "unsupported trace levels: FATAL"} assert invalid_format.status_code == 400 assert invalid_format.json() == {"status": "error", "detail": "unsupported trace format: xml"} + assert invalid_ancestor_depth.status_code == 400 + assert invalid_ancestor_depth.json() == { + "status": "error", + "detail": "query parameter must be >= 0: ancestor_depth=-1", + } + assert invalid_ancestor_type.status_code == 400 + assert invalid_ancestor_type.json() == { + "status": "error", + "detail": "invalid ancestor depth query parameter: ancestor_depth=up", + } def test_runtime_trace_logs_uses_configured_reader(monkeypatch) -> None: @@ -273,15 +417,21 @@ def test_runtime_trace_logs_uses_configured_reader(monkeypatch) -> None: ) class StubReader: - def read_trace(self, trace_id: str, levels: tuple[str, ...]) -> TraceLogView | None: + def read_trace( + self, + trace_id: str, + levels: tuple[str, ...], + ancestor_depth: int | None = 0, + ) -> TraceLogView | None: assert trace_id == "trace-1" assert levels == ("ERROR",) + assert ancestor_depth is None 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",)))) + result = asyncio.run(runtime.trace_logs("trace-1", TraceQueryRequest(levels=("ERROR",), ancestor_depth=None))) assert result == expected @@ -297,7 +447,11 @@ def test_mysql_trace_log_reader_maps_db_rows() -> None: self._current_query = query def fetchone(self) -> dict[str, object] | None: - return {"parent_id": "root-77"} + if self.executed[-1][1] == ("trace-1",): + return {"parent_id": "root-77"} + if self.executed[-1][1] == ("root-77",): + return {"parent_id": None} + return None def fetchall(self) -> list[dict[str, object]]: if "WHERE parent_id = %s" in self._current_query: @@ -362,7 +516,91 @@ def test_mysql_trace_log_reader_maps_db_rows() -> None: attrs_json={"attempt": 1}, ), ), + ancestors=(), ) assert len(factory.cursor.executed) == 3 assert factory.cursor.executed[1][1] == ("trace-1",) assert factory.cursor.executed[2][1] == ("trace-1", "ERROR", "WARNING") + + +def test_mysql_trace_log_reader_loads_requested_ancestors() -> 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: + if self.executed[-1][1] == ("trace-1",): + return {"parent_id": "parent-1"} + if self.executed[-1][1] == ("parent-1",): + return {"parent_id": "root-1"} + if self.executed[-1][1] == ("root-1",): + return {"parent_id": None} + return None + + 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 == "trace-1": + return [] + if parent_id == "parent-1": + return [{"trace_id": "trace-1"}] + if parent_id == "root-1": + return [{"trace_id": "parent-1"}] + return [] + trace_id = self.executed[-1][1][0] + return [ + { + "id": 8 if trace_id == "trace-1" else 9, + "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("trace-1", ("ERROR",), 1) + + assert view is not None + assert view.trace_id == "trace-1" + assert view.parent_id == "parent-1" + 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].child_ids == ("trace-1",)