splunk-logging
Version:
Splunk HTTP Event Collector logging interface
591 lines (527 loc) • 22.7 kB
JavaScript
/*
* Copyright 2015 Splunk, Inc.
*
* Licensed 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.
*/
var needle = require("needle");
var url = require("url");
var utils = require("./utils");
/**
* Default error handler for <code>SplunkLogger</code>.
* Prints the <code>err</code> and <code>context</code> to console.
*
* @param {Error|string} err - The error message, or an <code>Error</code> object.
* @param {object} [context] - The <code>context</code> of an event.
* @private
*/
/* istanbul ignore next*/
function _err(err, context) {
console.log("ERROR:", err, " CONTEXT", context);
}
/**
* The default format for Splunk Enterprise or Splunk Cloud events.
*
* This function can be overwritten, and can return any type (string, object, array, and so on).
*
* @param {anything} [message] - The event message.
* @param {string} [severity] - The event severity.
* @return {any} The event format to send to Splunk,
*/
function _defaultEventFormatter(message, severity) {
var event = {
message: message,
severity: severity
};
return event;
}
/**
* Constructs a SplunkLogger, to send events to Splunk Enterprise or Splunk Cloud
* via HTTP Event Collector. See <code>defaultConfig</code> for default
* configuration settings.
*
* @example
* var SplunkLogger = require("splunk-logging").Logger;
*
* var config = {
* token: "your-token-here",
* name: "my application",
* url: "https://splunk.local:8088"
* };
*
* var logger = new SplunkLogger(config);
*
* @property {object} config - Configuration settings for this <code>SplunkLogger</code> instance.
* @param {object} requestOptions - Options to pass to <code>{@link https://github.com/tomas/needle#needleposturl-data-options-callback|needle.post()}</code>.
* See the {@link https://github.com/tomas/needle#request-options|needle documentation} for all available options.
* @property {object[]} serializedContextQueue - Queue of serialized <code>context</code> objects to be sent to Splunk Enterprise or Splunk Cloud.
* @property {function} eventFormatter - Formats events, returning an event as a string, <code>function(message, severity)</code>.
* Can be overwritten, the default event formatter will display event and severity as properties in a JSON object.
* @property {function} error - A callback function for errors: <code>function(err, context)</code>.
* Defaults to <code>console.log</code> both values;
*
* @param {object} config - Configuration settings for a new [SplunkLogger]{@link SplunkLogger}.
* @param {string} config.token - HTTP Event Collector token, required.
* @param {string} [config.name=splunk-javascript-logging/0.11.1] - Name for this logger.
* @param {string} [config.host=localhost] - Hostname or IP address of Splunk Enterprise or Splunk Cloud server.
* @param {string} [config.maxRetries=0] - How many times to retry when HTTP POST to Splunk Enterprise or Splunk Cloud fails.
* @param {string} [config.path=/services/collector/event/1.0] - URL path to send data to on the Splunk Enterprise or Splunk Cloud server.
* @param {string} [config.protocol=https] - Protocol used to communicate with the Splunk Enterprise or Splunk Cloud server, <code>http</code> or <code>https</code>.
* @param {number} [config.port=8088] - HTTP Event Collector port on the Splunk Enterprise or Splunk Cloud server.
* @param {string} [config.url] - URL string to pass to {@link https://nodejs.org/api/url.html#url_url_parsing|url.parse}. This will try to set
* <code>host</code>, <code>path</code>, <code>protocol</code>, <code>port</code>, <code>url</code>. Any of these values will be overwritten if
* the corresponding property is set on <code>config</code>.
* @param {string} [config.level=info] - Logging level to use, will show up as the <code>severity</code> field of an event, see
* [SplunkLogger.levels]{@link SplunkLogger#levels} for common levels.
* @param {number} [config.batchInterval=0] - Automatically flush events after this many milliseconds.
* When set to a non-positive value, events will be sent one by one. This setting is ignored when non-positive.
* @param {number} [config.maxBatchSize=0] - Automatically flush events after the size of queued
* events exceeds this many bytes. This setting is ignored when non-positive.
* @param {number} [config.maxBatchCount=1] - Automatically flush events after this many
* events have been queued. Defaults to flush immediately on sending an event. This setting is ignored when non-positive.
* @constructor
* @throws Will throw an error if the <code>config</code> parameter is malformed.
*/
var SplunkLogger = function(config) {
this._timerID = null;
this._timerDuration = 0;
this.config = this._initializeConfig(config);
this.requestOptions = this._initializeRequestOptions();
this.serializedContextQueue = [];
this.eventsBatchSize = 0;
this.eventFormatter = _defaultEventFormatter;
this.error = _err;
this._enableTimer = utils.bind(this, this._enableTimer);
this._disableTimer = utils.bind(this, this._disableTimer);
this._initializeConfig = utils.bind(this, this._initializeConfig);
this._initializeRequestOptions = utils.bind(this, this._initializeRequestOptions);
this._validateMessage = utils.bind(this, this._validateMessage);
this._initializeMetadata = utils.bind(this, this._initializeMetadata);
this._initializeContext = utils.bind(this, this._initializeContext);
this._makeBody = utils.bind(this, this._makeBody);
this._post = utils.bind(this, this._post);
this._sendEvents = utils.bind(this, this._sendEvents);
this.send = utils.bind(this, this.send);
this.flush = utils.bind(this, this.flush);
};
/**
* Enum for common logging levels.
*
* @default info
* @readonly
* @enum {string}
*/
SplunkLogger.prototype.levels = {
DEBUG: "debug",
INFO: "info",
WARN: "warn",
ERROR: "error"
};
var defaultConfig = {
name: "splunk-javascript-logging/0.11.1",
host: "localhost",
path: "/services/collector/event/1.0",
protocol: "https",
port: 8088,
level: SplunkLogger.prototype.levels.INFO,
maxRetries: 0,
batchInterval: 0,
maxBatchSize: 0,
maxBatchCount: 1
};
var defaultRequestOptions = {
json: false,
strictSSL: false
};
/**
* Disables the interval timer set by <code>this._enableTimer()</code>.
*
* param {Number} interval - The batch interval.
* @private
*/
SplunkLogger.prototype._disableTimer = function() {
if (this._timerID) {
clearInterval(this._timerID);
this._timerDuration = 0;
this._timerID = null;
}
};
/**
* Configures an interval timer to flush any events in
* <code>this.serializedContextQueue</code> at the specified interval.
*
* param {Number} interval - The batch interval in milliseconds.
* @private
*/
SplunkLogger.prototype._enableTimer = function(interval) {
// Only enable the timer if possible
interval = utils.validateNonNegativeInt(interval, "Batch interval");
if (this._timerID) {
this._disableTimer();
}
// If batch interval is changed, update the config property
if (this.config) {
this.config.batchInterval = interval;
}
this._timerDuration = interval;
var that = this;
this._timerID = setInterval(function() {
if (that.serializedContextQueue.length > 0) {
that.flush();
}
}, interval);
};
/**
* Sets up the <code>config</code> with any default properties, and/or
* config properties set on <code>this.config</code>.
*
* @return {object} config
* @private
* @throws Will throw an error if the <code>config</code> parameter is malformed.
*/
SplunkLogger.prototype._initializeConfig = function(config) {
// Copy over the instance config
var ret = utils.copyObject(this.config);
if (!config) {
throw new Error("Config is required.");
}
else if (typeof config !== "object") {
throw new Error("Config must be an object.");
}
else if (!ret.hasOwnProperty("token") && !config.hasOwnProperty("token")) {
throw new Error("Config object must have a token.");
}
else if (typeof ret.token !== "string" && typeof config.token !== "string") {
throw new Error("Config token must be a string.");
}
else {
// Specifying the url will override host, port, scheme, & path if possible
if (config.url) {
var parsed = url.parse(config.url);
// Ignore the path if it's just "/"
var pathIsNotSlash = parsed.path && parsed.path !== "/";
if (parsed.protocol) {
config.protocol = parsed.protocol.replace(":", "");
}
if (parsed.port) {
config.port = parsed.port;
}
if (parsed.hostname && parsed.path) {
config.host = parsed.hostname;
if (pathIsNotSlash) {
config.path = parsed.path;
}
}
else if (pathIsNotSlash) {
// If hostname isn't set, but path is assume path is the host
config.host = parsed.path;
}
}
// Take the argument's value, then instance value, then the default value
ret.token = utils.orByProp("token", config, ret);
ret.name = utils.orByProp("name", config, ret, defaultConfig);
ret.level = utils.orByProp("level", config, ret, defaultConfig);
ret.host = utils.orByProp("host", config, ret, defaultConfig);
ret.path = utils.orByProp("path", config, ret, defaultConfig);
ret.protocol = utils.orByProp("protocol", config, ret, defaultConfig);
ret.port = utils.orByFalseyProp("port", config, ret, defaultConfig);
ret.port = utils.validateNonNegativeInt(ret.port, "Port");
if (ret.port < 1 || ret.port > 65535) {
throw new Error("Port must be an integer between 1 and 65535, found: " + ret.port);
}
ret.maxRetries = utils.orByProp("maxRetries", config, ret, defaultConfig);
ret.maxRetries = utils.validateNonNegativeInt(ret.maxRetries, "Max retries");
// Batching settings
ret.maxBatchCount = utils.orByFalseyProp("maxBatchCount", config, ret, defaultConfig);
ret.maxBatchCount = utils.validateNonNegativeInt(ret.maxBatchCount, "Max batch count");
ret.maxBatchSize = utils.orByFalseyProp("maxBatchSize", config, ret, defaultConfig);
ret.maxBatchSize = utils.validateNonNegativeInt(ret.maxBatchSize, "Max batch size");
ret.batchInterval = utils.orByFalseyProp("batchInterval", config, ret, defaultConfig);
ret.batchInterval = utils.validateNonNegativeInt(ret.batchInterval, "Batch interval");
// Has the interval timer not started, and needs to be started?
var startTimer = !this._timerID && ret.batchInterval > 0;
// Has the interval timer already started, and the interval changed to a different duration?
var changeTimer = this._timerID && this._timerDuration !== ret.batchInterval && ret.batchInterval > 0;
// Enable the timer
if (startTimer || changeTimer) {
this._enableTimer(ret.batchInterval);
}
// Disable timer - there is currently a timer, but config says we no longer need a timer
else if (this._timerID && (ret.batchInterval <= 0 || this._timerDuration < 0)) {
this._disableTimer();
}
}
return ret;
};
/**
* Initializes request options.
*
* @param {object} config
* @param {object} options - Options to pass to <code>{@link https://github.com/request/request#requestpost|request.post()}</code>.
* See the {@link http://github.com/request/request|request documentation} for all available options.
* @returns {object} requestOptions
* @private
*/
SplunkLogger.prototype._initializeRequestOptions = function(options) {
var ret = utils.copyObject(options || defaultRequestOptions);
if (options) {
ret.json = options.hasOwnProperty("json") ? options.json : defaultRequestOptions.json;
ret.strictSSL = options.strictSSL || defaultRequestOptions.strictSSL;
}
ret.headers = ret.headers || {};
return ret;
};
/**
* Throws an error if message is <code>undefined</code> or <code>null</code>.
*
* @private
* @throws Will throw an error if the <code>message</code> parameter is malformed.
*/
SplunkLogger.prototype._validateMessage = function(message) {
if (typeof message === "undefined" || message === null) {
throw new Error("Message argument is required.");
}
return message;
};
/**
* Initializes metadata. If <code>context.metadata</code> is false or empty,
* return an empty object.
*
* @param {object} context
* @returns {object} metadata
* @private
*/
SplunkLogger.prototype._initializeMetadata = function(context) {
var metadata = {};
if (context && context.hasOwnProperty("metadata")) {
if (context.metadata.hasOwnProperty("time")) {
metadata.time = context.metadata.time;
}
if (context.metadata.hasOwnProperty("host")) {
metadata.host = context.metadata.host;
}
if (context.metadata.hasOwnProperty("source")) {
metadata.source = context.metadata.source;
}
if (context.metadata.hasOwnProperty("sourcetype")) {
metadata.sourcetype = context.metadata.sourcetype;
}
if (context.metadata.hasOwnProperty("index")) {
metadata.index = context.metadata.index;
}
}
return metadata;
};
/**
* Initializes a context object.
*
* @param context
* @returns {object} context
* @throws Will throw an error if the <code>context</code> parameter is malformed.
* @private
*/
SplunkLogger.prototype._initializeContext = function(context) {
if (!context) {
throw new Error("Context argument is required.");
}
else if (typeof context !== "object") {
throw new Error("Context argument must be an object.");
}
else if (!context.hasOwnProperty("message")) {
throw new Error("Context argument must have the message property set.");
}
context.message = this._validateMessage(context.message);
context.severity = context.severity || defaultConfig.level;
context.metadata = context.metadata || this._initializeMetadata(context);
return context;
};
/**
* Takes anything and puts it in a JS object for the event/1.0 Splunk HTTP Event Collector format.
*
* @param {object} context
* @returns {object}
* @private
* @throws Will throw an error if the <code>context</code> parameter is malformed.
*/
SplunkLogger.prototype._makeBody = function(context) {
if (!context) {
throw new Error("Context parameter is required.");
}
var body = this._initializeMetadata(context);
var time = utils.formatTime(body.time || Date.now());
body.time = time.toString();
body.event = this.eventFormatter(context.message, context.severity || defaultConfig.level);
return body;
};
/**
* Makes an HTTP POST to the configured server.
*
* @param requestOptions
* @param {function} callback = A callback function: <code>function(err, response, body)</code>.
* @private
*/
SplunkLogger.prototype._post = function(requestOptions, callback) {
let body = requestOptions.body ;
let url = requestOptions.url;
let options = requestOptions;
options["rejectUnauthorized"] = requestOptions.rejectUnauthorized ? requestOptions.rejectUnauthorized : requestOptions.strictSSL;
needle.post(url,body,options, callback);
};
/**
* Sends events to Splunk Enterprise or Splunk Cloud, optionally with retries on non-Splunk errors.
*
* @param context
* @param {function} callback - A callback function: <code>function(err, response, body)</code>
* @private
*/
SplunkLogger.prototype._sendEvents = function(context, callback) {
callback = callback || /* istanbul ignore next*/ function(){};
// Initialize the config once more to avoid undefined vals below
this.config = this._initializeConfig(this.config);
// Makes a copy of the request options so we can set the body
var requestOptions = this._initializeRequestOptions(this.requestOptions);
requestOptions.body = this._validateMessage(context.message);
requestOptions.headers["Authorization"] = "Splunk " + this.config.token;
// Manually set the content-type header, the default is application/json
// since json is set to true.
requestOptions.headers["Content-Type"] = "application/x-www-form-urlencoded";
requestOptions.url = this.config.protocol + "://" + this.config.host + ":" + this.config.port + this.config.path;
// Initialize the context again, right before using it
context = this._initializeContext(context);
var that = this;
var splunkError = null; // Errors returned by Splunk Enterprise or Splunk Cloud
var requestError = null; // Any non-Splunk errors
// References so we don't have to deal with callback parameters
var _response = null;
var _body = null;
var numRetries = 0;
utils.whilst(
function() {
// Continue if we can (re)try
return numRetries++ <= that.config.maxRetries;
},
function(done) {
that._post(requestOptions, function(err, resp, body) {
// Store the latest error, response & body
splunkError = null;
requestError = err;
_response = resp;
// Retry if no Splunk error, a non-200 request response, and numRetries hasn't exceeded the limit
if (requestError && numRetries <= that.config.maxRetries) {
return utils.expBackoff({attempt: numRetries}, done);
}
else if (requestError) {
return done(err);
}
//the response body is itselt a json object
_body = body;
// Try to parse an error response from Splunk Enterprise or Splunk Cloud
if (!splunkError && _body && _body.code && _body.code.toString() !== "0") {
splunkError = new Error(_body.text);
splunkError.code = _body.code;
}
// Stop iterating
done(true);
});
},
function() {
// Call error() for a request error or Splunk error
if (requestError || splunkError) {
that.error(requestError || splunkError, context);
}
callback(requestError, _response, _body);
}
);
};
/**
* Sends or queues data to be sent based on batching settings.
* Default behavior is to send immediately.
*
* @example
* var SplunkLogger = require("splunk-logging").Logger;
* var config = {
* token: "your-token-here"
* };
*
* var logger = new SplunkLogger(config);
*
* // Payload to send to HTTP Event Collector.
* var payload = {
* message: {
* temperature: "70F",
* chickenCount: 500
* },
* severity: "info",
* metadata: {
* source: "chicken coop",
* sourcetype: "httpevent",
* index: "main",
* host: "farm.local",
* }
* };
*
* // The callback is only used if maxBatchCount=1, or
* // batching thresholds have been exceeded.
* logger.send(payload, function(err, resp, body) {
* if (err) {
* console.log("error:", err);
* }
* // If successful, body will be { text: 'Success', code: 0 }
* console.log("body", body);
* });
*
* @param {object} context - An object with at least the <code>data</code> property.
* @param {(object|string|Array|number|bool)} context.message - Data to send to Splunk.
* @param {string} [context.severity=info] - Severity level of this event.
* @param {object} [context.metadata] - Metadata for this event.
* @param {string} [context.metadata.host] - If not specified, Splunk Enterprise or Splunk Cloud will decide the value.
* @param {string} [context.metadata.index] - The Splunk Enterprise or Splunk Cloud index to send data to.
* If not specified, Splunk Enterprise or Splunk Cloud will decide the value.
* @param {string} [context.metadata.source] - If not specified, Splunk Enterprise or Splunk Cloud will decide the value.
* @param {string} [context.metadata.sourcetype] - If not specified, Splunk Enterprise or Splunk Cloud will decide the value.
* @param {function} [callback] - A callback function: <code>function(err, response, body)</code>.
* @throws Will throw an error if the <code>context</code> parameter is malformed.
* @public
*/
SplunkLogger.prototype.send = function(context, callback) {
context = this._initializeContext(context);
// Store the context, and its estimated length
var currentEvent = JSON.stringify(this._makeBody(context));
this.serializedContextQueue.push(currentEvent);
this.eventsBatchSize += Buffer.byteLength(currentEvent, "utf8");
var batchOverSize = this.eventsBatchSize > this.config.maxBatchSize && this.config.maxBatchSize > 0;
var batchOverCount = this.serializedContextQueue.length >= this.config.maxBatchCount && this.config.maxBatchCount > 0;
// Only flush if the queue's byte size is too large, or has too many events
if (batchOverSize || batchOverCount) {
this.flush(callback || function(){});
}
};
/**
* Manually send all events in <code>this.serializedContextQueue</code> to Splunk Enterprise or Splunk Cloud.
*
* @param {function} [callback] - A callback function: <code>function(err, response, body)</code>.
* @public
*/
SplunkLogger.prototype.flush = function(callback) {
callback = callback || function(){};
// Empty the queue, reset the eventsBatchSize
var queue = this.serializedContextQueue;
this.serializedContextQueue = [];
this.eventsBatchSize = 0;
// Send all queued events
var data = queue.join("");
var context = {
message: data
};
this._sendEvents(context, callback);
};
module.exports = SplunkLogger;