Skip to content

Commit 2a6be30

Browse files
authored
feat(winston): improve serialization of Error instances (#158)
This handles the various ways an Error instance can be passed to a Winston logger, and serializes those Errors to 'error.*' fields. Closes: #128
1 parent efb11b7 commit 2a6be30

File tree

9 files changed

+536
-59
lines changed

9 files changed

+536
-59
lines changed

packages/ecs-winston-format/CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,40 @@
22

33
## Unreleased
44

5+
- Fix/improve serialization of error details to `error.*` fields for the
6+
various ways a Winston logger handles `Error` instances.
7+
8+
```js
9+
const aCause = new Error('the cause');
10+
const anErr = new Error('boom', {cause: aCause});
11+
anErr.code = 42;
12+
13+
log.debug("some message", anErr); // Form 1
14+
log.info(anErr, {foo: "bar"}); // Form 2
15+
log.warn("some message", {err: anErr, foo: "bar"}); // Form 3
16+
```
17+
18+
1. Winston will add a `stack` field for an error passed this way.
19+
2. If the `logform.errors(...)` format is configured, Winston will serialize
20+
`anErr` passed this way.
21+
3. Passing an error via the `err` meta field is specific to
22+
`@elastic/ecs-winston-format` and is discouraged. If possible, use style 1.
23+
It will remain for now for backward compatibility.
24+
25+
With this change, all three cases above will result in `anErr` details being
26+
serialized to [ECS `error.*` fields](https://www.elastic.co/guide/en/ecs/current/ecs-error.html),
27+
as well as [`error.cause`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
28+
and other properties on the error instance. Forms 2 and 3 are enabled via
29+
the `convertErr: true` configuration option.
30+
See [examples/basic.js](./examples/basic.js).
31+
32+
In addition, if your Winston logger is configured to handle `uncaughtException`
33+
and/or `unhandledRejection` (https://github.com/winstonjs/winston#exceptions),
34+
then the Error instance included in this log record will be serialized to
35+
`error.*` fields. See [test/uncaught-exception.js](./test/uncaught-exception.js)
36+
and [test/unhandled-rejection.js](./test/unhandled-rejection.js) for examples.
37+
(https://github.com/elastic/ecs-logging-nodejs/issues/128)
38+
539
- Switch to `safe-stable-stringify` for JSON serialization. This library
640
protects against circular references and bigints.
741
(https://github.com/elastic/ecs-logging-nodejs/pull/155)

packages/ecs-winston-format/examples/basic.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,22 @@ const ecsFormat = require('../') // @elastic/ecs-winston-format
2323
const logger = winston.createLogger({
2424
level: 'info',
2525
format: ecsFormat(),
26+
// Compare to:
27+
// format: winston.format.combine(
28+
// winston.format.errors({stack: true, cause: true}),
29+
// winston.format.json()
30+
// ),
2631
transports: [
27-
new winston.transports.Console()
32+
new winston.transports.Console({
33+
handleExceptions: true,
34+
handleRejections: true
35+
})
2836
]
2937
})
3038

3139
logger.info('hi')
32-
logger.error('oops there is a problem', { foo: 'bar' })
40+
logger.warn('look out', { foo: 'bar' })
41+
42+
const err = new Error('boom', { cause: new Error('the cause') })
43+
err.code = 42
44+
logger.error('here is an exception', err)

packages/ecs-winston-format/index.js

Lines changed: 115 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@
1717

1818
'use strict'
1919

20-
const { MESSAGE } = require('triple-beam')
20+
const { MESSAGE, SPLAT } = require('triple-beam')
2121
const safeStableStringify = require('safe-stable-stringify')
2222
const {
2323
version,
24-
formatError,
2524
formatHttpRequest,
2625
formatHttpResponse
2726
} = require('@elastic/ecs-helpers')
@@ -48,11 +47,11 @@ try {
4847
const stringify = safeStableStringify.configure({ deterministic: false })
4948

5049
const reservedFields = {
50+
'@timestamp': true,
51+
error: true,
5152
level: true,
5253
'log.level': true,
53-
ecs: true,
54-
'@timestamp': true,
55-
err: true,
54+
message: true,
5655
req: true,
5756
res: true
5857
}
@@ -82,6 +81,117 @@ class EcsWinstonTransform {
8281
'ecs.version': version
8382
}
8483

84+
// Error handling. Winston has a number of ways that it does something with
85+
// `Error` instances passed to a logger.
86+
//
87+
// 1. `log.warn('a message', new Error('boom'))`
88+
// If `info[SPLAT][0] instanceof Error`, then convert it to `error.*` fields
89+
// in place of `info.stack`.
90+
//
91+
// 2. Winston logger configured to handle uncaughtException and/or unhandledRejection.
92+
// If `info.exception: true` and level is "error" and `info.trace` is an
93+
// Array and `info.message` starts with "uncaughtException:" or
94+
// "unhandledRejection:", then convert to `error.*` fields. These
95+
// conditions are to infer the `info` shape returned by Winston's
96+
// `ExceptionHandler` and `RejectionHandler`.
97+
// In this case the redundant `stack`, `trace`, `date` fields are dropped
98+
// and error details are moved to the `error.*` fields.
99+
//
100+
// If `opts.convertErr === true` (the default), then the next two forms are
101+
// considered as well.
102+
//
103+
// 3. `log.warn(new Error('boom'))`
104+
// `log.warn(new Error(''))`
105+
// `log.warn(new Error('boom'), {foo: 'bar'})`
106+
// If `info instanceof Error` or `info.message instanceof Error`, then
107+
// convert it to `error.*` fields. The latter two are a little strange, but
108+
// Winston's logger will transform that to `{ message: new Error(...) }`
109+
// and "logform/errors.js" will handle that.
110+
//
111+
// 4. `log.warn('a message', { err: new Error('boom') })`
112+
// If `info.err instanceof Error`, then convert to `error.*` fields.
113+
// Note: This feature doesn't really belong because it extends error
114+
// handling beyond what is typical in Winston. It remains for backward
115+
// compatibility.
116+
let err
117+
let delErrorLevel = false
118+
const splat0 = SPLAT && info[SPLAT] && info[SPLAT][0]
119+
if (splat0 instanceof Error) { // case 1
120+
// Undo the addition of this error's enumerable properties to the
121+
// top-level info object.
122+
err = splat0
123+
delete info.stack
124+
for (const propName in err) {
125+
delete info[propName]
126+
}
127+
} else if (info.exception === true &&
128+
info.level === 'error' &&
129+
Array.isArray(info.trace) &&
130+
(info.message.startsWith('uncaughtException:') ||
131+
info.message.startsWith('unhandledRejection:'))) { // case 2
132+
// The 'stack', 'trace', and trace in the 'message' are redundant.
133+
// 'date' is also redundant with '@timestamp'.
134+
delete info.stack
135+
delete info.trace
136+
delete info.date
137+
ecsFields.message = info.message.split(/\n/, 1)[0]
138+
// istanbul ignore else
139+
if (info.error instanceof Error) {
140+
err = info.error
141+
} else {
142+
ecsFields.error = {
143+
message: info.error.toString()
144+
}
145+
}
146+
delete info.error
147+
// Dev Note: We *could* translate some of the process and os fields, but
148+
// we don't currently.
149+
// https://www.elastic.co/guide/en/ecs/current/ecs-process.html
150+
// https://www.elastic.co/guide/en/ecs/current/ecs-host.html
151+
} else if (convertErr) { // cases 3 and 4
152+
if (info instanceof Error) {
153+
// With `log.info(err)`, Winston incorrectly uses `err` as the info
154+
// object -- (a) mutating it and (b) resulting in not being able to
155+
// differentiate `defaultMeta` and `err` properties.
156+
// The best we can do is, at least, not serialize `error.level` using
157+
// the incorrectly added `level` field.
158+
err = info
159+
delErrorLevel = true
160+
} else if (info.message instanceof Error) {
161+
// `log.info(err, {...})` or `log.info(new Error(''))` with empty message.
162+
err = info.message
163+
ecsFields.message = err.message
164+
} else if (info.err instanceof Error) {
165+
err = info.err
166+
delete info.err
167+
}
168+
}
169+
170+
// If we have an Error instance, then serialize it to `error.*` fields.
171+
if (err) {
172+
// First we add err's enumerable fields, as `logform.errors()` does.
173+
ecsFields.error = Object.assign({}, err)
174+
if (delErrorLevel) {
175+
delete ecsFields.error.level
176+
}
177+
// Then add standard ECS error fields (https://www.elastic.co/guide/en/ecs/current/ecs-error.html).
178+
// istanbul ignore next
179+
ecsFields.error.type = toString.call(err.constructor) === '[object Function]'
180+
? err.constructor.name
181+
: err.name
182+
ecsFields.error.message = err.message
183+
ecsFields.error.stack_trace = err.stack
184+
// The add some additional fields. `cause` is handled by
185+
// `logform.errors({cause: true})`. This implementation ensures it is
186+
// always a string to avoid its type varying depending on the value.
187+
// istanbul ignore next -- so coverage works for Node.js <16.9.0
188+
if (err.cause) {
189+
ecsFields.error.cause = err.cause instanceof Error
190+
? err.cause.stack
191+
: err.cause.toString()
192+
}
193+
}
194+
85195
// Add all unreserved fields.
86196
const keys = Object.keys(info)
87197
for (let i = 0, len = keys.length; i < len; i++) {
@@ -165,15 +275,6 @@ class EcsWinstonTransform {
165275
}
166276
}
167277

168-
// https://www.elastic.co/guide/en/ecs/current/ecs-error.html
169-
if (info.err !== undefined) {
170-
if (convertErr) {
171-
formatError(ecsFields, info.err)
172-
} else {
173-
ecsFields.err = info.err
174-
}
175-
}
176-
177278
// https://www.elastic.co/guide/en/ecs/current/ecs-http.html
178279
if (info.req !== undefined) {
179280
if (convertReqRes) {

packages/ecs-winston-format/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"autocannon": "^7.0.1",
5050
"elastic-apm-node": "^3.23.0",
5151
"express": "^4.17.1",
52+
"semver": "^7.5.4",
5253
"split2": "^3.2.2",
5354
"standard": "16.x",
5455
"tap": "^15.0.10",

packages/ecs-winston-format/test/basic.test.js

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,38 +20,12 @@
2020
const http = require('http')
2121
const test = require('tap').test
2222
const winston = require('winston')
23-
const Transport = require('winston-transport')
2423
const { MESSAGE } = require('triple-beam')
25-
const addFormats = require('ajv-formats').default
26-
const Ajv = require('ajv').default
2724
const { version } = require('@elastic/ecs-helpers')
2825

2926
const ecsFormat = require('../')
3027
const { ecsLoggingValidate } = require('../../../utils/lib/ecs-logging-validate')
31-
32-
const ajv = new Ajv({
33-
allErrors: true,
34-
verbose: true
35-
})
36-
addFormats(ajv)
37-
const validate = ajv.compile(require('../../../utils/schema.json'))
38-
39-
// Winston transport to capture logged records. Parsed JSON records are on
40-
// `.records`. Raw records (what Winston calls `info` objects) are on `.infos`.
41-
class CaptureTransport extends Transport {
42-
constructor () {
43-
super()
44-
this.records = []
45-
this.infos = []
46-
}
47-
48-
log (info, callback) {
49-
this.infos.push(info)
50-
const record = JSON.parse(info[MESSAGE])
51-
this.records.push(record)
52-
callback()
53-
}
54-
}
28+
const { validate, CaptureTransport } = require('./utils')
5529

5630
test('Should produce valid ecs logs', t => {
5731
t.plan(4)
@@ -300,19 +274,3 @@ test('can configure correlation fields', t => {
300274
t.equal(rec['event.dataset'], 'override-eventDataset')
301275
t.end()
302276
})
303-
304-
test('can handle circular refs', t => {
305-
const cap = new CaptureTransport()
306-
const logger = winston.createLogger({
307-
format: ecsFormat(),
308-
transports: [cap]
309-
})
310-
311-
const obj = { foo: 'bar' }
312-
obj.self = obj
313-
logger.info('hi', { obj })
314-
315-
const rec = cap.records[0]
316-
t.strictSame(rec.obj, { foo: 'bar', self: '[Circular]' })
317-
t.end()
318-
})

0 commit comments

Comments
 (0)