diff --git a/CHANGELOG.md b/CHANGELOG.md index 95837e4bb1c..2e7198762c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924. - 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 - Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman. +- 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. - 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. - 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. - 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. diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index 0cc738b99c6..0d3a0fed8c6 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -117,7 +117,7 @@ describe("logs cli", () => { it("formats UTC timestamp in pretty mode", () => { const result = formatLogTimestamp("2025-01-01T12:00:00.000Z", "pretty"); - expect(result).toBe("12:00:00"); + expect(result).toBe("12:00:00+00:00"); }); it("formats local time in plain mode when localTime is true", () => { @@ -132,13 +132,8 @@ describe("logs cli", () => { it("formats local time in pretty mode when localTime is true", () => { const utcTime = "2025-01-01T12:00:00.000Z"; const result = formatLogTimestamp(utcTime, "pretty", true); - // Should be HH:MM:SS format - expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); - // Should be different from UTC time (12:00:00) if not in UTC timezone - const tzOffset = new Date(utcTime).getTimezoneOffset(); - if (tzOffset !== 0) { - expect(result).not.toBe("12:00:00"); - } + // Should be HH:MM:SS±HH:MM format with timezone offset. + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/); }); it.each([ diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 17e273f6550..69b4fdf2a9f 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises"; import type { Command } from "commander"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { parseLogLine } from "../logging/parse-log-line.js"; -import { formatLocalIsoWithOffset, isValidTimeZone } from "../logging/timestamps.js"; +import { formatTimestamp, isValidTimeZone } from "../logging/timestamps.js"; import { formatDocsLink } from "../terminal/links.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; import { createSafeStreamWriter } from "../terminal/stream-writer.js"; @@ -74,16 +74,10 @@ export function formatLogTimestamp( return value; } - let timeString: string; - if (localTime) { - timeString = formatLocalIsoWithOffset(parsed); - } else { - timeString = parsed.toISOString(); - } if (mode === "pretty") { - return timeString.slice(11, 19); + return formatTimestamp(parsed, { style: "short", timeZone: localTime ? undefined : "UTC" }); } - return timeString; + return localTime ? formatTimestamp(parsed, { style: "long" }) : parsed.toISOString(); } function formatLogLine( diff --git a/src/infra/format-time/format-datetime.ts b/src/infra/format-time/format-datetime.ts index 37cdf713f8d..e2b772493e2 100644 --- a/src/infra/format-time/format-datetime.ts +++ b/src/infra/format-time/format-datetime.ts @@ -5,7 +5,6 @@ * Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone * that previously lived in envelope.ts and session-updates.ts. */ - /** * Validate an IANA timezone string. Returns the string if valid, undefined otherwise. */ diff --git a/src/logging/console-timestamp.test.ts b/src/logging/console-timestamp.test.ts index a15183c70ca..51136d6da79 100644 --- a/src/logging/console-timestamp.test.ts +++ b/src/logging/console-timestamp.test.ts @@ -29,15 +29,20 @@ describe("formatConsoleTimestamp", () => { return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`; } - it("pretty style returns local HH:MM:SS", () => { + it("pretty style returns local HH:MM:SS with timezone offset", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-17T18:01:02.345Z")); const result = formatConsoleTimestamp("pretty"); const now = new Date(); - expect(result).toBe( - `${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`, - ); + const h = pad2(now.getHours()); + const m = pad2(now.getMinutes()); + const s = pad2(now.getSeconds()); + const tzOffset = now.getTimezoneOffset(); + const tzSign = tzOffset <= 0 ? "+" : "-"; + const tzHours = pad2(Math.floor(Math.abs(tzOffset) / 60)); + const tzMinutes = pad2(Math.abs(tzOffset) % 60); + expect(result).toBe(`${h}:${m}:${s}${tzSign}${tzHours}:${tzMinutes}`); }); it("compact style returns local ISO-like timestamp with timezone offset", () => { diff --git a/src/logging/console.ts b/src/logging/console.ts index dbf8899ae8a..73291f9edc9 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -8,7 +8,7 @@ import { type LogLevel, normalizeLogLevel } from "./levels.js"; import { getLogger, type LoggerSettings } from "./logger.js"; import { resolveNodeRequireFromMeta } from "./node-require.js"; import { loggingState } from "./state.js"; -import { formatLocalIsoWithOffset } from "./timestamps.js"; +import { formatLocalIsoWithOffset, formatTimestamp } from "./timestamps.js"; export type ConsoleStyle = "pretty" | "compact" | "json"; type ConsoleSettings = { @@ -175,10 +175,7 @@ function isEpipeError(err: unknown): boolean { export function formatConsoleTimestamp(style: ConsoleStyle): string { const now = new Date(); if (style === "pretty") { - const h = String(now.getHours()).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - const s = String(now.getSeconds()).padStart(2, "0"); - return `${h}:${m}:${s}`; + return formatTimestamp(now, { style: "short" }); } return formatLocalIsoWithOffset(now); } diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 934cdcc28c4..5f2a2623298 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -13,7 +13,7 @@ import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; import { resolveNodeRequireFromMeta } from "./node-require.js"; import { loggingState } from "./state.js"; -import { formatLocalIsoWithOffset } from "./timestamps.js"; +import { formatTimestamp } from "./timestamps.js"; type ProcessWithBuiltinModule = NodeJS.Process & { getBuiltinModule?: (id: string) => unknown; @@ -185,7 +185,7 @@ function buildLogger(settings: ResolvedSettings): TsLogger { logger.attachTransport((logObj: LogObj) => { try { - const time = formatLocalIsoWithOffset(logObj.date ?? new Date()); + const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" }); const line = JSON.stringify({ ...logObj, time }); const payload = `${line}\n`; const payloadBytes = Buffer.byteLength(payload, "utf8"); @@ -194,7 +194,7 @@ function buildLogger(settings: ResolvedSettings): TsLogger { if (!warnedAboutSizeCap) { warnedAboutSizeCap = true; const warningLine = JSON.stringify({ - time: formatLocalIsoWithOffset(new Date()), + time: formatTimestamp(new Date(), { style: "long" }), level: "warn", subsystem: "logging", message: `log file size cap reached; suppressing writes file=${settings.file} maxFileBytes=${settings.maxFileBytes}`, diff --git a/src/logging/timestamps.test.ts b/src/logging/timestamps.test.ts index d0f5af9191b..c36c98cbc82 100644 --- a/src/logging/timestamps.test.ts +++ b/src/logging/timestamps.test.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { describe, expect, it } from "vitest"; -import { formatLocalIsoWithOffset, isValidTimeZone } from "./timestamps.js"; +import { formatLocalIsoWithOffset, formatTimestamp, isValidTimeZone } from "./timestamps.js"; describe("formatLocalIsoWithOffset", () => { const testDate = new Date("2025-01-01T04:00:00.000Z"); @@ -50,6 +50,35 @@ describe("formatLocalIsoWithOffset", () => { }); }); +describe("formatTimestamp", () => { + const testDate = new Date("2024-01-15T14:30:45.123Z"); + + it("formats short style with explicit UTC offset", () => { + expect(formatTimestamp(testDate, { style: "short", timeZone: "UTC" })).toBe("14:30:45+00:00"); + }); + + it("formats medium style with milliseconds and offset", () => { + expect(formatTimestamp(testDate, { style: "medium", timeZone: "UTC" })).toBe( + "14:30:45.123+00:00", + ); + }); + + it.each(["UTC", "America/New_York", "Europe/Paris"])( + "matches formatLocalIsoWithOffset for long style in %s", + (timeZone) => { + expect(formatTimestamp(testDate, { style: "long", timeZone })).toBe( + formatLocalIsoWithOffset(testDate, timeZone), + ); + }, + ); + + it("falls back to a valid offset when the timezone is invalid", () => { + expect(formatTimestamp(testDate, { style: "short", timeZone: "not-a-tz" })).toMatch( + /^\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/, + ); + }); +}); + describe("isValidTimeZone", () => { it("returns true for valid IANA timezones", () => { expect(isValidTimeZone("UTC")).toBe(true); diff --git a/src/logging/timestamps.ts b/src/logging/timestamps.ts index 5e43957cea7..29e0536873b 100644 --- a/src/logging/timestamps.ts +++ b/src/logging/timestamps.ts @@ -7,15 +7,27 @@ export function isValidTimeZone(tz: string): boolean { } } -export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string { - const explicit = timeZone ?? process.env.TZ; - const tz = - explicit && isValidTimeZone(explicit) - ? explicit - : Intl.DateTimeFormat().resolvedOptions().timeZone; +export type TimestampStyle = "short" | "medium" | "long"; +export type FormatTimestampOptions = { + style?: TimestampStyle; + timeZone?: string; +}; + +function resolveEffectiveTimeZone(timeZone?: string): string { + const explicit = timeZone ?? process.env.TZ; + return explicit && isValidTimeZone(explicit) + ? explicit + : Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function formatOffset(offsetRaw: string): string { + return offsetRaw === "GMT" ? "+00:00" : offsetRaw.slice(3); +} + +function getTimestampParts(date: Date, timeZone?: string) { const fmt = new Intl.DateTimeFormat("en", { - timeZone: tz, + timeZone: resolveEffectiveTimeZone(timeZone), year: "numeric", month: "2-digit", day: "2-digit", @@ -27,10 +39,37 @@ export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string { timeZoneName: "longOffset", }); - const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value])); - - const offsetRaw = parts.timeZoneName ?? "GMT"; - const offset = offsetRaw === "GMT" ? "+00:00" : offsetRaw.slice(3); - - return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${offset}`; + const parts = Object.fromEntries(fmt.formatToParts(date).map((part) => [part.type, part.value])); + return { + year: parts.year, + month: parts.month, + day: parts.day, + hour: parts.hour, + minute: parts.minute, + second: parts.second, + fractionalSecond: parts.fractionalSecond, + offset: formatOffset(parts.timeZoneName ?? "GMT"), + }; +} + +export function formatTimestamp(date: Date, options?: FormatTimestampOptions): string { + const style = options?.style ?? "medium"; + const parts = getTimestampParts(date, options?.timeZone); + + switch (style) { + case "short": + return `${parts.hour}:${parts.minute}:${parts.second}${parts.offset}`; + case "medium": + return `${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${parts.offset}`; + case "long": + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}${parts.offset}`; + } +} + +/** + * @deprecated Use formatTimestamp from "./timestamps.js" instead. + * This function will be removed in a future version. + */ +export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string { + return formatTimestamp(now, { style: "long", timeZone }); }