diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index a68654f37..95fdd2eae 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -54,10 +54,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 177; - private readonly MAX_BUNDLE_SIZE = 177; - private readonly MAX_RAW_DEFLATE_SIZE = 71; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 71; + private readonly MAX_RAW_SIZE = 181; + private readonly MAX_BUNDLE_SIZE = 181; + private readonly MAX_RAW_DEFLATE_SIZE = 73; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 73; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.4.2"; diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 03660c85f..f6a50f928 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -11,14 +11,14 @@ import { IAutoExceptionTelemetry, IChannelControls, IConfig, IConfigDefaults, IConfiguration, ICookieMgr, ICustomProperties, IDependencyTelemetry, IDiagnosticLogger, IDistributedTraceContext, IDynamicConfigHandler, IEventTelemetry, IExceptionTelemetry, ILoadedPlugin, 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 + 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, createSdkStatsMgrConfig, 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, @@ -398,6 +398,18 @@ export class AppInsightsSku implements IApplicationInsights()); + if (statsHook) { + _core.addUnloadHook(statsHook); + } + } + // Initialize the initial OTel API _otelApi = _initOTel(_self, "aisku", _onEnd, _onException); diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index 7b85a2396..105e00446 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -1,322 +1,329 @@ -// 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"; +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; +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 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(); -// } -// } + 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 } -// }; + private initializeCoreAndSender(config: any, instrumentationKey: string) { + const sender = new Sender(); + const core = new AppInsightsCore(); + const coreConfig = { + instrumentationKey, + stats: { + shrtInt: 900, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + // Match the Sender's endpoint so SDK Stats are tracked for it + match: [ config.endpointUrl ] + } + ] + } + ] + }, + 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); + 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, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.stats; + } + }); + core.setStatsMgr(statsMgr); + this._statsMgrUnloadHook = unloadHook; -// this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(), "count"); -// this.trackSpy = this.sandbox.spy(core, "track"); + let statsBeatState: IStatsBeatState = { + cKey: instrumentationKey, + endpoint: config.endpointUrl, + sdkVer: "1.0.0", + type: eStatsType.SDK + }; -// this.onDone(() => { -// sender.teardown(); -// }); + this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(statsBeatState), "count"); + this.trackSpy = this.sandbox.spy(core, "track"); -// return { core, sender, statsMgr, unloadHook }; -// } + this.onDone(() => { + sender.teardown(); + }); -// 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] -// }; -// } + return { core, sender, statsMgr, unloadHook }; + } -// 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 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 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}`); -// } + 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 + } -// 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" ] -// } -// ] -// } -// ] -// } -// }, -// }; + private assertStatsbeatCall(statusCode: number, eventName: string) { + Assert.equal(this.statsbeatCountSpy.callCount, 1, "SDK Stats 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"), "SDK Stats count should be called with startTime set"); + const statsbeatEvent = this.trackSpy.firstCall.args[0]; + Assert.equal(statsbeatEvent.baseType, "MetricData", "SDK Stats event should be of type MetricData"); + Assert.equal(statsbeatEvent.baseData.name, eventName, `Statsbeat event should be of type ${eventName}`); + } -// 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 -// }; + public registerTests() { + this.testCase({ + name: "SDK Stats initializes when stats is true", + test: () => { + const config = { + instrumentationKey: "Test-iKey", + featureOptIn: { + "StatsBeat": { + mode: FeatureOptInMode.enable + } + }, + stats: { + shrtInt: 900, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + match: [ "https://example.endpoint.com" ] + } + ] + } + ] + }, + }; -// const statsbeat = this._core.getStatsBeat(statsBeatState); + this._core.initialize(config, [this._sender]); + this._statsMgrUnloadHook = this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.stats; + } + }); + this._core.setStatsMgr(this._statsMgr); + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; -// QUnit.assert.ok(statsbeat, "Statsbeat is initialized"); -// QUnit.assert.ok(statsbeat.enabled, "Statsbeat is marked as initialized"); -// } -// }); + const statsbeat = this._core.getStatsBeat(statsBeatState); -// 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" })); -// }); + QUnit.assert.ok(statsbeat, "SDK Stats is initialized"); + QUnit.assert.ok(statsbeat.enabled, "SDK Stats is marked as initialized"); + } + }); -// const config = this.createSenderConfig(TransportType.Fetch); -// const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + this.testCaseAsync({ + name: "SDK Stats increments success count when fetch sender is called once", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { // only fetch is supported to stub, why? + return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); + }); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); -// this.processTelemetryAndFlush(sender, telemetryItem); + 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) -// }); + } + ].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 SDK Stats 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" })); -// }); + this.testCaseAsync({ + name: "SDK Stats increments throttle count when fetch sender is called with status 439", + useFakeTimers: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { + return Promise.resolve(new Response("{}", { status: 439, statusText: "Too Many Requests" })); + }); -// const config = this.createSenderConfig(TransportType.Fetch); -// const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + const 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: {} -// }; + 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.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 SDK Stats 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"); + this.testCaseAsync({ + name: "SDK Stats increments success count for beacon sender", + useFakeTimers: true, + stepDelay: 100, + steps: [ + () => { + const config = this.createSenderConfig(TransportType.Beacon); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; -// let sendBeaconCalled = false; -// this.hookSendBeacon((url: string) => { -// sendBeaconCalled = true; -// return true; -// }); -// QUnit.assert.ok(isBeaconsSupported(), "Beacon API is supported"); -// this.processTelemetryAndFlush(sender, telemetryItem); -// } -// ].concat(PollingAssert.createPollingAssert(() => { -// if (this.statsbeatCountSpy.called) { -// this.assertStatsbeatCall(200, "Request_Success_Count"); -// return true; -// } -// return false; -// }, "Waiting for beacon sender and Statsbeat count to be called") as any) -// }); + 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 SDK Stats 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); + this.testCaseAsync({ + name: "SDK Stats increments success count for xhr sender", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + fakeServerAutoRespond: true, + steps: [ + () => { + let window = getWindow(); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? + let config: any = this.createSenderConfig(TransportType.Xhr); + config.disableSendBeaconSplit = true; + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + console.log("xhr sender called", this._getXhrRequests().length); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; -// this.processTelemetryAndFlush(sender, telemetryItem); -// QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); -// console.log("xhr sender is called", this._getXhrRequests().length); -// (window as any).XMLHttpRequest = fakeXMLHttpRequest; + 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 + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + console.log("SDK Stats count called with success count for xhr sender"); + return true; + } + return false; + }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000) as any) + }); +} +} diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts index 477265d46..8f64142fd 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 { SampleTests } from "./Sample.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; -// import { StatsbeatTests } from "./StatsBeat.tests"; +import { StatsbeatTests } from "./StatsBeat.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new SenderTests().registerTests(); new SampleTests().registerTests(); - // new StatsbeatTests().registerTests(); + new StatsbeatTests().registerTests(); } \ No newline at end of file diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 751da8ef5..bb29a984f 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -3,12 +3,14 @@ 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, + IProcessTelemetryUnloadContext, ISample, IStatsBeatState, IStatsEventData, IStorageBuffer, ITelemetryItem, ITelemetryPluginChain, + ITelemetryUnloadState, IXDomainRequest, IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, - RequestDataType, RequestHeaders, SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, TransportType, + RequestDataType, RequestHeaders, STATS_SDK_ENDPOINT_KEY, 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, + eStatsType, formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-core-js"; @@ -34,7 +36,7 @@ const FetchSyncRequestSizeLimitBytes = 65000; // approx 64kb (the current Edge, interface IInternalPayloadData extends IPayloadData { oriPayload: IInternalStorageItem[]; retryCnt?: number; - // statsBeatData?: IStatsEventData; + statsBeatData?: IStatsEventData; } @@ -188,6 +190,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let _sendPostMgr: SenderPostManager; let _retryCodes: number[]; let _zipPayload: boolean; + let _httpInterface: IXHROverride; // The resolved http sender, captured so SDK Stats can be sent to their own endpoint dynamicProto(Sender, this, (_self, _base) => { @@ -453,6 +456,8 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { httpInterface = _alwaysUseCustomSend? customInterface : (httpInterface || customInterface || xhrInterface); + _httpInterface = httpInterface; + _self._sender = (payload: IInternalStorageItem[], isAsync: boolean) => { return _doSend(httpInterface, payload, isAsync); }; @@ -497,7 +502,17 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!isValidate) { return; } - + + // SDK Stats events carry the destination SDK Stats ingestion endpoint + // so they can be redirected away from the customer's breeze endpoint. Extract and + // remove the marker so it is not serialized into the outgoing envelope. + let statsEndpoint: string; + let data = telemetryItem.data; + if (data && data[STATS_SDK_ENDPOINT_KEY]) { + statsEndpoint = data[STATS_SDK_ENDPOINT_KEY]; + delete data[STATS_SDK_ENDPOINT_KEY]; + } + let aiEnvelope = _getEnvelope(telemetryItem, diagLogger); if (!aiEnvelope) { return; @@ -505,22 +520,28 @@ 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); - let payloadItem = { - item: payload, - cnt: 0, // inital cnt will always be 0 - bT: telemetryItem.baseType, // store baseType for SDK stats telemetryType mapping - iN: telemetryItem.name // store name so SDK stats can self-filter its own metrics - } as IInternalStorageItem; - - // enqueue the payload - buffer.enqueue(payloadItem); - - // ensure an invocation timeout is set - _setupTimer(); + + if (statsEndpoint) { + // Route SDK Stats directly to the SDK Stats ingestion endpoint, bypassing the + // customer send buffer so it is never mixed with customer telemetry. + _sendStatsBeat(payload, statsEndpoint); + } else { + // flush if we would exceed the max-size limit by adding this item + const buffer = _self._buffer; + _checkMaxSize(payload); + let payloadItem = { + item: payload, + cnt: 0, // inital cnt will always be 0 + bT: telemetryItem.baseType, // store baseType for SDK stats telemetryType mapping + iN: telemetryItem.name // store name so SDK stats can self-filter its own metrics + } as IInternalStorageItem; + + // enqueue the payload + buffer.enqueue(payloadItem); + + // ensure an invocation timeout is set + _setupTimer(); + } } catch (e) { _throwInternal(diagLogger, @@ -673,20 +694,60 @@ 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 _getStatsBeat() { + let statsBeatConfig: IStatsBeatState = { + cKey: _self._senderConfig.instrumentationKey, + endpoint: _endpointUrl, + sdkVer: EnvelopeCreator.Version, + type: eStatsType.SDK + }; + + 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 SDK Stats instance + return core && core.getStatsBeat ? core.getStatsBeat(statsBeatConfig) : null; + } - // let core = _self.core; + /** + * Send a single serialized SDK Stats payload to the provided SDK Stats ingestion + * endpoint. SDK Stats are routed to the distro-owned endpoint instead of the customer's breeze + * endpoint and therefore bypass the normal send buffer. The send reuses the resolved http + * sender and is sent immediately (SDK Stats events are infrequent and low volume). + * @param payloadStr - The serialized envelope to send. + * @param statsEndpoint - The SDK Stats ingestion endpoint URL to send the payload to. + */ + function _sendStatsBeat(payloadStr: string, statsEndpoint: string) { + try { + let sendInterface = _httpInterface; + if (sendInterface && payloadStr && statsEndpoint) { + let payloadItem = { + item: payloadStr, + cnt: 0 + } as IInternalStorageItem; + // markAsSent=false so the main send buffer is never touched, statsUrl redirects the POST + _doSend(sendInterface, [payloadItem], true, false, statsEndpoint); + } + } catch (e) { + // SDK Stats are best-effort and must never break customer telemetry + } + } - // // 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; - // } + /** + * 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 _countStatsBeat(status: number, payload?: IPayloadData) { + if (payload && payload.urlString === _endpointUrl) { + let statsbeat = _getStatsBeat(); + if (statsbeat) { + statsbeat.count(status, payload, _endpointUrl); + } + } + } function _xdrOnLoad (xdr: IXDomainRequest, payload: IInternalStorageItem[]) { const responseText = _getResponseText(xdr); @@ -714,23 +775,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 statsbeat = _getStatsBeat(); + if (statsbeat) { + if (xdr && (responseText + "" === "200" || responseText === "")) { + _consecutiveErrors = 0; + statsbeat.count(200, payload, _endpointUrl); + } else { + const results = parseResponse(responseText); + + if (results && results.itemsReceived && results.itemsReceived > results.itemsAccepted + && !_isRetryDisabled) { + statsbeat.count(206, payload, _endpointUrl); + } else { + statsbeat.count(499, payload, _endpointUrl); + } + } + } + } return _xdrOnLoad(xdr, payloadArr); @@ -740,10 +803,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // statsbeat.count(response.status, payload, _endpointUrl); - // } + _countStatsBeat(response.status, payload); return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, xhrOnComplete: (request: XMLHttpRequest, oncomplete: OnCompleteCallback, payload?: IPayloadData) => { @@ -751,18 +811,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) { + _countStatsBeat(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); - // } + _countStatsBeat(499, data); return _onBeaconRetry(data, onComplete, canSend); } @@ -1026,19 +1082,16 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } } - function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true): void | IPromise { + function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true, urlOverride?: string): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // statsbeat.count(status, payloadData, _endpointUrl); - // } + _countStatsBeat(status, payloadData); return _getOnComplete(payload, status, headers, response); }; - let payloadData = _getPayload(payload); - // if (payloadData) { - // payloadData.statsBeatData = {startTime: dateNow()}; - // } + let payloadData = _getPayload(payload, urlOverride); + if (payloadData) { + payloadData.statsBeatData = {startTime: dateNow()}; + } let sendPostFunc: SendPOSTFunction = sendInterface && sendInterface.sendPOST; if (sendPostFunc && payloadData) { @@ -1074,13 +1127,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return null; } - function _getPayload(payload: IInternalStorageItem[]): IInternalPayloadData { + function _getPayload(payload: IInternalStorageItem[], urlOverride?: string): IInternalPayloadData { if (isArray(payload) && payload.length > 0) { let batch = _self._buffer.batchPayloads(payload); let headers = _getHeaders(); let payloadData: IInternalPayloadData = { data: batch, - urlString: _endpointUrl, + urlString: urlOverride || _endpointUrl, headers: headers, disableXhrSync: _disableXhr, disableFetchKeepAlive: !_fetchKeepAlive, @@ -1469,6 +1522,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _initDefaults() { _self._sender = null; _self._buffer = null; + _httpInterface = null; _self._appId = null; _self._sample = null; _headers = {}; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index 894f3ab3f..9ba08baa5 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,10 +51,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 135; - private readonly MAX_BUNDLE_SIZE = 135; - private readonly MAX_RAW_DEFLATE_SIZE = 54; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 54; + private readonly MAX_RAW_SIZE = 137; + private readonly MAX_BUNDLE_SIZE = 137; + private readonly MAX_RAW_DEFLATE_SIZE = 55; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 55; 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/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index fef4337fa..e56eed4fe 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -1,366 +1,368 @@ -// 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(); +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 } from "../../../../src/core/StatsBeat"; +import { IStatsBeatState } from "../../../../src/interfaces/ai/IStatsBeat"; +import { eStatsType } from "../../../../src/enums/ai/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/enums/ai/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._config = { + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true, + featureOptIn: { + "StatsBeat": { + mode: FeatureOptInMode.enable + } + }, + 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"); + _self._statsMgr = createStatsMgr(); + _self._core = new AppInsightsCore(); + // 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()]); + + // 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: "SDK Stats: Initialization", + test: () => { + // Test with no initialization + Assert.equal(false, this._statsMgr.enabled, "SDK Stats manager 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; -// } -// }); + 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), "SDK Stats should not be created before initialization"); + + // Initialize + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.stats; + } + }); + Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); + + let newInst = this._statsMgr.newInst(statsBeatState); + 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); + Assert.equal(0, newInst.type); + } + }); + + this.testCase({ + name: "SDK Stats: count method tracks request metrics", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.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; + // 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"); + 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 failed request + statsBeat.count(500, payloadData, "https://example.endpoint.com"); -// // Test throttled request -// statsBeat.count(429, 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 trackStatsbeats is called when the 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 statsbeat timer fires"); + // 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: "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); + // 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._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.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"); + // 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 trackStatsbeats is called when the 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 statsbeat timer fires"); + // 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; -// } -// } + // 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; -// } -// }); + 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._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.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; + // 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"); + 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"); + // 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 * 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 statsbeat 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._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?.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 SDK Stats 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 + // Explicitly disable SDK Stats + 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"); + // Verify that SDK Stats is removed + const updatedStatsbeat = this._core.getStatsBeat(statsBeatState); + Assert.ok(!updatedStatsbeat, "SDK Stats 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 + // Re-enable SDK Stats + 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"); + // Verify that SDK Stats is created again + const reenabledStatsbeat = this._core.getStatsBeat(statsBeatState); + Assert.ok(reenabledStatsbeat, "SDK Stats 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 + // FeatureOptInMode.none falls back to the SDK default state (enabled), so SDK Stats stays enabled + 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"); + // Verify that SDK Stats remains enabled (none defaults to enabled) + Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is none (defaults to enabled)"); -// // 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"); + // Explicitly disable again before testing the null case + this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; + this.clock.tick(1); // Allow time for config changes to propagate + Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); -// // 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 + // A null mode also falls back to the SDK default state (enabled) + 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); -// } + // Verify that SDK Stats is recreated (null defaults to enabled) + Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is null (defaults to enabled)"); + } + }); + } +} + +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 + 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/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index f4298d3ab..0cd4c5e91 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 { StatsBeatTests } from "./ai/StatsBeat.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 StatsBeatTests(false).registerTests(); + new StatsBeatTests(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..e66f1ac17 100644 --- a/shared/AppInsightsCore/src/core/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/core/AppInsightsCore.ts @@ -32,6 +32,8 @@ 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 { IStatsBeat, IStatsBeatState } from "../interfaces/ai/IStatsBeat"; +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..."; @@ -385,8 +385,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 _statsBeat: IStatsBeat | null; + let _statsMgr: IStatsMgr | null; let _perfManager: IPerfManager | null; let _cfgPerfManager: IPerfManager | null; let _cookieManager: ICookieMgr | null; @@ -626,47 +626,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.getStatsBeat = (statsBeatState: IStatsBeatState) => { + // create a new SDK Stats instance if not initialized 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 SDK Stats instance + _statsBeat = _statsMgr.newInst(statsBeatState); + } + } else if (_statsBeat) { + // Disable and remove any previously created SDK Stats instance + _statsBeat.enabled = false; + _statsBeat = null; + } + + // Return the current SDK Stats instance or null if not created + return _statsBeat; + } + + // 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 (_statsBeat) { + _statsBeat.enabled = false; + _statsBeat = null; + } + } + + _statsMgr = statsMgr; + }; _self.eventCnt = (): number => { return _eventQueue.length; @@ -911,11 +911,11 @@ export class AppInsightsCore im let processUnloadCtx = createProcessTelemetryUnloadContext(_getPluginChain(), _self); processUnloadCtx.onComplete(() => { - // if (_statsBeat) { - // // Disable any statsbeat instance - // _statsBeat.enabled = false; - // _statsBeat = null; - // } + if (_statsBeat) { + // Disable any SDK Stats instance + _statsBeat.enabled = false; + _statsBeat = null; + } _hookContainer.run(_self.logger); @@ -1317,7 +1317,12 @@ export class AppInsightsCore im runTargetUnload(_notificationManager, false); _notificationManager = null; _perfManager = null; - // _statsBeat = null; + if (_statsBeat) { + // Disable any SDK Stats instance + _statsBeat.enabled = false; + } + _statsBeat = null; + _statsMgr = null; _cfgPerfManager = null; runTargetUnload(_cookieManager, false); _cookieManager = null; @@ -1345,11 +1350,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 +1736,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 getStatsBeat(statsBeatState: IStatsBeatState): IStatsBeat { + // @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/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts index ef0578302..035e13359 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/StatsBeat.ts @@ -8,11 +8,11 @@ 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 { eStatsEndpointType, 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 { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap, 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"; @@ -23,6 +23,109 @@ const STATS_MIN_INTERVAL_SECONDS = 60; // 1 minute const STATSBEAT_LANGUAGE = "JavaScript"; const STATSBEAT_TYPE = "Browser"; +/** + * The placeholder instrumentation key used when reporting SDK statistics to the distro-owned + * SDK Stats ingestion endpoint. The endpoint does not require authentication, the placeholder + * key only satisfies the connection-string / envelope iKey requirement and is ignored + * server-side. This matches the convention used by the Microsoft OpenTelemetry distros. + */ +export const STATS_SDK_IKEY = "00000000-0000-0000-0000-000000000000"; + +/** + * Distro-owned SDK statistics ingestion endpoints. SDK Stats events are routed here instead of + * to the customer's breeze endpoint. The EU endpoint is used when the customer's endpoint maps + * to an EU data-boundary region, otherwise the non-EU endpoint is used. + * @see https://github.com/microsoft/opentelemetry-distro-dotnet + */ +export const STATS_SDK_ENDPOINT_NON_EU = "https://stats.monitor.azure.com/v2/track"; +export const STATS_SDK_ENDPOINT_EU = "https://eu.stats.monitor.azure.com/v2/track"; + +/** + * The Microsoft-owned instrumentation keys used when reporting SDK statistics to the legacy + * breeze ingestion endpoints (the customer's own breeze endpoint is used as the host, only the + * iKey differs so the data lands in the Microsoft SDK Stats resource). The EU key is used when + * the customer's endpoint maps to an EU data-boundary region, otherwise the non-EU key is used. + */ +export const STATS_BREEZE_IKEY_NON_EU = "c4a29126-a7cb-47e5-b348-11414998b11e"; +export const STATS_BREEZE_IKEY_EU = "7dc56bab-3c0c-4e9f-9ebb-d1acadee8d0f"; + +/** + * The transient marker key, set on the {@link ITelemetryItem.data} of a SDK Stats event, that + * carries the destination SDK Stats ingestion endpoint. The sending channel reads this value to + * redirect the event to the SDK Stats endpoint and removes it before serializing the event. + */ +export const STATS_SDK_ENDPOINT_KEY = "_sdkStatsEndpoint"; + +/** + * 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"; + +// EU data-boundary regions, mirrors the EU region set used by the Azure Monitor OpenTelemetry exporter +const STATS_EU_REGIONS = [ + "francecentral", "francesouth", "germanywestcentral", "northeurope", "norwayeast", "norwaywest", + "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "ukwest", "westeurope" +]; + +/** + * Determine whether the provided customer endpoint maps to an EU data-boundary region. The region + * is extracted from the host (the leading host label, with any region replica suffix removed) and + * matched against the known EU data-boundary regions. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns true when the endpoint maps to an EU region, false otherwise (including unknown regions). + */ +function _isEuEndpoint(endpoint: string): boolean { + let isEU = false; + if (endpoint) { + let host = strLower(endpoint); + // Strip the scheme + let schemeIdx = strIndexOf(host, "://"); + if (schemeIdx !== -1) { + host = host.substring(schemeIdx + 3); + } + + // Extract the leading host label, e.g. "westeurope-5" from "westeurope-5.in.applicationinsights.azure.com/" + let label = host.split("/")[0].split(".")[0]; + // Remove any trailing region replica suffix, e.g. "westeurope-5" => "westeurope" + let dashIdx = strIndexOf(label, "-"); + if (dashIdx !== -1) { + label = label.substring(0, dashIdx); + } + + arrForEach(STATS_EU_REGIONS, (region) => { + if (region === label) { + isEU = true; + return -1; + } + }); + } + + return isEU; +} + +/** + * Determine the distro-owned SDK Stats ingestion endpoint for the provided customer endpoint. + * When the region maps to an EU region the EU endpoint is returned, otherwise (including unknown + * regions) the non-EU endpoint is returned. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns The SDK Stats ingestion endpoint URL. + */ +export function getStatsEndpoint(endpoint: string): string { + return _isEuEndpoint(endpoint) ? STATS_SDK_ENDPOINT_EU : STATS_SDK_ENDPOINT_NON_EU; +} + +/** + * Determine the Microsoft-owned instrumentation key to use when reporting SDK Stats to the legacy + * breeze endpoint for the provided customer endpoint. When the region maps to an EU region the EU + * key is returned, otherwise (including unknown regions) the non-EU key is returned. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns The breeze SDK Stats instrumentation key. + */ +export function getStatsBreezeIKey(endpoint: string): string { + return _isEuEndpoint(endpoint) ? STATS_BREEZE_IKEY_EU : STATS_BREEZE_IKEY_NON_EU; +} + /** * An internal interface to allow the IStatsBeat instance to call back to the manager for @@ -192,6 +295,8 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): baseType: "MetricData" }; + // 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(statsBeat, statsbeatEvent); } } @@ -299,22 +404,22 @@ function _getEndpointCfg(statsBeatConfig: IStatsBeatConfig, type: eStatsType): I } /** - * 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. + * This function retrieves the matching {@link IStatsBeatKeyMap} entry for the given endpoint from + * the provided endpoint configuration. It iterates through the key maps and checks if the endpoint + * matches any of the configured URL patterns. If a match is found, the corresponding key map entry + * (which carries the optional instrumentation key and SDK Stats ingestion endpoint URL) is returned. + * @param endpointCfg - The endpoint configuration to search. * @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. + * @returns The matching {@link IStatsBeatKeyMap} entry, or null if no match is found. */ -function _getIKey(endpointCfg: IStatsEndpointConfig, endpoint: string): string | null { - let statsKey: string = null; +function _getKeyMap(endpointCfg: IStatsEndpointConfig, endpoint: string): IStatsBeatKeyMap | null { + let matched: IStatsBeatKeyMap = null; if (endpointCfg.keyMap) { arrForEach(endpointCfg.keyMap, (keyMap) => { if (keyMap.match) { arrForEach(keyMap.match, (url) => { if (_isMatchEndpoint(url, endpoint)) { - statsKey = keyMap.key || null; + matched = keyMap; // Stop the loop if we found a match return -1; @@ -322,14 +427,14 @@ function _getIKey(endpointCfg: IStatsEndpointConfig, endpoint: string): string | }); } - if (statsKey) { + if (matched) { // Stop the loop if we found a match return -1; } }); } - return statsKey; + return matched; } export function createStatsMgr(): IStatsMgr { @@ -354,7 +459,7 @@ export function createStatsMgr(): IStatsMgr { 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) { + if (statsConfig && isFeatureEnabled(statsConfig.feature, details.cfg, true) === 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. @@ -374,23 +479,36 @@ export function createStatsMgr(): IStatsMgr { 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. + // Fetching the matching key map for the endpoint here to support the scenario where the + // endpoint is changed after the SDK Stats instance is created. This will ensure that the + // correct key / destination is used for the endpoint, and avoids tracking the 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; - } + let keyMap = _getKeyMap(endpointCfg, endpoint); + // Only send the event if the endpoint matched a configured key map (or the event type + // is non-zero, preserving the legacy behaviour for explicitly typed stats events). + if (keyMap || statsBeat.type) { + let useBreeze = _statsBeatConfig.mode === eStatsEndpointType.Breeze; + + // Resolve the iKey to use, falling back to the mode default when the matched key + // map does not specify one. Breeze mode uses the Microsoft-owned breeze SDK Stats + // iKey (region dependent); the SDK Stats endpoint uses the placeholder iKey. + let iKey = (keyMap && keyMap.key) || (useBreeze ? getStatsBreezeIKey(endpoint) : STATS_SDK_IKEY); + statsBeatEvent.iKey = iKey; + + // Resolve the destination SDK Stats ingestion endpoint. When sending to the legacy + // breeze endpoint there is no redirect (the event is sent to the customer's endpoint). + let url = (keyMap && keyMap.url) || (useBreeze ? null : getStatsEndpoint(endpoint)); + if (url) { + // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the + // event away from the customer's breeze endpoint. This marker is removed by the + // channel before the event is serialized. + statsBeatEvent.data = statsBeatEvent.data || {}; + statsBeatEvent.data[STATS_SDK_ENDPOINT_KEY] = url; + } - if (sendEvt) { _core.track(statsBeatEvent); } } @@ -424,3 +542,39 @@ export function createStatsMgr(): IStatsMgr { "enabled": { g: () => _isMgrEnabled } }); } + +/** + * Create the default {@link IStatsMgrConfig} used to enable the SDK Stats collection. By default the + * resulting events are routed to the distro-owned SDK Stats ingestion endpoint + * (`stats.monitor.azure.com` / `eu.stats.monitor.azure.com`). The destination can be changed at + * runtime via the CDN / dynamic config by setting `config.stats` (an {@link IStatsBeatConfig}) - for + * example setting `config.stats.mode` to {@link eStatsEndpointType.Breeze} routes SDK Stats to the + * legacy breeze endpoint instead. SDK Stats are enabled by default and can be opted-out using the + * `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. + * @returns The {@link IStatsMgrConfig} to pass to {@link IStatsMgr.init}. + */ +export function createSdkStatsMgrConfig(): IStatsMgrConfig { + return { + feature: STATS_SDK_FEATURE, + getCfg: (_core: IAppInsightsCore, cfg: CfgType): IStatsBeatConfig => { + // Read any CDN / dynamic config overrides. Accessing these inside the (dynamic) config + // change handler registers the dependency so changes are picked up at runtime. + let userCfg: IStatsBeatConfig = (cfg && cfg.stats) || {}; + + return { + mode: userCfg.mode || eStatsEndpointType.SdkStats, + shrtInt: userCfg.shrtInt, + // The destination iKey / endpoint are resolved per-event in _track based on the mode, + // so the default key map only needs to match all endpoints. A full key map (including + // explicit keys / urls) may be supplied via config.stats.endCfg to override this. + endCfg: userCfg.endCfg || [{ + type: eStatsType.SDK, + keyMap: [{ + match: ["*"] + }] + }] + }; + } + }; +} + diff --git a/shared/AppInsightsCore/src/enums/ai/StatsType.ts b/shared/AppInsightsCore/src/enums/ai/StatsType.ts index 720bd0123..04fcdd96e 100644 --- a/shared/AppInsightsCore/src/enums/ai/StatsType.ts +++ b/shared/AppInsightsCore/src/enums/ai/StatsType.ts @@ -8,3 +8,25 @@ export const enum eStatsType { } export type StatsType = number | eStatsType; + +/** + * Identifies which ingestion endpoint the SDK Stats events are sent to. This is configurable via + * the SDK configuration (and therefore the CDN / dynamic config) so the destination can be changed + * at runtime. + */ +export const enum eStatsEndpointType { + /** + * Send SDK Stats to the distro-owned SDK Stats ingestion endpoint (stats.monitor.azure.com). + * This is the default. + */ + SdkStats = 0, + + /** + * Send SDK Stats to the legacy breeze ingestion endpoint (the customer's own breeze endpoint + * host, using the Microsoft-owned SDK Stats instrumentation key). + */ + Breeze = 1, +} + +export type StatsEndpointType = number | eStatsEndpointType; + diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index c50fa017c..690d6636b 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -19,7 +19,7 @@ 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 { StatsType, eStatsType, StatsEndpointType, eStatsEndpointType } 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 +40,15 @@ 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 { 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, createSdkStatsMgrConfig, getStatsEndpoint, getStatsBreezeIKey, + STATS_SDK_IKEY, STATS_SDK_ENDPOINT_NON_EU, STATS_SDK_ENDPOINT_EU, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, + STATS_BREEZE_IKEY_NON_EU, STATS_BREEZE_IKEY_EU +} from "./core/StatsBeat"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; -//export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState} from "./interfaces/ai/IStatsBeat"; -//export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; -//export { IStatsMgr, IStatsMgrConfig } from "./interfaces/ai/IStatsMgr"; -//export { createStatsMgr } from "./core/StatsBeat"; export { isArray, isTypeof, isUndefined, isNullOrUndefined, isStrictUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, strEndsWith, strStartsWith, isDate, isError, isString, isNumber, isBoolean, arrForEach, arrIndexOf, diff --git a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts index 198b6ee0e..aa23c2327 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts @@ -15,6 +15,8 @@ import { INotificationListener } from "./INotificationListener"; import { INotificationManager } from "./INotificationManager"; import { IPerfManagerProvider } from "./IPerfManager"; import { IProcessTelemetryContext } from "./IProcessTelemetryContext"; +import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; +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