From 9f98d2766a500519b89a77472cf651380429feab Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Mon, 2 Mar 2026 15:44:37 +0100 Subject: [PATCH] fix(logs): respect TZ env var for timestamp display, fix Windows timezone (#21859) --- src/cli/logs-cli.ts | 5 +- src/logging/timestamps.test.ts | 109 ++++++++++++++++++--------------- src/logging/timestamps.ts | 48 +++++++++++---- 3 files changed, 96 insertions(+), 66 deletions(-) diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index afd3a2cd1ff..17e273f6550 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 } from "../logging/timestamps.js"; +import { formatLocalIsoWithOffset, 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"; @@ -223,7 +223,8 @@ 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); + const localTime = + Boolean(opts.localTime) || (!!process.env.TZ && isValidTimeZone(process.env.TZ)); while (true) { let payload: LogsTailPayload; diff --git a/src/logging/timestamps.test.ts b/src/logging/timestamps.test.ts index f2d72125987..d0f5af9191b 100644 --- a/src/logging/timestamps.test.ts +++ b/src/logging/timestamps.test.ts @@ -1,58 +1,65 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { describe, expect, it } from "vitest"; -import { formatLocalIsoWithOffset } from "./timestamps.js"; - -function buildFakeDate(parts: { - year: number; - month: number; - day: number; - hour: number; - minute: number; - second: number; - millisecond: number; - timezoneOffsetMinutes: number; -}): Date { - return { - getFullYear: () => parts.year, - getMonth: () => parts.month - 1, - getDate: () => parts.day, - getHours: () => parts.hour, - getMinutes: () => parts.minute, - getSeconds: () => parts.second, - getMilliseconds: () => parts.millisecond, - getTimezoneOffset: () => parts.timezoneOffsetMinutes, - } as unknown as Date; -} +import { formatLocalIsoWithOffset, isValidTimeZone } from "./timestamps.js"; describe("formatLocalIsoWithOffset", () => { - it("formats positive offset with millisecond padding", () => { - const value = formatLocalIsoWithOffset( - buildFakeDate({ - year: 2026, - month: 1, - day: 2, - hour: 3, - minute: 4, - second: 5, - millisecond: 6, - timezoneOffsetMinutes: -150, // UTC+02:30 - }), - ); - expect(value).toBe("2026-01-02T03:04:05.006+02:30"); + const testDate = new Date("2025-01-01T04:00:00.000Z"); + + it("produces +00:00 offset for UTC", () => { + const result = formatLocalIsoWithOffset(testDate, "UTC"); + expect(result).toBe("2025-01-01T04:00:00.000+00:00"); }); - it("formats negative offset", () => { - const value = formatLocalIsoWithOffset( - buildFakeDate({ - year: 2026, - month: 12, - day: 31, - hour: 23, - minute: 59, - second: 58, - millisecond: 321, - timezoneOffsetMinutes: 300, // UTC-05:00 - }), - ); - expect(value).toBe("2026-12-31T23:59:58.321-05:00"); + it("produces +08:00 offset for Asia/Shanghai", () => { + const result = formatLocalIsoWithOffset(testDate, "Asia/Shanghai"); + expect(result).toBe("2025-01-01T12:00:00.000+08:00"); + }); + + it("produces correct offset for America/New_York", () => { + const result = formatLocalIsoWithOffset(testDate, "America/New_York"); + // January is EST = UTC-5 + expect(result).toBe("2024-12-31T23:00:00.000-05:00"); + }); + + it("produces correct offset for America/New_York in summer (EDT)", () => { + const summerDate = new Date("2025-07-01T12:00:00.000Z"); + const result = formatLocalIsoWithOffset(summerDate, "America/New_York"); + // July is EDT = UTC-4 + expect(result).toBe("2025-07-01T08:00:00.000-04:00"); + }); + + it("outputs a valid ISO 8601 string with offset", () => { + const result = formatLocalIsoWithOffset(testDate, "Asia/Shanghai"); + // ISO 8601 with offset: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM + const iso8601WithOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/; + expect(result).toMatch(iso8601WithOffset); + }); + + it("falls back gracefully for an invalid timezone", () => { + const result = formatLocalIsoWithOffset(testDate, "not-a-tz"); + const iso8601WithOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/; + expect(result).toMatch(iso8601WithOffset); + }); + + it("does NOT use getHours, getMinutes, getTimezoneOffset in the implementation", () => { + const source = fs.readFileSync(path.resolve(__dirname, "timestamps.ts"), "utf-8"); + expect(source).not.toMatch(/\.getHours\s*\(/); + expect(source).not.toMatch(/\.getMinutes\s*\(/); + expect(source).not.toMatch(/\.getTimezoneOffset\s*\(/); + }); +}); + +describe("isValidTimeZone", () => { + it("returns true for valid IANA timezones", () => { + expect(isValidTimeZone("UTC")).toBe(true); + expect(isValidTimeZone("America/New_York")).toBe(true); + expect(isValidTimeZone("Asia/Shanghai")).toBe(true); + }); + + it("returns false for invalid timezone strings", () => { + expect(isValidTimeZone("not-a-tz")).toBe(false); + expect(isValidTimeZone("yo agent's")).toBe(false); + expect(isValidTimeZone("")).toBe(false); }); }); diff --git a/src/logging/timestamps.ts b/src/logging/timestamps.ts index 9945630b03b..5e43957cea7 100644 --- a/src/logging/timestamps.ts +++ b/src/logging/timestamps.ts @@ -1,14 +1,36 @@ -export function formatLocalIsoWithOffset(now: Date): string { - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const h = String(now.getHours()).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - const s = String(now.getSeconds()).padStart(2, "0"); - const ms = String(now.getMilliseconds()).padStart(3, "0"); - const tzOffset = now.getTimezoneOffset(); - const tzSign = tzOffset <= 0 ? "+" : "-"; - const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, "0"); - const tzMinutes = String(Math.abs(tzOffset) % 60).padStart(2, "0"); - return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`; +export function isValidTimeZone(tz: string): boolean { + try { + new Intl.DateTimeFormat("en", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +export function formatLocalIsoWithOffset(now: Date, timeZone?: string): string { + const explicit = timeZone ?? process.env.TZ; + const tz = + explicit && isValidTimeZone(explicit) + ? explicit + : Intl.DateTimeFormat().resolvedOptions().timeZone; + + const fmt = new Intl.DateTimeFormat("en", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + fractionalSecondDigits: 3 as 1 | 2 | 3, + 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}`; }