gpii-universal
Version:
Cross platform, core components of the GPII personalization infrastructure.
438 lines (399 loc) • 15.5 kB
JavaScript
/*
* Event logging.
* Handles logging of events that are deemed interesting enough to be recorded and sent to a log server for analysis.
* The log produced here is in JSON and sent to a different output than the usual fluid.log.
*
* Copyright 2017 Raising the Floor - International
*
* Licensed under the New BSD license. You may not use this file except in
* compliance with this License.
*
* The R&D leading to these results received funding from the
* Department of Education - Grant H421A150005 (GPII-APCP). However,
* these results do not necessarily represent the policy of the
* Department of Education, and you should not assume endorsement by the
* Federal Government.
*
* You may obtain a copy of the License at
* https://github.com/GPII/universal/blob/master/LICENSE.txt
*/
;
var fluid = require("infusion");
var fs = require("fs"),
moment = require("moment"),
net = require("net");
var gpii = fluid.registerNamespace("gpii"),
$ = fluid.registerNamespace("jQuery");
fluid.registerNamespace("gpii.eventLog");
fluid.defaults("gpii.eventLog", {
gradeNames: ["fluid.component", "fluid.contextAware"],
components: {
installID: {
type: "gpii.installID"
},
settingsDir: {
type: "gpii.settingsDir"
}
},
invokers: {
logEvent: {
funcName: "gpii.eventLog.log", // moduleName, event, data, level
args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2", "{arguments}.3"]
},
logError: "gpii.eventLog.logError",
getGpiiSettingsDir: "{settingsDir}.getGpiiSettingsDir",
getVersion: "gpii.eventLog.getVersion",
setState: {
funcName: "gpii.eventLog.setState",
args: ["{eventLog}", "{arguments}.0", "{arguments}.1"] // name, value
}
},
members: {
sequence: 0,
// Buffered log lines while there's not log server connection
logBuffer: [],
logLevel: fluid.logLevel.INFO,
// Maximum number of lines to buffer.
maxBufferlength: 0xfff,
// A TCP socket to send the log entries to (filebeat service). Taken from options.logDestination, if it's a url.
logServer: {
host: null,
port: null
},
// A file to write the log entries to. Taken from options.logDestination, if it's a local file.
logPath: null,
// Data to include in every log entry.
eventData: {
// The installation ID
installID: "@expand:{that}.installID.getInstallID()",
version: "@expand:{that}.getVersion()",
sessionID: undefined,
gpiiKey: undefined
},
// The start times for events which require durations to be recorded.
eventTimes: {}
},
listeners: {
"onCreate.logFile": {
func: "gpii.eventLog.initLogDestination",
args: ["{that}", "{that}.options.logDestination"]
},
"onCreate.log": {
func: "gpii.eventLog.logStartStop",
args: ["{that}", "start"]
},
"onDestroy.log": {
func: "gpii.eventLog.logStartStop",
args: ["{that}", "stop"]
}
},
// File path, or tcp://host:port
logDestination: null
});
/**
* A log event
* @typedef {Object} LogEvent
* @property {String} module The area of GPII the event is from.
* @property {String} event The name of the event.
* @property {String} data [optional] Extra information about the event.
* @property {Object} level The severity of the event (from fluid.logLevelsSpec, default: fluid.INFO).
* @property {String} version The eventLog module version. (automatically added)
* @property {String} timestamp Current time of the event. (automatically added)
* @property {String} sequence Ordered unique identifier to the event. (automatically added)
*/
/**
* Returns the actual date and time, as a string, in ISO 8601 format with the localtime + offset from UTC.
* eg: '2018-07-13T13:03:03.863+01:00'
* @return {String} The current date, in ISO-8601 format with localtime and UTC offset.
*/
gpii.eventLog.getTimestamp = function () {
return moment().toISOString(true);
};
/**
* Gets the version of the logger. This is logged so the log processing can adjust for any updates to this module.
*
* @return {String} The version of this module, from package.json.
*/
gpii.eventLog.getVersion = function () {
var packageJson = fluid.require("%eventLog/package.json");
return packageJson.version;
};
/**
* Logs the start or stop of GPII, without duplication.
* @param {Component} that - The gpii.eventLog instance.
* @param {String} state - "start" or "stop".
*/
gpii.eventLog.logStartStop = function (that, state) {
if (state !== gpii.eventLog.logStartStop.currentState) {
gpii.eventLog.logStartStop.currentState = state;
if (state === "start") {
// Start the event numbering based on the current timestamp to ensure it's unique (per instance).
that.sequence = Date.now() - 15e11;
}
that.logEvent("gpii", state);
}
};
/**
* Creates an object for the log. Everything in this object is what will be logged, and the "time" field will be added
* later when it is actually logged.
*
* @param {String} moduleName - The part of GPII causing this event.
* @param {String} event - Name of the event.
* @param {Any} [data] - [optional] Event specific data.
* @param {Object} level -[optional] Level of the log, see fluid.logLevelsSpec [FATAL,FAIL,WARN,IMPORTANT,INFO,TRACE].
* @return {LogEvent} The log object.
*/
gpii.eventLog.createLogObject = function (moduleName, event, data, level) {
var eventObject = {
module: moduleName || "GPII",
event: event,
level: level
};
var hasValue = (data !== null && data !== undefined);
if (hasValue && fluid.isPlainObject(data)) {
hasValue = !$.isEmptyObject(data);
}
if (hasValue) {
eventObject.data = fluid.copy(data);
// Replace any components in the data object with their id.
fluid.each(eventObject.data, function (value, key) {
if (fluid.isComponent(value)) {
eventObject.data[key] = value.id;
}
});
}
return eventObject;
};
/**
* Logs an event.
*
* @param {Component} that - The gpii.eventLog instance.
* @param {String} moduleName - The part of GPII causing this event.
* @param {String} event - The event name.
* @param {Object} [data] - [optional] Event specific data. Can (shallowly) contain components, in which case just the ID is
* logged.
* @param {Object} [level] - [optional] Level of the log, see fluid.logLevelsSpec [FATAL,FAIL,WARN,IMPORTANT,INFO,TRACE].
*/
gpii.eventLog.log = function (that, moduleName, event, data, level) {
var eventObject = gpii.eventLog.createLogObject(moduleName, event, data, level);
gpii.eventLog.writeLog(that, level, eventObject);
};
/**
* Logs an error.
*
* @param {Component} that - The gpii.eventLog instance.
* @param {String} moduleName - The part of GPII causing this error.
* @param {String} errType - Type of error.
* @param {Object} err - The error.
* @param {Object} [level] - [optional] Level of the log. default: fluid.logLevel.FAIL.
*/
gpii.eventLog.logError = function (that, moduleName, errType, err, level) {
if (!level) {
level = fluid.logLevel.FAIL;
}
var data = {};
if (err instanceof Error) {
// Error doesn't serialise
data.error = {};
fluid.each(Object.getOwnPropertyNames(err), function (source) {
// Ensure the first character of the field is lowercase - "Message" vs "message" was causing a duplicate
// field during the analysis.
var dest = source.charAt(0).toLowerCase() + source.slice(1);
data.error[dest] = err[source];
});
} else if (fluid.isPlainObject(err, true)) {
data.error = Object.assign({}, err);
} else {
data.error = {
message: err
};
}
if (!data.error.stack) {
data.error.stack = new Error().stack;
}
var eventObject = gpii.eventLog.createLogObject(moduleName, "Error." + errType, data);
gpii.eventLog.writeLog(that, fluid.logLevel.FAIL, eventObject);
};
// Log fluid.fail.
fluid.failureEvent.addListener(function (args) {
var err = Array.isArray(args) ? args.join(" ") : args;
gpii.eventLog.gotError(err, "Fail");
}, "gpii-eventLog", "before:fail");
/**
* Logs an error caught by onUncaughtException or failureEvent.
*
* @param {String} err - The error.
* @param {String} errType - The error type (defaults to "Exception").
*/
gpii.eventLog.gotError = function (err, errType) {
var eventLog = fluid.queryIoCSelector(fluid.rootComponent, "gpii.eventLog");
if (eventLog.length > 0) {
fluid.each(eventLog, function (inst) {
inst.logError(inst, null, errType || "Exception", err);
});
}
};
// Log uncaught exceptions.
fluid.onUncaughtException.addListener(gpii.eventLog.gotError, "gpii-eventLog");
/**
* Parses the log path (which can be overridden by the GPII_EVENT_LOG environment variable), and sets either the
* logServer or logPath member.
*
* @param {Component} that - The gpii.eventLog instance.
* @param {String} logDestination - Where the log is sent (a local file path, or a tcp socket in the format of
* `tcp://<host>:<port>`).
*/
gpii.eventLog.initLogDestination = function (that, logDestination) {
logDestination = process.env.GPII_EVENT_LOG || logDestination;
var match = logDestination && /tcp:\/*([^:]+):([0-9]+)/.exec(logDestination);
if (match) {
that.logServer.host = match[1];
that.logServer.port = match[2];
} else {
that.logPath = logDestination;
}
var dest = that.logServer.host ? ("tcp://" + that.logServer.host + ":" + that.logServer.port) : that.logPath;
fluid.log(fluid.logLevel.IMPORTANT, "Writing event log to " + dest);
};
/**
* Writes an event to the log file.
*
* @param {Component} that - The gpii.eventLog instance.
* @param {Object} level - Level of the log, see fluid.logLevelsSpec [FATAL,FAIL,WARN,IMPORTANT,INFO,TRACE].
* @param {LogEvent} event - The object. This will be modified to what has been sent to the log, adding the installID and
* timestamp fields.
*/
gpii.eventLog.writeLog = function (that, level, event) {
var eventLevel = gpii.eventLog.checkLevel(level);
event.level = eventLevel.value;
gpii.eventLog.recordDuration(that, event);
Object.assign(event, that.eventData);
event.timestamp = gpii.eventLog.getTimestamp();
event.sequence = that.sequence++;
if (eventLevel.priority <= that.logLevel.priority) {
var logLine = JSON.stringify(event) + "\n";
if (that.logServer.host && that.logServer.port) {
gpii.eventLog.writeLogTcp(that, logLine);
} else if (that.logPath) {
fs.appendFileSync(that.logPath, logLine);
}
}
};
/**
* Handles the timing of a pair of duration events.
*
* Some events can be paired into having a duration. For example, tooltip-shown and tooltip-hidden. The timestamp for
* the start event is recorded, so when the end event is seen the duration is calculated and added to the event data.
*
* @param {Component} that The gpii.eventLog instance.
* @param {LogEvent} event The event.
*/
gpii.eventLog.recordDuration = function (that, event) {
var endEvent = that.options.durationEvents[event.event];
if (endEvent) {
// This is the start of a pair of duration events.
if (!that.eventTimes[endEvent]) {
that.eventTimes[endEvent] = [];
}
that.eventTimes[endEvent].push(process.hrtime());
} else {
var startTimes = that.eventTimes[event.event];
var startTime = startTimes && startTimes.pop();
if (startTime) {
// This is the ending of a pair of duration events.
if (!event.data) {
event.data = {};
}
var memberName = event.data.hasOwnProperty("duration") ? "duration_auto" : "duration";
event.data[memberName] = process.hrtime(startTime)[0];
}
}
};
/**
* Sends a log line to the configured log server.
*
* @param {Component} that - The gpii.eventLog instance.
* @param {String} logLine - The log line, including the newline.
*/
gpii.eventLog.writeLogTcp = function (that, logLine) {
if (!that.logSocket) {
gpii.eventLog.connectLog(that);
}
if (that.logSocket.connecting) {
// Buffer the output until the connection is made. Socket.write already buffers, however if the connection
// fails the data will be lost.
if (that.logBuffer.length > that.maxBufferlength) {
fluid.log("Dropping metrics data - exceeded initial buffer size of " + that.maxBufferlength);
} else {
that.logBuffer.push(logLine);
}
} else {
that.logSocket.write(logLine);
}
};
/**
* Connect to a TCP port which receives the log data.
* @param {Component} that - The gpii.eventLog instance.
*/
gpii.eventLog.connectLog = function (that) {
if (that.logSocket) {
fluid.fail("Already connecting to log server");
}
// Connect to the server.
that.logSocket = new net.Socket();
that.logSocket.connect(that.logServer.port, that.logServer.host, function () {
gpii.eventLog.connectLog.lastError = null;
fluid.log("Connected to log server");
// Send the initial data.
if (that.logBuffer.length > 0) {
that.logSocket.write(that.logBuffer.join(""));
that.logBuffer = [];
}
});
that.logSocket.on("close", function () {
if (that.logSocket) {
that.logSocket.destroy();
that.logSocket = null;
}
});
that.logSocket.on("error", function (err) {
if (gpii.eventLog.connectLog.lastError !== err.code) {
fluid.log("Log server socket error:", err);
if (that.logSocket) {
that.logSocket.destroy();
}
// Don't repeat the same error again.
gpii.eventLog.connectLog.lastError = err.code;
}
});
};
/**
* Ensure that the loglevel has a valid value. The levels are defined in the fluid.logLevelsSpec
* Sets INFO as default loglevel
*
* @param {Object} level - Level to check, can be a string that represents the value or a property of fluid.logLevel.
* @return {Object} A valid fluid.logLevel, with INFO as default.
*/
gpii.eventLog.checkLevel = function (level) {
var togo;
if (typeof level === "string" && level in fluid.logLevelsSpec) {
togo = fluid.logLevel[level];
} else {
togo = fluid.isLogLevel(level) && level;
}
return togo || fluid.logLevel.INFO;
};
/**
* Specifies a field which gets logged with every subsequent event.
*
* @param {Component} that The gpii.eventLog instance.
* @param {String} name The name of the field.
* @param {Object} value The field value. undefined or null will remove the field.
*/
gpii.eventLog.setState = function (that, name, value) {
if (value === undefined || value === null) {
delete that.eventData[name];
} else {
that.eventData[name] = value;
}
};