Skip to content

Commit a71ba0f

Browse files
Merge pull request #258 from askui/feat/reporting-resilience-streaming-html
feat(reporting): Resilient composite reporters and lower memory HTML reports
2 parents 2a08598 + 07cdca4 commit a71ba0f

1 file changed

Lines changed: 162 additions & 48 deletions

File tree

src/askui/reporting.py

Lines changed: 162 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import base64
44
import io
55
import json
6+
import logging
67
import platform
78
import random
9+
import shutil
810
import sys
911
from abc import ABC, abstractmethod
1012
from datetime import datetime, timezone
@@ -18,6 +20,8 @@
1820

1921
from askui.utils.annotated_image import AnnotatedImage
2022

23+
logger = logging.getLogger(__name__)
24+
2125
if TYPE_CHECKING:
2226
from PIL import Image
2327

@@ -188,6 +192,70 @@ def generate(self) -> None:
188192
NULL_REPORTER = NullReporter()
189193

190194

195+
class ReporterErrorHandler(Reporter):
196+
"""A reporter that handles errors by logging them and skipping the reporter."""
197+
198+
def __init__(self, reporter: Reporter) -> None:
199+
self._reporter = reporter
200+
self._error_occurred = False
201+
202+
def _log_on_exception(self, error: Exception) -> None:
203+
error_message = getattr(error, "message", str(error))
204+
logger.exception(
205+
"Skipping the usage of reporter %s due to the following exception: %s",
206+
self._reporter,
207+
error_message,
208+
)
209+
self._error_occurred = True
210+
211+
@override
212+
def add_message(
213+
self,
214+
role: str,
215+
content: Union[str, dict[str, Any], list[Any]],
216+
image: Optional[Image.Image | list[Image.Image] | AnnotatedImage] = None,
217+
) -> None:
218+
if self._error_occurred:
219+
logger.debug("Skipping reporter due to previous error")
220+
return
221+
try:
222+
self._reporter.add_message(role, content, image)
223+
except Exception as e: # noqa: BLE001
224+
self._log_on_exception(e)
225+
226+
@override
227+
def add_usage_summary(self, usage: UsageSummary) -> None:
228+
if self._error_occurred:
229+
logger.debug("Skipping reporter due to previous error")
230+
return
231+
try:
232+
self._reporter.add_usage_summary(usage)
233+
except Exception as e: # noqa: BLE001
234+
self._log_on_exception(e)
235+
236+
@override
237+
def add_cache_execution_statistics(
238+
self, original_usage: dict[str, int | None]
239+
) -> None:
240+
if self._error_occurred:
241+
logger.debug("Skipping reporter due to previous error")
242+
return
243+
try:
244+
self._reporter.add_cache_execution_statistics(original_usage)
245+
except Exception as e: # noqa: BLE001
246+
self._log_on_exception(e)
247+
248+
@override
249+
def generate(self) -> None:
250+
if self._error_occurred:
251+
logger.debug("Skipping reporter due to previous error")
252+
return
253+
try:
254+
self._reporter.generate()
255+
except Exception as e: # noqa: BLE001
256+
self._log_on_exception(e)
257+
258+
191259
class CompositeReporter(Reporter):
192260
"""A reporter that combines multiple reporters.
193261
@@ -200,7 +268,9 @@ class CompositeReporter(Reporter):
200268
"""
201269

202270
def __init__(self, reporters: list[Reporter] | None = None) -> None:
203-
self._reporters = reporters or []
271+
self._reporters = [
272+
ReporterErrorHandler(reporter) for reporter in reporters or []
273+
]
204274

205275
@override
206276
def add_message(
@@ -243,14 +313,18 @@ class SystemInfo(TypedDict):
243313
class SimpleHtmlReporter(Reporter):
244314
"""A reporter that generates HTML reports with conversation logs and system information.
245315
316+
Messages are streamed to a temporary file as they arrive so that base64-encoded
317+
screenshots are never held in memory all at once. The final report is assembled
318+
as a single self-contained HTML file on `generate()`.
319+
246320
Args:
247321
report_dir (str, optional): Directory where reports will be saved.
248322
Defaults to `reports`.
249323
"""
250324

251325
def __init__(self, report_dir: str = "reports") -> None:
252326
self.report_dir = Path(report_dir)
253-
self.messages: list[dict[str, Any]] = []
327+
self._temp_messages_file: Path | None = None
254328
self.system_info = self._collect_system_info()
255329
self.usage_summary: UsageSummary | None = None
256330
self.cache_original_usage: dict[str, int | None] | None = None
@@ -276,29 +350,82 @@ def _format_content(self, content: Union[str, dict[str, Any], list[Any]]) -> str
276350
return json.dumps(content, indent=2)
277351
return str(content)
278352

353+
def _get_temp_messages_file(self) -> Path:
354+
"""Return the path to the temporary messages file, creating it if needed."""
355+
if self._temp_messages_file is None or not self._temp_messages_file.exists():
356+
self.report_dir.mkdir(parents=True, exist_ok=True)
357+
_report_ts = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
358+
self._temp_messages_file = (
359+
self.report_dir / f"AskUI_report_{_report_ts}.tmp"
360+
)
361+
return self._temp_messages_file
362+
363+
_MESSAGE_ROW_TEMPLATE = Template(
364+
'<tr class="{{ role_lower }}">'
365+
'<td class="timestamp">{{ ts_str }} UTC</td>'
366+
'<td><span class="role-badge role-{{ role_lower }}">{{ role }}</span></td>'
367+
'<td class="content-cell">'
368+
"{% if is_json %}"
369+
'<div class="json-content">'
370+
'<pre><code class="json">{{ content }}</code></pre>'
371+
"</div>"
372+
"{% else %}"
373+
"{{ content }}"
374+
"{% endif %}"
375+
"{% for image in images %}"
376+
'<br><img src="data:image/png;base64,{{ image }}" '
377+
'class="message-image" alt="Message image">'
378+
"{% endfor %}"
379+
"</td>"
380+
"</tr>\n"
381+
)
382+
383+
def _render_message_row(
384+
self,
385+
timestamp: datetime,
386+
role: str,
387+
content: str,
388+
is_json: bool,
389+
images: list[str],
390+
) -> str:
391+
"""Render a single conversation message as an HTML table row."""
392+
return self._MESSAGE_ROW_TEMPLATE.render(
393+
role_lower=role.lower(),
394+
ts_str=timestamp.strftime("%H:%M:%S.%f")[:-3],
395+
role=role,
396+
content=content,
397+
is_json=is_json,
398+
images=images,
399+
)
400+
279401
@override
280402
def add_message(
281403
self,
282404
role: str,
283405
content: Union[str, dict[str, Any], list[Any]],
284406
image: Optional[Image.Image | list[Image.Image] | AnnotatedImage] = None,
285407
) -> None:
286-
"""Add a message to the report."""
287-
# Track start time from first message
408+
"""Add a message to the report.
409+
410+
The rendered HTML row is written directly to a temporary file so that
411+
base64 image data is not accumulated in memory during long runs.
412+
"""
288413
if self._start_time is None:
289414
self._start_time = datetime.now(tz=timezone.utc)
290415

291416
_images = normalize_to_pil_images(image)
292417
_content = truncate_base64_images(content)
293418

294-
message = {
295-
"timestamp": datetime.now(tz=timezone.utc),
296-
"role": role,
297-
"content": self._format_content(_content),
298-
"is_json": isinstance(_content, (dict, list)),
299-
"images": [self._image_to_base64(img) for img in _images],
300-
}
301-
self.messages.append(message)
419+
timestamp = datetime.now(tz=timezone.utc)
420+
formatted_content = self._format_content(_content)
421+
is_json = isinstance(_content, (dict, list))
422+
image_b64s = [self._image_to_base64(img) for img in _images]
423+
424+
row_html = self._render_message_row(
425+
timestamp, role, formatted_content, is_json, image_b64s
426+
)
427+
with self._get_temp_messages_file().open(mode="a", encoding="utf-8") as f:
428+
f.write(row_html)
302429

303430
@override
304431
def add_usage_summary(self, usage: UsageSummary) -> None:
@@ -331,8 +458,11 @@ def generate(self) -> None:
331458
- System information
332459
- All collected messages with their content and images
333460
- Syntax-highlighted JSON content
461+
462+
Message rows are streamed from a temporary file so that the full set of
463+
base64 images is never held in memory simultaneously.
334464
"""
335-
template_str = """
465+
_HEADER_TEMPLATE = """
336466
<!DOCTYPE html>
337467
<html lang="en">
338468
<head>
@@ -1137,39 +1267,9 @@ def generate(self) -> None:
11371267
<th>Role</th>
11381268
<th>Content</th>
11391269
</tr>
1140-
{% for msg in messages %}
1141-
<tr class="{{ msg.role.lower() }}">
1142-
<td class="timestamp">{{ msg.timestamp.strftime('%H:%M:%S.%f')[:-3] }} UTC</td>
1143-
<td>
1144-
<span class="role-badge role-{{ msg.role.lower() }}">
1145-
{{ msg.role }}
1146-
</span>
1147-
</td>
1148-
<td class="content-cell">
1149-
{% if msg.is_json %}
1150-
<div class="json-content">
1151-
<pre><code class="json">{{ msg.content }}</code></pre>
1152-
</div>
1153-
{% else %}
1154-
{{ msg.content }}
1155-
{% endif %}
1156-
{% for image in msg.images %}
1157-
<br>
1158-
<img src="data:image/png;base64,{{ image }}"
1159-
class="message-image"
1160-
alt="Message image">
1161-
{% endfor %}
1162-
</td>
1163-
</tr>
1164-
{% endfor %}
1165-
</table>
1166-
</div>
1167-
</div>
1168-
</body>
1169-
</html>
11701270
"""
11711271

1172-
template = Template(template_str)
1272+
_FOOTER = " </table>\n </div>\n </div>\n </body>\n </html>"
11731273

11741274
# Calculate execution time
11751275
end_time = datetime.now(tz=timezone.utc)
@@ -1198,9 +1298,8 @@ def _format_conversation_duration(
11981298
).total_seconds()
11991299
)
12001300

1201-
html = template.render(
1301+
header_html = Template(_HEADER_TEMPLATE).render(
12021302
timestamp=end_time,
1203-
messages=self.messages,
12041303
system_info=self.system_info,
12051304
usage_summary=self.usage_summary,
12061305
cache_original_usage=self.cache_original_usage,
@@ -1209,11 +1308,26 @@ def _format_conversation_duration(
12091308
)
12101309

12111310
report_path = (
1212-
self.report_dir / f"report_{datetime.now(tz=timezone.utc):%Y%m%d%H%M%S%f}"
1311+
self.report_dir / f"report_{end_time:%Y%m%d%H%M%S%f}"
12131312
f"{random.randint(0, 1000):03}.html"
12141313
)
12151314
self.report_dir.mkdir(parents=True, exist_ok=True)
1216-
report_path.write_text(html, encoding="utf-8")
1315+
1316+
with report_path.open(mode="w", encoding="utf-8") as out:
1317+
out.write(header_html)
1318+
try:
1319+
if (
1320+
self._temp_messages_file is not None
1321+
and self._temp_messages_file.exists()
1322+
):
1323+
with self._temp_messages_file.open(
1324+
mode="r", encoding="utf-8"
1325+
) as tmp:
1326+
shutil.copyfileobj(tmp, out)
1327+
self._temp_messages_file.unlink()
1328+
self._temp_messages_file = None
1329+
finally:
1330+
out.write(_FOOTER)
12171331

12181332

12191333
class AllureReporter(Reporter):

0 commit comments

Comments
 (0)