packages/ecs-winston-format/index.js (188 lines of code) (raw):

// 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' // Min dep is triple-beam@1.1.0, which defines LEVEL and MESSAGE. SPLAT might be // undefined. const { LEVEL, MESSAGE, SPLAT } = require('triple-beam') const safeStableStringify = require('safe-stable-stringify') const { version, formatHttpRequest, formatHttpResponse } = require('@elastic/ecs-helpers') // We will query the Elastic APM agent if it is available. let elasticApm = null try { elasticApm = require('elastic-apm-node') } catch (ex) { // Silently ignore. } const stringify = safeStableStringify.configure() /** * A Winston `Format` for converting fields on the `info` object to ECS logging * format. * * @class {import('logform').Format} * @param {Config} opts - See index.d.ts. */ class EcsFieldsTransform { constructor (opts) { this.options = opts } transform (info, opts) { // istanbul ignore next opts = opts || {} const convertErr = opts.convertErr != null ? opts.convertErr : true const convertReqRes = opts.convertReqRes != null ? opts.convertReqRes : false const apmIntegration = opts.apmIntegration != null ? opts.apmIntegration : true // Do error handling first, because for case 3 we sometimes need to // *replace* the `info` object. Winston has a number of ways that it does // something with `Error` instances passed to a logger. // // 1. `log.warn('a message', new Error('boom'))` // If `info[SPLAT][0] instanceof Error`, then convert it to `error.*` fields // in place of `info.stack`. // // 2. Winston logger configured to handle uncaughtException and/or unhandledRejection. // If `info.exception: true` or `info.rejection: true`, and level is // "error", and `info.trace` is an Array and `info.message` starts with // "uncaughtException:" or "unhandledRejection:", then convert to // `error.*` fields. These conditions are to infer the `info` shape // returned by Winston's `ExceptionHandler` and `RejectionHandler`. // In this case the redundant `stack`, `trace`, `date` fields are dropped // and error details are moved to the `error.*` fields. // // If `opts.convertErr === true` (the default), then the next two forms are // considered as well. // // 3. `log.warn(new Error('boom'))` // `log.warn(new Error(''))` // `log.warn(new Error('boom'), {foo: 'bar'})` // If `info instanceof Error` or `info.message instanceof Error`, then // convert it to `error.*` fields. The latter two are a little strange, but // Winston's logger will transform that to `{ message: new Error(...) }` // and "logform/errors.js" will handle that. // // 4. `log.warn('a message', { err: new Error('boom') })` // If `info.err instanceof Error`, then convert to `error.*` fields. // Note: This feature doesn't really belong because it extends error // handling beyond what is typical in Winston. It remains for backward // compatibility. let err const splat0 = SPLAT && info[SPLAT] && info[SPLAT][0] if (splat0 instanceof Error) { // case 1 // Undo the addition of this error's enumerable properties to the // top-level info object. err = splat0 delete info.stack for (const propName in err) { delete info[propName] } } else if ( (info.exception === true || info.rejection === true) && info.level === 'error' && Array.isArray(info.trace) && (info.message.startsWith('uncaughtException:') || info.message.startsWith('unhandledRejection:'))) { // case 2 // The 'stack', 'trace', and trace in the 'message' are redundant. // 'date' is also redundant with '@timestamp'. delete info.stack delete info.trace delete info.date info.message = info.message.split(/\n/, 1)[0] // istanbul ignore else if (info.error instanceof Error) { err = info.error } else { info.error = { message: info.error.toString() } } delete info.error // Dev Note: We *could* translate some of the process and os fields, but // we don't currently. // https://www.elastic.co/guide/en/ecs/current/ecs-process.html // https://www.elastic.co/guide/en/ecs/current/ecs-host.html } else if (convertErr) { if (info instanceof Error) { // case 3a // With `log.info(err)`, Winston incorrectly uses `err` as the info // object -- (a) mutating it and (b) resulting in not being able to // differentiate `defaultMeta` and `err` properties. // The best we can do is, at least, not serialize `error.level` using // the incorrectly added `level` field. err = info info = Object.assign( { message: err.message, [LEVEL]: err.level }, err) delete err.level } else if (info.message instanceof Error) { // case 3b // `log.info(err, {...})` or `log.info(new Error(''))` with empty message. err = info.message info.message = err.message } else if (info.err instanceof Error) { // case 4 err = info.err delete info.err } } // If we have an Error instance, then serialize it to `error.*` fields. if (err) { // First we add err's enumerable fields, as `logform.errors()` does. info.error = Object.assign({}, err) // Then add standard ECS error fields (https://www.elastic.co/guide/en/ecs/current/ecs-error.html). // istanbul ignore next info.error.type = toString.call(err.constructor) === '[object Function]' ? err.constructor.name : err.name info.error.message = err.message info.error.stack_trace = err.stack // The add some additional fields. `cause` is handled by // `logform.errors({cause: true})`. This implementation ensures it is // always a string to avoid its type varying depending on the value. // istanbul ignore next -- so coverage works for Node.js <16.9.0 if (err.cause) { info.error.cause = err.cause instanceof Error ? err.cause.stack : err.cause.toString() } } // Core ECS logging fields. info['@timestamp'] = new Date().toISOString() info['log.level'] = info.level // Note: We do *not* remove `info.level`, even though it is not an ECS // field, because https://github.com/winstonjs/logform#info-objects says: // "Every info must have at least the level and message properties". // Instead, it will be excluded from serialization in `EcsStringifyTransform`. info['ecs.version'] = version let apm = null if (apmIntegration && elasticApm && elasticApm.isStarted && elasticApm.isStarted()) { apm = elasticApm } // Set a number of correlation fields from (a) the given options or (b) an // APM agent, if there is one running. let serviceName = opts.serviceName if (serviceName == null && apm) { // istanbul ignore next serviceName = (apm.getServiceName ? apm.getServiceName() // added in elastic-apm-node@3.11.0 : apm._conf.serviceName) // fallback to private `_conf` } if (serviceName) { info['service.name'] = serviceName } let serviceVersion = opts.serviceVersion if (serviceVersion == null && apm) { // istanbul ignore next serviceVersion = (apm.getServiceVersion ? apm.getServiceVersion() // added in elastic-apm-node@... : apm._conf.serviceVersion) // fallback to private `_conf` } if (serviceVersion) { info['service.version'] = serviceVersion } let serviceEnvironment = opts.serviceEnvironment if (serviceEnvironment == null && apm) { // istanbul ignore next serviceEnvironment = (apm.getServiceEnvironment ? apm.getServiceEnvironment() // added in elastic-apm-node@... : apm._conf.environment) // fallback to private `_conf` } if (serviceEnvironment) { info['service.environment'] = serviceEnvironment } let serviceNodeName = opts.serviceNodeName if (serviceNodeName == null && apm) { // istanbul ignore next serviceNodeName = (apm.getServiceNodeName ? apm.getServiceNodeName() // added in elastic-apm-node@... : apm._conf.serviceNodeName) // fallback to private `_conf` } if (serviceNodeName) { info['service.node.name'] = serviceNodeName } let eventDataset = opts.eventDataset if (eventDataset == null && serviceName) { eventDataset = serviceName } if (eventDataset) { info['event.dataset'] = eventDataset } // istanbul ignore else if (apm) { // https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html const tx = apm.currentTransaction if (tx) { info['trace.id'] = tx.traceId info['transaction.id'] = tx.id const span = apm.currentSpan // istanbul ignore else if (span) { info['span.id'] = span.id } } } // https://www.elastic.co/guide/en/ecs/current/ecs-http.html if (info.req !== undefined && convertReqRes) { formatHttpRequest(info, info.req) delete info.req } if (info.res !== undefined && convertReqRes) { formatHttpResponse(info, info.res) delete info.res } return info } } function ecsFields (opts) { return new EcsFieldsTransform(opts) } class EcsStringifyTransform { constructor (opts) { this.options = opts } transform (info, opts) { // `info.level` must stay (see note above), but we don't want to serialize // it, so exclude it from the stringified fields. There *is* a perf cost // for this. const { level, ...infoSansLevel } = info info[MESSAGE] = stringify(infoSansLevel) return info } } function ecsStringify (opts) { return new EcsStringifyTransform(opts) } /** * A Winston transform that composes `ecsFields(...)` and `ecsStringify()`. */ class EcsFormatTransform { constructor (opts) { this.options = opts this._fieldsTx = ecsFields(opts) this._stringifyTx = ecsStringify() } transform (info, opts) { info = this._fieldsTx.transform(info, this._fieldsTx.options) info = this._stringifyTx.transform(info, this._stringifyTx.options) return info } } function ecsFormat (opts) { return new EcsFormatTransform(opts) } // For backwards compatibility with v1.0.0, the top-level export is `ecsFormat`, // though using the named exports is preferred. module.exports = ecsFormat module.exports.ecsFormat = ecsFormat module.exports.ecsFields = ecsFields module.exports.ecsStringify = ecsStringify module.exports.default = ecsFormat