From d9a6674546af2ba3c961fc1d57173bd670804f1e Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 20 Oct 2026 15:15:29 -0700 Subject: [PATCH 1/4] Skip telemetry reconstruction for unrelated notification listeners Check for the matching sent, discarded or retry callback before rebuilding notification items. Preserve fresh snapshots and existing storage behavior, with regression coverage for dynamic listeners, retries and persisted items. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 865183c5-b401-4f7c-b41d-7d8384f0bba8 --- RELEASES.md | 4 +- .../Tests/Unit/src/Sender.tests.ts | 253 +++++++++++++++++- .../src/Sender.ts | 17 +- 3 files changed, 264 insertions(+), 10 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index c496ed72a..eb27e0318 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -2,7 +2,9 @@ > Note: ES3/IE8 compatibility will be removed in the future v3.x.x releases (scheduled for mid-late 2022), so if you need to retain ES3 compatibility you will need to remain on the 2.x.x versions of the SDK or your runtime will need install polyfill's to your ES3 environment before loading / initializing the SDK. - +## Unreleased Changes + +- Skip channel telemetry reconstruction when registered listeners do not handle the relevant sent, discarded, or retry notification. 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/Sender.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts index f1faeabc9..781c5c1a9 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts @@ -5,12 +5,13 @@ import { EnvelopeCreator } from '../../../src/EnvelopeCreator'; import { Exception, CtxTagKeys, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, utlCanUseSessionStorage, utlGetSessionStorage, utlSetSessionStorage, ITelemetryItem, AppInsightsCore, ITelemetryPlugin, DiagnosticLogger, NotificationManager, SendRequestReason, _eInternalMessageId, safeGetLogger, - isString, isArray, arrForEach, isBeaconsSupported, IXHROverride, IPayloadData,TransportType, getWindow, ActiveStatus + isString, isArray, arrForEach, isBeaconsSupported, IXHROverride, IPayloadData,TransportType, getWindow, ActiveStatus, + INotificationListener, createSdkStatsNotifCbk } from "@microsoft/applicationinsights-core-js"; import { ArraySendBuffer, SessionStorageSendBuffer } from "../../../src/SendBuffer"; import { IInternalStorageItem, ISenderConfig } from "../../../src/Interfaces"; import { createAsyncResolvedPromise } from "@nevware21/ts-async"; -import { isPromiseLike, isUndefined } from "@nevware21/ts-utils"; +import { isPromiseLike, isUndefined, objDefine } from "@nevware21/ts-utils"; import { SinonSpy } from 'sinon'; @@ -3898,6 +3899,254 @@ 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"); + } + }); + }); + + 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 5adc6bcea..379ec9944 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -15,8 +15,8 @@ import { } from "@microsoft/applicationinsights-core-js"; import { IPromise, createPromise, doAwait, doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, getInst, isFunction, isNumber, isPromiseLike, isString, isTruthy, mathFloor, mathMax, mathMin, objDeepFreeze, objDefine, - scheduleTimeout + ITimerHandler, arrSome, getInst, isFunction, isNumber, isPromiseLike, isString, isTruthy, mathFloor, mathMax, mathMin, objDeepFreeze, + objDefine, scheduleTimeout } from "@nevware21/ts-utils"; import { DependencyEnvelopeCreator, EnvelopeCreator, EventEnvelopeCreator, ExceptionEnvelopeCreator, MetricEnvelopeCreator, @@ -826,9 +826,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 reconstruct items when a listener handles this notification. let mgr = _getNotifyMgr(); - if (mgr && mgr.listeners && mgr.listeners.length) { + if (mgr && mgr.listeners && mgr.listeners.length && + arrSome(mgr.listeners, (listener) => !!(listener && listener.eventsDiscarded))) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsDiscarded(items, 1 /* NonRetryableStatus */, statusCode); @@ -880,9 +881,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 reconstruct items when a listener handles this notification. let mgr = _getNotifyMgr(); - if (mgr && mgr.listeners && mgr.listeners.length) { + if (mgr && mgr.listeners && mgr.listeners.length && + arrSome(mgr.listeners, (listener) => !!(listener && listener.eventsSent))) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsSent(items); @@ -1452,7 +1454,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 && + arrSome(mgr.listeners, (listener) => !!(listener && listener.eventsRetry))) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsRetry(items, statusCode); From 8607948599a55b1f2126854bfbd7cfea1780e65e Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 21 Oct 2026 13:34:44 -0700 Subject: [PATCH 2/4] Preserve custom notification managers when skipping reconstruction Let managers opt into callback-specific reconstruction checks without suppressing custom handlers. Add notification compatibility and SDK Stats regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4272788f-4337-461b-bf8c-be1ff308e19e --- RELEASES.md | 2 +- .../Tests/Unit/src/InternalSdkStats.tests.ts | 37 +++- .../Tests/Unit/src/Sender.tests.ts | 170 ++++++++++++++++++ .../src/Sender.ts | 14 +- .../Unit/src/ai/AppInsightsCoreSize.Tests.ts | 2 +- .../Unit/src/ai/NotificationManager.Tests.ts | 120 +++++++++++++ .../Tests/Unit/src/aiunittests.ts | 2 + .../src/core/NotificationManager.ts | 33 +++- .../src/interfaces/ai/INotificationManager.ts | 12 ++ 9 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 shared/AppInsightsCore/Tests/Unit/src/ai/NotificationManager.Tests.ts diff --git a/RELEASES.md b/RELEASES.md index eb27e0318..07bb7ecc1 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -4,7 +4,7 @@ ## Unreleased Changes -- Skip channel telemetry reconstruction when registered listeners do not handle the relevant sent, discarded, or retry notification. Notifications with matching listeners, including SDK Stats, retain the existing reconstruction behavior. +- Skip channel telemetry reconstruction when the notification manager confirms there are no consumers for the relevant sent, discarded, or retry notification. Custom managers without the optional `canNotify` 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 781c5c1a9..7b2a244e5 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts @@ -4004,6 +4004,176 @@ export class SenderTests extends AITestClass { }); }); + 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.canNotify = undefined; + checkNotification(); + manager.canNotify = () => undefined; + checkNotification(); + } + }); + }); + this.testCase({ name: "Channel notifications: retries preserve metadata and isolate listener mutations", useFakeTimers: true, diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 379ec9944..f07343b76 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -15,8 +15,8 @@ import { } from "@microsoft/applicationinsights-core-js"; import { IPromise, createPromise, doAwait, doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrSome, getInst, isFunction, isNumber, isPromiseLike, isString, isTruthy, mathFloor, mathMax, mathMin, objDeepFreeze, - objDefine, scheduleTimeout + ITimerHandler, getInst, isFunction, isNumber, isPromiseLike, isString, isTruthy, mathFloor, mathMax, mathMin, objDeepFreeze, objDefine, + scheduleTimeout } from "@nevware21/ts-utils"; import { DependencyEnvelopeCreator, EnvelopeCreator, EventEnvelopeCreator, ExceptionEnvelopeCreator, MetricEnvelopeCreator, @@ -826,10 +826,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _self._buffer && _self._buffer.clearSent(payload); - // Only reconstruct items when a listener handles this notification. + // Only the manager can determine whether its notification has consumers. let mgr = _getNotifyMgr(); if (mgr && mgr.listeners && mgr.listeners.length && - arrSome(mgr.listeners, (listener) => !!(listener && listener.eventsDiscarded))) { + (!mgr.canNotify || mgr.canNotify("eventsDiscarded") !== false)) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsDiscarded(items, 1 /* NonRetryableStatus */, statusCode); @@ -881,10 +881,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _onSuccess(payload: IInternalStorageItem[], countOfItemsInPayload: number) { _self._buffer && _self._buffer.clearSent(payload); - // Only reconstruct items when a listener handles this notification. + // Only the manager can determine whether its notification has consumers. let mgr = _getNotifyMgr(); if (mgr && mgr.listeners && mgr.listeners.length && - arrSome(mgr.listeners, (listener) => !!(listener && listener.eventsSent))) { + (!mgr.canNotify || mgr.canNotify("eventsSent") !== false)) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsSent(items); @@ -1455,7 +1455,7 @@ 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 && - arrSome(mgr.listeners, (listener) => !!(listener && listener.eventsRetry))) { + (!mgr.canNotify || mgr.canNotify("eventsRetry") !== false)) { let items = _extractTelemetryItems(payload); if (items) { mgr.eventsRetry(items, statusCode); 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..1b4789a61 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/NotificationManager.Tests.ts @@ -0,0 +1,120 @@ +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 { 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: canNotify tracks listeners and instance overrides for " + name, + useFakeTimers: true, + test: () => { + const manager = new NotificationManager(); + this.onDone(() => manager.unload(false)); + Assert.equal(false, manager.canNotify(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.canNotify(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.canNotify(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.canNotify(name), "Recognize the handler after DynamicProto caches it"); + + delete listener[name]; + Assert.equal(false, manager.canNotify(name), "Deleting a callback takes effect immediately"); + listener[name] = callback; + Assert.equal(true, manager.canNotify(name), "Restoring a callback takes effect immediately"); + manager.removeNotificationListener(listener); + Assert.equal(false, manager.canNotify(name), "Removing the listener stops notification work"); + + const override = this.sandbox.stub(manager, name); + Assert.equal(true, manager.canNotify(name), "An instance override may handle notifications directly"); + override.restore(); + Assert.equal(false, manager.canNotify(name), "Restoring the built-in handler restores the optimization"); + } + }); + + this.testCase({ + name: "NotificationManager: canNotify preserves prototype overrides for " + name, + test: () => { + const manager = new NotificationManager(); + this.onDone(() => manager.unload(false)); + Assert.equal(false, manager.canNotify(name), "Unmodified manager has no consumers"); + const override = this.sandbox.stub(NotificationManager.prototype, name); + Assert.equal(true, manager.canNotify(name), "A prototype override may handle notifications directly"); + override.restore(); + Assert.equal(false, manager.canNotify(name), "Restoring the prototype restores the optimization"); + } + }); + }); + + 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.canNotify("eventsSent"), "The original manager has no consumers"); + Assert.equal(true, wrapper.canNotify("eventsSent"), "An inherited hint cannot describe a wrapper's consumers"); + } + }); + + this.testCase({ + name: "NotificationManager: canNotify 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.canNotify("eventsSent"), "A subclass can consume events without listeners"); + Assert.equal(false, other.canNotify("eventsSent"), "Other instances still skip unused notifications"); + manager.eventsSent([]); + Assert.equal(1, callback.callCount, "The subclass handles the notification"); + Assert.equal(true, manager.canNotify("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..bfbd33a50 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,14 @@ interface IAsyncNotifications { cb: Array<{ fn: (listener: INotificationListener) => void, arg: INotificationListener }> } +function _getTelemetryCallbacks(manager: INotificationManager): INotificationListener { + return { + eventsSent: manager.eventsSent, + eventsDiscarded: manager.eventsDiscarded, + eventsRetry: manager.eventsRetry + }; +} + function _runScheduledListeners(asyncNotifications: IAsyncNotifications) { asyncNotifications.h = null; let callbacks = asyncNotifications.cb; @@ -65,6 +73,8 @@ export class NotificationManager implements INotificationManager { constructor(config?: IConfiguration) { let perfEvtsSendAll: boolean; let unloadHandler: IUnloadHook; + let _telemetryCallbacks: INotificationListener; + let _telemetryProxies: INotificationListener; let _listeners: INotificationListener[] = []; let _asyncNotifications: IAsyncNotifications = { h: null, @@ -159,6 +169,15 @@ export class NotificationManager implements INotificationManager { }); }; + _telemetryCallbacks = _getTelemetryCallbacks(_self); + _self.canNotify = function (name) { + 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 !== _telemetryCallbacks[name] && callback !== _telemetryProxies[name]) || + arrSome(_listeners, (listener) => !!(listener && listener[name])); + }; + _self.offlineEventsStored = (events: ITelemetryItem[]): void => { if (events && events.length) { _runListeners(_listeners, STR_OFFLINE_STORE, _asyncNotifications, (listener) => { @@ -221,8 +240,20 @@ export class NotificationManager implements INotificationManager { } }; }); + + _telemetryProxies = _getTelemetryCallbacks(NotificationManager.prototype); } + /** + * Indicates whether the named notification has a matching listener or an overridden handler. + * @param name - The telemetry notification method to query. + * @returns False only for an unmodified built-in handler with no matching listeners. + */ + canNotify(name: "eventsSent" | "eventsDiscarded" | "eventsRetry"): 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..95b2d6d8a 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 for avoiding telemetry reconstruction when a notification has no consumers. + * @param name - The telemetry 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. + */ + canNotify?(name: "eventsSent" | "eventsDiscarded" | "eventsRetry"): boolean; + /** * Adds a notification listener. * @param listener - The notification listener to be added. From 10671e6430fd1a5147ec13b1c05d39e0e6dc7248 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 22 Oct 2026 18:34:06 -0700 Subject: [PATCH 3/4] Adjust full SDK size budgets for notification listener API Raise only the uncompressed dist and browser budgets by 1 KB to account for the optional notification listener hint. Keep both 74 KB deflate limits unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"; From 2c1b743f0a8acfea4de2f02861814914ccba27a9 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 26 Oct 2026 11:20:16 -0700 Subject: [PATCH 4/4] Fix unused decoder patch during CI dependency resolution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- common/config/rush/pnpm-config.json | 2 +- common/config/rush/pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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'