Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions packages/logger/src/formatter/LogFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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',
Expand All @@ -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(
Expand All @@ -221,7 +256,8 @@ abstract class LogFormatter {
{} as Record<Intl.DateTimeFormatPartTypes, string>
);
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()
Expand Down
55 changes: 55 additions & 0 deletions packages/logger/tests/unit/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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([
Expand Down
Loading