Skip to content

Commit d0331a7

Browse files
authored
fix: usage of 'base' option to pino constructor (elastic#63)
The 'base' option to pino allows one to change (or null out) the default "bindings". ecs-format-pino wasn't handling (a) a changed base, nor (b) `base: null` which results in `formatters.bindings()` not being called by pino at all -- which broke addition of "ecs.version", "service.name", and "event.dataset".
1 parent 669704f commit d0331a7

File tree

5 files changed

+215
-94
lines changed

5 files changed

+215
-94
lines changed

loggers/pino/CHANGELOG.md

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

33
## Unreleased
44

5+
- Fix handling when the [`base`](https://getpino.io/#/docs/api?id=base-object)
6+
option is used to the pino constructor.
7+
8+
Before this change, using, for example:
9+
const log = pino({base: {foo: "bar"}, ...ecsFormat()})
10+
would result in two issues:
11+
1. The log records would not include the "foo" field.
12+
2. The log records would include `"process": {}, "host": {}` for the
13+
expected process.pid and os.hostname.
14+
15+
Further, if the following is used:
16+
const log = pino({base: null, ...ecsFormat()})
17+
pino does not call `formatters.bindings()` at all, resulting in log
18+
records that were missing "ecs.version" (making them invalid ecs-logging
19+
records) and part of the APM integration.
20+
521
- Add `apmIntegration: false` option to all ecs-logging formatters to
622
enable explicitly disabling Elastic APM integration.
723
([#62](https://github.com/elastic/ecs-logging-nodejs/pull/62))

loggers/pino/index.js

Lines changed: 128 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -67,130 +67,173 @@ function createEcsPinoOptions (opts) {
6767
}
6868

6969
let apm = null
70+
let apmServiceName = null
7071
if (apmIntegration && elasticApm && elasticApm.isStarted && elasticApm.isStarted()) {
7172
apm = elasticApm
73+
// Elastic APM v3.11.0 added getServiceName(). Fallback to private `apm._conf`.
74+
// istanbul ignore next
75+
apmServiceName = apm.getServiceName
76+
? apm.getServiceName()
77+
: apm._conf.serviceName
7278
}
7379

80+
let isServiceNameInBindings = false
81+
let isEventDatasetInBindings = false
82+
7483
const ecsPinoOptions = {
84+
messageKey: 'message',
85+
timestamp: () => `,"@timestamp":"${new Date().toISOString()}"`,
7586
formatters: {
7687
level (label, number) {
7788
return { 'log.level': label }
7889
},
7990

80-
// Add the following ECS fields:
81-
// - https://www.elastic.co/guide/en/ecs/current/ecs-process.html#field-process-pid
82-
// - https://www.elastic.co/guide/en/ecs/current/ecs-host.html#field-host-hostname
83-
// - https://www.elastic.co/guide/en/ecs/current/ecs-log.html#field-log-logger
84-
//
85-
// This is called once at logger creation, and for each child logger creation.
8691
bindings (bindings) {
8792
const {
88-
// We assume the default `pid` and `hostname` bindings
89-
// (https://getpino.io/#/docs/api?id=bindings) will be always be
90-
// defined because currently one cannot use this package *and*
91-
// pass a custom `formatters` to a pino logger.
93+
// `pid` and `hostname` are default bindings, unless overriden by
94+
// a `base: {...}` passed to logger creation.
9295
pid,
9396
hostname,
9497
// name is defined if `log = pino({name: 'my name', ...})`
95-
name
98+
name,
99+
// Warning: silently drop any "ecs" value from `base`. See
100+
// "ecs.version" comment below.
101+
ecs,
102+
...ecsBindings
96103
} = bindings
97104

98-
const ecsBindings = {
99-
ecs: {
100-
version
101-
},
102-
process: {
103-
pid: pid
104-
},
105-
host: {
106-
hostname: hostname
107-
}
105+
if (pid !== undefined) {
106+
// https://www.elastic.co/guide/en/ecs/current/ecs-process.html#field-process-pid
107+
ecsBindings.process = { pid: pid }
108+
}
109+
if (hostname !== undefined) {
110+
// https://www.elastic.co/guide/en/ecs/current/ecs-host.html#field-host-hostname
111+
ecsBindings.host = { hostname: hostname }
108112
}
109113
if (name !== undefined) {
114+
// https://www.elastic.co/guide/en/ecs/current/ecs-log.html#field-log-logger
110115
ecsBindings.log = { logger: name }
111116
}
112117

118+
// Note if service.name & event.dataset are set, to not do so again below.
119+
if (bindings.service && bindings.service.name) {
120+
isServiceNameInBindings = true
121+
}
122+
if (bindings.event && bindings.event.dataset) {
123+
isEventDatasetInBindings = true
124+
}
125+
126+
return ecsBindings
127+
},
128+
129+
log (obj) {
130+
const {
131+
req,
132+
res,
133+
err,
134+
...ecsObj
135+
} = obj
136+
137+
// https://www.elastic.co/guide/en/ecs/current/ecs-ecs.html
138+
// For "ecs.version" we take a heavier-handed approach, because it is
139+
// a require ecs-logging field: overwrite any possible "ecs" value from
140+
// the log statement. This means we don't need to spend the time
141+
// guarding against "ecs" being null, Array, Buffer, Date, etc.
142+
ecsObj.ecs = { version }
143+
113144
if (apm) {
114-
// https://github.com/elastic/apm-agent-nodejs/pull/1949 is adding
115-
// getServiceName() in v3.11.0. Fallback to private `apm._conf`.
116-
// istanbul ignore next
117-
const serviceName = apm.getServiceName
118-
? apm.getServiceName()
119-
: apm._conf.serviceName
120145
// A mis-configured APM Agent can be "started" but not have a
121146
// "serviceName".
122-
if (serviceName) {
123-
ecsBindings.service = { name: serviceName }
124-
ecsBindings.event = { dataset: serviceName + '.log' }
147+
if (apmServiceName) {
148+
// Per https://github.com/elastic/ecs-logging/blob/master/spec/spec.json
149+
// "service.name" and "event.dataset" should be automatically set
150+
// if not already by the user.
151+
if (!isServiceNameInBindings) {
152+
const service = ecsObj.service
153+
if (service === undefined) {
154+
ecsObj.service = { name: apmServiceName }
155+
} else if (!isVanillaObject(service)) {
156+
// Warning: "service" type conflicts with ECS spec. Overwriting.
157+
ecsObj.service = { name: apmServiceName }
158+
} else if (typeof service.name !== 'string') {
159+
ecsObj.service.name = apmServiceName
160+
}
161+
}
162+
if (!isEventDatasetInBindings) {
163+
const event = ecsObj.event
164+
if (event === undefined) {
165+
ecsObj.event = { dataset: apmServiceName + '.log' }
166+
} else if (!isVanillaObject(event)) {
167+
// Warning: "event" type conflicts with ECS spec. Overwriting.
168+
ecsObj.event = { dataset: apmServiceName + '.log' }
169+
} else if (typeof event.dataset !== 'string') {
170+
ecsObj.event.dataset = apmServiceName + '.log'
171+
}
172+
}
125173
}
126-
}
127-
128-
return ecsBindings
129-
}
130-
},
131-
messageKey: 'message',
132-
timestamp: () => `,"@timestamp":"${new Date().toISOString()}"`
133-
}
134174

135-
// For performance, avoid adding the `formatters.log` pino option unless we
136-
// know we'll do some processing in it.
137-
if (convertErr || convertReqRes || apm) {
138-
ecsPinoOptions.formatters.log = function (obj) {
139-
const {
140-
req,
141-
res,
142-
err,
143-
...ecsObj
144-
} = obj
145-
146-
// istanbul ignore else
147-
if (apm) {
148-
// https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html
149-
const tx = apm.currentTransaction
150-
if (tx) {
151-
ecsObj.trace = ecsObj.trace || {}
152-
ecsObj.trace.id = tx.traceId
153-
ecsObj.transaction = ecsObj.transaction || {}
154-
ecsObj.transaction.id = tx.id
155-
const span = apm.currentSpan
156-
// istanbul ignore else
157-
if (span) {
158-
ecsObj.span = ecsObj.span || {}
159-
ecsObj.span.id = span.id
175+
// https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html
176+
const tx = apm.currentTransaction
177+
if (tx) {
178+
ecsObj.trace = ecsObj.trace || {}
179+
ecsObj.trace.id = tx.traceId
180+
ecsObj.transaction = ecsObj.transaction || {}
181+
ecsObj.transaction.id = tx.id
182+
const span = apm.currentSpan
183+
// istanbul ignore else
184+
if (span) {
185+
ecsObj.span = ecsObj.span || {}
186+
ecsObj.span.id = span.id
187+
}
160188
}
161189
}
162-
}
163190

164-
// https://www.elastic.co/guide/en/ecs/current/ecs-http.html
165-
if (err !== undefined) {
166-
if (!convertErr) {
167-
ecsObj.err = err
168-
} else {
169-
formatError(ecsObj, err)
191+
// https://www.elastic.co/guide/en/ecs/current/ecs-http.html
192+
if (err !== undefined) {
193+
if (!convertErr) {
194+
ecsObj.err = err
195+
} else {
196+
formatError(ecsObj, err)
197+
}
170198
}
171-
}
172199

173-
// https://www.elastic.co/guide/en/ecs/current/ecs-http.html
174-
if (req !== undefined) {
175-
if (!convertReqRes) {
176-
ecsObj.req = req
177-
} else {
178-
formatHttpRequest(ecsObj, req)
200+
// https://www.elastic.co/guide/en/ecs/current/ecs-http.html
201+
if (req !== undefined) {
202+
if (!convertReqRes) {
203+
ecsObj.req = req
204+
} else {
205+
formatHttpRequest(ecsObj, req)
206+
}
179207
}
180-
}
181-
if (res !== undefined) {
182-
if (!convertReqRes) {
183-
ecsObj.res = res
184-
} else {
185-
formatHttpResponse(ecsObj, res)
208+
if (res !== undefined) {
209+
if (!convertReqRes) {
210+
ecsObj.res = res
211+
} else {
212+
formatHttpResponse(ecsObj, res)
213+
}
186214
}
187-
}
188215

189-
return ecsObj
216+
return ecsObj
217+
}
190218
}
191219
}
192220

193221
return ecsPinoOptions
194222
}
195223

224+
// Return true if the given arg is a "vanilla" object. Roughly the intent is
225+
// whether this is basic mapping of string keys to values that will serialize
226+
// as a JSON object.
227+
//
228+
// Currently, it excludes Map. The uses above don't really expect a user to:
229+
// service = new Map([["foo", "bar"]])
230+
// log.info({ service }, '...')
231+
//
232+
// There are many ways tackle this. See some attempts and benchmarks at:
233+
// https://gist.github.com/trentm/34131a92eede80fd2109f8febaa56f5a
234+
function isVanillaObject (o) {
235+
return (typeof o === 'object' &&
236+
(!o.constructor || o.constructor.name === 'Object'))
237+
}
238+
196239
module.exports = createEcsPinoOptions

loggers/pino/test/apm.test.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,36 @@ test('can override service.name, event.dataset', t => {
331331
const recs = stdout.trim().split(/\n/g).map(JSON.parse)
332332
t.equal(recs[0].service.name, 'myname')
333333
t.equal(recs[0].event.dataset, 'mydataset')
334+
335+
// If integrating with APM and the log record sets "service.name" to a
336+
// non-string or "service" to a non-object, then ecs-pino-format will
337+
// overwrite it because it conflicts with the ECS specified type.
334338
t.equal(recs[1].service.name, 'test-apm')
335339
t.equal(recs[1].event.dataset, 'test-apm.log')
340+
t.equal(recs[2].service.name, 'test-apm')
341+
t.equal(recs[2].event.dataset, 'test-apm.log')
342+
343+
t.equal(recs[3].service.name, 'test-apm')
344+
t.equal(recs[3].event.dataset, 'test-apm.log')
345+
t.end()
346+
})
347+
})
348+
349+
test('can override service.name, event.dataset in base arg to constructor', t => {
350+
execFile(process.execPath, [
351+
path.join(__dirname, 'use-apm-override-service-name.js'),
352+
'test-apm', // apm "serviceName"
353+
'mybindingname' // pino logger "service.name" value for `base` arg
354+
], {
355+
timeout: 5000
356+
}, function (err, stdout, stderr) {
357+
t.ifErr(err)
358+
const recs = stdout.trim().split(/\n/g).map(JSON.parse)
359+
t.equal(recs[0].service.name, 'myname')
360+
t.equal(recs[0].event.dataset, 'mydataset')
361+
362+
t.equal(recs[3].service.name, 'mybindingname')
363+
t.equal(recs[3].event.dataset, 'mybindingname')
336364
t.end()
337365
})
338366
})
@@ -351,8 +379,8 @@ test('unset APM serviceName does not set service.name, event.dataset, but also d
351379
const recs = stdout.trim().split(/\n/g).map(JSON.parse)
352380
t.equal(recs[0].service.name, 'myname')
353381
t.equal(recs[0].event.dataset, 'mydataset')
354-
t.equal(recs[1].service, undefined)
355-
t.equal(recs[1].event, undefined)
382+
t.equal(recs[3].service, undefined)
383+
t.equal(recs[3].event, undefined)
356384
t.end()
357385
})
358386
})

loggers/pino/test/basic.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,37 @@ test('ecsPinoFormat cases', suite => {
100100
req: { id: 42 },
101101
res: { status: 'OK' }
102102
}
103+
},
104+
{
105+
name: '`base: {}` should avoid "process" and "host" fields',
106+
pinoOpts: { base: {}, ...ecsFormat() },
107+
loggingFn: (log) => {
108+
log.info('hi')
109+
},
110+
rec: {
111+
'log.level': 'info',
112+
ecs: { version: ecsVersion },
113+
message: 'hi'
114+
}
115+
},
116+
{
117+
// The pino docs suggest:
118+
// > `base`
119+
// > Set to `null` to avoid adding `pid`, `hostname` and `name` properties to each log.
120+
//
121+
// This results in a given `formatters.bindings` **not getting called** at
122+
// all. In earlier versions of this package, that `formatters.bindings`
123+
// was used to add fields like "ecs.version".
124+
name: '`base: null` should not break ecs-logging format',
125+
pinoOpts: { base: null, ...ecsFormat() },
126+
loggingFn: (log) => {
127+
log.info('hi')
128+
},
129+
rec: {
130+
'log.level': 'info',
131+
ecs: { version: ecsVersion },
132+
message: 'hi'
133+
}
103134
}
104135
]
105136

loggers/pino/test/use-apm-override-service-name.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
// - flush APM and exit
2727

2828
const serviceName = process.argv[2] || ''
29+
const pinoServiceName = process.argv[3] || ''
2930
/* eslint-disable-next-line no-unused-vars */
3031
const apm = require('elastic-apm-node').start({
3132
// Use default serverUrl (fire and forget)
@@ -38,10 +39,12 @@ const apm = require('elastic-apm-node').start({
3839
const ecsFormat = require('../') // @elastic/ecs-pino-format
3940
const pino = require('pino')
4041

41-
const log = pino({ ...ecsFormat() })
42-
log.info({
43-
foo: 'bar',
44-
service: { name: 'myname' },
45-
event: { dataset: 'mydataset' }
46-
}, 'hi')
47-
log.info({ foo: 'bar' }, 'bye')
42+
const pinoOpts = ecsFormat()
43+
if (pinoServiceName) {
44+
pinoOpts.base = { service: { name: pinoServiceName }, event: { dataset: pinoServiceName } }
45+
}
46+
const log = pino(pinoOpts)
47+
log.info({ service: { name: 'myname' }, event: { dataset: 'mydataset' } }, 'override values')
48+
log.info({ service: { name: 42 }, event: { dataset: 42 } }, 'override values with nums')
49+
log.info({ service: 'myname', event: 'myevent' }, 'override top-level keys with invalid ECS type')
50+
log.info('bye')

0 commit comments

Comments
 (0)