Repository: microsoft/ApplicationInsights-node.js
Pull request #666 (2020-08-07 21:17:16)

Add heartbeat metric for VM
Continuation of [#660 ].

Detects whether current app is hosted in an Azure VM. We utilize [AzureMetaDataService ](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service)to retrieve vm information.


Patch:
diff --git a/AutoCollection/HeartBeat.ts b/AutoCollection/HeartBeat.ts
index de43623..a13470d 100644
--- a/AutoCollection/HeartBeat.ts
+++ b/AutoCollection/HeartBeat.ts
@@ -1,8 +1,15 @@
 import os = require("os");
 import TelemetryClient = require("../Library/TelemetryClient");
 import Constants = require("../Declarations/Constants");
+import Util = require("../Library/Util");
 import Config = require("../Library/Config");
 import Context = require("../Library/Context");
+import AutoCollectHttpDependencies = require("../AutoCollection/HttpDependencies");
+
+const AIMS_URI = "http://169.254.169.254/metadata/instance/compute";
+const AIMS_API_VERSION = "api-version=2017-12-01";
+const AIMS_FORMAT = "format=json";
+const ConnectionErrorMessage = "ENETUNREACH";
 
 class HeartBeat {
 
@@ -13,6 +20,11 @@ class HeartBeat {
     private _handle: NodeJS.Timer | null;
     private _isEnabled: boolean;
     private _isInitialized: boolean;
+    private _isVM: boolean;
+    private _vmData = <{[key: string]: string}>{};
+    private _azInst_vmId: string = "";	
+    private _azInst_subscriptionId: string = "";	
+    private _azInst_osType: string = "";
 
     constructor(client: TelemetryClient) {
         if (!HeartBeat.INSTANCE) {
@@ -51,6 +63,7 @@ class HeartBeat {
     }
 
     public trackHeartBeat(config: Config, callback: () => void) {
+        let waiting: boolean = false;
         let properties: {[key: string]: string} = {};
         const sdkVersion = Context.sdkVersion; // "node" or "node-nativeperf"
         properties["sdk"] = sdkVersion;
@@ -61,9 +74,31 @@ class HeartBeat {
             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();
+        } else if (config) {
+            if (this._isVM === undefined) {
+                waiting = true;
+                this._getAzureComputeMetadata(config, () => {
+                    if (this._isVM && Object.keys(this._vmData).length > 0) { // VM
+                        properties["azInst_vmId"] = this._vmData["vmId"] || "";
+                        properties["azInst_subscriptionId"] = this._vmData["subscriptionId"] || "";
+                        properties["azInst_osType"] = this._vmData["osType"] || "";
+                        this._azInst_vmId = this._vmData["vmId"] || "";
+                        this._azInst_subscriptionId = this._vmData["subscriptionId"] || "";
+                        this._azInst_osType = this._vmData["osType"] || "";
+                    }
+                    this._client.trackMetric({name: Constants.HeartBeatMetricName, value: 0, properties: properties});
+                    callback();
+                });
+            } else if (this._isVM) {
+                properties["azInst_vmId"] = this._azInst_vmId;	
+                properties["azInst_subscriptionId"] = this._azInst_subscriptionId;	
+                properties["azInst_osType"] = this._azInst_osType;
+            }
+        }
+        if (!waiting) {
+            this._client.trackMetric({name: Constants.HeartBeatMetricName, value: 0, properties: properties});
+            callback();
+        }
     }
 
     public dispose() {
@@ -71,6 +106,55 @@ class HeartBeat {
         this.enable(false);
         this._isInitialized = false;
     }
+
+    private _getAzureComputeMetadata(config: Config, callback: () => void) {
+        const metadataRequestUrl = `${AIMS_URI}?${AIMS_API_VERSION}&${AIMS_FORMAT}`;
+        const requestOptions = {
+            method: 'GET',
+            [AutoCollectHttpDependencies.disableCollectionRequestOption]: true,
+            headers: {
+                "Metadata": "True",
+            }
+        };
+
+        const req = Util.makeRequest(config, metadataRequestUrl, requestOptions, (res) => {
+            if (res.statusCode === 200) {
+                // Success; VM
+                this._isVM = true;
+                let virtualMachineData = "";
+                res.on('data', (data: any) => {
+                    virtualMachineData += data;
+                });
+                res.on('end', () => {
+                    this._vmData = this._isJSON(virtualMachineData) ? JSON.parse(virtualMachineData) : {};
+                    callback();
+                });
+            } else {
+                // else Retry on next heartbeat metrics call
+                callback();
+            }
+        });
+        if (req) {
+            req.on('error', (error: Error) => {
+                // Unable to contact endpoint.
+                // Do nothing for now.
+                if (error && error.message && error.message.indexOf(ConnectionErrorMessage) > -1) {
+                    this._isVM = false; // confirm it's not in VM
+                }
+                // errors other than connect ENETUNREACH - retry
+                callback();
+            });
+            req.end();
+        }
+    }
+
+    private _isJSON(str: string): boolean {
+        try {
+            return (JSON.parse(str) && !!str);
+        } catch (e) {
+            return false;
+        }
+    }
 }
 
 export = HeartBeat;


Test patch:
diff --git a/Tests/AutoCollection/Heartbeat.tests.ts b/Tests/AutoCollection/Heartbeat.tests.ts
index 567da93..8e8717e 100644
--- a/Tests/AutoCollection/Heartbeat.tests.ts
+++ b/Tests/AutoCollection/Heartbeat.tests.ts
@@ -49,22 +49,22 @@ describe("AutoCollection/HeartBeat", () => {
             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");
+                assert.equal(stub1.callCount, 1, "should call trackMetric for the appSrv heartbeat metric");
+                assert.equal(stub1.args[0][0].name, "HeartBeat", "should use correct name for heartbeat metric");
+                assert.equal(stub1.args[0][0].value, 0, "value should be 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");
+                assert.equal(keys1.length, 5, "should have 5 kv pairs added when resource type is appSrv");
+                assert.equal(keys1[0], "sdk", "sdk should be added as a key");
+                assert.equal(keys1[1], "osType", "osType should be added as a key");
+                assert.equal(keys1[2], "appSrv_SiteName", "appSrv_SiteName should be added as a key");
+                assert.equal(keys1[3], "appSrv_wsStamp", "appSrv_wsStamp should be added as a key");
+                assert.equal(keys1[4], "appSrv_wsHost", "appSrv_wsHost should be 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");
+                assert.equal(properties1["sdk"], Context.sdkVersion, "sdk version should be read from Context");
+                assert.equal(properties1["osType"], os.type(), "osType should be read from os library");
+                assert.equal(properties1["appSrv_SiteName"], "site_name", "appSrv_SiteName should be read from envrionment variable");
+                assert.equal(properties1["appSrv_wsStamp"], "stamp_name", "appSrv_wsStamp should be read from envrionment variable");
+                assert.equal(properties1["appSrv_wsHost"], "host_name", "appSrv_wsHost should be read from envrionment variable");
 
                 stub1.restore();
                 heartbeat1.dispose();
@@ -88,18 +88,18 @@ describe("AutoCollection/HeartBeat", () => {
             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");
+                assert.equal(stub2.callCount, 1, "should call trackMetric for the VM heartbeat metric");
+                assert.equal(stub2.args[0][0].name, "HeartBeat", "should use correct name for heartbeat metric");
+                assert.equal(stub2.args[0][0].value, 0, "value should be 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");
+                assert.equal(keys2.length, 3, "should have 3 kv pairs added when resource type is functiona app");
+                assert.equal(keys2[0], "sdk", "sdk should be added as a key");
+                assert.equal(keys2[1], "osType",  "osType should be added as a key");
+                assert.equal(keys2[2], "azfunction_appId", "azfunction_appId should be 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");
+                assert.equal(properties2["sdk"], Context.sdkVersion, "sdk version should be read from Context");
+                assert.equal(properties2["osType"], os.type(), "osType should be read from os library");
+                assert.equal(properties2["azfunction_appId"], "host_name", "azfunction_appId should be read from envrionment variable");
 
                 process.env = originalEnv;
                 stub2.restore();
diff --git a/Tests/EndToEnd.tests.ts b/Tests/EndToEnd.tests.ts
index 1c5690e..5a2468d 100644
--- a/Tests/EndToEnd.tests.ts
+++ b/Tests/EndToEnd.tests.ts
@@ -9,10 +9,12 @@ import events = require("events");
 import child_process = require("child_process");
 import AppInsights = require("../applicationinsights");
 import Sender = require("../Library/Sender");
-import AutoCollectHttpDependencies = require("../AutoCollection/HttpDependencies");
 import Traceparent = require("../Library/Traceparent");
 import { EventEmitter } from "events";
 import { CorrelationContextManager } from "../AutoCollection/CorrelationContextManager";
+import HeartBeat = require("../AutoCollection/HeartBeat");
+import TelemetryClient = require("../Library/TelemetryClient");
+import Context = require("../Library/Context");
 
 /**
  * A fake response class that passes by default
@@ -21,8 +23,11 @@ class fakeResponse {
     private callbacks: { [event: string]: (data?: any) => void } = Object.create(null);
     public setEncoding(): void { };
     public statusCode: number = 200;
+    private _responseData: any;
 
-    constructor(private passImmediately: boolean = true) { }
+    constructor(private passImmediately: boolean = true, responseData?: any) {
+        this._responseData = responseData ? responseData : "data";
+    }
 
     public on(event: string, callback: () => void) {
         if (!this.callbacks[event]) {
@@ -53,7 +58,7 @@ class fakeResponse {
     }
 
     public pass(test = false): void {
-        this.callbacks["data"] ? this.callbacks["data"]("data") : null;
+        this.callbacks["data"] ? this.callbacks["data"](this._responseData) : null;
         this.callbacks["end"] ? this.callbacks["end"]() : null;
         this.callbacks["finish"] ? this.callbacks["finish"]() : null;
     }
@@ -70,8 +75,11 @@ class fakeRequest {
     public write(): void { }
     public headers: { [id: string]: string } = {};
     public agent = { protocol: 'http' };
+    private _responseData: any;
 
-    constructor(private failImmediatly: boolean = true, public url: string = undefined) { }
+    constructor(private failImmediatly: boolean = true, public url: string = undefined, responseData?: any) { 
+        this._responseData = responseData;
+     }
 
     public on(event: string, callback: Function) {
         this.callbacks[event] = callback;
@@ -97,7 +105,7 @@ class fakeRequest {
     }
 
     public end(): void {
-        this.callbacks["end"] ? this.callbacks["end"](new fakeResponse(true)) : null;
+        this.callbacks["end"] ? this.callbacks["end"](new fakeResponse(true, this._responseData)) : null;
     }
 }
 
@@ -968,4 +976,94 @@ describe("EndToEnd", () => {
             }
         });
     });
+
+    describe("Heartbeat metrics for VM", () => {
+        var sandbox: sinon.SinonSandbox;
+
+        beforeEach(() => {
+            sandbox = sinon.sandbox.create();
+        });
+
+        afterEach(() => {
+            sandbox.restore();
+        });
+
+        it("should collect correct VM information from JSON response", (done) => {
+            // set up stub
+            const vmDataJSON = `{
+                "vmId": "1",
+                "subscriptionId": "2",
+                "osType": "Windows_NT"
+            }`;
+            var stub: sinon.SinonStub = sandbox.stub(http, "request", (options: any, callback: any) => {
+                var req = new fakeRequest(false, "http://169.254.169.254", vmDataJSON);
+                req.on("end", callback);
+                return req;
+            });
+
+            // set up sdk
+            const client = new TelemetryClient("key");
+            const heartbeat: HeartBeat = new HeartBeat(client);
+            heartbeat.enable(true, client.config);
+            HeartBeat.INSTANCE.enable(true, client.config);
+            const trackMetricStub = sinon.stub(heartbeat["_client"], "trackMetric");
+            
+            heartbeat["trackHeartBeat"](client.config, () => {
+                assert.equal(trackMetricStub.callCount, 1, "should call trackMetric for the VM heartbeat metric");	
+                assert.equal(trackMetricStub.args[0][0].name, "HeartBeat", "should use correct name for heartbeat metric");	
+                assert.equal(trackMetricStub.args[0][0].value, 0, "value should be 0");	
+                const keys = Object.keys(trackMetricStub.args[0][0].properties);	
+                assert.equal(keys.length, 5, "should have 4 kv pairs added when resource type is VM");	
+                assert.equal(keys[0], "sdk", "sdk should be added as a key");	
+                assert.equal(keys[1], "osType", "osType should be added as a key");
+                assert.equal(keys[2], "azInst_vmId",  "azInst_vmId should be added as a key");	
+                assert.equal(keys[3], "azInst_subscriptionId", "azInst_subscriptionId should be added as a key");	
+                assert.equal(keys[4], "azInst_osType", "azInst_osType should be added as a key");	
+
+                const properties = trackMetricStub.args[0][0].properties;	
+                assert.equal(properties["sdk"], Context.sdkVersion, "sdk version should be read from Context");	
+                assert.equal(properties["osType"], os.type(), "osType should be read from os library");
+                assert.equal(properties["azInst_vmId"], "1", "azInst_vmId should be read from response");	
+                assert.equal(properties["azInst_subscriptionId"], "2", "azInst_subscriptionId should be read from response");	
+                assert.equal(properties["azInst_osType"], "Windows_NT", "azInst_osType should be read from response");
+                trackMetricStub.restore();
+                heartbeat.dispose();
+                stub.restore();
+                done();
+            });
+        });
+
+        it("should only send name and value properties for heartbeat metric when get VM request fails", (done) => {
+            // set up stub
+            var stub: sinon.SinonStub = sandbox.stub(http, "request", (options: any, callback: any) => {
+                var req = new fakeRequest(true, "http://169.254.169.254");
+                return req;
+            });
+
+            // set up sdk
+            const client = new TelemetryClient("key");
+            const heartbeat: HeartBeat = new HeartBeat(client);
+            heartbeat.enable(true, client.config);
+            HeartBeat.INSTANCE.enable(true, client.config);
+            const trackMetricStub = sinon.stub(heartbeat["_client"], "trackMetric");
+            
+            heartbeat["trackHeartBeat"](client.config, () => {
+                assert.equal(trackMetricStub.callCount, 1, "should call trackMetric as heartbeat metric");	
+                assert.equal(trackMetricStub.args[0][0].name, "HeartBeat", "should use correct name for heartbeat metric");	
+                assert.equal(trackMetricStub.args[0][0].value, 0, "value should be 0");	
+                const keys = Object.keys(trackMetricStub.args[0][0].properties);	
+                assert.equal(keys.length, 2, "should have 2 kv pairs added when resource type is not web app, not function app, not VM");	
+                assert.equal(keys[0], "sdk", "sdk should be added as a key");	
+                assert.equal(keys[1], "osType", "osType should be added as a key");	
+
+                const properties = trackMetricStub.args[0][0].properties;	
+                assert.equal(properties["sdk"], Context.sdkVersion, "sdk version should be read from Context");	
+                assert.equal(properties["osType"], os.type(), "osType should be read from os library");
+                trackMetricStub.restore();
+                heartbeat.dispose();
+                stub.restore();
+                done();
+            });
+        });
+    });
 });