Skip to content

Commit 9fe4c75

Browse files
feat(logger): time zone aware timestamp in Logger (#2710)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 2494f97 commit 9fe4c75

File tree

5 files changed

+284
-4
lines changed

5 files changed

+284
-4
lines changed

Diff for: docs/core/logger.md

+20
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,26 @@ We prioritise log level settings in this order:
519519

520520
In the event you have set a log level in Powertools to a level that is lower than the ACL setting, we will output a warning log message informing you that your messages will be discarded by Lambda.
521521

522+
### Setting timestamp to custom Timezone
523+
524+
By default, Logger emits records with the default Lambda timestamp in **UTC**, i.e. `2016-06-20T12:08:10.000Z`
525+
526+
If you prefer to log in a specific timezone, you can configure it by setting the `TZ` environment variable. You can do this either as an environment variable or directly within your Lambda function settings.
527+
528+
[Click here](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime){target="_blank"} for a comprehensive list of available Lambda environment variables.
529+
530+
=== "customTimezone.ts"
531+
532+
```typescript hl_lines="8"
533+
--8<-- "examples/snippets/logger/customTimezone.ts"
534+
```
535+
536+
=== "customTimezoneOutput.json"
537+
538+
```json hl_lines="7 15"
539+
--8<-- "examples/snippets/logger/customTimezoneOutput.json"
540+
```
541+
522542
### Using multiple Logger instances across your code
523543

524544
The `createChild` method allows you to create a child instance of the Logger, which inherits all of the attributes from its parent. You have the option to override any of the settings and attributes from the parent logger, including [its settings](#utility-settings), any [extra keys](#appending-additional-keys), and [the log formatter](#custom-log-formatter-bring-your-own-formatter).

Diff for: examples/snippets/logger/customTimezone.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Logger } from '@aws-lambda-powertools/logger';
2+
3+
const logger = new Logger({ serviceName: 'serverlessAirline' });
4+
5+
export const handler = async (): Promise<void> => {
6+
logger.info('Hello, World!');
7+
8+
process.env.TZ = 'Europe/Rome';
9+
10+
const childLogger = logger.createChild();
11+
12+
childLogger.info('Ciao, Mondo!');
13+
};

Diff for: examples/snippets/logger/customTimezoneOutput.json

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"level": "INFO",
4+
"message": "Hello, World!",
5+
"sampling_rate": 0,
6+
"service": "serverlessAirline",
7+
"timestamp": "2024-07-01T11:00:37.886Z",
8+
"xray_trace_id": "1-66828c55-2bb635c65eb609c820ebe7bc"
9+
},
10+
{
11+
"level": "INFO",
12+
"message": "Ciao, Mondo!",
13+
"sampling_rate": 0,
14+
"service": "serverlessAirline",
15+
"timestamp": "2024-07-01T13:00:37.934+02:00",
16+
"xray_trace_id": "1-66828c55-2bb635c65eb609c820ebe7bc"
17+
}
18+
]

Diff for: packages/logger/src/formatter/LogFormatter.ts

+68-1
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,24 @@ abstract class LogFormatter implements LogFormatterInterface {
5757
}
5858

5959
/**
60-
* It formats a date into a string in simplified extended ISO format (ISO 8601).
60+
* Formats a given date into an ISO 8601 string, considering the configured timezone.
61+
* If `envVarsService` is set and the configured timezone differs from 'UTC',
62+
* the date is formatted to that timezone. Otherwise, it defaults to 'UTC'.
6163
*
6264
* @param {Date} now
6365
* @returns {string}
6466
*/
6567
public formatTimestamp(now: Date): string {
68+
const defaultTimezone = 'UTC';
69+
70+
/**
71+
* If a specific timezone is configured and it's not the default `UTC`,
72+
* format the timestamp with the appropriate timezone offset.
73+
**/
74+
const configuredTimezone = this.envVarsService?.getTimezone();
75+
if (configuredTimezone && !configuredTimezone.includes(defaultTimezone))
76+
return this.#generateISOTimestampWithOffset(now, configuredTimezone);
77+
6678
return now.toISOString();
6779
}
6880

@@ -91,6 +103,61 @@ abstract class LogFormatter implements LogFormatterInterface {
91103

92104
return '';
93105
}
106+
107+
/**
108+
* Generates a new Intl.DateTimeFormat object configured with the specified time zone
109+
* and formatting options. The time is displayed in 24-hour format (hour12: false).
110+
*
111+
* @param {string} timeZone - the IANA time zone identifier (e.g., "Asia/Dhaka").
112+
*/
113+
#getDateFormatter = (timeZone: string): Intl.DateTimeFormat => {
114+
const twoDigitFormatOption = '2-digit';
115+
116+
return new Intl.DateTimeFormat('en', {
117+
year: 'numeric',
118+
month: twoDigitFormatOption,
119+
day: twoDigitFormatOption,
120+
hour: twoDigitFormatOption,
121+
minute: twoDigitFormatOption,
122+
second: twoDigitFormatOption,
123+
hour12: false,
124+
timeZone,
125+
});
126+
};
127+
128+
/**
129+
* Generates an ISO 8601 timestamp string with the specified time zone and the local time zone offset.
130+
*
131+
* @param {Date} date - the date to format
132+
* @param {string} timeZone - the IANA time zone identifier (e.g., "Asia/Dhaka").
133+
*/
134+
#generateISOTimestampWithOffset(date: Date, timeZone: string): string {
135+
const { year, month, day, hour, minute, second } = this.#getDateFormatter(
136+
timeZone
137+
)
138+
.formatToParts(date)
139+
.reduce(
140+
(acc, item) => {
141+
acc[item.type] = item.value;
142+
143+
return acc;
144+
},
145+
{} as Record<Intl.DateTimeFormatPartTypes, string>
146+
);
147+
const datePart = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
148+
const offset = -date.getTimezoneOffset();
149+
const offsetSign = offset >= 0 ? '+' : '-';
150+
const offsetHours = Math.abs(Math.floor(offset / 60))
151+
.toString()
152+
.padStart(2, '0');
153+
const offsetMinutes = Math.abs(offset % 60)
154+
.toString()
155+
.padStart(2, '0');
156+
const millisecondPart = date.getMilliseconds().toString().padStart(3, '0');
157+
const offsetPart = `${offsetSign}${offsetHours}:${offsetMinutes}`;
158+
159+
return `${datePart}.${millisecondPart}${offsetPart}`;
160+
}
94161
}
95162

96163
export { LogFormatter };

Diff for: packages/logger/tests/unit/formatter/PowertoolsLogFormatter.test.ts

+165-3
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@ import { PowertoolsLogFormatter } from '../../../src/formatter/PowertoolsLogForm
88
import { LogItem } from '../../../src/index.js';
99
import type { UnformattedAttributes } from '../../../src/types/Logger.js';
1010
import type { LogAttributes } from '../../../src/types/Log.js';
11+
import { EnvironmentVariablesService } from '../../../src/config/EnvironmentVariablesService.js';
1112

1213
describe('Class: PowertoolsLogFormatter', () => {
13-
const mockDate = new Date(1466424490000);
14-
const dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
14+
const ENVIRONMENT_VARIABLES = process.env;
1515

1616
beforeEach(() => {
17-
dateSpy.mockClear();
17+
const mockDate = new Date(1466424490000);
18+
jest.useFakeTimers().setSystemTime(mockDate);
19+
process.env = { ...ENVIRONMENT_VARIABLES };
20+
});
21+
22+
afterAll(() => {
23+
process.env = ENVIRONMENT_VARIABLES;
24+
jest.useRealTimers();
1825
});
1926

2027
describe('Method: formatAttributes', () => {
@@ -309,6 +316,161 @@ describe('Class: PowertoolsLogFormatter', () => {
309316
// Assess
310317
expect(timestamp).toEqual('2016-06-20T12:08:10.000Z');
311318
});
319+
320+
test('it formats the timestamp to ISO 8601, accounting for the `America/New_York` timezone offset', () => {
321+
// Prepare
322+
process.env.TZ = 'America/New_York';
323+
/*
324+
Difference between UTC and `America/New_York`(GMT -04.00) is 240 minutes.
325+
The positive value indicates that `America/New_York` is behind UTC.
326+
*/
327+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(240);
328+
const formatter = new PowertoolsLogFormatter({
329+
envVarsService: new EnvironmentVariablesService(),
330+
});
331+
332+
// Act
333+
const timestamp = formatter.formatTimestamp(new Date());
334+
335+
// Assess
336+
expect(timestamp).toEqual('2016-06-20T08:08:10.000-04:00');
337+
});
338+
339+
test('it formats the timestamp to ISO 8601 with correct milliseconds for `America/New_York` timezone', () => {
340+
// Prepare
341+
process.env.TZ = 'America/New_York';
342+
const mockDate = new Date('2016-06-20T12:08:10.910Z');
343+
jest.useFakeTimers().setSystemTime(mockDate);
344+
/*
345+
Difference between UTC and `America/New_York`(GMT -04.00) is 240 minutes.
346+
The positive value indicates that `America/New_York` is behind UTC.
347+
*/
348+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(240);
349+
const formatter = new PowertoolsLogFormatter({
350+
envVarsService: new EnvironmentVariablesService(),
351+
});
352+
353+
// Act
354+
const timestamp = formatter.formatTimestamp(new Date());
355+
356+
// Assess
357+
expect(timestamp).toEqual('2016-06-20T08:08:10.910-04:00');
358+
});
359+
360+
test('it formats the timestamp to ISO 8601, adjusting for `America/New_York` timezone, preserving milliseconds and accounting for date change', () => {
361+
// Prepare
362+
process.env.TZ = 'America/New_York';
363+
const mockDate = new Date('2016-06-20T00:08:10.910Z');
364+
jest.useFakeTimers().setSystemTime(mockDate);
365+
/*
366+
Difference between UTC and `America/New_York`(GMT -04.00) is 240 minutes.
367+
The positive value indicates that `America/New_York` is behind UTC.
368+
*/
369+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(240);
370+
const formatter = new PowertoolsLogFormatter({
371+
envVarsService: new EnvironmentVariablesService(),
372+
});
373+
374+
// Act
375+
const timestamp = formatter.formatTimestamp(new Date());
376+
377+
// Assess
378+
expect(timestamp).toEqual('2016-06-19T20:08:10.910-04:00');
379+
});
380+
381+
test('if `envVarsService` is not set, ensures timestamp is formatted to `UTC` even with `America/New_York` timezone', () => {
382+
// Prepare
383+
process.env.TZ = 'America/New_York';
384+
/*
385+
Difference between UTC and `America/New_York`(GMT -04.00) is 240 minutes.
386+
The positive value indicates that `America/New_York` is behind UTC.
387+
*/
388+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(240);
389+
const formatter = new PowertoolsLogFormatter();
390+
391+
// Act
392+
const timestamp = formatter.formatTimestamp(new Date());
393+
394+
// Assess
395+
expect(timestamp).toEqual('2016-06-20T12:08:10.000Z');
396+
});
397+
398+
test('it formats the timestamp to ISO 8601, accounting for the `Asia/Dhaka` timezone offset', () => {
399+
// Prepare
400+
process.env.TZ = 'Asia/Dhaka';
401+
/*
402+
Difference between UTC and `Asia/Dhaka`(GMT +06.00) is 360 minutes.
403+
The negative value indicates that `Asia/Dhaka` is ahead of UTC.
404+
*/
405+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-360);
406+
const formatter = new PowertoolsLogFormatter({
407+
envVarsService: new EnvironmentVariablesService(),
408+
});
409+
410+
// Act
411+
const timestamp = formatter.formatTimestamp(new Date());
412+
413+
// Assess
414+
expect(timestamp).toEqual('2016-06-20T18:08:10.000+06:00');
415+
});
416+
417+
test('it formats the timestamp to ISO 8601 with correct milliseconds for `Asia/Dhaka` timezone', () => {
418+
// Prepare
419+
process.env.TZ = 'Asia/Dhaka';
420+
jest.useFakeTimers().setSystemTime(new Date('2016-06-20T12:08:10.910Z'));
421+
/*
422+
Difference between UTC and `Asia/Dhaka`(GMT +06.00) is 360 minutes.
423+
The negative value indicates that `Asia/Dhaka` is ahead of UTC.
424+
*/
425+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-360);
426+
const formatter = new PowertoolsLogFormatter({
427+
envVarsService: new EnvironmentVariablesService(),
428+
});
429+
430+
// Act
431+
const timestamp = formatter.formatTimestamp(new Date());
432+
433+
// Assess
434+
expect(timestamp).toEqual('2016-06-20T18:08:10.910+06:00');
435+
});
436+
437+
test('it formats the timestamp to ISO 8601, adjusting for `Asia/Dhaka` timezone, preserving milliseconds and accounting for date change', () => {
438+
// Prepare
439+
process.env.TZ = 'Asia/Dhaka';
440+
const mockDate = new Date('2016-06-20T20:08:10.910Z');
441+
jest.useFakeTimers().setSystemTime(mockDate);
442+
/*
443+
Difference between UTC and `Asia/Dhaka`(GMT +06.00) is 360 minutes.
444+
The negative value indicates that `Asia/Dhaka` is ahead of UTC.
445+
*/
446+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-360);
447+
const formatter = new PowertoolsLogFormatter({
448+
envVarsService: new EnvironmentVariablesService(),
449+
});
450+
451+
// Act
452+
const timestamp = formatter.formatTimestamp(new Date());
453+
454+
// Assess
455+
expect(timestamp).toEqual('2016-06-21T02:08:10.910+06:00');
456+
});
457+
458+
test('if `envVarsService` is not set, ensures timestamp is formatted to `UTC` even with `Asia/Dhaka` timezone', () => {
459+
// Prepare
460+
process.env.TZ = 'Asia/Dhaka';
461+
/*
462+
Difference between UTC and `Asia/Dhaka`(GMT +06.00) is 360 minutes.
463+
The negative value indicates that `Asia/Dhaka` is ahead of UTC.
464+
*/
465+
jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-360);
466+
const formatter = new PowertoolsLogFormatter();
467+
468+
// Act
469+
const timestamp = formatter.formatTimestamp(new Date());
470+
471+
// Assess
472+
expect(timestamp).toEqual('2016-06-20T12:08:10.000Z');
473+
});
312474
});
313475

314476
describe('Method: getCodeLocation', () => {

0 commit comments

Comments
 (0)