Доработка trace html
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "plba"
|
name = "plba"
|
||||||
version = "0.3.7"
|
version = "0.3.8"
|
||||||
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"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
|
||||||
from html import escape
|
from html import escape
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -12,12 +11,6 @@ from app_runtime.control.base import TraceQueryRequest
|
|||||||
from app_runtime.contracts.trace import TraceLevel, TraceLogRecord, TraceLogView
|
from app_runtime.contracts.trace import TraceLevel, TraceLogRecord, TraceLogView
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TraceStepGroup:
|
|
||||||
step: str
|
|
||||||
records: tuple[TraceLogRecord, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class TraceRequestParser:
|
class TraceRequestParser:
|
||||||
def parse(self, request: Request) -> TraceQueryRequest:
|
def parse(self, request: Request) -> TraceQueryRequest:
|
||||||
raw_levels = request.query_params.get("levels")
|
raw_levels = request.query_params.get("levels")
|
||||||
@@ -94,8 +87,7 @@ class TraceResponseRenderer:
|
|||||||
|
|
||||||
def _render_html(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse:
|
def _render_html(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse:
|
||||||
title = escape(f"Trace {trace_view.trace_id}")
|
title = escape(f"Trace {trace_view.trace_id}")
|
||||||
header_links = self._related_trace_links(trace_view, request)
|
lines = self._html_lines(trace_view, request)
|
||||||
sections = self._step_sections(trace_view, request)
|
|
||||||
html = f"""<!DOCTYPE html>
|
html = f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -104,146 +96,55 @@ class TraceResponseRenderer:
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<style>
|
<style>
|
||||||
:root {{
|
:root {{
|
||||||
color-scheme: light;
|
color-scheme: dark;
|
||||||
--bg: #f5f1e8;
|
--bg: #000000;
|
||||||
--panel: #fffaf2;
|
--fg: #cfcfc2;
|
||||||
--line: #d8cdbf;
|
--link: #66d9ef;
|
||||||
--ink: #2b241d;
|
--error: #f92672;
|
||||||
--muted: #766758;
|
--warning: #e6db74;
|
||||||
--error: #8f2d1f;
|
--info: #a6e22e;
|
||||||
--warning: #9a6b00;
|
--debug: #ae81ff;
|
||||||
--info: #295f8a;
|
|
||||||
--debug: #5d5d7a;
|
|
||||||
--link: #7b2f24;
|
|
||||||
}}
|
}}
|
||||||
body {{
|
body {{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: linear-gradient(180deg, #efe6d8 0%, var(--bg) 100%);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--fg);
|
||||||
font: 16px/1.5 Georgia, "Times New Roman", serif;
|
font: 15px/1.45 "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
}}
|
}}
|
||||||
.page {{
|
.page {{
|
||||||
max-width: 1200px;
|
padding: 14px 16px 24px;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}}
|
|
||||||
.card {{
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: 0 12px 40px rgba(43, 36, 29, 0.08);
|
|
||||||
padding: 20px 22px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}}
|
|
||||||
h1, h2 {{
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}}
|
|
||||||
h1 {{
|
|
||||||
font-size: 28px;
|
|
||||||
}}
|
|
||||||
h2 {{
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}}
|
|
||||||
.meta {{
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}}
|
|
||||||
.trace-links {{
|
|
||||||
margin: 12px 0 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}}
|
|
||||||
.trace-links li {{
|
|
||||||
margin: 6px 0;
|
|
||||||
}}
|
}}
|
||||||
a {{
|
a {{
|
||||||
color: var(--link);
|
color: var(--link);
|
||||||
text-decoration: none;
|
|
||||||
}}
|
|
||||||
a:hover {{
|
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}}
|
}}
|
||||||
.step-section + .step-section {{
|
.line {{
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 24px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}}
|
|
||||||
.step-title {{
|
|
||||||
margin: 0 0 14px;
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 700;
|
|
||||||
}}
|
|
||||||
.log-entry + .log-entry {{
|
|
||||||
margin-top: 14px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px dashed var(--line);
|
|
||||||
}}
|
|
||||||
.log-meta {{
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}}
|
|
||||||
.message {{
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}}
|
}}
|
||||||
.attrs {{
|
.msg-error {{
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f3ebde;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
}}
|
|
||||||
.level-ERROR {{
|
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
}}
|
}}
|
||||||
.level-WARNING {{
|
.msg-warning {{
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}}
|
}}
|
||||||
.level-INFO {{
|
.msg-info {{
|
||||||
color: var(--info);
|
color: var(--info);
|
||||||
}}
|
}}
|
||||||
.level-DEBUG {{
|
.msg-debug {{
|
||||||
color: var(--debug);
|
color: var(--debug);
|
||||||
}}
|
}}
|
||||||
@media (max-width: 640px) {{
|
@media (max-width: 640px) {{
|
||||||
.page {{
|
.page {{
|
||||||
padding: 14px;
|
padding: 12px 12px 20px;
|
||||||
}}
|
|
||||||
.card {{
|
|
||||||
padding: 16px 14px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}}
|
|
||||||
h1 {{
|
|
||||||
font-size: 22px;
|
|
||||||
}}
|
|
||||||
.step-title {{
|
|
||||||
font-size: 20px;
|
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<section class="card">
|
{lines}
|
||||||
<h1>{title}</h1>
|
|
||||||
<p class="meta">Parent: {escape(trace_view.parent_id or "")}</p>
|
|
||||||
<p class="meta">Children: {len(trace_view.child_ids)}</p>
|
|
||||||
</section>
|
|
||||||
<section class="card">
|
|
||||||
<h2>Related Traces</h2>
|
|
||||||
{header_links}
|
|
||||||
</section>
|
|
||||||
<section class="card">
|
|
||||||
<h2>Logs By Step</h2>
|
|
||||||
{sections}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -259,18 +160,6 @@ class TraceResponseRenderer:
|
|||||||
return record.message
|
return record.message
|
||||||
return f"{record.message}, {json.dumps(record.attrs_json, ensure_ascii=False, separators=(',', ':'))}"
|
return f"{record.message}, {json.dumps(record.attrs_json, ensure_ascii=False, separators=(',', ':'))}"
|
||||||
|
|
||||||
def _related_trace_links(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str:
|
|
||||||
links: list[str] = []
|
|
||||||
if trace_view.parent_id:
|
|
||||||
links.append(self._trace_link_item("parent", trace_view.parent_id, request))
|
|
||||||
links.extend(self._trace_link_item("child", child_id, request) for child_id in trace_view.child_ids)
|
|
||||||
return f"<ul class=\"trace-links\">{''.join(links)}</ul>"
|
|
||||||
|
|
||||||
def _trace_link_item(self, label: str, trace_id: str, request: TraceQueryRequest) -> str:
|
|
||||||
href = escape(self._trace_href(trace_id, request), quote=True)
|
|
||||||
text = escape(f"{label}: {trace_id}")
|
|
||||||
return f"<li><a href=\"{href}\">{text}</a></li>"
|
|
||||||
|
|
||||||
def _trace_href(self, trace_id: str, request: TraceQueryRequest) -> str:
|
def _trace_href(self, trace_id: str, request: TraceQueryRequest) -> str:
|
||||||
params = urlencode(
|
params = urlencode(
|
||||||
{
|
{
|
||||||
@@ -281,41 +170,54 @@ class TraceResponseRenderer:
|
|||||||
)
|
)
|
||||||
return f"/traces/{trace_id}?{params}"
|
return f"/traces/{trace_id}?{params}"
|
||||||
|
|
||||||
def _step_sections(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str:
|
def _html_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str:
|
||||||
groups = self._step_groups(trace_view.records)
|
lines = [
|
||||||
if not groups:
|
self._html_plain_line(f"trace_id: {self._trace_link(trace_view.trace_id, request)}"),
|
||||||
return "<section class=\"step-section\"><h3 class=\"step-title\">No step</h3><div>No log records for selected filters.</div></section>"
|
self._html_plain_line(f"parent_id: {self._optional_trace_link(trace_view.parent_id, request)}"),
|
||||||
return "".join(self._step_section(group, request.include_attrs_json) for group in groups)
|
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),
|
||||||
|
self._html_plain_line("--------------------------------------------------"),
|
||||||
|
]
|
||||||
|
previous_step: str | None = None
|
||||||
|
for record in trace_view.records:
|
||||||
|
current_step = str(record.step or "")
|
||||||
|
if previous_step is None:
|
||||||
|
lines.append(self._html_plain_line(escape(current_step)))
|
||||||
|
lines.append(self._html_plain_line(""))
|
||||||
|
elif current_step != previous_step:
|
||||||
|
lines.append(self._html_plain_line(""))
|
||||||
|
lines.append(self._html_plain_line(escape(current_step)))
|
||||||
|
lines.append(self._html_plain_line(""))
|
||||||
|
previous_step = current_step
|
||||||
|
lines.extend(self._html_message_lines(record, request.include_attrs_json))
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
def _step_groups(self, records: tuple[TraceLogRecord, ...]) -> tuple[TraceStepGroup, ...]:
|
def _html_message_lines(self, record: TraceLogRecord, include_attrs_json: bool) -> list[str]:
|
||||||
grouped: dict[str, list[TraceLogRecord]] = {}
|
lines = [self._html_colored_line(self._text_message(record, include_attrs_json), record.level)]
|
||||||
order: list[str] = []
|
return lines
|
||||||
for record in records:
|
|
||||||
step = str(record.step or "")
|
|
||||||
if step not in grouped:
|
|
||||||
grouped[step] = []
|
|
||||||
order.append(step)
|
|
||||||
grouped[step].append(record)
|
|
||||||
return tuple(TraceStepGroup(step=step, records=tuple(grouped[step])) for step in order)
|
|
||||||
|
|
||||||
def _step_section(self, group: TraceStepGroup, include_attrs_json: bool) -> str:
|
def _html_plain_line(self, content: str) -> str:
|
||||||
step_label = escape(group.step) if group.step else "No step"
|
return f"<div class=\"line\">{content or ' '}</div>"
|
||||||
entries = "".join(self._log_entry(record, include_attrs_json) for record in group.records)
|
|
||||||
return f"<section class=\"step-section\"><h3 class=\"step-title\">{step_label}</h3>{entries}</section>"
|
|
||||||
|
|
||||||
def _log_entry(self, record: TraceLogRecord, include_attrs_json: bool) -> str:
|
def _html_colored_line(self, content: str, level: str) -> str:
|
||||||
level = escape(record.level)
|
level_class = self._level_class(level)
|
||||||
status = escape(record.status or "")
|
return f"<div class=\"line {level_class}\">{escape(content)}</div>"
|
||||||
timestamp = escape(record.event_time.isoformat())
|
|
||||||
message = escape(record.message)
|
def _trace_link(self, trace_id: str, request: TraceQueryRequest) -> str:
|
||||||
attrs = ""
|
href = escape(self._trace_href(trace_id, request), quote=True)
|
||||||
if include_attrs_json:
|
text = escape(trace_id)
|
||||||
attrs_payload = escape(json.dumps(record.attrs_json, ensure_ascii=False, indent=2, sort_keys=True))
|
return f"<a href=\"{href}\">{text}</a>"
|
||||||
attrs = f"<div class=\"attrs\">{attrs_payload}</div>"
|
|
||||||
return (
|
def _optional_trace_link(self, trace_id: str | None, request: TraceQueryRequest) -> str:
|
||||||
f"<article class=\"log-entry level-{level}\">"
|
if not trace_id:
|
||||||
f"<div class=\"log-meta\">{timestamp} | {level} | {status}</div>"
|
return ""
|
||||||
f"<div class=\"message\">{message}</div>"
|
return self._trace_link(trace_id, request)
|
||||||
f"{attrs}"
|
|
||||||
f"</article>"
|
def _level_class(self, level: str) -> str:
|
||||||
)
|
if level == "ERROR":
|
||||||
|
return "msg-error"
|
||||||
|
if level == "WARNING":
|
||||||
|
return "msg-warning"
|
||||||
|
if level == "DEBUG":
|
||||||
|
return "msg-debug"
|
||||||
|
return "msg-info"
|
||||||
|
|||||||
@@ -201,17 +201,21 @@ def test_trace_endpoint_returns_html_page_with_related_links() -> 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 "<h2>Logs By Step</h2>" in response.text
|
assert "background: var(--bg);" in response.text
|
||||||
assert '<h3 class="step-title">load_stocks</h3>' in response.text
|
assert "--bg: #000000;" in response.text
|
||||||
assert '<h3 class="step-title">filter_stocks</h3>' in response.text
|
assert "--fg: #cfcfc2;" 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">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">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-2?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true">child-2</a></div>' in response.text
|
||||||
|
assert '<div class="line">load_stocks</div>' in response.text
|
||||||
|
assert '<div class="line">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
|
||||||
assert "2026-04-28T10:11:12+00:00 | INFO | ok" in response.text
|
assert "2026-04-28T10:11:12+00:00 | INFO | ok" not in response.text
|
||||||
assert "2026-04-28T10:11:12+00:00 | WARNING | degraded" in response.text
|
assert "2026-04-28T10:11:12+00:00 | WARNING | degraded" not in response.text
|
||||||
assert 'href="/traces/trace-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' not in response.text
|
assert "Related Traces" not in response.text
|
||||||
assert 'href="/traces/parent-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text
|
|
||||||
assert 'href="/traces/child-1?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text
|
|
||||||
assert 'href="/traces/child-2?format=html&levels=ERROR%2CWARNING%2CINFO&attrs_json=true"' in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_trace_endpoint_validates_query_params() -> None:
|
def test_trace_endpoint_validates_query_params() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user