INF-3 Добавить параметр для вывода дочерних и родительских трейсов

This commit is contained in:
2026-05-04 11:12:19 +03:00
parent df50e7acbb
commit aed12c9c4e
6 changed files with 356 additions and 32 deletions
+7 -1
View File
@@ -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."""
+1
View File
@@ -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]]
+71 -25
View File
@@ -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)]
+1 -1
View File
@@ -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
+34 -1
View File
@@ -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:
+242 -4
View File
@@ -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&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true&amp;ancestor_depth=all"' in response.text
assert 'href="/traces/parent-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true&amp;ancestor_depth=all"' in response.text
assert '<div class="line">ancestor[1]:</div>' 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",)