Добавил просмотр trace логов полностью
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
|
Быстрая установка через SSH-доступ к Git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install "plba @ git+ssh://git@git.lesha.spb.ru/alex/plba.git@main"
|
||||||
|
```
|
||||||
|
|
||||||
Установка пакета напрямую из Git-репозитория через `pip`:
|
Установка пакета напрямую из Git-репозитория через `pip`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "plba"
|
name = "plba"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "Platform runtime for business applications"
|
description = "Platform runtime for business applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -90,9 +90,12 @@ class TraceLogRecord:
|
|||||||
class TraceLogView:
|
class TraceLogView:
|
||||||
trace_id: str
|
trace_id: str
|
||||||
parent_id: str | None
|
parent_id: str | None
|
||||||
|
alias: str = ""
|
||||||
child_ids: tuple[str, ...] = ()
|
child_ids: tuple[str, ...] = ()
|
||||||
records: tuple[TraceLogRecord, ...] = ()
|
records: tuple[TraceLogRecord, ...] = ()
|
||||||
ancestors: tuple[TraceLogView, ...] = ()
|
ancestors: tuple[TraceLogView, ...] = ()
|
||||||
|
descendants: tuple[TraceLogView, ...] = ()
|
||||||
|
requested_trace_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TraceLogReader(Protocol):
|
class TraceLogReader(Protocol):
|
||||||
@@ -101,5 +104,7 @@ class TraceLogReader(Protocol):
|
|||||||
trace_id: str,
|
trace_id: str,
|
||||||
levels: tuple[TraceLevel, ...],
|
levels: tuple[TraceLevel, ...],
|
||||||
ancestor_depth: int | None = 0,
|
ancestor_depth: int | None = 0,
|
||||||
|
*,
|
||||||
|
view: Literal["tree", "single"] = "tree",
|
||||||
) -> TraceLogView | None:
|
) -> TraceLogView | None:
|
||||||
"""Load trace context and filtered log records."""
|
"""Load trace context and filtered log records."""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ ActionResult = str | dict[str, object]
|
|||||||
ActionHandler = Callable[[ControlActionRequest], Awaitable[ActionResult]]
|
ActionHandler = Callable[[ControlActionRequest], Awaitable[ActionResult]]
|
||||||
HealthHandler = Callable[[], Awaitable[HealthPayload]]
|
HealthHandler = Callable[[], Awaitable[HealthPayload]]
|
||||||
TraceResponseFormat = Literal["json", "text", "html"]
|
TraceResponseFormat = Literal["json", "text", "html"]
|
||||||
|
TraceViewMode = Literal["tree", "single"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -27,7 +28,8 @@ class TraceQueryRequest:
|
|||||||
levels: tuple[TraceLevel, ...] = ("ERROR", "WARNING", "INFO")
|
levels: tuple[TraceLevel, ...] = ("ERROR", "WARNING", "INFO")
|
||||||
include_attrs_json: bool = False
|
include_attrs_json: bool = False
|
||||||
response_format: TraceResponseFormat = "html"
|
response_format: TraceResponseFormat = "html"
|
||||||
ancestor_depth: int | None = 0
|
ancestor_depth: int | None = None
|
||||||
|
view: TraceViewMode = "tree"
|
||||||
|
|
||||||
|
|
||||||
TraceLookupHandler = Callable[[str, TraceQueryRequest], Awaitable[TraceLogView]]
|
TraceLookupHandler = Callable[[str, TraceQueryRequest], Awaitable[TraceLogView]]
|
||||||
|
|||||||
@@ -21,11 +21,16 @@ class TraceRequestParser:
|
|||||||
response_format = raw_format.strip().lower()
|
response_format = raw_format.strip().lower()
|
||||||
if response_format not in {"json", "text", "html"}:
|
if response_format not in {"json", "text", "html"}:
|
||||||
raise ValueError(f"unsupported trace format: {raw_format}")
|
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(
|
return TraceQueryRequest(
|
||||||
levels=self._trace_levels(raw_levels),
|
levels=self._trace_levels(raw_levels),
|
||||||
include_attrs_json=self._bool_param(request, "attrs_json") or False,
|
include_attrs_json=self._bool_param(request, "attrs_json") or False,
|
||||||
response_format=response_format,
|
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, ...]:
|
def _trace_levels(self, raw_levels: str | None) -> tuple[TraceLevel, ...]:
|
||||||
@@ -51,10 +56,10 @@ class TraceRequestParser:
|
|||||||
return False
|
return False
|
||||||
raise ValueError(f"invalid boolean query parameter: {name}={raw_value}")
|
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")
|
raw_value = request.query_params.get("ancestor_depth")
|
||||||
if raw_value is None:
|
if raw_value is None:
|
||||||
return 0
|
return None if default_all else 0
|
||||||
normalized = raw_value.strip().lower()
|
normalized = raw_value.strip().lower()
|
||||||
if normalized == "all":
|
if normalized == "all":
|
||||||
return None
|
return None
|
||||||
@@ -69,26 +74,35 @@ class TraceRequestParser:
|
|||||||
|
|
||||||
class TraceResponseRenderer:
|
class TraceResponseRenderer:
|
||||||
def render(self, trace_view: TraceLogView, request: TraceQueryRequest) -> Response:
|
def render(self, trace_view: TraceLogView, request: TraceQueryRequest) -> Response:
|
||||||
|
if request.view == "single":
|
||||||
if request.response_format == "json":
|
if request.response_format == "json":
|
||||||
return self._render_json(trace_view, request)
|
return self._render_json_single(trace_view, request)
|
||||||
if request.response_format == "html":
|
if request.response_format == "html":
|
||||||
return self._render_html(trace_view, request)
|
return self._render_html_single(trace_view, request)
|
||||||
return self._render_text(trace_view, request)
|
return self._render_text_single(trace_view, request)
|
||||||
|
if request.response_format == "json":
|
||||||
|
return self._render_json_tree(trace_view, request)
|
||||||
|
if request.response_format == "html":
|
||||||
|
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(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"trace_id": trace_view.trace_id,
|
"trace_id": trace_view.trace_id,
|
||||||
"parent_id": trace_view.parent_id or "",
|
"parent_id": trace_view.parent_id or "",
|
||||||
"child_ids": list(trace_view.child_ids),
|
"child_ids": list(trace_view.child_ids),
|
||||||
"messages": [record.as_dict(include_attrs_json=request.include_attrs_json) for record in trace_view.records],
|
"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]
|
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):
|
for index, entry in enumerate(lineage):
|
||||||
if index == 0:
|
if index == 0:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -97,10 +111,29 @@ class TraceResponseRenderer:
|
|||||||
lines.extend(self._text_trace_log_lines(entry, request))
|
lines.extend(self._text_trace_log_lines(entry, request))
|
||||||
return PlainTextResponse(content="\n".join(lines))
|
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}")
|
title = escape(f"Trace {trace_view.trace_id}")
|
||||||
lines = self._html_lines(trace_view, request)
|
lines = self._html_lines_single(trace_view, request)
|
||||||
html = f"""<!DOCTYPE html>
|
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'''<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@@ -160,8 +193,7 @@ class TraceResponseRenderer:
|
|||||||
{lines}
|
{lines}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>'''
|
||||||
return HTMLResponse(content=html)
|
|
||||||
|
|
||||||
def _child_id_lines(self, child_ids: tuple[str, ...]) -> list[str]:
|
def _child_id_lines(self, child_ids: tuple[str, ...]) -> list[str]:
|
||||||
lines = ["child_ids:"]
|
lines = ["child_ids:"]
|
||||||
@@ -178,6 +210,7 @@ class TraceResponseRenderer:
|
|||||||
"format": "html",
|
"format": "html",
|
||||||
"levels": ",".join(request.levels),
|
"levels": ",".join(request.levels),
|
||||||
"attrs_json": "true" if request.include_attrs_json else "false",
|
"attrs_json": "true" if request.include_attrs_json else "false",
|
||||||
|
"view": request.view,
|
||||||
}
|
}
|
||||||
if request.ancestor_depth is None:
|
if request.ancestor_depth is None:
|
||||||
params["ancestor_depth"] = "all"
|
params["ancestor_depth"] = "all"
|
||||||
@@ -186,9 +219,9 @@ class TraceResponseRenderer:
|
|||||||
query = urlencode(params)
|
query = urlencode(params)
|
||||||
return f"/traces/{trace_id}?{query}"
|
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]
|
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):
|
for index, entry in enumerate(lineage):
|
||||||
if index == 0:
|
if index == 0:
|
||||||
lines.append(self._html_plain_line(""))
|
lines.append(self._html_plain_line(""))
|
||||||
@@ -197,7 +230,17 @@ class TraceResponseRenderer:
|
|||||||
lines.extend(self._html_trace_log_lines(entry, request))
|
lines.extend(self._html_trace_log_lines(entry, request))
|
||||||
return "".join(lines)
|
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 {
|
return {
|
||||||
"trace_id": trace_view.trace_id,
|
"trace_id": trace_view.trace_id,
|
||||||
"parent_id": trace_view.parent_id or "",
|
"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],
|
"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 [
|
return [
|
||||||
f"trace_id: {trace_view.trace_id}",
|
f"trace_id: {trace_view.trace_id}",
|
||||||
f"parent_id: {trace_view.parent_id or ''}",
|
f"parent_id: {trace_view.parent_id or ''}",
|
||||||
*self._child_id_lines(trace_view.child_ids),
|
*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]:
|
def _text_trace_log_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]:
|
||||||
lines = [
|
lines = [
|
||||||
TRACE_SECTION_SEPARATOR,
|
TRACE_SECTION_SEPARATOR,
|
||||||
f"trace_id: {trace_view.trace_id}",
|
self._trace_context_label(trace_view),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
previous_step: str | None = None
|
previous_step: str | None = None
|
||||||
@@ -230,7 +294,7 @@ class TraceResponseRenderer:
|
|||||||
lines.append(self._text_message(record, request.include_attrs_json))
|
lines.append(self._text_message(record, request.include_attrs_json))
|
||||||
return lines
|
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 [
|
return [
|
||||||
self._html_plain_line(f"trace_id: {self._trace_link(trace_view.trace_id, request)}"),
|
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)}"),
|
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),
|
*(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]:
|
def _html_trace_log_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> list[str]:
|
||||||
lines = [
|
lines = [
|
||||||
self._html_plain_line(TRACE_SECTION_SEPARATOR),
|
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(""),
|
self._html_plain_line(""),
|
||||||
]
|
]
|
||||||
previous_step: str | None = None
|
previous_step: str | None = None
|
||||||
@@ -260,29 +338,36 @@ class TraceResponseRenderer:
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
def _html_message_lines(self, record: TraceLogRecord, include_attrs_json: bool) -> list[str]:
|
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 [self._html_colored_line(self._text_message(record, include_attrs_json), record.level)]
|
||||||
return lines
|
|
||||||
|
|
||||||
def _html_plain_line(self, content: str) -> str:
|
def _html_plain_line(self, content: str) -> str:
|
||||||
return f"<div class=\"line\">{content or ' '}</div>"
|
return f'<div class="line">{content or " "}</div>'
|
||||||
|
|
||||||
def _html_step_line(self, content: str) -> str:
|
def _html_step_line(self, content: str) -> str:
|
||||||
return f"<div class=\"line\" style=\"color: var(--step);\">{escape(content) or ' '}</div>"
|
return f'<div class="line" style="color: var(--step);">{escape(content) or " "}</div>'
|
||||||
|
|
||||||
def _html_colored_line(self, content: str, level: str) -> str:
|
def _html_colored_line(self, content: str, level: str) -> str:
|
||||||
level_class = self._level_class(level)
|
level_class = self._level_class(level)
|
||||||
return f"<div class=\"line {level_class}\">{escape(content)}</div>"
|
return f'<div class="line {level_class}">{escape(content)}</div>'
|
||||||
|
|
||||||
def _trace_link(self, trace_id: str, request: TraceQueryRequest) -> str:
|
def _trace_link(self, trace_id: str, request: TraceQueryRequest) -> str:
|
||||||
href = escape(self._trace_href(trace_id, request), quote=True)
|
href = escape(self._trace_href(trace_id, request), quote=True)
|
||||||
text = escape(trace_id)
|
text = escape(trace_id)
|
||||||
return f"<a href=\"{href}\">{text}</a>"
|
return f'<a href="{href}">{text}</a>'
|
||||||
|
|
||||||
def _optional_trace_link(self, trace_id: str | None, request: TraceQueryRequest) -> str:
|
def _optional_trace_link(self, trace_id: str | None, request: TraceQueryRequest) -> str:
|
||||||
if not trace_id:
|
if not trace_id:
|
||||||
return ""
|
return ""
|
||||||
return self._trace_link(trace_id, request)
|
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:
|
def _level_class(self, level: str) -> str:
|
||||||
if level == "ERROR":
|
if level == "ERROR":
|
||||||
return "msg-error"
|
return "msg-error"
|
||||||
@@ -291,3 +376,14 @@ class TraceResponseRenderer:
|
|||||||
if level == "INFO":
|
if level == "INFO":
|
||||||
return "msg-info"
|
return "msg-info"
|
||||||
return "msg-debug"
|
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
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class RuntimeManager:
|
|||||||
reader = build_trace_log_reader(self.traces.transport)
|
reader = build_trace_log_reader(self.traces.transport)
|
||||||
if reader is None:
|
if reader is None:
|
||||||
raise RuntimeError("trace log reader is not configured")
|
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:
|
if trace_view is None:
|
||||||
raise KeyError(trace_id)
|
raise KeyError(trace_id)
|
||||||
return trace_view
|
return trace_view
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
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.contracts.trace import TraceLevel, TraceLogReader, TraceLogRecord, TraceLogView, TraceTransport
|
||||||
from app_runtime.tracing.transport import MySqlTraceConnectionFactory, MySqlTraceTransport
|
from app_runtime.tracing.transport import MySqlTraceConnectionFactory, MySqlTraceTransport
|
||||||
|
|
||||||
|
TraceViewMode = Literal["tree", "single"]
|
||||||
|
|
||||||
|
|
||||||
class MySqlTraceLogReader(TraceLogReader):
|
class MySqlTraceLogReader(TraceLogReader):
|
||||||
def __init__(self, connection_factory: MySqlTraceConnectionFactory) -> None:
|
def __init__(self, connection_factory: MySqlTraceConnectionFactory) -> None:
|
||||||
@@ -16,20 +18,73 @@ class MySqlTraceLogReader(TraceLogReader):
|
|||||||
trace_id: str,
|
trace_id: str,
|
||||||
levels: tuple[TraceLevel, ...],
|
levels: tuple[TraceLevel, ...],
|
||||||
ancestor_depth: int | None = 0,
|
ancestor_depth: int | None = 0,
|
||||||
|
*,
|
||||||
|
view: TraceViewMode = "tree",
|
||||||
) -> TraceLogView | None:
|
) -> TraceLogView | None:
|
||||||
parent_id = self._read_parent_id(trace_id)
|
context = self._read_context(trace_id)
|
||||||
if parent_id is None and not self._trace_exists(trace_id):
|
if context is None:
|
||||||
return None
|
return None
|
||||||
|
parent_id, alias = context
|
||||||
|
if view == "single":
|
||||||
ancestors = self._read_ancestors(parent_id, levels, ancestor_depth)
|
ancestors = self._read_ancestors(parent_id, levels, ancestor_depth)
|
||||||
child_ids = self._read_child_ids(trace_id)
|
child_ids = self._read_child_ids(trace_id)
|
||||||
records = self._read_records(trace_id, levels)
|
records = self._read_records(trace_id, levels)
|
||||||
return TraceLogView(
|
return TraceLogView(
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
|
alias=alias,
|
||||||
child_ids=tuple(child_ids),
|
child_ids=tuple(child_ids),
|
||||||
records=tuple(records),
|
records=tuple(records),
|
||||||
ancestors=tuple(ancestors),
|
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)
|
||||||
|
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(self._read_records(trace_id, levels)),
|
||||||
|
descendants=tuple(view for view in descendants if view is not None),
|
||||||
|
)
|
||||||
|
|
||||||
def _read_ancestors(
|
def _read_ancestors(
|
||||||
self,
|
self,
|
||||||
@@ -43,11 +98,15 @@ class MySqlTraceLogReader(TraceLogReader):
|
|||||||
ancestors: list[TraceLogView] = []
|
ancestors: list[TraceLogView] = []
|
||||||
current_trace_id = parent_id
|
current_trace_id = parent_id
|
||||||
while current_trace_id is not None and (remaining_depth is None or remaining_depth > 0):
|
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(
|
ancestors.append(
|
||||||
TraceLogView(
|
TraceLogView(
|
||||||
trace_id=current_trace_id,
|
trace_id=current_trace_id,
|
||||||
parent_id=current_parent_id,
|
parent_id=current_parent_id,
|
||||||
|
alias=alias,
|
||||||
child_ids=tuple(self._read_child_ids(current_trace_id)),
|
child_ids=tuple(self._read_child_ids(current_trace_id)),
|
||||||
records=tuple(self._read_records(current_trace_id, levels)),
|
records=tuple(self._read_records(current_trace_id, levels)),
|
||||||
)
|
)
|
||||||
@@ -58,22 +117,15 @@ class MySqlTraceLogReader(TraceLogReader):
|
|||||||
ancestors.reverse()
|
ancestors.reverse()
|
||||||
return ancestors
|
return ancestors
|
||||||
|
|
||||||
def _trace_exists(self, trace_id: str) -> bool:
|
def _read_context(self, trace_id: str) -> tuple[str | None, str] | None:
|
||||||
query = "SELECT 1 FROM trace_contexts WHERE trace_id = %s"
|
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,))
|
|
||||||
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"
|
|
||||||
with self._connection_factory.connect() as connection:
|
with self._connection_factory.connect() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(query, (trace_id,))
|
cursor.execute(query, (trace_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return 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]:
|
def _read_records(self, trace_id: str, levels: tuple[TraceLevel, ...]) -> list[TraceLogRecord]:
|
||||||
placeholders = ", ".join(["%s"] * len(levels))
|
placeholders = ", ".join(["%s"] * len(levels))
|
||||||
|
|||||||
+223
-32
@@ -54,6 +54,7 @@ def test_trace_endpoint_returns_html_by_default() -> None:
|
|||||||
return TraceLogView(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="root-trace",
|
parent_id="root-trace",
|
||||||
|
alias="mailbox_sync",
|
||||||
child_ids=("child-1", "child-2"),
|
child_ids=("child-1", "child-2"),
|
||||||
records=(
|
records=(
|
||||||
_trace_record(row_id=1, level="ERROR", message="first error"),
|
_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.status_code == 200
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
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 "first error" in response.text
|
||||||
assert "second warning" in response.text
|
assert "second warning" in response.text
|
||||||
assert captured == [
|
assert captured == [
|
||||||
@@ -79,7 +80,8 @@ def test_trace_endpoint_returns_html_by_default() -> None:
|
|||||||
levels=("ERROR", "WARNING", "INFO"),
|
levels=("ERROR", "WARNING", "INFO"),
|
||||||
include_attrs_json=False,
|
include_attrs_json=False,
|
||||||
response_format="html",
|
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(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="root-trace",
|
parent_id="root-trace",
|
||||||
|
alias="mailbox_sync",
|
||||||
child_ids=("child-1", "child-2"),
|
child_ids=("child-1", "child-2"),
|
||||||
records=(
|
records=(
|
||||||
_trace_record(row_id=1, level="ERROR", message="first error"),
|
_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"
|
" - child-2\n"
|
||||||
"\n"
|
"\n"
|
||||||
"==============================\n"
|
"==============================\n"
|
||||||
"trace_id: trace-1\n"
|
"trace context: mailbox_sync, trace-1\n"
|
||||||
"\n"
|
"\n"
|
||||||
"step: process\n"
|
"step: process\n"
|
||||||
"first error\n"
|
"first error\n"
|
||||||
@@ -125,6 +128,7 @@ def test_trace_endpoint_appends_attrs_json_in_text_mode() -> None:
|
|||||||
return TraceLogView(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
alias="mailbox_sync",
|
||||||
child_ids=(),
|
child_ids=(),
|
||||||
records=(
|
records=(
|
||||||
_trace_record(row_id=1, level="ERROR", message="failure", attrs_json={"attempt": 2, "source": "crm"}),
|
_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"
|
"child_ids:\n"
|
||||||
"\n"
|
"\n"
|
||||||
"==============================\n"
|
"==============================\n"
|
||||||
"trace_id: trace-1\n"
|
"trace context: mailbox_sync, trace-1\n"
|
||||||
"\n"
|
"\n"
|
||||||
"step: process\n"
|
"step: process\n"
|
||||||
'failure, {"attempt":2,"source":"crm"}'
|
'failure, {"attempt":2,"source":"crm"}'
|
||||||
@@ -156,6 +160,7 @@ def test_trace_endpoint_separates_messages_by_step_in_text_mode() -> None:
|
|||||||
return TraceLogView(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
alias="mailbox_sync",
|
||||||
child_ids=(),
|
child_ids=(),
|
||||||
records=(
|
records=(
|
||||||
_trace_record(row_id=1, level="INFO", message="load first", step="load_stocks"),
|
_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"
|
"child_ids:\n"
|
||||||
"\n"
|
"\n"
|
||||||
"==============================\n"
|
"==============================\n"
|
||||||
"trace_id: trace-1\n"
|
"trace context: mailbox_sync, trace-1\n"
|
||||||
"\n"
|
"\n"
|
||||||
"step: load_stocks\n"
|
"step: load_stocks\n"
|
||||||
"load first\n"
|
"load first\n"
|
||||||
@@ -201,7 +206,7 @@ def test_trace_endpoint_returns_json_payload() -> None:
|
|||||||
|
|
||||||
client = _build_client(trace_provider)
|
client = _build_client(trace_provider)
|
||||||
try:
|
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:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -257,7 +262,7 @@ def test_trace_endpoint_returns_json_payload_with_ancestors() -> None:
|
|||||||
|
|
||||||
client = _build_client(trace_provider)
|
client = _build_client(trace_provider)
|
||||||
try:
|
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:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -303,6 +308,7 @@ def test_trace_endpoint_returns_html_page_with_related_links() -> None:
|
|||||||
return TraceLogView(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="parent-1",
|
parent_id="parent-1",
|
||||||
|
alias="order_confirmation",
|
||||||
child_ids=("child-1", "child-2"),
|
child_ids=("child-1", "child-2"),
|
||||||
records=(
|
records=(
|
||||||
_trace_record(row_id=1, level="INFO", message="loaded prices", step="load_stocks", status="ok"),
|
_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 "--error: #ff817d;" in response.text
|
||||||
assert "--other: #ececec;" in response.text
|
assert "--other: #ececec;" in response.text
|
||||||
assert 'font: 13px/1.1 "SFMono-Regular", monospace;' in response.text
|
assert 'font: 13px/1.1 "SFMono-Regular", monospace;' in response.text
|
||||||
assert '<div class="line">trace_id: <a href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true">trace-1</a></div>' in response.text
|
assert '<div class="line">trace_id: <a href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=tree&ancestor_depth=all">trace-1</a></div>' in response.text
|
||||||
assert '<div class="line">parent_id: <a href="/traces/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true">parent-1</a></div>' in response.text
|
assert '<div class="line">parent_id: <a href="/traces/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=tree&ancestor_depth=all">parent-1</a></div>' in response.text
|
||||||
assert '<div class="line">child_ids:</div>' in response.text
|
assert '<div class="line">child_ids:</div>' in response.text
|
||||||
assert '<div class="line"> - <a href="/traces/child-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true">child-1</a></div>' in response.text
|
assert '<div class="line"> - <a href="/traces/child-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=tree&ancestor_depth=all">child-1</a></div>' in response.text
|
||||||
assert '<div class="line"> - <a href="/traces/child-2?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true">child-2</a></div>' in response.text
|
assert '<div class="line"> - <a href="/traces/child-2?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=tree&ancestor_depth=all">child-2</a></div>' in response.text
|
||||||
assert '<div class="line">==============================</div>' in response.text
|
assert '<div class="line">==============================</div>' in response.text
|
||||||
|
assert '<div class="line">trace context: order_confirmation, <a href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true&view=tree&ancestor_depth=all">trace-1</a></div>' in response.text
|
||||||
assert '<div class="line" style="color: var(--step);">load_stocks</div>' in response.text
|
assert '<div class="line" style="color: var(--step);">load_stocks</div>' in response.text
|
||||||
assert '<div class="line">trace_id: <a href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true">trace-1</a></div>' in response.text
|
|
||||||
assert '<div class="line" style="color: var(--step);">filter_stocks</div>' in response.text
|
assert '<div class="line" style="color: var(--step);">filter_stocks</div>' in response.text
|
||||||
assert "loaded prices" in response.text
|
assert "loaded prices" in response.text
|
||||||
assert "filtered suspicious ticker" 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(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="parent-1",
|
parent_id="parent-1",
|
||||||
|
alias="child",
|
||||||
child_ids=(),
|
child_ids=(),
|
||||||
records=(_trace_record(row_id=1, level="INFO", message="child message"),),
|
records=(_trace_record(row_id=1, level="INFO", message="child message"),),
|
||||||
ancestors=(
|
ancestors=(
|
||||||
TraceLogView(
|
TraceLogView(
|
||||||
trace_id="root-1",
|
trace_id="root-1",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
alias="root",
|
||||||
child_ids=("parent-1",),
|
child_ids=("parent-1",),
|
||||||
records=(_trace_record(row_id=2, level="INFO", message="root message"),),
|
records=(_trace_record(row_id=2, level="INFO", message="root message"),),
|
||||||
),
|
),
|
||||||
TraceLogView(
|
TraceLogView(
|
||||||
trace_id="parent-1",
|
trace_id="parent-1",
|
||||||
parent_id="root-1",
|
parent_id="root-1",
|
||||||
|
alias="parent",
|
||||||
child_ids=("trace-1",),
|
child_ids=("trace-1",),
|
||||||
records=(_trace_record(row_id=3, level="WARNING", message="parent message"),),
|
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)
|
client = _build_client(trace_provider)
|
||||||
try:
|
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:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -379,21 +388,21 @@ def test_trace_endpoint_renders_ancestors_in_text_mode() -> None:
|
|||||||
"child_ids:\n"
|
"child_ids:\n"
|
||||||
"\n"
|
"\n"
|
||||||
"==============================\n"
|
"==============================\n"
|
||||||
"trace_id: root-1\n"
|
"trace context: root, root-1\n"
|
||||||
"\n"
|
"\n"
|
||||||
"step: process\n"
|
"step: process\n"
|
||||||
"root message\n"
|
"root message\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\n"
|
"\n"
|
||||||
"==============================\n"
|
"==============================\n"
|
||||||
"trace_id: parent-1\n"
|
"trace context: parent, parent-1\n"
|
||||||
"\n"
|
"\n"
|
||||||
"step: process\n"
|
"step: process\n"
|
||||||
"parent message\n"
|
"parent message\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\n"
|
"\n"
|
||||||
"==============================\n"
|
"==============================\n"
|
||||||
"trace_id: trace-1\n"
|
"trace context: child, trace-1\n"
|
||||||
"\n"
|
"\n"
|
||||||
"step: process\n"
|
"step: process\n"
|
||||||
"child message"
|
"child message"
|
||||||
@@ -405,18 +414,21 @@ def test_trace_endpoint_preserves_ancestor_depth_in_html_links() -> None:
|
|||||||
return TraceLogView(
|
return TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="parent-1",
|
parent_id="parent-1",
|
||||||
|
alias="child",
|
||||||
child_ids=("child-1",),
|
child_ids=("child-1",),
|
||||||
records=(_trace_record(row_id=1, level="INFO", message="loaded prices"),),
|
records=(_trace_record(row_id=1, level="INFO", message="loaded prices"),),
|
||||||
ancestors=(
|
ancestors=(
|
||||||
TraceLogView(
|
TraceLogView(
|
||||||
trace_id="root-1",
|
trace_id="root-1",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
alias="root",
|
||||||
child_ids=("parent-1",),
|
child_ids=("parent-1",),
|
||||||
records=(_trace_record(row_id=2, level="INFO", message="root info"),),
|
records=(_trace_record(row_id=2, level="INFO", message="root info"),),
|
||||||
),
|
),
|
||||||
TraceLogView(
|
TraceLogView(
|
||||||
trace_id="parent-1",
|
trace_id="parent-1",
|
||||||
parent_id="root-1",
|
parent_id="root-1",
|
||||||
|
alias="parent",
|
||||||
child_ids=("trace-1",),
|
child_ids=("trace-1",),
|
||||||
records=(_trace_record(row_id=3, level="WARNING", message="parent warning"),),
|
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)
|
client = _build_client(trace_provider)
|
||||||
try:
|
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:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
assert response.status_code == 200
|
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/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&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&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 response.text.index("root info") < response.text.index("parent warning") < response.text.index("loaded prices")
|
||||||
assert "root info" in response.text
|
assert "root info" in response.text
|
||||||
assert "parent warning" 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:
|
def test_trace_endpoint_validates_query_params() -> None:
|
||||||
client = _build_client(lambda _trace_id, _request: None)
|
client = _build_client(lambda _trace_id, _request: None)
|
||||||
try:
|
try:
|
||||||
@@ -445,6 +540,7 @@ def test_trace_endpoint_validates_query_params() -> None:
|
|||||||
invalid_format = client.get("/traces/trace-1?format=xml")
|
invalid_format = client.get("/traces/trace-1?format=xml")
|
||||||
invalid_ancestor_depth = client.get("/traces/trace-1?ancestor_depth=-1")
|
invalid_ancestor_depth = client.get("/traces/trace-1?ancestor_depth=-1")
|
||||||
invalid_ancestor_type = client.get("/traces/trace-1?ancestor_depth=up")
|
invalid_ancestor_type = client.get("/traces/trace-1?ancestor_depth=up")
|
||||||
|
invalid_view = client.get("/traces/trace-1?view=graph")
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -462,12 +558,18 @@ def test_trace_endpoint_validates_query_params() -> None:
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"detail": "invalid ancestor depth query parameter: ancestor_depth=up",
|
"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:
|
def test_runtime_trace_logs_uses_configured_reader(monkeypatch) -> None:
|
||||||
expected = TraceLogView(
|
expected = TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="root",
|
parent_id="root",
|
||||||
|
alias="runtime",
|
||||||
child_ids=("child-1",),
|
child_ids=("child-1",),
|
||||||
records=(_trace_record(row_id=1, level="ERROR", message="boom"),),
|
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,
|
trace_id: str,
|
||||||
levels: tuple[str, ...],
|
levels: tuple[str, ...],
|
||||||
ancestor_depth: int | None = 0,
|
ancestor_depth: int | None = 0,
|
||||||
|
*,
|
||||||
|
view: str = "tree",
|
||||||
) -> TraceLogView | None:
|
) -> TraceLogView | None:
|
||||||
assert trace_id == "trace-1"
|
assert trace_id == "trace-1"
|
||||||
assert levels == ("ERROR",)
|
assert levels == ("ERROR",)
|
||||||
assert ancestor_depth is None
|
assert ancestor_depth is None
|
||||||
|
assert view == "tree"
|
||||||
return expected
|
return expected
|
||||||
|
|
||||||
monkeypatch.setattr(runtime_module, "build_trace_log_reader", lambda _transport: StubReader())
|
monkeypatch.setattr(runtime_module, "build_trace_log_reader", lambda _transport: StubReader())
|
||||||
runtime = RuntimeManager()
|
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
|
assert result == expected
|
||||||
|
|
||||||
@@ -504,9 +609,9 @@ def test_mysql_trace_log_reader_maps_db_rows() -> None:
|
|||||||
|
|
||||||
def fetchone(self) -> dict[str, object] | None:
|
def fetchone(self) -> dict[str, object] | None:
|
||||||
if self.executed[-1][1] == ("trace-1",):
|
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",):
|
if self.executed[-1][1] == ("root-77",):
|
||||||
return {"parent_id": None}
|
return {"parent_id": None, "alias": "root"}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetchall(self) -> list[dict[str, object]]:
|
def fetchall(self) -> list[dict[str, object]]:
|
||||||
@@ -554,11 +659,12 @@ def test_mysql_trace_log_reader_maps_db_rows() -> None:
|
|||||||
factory = FakeConnectionFactory()
|
factory = FakeConnectionFactory()
|
||||||
reader = MySqlTraceLogReader(factory) # type: ignore[arg-type]
|
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(
|
assert view == TraceLogView(
|
||||||
trace_id="trace-1",
|
trace_id="trace-1",
|
||||||
parent_id="root-77",
|
parent_id="root-77",
|
||||||
|
alias="single",
|
||||||
child_ids=("child-1", "child-2"),
|
child_ids=("child-1", "child-2"),
|
||||||
records=(
|
records=(
|
||||||
TraceLogRecord(
|
TraceLogRecord(
|
||||||
@@ -591,11 +697,11 @@ def test_mysql_trace_log_reader_loads_requested_ancestors() -> None:
|
|||||||
|
|
||||||
def fetchone(self) -> dict[str, object] | None:
|
def fetchone(self) -> dict[str, object] | None:
|
||||||
if self.executed[-1][1] == ("trace-1",):
|
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",):
|
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",):
|
if self.executed[-1][1] == ("root-1",):
|
||||||
return {"parent_id": None}
|
return {"parent_id": None, "alias": "root"}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetchall(self) -> list[dict[str, object]]:
|
def fetchall(self) -> list[dict[str, object]]:
|
||||||
@@ -651,17 +757,101 @@ def test_mysql_trace_log_reader_loads_requested_ancestors() -> None:
|
|||||||
factory = FakeConnectionFactory()
|
factory = FakeConnectionFactory()
|
||||||
reader = MySqlTraceLogReader(factory) # type: ignore[arg-type]
|
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 is not None
|
||||||
assert view.trace_id == "trace-1"
|
assert view.trace_id == "trace-1"
|
||||||
assert view.parent_id == "parent-1"
|
assert view.parent_id == "parent-1"
|
||||||
|
assert view.alias == "child"
|
||||||
assert len(view.ancestors) == 1
|
assert len(view.ancestors) == 1
|
||||||
assert view.ancestors[0].trace_id == "parent-1"
|
assert view.ancestors[0].trace_id == "parent-1"
|
||||||
assert view.ancestors[0].parent_id == "root-1"
|
assert view.ancestors[0].parent_id == "root-1"
|
||||||
|
assert view.ancestors[0].alias == "parent"
|
||||||
assert view.ancestors[0].child_ids == ("trace-1",)
|
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:
|
def test_mysql_trace_log_reader_orders_ancestors_root_first() -> None:
|
||||||
class FakeCursor:
|
class FakeCursor:
|
||||||
def __init__(self) -> None:
|
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:
|
def fetchone(self) -> dict[str, object] | None:
|
||||||
if self.executed[-1][1] == ("trace-1",):
|
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",):
|
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",):
|
if self.executed[-1][1] == ("root-1",):
|
||||||
return {"parent_id": None}
|
return {"parent_id": None, "alias": "root"}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetchall(self) -> list[dict[str, object]]:
|
def fetchall(self) -> list[dict[str, object]]:
|
||||||
@@ -732,7 +922,8 @@ def test_mysql_trace_log_reader_orders_ancestors_root_first() -> None:
|
|||||||
factory = FakeConnectionFactory()
|
factory = FakeConnectionFactory()
|
||||||
reader = MySqlTraceLogReader(factory) # type: ignore[arg-type]
|
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 view is not None
|
||||||
assert tuple(ancestor.trace_id for ancestor in view.ancestors) == ("root-1", "parent-1")
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user