diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts new file mode 100644 index 000000000..497c51403 --- /dev/null +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -0,0 +1,378 @@ +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 { AppInsightsSku } from '../../../src/AISku'; +import { ICfgSyncMode } from '@microsoft/applicationinsights-cfgsync-js'; + +const TestInstrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; +const TestConnectionString = "InstrumentationKey=" + TestInstrumentationKey; + +export class SdkStatsFeatureTests extends AITestClass { + private _ai: AppInsightsSku | null = null; + + constructor() { + super("SdkStatsFeatureTests"); + } + + public testInitialize() { + try { + if (window.localStorage) { + window.localStorage.clear(); + } + } catch (e) { + // ignore + } + } + + public testFinishedCleanup(): void { + if (this._ai) { + this._ai.unload(false); + this._ai = null; + } + if (window.localStorage) { + window.localStorage.clear(); + } + } + + public registerTests() { + this._testSdkStatsEnabledByDefault(); + this._testSdkStatsDisabledViaFeatureOptIn(); + this._testSdkStatsDynamicEnableDisable(); + this._testSdkStatsConfigDefaults(); + this._testSdkStatsDynamicConfigChanges(); + } + + private _createAi(configOverrides?: Partial): AppInsightsSku { + let config: IConfiguration & IConfig = { + connectionString: TestConnectionString, + extensionConfig: { + ["AppInsightsCfgSyncPlugin"]: { + syncMode: ICfgSyncMode.Receive, + cfgUrl: "" + } + } + } as IConfiguration & IConfig; + + if (configOverrides) { + for (let key in configOverrides) { + if (configOverrides.hasOwnProperty(key)) { + (config as any)[key] = (configOverrides as any)[key]; + } + } + } + + let ai = new ApplicationInsights({ config: config }); + ai.loadAppInsights(); + this._ai = ai; + return ai; + } + + private _findSdkStatsListener(ai: AppInsightsSku): ISdkStatsNotifCbk | null { + let core = ai["core"]; + let notifyMgr = core.getNotifyMgr(); + let listeners = (notifyMgr as any).listeners; + if (listeners) { + for (let i = 0; i < listeners.length; i++) { + let listener = listeners[i]; + // The SDK stats listener has a flush and unload method + if (listener && typeof listener.flush === "function" && typeof listener.unload === "function" && + typeof listener.eventsSent === "function" && typeof listener.eventsRetry === "function") { + return listener as ISdkStatsNotifCbk; + } + } + } + return null; + } + + private _testSdkStatsEnabledByDefault() { + this.testCase({ + name: "SdkStatsFeature: SDK stats listener is added by default when featureOptIn is not specified", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + let listener = this._findSdkStatsListener(ai); + Assert.ok(listener, "SDK Stats listener should be added by default"); + } + }); + + this.testCase({ + name: "SdkStatsFeature: SDK stats listener is added when SdkStats feature is explicitly enabled", + useFakeTimers: true, + test: () => { + let ai = this._createAi({ + featureOptIn: { + ["SdkStats"]: { mode: FeatureOptInMode.enable } + } + }); + this.clock.tick(1); + + let listener = this._findSdkStatsListener(ai); + Assert.ok(listener, "SDK Stats listener should be present when explicitly enabled"); + } + }); + } + + private _testSdkStatsDisabledViaFeatureOptIn() { + this.testCase({ + name: "SdkStatsFeature: SDK stats listener is NOT added when SdkStats feature is disabled", + useFakeTimers: true, + test: () => { + let ai = this._createAi({ + featureOptIn: { + ["SdkStats"]: { mode: FeatureOptInMode.disable } + } + }); + this.clock.tick(1); + + let listener = this._findSdkStatsListener(ai); + Assert.ok(!listener, "SDK Stats listener should NOT be present when disabled"); + } + }); + } + + private _testSdkStatsDynamicEnableDisable() { + this.testCase({ + name: "SdkStatsFeature: disabling SdkStats feature dynamically removes the listener", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + // Should be enabled by default + let listener = this._findSdkStatsListener(ai); + Assert.ok(listener, "SDK Stats listener should be present initially"); + + // Disable the feature + ai.config.featureOptIn = { + ["SdkStats"]: { mode: FeatureOptInMode.disable } + }; + this.clock.tick(1); + + listener = this._findSdkStatsListener(ai); + Assert.ok(!listener, "SDK Stats listener should be removed after disabling feature"); + } + }); + + this.testCase({ + name: "SdkStatsFeature: re-enabling SdkStats feature dynamically adds the listener back", + useFakeTimers: true, + test: () => { + // Start with disabled + let ai = this._createAi({ + featureOptIn: { + ["SdkStats"]: { mode: FeatureOptInMode.disable } + } + }); + this.clock.tick(1); + + let listener = this._findSdkStatsListener(ai); + Assert.ok(!listener, "SDK Stats listener should NOT be present initially when disabled"); + + // Re-enable the feature + ai.config.featureOptIn = { + ["SdkStats"]: { mode: FeatureOptInMode.enable } + }; + this.clock.tick(1); + + listener = this._findSdkStatsListener(ai); + Assert.ok(listener, "SDK Stats listener should be added after re-enabling feature"); + } + }); + + this.testCase({ + name: "SdkStatsFeature: toggling SdkStats feature multiple times works correctly", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + // Initially enabled + Assert.ok(this._findSdkStatsListener(ai), "Listener should be present (initial)"); + + // Disable + ai.config.featureOptIn = { ["SdkStats"]: { mode: FeatureOptInMode.disable } }; + this.clock.tick(1); + Assert.ok(!this._findSdkStatsListener(ai), "Listener should be removed after first disable"); + + // Re-enable + ai.config.featureOptIn = { ["SdkStats"]: { mode: FeatureOptInMode.enable } }; + this.clock.tick(1); + Assert.ok(this._findSdkStatsListener(ai), "Listener should be present after re-enable"); + + // Disable again + ai.config.featureOptIn = { ["SdkStats"]: { mode: FeatureOptInMode.disable } }; + this.clock.tick(1); + Assert.ok(!this._findSdkStatsListener(ai), "Listener should be removed after second disable"); + } + }); + } + + private _testSdkStatsConfigDefaults() { + this.testCase({ + name: "SdkStatsFeature: sdkStats config defaults are applied (lang and int)", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + let config = ai.config; + Assert.ok(config.sdkStats, "sdkStats config should exist after initialization"); + Assert.equal("JavaScript", config.sdkStats!.lang, "lang should default to JavaScript"); + Assert.equal(900000, config.sdkStats!.int, "int should default to 900000 (15 minutes)"); + } + }); + + this.testCase({ + name: "SdkStatsFeature: user-provided sdkStats config is preserved", + useFakeTimers: true, + test: () => { + let ai = this._createAi({ + sdkStats: { + lang: "CustomLang", + ver: "1.0.0", + int: 60000 + } + }); + this.clock.tick(1); + + let config = ai.config; + Assert.ok(config.sdkStats, "sdkStats config should exist"); + Assert.equal("CustomLang", config.sdkStats!.lang, "User-provided lang should be preserved"); + Assert.equal("1.0.0", config.sdkStats!.ver, "User-provided ver should be preserved"); + Assert.equal(60000, config.sdkStats!.int, "User-provided int should be preserved"); + } + }); + } + + private _testSdkStatsDynamicConfigChanges() { + this.testCase({ + name: "SdkStatsFeature: changing sdkStats.lang dynamically triggers config change", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + let onChangeCalled = 0; + let expectedLang = "JavaScript"; + + let handler = onConfigChange(ai.config as any, (details: any) => { + onChangeCalled++; + if (details.cfg.sdkStats) { + Assert.equal(expectedLang, details.cfg.sdkStats.lang, + "sdkStats.lang should be " + expectedLang + " in onChange callback"); + } + }); + + Assert.equal(1, onChangeCalled, "onConfigChange should fire once initially"); + + // Change lang + expectedLang = "TypeScript"; + ai.config.sdkStats!.lang = "TypeScript"; + this.clock.tick(1); + Assert.equal(2, onChangeCalled, "onConfigChange should fire again after changing lang"); + + handler.rm(); + } + }); + + this.testCase({ + name: "SdkStatsFeature: changing sdkStats.int dynamically triggers config change", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + let onChangeCalled = 0; + let expectedInt = 900000; + + let handler = onConfigChange(ai.config as any, (details: any) => { + onChangeCalled++; + if (details.cfg.sdkStats) { + Assert.equal(expectedInt, details.cfg.sdkStats.int, + "sdkStats.int should be " + expectedInt + " in onChange callback"); + } + }); + + Assert.equal(1, onChangeCalled, "onConfigChange should fire once initially"); + + // Change interval + expectedInt = 60000; + ai.config.sdkStats!.int = 60000; + this.clock.tick(1); + Assert.equal(2, onChangeCalled, "onConfigChange should fire again after changing int"); + + handler.rm(); + } + }); + + this.testCase({ + name: "SdkStatsFeature: changing sdkStats.ver dynamically triggers config change", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + let onChangeCalled = 0; + let observedVer: string | undefined; + + let handler = onConfigChange(ai.config as any, (details: any) => { + onChangeCalled++; + if (details.cfg.sdkStats) { + observedVer = details.cfg.sdkStats.ver; + } + }); + + Assert.equal(1, onChangeCalled, "onConfigChange should fire once initially"); + + ai.config.sdkStats!.ver = "4.0.0"; + this.clock.tick(1); + Assert.equal(2, onChangeCalled, "onConfigChange should fire again after changing ver"); + Assert.equal("4.0.0", observedVer, "ver should be 4.0.0 in callback"); + Assert.equal("4.0.0", ai.config.sdkStats!.ver, "ver should be updated to 4.0.0"); + + handler.rm(); + } + }); + + this.testCase({ + name: "SdkStatsFeature: replacing entire sdkStats object dynamically triggers config change", + useFakeTimers: true, + test: () => { + let ai = this._createAi(); + this.clock.tick(1); + + let onChangeCalled = 0; + let observedLang: string | undefined; + let observedVer: string | undefined; + let observedInt: number | undefined; + + let handler = onConfigChange(ai.config as any, (details: any) => { + onChangeCalled++; + if (details.cfg.sdkStats) { + observedLang = details.cfg.sdkStats.lang; + observedVer = details.cfg.sdkStats.ver; + observedInt = details.cfg.sdkStats.int; + } + }); + + Assert.equal(1, onChangeCalled, "onConfigChange should fire once initially"); + + ai.config.sdkStats = { + lang: "Python", + ver: "2.0.0", + int: 30000 + }; + this.clock.tick(1); + Assert.equal(2, onChangeCalled, "onConfigChange should fire after replacing sdkStats block"); + + Assert.equal("Python", observedLang, "lang should be Python in callback"); + Assert.equal("2.0.0", observedVer, "ver should be 2.0.0 in callback"); + Assert.equal(30000, observedInt, "int should be 30000 in callback"); + + handler.rm(); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/aiskuunittests.ts b/AISKU/Tests/Unit/src/aiskuunittests.ts index 3088c034d..b3a8dbdbb 100644 --- a/AISKU/Tests/Unit/src/aiskuunittests.ts +++ b/AISKU/Tests/Unit/src/aiskuunittests.ts @@ -26,6 +26,7 @@ import { TraceSuppressionTests } from "./TraceSuppression.Tests"; import { TraceProviderTests } from "./TraceProvider.Tests"; import { TraceContextTests } from "./TraceContext.Tests"; import { OTelInitTests } from "./OTelInit.Tests"; +import { SdkStatsFeatureTests } from "./SdkStatsFeature.tests"; export function runTests() { new OTelInitTests().registerTests(); @@ -58,4 +59,5 @@ export function runTests() { new SpanContextPropagationTests().registerTests(); new SpanLifeCycleTests().registerTests(); new TelemetryItemGenerationTests().registerTests(); + new SdkStatsFeatureTests().registerTests(); } \ No newline at end of file diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 51a55e209..83849477d 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -11,13 +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, 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, 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, createSdkStatsNotifCbk, 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, @@ -64,6 +65,7 @@ const IKEY_USAGE = "iKeyUsage"; const CDN_USAGE = "CdnUsage"; const SDK_LOADER_VER = "SdkLoaderVer"; const ZIP_PAYLOAD = "zipPayload"; +const SDK_STATS = "SdkStats"; const default_limit = { samplingRate: 100, @@ -93,8 +95,14 @@ const defaultConfigValues: IConfigDefaults = { [IKEY_USAGE]: {mode: FeatureOptInMode.enable}, //for versions after 3.1.2 (>= 3.2.0) [CDN_USAGE]: {mode: FeatureOptInMode.disable}, [SDK_LOADER_VER]: {mode: FeatureOptInMode.disable}, - [ZIP_PAYLOAD]: {mode: FeatureOptInMode.none} + [ZIP_PAYLOAD]: {mode: FeatureOptInMode.none}, + [SDK_STATS]: {mode: FeatureOptInMode.enable} }, + sdkStats: cfgDfMerge({ + lang: "JavaScript", + ver: UNDEFINED_VALUE, + int: 900000 + }), throttleMgrCfg: cfgDfMerge<{[key:number]: IThrottleMgrConfig}>( { [_eInternalMessageId.DefaultThrottleMsgKey]:cfgDfMerge(default_throttle_config), @@ -196,6 +204,7 @@ export class AppInsightsSku implements IApplicationInsights; + let _sdkStatsListener: ISdkStatsNotifCbk; dynamicProto(AppInsightsSku, this, (_self) => { _initDefaults(); @@ -436,6 +445,22 @@ export class AppInsightsSku implements IApplicationInsights 0) { + // Extract HTTP status code from the EventBatchNotificationReason if in the ResponseFailure range (9000-9999) + let statusCode = (reason >= EventBatchNotificationReason.ResponseFailure && reason <= EventBatchNotificationReason.ResponseFailureMax) + ? reason - EventBatchNotificationReason.ResponseFailure + : 0; + _notifyEvents("eventsRetry", requeuedEvents, statusCode); + } + if (droppedEvents.length > 0) { _notifyEvents(strEventsDiscarded, droppedEvents, EventsDiscardedReason.NonRetryableStatus); } diff --git a/channels/1ds-post-js/test/Unit/src/PostChannelTest.ts b/channels/1ds-post-js/test/Unit/src/PostChannelTest.ts index 56471395a..02f317638 100644 --- a/channels/1ds-post-js/test/Unit/src/PostChannelTest.ts +++ b/channels/1ds-post-js/test/Unit/src/PostChannelTest.ts @@ -33,6 +33,8 @@ export class PostChannelTest extends AITestClass { private core: AppInsightsCore; private eventsSent: Array = []; private eventsDiscarded: Array = []; + private eventsRetried: Array = []; + private eventsRetriedStatusCodes: Array = []; private eventsSendRequests: Array = []; private testMessage: string; private beaconCalls = []; @@ -64,6 +66,8 @@ export class PostChannelTest extends AITestClass { this.testMessage = ""; this.eventsSent = []; this.eventsDiscarded = []; + this.eventsRetried = []; + this.eventsRetriedStatusCodes = []; this.eventsSendRequests = []; this.xhrOverride = new AutoCompleteXhrOverride(); this.setTimeoutOverride = (handler: Function, timeout?: number) => { @@ -1012,6 +1016,66 @@ export class PostChannelTest extends AITestClass { } }); + this.testCase({ + name: "eventsRetry notification fires when events are requeued after retriable failure", + useFakeTimers: true, + test: () => { + // Use an xhrOverride that returns a retriable 503 status + let sendCount = 0; + let retryXhrOverride: IXHROverride = { + sendPOST: (payload: IPayloadData, oncomplete: (status: number, headers: { [headerName: string]: string }) => void, sync?: boolean) => { + sendCount++; + // Always return 503 to trigger retry/requeue + oncomplete(503, {}); + } + }; + + this.config.extensionConfig[this.postChannel.identifier] = { + httpXHROverride: retryXhrOverride, + maxEventRetryAttempts: 3 + }; + + this.core.initialize(this.config, [this.postChannel]); + this.core.addNotificationListener({ + eventsRetry: (events: IExtendedTelemetryItem[], statusCode: number) => { + for (var i = 0; i < events.length; i++) { + this.eventsRetried.push(events[i]); + } + this.eventsRetriedStatusCodes.push(statusCode); + } + }); + + var event: IPostTransmissionTelemetryItem = { + name: 'testEvent', + sync: false, + latency: EventLatency.Normal, + iKey: 'testIkey' + }; + this.postChannel.processTelemetry(event); + + // Use synchronous flush to trigger immediate send via HttpManager + // For synchronous sends, HttpManager skips internal retry and directly + // calls _requeueEvents when a retriable status code (503) is received + this.postChannel.flush(false); + QUnit.assert.ok(sendCount > 0, "XHR should have been called, got " + sendCount); + + // The NotificationManager dispatches eventsRetry asynchronously via scheduleTimeout(0) + // Allow time for async notification dispatch and any backoff timers + for (var i = 0; i < 20; i++) { + this.clock.tick(1000); + if (this.eventsRetried.length > 0) { + break; + } + } + + // Verify that eventsRetry notification fired when events were requeued + QUnit.assert.ok(this.eventsRetried.length > 0, "eventsRetry should have been called at least once, got " + this.eventsRetried.length); + if (this.eventsRetried.length > 0) { + QUnit.assert.equal(this.eventsRetried[0].name, 'testEvent', "Retried event should be the test event"); + } + } + }); + this.testCase({ name: "setTimeout override", useFakeTimers: true, diff --git a/channels/applicationinsights-channel-js/src/Interfaces.ts b/channels/applicationinsights-channel-js/src/Interfaces.ts index 76a091f81..0bda44a35 100644 --- a/channels/applicationinsights-channel-js/src/Interfaces.ts +++ b/channels/applicationinsights-channel-js/src/Interfaces.ts @@ -14,6 +14,11 @@ export interface IInternalStorageItem { * total retry count */ cnt?: number; + /** + * baseType of the original telemetry item, used for SDK stats telemetry_type mapping. + * @since 3.3.12 + */ + bT?: string; } export interface ISenderConfig { diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index cd0546c03..183846be3 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -511,7 +511,8 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _checkMaxSize(payload); let payloadItem = { item: payload, - cnt: 0 // inital cnt will always be 0 + cnt: 0, // inital cnt will always be 0 + bT: telemetryItem.baseType // store baseType for SDK stats telemetry_type mapping } as IInternalStorageItem; // enqueue the payload @@ -626,12 +627,12 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { /** * error handler */ - _self._onError = (payload: IInternalStorageItem[] | string[], message: string, event?: ErrorEvent) => { + _self._onError = (payload: IInternalStorageItem[] | string[], message: string, event?: ErrorEvent, statusCode?: number) => { // since version 3.1.3, string[] is no-op if (_isStringArr(payload)) { return; } - return _onError(payload as IInternalStorageItem[], message, event); + return _onError(payload as IInternalStorageItem[], message, event, statusCode); }; /** @@ -799,7 +800,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { /** * error handler */ - function _onError(payload: IInternalStorageItem[], message: string, event?: ErrorEvent) { + function _onError(payload: IInternalStorageItem[], message: string, event?: ErrorEvent, statusCode?: number) { _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.OnError, @@ -807,6 +808,15 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { { message }); _self._buffer && _self._buffer.clearSent(payload); + + // Notify listeners of discarded events + let mgr = _getNotifyMgr(); + if (mgr) { + let items = _extractTelemetryItems(payload); + if (items) { + mgr.eventsDiscarded(items, 1 /* NonRetryableStatus */, statusCode); + } + } } /** * partial success handler @@ -852,6 +862,15 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { */ function _onSuccess(payload: IInternalStorageItem[], countOfItemsInPayload: number) { _self._buffer && _self._buffer.clearSent(payload); + + // Notify listeners of successful send + let mgr = _getNotifyMgr(); + if (mgr) { + let items = _extractTelemetryItems(payload); + if (items) { + mgr.eventsSent(items); + } + } } @@ -1114,7 +1133,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // Updates the end point url before retry if(status === 301 || status === 307 || status === 308) { if(!_checkAndUpdateEndPointUrl(responseUrl)) { - _self._onError(payload, errorMessage); + _self._onError(payload, errorMessage, undefined, status); return; } } @@ -1124,6 +1143,9 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!_isRetryDisabled) { const offlineBackOffMultiplier = 10; // arbritrary number _resendPayload(payload, offlineBackOffMultiplier); + + // Notify listeners of retry + _notifyRetry(payload, status); _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, @@ -1133,12 +1155,16 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } if (!_isRetryDisabled && _isRetriable(status)) { _resendPayload(payload); + + // Notify listeners of retry + _notifyRetry(payload, status); + _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.TransmissionFailed, ". " + "Response code " + status + ". Will retry to send " + payload.length + " items."); } else { - _self._onError(payload, errorMessage); + _self._onError(payload, errorMessage, undefined, status); } } else { // check if the xhr's responseURL or fetch's response.url is same as endpoint url @@ -1153,7 +1179,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (response && !_isRetryDisabled) { _self._onPartialSuccess(payload, response); } else { - _self._onError(payload, errorMessage); + _self._onError(payload, errorMessage, undefined, status); } } else { _consecutiveErrors = 0; @@ -1388,6 +1414,37 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } } + /** + * Extracts minimal ITelemetryItem objects from IInternalStorageItem[] for notification dispatch. + * Uses the stored baseType (bT) to reconstruct telemetry items. + */ + function _extractTelemetryItems(payload: IInternalStorageItem[]): ITelemetryItem[] { + if (payload && payload.length) { + let items: ITelemetryItem[] = []; + arrForEach(payload, (p) => { + if (p) { + let baseType = p.bT || "EventData"; + items.push({ name: baseType, baseType: baseType } as ITelemetryItem); + } + }); + return items.length ? items : null; + } + return null; + } + + /** + * Notify listeners of retry events. + */ + function _notifyRetry(payload: IInternalStorageItem[], statusCode: number) { + let mgr = _getNotifyMgr(); + if (mgr && mgr.eventsRetry) { + let items = _extractTelemetryItems(payload); + if (items) { + mgr.eventsRetry(items, statusCode); + } + } + } + /** @@ -1534,7 +1591,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { * @internal * since version 3.2.0, if the payload is string[], this function is no-op (string[] is only used for backwards Compatibility) */ - public _onError(payload: string[] | IInternalStorageItem[], message: string, event?: ErrorEvent) { + public _onError(payload: string[] | IInternalStorageItem[], message: string, event?: ErrorEvent, statusCode?: number) { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } 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 1b08dfc6d..b7a864cac 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts @@ -107,6 +107,10 @@ export class CfgSyncHelperTests extends AITestClass { // } //}, traceHdrMode: 3, + sdkStats: { + lang: "JavaScript", + int: 900000 + }, traceCfg: { generalLimits: { attributeCountLimit: 128 diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/SdkStatsNotificationCbk.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/SdkStatsNotificationCbk.Tests.ts new file mode 100644 index 000000000..6c82f56fd --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/SdkStatsNotificationCbk.Tests.ts @@ -0,0 +1,826 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "../../../../src/core/SdkStatsNotificationCbk"; +import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; +import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; +import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; +import { NotificationManager } from "../../../../src/core/NotificationManager"; + +export class SdkStatsNotificationCbkTests extends AITestClass { + private _trackedItems: ITelemetryItem[]; + private _listener: ISdkStatsNotifCbk; + + public testInitialize() { + super.testInitialize(); + this._trackedItems = []; + this._listener = null; + } + + public testCleanup() { + super.testCleanup(); + if (this._listener) { + this._listener.unload(); + this._listener = null; + } + this._trackedItems = []; + } + + public registerTests() { + this._testCreation(); + this._testEventsSent(); + this._testEventsDiscarded(); + this._testEventsRetry(); + this._testFlush(); + this._testTimerBasedFlush(); + this._testUnload(); + this._testBaseTypeMapping(); + this._testSdkStatsMetricFiltering(); + this._testNotificationManagerIntegration(); + this._testDynamicConfigChanges(); + } + + private _createListener(overrides?: Partial): ISdkStatsNotifCbk { + let _self = this; + let sdkStats: ISdkStatsConfig = { + lang: "JavaScript", + ver: "3.3.6", + int: 100 // short interval for testing + }; + + if (overrides) { + for (var key in overrides) { + if (overrides.hasOwnProperty(key)) { + (sdkStats as any)[key] = (overrides as any)[key]; + } + } + } + + let mockCore = { + track: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + config: { sdkStats: sdkStats } as IConfiguration + } as any as IAppInsightsCore; + + _self._listener = createSdkStatsNotifCbk(mockCore); + return _self._listener; + } + + private _makeItem(baseType: string, name?: string): ITelemetryItem { + return { + name: name || "test", + baseType: baseType + } as ITelemetryItem; + } + + private _testCreation() { + this.testCase({ + name: "SdkStatsNotifCbk: createSdkStatsNotifCbk returns an object with required methods", + test: () => { + let listener = this._createListener(); + + Assert.ok(listener, "Listener should be created"); + Assert.ok(listener.eventsSent, "eventsSent should be defined"); + Assert.ok(listener.eventsDiscarded, "eventsDiscarded should be defined"); + Assert.ok(listener.eventsRetry, "eventsRetry should be defined"); + Assert.ok(listener.flush, "flush should be defined"); + Assert.ok(listener.unload, "unload should be defined"); + } + }); + } + + private _testEventsSent() { + this.testCase({ + name: "SdkStatsNotifCbk: eventsSent accumulates success counts and flushes Item_Success_Count", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [ + this._makeItem("EventData"), + this._makeItem("ExceptionData"), + this._makeItem("EventData") + ]; + + listener.eventsSent(items); + listener.flush(); + + // Should have 2 metrics: one for CUSTOM_EVENT (count 2), one for EXCEPTION (count 1) + Assert.equal(2, this._trackedItems.length, "Should emit 2 metrics"); + + let successItems = this._trackedItems.filter(function (item) { + return item.name === "Item_Success_Count"; + }); + Assert.equal(2, successItems.length, "All metrics should be Item_Success_Count"); + + // Verify props + let customEventMetric = successItems.filter(function (item) { + return item.baseData.properties["telemetry_type"] === "CUSTOM_EVENT"; + })[0]; + Assert.ok(customEventMetric, "Should have CUSTOM_EVENT metric"); + Assert.equal(2, customEventMetric.baseData.average, "CUSTOM_EVENT count should be 2"); + Assert.equal("JavaScript", customEventMetric.baseData.properties["language"], "Language should be JavaScript"); + Assert.equal("3.3.6", customEventMetric.baseData.properties["version"], "Version should be 3.3.6"); + Assert.equal("unknown", customEventMetric.baseData.properties["computeType"], "computeType should be unknown"); + + let exceptionMetric = successItems.filter(function (item) { + return item.baseData.properties["telemetry_type"] === "EXCEPTION"; + })[0]; + Assert.ok(exceptionMetric, "Should have EXCEPTION metric"); + Assert.equal(1, exceptionMetric.baseData.average, "EXCEPTION count should be 1"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: eventsSent with multiple batches before flush accumulates correctly", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([this._makeItem("EventData")]); + listener.eventsSent([this._makeItem("EventData"), this._makeItem("EventData")]); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Should emit 1 metric (all CUSTOM_EVENT)"); + Assert.equal(3, this._trackedItems[0].baseData.average, "Should accumulate to 3"); + } + }); + } + + private _testEventsDiscarded() { + this.testCase({ + name: "SdkStatsNotifCbk: eventsDiscarded with NonRetryableStatus and sendType emits correct drop.code", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [ + this._makeItem("EventData"), + this._makeItem("RemoteDependencyData") + ]; + + // reason=1 (NonRetryableStatus), sendType=403 (HTTP status) + listener.eventsDiscarded(items, 1, 403); + listener.flush(); + + Assert.equal(2, this._trackedItems.length, "Should emit 2 dropped metrics"); + + let allDropped = this._trackedItems.filter(function (item) { + return item.name === "Item_Dropped_Count"; + }); + Assert.equal(2, allDropped.length, "All should be Item_Dropped_Count"); + + // Verify drop.code is the HTTP status code as string + allDropped.forEach(function (item) { + Assert.equal("403", item.baseData.properties["drop.code"], "drop.code should be '403'"); + }); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: eventsDiscarded with client exception reason emits CLIENT_EXCEPTION drop.code", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [this._makeItem("ExceptionData")]; + + // reason=2 (InvalidEvent) - should map to CLIENT_EXCEPTION + listener.eventsDiscarded(items, 2); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Should emit 1 dropped metric"); + Assert.equal("Item_Dropped_Count", this._trackedItems[0].name, "Name should be Item_Dropped_Count"); + Assert.equal("CLIENT_EXCEPTION", this._trackedItems[0].baseData.properties["drop.code"], "drop.code should be CLIENT_EXCEPTION"); + Assert.equal("EXCEPTION", this._trackedItems[0].baseData.properties["telemetry_type"], "telemetry_type should be EXCEPTION"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: eventsDiscarded with reason=1 but no sendType uses CLIENT_EXCEPTION", + test: () => { + let listener = this._createListener(); + + listener.eventsDiscarded([this._makeItem("EventData")], 1); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Should emit 1 metric"); + Assert.equal("CLIENT_EXCEPTION", this._trackedItems[0].baseData.properties["drop.code"], + "drop.code should be CLIENT_EXCEPTION when sendType is not provided"); + } + }); + } + + private _testEventsRetry() { + this.testCase({ + name: "SdkStatsNotifCbk: eventsRetry accumulates retry counts with status code", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [ + this._makeItem("EventData"), + this._makeItem("MessageData") + ]; + + listener.eventsRetry(items, 429); + listener.flush(); + + Assert.equal(2, this._trackedItems.length, "Should emit 2 retry metrics"); + + let allRetry = this._trackedItems.filter(function (item) { + return item.name === "Item_Retry_Count"; + }); + Assert.equal(2, allRetry.length, "All should be Item_Retry_Count"); + + allRetry.forEach(function (item) { + Assert.equal("429", item.baseData.properties["retry.code"], "retry.code should be '429'"); + }); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: eventsRetry with different status codes creates separate buckets", + test: () => { + let listener = this._createListener(); + + listener.eventsRetry([this._makeItem("EventData")], 429); + listener.eventsRetry([this._makeItem("EventData")], 503); + listener.eventsRetry([this._makeItem("EventData")], 429); + listener.flush(); + + Assert.equal(2, this._trackedItems.length, "Should emit 2 retry metrics (separate codes)"); + + let retryBy429 = this._trackedItems.filter(function (item) { + return item.baseData.properties["retry.code"] === "429"; + }); + Assert.equal(1, retryBy429.length, "Should have one 429 metric"); + Assert.equal(2, retryBy429[0].baseData.average, "429 count should be 2"); + + let retryBy503 = this._trackedItems.filter(function (item) { + return item.baseData.properties["retry.code"] === "503"; + }); + Assert.equal(1, retryBy503.length, "Should have one 503 metric"); + Assert.equal(1, retryBy503[0].baseData.average, "503 count should be 1"); + } + }); + } + + private _testFlush() { + this.testCase({ + name: "SdkStatsNotifCbk: flush resets accumulators (second flush emits nothing)", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([this._makeItem("EventData")]); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "First flush should emit 1 metric"); + + // Reset tracking + this._trackedItems = []; + listener.flush(); + + Assert.equal(0, this._trackedItems.length, "Second flush should emit nothing"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: flush emits all three metric types when success, dropped, and retry exist", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([this._makeItem("EventData")]); + listener.eventsDiscarded([this._makeItem("ExceptionData")], 2); + listener.eventsRetry([this._makeItem("MessageData")], 503); + listener.flush(); + + Assert.equal(3, this._trackedItems.length, "Should emit 3 metrics"); + + let names = this._trackedItems.map(function (item) { return item.name; }).sort(); + Assert.deepEqual(["Item_Dropped_Count", "Item_Retry_Count", "Item_Success_Count"], names, + "Should have all three metric types"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: flush emits MetricData baseType on all metrics", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([this._makeItem("EventData")]); + listener.flush(); + + Assert.equal("MetricData", this._trackedItems[0].baseType, "baseType should be MetricData"); + Assert.equal(1, this._trackedItems[0].baseData.sampleCount, "sampleCount should be 1"); + } + }); + } + + private _testTimerBasedFlush() { + this.testCase({ + name: "SdkStatsNotifCbk: metrics are automatically flushed after the configured timer interval", + useFakeTimers: true, + test: () => { + let listener = this._createListener(); // interval = 100ms + + // Queue some events — this starts the internal timer + listener.eventsSent([ + this._makeItem("EventData"), + this._makeItem("ExceptionData") + ]); + + // No flush called yet — nothing should have been emitted + Assert.equal(0, this._trackedItems.length, "No metrics should be emitted before timer fires"); + + // Advance the clock past the configured interval (100ms) + this.clock.tick(101); + + // The timer should have fired and flushed the accumulated counts + Assert.equal(2, this._trackedItems.length, "Metrics should be emitted after timer fires"); + + let names = this._trackedItems.map(function (item) { return item.name; }); + Assert.ok(names.indexOf("Item_Success_Count") >= 0, "Should contain Item_Success_Count"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: timer resets after flush and accumulates next interval independently", + useFakeTimers: true, + test: () => { + let listener = this._createListener(); // interval = 100ms + + // First interval + listener.eventsSent([this._makeItem("EventData")]); + this.clock.tick(101); + + Assert.equal(1, this._trackedItems.length, "First interval should emit 1 metric"); + Assert.equal(1, this._trackedItems[0].baseData.average, "First interval count should be 1"); + + // Reset tracking for second interval + this._trackedItems = []; + + // Second interval — new events + listener.eventsSent([this._makeItem("EventData"), this._makeItem("EventData")]); + this.clock.tick(101); + + Assert.equal(1, this._trackedItems.length, "Second interval should emit 1 metric"); + Assert.equal(2, this._trackedItems[0].baseData.average, "Second interval count should be 2"); + } + }); + } + + private _testUnload() { + this.testCase({ + name: "SdkStatsNotifCbk: unload flushes remaining counts", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([this._makeItem("EventData")]); + listener.unload(); + // Nullify to avoid double unload in testCleanup + this._listener = null; + + Assert.equal(1, this._trackedItems.length, "Should flush remaining counts on unload"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: unload with no pending data emits nothing", + test: () => { + let listener = this._createListener(); + + listener.unload(); + this._listener = null; + + Assert.equal(0, this._trackedItems.length, "Should not emit any metrics when no data"); + } + }); + } + + private _testBaseTypeMapping() { + this.testCase({ + name: "SdkStatsNotifCbk: all baseType values map to correct telemetry_type", + test: () => { + let listener = this._createListener(); + + let mappings: { [key: string]: string } = { + "EventData": "CUSTOM_EVENT", + "MetricData": "CUSTOM_METRIC", + "RemoteDependencyData": "DEPENDENCY", + "ExceptionData": "EXCEPTION", + "PageviewData": "PAGE_VIEW", + "PageviewPerformanceData": "PAGE_VIEW", + "MessageData": "TRACE", + "RequestData": "REQUEST", + "AvailabilityData": "AVAILABILITY" + }; + + for (var baseType in mappings) { + if (mappings.hasOwnProperty(baseType)) { + listener.eventsSent([this._makeItem(baseType)]); + } + } + + listener.flush(); + + // PageviewData and PageviewPerformanceData both map to PAGE_VIEW, so they'll be merged + // MetricData maps to CUSTOM_METRIC + // That gives us 8 unique telemetry_type values + Assert.equal(8, this._trackedItems.length, "Should have 8 unique telemetry_type metrics"); + + let types: string[] = this._trackedItems.map(function (item) { + return item.baseData.properties["telemetry_type"]; + }).sort(); + + Assert.deepEqual( + ["AVAILABILITY", "CUSTOM_EVENT", "CUSTOM_METRIC", "DEPENDENCY", "EXCEPTION", "PAGE_VIEW", "REQUEST", "TRACE"], + types, + "All expected telemetry types should be present" + ); + + // PAGE_VIEW should have count 2 (PageviewData + PageviewPerformanceData) + let pageView = this._trackedItems.filter(function (item) { + return item.baseData.properties["telemetry_type"] === "PAGE_VIEW"; + })[0]; + Assert.equal(2, pageView.baseData.average, "PAGE_VIEW count should be 2"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: unknown baseType defaults to CUSTOM_EVENT", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([this._makeItem("UnknownType")]); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Should emit 1 metric"); + Assert.equal("CUSTOM_EVENT", this._trackedItems[0].baseData.properties["telemetry_type"], + "Unknown baseType should default to CUSTOM_EVENT"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: missing baseType defaults to CUSTOM_EVENT", + test: () => { + let listener = this._createListener(); + + listener.eventsSent([{ name: "test" } as ITelemetryItem]); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Should emit 1 metric"); + Assert.equal("CUSTOM_EVENT", this._trackedItems[0].baseData.properties["telemetry_type"], + "Missing baseType should default to CUSTOM_EVENT"); + } + }); + } + + private _testSdkStatsMetricFiltering() { + this.testCase({ + name: "SdkStatsNotifCbk: SDK stats metrics (Item_Success_Count etc) are not counted", + test: () => { + let listener = this._createListener(); + + // These should be filtered out - they are SDK stats metrics themselves + let sdkStatsItems: ITelemetryItem[] = [ + this._makeItem("MetricData", "Item_Success_Count"), + this._makeItem("MetricData", "Item_Dropped_Count"), + this._makeItem("MetricData", "Item_Retry_Count") + ]; + + listener.eventsSent(sdkStatsItems); + listener.flush(); + + Assert.equal(0, this._trackedItems.length, + "SDK stats metrics should not be counted to prevent infinite recursion"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: SDK stats metrics are filtered but regular metrics still counted", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [ + this._makeItem("MetricData", "Item_Success_Count"), // filtered + this._makeItem("EventData", "myCustomEvent"), // counted + this._makeItem("MetricData", "Item_Retry_Count"), // filtered + this._makeItem("ExceptionData", "error") // counted + ]; + + listener.eventsSent(items); + listener.flush(); + + Assert.equal(2, this._trackedItems.length, "Should emit 2 metrics (2 types counted)"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: SDK stats filtering works for eventsDiscarded too", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [ + this._makeItem("MetricData", "Item_Success_Count"), + this._makeItem("EventData", "myEvent") + ]; + + listener.eventsDiscarded(items, 2); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Only non-SDK-stats items should be counted"); + Assert.equal("CUSTOM_EVENT", this._trackedItems[0].baseData.properties["telemetry_type"], + "Should only count the EventData item"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: SDK stats filtering works for eventsRetry too", + test: () => { + let listener = this._createListener(); + + let items: ITelemetryItem[] = [ + this._makeItem("MetricData", "Item_Dropped_Count"), + this._makeItem("MessageData", "trace") + ]; + + listener.eventsRetry(items, 429); + listener.flush(); + + Assert.equal(1, this._trackedItems.length, "Only non-SDK-stats items should be counted"); + Assert.equal("TRACE", this._trackedItems[0].baseData.properties["telemetry_type"], + "Should only count the MessageData item"); + } + }); + } + + private _testNotificationManagerIntegration() { + this.testCase({ + name: "SdkStatsNotifCbk: can be added to NotificationManager as a listener", + test: () => { + let listener = this._createListener(); + let mgr = new NotificationManager(); + mgr.addNotificationListener(listener); + + Assert.equal(1, mgr.listeners.length, "Listener should be added"); + Assert.equal(listener, mgr.listeners[0], "Should be the same listener instance"); + + mgr.removeNotificationListener(listener); + Assert.equal(0, mgr.listeners.length, "Listener should be removed"); + + mgr.unload(); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: listener has all required notification callback properties", + test: () => { + let listener = this._createListener(); + + // Verify the listener implements the expected INotificationListener methods + Assert.ok(typeof listener.eventsSent === "function", "eventsSent should be a function"); + Assert.ok(typeof listener.eventsDiscarded === "function", "eventsDiscarded should be a function"); + Assert.ok(typeof listener.eventsRetry === "function", "eventsRetry should be a function"); + Assert.ok(typeof listener.flush === "function", "flush should be a function"); + Assert.ok(typeof listener.unload === "function", "unload should be a function"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: removal from NotificationManager prevents listener from receiving events", + test: () => { + let listener = this._createListener(); + let mgr = new NotificationManager(); + mgr.addNotificationListener(listener); + + // Directly invoke listener to verify data flow + listener.eventsSent([this._makeItem("EventData")]); + listener.flush(); + Assert.equal(1, this._trackedItems.length, "Should have 1 metric before removal"); + + // Remove listener and verify it's gone + mgr.removeNotificationListener(listener); + Assert.equal(0, mgr.listeners.length, "Listener should be removed from manager"); + + mgr.unload(); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: multiple listeners can be added to NotificationManager", + test: () => { + let listener1 = this._createListener(); + let trackedItems2: ITelemetryItem[] = []; + let listener2 = createSdkStatsNotifCbk( + { + track: function (item: ITelemetryItem) { + trackedItems2.push(item); + }, + config: { + sdkStats: { + lang: "JavaScript", + ver: "3.3.6", + int: 100 + } + } as IConfiguration + } as any as IAppInsightsCore + ); + + let mgr = new NotificationManager(); + mgr.addNotificationListener(listener1); + mgr.addNotificationListener(listener2); + + Assert.equal(2, mgr.listeners.length, "Both listeners should be added"); + + mgr.removeNotificationListener(listener1); + Assert.equal(1, mgr.listeners.length, "Only one listener should remain"); + + mgr.removeNotificationListener(listener2); + Assert.equal(0, mgr.listeners.length, "No listeners should remain"); + + listener2.unload(); + mgr.unload(); + } + }); + } + + private _testDynamicConfigChanges() { + this.testCase({ + name: "SdkStatsNotifCbk: changing lang on core.config.sdkStats is picked up on next flush", + test: () => { + let _self = this; + let sdkStats: ISdkStatsConfig = { + lang: "JavaScript", + ver: "3.3.6", + int: 100 + }; + let config = { sdkStats: sdkStats } as IConfiguration; + let mockCore = { + track: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + config: config + } as any as IAppInsightsCore; + + let listener = createSdkStatsNotifCbk(mockCore); + _self._listener = listener; + + // First flush with original lang + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal(1, _self._trackedItems.length, "Should emit 1 metric"); + Assert.equal("JavaScript", _self._trackedItems[0].baseData.properties["language"], + "Language should be JavaScript initially"); + + // Change lang on config + _self._trackedItems = []; + config.sdkStats.lang = "TypeScript"; + + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal(1, _self._trackedItems.length, "Should emit 1 metric after lang change"); + Assert.equal("TypeScript", _self._trackedItems[0].baseData.properties["language"], + "Language should be TypeScript after config change"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: changing ver on core.config.sdkStats is picked up on next flush", + test: () => { + let _self = this; + let sdkStats: ISdkStatsConfig = { + lang: "JavaScript", + ver: "3.3.6", + int: 100 + }; + let config = { sdkStats: sdkStats } as IConfiguration; + let mockCore = { + track: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + config: config + } as any as IAppInsightsCore; + + let listener = createSdkStatsNotifCbk(mockCore); + _self._listener = listener; + + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal("3.3.6", _self._trackedItems[0].baseData.properties["version"], + "Version should be 3.3.6 initially"); + + // Change ver on config + _self._trackedItems = []; + config.sdkStats.ver = "4.0.0"; + + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal("4.0.0", _self._trackedItems[0].baseData.properties["version"], + "Version should be 4.0.0 after config change"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: changing int on core.config.sdkStats changes the timer interval after next flush", + useFakeTimers: true, + test: () => { + let _self = this; + let sdkStats: ISdkStatsConfig = { + lang: "JavaScript", + ver: "3.3.6", + int: 200 + }; + let config = { sdkStats: sdkStats } as IConfiguration; + let mockCore = { + track: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + config: config + } as any as IAppInsightsCore; + + let listener = createSdkStatsNotifCbk(mockCore); + _self._listener = listener; + + listener.eventsSent([_self._makeItem("EventData")]); + Assert.equal(0, _self._trackedItems.length, "Nothing emitted before timer fires"); + + config.sdkStats.int = 500; + + this.clock.tick(201); + Assert.equal(1, _self._trackedItems.length, "First timer fires at originally scheduled 200ms"); + + _self._trackedItems = []; + listener.eventsSent([_self._makeItem("EventData")]); + + this.clock.tick(201); + Assert.equal(0, _self._trackedItems.length, "Timer should NOT fire at old 200ms interval"); + + this.clock.tick(300); + Assert.equal(1, _self._trackedItems.length, "Timer should fire at new 500ms interval"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: defaults are used when sdkStats config is not provided", + test: () => { + let _self = this; + let config = {} as IConfiguration; + let mockCore = { + track: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + config: config + } as any as IAppInsightsCore; + + let listener = createSdkStatsNotifCbk(mockCore); + _self._listener = listener; + + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal(1, _self._trackedItems.length, "Should emit 1 metric"); + Assert.equal("JavaScript", _self._trackedItems[0].baseData.properties["language"], + "Language should default to JavaScript"); + Assert.equal("unknown", _self._trackedItems[0].baseData.properties["version"], + "Version should default to unknown"); + } + }); + + this.testCase({ + name: "SdkStatsNotifCbk: setting sdkStats after creation is picked up dynamically on flush", + test: () => { + let _self = this; + let config = {} as IConfiguration; + let mockCore = { + track: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + config: config + } as any as IAppInsightsCore; + + let listener = createSdkStatsNotifCbk(mockCore); + _self._listener = listener; + + // Flush without sdkStats set + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal("JavaScript", _self._trackedItems[0].baseData.properties["language"], + "Language should default to JavaScript"); + Assert.equal("unknown", _self._trackedItems[0].baseData.properties["version"], + "Version should default to unknown"); + + // Now set sdkStats on config + _self._trackedItems = []; + config.sdkStats = { lang: "Python", ver: "1.0.0", int: 100 }; + + listener.eventsSent([_self._makeItem("EventData")]); + listener.flush(); + + Assert.equal("Python", _self._trackedItems[0].baseData.properties["language"], + "Language should be Python from newly set config"); + Assert.equal("1.0.0", _self._trackedItems[0].baseData.properties["version"], + "Version should be 1.0.0 from newly set config"); + } + }); + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index 90ae0a7da..a177b7d35 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -31,6 +31,7 @@ import { SeverityLevelTests } from "./ai/SeverityLevel.tests"; import { ThrottleMgrTest } from "./ai/ThrottleMgr.tests"; import { UtilTests } from "./ai/Util.tests"; import { W3CTraceStateModesTests } from "./trace/W3CTraceStateModes.tests"; +import { SdkStatsNotificationCbkTests } from "./ai/SdkStatsNotificationCbk.Tests"; export function runTests() { new GlobalTestHooks().registerTests(); @@ -57,6 +58,7 @@ export function runTests() { // new StatsBeatTests(false).registerTests(); // new StatsBeatTests(true).registerTests(); new SendPostManagerTests().registerTests(); + new SdkStatsNotificationCbkTests().registerTests(); // Application Insights Common tests (merged from AppInsightsCommon) new ApplicationInsightsTests().registerTests(); diff --git a/shared/AppInsightsCore/src/constants/InternalConstants.ts b/shared/AppInsightsCore/src/constants/InternalConstants.ts index 12da0a457..f2c3258ff 100644 --- a/shared/AppInsightsCore/src/constants/InternalConstants.ts +++ b/shared/AppInsightsCore/src/constants/InternalConstants.ts @@ -19,6 +19,7 @@ export const STR_PRIORITY = "priority"; export const STR_EVENTS_SENT = "eventsSent"; export const STR_EVENTS_DISCARDED = "eventsDiscarded"; export const STR_EVENTS_SEND_REQUEST = "eventsSendRequest"; +export const STR_EVENTS_RETRY = "eventsRetry"; export const STR_PERF_EVENT = "perfEvent"; export const STR_OFFLINE_STORE = "offlineEventsStored"; export const STR_OFFLINE_SENT = "offlineBatchSent"; diff --git a/shared/AppInsightsCore/src/core/AppInsightsCore.ts b/shared/AppInsightsCore/src/core/AppInsightsCore.ts index 171574e46..a10075ade 100644 --- a/shared/AppInsightsCore/src/core/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/core/AppInsightsCore.ts @@ -109,6 +109,11 @@ const defaultConfig: IConfigDefaults = objDeepFreeze({ loggingLevelConsole: eLoggingSeverity.DISABLED, diagnosticLogInterval: UNDEFINED_VALUE, traceHdrMode: eTraceHeadersMode.All, + sdkStats: cfgDfMerge({ + lang: "JavaScript", + ver: UNDEFINED_VALUE, + int: 900000 + }), traceCfg: cfgDfMerge({ generalLimits: cfgDfMerge({ attributeValueLengthLimit: undefined, diff --git a/shared/AppInsightsCore/src/core/NotificationManager.ts b/shared/AppInsightsCore/src/core/NotificationManager.ts index d2a2834a5..e8397095f 100644 --- a/shared/AppInsightsCore/src/core/NotificationManager.ts +++ b/shared/AppInsightsCore/src/core/NotificationManager.ts @@ -5,7 +5,8 @@ import { IPromise, createAllPromise, createPromise, doAwaitResponse } from "@nev import { ITimerHandler, arrForEach, arrIndexOf, objDefine, safe, scheduleTimeout } from "@nevware21/ts-utils"; import { createDynamicConfig } from "../config/DynamicConfig"; import { - STR_EVENTS_DISCARDED, STR_EVENTS_SEND_REQUEST, STR_EVENTS_SENT, STR_OFFLINE_DROP, STR_OFFLINE_SENT, STR_OFFLINE_STORE, STR_PERF_EVENT + STR_EVENTS_DISCARDED, STR_EVENTS_RETRY, STR_EVENTS_SEND_REQUEST, STR_EVENTS_SENT, STR_OFFLINE_DROP, STR_OFFLINE_SENT, STR_OFFLINE_STORE, + STR_PERF_EVENT } from "../constants/InternalConstants"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { INotificationListener } from "../interfaces/ai/INotificationListener"; @@ -147,6 +148,17 @@ export class NotificationManager implements INotificationManager { } }; + /** + * Notification for events being retried. + * @param events - The array of events that are being retried. + * @param statusCode - The HTTP status code that triggered the retry. + */ + _self.eventsRetry = (events: ITelemetryItem[], statusCode: number): void => { + _runListeners(_listeners, STR_EVENTS_RETRY, _asyncNotifications, (listener) => { + listener.eventsRetry(events, statusCode); + }); + }; + _self.offlineEventsStored = (events: ITelemetryItem[]): void => { if (events && events.length) { _runListeners(_listeners, STR_OFFLINE_STORE, _asyncNotifications, (listener) => { @@ -254,6 +266,15 @@ export class NotificationManager implements INotificationManager { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } + /** + * Notification for events being retried. + * @param events - The array of events that are being retried. + * @param statusCode - The HTTP status code that triggered the retry. + */ + eventsRetry?(events: ITelemetryItem[], statusCode: number): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + /** * [Optional] This event is sent if you have enabled perf events, they are primarily used to track internal performance testing and debugging * the event can be displayed via the debug plugin extension. diff --git a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts new file mode 100644 index 000000000..2a18d82f0 --- /dev/null +++ b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +"use strict"; + +import { ITimerHandler, objCreate, objHasOwn, scheduleTimeout } from "@nevware21/ts-utils"; +import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; +import { INotificationListener } from "../interfaces/ai/INotificationListener"; +import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; +import { MetricDataType } from "../telemetry/ai/DataTypes"; + +var FLUSH_INTERVAL = 900000; // 15 min default +var MET_SUCCESS = "Item_Success_Count"; +var MET_DROPPED = "Item_Dropped_Count"; +var MET_RETRY = "Item_Retry_Count"; +var DROP_CLIENT_EXCEPTION = "CLIENT_EXCEPTION"; + +// Guard against prototype-polluting keys +function _safeKey(key: string): boolean { + return key !== "__proto__" && key !== "constructor" && key !== "prototype"; +} + +// Map baseType to spec telemetry_type values +var _typeMap: { [key: string]: string } = { + "EventData": "CUSTOM_EVENT", + "MetricData": "CUSTOM_METRIC", + "RemoteDependencyData": "DEPENDENCY", + "ExceptionData": "EXCEPTION", + "PageviewData": "PAGE_VIEW", + "PageviewPerformanceData": "PAGE_VIEW", + "MessageData": "TRACE", + "RequestData": "REQUEST", + "AvailabilityData": "AVAILABILITY" +}; + +/** + * Configuration for SDK Stats periodic reporting. + */ +export interface ISdkStatsConfig { + /** + * SDK language identifier, e.g. "JavaScript". + * @defaultValue "JavaScript" + */ + lang?: string; + /** + * SDK version string. + */ + ver?: string; + /** + * Flush interval in ms. + * @defaultValue 900000 (15 minutes) + */ + int?: number; +} + +/** + * Extended INotificationListener interface for SDK Stats that includes flush and unload operations. + */ +export interface ISdkStatsNotifCbk extends INotificationListener { + /** + * Flush accumulated counts and emit metrics via the configured track function. + */ + flush: () => void; + /** + * Flush remaining counts and cancel the timer. + */ + unload: () => void; +} + +/** + * Creates an INotificationListener that accumulates success/dropped/retry counts and periodically + * flushes them as Item_Success_Count, Item_Dropped_Count, and Item_Retry_Count metrics via core.track(). + * Reads config.sdkStats (lang, ver, int) dynamically from core.config on each flush. + * @param core - The IAppInsightsCore instance (provides track() and config) + * @returns An INotificationListener with flush and unload methods + */ +/*#__NO_SIDE_EFFECTS__*/ +export function createSdkStatsNotifCbk(core: IAppInsightsCore): ISdkStatsNotifCbk { + var _successCounts: { [telType: string]: number } = objCreate(null); + var _droppedCounts: { [code: string]: { [telType: string]: number } } = objCreate(null); + var _retryCounts: { [code: string]: { [telType: string]: number } } = objCreate(null); + var _timer: ITimerHandler; + var _interval = (core.config.sdkStats && core.config.sdkStats.int) || FLUSH_INTERVAL; + + function _ensureTimer() { + if (!_timer) { + _timer = scheduleTimeout(_flush, _interval); + } + } + + function _getTelType(item: ITelemetryItem): string { + var bt = item.baseType; + return (bt && objHasOwn(_typeMap, bt) && _typeMap[bt]) || "CUSTOM_EVENT"; + } + + function _isSdkStatsMetric(item: ITelemetryItem): boolean { + var n = item.name; + return n === MET_SUCCESS || n === MET_DROPPED || n === MET_RETRY; + } + + function _incSuccess(items: ITelemetryItem[]) { + if (!items || !items.length) { + return; + } + var changed = false; + for (var i = 0; i < items.length; i++) { + if (!_isSdkStatsMetric(items[i])) { + var t = _getTelType(items[i]); + if (_safeKey(t)) { + _successCounts[t] = (_successCounts[t] || 0) + 1; + changed = true; + } + } + } + if (changed) { + _ensureTimer(); + } + } + + /** + * Common helper to increment a bucketed counter (dropped or retry) keyed by code and telemetry type. + */ + function _incBucketed(counters: { [code: string]: { [telType: string]: number } }, items: ITelemetryItem[], code: string) { + if (items && items.length && _safeKey(code)) { + var bucket = counters[code]; + if (!bucket) { + bucket = counters[code] = objCreate(null); + } + var changed = false; + for (var i = 0; i < items.length; i++) { + if (!_isSdkStatsMetric(items[i])) { + var t = _getTelType(items[i]); + if (_safeKey(t)) { + bucket[t] = (bucket[t] || 0) + 1; + changed = true; + } + } + } + if (changed) { + _ensureTimer(); + } + } + } + + function _createMetric(name: string, value: number, telType: string, code?: string, codePropKey?: string): ITelemetryItem { + // Re-read from core.config each flush so dynamic config changes are picked up + var statsCfg = (core.config && core.config.sdkStats) || {}; + var props: { [key: string]: any } = { + telemetry_type: telType, + language: statsCfg.lang || "JavaScript", + version: statsCfg.ver || "unknown", + computeType: "unknown" + }; + + if (code && codePropKey) { + props[codePropKey] = code; + } + + return { + name: name, + baseType: MetricDataType, + baseData: { + name: name, + average: value, + sampleCount: 1, + properties: props + } + } as ITelemetryItem; + } + + function _mapDropCode(reason: number, sendType?: number): string { + // Maps eEventsDiscardedReason to spec drop.code values + // 1 = NonRetryableStatus → actual HTTP status code when available + if (reason === 1 && sendType) { + return "" + sendType; + } + return DROP_CLIENT_EXCEPTION; + } + + /** + * Common helper to flush bucketed counters (dropped or retry). + * @param counters - The bucketed counter object + * @param metricName - The metric name to emit (e.g. MET_DROPPED or MET_RETRY) + * @param codePropKey - The property key for the code dimension (e.g. "drop.code" or "retry.code") + */ + function _flushBucketed(counters: { [code: string]: { [telType: string]: number } }, metricName: string, codePropKey: string) { + for (var code in counters) { + if (objHasOwn(counters, code)) { + var bucket = counters[code]; + for (var telType in bucket) { + if (objHasOwn(bucket, telType)) { + var cnt = bucket[telType]; + if (cnt > 0) { + core.track(_createMetric(metricName, cnt, telType, code, codePropKey)); + } + } + } + } + } + } + + function _flush() { + if (_timer) { + _timer.cancel(); + _timer = null; + } + + // Re-read interval from core.config in case it changed dynamically + var statsCfg = (core.config && core.config.sdkStats) || {}; + _interval = statsCfg.int || FLUSH_INTERVAL; + + // Flush success counts + for (var telType in _successCounts) { + if (objHasOwn(_successCounts, telType)) { + var cnt = _successCounts[telType]; + if (cnt > 0) { + core.track(_createMetric(MET_SUCCESS, cnt, telType)); + } + } + } + + // Flush dropped and retry counts via common helper + _flushBucketed(_droppedCounts, MET_DROPPED, "drop.code"); + _flushBucketed(_retryCounts, MET_RETRY, "retry.code"); + + // Reset accumulators + _successCounts = objCreate(null); + _droppedCounts = objCreate(null); + _retryCounts = objCreate(null); + } + + return { + eventsSent: _incSuccess, + eventsDiscarded: function (events: ITelemetryItem[], reason: number, sendType?: number) { + var code = _mapDropCode(reason, sendType); + _incBucketed(_droppedCounts, events, code); + }, + eventsRetry: function (events: ITelemetryItem[], statusCode: number) { + var code = "" + statusCode; // numeric status code as string per spec + _incBucketed(_retryCounts, events, code); + }, + flush: _flush, + unload: function () { + // Flush remaining counts before unload + _flush(); + } + }; +} diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index cb5abe5d9..22669987d 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -39,6 +39,7 @@ 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 { 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"; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts index c3db75b74..87ac4cd99 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import { IPromise } from "@nevware21/ts-async"; +import { ISdkStatsConfig } from "../../core/SdkStatsNotificationCbk"; import { eTraceHeadersMode } from "../../enums/ai/TraceHeadersMode"; import { IOTelConfig } from "../otel/config/IOTelConfig"; import { IAppInsightsCore } from "./IAppInsightsCore"; @@ -258,6 +259,12 @@ export interface IConfiguration extends IOTelConfig { * @defaultValue eTraceHeadersMode.All */ traceHdrMode?: eTraceHeadersMode; + + /** + * [Optional] SDK Stats configuration for periodic reporting of telemetry pipeline metrics. + * @since 3.3.12 + */ + sdkStats?: ISdkStatsConfig; } ///** diff --git a/shared/AppInsightsCore/src/interfaces/ai/INotificationListener.ts b/shared/AppInsightsCore/src/interfaces/ai/INotificationListener.ts index ef78b4b36..37f29a06a 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/INotificationListener.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/INotificationListener.ts @@ -50,6 +50,14 @@ export interface INotificationListener { */ unload?(isAsync?: boolean): void | IPromise; + /** + * [Optional] A function called when events are being retried. + * @param events - The array of events that are being retried. + * @param statusCode - The HTTP status code that triggered the retry. + * @since 3.3.12 + */ + eventsRetry?(events: ITelemetryItem[], statusCode: number): void; + /** * [Optional] A function called when the offline events have been stored to the persistent storage * @param events - items that are stored in the persistent storage diff --git a/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts b/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts index 4457d643a..361bb8066 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts @@ -63,6 +63,14 @@ export interface INotificationManager { */ unload?(isAsync?: boolean): void | IPromise; + /** + * Notification for events being retried. + * @param events - The array of events that are being retried. + * @param statusCode - The HTTP status code that triggered the retry. + * @since 3.3.12 + */ + eventsRetry?(events: ITelemetryItem[], statusCode: number): void; + /** * [Optional] A function called when the offline events have been stored to the persistent storage * @param events - items that are stored in the persistent storage