fix(logs): respect TZ env var for timestamp display, fix Windows timezone (#21859)

This commit is contained in:
Robin Waslander
2026-03-02 15:44:37 +01:00
committed by GitHub
parent 944abe0a6c
commit 9f98d2766a
3 changed files with 96 additions and 66 deletions

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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}`;
}