diff --git a/AISKU/Tests/Manual/testVersionConflict.html b/AISKU/Tests/Manual/testVersionConflict.html index 2b7f4ca0e..1e1b5e997 100644 --- a/AISKU/Tests/Manual/testVersionConflict.html +++ b/AISKU/Tests/Manual/testVersionConflict.html @@ -10,7 +10,8 @@

Microsoft Application Insights JavaScript SDK - AISKU

- --> \ No newline at end of file diff --git a/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts b/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts index 14cac09ed..45973ccb1 100644 --- a/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts +++ b/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts @@ -154,7 +154,7 @@ export class ApplicationInsightsTests extends AITestClass { } public registerTests() { - this.addDynamicConfigTests() + this.addDynamicConfigTests(); this.addGenericE2ETests(); this.addAnalyticsApiTests(); this.addAsyncTests(); diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts new file mode 100644 index 000000000..b204ecc86 --- /dev/null +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -0,0 +1,255 @@ +import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; +import { AppInsightsCore, getWindow, IPayloadData, ITelemetryItem, TransportType } from "@microsoft/applicationinsights-core-js"; +import { Sender } from "../../../src/Sender"; +import { SinonSpy, SinonStub } from "sinon"; +import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; +import { isBeaconApiSupported } from "@microsoft/applicationinsights-common"; + +export class StatsbeatTests extends AITestClass { + private _core: AppInsightsCore; + private _sender: Sender; + private statsbeatCountSpy: SinonSpy; + private fetchStub: sinon.SinonStub; + private beaconStub: sinon.SinonStub; + private trackSpy: SinonSpy; + + public testInitialize() { + this._core = new AppInsightsCore(); + this._sender = new Sender(); + } + + public testFinishedCleanup() { + if (this._sender && this._sender.isInitialized()) { + this._sender.pause(); + this._sender._buffer.clear(); + this._sender.teardown(); + } + this._sender = null; + this._core = null; + if (this.statsbeatCountSpy) { + this.statsbeatCountSpy.restore(); + } + if (this.fetchStub) { + this.fetchStub.restore(); + } + if (this.beaconStub) { + this.beaconStub.restore(); + } + if (this.trackSpy) { + this.trackSpy.restore(); + } + } + + private initializeCoreAndSender(config: any, instrumentationKey: string) { + const sender = new Sender(); + const core = new AppInsightsCore(); + const coreConfig = { + instrumentationKey, + _sdk: { stats: true }, + extensionConfig: { [sender.identifier]: config } + }; + + core.initialize(coreConfig, [sender]); + this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(), "count"); + this.trackSpy = this.sandbox.spy(core, "track"); + + this.onDone(() => { + sender.teardown(); + }); + + return { core, sender }; + } + + private createSenderConfig(transportType: TransportType) { + return { + endpointUrl: "https://test", + emitLineDelimitedJson: false, + maxBatchInterval: 15000, + maxBatchSizeInBytes: 102400, + disableTelemetry: false, + enableSessionStorageBuffer: true, + isRetryDisabled: false, + isBeaconApiDisabled: false, + disableXhr: false, + onunloadDisableFetch: false, + onunloadDisableBeacon: false, + namePrefix: "", + samplingPercentage: 100, + customHeaders: [{ header: "header", value: "val" }], + convertUndefined: "", + eventsLimitInMem: 10000, + transports: [transportType] + }; + } + + private processTelemetryAndFlush(sender: Sender, telemetryItem: ITelemetryItem) { + try { + sender.processTelemetry(telemetryItem, null); + sender.flush(); + } catch (e) { + QUnit.assert.ok(false, "Unexpected error during telemetry processing"); + } + this.clock.tick(900000); // Simulate time passing for statsbeat to be sent + } + + private assertStatsbeatCall(statusCode: number, eventName: string) { + Assert.equal(this.statsbeatCountSpy.callCount, 1, "Statsbeat count should be called once"); + Assert.equal(this.statsbeatCountSpy.firstCall.args[0], statusCode, `Statsbeat count should be called with status ${statusCode}`); + const data = JSON.stringify(this.statsbeatCountSpy.firstCall.args[1]); + Assert.ok(data.includes("startTime"), "Statsbeat count should be called with startTime set"); + const statsbeatEvent = this.trackSpy.firstCall.args[0]; + Assert.equal(statsbeatEvent.baseType, "MetricData", "Statsbeat event should be of type MetricData"); + Assert.equal(statsbeatEvent.baseData.name, eventName, `Statsbeat event should be of type ${eventName}`); + } + + public registerTests() { + this.testCase({ + name: "Statsbeat initializes when stats is true", + test: () => { + const config = { + _sdk: { stats: true }, + instrumentationKey: "Test-iKey" + }; + + this._core.initialize(config, [this._sender]); + const statsbeat = this._core.getStatsBeat(); + + QUnit.assert.ok(statsbeat, "Statsbeat is initialized"); + QUnit.assert.ok(statsbeat.isInitialized(), "Statsbeat is marked as initialized"); + } + }); + + this.testCaseAsync({ + name: "Statsbeat increments success count when fetch sender is called once", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { // only fetch is supported to stub, why? + return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); + }); + + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + + this.processTelemetryAndFlush(sender, telemetryItem); + + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called && this.fetchStub.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + return true; + } + return false; + }, "Waiting for fetch sender and Statsbeat count to be called") as any) + }); + + this.testCaseAsync({ + name: "Statsbeat increments throttle count when fetch sender is called with status 439", + useFakeTimers: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { + return Promise.resolve(new Response("{}", { status: 439, statusText: "Too Many Requests" })); + }); + + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + + this.processTelemetryAndFlush(sender, telemetryItem); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called && this.fetchStub.called) { + this.assertStatsbeatCall(439, "Throttle_Count"); + return true; + } + return false; + }, "Waiting for fetch sender and Statsbeat count to be called") as any) + }); + + this.testCaseAsync({ + name: "Statsbeat increments success count for beacon sender", + useFakeTimers: true, + stepDelay: 100, + steps: [ + () => { + const config = this.createSenderConfig(TransportType.Beacon); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + let sendBeaconCalled = false; + this.hookSendBeacon((url: string) => { + sendBeaconCalled = true; + return true; + }); + QUnit.assert.ok(isBeaconApiSupported(), "Beacon API is supported"); + this.processTelemetryAndFlush(sender, telemetryItem); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + return true; + } + return false; + }, "Waiting for beacon sender and Statsbeat count to be called") as any) + }); + + + this.testCaseAsync({ + name: "Statsbeat increments success count for xhr sender", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + fakeServerAutoRespond: true, + steps: [ + () => { + let window = getWindow(); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? + let config = this.createSenderConfig(TransportType.Xhr) && {disableSendBeaconSplit: true}; + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + console.log("xhr sender called", this._getXhrRequests().length); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + this.processTelemetryAndFlush(sender, telemetryItem); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); + console.log("xhr sender is called", this._getXhrRequests().length); + (window as any).XMLHttpRequest = fakeXMLHttpRequest; + + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + console.log("Statsbeat count called with success count for xhr sender"); + return true; + } + return false; + }, "Waiting for xhr sender and Statsbeat count to be called", 60, 1000) as any) + }); +} +} \ No newline at end of file diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts index b4dd43ee5..97061376c 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts @@ -1,9 +1,11 @@ import { SenderTests } from "./Sender.tests"; import { SampleTests } from "./Sample.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; +import { StatsbeatTests } from "./StatsBeat.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new SenderTests().registerTests(); new SampleTests().registerTests(); + // new StatsbeatTests().registerTests(); } \ No newline at end of file diff --git a/channels/applicationinsights-channel-js/src/SendBuffer.ts b/channels/applicationinsights-channel-js/src/SendBuffer.ts index 91ea6d401..239425269 100644 --- a/channels/applicationinsights-channel-js/src/SendBuffer.ts +++ b/channels/applicationinsights-channel-js/src/SendBuffer.ts @@ -103,6 +103,8 @@ abstract class BaseSendBuffer { if (!isNullOrUndefined(_maxRetryCnt)) { if (payload.cnt > _maxRetryCnt) { // TODO: add log here on dropping payloads + // will log statsbeat exception later here + return; } } diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 9e76c676d..5a06d257c 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -7,20 +7,20 @@ import { import { ActiveStatus, BaseTelemetryPlugin, IAppInsightsCore, IBackendResponse, IChannelControls, IConfigDefaults, IConfiguration, IDiagnosticLogger, IInternalOfflineSupport, INotificationManager, IPayloadData, IPlugin, IProcessTelemetryContext, - IProcessTelemetryUnloadContext, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, IXDomainRequest, IXHROverride, - OnCompleteCallback, SendPOSTFunction, SendRequestReason, SenderPostManager, TransportType, _ISendPostMgrConfig, _ISenderOnComplete, - _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createProcessTelemetryContext, - createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, - isArray, isBeaconsSupported, isFetchSupported, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, - prependTransports, runTargetUnload + IProcessTelemetryUnloadContext, IStatsBeatConfig, IStatsBeatEvent, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, + IXDomainRequest, IXHROverride, OnCompleteCallback, SendPOSTFunction, SendRequestReason, SenderPostManager, TransportType, + _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, + createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, formatErrorMessageXdr, formatErrorMessageXhr, + getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFetchSupported, isNullOrUndefined, mergeEvtNamespace, objExtend, + onConfigChange, parseResponse, prependTransports, runTargetUnload } from "@microsoft/applicationinsights-core-js"; import { IPromise } from "@nevware21/ts-async"; import { ITimerHandler, isNumber, isPromiseLike, isString, isTruthy, mathFloor, mathMax, mathMin, objDeepFreeze, objDefine, scheduleTimeout } from "@nevware21/ts-utils"; import { - DependencyEnvelopeCreator, EventEnvelopeCreator, ExceptionEnvelopeCreator, MetricEnvelopeCreator, PageViewEnvelopeCreator, - PageViewPerformanceEnvelopeCreator, TraceEnvelopeCreator + DependencyEnvelopeCreator, EnvelopeCreator, EventEnvelopeCreator, ExceptionEnvelopeCreator, MetricEnvelopeCreator, + PageViewEnvelopeCreator, PageViewPerformanceEnvelopeCreator, TraceEnvelopeCreator } from "./EnvelopeCreator"; import { IInternalStorageItem, ISenderConfig } from "./Interfaces"; import { ArraySendBuffer, ISendBuffer, SessionStorageSendBuffer } from "./SendBuffer"; @@ -35,6 +35,7 @@ const FetchSyncRequestSizeLimitBytes = 65000; // approx 64kb (the current Edge, interface IInternalPayloadData extends IPayloadData { oriPayload: IInternalStorageItem[]; retryCnt?: number; + statsBeatData?: IStatsBeatEvent; } @@ -261,7 +262,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let diagLog = _self.diagLog(); _evtNamespace = mergeEvtNamespace(createUniqueNamespace("Sender"), core.evtNamespace && core.evtNamespace()); _offlineListener = createOfflineListener(_evtNamespace); - // This function will be re-called whenever any referenced configuration is changed _self._addHook(onConfigChange(config, (details) => { let config = details.cfg; @@ -272,6 +272,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // getExtCfg only finds undefined values from core let senderConfig = ctx.getExtCfg(identifier, defaultAppInsightsChannelConfig); let curExtUrl = senderConfig.endpointUrl; + // if it is not inital change (_endpointUrl has value) // if current sender endpoint url is not changed directly // means ExtCfg is not changed directly @@ -526,7 +527,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (_isStringArr(payload)) { return; } - return _xhrReadyStateChange(xhr, payload as IInternalStorageItem[],countOfItemsInPayload); + return _xhrReadyStateChange(xhr, payload as IInternalStorageItem[], countOfItemsInPayload); } @@ -574,6 +575,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { "Telemetry transmission failed, some telemetry will be lost: " + getExceptionName(e), { exception: dumpObj(e) }); } + // potential place to call countException q3 } } @@ -648,6 +650,15 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } + function _getStatsBeat() { + let statsBeatConfig = { + ikey: _self._senderConfig.instrumentationKey, + endpoint: _endpointUrl, + version: EnvelopeCreator.Version + } as IStatsBeatConfig; + return _self.core.getStatsBeat(statsBeatConfig); + } + function _xdrOnLoad (xdr: IXDomainRequest, payload: IInternalStorageItem[]) { const responseText = _getResponseText(xdr); if (xdr && (responseText + "" === "200" || responseText === "")) { @@ -670,29 +681,60 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { try { let onCompleteFuncs = { xdrOnComplete: (xdr: IXDomainRequest, oncomplete: OnCompleteCallback,payload?: IPayloadData) => { - let data = _getPayloadArr(payload); - if (!data) { + let payloadArr = _getPayloadArr(payload); + if (!payloadArr) { return; } - return _xdrOnLoad(xdr, data); + const responseText = _getResponseText(xdr); + let statsbeat = _getStatsBeat(); + if (statsbeat) { + if (xdr && (responseText + "" === "200" || responseText === "")) { + _consecutiveErrors = 0; + statsbeat.count(200, payload, _endpointUrl); + } else { + const results = parseResponse(responseText); + + if (results && results.itemsReceived && results.itemsReceived > results.itemsAccepted + && !_isRetryDisabled) { + statsbeat.count(206, payload, _endpointUrl); + } else { + statsbeat.count(499, payload, _endpointUrl); + } + } + } + + + + return _xdrOnLoad(xdr, payloadArr); }, fetchOnComplete: (response: Response, onComplete: OnCompleteCallback, resValue?: string, payload?: IPayloadData) => { - let data = _getPayloadArr(payload); - if (!data) { + let payloadArr = _getPayloadArr(payload); + if (!payloadArr) { return; } - return _checkResponsStatus(response.status, data, response.url, data.length, response.statusText, resValue || ""); + let statsbeat = _getStatsBeat(); + if (statsbeat) { + statsbeat.count(response.status, payload, _endpointUrl); + } + return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, xhrOnComplete: (request: XMLHttpRequest, oncomplete: OnCompleteCallback, payload?: IPayloadData) => { - let data = _getPayloadArr(payload); - if (!data) { + let payloadArr = _getPayloadArr(payload); + if (!payloadArr) { return; } - return _xhrReadyStateChange(request, data, data.length); - + let statsbeat = _getStatsBeat(); + if (statsbeat && request.readyState === 4) { + statsbeat.count(request.status, payload, _endpointUrl); + } + return _xhrReadyStateChange(request, payloadArr, payloadArr.length); }, beaconOnRetry: (data: IPayloadData, onComplete: OnCompleteCallback, canSend: (payload: IPayloadData, oncomplete: OnCompleteCallback, sync?: boolean) => boolean) => { + let statsbeat = _getStatsBeat(); + if (statsbeat) { + statsbeat.count(499, data, _endpointUrl); + } return _onBeaconRetry(data, onComplete, canSend); } @@ -792,7 +834,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let internalPayload = payload as IInternalPayloadData; let arr = internalPayload.oriPayload; if (arr && arr.length) { - return arr + return arr; } return null; } @@ -936,9 +978,16 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { + let statsbeat = _getStatsBeat(); + if (statsbeat) { + statsbeat.count(status, payloadData, _endpointUrl); + } return _getOnComplete(payload, status, headers, response); } let payloadData = _getPayload(payload); + if (payloadData) { + payloadData.statsBeatData = {startTime: dateNow()}; + } let sendPostFunc: SendPOSTFunction = sendInterface && sendInterface.sendPOST; if (sendPostFunc && payloadData) { // *********************************************************************************************** @@ -1039,7 +1088,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _self._onError(payload, errorMessage); } } else { - // check if the xhr's responseURL or fetch's response.url is same as endpoint url // TODO after 10 redirects force send telemetry with 'redirect=false' as query parameter. _checkAndUpdateEndPointUrl(responseUrl); diff --git a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts index 5aafcea69..0884aa9a4 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts @@ -1,9 +1,8 @@ import { AITestClass, Assert } from "@microsoft/ai-test-framework"; import { NonOverrideCfg } from "../../../src/Interfaces/ICfgSyncConfig"; -import { AppInsightsCore, CdnFeatureMode, FeatureOptInMode, IAppInsightsCore, IConfiguration, IFeatureOptIn, IFeatureOptInDetails, INotificationManager, IPlugin, ITelemetryItem, PerfManager } from "@microsoft/applicationinsights-core-js"; +import { ICookieMgrConfig, AppInsightsCore, CdnFeatureMode, FeatureOptInMode, IAppInsightsCore, IConfiguration, IFeatureOptIn, IFeatureOptInDetails, INotificationManager, IPlugin, ITelemetryItem, PerfManager } from "@microsoft/applicationinsights-core-js"; import { IConfig, IStorageBuffer } from "@microsoft/applicationinsights-common"; import { resolveCdnFeatureCfg, replaceByNonOverrideCfg, applyCdnfeatureCfg } from "../../../src/CfgSyncHelperFuncs"; -import { ICookieMgrConfig } from "@microsoft/applicationinsights-core-js/src/applicationinsights-core-js"; import { ICfgSyncCdnConfig } from "../../../src/Interfaces/ICfgSyncCdnConfig"; export class CfgSyncHelperTests extends AITestClass { @@ -102,7 +101,10 @@ export class CfgSyncHelperTests extends AITestClass { extensions:[{isFlushInvoked:false,isTearDownInvoked:false,isResumeInvoked:false,isPauseInvoked:false,identifier:"Sender",priority:1001}], channels:[], extensionConfig:{}, - enableDebug: false + _sdk: { + stats: false + }, + enableDebug: false, } let core = new AppInsightsCore(); diff --git a/shared/AppInsightsCore/Tests/Unit/src/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/StatsBeat.Tests.ts new file mode 100644 index 000000000..dafedc25f --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/StatsBeat.Tests.ts @@ -0,0 +1,280 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { AppInsightsCore, IAppInsightsCore, IConfiguration, IPlugin, ITelemetryItem } from "../../../src/applicationinsights-core-js"; +import { Statsbeat } from "../../../src/JavaScriptSDK/StatsBeat"; +import { IPayloadData } from "../../../src/JavaScriptSDK.Interfaces/IXHROverride"; +import * as sinon from "sinon"; +const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes + +export class StatsBeatTests extends AITestClass { + private _core: AppInsightsCore; + private _config: IConfiguration; + private _statsbeat: Statsbeat; + private _trackSpy: sinon.SinonSpy; + + constructor(emulateIe: boolean) { + super("StatsBeatTests", emulateIe); + } + + public testInitialize() { + let _self = this; + super.testInitialize(); + + _self._config = { + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true, + _sdk: { + stats: true // Enable statsbeat by default + } + }; + + _self._core = new AppInsightsCore(); + _self._statsbeat = new Statsbeat(); + + // Create spy for tracking telemetry + _self._trackSpy = this.sandbox.spy(_self._core, "track"); + } + + public testCleanup() { + super.testCleanup(); + this._core = null; + this._statsbeat = null; + } + + public registerTests() { + + this.testCase({ + name: "StatsBeat: Initialization", + test: () => { + // Test with no initialization + Assert.equal(false, this._statsbeat.isInitialized(), "StatsBeat should not be initialized by default"); + + // Initialize and test + this._statsbeat.initialize(this._core, { + ikey: "Test-iKey", + endpoint: "https://example.endpoint.com", + version: "1.0.0" + }); + Assert.equal(true, this._statsbeat.isInitialized(), "StatsBeat should be initialized after initialization"); + } + }); + + this.testCase({ + name: "StatsBeat: count method tracks request metrics", + useFakeTimers: true, + test: () => { + // Initialize StatsBeat + this._statsbeat.initialize(this._core, { + ikey: "Test-iKey", + endpoint: "https://example.endpoint.com", + version: "1.0.0" + }); + + // Create mock payload data with timing information + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsBeatData: { + startTime: "2023-10-01T00:00:00Z" // Simulated start time + } + } as IPayloadData; + + // Test successful request + this._statsbeat.count(200, payloadData, "https://example.endpoint.com"); + + // Test failed request + this._statsbeat.count(500, payloadData, "https://example.endpoint.com"); + + // Test throttled request + this._statsbeat.count(429, payloadData, "https://example.endpoint.com"); + + // Verify that trackStatsbeats is called when the timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + + // Verify that track was called + Assert.ok(this._trackSpy.called, "track should be called when statsbeat timer fires"); + + // When the timer fires, multiple metrics should be sent + Assert.ok(this._trackSpy.callCount >= 3, "Multiple metrics should be tracked"); + } + }); + + this.testCase({ + name: "StatsBeat: countException method tracks exceptions", + useFakeTimers: true, + test: () => { + // Initialize StatsBeat + this._statsbeat.initialize(this._core, { + ikey: "Test-iKey", + endpoint: "https://example.endpoint.com", + version: "1.0.0" + }); + + // Count an exception + this._statsbeat.countException("https://example.endpoint.com", "NetworkError"); + + // Verify that trackStatsbeats is called when the timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + + // Verify that track was called + Assert.ok(this._trackSpy.called, "track should be called when statsbeat timer fires"); + + // Check that exception metrics are tracked + let foundExceptionMetric = false; + for (let i = 0; i < this._trackSpy.callCount; i++) { + const call = this._trackSpy.getCall(i); + const item: ITelemetryItem = call.args[0]; + if (item.baseData && + item.baseData.properties && + item.baseData.properties.exceptionType === "NetworkError") { + foundExceptionMetric = true; + break; + } + } + + Assert.ok(foundExceptionMetric, "Exception metrics should be tracked"); + } + }); + + this.testCase({ + name: "StatsBeat: does not send metrics for different endpoints", + useFakeTimers: true, + test: () => { + // Initialize StatsBeat for a specific endpoint + this._statsbeat.initialize(this._core, { + ikey: "Test-iKey", + endpoint: "https://example.endpoint.com", + version: "1.0.0" + }); + + // Create mock payload data + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsBeatData: { + startTime: Date.now() + } + } as IPayloadData; + + // Set up spies to check internal calls + const countSpy = this.sandbox.spy(this._statsbeat, "count"); + + // Count metrics for a different endpoint + this._statsbeat.count(200, payloadData, "https://different.endpoint.com"); + + // Verify that trackStatsbeats is called when the timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + // The count method was called, but it should return early + Assert.equal(1, countSpy.callCount, "count method should be called"); + Assert.equal(0, this._trackSpy.callCount, "track should not be called for different endpoint"); + } + }); + + this.testCase({ + name: "StatsBeat: test dynamic configuration changes", + useFakeTimers: true, + test: () => { + // Setup core with statsbeat enabled + this._core.initialize(this._config, [new ChannelPlugin()]); + + // Verify that statsbeat is created + const statsbeat = this._core.getStatsBeat(); + Assert.ok(statsbeat, "Statsbeat should be created"); + + this._core.config._sdk.stats = false; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that statsbeat is removed + const updatedStatsbeat = this._core.getStatsBeat(); + Assert.ok(!updatedStatsbeat, "Statsbeat should be removed when disabled"); + + // Re-enable statsbeat + this._core.config._sdk.stats = true; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that statsbeat is created again + const reenabledStatsbeat = this._core.getStatsBeat(); + Assert.ok(reenabledStatsbeat, "Statsbeat should be recreated when re-enabled"); + + // Test that statsbeat is not created when disabled with undefined + this._core.config._sdk.stats = undefined; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that statsbeat is removed + Assert.ok(!this._core.getStatsBeat(), "Statsbeat should be removed when disabled"); + + // Re-enable statsbeat + this._core.config._sdk.stats = true; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that statsbeat is created again + Assert.ok( this._core.getStatsBeat(), "Statsbeat should be recreated when re-enabled"); + + // Test that statsbeat is not created when disabled with null value + this._core.config._sdk.stats = null; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that statsbeat is removed + Assert.ok(!this._core.getStatsBeat(), "Statsbeat should be removed when disabled"); + } + }); + } +} + +class ChannelPlugin implements IPlugin { + public isFlushInvoked = false; + public isTearDownInvoked = false; + public isResumeInvoked = false; + public isPauseInvoked = false; + + public identifier = "Sender"; + public priority: number = 1001; + + constructor() { + this.processTelemetry = this._processTelemetry.bind(this); + } + + public pause(): void { + this.isPauseInvoked = true; + } + + public resume(): void { + this.isResumeInvoked = true; + } + + public teardown(): void { + this.isTearDownInvoked = true; + } + + flush(async?: boolean, callBack?: () => void): void { + this.isFlushInvoked = true; + if (callBack) { + callBack(); + } + } + + public processTelemetry(env: ITelemetryItem) {} + + setNextPlugin(next: any) { + // no next setup + } + + public initialize = (config: IConfiguration, core: IAppInsightsCore, plugin: IPlugin[]) => { + } + + private _processTelemetry(env: ITelemetryItem) { + } +} + +class CustomTestError extends Error { + constructor(message = "") { + super(message); + this.name = "CustomTestError"; + this.message = message + " -- test error."; + } +} \ No newline at end of file diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index 6fc31aebc..8a1f8bbdb 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -11,6 +11,7 @@ import { UpdateConfigTests } from "./UpdateConfig.Tests"; import { EventsDiscardedReasonTests } from "./EventsDiscardedReason.Tests"; import { W3cTraceParentTests } from "./W3cTraceParentTests"; import { DynamicConfigTests } from "./DynamicConfig.Tests"; +import { StatsBeatTests } from './StatsBeat.Tests'; export function runTests() { new GlobalTestHooks().registerTests(); @@ -26,4 +27,6 @@ export function runTests() { new UpdateConfigTests().registerTests(); new EventsDiscardedReasonTests().registerTests(); new W3cTraceParentTests().registerTests(); + // new StatsBeatTests(false).registerTests(); + // new StatsBeatTests(true).registerTests(); } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts index fbb62ba7c..80893b388 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts @@ -16,6 +16,7 @@ import { INotificationListener } from "./INotificationListener"; import { INotificationManager } from "./INotificationManager"; import { IPerfManagerProvider } from "./IPerfManager"; import { IProcessTelemetryContext } from "./IProcessTelemetryContext"; +import { IStatsBeat, IStatsBeatConfig } from "./IStatsBeat"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "./ITelemetryInitializers"; import { ITelemetryItem } from "./ITelemetryItem"; import { IPlugin, ITelemetryPlugin } from "./ITelemetryPlugin"; @@ -124,6 +125,8 @@ export interface IAppInsightsCore = objDeepFreeze({ [STR_EXTENSION_CONFIG]: { ref: true, v: {} }, [STR_CREATE_PERF_MGR]: UNDEFINED_VALUE, loggingLevelConsole: eLoggingSeverity.DISABLED, - diagnosticLogInterval: UNDEFINED_VALUE + diagnosticLogInterval: UNDEFINED_VALUE, + _sdk: cfgDfMerge({ + stats: false + }) }); /** @@ -275,6 +281,7 @@ export class AppInsightsCore im let _logger: IDiagnosticLogger; let _eventQueue: ITelemetryItem[]; let _notificationManager: INotificationManager | null | undefined; + let _statsBeat: IStatsBeat | null; let _perfManager: IPerfManager | null; let _cfgPerfManager: IPerfManager | null; let _cookieManager: ICookieMgr | null; @@ -351,6 +358,13 @@ export class AppInsightsCore im _initInMemoMaxSize = rootCfg.initInMemoMaxSize || maxInitQueueSize; + // uncomment this until throttle is implemented + // if (config._sdk.stats === true){ + // _statsBeat = _statsBeat || new Statsbeat(); + // } else { + // _statsBeat = null; + // } + _handleIKeyEndpointPromises(rootCfg); // Mark the extensionConfig and all first level keys as referenced @@ -500,6 +514,18 @@ export class AppInsightsCore im return _perfManager || _cfgPerfManager || getGblPerfMgr(); }; + _self.getStatsBeat = (statsBeatConfig?: IStatsBeatConfig): IStatsBeat => { + // create a new statsbeat if not initialize yet or the endpoint is different + // otherwise, return the existing one, or null + + // uncomment this until throttle is implemented + // if (statsBeatConfig && this.config._sdk.stats === true && _statsBeat && _statsBeat.getEndpoint() !== statsBeatConfig.endpoint) { + // _statsBeat = new Statsbeat(); + // _statsBeat.initialize(this, statsBeatConfig); + // } + return _statsBeat; + }; + _self.setPerfMgr = (perfMgr: IPerfManager) => { _perfManager = perfMgr; }; @@ -1018,6 +1044,7 @@ export class AppInsightsCore im runTargetUnload(_notificationManager, false); _notificationManager = null; _perfManager = null; + _statsBeat = null; _cfgPerfManager = null; runTargetUnload(_cookieManager, false); _cookieManager = null; @@ -1430,6 +1457,11 @@ export class AppInsightsCore im return null; } + public getStatsBeat(statsBeatConfig?: IStatsBeatConfig): IStatsBeat { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + public setPerfMgr(perfMgr: IPerfManager) { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/NetworkStatsbeat.ts b/shared/AppInsightsCore/src/JavaScriptSDK/NetworkStatsbeat.ts new file mode 100644 index 000000000..a7dcdc835 --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK/NetworkStatsbeat.ts @@ -0,0 +1,22 @@ +export interface NetworkStatsbeat { + host: string; + totalRequest: number; + success: number; + throttle: Record; + failure: Record; + retry: Record; + exception: Record; + requestDuration: number; +} +export function createNetworkStatsbeat(host: string): NetworkStatsbeat { + return { + host, + totalRequest: 0, + success: 0, + throttle: {}, + failure: {}, + retry: {}, + exception: {}, + requestDuration: 0 + }; +} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts b/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts index f27d1da09..40d1d5be9 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts @@ -249,7 +249,7 @@ export class SenderPostManager { } else { // if can send - _onSuccess(STR_EMPTY, oncomplete); + _onSuccess(STR_EMPTY, oncomplete); // if success, onComplete is called with status code 200 } } @@ -429,12 +429,14 @@ export class SenderPostManager { } - function _handleError(res?: string) { + function _handleError(res?: string, statusCode?: number) { // In case there is an error in the request. Set the status to 0 for 1ds and 400 for appInsights // so that the events can be retried later. - - _doOnComplete(oncomplete, _isOneDs? 0 : 400, {}, _isOneDs? STR_EMPTY: res); - + if (statusCode) { + _doOnComplete(oncomplete, _isOneDs? 0 : statusCode, {}, _isOneDs? STR_EMPTY: res); + } else { + _doOnComplete(oncomplete, _isOneDs? 0 : 400, {}, _isOneDs? STR_EMPTY: res); + } } function _onFetchComplete(response: Response, payload?: IPayloadData, value?: string) { @@ -468,7 +470,11 @@ export class SenderPostManager { */ if (!_isOneDs && !response.ok) { // this is for appInsights only - _handleError(response.statusText); + if (response.status){ + _handleError(response.statusText, response.status); + } else { + _handleError(response.statusText, 499); + } resolveFunc && resolveFunc(false); } else { if (_isOneDs && !response.body) { @@ -484,19 +490,23 @@ export class SenderPostManager { } } catch (e) { - _handleError(dumpObj(e)); + if (response && response.status){ + _handleError(dumpObj(e), response.status); + } else { + _handleError(dumpObj(e), 499); + } rejectFunc && rejectFunc(e); } } else { - _handleError(result.reason && result.reason.message); + _handleError(result.reason && result.reason.message, 499); rejectFunc && rejectFunc(result.reason); } } }); } catch (e) { if (!responseHandled) { - _handleError(dumpObj(e)); + _handleError(dumpObj(e), 499); rejectFunc && rejectFunc(e); } } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/StatsBeat.ts b/shared/AppInsightsCore/src/JavaScriptSDK/StatsBeat.ts new file mode 100644 index 000000000..97ffd8d9c --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK/StatsBeat.ts @@ -0,0 +1,203 @@ +import dynamicProto from "@microsoft/dynamicproto-js"; +import { ITimerHandler, scheduleTimeout, utcNow } from "@nevware21/ts-utils"; +import { IAppInsightsCore } from "../JavaScriptSDK.Interfaces/IAppInsightsCore"; +import { IStatsBeat, IStatsBeatConfig } from "../JavaScriptSDK.Interfaces/IStatsBeat"; +import { ITelemetryItem } from "../JavaScriptSDK.Interfaces/ITelemetryItem"; +import { IPayloadData } from "../JavaScriptSDK.Interfaces/IXHROverride"; +import { NetworkStatsbeat, createNetworkStatsbeat } from "./NetworkStatsbeat"; + +const INSTRUMENTATION_KEY = "c4a29126-a7cb-47e5-b348-11414998b11e"; +const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes +const STATSBEAT_LANGUAGE = "JavaScript"; +const STATSBEAT_TYPE = "Browser"; + +export class Statsbeat implements IStatsBeat { + constructor() { + let _networkCounter: NetworkStatsbeat; + let _isEnabled: boolean = false; + let _core: IAppInsightsCore; + let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. + // Custom dimensions + let _cikey: string; + let _language: string; + let _sdkVersion: string; + let _os: string; + dynamicProto(Statsbeat, this, (_self, _base) => { + _self.initialize = (core: IAppInsightsCore, statsBeatConfig: IStatsBeatConfig) => { + _core = core; + _networkCounter = createNetworkStatsbeat(statsBeatConfig.endpoint); + _isEnabled = true; + _sdkVersion = statsBeatConfig.version; + _getCustomProperties(statsBeatConfig.ikey); + } + + _self.isInitialized = (): boolean => { + return !!_isEnabled; + } + + _self.count = (status: number, payloadData: IPayloadData, endpoint: string) => { + if (!_isEnabled || !_checkEndpoint(endpoint)) { + return; + } + if (payloadData && payloadData["statsBeatData"] && payloadData["statsBeatData"]["startTime"]) { + _networkCounter.totalRequest = (_networkCounter.totalRequest || 0) + 1; + _networkCounter.requestDuration += utcNow() - payloadData["statsBeatData"]["startTime"]; + } + let retryArray = [401, 403, 408, 429, 500, 502, 503, 504]; + let throttleArray = [402, 439]; + if (status >= 200 && status < 300) { + _networkCounter.success++; + } else if (retryArray.indexOf(status) !== -1) { + _networkCounter.retry[status] = (_networkCounter.retry[status] || 0) + 1; + } else if (throttleArray.indexOf(status) !== -1) { + _networkCounter.throttle[status] = (_networkCounter.throttle[status] || 0) + 1; + } else if (status !== 307 && status !== 308) { + _networkCounter.failure[status] = (_networkCounter.failure[status] || 0) + 1; + } + _setupTimer(); + }; + + _self.getEndpoint = (): string => { + return _networkCounter?_networkCounter.host:null; + } + + _self.countException = (endpoint: string, exceptionType: string) => { + if (!_isEnabled || !_checkEndpoint(endpoint)) { + return; + } + _networkCounter.exception[exceptionType] = (_networkCounter.exception[exceptionType] || 0) + 1; + _setupTimer(); + } + + function _setupTimer() { + if (!_timeoutHandle) { + _timeoutHandle = scheduleTimeout(() => { + _timeoutHandle = null; + trackStatsbeats(); + }, STATS_COLLECTION_SHORT_INTERVAL); + } + } + + function trackStatsbeats(){ + _trackSendRequestDuration(); + _trackSendRequestsCount(); + _networkCounter = createNetworkStatsbeat(_networkCounter.host); + _timeoutHandle && _timeoutHandle.cancel(); + _timeoutHandle = null; + } + + function _checkEndpoint(endpoint: string) { + return _networkCounter && _networkCounter.host === endpoint; + } + + function _getCustomProperties(ikey: string) { + _cikey = ikey; + _language = STATSBEAT_LANGUAGE; + _os = STATSBEAT_TYPE; + } + + function _sendStatsbeats(name: string, val: number, properties?: { [name: string]: any }) { + if (!val || val <= 0){ + return; + } + // Add extra properties + let baseProperties = { + "rp": "unknown", + "attach": "Manual", + "cikey": _cikey, + "os": _os, + "language": _language, + "version": _sdkVersion, + "endpoint": "breeze", + "host": _networkCounter.host + } as { [key: string]: any }; + + // Manually merge properties instead of using spread syntax + let combinedProps: { [key: string]: any } = { "host": _networkCounter.host }; + + // Add properties if present + if (properties) { + for (let key in properties) { + if (properties.hasOwnProperty(key)) { + combinedProps[key] = properties[key]; + } + } + } + // Add base properties + for (let key in baseProperties) { + if (baseProperties.hasOwnProperty(key)) { + combinedProps[key] = baseProperties[key]; + } + } + let statsbeatEvent: ITelemetryItem = { + iKey: INSTRUMENTATION_KEY, + name: name, + baseData: { + name: name, + average: val, + properties: combinedProps + }, + baseType: "MetricData" + }; + _core.track(statsbeatEvent); + } + + function _trackSendRequestDuration() { + var totalRequest = _networkCounter.totalRequest; + + if (_networkCounter.totalRequest > 0 ) { + let averageRequestExecutionTime = _networkCounter.requestDuration / totalRequest; + _sendStatsbeats("Request_Duration", averageRequestExecutionTime); + } + } + + function _trackSendRequestsCount() { + var currentCounter = _networkCounter; + _sendStatsbeats("Request_Success_Count", currentCounter.success); + + for (const code in currentCounter.failure) { + const count = currentCounter.failure[code]; + _sendStatsbeats("failure", count, { statusCode: code }); + } + + for (const code in currentCounter.retry) { + const count = currentCounter.retry[code]; + _sendStatsbeats("retry", count, { statusCode: code }); + } + + for (const code in currentCounter.exception) { + const count = currentCounter.exception[code]; + _sendStatsbeats("exception", count, { exceptionType: code }); + } + + for (const code in currentCounter.throttle) { + const count = currentCounter.throttle[code]; + _sendStatsbeats("Throttle_Count", count, { statusCode: code }); + } + } + }); + } + + public initialize(core: IAppInsightsCore, statsBeatConfig: IStatsBeatConfig) { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + public isInitialized(): boolean { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return false; + } + + public count(status: number, payloadData: IPayloadData, endpoint: string) { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + public countException(endpoint: string, exceptionType: string) { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + public getEndpoint(): string { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + +} diff --git a/shared/AppInsightsCore/src/applicationinsights-core-js.ts b/shared/AppInsightsCore/src/applicationinsights-core-js.ts index 78cc7151f..6af27edca 100644 --- a/shared/AppInsightsCore/src/applicationinsights-core-js.ts +++ b/shared/AppInsightsCore/src/applicationinsights-core-js.ts @@ -36,6 +36,8 @@ export { parseResponse } from "./JavaScriptSDK/ResponseHelpers"; export { IXDomainRequest, IBackendResponse } from "./JavaScriptSDK.Interfaces/IXDomainRequest"; export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IInternalXhrOverride } from "./JavaScriptSDK.Interfaces/ISenderPostManager"; export { SenderPostManager } from "./JavaScriptSDK/SenderPostManager"; +export { IStatsBeatEvent } from "./JavaScriptSDK.Interfaces/IStatsBeatEvent"; +export { IStatsBeat, IStatsBeatConfig } from "./JavaScriptSDK.Interfaces/IStatsBeat"; export { isArray, isTypeof, isUndefined, isNullOrUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, strEndsWith, strStartsWith, isDate, isError, isString, isNumber, isBoolean, arrForEach, arrIndexOf,