Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Omit specific headers by default #92

Merged
merged 18 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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
28 changes: 28 additions & 0 deletions .changeset/moody-bobcats-travel.md
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the release summary format ok?
I'm not sure what it will look like in the release notes.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@seek/logger': minor
---

Omit a default set or the specified headers from the logged object

By default, the following headers in the `headers` and `req.headers`
properties will be omitted from the logged object:

- 'x-envoy-attempt-count'
- 'x-envoy-decorator-operation'
- 'x-envoy-expected-rq-timeout-ms'
- 'x-envoy-external-address'
- 'x-envoy-internal'
- 'x-envoy-peer-metadata'
- 'x-envoy-peer-metadata-id'
- 'x-envoy-upstream-service-time'

If you would like to opt out of this, you can provide an empty list or your
own list of headers to omit in the `omitHeaderNames` property when creating
your logger e.g.

```typescript
const logger = createLogger({
name: 'my-app',
omitHeaderNames: ['dnt', 'sec-fetch-dest'],
});
```
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ childLogger.error({ err }, 'Something bad happened');

### Standardised fields

**@seek/logger** bundles custom `req` and `res` serializers along with [Pino]'s standard set.
**@seek/logger** bundles custom `req`, `res` and `headers` serializers along with [Pino]'s standard set.
User-defined serializers will take precedence over predefined ones.

Use the following standardised logging fields to benefit from customised serialization:
Expand All @@ -57,12 +57,21 @@ Use the following standardised logging fields to benefit from customised seriali

- `req` for HTTP requests.

The request object is trimmed to a set of essential fields.
The request object is trimmed to a set of essential fields.
Certain headers are omitted by default (e.g. `x-envoy-peer-metadata`).
To opt out of this behavior, set the `omitHeaderNames` logger option to an empty list `[]`
or provide your own list.

- `res` for HTTP responses.

The response object is trimmed to a set of essential fields.

- `headers` for tracing headers.

Certain headers are omitted by default (e.g. `x-envoy-peer-metadata`).
To opt out of this behavior, set the `omitHeaderNames` logger option to an empty list `[]`
or provide your own list.

All other fields will be logged directly.

### Typed fields
Expand Down
72 changes: 72 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import split from 'split2';

import { defaultOmitHeaderNames } from './serializers';

import createLogger, { type LoggerOptions } from '.';

const bearerToken =
Expand Down Expand Up @@ -558,3 +560,73 @@ testLog(
maxObjectDepth: 2,
},
);

const objectWithDefaultOmitHeaderNameKeys = defaultOmitHeaderNames.reduce(
(headers, key) => ({ ...headers, [key]: 'header value' }),
{},
);

testLog(
'should omit defaultOmitHeaderNames by default',
{
headers: {
['authorization']: bearerToken,
...objectWithDefaultOmitHeaderNameKeys,
['x-request-id']: 'some-uuid',
},
req: {
headers: {
['authorization']: bearerToken,
...objectWithDefaultOmitHeaderNameKeys,
['x-request-id']: 'some-uuid',
},
},
},
{
headers: {
['authorization']: redactedBearer,
['x-request-id']: 'some-uuid',
},
req: {
headers: {
['authorization']: redactedBearer,
['x-request-id']: 'some-uuid',
},
},
},
'info',
);

testLog(
'should keep defaultOmitHeaderNames when omitHeaderNames option is empty',
{
headers: {
['authorization']: bearerToken,
...objectWithDefaultOmitHeaderNameKeys,
['x-request-id']: 'some-uuid',
},
req: {
headers: {
['authorization']: bearerToken,
...objectWithDefaultOmitHeaderNameKeys,
['x-request-id']: 'some-uuid',
},
},
},
{
headers: {
['authorization']: redactedBearer,
...objectWithDefaultOmitHeaderNameKeys,
['x-request-id']: 'some-uuid',
},
req: {
headers: {
['authorization']: redactedBearer,
...objectWithDefaultOmitHeaderNameKeys,
['x-request-id']: 'some-uuid',
},
},
},
'info',
{ omitHeaderNames: [] },
);
11 changes: 5 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import base from './base';
import { withRedaction } from './destination';
import { type FormatterOptions, createFormatters } from './formatters';
import * as redact from './redact';
import serializers from './serializers';
import { type SerializerOptions, createSerializers } from './serializers';

export { pino };

export type LoggerOptions = pino.LoggerOptions & FormatterOptions;
export type LoggerOptions = pino.LoggerOptions &
FormatterOptions &
SerializerOptions;
export type Logger = pino.Logger;

/**
Expand All @@ -25,10 +27,7 @@ export default (
}),
): Logger => {
opts.redact = redact.addDefaultRedactPathStrings(opts.redact);
opts.serializers = {
...serializers,
...opts.serializers,
};
opts.serializers = createSerializers(opts);
const formatters = createFormatters(opts);
opts.base = {
...base,
Expand Down
64 changes: 52 additions & 12 deletions src/serializers/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import serializers from './index';
import {
type Request,
createSerializers,
defaultOmitHeaderNames,
} from './index';

const serializers = createSerializers({});

describe('req', () => {
const remoteAddress = '::ffff:123.45.67.89';
Expand Down Expand Up @@ -88,6 +94,51 @@ describe('req', () => {

expect(result).toStrictEqual({ ...expectedRequestBase });
});

it('omits defaultOmitHeaderNames by default', () => {
const objectWithDefaultOmitHeaderNameKeys = defaultOmitHeaderNames.reduce(
(headers, key) => ({ ...headers, [key]: 'header value' }),
{},
);
const request = {
...requestBase,
headers: {
...requestBase.headers,
...objectWithDefaultOmitHeaderNameKeys,
},
} as Partial<Request> as Request;
const result = serializers.req(request);

expect(result).toStrictEqual({ ...expectedRequestBase });
});

it('omits only specified headers when omitHeaderNames is provided', () => {
const objectWithDefaultOmitHeaderNameKeys = defaultOmitHeaderNames.reduce(
(headers, key) => ({ ...headers, [key]: 'header value' }),
{},
);
const request = {
...requestBase,
headers: {
...requestBase.headers,
['omit-me']: 'header value',
...objectWithDefaultOmitHeaderNameKeys,
},
} as Partial<Request> as Request;

const expectedRequest = {
...expectedRequestBase,
headers: {
...requestBase.headers,
...objectWithDefaultOmitHeaderNameKeys,
},
};

const altSerializers = createSerializers({ omitHeaderNames: ['omit-me'] });
const result = altSerializers.req(request);

expect(result).toStrictEqual(expectedRequest);
});
});

describe('res', () => {
Expand Down Expand Up @@ -122,14 +173,3 @@ describe('res', () => {
});
});
});

describe('serializers', () => {
test('it exports only err, errWithCause, req, res', () => {
expect(serializers).toStrictEqual({
err: expect.any(Function),
errWithCause: expect.any(Function),
req: expect.any(Function),
res: expect.any(Function),
});
});
});
47 changes: 39 additions & 8 deletions src/serializers/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import type pino from 'pino';
import { err, errWithCause } from 'pino-std-serializers';

import { omitProperties } from './omitProperties';
import { createOmitPropertiesSerializer } from './omitPropertiesSerializer';

export const defaultOmitHeaderNames = [
'x-envoy-attempt-count',
'x-envoy-decorator-operation',
'x-envoy-expected-rq-timeout-ms',
'x-envoy-external-address',
'x-envoy-internal',
'x-envoy-peer-metadata',
'x-envoy-peer-metadata-id',
'x-envoy-upstream-service-time',
];

export interface SerializerOptions {
omitHeaderNames?: string[];
}

interface Socket {
remoteAddress?: string;
remotePort?: string;
}
interface Request extends Record<string, unknown> {
export interface Request extends Record<string, unknown> {
method: string;
url: string;
headers: Record<string, string>;
Expand All @@ -26,12 +45,15 @@ const isObject = (value: unknown): boolean => {
return value != null && (type === 'object' || type === 'function');
};

const req = (request: Request) =>
const createReqSerializer = (opts: SerializerOptions) => (request: Request) =>
isObject(request)
? {
method: request.method,
url: request.url,
headers: request.headers,
headers: omitProperties(
request.headers,
opts.omitHeaderNames ?? defaultOmitHeaderNames,
),
remoteAddress: request?.socket?.remoteAddress,
remotePort: request?.socket?.remotePort,
}
Expand All @@ -45,9 +67,18 @@ const res = (response: Response) =>
}
: response;

export default {
err,
errWithCause,
res,
req,
export const createSerializers = (
opts: SerializerOptions & Pick<pino.LoggerOptions, 'serializers'>,
) => {
const omitHeaderNamesSerializer = createOmitPropertiesSerializer('headers', {
omitPropertyNames: opts.omitHeaderNames ?? defaultOmitHeaderNames,
});
return {
err,
errWithCause,
req: createReqSerializer(opts),
res,
...omitHeaderNamesSerializer,
...opts.serializers,
};
};
47 changes: 47 additions & 0 deletions src/serializers/omitProperties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { omitProperties } from './omitProperties';

const omitPropertyNames = ['omit-prop', 'omit.prop', '', '0'];

const createInput = (): Readonly<Record<string, unknown>> =>
Object.freeze({
keepProp: 'Some value',
['omit-prop']: 'omit with dash',
['omit.prop']: 'omit with dot',
['']: 'omit with empty key',
['0']: 'omit number as text key',
omit: { prop: 'DO NOT omit' },
});

it('omits list of keys from object', () => {
const input = createInput();

const result = omitProperties(input, omitPropertyNames);

expect(result).toStrictEqual({
keepProp: 'Some value',
omit: { prop: 'DO NOT omit' },
});
});

it('does not alter input object', () => {
const input = createInput();

const originalInput = structuredClone(input);

omitProperties(input, omitPropertyNames);

expect(input).toEqual(originalInput);
});

it.each`
scenario | input
${'undefined'} | ${undefined}
${'null'} | ${null}
${'an empty object'} | ${{}}
${'not an object'} | ${'key1=value1,key2=value2'}
${'an array'} | ${[{ key1: 'value1' }, { key2: 'value2' }]}
`('returns input when it is $scenario', ({ input }) => {
const result = omitProperties(input, omitPropertyNames);

expect(result).toStrictEqual(input);
});
20 changes: 20 additions & 0 deletions src/serializers/omitProperties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const omitProperties = (
input: unknown,
properties: string[],
): unknown => {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return input;
}

// We can get away with a shallow clone as we only touch top-level properties.
const output: Record<PropertyKey, unknown> = {
...input,
};

for (const property of properties) {
// Remove the property from our shallow clone.
delete output[property];
}
Comment on lines +9 to +17
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My assumption is that this is overall cheaper than re-spreading the object on every iteration

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete seems the fastest; thanks for showing me this.

Tested in TS Playground


return output;
};
Loading