Repository: microsoft/ApplicationInsights-node.js
Pull request #752 (2021-04-14 23:15:20)

Adding 206 response handling from Breeze 
Adding task to clean old files


Patch:
diff --git a/Declarations/Contracts/Constants.ts b/Declarations/Contracts/Constants.ts
index 5cad943..e76d014 100644
--- a/Declarations/Contracts/Constants.ts
+++ b/Declarations/Contracts/Constants.ts
@@ -1,8 +1,26 @@
 import { Domain, EventData, ExceptionData, MessageData, MetricData, PageViewData, RemoteDependencyData, RequestData } from "./Generated";
 
+/**
+ * Breeze response definition.
+ */
+export interface BreezeResponse {
+    itemsReceived: number;
+    itemsAccepted: number;
+    errors: BreezeError[];
+}
+
+/**
+* Breeze errors.
+*/
+export interface BreezeError {
+    index: number;
+    statusCode: number;
+    message: string;
+}
+
 export class RemoteDependencyDataConstants {
-    public static TYPE_HTTP:string = "Http";
-    public static TYPE_AI:string = "Http (tracked component)";
+    public static TYPE_HTTP: string = "Http";
+    public static TYPE_AI: string = "Http (tracked component)";
 }
 
 export interface ISupportProperties extends Domain {
diff --git a/Library/Channel.ts b/Library/Channel.ts
index 6cdd1e3..8ac3df8 100644
--- a/Library/Channel.ts
+++ b/Library/Channel.ts
@@ -12,7 +12,7 @@ class Channel {
     protected _getBatchIntervalMs: () => number;
 
     public _sender: Sender;
-    public _buffer: string[];
+    public _buffer: Contracts.EnvelopeTelemetry[];
 
     constructor(isDisabled: () => boolean, getBatchSize: () => number, getBatchIntervalMs: () => number, sender: Sender) {
         this._buffer = [];
@@ -52,14 +52,8 @@ class Channel {
             return;
         }
 
-        // check if the incoming payload is too large, truncate if necessary
-        var payload: string = this._stringify(envelope);
-        if (typeof payload !== "string") {
-            return;
-        }
-
         // enqueue the payload
-        this._buffer.push(payload);
+        this._buffer.push(envelope);
 
         // flush if we would exceed the max-size limit by adding this item
         if (this._buffer.length >= this._getBatchSize()) {
@@ -82,17 +76,14 @@ class Channel {
     public triggerSend(isNodeCrashing: boolean, callback?: (v: string) => void) {
         let bufferIsEmpty = this._buffer.length < 1;
         if (!bufferIsEmpty) {
-            // compose an array of payloads
-            var batch = this._buffer.join("\n");
-
             // invoke send
             if (isNodeCrashing) {
-                this._sender.saveOnCrash(batch);
+                this._sender.saveOnCrash(this._buffer);
                 if (typeof callback === "function") {
                     callback("data saved on crash");
                 }
             } else {
-                this._sender.send(Buffer.from ? Buffer.from(batch) : new Buffer(batch), callback);
+                this._sender.send(this._buffer, callback);
             }
         }
 
@@ -100,21 +91,13 @@ class Channel {
         this._lastSend = +new Date;
 
         // clear buffer
-        this._buffer.length = 0;
+        this._buffer = [];
         clearTimeout(this._timeoutHandle);
         this._timeoutHandle = null;
         if (bufferIsEmpty && typeof callback === "function") {
             callback("no data to send");
         }
     }
-
-    protected _stringify(envelope: Contracts.EnvelopeTelemetry) {
-        try {
-            return JSON.stringify(envelope);
-        } catch (error) {
-            Logging.warn("Failed to serialize payload", error, envelope);
-        }
-    }
 }
 
 export = Channel;
diff --git a/Library/Sender.ts b/Library/Sender.ts
index 5cf455c..b11d381 100644
--- a/Library/Sender.ts
+++ b/Library/Sender.ts
@@ -7,6 +7,7 @@ import child_process = require("child_process");
 
 import Logging = require("./Logging");
 import Config = require("./Config")
+import Contracts = require("../Declarations/Contracts");
 import AutoCollectHttpDependencies = require("../AutoCollection/HttpDependencies");
 import Util = require("./Util");
 
@@ -18,9 +19,11 @@ class Sender {
     private static ACL_IDENTITY: string = null;
 
     // the amount of time the SDK will wait between resending cached data, this buffer is to avoid any throttling from the service side
-    public static WAIT_BETWEEN_RESEND = 60 * 1000;
-    public static MAX_BYTES_ON_DISK = 50 * 1000 * 1000;
+    public static WAIT_BETWEEN_RESEND = 60 * 1000; // 1 minute
+    public static MAX_BYTES_ON_DISK = 50 * 1024 * 1024; // 50 mb
     public static MAX_CONNECTION_FAILURES_BEFORE_WARN = 5;
+    public static CLEANUP_TIMEOUT = 60 * 60 * 1000; // 1 hour
+    public static FILE_RETEMPTION_PERIOD = 7 * 24 * 60 * 60 * 1000; // 7 days
     public static TEMPDIR_PREFIX: string = "appInsights-node";
     public static OS_PROVIDES_FILE_PROTECTION = false;
     public static USE_ICACLS = os.type() === "Windows_NT";
@@ -32,7 +35,9 @@ class Sender {
     private _numConsecutiveFailures: number;
     private _numConsecutiveRedirects: number;
     private _resendTimer: NodeJS.Timer | null;
+    private _fileCleanupTimer: NodeJS.Timer;
     private _redirectedHost: string = null;
+    private _tempDir: string;
     protected _resendInterval: number;
     protected _maxBytesOnDisk: number;
 
@@ -46,6 +51,9 @@ class Sender {
         this._numConsecutiveFailures = 0;
         this._numConsecutiveRedirects = 0;
         this._resendTimer = null;
+        this._fileCleanupTimer = null;
+        // tmpdir is /tmp for *nix and USERDIR/AppData/Local/Temp for Windows
+        this._tempDir = path.join(os.tmpdir(), Sender.TEMPDIR_PREFIX + this._config.instrumentationKey);
 
         if (!Sender.OS_PROVIDES_FILE_PROTECTION) {
             // Node's chmod levels do not appropriately restrict file access on Windows
@@ -83,148 +91,190 @@ class Sender {
             this._enableDiskRetryMode = false;
             Logging.warn(Sender.TAG, "Ignoring request to enable disk retry mode. Sufficient file protection capabilities were not detected.")
         }
+        if (this._enableDiskRetryMode) {
+            // Starts file cleanup task
+            if(!this._fileCleanupTimer){
+                this._fileCleanupTimer = setTimeout(() => { this._fileCleanupTask(); }, Sender.CLEANUP_TIMEOUT);
+                this._fileCleanupTimer.unref();
+            }
+        }
+        else {
+            if (this._fileCleanupTimer) {
+                clearTimeout(this._fileCleanupTimer);
+            }
+        }
     }
 
-    public send(payload: Buffer, callback?: (v: string) => void) {
-        var endpointUrl = this._redirectedHost || this._config.endpointUrl;
+    public send(envelopes: Contracts.EnvelopeTelemetry[], callback?: (v: string) => void) {
+        if (envelopes) {
+            var endpointUrl = this._redirectedHost || 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: <{ [key: string]: string }>{
-                "Content-Type": "application/x-json-stream"
-            }
-        };
+            // todo: investigate specifying an agent here: https://nodejs.org/api/http.html#http_class_http_agent
+            var options = {
+                method: "POST",
+                withCredentials: false,
+                headers: <{ [key: string]: string }>{
+                    "Content-Type": "application/x-json-stream"
+                }
+            };
 
-        zlib.gzip(payload, (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.toString();
+            let batch: string = "";
+
+            envelopes.forEach(envelope => {
+                var payload: string = this._stringify(envelope);
+                if (typeof payload !== "string") {
+                    return;
+                }
+                batch += payload + "\n";
+            });
+            // Remove last \n
+            if (batch.length > 0) {
+                batch = batch.substring(0, batch.length - 1);
             }
 
-            Logging.info(Sender.TAG, options);
+            let payload: Buffer = Buffer.from ? Buffer.from(batch) : new Buffer(batch);
 
-            // Ensure this request is not captured by auto-collection.
-            (<any>options)[AutoCollectHttpDependencies.disableCollectionRequestOption] = true;
+            zlib.gzip(payload, (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.toString();
+                }
 
-            var requestCallback = (res: http.ClientResponse) => {
-                res.setEncoding("utf-8");
+                Logging.info(Sender.TAG, options);
 
-                //returns empty if the data is accepted
-                var responseString = "";
-                res.on("data", (data: string) => {
-                    responseString += data;
-                });
+                // Ensure this request is not captured by auto-collection.
+                (<any>options)[AutoCollectHttpDependencies.disableCollectionRequestOption] = true;
 
-                res.on("end", () => {
-                    this._numConsecutiveFailures = 0;
-                    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(() => {
-                                    this._resendTimer = null;
-                                    this._sendFirstFileOnDisk()
-                                }, this._resendInterval);
-                                this._resendTimer.unref();
+                var requestCallback = (res: http.ClientResponse) => {
+                    res.setEncoding("utf-8");
+
+                    //returns empty if the data is accepted
+                    var responseString = "";
+                    res.on("data", (data: string) => {
+                        responseString += data;
+                    });
+
+                    res.on("end", () => {
+                        this._numConsecutiveFailures = 0;
+                        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(() => {
+                                        this._resendTimer = null;
+                                        this._sendFirstFileOnDisk()
+                                    }, this._resendInterval);
+                                    this._resendTimer.unref();
+                                }
+                            } else if (this._isRetriable(res.statusCode)) {
+                                // If response from Breeze
+                                try {
+                                    const breezeResponse = JSON.parse(responseString) as Contracts.BreezeResponse;
+                                    let filteredEnvelopes: Contracts.EnvelopeTelemetry[] = [];
+                                    breezeResponse.errors.forEach(error => {
+                                        if (this._isRetriable(error.statusCode)) {
+                                            filteredEnvelopes.push(envelopes[error.index]);
+                                        }
+                                    });
+                                    if (filteredEnvelopes.length > 0) {
+                                        this._storeToDisk(filteredEnvelopes);
+                                    }
+                                }
+                                catch (ex) {
+                                    this._storeToDisk(envelopes); // Retriable status code with not valid Breeze response
+                                }
                             }
-                            // store to disk in case of burst throttling
-                        } else if (
-                            res.statusCode === 408 || // Timeout
-                            res.statusCode === 429 || // Throttle
-                            res.statusCode === 439 || // Quota
-                            res.statusCode === 500 || // Server Error
-                            res.statusCode === 503) { // Service unavailable
-
-                            // TODO: Do not support partial success (206) until _sendFirstFileOnDisk checks payload age
-                            this._storeToDisk(payload);
                         }
-                    }
-                    // Redirect handling
-                    if (res.statusCode === 301 || // Moved Permanently
-                        res.statusCode === 308) { // Permanent Redirect
-                        // Try to get redirect header
-                        const locationHeader = res.headers["location"] ? res.headers["location"].toString() : null;
-                        if (locationHeader) {
-                            this._handleRedirect(locationHeader);
+                        // Redirect handling
+                        if (res.statusCode === 308) { // Permanent Redirect
+                            // Try to get redirect header
+                            const locationHeader = res.headers["location"] ? res.headers["location"].toString() : null;
+                            if (locationHeader) {
+                                this._handleRedirect(locationHeader);
+                            }
+                        }
+                        else {
+                            this._numConsecutiveRedirects = 0;
                         }
-                    }
-                    else {
-                        this._numConsecutiveRedirects = 0;
-                    }
 
-                    Logging.info(Sender.TAG, responseString);
-                    if (typeof this._onSuccess === "function") {
-                        this._onSuccess(responseString);
-                    }
+                        Logging.info(Sender.TAG, responseString);
+                        if (typeof this._onSuccess === "function") {
+                            this._onSuccess(responseString);
+                        }
 
-                    if (typeof callback === "function") {
-                        callback(responseString);
-                    }
-                });
-            };
+                        if (typeof callback === "function") {
+                            callback(responseString);
+                        }
+                    });
+                };
 
-            var req = Util.makeRequest(this._config, endpointUrl, options, requestCallback);
+                var req = Util.makeRequest(this._config, endpointUrl, options, requestCallback);
 
-            req.on("error", (error: Error) => {
-                // todo: handle error codes better (group to recoverable/non-recoverable and persist)
-                this._numConsecutiveFailures++;
+                req.on("error", (error: 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) {
-                    let 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:`;
+                    // 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) {
+                        let 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, Util.dumpObj(error));
+                    } else {
+                        let notice = "Transient failure to reach ingestion endpoint. This batch of telemetry items will be retried. Error:";
+                        Logging.info(Sender.TAG, notice, Util.dumpObj(error))
                     }
-                    Logging.warn(Sender.TAG, notice, error);
-                } else {
-                    let 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);
+                    this._onErrorHelper(error);
 
-                if (typeof callback === "function") {
-                    var errorMessage = "error sending telemetry";
-                    if (error && (typeof error.toString === "function")) {
-                        errorMessage = error.toString();
+                    if (typeof callback === "function") {
+                        if (error) {
+                            callback(Util.dumpObj(error));
+                        }
+                        callback("Error sending telemetry");
                     }
 
-                    callback(errorMessage);
-                }
+                    if (this._enableDiskRetryMode) {
+                        this._storeToDisk(envelopes);
+                    }
+                });
 
-                if (this._enableDiskRetryMode) {
-                    this._storeToDisk(payload);
-                }
+                req.write(dataToSend);
+                req.end();
             });
-
-            req.write(dataToSend);
-            req.end();
-        });
+        }
     }
 
-    public saveOnCrash(payload: string) {
+    public saveOnCrash(envelopes: Contracts.EnvelopeTelemetry[]) {
         if (this._enableDiskRetryMode) {
-            this._storeToDiskSync(payload);
+            this._storeToDiskSync(this._stringify(envelopes));
         }
     }
 
+    private _isRetriable(statusCode: number) {
+        return (
+            statusCode === 206 || // Retriable
+            statusCode === 408 || // Timeout
+            statusCode === 429 || // Throttle
+            statusCode === 439 || // Quota
+            statusCode === 500 || // Server Error
+            statusCode === 503 // Server Unavilable
+        );
+    }
+
     private _handleRedirect(location: string) {
         this._numConsecutiveRedirects++;
         // To prevent circular redirects
         if (this._numConsecutiveRedirects < 10) {
             this._redirectedHost = location;
         }
-        else {
-            //TODO: Add ?redirect=false to avoid further redirection when 2.1 endpoint available
-        }
     }
 
     private _runICACLS(args: string[], callback: (err: Error) => void) {
@@ -420,21 +470,18 @@ class Sender {
     /**
      * Stores the payload as a json file on disk in the temp directory
      */
-    private _storeToDisk(payload: any) {
-        // tmpdir is /tmp for *nix and USERDIR/AppData/Local/Temp for Windows
-        var directory = path.join(os.tmpdir(), Sender.TEMPDIR_PREFIX + this._config.instrumentationKey);
-
+    private _storeToDisk(envelopes: Contracts.EnvelopeTelemetry[]) {
         // 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, (error) => {
+        Logging.info(Sender.TAG, "Checking existence of data storage directory: " + this._tempDir);
+        this._confirmDirExists(this._tempDir, (error) => {
             if (error) {
                 Logging.warn(Sender.TAG, "Error while checking/creating directory: " + (error && error.message));
                 this._onErrorHelper(error);
                 return;
             }
 
-            this._getShallowDirectorySize(directory, (err, size) => {
+            this._getShallowDirectorySize(this._tempDir, (err, size) => {
                 if (err || size < 0) {
                     Logging.warn(Sender.TAG, "Error while checking directory size: " + (err && err.message));
                     this._onErrorHelper(err);
@@ -447,12 +494,12 @@ class Sender {
                 //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);
+                var fileFullPath = path.join(this._tempDir, 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: 0o600 }, (error) => this._onErrorHelper(error));
+                fs.writeFile(fileFullPath, this._stringify(envelopes), { mode: 0o600 }, (error) => this._onErrorHelper(error));
             });
         });
     }
@@ -462,19 +509,16 @@ class Sender {
      * this is used when storing data before crashes
      */
     private _storeToDiskSync(payload: any) {
-        // 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);
+            Logging.info(Sender.TAG, "Checking existence of data storage directory: " + this._tempDir);
+            if (!fs.existsSync(this._tempDir)) {
+                fs.mkdirSync(this._tempDir);
             }
 
             // Make sure permissions are valid
-            this._applyACLRulesSync(directory);
+            this._applyACLRulesSync(this._tempDir);
 
-            let dirSize = this._getShallowDirectorySizeSync(directory);
+            let dirSize = this._getShallowDirectorySizeSync(this._tempDir);
             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;
@@ -483,7 +527,7 @@ class Sender {
             //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);
+            var fileFullPath = path.join(this._tempDir, 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);
@@ -500,22 +544,27 @@ class Sender {
      * reads the first file if exist, deletes it and tries to send its load
      */
     private _sendFirstFileOnDisk(): void {
-        var tempDir = path.join(os.tmpdir(), Sender.TEMPDIR_PREFIX + this._config.instrumentationKey);
 
-        fs.exists(tempDir, (exists: boolean) => {
+        fs.exists(this._tempDir, (exists: boolean) => {
             if (exists) {
-                fs.readdir(tempDir, (error, files) => {
+                fs.readdir(this._tempDir, (error, files) => {
                     if (!error) {
                         files = files.filter(f => path.basename(f).indexOf(".ai.json") > -1);
                         if (files.length > 0) {
                             var firstFile = files[0];
-                            var filePath = path.join(tempDir, firstFile);
-                            fs.readFile(filePath, (error, payload) => {
+                            var filePath = path.join(this._tempDir, firstFile);
+                            fs.readFile(filePath, (error, buffer) => {
                                 if (!error) {
                                     // delete the file first to prevent double sending
                                     fs.unlink(filePath, (error) => {
                                         if (!error) {
-                                            this.send(payload);
+                                            try {
+                                                let envelopes: Contracts.EnvelopeTelemetry[] = JSON.parse(buffer.toString());
+                                                this.send(envelopes);
+                                            }
+                                            catch (error) {
+                                                Logging.warn("Failed to read persisted file", error);
+                                            }
                                         } else {
                                             this._onErrorHelper(error);
                                         }
@@ -538,6 +587,44 @@ class Sender {
             this._onError(error);
         }
     }
+
+    private _stringify(payload: any) {
+        try {
+            return JSON.stringify(payload);
+        } catch (error) {
+            Logging.warn("Failed to serialize payload", error, payload);
+        }
+    }
+
+    private _fileCleanupTask() {
+        fs.exists(this._tempDir, (exists: boolean) => {
+            if (exists) {
+                fs.readdir(this._tempDir, (error, files) => {
+                    if (!error) {
+                        files = files.filter(f => path.basename(f).indexOf(".ai.json") > -1);
+                        if (files.length > 0) {
+
+                            files.forEach(file => {
+                                // Check expiration
+                                let fileCreationDate: Date = new Date(parseInt(file.split(".ai.json")[0]));
+                                let expired = new Date(+(new Date()) - Sender.FILE_RETEMPTION_PERIOD) > fileCreationDate;
+                                if (expired) {
+                                    var filePath = path.join(this._tempDir, file);
+                                    fs.unlink(filePath, (error) => {
+                                        if (error) {
+                                            this._onErrorHelper(error);
+                                        }
+                                    });
+                                }
+                            });
+                        }
+                    } else {
+                        this._onErrorHelper(error);
+                    }
+                });
+            }
+        });
+    }
 }
 
 export = Sender;
diff --git a/Library/Util.ts b/Library/Util.ts
index edce089..79b9a75 100644
--- a/Library/Util.ts
+++ b/Library/Util.ts
@@ -41,8 +41,8 @@ class Util {
     /**
      * helper method to trim strings (IE8 does not implement String.prototype.trim)
      */
-    public static trim(str:string):string {
-        if(typeof str === "string") {
+    public static trim(str: string): string {
+        if (typeof str === "string") {
             return str.replace(/^\s+|\s+$/g, "");
         } else {
             return "";
@@ -116,14 +116,14 @@ class Util {
     /**
      * Check if an object is of type Array
      */
-    public static isArray(obj:any):boolean {
+    public static isArray(obj: any): boolean {
         return Object.prototype.toString.call(obj) === "[object Array]";
     }
 
     /**
      * Check if an object is of type Error
      */
-    public static isError(obj:any):boolean {
+    public static isError(obj: any): boolean {
         return obj instanceof Error;
     }
 
@@ -135,14 +135,14 @@ class Util {
     /**
      * Check if an object is of type Date
      */
-    public static isDate(obj:any):boolean {
+    public static isDate(obj: any): boolean {
         return Object.prototype.toString.call(obj) === "[object Date]";
     }
 
     /**
      * Convert ms to c# time span format
      */
-    public static msToTimeSpan(totalms:number):string {
+    public static msToTimeSpan(totalms: number): string {
         if (isNaN(totalms) || totalms < 0) {
             totalms = 0;
         }
@@ -194,7 +194,7 @@ class Util {
      * Validate that an object is of type { [key: string]: string }
      */
     public static validateStringMap(obj: any): { [key: string]: string } {
-        if(typeof obj !== "object") {
+        if (typeof obj !== "object") {
             Logging.info("Invalid properties dropped from payload");
             return;
         }
@@ -242,7 +242,7 @@ class Util {
         }
 
         for (let i = 0; i < excludedDomains.length; i++) {
-            let regex = new RegExp(excludedDomains[i].replace(/\./g,"\.").replace(/\*/g,".*"));
+            let regex = new RegExp(excludedDomains[i].replace(/\./g, "\.").replace(/\*/g, ".*"));
             if (regex.test(url.parse(requestUrl).hostname)) {
                 return false;
             }
@@ -255,7 +255,7 @@ class Util {
         const contextHeaders = response.headers && response.headers[RequestResponseHeaders.requestContextHeader];
         if (contextHeaders) {
             const keyValues = (<any>contextHeaders).split(",");
-            for(let i = 0; i < keyValues.length; ++i) {
+            for (let i = 0; i < keyValues.length; ++i) {
                 const keyValue = keyValues[i].split("=");
                 if (keyValue.length == 2 && keyValue[0] == key) {
                     return keyValue[1];
@@ -286,7 +286,8 @@ class Util {
         }
 
         var requestUrlParsed = url.parse(requestUrl);
-        var options = {...requestOptions,
+        var options = {
+            ...requestOptions,
             host: requestUrlParsed.hostname,
             port: requestUrlParsed.port,
             path: requestUrlParsed.pathname,
@@ -312,11 +313,13 @@ class Util {
                 Logging.info("Proxies that use HTTPS are not supported");
                 proxyUrl = undefined;
             } else {
-                options = {...options,
+                options = {
+                    ...options,
                     host: proxyUrlParsed.hostname,
                     port: proxyUrlParsed.port || "80",
                     path: requestUrl,
-                    headers: {...options.headers,
+                    headers: {
+                        ...options.headers,
                         Host: requestUrlParsed.hostname,
                     },
                 };
@@ -369,10 +372,25 @@ class Util {
         }
     }
 
+    /**
+     * Returns string representation of an object suitable for diagnostics logging.
+     */
+    public static dumpObj(object: any): string {
+        const objectTypeDump: string = Object["prototype"].toString.call(object);
+        let propertyValueDump: string = "";
+        if (objectTypeDump === "[object Error]") {
+            propertyValueDump = "{ stack: '" + object.stack + "', message: '" + object.message + "', name: '" + object.name + "'";
+        } else {
+            propertyValueDump = JSON.stringify(object);
+        }
+
+        return objectTypeDump + propertyValueDump;
+    }
+
     private static addCorrelationIdHeaderFromString(client: TelemetryClient, response: http.ClientRequest | http.ServerResponse, correlationHeader: string) {
         const components = correlationHeader.split(",");
         const key = `${RequestResponseHeaders.requestContextSourceKey}=`;
-        const found = components.some(value => value.substring(0,key.length) === key);
+        const found = components.some(value => value.substring(0, key.length) === key);
 
         if (!found) {
             response.setHeader(


Test patch:
diff --git a/Tests/Library/Channel.tests.ts b/Tests/Library/Channel.tests.ts
index 718789c..ab055d2 100644
--- a/Tests/Library/Channel.tests.ts
+++ b/Tests/Library/Channel.tests.ts
@@ -25,7 +25,7 @@ describe("Library/Channel", () => {
     var sendSpy = sinon.spy(sender, "send");
     var saveSpy = sinon.spy(sender, "saveOnCrash");
 
-    var channel:ChannelMock;
+    var channel: ChannelMock;
     var config: any;
     var clock: any;
     before(() => clock = sinon.useFakeTimers());
@@ -55,7 +55,7 @@ describe("Library/Channel", () => {
             channel.send(testEnvelope);
             clock.tick(config.batchInterval);
             assert.ok(sendSpy.calledOnce);
-            assert.equal(sendSpy.firstCall.args[0].toString(), JSON.stringify(testEnvelope));
+            assert.equal(sendSpy.firstCall.args[0][0], testEnvelope);
         });
 
         it("should do nothing if disabled", () => {
@@ -74,16 +74,6 @@ describe("Library/Channel", () => {
             warnStub.restore();
         });
 
-        it("should not crash JSON.stringify", () => {
-            var a = <any>{b: null};
-            a.b = a;
-
-            var warnStub = sinon.stub(console, "warn");
-            assert.doesNotThrow(() => channel.send(a));
-            assert.ok(warnStub.calledOnce);
-            warnStub.restore();
-        });
-
         it("should flush the buffer when full", () => {
             for (var i = 0; i < config.batchSize; i++) {
                 channel.send(testEnvelope);
@@ -96,7 +86,7 @@ describe("Library/Channel", () => {
         it("should add the payload to the buffer", () => {
             channel.send(testEnvelope);
             assert.ok(channel.getBuffer().length === 1);
-            assert.ok(channel.getBuffer()[0] === JSON.stringify(testEnvelope));
+            assert.ok(channel.getBuffer()[0] === testEnvelope);
         });
 
         it("should start the timer if not started", () => {
@@ -137,17 +127,6 @@ describe("Library/Channel", () => {
             assert.ok(!channel.getTimeoutHandle());
         });
 
-        it("should format X-JSON by default", () => {
-            var first: any = { "first": true };
-            var second: any = { "second": true };
-            channel.send(first);
-            channel.send(second);
-            channel.triggerSend(true);
-            assert.ok(sendSpy.notCalled);
-            assert.ok(saveSpy.calledOnce);
-            assert.ok(saveSpy.calledWith(JSON.stringify(first) + "\n" + JSON.stringify(second)))
-        });
-
         it("should not send if empty", () => {
             channel.triggerSend(false);
             assert.ok(sendSpy.notCalled);
diff --git a/Tests/Library/Client.tests.ts b/Tests/Library/Client.tests.ts
index f1fa53d..1af5ac0 100644
--- a/Tests/Library/Client.tests.ts
+++ b/Tests/Library/Client.tests.ts
@@ -919,7 +919,8 @@ describe("Library/TelemetryClient", () => {
             triggerStub.restore();
 
             // fake something in the buffer
-            client.channel._buffer.push("");
+            var testEnvelope = new Contracts.Envelope();
+            client.channel._buffer.push(testEnvelope);
             client.flush({ isAppCrashing: true });
 
             assert.ok(saveOnCrashStub.calledOnce);
diff --git a/Tests/Library/Sender.tests.ts b/Tests/Library/Sender.tests.ts
index 414f317..8799dd8 100644
--- a/Tests/Library/Sender.tests.ts
+++ b/Tests/Library/Sender.tests.ts
@@ -1,10 +1,12 @@
 import assert = require("assert");
+import fs = require("fs");
 import sinon = require("sinon");
 import nock = require("nock");
 
 import Sender = require("../../Library/Sender");
 import Config = require("../../Library/Config");
 import Constants = require("../../Declarations/Constants");
+import Contracts = require("../../Declarations/Contracts");
 
 class SenderMock extends Sender {
     public getResendInterval() {
@@ -13,13 +15,129 @@ class SenderMock extends Sender {
 }
 
 describe("Library/Sender", () => {
-    var sender: SenderMock;
+
+    var testEnvelope = new Contracts.Envelope();
+    var sandbox: sinon.SinonSandbox;
+    let interceptor: nock.Interceptor;
+
+    before(() => {
+        interceptor = nock(Constants.DEFAULT_BREEZE_ENDPOINT)
+            .post("/v2/track", (body: string) => {
+                return true;
+            });
+    });
 
     beforeEach(() => {
-        sender = new SenderMock(new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"));
+        sandbox = sinon.sandbox.create();
+    });
+
+    afterEach(() => {
+        sandbox.restore();
+    });
+
+    after(() => {
+        nock.cleanAll();
+    });
+
+
+    describe("#send(envelope)", () => {
+        var sender: Sender;
+
+        before(() => {
+            sender = new Sender(new Config("2bb22222-bbbb-1ccc-8ddd-eeeeffff3333"));
+            (<any>sender.constructor).USE_ICACLS = false;
+            sender.setDiskRetryMode(true);
+        });
+
+        after(() => {
+            (<any>sender.constructor).USE_ICACLS = true;
+            sender.setDiskRetryMode(false);
+        });
+
+        it("should not crash JSON.stringify", () => {
+            var a = <any>{ b: null };
+            a.b = a;
+            var warnStub = sandbox.stub(console, "warn");
+            assert.doesNotThrow(() => sender.send([a]));
+            assert.ok(warnStub.calledOnce);
+        });
+
+        it("should try to send telemetry from disk when 200", (done) => {
+            var breezeResponse: Contracts.BreezeResponse = {
+                itemsAccepted: 1,
+                itemsReceived: 1,
+                errors: []
+            };
+            let diskEnvelope = new Contracts.Envelope();
+            diskEnvelope.name = "DiskEnvelope";
+            sender["_storeToDisk"]([diskEnvelope]);
+            var sendSpy = sandbox.spy(sender, "send");
+            interceptor.reply(200, breezeResponse).persist();
+            sender["_resendInterval"] = 100;
+            sender.send([testEnvelope], (responseText) => {
+                // Wait for resend timer
+                setTimeout(() => {
+                    assert.ok(sendSpy.calledTwice);
+                    assert.equal(sendSpy.secondCall.args[0][0].name, "DiskEnvelope");
+                    done();
+                }, 200)
+
+            });
+        });
+
+        it("should put telemetry in disk when retryable code is returned", (done) => {
+            var envelope = new Contracts.Envelope();
+            envelope.name = "TestRetryable";
+            interceptor.reply(408, null);
+            var storeStub = sandbox.stub(sender, "_storeToDisk");
+            sender.send([envelope], (responseText) => {
+                assert.ok(storeStub.calledOnce);
+                assert.equal(storeStub.firstCall.args[0][0].name, "TestRetryable");
+                done();
+            });
+        });
+
+        it("should retry only failed events in partial content response", (done) => {
+            var breezeResponse: Contracts.BreezeResponse = {
+                itemsAccepted: 2,
+                itemsReceived: 4,
+                errors: [{
+                    index: 0,
+                    statusCode: 408,
+                    message: ""
+                }, {
+                    index: 2,
+                    statusCode: 123,
+                    message: ""
+                }]
+            };
+            var envelopes = [];
+            for (var i = 0; i < 4; i++) {
+                var newEnvelope = new Contracts.Envelope();
+                newEnvelope.name = "TestPartial" + i;
+                envelopes.push(newEnvelope);
+            }
+            interceptor.reply(206, breezeResponse);
+            var storeStub = sandbox.stub(sender, "_storeToDisk");
+            sender.send(envelopes, () => {
+                assert.ok(storeStub.calledOnce);
+                assert.equal(storeStub.firstCall.args[0].length, 1);
+                assert.equal(storeStub.firstCall.args[0][0].name, "TestPartial0");
+                done();
+            });
+        });
     });
 
     describe("#setOfflineMode(value, resendInterval)", () => {
+        var sender: SenderMock;
+        beforeEach(() => {
+            sender = new SenderMock(new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"));
+        });
+
+        after(()=>{
+            sender.setDiskRetryMode(false);
+        });
+
         it("default resend interval is 60 seconds", () => {
             sender.setDiskRetryMode(true);
             assert.equal(Sender.WAIT_BETWEEN_RESEND, sender.getResendInterval());
@@ -43,41 +161,10 @@ describe("Library/Sender", () => {
     });
 
     describe("#endpoint redirect", () => {
-        var sandbox: sinon.SinonSandbox;
-        let interceptor: nock.Interceptor;
-
-        before(() => {
-            interceptor = nock(Constants.DEFAULT_BREEZE_ENDPOINT)
-                .post("/v2/track", (body: string) => {
-                    return true;
-                });
-        });
-
-        beforeEach(() => {
-            sandbox = sinon.sandbox.create();
-        });
-
-        afterEach(() => {
-            sandbox.restore();
-        });
-
-        after(() => {
-            nock.cleanAll();
-        });
-
-        it("should change ingestion endpoint when redirect response code is returned (301)", (done) => {
-            interceptor.reply(301, {}, { "Location": "testLocation" });
-            var testSender = new Sender(new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"));
-            testSender.send(new Buffer("test"), (responseText) => {
-                assert.equal(testSender["_redirectedHost"], "testLocation");
-                done();
-            });
-        });
-
         it("should change ingestion endpoint when redirect response code is returned (308)", (done) => {
             interceptor.reply(308, {}, { "Location": "testLocation" });
-            var testSender = new Sender(new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"));
-            testSender.send(new Buffer("test"), (responseText) => {
+            var testSender = new Sender(new Config("2bb22222-bbbb-1ccc-8ddd-eeeeffff3333"));
+            testSender.send([testEnvelope], (responseText) => {
                 assert.equal(testSender["_redirectedHost"], "testLocation");
                 done();
             });
@@ -85,8 +172,8 @@ describe("Library/Sender", () => {
 
         it("should not change ingestion endpoint if redirect is not triggered", (done) => {
             interceptor.reply(200, {}, { "Location": "testLocation" });
-            var testSender = new Sender(new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"));
-            testSender.send(new Buffer("test"), (responseText) => {
+            var testSender = new Sender(new Config("2bb22222-bbbb-1ccc-8ddd-eeeeffff3333"));
+            testSender.send([testEnvelope], (responseText) => {
                 assert.equal(testSender["_redirectedHost"], null);
                 done();
             });
@@ -101,10 +188,10 @@ describe("Library/Sender", () => {
                     return true;
                 }).reply(200, { "redirectProperty": true });
             interceptor.reply(308, {}, { "Location": redirectLocation });
-            var testSender = new Sender(new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"));
-            testSender.send(new Buffer("test"), () => {
+            var testSender = new Sender(new Config("2bb22222-bbbb-1ccc-8ddd-eeeeffff3333"));
+            testSender.send([testEnvelope], () => {
                 assert.equal(testSender["_redirectedHost"], redirectLocation);
-                testSender.send(new Buffer("test"), (responseText) => {
+                testSender.send([testEnvelope], (responseText) => {
                     assert.equal(responseText, '{"redirectProperty":true}');
                     done();
                 });
@@ -112,4 +199,31 @@ describe("Library/Sender", () => {
         });
 
     });
+
+    describe("#fileCleanupTask", () => {
+        var sender: Sender;
+
+        after(() => {
+            (<any>sender.constructor).USE_ICACLS = true;
+            sender.setDiskRetryMode(false);
+        });
+
+        it("must clean old files from temp location", (done) => {
+            var deleteSpy = sandbox.spy(fs, "unlink");
+            sender = new Sender(new Config("3bb33333-bbbb-1ccc-8ddd-eeeeffff3333"));
+            (<any>sender.constructor).USE_ICACLS = false;
+            (<any>sender.constructor).CLEANUP_TIMEOUT = 500;
+            (<any>sender.constructor).FILE_RETEMPTION_PERIOD = 1;
+            var taskSpy = sandbox.spy(sender, "_fileCleanupTask");
+            sender.setDiskRetryMode(true);
+            let diskEnvelope = new Contracts.Envelope();
+            diskEnvelope.name = "DiskEnvelope";
+            sender["_storeToDisk"]([diskEnvelope]);
+            setTimeout(() => {
+                assert.ok(taskSpy.calledOnce);
+                assert.ok(deleteSpy.called);
+                done();
+            }, 600);
+        });
+    });
 });