Skip to content

Commit bd5afad

Browse files
konanokhxy91819
andauthored
fix(ui): use precise hourly message counts for Peak Error Hours (#49396)
Merged via squash. Prepared head SHA: fbbf43b Co-authored-by: konanok <30515586+konanok@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819
1 parent a0fd105 commit bd5afad

6 files changed

Lines changed: 462 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
3333
- CLI/update: tolerate stale memory-runtime import failures during best-effort CLI process teardown, so `openclaw update` replacing hashed runtime chunks before the finalizer runs no longer surfaces as exit-time `Cannot find module` noise. Thanks @vincentkoc.
3434
- CLI/channels logs: reuse the rolling log-file resolver so `openclaw channels logs` falls back to the active dated log across date boundaries without reading unrelated custom log files. Fixes #42875; carries forward #42904 and #43043. Thanks @ethanclaw and @wdskuki.
3535
- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007.
36+
- Control UI: fix Peak Error Hours showing incorrect hourly rates when the browser's timezone observes DST, by storing hourly message counts with UTC date keys and using DST-aware `Date.getHours()` for local conversion. Also extract `accumulateMessageCounts` helper to reduce duplicated daily/hourly aggregation logic. (#49396) Thanks @konanok.
3637
- iMessage: normalize known leading attributedBody corruption markers on sent-message echo text keys so delayed reflected echoes with U+FFFD/U+FFFE/U+FFFF/FEFF prefixes are dropped without collapsing interior text. Fixes #59973; carries forward #59980 and #62191. Thanks @neeravmakwana and @maguilar631697.
3738
- Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue.
3839
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.

src/infra/session-cost-usage.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs/promises";
2+
import os from "node:os";
23
import path from "node:path";
34
import { afterAll, beforeAll, describe, expect, it } from "vitest";
45
import type { OpenClawConfig } from "../config/config.js";
@@ -274,6 +275,17 @@ describe("session cost usage", () => {
274275
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
275276
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
276277
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.4");
278+
279+
// utcQuarterHourMessageCounts should use UTC quarter-hour buckets
280+
// start = 2026-02-01T10:00Z → quarterIndex = floor((10*60+0)/15) = 40
281+
// end = 2026-02-01T10:05Z → quarterIndex = floor((10*60+5)/15) = 40
282+
expect(summary?.utcQuarterHourMessageCounts).toBeDefined();
283+
expect(summary?.utcQuarterHourMessageCounts?.length).toBe(1);
284+
expect(summary?.utcQuarterHourMessageCounts?.[0]?.quarterIndex).toBe(40);
285+
expect(summary?.utcQuarterHourMessageCounts?.[0]?.date).toBe("2026-02-01");
286+
expect(summary?.utcQuarterHourMessageCounts?.[0]?.total).toBe(2);
287+
expect(summary?.utcQuarterHourMessageCounts?.[0]?.user).toBe(1);
288+
expect(summary?.utcQuarterHourMessageCounts?.[0]?.assistant).toBe(1);
277289
});
278290

279291
it("does not exclude sessions with mtime after endMs during discovery", async () => {
@@ -753,6 +765,83 @@ example
753765
expect(logs?.[0]?.content).toBe("hello there");
754766
});
755767

768+
it("buckets hourly message counts into UTC quarter-hour slots", async () => {
769+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-quarter-"));
770+
const sessionFile = path.join(root, "session.jsonl");
771+
772+
// Messages at different UTC quarter-hour boundaries:
773+
// 00:14 UTC → quarterIndex = floor((0*60+14)/15) = 0
774+
// 00:15 UTC → quarterIndex = floor((0*60+15)/15) = 1
775+
// 06:30 UTC → quarterIndex = floor((6*60+30)/15) = 26
776+
// 23:59 UTC → quarterIndex = floor((23*60+59)/15) = 95
777+
const entries = [
778+
{
779+
type: "message",
780+
timestamp: "2026-03-15T00:14:00.000Z",
781+
message: { role: "user", content: "a" },
782+
},
783+
{
784+
type: "message",
785+
timestamp: "2026-03-15T00:15:00.000Z",
786+
message: { role: "user", content: "b" },
787+
},
788+
{
789+
type: "message",
790+
timestamp: "2026-03-15T06:30:00.000Z",
791+
message: {
792+
role: "assistant",
793+
provider: "openai",
794+
model: "gpt-5.2",
795+
usage: { input: 5, output: 5, totalTokens: 10, cost: { total: 0.001 } },
796+
},
797+
},
798+
{
799+
type: "message",
800+
timestamp: "2026-03-15T23:59:00.000Z",
801+
message: {
802+
role: "assistant",
803+
provider: "openai",
804+
model: "gpt-5.2",
805+
stopReason: "error",
806+
usage: { input: 3, output: 3, totalTokens: 6, cost: { total: 0.001 } },
807+
},
808+
},
809+
];
810+
811+
await fs.writeFile(
812+
sessionFile,
813+
entries.map((entry) => JSON.stringify(entry)).join("\n"),
814+
"utf-8",
815+
);
816+
817+
const summary = await loadSessionCostSummary({ sessionFile });
818+
const quarterHourly = summary?.utcQuarterHourMessageCounts;
819+
expect(quarterHourly).toBeDefined();
820+
expect(quarterHourly?.length).toBe(4);
821+
822+
// Sort by quarterIndex for deterministic checks
823+
const sorted = [...(quarterHourly ?? [])].toSorted((a, b) => a.quarterIndex - b.quarterIndex);
824+
expect(sorted[0]?.quarterIndex).toBe(0); // 00:14
825+
expect(sorted[0]?.user).toBe(1);
826+
expect(sorted[1]?.quarterIndex).toBe(1); // 00:15
827+
expect(sorted[1]?.user).toBe(1);
828+
expect(sorted[2]?.quarterIndex).toBe(26); // 06:30
829+
expect(sorted[2]?.assistant).toBe(1);
830+
expect(sorted[3]?.quarterIndex).toBe(95); // 23:59
831+
expect(sorted[3]?.assistant).toBe(1);
832+
expect(sorted[3]?.errors).toBe(1); // stopReason "error"
833+
});
834+
835+
it("returns undefined utcQuarterHourMessageCounts when session has no messages", async () => {
836+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-empty-hourly-"));
837+
const sessionFile = path.join(root, "session.jsonl");
838+
// Empty file — no entries at all
839+
await fs.writeFile(sessionFile, "", "utf-8");
840+
841+
const summary = await loadSessionCostSummary({ sessionFile });
842+
expect(summary?.utcQuarterHourMessageCounts).toBeUndefined();
843+
});
844+
756845
it("preserves totals and cumulative values when downsampling timeseries", async () => {
757846
const root = await makeSessionCostRoot("timeseries-downsample");
758847
const sessionsDir = path.join(root, "agents", "main", "sessions");

src/infra/session-cost-usage.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type {
3838
SessionLogEntry,
3939
SessionMessageCounts,
4040
SessionModelUsage,
41+
SessionUtcQuarterHourMessageCounts,
4142
SessionToolUsage,
4243
SessionUsageTimePoint,
4344
SessionUsageTimeSeries,
@@ -57,6 +58,7 @@ export type {
5758
SessionLogEntry,
5859
SessionMessageCounts,
5960
SessionModelUsage,
61+
SessionUtcQuarterHourMessageCounts,
6062
SessionToolUsage,
6163
SessionUsageTimePoint,
6264
SessionUsageTimeSeries,
@@ -165,6 +167,39 @@ const parseTranscriptEntry = (entry: Record<string, unknown>): ParsedTranscriptE
165167
const formatDayKey = (date: Date): string =>
166168
date.toLocaleDateString("en-CA", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone });
167169

170+
const formatUtcDayKey = (date: Date): string =>
171+
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
172+
173+
/**
174+
* Accumulate message-level counts into a bucket (daily or UTC quarter-hour).
175+
* Avoids duplicating the same logic for both daily and quarter-hour message counts.
176+
*/
177+
const accumulateMessageCounts = (
178+
bucket: {
179+
total: number;
180+
user: number;
181+
assistant: number;
182+
toolCalls: number;
183+
toolResults: number;
184+
errors: number;
185+
},
186+
entry: ParsedTranscriptEntry,
187+
errorStopReasons: Set<string>,
188+
) => {
189+
bucket.total += entry.role === "user" || entry.role === "assistant" ? 1 : 0;
190+
if (entry.role === "user") {
191+
bucket.user += 1;
192+
} else if (entry.role === "assistant") {
193+
bucket.assistant += 1;
194+
}
195+
bucket.toolCalls += entry.toolNames.length;
196+
bucket.toolResults += entry.toolResultCounts.total;
197+
bucket.errors += entry.toolResultCounts.errors;
198+
if (entry.stopReason && errorStopReasons.has(entry.stopReason)) {
199+
bucket.errors += 1;
200+
}
201+
};
202+
168203
const computeLatencyStats = (values: number[]): SessionLatencyStats | undefined => {
169204
if (!values.length) {
170205
return undefined;
@@ -572,6 +607,7 @@ export async function loadSessionCostSummary(params: {
572607
const activityDatesSet = new Set<string>();
573608
const dailyMap = new Map<string, { tokens: number; cost: number }>();
574609
const dailyMessageMap = new Map<string, SessionDailyMessageCounts>();
610+
const utcQuarterHourMessageMap = new Map<string, SessionUtcQuarterHourMessageCounts>();
575611
const dailyLatencyMap = new Map<string, number[]>();
576612
const dailyModelUsageMap = new Map<string, SessionDailyModelUsage>();
577613
const messageCounts: SessionMessageCounts = {
@@ -669,19 +705,27 @@ export async function loadSessionCostSummary(params: {
669705
toolResults: 0,
670706
errors: 0,
671707
};
672-
daily.total += entry.role === "user" || entry.role === "assistant" ? 1 : 0;
673-
if (entry.role === "user") {
674-
daily.user += 1;
675-
} else if (entry.role === "assistant") {
676-
daily.assistant += 1;
677-
}
678-
daily.toolCalls += entry.toolNames.length;
679-
daily.toolResults += entry.toolResultCounts.total;
680-
daily.errors += entry.toolResultCounts.errors;
681-
if (entry.stopReason && errorStopReasons.has(entry.stopReason)) {
682-
daily.errors += 1;
683-
}
708+
accumulateMessageCounts(daily, entry, errorStopReasons);
684709
dailyMessageMap.set(dayKey, daily);
710+
711+
// Per-quarter-hour message counts for precise hourly stats (UTC-based)
712+
const quarterIndex = Math.floor(
713+
(entry.timestamp.getUTCHours() * 60 + entry.timestamp.getUTCMinutes()) / 15,
714+
);
715+
const utcDayKey = formatUtcDayKey(entry.timestamp);
716+
const quarterKey = `${utcDayKey}::${quarterIndex}`;
717+
const utcQuarterHour = utcQuarterHourMessageMap.get(quarterKey) ?? {
718+
date: utcDayKey,
719+
quarterIndex,
720+
total: 0,
721+
user: 0,
722+
assistant: 0,
723+
toolCalls: 0,
724+
toolResults: 0,
725+
errors: 0,
726+
};
727+
accumulateMessageCounts(utcQuarterHour, entry, errorStopReasons);
728+
utcQuarterHourMessageMap.set(quarterKey, utcQuarterHour);
685729
}
686730

687731
if (!entry.usage) {
@@ -767,6 +811,10 @@ export async function loadSessionCostSummary(params: {
767811
dailyMessageMap.values(),
768812
).toSorted((a, b) => a.date.localeCompare(b.date));
769813

814+
const utcQuarterHourMessageCounts: SessionUtcQuarterHourMessageCounts[] = Array.from(
815+
utcQuarterHourMessageMap.values(),
816+
).toSorted((a, b) => a.date.localeCompare(b.date) || a.quarterIndex - b.quarterIndex);
817+
770818
const dailyLatency: SessionDailyLatency[] = Array.from(dailyLatencyMap.entries())
771819
.map(([date, values]) => {
772820
const stats = computeLatencyStats(values);
@@ -814,6 +862,9 @@ export async function loadSessionCostSummary(params: {
814862
activityDates: Array.from(activityDatesSet).toSorted(),
815863
dailyBreakdown,
816864
dailyMessageCounts,
865+
utcQuarterHourMessageCounts: utcQuarterHourMessageCounts.length
866+
? utcQuarterHourMessageCounts
867+
: undefined,
817868
dailyLatency: dailyLatency.length ? dailyLatency : undefined,
818869
dailyModelUsage: dailyModelUsage.length ? dailyModelUsage : undefined,
819870
messageCounts,

src/infra/session-cost-usage.types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ export type SessionDailyMessageCounts = {
7878
errors: number;
7979
};
8080

81+
export type SessionUtcQuarterHourMessageCounts = {
82+
date: string; // YYYY-MM-DD (UTC)
83+
quarterIndex: number; // 0-95, UTC quarter-hour bucket (index = floor((utcH * 60 + utcM) / 15))
84+
total: number;
85+
user: number;
86+
assistant: number;
87+
toolCalls: number;
88+
toolResults: number;
89+
errors: number;
90+
};
91+
8192
export type SessionLatencyStats = {
8293
count: number;
8394
avgMs: number;
@@ -130,6 +141,7 @@ export type SessionCostSummary = CostUsageTotals & {
130141
activityDates?: string[]; // YYYY-MM-DD dates when session had activity
131142
dailyBreakdown?: SessionDailyUsage[]; // Per-day token/cost breakdown
132143
dailyMessageCounts?: SessionDailyMessageCounts[];
144+
utcQuarterHourMessageCounts?: SessionUtcQuarterHourMessageCounts[]; // UTC quarter-hour buckets for precise hourly stats
133145
dailyLatency?: SessionDailyLatency[];
134146
dailyModelUsage?: SessionDailyModelUsage[];
135147
messageCounts?: SessionMessageCounts;

0 commit comments

Comments
 (0)