UNPKG

winston-cloudwatch

Version:
237 lines (209 loc) 8.17 kB
var LIMITS = { MAX_EVENT_MSG_SIZE_BYTES: 256000, // The real max size is 262144, we leave some room for overhead on each message MAX_BATCH_SIZE_BYTES: 1000000, // We leave some fudge factor here too. } // CloudWatch adds 26 bytes per log event based on their documentation: // https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html var BASE_EVENT_SIZE_BYTES = 26; var find = require('lodash.find'), async = require('async'), debug = require('./utils').debug; var lib = { _postingEvents: {}, _nextToken: {} }; lib.upload = function(aws, groupName, streamName, logEvents, retentionInDays, options, cb) { debug('upload', logEvents); // trying to send a batch before the last completed // would cause InvalidSequenceTokenException. if (lib._postingEvents[streamName] || logEvents.length <= 0) { debug('nothing to do or already doing something'); return cb(); } lib._postingEvents[streamName] = true; safeUpload(function(err) { delete lib._postingEvents[streamName]; return cb(err); }); // safeUpload introduced after https://github.com/lazywithclass/winston-cloudwatch/issues/55 // Note that calls to upload() can occur at a greater frequency // than getToken() responses are processed. By way of example, consider if add() is // called at 0s and 1.1s, each time with a single event, and upload() is called // at 1.0s and 2.0s, with the same logEvents array, but calls to getToken() // take 1.5s to return. When the first call to getToken() DOES return, // it will send both events and empty the array. Then, when the second call // go getToken() returns, without this check also here, it would attempt to send // an empty array, resulting in the InvalidParameterException. function safeUpload(cb) { lib.getToken(aws, groupName, streamName, retentionInDays, options, function(err, token) { if (err) { debug('error getting token', err, true); return cb(err); } var entryIndex = 0; var bytes = 0; while (entryIndex < logEvents.length) { var ev = logEvents[entryIndex]; // unit tests pass null elements var evSize = ev ? Buffer.byteLength(ev.message, 'utf8') + BASE_EVENT_SIZE_BYTES : 0; if(evSize > LIMITS.MAX_EVENT_MSG_SIZE_BYTES) { evSize = LIMITS.MAX_EVENT_MSG_SIZE_BYTES; ev.message = ev.message.substring(0, evSize); const msgTooBigErr = new Error('Message Truncated because it exceeds the CloudWatch size limit'); msgTooBigErr.logEvent = ev; cb(msgTooBigErr); } if (bytes + evSize > LIMITS.MAX_BATCH_SIZE_BYTES) break; bytes += evSize; entryIndex++; } var payload = { logGroupName: groupName, logStreamName: streamName, logEvents: logEvents.splice(0, entryIndex) }; if (token) payload.sequenceToken = token; lib._postingEvents[streamName] = true; debug('send to aws'); aws.putLogEvents(payload, function(err, data) { debug('sent to aws, err: ', err, ' data: ', data) if (err) { // InvalidSequenceToken means we need to do a describe to get another token // also do the same if ResourceNotFound as that will result in the last token // for the group being set to null if (err.name === 'InvalidSequenceTokenException' || err.name === 'ResourceNotFoundException') { debug(err.name + ', retrying', true); lib.submitWithAnotherToken(aws, groupName, streamName, payload, retentionInDays, options, cb) } else { debug('error during putLogEvents', err, true) retrySubmit(aws, payload, 3, cb) } } else { if (data && data.nextSequenceToken) { lib._nextToken[previousKeyMapKey(groupName, streamName)] = data.nextSequenceToken; } delete lib._postingEvents[streamName]; cb() } }); }); } }; lib.submitWithAnotherToken = function(aws, groupName, streamName, payload, retentionInDays, options, cb) { lib._nextToken[previousKeyMapKey(groupName, streamName)] = null; lib.getToken(aws, groupName, streamName, retentionInDays, options, function(err, token) { payload.sequenceToken = token; aws.putLogEvents(payload, function(err) { delete lib._postingEvents[streamName]; cb(err) }); }) } function retrySubmit(aws, payload, times, cb) { debug('retrying to upload', times, 'more times') aws.putLogEvents(payload, function(err) { if (err && times > 0) { retrySubmit(aws, payload, times - 1, cb) } else { delete lib._postingEvents[payload.logStreamName]; cb(err) } }) } lib.getToken = function(aws, groupName, streamName, retentionInDays, options, cb) { var existingNextToken = lib._nextToken[previousKeyMapKey(groupName, streamName)]; if (existingNextToken != null) { debug('using existing next token and assuming exists', existingNextToken); cb(null, existingNextToken); return; } const calls = options.ensureLogGroup !== false ? [ lib.ensureGroupPresent.bind(null, aws, groupName, retentionInDays), lib.getStream.bind(null, aws, groupName, streamName) ] : [ lib.getStream.bind(null, aws, groupName, streamName) ]; async.series(calls, function(err, resources) { var groupPresent = calls.length>1 ? resources[0] : true, stream = calls.length === 1 ? resources[0] : resources[1]; if (groupPresent && stream) { debug('token found', stream.uploadSequenceToken); cb(err, stream.uploadSequenceToken); } else { debug('token not found', err); cb(err); } }); }; function previousKeyMapKey(group, stream) { return group + ':' + stream; } lib.ensureGroupPresent = function ensureGroupPresent(aws, name, retentionInDays, cb) { debug('ensure group present'); var params = { logGroupName: name }; aws.describeLogStreams(params, function(err, data) { // TODO we should cb(err, false) if there's an error? if (err && err.name == 'ResourceNotFoundException') { debug('create group'); return aws.createLogGroup(params, lib.ignoreInProgress(function(err) { if(!err) lib.putRetentionPolicy(aws, name, retentionInDays); cb(err, err ? false : true); })); } else { lib.putRetentionPolicy(aws, name, retentionInDays); cb(err, true); } }); }; lib.putRetentionPolicy = function putRetentionPolicy(aws, groupName, days) { var params = { logGroupName: groupName, retentionInDays: days }; if (days > 0) { debug('setting retention policy for "' + groupName + '" to ' + days + ' days'); aws.putRetentionPolicy(params, function(err, data) { if (err) console.error('failed to set retention policy for ' + groupName + ' to ' + days + ' days due to ' + err.stack); }); } }; lib.getStream = function getStream(aws, groupName, streamName, cb) { var params = { logGroupName: groupName, logStreamNamePrefix: streamName }; aws.describeLogStreams(params, function(err, data) { debug('ensure stream present'); if (err) return cb(err); var stream = find(data.logStreams, function(stream) { return stream.logStreamName === streamName; }); if (!stream) { debug('create stream'); aws.createLogStream({ logGroupName: groupName, logStreamName: streamName }, lib.ignoreInProgress(function(err) { if (err) return cb(err); getStream(aws, groupName, streamName, cb); })); } else { cb(null, stream); } }); }; lib.ignoreInProgress = function ignoreInProgress(cb) { return function(err, data) { if (err && (err.name == 'OperationAbortedException' || err.name == 'ResourceAlreadyExistsException')) { debug('ignore operation in progress', err.message); cb(null, data); } else { cb(err, data); } }; }; lib.clearSequenceToken = function clearSequenceToken(group, stream) { delete lib._nextToken[previousKeyMapKey(group, stream)]; } module.exports = lib;