// Licensed to Elasticsearch B.V. under one or more contributor // license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright // ownership. Elasticsearch B.V. licenses this file to you under // the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. 'use strict' const { version, formatError, formatHttpRequest, formatHttpResponse } = require('@elastic/ecs-helpers') const { hasOwnProperty } = Object.prototype let triedElasticApmImport = false let elasticApm = null // Create options for `pino(...)` that configure it for ecs-logging output. // // @param {Object} opts - Optional. // - {Boolean} opts.convertErr - Whether to convert a logged `err` field // to ECS error fields. Default true, to match Pino's default of having // an `err` serializer. // - {Boolean} opts.convertReqRes - Whether to convert logged `req` and `res` // HTTP request and response fields to ECS HTTP, User agent, and URL // fields. Default false. // - {Boolean} opts.apmIntegration - Whether to automatically integrate with // Elastic APM (https://github.com/elastic/apm-agent-nodejs). If a started // APM agent is detected, then log records will include the following // fields: // - "service.name" - the configured serviceName in the agent // - "event.dataset" - set to "$serviceName.log" for correlation in Kibana // - "trace.id", "transaction.id", and "span.id" - if there is a current // active trace when the log call is made // Default true. function createEcsPinoOptions (opts) { let convertErr = true let convertReqRes = false let apmIntegration = true if (opts) { if (hasOwnProperty.call(opts, 'convertErr')) { convertErr = opts.convertErr } if (hasOwnProperty.call(opts, 'convertReqRes')) { convertReqRes = opts.convertReqRes } if (hasOwnProperty.call(opts, 'apmIntegration')) { apmIntegration = opts.apmIntegration } } let apm = null let apmServiceName = null if (apmIntegration) { // istanbul ignore if if (opts && opts._elasticApm) { // `opts._elasticApm` is an internal/testing-only option to be used // for testing in the APM agent where the import is a local path // rather than "elastic-apm-node". elasticApm = opts._elasticApm } else if (!triedElasticApmImport) { triedElasticApmImport = true // We lazily require this module here instead of at the top-level to // avoid a possible circular-require if the user code does // `require('@elastic/ecs-pino-format')` and has a "node_modules/" // where 'elastic-apm-node' shares the same ecs-pino-format install. try { elasticApm = require('elastic-apm-node') } catch (ex) { // Silently ignore. } } if (elasticApm && elasticApm.isStarted && elasticApm.isStarted()) { apm = elasticApm // Elastic APM v3.11.0 added getServiceName(). Fallback to private `apm._conf`. // istanbul ignore next apmServiceName = apm.getServiceName ? apm.getServiceName() : apm._conf.serviceName } } let isServiceNameInBindings = false let isEventDatasetInBindings = false const ecsPinoOptions = { messageKey: 'message', timestamp: () => `,"@timestamp":"${new Date().toISOString()}"`, formatters: { level (label, number) { return { 'log.level': label } }, bindings (bindings) { const { // `pid` and `hostname` are default bindings, unless overriden by // a `base: {...}` passed to logger creation. pid, hostname, // name is defined if `log = pino({name: 'my name', ...})` name, // Warning: silently drop any "ecs" value from `base`. See // "ecs.version" comment below. ecs, ...ecsBindings } = bindings if (pid !== undefined) { // https://www.elastic.co/guide/en/ecs/current/ecs-process.html#field-process-pid ecsBindings.process = { pid: pid } } if (hostname !== undefined) { // https://www.elastic.co/guide/en/ecs/current/ecs-host.html#field-host-hostname ecsBindings.host = { hostname: hostname } } if (name !== undefined) { // https://www.elastic.co/guide/en/ecs/current/ecs-log.html#field-log-logger ecsBindings.log = { logger: name } } // Note if service.name & event.dataset are set, to not do so again below. if (bindings.service && bindings.service.name) { isServiceNameInBindings = true } if (bindings.event && bindings.event.dataset) { isEventDatasetInBindings = true } return ecsBindings }, log (obj) { const { req, res, err, ...ecsObj } = obj // https://www.elastic.co/guide/en/ecs/current/ecs-ecs.html // For "ecs.version" we take a heavier-handed approach, because it is // a require ecs-logging field: overwrite any possible "ecs" value from // the log statement. This means we don't need to spend the time // guarding against "ecs" being null, Array, Buffer, Date, etc. ecsObj.ecs = { version } if (apm) { // A mis-configured APM Agent can be "started" but not have a // "serviceName". if (apmServiceName) { // Per https://github.com/elastic/ecs-logging/blob/master/spec/spec.json // "service.name" and "event.dataset" should be automatically set // if not already by the user. if (!isServiceNameInBindings) { const service = ecsObj.service if (service === undefined) { ecsObj.service = { name: apmServiceName } } else if (!isVanillaObject(service)) { // Warning: "service" type conflicts with ECS spec. Overwriting. ecsObj.service = { name: apmServiceName } } else if (typeof service.name !== 'string') { ecsObj.service.name = apmServiceName } } if (!isEventDatasetInBindings) { const event = ecsObj.event if (event === undefined) { ecsObj.event = { dataset: apmServiceName + '.log' } } else if (!isVanillaObject(event)) { // Warning: "event" type conflicts with ECS spec. Overwriting. ecsObj.event = { dataset: apmServiceName + '.log' } } else if (typeof event.dataset !== 'string') { ecsObj.event.dataset = apmServiceName + '.log' } } } // https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html const tx = apm.currentTransaction if (tx) { ecsObj.trace = ecsObj.trace || {} ecsObj.trace.id = tx.traceId ecsObj.transaction = ecsObj.transaction || {} ecsObj.transaction.id = tx.id const span = apm.currentSpan // istanbul ignore else if (span) { ecsObj.span = ecsObj.span || {} ecsObj.span.id = span.id } } } // https://www.elastic.co/guide/en/ecs/current/ecs-http.html if (err !== undefined) { if (!convertErr) { ecsObj.err = err } else { formatError(ecsObj, err) } } // https://www.elastic.co/guide/en/ecs/current/ecs-http.html if (req !== undefined) { if (!convertReqRes) { ecsObj.req = req } else { formatHttpRequest(ecsObj, req) } } if (res !== undefined) { if (!convertReqRes) { ecsObj.res = res } else { formatHttpResponse(ecsObj, res) } } return ecsObj } } } return ecsPinoOptions } // Return true if the given arg is a "vanilla" object. Roughly the intent is // whether this is basic mapping of string keys to values that will serialize // as a JSON object. // // Currently, it excludes Map. The uses above don't really expect a user to: // service = new Map([["foo", "bar"]]) // log.info({ service }, '...') // // There are many ways tackle this. See some attempts and benchmarks at: // https://gist.github.com/trentm/34131a92eede80fd2109f8febaa56f5a function isVanillaObject (o) { return (typeof o === 'object' && (!o.constructor || o.constructor.name === 'Object')) } module.exports = createEcsPinoOptions