Skip to content

Commit

Permalink
feat: add the ability to set custom serializers
Browse files Browse the repository at this point in the history
This resolves #1248 - see this issue for context.
  • Loading branch information
rowanmanning committed Oct 30, 2024
1 parent fa2945b commit 2c3e896
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 6 deletions.
56 changes: 56 additions & 0 deletions packages/logger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A simple and fast logger based on [Pino](https://getpino.io/), with FT preferenc
* [`Logger` configuration options](#logger-configuration-options)
* [`options.baseLogData`](#optionsbaselogdata)
* [`options.logLevel`](#optionsloglevel)
* [`options.serializers`](#optionsserializers)
* [`options.transforms`](#optionstransforms)
* [`options.withPrettifier`](#optionswithprettifier)
* [`logger.log()` and shortcut methods](#loggerlog-and-shortcut-methods)
Expand Down Expand Up @@ -147,6 +148,61 @@ It's also possible to set this option as an environment variable, which is how y
* `LOG_LEVEL`: The preferred way to set log level in an environment variable
* `SPLUNK_LOG_LEVEL`: The legacy way to set log level, to maintain compatibility with [n-logger](https://github.com/Financial-Times/n-logger)

#### `options.serializers`

You can customize the way that the logger converts certain properties to JSON by specifying serializers. This allows you to extract only the information you need or to fully transform a single property value.

This option must be an object. Each key corresponds to the log property you want to serialize, and each value must be a function that performs the serialization. Expressed as a TypeScript type:

```ts
type Serializer = (value, propertyName) => any
```
When you define a serializer for a property, your serializer function will be called every time we encounter that property _at the top level_ of a log data object. E.g.
```js
const fruitEmoji = {
apple: '🍏',
banana: '🍌',
coconut: '🥥'
};
function emojifyFruit(value) {
return fruitEmoji[value] || value;
}

const logger = new Logger({
serializers: {
// If a "snack" property is found in a log, this function will be called
snack: emojifyFruit
}
});

logger.info({
message: 'Hello World',
snack: 'banana'
});
// Outputs:
// {
// "level": "info",
// "message": "Hello World",
// "snack": "🍌"
// }
```

> [!WARNING]<br />
> It's your responsibility to properly handle unexpected data in your log serializers. You should ideally type guard to avoid your logs failing to send. If an unexpected error is encountered in a serializer then you'll see `LOG_METHOD_FAILURE` errors appear in your logs.
>
> E.g. taking the example above, we would probably ensure that the property we're working with is a string:
>
> ```js
> function emojifyFruit(value) {
> if (typeof value === 'string' && fruitEmoji[value]) {
> return fruitEmoji[value];
> }
> // Always return the original value if you can't process it, so you don't lose log data
> return value;
> }
#### `options.transforms`
An array of functions which are called on log data before logs are output. This allows you to apply transformations to the final log object before it's sent.
Expand Down
53 changes: 47 additions & 6 deletions packages/logger/lib/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const appInfo = require('@dotcom-reliability-kit/app-info');
* LoggerOptions,
* LogLevel,
* LogLevelInfo,
* LogSerializer,
* LogTransform,
* LogTransport,
* PrivateLoggerOptions
Expand Down Expand Up @@ -90,6 +91,16 @@ module.exports = class Logger {
*/
#baseLogData = {};

/**
* @type {{[key: string]: LogSerializer}}
*/
#serializers = {};

/**
* @type {string[]}
*/
#serializedProperties = [];

/**
* @type {LogTransform[]}
*/
Expand Down Expand Up @@ -134,6 +145,34 @@ module.exports = class Logger {
this.#logLevel = logLevel;
}

// Default and set the serializers option
if (options.serializers) {
if (
typeof options.serializers !== 'object' ||
Array.isArray(options.serializers) ||
options.serializers === null ||
Object.values(options.serializers).some(
(serializer) => typeof serializer !== 'function'
)
) {
throw new TypeError(
'The `serializers` option must be an object where each property value is a function'
);
}
this.#serializers = options.serializers;
}

// We always set the error serializer - it's too important and making this configurable
// complicates log zipping, we'd have to use the same custom serializer for when top-level
// log data is an error instance
this.#serializers.error = this.#serializers.err = (error) => {
if (error instanceof Error) {
return serializeError(error);
}
return error;
};
this.#serializedProperties = Object.keys(this.#serializers);

// Default and set the transforms option
if (options.transforms) {
if (
Expand Down Expand Up @@ -292,12 +331,14 @@ module.exports = class Logger {
sanitizedLogData.message = null;
}

if (sanitizedLogData.error && sanitizedLogData.error instanceof Error) {
sanitizedLogData.error = serializeError(sanitizedLogData.error);
}

if (sanitizedLogData.err && sanitizedLogData.err instanceof Error) {
sanitizedLogData.err = serializeError(sanitizedLogData.err);
// Serialize properties which have a custom serializer
for (const key of this.#serializedProperties) {
if (sanitizedLogData[key] !== undefined) {
sanitizedLogData[key] = this.#serializers[key](
sanitizedLogData[key],
key
);
}
}

// Transform the log data
Expand Down
95 changes: 95 additions & 0 deletions packages/logger/test/unit/lib/logger.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,22 @@ describe('@dotcom-reliability-kit/logger/lib/logger', () => {
error: 'mock serialized error'
});
});

describe('when the error property is not an error instance', () => {
beforeEach(() => {
mockPinoLogger.mockCanonicalLevel.mockClear();
jest.spyOn(Logger, 'zipLogData').mockReturnValue({
error: 'not an error',
isMockZippedData: true
});
serializeError.mockClear();
logger.log('mockLevel', 'mock message', { mockData: true });
});

it('does not serialize the error', () => {
expect(serializeError).toBeCalledTimes(0);
});
});
});

describe('when the log data has an err property as a sub-property', () => {
Expand Down Expand Up @@ -621,6 +637,85 @@ describe('@dotcom-reliability-kit/logger/lib/logger', () => {
});
});

describe('when a `serializers` option is set', () => {
let mockSerializers;

beforeEach(() => {
jest.spyOn(Logger, 'getLogLevelInfo').mockReturnValue({
logLevel: 'mockCanonicalLevel',
isDeprecated: false
});
jest.spyOn(Logger, 'zipLogData').mockReturnValue({
isMockZippedData: true,
message: 'mock zipped message',
mockProperty1: 'mock-value-1'
});
mockPinoLogger.mockCanonicalLevel.mockClear();
mockSerializers = {
mockProperty1: jest.fn(() => 'mock-serialized-value-1'),
mockProperty2: jest.fn(() => 'mock-serialized-value-2')
};
logger = new Logger({
serializers: mockSerializers
});
});

describe('.log(level, ...logData)', () => {
beforeEach(() => {
logger.log('mockLevel', 'mock message', { mockData: true });
});

it('calls all serializers with the zipped log data properties if set', () => {
expect(mockSerializers.mockProperty1).toHaveBeenCalledTimes(1);
expect(mockSerializers.mockProperty2).toHaveBeenCalledTimes(0);
expect(mockSerializers.mockProperty1).toHaveBeenCalledWith(
'mock-value-1',
'mockProperty1'
);
});

it('calls the relevant log transport method with the serialized log data', () => {
expect(mockPinoLogger.mockCanonicalLevel).toHaveBeenCalledTimes(1);
expect(mockPinoLogger.mockCanonicalLevel).toHaveBeenCalledWith({
isMockZippedData: true,
message: 'mock zipped message',
mockProperty1: 'mock-serialized-value-1'
});
});
});

describe('when the serializers option is not an object', () => {
it('throws a type error', () => {
expect(() => {
logger = new Logger({
serializers: []
});
}).toThrow(
new TypeError(
'The `serializers` option must be an object where each property value is a function'
)
);
});
});

describe('when one of the serializers is not a function', () => {
it('throws a type error', () => {
expect(() => {
logger = new Logger({
serializers: {
mockProperty1: jest.fn(() => 'mock-serialized-value-1'),
mockProperty2: 'nope'
}
});
}).toThrow(
new TypeError(
'The `serializers` option must be an object where each property value is a function'
)
);
});
});
});

describe('when a `transforms` option is set', () => {
let mockTransforms;

Expand Down
3 changes: 3 additions & 0 deletions packages/logger/types/logger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type LogTransform = (logData: { [key: string]: any }) => {
[key: string]: any;
};

export type LogSerializer = (value: any, propertyName: string) => any;

export type LogTransport = {
level?: string;
debug: (...args: any) => any;
Expand All @@ -35,6 +37,7 @@ export type LoggerOptions = {
baseLogData?: object;
logLevel?: LogLevel;
transforms?: LogTransform[];
serializers?: { [key: string]: LogSerializer };
withPrettifier?: boolean;
};

Expand Down

0 comments on commit 2c3e896

Please sign in to comment.