diff --git a/docs/api.md b/docs/api.md index 1d76d7c84..c3db8fa04 100644 --- a/docs/api.md +++ b/docs/api.md @@ -465,6 +465,11 @@ in the serializers. The only exception is the `err` serializer as it is also app the object is an instance of `Error`, e.g. `logger.info(new Error('kaboom'))`. See `errorKey` option to change `err` namespace. +For performance-sensitive cases where the serializer can produce JSON directly, +it may return `pino.raw(jsonString)` to inject a pre-serialized JSON string +into the log output, bypassing the overhead of `JSON.stringify`. +See [`pino.raw()`](#pino-raw). + * See [pino.stdSerializers](#pino-stdserializers) #### `msgPrefix` (String) @@ -1188,6 +1193,43 @@ Exposes the cumulative `msgPrefix` of the logger. ## Statics + +### `pino.raw(value) => RawJSON` + +Wraps a pre-serialized JSON string so that pino injects it directly into the +log output without additional stringification. This is a performance +optimization for serializers that can produce JSON directly, avoiding the +overhead of creating an intermediate object that pino would then re-stringify. + +* `value` (String): A valid JSON string. +* Returns: An opaque marker object to be returned from a serializer. + +The caller is responsible for providing valid JSON. Pino does not validate +the string — invalid JSON will corrupt the log output. Values returned via +`pino.raw()` bypass pino's redaction. If you need redaction, apply it within +the serializer before calling `pino.raw()`. + +```js +const pino = require('pino') + +const logger = pino({ + serializers: { + headers: (rawHeaders) => { + // Build JSON directly from raw undici headers (Buffer[]) + let json = '{' + for (let i = 0; i < rawHeaders.length; i += 2) { + if (i > 0) json += ',' + json += '"' + rawHeaders[i] + '":"' + rawHeaders[i + 1] + '"' + } + return pino.raw(json + '}') + } + } +}) + +logger.info({ headers: rawUndiciHeaders }) +// headers value is injected as-is, no double serialization +``` + ### `pino.destination([opts]) => SonicBoom` diff --git a/lib/tools.js b/lib/tools.js index 5b4af61f2..692f05225 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -23,7 +23,8 @@ const { messageKeySym, errorKeySym, nestedKeyStrSym, - msgPrefixSym + msgPrefixSym, + rawJSONSym } = require('./symbols') const { isMainThread } = require('worker_threads') const transport = require('./transport') @@ -373,6 +374,9 @@ function createArgsNormalizer (defaultOptions) { } function stringify (obj, stringifySafeFn) { + if (obj !== null && typeof obj === 'object' && rawJSONSym in obj) { + return obj[rawJSONSym] + } try { return JSON.stringify(obj) } catch (_) { diff --git a/pino.d.ts b/pino.d.ts index 0e2cd8da3..b01f3e88b 100644 --- a/pino.d.ts +++ b/pino.d.ts @@ -798,6 +798,7 @@ declare namespace pino { readonly useOnlyCustomLevelsSym: unique symbol; readonly formattersSym: unique symbol; readonly hooksSym: unique symbol; + readonly rawJSONSym: unique symbol; }; /** @@ -843,6 +844,41 @@ declare namespace pino { * but for peak log writing performance, it is strongly recommended to use `pino.destination` to create the destination stream. * @returns A Sonic-Boom stream to be used as destination for the pino function */ + /** + * Opaque type representing a pre-serialized JSON string. + * When returned from a serializer, pino injects the raw string + * directly into the log output without additional stringification. + */ + export interface RawJSON { + readonly [symbols.rawJSONSym]: string; + } + + /** + * Wraps a pre-serialized JSON string so that pino injects it directly + * into the log output. Designed to be used inside serializers only. + * + * The caller is responsible for providing valid JSON. + * + * @param value A valid JSON string. + * + * @example + * ```js + * const logger = pino({ + * serializers: { + * headers: (rawHeaders) => { + * let json = '{' + * for (let i = 0; i < rawHeaders.length; i += 2) { + * if (i > 0) json += ',' + * json += '"' + rawHeaders[i] + '":"' + rawHeaders[i + 1] + '"' + * } + * return pino.raw(json + '}') + * } + * } + * }) + * ``` + */ + export function raw(value: string): RawJSON; + export function destination( dest?: number | object | string | DestinationStream | NodeJS.WritableStream | SonicBoomOpts, ): SonicBoom; diff --git a/pino.js b/pino.js index 9720bcb1b..ec3cab68d 100644 --- a/pino.js +++ b/pino.js @@ -44,7 +44,8 @@ const { nestedKeyStrSym, mixinMergeStrategySym, msgPrefixSym, - transportUsesMultistreamSym + transportUsesMultistreamSym, + rawJSONSym } = symbols const { epochTime, nullTime } = time const { pid } = process @@ -232,6 +233,13 @@ function pino (...args) { return instance } +function raw (value) { + if (typeof value !== 'string') { + throw new TypeError(`pino.raw() expects a pre-serialized JSON string, received ${typeof value}`) + } + return { [rawJSONSym]: value } +} + module.exports = pino module.exports.destination = (dest = process.stdout.fd) => { @@ -251,6 +259,7 @@ module.exports.stdSerializers = serializers module.exports.stdTimeFunctions = Object.assign({}, time) module.exports.symbols = symbols module.exports.version = version +module.exports.raw = raw // Enables default and name export with TypeScript and Babel module.exports.default = pino diff --git a/test/serializers.test.js b/test/serializers.test.js index b8400716b..d58b24b9a 100644 --- a/test/serializers.test.js +++ b/test/serializers.test.js @@ -255,3 +255,50 @@ test('custom serializer for messageKey', async () => { const { msg } = await once(stream, 'data') assert.equal(msg, '422') }) + +test('pino.raw() injects raw JSON object from serializer', async () => { + const stream = sink() + const instance = pino({ + serializers: { + headers: () => pino.raw('{"host":"example.com","accept":"*/*"}') + } + }, stream) + + instance.info({ headers: ['host', 'example.com'] }) + const o = await once(stream, 'data') + assert.equal(o.headers.host, 'example.com') + assert.equal(o.headers.accept, '*/*') +}) + +test('pino.raw() injects raw JSON string from serializer', async () => { + const stream = sink() + const instance = pino({ + serializers: { + data: () => pino.raw('"already-a-json-string"') + } + }, stream) + + instance.info({ data: 'original' }) + const o = await once(stream, 'data') + assert.equal(o.data, 'already-a-json-string') +}) + +test('pino.raw() works in child logger bindings', async () => { + const stream = sink() + const parent = pino({ + serializers: { + headers: () => pino.raw('{"x-req-id":"abc123"}') + } + }, stream) + const child = parent.child({ headers: { 'x-req-id': 'abc123' } }) + + child.info('test') + const o = await once(stream, 'data') + assert.equal(o.headers['x-req-id'], 'abc123') +}) + +test('pino.raw() throws on non-string argument', async () => { + assert.throws(() => pino.raw(42), TypeError) + assert.throws(() => pino.raw({}), TypeError) + assert.throws(() => pino.raw(null), TypeError) +}) diff --git a/test/types/pino.test-d.ts b/test/types/pino.test-d.ts index 337f557f3..362232f0c 100644 --- a/test/types/pino.test-d.ts +++ b/test/types/pino.test-d.ts @@ -615,6 +615,17 @@ parentLogger2.onChild = (child) => { expect(child).type.not.toHaveProperty('doesntExist') } +// pino.raw() +const rawValue = pino.raw('{"key":"value"}') +expect(rawValue).type.toBe() +expect(pino.raw).type.toBeCallableWith('valid json string') + +pino({ + serializers: { + headers: (value): pino.RawJSON => pino.raw(JSON.stringify(value)) + } +}) + const childLogger2 = parentLogger2.child({}) expect(childLogger2).type.not.toHaveProperty('doesntExist')