Repository: microsoft/ApplicationInsights-node.js
Pull request #660 (2020-07-31 02:31:56)

Heartbeat metric of web app and function app

lzchen: @xiao-lix 
Can you include a description for exactly what features are included here and the purpose?
Also, a more descriptive title would be useful.

Patch:
diff --git a/AutoCollection/HeartBeat.ts b/AutoCollection/HeartBeat.ts
new file mode 100644
index 0000000..de43623
--- /dev/null
+++ b/AutoCollection/HeartBeat.ts
@@ -0,0 +1,76 @@
+import os = require("os");
+import TelemetryClient = require("../Library/TelemetryClient");
+import Constants = require("../Declarations/Constants");
+import Config = require("../Library/Config");
+import Context = require("../Library/Context");
+
+class HeartBeat {
+
+    public static INSTANCE: HeartBeat;
+
+    private _collectionInterval: number = 900000;
+    private _client: TelemetryClient;
+    private _handle: NodeJS.Timer | null;
+    private _isEnabled: boolean;
+    private _isInitialized: boolean;
+
+    constructor(client: TelemetryClient) {
+        if (!HeartBeat.INSTANCE) {
+            HeartBeat.INSTANCE = this;
+        }
+
+        this._isInitialized = false;
+        this._client = client;
+    }
+
+    public enable(isEnabled: boolean, config?: Config) {
+        this._isEnabled = isEnabled;
+        if (this._isEnabled && !this._isInitialized) {
+            this._isInitialized = true;
+        }
+
+        if (isEnabled) {
+            if (!this._handle) {
+                this._handle = setInterval(() => this.trackHeartBeat(config, () => {}), this._collectionInterval);
+                this._handle.unref(); // Allow the app to terminate even while this loop is going on
+            }
+        } else {
+            if (this._handle) {
+                clearInterval(this._handle);
+                this._handle = null;
+            }
+        }
+    }
+
+    public isInitialized() {
+        return this._isInitialized;
+    }
+
+    public static isEnabled() {
+        return HeartBeat.INSTANCE && HeartBeat.INSTANCE._isEnabled;
+    }
+
+    public trackHeartBeat(config: Config, callback: () => void) {
+        let properties: {[key: string]: string} = {};
+        const sdkVersion = Context.sdkVersion; // "node" or "node-nativeperf"
+        properties["sdk"] = sdkVersion;
+        properties["osType"] = os.type();
+        if (process.env.WEBSITE_SITE_NAME) { // Web apps
+            properties["appSrv_SiteName"] = process.env.WEBSITE_SITE_NAME || "";
+            properties["appSrv_wsStamp"] = process.env.WEBSITE_HOME_STAMPNAME || "";
+            properties["appSrv_wsHost"] = process.env.WEBSITE_HOSTNAME || "";
+        } else if (process.env.FUNCTIONS_WORKER_RUNTIME) { // Function apps
+            properties["azfunction_appId"] = process.env.WEBSITE_HOSTNAME;
+        } 
+        this._client.trackMetric({name: Constants.HeartBeatMetricName, value: 0, properties: properties});
+        callback();
+    }
+
+    public dispose() {
+        HeartBeat.INSTANCE = null;
+        this.enable(false);
+        this._isInitialized = false;
+    }
+}
+
+export = HeartBeat;
diff --git a/Bootstrap/Default.ts b/Bootstrap/Default.ts
index b592a5a..072b6c4 100644
--- a/Bootstrap/Default.ts
+++ b/Bootstrap/Default.ts
@@ -1,6 +1,7 @@
 import * as types from "../applicationinsights";
 import * as Helpers from "./Helpers";
 import * as DataModel from "./DataModel";
+import Constants = require("../Declarations/Constants");
 import { StatusLogger, StatusContract } from "./StatusLogger";
 import { DiagnosticLogger } from "./DiagnosticLogger";
 
@@ -85,9 +86,21 @@ export function setupAndStart(setupString = _setupString): typeof types | null {
             return true;
         }
 
+        const copyOverPrefixInternalSdkVersionToHeartBeatMetric = function (envelope: types.Contracts.Envelope, _contextObjects: Object) {
+            var appInsightsSDKVersion = _appInsights.defaultClient.context.keys.internalSdkVersion;
+            const sdkVersion = envelope.tags[appInsightsSDKVersion] || "";
+            if (envelope.name === Constants.HeartBeatMetricName) {
+                ((envelope.data as any).baseData).properties = ((envelope.data as any).baseData).properties || {};
+                ((envelope.data as any).baseData).properties["sdk"] = sdkVersion;
+            }
+
+            return true;
+        }
+
         // Instrument the SDK
         _appInsights.setup(setupString).setSendLiveMetrics(true);
         _appInsights.defaultClient.addTelemetryProcessor(prefixInternalSdkVersion);
+        _appInsights.defaultClient.addTelemetryProcessor(copyOverPrefixInternalSdkVersionToHeartBeatMetric);
         _appInsights.start();
 
         // Agent successfully instrumented the SDK
diff --git a/Declarations/Constants.ts b/Declarations/Constants.ts
index 82aac10..61ae756 100644
--- a/Declarations/Constants.ts
+++ b/Declarations/Constants.ts
@@ -133,3 +133,5 @@ export const DependencyTypeName = {
     Http: "HTTP",
     InProc: "InProc",
 }
+
+export const HeartBeatMetricName = "HeartBeat";
\ No newline at end of file
diff --git a/applicationinsights.ts b/applicationinsights.ts
index 73aef61..b259e4d 100644
--- a/applicationinsights.ts
+++ b/applicationinsights.ts
@@ -2,6 +2,7 @@ import CorrelationContextManager = require("./AutoCollection/CorrelationContextM
 import AutoCollectConsole = require("./AutoCollection/Console");
 import AutoCollectExceptions = require("./AutoCollection/Exceptions");
 import AutoCollectPerformance = require("./AutoCollection/Performance");
+import HeartBeat = require("./AutoCollection/HeartBeat");
 import AutoCollectHttpDependencies = require("./AutoCollection/HttpDependencies");
 import AutoCollectHttpRequests = require("./AutoCollection/HttpRequests");
 import CorrelationIdManager = require("./Library/CorrelationIdManager");
@@ -38,6 +39,7 @@ let _isConsole = true;
 let _isConsoleLog = false;
 let _isExceptions = true;
 let _isPerformance = true;
+let _isHeartBeat = false; // off by default for now
 let _isRequests = true;
 let _isDependencies = true;
 let _isDiskRetry = true;
@@ -53,6 +55,7 @@ let _diskRetryMaxBytes: number = undefined;
 let _console: AutoCollectConsole;
 let _exceptions: AutoCollectExceptions;
 let _performance: AutoCollectPerformance;
+let _heartbeat: HeartBeat;
 let _nativePerformance: AutoCollectNativePerformance;
 let _serverRequests: AutoCollectHttpRequests;
 let _clientRequests: AutoCollectHttpDependencies;
@@ -83,6 +86,7 @@ export function setup(setupString?: string) {
         _console = new AutoCollectConsole(defaultClient);
         _exceptions = new AutoCollectExceptions(defaultClient);
         _performance = new AutoCollectPerformance(defaultClient);
+        _heartbeat = new HeartBeat(defaultClient);
         _serverRequests = new AutoCollectHttpRequests(defaultClient);
         _clientRequests = new AutoCollectHttpDependencies(defaultClient);
         if (!_nativePerformance) {
@@ -111,6 +115,7 @@ export function start() {
         _console.enable(_isConsole, _isConsoleLog);
         _exceptions.enable(_isExceptions);
         _performance.enable(_isPerformance);
+        _heartbeat.enable(_isHeartBeat, defaultClient.config);
         _nativePerformance.enable(_isNativePerformance, _disabledExtendedMetrics);
         _serverRequests.useAutoCorrelation(_isCorrelating, _forceClsHooked);
         _serverRequests.enable(_isRequests);
@@ -231,6 +236,19 @@ export class Configuration {
             _nativePerformance.enable(extendedMetricsConfig.isEnabled, extendedMetricsConfig.disabledMetrics);
         }
 
+        return Configuration;
+    }
+
+    /**
+     * Sets the state of request tracking (enabled by default)
+     * @param value if true HeartBeat metric data will be collected every 15 mintues and sent to Application Insights
+     * @returns {Configuration} this class
+     */
+    public static setAutoCollectHeartbeat(value: boolean) {
+        _isHeartBeat = value;
+        if (_isStarted) {
+            _heartbeat.enable(value, defaultClient.config);
+        }
 
         return Configuration;
     }
@@ -355,6 +373,9 @@ export function dispose() {
     if (_performance) {
         _performance.dispose();
     }
+    if (_heartbeat) {
+        _heartbeat.dispose();
+    }
     if (_nativePerformance) {
         _nativePerformance.dispose();
     }


Test patch:
diff --git a/Tests/AutoCollection/Heartbeat.tests.ts b/Tests/AutoCollection/Heartbeat.tests.ts
new file mode 100644
index 0000000..05774a2
--- /dev/null
+++ b/Tests/AutoCollection/Heartbeat.tests.ts
@@ -0,0 +1,111 @@
+import assert = require("assert");
+import sinon = require("sinon");
+import os = require("os");
+
+import AppInsights = require("../../applicationinsights");
+import HeartBeat = require("../../AutoCollection/HeartBeat");
+import TelemetryClient = require("../../Library/TelemetryClient");
+import Context = require("../../Library/Context");
+
+describe("AutoCollection/HeartBeat", () => {
+    const client = new TelemetryClient("key");
+    client.config.correlationId = "testicd";
+
+    afterEach(() => {
+        AppInsights.dispose();
+    });
+
+    describe("#init and #dispose()", () => {
+        it("init should enable and dispose should stop autocollection interval", () => {
+            var setIntervalSpy = sinon.spy(global, "setInterval");
+            var clearIntervalSpy = sinon.spy(global, "clearInterval");
+            AppInsights.setup("key")
+                .setAutoCollectPerformance(false, false)
+                .setAutoCollectHeartbeat(true)
+                .start();
+            assert.equal(setIntervalSpy.callCount, 1, "setInteval should be called once as part of heartbeat initialization");
+            AppInsights.dispose();
+            assert.equal(clearIntervalSpy.callCount, 1, "clearInterval should be called once as part of heartbeat shutdown");
+
+            setIntervalSpy.restore();
+            clearIntervalSpy.restore();
+        });
+    });
+
+    describe("#trackHeartBeat()", () => {
+        it("should read correct web app values from envrionment variable", (done) => {
+            const heartbeat1: HeartBeat = new HeartBeat(client);
+            heartbeat1.enable(true, client.config);
+            HeartBeat.INSTANCE.enable(true, client.config);
+            const stub1 = sinon.stub(heartbeat1["_client"], "trackMetric");
+
+            var env1 = <{[id: string]: string}>{};
+
+            env1["WEBSITE_SITE_NAME"] = "site_name";
+            env1[ "WEBSITE_HOME_STAMPNAME"] = "stamp_name";
+            env1["WEBSITE_HOSTNAME"] = "host_name";
+
+            var originalEnv = process.env;
+            process.env = env1;
+
+            heartbeat1["trackHeartBeat"](client.config, () => {
+                assert.equal(stub1.callCount, 1, "calls trackMetric for the appSrv heartbeat metric");
+                assert.equal(stub1.args[0][0].name, "HeartBeat", "uses correct name for heartbeat metric");
+                assert.equal(stub1.args[0][0].value, 0, "checks value is 0");
+                const keys1 = Object.keys(stub1.args[0][0].properties);
+                assert.equal(keys1.length, 5, "makes sure 5 kv pairs are added when resource type is appSrv");
+                assert.equal(keys1[0], "sdk", "sdk is added as a key");
+                assert.equal(keys1[1], "osType", "osType is added as a key");
+                assert.equal(keys1[2], "appSrv_SiteName", "appSrv_SiteName is added as a key");
+                assert.equal(keys1[3], "appSrv_wsStamp", "appSrv_wsStamp is added as a key");
+                assert.equal(keys1[4], "appSrv_wsHost", "appSrv_wsHost is added as a key");
+                const properties1 = stub1.args[0][0].properties;
+                assert.equal(properties1["sdk"], Context.sdkVersion, "sdk version is read from Context");
+                assert.equal(properties1["osType"], os.type(), "osType is read from os library");
+                assert.equal(properties1["appSrv_SiteName"], "site_name", "appSrv_SiteName is read from envrionment variable");
+                assert.equal(properties1["appSrv_wsStamp"], "stamp_name", "appSrv_wsStamp is read from envrionment variable");
+                assert.equal(properties1["appSrv_wsHost"], "host_name", "appSrv_wsHost is read from envrionment variable");
+
+                stub1.restore();
+                heartbeat1.dispose();
+                process.env = originalEnv;
+                done();
+            });
+        });
+
+        it("should read correct function app values from envrionment variable", (done) => {
+            const heartbeat2: HeartBeat = new HeartBeat(client);
+            heartbeat2.enable(true, client.config);
+            HeartBeat.INSTANCE.enable(true, client.config);
+            const stub2 = sinon.stub(heartbeat2["_client"], "trackMetric");
+
+            var env2 = <{[id: string]: string}>{};
+            
+            env2["FUNCTIONS_WORKER_RUNTIME"] = "nodejs";
+            env2["WEBSITE_HOSTNAME"] = "host_name";
+
+            var originalEnv = process.env;
+            process.env = env2;
+        
+            heartbeat2["trackHeartBeat"](client.config, () => {
+                assert.equal(stub2.callCount, 1, "calls trackMetric for the VM heartbeat metric");
+                assert.equal(stub2.args[0][0].name, "HeartBeat", "uses correct name for heartbeat metric");
+                assert.equal(stub2.args[0][0].value, 0, "checks value is 0");
+                const keys2 = Object.keys(stub2.args[0][0].properties);
+                assert.equal(keys2.length, 3, "makes sure 3 kv pairs are added when resource type is functiona app");
+                assert.equal(keys2[0], "sdk", "sdk is added as a key");
+                assert.equal(keys2[1], "osType",  "osType is added as a key");
+                assert.equal(keys2[2], "azfunction_appId", "azfunction_appId is added as a key");
+                const properties2 = stub2.args[0][0].properties;
+                assert.equal(properties2["sdk"], Context.sdkVersion, "sdk version is read from Context");
+                assert.equal(properties2["osType"], os.type(), "osType is read from os library");
+                assert.equal(properties2["azfunction_appId"], "host_name", "azfunction_appId is read from envrionment variable");
+
+                process.env = originalEnv;
+                stub2.restore();
+                heartbeat2.dispose();
+                done();
+            });
+        });
+    });
+});
\ No newline at end of file
diff --git a/Tests/AutoCollection/NativePerformance.tests.ts b/Tests/AutoCollection/NativePerformance.tests.ts
index 1da19de..0b39b93 100644
--- a/Tests/AutoCollection/NativePerformance.tests.ts
+++ b/Tests/AutoCollection/NativePerformance.tests.ts
@@ -17,7 +17,10 @@ describe("AutoCollection/NativePerformance", () => {
                 var setIntervalSpy = sinon.spy(global, "setInterval");
                 var clearIntervalSpy = sinon.spy(global, "clearInterval");
 
-                AppInsights.setup("key").setAutoCollectPerformance(false, true).start();
+                AppInsights.setup("key")
+                    .setAutoCollectHeartbeat(false)
+                    .setAutoCollectPerformance(false, true)
+                    .start();
                 if (AutoCollectNativePerformance["_metricsAvailable"]) {
                     assert.equal(setIntervalSpy.callCount, 1, "setInterval should be called once as part of NativePerformance initialization");
                     AppInsights.dispose();
diff --git a/Tests/AutoCollection/Performance.tests.ts b/Tests/AutoCollection/Performance.tests.ts
index 5c806c9..63c6a7f 100644
--- a/Tests/AutoCollection/Performance.tests.ts
+++ b/Tests/AutoCollection/Performance.tests.ts
@@ -13,7 +13,10 @@ describe("AutoCollection/Performance", () => {
         it("init should enable and dispose should stop autocollection interval", () => {
             var setIntervalSpy = sinon.spy(global, "setInterval");
             var clearIntervalSpy = sinon.spy(global, "clearInterval");
-            AppInsights.setup("key").setAutoCollectPerformance(true, false).start();
+            AppInsights.setup("key")
+                .setAutoCollectHeartbeat(false)
+                .setAutoCollectPerformance(true, false)
+                .start();
             assert.equal(setIntervalSpy.callCount, 1, "setInteval should be called once as part of performance initialization");
             AppInsights.dispose();
             assert.equal(clearIntervalSpy.callCount, 1, "clearInterval should be called once as part of performance shutdown");
diff --git a/Tests/Bootstrap/Default.spec.ts b/Tests/Bootstrap/Default.spec.ts
index 92aa68e..cbbf6cb 100644
--- a/Tests/Bootstrap/Default.spec.ts
+++ b/Tests/Bootstrap/Default.spec.ts
@@ -48,8 +48,8 @@ describe("#setupAndStart()", () => {
         assert.ok(instance1.defaultClient);
         const instance2 = Default.setupAndStart("abc");
         assert.deepEqual(instance1.defaultClient, instance2.defaultClient);
-        assert.deepEqual(instance1.defaultClient["_telemetryProcessors"].length, 1)
-        assert.deepEqual(instance2.defaultClient["_telemetryProcessors"].length, 1)
+        assert.deepEqual(instance1.defaultClient["_telemetryProcessors"].length, 2)
+        assert.deepEqual(instance2.defaultClient["_telemetryProcessors"].length, 2)
 
         // Cleanup
         alreadyExistsStub.restore();
diff --git a/Tests/applicationInsights.tests.ts b/Tests/applicationInsights.tests.ts
index 52006f6..951dd8d 100644
--- a/Tests/applicationInsights.tests.ts
+++ b/Tests/applicationInsights.tests.ts
@@ -103,7 +103,7 @@ describe("ApplicationInsights", () => {
             AppInsights.dispose();
         })
 
-        it("should enable AI tracing mode by default", () => {
+        it("should enable W3C tracing mode by default", () => {
             AppInsights.setup("key").start();
             assert.equal(CorrelationIdManager.w3cEnabled, true);
         });