From 851fcb2617987777c4295f0fde4839f588cd67a1 Mon Sep 17 00:00:00 2001 From: Peter Lee Date: Wed, 11 Feb 2026 21:24:08 +0800 Subject: [PATCH] feat: Add --localTime option to logs command for local timezone display (#13818) * feat: add --localTime options to make logs to show time with local time zone fix #12447 * fix: prep logs local-time option and docs (#13818) (thanks @xialonglee) --------- Co-authored-by: xialonglee Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/logs.md | 4 + .../pi-embedded-subscribe.tools.test.ts | 2 +- src/cli/logs-cli.test.ts | 80 +++++++++++++++++++ src/cli/logs-cli.ts | 28 +++++-- 5 files changed, 109 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d541b004c..c38a8ccb4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut. +- CLI: add `openclaw logs --local-time` (plus `--localTime` compatibility alias) to display log timestamps in local timezone. (#13818) Thanks @xialonglee. ## 2026.2.9 diff --git a/docs/cli/logs.md b/docs/cli/logs.md index 7de8689c5c4..4b40ed22369 100644 --- a/docs/cli/logs.md +++ b/docs/cli/logs.md @@ -21,4 +21,8 @@ openclaw logs openclaw logs --follow openclaw logs --json openclaw logs --limit 500 +openclaw logs --local-time +openclaw logs --follow --local-time ``` + +Use `--local-time` to render timestamps in your local timezone. `--localTime` is supported as a compatibility alias. diff --git a/src/agents/pi-embedded-subscribe.tools.test.ts b/src/agents/pi-embedded-subscribe.tools.test.ts index d526ac6fd3a..4e002b4083a 100644 --- a/src/agents/pi-embedded-subscribe.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.test.ts @@ -33,6 +33,6 @@ describe("extractMessagingToolSend", () => { expect(result?.tool).toBe("message"); expect(result?.provider).toBe("slack"); - expect(result?.to).toBe("channel:c1"); + expect(result?.to).toBe("channel:C1"); }); }); diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index 054badc76ba..e1eb6c5eb26 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatLogTimestamp } from "./logs-cli.js"; const callGatewayFromCli = vi.fn(); @@ -53,6 +54,40 @@ describe("logs cli", () => { expect(stderrWrites.join("")).toContain("Log cursor reset"); }); + it("wires --local-time through CLI parsing and emits local timestamps", async () => { + callGatewayFromCli.mockResolvedValueOnce({ + file: "/tmp/openclaw.log", + lines: [ + JSON.stringify({ + time: "2025-01-01T12:00:00.000Z", + _meta: { logLevelName: "INFO", name: JSON.stringify({ subsystem: "gateway" }) }, + 0: "line one", + }), + ], + }); + + const stdoutWrites: string[] = []; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutWrites.push(String(chunk)); + return true; + }); + + const { registerLogsCli } = await import("./logs-cli.js"); + const program = new Command(); + program.exitOverride(); + registerLogsCli(program); + + await program.parseAsync(["logs", "--local-time", "--plain"], { from: "user" }); + + stdoutSpy.mockRestore(); + + const output = stdoutWrites.join(""); + expect(output).toContain("line one"); + const timestamp = output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?/u)?.[0]; + expect(timestamp).toBeTruthy(); + expect(timestamp?.endsWith("Z")).toBe(false); + }); + it("warns when the output pipe closes", async () => { callGatewayFromCli.mockResolvedValueOnce({ file: "/tmp/openclaw.log", @@ -82,4 +117,49 @@ describe("logs cli", () => { expect(stderrWrites.join("")).toContain("output stdout closed"); }); + + describe("formatLogTimestamp", () => { + it("formats UTC timestamp in plain mode by default", () => { + const result = formatLogTimestamp("2025-01-01T12:00:00.000Z"); + expect(result).toBe("2025-01-01T12:00:00.000Z"); + }); + + it("formats UTC timestamp in pretty mode", () => { + const result = formatLogTimestamp("2025-01-01T12:00:00.000Z", "pretty"); + expect(result).toBe("12:00:00"); + }); + + it("formats local time in plain mode when localTime is true", () => { + const utcTime = "2025-01-01T12:00:00.000Z"; + const result = formatLogTimestamp(utcTime, "plain", true); + // Should be local time without 'Z' suffix + expect(result).not.toContain("Z"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + // The exact time depends on timezone, but should be different from UTC + expect(result).not.toBe(utcTime); + }); + + 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"); + } + }); + + it("handles empty or invalid timestamps", () => { + expect(formatLogTimestamp(undefined)).toBe(""); + expect(formatLogTimestamp("")).toBe(""); + expect(formatLogTimestamp("invalid-date")).toBe("invalid-date"); + }); + + it("preserves original value for invalid dates", () => { + const result = formatLogTimestamp("not-a-date"); + expect(result).toBe("not-a-date"); + }); + }); }); diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index f6e53bd7360..7282bdcdb36 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -26,6 +26,7 @@ type LogsCliOptions = { json?: boolean; plain?: boolean; color?: boolean; + localTime?: boolean; url?: string; token?: string; timeout?: string; @@ -59,7 +60,11 @@ async function fetchLogs( return payload as LogsTailPayload; } -function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") { +export function formatLogTimestamp( + value?: string, + mode: "pretty" | "plain" = "plain", + localTime = false, +) { if (!value) { return ""; } @@ -67,10 +72,18 @@ function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") if (Number.isNaN(parsed.getTime())) { return value; } - if (mode === "pretty") { - return parsed.toISOString().slice(11, 19); + let timeString: string; + if (localTime) { + const tzoffset = parsed.getTimezoneOffset() * 60000; // offset in milliseconds + const localISOTime = new Date(parsed.getTime() - tzoffset).toISOString().slice(0, -1); + timeString = localISOTime; + } else { + timeString = parsed.toISOString(); } - return parsed.toISOString(); + if (mode === "pretty") { + return timeString.slice(11, 19); + } + return timeString; } function formatLogLine( @@ -78,6 +91,7 @@ function formatLogLine( opts: { pretty: boolean; rich: boolean; + localTime: boolean; }, ): string { const parsed = parseLogLine(raw); @@ -85,7 +99,7 @@ function formatLogLine( return raw; } const label = parsed.subsystem ?? parsed.module ?? ""; - const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain"); + const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain", opts.localTime); const level = parsed.level ?? ""; const levelLabel = level.padEnd(5).trim(); const message = parsed.message || parsed.raw; @@ -192,6 +206,8 @@ export function registerLogsCli(program: Command) { .option("--json", "Emit JSON log lines", false) .option("--plain", "Plain text output (no ANSI styling)", false) .option("--no-color", "Disable ANSI colors") + .option("--local-time", "Display timestamps in local timezone", false) + .option("--localTime", "Alias for --local-time", false) .addHelpText( "after", () => @@ -208,6 +224,7 @@ export function registerLogsCli(program: Command) { const jsonMode = Boolean(opts.json); const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain; const rich = isRich() && opts.color !== false; + const localTime = Boolean(opts.localTime); while (true) { let payload: LogsTailPayload; @@ -279,6 +296,7 @@ export function registerLogsCli(program: Command) { formatLogLine(line, { pretty, rich, + localTime, }), ) ) {