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 cc94f03cb..3d171e1e4 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 = 182; + private readonly MAX_BUNDLE_SIZE = 183; + 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.4"; diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index 884092241..99ffaf3a7 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -1,6 +1,8 @@ import { ApplicationInsights, IConfig, IConfiguration } from '../../../src/applicationinsights-web'; import { AITestClass, Assert } from '@microsoft/ai-test-framework'; -import { FeatureOptInMode, ISdkStatsNotifCbk, onConfigChange } from '@microsoft/applicationinsights-core-js'; +import { + _eInternalMessageId, FeatureOptInMode, ISdkStatsNotifCbk, onConfigChange, STATS_SDK_FEATURE, ThrottleMgr +} from '@microsoft/applicationinsights-core-js'; import { AppInsightsSku } from '../../../src/AISku'; import { ICfgSyncMode } from '@microsoft/applicationinsights-cfgsync-js'; @@ -37,9 +39,13 @@ export class SdkStatsFeatureTests extends AITestClass { public registerTests() { this._testSdkStatsEnabledByDefault(); this._testSdkStatsDisabledViaFeatureOptIn(); + this._testCustomerSdkStatsIgnoresInternalThrottle(); this._testSdkStatsDynamicEnableDisable(); this._testSdkStatsConfigDefaults(); + this._testSdkStatsDailyThrottle(); this._testSdkStatsDynamicConfigChanges(); + this._testInternalSdkStatsDynamicConfigInitialization(); + this._testSnippetSdkVersion(); } private _createAi(configOverrides?: Partial): AppInsightsSku { @@ -132,6 +138,34 @@ export class SdkStatsFeatureTests extends AITestClass { }); } + private _testCustomerSdkStatsIgnoresInternalThrottle() { + this.testCase({ + name: "SdkStatsFeature: dedicated SDK Stats throttle does not disable customer SDK Stats", + useFakeTimers: true, + test: () => { + let ai = this._createAi({ + throttleMgrCfg: { + [STATS_SDK_FEATURE]: { + disabled: false, + limit: { + samplingRate: 0 + } + } + } + }); + this.clock.tick(1); + + Assert.ok(this._findSdkStatsListener(ai), + "Customer SDK Stats should remain enabled when dedicated SDK Stats are throttled"); + Assert.ok(ai["core"].getSdkStats({ + cKey: TestInstrumentationKey, + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }), "The throttle should apply to SDK Stats operations without disabling the manager"); + } + }); + } + private _testSdkStatsDynamicEnableDisable() { this.testCase({ name: "SdkStatsFeature: disabling SdkStats feature dynamically removes the listener", @@ -218,8 +252,19 @@ export class SdkStatsFeatureTests extends AITestClass { this.clock.tick(1); let config = ai.config; + let statsThrottle = config.throttleMgrCfg[STATS_SDK_FEATURE]; Assert.ok(config.sdkStats, "sdkStats config should exist after initialization"); - Assert.equal(900000, config.sdkStats!.int, "int should default to 900000 (15 minutes)"); + Assert.equal(900000, config.sdkStats.int, "int should default to 900000 (15 minutes)"); + Assert.equal(100, statsThrottle.limit.samplingRate, + "Dedicated SDK Stats throttle should use the legacy default sampling rate"); + Assert.equal(24 * 60 * 60 * 1000 / config.sdkStats.int, + statsThrottle.limit.maxSendNumber, + "Dedicated SDK Stats throttle should cover 96 reports at 15-minute intervals in one day"); + Assert.deepEqual({ dayInterval: 1 }, statsThrottle.interval, + "Dedicated SDK Stats should use a daily interval without quarterly date restrictions"); + Assert.deepEqual({ monthInterval: 3, daysOfMonth: [28] }, + config.throttleMgrCfg[_eInternalMessageId.DefaultThrottleMsgKey].interval, + "Diagnostic messages should retain the quarterly interval"); } }); @@ -230,13 +275,90 @@ export class SdkStatsFeatureTests extends AITestClass { let ai = this._createAi({ sdkStats: { int: 60000 + }, + throttleMgrCfg: { + [STATS_SDK_FEATURE]: { + limit: { + maxSendNumber: 48 + }, + interval: { + dayInterval: 2 + } + } } }); this.clock.tick(1); let config = ai.config; + let statsThrottle = config.throttleMgrCfg[STATS_SDK_FEATURE]; Assert.ok(config.sdkStats, "sdkStats config should exist"); - Assert.equal(60000, config.sdkStats!.int, "User-provided int should be preserved"); + Assert.equal(60000, config.sdkStats.int, "User-provided int should be preserved"); + Assert.equal(48, statsThrottle.limit.maxSendNumber, + "User-provided maxSendNumber should be preserved"); + Assert.equal(2, statsThrottle.interval.dayInterval, + "User-provided dayInterval should be preserved"); + + statsThrottle.limit.maxSendNumber = 192; + statsThrottle.interval.dayInterval = 3; + this.clock.tick(1); + Assert.equal(192, statsThrottle.limit.maxSendNumber, + "maxSendNumber should support runtime updates"); + Assert.equal(3, statsThrottle.interval.dayInterval, + "dayInterval should support runtime updates"); + } + }); + } + + private _testSdkStatsDailyThrottle() { + this.testCase({ + name: "SdkStatsFeature: default throttle caps SDK Stats at 96 callbacks and permits sending the next day", + useFakeTimers: true, + test: () => { + this.clock.setSystemTime(Date.UTC(2026, 8, 24, 12)); + localStorage.setItem("appInsightsThrottle-feature-" + STATS_SDK_FEATURE, JSON.stringify({ + date: new Date(Date.UTC(2026, 8, 23, 12)), + count: 8832 + })); + let ai = this._createAi({ + throttleMgrCfg: { + [STATS_SDK_FEATURE]: { + limit: { samplingRate: 1000000 } + } + } + }); + let throttleMgr = new ThrottleMgr(ai.core); + throttleMgr.onReadyState(true); + let calls = 0; + const callback = () => { + calls++; + }; + + let result = throttleMgr.useFeature(STATS_SDK_FEATURE, callback, true); + Assert.equal(true, result.isThrottled, "SDK Stats should be eligible on the 24th, not just the 28th"); + Assert.equal(96, result.throttleNum, "A quarterly backlog should be capped at the daily limit"); + Assert.equal(96, calls, "Only 96 callbacks should run"); + + for (let lp = 0; lp < 100; lp++) { + throttleMgr.useFeature(STATS_SDK_FEATURE, callback, true); + } + Assert.equal(96, calls, "Subsequent attempts on the same UTC day should not run callbacks"); + + this.clock.setSystemTime(Date.UTC(2026, 8, 25, 12)); + result = throttleMgr.useFeature(STATS_SDK_FEATURE, callback, true); + Assert.equal(96, result.throttleNum, "The next UTC day should permit another capped flush"); + Assert.equal(192, calls, "Both daily flushes should respect the 96-callback limit"); + + ai.config.throttleMgrCfg[STATS_SDK_FEATURE].interval.dayInterval = 2; + this.clock.tick(1); + this.clock.setSystemTime(Date.UTC(2026, 8, 26, 12)); + result = throttleMgr.useFeature(STATS_SDK_FEATURE, callback, true); + Assert.equal(0, result.throttleNum, "The updated two-day interval should skip the next day"); + Assert.equal(192, calls, "An ineligible day should not run callbacks"); + + this.clock.setSystemTime(Date.UTC(2026, 8, 27, 12)); + result = throttleMgr.useFeature(STATS_SDK_FEATURE, callback, true); + Assert.equal(2, result.throttleNum, "The next eligible day should flush the accumulated attempts"); + Assert.equal(194, calls, "Runtime interval changes should affect callback eligibility"); } }); } @@ -303,4 +425,112 @@ export class SdkStatsFeatureTests extends AITestClass { } }); } + + private _testInternalSdkStatsDynamicConfigInitialization() { + this.testCase({ + name: "SdkStatsFeature: internal SDK Stats initializes when dynamic configuration arrives after loadAppInsights", + useFakeTimers: true, + useFakeServer: true, + test: () => { + const cfgUrl = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; + const statsHost = "tst-data.stats.monitor.azure.com"; + const statsIKey = "000e0000-e000-0000-a000-000000000000"; + const state = { + cKey: TestInstrumentationKey, + endpoint: "https://dynamic-config.example.com/v2/track", + sdkVer: "1.0.0" + }; + const storageKey = state.cKey + ":" + state.endpoint; + sessionStorage.removeItem(storageKey); + this.onDone(() => sessionStorage.removeItem(storageKey)); + this.hookSendBeacon(() => {}); + this.hookFetch((resolve) => resolve(new Response("{}", { status: 200 }))); + let xhrSendSpy = this.sandbox.spy(XMLHttpRequest.prototype, "send"); + + let ai = this._createAi(); + this.clock.tick(1); + + Assert.ok(ai.config.stats, "loadAppInsights should seed the dynamic stats configuration"); + Assert.equal(undefined, ai.config.stats.cfgUrl, "The SDK Stats config URL should not be supplied initially"); + Assert.equal(undefined, ai.config.stats.iKey, "The SDK Stats instrumentation key should not be supplied initially"); + let stats = ai.core.getSdkStats(state); + Assert.ok(stats && stats.enabled, "loadAppInsights should initialize the internal SDK Stats manager"); + stats.countException(state.endpoint, "NetworkError"); + this.clock.tick(1000); + Assert.equal(0, this.activeXhrRequests.length, "SDK Stats should not send before configuration arrives"); + + let fetchedUrls: string[] = []; + ai.updateCfg({ + stats: { + shrtInt: 1, + cfgUrl: cfgUrl, + iKey: statsIKey, + overrideCfgFn: (url, oncomplete) => { + fetchedUrls.push(url); + oncomplete({ enabled: true, url: statsHost }); + } + }, + throttleMgrCfg: { + [_eInternalMessageId.DefaultThrottleMsgKey]: { disabled: false }, + [STATS_SDK_FEATURE]: { + limit: { samplingRate: 1000000 } + } + } + }); + this.clock.tick(1002); + + Assert.deepEqual([cfgUrl], fetchedUrls, + "The existing manager should use the dynamically supplied config fetcher and URL"); + Assert.strictEqual(stats, ai.core.getSdkStats(state), + "Configuration should initialize sending without replacing the stats instance"); + let requests = this.activeXhrRequests; + Assert.equal(1, requests.length, "The isolated SDK Stats sender should send the buffered counter"); + Assert.equal("https://" + statsHost + "/v2/track", requests[0].url, "SDK Stats should use the remote-configured endpoint"); + let payload = JSON.parse(xhrSendSpy.firstCall.args[0]); + Assert.equal(statsIKey, payload[0].iKey, "SDK Stats should use the dynamically supplied instrumentation key"); + Assert.equal("exception", payload[0].data.baseData.metrics[0].name, "The buffered exception should be reported"); + Assert.equal(1, payload[0].data.baseData.metrics[0].value, "The buffered exception count should be preserved"); + requests[0].respond(200, {}, ""); + + ai.config.featureOptIn = { + [STATS_SDK_FEATURE]: { mode: FeatureOptInMode.disable } + }; + this.clock.tick(1); + Assert.equal(null, ai.core.getSdkStats(state), + "Disabling the internal feature dynamically should remove its stats instance"); + Assert.equal(false, stats.enabled, "The previous stats instance should be stopped"); + + ai.config.featureOptIn[STATS_SDK_FEATURE].mode = FeatureOptInMode.enable; + this.clock.tick(1); + let reenabledStats = ai.core.getSdkStats(state); + Assert.ok(reenabledStats && reenabledStats.enabled, "Re-enabling the internal feature should initialize stats again"); + } + }); + } + + 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..8fc8e6fff 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -13,12 +13,12 @@ import { IMetricTelemetry, INotificationManager, IOTelApi, IOTelSpanOptions, IPageViewPerformanceTelemetry, IPageViewTelemetry, IPlugin, IReadableSpan, IRequestHeaders, ISdkStatsNotifCbk, ISpanScope, ITelemetryContext as Common_ITelemetryContext, 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, - removePageHideEventListener, removePageUnloadEventListener, useSpan + IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, PropertiesPluginIdentifier, + STATS_SDK_FEATURE, ThrottleMgr, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, addPageHideEventListener, + addPageUnloadEventListener, cfgDfMerge, 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 { AjaxPlugin as DependenciesPlugin, DependencyInitializerFunction, DependencyListenerFunction, IDependencyInitializerHandler, @@ -84,6 +84,17 @@ const default_throttle_config = { interval: cfgDfMerge(default_interval) } as IThrottleMgrConfig; +const sdk_stats_throttle_config = { + disabled: false, + limit: cfgDfMerge({ + samplingRate: 100, + maxSendNumber: 96 // 24 hours * 4 reports per hour + }), + interval: { + dayInterval: 1 + } +} as IThrottleMgrConfig; + // We need to include all properties that we only reference that we want to be dynamically updatable here // So they are converted even when not specified in the passed configuration const defaultConfigValues: IConfigDefaults = { @@ -102,12 +113,13 @@ const defaultConfigValues: IConfigDefaults = { sdkStats: cfgDfMerge({ int: 900000 }), - throttleMgrCfg: cfgDfMerge<{[key:number]: IThrottleMgrConfig}>( + throttleMgrCfg: cfgDfMerge<{[key: string]: IThrottleMgrConfig}>( { [_eInternalMessageId.DefaultThrottleMsgKey]:cfgDfMerge(default_throttle_config), [_eInternalMessageId.InstrumentationKeyDeprecation]:cfgDfMerge(default_throttle_config), [_eInternalMessageId.SdkLdrUpdate]:cfgDfMerge(default_throttle_config), - [_eInternalMessageId.CdnDeprecation]:cfgDfMerge(default_throttle_config) + [_eInternalMessageId.CdnDeprecation]:cfgDfMerge(default_throttle_config), + [STATS_SDK_FEATURE]: cfgDfMerge(sdk_stats_throttle_config) } ), extensionConfig: cfgDfMerge<{[key: string]: any}>({ @@ -398,6 +410,30 @@ 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; + } + }, STATS_SDK_FEATURE, _throttleMgr.useFeature)); + // Initialize the initial OTel API _otelApi = _initOTel(_self, "aisku", _onEnd, _onException); @@ -405,14 +441,15 @@ export class AppInsightsSku implements IApplicationInsights properties.context }); - if (!_throttleMgr){ - _throttleMgr = new ThrottleMgr(_core); - } let sdkSrc = _findSdkSourceFile(); if (sdkSrc && _self.context) { _self.context.internal.sdkSrc = sdkSrc; } _updateSnippetProperties(_self.snippet); + + if (_config.stats) { + _config.stats.snp = _snippetVersion; + } // Empty queue of all api calls logged prior to sdk download _self.emptyQueue(); diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts new file mode 100644 index 000000000..c6087cd4b --- /dev/null +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -0,0 +1,272 @@ +import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; +import { + AppInsightsCore, createStatsMgr, FeatureOptInMode, IAppInsightsCore, IConfiguration, IInternalSdkStatsState, + IStatsMgr, ITelemetryItem, TransportType +} from "@microsoft/applicationinsights-core-js"; +import { Sender } from "../../../src/Sender"; +import { SinonSpy } from "sinon"; +import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; +import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; + +const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_HOST = "tst-data.stats.monitor.azure.com"; + +export class InternalSdkStatsTests extends AITestClass { + private _core: AppInsightsCore; + private _sender: Sender; + private _statsMgr: IStatsMgr; + private internalSdkStatsCountSpy: SinonSpy; + + public testInitialize() { + this._core = new AppInsightsCore(); + this._sender = new Sender(); + this._statsMgr = createStatsMgr(); + this.onDone(() => { + if (this._core.isInitialized()) { + this._sender.pause(); + this._sender._buffer.clear(); + this._core.unload(false); + } + }); + } + + private createStatsCore(config: IConfiguration): IAppInsightsCore { + return { + config, + isInitialized: () => true, + track: (item: ITelemetryItem) => { + }, + unload: () => { + } + } as any; + } + + private initializeCoreAndSender(config: Partial, instrumentationKey: string) { + const sender = new Sender(); + const core = new AppInsightsCore(); + this.onDone(() => { + if (core.isInitialized()) { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + } + }); + const coreConfig = { + instrumentationKey, + stats: { + shrtInt: 900, + // The config url gates collection, without it nothing is collected or sent + cfgUrl: STATS_TEST_CFG_URL, + 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: STATS_TEST_HOST }); + } + }, + 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.addUnloadHook(unloadHook); + core.setStatsMgr(statsMgr); + + let internalSdkStatsState: IInternalSdkStatsState = { + cKey: instrumentationKey, + endpoint: config.endpointUrl, + sdkVer: "javascript:3.4.3:snp6" + }; + + this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); + return { core, sender, statsMgr, unloadHook }; + } + + private createSenderConfig(transportType: TransportType): Partial { + 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) { + sender.processTelemetry(telemetryItem, null); + sender.flush(); + } + + 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: STATS_TEST_CFG_URL, + iKey: "Stats-Test-iKey", + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: STATS_TEST_HOST }); + } + } + + }; + + this._core.initialize(config, [this._sender]); + this._core.addUnloadHook(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.testCase({ + name: "SDK Stats increments success count when fetch sender is called once", + useFakeTimers: true, + useFakeServer: true, + test: () => { + const fetchCalls = this.hookFetch((resolve) => { + 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); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called && fetchCalls.length > 0; + }, "Waiting for fetch sender and SDK Stats count to be called", 30, 100)).add(() => { + Assert.equal(1, fetchCalls.length, "Fetch sender should be called once"); + this.assertInternalSdkStatsCall(200); + }); + } + }); + + this.testCase({ + name: "SDK Stats increments throttle count when fetch sender is called with status 439", + useFakeTimers: true, + test: () => { + const fetchCalls = this.hookFetch((resolve) => { + 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); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called && fetchCalls.length > 0; + }, "Waiting for fetch sender and SDK Stats count to be called", 30, 100)).add(() => { + Assert.equal(1, fetchCalls.length, "Fetch sender should be called once"); + this.assertInternalSdkStatsCall(439); + }); + } + }); + + this.testCase({ + name: "SDK Stats increments success count for beacon sender", + useFakeTimers: true, + test: () => { + 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: {} + }; + const beaconCalls = this.hookSendBeacon(() => {}); + QUnit.assert.ok(isBeaconsSupported(), "Beacon API is supported"); + this.processTelemetryAndFlush(sender, telemetryItem); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called && beaconCalls.length > 0; + }, "Waiting for beacon sender and SDK Stats count to be called", 30, 100)).add(() => { + Assert.equal(1, beaconCalls.length, "Beacon sender should be called once"); + this.assertInternalSdkStatsCall(200); + }); + } + }); + + + this.testCase({ + name: "SDK Stats increments success count for xhr sender", + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: true, + test: () => { + let config: Partial = this.createSenderConfig(TransportType.Xhr); + config.disableSendBeaconSplit = true; + 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); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called; + }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000)).add(() => { + this.assertInternalSdkStatsCall(200); + }); + } + }); + } +} 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/docs/ThrottleMgr.md b/docs/ThrottleMgr.md index b0e68f8fb..f7a7ccbd5 100644 --- a/docs/ThrottleMgr.md +++ b/docs/ThrottleMgr.md @@ -84,3 +84,36 @@ throttleMgr.isTriggered(instrumentationKeyDeprecation); throttleMgr.sendMessage(instrumentationKeyDeprecation, "Instrumentation Key Deprecation Message"); ``` + +### Throttle Feature Rollout + +`useFeature()` combines `featureOptIn` with an optional throttle configuration that uses the same +string key. It runs the supplied callback when the persisted sampling, interval, and send-count +limits allow the feature operation. + +Feature throttle state is persisted separately from numeric internal-message state. When the named +throttle configuration is missing, only the `featureOptIn` state is used. A configuration with +`disabled: true` suppresses the callback. + +```javascript +let coreCfg = { + featureOptIn: { + sdkStats: { mode: 3 } + }, + throttleMgrCfg: { + sdkStats: { + disabled: false, + limit: { + samplingRate: 1000000 + } + } + } +}; + +core.initialize(coreCfg, [sender]); +const throttleMgr = new ThrottleMgr(core); + +throttleMgr.useFeature("sdkStats", () => { + // Perform the feature operation. +}, true); +``` 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 f2b45254f..1106d6f25 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 = 138; + private readonly MAX_BUNDLE_SIZE = 139; + 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..f8eb378e7 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -0,0 +1,1136 @@ +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"; +import { UseFeatureFn } from "../../../../src/interfaces/ai/IThrottleMgr"; + +const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes +const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_HOST = "tst-data.stats.monitor.azure.com"; +const STATS_TEST_IKEY = "Stats-Test-iKey"; +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(); + + _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: STATS_TEST_HOST }); + } + } + }; + + _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 testFinishedCleanup() { + 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 = []; + } + + 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", + useFeature?: UseFeatureFn + ) { + let hook = this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName, useFeature); + core.addUnloadHook(hook); + return hook; + } + + 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://" + STATS_TEST_HOST + "/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: STATS_TEST_HOST }); + }; + 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: STATS_TEST_HOST }); + }; + 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-tst-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: manager delegates generated events to the SKU feature throttle", + useFakeTimers: true, + test: () => { + let featureUses = 0; + this._initStatsMgr(this._core, "InternalSdkStats", (feature, callback, sdkDefaultState) => { + featureUses++; + Assert.equal("InternalSdkStats", feature, "The configured feature name should be checked"); + Assert.equal(true, sdkDefaultState, "SDK Stats should remain enabled by default"); + callback(); + return { + isThrottled: true, + throttleNum: 1 + }; + }); + + 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(true, this._statsMgr.enabled, "The feature throttle should not disable dynamic manager configuration"); + Assert.ok(featureUses > 0, "Generated SDK Stats events should use the feature throttle"); + Assert.ok(this._trackSpy.called, "The feature callback should perform the SDK Stats operation"); + } + }); + + this.testCase({ + name: "SDK Stats: queued feature operations retain their sampling percentage", + useFakeTimers: true, + test: () => { + let featureCallback: () => void; + this._initStatsMgr(this._core, "InternalSdkStats", (_feature, callback) => { + featureCallback = callback; + return null; + }); + + 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(featureCallback, "The generated event should be queued by the feature throttle"); + Assert.equal(0, this._trackSpy.callCount, "The queued SDK Stats event should not be sent early"); + + this._core.config.stats.samplingPercentage = 1; + this.clock.tick(1); + featureCallback(); + + Assert.equal(1, this._trackSpy.callCount, "The feature callback should send the queued event"); + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "The queued event should retain the sampling percentage used for its sampling decision"); + } + }); + + this.testCase({ + name: "SDK Stats: sampling percentage defaults to 100 percent", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + Assert.equal(100, this._core.config.stats.samplingPercentage, + "The default should be applied to the dynamic SDK Stats config"); + + 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(1, this._trackSpy.callCount, "The default should send every generated SDK Stats item"); + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "SDK Stats should include the default sampling percentage"); + } + }); + + this.testCase({ + name: "SDK Stats: sampling percentage dynamically controls generated telemetry", + useFakeTimers: true, + test: () => { + this._core.config.stats.samplingPercentage = 0; + this.clock.tick(1); + this._initStatsMgr(); + + let state = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }; + + Assert.equal(true, this._statsMgr.enabled, "A zero sampling percentage should not disable collection"); + let internalSdkStats = this._statsMgr.newInst(state); + Assert.ok(internalSdkStats, "SDK Stats should still be collected at zero percent"); + internalSdkStats.countException(state.endpoint, "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "A zero sampling percentage should drop generated SDK Stats"); + Assert.equal(0, this._statsCoreConfigs.length, "No isolated core should be needed when every item is sampled out"); + + this._core.config.stats.samplingPercentage = 100; + this.clock.tick(1); + + internalSdkStats.countException(state.endpoint, "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "A dynamic sampling change should allow generated SDK Stats"); + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "SDK Stats should include the configured sampling percentage"); + + let trackCount = this._trackSpy.callCount; + this._core.config.stats.samplingPercentage = 0; + this.clock.tick(1); + internalSdkStats.countException(state.endpoint, "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(true, this._statsMgr.enabled, "Sampling changes should not disable collection"); + Assert.equal(trackCount, this._trackSpy.callCount, "A dynamic zero sampling percentage should drop generated SDK Stats"); + } + }); + + this.testCase({ + name: "SDK Stats: invalid sampling percentage defaults to 100 percent", + useFakeTimers: true, + test: () => { + this._core.config.stats.samplingPercentage = 101; + 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(100, this._trackSpy.firstCall.args[0].sampleRate, + "Invalid sampling percentages should use the default"); + } + }); + + 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: STATS_TEST_HOST }); + 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-tst-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-tst-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"); + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("HTTPS://WESTEUROPE.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "EU endpoint matching should be case insensitive"); + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU endpoint without a scheme should resolve to the EU url"); + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("custom://westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU endpoint with a custom scheme should resolve to the EU url"); + Assert.equal(STATS_TEST_CFG_URL, + getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/westeurope", STATS_TEST_CFG_URL), + "An EU region outside the host prefix should not 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-tst-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-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://northeurope.in.applicationinsights.azure.com/", "tst-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: serialization failures do not break counter updates", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + let stringifyStub = this.sandbox.stub(JSON, "stringify").callsFake(() => { + throw new Error("Serialization failed"); + }); + let didThrow = false; + + try { + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + } catch (e) { + didThrow = true; + } + stringifyStub.restore(); + + Assert.equal(false, didThrow, "A serialization failure should not escape SDK Stats"); + Assert.equal(1, stringifyStub.callCount, "SDK Stats should attempt to persist the updated counters"); + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + let stored = _readStatsStorage("Test-iKey", "https://example.endpoint.com"); + Assert.equal(2, stored.cnt.exception["NetworkError"], + "Counters updated before the serialization failure should be persisted by the next update"); + } + }); + + 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: STATS_TEST_HOST }); + } + } + } 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/ai/ThrottleMgr.tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts index 8a0da73dd..9d9c3a612 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts @@ -3,6 +3,7 @@ import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; +import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; import { _eInternalMessageId } from "../../../../src/enums/ai/LoggingEnums"; import { ThrottleMgr } from "../../../../src/diagnostics/ThrottleMgr"; import { SinonSpy } from "sinon"; @@ -110,6 +111,125 @@ export class ThrottleMgrTest extends AITestClass { public registerTests() { + this.testCase({ + name: "ThrottleMgrTest: useFeature queues callbacks and uses persisted feature throttling", + useFakeTimers: true, + test: () => { + let feature = this._msgId + "Feature"; + let featureStorageName = "appInsightsThrottle-feature-" + feature; + let date = new Date(); + window.localStorage[featureStorageName] = JSON.stringify({ + date: date, + count: 5 + }); + let coreCfg = { + instrumentationKey: "test", + featureOptIn: { + [feature]: { mode: FeatureOptInMode.enable } + }, + throttleMgrCfg: { + [feature]: { + disabled: false, + limit: { + samplingRate: 1000000, + maxSendNumber: 2 + }, + interval: { + dayInterval: 1 + } + } + } + } as IConfiguration & IConfig; + this._core.initialize(coreCfg, [this._channel]); + + let throttleMgr = new ThrottleMgr(this._core); + Assert.equal(undefined, throttleMgr.getConfig()[this._msgId], + "A feature key that begins with digits should not configure an internal message"); + let featureUses = 0; + Assert.equal(null, throttleMgr.useFeature(feature, () => { + featureUses++; + }, true), "The feature operation should be queued until the throttle manager is ready"); + Assert.equal(0, featureUses, "The queued callback should not run early"); + + Assert.equal(true, throttleMgr.onReadyState(true), "The queued feature operation should be flushed"); + Assert.equal(2, featureUses, "The persisted count and maxSendNumber should control callback invocations"); + let storedFeature = JSON.parse(window.localStorage[featureStorageName]); + Assert.equal(0, storedFeature.count, "The persisted feature count should reset after triggering"); + compareDates(date, storedFeature.date); + compareDates(date, storedFeature.preTriggerDate); + Assert.equal(undefined, window.localStorage["appInsightsThrottle-" + feature], + "Feature state should use a separate storage key from numeric messages"); + + let result = throttleMgr.useFeature(feature, () => { + featureUses++; + }, true); + Assert.deepEqual({ + isThrottled: false, + throttleNum: 0 + }, result, "The feature operation should only trigger once per day"); + Assert.equal(2, featureUses, "A second feature callback should not run on the same day"); + storedFeature = JSON.parse(window.localStorage[featureStorageName]); + Assert.equal(1, storedFeature.count, "Suppressed feature operations should be persisted"); + + this._core.config.featureOptIn[feature].mode = FeatureOptInMode.disable; + this.clock.tick(1); + Assert.equal(null, throttleMgr.useFeature(feature, () => { + featureUses++; + }, true), "Feature opt-out should take precedence over throttling"); + + this._core.config.featureOptIn[feature].mode = FeatureOptInMode.enable; + this._core.config.throttleMgrCfg[feature].disabled = true; + this.clock.tick(1); + result = throttleMgr.useFeature(feature, () => { + featureUses++; + }, true); + Assert.deepEqual({ + isThrottled: false, + throttleNum: 0 + }, result, "A disabled throttle should not invoke the feature operation"); + Assert.equal(2, featureUses, "A disabled throttle should suppress feature callbacks"); + Assert.equal(0, this.loggingSpy.callCount, "Feature checks should not emit internal messages"); + } + }); + + this.testCase({ + name: "ThrottleMgrTest: queued feature callbacks use the latest dynamic throttle config", + useFakeTimers: true, + test: () => { + let feature = "dynamicFeature"; + this._core.initialize({ + instrumentationKey: "test", + featureOptIn: { + [feature]: { mode: FeatureOptInMode.enable } + }, + throttleMgrCfg: {} + } as IConfiguration & IConfig, [this._channel]); + + let throttleMgr = new ThrottleMgr(this._core); + let featureUses = 0; + throttleMgr.useFeature(feature, () => { + featureUses++; + }, true); + + this._core.updateCfg({ + throttleMgrCfg: { + [feature]: { + disabled: false, + limit: { + samplingRate: 0 + }, + interval: { + dayInterval: 1 + } + } + } + }); + this.clock.tick(1); + Assert.equal(true, throttleMgr.onReadyState(true), "The feature callback should be removed from the queue"); + Assert.equal(0, featureUses, "The latest zero sampling rate should suppress the queued callback"); + } + }); + this.testCase({ name: "ThrottleMgrTest: Default config should be set from root", test: () => { @@ -400,7 +520,8 @@ export class ThrottleMgrTest extends AITestClass { Assert.ok(target && target.length === 1, "target should contain queue"); let queue = target[0][this._msgKey]; Assert.deepEqual(queue.length,1, "should have 1 item"); - Assert.equal(queue[0].msgID, this._msgId, "should be correct msgId"); + Assert.equal(queue[0].key, this._msgId, "should be correct msgId"); + Assert.equal("function", typeof queue[0].callback, "should queue a callback"); throttleMgr.onReadyState(true); target = throttleMgr["_getDbgPlgTargets"](); @@ -463,10 +584,12 @@ export class ThrottleMgrTest extends AITestClass { Assert.ok(target && target.length === 1, "target should contain queue"); let queue = target[0][this._msgKey]; Assert.deepEqual(queue.length,1, "should have 1 item"); - Assert.equal(queue[0].msgID, this._msgId, "should be correct msgId"); + Assert.equal(queue[0].key, this._msgId, "should be correct msgId"); + Assert.equal("function", typeof queue[0].callback, "should queue a callback"); queue = target[0][msgId]; Assert.deepEqual(queue.length,1, "should have 1 item test1"); - Assert.equal(queue[0].msgID, msgId, "should be correct msgId test1"); + Assert.equal(queue[0].key, msgId, "should be correct msgId test1"); + Assert.equal("function", typeof queue[0].callback, "should queue a callback test1"); throttleMgr.onReadyState(true); target = throttleMgr["_getDbgPlgTargets"](); 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..956b88293 --- /dev/null +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -0,0 +1,777 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { doAwaitResponse } from "@nevware21/ts-async"; +import { + ITimerHandler, arrIndexOf, isNumber, isString, mathRandom, objCreate, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, + strLower, strStartsWith, strSubstring, utcNow +} from "@nevware21/ts-utils"; +import { onConfigChange } from "../config/DynamicConfig"; +import { DEFAULT_BREEZE_PATH, DisabledPropertyName, SampleRate } 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, IInternalSdkStatsConfig, 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 { UseFeatureFn } from "../interfaces/ai/IThrottleMgr"; +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_SAMPLING_PERCENTAGE = 100; +const STATS_TYPE = "Browser"; + +/** 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)/i; + +/** + * Returns the SDK Stats config URL (`cfg/v1.json`) for the endpoint, derived from the configured + * base url. For EU data-boundary endpoints an `eu-` 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 (STATS_EU_REGION_PATTERN.test(endpoint)) { + result = cfgUrl.replace(/^([^:]+:\/\/)?/, "$1eu-"); + } + } + + 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) { + try { + let json = getJSON(); + if (json) { + utlSetSessionStorage(logger, key, json.stringify(store)); + } + } catch (e) { + // Persistence is best-effort; serialization can fail when built-in prototypes are modified. + } +} + +/** + * 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 _featureName: string; + let _statsCore: IAppInsightsCore; + let _useFeature: UseFeatureFn; + let _sampleRate = STATS_SAMPLING_PERCENTAGE; + 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, useFeature?: UseFeatureFn + ) { + 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; + _featureName = featureName || STATS_SDK_FEATURE; + _useFeature = useFeature; + // 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; + _sampleRate = STATS_SAMPLING_PERCENTAGE; + if (isFeatureEnabled(_featureName, 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) { + details.setDf(statsCfg, _sdkStatsConfigDefaults); + // 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); + let sampleRate = details.set(statsCfg, "samplingPercentage", statsCfg.samplingPercentage); + if (isNumber(sampleRate) && sampleRate >= 0 && sampleRate <= 100) { + _sampleRate = sampleRate; + } + _isMgrEnabled = true; + _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; + _featureName = null; + _useFeature = 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 sampleRate = _sampleRate; + if (!internalSdkStatsEvent && sampleRate > 0) { + // Validate the pipeline before the counters are reset. At 0% no pipeline is required. + let statsCore = _getStatsCore(_buildStatsEndpoint(cfgResult.url)); + if (!statsCore) { + return null; + } + } else if (internalSdkStatsEvent && + (sampleRate >= 100 || (sampleRate > 0 && mathRandom() * 100 < sampleRate))) { + let trackStats = () => { + let currentCfg = _resolveStatsCfg(internalSdkStats.endpoint); + if (currentCfg && currentCfg.enabled) { + let statsCore = _getStatsCore(_buildStatsEndpoint(currentCfg.url)); + if (statsCore) { + internalSdkStatsEvent[SampleRate] = sampleRate; + statsCore.track(internalSdkStatsEvent); + } + } + }; + if (_useFeature) { + _useFeature(_featureName, trackStats, true); + } else { + trackStats(); + } + } + 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 a stats object allows the manager to apply its nested defaults; 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: {} +}; + +const _sdkStatsConfigDefaults: IConfigDefaults = { + samplingPercentage: STATS_SAMPLING_PERCENTAGE +}; 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/diagnostics/ThrottleMgr.ts b/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts index 4f669fd4b..e08647d7d 100644 --- a/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts +++ b/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { arrForEach, arrIndexOf, isNullOrUndefined, mathFloor, mathMin, objForEachKey, strTrim } from "@nevware21/ts-utils"; +import { arrForEach, arrIndexOf, isNullOrUndefined, mathFloor, mathMin, objCreate, objForEachKey, strTrim } from "@nevware21/ts-utils"; import { onConfigChange } from "../config/DynamicConfig"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; @@ -10,19 +10,23 @@ import { IConfig } from "../interfaces/ai/IConfig"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; import { IThrottleInterval, IThrottleLocalStorageObj, IThrottleMgrConfig, IThrottleResult } from "../interfaces/ai/IThrottleMgr"; +import { isFeatureEnabled } from "../utils/HelperFuncs"; import { randomValue } from "../utils/RandomHelper"; import { utlCanUseLocalStorage, utlGetLocalStorage, utlSetLocalStorage } from "../utils/StorageHelperFuncs"; const THROTTLE_STORAGE_PREFIX = "appInsightsThrottle"; +const FEATURE_STORAGE_PREFIX = "feature-"; -interface SendMsgParameter { - msgID: _eInternalMessageId, - message: string, - severity?: eLoggingSeverity +interface ThrottleQueueItem { + key: _eInternalMessageId | number | string; + callback: () => void; + isFeature: boolean; + sdkDefaultState?: boolean; } export class ThrottleMgr { public canThrottle: (msgId: _eInternalMessageId | number) => boolean; + public useFeature: (feature: string, callback: () => void, sdkDefaultState?: boolean) => IThrottleResult | null; public sendMessage: (msgId: _eInternalMessageId, message: string, severity?: eLoggingSeverity) => IThrottleResult | null; public getConfig: () => IThrottleMgrConfig; public isTriggered: (msgId: _eInternalMessageId | number) => boolean; // this function is to get previous triggered status @@ -37,12 +41,12 @@ export class ThrottleMgr { let _canUseLocalStorage: boolean; let _logger: IDiagnosticLogger | null | undefined; let _config: {[msgKey: number]: IThrottleMgrConfig}; - let _localStorageObj: {[msgKey: number]: IThrottleLocalStorageObj | null | undefined}; - let _isTriggered: {[msgKey: number]: boolean}; //_isTriggered is to make sure that we only trigger throttle once a day + let _featureConfig: {[feature: string]: IThrottleMgrConfig}; + let _localStorageObj: {[key: string]: IThrottleLocalStorageObj | null | undefined}; + let _isTriggered: {[key: string]: boolean}; //_isTriggered is to make sure that we only trigger throttle once a day let _namePrefix: string; - let _queue: {[msgKey: number]: Array}; + let _queue: {[key: string]: Array}; let _isReady: boolean = false; - let _isSpecificDaysGiven: boolean = false; _initConfig(); @@ -67,6 +71,21 @@ export class ThrottleMgr { return _canThrottle(cfg, _canUseLocalStorage, localObj); } + /** + * Run a feature operation when enabled by featureOptIn and allowed by its named throttle configuration. + * @param feature - The featureOptIn and throttleMgrCfg key. + * @param callback - The feature operation to run when allowed. + * @param sdkDefaultState - The default state when featureOptIn does not define the feature. + * @returns The result of applying the throttle, or null when queued or disabled by featureOptIn. + */ + _self.useFeature = (feature: string, callback: () => void, sdkDefaultState?: boolean): IThrottleResult | null => { + if (!feature || !callback || isFeatureEnabled(feature, core.config, sdkDefaultState) !== true) { + return null; + } + + return _flushCallback(feature, callback, true, true, sdkDefaultState); + } + /** * Check if throttle is triggered on current day(UTC) * if canThrottle returns false, isTriggered will return false @@ -91,20 +110,7 @@ export class ThrottleMgr { * @returns if message queue is flushed */ _self.flush = (msgId: _eInternalMessageId | number): boolean => { - try { - let queue = _getQueueByKey(msgId); - if (queue && queue.length > 0) { - let items = queue.slice(0); - _queue[msgId] = [] - arrForEach(items, (item: SendMsgParameter) => { - _flushMessage(item.msgID, item.message, item.severity, false); - }); - return true; - } - } catch(err) { - // eslint-disable-next-line no-empty - } - return false; + return _flushQueue(_getMapKey(msgId, false)); } /** @@ -116,7 +122,7 @@ export class ThrottleMgr { if (_queue) { let result = true; objForEachKey(_queue, (key) => { - let isFlushed = _self.flush(parseInt(key)); + let isFlushed = _flushQueue(key); result = result && isFlushed; }); return result; @@ -144,37 +150,53 @@ export class ThrottleMgr { } _self.sendMessage = (msgID: _eInternalMessageId | number, message: string, severity?: eLoggingSeverity): IThrottleResult | null => { - return _flushMessage(msgID, message, severity, true); - + return _flushCallback(msgID, () => { + _sendMessage(msgID, _logger, message, severity); + }, true, false); } - function _flushMessage(msgID: _eInternalMessageId | number, message: string, severity?: eLoggingSeverity, saveUnsentMsg?: boolean) { + function _flushCallback( + key: _eInternalMessageId | number | string, callback: () => void, saveUnsent?: boolean, isFeature?: boolean, sdkDefaultState?: boolean + ): IThrottleResult | null { + if (isFeature && isFeatureEnabled(key + "", core.config, sdkDefaultState) !== true) { + return null; + } + if (_isReady) { - let isSampledIn = _canSampledIn(msgID); + let cfg = _getCfgByKey(key, isFeature); + if (isFeature && !cfg) { + callback(); + return { + isThrottled: true, + throttleNum: 1 + }; + } + + let isSampledIn = _canSampledIn(cfg); if (!isSampledIn) { - return; + return null; } - let cfg = _getCfgByKey(msgID); - let localStorageObj = _getLocalStorageObjByKey(msgID); + let localStorageObj = _getLocalStorageObjByKey(key, isFeature); let canThrottle = _canThrottle(cfg, _canUseLocalStorage, localStorageObj); let throttled = false; let number = 0; - let isTriggered = _isTrigger(msgID); + let isTriggered = _isTrigger(key, isFeature); + let mapKey = _getMapKey(key, isFeature); try { if (canThrottle && !isTriggered) { number = mathMin(cfg.limit.maxSendNumber, localStorageObj.count + 1); localStorageObj.count = 0; throttled = true; - _isTriggered[msgID] = true; + _isTriggered[mapKey] = true; localStorageObj.preTriggerDate = new Date(); } else { - _isTriggered[msgID] = canThrottle; + _isTriggered[mapKey] = canThrottle; localStorageObj.count += 1; } - let localStorageName = _getLocalStorageName(msgID); + let localStorageName = _getLocalStorageName(key, _namePrefix, isFeature); _resetLocalStorage(_logger, localStorageName, localStorageObj); for (let i = 0; i < number; i++) { - _sendMessage(msgID, _logger, message, severity); + callback(); } } catch(e) { // eslint-disable-next-line no-empty @@ -184,24 +206,43 @@ export class ThrottleMgr { throttleNum: number } as IThrottleResult; } else { - if (!!saveUnsentMsg) { - let queue = _getQueueByKey(msgID); + if (!!saveUnsent) { + let queue = _getQueueByKey(key, isFeature); queue.push({ - msgID: msgID, - message: message, - severity: severity - } as SendMsgParameter); + key: key, + callback: callback, + isFeature: !!isFeature, + sdkDefaultState: sdkDefaultState + }); } } return null; } + + function _flushQueue(mapKey: string): boolean { + try { + let queue = _queue[mapKey]; + if (queue && queue.length > 0) { + let items = queue.slice(0); + _queue[mapKey] = []; + arrForEach(items, (item: ThrottleQueueItem) => { + _flushCallback(item.key, item.callback, false, item.isFeature, item.sdkDefaultState); + }); + return true; + } + } catch(err) { + // eslint-disable-next-line no-empty + } + return false; + } function _initConfig() { _logger = safeGetLogger(core); - _isTriggered = {}; - _localStorageObj = {}; - _queue = {}; - _config = {}; + _isTriggered = objCreate(null); + _localStorageObj = objCreate(null); + _queue = objCreate(null); + _config = objCreate(null); + _featureConfig = objCreate(null); _setCfgByKey(_eInternalMessageId.DefaultThrottleMsgKey); _namePrefix = !isNullOrUndefined(namePrefix)? namePrefix : ""; @@ -211,31 +252,49 @@ export class ThrottleMgr { let configMgr = coreConfig.throttleMgrCfg || {}; objForEachKey(configMgr, (key, cfg) => { - _setCfgByKey(parseInt(key), cfg) + _setCfgByKey(key, cfg, true); + let msgId = parseInt(key); + if (msgId + "" === key) { + _setCfgByKey(msgId, cfg, false); + } }); })); } - function _getCfgByKey(msgID: _eInternalMessageId | number) { - return _config[msgID] || _config[_eInternalMessageId.DefaultThrottleMsgKey]; + function _getCfgByKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { + if (isFeature) { + let feature = key + ""; + let coreConfig = core.config as IConfiguration & IConfig; + let featureCfg = coreConfig.throttleMgrCfg && coreConfig.throttleMgrCfg[feature]; + if (featureCfg) { + _setCfgByKey(feature, featureCfg, true); + } else { + delete _featureConfig[feature]; + } + return _featureConfig[feature]; + } + return _config[+key] || _config[_eInternalMessageId.DefaultThrottleMsgKey]; } - function _setCfgByKey(msgID: _eInternalMessageId | number, config?: IThrottleMgrConfig) { + function _setCfgByKey(key: _eInternalMessageId | number | string, config?: IThrottleMgrConfig, isFeature?: boolean) { try { let cfg = config || {}; let curCfg = {} as IThrottleMgrConfig; curCfg.disabled = !!cfg.disabled; let configInterval = cfg.interval || {}; - _isSpecificDaysGiven = configInterval?.daysOfMonth && configInterval?.daysOfMonth.length > 0; curCfg.interval = _getIntervalConfig(configInterval); let limit = { - samplingRate: cfg.limit?.samplingRate || 100, + samplingRate: !isNullOrUndefined(cfg.limit?.samplingRate) ? cfg.limit.samplingRate : 100, // dafault: every time sent only 1 event maxSendNumber: cfg.limit?.maxSendNumber || 1 }; curCfg.limit = limit; - _config[msgID] = curCfg; + if (isFeature) { + _featureConfig[key + ""] = curCfg; + } else { + _config[+key] = curCfg; + } } catch (e) { // eslint-disable-next-line no-empty @@ -246,14 +305,14 @@ export class ThrottleMgr { interval = interval || {}; let monthInterval = interval?.monthInterval; let dayInterval = interval?.dayInterval; + let isSpecificDaysGiven = interval?.daysOfMonth && interval?.daysOfMonth.length > 0; // default: send data every 3 month each year if (isNullOrUndefined(monthInterval) && isNullOrUndefined(dayInterval)) { interval.monthInterval = 3; - if (!_isSpecificDaysGiven) { + if (!isSpecificDaysGiven) { // default: send data on 28th interval.daysOfMonth = [28]; - _isSpecificDaysGiven = true; } } interval = { @@ -277,7 +336,7 @@ export class ThrottleMgr { } let dayCheck = 1; - if (_isSpecificDaysGiven) { + if (interval?.daysOfMonth && interval.daysOfMonth.length > 0) { dayCheck = arrIndexOf(interval.daysOfMonth, curDate.getUTCDate()); } else if (interval?.dayInterval) { let daySpan = mathFloor((curDate.getTime() - date.getTime()) / 86400000); @@ -289,10 +348,10 @@ export class ThrottleMgr { return false; } - function _getLocalStorageName(msgKey: _eInternalMessageId | number, prefix?: string) { + function _getLocalStorageName(key: _eInternalMessageId | number | string, prefix?: string, isFeature?: boolean) { let fix = !isNullOrUndefined(prefix)? prefix : ""; - if (msgKey) { - return THROTTLE_STORAGE_PREFIX + fix + "-" + msgKey; + if (key) { + return THROTTLE_STORAGE_PREFIX + fix + "-" + (isFeature ? FEATURE_STORAGE_PREFIX : "") + key; } return null; } @@ -383,25 +442,29 @@ export class ThrottleMgr { // NOTE: config.limit.samplingRate is set to 4 decimal places, // so config.limit.samplingRate = 1 means 0.0001% - function _canSampledIn(msgID: _eInternalMessageId) { + function _canSampledIn(cfg: IThrottleMgrConfig) { try { - let cfg = _getCfgByKey(msgID) - return randomValue(1000000) <= cfg.limit.samplingRate; + return cfg.limit.samplingRate > 0 && randomValue(1000000) <= cfg.limit.samplingRate; } catch (e) { // eslint-disable-next-line no-empty } return false; } - function _getLocalStorageObjByKey(key: _eInternalMessageId | number) { + function _getMapKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { + return (isFeature ? FEATURE_STORAGE_PREFIX : "") + key; + } + + function _getLocalStorageObjByKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { try { - let curObj = _localStorageObj[key]; + let mapKey = _getMapKey(key, isFeature); + let curObj = _localStorageObj[mapKey]; if (!curObj) { - let localStorageName = _getLocalStorageName(key, _namePrefix); + let localStorageName = _getLocalStorageName(key, _namePrefix, isFeature); curObj = _getLocalStorageObj(utlGetLocalStorage(_logger, localStorageName), _logger, localStorageName); - _localStorageObj[key] = curObj; + _localStorageObj[mapKey] = curObj; } - return _localStorageObj[key]; + return _localStorageObj[mapKey]; } catch (e) { // eslint-disable-next-line no-empty @@ -409,25 +472,24 @@ export class ThrottleMgr { return null; } - function _isTrigger(key: _eInternalMessageId | number) { - let isTrigger = _isTriggered[key]; - if (isNullOrUndefined(isTrigger)) { - isTrigger = false; - let localStorageObj = _getLocalStorageObjByKey(key); - if (localStorageObj) { - isTrigger = _isTriggeredOnCurDate(localStorageObj.preTriggerDate); - } - _isTriggered[key] = isTrigger; + function _isTrigger(key: _eInternalMessageId | number | string, isFeature?: boolean) { + let mapKey = _getMapKey(key, isFeature); + let isTrigger = false; + let localStorageObj = _getLocalStorageObjByKey(key, isFeature); + if (localStorageObj) { + isTrigger = _isTriggeredOnCurDate(localStorageObj.preTriggerDate); } - return _isTriggered[key]; + _isTriggered[mapKey] = isTrigger; + return isTrigger; } - function _getQueueByKey(key: _eInternalMessageId | number) { - _queue = _queue || {}; - if (isNullOrUndefined(_queue[key])) { - _queue[key] = []; + function _getQueueByKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { + _queue = _queue || objCreate(null); + let mapKey = _getMapKey(key, isFeature); + if (isNullOrUndefined(_queue[mapKey])) { + _queue[mapKey] = []; } - return _queue[key]; + return _queue[mapKey]; } } } 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..88c91133b 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, @@ -356,7 +360,7 @@ export { // Throttle manager interfaces export { - IThrottleLimit, IThrottleInterval, IThrottleMgrConfig, + UseFeatureFn, IThrottleLimit, IThrottleInterval, IThrottleMgrConfig, IThrottleLocalStorageObj, IThrottleResult } from "./interfaces/ai/IThrottleMgr"; 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.4.4 + */ +export interface IInternalSdkStatsConfig { + /** + * Percentage of generated SDK Stats telemetry that is sent. Each generated SDK Stats item is + * sampled independently using the current dynamic value. Included telemetry is stamped with this + * value so the ingestion service can account for the sampling rate. + * + * Values must be between 0 and 100. Invalid values use the default. + * @default 100 + */ + samplingPercentage?: number; + + /** + * 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..e9fc95592 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -3,39 +3,22 @@ import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; -import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "./IStatsBeat"; +import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; +import { UseFeatureFn } from "./IThrottleMgr"; 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.4.4 */ -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 +29,35 @@ 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. + * @param useFeature - Optional feature operation throttle supplied by the SKU. * @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, + useFeature?: UseFeatureFn + ) => 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/interfaces/ai/IThrottleMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts index 12a836235..b15181bdc 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts @@ -51,8 +51,9 @@ export interface IThrottleInterval { export interface IThrottleMgrConfig { /** - * Identifies if throttle is disabled - * Default: false + * Identifies if throttling is disabled. + * When true, no messages are sent and no feature callbacks are invoked. + * Default: false */ disabled?: boolean; @@ -105,3 +106,9 @@ export interface IThrottleResult { */ throttleNum: number; } + +/** + * Runs a dynamically configured feature operation when its throttle allows it. + * @since 3.4.4 + */ +export type UseFeatureFn = (feature: string, callback: () => void, sdkDefaultState?: boolean) => IThrottleResult | null; diff --git a/shared/AppInsightsCore/src/utils/DataCacheHelper.ts b/shared/AppInsightsCore/src/utils/DataCacheHelper.ts index d02887175..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.4"; +const version = "#version#"; let instanceName = "." + newId(6); let _dataUid = 0;