appc-logger
Version:
Appcelerator Logger
605 lines (565 loc) • 17.1 kB
JavaScript
'use strict';
const bunyan = require('bunyan'),
_ = require('lodash'),
fs = require('fs-extra'),
path = require('path'),
assert = require('assert'),
debug = require('debug')('appc:logger'),
supportedHttpServices = [ 'restify', 'express', 'expressjs' ],
arrowCloudHosted = !!process.env.NODE_ACS_URL,
arrowCloudLogDir = arrowCloudHosted && searchForArrowCloudLogDir();
// istanbul ignore next
/**
* Looks through the filesystem for a writable spot to which we can write the logs.
* @returns {string}
*/
function searchForArrowCloudLogDir() {
if (isWritable('/ctlog')) {
return '/ctlog';
}
if (process.env.HOME && isWritable(path.join(process.env.HOME, 'ctlog'))) {
return path.join(process.env.HOME, 'ctlog');
}
if (process.env.USERPROFILE && isWritable(path.join(process.env.USERPROFILE, 'ctlog'))) {
return path.join(process.env.USERPROFILE, 'ctlog');
}
if (isWritable('./logs')) {
return path.resolve('./logs');
}
throw new Error('No writable logging directory was found.');
}
// istanbul ignore next
/**
* Checks if a directory is writable, returning a boolean or throwing an exception, depending on the arguments.
* @param {string} dir The directory to check.
* @returns {boolean} Whether or not the directory is writable.
*/
function isWritable(dir) {
debug('checking if ' + dir + ' is writable');
try {
if (!fs.existsSync(dir)) {
debug(' - it does not exist yet, attempting to create it');
fs.mkdirSync(dir);
}
if (fs.accessSync) {
fs.accessSync(dir, fs.W_OK);
} else {
debug(' - fs.accessSync is not available, falling back to manual write detection');
fs.writeFileSync(path.join(dir, '.foo'), 'foo');
assert.equal(fs.readFileSync(path.join(dir, '.foo'), 'UTF-8'), 'foo');
fs.unlinkSync(path.join(dir, '.foo'));
}
debug(' - yes, it is writable');
return true;
} catch (exc) {
debug(' - no, it is not writable: ', exc);
return false;
}
}
/**
* A bunyan serializer for req.route of express/restify
* @param {Object} route original route
* @return {Object} serialized route
*/
function routeSerializer(route) {
if (route && route.path) {
return {
path: route.path,
methods: route.methods,
method: route.method
};
}
return route;
}
/**
* create a server logger (based on expressjs or restify),
* and setup not only the main logger but also the request logger.
* @param {Object} server instance of express app or restify server
* @param {string} serviceType express, expressjs or restify
* @param {Object} options options: {name:'server/app name', level:'debug', afterEvent:'afterEvent', logSingleRequest:false, adiLogging: false, adiPathFilter: []}
* @return {Object} server logger instance, with a request logger inside
*/
function createHttpLogger(server, serviceType, options) {
if (typeof service === 'object') {
options = serviceType;
// default service type: express
serviceType = 'express';
}
if (supportedHttpServices.indexOf(serviceType) === -1) {
throw new Error('Wrong service type');
}
// variable to indicate if this app is hosted by Arrow Cloud
const ConsoleLogger = require('./console'),
logDir = arrowCloudHosted ? arrowCloudLogDir : (options && options.logs || './logs');
let skipRequestLog;
// if not hosted, create the directory.
if (!arrowCloudHosted && !fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
// turn off if specified we don't want logging
if (!arrowCloudHosted && options && !options.logSingleRequest) {
skipRequestLog = true;
}
if (options && options.adiPathFilter === undefined) {
options.adiPathFilter = [];
}
const serverName = server.name || (options && options.name),
consoleLogger = new ConsoleLogger(options),
serverLogger = bunyan.createLogger({
name: serverName || 'server',
serializers: {
req: bunyan.stdSerializers.req,
res: bunyan.stdSerializers.res
},
streams: [ {
level: options && options.level || 'info',
type: 'raw',
stream: consoleLogger
} ]
}),
// only create the request logger if we have a log directory to log to
requestLogger = !skipRequestLog && fs.existsSync(logDir) && bunyan.createLogger({
name: serverName || 'requests',
serializers: {
req: bunyan.stdSerializers.req,
res: bunyan.stdSerializers.res,
route: routeSerializer
},
streams: [ {
type: 'rotating-file',
period: '1d',
count: 1,
level: 'trace',
path: path.join(logDir, options && options.requestsLogFilename || 'requests.log')
} ]
}),
adiLogger = options && options.adiLogging && fs.existsSync(logDir) && bunyan.createLogger({
name: serverName || 'adiLogs',
serializers: {
req: bunyan.stdSerializers.req,
res: bunyan.stdSerializers.res,
route: routeSerializer
},
streams: [ {
type: 'rotating-file',
period: '1d',
count: 1,
level: 'trace',
path: path.join(logDir, 'adi-analytics.log')
} ]
});
serverLogger.requestLogger = requestLogger;
server.log = serverLogger;
(server.pre || server.use).call(server, function (req, resp, next) {
req.requestId = req.requestId || (serviceType === 'restify' ? req.getId() : require('uuid-v4')());
if (serviceType === 'restify') {
// record timestamp if using restify framework
req.started = req.started || process.hrtime();
}
if (options && options.logSingleRequest && (req.url && req.url !== '/arrowPing.json')) {
// we prefix with date to make it easier to sort logs by timestamp
const name = 'request-' + req.requestId,
logname = path.join(logDir, name + '.log'),
logstream = fs.createWriteStream(logname),
log = bunyan.createLogger({
name: name,
serializers: {
req: bunyan.stdSerializers.req,
res: bunyan.stdSerializers.res,
route: routeSerializer
},
streams: [ {
level: 'trace',
stream: logstream
}, {
level: options && options.level || 'info',
type: 'raw',
stream: consoleLogger
} ]
});
// jscs:disable requireCamelCaseOrUpperCaseIdentifiers
log.info({
req_id: req.requestId,
req: req,
res: resp,
route: req.route,
start: true,
ignore: true
}, 'start');
req.log = log;
req.logname = logname;
req.logstream = logstream;
req._logname = name;
req.cleanStream = cleanStream;
}
next();
});
/**
* derive the port if its in the host string.
* @param {Object} req a express req object
* @return {string} string representation of port
*/
function getPort(req) {
if (req.connection && req.connection.localPort) {
return req.connection.localPort.toString();
}
const host = req.headers && req.headers.host;
let protocolSrc = 80;
if (host && ((host.match(/:/g) || []).length) === 1) {
const possiblePort = host.split(':')[1];
protocolSrc = isNaN(possiblePort) ? protocolSrc : possiblePort;
}
return protocolSrc;
}
/**
* derive the status string from the status code
* @param {Object} res express res object
* @return {string} success or error string
*/
function getStatus (res) {
let status;
const statusCode = res.statusCode;
if (statusCode) {
status = Math.floor(statusCode / 100) * 100;
}
switch (status) {
case 100:
case 200:
case 300:
return 'success';
default:
return 'failure';
}
}
/**
* get the request id from the logname
* @param {Object} req express request object
* @return {string} string representation of request id
*/
function getCorrelationId(req) {
return req.requestId || null;
}
/**
* Determine whether a certain URL is whitelisted based off the prefix
* @param {string} url URL to check
* @return {boolean} Is the URL in the whitelist
*/
function isWhitelisted(url) {
return options.adiPathFilter.some(function (route) {
return url.substr(0, route.length) === route;
});
}
/**
* A function to log request into file
* @param {Object} req Request
* @param {Object} res Response
*/
function logRequest(req, res) {
var responseTime = Math.round(req.duration),
shouldLogRequest = requestLogger && req && req.url && req.url !== '/arrowPing.json',
shouldADILog = options && options.adiLogging && adiLogger && req && req.url && isWhitelisted(req.url),
time = new Date().getTime(),
uri = (req.route && req.route.path) || req.path || (req.url && req.url.split('?')[0]),
localPort = getPort(req);
if (shouldLogRequest && req.log && req.logname && (req.logmetadata === undefined || req.logmetadata)) {
requestLogger.info({
req_id: req.requestId,
req: req,
res: res,
route: req.route,
name: req._logname,
logname: req.logname,
response_time: responseTime
});
const result = {
url: req.url,
req_headers: req.headers,
status: res.status,
req_id: req.requestId,
name: req._logname,
logname: req.logname,
response_time: responseTime
};
fs.writeFile(req.logname + '.metadata', JSON.stringify(result));
} else if (shouldLogRequest) {
requestLogger.info({
req_id: req.requestId,
response_time: responseTime,
route: req.route,
req: req,
res: res
});
}
if (shouldADILog) {
adiLogger.info({
type: 'transaction',
time: time,
path: uri,
protocol: req.protocol,
protocolSrc: localPort,
duration: responseTime,
status: getStatus(res),
serviceContexts: [
{
service: '',
monitor: '',
client: null,
org: null,
app: options.name,
method: '',
status: '',
duration: ''
}
],
customMsgAtts: {},
correlationId: getCorrelationId(req),
legs: [
{
uri: uri,
status: res.statusCode,
statusText: '',
method: req.method,
vhost: null,
wafStatus: 0,
bytesSent: parseInt(res.get('Content-Length'), 10) || null,
bytesReceived: parseInt(req.get('Content-Length'), 10) || null,
remoteName: '',
remoteAddr: '',
localAddr: '',
remotePort: '',
localPort: '',
sslsubject: null,
leg: 0,
timestamp: time,
duration: responseTime,
serviceName: uri,
subject: null,
operation: req.method + ' - ' + uri,
type: req.protocol,
finalStatus: ''
}
]
});
}
if (req.cleanStream) {
req.cleanStream();
req.cleanStream = null;
}
}
if (serviceType === 'restify') {
// restify framework
// by default, listen for the server's 'after' event but also let the
// creator tell us to use a different event. this is nice when you have
// additional things you want to do before ending the logging (after the server might
// have sent the response) that you want to log or capture before ending
const afterEvent = options && options.afterEvent || 'after';
server.on(afterEvent, function (req, res) {
// see if we've already calculated the duration and if so, use it
var duration = req.duration;
if (!duration) {
// otherwise we need to calculate it
const time = process.hrtime(req.started);
duration = (time[0] / 1000) + (time[1] * 1.0e-6);
req.duration = duration;
}
logRequest(req, res);
});
} else {
// express framework
const responseTimeMiddleWare = require('response-time');
server.use(responseTimeMiddleWare(function (req, res, duration) {
req.duration = duration;
logRequest(req, res);
}));
}
return serverLogger;
}
/**
* Cleans a req log stream up after a delayed period. This should be used as a method of a req, and called within the
* context of a req only (ie. req.cleanStream = cleanStream; req.cleanStream()).
*/
function cleanStream() {
var logStream = this.logstream;
this.logstream = this.cleanStream = null;
this.log = null;
this.logname = null;
this._logname = null;
// wait a little bit to let other end events process before closing the stream
logStream && setTimeout(logStream.end.bind(logStream), 500);
}
/**
* create a restify server logger and setup not only the main
* logger but also the request logger.
*
* @param {Object} server - restify server
* @param {Object} options - options
* @return {Object} server logger instance, with a request logger inside
*/
function createRestifyLogger(server, options) {
return createHttpLogger(server, 'restify', options);
}
/**
* create a expressjs logger and setup not only the main
* logger but also the request logger.
*
* @param {Object} app - expressjs app
* @param {Object} options - options
* @return {Object} server logger instance, with a request logger inside
*/
function createExpressLogger(app, options) {
return createHttpLogger(app, 'express', options);
}
/**
* Create a default logger
*
* @param {Object} options - options
* @returns {Object}
*/
function createDefaultLogger(options) {
const ConsoleLogger = require('./console'),
consoleLogger = new ConsoleLogger(options),
config = _.mergeWith({
name: 'logger',
streams: [
{
level: options && options.level || 'trace',
type: 'raw',
stream: consoleLogger
}
]
}, options, function (a, b) {
return _.isArray(a) ? a.concat(b) : undefined;
});
consoleLogger.level = bunyan.resolveLevel(options && options.level || 'trace');
// default is to add the problem logger
if (!options || options.problemLogger || options.problemLogger === undefined) {
const ProblemLogger = require('./problem');
config.streams.push({
level: 'trace',
type: 'raw',
stream: new ProblemLogger(options)
});
}
const defaultLogger = bunyan.createLogger(config);
/**
* Set log level
* Backward compatible with Arrow Cloud MVC framework
* @param {Object} nameOrNum log level in string or number
* @return {String}
*/
defaultLogger.setLevel = function (nameOrNum) {
var level = 'trace';
try {
level = bunyan.resolveLevel(nameOrNum);
} catch (e) {} // eslint-disable-line no-empty
consoleLogger.level = level;
return this.level(level);
};
return defaultLogger;
}
/**
* Clone object
*
* @param {Object} obj - object to clone
* @param {Array} [seen] An array containing objects that have been seen before, useful to prevent infinite circular recursion.
* @return {Object}
*/
function specialObjectClone(obj, seen) {
if (obj === undefined || obj === null) {
return obj;
}
const type = typeof(obj);
if (type === 'function') {
return null;
}
if (type !== 'object') {
return obj;
}
if (obj instanceof Error) {
return obj;
}
if (obj instanceof Buffer) {
return '[Buffer]';
}
if (obj instanceof RegExp) {
return '/' + obj.source + '/';
}
let newobj = {};
if (Array.isArray(obj)) {
const maskFields = [ '--password', '-password' ];
newobj = null;
for (let i = 0, l = obj.length; i < l; ++i) {
if (maskFields.indexOf(obj[i]) > -1 && obj[++i]) {
if (newobj === null) {
newobj = obj.slice(0);
}
newobj[i] = '[HIDDEN]';
}
}
return newobj ? newobj : obj;
}
// we need to deal with circular references
seen = seen || [];
// clone so we don't mutate original object
const keys = Object.keys(obj),
length = keys.length;
for (let c = 0; c < length; c++) {
const key = keys[c];
let value = obj[key];
// if the object contains a password key, we want to
// not log the actual password
if (/^password[-_]?/.test(key)) {
value = '[HIDDEN]';
} else if (key === 'req' || key === 'res') {
// Skip.
} else if (typeof(value) === 'object') {
if (seen.indexOf(value) !== -1) {
value = '[Circular]';
} else {
seen.push(value);
value = specialObjectClone(value, seen);
}
}
newobj[key] = value;
}
return newobj;
}
const patchedEmit = bunyan.prototype._emit;
/**
* monkey patch Bunyan to support suppressing password fields
* @param {Object} rec log record
* @param {Boolean} [noemit] Set to true to skip emission
* and just return the JSON string.
* @returns {String}
*/
bunyan.prototype._emit = function (rec, noemit) {
// we can skip built-in fields so just pull out fields that aren't one of them
const fields = _.omit(rec, 'name', 'hostname', 'pid', 'level', 'msg', 'v', 'time'),
keys = Object.keys(fields);
if (keys.length) {
// we found properties in the rec that aren't built-in. we need to
// make sure that any of these fields aren't named password and if so
// mask the value
const seen = [];
let obj = specialObjectClone(_.pick(rec, keys), seen);
_.merge(rec, obj);
}
return patchedEmit.call(this, rec, noemit);
};
/**
* remove any ANSI color codes from the string
* @param {string} str raw string to strip color codes from
* @returns {string}
*/
function stripColors(str) {
// eslint-disable-next-line no-control-regex
return String(str).replace(/\u001b\[\d+m/g, '');
}
exports.createLogger = createDefaultLogger;
exports.createDefaultLogger = createDefaultLogger;
exports.createHttpLogger = createHttpLogger;
exports.createRestifyLogger = createRestifyLogger;
exports.createExpressLogger = createExpressLogger;
exports.stripColors = stripColors;
exports.specialObjectClone = specialObjectClone;
exports.arrowCloudLogDir = arrowCloudLogDir;