From fafad68f8d01527ae949df1372b7f04c704b56e2 Mon Sep 17 00:00:00 2001 From: Fayenix Date: Sun, 21 Jun 2026 12:45:08 -0700 Subject: [PATCH] feat(2c): add log timezone support --- .../agent-contract/log-record.ts | 2 + .../core/config/defaults.ts | 1 + apps/memos-local-plugin/core/config/index.ts | 6 +++ apps/memos-local-plugin/core/config/schema.ts | 2 + .../core/logger/format/compact.ts | 2 +- .../core/logger/format/pretty.ts | 2 +- apps/memos-local-plugin/core/logger/index.ts | 6 +++ apps/memos-local-plugin/core/time.ts | 45 +++++++++++++++-- .../tests/unit/config/load.test.ts | 21 ++++++++ .../tests/unit/id-time.test.ts | 18 +++++++ .../tests/unit/logger/dispatch.test.ts | 48 +++++++++++++++++++ .../tests/unit/logger/format.test.ts | 30 ++++++++++++ 12 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 apps/memos-local-plugin/tests/unit/logger/format.test.ts diff --git a/apps/memos-local-plugin/agent-contract/log-record.ts b/apps/memos-local-plugin/agent-contract/log-record.ts index 81e87c072..59a35454b 100644 --- a/apps/memos-local-plugin/agent-contract/log-record.ts +++ b/apps/memos-local-plugin/agent-contract/log-record.ts @@ -56,6 +56,8 @@ export interface SerializedLogError { export interface LogRecord { /** Unix epoch milliseconds (UTC). */ ts: number; + /** IANA timezone used for display formatting. Canonical event time remains `ts`. */ + tz?: string; level: LogLevel; kind: LogKind; channel: string; diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 1cf2d2cf6..01fe0b711 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -277,6 +277,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { logging: { level: "info", detailedView: false, + timezone: "UTC", console: { enabled: true, pretty: true, channels: ["*"] }, file: { enabled: true, diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 1466c27ef..d7cb6638d 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -86,6 +86,12 @@ export function resolveConfig(raw: unknown, warnings?: string[]): ResolvedConfig }); } + try { + new Intl.DateTimeFormat("en-US", { timeZone: completed.logging.timezone }).format(0); + } catch { + throw new MemosError("config_invalid", `invalid logging.timezone: ${completed.logging.timezone}`); + } + return Object.freeze(completed) as ResolvedConfig; } diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 7c9ff193b..bce3e6bcb 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -505,6 +505,8 @@ const LoggingSchema = Type.Object({ ], { default: "info" }), /** Viewer-only switch: expose detailed logs, lifecycle tags and chain view. */ detailedView: Bool(false), + /** IANA timezone for log timestamp display. */ + timezone: StringWithDefault("UTC"), console: Type.Object({ enabled: Bool(true), pretty: Bool(true), diff --git a/apps/memos-local-plugin/core/logger/format/compact.ts b/apps/memos-local-plugin/core/logger/format/compact.ts index 4058a45b5..1d1fd0521 100644 --- a/apps/memos-local-plugin/core/logger/format/compact.ts +++ b/apps/memos-local-plugin/core/logger/format/compact.ts @@ -8,7 +8,7 @@ import type { LogRecord } from "../types.js"; export function formatCompact(record: LogRecord): string { const parts: string[] = []; - parts.push(isoFromEpochMs(record.ts)); + parts.push(isoFromEpochMs(record.ts, record.tz, { offset: true })); parts.push(record.level); parts.push(record.kind); parts.push(`channel=${record.channel}`); diff --git a/apps/memos-local-plugin/core/logger/format/pretty.ts b/apps/memos-local-plugin/core/logger/format/pretty.ts index 66da5052b..d4f225e25 100644 --- a/apps/memos-local-plugin/core/logger/format/pretty.ts +++ b/apps/memos-local-plugin/core/logger/format/pretty.ts @@ -30,7 +30,7 @@ const KIND_TAG: Record = { }; export function formatPretty(record: LogRecord, opts: { color: boolean }): string { - const ts = isoFromEpochMs(record.ts).slice(11, 23); // HH:mm:ss.SSS + const ts = isoFromEpochMs(record.ts, record.tz).slice(11, 23); // HH:mm:ss.SSS const lvl = record.level.toUpperCase().padEnd(5); const kind = KIND_TAG[record.kind] ?? ""; const channel = record.channel ?? "?"; diff --git a/apps/memos-local-plugin/core/logger/index.ts b/apps/memos-local-plugin/core/logger/index.ts index 6c4f4087c..d9ac5afb0 100644 --- a/apps/memos-local-plugin/core/logger/index.ts +++ b/apps/memos-local-plugin/core/logger/index.ts @@ -62,6 +62,8 @@ interface LoggerCore { pid: number; host: string; seq: number; + /** IANA timezone used by display formatters. */ + tz: string; /** Whether file sinks are wired up (false in pre-init / null mode). */ filesActive: boolean; } @@ -90,6 +92,7 @@ function bootstrapConsoleOnly(): LoggerCore { pid: process.pid, host: hostname(), seq: 0, + tz: "UTC", filesActive: false, }; } @@ -199,6 +202,7 @@ export function initLogger( pid: process.pid, host: hostname(), seq: 0, + tz: logging.timezone, filesActive: filesEnabled, }; @@ -229,6 +233,7 @@ export function initTestLogger(): void { pid: process.pid, host: hostname(), seq: 0, + tz: "UTC", filesActive: false, }; } @@ -360,6 +365,7 @@ function emitToCore(record: LogRecord, skipLevelGate = false): void { host: core.host, src: "ts", seq: ++core.seq, + tz: core.tz, ...record, }; const safe = core.redactor.redact(enriched); diff --git a/apps/memos-local-plugin/core/time.ts b/apps/memos-local-plugin/core/time.ts index 84830c526..7437c48d9 100644 --- a/apps/memos-local-plugin/core/time.ts +++ b/apps/memos-local-plugin/core/time.ts @@ -44,7 +44,46 @@ export function formatDurationMs(ms: number): string { return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; } -/** Convenience: ISO 8601 string for a given epoch ms (UTC). */ -export function isoFromEpochMs(ms: EpochMs): string { - return new Date(ms).toISOString(); +export interface IsoFromEpochOptions { + /** Include numeric UTC offset for localized timestamps. UTC fast path stays unchanged. */ + offset?: boolean; +} + +/** Convenience: ISO 8601 string for a given epoch ms. Defaults to UTC. */ +export function isoFromEpochMs(ms: EpochMs, tz?: string, opts: IsoFromEpochOptions = {}): string { + if (!tz || tz === "UTC") return new Date(ms).toISOString(); + const d = new Date(ms); + const parts = dateTimeParts(d, tz); + const base = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond ?? "000"}`; + return opts.offset ? base + offsetForTimeZone(d, tz) : base; +} + +function dateTimeParts(d: Date, tz: string): Record { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + hour12: false, + }); + const out: Record = {}; + for (const part of fmt.formatToParts(d)) { + if (part.type !== "literal") out[part.type] = part.value; + } + return out; +} + +function offsetForTimeZone(d: Date, tz: string): string { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + timeZoneName: "longOffset", + hour: "2-digit", + }); + const zone = fmt.formatToParts(d).find((part) => part.type === "timeZoneName")?.value; + if (!zone || zone === "GMT" || zone === "UTC") return "+00:00"; + return zone.replace(/^GMT/, ""); } diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 77f2f6b3f..b25ba4832 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; +import { MemosError } from "../../../agent-contract/errors.js"; import { DEFAULT_CONFIG, loadConfig, resolveConfig, resolveHome } from "../../../core/config/index.js"; import { makeTmpHome } from "../../helpers/tmp-home.js"; @@ -21,6 +22,26 @@ describe("config/loadConfig", () => { expect(result.config.embedding.provider).toBe(DEFAULT_CONFIG.embedding.provider); }); + + it("defaults logging.timezone to UTC and accepts custom IANA zones", () => { + expect(resolveConfig({}).logging.timezone).toBe("UTC"); + const cfg = resolveConfig({ logging: { timezone: "America/Los_Angeles" } }); + expect(cfg.logging.timezone).toBe("America/Los_Angeles"); + }); + + it("rejects invalid logging.timezone with config_invalid", () => { + expect(() => resolveConfig({ logging: { timezone: "Not/AZone" } })).toThrow(MemosError); + try { + resolveConfig({ logging: { timezone: "Not/AZone" } }); + throw new Error("expected resolveConfig to reject invalid timezone"); + } catch (err) { + expect(MemosError.is(err)).toBe(true); + expect((err as MemosError).code).toBe("config_invalid"); + expect((err as MemosError).message).toContain("invalid logging.timezone"); + } + }); + + it("merges YAML over defaults and preserves unspecified branches", async () => { const yaml = ` viewer: diff --git a/apps/memos-local-plugin/tests/unit/id-time.test.ts b/apps/memos-local-plugin/tests/unit/id-time.test.ts index 5f277640d..5b3f4dabc 100644 --- a/apps/memos-local-plugin/tests/unit/id-time.test.ts +++ b/apps/memos-local-plugin/tests/unit/id-time.test.ts @@ -56,4 +56,22 @@ describe("core/time", () => { const t = 1_700_000_000_000; expect(isoFromEpochMs(t)).toBe(new Date(t).toISOString()); }); + + it("isoFromEpochMs stays byte-identical to Date.toISOString by default and for UTC", () => { + const t = Date.UTC(2026, 5, 21, 21, 30, 45, 123); + expect(isoFromEpochMs(t)).toBe(new Date(t).toISOString()); + expect(isoFromEpochMs(t, "UTC")).toBe(new Date(t).toISOString()); + }); + + it("isoFromEpochMs formats local wall time for a configured timezone", () => { + const t = Date.UTC(2026, 5, 21, 21, 30, 45, 123); + expect(isoFromEpochMs(t, "America/Los_Angeles")).toBe("2026-06-21T14:30:45.123"); + }); + + it("isoFromEpochMs can include numeric UTC offset for compact logs", () => { + const summer = Date.UTC(2026, 5, 21, 21, 30, 45, 123); + const winter = Date.UTC(2026, 11, 21, 21, 30, 45, 123); + expect(isoFromEpochMs(summer, "America/Los_Angeles", { offset: true })).toBe("2026-06-21T14:30:45.123-07:00"); + expect(isoFromEpochMs(winter, "America/Los_Angeles", { offset: true })).toBe("2026-12-21T13:30:45.123-08:00"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts b/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts index ee3297761..93b8712f5 100644 --- a/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts +++ b/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts @@ -155,4 +155,52 @@ logging: await rootLogger.flush(); expect(memoryBuffer().tail({ limit: 1 }).at(0)?.msg).toBe("py.heartbeat"); }); + + it("attaches configured timezone to locally-created records", async () => { + const yaml = ` +logging: + timezone: America/Los_Angeles +`; + const ctx = await makeTmpHome({ agent: "openclaw", configYaml: yaml }); + cleanup = ctx.cleanup; + initLogger(ctx.config, ctx.home); + + rootLogger.child({ channel: "core.session" }).info("timezone.local"); + await rootLogger.flush(); + + expect(memoryBuffer().tail({ limit: 1 }).at(0)?.tz).toBe("America/Los_Angeles"); + }); + + it("forward preserves explicit timezone and defaults missing timezone", async () => { + const yaml = ` +logging: + timezone: America/Los_Angeles +`; + const ctx = await makeTmpHome({ agent: "openclaw", configYaml: yaml }); + cleanup = ctx.cleanup; + initLogger(ctx.config, ctx.home); + + const log = rootLogger.child({ channel: "adapter.hermes" }); + log.forward({ + ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123), + level: "info", + kind: "app", + channel: "adapter.hermes", + msg: "py.explicit", + src: "py", + tz: "Europe/London", + }); + log.forward({ + ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123), + level: "info", + kind: "app", + channel: "adapter.hermes", + msg: "py.defaulted", + src: "py", + }); + + const tail = memoryBuffer().tail({ limit: 2 }); + expect(tail.find((r) => r.msg === "py.explicit")?.tz).toBe("Europe/London"); + expect(tail.find((r) => r.msg === "py.defaulted")?.tz).toBe("America/Los_Angeles"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/logger/format.test.ts b/apps/memos-local-plugin/tests/unit/logger/format.test.ts new file mode 100644 index 000000000..2d631d64e --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/logger/format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import type { LogRecord } from "../../../agent-contract/log-record.js"; +import { formatCompact } from "../../../core/logger/format/compact.js"; +import { formatPretty } from "../../../core/logger/format/pretty.js"; + +const base: LogRecord = { + ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123), + level: "info", + kind: "app", + channel: "core.session", + msg: "session.opened", +}; + +describe("logger/format", () => { + it("pretty formatter displays configured local time", () => { + const out = formatPretty({ ...base, tz: "America/Los_Angeles" }, { color: false }); + expect(out.startsWith("14:30:45.123 INFO [core.session] session.opened")).toBe(true); + }); + + it("compact formatter preserves UTC default output", () => { + const out = formatCompact(base); + expect(out.startsWith("2026-06-21T21:30:45.123Z info app ")).toBe(true); + }); + + it("compact formatter emits offset-bearing local timestamp", () => { + const out = formatCompact({ ...base, tz: "America/Los_Angeles" }); + expect(out.startsWith("2026-06-21T14:30:45.123-07:00 info app ")).toBe(true); + }); +});