Skip to content

Commit de84aee

Browse files
committed
fix: unify log timestamp offsets (#38904) (thanks @sahilsatralkar)
1 parent 3e2e9bc commit de84aee

File tree

9 files changed

+100
-41
lines changed

9 files changed

+100
-41
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
3838
- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924.
3939
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
4040
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
41+
- CLI/logging: make pretty log timestamps always include an explicit timezone offset in default UTC and `--local-time` modes, so incident triage no longer mixes ambiguous clock displays. (#38904) Thanks @sahilsatralkar.
4142
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
4243
- DeepSeek/pricing: replace the zero-cost DeepSeek catalog rates with the current DeepSeek V3.2 pricing so usage totals stop showing `$0.00` for DeepSeek sessions. (#54143) Thanks @arkyu2077.
4344
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.

src/cli/logs-cli.test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe("logs cli", () => {
117117

118118
it("formats UTC timestamp in pretty mode", () => {
119119
const result = formatLogTimestamp("2025-01-01T12:00:00.000Z", "pretty");
120-
expect(result).toBe("12:00:00");
120+
expect(result).toBe("12:00:00+00:00");
121121
});
122122

123123
it("formats local time in plain mode when localTime is true", () => {
@@ -132,13 +132,8 @@ describe("logs cli", () => {
132132
it("formats local time in pretty mode when localTime is true", () => {
133133
const utcTime = "2025-01-01T12:00:00.000Z";
134134
const result = formatLogTimestamp(utcTime, "pretty", true);
135-
// Should be HH:MM:SS format
136-
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/);
137-
// Should be different from UTC time (12:00:00) if not in UTC timezone
138-
const tzOffset = new Date(utcTime).getTimezoneOffset();
139-
if (tzOffset !== 0) {
140-
expect(result).not.toBe("12:00:00");
141-
}
135+
// Should be HH:MM:SS±HH:MM format with timezone offset.
136+
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
142137
});
143138

144139
it.each([

src/cli/logs-cli.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
22
import type { Command } from "commander";
33
import { buildGatewayConnectionDetails } from "../gateway/call.js";
44
import { parseLogLine } from "../logging/parse-log-line.js";
5-
import { formatLocalIsoWithOffset, isValidTimeZone } from "../logging/timestamps.js";
5+
import { formatTimestamp, isValidTimeZone } from "../logging/timestamps.js";
66
import { formatDocsLink } from "../terminal/links.js";
77
import { clearActiveProgressLine } from "../terminal/progress-line.js";
88
import { createSafeStreamWriter } from "../terminal/stream-writer.js";
@@ -74,16 +74,10 @@ export function formatLogTimestamp(
7474
return value;
7575
}
7676

77-
let timeString: string;
78-
if (localTime) {
79-
timeString = formatLocalIsoWithOffset(parsed);
80-
} else {
81-
timeString = parsed.toISOString();
82-
}
8377
if (mode === "pretty") {
84-
return timeString.slice(11, 19);
78+
return formatTimestamp(parsed, { style: "short", timeZone: localTime ? undefined : "UTC" });
8579
}
86-
return timeString;
80+
return localTime ? formatTimestamp(parsed, { style: "long" }) : parsed.toISOString();
8781
}
8882

8983
function formatLogLine(

src/infra/format-time/format-datetime.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
* Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone
66
* that previously lived in envelope.ts and session-updates.ts.
77
*/
8-
98
/**
109
* Validate an IANA timezone string. Returns the string if valid, undefined otherwise.
1110
*/

src/logging/console-timestamp.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,20 @@ describe("formatConsoleTimestamp", () => {
2929
return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`;
3030
}
3131

32-
it("pretty style returns local HH:MM:SS", () => {
32+
it("pretty style returns local HH:MM:SS with timezone offset", () => {
3333
vi.useFakeTimers();
3434
vi.setSystemTime(new Date("2026-01-17T18:01:02.345Z"));
3535

3636
const result = formatConsoleTimestamp("pretty");
3737
const now = new Date();
38-
expect(result).toBe(
39-
`${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`,
40-
);
38+
const h = pad2(now.getHours());
39+
const m = pad2(now.getMinutes());
40+
const s = pad2(now.getSeconds());
41+
const tzOffset = now.getTimezoneOffset();
42+
const tzSign = tzOffset <= 0 ? "+" : "-";
43+
const tzHours = pad2(Math.floor(Math.abs(tzOffset) / 60));
44+
const tzMinutes = pad2(Math.abs(tzOffset) % 60);
45+
expect(result).toBe(`${h}:${m}:${s}${tzSign}${tzHours}:${tzMinutes}`);
4146
});
4247

4348
it("compact style returns local ISO-like timestamp with timezone offset", () => {

src/logging/console.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { type LogLevel, normalizeLogLevel } from "./levels.js";
88
import { getLogger, type LoggerSettings } from "./logger.js";
99
import { resolveNodeRequireFromMeta } from "./node-require.js";
1010
import { loggingState } from "./state.js";
11-
import { formatLocalIsoWithOffset } from "./timestamps.js";
11+
import { formatLocalIsoWithOffset, formatTimestamp } from "./timestamps.js";
1212

1313
export type ConsoleStyle = "pretty" | "compact" | "json";
1414
type ConsoleSettings = {
@@ -175,10 +175,7 @@ function isEpipeError(err: unknown): boolean {
175175
export function formatConsoleTimestamp(style: ConsoleStyle): string {
176176
const now = new Date();
177177
if (style === "pretty") {
178-
const h = String(now.getHours()).padStart(2, "0");
179-
const m = String(now.getMinutes()).padStart(2, "0");
180-
const s = String(now.getSeconds()).padStart(2, "0");
181-
return `${h}:${m}:${s}`;
178+
return formatTimestamp(now, { style: "short" });
182179
}
183180
return formatLocalIsoWithOffset(now);
184181
}

src/logging/logger.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { resolveEnvLogLevelOverride } from "./env-log-level.js";
1313
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
1414
import { resolveNodeRequireFromMeta } from "./node-require.js";
1515
import { loggingState } from "./state.js";
16-
import { formatLocalIsoWithOffset } from "./timestamps.js";
16+
import { formatTimestamp } from "./timestamps.js";
1717

1818
type ProcessWithBuiltinModule = NodeJS.Process & {
1919
getBuiltinModule?: (id: string) => unknown;
@@ -185,7 +185,7 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
185185

186186
logger.attachTransport((logObj: LogObj) => {
187187
try {
188-
const time = formatLocalIsoWithOffset(logObj.date ?? new Date());
188+
const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" });
189189
const line = JSON.stringify({ ...logObj, time });
190190
const payload = `${line}\n`;
191191
const payloadBytes = Buffer.byteLength(payload, "utf8");
@@ -194,7 +194,7 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
194194
if (!warnedAboutSizeCap) {
195195
warnedAboutSizeCap = true;
196196
const warningLine = JSON.stringify({
197-
time: formatLocalIsoWithOffset(new Date()),
197+
time: formatTimestamp(new Date(), { style: "long" }),
198198
level: "warn",
199199
subsystem: "logging",
200200
message: `log file size cap reached; suppressing writes file=${settings.file} maxFileBytes=${settings.maxFileBytes}`,

src/logging/timestamps.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from "node:fs";
22
import * as path from "node:path";
33
import { describe, expect, it } from "vitest";
4-
import { formatLocalIsoWithOffset, isValidTimeZone } from "./timestamps.js";
4+
import { formatLocalIsoWithOffset, formatTimestamp, isValidTimeZone } from "./timestamps.js";
55

66
describe("formatLocalIsoWithOffset", () => {
77
const testDate = new Date("2025-01-01T04:00:00.000Z");
@@ -50,6 +50,35 @@ describe("formatLocalIsoWithOffset", () => {
5050
});
5151
});
5252

53+
describe("formatTimestamp", () => {
54+
const testDate = new Date("2024-01-15T14:30:45.123Z");
55+
56+
it("formats short style with explicit UTC offset", () => {
57+
expect(formatTimestamp(testDate, { style: "short", timeZone: "UTC" })).toBe("14:30:45+00:00");
58+
});
59+
60+
it("formats medium style with milliseconds and offset", () => {
61+
expect(formatTimestamp(testDate, { style: "medium", timeZone: "UTC" })).toBe(
62+
"14:30:45.123+00:00",
63+
);
64+
});
65+
66+
it.each(["UTC", "America/New_York", "Europe/Paris"])(
67+
"matches formatLocalIsoWithOffset for long style in %s",
68+
(timeZone) => {
69+
expect(formatTimestamp(testDate, { style: "long", timeZone })).toBe(
70+
formatLocalIsoWithOffset(testDate, timeZone),
71+
);
72+
},
73+
);
74+
75+
it("falls back to a valid offset when the timezone is invalid", () => {
76+
expect(formatTimestamp(testDate, { style: "short", timeZone: "not-a-tz" })).toMatch(
77+
/^\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/,
78+
);
79+
});
80+
});
81+
5382
describe("isValidTimeZone", () => {
5483
it("returns true for valid IANA timezones", () => {
5584
expect(isValidTimeZone("UTC")).toBe(true);

src/logging/timestamps.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,27 @@ export function isValidTimeZone(tz: string): boolean {
77
}
88
}
99

10-
export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string {
10+
export type TimestampStyle = "short" | "medium" | "long";
11+
12+
export type FormatTimestampOptions = {
13+
style?: TimestampStyle;
14+
timeZone?: string;
15+
};
16+
17+
function resolveEffectiveTimeZone(timeZone?: string): string {
1118
const explicit = timeZone ?? process.env.TZ;
12-
const tz =
13-
explicit && isValidTimeZone(explicit)
14-
? explicit
15-
: Intl.DateTimeFormat().resolvedOptions().timeZone;
19+
return explicit && isValidTimeZone(explicit)
20+
? explicit
21+
: Intl.DateTimeFormat().resolvedOptions().timeZone;
22+
}
23+
24+
function formatOffset(offsetRaw: string): string {
25+
return offsetRaw === "GMT" ? "+00:00" : offsetRaw.slice(3);
26+
}
1627

28+
function getTimestampParts(date: Date, timeZone?: string) {
1729
const fmt = new Intl.DateTimeFormat("en", {
18-
timeZone: tz,
30+
timeZone: resolveEffectiveTimeZone(timeZone),
1931
year: "numeric",
2032
month: "2-digit",
2133
day: "2-digit",
@@ -27,10 +39,37 @@ export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string {
2739
timeZoneName: "longOffset",
2840
});
2941

30-
const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value]));
42+
const parts = Object.fromEntries(fmt.formatToParts(date).map((part) => [part.type, part.value]));
43+
return {
44+
year: parts.year,
45+
month: parts.month,
46+
day: parts.day,
47+
hour: parts.hour,
48+
minute: parts.minute,
49+
second: parts.second,
50+
fractionalSecond: parts.fractionalSecond,
51+
offset: formatOffset(parts.timeZoneName ?? "GMT"),
52+
};
53+
}
3154

32-
const offsetRaw = parts.timeZoneName ?? "GMT";
33-
const offset = offsetRaw === "GMT" ? "+00:00" : offsetRaw.slice(3);
55+
export function formatTimestamp(date: Date, options?: FormatTimestampOptions): string {
56+
const style = options?.style ?? "medium";
57+
const parts = getTimestampParts(date, options?.timeZone);
3458

35-
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${offset}`;
59+
switch (style) {
60+
case "short":
61+
return `${parts.hour}:${parts.minute}:${parts.second}${parts.offset}`;
62+
case "medium":
63+
return `${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${parts.offset}`;
64+
case "long":
65+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${parts.offset}`;
66+
}
67+
}
68+
69+
/**
70+
* @deprecated Use formatTimestamp from "./timestamps.js" instead.
71+
* This function will be removed in a future version.
72+
*/
73+
export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string {
74+
return formatTimestamp(now, { style: "long", timeZone });
3675
}

0 commit comments

Comments
 (0)