472 lines
23 KiB
JavaScript
472 lines
23 KiB
JavaScript
"use strict";
|
|
var fs = require("fs");
|
|
var os = require("os");
|
|
var path = require("path");
|
|
var zlib = require("zlib");
|
|
var child_process = require("child_process");
|
|
var Logging = require("./Logging");
|
|
var AutoCollectHttpDependencies = require("../AutoCollection/HttpDependencies");
|
|
var Util = require("./Util");
|
|
var Sender = (function () {
|
|
function Sender(config, onSuccess, onError) {
|
|
this._config = config;
|
|
this._onSuccess = onSuccess;
|
|
this._onError = onError;
|
|
this._enableDiskRetryMode = false;
|
|
this._resendInterval = Sender.WAIT_BETWEEN_RESEND;
|
|
this._maxBytesOnDisk = Sender.MAX_BYTES_ON_DISK;
|
|
this._numConsecutiveFailures = 0;
|
|
this._resendTimer = null;
|
|
if (!Sender.OS_PROVIDES_FILE_PROTECTION) {
|
|
// Node's chmod levels do not appropriately restrict file access on Windows
|
|
// Use the built-in command line tool ICACLS on Windows to properly restrict
|
|
// access to the temporary directory used for disk retry mode.
|
|
if (Sender.USE_ICACLS) {
|
|
// This should be async - but it's currently safer to have this synchronous
|
|
// This guarantees we can immediately fail setDiskRetryMode if we need to
|
|
try {
|
|
Sender.OS_PROVIDES_FILE_PROTECTION = fs.existsSync(Sender.ICACLS_PATH);
|
|
}
|
|
catch (e) { }
|
|
if (!Sender.OS_PROVIDES_FILE_PROTECTION) {
|
|
Logging.warn(Sender.TAG, "Could not find ICACLS in expected location! This is necessary to use disk retry mode on Windows.");
|
|
}
|
|
}
|
|
else {
|
|
// chmod works everywhere else
|
|
Sender.OS_PROVIDES_FILE_PROTECTION = true;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Enable or disable offline mode
|
|
*/
|
|
Sender.prototype.setDiskRetryMode = function (value, resendInterval, maxBytesOnDisk) {
|
|
this._enableDiskRetryMode = Sender.OS_PROVIDES_FILE_PROTECTION && value;
|
|
if (typeof resendInterval === 'number' && resendInterval >= 0) {
|
|
this._resendInterval = Math.floor(resendInterval);
|
|
}
|
|
if (typeof maxBytesOnDisk === 'number' && maxBytesOnDisk >= 0) {
|
|
this._maxBytesOnDisk = Math.floor(maxBytesOnDisk);
|
|
}
|
|
if (value && !Sender.OS_PROVIDES_FILE_PROTECTION) {
|
|
this._enableDiskRetryMode = false;
|
|
Logging.warn(Sender.TAG, "Ignoring request to enable disk retry mode. Sufficient file protection capabilities were not detected.");
|
|
}
|
|
};
|
|
Sender.prototype.send = function (payload, callback) {
|
|
var _this = this;
|
|
var endpointUrl = this._config.endpointUrl;
|
|
// todo: investigate specifying an agent here: https://nodejs.org/api/http.html#http_class_http_agent
|
|
var options = {
|
|
method: "POST",
|
|
withCredentials: false,
|
|
headers: {
|
|
"Content-Type": "application/x-json-stream"
|
|
}
|
|
};
|
|
zlib.gzip(payload, function (err, buffer) {
|
|
var dataToSend = buffer;
|
|
if (err) {
|
|
Logging.warn(err);
|
|
dataToSend = payload; // something went wrong so send without gzip
|
|
options.headers["Content-Length"] = payload.length.toString();
|
|
}
|
|
else {
|
|
options.headers["Content-Encoding"] = "gzip";
|
|
options.headers["Content-Length"] = buffer.length;
|
|
}
|
|
Logging.info(Sender.TAG, options);
|
|
// Ensure this request is not captured by auto-collection.
|
|
options[AutoCollectHttpDependencies.disableCollectionRequestOption] = true;
|
|
var requestCallback = function (res) {
|
|
res.setEncoding("utf-8");
|
|
//returns empty if the data is accepted
|
|
var responseString = "";
|
|
res.on("data", function (data) {
|
|
responseString += data;
|
|
});
|
|
res.on("end", function () {
|
|
_this._numConsecutiveFailures = 0;
|
|
Logging.info(Sender.TAG, responseString);
|
|
if (typeof _this._onSuccess === "function") {
|
|
_this._onSuccess(responseString);
|
|
}
|
|
if (typeof callback === "function") {
|
|
callback(responseString);
|
|
}
|
|
if (_this._enableDiskRetryMode) {
|
|
// try to send any cached events if the user is back online
|
|
if (res.statusCode === 200) {
|
|
if (!_this._resendTimer) {
|
|
_this._resendTimer = setTimeout(function () {
|
|
_this._resendTimer = null;
|
|
_this._sendFirstFileOnDisk();
|
|
}, _this._resendInterval);
|
|
_this._resendTimer.unref();
|
|
}
|
|
// store to disk in case of burst throttling
|
|
}
|
|
else if (res.statusCode === 408 ||
|
|
res.statusCode === 429 ||
|
|
res.statusCode === 439 ||
|
|
res.statusCode === 500 ||
|
|
res.statusCode === 503) {
|
|
// TODO: Do not support partial success (206) until _sendFirstFileOnDisk checks payload age
|
|
_this._storeToDisk(payload);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
var req = Util.makeRequest(_this._config, endpointUrl, options, requestCallback);
|
|
req.on("error", function (error) {
|
|
// todo: handle error codes better (group to recoverable/non-recoverable and persist)
|
|
_this._numConsecutiveFailures++;
|
|
// Only use warn level if retries are disabled or we've had some number of consecutive failures sending data
|
|
// This is because warn level is printed in the console by default, and we don't want to be noisy for transient and self-recovering errors
|
|
// Continue informing on each failure if verbose logging is being used
|
|
if (!_this._enableDiskRetryMode || _this._numConsecutiveFailures > 0 && _this._numConsecutiveFailures % Sender.MAX_CONNECTION_FAILURES_BEFORE_WARN === 0) {
|
|
var notice = "Ingestion endpoint could not be reached. This batch of telemetry items has been lost. Use Disk Retry Caching to enable resending of failed telemetry. Error:";
|
|
if (_this._enableDiskRetryMode) {
|
|
notice = "Ingestion endpoint could not be reached " + _this._numConsecutiveFailures + " consecutive times. There may be resulting telemetry loss. Most recent error:";
|
|
}
|
|
Logging.warn(Sender.TAG, notice, error);
|
|
}
|
|
else {
|
|
var notice = "Transient failure to reach ingestion endpoint. This batch of telemetry items will be retried. Error:";
|
|
Logging.info(Sender.TAG, notice, error);
|
|
}
|
|
_this._onErrorHelper(error);
|
|
if (typeof callback === "function") {
|
|
var errorMessage = "error sending telemetry";
|
|
if (error && (typeof error.toString === "function")) {
|
|
errorMessage = error.toString();
|
|
}
|
|
callback(errorMessage);
|
|
}
|
|
if (_this._enableDiskRetryMode) {
|
|
_this._storeToDisk(payload);
|
|
}
|
|
});
|
|
req.write(dataToSend);
|
|
req.end();
|
|
});
|
|
};
|
|
Sender.prototype.saveOnCrash = function (payload) {
|
|
if (this._enableDiskRetryMode) {
|
|
this._storeToDiskSync(payload);
|
|
}
|
|
};
|
|
Sender.prototype._runICACLS = function (args, callback) {
|
|
var aclProc = child_process.spawn(Sender.ICACLS_PATH, args, { windowsHide: true });
|
|
aclProc.on("error", function (e) { return callback(e); });
|
|
aclProc.on("close", function (code, signal) {
|
|
return callback(code === 0 ? null : new Error("Setting ACL restrictions did not succeed (ICACLS returned code " + code + ")"));
|
|
});
|
|
};
|
|
Sender.prototype._runICACLSSync = function (args) {
|
|
// Some very old versions of Node (< 0.11) don't have this
|
|
if (child_process.spawnSync) {
|
|
var aclProc = child_process.spawnSync(Sender.ICACLS_PATH, args, { windowsHide: true });
|
|
if (aclProc.error) {
|
|
throw aclProc.error;
|
|
}
|
|
else if (aclProc.status !== 0) {
|
|
throw new Error("Setting ACL restrictions did not succeed (ICACLS returned code " + aclProc.status + ")");
|
|
}
|
|
}
|
|
else {
|
|
throw new Error("Could not synchronously call ICACLS under current version of Node.js");
|
|
}
|
|
};
|
|
Sender.prototype._getACLIdentity = function (callback) {
|
|
if (Sender.ACL_IDENTITY) {
|
|
return callback(null, Sender.ACL_IDENTITY);
|
|
}
|
|
var psProc = child_process.spawn(Sender.POWERSHELL_PATH, ["-Command", "[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"], {
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'] // Needed to prevent hanging on Win 7
|
|
});
|
|
var data = "";
|
|
psProc.stdout.on("data", function (d) { return data += d; });
|
|
psProc.on("error", function (e) { return callback(e, null); });
|
|
psProc.on("close", function (code, signal) {
|
|
Sender.ACL_IDENTITY = data && data.trim();
|
|
return callback(code === 0 ? null : new Error("Getting ACL identity did not succeed (PS returned code " + code + ")"), Sender.ACL_IDENTITY);
|
|
});
|
|
};
|
|
Sender.prototype._getACLIdentitySync = function () {
|
|
if (Sender.ACL_IDENTITY) {
|
|
return Sender.ACL_IDENTITY;
|
|
}
|
|
// Some very old versions of Node (< 0.11) don't have this
|
|
if (child_process.spawnSync) {
|
|
var psProc = child_process.spawnSync(Sender.POWERSHELL_PATH, ["-Command", "[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"], {
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'] // Needed to prevent hanging on Win 7
|
|
});
|
|
if (psProc.error) {
|
|
throw psProc.error;
|
|
}
|
|
else if (psProc.status !== 0) {
|
|
throw new Error("Getting ACL identity did not succeed (PS returned code " + psProc.status + ")");
|
|
}
|
|
Sender.ACL_IDENTITY = psProc.stdout && psProc.stdout.toString().trim();
|
|
return Sender.ACL_IDENTITY;
|
|
}
|
|
else {
|
|
throw new Error("Could not synchronously get ACL identity under current version of Node.js");
|
|
}
|
|
};
|
|
Sender.prototype._getACLArguments = function (directory, identity) {
|
|
return [directory,
|
|
"/grant", "*S-1-5-32-544:(OI)(CI)F",
|
|
"/grant", identity + ":(OI)(CI)F",
|
|
"/inheritance:r"]; // Remove all inherited permissions
|
|
};
|
|
Sender.prototype._applyACLRules = function (directory, callback) {
|
|
var _this = this;
|
|
if (!Sender.USE_ICACLS) {
|
|
return callback(null);
|
|
}
|
|
// For performance, only run ACL rules if we haven't already during this session
|
|
if (Sender.ACLED_DIRECTORIES[directory] === undefined) {
|
|
// Avoid multiple calls race condition by setting ACLED_DIRECTORIES to false for this directory immediately
|
|
// If batches are being failed faster than the processes spawned below return, some data won't be stored to disk
|
|
// This is better than the alternative of potentially infinitely spawned processes
|
|
Sender.ACLED_DIRECTORIES[directory] = false;
|
|
// Restrict this directory to only current user and administrator access
|
|
this._getACLIdentity(function (err, identity) {
|
|
if (err) {
|
|
Sender.ACLED_DIRECTORIES[directory] = false; // false is used to cache failed (vs undefined which is "not yet tried")
|
|
return callback(err);
|
|
}
|
|
else {
|
|
_this._runICACLS(_this._getACLArguments(directory, identity), function (err) {
|
|
Sender.ACLED_DIRECTORIES[directory] = !err;
|
|
return callback(err);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
else {
|
|
return callback(Sender.ACLED_DIRECTORIES[directory] ? null :
|
|
new Error("Setting ACL restrictions did not succeed (cached result)"));
|
|
}
|
|
};
|
|
Sender.prototype._applyACLRulesSync = function (directory) {
|
|
if (Sender.USE_ICACLS) {
|
|
// For performance, only run ACL rules if we haven't already during this session
|
|
if (Sender.ACLED_DIRECTORIES[directory] === undefined) {
|
|
this._runICACLSSync(this._getACLArguments(directory, this._getACLIdentitySync()));
|
|
Sender.ACLED_DIRECTORIES[directory] = true; // If we get here, it succeeded. _runIACLSSync will throw on failures
|
|
return;
|
|
}
|
|
else if (!Sender.ACLED_DIRECTORIES[directory]) {
|
|
throw new Error("Setting ACL restrictions did not succeed (cached result)");
|
|
}
|
|
}
|
|
};
|
|
Sender.prototype._confirmDirExists = function (directory, callback) {
|
|
var _this = this;
|
|
fs.lstat(directory, function (err, stats) {
|
|
if (err && err.code === 'ENOENT') {
|
|
fs.mkdir(directory, function (err) {
|
|
if (err && err.code !== 'EEXIST') {
|
|
callback(err);
|
|
}
|
|
else {
|
|
_this._applyACLRules(directory, callback);
|
|
}
|
|
});
|
|
}
|
|
else if (!err && stats.isDirectory()) {
|
|
_this._applyACLRules(directory, callback);
|
|
}
|
|
else {
|
|
callback(err || new Error("Path existed but was not a directory"));
|
|
}
|
|
});
|
|
};
|
|
/**
|
|
* Computes the size (in bytes) of all files in a directory at the root level. Asynchronously.
|
|
*/
|
|
Sender.prototype._getShallowDirectorySize = function (directory, callback) {
|
|
// Get the directory listing
|
|
fs.readdir(directory, function (err, files) {
|
|
if (err) {
|
|
return callback(err, -1);
|
|
}
|
|
var error = null;
|
|
var totalSize = 0;
|
|
var count = 0;
|
|
if (files.length === 0) {
|
|
callback(null, 0);
|
|
return;
|
|
}
|
|
// Query all file sizes
|
|
for (var i = 0; i < files.length; i++) {
|
|
fs.stat(path.join(directory, files[i]), function (err, fileStats) {
|
|
count++;
|
|
if (err) {
|
|
error = err;
|
|
}
|
|
else {
|
|
if (fileStats.isFile()) {
|
|
totalSize += fileStats.size;
|
|
}
|
|
}
|
|
if (count === files.length) {
|
|
// Did we get an error?
|
|
if (error) {
|
|
callback(error, -1);
|
|
}
|
|
else {
|
|
callback(error, totalSize);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
/**
|
|
* Computes the size (in bytes) of all files in a directory at the root level. Synchronously.
|
|
*/
|
|
Sender.prototype._getShallowDirectorySizeSync = function (directory) {
|
|
var files = fs.readdirSync(directory);
|
|
var totalSize = 0;
|
|
for (var i = 0; i < files.length; i++) {
|
|
totalSize += fs.statSync(path.join(directory, files[i])).size;
|
|
}
|
|
return totalSize;
|
|
};
|
|
/**
|
|
* Stores the payload as a json file on disk in the temp directory
|
|
*/
|
|
Sender.prototype._storeToDisk = function (payload) {
|
|
var _this = this;
|
|
// tmpdir is /tmp for *nix and USERDIR/AppData/Local/Temp for Windows
|
|
var directory = path.join(os.tmpdir(), Sender.TEMPDIR_PREFIX + this._config.instrumentationKey);
|
|
// This will create the dir if it does not exist
|
|
// Default permissions on *nix are directory listing from other users but no file creations
|
|
Logging.info(Sender.TAG, "Checking existence of data storage directory: " + directory);
|
|
this._confirmDirExists(directory, function (error) {
|
|
if (error) {
|
|
Logging.warn(Sender.TAG, "Error while checking/creating directory: " + (error && error.message));
|
|
_this._onErrorHelper(error);
|
|
return;
|
|
}
|
|
_this._getShallowDirectorySize(directory, function (err, size) {
|
|
if (err || size < 0) {
|
|
Logging.warn(Sender.TAG, "Error while checking directory size: " + (err && err.message));
|
|
_this._onErrorHelper(err);
|
|
return;
|
|
}
|
|
else if (size > _this._maxBytesOnDisk) {
|
|
Logging.warn(Sender.TAG, "Not saving data due to max size limit being met. Directory size in bytes is: " + size);
|
|
return;
|
|
}
|
|
//create file - file name for now is the timestamp, a better approach would be a UUID but that
|
|
//would require an external dependency
|
|
var fileName = new Date().getTime() + ".ai.json";
|
|
var fileFullPath = path.join(directory, fileName);
|
|
// Mode 600 is w/r for creator and no read access for others (only applies on *nix)
|
|
// For Windows, ACL rules are applied to the entire directory (see logic in _confirmDirExists and _applyACLRules)
|
|
Logging.info(Sender.TAG, "saving data to disk at: " + fileFullPath);
|
|
fs.writeFile(fileFullPath, payload, { mode: 384 }, function (error) { return _this._onErrorHelper(error); });
|
|
});
|
|
});
|
|
};
|
|
/**
|
|
* Stores the payload as a json file on disk using sync file operations
|
|
* this is used when storing data before crashes
|
|
*/
|
|
Sender.prototype._storeToDiskSync = function (payload) {
|
|
// tmpdir is /tmp for *nix and USERDIR/AppData/Local/Temp for Windows
|
|
var directory = path.join(os.tmpdir(), Sender.TEMPDIR_PREFIX + this._config.instrumentationKey);
|
|
try {
|
|
Logging.info(Sender.TAG, "Checking existence of data storage directory: " + directory);
|
|
if (!fs.existsSync(directory)) {
|
|
fs.mkdirSync(directory);
|
|
}
|
|
// Make sure permissions are valid
|
|
this._applyACLRulesSync(directory);
|
|
var dirSize = this._getShallowDirectorySizeSync(directory);
|
|
if (dirSize > this._maxBytesOnDisk) {
|
|
Logging.info(Sender.TAG, "Not saving data due to max size limit being met. Directory size in bytes is: " + dirSize);
|
|
return;
|
|
}
|
|
//create file - file name for now is the timestamp, a better approach would be a UUID but that
|
|
//would require an external dependency
|
|
var fileName = new Date().getTime() + ".ai.json";
|
|
var fileFullPath = path.join(directory, fileName);
|
|
// Mode 600 is w/r for creator and no access for anyone else (only applies on *nix)
|
|
Logging.info(Sender.TAG, "saving data before crash to disk at: " + fileFullPath);
|
|
fs.writeFileSync(fileFullPath, payload, { mode: 384 });
|
|
}
|
|
catch (error) {
|
|
Logging.warn(Sender.TAG, "Error while saving data to disk: " + (error && error.message));
|
|
this._onErrorHelper(error);
|
|
}
|
|
};
|
|
/**
|
|
* Check for temp telemetry files
|
|
* reads the first file if exist, deletes it and tries to send its load
|
|
*/
|
|
Sender.prototype._sendFirstFileOnDisk = function () {
|
|
var _this = this;
|
|
var tempDir = path.join(os.tmpdir(), Sender.TEMPDIR_PREFIX + this._config.instrumentationKey);
|
|
fs.exists(tempDir, function (exists) {
|
|
if (exists) {
|
|
fs.readdir(tempDir, function (error, files) {
|
|
if (!error) {
|
|
files = files.filter(function (f) { return path.basename(f).indexOf(".ai.json") > -1; });
|
|
if (files.length > 0) {
|
|
var firstFile = files[0];
|
|
var filePath = path.join(tempDir, firstFile);
|
|
fs.readFile(filePath, function (error, payload) {
|
|
if (!error) {
|
|
// delete the file first to prevent double sending
|
|
fs.unlink(filePath, function (error) {
|
|
if (!error) {
|
|
_this.send(payload);
|
|
}
|
|
else {
|
|
_this._onErrorHelper(error);
|
|
}
|
|
});
|
|
}
|
|
else {
|
|
_this._onErrorHelper(error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
else {
|
|
_this._onErrorHelper(error);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
Sender.prototype._onErrorHelper = function (error) {
|
|
if (typeof this._onError === "function") {
|
|
this._onError(error);
|
|
}
|
|
};
|
|
Sender.TAG = "Sender";
|
|
Sender.ICACLS_PATH = process.env.systemdrive + "/windows/system32/icacls.exe";
|
|
Sender.POWERSHELL_PATH = process.env.systemdrive + "/windows/system32/windowspowershell/v1.0/powershell.exe";
|
|
Sender.ACLED_DIRECTORIES = {};
|
|
Sender.ACL_IDENTITY = null;
|
|
// the amount of time the SDK will wait between resending cached data, this buffer is to avoid any throttling from the service side
|
|
Sender.WAIT_BETWEEN_RESEND = 60 * 1000;
|
|
Sender.MAX_BYTES_ON_DISK = 50 * 1000 * 1000;
|
|
Sender.MAX_CONNECTION_FAILURES_BEFORE_WARN = 5;
|
|
Sender.TEMPDIR_PREFIX = "appInsights-node";
|
|
Sender.OS_PROVIDES_FILE_PROTECTION = false;
|
|
Sender.USE_ICACLS = os.type() === "Windows_NT";
|
|
return Sender;
|
|
}());
|
|
module.exports = Sender;
|
|
//# sourceMappingURL=Sender.js.map
|