diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 51a55e209..21f08ff44 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,9 @@ const IKEY_USAGE = "iKeyUsage"; const CDN_USAGE = "CdnUsage"; const SDK_LOADER_VER = "SdkLoaderVer"; const ZIP_PAYLOAD = "zipPayload"; +const SDK_STATS = "SdkStats"; +const SDK_STATS_VERSION = "#version#"; +const SDK_STATS_FLUSH_INTERVAL = 900000; // 15 minutes in ms const default_limit = { samplingRate: 100, @@ -93,7 +97,8 @@ 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} }, throttleMgrCfg: cfgDfMerge<{[key:number]: IThrottleMgrConfig}>( { @@ -196,6 +201,7 @@ export class AppInsightsSku implements IApplicationInsights; + let _sdkStatsListener: ISdkStatsNotifCbk; dynamicProto(AppInsightsSku, this, (_self) => { _initDefaults(); @@ -390,6 +396,19 @@ export class AppInsightsSku implements IApplicationInsights { + _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/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..d9038ecc3 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/SdkStatsNotificationCbk.Tests.ts @@ -0,0 +1,628 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "../../../../src/core/SdkStatsNotificationCbk"; +import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; +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(); + } + + private _createListener(overrides?: Partial): ISdkStatsNotifCbk { + let _self = this; + let cfg: ISdkStatsConfig = { + trk: function (item: ITelemetryItem) { + _self._trackedItems.push(item); + }, + lang: "JavaScript", + ver: "3.3.6", + int: 100 // short interval for testing + }; + + if (overrides) { + for (var key in overrides) { + if (overrides.hasOwnProperty(key)) { + (cfg as any)[key] = (overrides as any)[key]; + } + } + } + + _self._listener = createSdkStatsNotifCbk(cfg); + 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({ + trk: function (item: ITelemetryItem) { + trackedItems2.push(item); + }, + lang: "JavaScript", + ver: "3.3.6", + int: 100 + }); + + 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(); + } + }); + } +} 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/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..5ba070f80 --- /dev/null +++ b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +"use strict"; + +import { ITimerHandler, objCreate, objHasOwn, scheduleTimeout } from "@nevware21/ts-utils"; +import { INotificationListener } from "../interfaces/ai/INotificationListener"; +import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; + +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 P_LANG = "language"; +var P_VER = "version"; +var P_COMPUTE = "computeType"; +var P_TEL_TYPE = "telemetry_type"; +var P_DROP_CODE = "drop.code"; +var P_RETRY_CODE = "retry.code"; +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 interface for the SDK Stats notification callback. + */ +export interface ISdkStatsConfig { + /** + * The track function to call when flushing metrics. Typically core.track(). + */ + trk: (item: ITelemetryItem) => void; + /** + * SDK language identifier, e.g. "JavaScript" + */ + lang: string; + /** + * SDK version string. + */ + ver: string; + /** + * Flush interval override in ms (default 900000 = 15 min). + */ + 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(). + * @param cfg - The SDK stats configuration + * @returns An INotificationListener with flush and unload methods + */ +/*#__NO_SIDE_EFFECTS__*/ +export function createSdkStatsNotifCbk(cfg: ISdkStatsConfig): 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 = cfg.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[]) { + 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; + } + } + } + _ensureTimer(); + } + + function _incDropped(items: ITelemetryItem[], code: string) { + if (!_safeKey(code)) { + return; + } + var bucket: { [telType: string]: number }; + if (objHasOwn(_droppedCounts, code)) { + bucket = _droppedCounts[code]; + } else { + bucket = objCreate(null); + _droppedCounts[code] = bucket; + } + 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; + } + } + } + _ensureTimer(); + } + + function _incRetry(items: ITelemetryItem[], code: string) { + if (!_safeKey(code)) { + return; + } + var bucket: { [telType: string]: number }; + if (objHasOwn(_retryCounts, code)) { + bucket = _retryCounts[code]; + } else { + bucket = objCreate(null); + _retryCounts[code] = bucket; + } + 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; + } + } + } + _ensureTimer(); + } + + function _createMetric(name: string, value: number, props: { [key: string]: any }): ITelemetryItem { + // Merge standard dimensions + props[P_LANG] = cfg.lang; + props[P_VER] = cfg.ver; + props[P_COMPUTE] = "unknown"; // Browser SDK cannot reliably detect compute type + + return { + name: name, + baseType: "MetricData", + 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; + } + + function _flush() { + if (_timer) { + _timer.cancel(); + _timer = null; + } + + var telType: string; + var code: string; + var cnt: number; + var bucket: { [telType: string]: number }; + + // Flush success counts + for (telType in _successCounts) { + if (objHasOwn(_successCounts, telType)) { + cnt = _successCounts[telType]; + if (cnt > 0) { + var successProps: { [key: string]: any } = {}; + successProps[P_TEL_TYPE] = telType; + cfg.trk(_createMetric(MET_SUCCESS, cnt, successProps)); + } + } + } + + // Flush dropped counts + for (code in _droppedCounts) { + if (objHasOwn(_droppedCounts, code)) { + bucket = _droppedCounts[code]; + for (telType in bucket) { + if (objHasOwn(bucket, telType)) { + cnt = bucket[telType]; + if (cnt > 0) { + var dropProps: { [key: string]: any } = {}; + dropProps[P_TEL_TYPE] = telType; + dropProps[P_DROP_CODE] = code; + cfg.trk(_createMetric(MET_DROPPED, cnt, dropProps)); + } + } + } + } + } + + // Flush retry counts + for (code in _retryCounts) { + if (objHasOwn(_retryCounts, code)) { + bucket = _retryCounts[code]; + for (telType in bucket) { + if (objHasOwn(bucket, telType)) { + cnt = bucket[telType]; + if (cnt > 0) { + var retryProps: { [key: string]: any } = {}; + retryProps[P_TEL_TYPE] = telType; + retryProps[P_RETRY_CODE] = code; + cfg.trk(_createMetric(MET_RETRY, cnt, retryProps)); + } + } + } + } + } + + // 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); + _incDropped(events, code); + }, + eventsRetry: function (events: ITelemetryItem[], statusCode: number) { + var code = "" + statusCode; // numeric status code as string per spec + _incRetry(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/INotificationListener.ts b/shared/AppInsightsCore/src/interfaces/ai/INotificationListener.ts index ef78b4b36..d14428e81 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.6 + */ + 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..7a1d67af9 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.6 + */ + 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