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')