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(serializers): Implemented "error with cause" serializer #130

Merged
merged 7 commits into from
Apr 9, 2023
8 changes: 7 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@ export interface SerializedError {
}

/**
* Serializes an Error object.
* Serializes an Error object. Does not serialize "err.cause" fields (will append the err.cause.message to err.message
* and err.cause.stack to err.stack)
*/
export function err(err: Error): SerializedError;

/**
* Serializes an Error object, including full serialization for any err.cause fields recursively.
*/
export function errWithCause(err: Error): SerializedError;

export interface SerializedRequest {
/**
* Defaults to `undefined`, unless there is an `id` property already attached to the `request` object or
Expand Down
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
'use strict'

const errSerializer = require('./lib/err')
const errWithCauseSerializer = require('./lib/err-with-cause')
const reqSerializers = require('./lib/req')
const resSerializers = require('./lib/res')

module.exports = {
err: errSerializer,
errWithCause: errWithCauseSerializer,
mapHttpRequest: reqSerializers.mapHttpRequest,
mapHttpResponse: resSerializers.mapHttpResponse,
req: reqSerializers.reqSerializer,
Expand Down
48 changes: 48 additions & 0 deletions lib/err-proto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict'

const seen = Symbol('circular-ref-tag')
const rawSymbol = Symbol('pino-raw-err-ref')

const pinoErrProto = Object.create({}, {
type: {
enumerable: true,
writable: true,
value: undefined
},
message: {
enumerable: true,
writable: true,
value: undefined
},
stack: {
enumerable: true,
writable: true,
value: undefined
},
aggregateErrors: {
enumerable: true,
writable: true,
value: undefined
},
raw: {
enumerable: false,
get: function () {
return this[rawSymbol]
},
set: function (val) {
this[rawSymbol] = val
}
}
})
Object.defineProperty(pinoErrProto, rawSymbol, {
writable: true,
value: {}
})

module.exports = {
pinoErrProto,
pinoErrorSymbols: {
seen,
rawSymbol
}
}
48 changes: 48 additions & 0 deletions lib/err-with-cause.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict'

module.exports = errWithCauseSerializer

const { isErrorLike } = require('./err-helpers')
const { pinoErrProto, pinoErrorSymbols } = require('./err-proto')
const { seen } = pinoErrorSymbols

const { toString } = Object.prototype

function errWithCauseSerializer (err) {
if (!isErrorLike(err)) {
return err
}

err[seen] = undefined // tag to prevent re-looking at this
const _err = Object.create(pinoErrProto)
_err.type = toString.call(err.constructor) === '[object Function]'
? err.constructor.name
: err.name
_err.message = err.message
_err.stack = err.stack

if (Array.isArray(err.errors)) {
_err.aggregateErrors = err.errors.map(err => errWithCauseSerializer(err))
}

if (isErrorLike(err.cause) && !Object.prototype.hasOwnProperty.call(err.cause, seen)) {
_err.cause = errWithCauseSerializer(err.cause)
}

for (const key in err) {
if (_err[key] === undefined) {
const val = err[key]
if (isErrorLike(val)) {
if (!Object.prototype.hasOwnProperty.call(val, seen)) {
_err[key] = errWithCauseSerializer(val)
}
} else {
_err[key] = val
}
}
}

delete err[seen] // clean up tag in case err is serialized again later
_err.raw = err
return _err
}
39 changes: 2 additions & 37 deletions lib/err.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,10 @@
module.exports = errSerializer

const { messageWithCauses, stackWithCauses, isErrorLike } = require('./err-helpers')
const { pinoErrProto, pinoErrorSymbols } = require('./err-proto')
const { seen } = pinoErrorSymbols

const { toString } = Object.prototype
const seen = Symbol('circular-ref-tag')
const rawSymbol = Symbol('pino-raw-err-ref')
const pinoErrProto = Object.create({}, {
type: {
enumerable: true,
writable: true,
value: undefined
},
message: {
enumerable: true,
writable: true,
value: undefined
},
stack: {
enumerable: true,
writable: true,
value: undefined
},
aggregateErrors: {
enumerable: true,
writable: true,
value: undefined
},
raw: {
enumerable: false,
get: function () {
return this[rawSymbol]
},
set: function (val) {
this[rawSymbol] = val
}
}
})
Object.defineProperty(pinoErrProto, rawSymbol, {
writable: true,
value: {}
})

function errSerializer (err) {
if (!isErrorLike(err)) {
Expand Down
203 changes: 203 additions & 0 deletions test/err-with-cause.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
'use strict'
jsumners marked this conversation as resolved.
Show resolved Hide resolved

const test = require('tap').test
const serializer = require('../lib/err-with-cause')
const wrapErrorSerializer = require('../').wrapErrorSerializer

test('serializes Error objects', function (t) {
t.plan(3)
const serialized = serializer(Error('foo'))
t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.match(serialized.stack, /err-with-cause\.test\.js:/)
})

test('serializes Error objects with extra properties', function (t) {
t.plan(5)
const err = Error('foo')
err.statusCode = 500
const serialized = serializer(err)
t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.ok(serialized.statusCode)
t.equal(serialized.statusCode, 500)
t.match(serialized.stack, /err-with-cause\.test\.js:/)
})

test('serializes Error objects with subclass "type"', function (t) {
t.plan(1)

class MyError extends Error {}

const err = new MyError('foo')
const serialized = serializer(err)
t.equal(serialized.type, 'MyError')
})

test('serializes nested errors', function (t) {
t.plan(7)
const err = Error('foo')
err.inner = Error('bar')
const serialized = serializer(err)
t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.match(serialized.stack, /err-with-cause\.test\.js:/)
t.equal(serialized.inner.type, 'Error')
t.equal(serialized.inner.message, 'bar')
t.match(serialized.inner.stack, /Error: bar/)
t.match(serialized.inner.stack, /err-with-cause\.test\.js:/)
})

test('serializes error causes', function (t) {
const innerErr = Error('inner')
const middleErr = Error('middle')
middleErr.cause = innerErr
const outerErr = Error('outer')
outerErr.cause = middleErr

const serialized = serializer(outerErr)

t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'outer')
t.match(serialized.stack, /err-with-cause\.test\.js:/)

t.equal(serialized.cause.type, 'Error')
t.equal(serialized.cause.message, 'middle')
t.match(serialized.cause.stack, /err-with-cause\.test\.js:/)

t.equal(serialized.cause.cause.type, 'Error')
t.equal(serialized.cause.cause.message, 'inner')
t.match(serialized.cause.cause.stack, /err-with-cause\.test\.js:/)

t.end()
})

test('keeps non-error cause', function (t) {
t.plan(3)
const err = Error('foo')
err.cause = 'abc'
const serialized = serializer(err)
t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.equal(serialized.cause, 'abc')
})

test('prevents infinite recursion', function (t) {
t.plan(4)
const err = Error('foo')
err.inner = err
const serialized = serializer(err)
t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.match(serialized.stack, /err-with-cause\.test\.js:/)
t.notOk(serialized.inner)
})

test('cleans up infinite recursion tracking', function (t) {
t.plan(8)
const err = Error('foo')
const bar = Error('bar')
err.inner = bar
bar.inner = err

serializer(err)
const serialized = serializer(err)

t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.match(serialized.stack, /err-with-cause\.test\.js:/)
t.ok(serialized.inner)
t.equal(serialized.inner.type, 'Error')
t.equal(serialized.inner.message, 'bar')
t.match(serialized.inner.stack, /Error: bar/)
t.notOk(serialized.inner.inner)
})

test('err.raw is available', function (t) {
t.plan(1)
const err = Error('foo')
const serialized = serializer(err)
t.equal(serialized.raw, err)
})

test('redefined err.constructor doesnt crash serializer', function (t) {
t.plan(10)

function check (a, name) {
t.equal(a.type, name)
t.equal(a.message, 'foo')
}

const err1 = TypeError('foo')
err1.constructor = '10'

const err2 = TypeError('foo')
err2.constructor = undefined

const err3 = Error('foo')
err3.constructor = null

const err4 = Error('foo')
err4.constructor = 10

class MyError extends Error {}

const err5 = new MyError('foo')
err5.constructor = undefined

check(serializer(err1), 'TypeError')
check(serializer(err2), 'TypeError')
check(serializer(err3), 'Error')
check(serializer(err4), 'Error')
// We do not expect 'MyError' because err5.constructor has been blown away.
// `err5.name` is 'Error' from the base class prototype.
check(serializer(err5), 'Error')
})

test('pass through anything that does not look like an Error', function (t) {
t.plan(3)

function check (a) {
t.equal(serializer(a), a)
}

check('foo')
check({ hello: 'world' })
check([1, 2])
})

test('can wrap err serializers', function (t) {
t.plan(5)
const err = Error('foo')
err.foo = 'foo'
const serializer = wrapErrorSerializer(function (err) {
delete err.foo
err.bar = 'bar'
return err
})
const serialized = serializer(err)
t.equal(serialized.type, 'Error')
t.equal(serialized.message, 'foo')
t.match(serialized.stack, /err-with-cause\.test\.js:/)
t.notOk(serialized.foo)
t.equal(serialized.bar, 'bar')
})

test('serializes aggregate errors', { skip: !global.AggregateError }, function (t) {
t.plan(14)
const foo = new Error('foo')
const bar = new Error('bar')
for (const aggregate of [
new AggregateError([foo, bar], 'aggregated message'), // eslint-disable-line no-undef
{ errors: [foo, bar], message: 'aggregated message', stack: 'err-with-cause.test.js:' }
]) {
const serialized = serializer(aggregate)
t.equal(serialized.message, 'aggregated message')
t.equal(serialized.aggregateErrors.length, 2)
t.equal(serialized.aggregateErrors[0].message, 'foo')
t.equal(serialized.aggregateErrors[1].message, 'bar')
t.match(serialized.aggregateErrors[0].stack, /^Error: foo/)
t.match(serialized.aggregateErrors[1].stack, /^Error: bar/)
t.match(serialized.stack, /err-with-cause\.test\.js:/)
}
})
4 changes: 4 additions & 0 deletions test/types/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {IncomingMessage, ServerResponse} from "http";
import {
err,
errWithCause,
req,
res,
SerializedError,
Expand Down Expand Up @@ -45,6 +46,9 @@ const fakeError = new Error('A fake error for testing');
const serializedError: SerializedError = err(fakeError);
const mySerializer = wrapErrorSerializer(customErrorSerializer);

const fakeErrorWithCause = new Error('A fake error for testing with cause', { cause: new Error('An inner fake error') });
const serializedErrorWithCause: SerializedError = errWithCause(fakeError);

const request: IncomingMessage = {} as IncomingMessage
const serializedRequest: SerializedRequest = req(request);
const myReqSerializer = wrapRequestSerializer(customRequestSerializer);
Expand Down
Loading