Skip to content

Commit fbc8688

Browse files
feat(logger): custom function for unserializable values (JSON replacer) (#2739)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 08ee657 commit fbc8688

File tree

7 files changed

+210
-39
lines changed

7 files changed

+210
-39
lines changed

Diff for: docs/core/logger.md

+20
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,26 @@ This is how the printed log would look:
760760
!!! tip "Custom Log formatter and Child loggers"
761761
It is not necessary to pass the `LogFormatter` each time a [child logger](#using-multiple-logger-instances-across-your-code) is created. The parent's LogFormatter will be inherited by the child logger.
762762

763+
### Bring your own JSON serializer
764+
765+
You can extend the default JSON serializer by passing a custom serializer function to the `Logger` constructor, using the `jsonReplacerFn` option. This is useful when you want to customize the serialization of specific values.
766+
767+
=== "unserializableValues.ts"
768+
769+
```typescript hl_lines="4-5 7"
770+
--8<-- "examples/snippets/logger/unserializableValues.ts"
771+
```
772+
773+
=== "unserializableValues.json"
774+
775+
```json hl_lines="8"
776+
--8<-- "examples/snippets/logger/unserializableValues.json"
777+
```
778+
779+
By default, Logger uses `JSON.stringify()` to serialize log items and a [custom replacer function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter) to serialize common unserializable values such as `BigInt`, circular references, and `Error` objects.
780+
781+
When you extend the default JSON serializer, we will call your custom serializer function before the default one. This allows you to customize the serialization while still benefiting from the default behavior.
782+
763783
## Testing your code
764784

765785
### Inject Lambda Context

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

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"level": "INFO",
3+
"message": "Serialize with custom serializer",
4+
"sampling_rate": 0,
5+
"service": "serverlessAirline",
6+
"timestamp": "2024-07-07T09:52:14.212Z",
7+
"xray_trace_id": "1-668a654d-396c646b760ee7d067f32f18",
8+
"serializedValue": [1, 2, 3]
9+
}

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

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Logger } from '@aws-lambda-powertools/logger';
2+
import type { CustomReplacerFn } from '@aws-lambda-powertools/logger/types';
3+
4+
const jsonReplacerFn: CustomReplacerFn = (_: string, value: unknown) =>
5+
value instanceof Set ? [...value] : value;
6+
7+
const logger = new Logger({ serviceName: 'serverlessAirline', jsonReplacerFn });
8+
9+
export const handler = async (): Promise<void> => {
10+
logger.info('Serialize with custom serializer', {
11+
serializedValue: new Set([1, 2, 3]),
12+
});
13+
};

Diff for: packages/logger/src/Logger.ts

+45-35
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
LogItemMessage,
2525
LoggerInterface,
2626
PowertoolsLogData,
27+
CustomJsonReplacerFn,
2728
} from './types/Logger.js';
2829

2930
/**
@@ -200,6 +201,10 @@ class Logger extends Utility implements LoggerInterface {
200201
* We keep this value to be able to reset the log level to the initial value when the sample rate is refreshed.
201202
*/
202203
#initialLogLevel = 12;
204+
/**
205+
* Replacer function used to serialize the log items.
206+
*/
207+
#jsonReplacerFn?: CustomJsonReplacerFn;
203208

204209
/**
205210
* Log level used by the current instance of Logger.
@@ -309,6 +314,7 @@ class Logger extends Utility implements LoggerInterface {
309314
environment: this.powertoolsLogData.environment,
310315
persistentLogAttributes: this.persistentLogAttributes,
311316
temporaryLogAttributes: this.temporaryLogAttributes,
317+
jsonReplacerFn: this.#jsonReplacerFn,
312318
},
313319
options
314320
)
@@ -674,6 +680,42 @@ class Logger extends Utility implements LoggerInterface {
674680
return new Logger(options);
675681
}
676682

683+
/**
684+
* A custom JSON replacer function that is used to serialize the log items.
685+
*
686+
* By default, we already extend the default serialization behavior to handle `BigInt` and `Error` objects, as well as remove circular references.
687+
* When a custom JSON replacer function is passed to the Logger constructor, it will be called **before** our custom rules for each key-value pair in the object being stringified.
688+
*
689+
* This allows you to customize the serialization while still benefiting from the default behavior.
690+
*
691+
* @see {@link ConstructorOptions.jsonReplacerFn}
692+
*
693+
* @param key - The key of the value being stringified.
694+
* @param value - The value being stringified.
695+
*/
696+
protected getJsonReplacer(): (key: string, value: unknown) => void {
697+
const references = new WeakSet();
698+
699+
return (key, value) => {
700+
if (this.#jsonReplacerFn) value = this.#jsonReplacerFn?.(key, value);
701+
702+
if (value instanceof Error) {
703+
value = this.getLogFormatter().formatError(value);
704+
}
705+
if (typeof value === 'bigint') {
706+
return value.toString();
707+
}
708+
if (typeof value === 'object' && value !== null) {
709+
if (references.has(value)) {
710+
return;
711+
}
712+
references.add(value);
713+
}
714+
715+
return value;
716+
};
717+
}
718+
677719
/**
678720
* It stores information that is printed in all log items.
679721
*
@@ -835,40 +877,6 @@ class Logger extends Utility implements LoggerInterface {
835877
return this.powertoolsLogData;
836878
}
837879

838-
/**
839-
* When the data added in the log item contains object references or BigInt values,
840-
* `JSON.stringify()` can't handle them and instead throws errors:
841-
* `TypeError: cyclic object value` or `TypeError: Do not know how to serialize a BigInt`.
842-
* To mitigate these issues, this method will find and remove all cyclic references and convert BigInt values to strings.
843-
*
844-
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#exceptions
845-
* @private
846-
*/
847-
private getReplacer(): (
848-
key: string,
849-
value: LogAttributes | Error | bigint
850-
) => void {
851-
const references = new WeakSet();
852-
853-
return (key, value) => {
854-
let item = value;
855-
if (item instanceof Error) {
856-
item = this.getLogFormatter().formatError(item);
857-
}
858-
if (typeof item === 'bigint') {
859-
return item.toString();
860-
}
861-
if (typeof item === 'object' && value !== null) {
862-
if (references.has(item)) {
863-
return;
864-
}
865-
references.add(item);
866-
}
867-
868-
return item;
869-
};
870-
}
871-
872880
/**
873881
* It returns true and type guards the log level if a given log level is valid.
874882
*
@@ -920,7 +928,7 @@ class Logger extends Utility implements LoggerInterface {
920928
this.console[consoleMethod](
921929
JSON.stringify(
922930
log.getAttributes(),
923-
this.getReplacer(),
931+
this.getJsonReplacer(),
924932
this.logIndentation
925933
)
926934
);
@@ -1119,6 +1127,7 @@ class Logger extends Utility implements LoggerInterface {
11191127
persistentKeys,
11201128
persistentLogAttributes, // deprecated in favor of persistentKeys
11211129
environment,
1130+
jsonReplacerFn,
11221131
} = options;
11231132

11241133
if (persistentLogAttributes && persistentKeys) {
@@ -1143,6 +1152,7 @@ class Logger extends Utility implements LoggerInterface {
11431152
this.setLogFormatter(logFormatter);
11441153
this.setConsole();
11451154
this.setLogIndentation();
1155+
this.#jsonReplacerFn = jsonReplacerFn;
11461156

11471157
return this;
11481158
}

Diff for: packages/logger/src/types/Logger.ts

+23
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,35 @@ type InjectLambdaContextOptions = {
2828
resetKeys?: boolean;
2929
};
3030

31+
/**
32+
* A custom JSON replacer function that can be passed to the Logger constructor to extend the default serialization behavior.
33+
*
34+
* By default, we already extend the default serialization behavior to handle `BigInt` and `Error` objects, as well as remove circular references.
35+
* When a custom JSON replacer function is passed to the Logger constructor, it will be called **before** our custom rules for each key-value pair in the object being stringified.
36+
*
37+
* This allows you to customize the serialization while still benefiting from the default behavior.
38+
*
39+
* @param key - The key of the value being stringified.
40+
* @param value - The value being stringified.
41+
*/
42+
type CustomJsonReplacerFn = (key: string, value: unknown) => unknown;
43+
3144
type BaseConstructorOptions = {
3245
logLevel?: LogLevel;
3346
serviceName?: string;
3447
sampleRateValue?: number;
3548
logFormatter?: LogFormatterInterface;
3649
customConfigService?: ConfigServiceInterface;
3750
environment?: Environment;
51+
/**
52+
* A custom JSON replacer function that can be passed to the Logger constructor to extend the default serialization behavior.
53+
*
54+
* By default, we already extend the default serialization behavior to handle `BigInt` and `Error` objects, as well as remove circular references.
55+
* When a custom JSON replacer function is passed to the Logger constructor, it will be called **before** our custom rules for each key-value pair in the object being stringified.
56+
*
57+
* This allows you to customize the serialization while still benefiting from the default behavior.
58+
*/
59+
jsonReplacerFn?: CustomJsonReplacerFn;
3860
};
3961

4062
type PersistentKeysOption = {
@@ -139,4 +161,5 @@ export type {
139161
PowertoolsLogData,
140162
ConstructorOptions,
141163
InjectLambdaContextOptions,
164+
CustomJsonReplacerFn,
142165
};

Diff for: packages/logger/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export type {
1414
PowertoolsLogData,
1515
ConstructorOptions,
1616
InjectLambdaContextOptions,
17+
CustomJsonReplacerFn,
1718
} from './Logger.js';

Diff for: packages/logger/tests/unit/Logger.test.ts

+99-4
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { ConfigServiceInterface } from '../../src/types/ConfigServiceInterface.j
1010
import { EnvironmentVariablesService } from '../../src/config/EnvironmentVariablesService.js';
1111
import { PowertoolsLogFormatter } from '../../src/formatter/PowertoolsLogFormatter.js';
1212
import { LogLevelThresholds, LogLevel } from '../../src/types/Log.js';
13-
import type {
14-
LogFunction,
15-
ConstructorOptions,
13+
import {
14+
type LogFunction,
15+
type ConstructorOptions,
16+
type CustomJsonReplacerFn,
1617
} from '../../src/types/Logger.js';
1718
import { LogJsonIndent } from '../../src/constants.js';
1819
import type { Context } from 'aws-lambda';
@@ -1190,7 +1191,7 @@ describe('Class: Logger', () => {
11901191
});
11911192
});
11921193

1193-
describe('Feature: handle safely unexpected errors', () => {
1194+
describe('Feature: custom JSON replacer function', () => {
11941195
test('when a logged item references itself, the logger ignores the keys that cause a circular reference', () => {
11951196
// Prepare
11961197
const logger = new Logger({
@@ -1312,6 +1313,100 @@ describe('Class: Logger', () => {
13121313
})
13131314
);
13141315
});
1316+
1317+
it('should correctly serialize custom values using the provided jsonReplacerFn', () => {
1318+
// Prepare
1319+
const jsonReplacerFn: CustomJsonReplacerFn = (
1320+
_: string,
1321+
value: unknown
1322+
) => (value instanceof Set ? [...value] : value);
1323+
1324+
const logger = new Logger({ jsonReplacerFn });
1325+
const consoleSpy = jest.spyOn(
1326+
logger['console'],
1327+
getConsoleMethod(methodOfLogger)
1328+
);
1329+
const message = `This is an ${methodOfLogger} log with Set value`;
1330+
1331+
const logItem = { value: new Set([1, 2]) };
1332+
1333+
// Act
1334+
logger[methodOfLogger](message, logItem);
1335+
1336+
// Assess
1337+
expect(consoleSpy).toHaveBeenCalledTimes(1);
1338+
expect(consoleSpy).toHaveBeenNthCalledWith(
1339+
1,
1340+
JSON.stringify({
1341+
level: methodOfLogger.toUpperCase(),
1342+
message: message,
1343+
sampling_rate: 0,
1344+
service: 'hello-world',
1345+
timestamp: '2016-06-20T12:08:10.000Z',
1346+
xray_trace_id: '1-5759e988-bd862e3fe1be46a994272793',
1347+
value: [1, 2],
1348+
})
1349+
);
1350+
});
1351+
1352+
it('should serialize using both the existing replacer and the customer-provided one', () => {
1353+
// Prepare
1354+
const jsonReplacerFn: CustomJsonReplacerFn = (
1355+
_: string,
1356+
value: unknown
1357+
) => {
1358+
if (value instanceof Set || value instanceof Map) {
1359+
return [...value];
1360+
}
1361+
1362+
return value;
1363+
};
1364+
1365+
const logger = new Logger({ jsonReplacerFn });
1366+
const consoleSpy = jest.spyOn(
1367+
logger['console'],
1368+
getConsoleMethod(methodOfLogger)
1369+
);
1370+
1371+
const message = `This is an ${methodOfLogger} log with Set value`;
1372+
const logItem = { value: new Set([1, 2]), number: BigInt(42) };
1373+
1374+
// Act
1375+
logger[methodOfLogger](message, logItem);
1376+
1377+
// Assess
1378+
expect(consoleSpy).toHaveBeenCalledTimes(1);
1379+
expect(consoleSpy).toHaveBeenNthCalledWith(
1380+
1,
1381+
JSON.stringify({
1382+
level: methodOfLogger.toUpperCase(),
1383+
message: message,
1384+
sampling_rate: 0,
1385+
service: 'hello-world',
1386+
timestamp: '2016-06-20T12:08:10.000Z',
1387+
xray_trace_id: '1-5759e988-bd862e3fe1be46a994272793',
1388+
value: [1, 2],
1389+
number: '42',
1390+
})
1391+
);
1392+
});
1393+
1394+
it('should pass the JSON customer-provided replacer function to child loggers', () => {
1395+
// Prepare
1396+
const jsonReplacerFn: CustomJsonReplacerFn = (
1397+
_: string,
1398+
value: unknown
1399+
) => (value instanceof Set ? [...value] : value);
1400+
const logger = new Logger({ jsonReplacerFn });
1401+
1402+
// Act
1403+
const childLogger = logger.createChild();
1404+
1405+
// Assess
1406+
expect(() =>
1407+
childLogger.info('foo', { foo: new Set([1, 2]) })
1408+
).not.toThrow();
1409+
});
13151410
});
13161411
}
13171412
);

0 commit comments

Comments
 (0)