diff --git a/.aiAutoMinify.json b/.aiAutoMinify.json index 5c3314d09..406527b2a 100644 --- a/.aiAutoMinify.json +++ b/.aiAutoMinify.json @@ -22,7 +22,6 @@ "eLoggingSeverity", "_eInternalMessageId", "SendRequestReason", - "eStatsType", "TelemetryUnloadReason", "TelemetryUpdateReason", "eTraceHeadersMode", diff --git a/AISKU/Tests/Manual/testVersionConflict.html b/AISKU/Tests/Manual/testVersionConflict.html index 1e1b5e997..2eb37aea1 100644 --- a/AISKU/Tests/Manual/testVersionConflict.html +++ b/AISKU/Tests/Manual/testVersionConflict.html @@ -20,7 +20,7 @@ // onInit: null, // Once the application insights instance has loaded and initialized this callback function will be called with 1 argument -- the sdk instance (DO NOT ADD anything to the sdk.queue -- As they won't get called) sri: true, // Custom optional value to specify whether fetching the snippet from integrity file and do integrity check cfg: { // Application Insights Configuration - disableStatsBeat: false, + disableInternalSdkStats: false, connectionString: "YOUR_INSTRUMENTATION_KEY", } }); diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index 88a79eca8..4d13a1bc1 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -54,10 +54,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 177; - private readonly MAX_BUNDLE_SIZE = 177; - private readonly MAX_RAW_DEFLATE_SIZE = 71; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 71; + private readonly MAX_RAW_SIZE = 181; + private readonly MAX_BUNDLE_SIZE = 182; + private readonly MAX_RAW_DEFLATE_SIZE = 74; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 74; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.4.3"; diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index 884092241..a39963900 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -40,6 +40,7 @@ export class SdkStatsFeatureTests extends AITestClass { this._testSdkStatsDynamicEnableDisable(); this._testSdkStatsConfigDefaults(); this._testSdkStatsDynamicConfigChanges(); + this._testSnippetSdkVersion(); } private _createAi(configOverrides?: Partial): AppInsightsSku { @@ -303,4 +304,30 @@ export class SdkStatsFeatureTests extends AITestClass { } }); } + + private _testSnippetSdkVersion() { + this.testCase({ + name: "SdkStatsFeature: snippet version is included in the SDK Stats version", + useFakeTimers: true, + test: () => { + let config = { + connectionString: TestConnectionString, + stats: {}, + extensionConfig: { + ["AppInsightsCfgSyncPlugin"]: { + syncMode: ICfgSyncMode.Receive, + cfgUrl: "" + } + } + } as IConfiguration & IConfig; + let ai = new AppInsightsSku({ config: config, sv: "6", queue: [] } as any); + ai.loadAppInsights(); + this._ai = ai; + this.clock.tick(1); + + Assert.equal("6", ai.config.stats.snp, "SDK Stats config should contain snippet version 6"); + } + }); + } + } diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 03660c85f..1dc08cd12 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -15,9 +15,9 @@ import { ITelemetryInitializerHandler, ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, IThrottleInterval, IThrottleLimit, IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, PropertiesPluginIdentifier, ThrottleMgr, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, - cfgDfValidate, createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsNotifCbk, createTraceProvider, - createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, isNullOrUndefined, - isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, + cfgDfValidate, createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsNotifCbk, createStatsMgr, + createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, + isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, removePageHideEventListener, removePageUnloadEventListener, useSpan } from "@microsoft/applicationinsights-core-js"; import { @@ -66,7 +66,7 @@ const CDN_USAGE = "CdnUsage"; const SDK_LOADER_VER = "SdkLoaderVer"; const ZIP_PAYLOAD = "zipPayload"; const SDK_STATS = "SdkStats"; -var _sdkVersion = "#version#"; +var _sdkVersion = '3.4.3'; const default_limit = { samplingRate: 100, @@ -398,6 +398,31 @@ export class AppInsightsSku implements IApplicationInsights(_core, (statsConfig) => { + try { + let statsCore = new AppInsightsCore(); + (statsConfig as IConfiguration & IConfig).maxBatchInterval = 1; + statsCore.initialize(statsConfig as IConfiguration & IConfig, [new Sender()]); + return statsCore; + } catch (e) { + _throwInternal(_core.logger, eLoggingSeverity.WARNING, + _eInternalMessageId.InternalSdkStatsManagerException, "Failed to create SDK Stats core"); + return null; + } + }); + if (statsHook) { + _core.addUnloadHook(statsHook); + } + } + // Initialize the initial OTel API _otelApi = _initOTel(_self, "aisku", _onEnd, _onException); @@ -413,6 +438,10 @@ export class AppInsightsSku implements IApplicationInsights true, + track: (item: ITelemetryItem) => { + }, + unload: () => { + } + } as any; + return this.statsCore; + } + + private initializeCoreAndSender(config: any, instrumentationKey: string) { + const sender = new Sender(); + const core = new AppInsightsCore(); + const coreConfig = { + instrumentationKey, + stats: { + shrtInt: 900, + // The config url gates collection, without it nothing is collected or sent + cfgUrl: "https://data.stats.monitor.azure.com/cfg/v1.json", + iKey: "Stats-Test-iKey", + snp: "6", + // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests + // do not depend on a network fetch of the cfg/v1.json endpoint. + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + } + }, + extensionConfig: { [sender.identifier]: config } + }; + + let statsMgr = createStatsMgr(); + // Initialize the core first, then init the manager against that same (now initialized) + // core so it can enable itself (createStatsMgr().init() only enables once the core is initialized). + core.initialize(coreConfig, [sender]); + let unloadHook = statsMgr.init(core, (config) => this.createStatsCore(config), "InternalSdkStats"); + core.setStatsMgr(statsMgr); + this._statsMgrUnloadHook = unloadHook; + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: instrumentationKey, + endpoint: config.endpointUrl, + sdkVer: "javascript:3.4.3:snp6", + }; + + this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); + this.onDone(() => { + sender.teardown(); + }); + + return { core, sender, statsMgr, unloadHook }; + } + + 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 internalSdkStats to be sent + } + + private assertInternalSdkStatsCall(statusCode: number) { + Assert.equal(this.internalSdkStatsCountSpy.callCount, 1, "SDK Stats count should be called once"); + Assert.equal(this.internalSdkStatsCountSpy.firstCall.args[0], statusCode, `InternalSdkStats count should be called with status ${statusCode}`); + const data = JSON.stringify(this.internalSdkStatsCountSpy.firstCall.args[1]); + Assert.ok(data.includes("startTime"), "SDK Stats count should be called with startTime set"); + } + + public registerTests() { + this.testCase({ + name: "SDK Stats initializes when stats is true", + test: () => { + const config = { + instrumentationKey: "Test-iKey", + featureOptIn: { + "InternalSdkStats": { + mode: FeatureOptInMode.enable + } + }, + stats: { + shrtInt: 900, + cfgUrl: "https://data.stats.monitor.azure.com/cfg/v1.json", + iKey: "Stats-Test-iKey", + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + } + } + + }; + + this._core.initialize(config, [this._sender]); + this._statsMgrUnloadHook = this._statsMgr.init( + this._core, (statsConfig) => this.createStatsCore(statsConfig), "InternalSdkStats" + ); + this._core.setStatsMgr(this._statsMgr); + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + + const internalSdkStats = this._core.getSdkStats(internalSdkStatsState); + + QUnit.assert.ok(internalSdkStats, "SDK Stats is initialized"); + QUnit.assert.ok(internalSdkStats.enabled, "SDK Stats is marked as initialized"); + } + }); + + this.testCaseAsync({ + name: "SDK Stats 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.internalSdkStatsCountSpy.called && this.fetchStub.called) { + this.assertInternalSdkStatsCall(200); + return true; + } + return false; + }, "Waiting for fetch sender and SDK Stats count to be called") as any) + }); + + this.testCaseAsync({ + name: "SDK Stats 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.internalSdkStatsCountSpy.called && this.fetchStub.called) { + this.assertInternalSdkStatsCall(439); + return true; + } + return false; + }, "Waiting for fetch sender and SDK Stats count to be called") as any) + }); + + this.testCaseAsync({ + name: "SDK Stats 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(isBeaconsSupported(), "Beacon API is supported"); + this.processTelemetryAndFlush(sender, telemetryItem); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.internalSdkStatsCountSpy.called) { + this.assertInternalSdkStatsCall(200); + return true; + } + return false; + }, "Waiting for beacon sender and SDK Stats count to be called") as any) + }); + + + this.testCaseAsync({ + name: "SDK Stats 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: any = this.createSenderConfig(TransportType.Xhr); + config.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.internalSdkStatsCountSpy.called) { + this.assertInternalSdkStatsCall(200); + console.log("SDK Stats count called with success count for xhr sender"); + return true; + } + return false; + }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000) as any) + }); + } +} diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts deleted file mode 100644 index 7b85a2396..000000000 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ /dev/null @@ -1,322 +0,0 @@ -// import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; -// import { AppInsightsCore, createStatsMgr, eStatsType, FeatureOptInMode, getWindow, IPayloadData, IStatsBeatState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; -// import { Sender } from "../../../src/Sender"; -// import { SinonSpy, SinonStub } from "sinon"; -// import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; -// import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; - -// export class StatsbeatTests extends AITestClass { -// private _core: AppInsightsCore; -// private _sender: Sender; -// private _statsMgr: IStatsMgr; -// private _statsMgrUnloadHook: IUnloadHook | null; -// private statsbeatCountSpy: SinonSpy; -// private fetchStub: sinon.SinonStub; -// private beaconStub: sinon.SinonStub; -// private trackSpy: SinonSpy; - -// public testInitialize() { -// this._core = new AppInsightsCore(); -// this._sender = new Sender(); -// this._statsMgr = createStatsMgr(); -// } - -// public testFinishedCleanup() { -// if (this._sender && this._sender.isInitialized()) { -// this._sender.pause(); -// this._sender._buffer.clear(); -// this._sender.teardown(); -// } -// this._sender = null; -// this._core = null; -// this._statsMgr = null; -// if (this._statsMgrUnloadHook) { -// this._statsMgrUnloadHook.rm(); -// this._statsMgrUnloadHook = 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: { -// shrtInt: 900, -// endCfg: [ -// { -// type: 0, -// keyMap: [ -// { -// key: "stats-key1", -// match: [ "https://example.endpoint.com" ] -// } -// ] -// } -// ] -// } -// }, -// extensionConfig: { [sender.identifier]: config } -// }; - -// let statsMgr = createStatsMgr(); -// // Initialize -// let unloadHook = statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); - -// core.initialize(coreConfig, [sender]); -// core.setStatsMgr(statsMgr); - -// this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(), "count"); -// this.trackSpy = this.sandbox.spy(core, "track"); - -// this.onDone(() => { -// sender.teardown(); -// }); - -// return { core, sender, statsMgr, unloadHook }; -// } - -// 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 = { -// instrumentationKey: "Test-iKey", -// featureOptIn: { -// "StatsBeat": { -// mode: FeatureOptInMode.enable -// } -// }, -// _sdk: { -// stats: { -// shrtInt: 900, -// endCfg: [ -// { -// type: 0, -// keyMap: [ -// { -// key: "stats-key1", -// match: [ "https://example.endpoint.com" ] -// } -// ] -// } -// ] -// } -// }, -// }; - -// this._core.initialize(config, [this._sender]); -// this._statsMgrUnloadHook = this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; - -// const statsbeat = this._core.getStatsBeat(statsBeatState); - -// QUnit.assert.ok(statsbeat, "Statsbeat is initialized"); -// QUnit.assert.ok(statsbeat.enabled, "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(isBeaconsSupported(), "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 477265d46..105f350c2 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts @@ -1,11 +1,11 @@ -import { SenderTests } from "./Sender.tests"; +import { SenderTests } from "./Sender.tests"; import { SampleTests } from "./Sample.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; -// import { StatsbeatTests } from "./StatsBeat.tests"; +import { InternalSdkStatsTests } from "./InternalSdkStats.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new SenderTests().registerTests(); new SampleTests().registerTests(); - // new StatsbeatTests().registerTests(); + new InternalSdkStatsTests().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 961eca1f0..e1d282646 100644 --- a/channels/applicationinsights-channel-js/src/SendBuffer.ts +++ b/channels/applicationinsights-channel-js/src/SendBuffer.ts @@ -102,7 +102,7 @@ abstract class BaseSendBuffer { if (!isNullOrUndefined(_maxRetryCnt)) { if (payload.cnt > _maxRetryCnt) { // TODO: add log here on dropping payloads - // will log statsbeat exception later here + // will log internalSdkStats exception later here return; } diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 751da8ef5..defda7bec 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -2,15 +2,16 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { ActiveStatus, BaseTelemetryPlugin, BreezeChannelIdentifier, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, EventDataType, ExceptionDataType, IAppInsightsCore, IBackendResponse, IChannelControls, IConfig, IConfigDefaults, IConfiguration, IDiagnosticLogger, - IEnvelope, IInternalOfflineSupport, INotificationManager, IOfflineListener, IPayloadData, IPlugin, IProcessTelemetryContext, - IProcessTelemetryUnloadContext, ISample, IStorageBuffer, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, IXDomainRequest, - IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, - RequestDataType, RequestHeaders, SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, TransportType, - _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, - createOfflineListener, createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, - formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, - isFetchSupported, isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, - prependTransports, runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix + IEnvelope, IInternalOfflineSupport, IInternalSdkStatsState, INotificationManager, IOfflineListener, IPayloadData, IPlugin, + IProcessTelemetryContext, IProcessTelemetryUnloadContext, ISample, IStatsEventData, IStorageBuffer, ITelemetryItem, + ITelemetryPluginChain, ITelemetryUnloadState, IXDomainRequest, IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, + PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, RequestDataType, RequestHeaders, SampleRate, SendPOSTFunction, + SendRequestReason, SenderPostManager, TraceDataType, TransportType, _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, + _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createOfflineListener, createProcessTelemetryContext, + createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, formatErrorMessageXdr, formatErrorMessageXhr, + getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, isInternalApplicationInsightsEndpoint, + isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, runTargetUnload, + utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-core-js"; import { IPromise, createPromise, doAwait, doAwaitResponse } from "@nevware21/ts-async"; import { @@ -34,7 +35,7 @@ const FetchSyncRequestSizeLimitBytes = 65000; // approx 64kb (the current Edge, interface IInternalPayloadData extends IPayloadData { oriPayload: IInternalStorageItem[]; retryCnt?: number; - // statsBeatData?: IStatsEventData; + statsData?: IStatsEventData; } @@ -497,7 +498,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!isValidate) { return; } - + let aiEnvelope = _getEnvelope(telemetryItem, diagLogger); if (!aiEnvelope) { return; @@ -505,7 +506,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // check if the incoming payload is too large, truncate if necessary const payload: string = _serializer.serialize(aiEnvelope); - + // flush if we would exceed the max-size limit by adding this item const buffer = _self._buffer; _checkMaxSize(payload); @@ -518,7 +519,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // enqueue the payload buffer.enqueue(payloadItem); - + // ensure an invocation timeout is set _setupTimer(); @@ -673,20 +674,37 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } - // function _getStatsBeat() { - // let statsBeatConfig: IStatsBeatState = { - // cKey: _self._senderConfig.instrumentationKey, - // endpoint: _endpointUrl, - // sdkVer: EnvelopeCreator.Version, - // type: eStatsType.SDK - // }; + function _getSdkStats() { + let statsCfg = _self.core.config.stats; + let snp = statsCfg && statsCfg.snp; + let internalSdkStatsConfig: IInternalSdkStatsState = { + cKey: _self._senderConfig.instrumentationKey, + endpoint: _endpointUrl, + sdkVer: "javascript:" + EnvelopeCreator.Version + (snp ? ":snp" + snp : "") + }; - // let core = _self.core; + let core = _self.core; - // // During page unload the core may have been cleared and some async events may not have been sent yet - // // resulting in the core being null. In this case we don't want to create a statsbeat instance - // return core ? core.getStatsBeat(statsBeatConfig) : null; - // } + // During page unload the core may have been cleared and some async events may not have been sent yet + // resulting in the core being null. In this case we don't want to create a SDK Stats instance + return core && core.getSdkStats ? core.getSdkStats(internalSdkStatsConfig) : null; + } + + /** + * Record the result of a send against the SDK Stats instance for the customer + * endpoint. Sends to the SDK Stats ingestion endpoint itself are intentionally skipped (the + * payload targets a different url) to avoid a self-referential feedback loop. + * @param status - The resulting status code of the send. + * @param payload - The payload that was sent. + */ + function _countInternalSdkStats(status: number, payload?: IPayloadData) { + if (payload && payload.urlString === _endpointUrl) { + let internalSdkStats = _getSdkStats(); + if (internalSdkStats) { + internalSdkStats.count(status, payload, _endpointUrl); + } + } + } function _xdrOnLoad (xdr: IXDomainRequest, payload: IInternalStorageItem[]) { const responseText = _getResponseText(xdr); @@ -714,23 +732,25 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - //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); - // } - // } - // } + if (payload && payload.urlString === _endpointUrl) { + const responseText = _getResponseText(xdr); + let internalSdkStats = _getSdkStats(); + if (internalSdkStats) { + if (xdr && (responseText + "" === "200" || responseText === "")) { + _consecutiveErrors = 0; + internalSdkStats.count(200, payload, _endpointUrl); + } else { + const results = parseResponse(responseText); + + if (results && results.itemsReceived && results.itemsReceived > results.itemsAccepted + && !_isRetryDisabled) { + internalSdkStats.count(206, payload, _endpointUrl); + } else { + internalSdkStats.count(499, payload, _endpointUrl); + } + } + } + } return _xdrOnLoad(xdr, payloadArr); @@ -740,10 +760,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // statsbeat.count(response.status, payload, _endpointUrl); - // } + _countInternalSdkStats(response.status, payload); return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, xhrOnComplete: (request: XMLHttpRequest, oncomplete: OnCompleteCallback, payload?: IPayloadData) => { @@ -751,18 +768,14 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - // let statsbeat = _getStatsBeat(); - // if (statsbeat && request.readyState === 4) { - // statsbeat.count(request.status, payload, _endpointUrl); - // } + if (request.readyState === 4) { + _countInternalSdkStats(request.status, payload); + } 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); - // } + _countInternalSdkStats(499, data); return _onBeaconRetry(data, onComplete, canSend); } @@ -1028,17 +1041,13 @@ 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); - // } - + _countInternalSdkStats(status, payloadData); return _getOnComplete(payload, status, headers, response); }; let payloadData = _getPayload(payload); - // if (payloadData) { - // payloadData.statsBeatData = {startTime: dateNow()}; - // } + if (payloadData) { + payloadData.statsData = {startTime: dateNow()}; + } let sendPostFunc: SendPOSTFunction = sendInterface && sendInterface.sendPOST; if (sendPostFunc && payloadData) { @@ -1388,7 +1397,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let core = _self.core; if (core) { // During page unload the core may have been cleared and some async events may not have been sent yet - // resulting in the core being null. In this case we don't want to create a statsbeat instance + // resulting in the core being null. In this case we don't want to create a internalSdkStats instance if (core[func]) { result = core[func](); 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 6b974d459..65e22aa48 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts @@ -101,11 +101,6 @@ export class CfgSyncHelperTests extends AITestClass { extensions:[{isFlushInvoked:false,isTearDownInvoked:false,isResumeInvoked:false,isPauseInvoked:false,identifier:"Sender",priority:1001}], channels:[], extensionConfig:{}, - //_sdk: { - // stats: { - // endCfg: [] - // } - //}, traceHdrMode: 3, sdkStats: { int: 900000 diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index 894f3ab3f..90ac1ad7c 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,10 +51,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 135; - private readonly MAX_BUNDLE_SIZE = 135; - private readonly MAX_RAW_DEFLATE_SIZE = 54; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 54; + private readonly MAX_RAW_SIZE = 137; + private readonly MAX_BUNDLE_SIZE = 138; + private readonly MAX_RAW_DEFLATE_SIZE = 56; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; private readonly rawFilePath = "../dist/es5/index.min.js"; private readonly prodFilePath = "../browser/es5/applicationinsights-core-js.min.js"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts new file mode 100644 index 000000000..1085b0a28 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -0,0 +1,961 @@ +import * as sinon from "sinon"; +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; +import { IStatsMgr } from "../../../../src/interfaces/ai/IStatsMgr"; +import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; +import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; +import { createStatsMgr, getStatsCfgUrl } from "../../../../src/core/InternalSdkStats"; +import { IInternalSdkStatsState } from "../../../../src/interfaces/ai/IInternalSdkStats"; +import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; +import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; +import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; +import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; + +const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes +const STATS_TEST_CFG_URL = "https://data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_IKEY = "Stats-Test-iKey"; +function _clearStatsStorage() { + try { + let storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; + if (storage) { + let keys: string[] = []; + for (let lp = 0; lp < storage.length; lp++) { + let key = storage.key(lp); + if (key && key.indexOf("Test-iKey:") === 0) { + keys.push(key); + } + } + + for (let lp = 0; lp < keys.length; lp++) { + storage.removeItem(keys[lp]); + } + } + } catch (e) { + // Session storage may be unavailable. + } +} + +function _readStatsStorage(cKey: string, endpoint: string): any { + try { + let raw = sessionStorage.getItem(cKey + ":" + endpoint); + let value = raw ? JSON.parse(raw) : null; + return value ? { st: value[0], cnt: value[1] } : null; + } catch (e) { + return null; + } +} + +export class InternalSdkStatsTests extends AITestClass { + private _core: AppInsightsCore; + private _config: IConfiguration; + private _statsMgr: IStatsMgr; + private _trackSpy: sinon.SinonSpy; + private _rootTrackSpy: sinon.SinonSpy; + private _statsCoreConfigs: IConfiguration[]; + private _statsCores: AppInsightsCore[]; + + constructor(emulateIe: boolean) { + super("InternalSdkStatsTests", emulateIe); + } + + public testInitialize() { + let _self = this; + super.testInitialize(); + + _clearStatsStorage(); + + _self._config = { + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true, + featureOptIn: { + "InternalSdkStats": { + mode: FeatureOptInMode.enable + } + }, + stats: { + shrtInt: STATS_COLLECTION_SHORT_INTERVAL, + // The config url gates collection, without it nothing is collected or sent + cfgUrl: STATS_TEST_CFG_URL, + iKey: STATS_TEST_IKEY, + // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests + // do not attempt a real network fetch of the cfg/v1.json endpoint. + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + } + } + }; + + _self._statsMgr = createStatsMgr(); + _self._core = new AppInsightsCore(); + _self._statsCoreConfigs = []; + _self._statsCores = []; + // Initialize the core once here (with a minimal channel plugin) so the stats manager + // can be enabled when init() is called - createStatsMgr().init() only hooks config + // changes and enables the manager when the core is already initialized. + _self._core.initialize(_self._config, [new ChannelPlugin()]); + + _self._trackSpy = this.sandbox.spy(); + _self._rootTrackSpy = this.sandbox.spy(_self._core, "track"); + } + + public testCleanup() { + super.testCleanup(); + if (this._core && this._core.isInitialized()) { + this._core.unload(false); + } + for (let lp = 0; lp < this._statsCores.length; lp++) { + this._statsCores[lp].isInitialized() && this._statsCores[lp].unload(false); + } + this._core = null as any; + this._statsMgr = null as any; + this._statsCores = []; + _clearStatsStorage(); + } + + private _createStatsCore(config: IConfiguration): IAppInsightsCore { + let core = new AppInsightsCore(); + core.initialize(config, [new ChannelPlugin()]); + let track = core.track; + this.sandbox.stub(core, "track").callsFake((item: ITelemetryItem) => { + this._trackSpy(item); + track.call(core, item); + }); + this._statsCoreConfigs.push(config); + this._statsCores.push(core); + return core; + } + + private _initStatsMgr(core: IAppInsightsCore = this._core, featureName: string = "InternalSdkStats") { + return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName); + } + + public registerTests() { + + this.testCase({ + name: "SDK Stats: Initialization", + test: () => { + // Test with no initialization + Assert.equal(false, this._statsMgr.enabled, "SDK Stats manager should not be initialized by default"); + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + Assert.equal(null, this._statsMgr.newInst(internalSdkStatsState), "SDK Stats should not be created before initialization"); + + // Initialize + this._initStatsMgr(); + Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); + + let newInst = this._statsMgr.newInst(internalSdkStatsState); + Assert.ok(!!newInst, "SDK Stats should be created after initialization"); + Assert.equal(true, newInst.enabled, "SDK Stats should be enabled after initialization"); + Assert.equal("https://example.endpoint.com", newInst.endpoint); + } + }); + + this.testCase({ + name: "SDK Stats: count method tracks request metrics", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager + this._initStatsMgr(); + + // Create mock payload data with timing information + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsData: { + startTime: Date.now() // Simulated start time (numeric, used in duration arithmetic) + } + } as IPayloadData; + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + + // Test successful request + internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); + + // Test failed request + internalSdkStats.count(500, payloadData, "https://example.endpoint.com"); + + // Test throttled request + internalSdkStats.count(429, payloadData, "https://example.endpoint.com"); + + // Verify that track is called when the collection timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + // Verify that track was called + Assert.ok(this._trackSpy.called, "track should be called when SDK Stats 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: "SDK Stats: countException method tracks exceptions", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager + this._initStatsMgr(); + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + + // Count an exception + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + // Verify that track is called when the collection timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + // Verify that track was called + Assert.ok(this._trackSpy.called, "track should be called when SDK Stats 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: "SDK Stats: does not send metrics for different endpoints", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager for a specific endpoint + this._initStatsMgr(); + + // Create mock payload data + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsData: { + startTime: Date.now() + } + } as IPayloadData; + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + + // Set up spies to check internal calls + const countSpy = this.sandbox.spy(internalSdkStats, "count"); + + // Count metrics for a different endpoint + internalSdkStats.count(200, payloadData, "https://different.endpoint.com"); + + // Verify that track is called when the collection timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 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: "SDK Stats: test dynamic configuration changes", + useFakeTimers: true, + test: () => { + // Setup core with internalSdkStats enabled (guard against re-initialization since the + // core is now initialized in testInitialize()) + if (!this._core.isInitialized()) { + this._core.initialize(this._config, [new ChannelPlugin()]); + } + // Initialize SDK Stats manager for a specific endpoint + this._initStatsMgr(); + this._core.setStatsMgr(this._statsMgr); + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + + // Verify that SDK Stats is created + const internalSdkStats = this._core.getSdkStats(internalSdkStatsState); + Assert.ok(!!internalSdkStats, "InternalSdkStats should be created"); + + // Explicitly disable SDK Stats + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.disable; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that SDK Stats is removed + const updatedInternalSdkStats = this._core.getSdkStats(internalSdkStatsState); + Assert.ok(!updatedInternalSdkStats, "SDK Stats should be removed when disabled"); + + // Re-enable SDK Stats + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.enable; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that SDK Stats is created again + const reenabledInternalSdkStats = this._core.getSdkStats(internalSdkStatsState); + Assert.ok(reenabledInternalSdkStats, "SDK Stats should be recreated when re-enabled"); + + // FeatureOptInMode.none falls back to the SDK default state (enabled), so SDK Stats stays enabled + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.none; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that SDK Stats remains enabled (none defaults to enabled) + Assert.ok(!!this._core.getSdkStats(internalSdkStatsState), "SDK Stats should remain enabled when mode is none (defaults to enabled)"); + + // Explicitly disable again before testing the null case + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.disable; + this.clock.tick(1); // Allow time for config changes to propagate + Assert.ok(!this._core.getSdkStats(internalSdkStatsState), "SDK Stats should be removed when disabled"); + + // A null mode also falls back to the SDK default state (enabled) + this._core.config.featureOptIn["InternalSdkStats"].mode = null; + this.clock.tick(1); // Allow time for config changes to propagate + + // Verify that SDK Stats is recreated (null defaults to enabled) + Assert.ok(!!this._core.getSdkStats(internalSdkStatsState), "SDK Stats should remain enabled when mode is null (defaults to enabled)"); + } + }); + + this.testCase({ + name: "SDK Stats: routes events to the remote configured SDK Stats endpoint", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsData: { + startTime: Date.now() + } + } as IPayloadData; + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); + Assert.equal(0, this._rootTrackSpy.callCount, "SDK Stats should not use the customer core"); + Assert.equal(STATS_TEST_IKEY, this._statsCoreConfigs[0].instrumentationKey, + "The isolated core should use the SDK Stats instrumentation key"); + Assert.equal("https://data.stats.monitor.azure.com/v2/track", this._statsCoreConfigs[0].endpointUrl, + "The isolated core should use the remote configured endpoint"); + Assert.equal(1, this._statsCoreConfigs.length, "One isolated core should handle the SDK Stats batch"); + + for (let i = 0; i < this._trackSpy.callCount; i++) { + const item: ITelemetryItem = this._trackSpy.getCall(i).args[0]; + Assert.equal(STATS_TEST_IKEY, item.iKey, "SDK Stats should use the configured instrumentation key"); + } + } + }); + + this.testCase({ + name: "SDK Stats: does not send when the remote configuration is disabled", + useFakeTimers: true, + test: () => { + // Override the remote SDK Stats configuration to report collection as disabled + this._core.config.stats.overrideCfgFn = (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: false, url: "data.stats.monitor.azure.com" }); + }; + this.clock.tick(1); // Allow the config change to propagate + + this._initStatsMgr(); + + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsData: { + startTime: Date.now() + } + } as IPayloadData; + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + }; + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "track should not be called when the remote configuration disables SDK Stats"); + } + }); + + this.testCase({ + name: "SDK Stats: customer telemetry initializers cannot inspect or modify SDK Stats", + useFakeTimers: true, + test: () => { + let initializerCalls = 0; + this._core.addTelemetryInitializer(() => { + initializerCalls++; + throw new Error("Customer initializer should not receive SDK Stats"); + }); + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "The isolated core should receive SDK Stats"); + Assert.equal(0, initializerCalls, "Customer telemetry initializers should not receive SDK Stats"); + Assert.equal(0, this._rootTrackSpy.callCount, "The customer core should not track SDK Stats"); + } + }); + + this.testCase({ + name: "SDK Stats: unloading the manager unloads the isolated core", + useFakeTimers: true, + test: () => { + let hook = this._initStatsMgr(); + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + let unloadSpy = this.sandbox.spy(this._statsCores[0], "unload"); + hook.rm(); + + Assert.equal(1, unloadSpy.callCount, "The isolated core should be unloaded with the manager"); + } + }); + + this.testCase({ + name: "SDK Stats: invalidates the endpoint cache when cfgUrl changes", + useFakeTimers: true, + test: () => { + let fetchedUrls: string[] = []; + this._core.config.stats.overrideCfgFn = (cfgUrl, oncomplete) => { + fetchedUrls.push(cfgUrl); + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + }; + this.clock.tick(1); + + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://westeurope.in.applicationinsights.azure.com", + sdkVer: "1.0.0" + }); + + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", fetchedUrls[0], + "The initial endpoint should use the configured EU url"); + + this._core.config.stats.cfgUrl = "https://next.stats.monitor.azure.com/cfg/v1.json"; + this.clock.tick(1); + + internalSdkStats.countException("https://westeurope.in.applicationinsights.azure.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal("https://eu-next.stats.monitor.azure.com/cfg/v1.json", fetchedUrls[1], + "The updated cfgUrl should replace the cached endpoint"); + } + }); + + this.testCase({ + name: "SDK Stats: manager enables by default when config.stats is not provided", + test: () => { + // A core without an explicit config.stats should still enable the manager, because the + // manager seeds an (empty) stats config default. + let core = new AppInsightsCore(); + core.initialize({ + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true + } as IConfiguration, [new ChannelPlugin()]); + + let statsMgr = createStatsMgr(); + let hook = statsMgr.init(core, (config) => this._createStatsCore(config), "InternalSdkStats"); + + Assert.equal(true, statsMgr.enabled, "Manager should be enabled by default via the seeded stats config"); + + hook && hook.rm(); + core.unload(false); + } + }); + + this.testCase({ + name: "SDK Stats: recreates the isolated core when its configuration changes", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + let unloadSpy = this.sandbox.spy(this._statsCores[0], "unload"); + this._core.config.stats.iKey = "Updated-Stats-iKey"; + this.clock.tick(1); + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(1, unloadSpy.callCount, "The previous isolated core should be unloaded"); + Assert.equal(2, this._statsCoreConfigs.length, "A new isolated core should be created"); + Assert.equal("Updated-Stats-iKey", this._statsCoreConfigs[1].instrumentationKey, + "The new isolated core should use the updated configuration"); + } + }); + + this.testCase({ + name: "SDK Stats: does not send when no cfgUrl has been configured", + useFakeTimers: true, + test: () => { + // Remove the configured cfg url, without it there is nothing to resolve so nothing is sent + this._core.config.stats.cfgUrl = null; + this.clock.tick(1); // Allow the config change to propagate + + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "track should not be called when no cfgUrl is configured"); + } + }); + + this.testCase({ + name: "SDK Stats: starts sending once the cfgUrl arrives from the dynamic config", + useFakeTimers: true, + test: () => { + // Simulate the CDN configuration arriving after initialization by starting without a url + this._core.config.stats.cfgUrl = null; + this.clock.tick(1); + + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent before the cfgUrl is available"); + + // The CDN / dynamic config now supplies the url + this._core.config.stats.cfgUrl = STATS_TEST_CFG_URL; + this.clock.tick(1); // Allow the config change to propagate + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "SDK Stats should be sent once the cfgUrl is supplied by the config"); + } + }); + + this.testCase({ + name: "SDK Stats: does not send until an instrumentation key is configured", + useFakeTimers: true, + test: () => { + this._core.config.stats.iKey = null; + this.clock.tick(1); + + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent without an instrumentation key"); + Assert.equal(1, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], + "Counters should remain persisted"); + + this._core.config.stats.iKey = STATS_TEST_IKEY; + this.clock.tick(1); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "Persisted counters should be sent after the instrumentation key is configured"); + } + }); + + this.testCase({ + name: "SDK Stats: retains persisted counters while remote config is unresolved", + useFakeTimers: true, + test: () => { + let completeFetch: (result: { enabled: boolean, url: string } | null) => void; + this._core.config.stats.overrideCfgFn = (_cfgUrl, oncomplete) => { + completeFetch = oncomplete; + }; + this.clock.tick(1); + + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent before remote config resolves"); + Assert.equal(0, this._statsCoreConfigs.length, "The isolated core should not be created before config resolves"); + Assert.equal(1, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], + "Unsent counters should remain persisted"); + + completeFetch({ enabled: true, url: "data.stats.monitor.azure.com" }); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "Persisted counters should be sent on the next interval"); + Assert.equal(1, this._statsCoreConfigs.length, "The isolated core should be created after config resolves"); + Assert.equal(undefined, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], + "Counters should reset after they are processed"); + } + }); + + this.testCase({ + name: "SDK Stats: getStatsCfgUrl derives the EU url and requires a configured url", + test: () => { + Assert.equal(null, getStatsCfgUrl("https://westeurope.in.applicationinsights.azure.com/", null), + "No configured url should resolve to null"); + Assert.equal(null, getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/", undefined), + "No configured url should resolve to null"); + + Assert.equal(STATS_TEST_CFG_URL, getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "A non-EU endpoint should use the configured url as-is"); + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU endpoint should have the eu- prefix inserted in front of the host"); + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://westeurope-5.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU region replica endpoint should also resolve to the EU url"); + + let euRegions = [ + "francecentral", "francesouth", "germanywestcentral", "norwayeast", + "norwaywest", "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "ukwest" + ]; + for (let lp = 0; lp < euRegions.length; lp++) { + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://" + euRegions[lp] + ".in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + euRegions[lp] + " should resolve to the EU url"); + } + + Assert.equal("eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://northeurope.in.applicationinsights.azure.com/", "data.stats.monitor.azure.com/cfg/v1.json"), + "A configured url without a scheme should still get the eu- prefix"); + } + }); + + this.testCase({ + name: "SDK Stats: counters are persisted to session storage", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + // Move off the fake timer epoch. + this.clock.tick(1000); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + + internalSdkStats.count(200, { statsData: { startTime: Date.now() } } as any, "https://example.endpoint.com"); + internalSdkStats.count(400, { statsData: { startTime: Date.now() } } as any, "https://example.endpoint.com"); + internalSdkStats.count(500, { statsData: { startTime: Date.now() } } as any, "https://example.endpoint.com"); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + let stored = _readStatsStorage("Test-iKey", "https://example.endpoint.com"); + Assert.ok(!!stored, "The SDK Stats state should be persisted to session storage"); + Assert.equal(1000, stored.st, "The collection window start should be persisted"); + Assert.equal(1, stored.cnt.success, "The success count should be persisted"); + Assert.equal(3, stored.cnt.totalRequest, "The total request count should be persisted"); + Assert.equal(1, stored.cnt.failure["400"], "The failure count should be persisted"); + Assert.equal(1, stored.cnt.retry["500"], "The retry count should be persisted"); + Assert.equal(1, stored.cnt.exception["NetworkError"], "The exception count should be persisted"); + } + }); + + this.testCase({ + name: "SDK Stats: a new instance resumes the persisted counters and collection window", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + let state = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }; + + // First page load. + let first = this._statsMgr.newInst(state); + first.countException("https://example.endpoint.com", "NetworkError"); + first.enabled = false; + + let windowStart = _readStatsStorage("Test-iKey", "https://example.endpoint.com").st; + + this.clock.tick((STATS_COLLECTION_SHORT_INTERVAL - 10) * 1000); + + // Second page load resumes the window. + let second = this._statsMgr.newInst(state); + Assert.equal(windowStart, _readStatsStorage("Test-iKey", "https://example.endpoint.com").st, + "The collection window should not be restarted by a new instance"); + + second.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(11 * 1000); + + Assert.ok(this._trackSpy.called, "The resumed window should complete after the remaining time"); + + let exceptionCount = 0; + for (let i = 0; i < this._trackSpy.callCount; i++) { + const item: ITelemetryItem = this._trackSpy.getCall(i).args[0]; + if (item.name === "exception") { + exceptionCount = item.baseData.average; + } + } + + Assert.equal(2, exceptionCount, "The counts from both instances should be accumulated into one window"); + } + }); + + this.testCase({ + name: "SDK Stats: the persisted counters are reset once the window is sent", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "The window should have been sent"); + + let stored = _readStatsStorage("Test-iKey", "https://example.endpoint.com"); + Assert.ok(!!stored, "The reset state should still be persisted"); + Assert.equal(0, stored.cnt.success, "The success count should be reset after sending"); + Assert.equal(undefined, stored.cnt.exception["NetworkError"], "The exception counts should be reset after sending"); + } + }); + + this.testCase({ + name: "SDK Stats: separate endpoints do not share persisted counters", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + let first = this._statsMgr.newInst({ cKey: "Test-iKey", endpoint: "https://one.endpoint.com", sdkVer: "1.0.0" }); + let second = this._statsMgr.newInst({ cKey: "Test-iKey", endpoint: "https://two.endpoint.com", sdkVer: "1.0.0" }); + + first.countException("https://one.endpoint.com", "NetworkError"); + second.countException("https://two.endpoint.com", "NetworkError"); + second.countException("https://two.endpoint.com", "NetworkError"); + + Assert.equal(1, _readStatsStorage("Test-iKey", "https://one.endpoint.com").cnt.exception["NetworkError"], + "The first endpoint should only have its own counts"); + Assert.equal(2, _readStatsStorage("Test-iKey", "https://two.endpoint.com").cnt.exception["NetworkError"], + "The second endpoint should only have its own counts"); + } + }); + + this.testCase({ + name: "SDK Stats: defaults to a one hour collection interval", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + core.initialize({ + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true, + stats: { + cfgUrl: STATS_TEST_CFG_URL, + iKey: STATS_TEST_IKEY, + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + } + } + } as IConfiguration, [new ChannelPlugin()]); + + let trackSpy = this.sandbox.spy(core, "track"); + let statsMgr = createStatsMgr(); + let hook = statsMgr.init(core, (config) => this._createStatsCore(config), "InternalSdkStats"); + + let internalSdkStats = statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://hourly.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://hourly.endpoint.com", "NetworkError"); + + this.clock.tick(60 * 60 * 1000 - 1); + Assert.equal(0, trackSpy.callCount, "Nothing should be sent before the one hour interval elapses"); + + this.clock.tick(2); + Assert.ok(this._trackSpy.called, "The stats should be sent once the one hour interval elapses"); + Assert.equal(0, trackSpy.callCount, "The customer core should not send SDK Stats"); + + hook && hook.rm(); + core.unload(false); + } + }); + + this.testCase({ + name: "SDK Stats: accepts a positive interval below one minute from dynamic config", + useFakeTimers: true, + test: () => { + this._core.config.stats.shrtInt = 1; + this.clock.tick(1); // Allow the config change to propagate + + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://short.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://short.endpoint.com", "NetworkError"); + + this.clock.tick(1001); + + Assert.ok(this._trackSpy.called, "A positive dynamic interval below one minute should be honored"); + } + }); + + this.testCase({ + name: "SDK Stats: reschedules an active window when the dynamic interval changes", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://dynamic.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://dynamic.endpoint.com", "NetworkError"); + + this.clock.tick(100 * 1000); + this._core.config.stats.shrtInt = 120; + this.clock.tick(1); + + this.clock.tick(20 * 1000 - 2); + Assert.equal(0, this._trackSpy.callCount, "The updated interval should not fire early"); + + this.clock.tick(2); + Assert.ok(this._trackSpy.called, "The active window should use the updated interval"); + } + }); + } +} + +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."; + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts deleted file mode 100644 index fef4337fa..000000000 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ /dev/null @@ -1,366 +0,0 @@ -// import * as sinon from "sinon"; -// import { Assert, AITestClass } from "@microsoft/ai-test-framework"; -// import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; -// import { IStatsMgr } from "../../../../src/JavaScriptSDK.Interfaces/IStatsMgr"; -// import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; -// import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; -// import { createStatsMgr } from "../../../../src/JavaScriptSDK/StatsBeat"; -// import { IStatsBeatState } from "../../../../src/JavaScriptSDK.Interfaces/IStatsBeat"; -// import { eStatsType } from "../../../../src/JavaScriptSDK.Enums/StatsType"; -// import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; -// import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; -// import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; -// import { FeatureOptInMode } from "../../../../src/JavaScriptSDK.Enums/FeatureOptInEnums"; - -// const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes - -// export class StatsBeatTests extends AITestClass { -// private _core: AppInsightsCore; -// private _config: IConfiguration; -// private _statsMgr: IStatsMgr; -// 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: { -// shrtInt: STATS_COLLECTION_SHORT_INTERVAL, -// endCfg: [ -// { -// type: 0, -// keyMap: [ -// { -// key: "stats-key1", -// match: [ "https://example.endpoint.com" ] -// } -// ] -// } -// ] -// } -// } -// }; - -// _self._statsMgr = createStatsMgr(); -// _self._core = new AppInsightsCore(); -// // _self._statsMgr.init(_self._core, { -// // feature: "StatsBeat", -// // getCfg: (core, cfg) => { -// // return cfg?._sdk?.stats; -// // } -// // }); - -// // Create spy for tracking telemetry -// _self._trackSpy = this.sandbox.spy(_self._core, "track"); -// } - -// public testCleanup() { -// super.testCleanup(); -// this._core = null as any; -// this._statsMgr = null as any; -// } - -// public registerTests() { - -// this.testCase({ -// name: "StatsBeat: Initialization", -// test: () => { -// // Test with no initialization -// Assert.equal(false, this._statsMgr.enabled, "StatsBeat should not be initialized by default"); - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// Assert.equal(null, this._statsMgr.newInst(statsBeatState), "StatsBeat should not be created before initialization"); - -// // Initialize -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); -// Assert.equal(true, this._statsMgr.enabled, "StatsBeat should be initialized after initialization"); - -// let newInst = this._statsMgr.newInst(statsBeatState); -// Assert.ok(!!newInst, "StatsBeat should be created after initialization"); -// Assert.equal(true, newInst.enabled, "StatsBeat should be enabled after initialization"); -// Assert.equal("https://example.endpoint.com", newInst.endpoint); -// Assert.equal(0, newInst.type); -// } -// }); - -// this.testCase({ -// name: "StatsBeat: count method tracks request metrics", -// useFakeTimers: true, -// test: () => { -// // Initialize StatsBeat -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); - -// // 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; - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// let statsBeat = this._statsMgr.newInst(statsBeatState); - -// // Test successful request -// statsBeat.count(200, payloadData, "https://example.endpoint.com"); - -// // Test failed request -// statsBeat.count(500, payloadData, "https://example.endpoint.com"); - -// // Test throttled request -// 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._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// let statsBeat = this._statsMgr.newInst(statsBeatState); - -// // Count an exception -// 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._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); - -// // Create mock payload data -// const payloadData = { -// urlString: "https://example.endpoint.com", -// data: "testData", -// headers: {}, -// timeout: 0, -// disableXhrSync: false, -// statsBeatData: { -// startTime: Date.now() -// } -// } as IPayloadData; - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// let statsBeat = this._statsMgr.newInst(statsBeatState); - -// // Set up spies to check internal calls -// const countSpy = this.sandbox.spy(statsBeat, "count"); - -// // Count metrics for a different endpoint -// 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()]); -// // Initialize StatsBeat for a specific endpoint -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); -// this._core.setStatsMgr(this._statsMgr); - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; - -// // Verify that statsbeat is created -// const statsbeat = this._core.getStatsBeat(statsBeatState); -// Assert.ok(!!statsbeat, "Statsbeat should be created"); - -// // Explicitly disable statsbeat -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; -// this.clock.tick(1); // Allow time for config changes to propagate - -// // Verify that statsbeat is removed -// const updatedStatsbeat = this._core.getStatsBeat(statsBeatState); -// Assert.ok(!updatedStatsbeat, "Statsbeat should be removed when disabled"); - -// // Re-enable statsbeat -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; -// this.clock.tick(1); // Allow time for config changes to propagate - -// // Verify that statsbeat is created again -// const reenabledStatsbeat = this._core.getStatsBeat(statsBeatState); -// Assert.ok(reenabledStatsbeat, "Statsbeat should be recreated when re-enabled"); - -// // Test that statsbeat is not created when disabled with undefined -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.none; -// this.clock.tick(1); // Allow time for config changes to propagate - -// // Verify that statsbeat is removed -// Assert.ok(!this._core.getStatsBeat(statsBeatState), "Statsbeat should be removed when disabled"); - -// // Re-enable statsbeat -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; -// this.clock.tick(1); // Allow time for config changes to propagate - -// // Verify that statsbeat is created again -// Assert.ok(!!this._core.getStatsBeat(statsBeatState), "Statsbeat should be recreated when re-enabled"); - -// // Test that statsbeat is not created when disabled with null value -// this._core.config.featureOptIn["StatsBeat"].mode = null; -// this.clock.tick(1); // Allow time for config changes to propagate - -// // Verify that statsbeat is removed -// Assert.ok(!this._core.getStatsBeat(statsBeatState), "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 f4298d3ab..e476342e7 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -12,7 +12,7 @@ import { EventsDiscardedReasonTests } from "./ai/EventsDiscardedReason.Tests"; import { W3cTraceParentTests } from "./trace/W3cTraceParentTests"; import { DynamicConfigTests } from "./config/DynamicConfig.Tests"; import { SendPostManagerTests } from "./ai/SendPostManager.Tests"; -// import { StatsBeatTests } from "./StatsBeat.Tests"; +import { InternalSdkStatsTests } from "./ai/InternalSdkStats.Tests"; import { OTelTraceApiTests } from "./trace/traceState.Tests"; import { CommonUtilsTests } from "./OpenTelemetry/commonUtils.Tests"; import { OpenTelemetryErrorsTests } from "./OpenTelemetry/errors.Tests"; @@ -64,8 +64,8 @@ export function runTests() { new W3cTraceStateTests().registerTests(); new TraceUtilsTests().registerTests(); new OTelNegativeTests().registerTests(); - // new StatsBeatTests(false).registerTests(); - // new StatsBeatTests(true).registerTests(); + new InternalSdkStatsTests(false).registerTests(); + new InternalSdkStatsTests(true).registerTests(); new SendPostManagerTests().registerTests(); new SdkStatsNotificationCbkTests().registerTests(); diff --git a/shared/AppInsightsCore/src/core/AppInsightsCore.ts b/shared/AppInsightsCore/src/core/AppInsightsCore.ts index 70a61aed9..1dae41f72 100644 --- a/shared/AppInsightsCore/src/core/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/core/AppInsightsCore.ts @@ -28,10 +28,12 @@ import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { ICookieMgr } from "../interfaces/ai/ICookieMgr"; import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; import { IDistributedTraceContext } from "../interfaces/ai/IDistributedTraceContext"; +import { IInternalSdkStats, IInternalSdkStatsState } from "../interfaces/ai/IInternalSdkStats"; import { INotificationListener } from "../interfaces/ai/INotificationListener"; import { INotificationManager } from "../interfaces/ai/INotificationManager"; import { IPerfManager } from "../interfaces/ai/IPerfManager"; import { IProcessTelemetryContext, IProcessTelemetryUpdateContext } from "../interfaces/ai/IProcessTelemetryContext"; +import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "../interfaces/ai/ITelemetryInitializers"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPlugin, ITelemetryPlugin } from "../interfaces/ai/ITelemetryPlugin"; @@ -67,8 +69,6 @@ import { TelemetryInitializerPlugin } from "./TelemetryInitializerPlugin"; import { IUnloadHandlerContainer, UnloadHandler, createUnloadHandlerContainer } from "./UnloadHandlerContainer"; import { IUnloadHookContainer, createUnloadHookContainer } from "./UnloadHookContainer"; -// import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "../interfaces/ai/IStatsBeat"; -// import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; const strValidationError = "Plugins must provide initialize method"; const strNotificationManager = "_notificationManager"; const strSdkUnloadingError = "SDK is still unloading..."; @@ -78,23 +78,6 @@ const maxInitTimeout = 50000; const maxAttributeCount = 128; // const strPluginUnloadFailed = "Failed to unload plugin"; -// /** -// * Default StatsBeatMgr configuration -// * @internal -// */ -// const defaultStatsCfg: IConfigDefaults = objDeepFreeze({ -// shrtInt: UNDEFINED_VALUE, -// endCfg: cfgDfMerge([]) -// }); - -// /** -// * Default SDK initialization configuration -// * @internal -// */ -// const defaultSdkConfig: IConfigDefaults = objDeepFreeze({ -// stats: { rdOnly: true, mrg: true, v: defaultStatsCfg } -// }); - /** * The default settings for the config. * WE MUST include all defaults here to ensure that the config is created with all of the properties @@ -129,7 +112,6 @@ const defaultConfig: IConfigDefaults = objDeepFreeze({ serviceName: null, suppressTracing: false }) - // _sdk: { rdOnly: true, ref: true, v: defaultSdkConfig } }); function _getDefaultConfig(core: IAppInsightsCore): IConfigDefaults { @@ -385,8 +367,8 @@ export class AppInsightsCore im let _logger: IDiagnosticLogger; let _eventQueue: ITelemetryItem[]; let _notificationManager: INotificationManager | null | undefined; - // let _statsBeat: IStatsBeat | null; - // let _statsMgr: IStatsMgr | null; + let _internalSdkStats: IInternalSdkStats | null; + let _statsMgr: IStatsMgr | null; let _perfManager: IPerfManager | null; let _cfgPerfManager: IPerfManager | null; let _cookieManager: ICookieMgr | null; @@ -626,47 +608,47 @@ export class AppInsightsCore im _perfManager = perfMgr; }; - // _self.getStatsBeat = (statsBeatState: IStatsBeatState) => { - // // create a new statsbeat if not initialize yet or the endpoint is different - // // otherwise, return the existing one, or null - - // if (statsBeatState) { - // if (_statsMgr && _statsMgr.enabled) { - // if (_statsBeat && _statsBeat.endpoint !== statsBeatState.endpoint) { - // // Different endpoint, so unload the existing and create a new one - // _statsBeat.enabled = false; - // _statsBeat = null; - // } - - // if (!_statsBeat) { - // // Create a new statsbeat instance - // _statsBeat = _statsMgr.newInst(statsBeatState); - // } - // } else if (_statsBeat) { - // // Disable and remove any previously created statsbeat instance - // _statsBeat.enabled = false; - // _statsBeat = null; - // } - - // // Return the current statsbeat instance or null if not created - // return _statsBeat; - // } - - // // Return null as no statsbeat state was provided - // return null; - // }; - - // _self.setStatsMgr = (statsMgr: IStatsMgr) => { - // if (_statsMgr && _statsMgr !== statsMgr) { - // // Disable any previously created statsbeat instance - // if (_statsBeat) { - // _statsBeat.enabled = false; - // _statsBeat = null; - // } - // } - - // _statsMgr = statsMgr; - // }; + _self.getSdkStats = (internalSdkStatsState: IInternalSdkStatsState) => { + // create a new SDK Stats instance if not initialized yet or the endpoint is different + // otherwise, return the existing one, or null + + if (internalSdkStatsState) { + if (_statsMgr && _statsMgr.enabled) { + if (_internalSdkStats && _internalSdkStats.endpoint !== internalSdkStatsState.endpoint) { + // Different endpoint, so unload the existing and create a new one + _internalSdkStats.enabled = false; + _internalSdkStats = null; + } + + if (!_internalSdkStats) { + // Create a new SDK Stats instance + _internalSdkStats = _statsMgr.newInst(internalSdkStatsState); + } + } else if (_internalSdkStats) { + // Disable and remove any previously created SDK Stats instance + _internalSdkStats.enabled = false; + _internalSdkStats = null; + } + + // Return the current SDK Stats instance or null if not created + return _internalSdkStats; + } + + // Return null as no SDK Stats state was provided + return null; + }; + + _self.setStatsMgr = (statsMgr: IStatsMgr) => { + if (_statsMgr && _statsMgr !== statsMgr) { + // Disable any previously created SDK Stats instance + if (_internalSdkStats) { + _internalSdkStats.enabled = false; + _internalSdkStats = null; + } + } + + _statsMgr = statsMgr; + }; _self.eventCnt = (): number => { return _eventQueue.length; @@ -911,11 +893,11 @@ export class AppInsightsCore im let processUnloadCtx = createProcessTelemetryUnloadContext(_getPluginChain(), _self); processUnloadCtx.onComplete(() => { - // if (_statsBeat) { - // // Disable any statsbeat instance - // _statsBeat.enabled = false; - // _statsBeat = null; - // } + if (_internalSdkStats) { + // Disable any SDK Stats instance + _internalSdkStats.enabled = false; + _internalSdkStats = null; + } _hookContainer.run(_self.logger); @@ -1317,7 +1299,12 @@ export class AppInsightsCore im runTargetUnload(_notificationManager, false); _notificationManager = null; _perfManager = null; - // _statsBeat = null; + if (_internalSdkStats) { + // Disable any SDK Stats instance + _internalSdkStats.enabled = false; + } + _internalSdkStats = null; + _statsMgr = null; _cfgPerfManager = null; runTargetUnload(_cookieManager, false); _cookieManager = null; @@ -1345,11 +1332,6 @@ export class AppInsightsCore im _initInMemoMaxSize = null; _isStatusSet = false; _initTimer = null; - // if (_statsBeat) { - // // Unload and disable any statsbeat instance - // _statsBeat.enabled = false; - // } - // _statsBeat = null; } function _createTelCtx(): IProcessTelemetryContext { @@ -1736,14 +1718,14 @@ export class AppInsightsCore im return null; } - // public getStatsBeat(statsBeatState: IStatsBeatState): IStatsBeat { - // // @ DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - // return null; - // } + public getSdkStats(internalSdkStatsState: IInternalSdkStatsState): IInternalSdkStats { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } - // public setStatsMgr(statsMgr?: IStatsMgr): void { - // // @ DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - // } + public setStatsMgr(statsMgr?: IStatsMgr): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } public setPerfMgr(perfMgr: IPerfManager) { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts new file mode 100644 index 000000000..aeccb47da --- /dev/null +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -0,0 +1,775 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { doAwaitResponse } from "@nevware21/ts-async"; +import { + ITimerHandler, arrIndexOf, isNumber, isString, objCreate, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, strIndexOf, + strLower, strStartsWith, strSubstring, utcNow +} from "@nevware21/ts-utils"; +import { onConfigChange } from "../config/DynamicConfig"; +import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constants"; +import { STR_EMPTY } from "../constants/InternalConstants"; +import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; +import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; +import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; +import { IConfiguration } from "../interfaces/ai/IConfiguration"; +import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; +import { + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn +} from "../interfaces/ai/IInternalSdkStats"; +import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; +import { CreateStatsCoreFn, IStatsMgr } from "../interfaces/ai/IStatsMgr"; +import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; +import { IPayloadData } from "../interfaces/ai/IXHROverride"; +import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; +import { MetricDataType } from "../telemetry/ai/DataTypes"; +import { getJSON, isFetchSupported, isXhrSupported } from "../utils/EnvUtils"; +import { getResponseText, isFeatureEnabled, openXhr } from "../utils/HelperFuncs"; +import { utlGetSessionStorage, utlSetSessionStorage } from "../utils/StorageHelperFuncs"; + +/** Default collection interval in seconds; override with `stats.shrtInt`. */ +const STATS_COLLECTION_INTERVAL_SECONDS = 3600; // 1 hour +const STATS_LANGUAGE = "JavaScript"; +const STATS_TYPE = "Browser"; + +/** The host prefix added to the configured SDK Stats config url for EU data-boundary regions. */ +const STATS_EU_HOST_PREFIX = "eu-"; + +/** Ingestion path for future 1DS (OneCollector) SDK Stats; the AI SKU uses {@link DEFAULT_BREEZE_PATH}. */ +export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; + +/** + * The default feature name used to gate the SDK Stats manager. SDK Stats is enabled by default + * and can be opted-out via the featureOptIn configuration using this name. + */ +export const STATS_SDK_FEATURE = "sdkStats"; + +// Prefixes for EU data-boundary regions used by the Azure Monitor OpenTelemetry exporter +const STATS_EU_REGION_PATTERN = /^(france|germany|northeurope|norway|sweden|switzerland|uk|westeurope)/; + +/** + * Determine whether the provided customer endpoint maps to an EU data-boundary region. The region + * is extracted from the host (the leading host label, with any region replica suffix removed) and + * matched against the known EU data-boundary regions. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns true when the endpoint maps to an EU region, false otherwise (including unknown regions). + */ +function _isEuEndpoint(endpoint: string): boolean { + let isEU = false; + if (endpoint) { + let host = strLower(endpoint); + // Strip the scheme + let schemeIdx = strIndexOf(host, "://"); + if (schemeIdx !== -1) { + host = strSubstring(host, schemeIdx + 3); + } + + // Extract the leading host label, e.g. "westeurope-5" from "westeurope-5.in.applicationinsights.azure.com/" + let label = host.split("/")[0].split(".")[0]; + // Remove any trailing region replica suffix, e.g. "westeurope-5" => "westeurope" + let dashIdx = strIndexOf(label, "-"); + if (dashIdx !== -1) { + label = strSubstring(label, 0, dashIdx); + } + + isEU = STATS_EU_REGION_PATTERN.test(label); + } + + return isEU; +} + +/** + * Returns the SDK Stats config URL (`cfg/v1.json`) for the endpoint, derived from the configured + * base url. For EU data-boundary endpoints the {@link STATS_EU_HOST_PREFIX} is inserted in front of + * the host, e.g. `https://data.stats...` => `https://eu-data.stats...`. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @param cfgUrl - The configured (non-EU) SDK Stats config url, when not supplied null is returned. + * @returns The config url to fetch, or null when no url has been configured. + */ +export function getStatsCfgUrl(endpoint: string, cfgUrl: string): string { + let result: string = null; + if (cfgUrl) { + result = cfgUrl; + if (_isEuEndpoint(endpoint)) { + // Insert the EU prefix in front of the host (after any scheme) + let schemeIdx = strIndexOf(cfgUrl, "://"); + let hostIdx = schemeIdx !== -1 ? schemeIdx + 3 : 0; + result = strSubstring(cfgUrl, 0, hostIdx) + STATS_EU_HOST_PREFIX + strSubstring(cfgUrl, hostIdx); + } + } + + return result; +} + +/** Parse the SDK Stats config JSON (`{ ver, enabled, url }`); null if empty or unparseable. */ +function _parseStatsCfg(response: string): IInternalSdkStatsCfgResult | null { + let result: IInternalSdkStatsCfgResult = null; + let json = getJSON(); + if (response && json) { + try { + let cfg = json.parse(response); + if (cfg) { + result = { + // Fail-closed: only treat as enabled when explicitly true + enabled: cfg.enabled === true, + url: isString(cfg.url) ? cfg.url : null + }; + } + } catch (e) { + // Unparseable -> no config available + } + } + + return result; +} + +/** Default SDK Stats config fetch (fetch, else XHR); calls oncomplete with the parsed config or null. */ +function _defaultStatsCfgFetch(cfgUrl: string, oncomplete: (result: IInternalSdkStatsCfgResult | null) => void): void { + function _complete(response?: string) { + try { + oncomplete(response ? _parseStatsCfg(response) : null); + } catch (e) { + // Ignore callback errors + } + } + + try { + if (isFetchSupported()) { + let init: RequestInit = { method: "GET" }; + init[DisabledPropertyName] = true; + + doAwaitResponse(fetch(cfgUrl, init), (result) => { + let response = result.value; + if (!result.rejected && response && response.ok) { + doAwaitResponse(response.text(), (res) => { + _complete(res.rejected ? null : res.value); + }); + } else { + _complete(); + } + }); + } else if (isXhrSupported()) { + // openXhr marks the request disabled so it isn't self-tracked + let xhr = openXhr("GET", cfgUrl, false, true, false, 10000); + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + _complete(xhr.status >= 200 && xhr.status < 400 ? getResponseText(xhr) : null); + } + }; + xhr.onerror = () => { + _complete(); + }; + xhr.ontimeout = () => { + _complete(); + }; + xhr.send(); + } else { + _complete(); + } + } catch (e) { + _complete(); + } +} + +/** Build the ingestion endpoint from the config host: `https://` + {@link DEFAULT_BREEZE_PATH}. */ +function _buildStatsEndpoint(host: string): string { + let endpoint: string = null; + if (host) { + let base = strStartsWith(strLower(host), "http") ? host : "https://" + host; + if (strEndsWith(base, "/")) { + base = strSubstring(base, 0, base.length - 1); + } + + endpoint = base + DEFAULT_BREEZE_PATH; + } + + return endpoint; +} + + +/** + * An internal interface to allow the IInternalSdkStats instance to call back to the manager for + * critical tasks, like starting the timer, sending the events and to inform the manager + * that this instance is stopping. This is used to ensure that the manager is able to + * track and control the lifecycle of the instance. + * @internal + */ +interface _IMgrCallbacks { + /** + * Provides a callback to the manager to start a timer for the internalSdkStats instance. + * This is used to ensure that the manager is able to control the lifecycle of the instance + * @param cb - The callback to call when the timer is started + * @param delay - The delay (in milliseconds) before the callback should be called + * @returns A handle to the timer that was started, this can be used to cancel the timer if needed + */ + start: (cb: () => void, delay: number) => ITimerHandler; + + /** Returns the current collection interval in milliseconds. */ + interval: () => number; + + /** Registers for collection interval changes. */ + watchInterval: (cb: () => void) => () => void; + + /** + * Provides a callback to the manager to send the internalSdkStats event to the core. + * This is used to ensure that the manager is able to control the lifecycle of the instance + * @param internalSdkStatsEvent - The internalSdkStats event to send to the core + * @param endpoint - The endpoint to send the event to + */ + track: (internalSdkStats: IInternalSdkStats, internalSdkStatsEvent?: ITelemetryItem) => boolean | null; +} + +/** + * Creates a new IInternalSdkStatsNetwork instance with the specified host. + * @param host - The host for the IInternalSdkStatsNetwork instance. + * @returns A new IInternalSdkStatsNetwork instance. + */ +function _createInternalSdkStatsNetwork(host: string): IInternalSdkStatsNetwork { + return { + host, + totalRequest: 0, + success: 0, + // Untrusted keys must not reach Object.prototype. + throttle: objCreate(null), + failure: objCreate(null), + retry: objCreate(null), + exception: objCreate(null), + requestDuration: 0 + }; +} + +/** SDK Stats state persisted as `[windowStart, counters]`. */ +type _IStatsStore = [number, IInternalSdkStatsNetwork]; + +/** Validates persisted counts and restores them to a null-prototype object. */ +function _reviveCounts(src: any): { [key: string]: number } { + let result: { [key: string]: number } = objCreate(null); + objForEachKey(src, (key, value) => { + value = +value; + if (value > 0) { + result[key] = value; + } + }); + + return result; +} + +/** Restores validated counters from session storage. */ +function _reviveCounters(host: string, cnt: any): IInternalSdkStatsNetwork { + let counter = _createInternalSdkStatsNetwork(host); + if (cnt) { + counter.totalRequest = +cnt.totalRequest || 0; + counter.success = +cnt.success || 0; + counter.requestDuration = +cnt.requestDuration || 0; + counter.throttle = _reviveCounts(cnt.throttle); + counter.failure = _reviveCounts(cnt.failure); + counter.retry = _reviveCounts(cnt.retry); + counter.exception = _reviveCounts(cnt.exception); + } + + return counter; +} + +function _loadStore(logger: IDiagnosticLogger, key: string): _IStatsStore { + try { + let json = getJSON(); + let raw = json && utlGetSessionStorage(logger, key); + if (raw) { + let parsed = json.parse(raw); + if (parsed[0] >= 0) { + return parsed; + } + } + } catch (e) { + // Start a new window. + } + + return null; +} + +function _saveStore(logger: IDiagnosticLogger, key: string, store: _IStatsStore) { + let json = getJSON(); + if (json) { + utlSetSessionStorage(logger, key, json.stringify(store)); + } +} + +/** + * Creates a new IInternalSdkStats instance with the specified manager callbacks and internalSdkStats state. + * @param mgr - The manager callbacks to use for the IInternalSdkStats instance. + * @param internalSdkStatsStats - The internalSdkStats state to use for the IInternalSdkStats instance. + * @returns A new IInternalSdkStats instance. + */ +function _createInternalSdkStats( + mgr: _IMgrCallbacks, internalSdkStatsStats: IInternalSdkStatsState, logger: IDiagnosticLogger +): IInternalSdkStats { + // Isolate counters by customer key and endpoint. + let _storeKey = (internalSdkStatsStats.cKey || STR_EMPTY) + ":" + internalSdkStatsStats.endpoint; + let _store = _loadStore(logger, _storeKey); + // Start a new window only when the first value is recorded. + let _windowStart = _store ? _store[0] : -1; + let _networkCounter: IInternalSdkStatsNetwork = _reviveCounters(internalSdkStatsStats.endpoint, _store && _store[1]); + let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. + let _isEnabled: boolean = true; // Flag to check if internalSdkStats is enabled or not + let _removeIntervalListener: () => void; + + function _startWindow() { + if (_windowStart < 0) { + _windowStart = utcNow(); + } + } + + function _persist() { + _saveStore(logger, _storeKey, [_windowStart, _networkCounter]); + } + + /** Returns the remaining time in the current window. */ + function _remaining() { + let interval = mgr.interval(); + let elapsed = utcNow() - _windowStart; + let remaining = interval; + if (elapsed >= 0) { + remaining = interval - elapsed; + } + + return remaining > 0 ? remaining : 0; + } + + function _setupTimer() { + if (_isEnabled && _windowStart >= 0 && !_timeoutHandle) { + _timeoutHandle = mgr.start(() => { + _timeoutHandle = null; + trackInternalSdkStats(); + }, _remaining()); + } + } + + function trackInternalSdkStats() { + if (_isEnabled) { + let ready = mgr.track(internalSdkStats); + if (ready !== null) { + if (ready) { + _trackSendRequestDuration(); + _trackSendRequestsCount(); + } + _networkCounter = _createInternalSdkStatsNetwork(_networkCounter.host); + // Persist the reset state; restart the window on the next value. + _windowStart = -1; + _persist(); + } else { + // Keep the counters and retry during the next interval. + _windowStart = utcNow(); + _persist(); + _setupTimer(); + } + } + } + + /** + * This is a simple helper that checks if the currently reporting endpoint is the same as this instance was + * created with. This is used to ensure that we only send internalSdkStats events to the endpoint that was used + * when the instance was created. This is important as the endpoint can change during the lifetime of the + * instance and we don't want to send internalSdkStats events to the wrong endpoint. + * @param endpoint + * @returns true if the endpoint is the same as the one used to create the instance, false otherwise + */ + function _checkEndpoint(endpoint: string) { + return _networkCounter.host === endpoint; + } + + function _inc(counter: { [key: string]: number }, key: string | number) { + counter[key] = (counter[key] || 0) + 1; + } + + /** + * Attempt to send internalSdkStats events to the server. This is done by creating a new event and sending it to the core. + * The event is created with the name and value passed in, and any additional properties are added to the event as well. + * This will only send the event when + * - the internalSdkStats is enabled + * - the internalSdkStats key is set for the current endpoint + * - the value is greater than 0 + * @param name - The name of the event to send + * @param val - The value of the event to send + * @param properties - Optional additional properties to add to the event + */ + function _sendInternalSdkStatss(name: string, val: number, properties?: { [name: string]: any }) { + if (_isEnabled && val && val > 0){ + // Add extra properties + let baseProperties = { + "rp": "unknown", + "attach": "Manual", + "cikey": internalSdkStatsStats.cKey, + "os": STATS_TYPE, + "language": STATS_LANGUAGE, + "version": internalSdkStatsStats.sdkVer || "unknown", + "endpoint": "breeze", + "host": _networkCounter.host + } as { [key: string]: any }; + + let combinedProps: { [key: string]: any } = {}; + objForEachKey(properties, (key, value) => { + combinedProps[key] = value; + }); + objForEachKey(baseProperties, (key, value) => { + combinedProps[key] = value; + }); + + let internalSdkStatsEvent: ITelemetryItem = { + name: name, + baseData: { + name: name, + average: val, + properties: combinedProps + }, + baseType: MetricDataType + }; + + // The destination iKey and (optional) SDK Stats ingestion endpoint are resolved and + // stamped by the manager (see _track) based on the current (dynamic) configuration. + mgr.track(internalSdkStats, internalSdkStatsEvent); + } + } + + function _trackSendRequestDuration() { + var totalRequest = _networkCounter.totalRequest; + + if (totalRequest > 0 ) { + _sendInternalSdkStatss("Request_Duration", _networkCounter.requestDuration / totalRequest); + } + } + + function _sendCounts(counts: { [code: string]: number }, name: string, codeKey: string) { + for (const code in counts) { + let props: { [key: string]: any } = {}; + props[codeKey] = code; + _sendInternalSdkStatss(name, counts[code], props); + } + } + + function _trackSendRequestsCount() { + var currentCounter = _networkCounter; + _sendInternalSdkStatss("Request_Success_Count", currentCounter.success); + _sendCounts(currentCounter.failure, "failure", "statusCode"); + _sendCounts(currentCounter.retry, "retry", "statusCode"); + _sendCounts(currentCounter.exception, "exception", "exceptionType"); + _sendCounts(currentCounter.throttle, "Throttle_Count", "statusCode"); + } + + function _setEnabled(isEnabled: boolean) { + _isEnabled = isEnabled; + if (!_isEnabled) { + if (_timeoutHandle) { + _timeoutHandle.cancel(); + _timeoutHandle = null; + } + if (_removeIntervalListener) { + _removeIntervalListener(); + _removeIntervalListener = null; + } + } + } + + _removeIntervalListener = mgr.watchInterval(() => { + if (_timeoutHandle) { + _timeoutHandle.cancel(); + _timeoutHandle = null; + } + _setupTimer(); + }); + + // THE internalSdkStats instance being created and returned + let internalSdkStats: IInternalSdkStats = { + enabled: !!_isEnabled, + endpoint: STR_EMPTY, + count: (status: number, payloadData: IPayloadData, endpoint: string) => { + if (_isEnabled && _checkEndpoint(endpoint)) { + let statsData = payloadData && (payloadData as any)["statsData"]; + let startTime = statsData && statsData["startTime"]; + if (startTime) { + _networkCounter.totalRequest++; + _networkCounter.requestDuration += utcNow() - 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) { + _inc(_networkCounter.retry, status); + } else if (throttleArray.indexOf(status) !== -1) { + _inc(_networkCounter.throttle, status); + } else if (status !== 307 && status !== 308) { + _inc(_networkCounter.failure, status); + } + + // Persist in case the page unloads before the interval ends. + _startWindow(); + _persist(); + _setupTimer(); + } + }, + countException: (endpoint: string, exceptionType: string) => { + if (_isEnabled && _checkEndpoint(endpoint)) { + _inc(_networkCounter.exception, exceptionType); + _startWindow(); + _persist(); + _setupTimer(); + } + } + }; + + // Make the properties readonly / reactive to changes + return objDefineProps(internalSdkStats, { + enabled: { g: () => _isEnabled, s: _setEnabled }, + endpoint: { g: () => _networkCounter.host } + }); +} + +export function createStatsMgr(): IStatsMgr { + let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not + let _core: IAppInsightsCore; // The customer core observed for configuration and endpoint changes + let _createStatsCore: CreateStatsCoreFn; + let _statsCore: IAppInsightsCore; + let _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; + let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; + let _statsIKey: string; + // The configured SDK Stats config url (cfg/v1.json), sourced from the dynamic config. When it is + // not supplied (no CDN / host configuration) SDK Stats are not collected or sent. + let _statsCfgUrl: string; + // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. + // Null-prototype so the config supplied url can never be used to pollute Object.prototype. + let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = objCreate(null); + let _endpointCfgCache: { [endpoint: string]: string } = objCreate(null); + let _intervalListeners: Array<() => void> = []; + + function _unloadStatsCore() { + let statsCore = _statsCore; + _statsCore = null; + statsCore && statsCore.unload(false); + } + + function _getStatsCore(endpoint: string): IAppInsightsCore { + if (_statsCore && _statsCore.config.endpointUrl !== endpoint) { + _unloadStatsCore(); + } + + if (!_statsCore) { + _statsCore = _createStatsCore({ + instrumentationKey: _statsIKey, + endpointUrl: endpoint + }); + } + + return _statsCore; + } + + // Lazily initialize the manager and start listening for configuration changes + // This is also required to handle "unloading" and then re-initializing again + function _init( + core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string + ) { + if (_core) { + // If the core is already set, then just return with an empty unload hook + _throwInternal(safeGetLogger(core), eLoggingSeverity.WARNING, _eInternalMessageId.InternalSdkStatsManagerException, "InternalSdkStats manager is already initialized"); + return null; + } + + _core = core; + _createStatsCore = createStatsCore; + // Start listening for configuration changes from the single global config, within a config + // change handler. This supports the scenario where the config is changed after the manager + // has been created (including CDN / dynamic config updates). + let configHook = onConfigChange(core.config, (details) => { + let previousInterval = _shortInterval; + let previousCfgUrl = _statsCfgUrl; + let previousIKey = _statsIKey; + // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) + _isMgrEnabled = false; + _statsCfgFetchFn = null; + _statsIKey = null; + _statsCfgUrl = null; + if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { + // Seed the SDK Stats defaults into the single global config so they remain dynamic and + // can be overridden via the CDN / dynamic config or by the SKU. + details.setDf(details.cfg, _sdkStatsDefaults); + // Read the nested stats config directly (registers the dynamic dependency on the + // stats object) and copy the individual values into local (minifiable) variables + // instead of holding the config object and repeatedly reading its properties. + let statsCfg = details.cfg.stats; + if (statsCfg) { + _isMgrEnabled = true; + // Make the override fetch fn a dynamic property before snapshotting it so a later + // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. + _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); + _statsIKey = details.set(statsCfg, "iKey", statsCfg.iKey); + // Same for the config url, it is normally delivered by the CDN configuration after + // initialization has completed, so this handler must re-run when it arrives. + _statsCfgUrl = details.set(statsCfg, "cfgUrl", statsCfg.cfgUrl); + _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; // Reset to the default in-case the config is removed / changed + if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > 0) { + _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds + } + } + } + + if (!_isMgrEnabled || _statsCfgUrl !== previousCfgUrl || _statsIKey !== previousIKey) { + _unloadStatsCore(); + } + + if (_statsCfgUrl !== previousCfgUrl) { + _endpointCfgCache = objCreate(null); + } + + if (_shortInterval !== previousInterval) { + for (let lp = 0; lp < _intervalListeners.length; lp++) { + _intervalListeners[lp](); + } + } + }); + + return { + rm: () => { + configHook.rm(); + _unloadStatsCore(); + _createStatsCore = null; + _core = null; + } + }; + } + + /** + * Resolve the remote SDK Stats config for the endpoint, starting a fetch on first use. Returns + * null until resolved (or on failure, or when no config url has been configured) so the caller + * skips sending. + */ + function _resolveStatsCfg(endpoint: string): IInternalSdkStatsCfgResult { + let cfgUrl = _endpointCfgCache[endpoint]; + if (!cfgUrl) { + cfgUrl = getStatsCfgUrl(endpoint, _statsCfgUrl); + if (cfgUrl) { + _endpointCfgCache[endpoint] = cfgUrl; + } + } + if (!cfgUrl) { + // No configured SDK Stats config url -> nothing to resolve and nothing is sent + return null; + } + + let entry = _cfgCache[cfgUrl]; + if (!entry) { + entry = _cfgCache[cfgUrl] = { pending: false, result: null }; + } + + if (!entry.result && !entry.pending) { + entry.pending = true; + let fetchFn = _statsCfgFetchFn || _defaultStatsCfgFetch; + try { + fetchFn(cfgUrl, (result) => { + entry.pending = false; + // null on failure so a later interval retries + entry.result = result; + }); + } catch (e) { + // Reset so a later interval retries + entry.pending = false; + } + } + + return entry.result; + } + + function _track(internalSdkStats: IInternalSdkStats, internalSdkStatsEvent?: ITelemetryItem): boolean | null { + if (_isMgrEnabled) { + if (!_statsIKey) { + return null; + } + + // The remote cfg file is the sole authority for whether collection is enabled and where + // to send the events. Re-resolved here (rather than cached on the instance) to support the + // endpoint changing after the instance was created. + let cfgResult = _resolveStatsCfg(internalSdkStats.endpoint); + if (!cfgResult) { + return null; + } + if (!cfgResult.enabled) { + return false; + } + + let url = _buildStatsEndpoint(cfgResult.url); + let statsCore = _getStatsCore(url); + if (!statsCore) { + return null; + } + + if (internalSdkStatsEvent) { + statsCore.track(internalSdkStatsEvent); + } + return true; + } + + return false; + } + + function _watchInterval(cb: () => void): () => void { + _intervalListeners.push(cb); + return () => { + let idx = arrIndexOf(_intervalListeners, cb); + if (idx !== -1) { + _intervalListeners.splice(idx, 1); + } + }; + } + + function _createInstance(state: IInternalSdkStatsState): IInternalSdkStats { + let instance: IInternalSdkStats = null; + + if (_isMgrEnabled) { + // Prefetch the remote config so it's ready by the first interval + if (state && state.endpoint) { + _resolveStatsCfg(state.endpoint); + } + + let callbacks: _IMgrCallbacks = { + start: (cb: () => void, delay: number) => { + return scheduleTimeout(cb, delay); + }, + interval: () => _shortInterval, + watchInterval: _watchInterval, + track: _track + }; + + instance = _createInternalSdkStats(callbacks, state, safeGetLogger(_core)); + } + + return instance; + } + + let theMgr = { + enabled: false, + newInst: _createInstance, + init: _init + }; + + return objDefineProps(theMgr, { + "enabled": { g: () => _isMgrEnabled } + }); +} + +/** + * The default {@link IInternalSdkStatsConfig} values for SDK Stats collection. These are seeded into the + * single global config (via {@link IWatchDetails.setDf}) by the manager so they remain dynamic and + * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). No config + * url is defaulted, the `stats.cfgUrl` value is expected to be delivered by the CDN / dynamic + * configuration, and until it is, no SDK Stats are collected or sent. The events are routed to the + * distro-owned SDK Stats ingestion endpoint, whose host (and whether collection is enabled) is read + * at runtime from the SDK Stats configuration identified by that url. SDK Stats can also be + * opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. + */ +const _sdkStatsDefaults: IConfigDefaults = { + // Seeding an (empty) stats object allows the manager to run; the config url, destination and + // enabled state are all resolved from the dynamic / remote SDK Stats configuration. A plain + // object (rather than cfgDfMerge) is used so setDf seeds and makes the stats property dynamic + // without marking it as a reference (avoiding the in-place reference side effect). + stats: {} +}; diff --git a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts index 7421996dc..49dd5f201 100644 --- a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts +++ b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts @@ -11,23 +11,15 @@ var MET_SUCCESS = "Item_Success_Count"; var MET_DROPPED = "Item_Dropped_Count"; var MET_RETRY = "Item_Retry_Count"; var DROP_CLIENT_EXCEPTION = "CLIENT_EXCEPTION"; +var DEFAULT_TEL_TYPE = "CUSTOM_EVENT"; // Top-level event name for AI stats. Matches the standard AI event naming // (Microsoft.ApplicationInsights..). var AI_STATS_PREFIX = "Microsoft.ApplicationInsights."; var AI_STATS_SUFFIX = "SdkStats"; -// Removes all own keys from an object in place (used to reset accumulators without re-allocating). -function _clearObj(obj: { [key: string]: any }): void { - for (var key in obj) { - if (objHasOwn(obj, key)) { - delete obj[key]; - } - } -} - // Map baseType to spec telemetryType values var _typeMap: { [key: string]: string } = { - "EventData": "CUSTOM_EVENT", + "EventData": DEFAULT_TEL_TYPE, "MetricData": "CUSTOM_METRIC", "RemoteDependencyData": "DEPENDENCY", "ExceptionData": "EXCEPTION", @@ -36,9 +28,9 @@ var _typeMap: { [key: string]: string } = { "MessageData": "TRACE", "RequestData": "REQUEST", "AvailabilityData": "AVAILABILITY", - "PageActionData": "CUSTOM_EVENT", - "ContentUpdateData": "CUSTOM_EVENT", - "PageUnloadData": "CUSTOM_EVENT" + "PageActionData": DEFAULT_TEL_TYPE, + "ContentUpdateData": DEFAULT_TEL_TYPE, + "PageUnloadData": DEFAULT_TEL_TYPE }; /** @@ -109,7 +101,7 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin function _getTelType(item: ITelemetryItem): string { var bt = item.baseType; - return (bt && objHasOwn(_typeMap, bt) && _typeMap[bt]) || "CUSTOM_EVENT"; + return (bt && objHasOwn(_typeMap, bt) && _typeMap[bt]) || DEFAULT_TEL_TYPE; } function _isSdkStatsMetric(item: ITelemetryItem): boolean { @@ -126,6 +118,7 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin if (!_isSdkStatsMetric(items[i])) { var t = _getTelType(items[i]); if (!isUnsafePropKey(t)) { + // _successCounts is a null-prototype object (objCreate(null)) so this cannot pollute Object.prototype _successCounts[t] = (_successCounts[t] || 0) + 1; changed = true; } @@ -152,6 +145,7 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin if (!_isSdkStatsMetric(items[i])) { var t = _getTelType(items[i]); if (!isUnsafePropKey(t)) { + // bucket is a null-prototype object (objCreate(null)) so this cannot pollute Object.prototype bucket[t] = (bucket[t] || 0) + 1; changed = true; } @@ -244,10 +238,10 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin _flushBucketed(_droppedCounts, MET_DROPPED, "dropCode"); _flushBucketed(_retryCounts, MET_RETRY, "retryCode"); - // Reset accumulators in place to avoid allocating new null-prototype objects each flush - _clearObj(_successCounts); - _clearObj(_droppedCounts); - _clearObj(_retryCounts); + // Reset accumulators for the next interval + _successCounts = objCreate(null); + _droppedCounts = objCreate(null); + _retryCounts = objCreate(null); } return { diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts deleted file mode 100644 index ef0578302..000000000 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ /dev/null @@ -1,426 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { - ITimerHandler, arrForEach, isNumber, makeGlobRegex, objDefineProps, scheduleTimeout, strIndexOf, strLower, utcNow -} from "@nevware21/ts-utils"; -import { onConfigChange } from "../config/DynamicConfig"; -import { STR_EMPTY } from "../constants/InternalConstants"; -import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; -import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; -import { eStatsType } from "../enums/ai/StatsType"; -import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; -import { IConfiguration } from "../interfaces/ai/IConfiguration"; -import { INetworkStatsbeat } from "../interfaces/ai/INetworkStatsbeat"; -import { IStatsBeat, IStatsBeatConfig, IStatsBeatState, IStatsEndpointConfig } from "../interfaces/ai/IStatsBeat"; -import { IStatsMgr, IStatsMgrConfig } from "../interfaces/ai/IStatsMgr"; -import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; -import { IPayloadData } from "../interfaces/ai/IXHROverride"; -import { isFeatureEnabled } from "../utils/HelperFuncs"; - -const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes -const STATS_MIN_INTERVAL_SECONDS = 60; // 1 minute -const STATSBEAT_LANGUAGE = "JavaScript"; -const STATSBEAT_TYPE = "Browser"; - - -/** - * An internal interface to allow the IStatsBeat instance to call back to the manager for - * critical tasks, like starting the timer, sending the events and to inform the manager - * that this instance is stopping. This is used to ensure that the manager is able to - * track and control the lifecycle of the instance. - * @internal - */ -interface _IMgrCallbacks { - /** - * Provides a callback to the manager to start a timer for the statsbeat instance. - * This is used to ensure that the manager is able to control the lifecycle of the instance - * @param cb - The callback to call when the timer is started - * @returns A handle to the timer that was started, this can be used to cancel the timer if needed - */ - start: (cb: () => void) => ITimerHandler; - - /** - * Provides a callback to the manager to send the statsbeat event to the core. - * This is used to ensure that the manager is able to control the lifecycle of the instance - * @param statsbeatEvent - The statsbeat event to send to the core - * @param endpoint - The endpoint to send the event to - */ - track: (statsBeat: IStatsBeat, statsbeatEvent: ITelemetryItem) => void; -} - -/** - * This function checks if the provided endpoint matches the provided urlMatch. It - * compares the endpoint with the urlMatch in a case-insensitive manner and also checks - * if the endpoint is a substring of the urlMatch. The urlMatch can also be a regex - * pattern, in which case it will be checked against the endpoint using regex. - * @param endpoint - The endpoint to check against the URL. - * @param urlMatch - The URL to check against the endpoint. - * @returns true if the URL matches the endpoint, false otherwise. - */ -function _isMatchEndpoint(endpoint: string, urlMatch: string): boolean { - let lwrUrl = strLower(urlMatch); - - // Check if the endpoint is a substring of the URL - if (strIndexOf(endpoint, lwrUrl) !== -1) { - return true; - } - - // If it looks like a regex pattern, check if the endpoint matches the regex - if (strIndexOf(lwrUrl, "*") != -1 || strIndexOf(lwrUrl, "?") != -1) { - // Check if the endpoint is a regex pattern - let regex = makeGlobRegex(lwrUrl); - if (regex.test(endpoint)) { - return true; - } - } - - return false; -} - -/** - * Creates a new INetworkStatsbeat instance with the specified host. - * @param host - The host for the INetworkStatsbeat instance. - * @returns A new INetworkStatsbeat instance. - */ -function _createNetworkStatsbeat(host: string): INetworkStatsbeat { - return { - host, - totalRequest: 0, - success: 0, - throttle: {}, - failure: {}, - retry: {}, - exception: {}, - requestDuration: 0 - }; -} - -/** - * Creates a new IStatsBeat instance with the specified manager callbacks and statsbeat state. - * @param mgr - The manager callbacks to use for the IStatsBeat instance. - * @param statsBeatStats - The statsbeat state to use for the IStatsBeat instance. - * @returns A new IStatsBeat instance. - */ -function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): IStatsBeat { - let _networkCounter: INetworkStatsbeat = _createNetworkStatsbeat(statsBeatStats.endpoint); - let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. - let _isEnabled: boolean = true; // Flag to check if statsbeat is enabled or not - - function _setupTimer() { - if (_isEnabled && !_timeoutHandle) { - _timeoutHandle = mgr.start(() => { - _timeoutHandle = null; - trackStatsbeats(); - }); - } - } - - function trackStatsbeats() { - if (_isEnabled) { - _trackSendRequestDuration(); - _trackSendRequestsCount(); - _networkCounter = _createNetworkStatsbeat(_networkCounter.host); - _timeoutHandle && _timeoutHandle.cancel(); - _timeoutHandle = null; - } - } - - /** - * This is a simple helper that checks if the currently reporting endpoint is the same as this instance was - * created with. This is used to ensure that we only send statsbeat events to the endpoint that was used - * when the instance was created. This is important as the endpoint can change during the lifetime of the - * instance and we don't want to send statsbeat events to the wrong endpoint. - * @param endpoint - * @returns true if the endpoint is the same as the one used to create the instance, false otherwise - */ - function _checkEndpoint(endpoint: string) { - return _networkCounter.host === endpoint; - } - - /** - * Attempt to send statsbeat events to the server. This is done by creating a new event and sending it to the core. - * The event is created with the name and value passed in, and any additional properties are added to the event as well. - * This will only send the event when - * - the statsbeat is enabled - * - the statsbeat key is set for the current endpoint - * - the value is greater than 0 - * @param name - The name of the event to send - * @param val - The value of the event to send - * @param properties - Optional additional properties to add to the event - */ - function _sendStatsbeats(name: string, val: number, properties?: { [name: string]: any }) { - if (_isEnabled && val && val > 0){ - // Add extra properties - let baseProperties = { - "rp": "unknown", - "attach": "Manual", - "cikey": statsBeatStats.cKey, - "os": STATSBEAT_TYPE, - "language": STATSBEAT_LANGUAGE, - "version": statsBeatStats.sdkVer || "unknown", - "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 = { - name: name, - baseData: { - name: name, - average: val, - properties: combinedProps - }, - baseType: "MetricData" - }; - - mgr.track(statsBeat, 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 }); - } - } - - function _setEnabled(isEnabled: boolean) { - _isEnabled = isEnabled; - if (!_isEnabled) { - if (_timeoutHandle) { - _timeoutHandle.cancel(); - _timeoutHandle = null; - } - } - } - - // THE statsbeat instance being created and returned - let statsBeat: IStatsBeat = { - enabled: !!_isEnabled, - endpoint: STR_EMPTY, - type: eStatsType.SDK, - count: (status: number, payloadData: IPayloadData, endpoint: string) => { - if (_isEnabled && _checkEndpoint(endpoint)) { - if (payloadData && (payloadData as any)["statsBeatData"] && (payloadData as any)["statsBeatData"]["startTime"]) { - _networkCounter.totalRequest = (_networkCounter.totalRequest || 0) + 1; - _networkCounter.requestDuration += utcNow() - (payloadData as any)["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(); - } - }, - countException: (endpoint: string, exceptionType: string) => { - if (_isEnabled && _checkEndpoint(endpoint)) { - _networkCounter.exception[exceptionType] = (_networkCounter.exception[exceptionType] || 0) + 1; - _setupTimer(); - } - } - }; - - // Make the properties readonly / reactive to changes - return objDefineProps(statsBeat, { - enabled: { g: () => _isEnabled, s: _setEnabled }, - type: { g: () => statsBeatStats.type }, - endpoint: { g: () => _networkCounter.host } - }); -} - -function _getEndpointCfg(statsBeatConfig: IStatsBeatConfig, type: eStatsType): IStatsEndpointConfig { - let endpointCfg: IStatsEndpointConfig = null; - if (statsBeatConfig && statsBeatConfig.endCfg) { - arrForEach(statsBeatConfig.endCfg, (value) => { - if (value.type === type) { - endpointCfg = value; - return -1; // Stop the loop if we found a match - } - }); - } - - return endpointCfg; -} - -/** - * This function retrieves the stats instrumentation key (iKey) for the given endpoint from - * the statsBeatConfig. It iterates through the keys in the statsBeatConfig and checks if - * the endpoint matches any of the URLs associated with that key. If a match is found, it - * returns the corresponding iKey. - * @param statsBeatConfig - The configuration object for StatsBeat. - * @param endpoint - The endpoint to check against the URLs in the configuration. - * @returns The iKey associated with the matching endpoint, or null if no match is found. - */ -function _getIKey(endpointCfg: IStatsEndpointConfig, endpoint: string): string | null { - let statsKey: string = null; - if (endpointCfg.keyMap) { - arrForEach(endpointCfg.keyMap, (keyMap) => { - if (keyMap.match) { - arrForEach(keyMap.match, (url) => { - if (_isMatchEndpoint(url, endpoint)) { - statsKey = keyMap.key || null; - - // Stop the loop if we found a match - return -1; - } - }); - } - - if (statsKey) { - // Stop the loop if we found a match - return -1; - } - }); - } - - return statsKey; -} - -export function createStatsMgr(): IStatsMgr { - let _isMgrEnabled: boolean = false; // Flag to check if statsbeat is enabled or not - let _core: IAppInsightsCore; // The core instance that is used to send telemetry - let _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; - let _statsBeatConfig: IStatsBeatConfig; - - // Lazily initialize the manager and start listening for configuration changes - // This is also required to handle "unloading" and then re-initializing again - function _init(core: IAppInsightsCore, statsConfig: IStatsMgrConfig, featureName?: string) { - if (_core) { - // If the core is already set, then just return with an empty unload hook - _throwInternal(safeGetLogger(core), eLoggingSeverity.WARNING, _eInternalMessageId.StatsBeatManagerException, "StatsBeat manager is already initialized"); - return null; - } - - _core = core; - if (core && core.isInitialized()) { - // Start listening for configuration changes from the core config, within a config change handler - // This will support the scenario where the config is changed after the statsbeat has been created - return onConfigChange(core.config, (details) => { - // Check the feature state again to see if it has changed - _isMgrEnabled = false; - if (statsConfig && isFeatureEnabled(statsConfig.feature, details.cfg, false) === true) { - // Call the getCfg function to get the latest configuration for the statsbeat instance - // This should also evaluate the throttling level and other settings for the statsbeat instance - // to determine if it should be enabled or not. - _statsBeatConfig = statsConfig.getCfg(core, details.cfg); - if (_statsBeatConfig) { - _isMgrEnabled = true; - _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed - if (isNumber(_statsBeatConfig.shrtInt) && _statsBeatConfig.shrtInt > STATS_MIN_INTERVAL_SECONDS) { - _shortInterval = _statsBeatConfig.shrtInt * 1000; // Convert to milliseconds - } - } - } - }); - } - } - - function _track(statsBeat: IStatsBeat, statsBeatEvent: ITelemetryItem) { - if (_isMgrEnabled && _statsBeatConfig) { - let endpoint = statsBeat.endpoint; - let sendEvt = !!statsBeat.type; - - // Fetching the stats key for the endpoint here to support the scenario where the endpoint is changed - // after the statsbeat instance is created. This will ensure that the correct stats key is used for the endpoint. - // It also avoids the tracking of the statsbeat event if the endpoint is not in the config. - let endpointCfg = _getEndpointCfg(_statsBeatConfig, statsBeat.type); - if (endpointCfg) { - // Check for key remapping - let statsKey = _getIKey(endpointCfg, endpoint); - if (statsKey) { - // Using this iKey for the statsbeat event - statsBeatEvent.iKey = statsKey; - // We have specific config for this endpoint, so we can send the event - sendEvt = true; - } - - if (sendEvt) { - _core.track(statsBeatEvent); - } - } - } - } - - function _createInstance(state: IStatsBeatState): IStatsBeat { - let instance: IStatsBeat = null; - - if (_isMgrEnabled) { - let callbacks: _IMgrCallbacks = { - start: (cb: () => void) => { - return scheduleTimeout(cb, _shortInterval); - }, - track: _track - }; - - instance = _createStatsBeat(callbacks, state); - } - - return instance; - } - - let theMgr = { - enabled: false, - newInst: _createInstance, - init: _init - }; - - return objDefineProps(theMgr, { - "enabled": { g: () => _isMgrEnabled } - }); -} diff --git a/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts b/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts index 28b16bd8f..60cb3a611 100644 --- a/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts +++ b/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts @@ -127,8 +127,8 @@ export const enum _eInternalMessageId { CdnDeprecation = 110, SdkLdrUpdate = 111, InitPromiseException = 112, - StatsBeatManagerException = 113, - StatsBeatException = 114, + InternalSdkStatsManagerException = 113, + InternalSdkStatsException = 114, AttributeError = 115, SpanError = 116, TraceError = 117, diff --git a/shared/AppInsightsCore/src/enums/ai/StatsType.ts b/shared/AppInsightsCore/src/enums/ai/StatsType.ts deleted file mode 100644 index 720bd0123..000000000 --- a/shared/AppInsightsCore/src/enums/ai/StatsType.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// @skip-file-minify - -export const enum eStatsType { - SDK = 0, - CLIENT = 1, -} - -export type StatsType = number | eStatsType; diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index c50fa017c..45fcbcdbd 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -19,7 +19,6 @@ export { IUnloadHook, ILegacyUnloadHook } from "./interfaces/ai/IUnloadHook"; export { eEventsDiscardedReason, EventsDiscardedReason, eBatchDiscardedReason, BatchDiscardedReason } from "./enums/ai/EventsDiscardedReason"; export { eDependencyTypes, DependencyTypes } from "./enums/ai/DependencyTypes"; export { SendRequestReason } from "./enums/ai/SendRequestReason"; -//export { StatsType, eStatsType } from "./enums/ai/StatsType"; export { TelemetryUpdateReason } from "./enums/ai/TelemetryUpdateReason"; export { TelemetryUnloadReason } from "./enums/ai/TelemetryUnloadReason"; export { eUrlRedactionOptions, UrlRedactionOptions } from "./enums/ai/UrlRedactionOptions" @@ -40,11 +39,16 @@ export { parseResponse } from "./core/ResponseHelpers"; export { IXDomainRequest, IBackendResponse } from "./interfaces/ai/IXDomainRequest"; export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IInternalXhrOverride } from "./interfaces/ai/ISenderPostManager"; export { SenderPostManager } from "./core/SenderPostManager"; +export { + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn +} from "./interfaces/ai/IInternalSdkStats"; +export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; +export { CreateStatsCoreFn, IStatsMgr } from "./interfaces/ai/IStatsMgr"; +export { + createStatsMgr, getStatsCfgUrl, + STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_FEATURE +} from "./core/InternalSdkStats"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; -//export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState} from "./interfaces/ai/IStatsBeat"; -//export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; -//export { IStatsMgr, IStatsMgrConfig } from "./interfaces/ai/IStatsMgr"; -//export { createStatsMgr } from "./core/StatsBeat"; export { isArray, isTypeof, isUndefined, isNullOrUndefined, isStrictUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, strEndsWith, strStartsWith, isDate, isError, isString, isNumber, isBoolean, arrForEach, arrIndexOf, diff --git a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts index 198b6ee0e..e0394381d 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts @@ -11,10 +11,12 @@ import { IChannelControls } from "./IChannelControls"; import { IConfiguration } from "./IConfiguration"; import { ICookieMgr } from "./ICookieMgr"; import { IDiagnosticLogger } from "./IDiagnosticLogger"; +import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; import { INotificationListener } from "./INotificationListener"; import { INotificationManager } from "./INotificationManager"; import { IPerfManagerProvider } from "./IPerfManager"; import { IProcessTelemetryContext } from "./IProcessTelemetryContext"; +import { IStatsMgr } from "./IStatsMgr"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "./ITelemetryInitializers"; import { ITelemetryItem } from "./ITelemetryItem"; import { IPlugin, ITelemetryPlugin } from "./ITelemetryPlugin"; @@ -22,8 +24,6 @@ import { ITelemetryUnloadState } from "./ITelemetryUnloadState"; import { ITraceHost, ITraceProvider } from "./ITraceProvider"; import { ILegacyUnloadHook, IUnloadHook } from "./IUnloadHook"; -// import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; -// import { IStatsMgr } from "./IStatsMgr"; export interface ILoadedPlugin { plugin: T; @@ -121,21 +121,21 @@ export interface IAppInsightsCore void) => void; + +/** + * The configuration for the stats beat definition + * @since 3.3.7 + */ +export interface IInternalSdkStatsConfig { + /** + * Collection interval in seconds. Counters persist in session storage across page loads. + * Non-positive values are ignored and the default is used instead. + * @default 3600 (1 hour) + */ + shrtInt?: number; + + /** + * The url of the remote SDK Stats configuration (`cfg/v1.json`) that identifies whether SDK Stats + * collection is currently enabled and the host that the events should be sent to. This value is + * normally delivered by the CDN / dynamic configuration rather than being hard coded by the SDK. + * + * When the customer endpoint maps to an EU data-boundary region the `eu-` prefix is automatically + * inserted in front of the host, e.g. `https://data.stats.monitor.azure.com/cfg/v1.json` becomes + * `https://eu-data.stats.monitor.azure.com/cfg/v1.json`. + * + * When not supplied no SDK Stats are collected or sent. + * @default undefined + */ + cfgUrl?: string; + + /** + * Instrumentation key used for SDK Stats events. When omitted, SDK Stats are not sent. + * @default undefined + */ + iKey?: string; + + /** Snippet version appended to the SDK Stats version. */ + snp?: string; + + /** + * Optional override for the function used to fetch the remote SDK Stats configuration + * (`cfg/v1.json`). When not provided the default fetch / XHR based implementation is used. This + * is primarily intended for testing or advanced scenarios where the configuration needs to be + * resolved through a custom mechanism. + */ + overrideCfgFn?: InternalSdkStatsCfgFetchFn; +} diff --git a/shared/AppInsightsCore/src/interfaces/ai/INetworkStatsbeat.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStatsNetwork.ts similarity index 93% rename from shared/AppInsightsCore/src/interfaces/ai/INetworkStatsbeat.ts rename to shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStatsNetwork.ts index bb9232886..395e5442d 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/INetworkStatsbeat.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStatsNetwork.ts @@ -6,7 +6,7 @@ * and sending statistics about network requests. It is used to track the performance * and usage of network requests, and to identify any issues or errors that may occur. */ -export interface INetworkStatsbeat { +export interface IInternalSdkStatsNetwork { host: string; totalRequest: number; success: number; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts deleted file mode 100644 index 09468efab..000000000 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { StatsType } from "../../enums/ai/StatsType"; -import { IPayloadData } from "./IXHROverride"; - -/** - * The interface for the stats beat plugin, which is responsible for collecting and sending statistics about the SDK. - * It is used to track the performance and usage of the SDK, and to identify any issues or errors that may occur. - * @since 3.3.7 - */ -export interface IStatsBeat { - /** - * Returns whether this instance of the stats beat is enabled or not. - * @returns True if the stats beat is enabled, false otherwise. - */ - enabled: boolean; - - /** - * Return the current endpoint where the stats beat is sending events. - * @returns The current endpoint URL. - */ - endpoint: string; - - /** - * Returns the StatsType for this instance of the stats beat. - * @returns The current stats type. - */ - type: StatsType; - - /** - * Count the number of events sent to the endpoint with the given status code. - * @param status - The status code of the event. - * @param payloadData - The payload data of the event. - * @param endpoint - The endpoint where the event was sent. - */ - count(status: number, payloadData: IPayloadData, endpoint: string): void; - - /** - * Record an exception for the given endpoint and exception type. - * @param endpoint - The endpoint where the exception occurred. - * @param exceptionType - The type of the exception. - */ - countException(endpoint: string, exceptionType: string): void; -} - -/** - * The configuration passed to the stats beat plugin to record statistics about the SDK - * @since 3.3.7 - */ -export interface IStatsBeatState { - /** - * The current instrumentation key. - */ - cKey: string; - - /** - * The current endpoint where the events are sent. - */ - endpoint: string; - - /** - * The current Sdk version. - */ - sdkVer?: string; - - /** - * The type of the stats event. - */ - type?: StatsType; -} - -/** - * The configuration for the collection of supported endpoints - * @since 3.3.7 - */ -export interface IStatsBeatKeyMap { - /** - * The key to used to for any matching endpoints. - */ - key?: string; - - /** - * An array of string URLs that are supported by the endpoint, - * the string values are used to compar against the endpoint URL - * in a case insensitive manner. The values may also contain wildcards - * characters "*", "**" and "?" to match any number of characters using - * a glob style pattern. - */ - match: string[]; -} - -/** - * The configuration for the stats beat plugin, which is used to track the performance and usage of the SDK. - * It is used to identify any issues or errors that may occur, and to provide insights into the usage of the SDK. - * @since 3.3.7 - */ -export interface IStatsEndpointConfig { - /** - * Identifies the key(s) associated with the endpoints for the type of stats event. - */ - type: StatsType; - - /** - * The matching endpoints. - */ - keyMap?: IStatsBeatKeyMap[] -} - -/** - * The configuration for the stats beat definition - * @since 3.3.7 - */ -export interface IStatsBeatConfig { - /** - * The short collection interval in seconds to send the stats beat events. - * Default: 15 min - */ - shrtInt?: number; - - /** - * The Endpoint configurations for the stats beat plugin. - * This is used to identify the endpoints that are supported by the stats beat plugin. - */ - endCfg?: IStatsEndpointConfig[]; -} diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index 1913a3ac9..a01413d5c 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -3,39 +3,21 @@ import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; -import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "./IStatsBeat"; +import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; import { IUnloadHook } from "./IUnloadHook"; /** - * The interface for the Stats manager, which is passed to the StatsBeat instance - * during initialization. It provides an abstractions to allow the StatsBeat instance to - * access the configuration and state of the StatsBeat manager. + * Creates and initializes the isolated core used to send SDK Stats. + * @param config - The SDK Stats configuration containing the resolved iKey and endpoint. + * Implementations must handle creation errors and return null rather than throw. + * @returns The initialized core instance, or null when an SDK Stats pipeline cannot be created. + * @since 3.3.7 */ -export interface IStatsMgrConfig { - - /** - * Identifies the feature name used for this instance to determine if the StatsBeat instance - * should be initialized or not. This is used to identify the feature that this instance of the - */ - feature: string; - - /** - * A function to obtain the current configuration for the StatsBeat instance, this callback - * is called in a dynamic config context, so when any of the configuration values change, - * tis function will be called again to obtain the latest configuration values. - * This should also evaluate any throttling level and other settings for the statsbeat instance - * to determine if it should be enabled or not and return the appropriate configuration object. - * @param cfg - The current configuration object for the StatsBeat instance. - * @returns The configuration object that should be used to initialize / reinitialize the StatsBeat instance. - * It may return null if the StatsBeat instance should not be initialized or reinitialized, if the manager - * is already initialized and null is returned, the StatsBeat instance will be disabled. - */ - getCfg: (core: IAppInsightsCore, cfg: CfgType) => IStatsBeatConfig | undefined | null; -} +export type CreateStatsCoreFn = (config: IConfiguration) => IAppInsightsCore | null; /** - * The Interface which defines the StatsBeat manager, which is responsible for creating and - * managing the StatsBeat instance. + * The Interface which defines the InternalSdkStats manager, which is responsible for creating and + * managing the InternalSdkStats instance. * @since 3.3.7 */ export interface IStatsMgr { @@ -46,22 +28,33 @@ export interface IStatsMgr { readonly enabled: boolean; /** - * Initialize and associate this manager with the provided core instance and configuration. + * Initialize and associate this manager with the provided core instance. The manager reads its + * configuration directly from the single global config (`config.stats`) and gates itself behind + * the SDK Stats feature flag, so any changes made via the CDN / dynamic config are picked up at + * runtime. * @param core - The core instance to associate with this manager. - * @param isEnabled - + * @param createStatsCore - Creates an isolated, initialized core for SDK Stats. The callback lets + * each SKU select the appropriate channel and plugins without using the customer's pipeline. + * @param featureName - The optional featureOptIn name used to gate the manager. Defaults to the + * SDK Stats feature (`STATS_SDK_FEATURE`) which is enabled by default and can be opted-out via the + * `featureOptIn` configuration. * @returns The unload hook for the stats beat manager, which can be used to unload * and disable the manager. This may return null if the manager cannot be initialized. * @remarks This method should be called only once, and it may throw an error if called multiple times. */ - init: (core: IAppInsightsCore, cfg: IStatsMgrConfig) => IUnloadHook | null; + init: ( + core: IAppInsightsCore, + createStatsCore: CreateStatsCoreFn, + featureName?: string + ) => IUnloadHook | null; /** - * Returns a new {@link IStatsBeat} instance for the current state which includes the endpoint. + * Returns a new {@link IInternalSdkStats} instance for the current state which includes the endpoint. * This method should be called only after the manager has been initialized and the - * {@link IStatsBeatConfig} has been set, otherwise it will return null. + * {@link IInternalSdkStatsConfig} has been set, otherwise it will return null. * @param state - The current state of the stats beat manager. * @returns A new instance of the stats beat or null if the manager or the configuration does not support - * the {@link IStatsBeatState}. + * the {@link IInternalSdkStatsState}. */ - newInst: (state: IStatsBeatState) => IStatsBeat; + newInst: (state: IInternalSdkStatsState) => IInternalSdkStats; } \ No newline at end of file diff --git a/shared/AppInsightsCore/src/utils/DataCacheHelper.ts b/shared/AppInsightsCore/src/utils/DataCacheHelper.ts index 043f47a2a..8b577f77c 100644 --- a/shared/AppInsightsCore/src/utils/DataCacheHelper.ts +++ b/shared/AppInsightsCore/src/utils/DataCacheHelper.ts @@ -6,7 +6,7 @@ import { STR_EMPTY } from "../constants/InternalConstants"; import { normalizeJsName } from "./HelperFuncs"; import { newId } from "./RandomHelper"; -const version = "3.4.3"; +const version = "#version#"; let instanceName = "." + newId(6); let _dataUid = 0;