logsene-js
Version:
JavaScript client for Sematext Logs
582 lines (547 loc) • 19.6 kB
JavaScript
/*
* See the NOTICE.txt file distributed with this work for additional information
* regarding copyright ownership.
* Sematext licenses logsene-js 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 nodeFetch = require('node-fetch')
const fs = require('fs')
const util = require('util')
const os = require('os')
const events = require('events')
const ipAddress = require('ip').address()
const path = require('path')
const stringifySafe = require('fast-safe-stringify')
const streamBuffers = require('stream-buffers')
// settings for node stream buffer
const initialBufferSize = 1024 * 1024
const incrementBuffer = 1024 * 1024
// re-usable regular expressions
const limitRegex = /limit/i
const appNotFoundRegEx = /Application not found for token/i
const disableJsonEnrichment = (process.env.ENABLE_JSON_ENRICHMENT === 'false')
let replaceDotsInFieldNames = true
if (process.env.REPLACE_DOTS_IN_FIELD_NAMES === 'false' || process.env.REPLACE_DOTS_IN_FIELD_NAMES === '0') {
replaceDotsInFieldNames = false
}
// load ENV like Logsene receivers from file containing
// env vars e.g. SPM_RECEIVER_URL, EVENTS_RECEIVER_URL, LOGSENE_RECEIVER_URL
// the file overwrites the actual environment
// and is used by Sematext Enterprise or multi-region setups to
// setup receiver URLs
function loadEnvFromFile (fileName) {
try {
const receivers = fs.readFileSync(fileName).toString()
if (receivers) {
const lines = receivers.split('\n')
lines.forEach(function (line) {
const kv = line.split('=')
if (kv.length === 2 && kv[1].length > 0) {
process.env[kv[0].trim()] = kv[1].trim()
if (/logsene-js/.test(process.env.DEBUG)) {
console.log(kv[0].trim() + ' = ' + kv[1].trim())
}
}
})
}
if (/logsene-js/.test(process.env.DEBUG)) {
console.log(new Date(), 'loading Sematext receiver URLs from ' + fileName)
}
} catch (error) {
// ignore missing file or wrong format
if (/logsene-js/.test(process.env.DEBUG)) {
console.error(error.message)
}
}
}
const envFileName = '/etc/sematext/receivers.config'
/**
if (/win/.test(os.platform()) {
envFileName = process.env.ProgramData + '\\Sematext\\receivers.config'
}
**/
loadEnvFromFile(envFileName)
// removing LOGSENE from ENV variable names
// and be backward compatible in case users still use old ENV variable names
const envVarMapping = [
['LOGSENE_MAX_MESSAGE_FIELD_SIZE', 'LOGS_MAX_MESSAGE_FIELD_SIZE'],
['LOGSENE_MAX_STORED_REQUESTS', 'LOGS_MAX_STORED_REQUESTS'],
['LOGSENE_BULK_SIZE_BYTES', 'LOGS_BULK_SIZE_BYTES'],
['LOGSENE_BULK_SIZE', 'LOGS_BULK_SIZE'],
['LOGSENE_LOG_INTERVAL', 'LOG_INTERVAL'],
['LOGSENE_DISK_BUFFER_INTERVAL', 'LOGS_DISK_BUFFER_INTERVAL'],
['LOGSENE_RECEIVER_URL', 'LOGS_RECEIVER_URL'],
['SPM_RECEIVER_URL', 'MONITORING_RECEIVER_URL'],
['SPM_TOKEN', 'MONITORING_TOKEN'],
['LOGSENE_REMOVE_FIELDS', 'REMOVE_FIELDS'],
['LOGSENE_TMP_DIR', 'LOGS_TMP_DIR'],
['LOGSENE_BUFFER_ON_APP_LIMIT ', 'LOGS_BUFFER_ON_APP_LIMIT'],
['LOGSENE_REMOVE_FIELDS', 'LOGS_REMOVE_FIELDS'],
['LOGSENE_REMOVE_FIELDS', 'REMOVE_FIELDS'],
['SPM_REPORTED_HOSTNAME', 'REPORTED_HOSTNAME']
]
function mapEnv (item) {
if ((!process.env[item[0]]) && (process.env[item[1]] !== undefined)) {
process.env[item[0]] = process.env[item[1]]
if (/logsene-js/.test(process.env.DEBUG)) {
console.log('Set ', item[0], ' to ', process.env[item[0]], ' from ', item[1])
}
}
}
envVarMapping.forEach(mapEnv)
// SPM_REPORTED_HOSTNAME might be set by Sematext Docker Agent
// the container hostname might not be helpful ...
// this might be removed after next release of SDA setting xLogseneOrigin from SDA
var xLogseneOrigin = process.env.SPM_REPORTED_HOSTNAME || os.hostname()
// limit message size
var MAX_MESSAGE_FIELD_SIZE = Number(process.env.LOGSENE_MAX_MESSAGE_FIELD_SIZE) || 1024 * 240 // 240 K, leave
// settings for bulk requests
var MIN_LOGSENE_BULK_SIZE = 200
var MAX_LOGSENE_BULK_SIZE = 10000
var MAX_STORED_REQUESTS = Number(process.env.LOGSENE_MAX_STORED_REQUESTS) || 10000
var MAX_CLIENT_SOCKETS = Number(process.env.MAX_CLIENT_SOCKETS) || 5
// upper limit a user could set - 10 MB as configured in Sematext Cloud receivers
var MAX_LOGSENE_BULK_SIZE_BYTES = 10 * 1000 * 1000
// lower limit a user could set
var MIN_LOGSENE_BULK_SIZE_BYTES = 1024 * 1024
var MAX_LOGSENE_BUFFER_SIZE = Number(process.env.LOGSENE_BULK_SIZE_BYTES) || 1024 * 1024 * 3 // max 3 MB per http request
// check limits set by users, and adjust if those would lead to problematic settings
if (MAX_LOGSENE_BUFFER_SIZE > MAX_LOGSENE_BULK_SIZE_BYTES) {
MAX_LOGSENE_BUFFER_SIZE = MAX_LOGSENE_BULK_SIZE_BYTES
}
if (MAX_LOGSENE_BUFFER_SIZE < MIN_LOGSENE_BULK_SIZE_BYTES) {
MAX_LOGSENE_BUFFER_SIZE = MIN_LOGSENE_BULK_SIZE_BYTES
}
var LOGSENE_BULK_SIZE = Number(process.env.LOGSENE_BULK_SIZE) || 500 // max 500 messages per bulk req.
if (LOGSENE_BULK_SIZE > MAX_LOGSENE_BULK_SIZE) {
LOGSENE_BULK_SIZE = MAX_LOGSENE_BULK_SIZE
}
if (LOGSENE_BULK_SIZE < MIN_LOGSENE_BULK_SIZE) {
LOGSENE_BULK_SIZE = MIN_LOGSENE_BULK_SIZE
}
const deleteKey = require('del-key')
function removeFields (fieldList, doc) {
if (fieldList && fieldList.length > 0 && fieldList[0] !== '') {
for (let i = fieldList.length; i >= 0; --i) {
deleteKey(doc, fieldList[i])
}
}
return doc
}
// Create a deep clone of an object while allowing caller to rename
// keys, replace values, or reject key-pairs entirely.
//
// Does not modify the source object. Callback receives (key, value)
// and is expected to return a two-item array [newKey, newValue], or
// null if the pair should be absent from the resulting object.
function deepConvert (src, cb) {
let dest
if (Array.isArray(src)) {
dest = []
} else {
dest = {}
}
if (dest) {
for (let key in src) {
if (src.hasOwnProperty(key)) {
const val = src[key]
const newKV = cb(key, val)
if (newKV === null) {
// skip this field entirely
continue
}
const newKey = newKV[0]
const newVal = newKV[1]
if (newVal !== undefined &&
newVal !== null &&
(Array.isArray(newVal) || newVal.constructor === Object)) {
dest[newKey] = deepConvert(newVal, cb)
} else {
dest[newKey] = newVal
}
}
}
} else {
dest = src
}
return dest
}
/**
* token - the LOGSENE Token
* type - type of log (string)
* url - optional alternative URL for Logsene receiver (e.g. for on premises version)
* storageDirectory - directory where to buffer logs
* options - @object { useIndexInBulkUrl, httpOptions, silent }
*/
function Logsene (token, type, url, storageDirectory, options) {
if (!token) {
throw new Error('Logsene token not specified')
}
if (options) {
this.options = options
} else {
this.options = {
useIndexInBulkUrl: false,
silent: false
}
}
if (url && /logsene/.test(url)) {
// logs to logsene should use /TOKEN/_bulk
this.options.useIndexInBulkUrl = true
}
this.request = null
this.maxMessageFieldSize = MAX_MESSAGE_FIELD_SIZE
this.xLogseneOrigin = xLogseneOrigin
this.token = token
this.setUrl(url || process.env.LOGSENE_URL || process.env.LOGSENE_RECEIVER_URL || process.env.LOGS_URL || 'https://logsene-receiver.sematext.com/_bulk')
this.type = type || 'logs'
this.hostname = process.env.SPM_REPORTED_HOSTNAME || os.hostname()
this.bulkReq = new streamBuffers.WritableStreamBuffer({
initialSize: initialBufferSize,
incrementAmount: incrementBuffer
})
this.logCount = 0
this.sourceName = null
if (process.mainModule && process.mainModule.filename) {
this.sourceName = path.basename(process.mainModule.filename)
}
events.EventEmitter.call(this)
let self = this
self.lastSend = Date.now()
const logInterval = Number(process.env.LOGSENE_LOG_INTERVAL) || 20000
const tid = setInterval(function () {
if (self.logCount > 0 && (Date.now() - self.lastSend) > (logInterval - 1000)) {
self.send()
}
}, logInterval)
if (tid.unref) {
tid.unref()
}
process.on('beforeExit', function () {
self.send()
})
if (process.env.LOGSENE_TMP_DIR || storageDirectory) {
this.diskBuffer(true, process.env.LOGSENE_TMP_DIR || storageDirectory)
}
const fieldListStr = process.env.LOGSENE_REMOVE_FIELDS || process.env.REMOVE_FIELDS || ''
this.removeFieldsList = fieldListStr.replace(/ /g, '').split(',')
// error handling
self.on('x-logsene-error', function (errObj) {
if (!self.options.silent) {
console.error(
new Date().toISOString() +
` logsene-js: cannot reach the receiver URL: ${errObj.err.url}, please check the error message ...`,
errObj
)
}
})
}
util.inherits(Logsene, events.EventEmitter)
Logsene.prototype.setUrl = function (url) {
let tmpUrl = url
if (url.indexOf('_bulk') === -1) {
tmpUrl = url + '/_bulk'
} else {
tmpUrl = url
}
if (this.options && this.options.useIndexInBulkUrl) {
this.url = tmpUrl.replace('_bulk', this.token + '/_bulk')
} else {
this.url = tmpUrl
}
let Agent = null
let httpOptions = { maxSockets: MAX_CLIENT_SOCKETS, keepAlive: false, maxFreeSockets: MAX_CLIENT_SOCKETS }
if (this.options.httpOptions) {
const keys = Object.keys(this.options.httpOptions)
for (let i = 0; i < keys.length; i++) {
httpOptions[keys[i]] = this.options.httpOptions[keys[i]]
}
}
if (/^https/.test(url)) {
Agent = require('https').Agent
} else {
Agent = require('http').Agent
}
this.httpAgent = new Agent(httpOptions)
this.httpDefaults = {
agent: this.httpAgent,
timeout: 60000,
}
}
const DiskBuffer = require('./DiskBuffer.js')
Logsene.prototype.diskBuffer = function (enabled, dir) {
if (enabled) {
const tmpDir = path.join((dir || require('os').tmpdir()), this.token)
this.db = DiskBuffer.createDiskBuffer({
tmpDir: tmpDir,
maxStoredRequests: Number(MAX_STORED_REQUESTS),
interval: process.env.LOGSENE_DISK_BUFFER_INTERVAL || 5000
})
this.db.syncFileListFromDir()
let self = this
if (!this.db.isCached) {
// only the first instance registers for retransmit-req
// to avoid double event handling from multiple instances
this.db.on('retransmit-req', function (event) {
self.shipFile(event.fileName, event.buffer, function (err, res) {
if (err && err.httpBody && appNotFoundRegEx.test(err.httpBody)) {
// remove file from DiskBuffer when token is invalid
self.db.rmFile.call(self.db, event.fileName)
self.db.retransmitNext.call(self.db)
return
}
if (!err && res) {
self.db.rmFile.call(self.db, event.fileName)
self.db.retransmitNext.call(self.db)
} else {
self.db.unlock.call(self.db, event.fileName)
}
})
})
}
}
this.persistence = enabled
}
/**
* Add log message to send buffer
* @param level - log level e.g. 'info', 'warning', 'error'
* @param message - text message
* @param fields - Object with custom fields or overwrite of any other field e.g. e.g. "{@timestamp: new Date.toISOString()}"
* @param callback (err, msg object)
*/
Logsene.prototype.log = function (level, message, fields, callback) {
this.logCount = this.logCount + 1
let type = 'logs'
let logType = 'logs'
if (fields) {
logType = fields._type
}
if (this.options.useIndexInBulkUrl) {
// not a Sematext service -> use only one type per index
// Elasticsearch > 6.x allows only one type per index
type = this.type
}
let msg = {
'@timestamp': new Date(),
severity: level,
message,
logType,
os: {
host: this.hostname,
hostip: ipAddress
}
}
let elasticsearchDocId = null
if (fields && fields._type) {
delete fields._type
}
if (fields && fields._id) {
elasticsearchDocId = fields._id
}
if (disableJsonEnrichment) {
msg = {}
}
let esSanitizedFields = deepConvert(fields, function (key, val) {
if (typeof val === 'function') {
return null
} else {
if (replaceDotsInFieldNames) {
return [key.replace(/\./g, '_').replace(/^_+/, ''), val]
} else {
// remove leading _
return [key.replace(/^_+/, ''), val]
}
}
})
msg = Object.assign(msg, esSanitizedFields)
if (msg['@timestamp'] && typeof msg['@timestamp'] === 'number') {
msg['@timestamp'] = new Date(msg['@timestamp'])
}
msg = removeFields(this.removeFieldsList, msg)
let _index = this.token
if (fields && typeof (fields._index) === 'function') {
_index = fields._index(msg)
} else {
if (fields && fields._index != undefined) {
_index = fields._index
delete msg.index
}
}
if (msg.message && typeof msg.message === 'string' && Buffer.byteLength(msg.message, 'utf8') > this.maxMessageFieldSize) {
// new nodejs api, let's use Buffer.alloc instead of Buffer(size)
let cutMsg = Buffer.alloc ? Buffer.alloc(this.maxMessageFieldSize) : new Buffer(this.maxMessageFieldSize)
cutMsg.write(msg.message)
msg.message = cutMsg.toString()
if (msg.originalLine) {
// when message is too large and logagent added originalLine,
// this should be removed to stay under the limits in receiver
delete msg.originalLine
}
msg.logsene_client_warning = 'Warning: message field too large > ' + this.maxMessageFieldSize + ' bytes'
}
if (elasticsearchDocId !== null) {
this.bulkReq.write(stringifySafe({ 'index': { '_index': _index, '_id': String(elasticsearchDocId), '_type': type || this.type } }) + '\n')
} else {
this.bulkReq.write(stringifySafe({ 'index': { '_index': _index, '_type': type || this.type } }) + '\n')
}
this.bulkReq.write(stringifySafe(msg) + '\n')
if (this.logCount === LOGSENE_BULK_SIZE || this.bulkReq.size() > MAX_LOGSENE_BUFFER_SIZE) {
this.send()
}
this.emit('logged', { msg: msg, _index: _index })
if (callback) {
callback(null, msg)
}
}
/**
* Sending log entry to LOGSENE - this function is triggered every 100 log message or 30 seconds.
* @callback {function} optional callback function
*/
Logsene.prototype.send = function (callback) {
let self = this
const buffer = this.bulkReq
this.bulkReq = new streamBuffers.WritableStreamBuffer({
initialSize: initialBufferSize,
incrementAmount: incrementBuffer
})
buffer.end()
self.lastSend = Date.now()
let count = this.logCount
let options = {
url: this.url,
logCount: count,
headers: {
'User-Agent': 'logsene-js',
'Content-Type': 'application/json',
'Connection': 'Close',
'x-logsene-origin': this.xLogseneOrigin || xLogseneOrigin
},
body: buffer.getContents(),
method: 'POST'
}
if (options.body === false) {
return
}
function httpResult (err, res) {
// if (res && res.body) console.log(res.statusCode, res.body)
let logseneError = null
let responseJson = null
if (res && res.headers && res.headers['x-logsene-error']) {
logseneError = res.headers['x-logsene-error']
}
let errorMessage = null
let errObj = null
if (err || (res && res.status > 399) || logseneError) {
if (err && (err.code || err.message)) {
err.url = options.url
}
if (res && res.status) {
errorMessage = 'HTTP status code:' + res.status
}
if (logseneError) {
errorMessage += ', ' + logseneError
}
errObj = { source: 'logsene-js', err: (err || { message: errorMessage, httpStatus: res.status, httpBody: res.body, url: options.url }) }
self.emit('x-logsene-error', errObj)
if (self.persistence) {
let storeFileFlag = true
// don't use disk buffer for invalid Logsene tokens
if (res && res.body && appNotFoundRegEx.test(res.body)) {
storeFileFlag = false
}
if (res && res.status && res.status === 400) {
storeFileFlag = false
}
if (logseneError && limitRegex.test(logseneError)) {
// && process.env.LOGSENE_BUFFER_ON_APP_LIMIT === 'false'
storeFileFlag = false
}
if (storeFileFlag) {
options.body = options.body.toString()
self.db.store(options, function () {
delete options.body
})
} else {
self.emit('fileNotStored', options)
}
}
} else {
return res.json()
.then(responseJson => {
responseJson.items.forEach(function (item) {
let result = item.index || item.create || item.update || item.delete
if (result && result.status > 399) {
errorMessage = 'HTTP status code:' + result.status + ' Error: ' + JSON.stringify(result.error)
let errObj = {
source: 'logsene-js',
err: { message: errorMessage, httpStatus: result.status, httpBody: result, url: options.url }
}
self.emit('x-logsene-error', errObj)
}
})
self.emit('log', { source: 'logsene-js', count: count, url: options.url })
delete options.body
if (callback) {
callback(errObj, res)
}
})
.catch(err => {
errObj = { source: 'logsene-js', err: err }
self.emit('x-logsene-error', errObj)
})
}
}
self.logCount = Math.max(self.logCount - count, 0)
options = {...options, ...this.httpDefaults}
nodeFetch(this.url, options)
.then(response => httpResult(null, response))
.catch(err => httpResult(err, null))
}
Logsene.prototype.shipFile = function (name, data, cb) {
let self = this
let options = null
try {
options = JSON.parse(data)
} catch (err) {
// wrong file format
}
if (options == null || options.options) {
// wrong file format?
// cleanup from earlier versions
// self.db.rmFile(name)
return cb(new Error('wrong bulk file format'))
}
options.body = options.body.toString()
options.url = self.url
const callbackFunc = function (err, res) {
if (err || (res && res.status > 399)) {
let errObj = { source: 'logsene re-transmit', err: (err || { message: 'Logsene re-transmit status code:' + res.status, httpStatus: res.status, httpBody: res.body, url: options.url, fileName: name }) }
self.emit('x-logsene-error', errObj)
if (cb) {
cb(errObj)
}
} else {
if (cb) {
cb(null, { file: name, count: options.logCount })
}
self.emit('file shipped', { file: name, count: options.logCount })
self.emit('rt', { count: options.logCount, source: 'logsene', file: name, url: String(options.url), request: null, response: null })
}
}
nodeFetch(options.url, options)
.then(response => callbackFunc(null, response))
.catch(error => callbackFunc(error, null))
}
module.exports = Logsene