Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
42 changes: 42 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1188,6 +1193,43 @@ Exposes the cumulative `msgPrefix` of the logger.

## Statics

<a id="pino-raw"></a>
### `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
```

<a id="pino-destination"></a>
### `pino.destination([opts]) => SonicBoom`

Expand Down
6 changes: 5 additions & 1 deletion lib/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const {
messageKeySym,
errorKeySym,
nestedKeyStrSym,
msgPrefixSym
msgPrefixSym,
rawJSONSym
} = require('./symbols')
const { isMainThread } = require('worker_threads')
const transport = require('./transport')
Expand Down Expand Up @@ -373,6 +374,9 @@ function createArgsNormalizer (defaultOptions) {
}

function stringify (obj, stringifySafeFn) {
if (obj !== null && typeof obj === 'object' && rawJSONSym in obj) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you just keep the last check?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

return obj[rawJSONSym]
}
try {
return JSON.stringify(obj)
} catch (_) {
Expand Down
36 changes: 36 additions & 0 deletions pino.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,7 @@ declare namespace pino {
readonly useOnlyCustomLevelsSym: unique symbol;
readonly formattersSym: unique symbol;
readonly hooksSym: unique symbol;
readonly rawJSONSym: unique symbol;
};

/**
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion pino.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const {
nestedKeyStrSym,
mixinMergeStrategySym,
msgPrefixSym,
transportUsesMultistreamSym
transportUsesMultistreamSym,
rawJSONSym
} = symbols
const { epochTime, nullTime } = time
const { pid } = process
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions test/serializers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
11 changes: 11 additions & 0 deletions test/types/pino.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<pino.RawJSON>()
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')

Expand Down
Loading