@tiledesk/tiledesk-server
Version:
The Tiledesk server module
411 lines (389 loc) • 12.9 kB
JavaScript
/**
* @module 'winston-mongodb'
* @fileoverview Winston transport for logging into MongoDB
* @license MIT
* @author charlie@nodejitsu.com (Charlie Robbins)
* @author 0@39.yt (Yurij Mikhalevich)
*/
'use strict';
const util = require('util');
const os = require('os');
const mongodb = require('mongodb');
const winston = require('winston');
const Transport = require('winston-transport');
const Stream = require('stream').Stream;
const helpers = require('./helpers');
/**
* Constructor for the MongoDB transport object.
* @constructor
* @param {Object} options
* @param {string=info} options.level Level of messages that this transport
* should log.
* @param {boolean=false} options.silent Boolean flag indicating whether to
* suppress output.
* @param {string|Object} options.db MongoDB connection uri or preconnected db
* object.
* @param {Object} options.options MongoDB connection parameters
* (optional, defaults to `{poolSize: 2, autoReconnect: true, useNewUrlParser: true}`).
* @param {string=logs} options.collection The name of the collection you want
* to store log messages in.
* @param {boolean=false} options.storeHost Boolean indicating if you want to
* store machine hostname in logs entry, if set to true it populates MongoDB
* entry with 'hostname' field, which stores os.hostname() value.
* @param {string} options.label Label stored with entry object if defined.
* @param {string} options.name Transport instance identifier. Useful if you
* need to create multiple MongoDB transports.
* @param {boolean=false} options.capped In case this property is true,
* winston-mongodb will try to create new log collection as capped.
* @param {number=10000000} options.cappedSize Size of logs capped collection
* in bytes.
* @param {number} options.cappedMax Size of logs capped collection in number
* of documents.
* @param {boolean=false} options.tryReconnect Will try to reconnect to the
* database in case of fail during initialization. Works only if `db` is
* a string.
* @param {boolean=false} options.decolorize Will remove color attributes from
* the log entry message.
* @param {boolean=false} options.leaveConnectionOpen Will leave MongoClient connected
* after transport shut down.
* @param {number} options.expireAfterSeconds Seconds before the entry is removed.
* Do not use if capped is set.
*/
let MongoDB = exports.MongoDB = function(options) {
Transport.call(this, options);
options = (options || {});
if (!options.db) {
throw new Error('You should provide db to log to.');
}
this.name = options.name || 'mongodb';
this.db = options.db;
this.options = options.options;
if (!this.options) {
this.options = {
poolSize: 2,
autoReconnect: true,
useNewUrlParser: true
};
}
this.collection = (options.collection || 'log');
this.level = (options.level || 'info');
this.labelRequired = (options.labelRequired || false);
this.silent = options.silent;
this.storeHost = options.storeHost;
this.label = options.label;
this.capped = options.capped;
this.cappedSize = (options.cappedSize || 10000000);
this.cappedMax = options.cappedMax;
this.decolorize = options.decolorize;
this.leaveConnectionOpen = options.leaveConnectionOpen;
this.expireAfterSeconds = !this.capped && options.expireAfterSeconds;
this.metaKey = options.metaKey || 'metadata';
if (this.storeHost) {
this.hostname = os.hostname();
}
this._opQueue = [];
let self = this;
function setupDatabaseAndEmptyQueue(db) {
return createCollection(db).then(db=>{
self.logDb = db;
processOpQueue();
}, err=>{
if (self.mongoClient && !self.leaveConnectionOpen) {
self.mongoClient.close();
}
console.error('winston-mongodb, initialization error: ', err);
});
}
function processOpQueue() {
self._opQueue.forEach(operation=>
self[operation.method].apply(self, operation.args));
delete self._opQueue;
}
function createCollection(db) {
let opts = Object.assign(
{},
self.capped ? {capped: true, size: self.cappedSize, max: self.cappedMax} : {}
);
return db.createCollection(self.collection, opts).then(col=>{
const ttlIndexName = 'timestamp_1';
let indexOpts = {name: ttlIndexName, background: true};
if (self.expireAfterSeconds) {
indexOpts.expireAfterSeconds = self.expireAfterSeconds;
}
return col.indexInformation({full: true}).then(info=>{
info = info.filter(i=>i.name === ttlIndexName);
if (info.length === 0) { // if its a new index then create it
return col.createIndex({timestamp: -1}, indexOpts);
} else { // if index existed with the same name check if expireAfterSeconds param has changed
if (info[0].expireAfterSeconds !== undefined &&
info[0].expireAfterSeconds !== self.expireAfterSeconds) {
return col.dropIndex(ttlIndexName)
.then(()=>col.createIndex({timestamp: -1}, indexOpts));
}
}
});
}).catch(err => {
// Since mongodb@3.6.0 db.createCollection throws an error (48) if the collection already exists
if (err.code !== 48) throw err;
return db.collection(self.collection);
}).then(()=>db);
}
function connectToDatabase(logger) {
return mongodb.MongoClient.connect(logger.db, logger.options
).then(client=>{
logger.mongoClient = client;
setupDatabaseAndEmptyQueue(client.db());
}, err=>{
console.error('winston-mongodb: error initialising logger', err);
if (options.tryReconnect) {
console.log('winston-mongodb: will try reconnecting in 10 seconds');
return new Promise(resolve=>setTimeout(resolve, 10000)
).then(()=>connectToDatabase(logger));
}
});
}
if ('string' === typeof this.db) {
connectToDatabase(this);
} else if ('function' === typeof this.db.then) {
this.db.then(client=>{
this.mongoClient = client;
setupDatabaseAndEmptyQueue(client.db());
}, err=>console.error('winston-mongodb: error initialising logger from promise', err));
} else if ('function' === typeof this.db.db) {
this.mongoClient = this.db;
setupDatabaseAndEmptyQueue(this.db.db())
} else { // preconnected object
console.warn(
'winston-mongodb: preconnected object support is deprecated and will be removed in v5');
setupDatabaseAndEmptyQueue(this.db);
}
};
/**
* Inherit from `winston-transport`.
*/
util.inherits(MongoDB, Transport);
/**
* Define a getter so that `winston.transports.MongoDB`
* is available and thus backwards compatible.
*/
winston.transports.MongoDB = MongoDB;
/**
* Closes MongoDB connection so using process would not hang up.
* Used by winston Logger.close on transports.
*/
MongoDB.prototype.close = function() {
this.logDb = null;
if (!this.mongoClient || this.leaveConnectionOpen) {
return;
}
this.mongoClient.close().then(()=>this.mongoClient = null).catch(err=>{
console.error('Winston MongoDB transport encountered on error during '
+ 'closing.', err);
});
};
/**
* Core logging method exposed to Winston. Metadata is optional.
* @param {Object} info Logging metadata
* @param {Function} cb Continuation to respond to when complete.
*/
MongoDB.prototype.log = function(info, cb) {
if (!this.logDb) {
this._opQueue.push({method: 'log', args: arguments});
return true;
}
if (!cb) {
cb = ()=>{};
}
// Avoid reentrancy that can be not assumed by database code.
// If database logs, better not to call database itself in the same call.
process.nextTick(()=>{
if (this.silent) {
cb(null, true);
}
const decolorizeRegex = new RegExp(/\u001b\[[0-9]{1,2}m/g);
let entry = { timestamp: new Date(), level: (this.decolorize) ? info.level.replace(decolorizeRegex, '') : info.level };
// console.log("info",info)
// console.log("info.label",info.label)
let message = util.format(info.message, ...(info.splat || []));
entry.message = (this.decolorize) ? message.replace(decolorizeRegex, '') : message;
entry.meta = helpers.prepareMetaData(info[this.metaKey]);
if (this.storeHost) {
entry.hostname = this.hostname;
}
if (this.label) {
entry.label = this.label;
}
// console.log("info.labelRequired",info.labelRequired)
if (this.labelRequired && !info.label) {
return cb(null, true);
}
if (info.label) {
entry.label = info.label;
}
this.logDb.collection(this.collection).insertOne(entry).then(()=>{
this.emit('logged');
cb(null, true);
}).catch(err=>{
this.emit('error', err);
cb(err);
});
});
return true;
};
/**
* Query the transport. Options object is optional.
* @param {Object=} opt_options Loggly-like query options for this instance.
* @param {Function} cb Continuation to respond to when complete.
* @return {*}
*/
MongoDB.prototype.query = function(opt_options, cb) {
if (!this.logDb) {
this._opQueue.push({method: 'query', args: arguments});
return;
}
if ('function' === typeof opt_options) {
cb = opt_options;
opt_options = {};
}
let options = this.normalizeQuery(opt_options);
let query = {timestamp: {$gte: options.from, $lte: options.until}};
let opt = {
skip: options.start,
limit: options.rows,
sort: {timestamp: options.order === 'desc' ? -1 : 1}
};
if (options.fields) {
opt.fields = options.fields;
}
this.logDb.collection(this.collection).find(query, opt).toArray().then(docs=>{
if (!options.includeIds) {
docs.forEach(log=>delete log._id);
}
cb(null, docs);
}).catch(cb);
};
/**
* Returns a log stream for this transport. Options object is optional.
* This will only work with a capped collection.
* @param {Object} options Stream options for this instance.
* @param {Stream} stream Pass in a pre-existing stream.
* @return {Stream}
*/
MongoDB.prototype.stream = function(options, stream) {
options = options || {};
stream = stream || new Stream;
let start = options.start;
if (!this.logDb) {
this._opQueue.push({method: 'stream', args: [options, stream]});
return stream;
}
stream.destroy = function() {
this.destroyed = true;
};
if (start === -1) {
start = null;
}
let col = this.logDb.collection(this.collection);
if (start != null) {
col.find({}, {skip: start}).toArray().then(docs=>{
docs.forEach(doc=>{
if (!options.includeIds) {
delete doc._id;
}
stream.emit('log', doc);
});
delete options.start;
this.stream(options, stream);
}).catch(err=>stream.emit('error', err));
return stream;
}
if (stream.destroyed) {
return stream;
}
col.isCapped().then(capped=>{
if (!capped) {
return this.streamPoll(options, stream);
}
let cursor = col.find({}, {tailable: true});
stream.destroy = function() {
this.destroyed = true;
cursor.destroy();
};
cursor.on('data', doc=>{
if (!options.includeIds) {
delete doc._id;
}
stream.emit('log', doc);
});
cursor.on('error', err=>stream.emit('error', err));
}).catch(err=>stream.emit('error', err));
return stream;
};
/**
* Returns a log stream for this transport. Options object is optional.
* @param {Object} options Stream options for this instance.
* @param {Stream} stream Pass in a pre-existing stream.
* @return {Stream}
*/
MongoDB.prototype.streamPoll = function(options, stream) {
options = options || {};
stream = stream || new Stream;
let self = this;
let start = options.start;
let last;
if (!this.logDb) {
this._opQueue.push({method: 'streamPoll', args: [options, stream]});
return stream;
}
if (start === -1) {
start = null;
}
if (start == null) {
last = new Date(new Date - 1000);
}
stream.destroy = function() {
this.destroyed = true;
};
(function check() {
let query = last ? {timestamp: {$gte: last}} : {};
self.logDb.collection(self.collection).find(query).toArray().then(docs=>{
if (stream.destroyed) {
return;
}
if (!docs.length) {
return next();
}
if (start == null) {
docs.forEach(doc=>{
if (!options.includeIds) {
delete doc._id;
}
stream.emit('log', doc);
});
} else {
docs.forEach(doc=>{
if (!options.includeIds) {
delete doc._id;
}
if (!start) {
stream.emit('log', doc);
} else {
start -= 1;
}
});
}
last = new Date(docs.pop().timestamp);
next();
}).catch(err=>{
if (stream.destroyed) {
return;
}
next();
stream.emit('error', err);
});
function next() {
setTimeout(check, 2000);
}
})();
return stream;
};