Skip to content

Commit 4f7cee0

Browse files
feat: add per-conversation duration to Html reports
1 parent ae5d6c2 commit 4f7cee0

3 files changed

Lines changed: 62 additions & 5 deletions

File tree

src/askui/callbacks/usage_tracking_callback.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from datetime import datetime, timezone
56
from typing import TYPE_CHECKING
67

78
from opentelemetry import trace
@@ -172,11 +173,22 @@ class StepUsageSummary(UsageSummary):
172173

173174

174175
class ConversationUsageSummary(UsageSummary):
175-
"""Usage summary for one conversation including per-step breakdown."""
176+
"""Usage summary for one conversation including per-step breakdown.
177+
178+
Args:
179+
conversation_index (int): 1-based index of the conversation within the
180+
current agent lifecycle.
181+
conversation_id (str): Unique identifier of the conversation.
182+
step_summaries (list[StepUsageSummary]): Per-step usage summaries.
183+
duration_seconds (float | None): Wall-clock duration of the conversation
184+
in seconds, measured between `on_conversation_start` and
185+
`on_conversation_end`. `None` if duration was not tracked.
186+
"""
176187

177188
conversation_index: int
178189
conversation_id: str
179190
step_summaries: list[StepUsageSummary] = Field(default_factory=list)
191+
duration_seconds: float | None = None
180192

181193

182194
class UsageTrackingCallback(ConversationCallback):
@@ -199,12 +211,14 @@ def __init__(
199211
self._per_conversation_summaries: list[ConversationUsageSummary] = []
200212
self._per_step_summaries: list[StepUsageSummary] = []
201213
self._conversation_index: int = 0
214+
self._conversation_start_time: datetime | None = None
202215

203216
@override
204217
def on_conversation_start(self, conversation: Conversation) -> None:
205218
self._per_conversation_usage = UsageSummary.create_from(self._summary)
206219
self._per_step_summaries = []
207220
self._conversation_index += 1
221+
self._conversation_start_time = datetime.now(tz=timezone.utc)
208222

209223
@override
210224
def on_step_end(
@@ -237,9 +251,15 @@ def on_conversation_end(self, conversation: Conversation) -> None:
237251
generated_steps: list[StepUsageSummary] = [
238252
step_summary.generate() for step_summary in self._per_step_summaries
239253
]
254+
duration_seconds: float | None = None
255+
if self._conversation_start_time is not None:
256+
duration_seconds = (
257+
datetime.now(tz=timezone.utc) - self._conversation_start_time
258+
).total_seconds()
240259
conversation_summary = self._create_conversation_summary(
241260
conversation=conversation,
242261
generated_step_summaries=generated_steps,
262+
duration_seconds=duration_seconds,
243263
)
244264
self._per_conversation_summaries.append(conversation_summary)
245265
self._summary.per_conversation_summaries = list(
@@ -275,11 +295,13 @@ def _create_conversation_summary(
275295
self,
276296
conversation: Conversation,
277297
generated_step_summaries: list[StepUsageSummary],
298+
duration_seconds: float | None = None,
278299
) -> ConversationUsageSummary:
279300
conversation_summary = ConversationUsageSummary(
280301
conversation_index=self._conversation_index,
281302
conversation_id=conversation.conversation_id,
282303
step_summaries=generated_step_summaries,
304+
duration_seconds=duration_seconds,
283305
input_tokens=self._per_conversation_usage.input_tokens,
284306
output_tokens=self._per_conversation_usage.output_tokens,
285307
cache_creation_input_tokens=(

src/askui/reporting.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ def normalize_to_pil_images(
3737
return [image]
3838

3939

40+
def _format_duration(seconds: float) -> str:
41+
"""Format a duration given in seconds as ``HH:MM:SS`` or
42+
``HH:MM:SS.mmm`` for sub-second precision.
43+
44+
Used by `SimpleHtmlReporter` to render both the overall execution time and
45+
per-conversation durations consistently.
46+
"""
47+
total_seconds = max(float(seconds), 0.0)
48+
whole_seconds = int(total_seconds)
49+
millis = int(round((total_seconds - whole_seconds) * 1000))
50+
if millis == 1000:
51+
whole_seconds += 1
52+
millis = 0
53+
hours, remainder = divmod(whole_seconds, 3600)
54+
minutes, secs = divmod(remainder, 60)
55+
base = f"{hours:02d}:{minutes:02d}:{secs:02d}"
56+
if whole_seconds == 0 and millis > 0:
57+
return f"{base}.{millis:03d}"
58+
return base
59+
60+
4061
def truncate_base64_images(content: Any) -> Any:
4162
"""Replace base64 image data with a placeholder to keep reports readable.
4263
@@ -1010,6 +1031,9 @@ def generate(self) -> None:
10101031
</span>
10111032
<span class="usage-breakdown-meta">
10121033
{{ conversation_usage.step_summaries | length }} step(s),
1034+
{% if conversation_usage.duration_seconds is not none %}
1035+
Duration: {{ format_duration(conversation_usage.duration_seconds) }},
1036+
{% endif %}
10131037
Input {{ "{:,}".format(conversation_usage.input_tokens or 0) }},
10141038
Output {{ "{:,}".format(conversation_usage.output_tokens or 0) }},
10151039
Cache Create {{ "{:,}".format(conversation_usage.cache_creation_input_tokens or 0) }},
@@ -1026,6 +1050,9 @@ def generate(self) -> None:
10261050
<table class="nested-table">
10271051
<tr>
10281052
<th>Conversation ID</th>
1053+
{% if conversation_usage.duration_seconds is not none %}
1054+
<th>Duration</th>
1055+
{% endif %}
10291056
<th>Input Tokens</th>
10301057
<th>Output Tokens</th>
10311058
<th>Cache Create</th>
@@ -1036,6 +1063,9 @@ def generate(self) -> None:
10361063
</tr>
10371064
<tr class="system">
10381065
<td class="mono">{{ conversation_usage.conversation_id }}</td>
1066+
{% if conversation_usage.duration_seconds is not none %}
1067+
<td>{{ format_duration(conversation_usage.duration_seconds) }}</td>
1068+
{% endif %}
10391069
<td>{{ "{:,}".format(conversation_usage.input_tokens or 0) }}</td>
10401070
<td>{{ "{:,}".format(conversation_usage.output_tokens or 0) }}</td>
10411071
<td>{{ "{:,}".format(conversation_usage.cache_creation_input_tokens or 0) }}</td>
@@ -1141,10 +1171,9 @@ def generate(self) -> None:
11411171
end_time = datetime.now(tz=timezone.utc)
11421172
execution_time_formatted: str | None = None
11431173
if self._start_time is not None:
1144-
total_secs = int((end_time - self._start_time).total_seconds())
1145-
hours, remainder = divmod(total_secs, 3600)
1146-
minutes, secs = divmod(remainder, 60)
1147-
execution_time_formatted = f"{hours:02d}:{minutes:02d}:{secs:02d}"
1174+
execution_time_formatted = _format_duration(
1175+
(end_time - self._start_time).total_seconds()
1176+
)
11481177

11491178
html = template.render(
11501179
timestamp=end_time,
@@ -1153,6 +1182,7 @@ def generate(self) -> None:
11531182
usage_summary=self.usage_summary,
11541183
cache_original_usage=self.cache_original_usage,
11551184
execution_time_formatted=execution_time_formatted,
1185+
format_duration=_format_duration,
11561186
)
11571187

11581188
report_path = (

tests/unit/model_providers/test_model_pricing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ def test_tracks_per_step_per_conversation_and_total_usage(self) -> None:
245245
assert per_conversation_summary.output_tokens == 30
246246
_assert_close(per_conversation_summary.total_cost, 0.0009)
247247
assert len(per_conversation_summary.step_summaries) == 2
248+
assert per_conversation_summary.duration_seconds is not None
249+
assert per_conversation_summary.duration_seconds >= 0.0
248250

249251
first_step = per_conversation_summary.step_summaries[0]
250252
assert first_step.step_index == 0
@@ -301,6 +303,9 @@ def test_accumulates_multiple_conversations(self) -> None:
301303
assert len(summary.per_conversation_summaries) == 2
302304
assert summary.per_conversation_summaries[0].conversation_id == "conversation-1"
303305
assert summary.per_conversation_summaries[1].conversation_id == "conversation-2"
306+
for per_conversation_summary in summary.per_conversation_summaries:
307+
assert per_conversation_summary.duration_seconds is not None
308+
assert per_conversation_summary.duration_seconds >= 0.0
304309

305310
def test_includes_cache_costs_from_provider_pricing(self) -> None:
306311
pricing = ModelPricing(

0 commit comments

Comments
 (0)