diff --git a/packages/logger/src/formatter/LogFormatter.ts b/packages/logger/src/formatter/LogFormatter.ts index be14cc537d..c86cf4a8b4 100644 --- a/packages/logger/src/formatter/LogFormatter.ts +++ b/packages/logger/src/formatter/LogFormatter.ts @@ -15,6 +15,12 @@ import type { LogItem } from './LogItem.js'; * @abstract */ abstract class LogFormatter { + /** + * Whether a warning about an unresolvable timezone has already been emitted, + * used to avoid logging the same warning on every formatted log entry. + */ + #timezoneWarningEmitted = false; + /** * Format key-value pairs of log attributes. * @@ -175,19 +181,44 @@ abstract class LogFormatter { return ''; } + /** + * Resolve the time zone to use for formatting timestamps. + * + * Validation is delegated to `Intl.DateTimeFormat` itself, so both canonical + * identifiers (e.g. `Asia/Calcutta`) and their aliases (e.g. `Asia/Kolkata`) + * are accepted. + * + * If the provided time zone cannot be resolved - e.g. `TZ` is set to + * `:/etc/localtime` in certain Docker images - we fall back to UTC and emit + * a one-time warning. + * + * @param timezone - IANA time zone identifier (e.g., "Asia/Dhaka"). + */ + readonly #resolveTimezone = (timezone: string): string => { + try { + new Intl.DateTimeFormat('en', { timeZone: timezone }); + return timezone; + } catch { + if (!this.#timezoneWarningEmitted) { + this.#timezoneWarningEmitted = true; + console.warn( + `Invalid or unresolvable time zone: "${timezone}" - falling back to UTC.` + ); + } + return 'UTC'; + } + }; + /** * Create a new Intl.DateTimeFormat object configured with the specified time zone * and formatting options. * * The time is displayed in 24-hour format (hour12: false). * - * @param timezone - IANA time zone identifier (e.g., "Asia/Dhaka"). + * @param timezone - resolvable IANA time zone identifier (e.g., "Asia/Dhaka"). */ readonly #getDateFormatter = (timezone: string): Intl.DateTimeFormat => { const twoDigitFormatOption = '2-digit'; - const validTimeZone = Intl.supportedValuesOf('timeZone').includes(timezone) - ? timezone - : 'UTC'; return new Intl.DateTimeFormat('en', { hourCycle: 'h23', @@ -197,19 +228,23 @@ abstract class LogFormatter { hour: twoDigitFormatOption, minute: twoDigitFormatOption, second: twoDigitFormatOption, - timeZone: validTimeZone, + timeZone: timezone, }); }; /** * Generate an ISO 8601 timestamp string with the specified time zone and the local time zone offset. * + * If the time zone cannot be resolved, both the date/time digits and the offset + * fall back to UTC so that the emitted timestamp always denotes the correct instant. + * * @param date - date to format * @param timezone - IANA time zone identifier (e.g., "Asia/Dhaka"). */ #generateISOTimestampWithOffset(date: Date, timezone: string): string { + const resolvedTimezone = this.#resolveTimezone(timezone); const { year, month, day, hour, minute, second } = this.#getDateFormatter( - timezone + resolvedTimezone ) .formatToParts(date) .reduce( @@ -221,7 +256,8 @@ abstract class LogFormatter { {} as Record ); const datePart = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - const offset = -date.getTimezoneOffset(); + const offset = + resolvedTimezone === timezone ? -date.getTimezoneOffset() : 0; const offsetSign = offset >= 0 ? '+' : '-'; const offsetHours = Math.abs(Math.floor(offset / 60)) .toString() diff --git a/packages/logger/tests/unit/formatters.test.ts b/packages/logger/tests/unit/formatters.test.ts index 27a01d4f9a..85bcab7761 100644 --- a/packages/logger/tests/unit/formatters.test.ts +++ b/packages/logger/tests/unit/formatters.test.ts @@ -644,6 +644,7 @@ describe('Formatters', () => { it('defaults to :UTC when the TZ env variable is set to :/etc/localtime', () => { // Prepare vi.stubEnv('TZ', ':/etc/localtime'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0); const formatter = new PowertoolsLogFormatter(); @@ -654,6 +655,60 @@ describe('Formatters', () => { expect(timestamp).toEqual('2016-06-20T12:08:10.000+00:00'); }); + it('formats the timestamp using the `Asia/Kolkata` timezone alias, which is resolvable but not in the canonical list', () => { + // Prepare + vi.stubEnv('TZ', 'Asia/Kolkata'); + /* + Difference between UTC and `Asia/Kolkata`(GMT +05.30) is 330 minutes. + The negative value indicates that `Asia/Kolkata` is ahead of UTC. + */ + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-330); + const formatter = new PowertoolsLogFormatter(); + + // Act + const timestamp = formatter.formatTimestamp(new Date()); + + // Assess + expect(timestamp).toEqual('2016-06-20T17:38:10.000+05:30'); + }); + + it('falls back to UTC for both the date and the offset when the timezone is unresolvable', () => { + // Prepare + vi.stubEnv('TZ', ':/etc/localtime'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + /* + Simulate an OS-level local timezone with a non-zero offset while the + TZ value itself is unresolvable by Intl.DateTimeFormat; the emitted + timestamp must not mix UTC digits with a non-zero offset. + */ + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-330); + const formatter = new PowertoolsLogFormatter(); + + // Act + const timestamp = formatter.formatTimestamp(new Date()); + + // Assess + expect(timestamp).toEqual('2016-06-20T12:08:10.000+00:00'); + }); + + it('emits a warning only once when falling back to UTC for an unresolvable timezone', () => { + // Prepare + vi.stubEnv('TZ', ':/etc/localtime'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0); + const formatter = new PowertoolsLogFormatter(); + + // Act + formatter.formatTimestamp(new Date()); + formatter.formatTimestamp(new Date()); + + // Assess + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid or unresolvable time zone: ":/etc/localtime" - falling back to UTC.' + ); + }); + // #region format stack traces it.each([