diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index 46a301f59..f584d55a9 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -54,8 +54,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 182; - private readonly MAX_BUNDLE_SIZE = 183; + private readonly MAX_RAW_SIZE = 183; + private readonly MAX_BUNDLE_SIZE = 184; private readonly MAX_RAW_DEFLATE_SIZE = 74; private readonly MAX_BUNDLE_DEFLATE_SIZE = 74; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; diff --git a/RELEASES.md b/RELEASES.md index cd9e6133c..5080accd3 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -14,6 +14,7 @@ legacy key remains supported for customer compatibility and uses the same endpoint safety checks. - Payload headers are isolated per request so a compressed asynchronous send cannot leak `Content-Encoding: gzip` into a later uncompressed synchronous, unload, beacon, or fallback request. +- Skip channel telemetry reconstruction when the notification manager confirms there are no consumers for the relevant sent, discarded, or retry notification. Add the optional `hasListener(name: string)` hint to the core notification manager. Custom managers without this hint and overridden built-in notification handlers retain the existing dispatch behavior. Notifications with matching listeners, including SDK Stats, retain the existing reconstruction behavior. ## 3.4.5 (October 5th, 2026) diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts index 78bd8dd09..7ccde6967 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -1,9 +1,9 @@ import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; import { - AppInsightsCore, createStatsMgr, FeatureOptInMode, IAppInsightsCore, IConfiguration, IInternalSdkStatsState, + AppInsightsCore, createSdkStatsNotifCbk, createStatsMgr, FeatureOptInMode, IAppInsightsCore, IConfiguration, IInternalSdkStatsState, isBeaconsSupported, IStatsMgr, ITelemetryItem, TransportType } from "@microsoft/applicationinsights-core-js"; -import { objDefine } from "@nevware21/ts-utils"; +import { arrForEach, objDefine } from "@nevware21/ts-utils"; import { SinonSpy } from "sinon"; import { Sender } from "../../../src/Sender"; import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; @@ -281,6 +281,39 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats counts network responses independently of notification listeners", + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: false, + test: () => { + const config = this.createSenderConfig(TransportType.Xhr); + const { core, sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + const checkResponses = () => { + arrForEach([200, 400, 503], (status) => { + sender._buffer.clear(); + this.internalSdkStatsCountSpy.resetHistory(); + this.processTelemetryAndFlush(sender, { + name: "customer event", baseType: "EventData", baseData: { name: "customer event" } + }); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, status); + this.clock.tick(1); + this.assertInternalSdkStatsCall(status); + }); + }; + + QUnit.assert.equal(core.getNotifyMgr().listeners.length, 0, "Start without notification listeners"); + checkResponses(); + core.addNotificationListener({ perfEvent: () => {} }); + checkResponses(); + + core.config.extensionConfig[sender.identifier].enableSessionStorageBuffer = false; + this.clock.tick(1); + core.addNotificationListener(createSdkStatsNotifCbk(core, "test")); + checkResponses(); + } + }); + this.testCase({ name: "SDK Stats increments success count for xhr sender", useFakeTimers: true, diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts index 3633f28fe..bc828954f 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts @@ -6,14 +6,14 @@ import { Exception, CtxTagKeys, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, utlCanUseSessionStorage, utlGetSessionStorage, utlSetSessionStorage, ITelemetryItem, AppInsightsCore, ITelemetryPlugin, DiagnosticLogger, NotificationManager, SendRequestReason, _eInternalMessageId, safeGetLogger, isString, isArray, arrForEach, isBeaconsSupported, isFeatureEnabled, IXHROverride, IPayloadData,TransportType, getWindow, ActiveStatus, - FeatureOptInMode + FeatureOptInMode, INotificationListener, createSdkStatsNotifCbk } from "@microsoft/applicationinsights-core-js"; import { ArraySendBuffer, SessionStorageSendBuffer } from "../../../src/SendBuffer"; import { STR_ZIP_PAYLOAD, STR_ZIP_PAYLOAD_V2 } from "../../../src/Constants"; import { isCompressionEndpoint } from "../../../src/EndpointUtils"; import { IInternalStorageItem, ISenderConfig } from "../../../src/Interfaces"; import { createAsyncResolvedPromise, createPromise } from "@nevware21/ts-async"; -import { isPromiseLike, isUndefined } from "@nevware21/ts-utils"; +import { isPromiseLike, isUndefined, objDefine } from "@nevware21/ts-utils"; import { SinonSpy } from 'sinon'; @@ -4221,6 +4221,424 @@ export class SenderTests extends AITestClass { } }); + arrForEach([ + { name: "eventsSent", status: 200 }, + { name: "eventsDiscarded", status: 400 }, + { name: "eventsRetry", status: 503 } + ], (notification) => { + this.testCase({ + name: "Channel notifications: only extract items for " + notification.name + " listeners", + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: false, + test: () => { + const core = new AppInsightsCore(); + const sender = this._sender; + const senderConfig: Partial = { + enableSessionStorageBuffer: false, + transports: [TransportType.Xhr] + }; + core.initialize({ + instrumentationKey: "000e0000-e000-0000-a000-000000000000", + extensionConfig: { [sender.identifier]: senderConfig } + }, [sender]); + this.onDone(() => { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + }); + + let metadataReads = 0; + const items: IInternalStorageItem[] = [{ item: "{}", cnt: 0 }, { item: "{}", cnt: 0 }]; + arrForEach(items, (item, index) => { + // These fields are read by extraction, not by the in-memory send/retry path. + objDefine(item, "bT", { g: () => { + metadataReads++; + return "EventData"; + } }); + objDefine(item, "iN", { g: () => { + metadataReads++; + return "event" + index; + } }); + }); + const send = () => { + sender._buffer.clear(); + arrForEach(items, (item) => sender._buffer.enqueue(item)); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, notification.status); + this.clock.tick(1); + }; + + send(); + QUnit.assert.equal(metadataReads, 0, "No extraction without listeners"); + + const unrelatedListener: INotificationListener = { + eventsSendRequest: this.sandbox.spy(), + perfEvent: this.sandbox.spy(), + offlineEventsStored: this.sandbox.spy() + }; + arrForEach(["eventsSent", "eventsDiscarded", "eventsRetry"], (name) => { + if (name !== notification.name) { + unrelatedListener[name] = this.sandbox.spy(); + } + }); + core.addNotificationListener(unrelatedListener); + send(); + QUnit.assert.equal(metadataReads, 0, "Unrelated listeners do not reconstruct either item"); + + const notifySpy = this.sandbox.spy(); + const listener: INotificationListener = {}; + // Add the listener after the items have already been buffered. + sender._buffer.clear(); + arrForEach(items, (item) => sender._buffer.enqueue(item)); + listener[notification.name] = notifySpy; + core.addNotificationListener(listener); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, notification.status); + QUnit.assert.equal(notifySpy.callCount, 0, "Notification remains asynchronous"); + this.clock.tick(1); + QUnit.assert.equal(metadataReads, 4, "Read each item's name and baseType exactly once"); + QUnit.assert.equal(notifySpy.callCount, 1, "New listener receives the notification"); + QUnit.assert.deepEqual(notifySpy.firstCall.args[0], [ + { name: "event0", baseType: "EventData" }, + { name: "event1", baseType: "EventData" } + ], "Keep the minimal notification shape and original names"); + if (notification.name === "eventsDiscarded") { + QUnit.assert.equal(notifySpy.firstCall.args[1], 1, "Keep NonRetryableStatus reason"); + QUnit.assert.equal(notifySpy.firstCall.args[2], 400, "Keep discard status"); + } else if (notification.name === "eventsRetry") { + QUnit.assert.equal(notifySpy.firstCall.args[1], 503, "Keep retry status"); + QUnit.assert.equal(items[0].cnt, 3, "Retries still increment the buffered count"); + QUnit.assert.equal(sender._buffer.count(), 2, "Retries retain both items"); + } + + delete listener[notification.name]; + send(); + QUnit.assert.equal(metadataReads, 4, "Removing the callback stops extraction immediately"); + listener[notification.name] = notifySpy; + send(); + QUnit.assert.equal(metadataReads, 8, "Restoring the callback enables extraction immediately"); + QUnit.assert.equal(notifySpy.callCount, 2, "Restored callback receives the notification"); + core.removeNotificationListener(listener); + send(); + QUnit.assert.equal(metadataReads, 8, "Removing the listener stops extraction"); + } + }); + }); + + arrForEach([ + { status: 200, metric: "Item_Success_Count" }, + { status: 400, metric: "Item_Dropped_Count" }, + { status: 503, metric: "Item_Retry_Count" } + ], (notification) => { + this.testCase({ + name: "Channel notifications: sole SDK Stats listener counts status " + notification.status, + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: false, + test: () => { + const core = new AppInsightsCore(); + const sender = this._sender; + core.initialize({ + instrumentationKey: "000e0000-e000-0000-a000-000000000000", + extensionConfig: { + [sender.identifier]: { + enableSessionStorageBuffer: false, + transports: [TransportType.Xhr] + } + } + }, [sender]); + this.onDone(() => { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + }); + + const statsName = "Microsoft.ApplicationInsights.000e0000e0000000a000000000000000.SdkStats"; + const stats = createSdkStatsNotifCbk(core, "test"); + const trackSpy = this.sandbox.spy(core, "track"); + core.addNotificationListener(stats); + QUnit.assert.deepEqual(core.getNotifyMgr().listeners, [stats], "SDK Stats is the only listener"); + + const sendBatch = () => { + sender._buffer.clear(); + arrForEach(["first", "second"], (name) => { + sender.processTelemetry({ name, baseType: "EventData", baseData: { name } }); + }); + sender.processTelemetry({ + name: "trace", baseType: "MessageData", baseData: { message: "trace" } + }); + sender.processTelemetry({ + name: statsName, baseType: "MetricData", baseData: { name: "Item_Success_Count", average: 1 } + }); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, notification.status); + stats.flush(); + QUnit.assert.equal(trackSpy.callCount, 0, "Stats are not delivered synchronously"); + this.clock.tick(1); + + if (notification.status === 503) { + QUnit.assert.equal(sender._buffer.count(), 4, "Retry retains customer and stats telemetry"); + arrForEach(sender._buffer.getItems(), (item) => { + QUnit.assert.equal(item.cnt, 1, "Each buffered item is retried once"); + }); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, 200); + this.clock.tick(1); + } + QUnit.assert.equal(sender._buffer.count(), 0, "Completed items leave the buffer"); + stats.flush(); + }; + + const assertCounts = () => { + const actual: { metric: string, type: string, count: number, code: string }[] = []; + arrForEach(trackSpy.getCalls(), (call) => { + const item: ITelemetryItem = call.args[0]; + const data = item.baseData; + QUnit.assert.equal(item.name, statsName, "Stats preserve their self-filtering name"); + QUnit.assert.equal(item.baseType, "MetricData", "Stats retain their telemetry shape"); + actual.push({ + metric: data.name, + type: data.properties.telemetryType, + count: data.average, + code: data.properties.dropCode || data.properties.retryCode || "" + }); + }); + const code = notification.status === 200 ? "" : "" + notification.status; + const expected = [ + { metric: notification.metric, type: "CUSTOM_EVENT", count: 2, code }, + { metric: notification.metric, type: "TRACE", count: 1, code } + ]; + if (notification.status === 503) { + expected.unshift( + { metric: "Item_Success_Count", type: "CUSTOM_EVENT", count: 2, code: "" }, + { metric: "Item_Success_Count", type: "TRACE", count: 1, code: "" } + ); + } + QUnit.assert.deepEqual(actual, expected, "Count ordinary items exactly once and exclude stats themselves"); + stats.flush(); + QUnit.assert.equal(trackSpy.callCount, expected.length, "Flushing again does not duplicate counts"); + trackSpy.resetHistory(); + }; + + sendBatch(); + assertCounts(); + + core.removeNotificationListener(stats); + sendBatch(); + QUnit.assert.equal(trackSpy.callCount, 0, "Removed SDK Stats listener receives no counts"); + + core.config.extensionConfig[sender.identifier].enableSessionStorageBuffer = true; + this.clock.tick(1); + QUnit.assert.ok(sender._buffer instanceof SessionStorageSendBuffer, "Switch buffer configuration dynamically"); + core.addNotificationListener(stats); + sendBatch(); + assertCounts(); + } + }); + }); + + arrForEach([ + { name: "eventsSent", status: 200 }, + { name: "eventsDiscarded", status: 400 }, + { name: "eventsRetry", status: 503 } + ], (notification) => { + this.testCase({ + name: "Channel notifications: custom manager still receives " + notification.name, + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: false, + test: () => { + const core = new AppInsightsCore(); + const sender = this._sender; + const manager = new NotificationManager(); + const notifySpy = this.sandbox.spy(); + // A custom manager may observe notifications independently of its registered listeners. + manager[notification.name] = notifySpy; + manager.addNotificationListener({ perfEvent: () => {} }); + core.initialize({ + instrumentationKey: "000e0000-e000-0000-a000-000000000000", + disableDbgExt: true, + extensionConfig: { + [sender.identifier]: { + enableSessionStorageBuffer: false, + transports: [TransportType.Xhr] + } + } + }, [sender], undefined, manager); + QUnit.assert.equal(manager.listeners.length, 1, "Only the unrelated listener is registered"); + this.onDone(() => { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + }); + const checkNotification = () => { + sender._buffer.clear(); + notifySpy.resetHistory(); + sender.processTelemetry({ name: "event", baseType: "EventData", baseData: { name: "event" } }); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, notification.status); + QUnit.assert.equal(notifySpy.callCount, 1, "Preserve calls to a caller-supplied notification manager"); + if (notifySpy.called) { + QUnit.assert.deepEqual(notifySpy.firstCall.args[0], [{ name: "event", baseType: "EventData" }]); + } + }; + checkNotification(); + core.config.extensionConfig[sender.identifier].enableSessionStorageBuffer = true; + this.clock.tick(1); + QUnit.assert.ok(sender._buffer instanceof SessionStorageSendBuffer, "Switch buffer configuration dynamically"); + checkNotification(); + manager.hasListener = undefined; + checkNotification(); + manager.hasListener = () => undefined; + checkNotification(); + } + }); + }); + + this.testCase({ + name: "Channel notifications: retries preserve metadata and isolate listener mutations", + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: false, + test: () => { + const core = new AppInsightsCore(); + const sender = this._sender; + const senderConfig: Partial = { + enableSessionStorageBuffer: false, + transports: [TransportType.Xhr] + }; + core.initialize({ + instrumentationKey: "000e0000-e000-0000-a000-000000000000", + extensionConfig: { [sender.identifier]: senderConfig } + }, [sender]); + this.onDone(() => { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + }); + + const name = "Microsoft.ApplicationInsights.000e0000e0000000a000000000000000.SdkStats"; + const original: ITelemetryItem = { + name, + baseType: "MetricData", + baseData: { name: "Item_Success_Count", average: 1 } + }; + sender.processTelemetry(original); + const stored = sender._buffer.getItems()[0]; + original.name = "changed after buffering"; + original.baseType = "EventData"; + let retryItems: ITelemetryItem[]; + const successSpy = this.sandbox.spy(); + const stats = createSdkStatsNotifCbk(core, "test"); + core.addNotificationListener(stats); + core.addNotificationListener({ + eventsRetry: (events) => { + retryItems = events; + events[0].name = "changed by listener"; + events[0].baseType = "EventData"; + events.push({ name: "added by listener" }); + }, + eventsSent: successSpy + }); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, 503); + this.clock.tick(1); + QUnit.assert.equal(stored.cnt, 1, "Item is buffered for retry"); + QUnit.assert.equal(stored.iN, name, "Listener mutation does not change the stored name"); + QUnit.assert.equal(stored.bT, "MetricData", "Listener mutation does not change the stored type"); + + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, 200); + this.clock.tick(1); + const sent = successSpy.firstCall.args[0]; + QUnit.assert.deepEqual(sent, [{ name, baseType: "MetricData" }], "Retry success gets original metadata"); + QUnit.assert.notStrictEqual(sent, retryItems, "Each notification has a fresh array"); + QUnit.assert.notStrictEqual(sent[0], retryItems[0], "Each notification has a fresh item"); + const trackSpy = this.sandbox.spy(core, "track"); + stats.flush(); + QUnit.assert.equal(trackSpy.callCount, 0, "SDK Stats still self-filters retry and success notifications"); + } + }); + + this.testCase({ + name: "Channel notifications: restored session items preserve partial success and legacy defaults", + useFakeTimers: true, + useFakeServer: true, + fakeServerAutoRespond: false, + test: () => { + const core = new AppInsightsCore(); + const sender = this._sender; + const stored: IInternalStorageItem[] = [ + { item: "{\"event\":0}", cnt: 0, bT: "MetricData", iN: "original metric" }, + { item: "{\"event\":1}", cnt: 0 }, + { item: "{\"event\":2}", cnt: 1, bT: "MessageData" } + ]; + const storage: { [key: string]: string } = { + [BUFFER_KEY]: JSON.stringify(stored.slice(0, 2)), + [SENT_BUFFER_KEY]: JSON.stringify(stored.slice(2)) + }; + const senderConfig: Partial = { + bufferOverride: { + getItem: (logger, key) => storage[key], + setItem: (logger, key, value) => { + storage[key] = value; + return true; + } + }, + transports: [TransportType.Xhr] + }; + core.initialize({ + instrumentationKey: "000e0000-e000-0000-a000-000000000000", + extensionConfig: { [sender.identifier]: senderConfig } + }, [sender]); + this.onDone(() => { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + }); + QUnit.assert.deepEqual(sender._buffer.getItems(), stored, "Restore current and older session items"); + const sentSpy = this.sandbox.spy(); + const discardedSpy = this.sandbox.spy(); + const retrySpy = this.sandbox.spy(); + core.addNotificationListener({ + eventsSent: sentSpy, + eventsDiscarded: discardedSpy, + eventsRetry: retrySpy + }); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], { + itemsReceived: 3, + itemsAccepted: 1, + errors: [ + { index: 1, statusCode: 400, message: "rejected" }, + { index: 2, statusCode: 503, message: "retry" } + ] + }, 206); + this.clock.tick(1); + QUnit.assert.deepEqual(sentSpy.firstCall.args[0], + [{ name: "original metric", baseType: "MetricData" }], "Only accepted item is sent"); + QUnit.assert.deepEqual(discardedSpy.firstCall.args[0], + [{ name: "EventData", baseType: "EventData" }], "Legacy item keeps the default name and type"); + QUnit.assert.equal(retrySpy.callCount, 0, "Partial success does not add new retry notifications"); + const retried = [{ item: "{\"event\":2}", cnt: 2, bT: "MessageData" }]; + QUnit.assert.deepEqual(sender._buffer.getItems(), retried, "Only retriable item remains buffered"); + QUnit.assert.deepEqual(JSON.parse(storage[BUFFER_KEY]), retried, "Persist only existing storage fields"); + QUnit.assert.deepEqual(JSON.parse(storage[SENT_BUFFER_KEY]), [], "Clear completed sent items"); + + core.config.extensionConfig[sender.identifier].enableSessionStorageBuffer = false; + core.config.extensionConfig[sender.identifier].bufferOverride = false; + this.clock.tick(1); + QUnit.assert.ok(sender._buffer instanceof ArraySendBuffer, "Support changing buffer type at runtime"); + QUnit.assert.deepEqual(sender._buffer.getItems(), retried, "Buffer transition preserves retry metadata"); + sender.flush(); + this.sendJsonResponse(this._getXhrRequests().slice(-1)[0], {}, 200); + this.clock.tick(1); + QUnit.assert.deepEqual(sentSpy.secondCall.args[0], + [{ name: "MessageData", baseType: "MessageData" }], "Older stored item falls back to its baseType"); + } + }); + this.testCase({ name: "Channel Config: Notification is sent when requests are being sent when requests exceed max batch size", useFakeTimers: true, diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 6daf29029..1e65f7328 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -833,9 +833,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _self._buffer && _self._buffer.clearSent(payload); - // Notify listeners of discarded events (skip extraction entirely when no listeners are registered) + // Only the manager can determine whether its notification has consumers. let mgr = _getNotifyMgr(); - if (mgr && mgr.listeners && mgr.listeners.length) { + if (mgr && mgr.listeners && mgr.listeners.length && + (!mgr.hasListener || mgr.hasListener("eventsDiscarded") !== false)) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsDiscarded(items, 1 /* NonRetryableStatus */, statusCode); @@ -887,9 +888,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _onSuccess(payload: IInternalStorageItem[], countOfItemsInPayload: number) { _self._buffer && _self._buffer.clearSent(payload); - // Notify listeners of successful send (skip extraction entirely when no listeners are registered) + // Only the manager can determine whether its notification has consumers. let mgr = _getNotifyMgr(); - if (mgr && mgr.listeners && mgr.listeners.length) { + if (mgr && mgr.listeners && mgr.listeners.length && + (!mgr.hasListener || mgr.hasListener("eventsSent") !== false)) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsSent(items); @@ -1460,7 +1462,8 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { */ function _notifyRetry(payload: IInternalStorageItem[], statusCode: number) { let mgr = _getNotifyMgr(); - if (mgr && mgr.eventsRetry && mgr.listeners && mgr.listeners.length) { + if (mgr && mgr.eventsRetry && mgr.listeners && mgr.listeners.length && + (!mgr.hasListener || mgr.hasListener("eventsRetry") !== false)) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsRetry(items, statusCode); diff --git a/common/config/rush/pnpm-config.json b/common/config/rush/pnpm-config.json index 24b9203c3..fc519507b 100644 --- a/common/config/rush/pnpm-config.json +++ b/common/config/rush/pnpm-config.json @@ -11,7 +11,7 @@ "form-data": ">=2.5.6 <3.0.0", "js-yaml": ">=4.3.1 <5.0.0", "markdown-it": ">=14.2.0", - "decode-uri-component": ">=0.5.0", + "decode-uri-component": "0.5.0", "@microsoft/api-extractor": "7.58.7", "basic-ftp": ">=6.2.1", "brace-expansion": ">=5.0.12 <6.0.0", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index bd02029ef..c50266506 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -15,7 +15,7 @@ overrides: form-data: '>=2.5.6 <3.0.0' js-yaml: '>=4.3.1 <5.0.0' markdown-it: '>=14.2.0' - decode-uri-component: '>=0.5.0' + decode-uri-component: 0.5.0 '@microsoft/api-extractor': 7.58.7 basic-ftp: '>=6.2.1' brace-expansion: '>=5.0.12 <6.0.0' diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index 1106d6f25..70f03bb31 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,7 +51,7 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 138; + private readonly MAX_RAW_SIZE = 139; private readonly MAX_BUNDLE_SIZE = 139; private readonly MAX_RAW_DEFLATE_SIZE = 56; private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/NotificationManager.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/NotificationManager.Tests.ts new file mode 100644 index 000000000..e9193025a --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/NotificationManager.Tests.ts @@ -0,0 +1,158 @@ +import dynamicProto from "@microsoft/dynamicproto-js"; +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { arrForEach, objCreate } from "@nevware21/ts-utils"; +import { NotificationManager } from "../../../../src/core/NotificationManager"; +import { INotificationListener } from "../../../../src/interfaces/ai/INotificationListener"; +import { INotificationManager } from "../../../../src/interfaces/ai/INotificationManager"; +import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; + +class CustomNotificationManager extends NotificationManager { + constructor(callback: (events: ITelemetryItem[]) => void) { + super(); + dynamicProto(CustomNotificationManager, this, (_self, _base) => { + _self.eventsSent = (events) => { + callback(events); + _base.eventsSent(events); + }; + }); + } +} + +export class NotificationManagerTests extends AITestClass { + public registerTests() { + const notifications: Array<"eventsSent" | "eventsDiscarded" | "eventsRetry"> = [ + "eventsSent", "eventsDiscarded", "eventsRetry" + ]; + arrForEach(notifications, (name) => { + this.testCase({ + name: "NotificationManager: hasListener tracks listeners and instance overrides for " + name, + useFakeTimers: true, + test: () => { + const manager = new NotificationManager(); + this.onDone(() => manager.unload(false)); + Assert.equal(false, manager.hasListener(name), "No consumers before the first dispatch"); + const unrelated: INotificationListener = { perfEvent: () => {} }; + arrForEach(notifications, (other) => { + if (other !== name) { + unrelated[other] = this.sandbox.spy(); + } + }); + manager.addNotificationListener(unrelated); + Assert.equal(false, manager.hasListener(name), "Other notification callbacks are not consumers"); + + const callback = this.sandbox.spy(); + const listener: INotificationListener = {}; + listener[name] = callback; + manager.addNotificationListener(listener); + Assert.equal(true, manager.hasListener(name), "A matching callback is a consumer"); + Assert.equal(0, callback.callCount, "Querying does not invoke the callback"); + manager[name].call(manager, [], 1, 400); + Assert.equal(0, callback.callCount, "Dispatch remains asynchronous"); + this.clock.tick(1); + Assert.equal(1, callback.callCount, "Dispatch reaches the matching listener"); + Assert.equal(true, manager.hasListener(name), "Recognize the handler after DynamicProto caches it"); + + delete listener[name]; + Assert.equal(false, manager.hasListener(name), "Deleting a callback takes effect immediately"); + listener[name] = callback; + Assert.equal(true, manager.hasListener(name), "Restoring a callback takes effect immediately"); + manager.removeNotificationListener(listener); + Assert.equal(false, manager.hasListener(name), "Removing the listener stops notification work"); + + const override = this.sandbox.stub(manager, name); + Assert.equal(true, manager.hasListener(name), "An instance override may handle notifications directly"); + override.restore(); + Assert.equal(false, manager.hasListener(name), "Restoring the built-in handler restores the optimization"); + } + }); + + this.testCase({ + name: "NotificationManager: hasListener preserves prototype overrides for " + name, + test: () => { + const manager = new NotificationManager(); + this.onDone(() => manager.unload(false)); + Assert.equal(false, manager.hasListener(name), "Unmodified manager has no consumers"); + const override = this.sandbox.stub(NotificationManager.prototype, name); + Assert.equal(true, manager.hasListener(name), "A prototype override may handle notifications directly"); + override.restore(); + Assert.equal(false, manager.hasListener(name), "Restoring the prototype restores the optimization"); + } + }); + }); + + arrForEach([ + "eventsSendRequest", "perfEvent", "offlineEventsStored", "offlineBatchSent", "offlineBatchDrop", "unload", + "futureNotification" + ], (name) => { + this.testCase({ + name: "NotificationManager: hasListener accepts other notification names: " + name, + test: () => { + const manager = new NotificationManager(); + this.onDone(() => manager.unload(false)); + Assert.equal(false, manager.hasListener(name), "No matching consumers"); + const listener: INotificationListener & { futureNotification?: () => void } = {}; + listener[name] = this.sandbox.spy(); + manager.addNotificationListener(listener); + Assert.equal(true, manager.hasListener(name), "A matching callback is a consumer"); + Assert.equal(0, listener[name].callCount, "Querying does not dispatch the notification"); + delete listener[name]; + Assert.equal(false, manager.hasListener(name), "Deleting the callback takes effect immediately"); + listener[name] = this.sandbox.spy(); + manager.removeNotificationListener(listener); + Assert.equal(false, manager.hasListener(name), "Removing the listener stops notification work"); + } + }); + }); + + this.testCase({ + name: "NotificationManager: optional hint preserves interface-to-class compatibility", + test: () => { + const interfaceManager: INotificationManager = new NotificationManager(); + const manager: NotificationManager = interfaceManager; + this.onDone(() => manager.unload(false)); + Assert.equal(false, manager.hasListener("eventsSent"), "The built-in manager implements the optional hint"); + manager.hasListener = undefined; + const legacyManager: INotificationManager = manager; + Assert.equal(undefined, legacyManager.hasListener, "Managers without the hint remain supported"); + } + }); + + this.testCase({ + name: "NotificationManager: inherited hints do not suppress wrapper notifications", + test: () => { + const manager = new NotificationManager(); + const wrapper: NotificationManager = objCreate(manager); + this.onDone(() => manager.unload(false)); + wrapper.eventsSent = this.sandbox.spy(); + Assert.equal(false, manager.hasListener("eventsSent"), "The original manager has no consumers"); + Assert.equal(true, wrapper.hasListener("eventsSent"), "An inherited hint cannot describe a wrapper's consumers"); + } + }); + + this.testCase({ + name: "NotificationManager: hasListener preserves subclass overrides", + useFakeTimers: true, + test: () => { + const callback = this.sandbox.spy(); + const manager = new CustomNotificationManager(callback); + const other = new NotificationManager(); + this.onDone(() => { + manager.unload(false); + other.unload(false); + }); + Assert.equal(true, manager.hasListener("eventsSent"), "A subclass can consume events without listeners"); + Assert.equal(false, other.hasListener("eventsSent"), "Other instances still skip unused notifications"); + manager.eventsSent([]); + Assert.equal(1, callback.callCount, "The subclass handles the notification"); + Assert.equal(true, manager.hasListener("eventsSent"), "The cached subclass implementation remains a consumer"); + + const listener = { eventsSent: this.sandbox.spy() }; + manager.addNotificationListener(listener); + manager.eventsSent([]); + this.clock.tick(1); + Assert.equal(2, callback.callCount, "The subclass still handles the notification"); + Assert.equal(1, listener.eventsSent.callCount, "Delegating to the base preserves listener delivery"); + } + }); + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index e476342e7..7a4d569d8 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -32,6 +32,7 @@ import { ThrottleMgrTest } from "./ai/ThrottleMgr.tests"; import { UtilTests } from "./ai/Util.tests"; import { W3CTraceStateModesTests } from "./trace/W3CTraceStateModes.tests"; import { SdkStatsNotificationCbkTests } from "./ai/SdkStatsNotificationCbk.Tests"; +import { NotificationManagerTests } from "./ai/NotificationManager.Tests"; // 1ds core tests import { CoreTest } from './ext/CoreTest'; @@ -68,6 +69,7 @@ export function runTests() { new InternalSdkStatsTests(true).registerTests(); new SendPostManagerTests().registerTests(); new SdkStatsNotificationCbkTests().registerTests(); + new NotificationManagerTests().registerTests(); // Application Insights Common tests (merged from AppInsightsCommon) new ApplicationInsightsTests().registerTests(); diff --git a/shared/AppInsightsCore/src/core/NotificationManager.ts b/shared/AppInsightsCore/src/core/NotificationManager.ts index bf0d55451..39a25f832 100644 --- a/shared/AppInsightsCore/src/core/NotificationManager.ts +++ b/shared/AppInsightsCore/src/core/NotificationManager.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import dynamicProto from "@microsoft/dynamicproto-js"; import { IPromise, createAllPromise, createPromise, doAwaitResponse } from "@nevware21/ts-async"; -import { ITimerHandler, arrForEach, arrIndexOf, objDefine, safe, scheduleTimeout } from "@nevware21/ts-utils"; +import { ITimerHandler, arrForEach, arrIndexOf, arrSome, objDefine, safe, scheduleTimeout } from "@nevware21/ts-utils"; import { createDynamicConfig } from "../config/DynamicConfig"; import { STR_EVENTS_DISCARDED, STR_EVENTS_RETRY, STR_EVENTS_SEND_REQUEST, STR_EVENTS_SENT, STR_OFFLINE_DROP, STR_OFFLINE_SENT, STR_OFFLINE_STORE, @@ -25,6 +25,20 @@ interface IAsyncNotifications { cb: Array<{ fn: (listener: INotificationListener) => void, arg: INotificationListener }> } +function _getNotificationCallbacks(manager: INotificationManager): INotificationListener { + return { + eventsSent: manager.eventsSent, + eventsDiscarded: manager.eventsDiscarded, + eventsRetry: manager.eventsRetry, + eventsSendRequest: manager.eventsSendRequest, + perfEvent: manager.perfEvent, + offlineEventsStored: manager.offlineEventsStored, + offlineBatchSent: manager.offlineBatchSent, + offlineBatchDrop: manager.offlineBatchDrop, + unload: manager.unload + }; +} + function _runScheduledListeners(asyncNotifications: IAsyncNotifications) { asyncNotifications.h = null; let callbacks = asyncNotifications.cb; @@ -65,6 +79,8 @@ export class NotificationManager implements INotificationManager { constructor(config?: IConfiguration) { let perfEvtsSendAll: boolean; let unloadHandler: IUnloadHook; + let _notificationCallbacks: INotificationListener; + let _notificationProxies: INotificationListener; let _listeners: INotificationListener[] = []; let _asyncNotifications: IAsyncNotifications = { h: null, @@ -159,6 +175,14 @@ export class NotificationManager implements INotificationManager { }); }; + _self.hasListener = function (name: string) { + let callback = _self[name]; + // Only this instance's built-in handlers can use the listener-only fast path. + // DynamicProto may expose either the prototype proxy or the instance implementation. + return this !== _self || (callback !== _notificationCallbacks[name] && callback !== _notificationProxies[name]) || + arrSome(_listeners, (listener) => !!(listener && listener[name])); + }; + _self.offlineEventsStored = (events: ITelemetryItem[]): void => { if (events && events.length) { _runListeners(_listeners, STR_OFFLINE_STORE, _asyncNotifications, (listener) => { @@ -220,9 +244,23 @@ export class NotificationManager implements INotificationManager { _finishUnload(); } }; + + _notificationCallbacks = _getNotificationCallbacks(_self); }); + + _notificationProxies = _getNotificationCallbacks(NotificationManager.prototype); } + /** + * Indicates whether the named notification has a matching listener or an overridden handler. + * @param name - The notification method to query. + * @returns True for a matching listener or custom handler; false when the notification has no consumers. + */ + hasListener?(name: string): boolean { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + /** * Adds a notification listener. * @param listener - The notification listener to be added. diff --git a/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts b/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts index 02aec6a56..a6870669b 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/INotificationManager.ts @@ -12,6 +12,18 @@ import { IPayloadData } from "./IXHROverride"; export interface INotificationManager { listeners: INotificationListener[]; + /** + * Optional hint indicating whether a notification has a matching listener or custom handler. + * @param name - The notification method to query. + * @returns False only when calling the method would have no effect. Callers must retain their + * existing dispatch behavior when this hint is absent or does not return false. + * @remarks + * Implementations must account for custom notification handling as well as registered listeners. + * The built-in manager returns true for overridden notification methods, even without matching listeners. + * This method must not dispatch notifications or change listener state. + */ + hasListener?(name: string): boolean; + /** * Adds a notification listener. * @param listener - The notification listener to be added.