logdna
Version:
LogDNA's Node.js Logging Module.
457 lines (391 loc) • 13.1 kB
JavaScript
'use strict'
/*
* LogDNA NPM Module
* - supports levels (Debug, Info, Notice, Warning, Error, Critical, Alert, Emerge)
* - supports Winston
* - supports Bunyan
*/
// External Modules
const Agent = require('agentkeepalive')
const axios = require('axios')
const clone = require('lodash.clonedeep')
const debug = require('util').debuglog('logdna')
const querystring = require('querystring')
const os = require('os')
const sizeof = require('object-sizeof')
const stringify = require('json-stringify-safe')
const validUrl = require('valid-url')
// Configuration
const configs = require('./configs')
const TAGS_RE = /\s*,\s*/
// Variables
let loggers = []
const checkStringParam = (param, name, optional) => {
if (optional && !param) return
if (!param || typeof param !== 'string') {
throw new Error(`${name} is undefined or not passed as a String`)
} else if (param.length > configs.MAX_INPUT_LENGTH) {
throw new Error(`${name} cannot be longer than ${configs.MAX_INPUT_LENGTH} chars`)
}
}
const isValidTimestamp = (timestamp) => {
const valid = (new Date(timestamp)).getTime() > 0
if (!valid || Math.abs(timestamp - Date.now()) > configs.MS_IN_A_DAY) {
debug('Error: The timestamp used is either invalid or not within one day. Date.now() will be used in its place.')
return false
}
return true
}
function Logger(key, opts) {
if (!(this instanceof Logger)) {
return new Logger(key, opts)
}
const options = opts || {}
checkStringParam(key, 'LogDNA Ingestion Key', false)
checkStringParam(options.hostname, 'Hostname', true)
checkStringParam(options.mac, 'MAC Address', true)
checkStringParam(options.ip, 'IP Address', true)
checkStringParam(options.level, 'Level', true)
checkStringParam(options.app, 'App', true)
checkStringParam(options.logdna_url, 'LogDNA URL', true)
let tags = options.tags
if (tags) {
if (typeof tags === 'string') {
tags = tags.split(TAGS_RE)
}
if (Array.isArray(tags)) {
tags = tags
.filter(tag => tag !== '')
.map(tag => tag.trim())
.join(',')
} else {
throw new Error('Tags should be passed as a String or an Array')
}
}
if (options.timeout) {
if (!Number.isInteger(options.timeout)) {
throw new Error('Timeout must be an Integer')
}
if (options.timeout > configs.MAX_REQUEST_TIMEOUT) {
throw new Error(`Timeout cannot be longer than ${configs.MAX_REQUEST_TIMEOUT}`)
}
}
if (options.hostname && !configs.HOSTNAME_CHECK.test(options.hostname)) {
throw new Error('Invalid hostname')
}
if (options.mac && !configs.MAC_ADDR_CHECK.test(options.mac)) {
throw new Error('Invalid MAC Address format')
}
if (options.ip && !configs.IP_ADDR_CHECK.test(options.ip)) {
throw new Error('Invalid IP Address format')
}
if (options.logdna_url && !validUrl.isUri(options.logdna_url)) {
throw new Error('Invalid URL')
}
if (options.flushLimit && Number.isInteger(options.flushLimit)) {
this._flushLimit = options.flushLimit
} else {
this._flushLimit = configs.FLUSH_BYTE_LIMIT
}
if (options.retryTimes && Number.isInteger(options.retryTimes)) {
this._retryTimes = options.retryTimes
} else {
this._retryTimes = configs.RETRY_TIMES
}
if (options.flushInterval && Number.isInteger(options.flushInterval)) {
this._flushInterval = options.flushInterval
} else {
this._flushInterval = configs.FLUSH_INTERVAL
}
if (options.retryTimeout && Number.isInteger(options.retryTimeout)) {
this._retryTimeout = options.retryTimeout
} else {
this._retryTimeout = configs.BACKOFF_PERIOD
}
if (options.failedBufRetentionLimit && Number.isInteger(options.failedBufRetentionLimit)) {
this._failedBufRetentionLimit = options.failedBufRetentionLimit
} else {
this._failedBufRetentionLimit = configs.FAILED_BUF_RETENTION_LIMIT
}
const {shimProperties} = options
if (shimProperties && shimProperties instanceof Array && shimProperties.length) {
this._shimProperties = options.shimProperties
}
this._max_length = options.max_length || true
this._index_meta = options.index_meta || false
this._url = options.logdna_url || configs.LOGDNA_URL
this._bufByteLength = 0
this._buf = []
this._meta = {}
this._isLoggingBackedOff = false
this._attempts = 0
this.__dbgLines = null
this.__bufSizePreserve = null
this.callback = null
this.source = {
hostname: options.hostname || os.hostname()
, app: options.app || 'default'
, level: options.level || 'INFO'
, env: options.env || undefined
, tags: tags || undefined
}
const useHttps = (options.protocol || configs.AGENT_PROTOCOL) === 'https'
this._req = {
auth: {username: key}
, agent: useHttps ? new Agent.HttpsAgent(configs.AGENT_SETTING) : new Agent(configs.AGENT_SETTING)
, headers: {...clone(configs.DEFAULT_REQUEST_HEADER), ...typeof window === 'undefined' && {
'user-agent': options.UserAgent || configs.DEFAULT_USER_AGENT
}}
, qs: {
hostname: this.source.hostname
, mac: options.mac || undefined
, ip: options.ip || undefined
, tags: this.source.tags || undefined
}
, timeout: options.timeout || configs.DEFAULT_REQUEST_TIMEOUT
, withCredentials: options.with_credentials || configs.REQUEST_WITH_CREDENTIALS
, useHttps
}
this._req.headers.Authorization = 'Basic ' + Buffer.from(`${key}:`).toString('base64')
loggers.push(this)
}
Logger.prototype.log = function(statement, opts, callback) {
this._err = false
if (typeof statement === 'object') {
statement = JSON.parse(JSON.stringify(statement))
statement = stringify(statement, null, 2)
}
const message = {
timestamp: Date.now()
, line: statement
, level: this.source.level
, app: this.source.app
, env: this.source.env
, meta: this._meta
}
if (opts) {
if (typeof opts === 'function') {
callback = opts
} else if (typeof opts === 'string') {
if (opts.length > configs.MAX_INPUT_LENGTH) {
debug('Level had more than ' + configs.MAX_INPUT_LENGTH + ' chars, was truncated')
opts = opts.substring(0, configs.MAX_INPUT_LENGTH)
}
message.level = opts
} else {
if (typeof opts !== 'object') {
const err = new TypeError('options parameter must be a level (string), or object')
err.meta = {opts}
this._err = err
debug(err.message)
}
message.level = opts.level || message.level
message.app = opts.app || message.app
message.env = opts.env || message.env
if (opts.timestamp && isValidTimestamp(opts.timestamp)) {
message.timestamp = opts.timestamp
}
if (opts.context && !opts.meta) {
opts.meta = opts.context
}
if (typeof opts.meta === 'object') {
opts.meta = {...message.meta, ...opts.meta}
if (opts.index_meta || (opts.index_meta === undefined && this._index_meta)) {
message.meta = opts.meta
} else {
message.meta = stringify(opts.meta)
}
}
if (opts.logSourceCRN) {
message.logSourceCRN = opts.logSourceCRN
}
if (opts.saveServiceCopy) {
message.saveServiceCopy = opts.saveServiceCopy
}
if (opts.appOverride) {
message.appOverride = opts.appOverride
}
if (this._shimProperties) {
this._shimProperties.forEach((prop) => {
if (opts.hasOwnProperty(prop)) {
message[prop] = opts[prop]
}
})
}
}
} else if (!this._index_meta) {
message.meta = stringify(message.meta)
}
if (this._err) {
return callback(this._err)
}
this._bufferLog(message, callback)
}
Logger.prototype._bufferLog = function(message, callback) {
if (!callback || typeof callback !== 'function') {
callback = (err) => { debug(err) }
}
if (!message || !message.line) {
debug('Ignoring empty message')
return setImmediate(callback)
}
if (this._max_length && message.line.length > configs.MAX_LINE_LENGTH) {
message.line = message.line.substring(0, configs.MAX_LINE_LENGTH) + ' (cut off, too long...)'
debug('Line was longer than ' + configs.MAX_LINE_LENGTH + ' chars and was truncated.')
}
this._bufByteLength += sizeof(message)
if (this._bufByteLength >= this._failedBufRetentionLimit) {
debug(`buffer size exceeded the failed buffer retention limit. Dropping this line: ${message.line}`)
this._bufByteLength -= sizeof(message)
} else {
debug('Buffering message: %s', message.line)
this._buf.push(message)
}
if (!this._isLoggingBackedOff && (this._bufByteLength >= this._flushLimit)) {
debug('Buffer size meets (or exceeds) flush limit. Immediately flushing')
this._flush(callback)
return
}
if (this._isLoggingBackedOff) {
debug('Backing off.')
this._isLoggingBackedOff = false
this._flusher = setTimeout(() => {
this._flush(callback)
}, this._retryTimeout)
}
if (!this._flusher) {
debug('No scheduled flush. Scheduling for %d ms from now.', configs.FLUSH_INTERVAL)
this._flusher = setTimeout(() => {
this._flush(callback)
}, this._flushInterval)
}
}
Logger.prototype.addMetaProperty = function(key, value) {
this._meta[key] = value
}
Logger.prototype.removeMetaProperty = function(key) {
if (this._meta[key]) {
delete this._meta[key]
} else {
throw new Error(`There is no meta property called ${key}`)
}
}
Logger.prototype._sendRequest = function(config, instance) {
axios(config)
.then((response) => {
if (response && response.status === 200) {
instance._isLoggingBackedOff = false
instance._attempts = 0
return instance.callback(null, {
lines: instance.__dbgLines
, httpStatus: response.status
, body: response
})
}
return instance.callback(`Unsuccessful request. Status text: ${response ? response.statusText : ''}`)
})
.catch((err) => {
if (err.response) instance._isLoggingBackedOff = true
// Return to buffer
instance._buf = instance._buf.concat(JSON.parse(config.data).ls)
instance._bufByteLength += instance.__bufSizePreserve
if (!instance._flusher && instance._attempts < instance._retryTimes) {
instance._attempts += 1
instance._flusher = setTimeout(() => {
instance._flush(instance.callback)
}, instance._retryTimeout)
}
let message = 'An error occured while making the request.'
if (err && err.response) {
message += ` Response status code: ${err.response.status} ${err.response.statusText}`
}
return instance.callback(message)
})
}
Logger.prototype._flush = function(callback) {
if (!callback || typeof callback !== 'function') {
throw new Error('flush function expects a callback')
}
clearTimeout(this._flusher)
this._flusher = null
if (this._buf.length === 0) {
debug('Nothing to flush')
return callback()
}
const data = stringify({e: 'ls', ls: this._buf})
this._req.qs.now = Date.now()
this.__dbgLines = this._buf.map(function(msg) { return msg.line })
this.__bufSizePreserve = this._bufByteLength
this.callback = callback
this._bufByteLength = 0
this._buf.length = 0
const _config = {
method: 'post'
, url: this._url + '?' + querystring.stringify(this._req.qs)
, headers: this._req.headers
, data
, timeout: this._req.timeout
, withCredentials: this._req.withCredentials
, json: true
}
if (this._req.useHttps) {
_config.httpsAgent = this._req.agent
} else {
_config.httpAgent = this._req.agent
}
this._sendRequest(_config, this)
}
/*
* Populate short-hand for each supported Log Level
*/
configs.LOG_LEVELS.forEach(function(level) {
const levelFunctionName = level.toLowerCase()
Logger.prototype[levelFunctionName] = function(statement, opts, callback) {
if (opts && typeof opts === 'function') {
callback = opts
opts = {}
}
opts = opts || {}
opts.level = level
this.log(statement, opts, callback)
}
})
const flushAll = function(cb) {
if (!cb || typeof cb !== 'function') {
cb = debug
}
// Variables used to wait for all the flushes to be completed
let counter = 0
const loggersCount = loggers.length
const complete = (errors) => {
if (errors.length > 0) {
return cb(`The following errors happened while flushing all loggers: ${errors}`)
}
return cb()
}
const errors = []
loggers.forEach((logger) => {
logger._flush((err) => {
if (err) {
errors.push(err)
}
// Counter is incremented after each flush
if (++counter === loggersCount) {
/*
* Flushed all the loggers
* Completes by calling the callback
*/
complete(errors)
}
})
})
}
exports.Logger = Logger
exports.createLogger = function(key, options) {
return new Logger(key, options)
}
exports.flushAll = flushAll
exports.cleanUpAll = function(cb) {
flushAll(cb)
loggers = []
}