UNPKG

overlog

Version:

Simple logs collection and retrieval.

553 lines (516 loc) 18.2 kB
// Keeps messages in an intermediate dir first, atomically moves them to the destination one. // // Requirements for the entries to be logged: // 1) Must be JSON. // 2) Must have the "ms" field with Date.now() output, the milliseconds. // 3) Logging must be close to real-time: entry "ms" field populated by the caller must be close to "Date.now()". // This is important for the fetcher in order to not have to go through extra files. // // The easiest way to test this module is to run it as a command-line application. // Please refer to the shell test for mode details. // // Uses file-based lock (via 'npm install pidlock') or a pubsub channel (via 'npm install faye'). // // Intermediate filename schema: ('tmp:' + random_hash + '.log'). // Destination filename schema: ('YYYY-MM-DD-HH-MM' + ':' + random_hash + ':' + firstentry_ms + ':' + lastentry_ms + '.log). // // Where "random_hash" is five random lowercase latin characters. var _ = require('underscore'); var assert = require('assert'); var dateformat = require('dateformat'); var fs = require('fs'); var pidlock = require('pidlock'); var mkpath = require('mkpath'); var path = require('path'); var synchronized = require('synchronized'); var http = require('http'); var faye = require('faye'); var express = require('express'); var CircularBuffer = require('./CircularBuffer'); var prefix = 'DEBUG '; function safeParseJson(string) { try { return JSON.parse(string); } catch (exception) { return null; } }; function generateRandomHash() { var result = ''; for (var i = 0; i < 5; ++i) { result += (Math.floor(Math.random() * 26) + 10).toString(36); } return result; }; var mock_ms = 0; function Impl(params) { this.params = _.clone(params); if (this.params.verbose) { this.log = function(message) { console.log(prefix + message); }; } else { this.log = function() {}; } assert(_.isString(this.params.intermediate_dir)); assert(fs.existsSync(this.params.intermediate_dir)); assert(_.isString(this.params.destination_dir)); assert(fs.existsSync(this.params.destination_dir)); if (!params.mock_time) { this.now = Date.now; } else { this.now = function() { return mock_ms; }; } this.start_time_ms = this.now(); this.recent_entries = new CircularBuffer(); this.recent_entries_accepted_ms = new CircularBuffer(); this.recent_files = new CircularBuffer(); this.replayIntermediateFiles(); this.total_consumed = 0; this.total_replayed = 0; this.total_file_renames = 0; this.ensureFileIsOpen(); this.installTimeBoundaryFlusher(); }; Impl.prototype.ensureFileIsOpen = function() { if (_.isUndefined(this.fd)) { this.fn = path.join(this.params.intermediate_dir, 'tmp:' + generateRandomHash() + '.log'); this.log('Intermediate "' + this.fn + '": opening.'); this.fd = fs.openSync(this.fn, 'a'); this.file_start_time_ms = this.now(); this.log('Intermediate "' + this.fn + '": opened, fd=' + this.fd); this.uncommitted_entries = []; this.ms = { earliest: null, latest: null, }; this.current_file_formatted_date = this.getFormattedDate(); } }; Impl.prototype.getFormattedDate = function() { return dateformat(this.now(), this.params.filename_dateformat || 'yyyy-mm-dd-HH'); }; Impl.prototype.ensureFileIsFlushed = function(force_write) { if (!_.isUndefined(this.fd)) { this.log('Intermediate "' + this.fn + '": closing.'); fs.fsyncSync(this.fd); fs.closeSync(this.fd); delete this.fd; this.log('Intermediate "' + this.fn + '": closed.'); if (force_write || this.uncommitted_entries.length > 0) { var destination_fn = path.join( this.params.destination_dir, this.getFormattedDate() + ':' + generateRandomHash() + ':' + this.ms.earliest + ':' + this.ms.latest + '.log'); this.log('Destination "' + destination_fn + '": atomically renaming, ' + this.uncommitted_entries.length + ' entries.'); fs.renameSync(this.fn, destination_fn); this.log('Destination "' + destination_fn + '": atomically renamed.'); this.recent_files.push({ name: destination_fn, timestamp_ms: this.now() }); ++this.total_file_renames; this.uncommitted_entries = []; } else { this.log('Intermediate "' + this.fn + '": unlinking empty.'); fs.unlinkSync(this.fn); this.log('Intermediate "' + this.fn + '": unlinked.'); } delete this.fn; delete this.ms; } }; Impl.prototype.stats = function(unittest_mode) { var result = { total_consumed: this.total_consumed, total_replayed: this.total_replayed, total_file_renames: this.total_file_renames, current_file: { number_of_entries: this.uncommitted_entries.length, time_interval: this.ms, }, }; if (!unittest_mode) { result.current_file.fn = this.fn; result.uptime_in_seconds = 1e-3 * (this.now() - this.start_time_ms); result.current_file.age_in_seconds = 1e-3 * (this.now() - this.file_start_time_ms); result.recent_entries = this.recent_entries.dump(); result.recent_files = this.recent_files.dump(); result.qps_overall = result.total_consumed / result.uptime_in_seconds; var msq = this.recent_entries_accepted_ms; var msq_size = msq.size(); if (msq_size > 0) { result.qps_on_recent_entries = msq_size / (1e-3 * (this.now() - msq.peek_least_recent())); } } return result; }; Impl.prototype.appendEntry = function(e) { assert(_.isObject(e)); if (!_.isNumber(e.ms)) { this.log('NEED_MS_FIELD.'); return; } if (this.params.mock_time) { mock_ms = e.ms; } var call_timestamp = this.now(); if (Math.abs(e.ms - this.now()) > this.params.max_time_discrepancy_ms) { this.log('LARGE_TIME_DISCREPANCY'); if (!this.skipped_because_of_time_discrepancy) { this.skipped_because_of_time_discrepancy = 0; } if (!this.skipped_because_of_discrepancy_threshold) { this.skipped_because_of_discrepancy_threshold = 1; } ++this.skipped_because_of_time_discrepancy; if (this.skipped_because_of_time_discrepancy >= this.skipped_because_of_discrepancy_threshold) { this.skipped_because_of_discrepancy_threshold *= 10; this.log(this.skipped_because_of_time_discrepancy + ' total skipped because of time discrepancy.'); } return; } this.flushIfDestinationFilenameHasChanged(); this.ensureFileIsOpen(); if (!this.ms.earliest || e.ms < this.ms.earliest) { this.ms.earliest = e.ms; } if (!this.ms.latest || e.ms > this.ms.latest) { this.ms.latest = e.ms; } fs.writeSync(this.fd, JSON.stringify(e) + '\n'); if (this.params.max_file_age_ms && this.uncommitted_entries.length === 0) { var self = this; var fn_to_flush = this.fn; setTimeout( function() { if (self.fn === fn_to_flush) { self.log('Intermediate "' + self.fn + '": flushed by timeout.'); self.flush(); } }, this.params.max_file_age_ms); } this.recent_entries.push(_.clone(e)); this.recent_entries_accepted_ms.push(call_timestamp); this.uncommitted_entries.push(_.clone(e)); ++this.total_consumed; if (this.params.log_frequency && ((this.uncommitted_entries.length % this.params.log_frequency)) === 0) { this.log('Intermediate "' + this.fn + '": ' + this.uncommitted_entries.length + ' entries.'); } if (this.params.max_entries_per_file && (this.uncommitted_entries.length > this.params.max_entries_per_file)) { this.flush(); } }; Impl.prototype.flush = function() { this.ensureFileIsFlushed(); this.ensureFileIsOpen(); }; Impl.prototype.getPendingEntries = function() { return this.uncommitted_entries; }; Impl.prototype.finalFlush = function() { this.ensureFileIsFlushed(); }; Impl.prototype.flushIfDestinationFilenameHasChanged = function() { var formatted_timestamp = this.getFormattedDate(); if (formatted_timestamp != this.current_file_formatted_date) { if (this.uncommitted_entries.length > 0) { this.log('Time boundary: "' + formatted_timestamp + '": flushing.'); this.flush(); } else { this.log('Time boundary: "' + formatted_timestamp + '": nothing to flush.'); this.current_file_formatted_date = formatted_timestamp; } } }; Impl.prototype.installTimeBoundaryFlusher = function() { var self = this; setInterval(function() { self.flushIfDestinationFilenameHasChanged(); }, 1000); }; Impl.prototype.tearDown = function(callback) { this.ensureFileIsFlushed(); if (_.isFunction(callback)) { callback(); } }; Impl.prototype.replayIntermediateFiles = function() { var self = this; var files = fs.readdirSync(this.params.intermediate_dir); if (files.length > 0) { self.log('Replaying ' + files.length + ' files.'); _.each(files, function(fn) { var full_fn = path.join(self.params.intermediate_dir, fn); self.log('Replaying "' + fn + '": begin.'); self.ms = { earliest: null, latest: null, }; var lines = { total: 0, good: 0, }; _.each(fs.readFileSync(full_fn).toString().split('\n'), function(line) { if (line) { ++lines.total; var e = safeParseJson(line); if (_.isObject(e) && _.isNumber(e.ms)) { ++lines.good; ++self.total_replayed; self.ensureFileIsOpen(); if (!self.ms.earliest || e.ms < self.ms.earliest) { self.ms.earliest = e.ms; } if (!self.ms.latest || e.ms > self.ms.latest) { self.ms.latest = e.ms; } fs.writeSync(self.fd, JSON.stringify(e) + '\n'); } } }); self.log( 'Replaying "' + fn + '": ' + lines.good + ' entries parsed' + (lines.total === lines.good ? '' : ' (out of ' + lines.total + ' lines)') + '.'); self.ensureFileIsFlushed(true); self.log('Replaying "' + fn + '": done.'); fs.unlinkSync(full_fn); }); } }; function runStorer(config, userCodeCallback, tearDownCallback, extraCallbacks) { var intermediate_dir = config.storer_intermediate_dir || path.join(config.storer_workdir, '/intermediate'); var publish = (extraCallbacks && extraCallbacks.publish) ? extraCallbacks.publish : (function() {}); console.log('Intermediate dir "' + intermediate_dir + '".'); mkpath.sync(intermediate_dir); var destination_dir = config.storer_destination_dir || path.join(config.storer_workdir, '/destination'); console.log('Destination dir "' + destination_dir + '".'); mkpath.sync(destination_dir); var impl = new Impl({ verbose: config.verbose, intermediate_dir: intermediate_dir, destination_dir: destination_dir, log_frequency: config.storer_log_frequency, max_time_discrepancy_ms: config.storer_max_time_discrepancy_ms, max_entries_per_file: config.storer_max_entries_per_file, max_file_age_ms: config.storer_max_file_age_ms, mock_time: config.storer_mock_time, filename_dateformat: config.storer_filename_dateformat, }); if (extraCallbacks && extraCallbacks.set_stats_callback) { extraCallbacks.set_stats_callback(function() { return impl.stats(); }); } if (extraCallbacks && extraCallbacks.set_pending_callback) { extraCallbacks.set_pending_callback(function() { return impl.getPendingEntries(); }); } process.on('SIGINT', function() { console.log('SIGINT, caught.'); impl.finalFlush(); tearDownCallback(function() { console.log('SIGINT, processed. You should not be seeing this.'); process.exit(-1); }); }); userCodeCallback({ push: function(entry, callback) { var json; if (_.isString(entry)) { if (config.storer_debug && entry === 'STOP') { tearDownCallback(function() { console.log('STOP, processed. You should not be seeing this.'); process.exit(-1); }); return; } else if (config.storer_debug && entry === 'UNITTEST_STATS') { console.log('UNITTEST\t' + JSON.stringify(impl.stats(true))); if (_.isFunction(callback)) { setTimeout(callback, 0); } return; } else if (config.storer_debug && entry === 'STATS') { console.log(JSON.stringify(impl.stats())); if (_.isFunction(callback)) { setTimeout(callback, 0); } return; } else if (config.storer_debug && entry === 'CONFIG') { console.log(JSON.stringify(config, null, 2)); if (_.isFunction(callback)) { setTimeout(callback, 0); } return; } else if (config.storer_debug && entry === 'CREATE') { impl.ensureFileIsOpen(); if (_.isFunction(callback)) { setTimeout(callback, 0); } return; } else if (config.storer_debug && entry === 'FLUSH') { impl.ensureFileIsFlushed(); if (_.isFunction(callback)) { setTimeout(callback, 0); } return; } else if (config.storer_debug && entry === 'STATUS') { console.log(JSON.stringify(impl, null, 2)); if (_.isFunction(callback)) { setTimeout(callback, 0); } return; } json = safeParseJson(entry); } else { json = entry; } if (_.isObject(json)) { impl.appendEntry(json); publish(json); } else { impl.log('INVALID_JSON'); } if (_.isFunction(callback)) { setTimeout(callback, 0); } }, stats: impl.stats, shutdown: function(callback) { impl.tearDown(); tearDownCallback(callback, function() { console.log('shutdown, processed. You should not be seeing this.'); process.exit(-1); }); } }); }; function spawnUsingPidLock(config, userCodeCallback) { var lockdir = config.storer_lockdir || config.storer_workdir; assert(_.isString(lockdir) && lockdir !== ''); mkpath.sync(lockdir); console.log('Lock ("' + lockdir + '" / "' + config.storer_lockname + '"): acquiring.'); pidlock.guard(lockdir, config.storer_lockname, function(error, data, cleanup) { if (error) { console.log('Lock ("' + lockdir + '" / "' + config.storer_lockname + '"): busy.'); } else { console.log('Lock ("' + lockdir + '" / "' + config.storer_lockname + '"): acquired.'); runStorer(config, userCodeCallback, function(tearDownCallback) { console.log('Lock ("' + lockdir + '" / "' + config.storer_lockname + '"): releasing.'); cleanup(); console.log('Lock ("' + lockdir + '" / "' + config.storer_lockname + '"): releases.'); if (_.isFunction(tearDownCallback)) { tearDownCallback(); } }); } }); }; function spawnUsingFaye(config, userCodeCallback) { var pubsub_port = config.pubsub_port; var pubsub_mount = config.pubsub_mount; var pubsub_channel = '/' + config.pubsub_channel; var pubsub_teardown_delay_ms = config.storer_pubsub_server_teardown_delay_ms || 2000; var app = express(); var healthzCallback = function() { return 'STARTING' }; var pendingCallback = function() { return []; }; var statsCallback = healthzCallback; app.get('/', function(request, response) { response.send(healthzCallback()); }); app.get('/pending', function(request, response) { response.send(JSON.stringify(pendingCallback())); }); app.get('/healthz', function(request, response) { response.send(healthzCallback()); }); app.get('/statusz', function(request, response) { var result = statsCallback(); response.format({ text: function() { response.send(JSON.stringify(result)); }, html: function() { response.writeHead(200, { "Content-Type": "application/json" }); response.write(JSON.stringify(result, null, 2)); response.end(); }, }); }); var server = http.createServer(app); console.log('Faye: mount "' + pubsub_mount + '", channel "' + pubsub_channel + '", port ' + pubsub_port + '.'); var bayeux = new faye.NodeAdapter({ mount: pubsub_mount }); bayeux.attach(server); server.listen(pubsub_port, function() { console.log('Faye: server started.'); healthzCallback = function() { return 'OK'; }; var client = bayeux.getClient(); var publish = function(entry) { client.publish(pubsub_channel, { entry: entry }); }; runStorer(config, userCodeCallback, function() { console.log('Faye: stopping server.'); server.close(function() { console.log('Faye: server stopped.'); process.exit(0); }); // Need to explicitly terminate since some PubSub connections may still be open. setTimeout(function() { console.log('Faye: could not stop the server, likely due to persistent connections; terminating anyway.'); process.exit(0); }, pubsub_teardown_delay_ms); }, { set_stats_callback: function(callback) { statsCallback = callback; }, set_pending_callback: function(callback) { pendingCallback = callback; }, publish: publish, }); }); }; module.exports.spawn = function(config, userCodeCallback) { assert(_.isFunction(userCodeCallback)); var spawn = (config.pubsub_channel && config.pubsub_mount && config.pubsub_port) ? spawnUsingFaye : spawnUsingPidLock; spawn(config, userCodeCallback); }; if (require.main === module) { var config = require('./config').fromCommandLine(); module.exports.spawn(config, function(storer) { var rl = require('readline').createInterface(process.stdin, process.stdout); var lock; rl.on('line', function(line) { synchronized(lock, function(callback) { storer.push(line, callback); }); }); rl.on('close', function() { synchronized(lock, function(callback) { console.log('Tearing down.'); storer.shutdown(function() { console.log('Done.'); process.exit(0); }); }); }); }); }