From af220a304f90544ad6491c914951f8bc91fcf31b Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 16 Jun 2026 17:09:14 -0700 Subject: [PATCH 01/51] Enable SDK Stats and route to the SDK Stats ingestion endpoint Completes and enables the SDK Stats manager (createStatsMgr) and routes the resulting events to the distro-owned SDK Stats ingestion endpoint (stats.monitor.azure.com / eu.stats.monitor.azure.com) instead of the customer's breeze endpoint, matching the Microsoft OpenTelemetry distro. - StatsBeat.ts: add SDK Stats endpoint constants, EU/non-EU region detection, per-event destination + placeholder iKey stamping, enabled-by-default feature gate, and createSdkStatsMgrConfig(). - AppInsightsCore / IAppInsightsCore / index: restore getStatsBeat, setStatsMgr, fields, unload cleanup, stubs, and exports. - Sender.ts: restore request-counting hooks and statsBeatData; redirect SDK Stats items to the SDK Stats endpoint via a per-item URL override, bypassing the customer buffer; exclude SDK Stats sends from counting. - AISku.ts: create, init and set the SDK Stats manager after core init. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 15 +- .../src/Sender.ts | 195 +++++++++++------- .../src/core/AppInsightsCore.ts | 126 +++++------ shared/AppInsightsCore/src/core/StatsBeat.ts | 109 +++++++++- shared/AppInsightsCore/src/index.ts | 13 +- .../src/interfaces/ai/IAppInsightsCore.ts | 34 +-- 6 files changed, 333 insertions(+), 159 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 51a55e209..be260df3e 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -15,7 +15,8 @@ import { 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, + createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsMgrConfig, createStatsMgr, createTraceProvider, + createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, removePageHideEventListener, removePageUnloadEventListener, useSpan } from "@microsoft/applicationinsights-core-js"; @@ -390,6 +391,18 @@ export class AppInsightsSku implements IApplicationInsights()); + if (statsHook) { + _core.addUnloadHook(statsHook); + } + } + // Initialize the initial OTel API _otelApi = _initOTel(_self, "aisku", _onEnd, _onException); diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 2d2b83bcc..49395dbe5 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -3,12 +3,14 @@ import { ActiveStatus, BaseTelemetryPlugin, BreezeChannelIdentifier, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, EventDataType, ExceptionDataType, IAppInsightsCore, IBackendResponse, IChannelControls, IConfig, IConfigDefaults, IConfiguration, IDiagnosticLogger, IEnvelope, IInternalOfflineSupport, INotificationManager, IOfflineListener, IPayloadData, IPlugin, IProcessTelemetryContext, - IProcessTelemetryUnloadContext, ISample, IStorageBuffer, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, IXDomainRequest, + IProcessTelemetryUnloadContext, ISample, IStatsBeatState, IStatsEventData, IStorageBuffer, ITelemetryItem, ITelemetryPluginChain, + ITelemetryUnloadState, IXDomainRequest, IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, - RequestDataType, RequestHeaders, SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, TransportType, + RequestDataType, RequestHeaders, STATS_SDK_ENDPOINT_KEY, SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, + TransportType, _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createOfflineListener, createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, - formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, + eStatsType, formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-core-js"; @@ -34,7 +36,7 @@ const FetchSyncRequestSizeLimitBytes = 65000; // approx 64kb (the current Edge, interface IInternalPayloadData extends IPayloadData { oriPayload: IInternalStorageItem[]; retryCnt?: number; - // statsBeatData?: IStatsEventData; + statsBeatData?: IStatsEventData; } @@ -188,6 +190,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let _sendPostMgr: SenderPostManager; let _retryCodes: number[]; let _zipPayload: boolean; + let _httpInterface: IXHROverride; // The resolved http sender, captured so SDK Stats can be sent to their own endpoint dynamicProto(Sender, this, (_self, _base) => { @@ -453,6 +456,8 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { httpInterface = _alwaysUseCustomSend? customInterface : (httpInterface || customInterface || xhrInterface); + _httpInterface = httpInterface; + _self._sender = (payload: IInternalStorageItem[], isAsync: boolean) => { return _doSend(httpInterface, payload, isAsync); }; @@ -497,7 +502,16 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!isValidate) { return; } - + + // SDK Stats events carry the destination SDK Stats ingestion endpoint + // so they can be redirected away from the customer's breeze endpoint. Extract and + // remove the marker so it is not serialized into the outgoing envelope. + let statsEndpoint: string; + if (telemetryItem.data && telemetryItem.data[STATS_SDK_ENDPOINT_KEY]) { + statsEndpoint = telemetryItem.data[STATS_SDK_ENDPOINT_KEY]; + delete telemetryItem.data[STATS_SDK_ENDPOINT_KEY]; + } + let aiEnvelope = _getEnvelope(telemetryItem, diagLogger); if (!aiEnvelope) { return; @@ -505,20 +519,26 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // check if the incoming payload is too large, truncate if necessary const payload: string = _serializer.serialize(aiEnvelope); - - // flush if we would exceed the max-size limit by adding this item - const buffer = _self._buffer; - _checkMaxSize(payload); - let payloadItem = { - item: payload, - cnt: 0 // inital cnt will always be 0 - } as IInternalStorageItem; - - // enqueue the payload - buffer.enqueue(payloadItem); - - // ensure an invocation timeout is set - _setupTimer(); + + if (statsEndpoint) { + // Route SDK Stats directly to the SDK Stats ingestion endpoint, bypassing the + // customer send buffer so it is never mixed with customer telemetry. + _sendStatsBeat(payload, statsEndpoint); + } else { + // flush if we would exceed the max-size limit by adding this item + const buffer = _self._buffer; + _checkMaxSize(payload); + let payloadItem = { + item: payload, + cnt: 0 // inital cnt will always be 0 + } as IInternalStorageItem; + + // enqueue the payload + buffer.enqueue(payloadItem); + + // ensure an invocation timeout is set + _setupTimer(); + } } catch (e) { _throwInternal(diagLogger, @@ -671,20 +691,60 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } - // function _getStatsBeat() { - // let statsBeatConfig: IStatsBeatState = { - // cKey: _self._senderConfig.instrumentationKey, - // endpoint: _endpointUrl, - // sdkVer: EnvelopeCreator.Version, - // type: eStatsType.SDK - // }; + function _getStatsBeat() { + let statsBeatConfig: IStatsBeatState = { + cKey: _self._senderConfig.instrumentationKey, + endpoint: _endpointUrl, + sdkVer: EnvelopeCreator.Version, + type: eStatsType.SDK + }; + + let core = _self.core; + + // During page unload the core may have been cleared and some async events may not have been sent yet + // resulting in the core being null. In this case we don't want to create a SDK Stats instance + return core && core.getStatsBeat ? core.getStatsBeat(statsBeatConfig) : null; + } - // let core = _self.core; + /** + * Send a single serialized SDK Stats payload to the provided SDK Stats ingestion + * endpoint. SDK Stats are routed to the distro-owned endpoint instead of the customer's breeze + * endpoint and therefore bypass the normal send buffer. The send reuses the resolved http + * sender and is sent immediately (SDK Stats events are infrequent and low volume). + * @param payloadStr - The serialized envelope to send. + * @param statsEndpoint - The SDK Stats ingestion endpoint URL to send the payload to. + */ + function _sendStatsBeat(payloadStr: string, statsEndpoint: string) { + try { + let sendInterface = _httpInterface; + if (sendInterface && payloadStr && statsEndpoint) { + let payloadItem = { + item: payloadStr, + cnt: 0 + } as IInternalStorageItem; + // markAsSent=false so the main send buffer is never touched, statsUrl redirects the POST + _doSend(sendInterface, [payloadItem], true, false, statsEndpoint); + } + } catch (e) { + // SDK Stats are best-effort and must never break customer telemetry + } + } - // // During page unload the core may have been cleared and some async events may not have been sent yet - // // resulting in the core being null. In this case we don't want to create a statsbeat instance - // return core ? core.getStatsBeat(statsBeatConfig) : null; - // } + /** + * Record the result of a send against the SDK Stats instance for the customer + * endpoint. Sends to the SDK Stats ingestion endpoint itself are intentionally skipped (the + * payload targets a different url) to avoid a self-referential feedback loop. + * @param status - The resulting status code of the send. + * @param payload - The payload that was sent. + */ + function _countStatsBeat(status: number, payload?: IPayloadData) { + if (payload && payload.urlString === _endpointUrl) { + let statsbeat = _getStatsBeat(); + if (statsbeat) { + statsbeat.count(status, payload, _endpointUrl); + } + } + } function _xdrOnLoad (xdr: IXDomainRequest, payload: IInternalStorageItem[]) { const responseText = _getResponseText(xdr); @@ -712,23 +772,25 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - //const responseText = _getResponseText(xdr); - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // if (xdr && (responseText + "" === "200" || responseText === "")) { - // _consecutiveErrors = 0; - // statsbeat.count(200, payload, _endpointUrl); - // } else { - // const results = parseResponse(responseText); - - // if (results && results.itemsReceived && results.itemsReceived > results.itemsAccepted - // && !_isRetryDisabled) { - // statsbeat.count(206, payload, _endpointUrl); - // } else { - // statsbeat.count(499, payload, _endpointUrl); - // } - // } - // } + if (payload && payload.urlString === _endpointUrl) { + const responseText = _getResponseText(xdr); + let statsbeat = _getStatsBeat(); + if (statsbeat) { + if (xdr && (responseText + "" === "200" || responseText === "")) { + _consecutiveErrors = 0; + statsbeat.count(200, payload, _endpointUrl); + } else { + const results = parseResponse(responseText); + + if (results && results.itemsReceived && results.itemsReceived > results.itemsAccepted + && !_isRetryDisabled) { + statsbeat.count(206, payload, _endpointUrl); + } else { + statsbeat.count(499, payload, _endpointUrl); + } + } + } + } return _xdrOnLoad(xdr, payloadArr); @@ -738,10 +800,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // statsbeat.count(response.status, payload, _endpointUrl); - // } + _countStatsBeat(response.status, payload); return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, xhrOnComplete: (request: XMLHttpRequest, oncomplete: OnCompleteCallback, payload?: IPayloadData) => { @@ -749,18 +808,14 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - // let statsbeat = _getStatsBeat(); - // if (statsbeat && request.readyState === 4) { - // statsbeat.count(request.status, payload, _endpointUrl); - // } + if (request.readyState === 4) { + _countStatsBeat(request.status, payload); + } return _xhrReadyStateChange(request, payloadArr, payloadArr.length); }, beaconOnRetry: (data: IPayloadData, onComplete: OnCompleteCallback, canSend: (payload: IPayloadData, oncomplete: OnCompleteCallback, sync?: boolean) => boolean) => { - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // statsbeat.count(499, data, _endpointUrl); - // } + _countStatsBeat(499, data); return _onBeaconRetry(data, onComplete, canSend); } @@ -1006,19 +1061,16 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } } - function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true): void | IPromise { + function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true, urlOverride?: string): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { - // let statsbeat = _getStatsBeat(); - // if (statsbeat) { - // statsbeat.count(status, payloadData, _endpointUrl); - // } + _countStatsBeat(status, payloadData); return _getOnComplete(payload, status, headers, response); }; - let payloadData = _getPayload(payload); - // if (payloadData) { - // payloadData.statsBeatData = {startTime: dateNow()}; - // } + let payloadData = _getPayload(payload, urlOverride); + if (payloadData) { + payloadData.statsBeatData = {startTime: dateNow()}; + } let sendPostFunc: SendPOSTFunction = sendInterface && sendInterface.sendPOST; if (sendPostFunc && payloadData) { @@ -1054,13 +1106,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return null; } - function _getPayload(payload: IInternalStorageItem[]): IInternalPayloadData { + function _getPayload(payload: IInternalStorageItem[], urlOverride?: string): IInternalPayloadData { if (isArray(payload) && payload.length > 0) { let batch = _self._buffer.batchPayloads(payload); let headers = _getHeaders(); let payloadData: IInternalPayloadData = { data: batch, - urlString: _endpointUrl, + urlString: urlOverride || _endpointUrl, headers: headers, disableXhrSync: _disableXhr, disableFetchKeepAlive: !_fetchKeepAlive, @@ -1409,6 +1461,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _initDefaults() { _self._sender = null; _self._buffer = null; + _httpInterface = null; _self._appId = null; _self._sample = null; _headers = {}; diff --git a/shared/AppInsightsCore/src/core/AppInsightsCore.ts b/shared/AppInsightsCore/src/core/AppInsightsCore.ts index 670f72d01..531d4aa6c 100644 --- a/shared/AppInsightsCore/src/core/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/core/AppInsightsCore.ts @@ -32,6 +32,8 @@ import { INotificationListener } from "../interfaces/ai/INotificationListener"; import { INotificationManager } from "../interfaces/ai/INotificationManager"; import { IPerfManager } from "../interfaces/ai/IPerfManager"; import { IProcessTelemetryContext, IProcessTelemetryUpdateContext } from "../interfaces/ai/IProcessTelemetryContext"; +import { IStatsBeat, IStatsBeatState } from "../interfaces/ai/IStatsBeat"; +import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "../interfaces/ai/ITelemetryInitializers"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPlugin, ITelemetryPlugin } from "../interfaces/ai/ITelemetryPlugin"; @@ -67,8 +69,6 @@ import { TelemetryInitializerPlugin } from "./TelemetryInitializerPlugin"; import { IUnloadHandlerContainer, UnloadHandler, createUnloadHandlerContainer } from "./UnloadHandlerContainer"; import { IUnloadHookContainer, createUnloadHookContainer } from "./UnloadHookContainer"; -// import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "../interfaces/ai/IStatsBeat"; -// import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; const strValidationError = "Plugins must provide initialize method"; const strNotificationManager = "_notificationManager"; const strSdkUnloadingError = "SDK is still unloading..."; @@ -382,8 +382,8 @@ export class AppInsightsCore im let _logger: IDiagnosticLogger; let _eventQueue: ITelemetryItem[]; let _notificationManager: INotificationManager | null | undefined; - // let _statsBeat: IStatsBeat | null; - // let _statsMgr: IStatsMgr | null; + let _statsBeat: IStatsBeat | null; + let _statsMgr: IStatsMgr | null; let _perfManager: IPerfManager | null; let _cfgPerfManager: IPerfManager | null; let _cookieManager: ICookieMgr | null; @@ -623,47 +623,47 @@ export class AppInsightsCore im _perfManager = perfMgr; }; - // _self.getStatsBeat = (statsBeatState: IStatsBeatState) => { - // // create a new statsbeat if not initialize yet or the endpoint is different - // // otherwise, return the existing one, or null - - // if (statsBeatState) { - // if (_statsMgr && _statsMgr.enabled) { - // if (_statsBeat && _statsBeat.endpoint !== statsBeatState.endpoint) { - // // Different endpoint, so unload the existing and create a new one - // _statsBeat.enabled = false; - // _statsBeat = null; - // } - - // if (!_statsBeat) { - // // Create a new statsbeat instance - // _statsBeat = _statsMgr.newInst(statsBeatState); - // } - // } else if (_statsBeat) { - // // Disable and remove any previously created statsbeat instance - // _statsBeat.enabled = false; - // _statsBeat = null; - // } - - // // Return the current statsbeat instance or null if not created - // return _statsBeat; - // } - - // // Return null as no statsbeat state was provided - // return null; - // }; - - // _self.setStatsMgr = (statsMgr: IStatsMgr) => { - // if (_statsMgr && _statsMgr !== statsMgr) { - // // Disable any previously created statsbeat instance - // if (_statsBeat) { - // _statsBeat.enabled = false; - // _statsBeat = null; - // } - // } - - // _statsMgr = statsMgr; - // }; + _self.getStatsBeat = (statsBeatState: IStatsBeatState) => { + // create a new SDK Stats instance if not initialized yet or the endpoint is different + // otherwise, return the existing one, or null + + if (statsBeatState) { + if (_statsMgr && _statsMgr.enabled) { + if (_statsBeat && _statsBeat.endpoint !== statsBeatState.endpoint) { + // Different endpoint, so unload the existing and create a new one + _statsBeat.enabled = false; + _statsBeat = null; + } + + if (!_statsBeat) { + // Create a new SDK Stats instance + _statsBeat = _statsMgr.newInst(statsBeatState); + } + } else if (_statsBeat) { + // Disable and remove any previously created SDK Stats instance + _statsBeat.enabled = false; + _statsBeat = null; + } + + // Return the current SDK Stats instance or null if not created + return _statsBeat; + } + + // Return null as no SDK Stats state was provided + return null; + }; + + _self.setStatsMgr = (statsMgr: IStatsMgr) => { + if (_statsMgr && _statsMgr !== statsMgr) { + // Disable any previously created SDK Stats instance + if (_statsBeat) { + _statsBeat.enabled = false; + _statsBeat = null; + } + } + + _statsMgr = statsMgr; + }; _self.eventCnt = (): number => { return _eventQueue.length; @@ -908,11 +908,11 @@ export class AppInsightsCore im let processUnloadCtx = createProcessTelemetryUnloadContext(_getPluginChain(), _self); processUnloadCtx.onComplete(() => { - // if (_statsBeat) { - // // Disable any statsbeat instance - // _statsBeat.enabled = false; - // _statsBeat = null; - // } + if (_statsBeat) { + // Disable any SDK Stats instance + _statsBeat.enabled = false; + _statsBeat = null; + } _hookContainer.run(_self.logger); @@ -1314,7 +1314,12 @@ export class AppInsightsCore im runTargetUnload(_notificationManager, false); _notificationManager = null; _perfManager = null; - // _statsBeat = null; + if (_statsBeat) { + // Disable any SDK Stats instance + _statsBeat.enabled = false; + } + _statsBeat = null; + _statsMgr = null; _cfgPerfManager = null; runTargetUnload(_cookieManager, false); _cookieManager = null; @@ -1342,11 +1347,6 @@ export class AppInsightsCore im _initInMemoMaxSize = null; _isStatusSet = false; _initTimer = null; - // if (_statsBeat) { - // // Unload and disable any statsbeat instance - // _statsBeat.enabled = false; - // } - // _statsBeat = null; } function _createTelCtx(): IProcessTelemetryContext { @@ -1733,14 +1733,14 @@ export class AppInsightsCore im return null; } - // public getStatsBeat(statsBeatState: IStatsBeatState): IStatsBeat { - // // @ DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - // return null; - // } + public getStatsBeat(statsBeatState: IStatsBeatState): IStatsBeat { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } - // public setStatsMgr(statsMgr?: IStatsMgr): void { - // // @ DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - // } + public setStatsMgr(statsMgr?: IStatsMgr): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } public setPerfMgr(perfMgr: IPerfManager) { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts index ef0578302..3c5da11aa 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/StatsBeat.ts @@ -23,6 +23,80 @@ const STATS_MIN_INTERVAL_SECONDS = 60; // 1 minute const STATSBEAT_LANGUAGE = "JavaScript"; const STATSBEAT_TYPE = "Browser"; +/** + * The placeholder instrumentation key used when reporting SDK statistics to the distro-owned + * SDK Stats ingestion endpoint. The endpoint does not require authentication, the placeholder + * key only satisfies the connection-string / envelope iKey requirement and is ignored + * server-side. This matches the convention used by the Microsoft OpenTelemetry distros. + */ +export const STATS_SDK_IKEY = "00000000-0000-0000-0000-000000000000"; + +/** + * Distro-owned SDK statistics ingestion endpoints. SDK Stats events are routed here instead of + * to the customer's breeze endpoint. The EU endpoint is used when the customer's endpoint maps + * to an EU data-boundary region, otherwise the non-EU endpoint is used. + * @see https://github.com/microsoft/opentelemetry-distro-dotnet + */ +export const STATS_SDK_ENDPOINT_NON_EU = "https://stats.monitor.azure.com/v2/track"; +export const STATS_SDK_ENDPOINT_EU = "https://eu.stats.monitor.azure.com/v2/track"; + +/** + * The transient marker key, set on the {@link ITelemetryItem.data} of a SDK Stats event, that + * carries the destination SDK Stats ingestion endpoint. The sending channel reads this value to + * redirect the event to the SDK Stats endpoint and removes it before serializing the event. + */ +export const STATS_SDK_ENDPOINT_KEY = "_sdkStatsEndpoint"; + +/** + * The default feature name used to gate the SDK Stats manager. SDK Stats is enabled by default + * and can be opted-out via the featureOptIn configuration using this name. + */ +export const STATS_SDK_FEATURE = "sdkStats"; + +// EU data-boundary regions, mirrors the EU region set used by the Azure Monitor OpenTelemetry exporter +const STATS_EU_REGIONS = [ + "francecentral", "francesouth", "germanywestcentral", "northeurope", "norwayeast", "norwaywest", + "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "ukwest", "westeurope" +]; + +/** + * Determine the distro-owned SDK Stats ingestion endpoint for the provided customer endpoint. + * The region is extracted from the host (the sub-domain preceding ".in.applicationinsights" or + * the leading label of the host) and matched against the known EU data-boundary regions. When + * the region maps to an EU region the EU endpoint is returned, otherwise (including unknown + * regions) the non-EU endpoint is returned. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns The SDK Stats ingestion endpoint URL. + */ +export function getStatsEndpoint(endpoint: string): string { + let isEU = false; + if (endpoint) { + let host = strLower(endpoint); + // Strip the scheme + let schemeIdx = strIndexOf(host, "://"); + if (schemeIdx !== -1) { + host = host.substring(schemeIdx + 3); + } + + // Extract the leading host label, e.g. "westeurope-5" from "westeurope-5.in.applicationinsights.azure.com/" + let label = host.split("/")[0].split(".")[0]; + // Remove any trailing region replica suffix, e.g. "westeurope-5" => "westeurope" + let dashIdx = strIndexOf(label, "-"); + if (dashIdx !== -1) { + label = label.substring(0, dashIdx); + } + + arrForEach(STATS_EU_REGIONS, (region) => { + if (region === label) { + isEU = true; + return -1; + } + }); + } + + return isEU ? STATS_SDK_ENDPOINT_EU : STATS_SDK_ENDPOINT_NON_EU; +} + /** * An internal interface to allow the IStatsBeat instance to call back to the manager for @@ -106,6 +180,7 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): let _networkCounter: INetworkStatsbeat = _createNetworkStatsbeat(statsBeatStats.endpoint); let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. let _isEnabled: boolean = true; // Flag to check if statsbeat is enabled or not + let _statsEndpoint: string = getStatsEndpoint(statsBeatStats.endpoint); // The SDK Stats ingestion endpoint to send the events to function _setupTimer() { if (_isEnabled && !_timeoutHandle) { @@ -184,12 +259,19 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): let statsbeatEvent: ITelemetryItem = { name: name, + iKey: STATS_SDK_IKEY, baseData: { name: name, average: val, properties: combinedProps }, - baseType: "MetricData" + baseType: "MetricData", + // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the + // event away from the customer's breeze endpoint. This marker is removed by the + // channel before the event is serialized. + data: { + [STATS_SDK_ENDPOINT_KEY]: _statsEndpoint + } }; mgr.track(statsBeat, statsbeatEvent); @@ -354,7 +436,7 @@ export function createStatsMgr(): IStatsMgr { return onConfigChange(core.config, (details) => { // Check the feature state again to see if it has changed _isMgrEnabled = false; - if (statsConfig && isFeatureEnabled(statsConfig.feature, details.cfg, false) === true) { + if (statsConfig && isFeatureEnabled(statsConfig.feature, details.cfg, true) === true) { // Call the getCfg function to get the latest configuration for the statsbeat instance // This should also evaluate the throttling level and other settings for the statsbeat instance // to determine if it should be enabled or not. @@ -424,3 +506,26 @@ export function createStatsMgr(): IStatsMgr { "enabled": { g: () => _isMgrEnabled } }); } + +/** + * Create the default {@link IStatsMgrConfig} used to enable the SDK Stats collection + * and route the resulting events to the distro-owned SDK Stats ingestion endpoint + * (`stats.monitor.azure.com` / `eu.stats.monitor.azure.com`). SDK Stats are enabled by default and + * can be opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. + * @returns The {@link IStatsMgrConfig} to pass to {@link IStatsMgr.init}. + */ +export function createSdkStatsMgrConfig(): IStatsMgrConfig { + return { + feature: STATS_SDK_FEATURE, + getCfg: () => ({ + endCfg: [{ + type: eStatsType.SDK, + keyMap: [{ + key: STATS_SDK_IKEY, + match: ["*"] + }] + }] + }) + }; +} + diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index a258ed91c..134e46587 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -19,7 +19,7 @@ export { IUnloadHook, ILegacyUnloadHook } from "./interfaces/ai/IUnloadHook"; export { eEventsDiscardedReason, EventsDiscardedReason, eBatchDiscardedReason, BatchDiscardedReason } from "./enums/ai/EventsDiscardedReason"; export { eDependencyTypes, DependencyTypes } from "./enums/ai/DependencyTypes"; export { SendRequestReason } from "./enums/ai/SendRequestReason"; -//export { StatsType, eStatsType } from "./enums/ai/StatsType"; +export { StatsType, eStatsType } from "./enums/ai/StatsType"; export { TelemetryUpdateReason } from "./enums/ai/TelemetryUpdateReason"; export { TelemetryUnloadReason } from "./enums/ai/TelemetryUnloadReason"; export { eUrlRedactionOptions, UrlRedactionOptions } from "./enums/ai/UrlRedactionOptions" @@ -40,10 +40,13 @@ export { parseResponse } from "./core/ResponseHelpers"; export { IXDomainRequest, IBackendResponse } from "./interfaces/ai/IXDomainRequest"; export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IInternalXhrOverride } from "./interfaces/ai/ISenderPostManager"; export { SenderPostManager } from "./core/SenderPostManager"; -//export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState} from "./interfaces/ai/IStatsBeat"; -//export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; -//export { IStatsMgr, IStatsMgrConfig } from "./interfaces/ai/IStatsMgr"; -//export { createStatsMgr } from "./core/StatsBeat"; +export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState} from "./interfaces/ai/IStatsBeat"; +export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; +export { IStatsMgr, IStatsMgrConfig } from "./interfaces/ai/IStatsMgr"; +export { + createStatsMgr, createSdkStatsMgrConfig, getStatsEndpoint, + STATS_SDK_IKEY, STATS_SDK_ENDPOINT_NON_EU, STATS_SDK_ENDPOINT_EU, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE +} from "./core/StatsBeat"; export { isArray, isTypeof, isUndefined, isNullOrUndefined, isStrictUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, strEndsWith, strStartsWith, isDate, isError, isString, isNumber, isBoolean, arrForEach, arrIndexOf, diff --git a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts index 198b6ee0e..aa23c2327 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts @@ -15,6 +15,8 @@ import { INotificationListener } from "./INotificationListener"; import { INotificationManager } from "./INotificationManager"; import { IPerfManagerProvider } from "./IPerfManager"; import { IProcessTelemetryContext } from "./IProcessTelemetryContext"; +import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; +import { IStatsMgr } from "./IStatsMgr"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "./ITelemetryInitializers"; import { ITelemetryItem } from "./ITelemetryItem"; import { IPlugin, ITelemetryPlugin } from "./ITelemetryPlugin"; @@ -22,8 +24,6 @@ import { ITelemetryUnloadState } from "./ITelemetryUnloadState"; import { ITraceHost, ITraceProvider } from "./ITraceProvider"; import { ILegacyUnloadHook, IUnloadHook } from "./IUnloadHook"; -// import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; -// import { IStatsMgr } from "./IStatsMgr"; export interface ILoadedPlugin { plugin: T; @@ -121,21 +121,21 @@ export interface IAppInsightsCore Date: Tue, 16 Jun 2026 17:32:51 -0700 Subject: [PATCH 02/51] Make SDK Stats destination configurable via dynamic/CDN config Adds support for sending SDK Stats to either the new SDK Stats endpoint (stats.monitor.azure.com) or the legacy breeze endpoints, selectable at runtime via config.stats (IStatsBeatConfig), which is overridable through the CDN / dynamic config. - StatsType.ts: add eStatsEndpointType (SdkStats / Breeze) enum. - IStatsBeat.ts: add IStatsBeatConfig.mode and IStatsBeatKeyMap.url. - IConfiguration.ts: add stats?: IStatsBeatConfig (dynamic-config surface). - StatsBeat.ts: factor EU detection into _isEuEndpoint; add getStatsBreezeIKey + breeze SDK Stats iKey constants; resolve the destination iKey/endpoint per-event in _track based on the (dynamic) mode; createSdkStatsMgrConfig now reads config.stats so the endpoint and key map can be overridden via the CDN at runtime. Defaults to the SDK Stats endpoint. - index.ts: export the new enum, helper and constants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- shared/AppInsightsCore/src/core/StatsBeat.ts | 159 ++++++++++++------ .../AppInsightsCore/src/enums/ai/StatsType.ts | 22 +++ shared/AppInsightsCore/src/index.ts | 7 +- .../src/interfaces/ai/IConfiguration.ts | 9 + .../src/interfaces/ai/IStatsBeat.ts | 19 ++- 5 files changed, 156 insertions(+), 60 deletions(-) diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts index 3c5da11aa..035e13359 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/StatsBeat.ts @@ -8,11 +8,11 @@ import { onConfigChange } from "../config/DynamicConfig"; import { STR_EMPTY } from "../constants/InternalConstants"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; -import { eStatsType } from "../enums/ai/StatsType"; +import { eStatsEndpointType, eStatsType } from "../enums/ai/StatsType"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { INetworkStatsbeat } from "../interfaces/ai/INetworkStatsbeat"; -import { IStatsBeat, IStatsBeatConfig, IStatsBeatState, IStatsEndpointConfig } from "../interfaces/ai/IStatsBeat"; +import { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap, IStatsBeatState, IStatsEndpointConfig } from "../interfaces/ai/IStatsBeat"; import { IStatsMgr, IStatsMgrConfig } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; @@ -40,6 +40,15 @@ export const STATS_SDK_IKEY = "00000000-0000-0000-0000-000000000000"; export const STATS_SDK_ENDPOINT_NON_EU = "https://stats.monitor.azure.com/v2/track"; export const STATS_SDK_ENDPOINT_EU = "https://eu.stats.monitor.azure.com/v2/track"; +/** + * The Microsoft-owned instrumentation keys used when reporting SDK statistics to the legacy + * breeze ingestion endpoints (the customer's own breeze endpoint is used as the host, only the + * iKey differs so the data lands in the Microsoft SDK Stats resource). The EU key is used when + * the customer's endpoint maps to an EU data-boundary region, otherwise the non-EU key is used. + */ +export const STATS_BREEZE_IKEY_NON_EU = "c4a29126-a7cb-47e5-b348-11414998b11e"; +export const STATS_BREEZE_IKEY_EU = "7dc56bab-3c0c-4e9f-9ebb-d1acadee8d0f"; + /** * The transient marker key, set on the {@link ITelemetryItem.data} of a SDK Stats event, that * carries the destination SDK Stats ingestion endpoint. The sending channel reads this value to @@ -60,15 +69,13 @@ const STATS_EU_REGIONS = [ ]; /** - * Determine the distro-owned SDK Stats ingestion endpoint for the provided customer endpoint. - * The region is extracted from the host (the sub-domain preceding ".in.applicationinsights" or - * the leading label of the host) and matched against the known EU data-boundary regions. When - * the region maps to an EU region the EU endpoint is returned, otherwise (including unknown - * regions) the non-EU endpoint is returned. + * Determine whether the provided customer endpoint maps to an EU data-boundary region. The region + * is extracted from the host (the leading host label, with any region replica suffix removed) and + * matched against the known EU data-boundary regions. * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. - * @returns The SDK Stats ingestion endpoint URL. + * @returns true when the endpoint maps to an EU region, false otherwise (including unknown regions). */ -export function getStatsEndpoint(endpoint: string): string { +function _isEuEndpoint(endpoint: string): boolean { let isEU = false; if (endpoint) { let host = strLower(endpoint); @@ -94,7 +101,29 @@ export function getStatsEndpoint(endpoint: string): string { }); } - return isEU ? STATS_SDK_ENDPOINT_EU : STATS_SDK_ENDPOINT_NON_EU; + return isEU; +} + +/** + * Determine the distro-owned SDK Stats ingestion endpoint for the provided customer endpoint. + * When the region maps to an EU region the EU endpoint is returned, otherwise (including unknown + * regions) the non-EU endpoint is returned. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns The SDK Stats ingestion endpoint URL. + */ +export function getStatsEndpoint(endpoint: string): string { + return _isEuEndpoint(endpoint) ? STATS_SDK_ENDPOINT_EU : STATS_SDK_ENDPOINT_NON_EU; +} + +/** + * Determine the Microsoft-owned instrumentation key to use when reporting SDK Stats to the legacy + * breeze endpoint for the provided customer endpoint. When the region maps to an EU region the EU + * key is returned, otherwise (including unknown regions) the non-EU key is returned. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @returns The breeze SDK Stats instrumentation key. + */ +export function getStatsBreezeIKey(endpoint: string): string { + return _isEuEndpoint(endpoint) ? STATS_BREEZE_IKEY_EU : STATS_BREEZE_IKEY_NON_EU; } @@ -180,7 +209,6 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): let _networkCounter: INetworkStatsbeat = _createNetworkStatsbeat(statsBeatStats.endpoint); let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. let _isEnabled: boolean = true; // Flag to check if statsbeat is enabled or not - let _statsEndpoint: string = getStatsEndpoint(statsBeatStats.endpoint); // The SDK Stats ingestion endpoint to send the events to function _setupTimer() { if (_isEnabled && !_timeoutHandle) { @@ -259,21 +287,16 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): let statsbeatEvent: ITelemetryItem = { name: name, - iKey: STATS_SDK_IKEY, baseData: { name: name, average: val, properties: combinedProps }, - baseType: "MetricData", - // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the - // event away from the customer's breeze endpoint. This marker is removed by the - // channel before the event is serialized. - data: { - [STATS_SDK_ENDPOINT_KEY]: _statsEndpoint - } + baseType: "MetricData" }; + // The destination iKey and (optional) SDK Stats ingestion endpoint are resolved and + // stamped by the manager (see _track) based on the current (dynamic) configuration. mgr.track(statsBeat, statsbeatEvent); } } @@ -381,22 +404,22 @@ function _getEndpointCfg(statsBeatConfig: IStatsBeatConfig, type: eStatsType): I } /** - * This function retrieves the stats instrumentation key (iKey) for the given endpoint from - * the statsBeatConfig. It iterates through the keys in the statsBeatConfig and checks if - * the endpoint matches any of the URLs associated with that key. If a match is found, it - * returns the corresponding iKey. - * @param statsBeatConfig - The configuration object for StatsBeat. + * This function retrieves the matching {@link IStatsBeatKeyMap} entry for the given endpoint from + * the provided endpoint configuration. It iterates through the key maps and checks if the endpoint + * matches any of the configured URL patterns. If a match is found, the corresponding key map entry + * (which carries the optional instrumentation key and SDK Stats ingestion endpoint URL) is returned. + * @param endpointCfg - The endpoint configuration to search. * @param endpoint - The endpoint to check against the URLs in the configuration. - * @returns The iKey associated with the matching endpoint, or null if no match is found. + * @returns The matching {@link IStatsBeatKeyMap} entry, or null if no match is found. */ -function _getIKey(endpointCfg: IStatsEndpointConfig, endpoint: string): string | null { - let statsKey: string = null; +function _getKeyMap(endpointCfg: IStatsEndpointConfig, endpoint: string): IStatsBeatKeyMap | null { + let matched: IStatsBeatKeyMap = null; if (endpointCfg.keyMap) { arrForEach(endpointCfg.keyMap, (keyMap) => { if (keyMap.match) { arrForEach(keyMap.match, (url) => { if (_isMatchEndpoint(url, endpoint)) { - statsKey = keyMap.key || null; + matched = keyMap; // Stop the loop if we found a match return -1; @@ -404,14 +427,14 @@ function _getIKey(endpointCfg: IStatsEndpointConfig, endpoint: string): string | }); } - if (statsKey) { + if (matched) { // Stop the loop if we found a match return -1; } }); } - return statsKey; + return matched; } export function createStatsMgr(): IStatsMgr { @@ -456,23 +479,36 @@ export function createStatsMgr(): IStatsMgr { function _track(statsBeat: IStatsBeat, statsBeatEvent: ITelemetryItem) { if (_isMgrEnabled && _statsBeatConfig) { let endpoint = statsBeat.endpoint; - let sendEvt = !!statsBeat.type; - // Fetching the stats key for the endpoint here to support the scenario where the endpoint is changed - // after the statsbeat instance is created. This will ensure that the correct stats key is used for the endpoint. - // It also avoids the tracking of the statsbeat event if the endpoint is not in the config. + // Fetching the matching key map for the endpoint here to support the scenario where the + // endpoint is changed after the SDK Stats instance is created. This will ensure that the + // correct key / destination is used for the endpoint, and avoids tracking the event if the + // endpoint is not in the config. let endpointCfg = _getEndpointCfg(_statsBeatConfig, statsBeat.type); if (endpointCfg) { - // Check for key remapping - let statsKey = _getIKey(endpointCfg, endpoint); - if (statsKey) { - // Using this iKey for the statsbeat event - statsBeatEvent.iKey = statsKey; - // We have specific config for this endpoint, so we can send the event - sendEvt = true; - } + let keyMap = _getKeyMap(endpointCfg, endpoint); + // Only send the event if the endpoint matched a configured key map (or the event type + // is non-zero, preserving the legacy behaviour for explicitly typed stats events). + if (keyMap || statsBeat.type) { + let useBreeze = _statsBeatConfig.mode === eStatsEndpointType.Breeze; + + // Resolve the iKey to use, falling back to the mode default when the matched key + // map does not specify one. Breeze mode uses the Microsoft-owned breeze SDK Stats + // iKey (region dependent); the SDK Stats endpoint uses the placeholder iKey. + let iKey = (keyMap && keyMap.key) || (useBreeze ? getStatsBreezeIKey(endpoint) : STATS_SDK_IKEY); + statsBeatEvent.iKey = iKey; + + // Resolve the destination SDK Stats ingestion endpoint. When sending to the legacy + // breeze endpoint there is no redirect (the event is sent to the customer's endpoint). + let url = (keyMap && keyMap.url) || (useBreeze ? null : getStatsEndpoint(endpoint)); + if (url) { + // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the + // event away from the customer's breeze endpoint. This marker is removed by the + // channel before the event is serialized. + statsBeatEvent.data = statsBeatEvent.data || {}; + statsBeatEvent.data[STATS_SDK_ENDPOINT_KEY] = url; + } - if (sendEvt) { _core.track(statsBeatEvent); } } @@ -508,24 +544,37 @@ export function createStatsMgr(): IStatsMgr { } /** - * Create the default {@link IStatsMgrConfig} used to enable the SDK Stats collection - * and route the resulting events to the distro-owned SDK Stats ingestion endpoint - * (`stats.monitor.azure.com` / `eu.stats.monitor.azure.com`). SDK Stats are enabled by default and - * can be opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. + * Create the default {@link IStatsMgrConfig} used to enable the SDK Stats collection. By default the + * resulting events are routed to the distro-owned SDK Stats ingestion endpoint + * (`stats.monitor.azure.com` / `eu.stats.monitor.azure.com`). The destination can be changed at + * runtime via the CDN / dynamic config by setting `config.stats` (an {@link IStatsBeatConfig}) - for + * example setting `config.stats.mode` to {@link eStatsEndpointType.Breeze} routes SDK Stats to the + * legacy breeze endpoint instead. SDK Stats are enabled by default and can be opted-out using the + * `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. * @returns The {@link IStatsMgrConfig} to pass to {@link IStatsMgr.init}. */ export function createSdkStatsMgrConfig(): IStatsMgrConfig { return { feature: STATS_SDK_FEATURE, - getCfg: () => ({ - endCfg: [{ - type: eStatsType.SDK, - keyMap: [{ - key: STATS_SDK_IKEY, - match: ["*"] + getCfg: (_core: IAppInsightsCore, cfg: CfgType): IStatsBeatConfig => { + // Read any CDN / dynamic config overrides. Accessing these inside the (dynamic) config + // change handler registers the dependency so changes are picked up at runtime. + let userCfg: IStatsBeatConfig = (cfg && cfg.stats) || {}; + + return { + mode: userCfg.mode || eStatsEndpointType.SdkStats, + shrtInt: userCfg.shrtInt, + // The destination iKey / endpoint are resolved per-event in _track based on the mode, + // so the default key map only needs to match all endpoints. A full key map (including + // explicit keys / urls) may be supplied via config.stats.endCfg to override this. + endCfg: userCfg.endCfg || [{ + type: eStatsType.SDK, + keyMap: [{ + match: ["*"] + }] }] - }] - }) + }; + } }; } diff --git a/shared/AppInsightsCore/src/enums/ai/StatsType.ts b/shared/AppInsightsCore/src/enums/ai/StatsType.ts index 720bd0123..04fcdd96e 100644 --- a/shared/AppInsightsCore/src/enums/ai/StatsType.ts +++ b/shared/AppInsightsCore/src/enums/ai/StatsType.ts @@ -8,3 +8,25 @@ export const enum eStatsType { } export type StatsType = number | eStatsType; + +/** + * Identifies which ingestion endpoint the SDK Stats events are sent to. This is configurable via + * the SDK configuration (and therefore the CDN / dynamic config) so the destination can be changed + * at runtime. + */ +export const enum eStatsEndpointType { + /** + * Send SDK Stats to the distro-owned SDK Stats ingestion endpoint (stats.monitor.azure.com). + * This is the default. + */ + SdkStats = 0, + + /** + * Send SDK Stats to the legacy breeze ingestion endpoint (the customer's own breeze endpoint + * host, using the Microsoft-owned SDK Stats instrumentation key). + */ + Breeze = 1, +} + +export type StatsEndpointType = number | eStatsEndpointType; + diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 134e46587..2df477a6f 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -19,7 +19,7 @@ export { IUnloadHook, ILegacyUnloadHook } from "./interfaces/ai/IUnloadHook"; export { eEventsDiscardedReason, EventsDiscardedReason, eBatchDiscardedReason, BatchDiscardedReason } from "./enums/ai/EventsDiscardedReason"; export { eDependencyTypes, DependencyTypes } from "./enums/ai/DependencyTypes"; export { SendRequestReason } from "./enums/ai/SendRequestReason"; -export { StatsType, eStatsType } from "./enums/ai/StatsType"; +export { StatsType, eStatsType, StatsEndpointType, eStatsEndpointType } from "./enums/ai/StatsType"; export { TelemetryUpdateReason } from "./enums/ai/TelemetryUpdateReason"; export { TelemetryUnloadReason } from "./enums/ai/TelemetryUnloadReason"; export { eUrlRedactionOptions, UrlRedactionOptions } from "./enums/ai/UrlRedactionOptions" @@ -44,8 +44,9 @@ export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; export { IStatsMgr, IStatsMgrConfig } from "./interfaces/ai/IStatsMgr"; export { - createStatsMgr, createSdkStatsMgrConfig, getStatsEndpoint, - STATS_SDK_IKEY, STATS_SDK_ENDPOINT_NON_EU, STATS_SDK_ENDPOINT_EU, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE + createStatsMgr, createSdkStatsMgrConfig, getStatsEndpoint, getStatsBreezeIKey, + STATS_SDK_IKEY, STATS_SDK_ENDPOINT_NON_EU, STATS_SDK_ENDPOINT_EU, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, + STATS_BREEZE_IKEY_NON_EU, STATS_BREEZE_IKEY_EU } from "./core/StatsBeat"; export { isArray, isTypeof, isUndefined, isNullOrUndefined, isStrictUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, diff --git a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts index be2ed3793..5c1889cec 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts @@ -11,6 +11,7 @@ import { IExceptionConfig } from "./IExceptionConfig"; import { IFeatureOptIn } from "./IFeatureOptIn"; import { INotificationManager } from "./INotificationManager"; import { IPerfManager } from "./IPerfManager"; +import { IStatsBeatConfig } from "./IStatsBeat"; import { ITelemetryPlugin } from "./ITelemetryPlugin"; /** @@ -247,6 +248,14 @@ export interface IConfiguration extends IOTelConfig { */ redactQueryParams?: string[]; + /** + * [Optional] Configuration for the SDK Stats (internal SDK statistics) collection. This may be + * supplied / overridden via the CDN / dynamic config to change the SDK Stats behaviour at runtime, + * for example setting `stats.mode` to {@link eStatsEndpointType.Breeze} routes SDK Stats to the + * legacy breeze endpoint instead of the distro-owned SDK Stats endpoint. + */ + stats?: IStatsBeatConfig; + ///** // * [Optional] Internal SDK configuration for developers // * @internal diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts index 09468efab..9ffcf36d9 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { StatsType } from "../../enums/ai/StatsType"; +import { StatsEndpointType, StatsType } from "../../enums/ai/StatsType"; import { IPayloadData } from "./IXHROverride"; /** @@ -80,6 +80,12 @@ export interface IStatsBeatKeyMap { */ key?: string; + /** + * The SDK Stats ingestion endpoint URL that matching events should be redirected to. When + * omitted, matching events are sent to the customer's configured (breeze) endpoint instead. + */ + url?: string; + /** * An array of string URLs that are supported by the endpoint, * the string values are used to compar against the endpoint URL @@ -117,7 +123,16 @@ export interface IStatsBeatConfig { * Default: 15 min */ shrtInt?: number; - + + /** + * Identifies which ingestion endpoint the SDK Stats events are sent to. When set to + * {@link eStatsEndpointType.Breeze} the events are sent to the legacy breeze endpoint, otherwise + * they are sent to the distro-owned SDK Stats endpoint. This is configurable via the CDN / + * dynamic config so the destination can be changed at runtime. + * Default: {@link eStatsEndpointType.SdkStats} + */ + mode?: StatsEndpointType; + /** * The Endpoint configurations for the stats beat plugin. * This is used to identify the endpoints that are supported by the stats beat plugin. From 82dc7e7da5390962df0c9caf6cbd3873368e0f35 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 16 Jun 2026 20:35:18 -0700 Subject: [PATCH 03/51] Optimize repeated property access in SDK Stats endpoint detection Cache telemetryItem.data reference to avoid repeated property lookups. Reduces minified output size in hot path (runs for every telemetry item). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- channels/applicationinsights-channel-js/src/Sender.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 49395dbe5..7eafc99cc 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -507,9 +507,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // so they can be redirected away from the customer's breeze endpoint. Extract and // remove the marker so it is not serialized into the outgoing envelope. let statsEndpoint: string; - if (telemetryItem.data && telemetryItem.data[STATS_SDK_ENDPOINT_KEY]) { - statsEndpoint = telemetryItem.data[STATS_SDK_ENDPOINT_KEY]; - delete telemetryItem.data[STATS_SDK_ENDPOINT_KEY]; + let data = telemetryItem.data; + if (data && data[STATS_SDK_ENDPOINT_KEY]) { + statsEndpoint = data[STATS_SDK_ENDPOINT_KEY]; + delete data[STATS_SDK_ENDPOINT_KEY]; } let aiEnvelope = _getEnvelope(telemetryItem, diagLogger); From dee400d8fcc10a6e808d3de237b63e9acc6ffc48 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 16 Jun 2026 20:40:04 -0700 Subject: [PATCH 04/51] Re-enable SDK Stats unit tests with updated terminology - Uncommented all SDK Stats test files - Updated test case names from 'StatsBeat' to 'SDK Stats' - Updated test assertions to use 'SDK Stats' terminology - Fixed import paths for relocated modules - Tests now reference the feature as 'SDK Stats' in all user-facing messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/StatsBeat.tests.ts | 582 +++++++-------- .../Tests/Unit/src/aichannel.tests.ts | 4 +- .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 672 +++++++++--------- .../Tests/Unit/src/aiunittests.ts | 6 +- 4 files changed, 632 insertions(+), 632 deletions(-) diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index 7b85a2396..22abd0556 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -1,322 +1,322 @@ -// import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; -// import { AppInsightsCore, createStatsMgr, eStatsType, FeatureOptInMode, getWindow, IPayloadData, IStatsBeatState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; -// import { Sender } from "../../../src/Sender"; -// import { SinonSpy, SinonStub } from "sinon"; -// import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; -// import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; +import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; +import { AppInsightsCore, createStatsMgr, eStatsType, FeatureOptInMode, getWindow, IPayloadData, IStatsBeatState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; +import { Sender } from "../../../src/Sender"; +import { SinonSpy, SinonStub } from "sinon"; +import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; +import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; -// export class StatsbeatTests extends AITestClass { -// private _core: AppInsightsCore; -// private _sender: Sender; -// private _statsMgr: IStatsMgr; -// private _statsMgrUnloadHook: IUnloadHook | null; -// private statsbeatCountSpy: SinonSpy; -// private fetchStub: sinon.SinonStub; -// private beaconStub: sinon.SinonStub; -// private trackSpy: SinonSpy; +export class StatsbeatTests extends AITestClass { + private _core: AppInsightsCore; + private _sender: Sender; + private _statsMgr: IStatsMgr; + private _statsMgrUnloadHook: IUnloadHook | null; + private statsbeatCountSpy: SinonSpy; + private fetchStub: sinon.SinonStub; + private beaconStub: sinon.SinonStub; + private trackSpy: SinonSpy; -// public testInitialize() { -// this._core = new AppInsightsCore(); -// this._sender = new Sender(); -// this._statsMgr = createStatsMgr(); -// } + public testInitialize() { + this._core = new AppInsightsCore(); + this._sender = new Sender(); + this._statsMgr = createStatsMgr(); + } -// public testFinishedCleanup() { -// if (this._sender && this._sender.isInitialized()) { -// this._sender.pause(); -// this._sender._buffer.clear(); -// this._sender.teardown(); -// } -// this._sender = null; -// this._core = null; -// this._statsMgr = null; -// if (this._statsMgrUnloadHook) { -// this._statsMgrUnloadHook.rm(); -// this._statsMgrUnloadHook = null; -// } -// if (this.statsbeatCountSpy) { -// this.statsbeatCountSpy.restore(); -// } -// if (this.fetchStub) { -// this.fetchStub.restore(); -// } -// if (this.beaconStub) { -// this.beaconStub.restore(); -// } -// if (this.trackSpy) { -// this.trackSpy.restore(); -// } -// } + public testFinishedCleanup() { + if (this._sender && this._sender.isInitialized()) { + this._sender.pause(); + this._sender._buffer.clear(); + this._sender.teardown(); + } + this._sender = null; + this._core = null; + this._statsMgr = null; + if (this._statsMgrUnloadHook) { + this._statsMgrUnloadHook.rm(); + this._statsMgrUnloadHook = null; + } + if (this.statsbeatCountSpy) { + this.statsbeatCountSpy.restore(); + } + if (this.fetchStub) { + this.fetchStub.restore(); + } + if (this.beaconStub) { + this.beaconStub.restore(); + } + if (this.trackSpy) { + this.trackSpy.restore(); + } + } -// private initializeCoreAndSender(config: any, instrumentationKey: string) { -// const sender = new Sender(); -// const core = new AppInsightsCore(); -// const coreConfig = { -// instrumentationKey, -// _sdk: { -// stats: { -// shrtInt: 900, -// endCfg: [ -// { -// type: 0, -// keyMap: [ -// { -// key: "stats-key1", -// match: [ "https://example.endpoint.com" ] -// } -// ] -// } -// ] -// } -// }, -// extensionConfig: { [sender.identifier]: config } -// }; + private initializeCoreAndSender(config: any, instrumentationKey: string) { + const sender = new Sender(); + const core = new AppInsightsCore(); + const coreConfig = { + instrumentationKey, + _sdk: { + stats: { + shrtInt: 900, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + match: [ "https://example.endpoint.com" ] + } + ] + } + ] + } + }, + extensionConfig: { [sender.identifier]: config } + }; -// let statsMgr = createStatsMgr(); -// // Initialize -// let unloadHook = statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); + let statsMgr = createStatsMgr(); + // Initialize + let unloadHook = statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); -// core.initialize(coreConfig, [sender]); -// core.setStatsMgr(statsMgr); + core.initialize(coreConfig, [sender]); + core.setStatsMgr(statsMgr); -// this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(), "count"); -// this.trackSpy = this.sandbox.spy(core, "track"); + this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(), "count"); + this.trackSpy = this.sandbox.spy(core, "track"); -// this.onDone(() => { -// sender.teardown(); -// }); + this.onDone(() => { + sender.teardown(); + }); -// return { core, sender, statsMgr, unloadHook }; -// } + return { core, sender, statsMgr, unloadHook }; + } -// private createSenderConfig(transportType: TransportType) { -// return { -// endpointUrl: "https://test", -// emitLineDelimitedJson: false, -// maxBatchInterval: 15000, -// maxBatchSizeInBytes: 102400, -// disableTelemetry: false, -// enableSessionStorageBuffer: true, -// isRetryDisabled: false, -// isBeaconApiDisabled: false, -// disableXhr: false, -// onunloadDisableFetch: false, -// onunloadDisableBeacon: false, -// namePrefix: "", -// samplingPercentage: 100, -// customHeaders: [{ header: "header", value: "val" }], -// convertUndefined: "", -// eventsLimitInMem: 10000, -// transports: [transportType] -// }; -// } + private createSenderConfig(transportType: TransportType) { + return { + endpointUrl: "https://test", + emitLineDelimitedJson: false, + maxBatchInterval: 15000, + maxBatchSizeInBytes: 102400, + disableTelemetry: false, + enableSessionStorageBuffer: true, + isRetryDisabled: false, + isBeaconApiDisabled: false, + disableXhr: false, + onunloadDisableFetch: false, + onunloadDisableBeacon: false, + namePrefix: "", + samplingPercentage: 100, + customHeaders: [{ header: "header", value: "val" }], + convertUndefined: "", + eventsLimitInMem: 10000, + transports: [transportType] + }; + } -// private processTelemetryAndFlush(sender: Sender, telemetryItem: ITelemetryItem) { -// try { -// sender.processTelemetry(telemetryItem, null); -// sender.flush(); -// } catch (e) { -// QUnit.assert.ok(false, "Unexpected error during telemetry processing"); -// } -// this.clock.tick(900000); // Simulate time passing for statsbeat to be sent -// } + private processTelemetryAndFlush(sender: Sender, telemetryItem: ITelemetryItem) { + try { + sender.processTelemetry(telemetryItem, null); + sender.flush(); + } catch (e) { + QUnit.assert.ok(false, "Unexpected error during telemetry processing"); + } + this.clock.tick(900000); // Simulate time passing for statsbeat to be sent + } -// private assertStatsbeatCall(statusCode: number, eventName: string) { -// Assert.equal(this.statsbeatCountSpy.callCount, 1, "Statsbeat count should be called once"); -// Assert.equal(this.statsbeatCountSpy.firstCall.args[0], statusCode, `Statsbeat count should be called with status ${statusCode}`); -// const data = JSON.stringify(this.statsbeatCountSpy.firstCall.args[1]); -// Assert.ok(data.includes("startTime"), "Statsbeat count should be called with startTime set"); -// const statsbeatEvent = this.trackSpy.firstCall.args[0]; -// Assert.equal(statsbeatEvent.baseType, "MetricData", "Statsbeat event should be of type MetricData"); -// Assert.equal(statsbeatEvent.baseData.name, eventName, `Statsbeat event should be of type ${eventName}`); -// } + private assertStatsbeatCall(statusCode: number, eventName: string) { + Assert.equal(this.statsbeatCountSpy.callCount, 1, "SDK Stats count should be called once"); + Assert.equal(this.statsbeatCountSpy.firstCall.args[0], statusCode, `Statsbeat count should be called with status ${statusCode}`); + const data = JSON.stringify(this.statsbeatCountSpy.firstCall.args[1]); + Assert.ok(data.includes("startTime"), "SDK Stats count should be called with startTime set"); + const statsbeatEvent = this.trackSpy.firstCall.args[0]; + Assert.equal(statsbeatEvent.baseType, "MetricData", "SDK Stats event should be of type MetricData"); + Assert.equal(statsbeatEvent.baseData.name, eventName, `Statsbeat event should be of type ${eventName}`); + } -// public registerTests() { -// this.testCase({ -// name: "Statsbeat initializes when stats is true", -// test: () => { -// const config = { -// instrumentationKey: "Test-iKey", -// featureOptIn: { -// "StatsBeat": { -// mode: FeatureOptInMode.enable -// } -// }, -// _sdk: { -// stats: { -// shrtInt: 900, -// endCfg: [ -// { -// type: 0, -// keyMap: [ -// { -// key: "stats-key1", -// match: [ "https://example.endpoint.com" ] -// } -// ] -// } -// ] -// } -// }, -// }; + public registerTests() { + this.testCase({ + name: "SDK Stats initializes when stats is true", + test: () => { + const config = { + instrumentationKey: "Test-iKey", + featureOptIn: { + "StatsBeat": { + mode: FeatureOptInMode.enable + } + }, + _sdk: { + stats: { + shrtInt: 900, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + match: [ "https://example.endpoint.com" ] + } + ] + } + ] + } + }, + }; -// this._core.initialize(config, [this._sender]); -// this._statsMgrUnloadHook = this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; + this._core.initialize(config, [this._sender]); + this._statsMgrUnloadHook = this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; -// const statsbeat = this._core.getStatsBeat(statsBeatState); + const statsbeat = this._core.getStatsBeat(statsBeatState); -// QUnit.assert.ok(statsbeat, "Statsbeat is initialized"); -// QUnit.assert.ok(statsbeat.enabled, "Statsbeat is marked as initialized"); -// } -// }); + QUnit.assert.ok(statsbeat, "SDK Stats is initialized"); + QUnit.assert.ok(statsbeat.enabled, "SDK Stats is marked as initialized"); + } + }); -// this.testCaseAsync({ -// name: "Statsbeat increments success count when fetch sender is called once", -// useFakeTimers: true, -// useFakeServer: true, -// stepDelay: 100, -// steps: [ -// () => { -// this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { // only fetch is supported to stub, why? -// return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); -// }); + this.testCaseAsync({ + name: "SDK Stats increments success count when fetch sender is called once", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { // only fetch is supported to stub, why? + return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); + }); -// const config = this.createSenderConfig(TransportType.Fetch); -// const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; -// this.processTelemetryAndFlush(sender, telemetryItem); + this.processTelemetryAndFlush(sender, telemetryItem); -// } -// ].concat(PollingAssert.createPollingAssert(() => { -// if (this.statsbeatCountSpy.called && this.fetchStub.called) { -// this.assertStatsbeatCall(200, "Request_Success_Count"); -// return true; -// } -// return false; -// }, "Waiting for fetch sender and Statsbeat count to be called") as any) -// }); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called && this.fetchStub.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + return true; + } + return false; + }, "Waiting for fetch sender and SDK Stats count to be called") as any) + }); -// this.testCaseAsync({ -// name: "Statsbeat increments throttle count when fetch sender is called with status 439", -// useFakeTimers: true, -// stepDelay: 100, -// steps: [ -// () => { -// this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { -// return Promise.resolve(new Response("{}", { status: 439, statusText: "Too Many Requests" })); -// }); + this.testCaseAsync({ + name: "SDK Stats increments throttle count when fetch sender is called with status 439", + useFakeTimers: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { + return Promise.resolve(new Response("{}", { status: 439, statusText: "Too Many Requests" })); + }); -// const config = this.createSenderConfig(TransportType.Fetch); -// const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; -// this.processTelemetryAndFlush(sender, telemetryItem); -// } -// ].concat(PollingAssert.createPollingAssert(() => { -// if (this.statsbeatCountSpy.called && this.fetchStub.called) { -// this.assertStatsbeatCall(439, "Throttle_Count"); -// return true; -// } -// return false; -// }, "Waiting for fetch sender and Statsbeat count to be called") as any) -// }); + this.processTelemetryAndFlush(sender, telemetryItem); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called && this.fetchStub.called) { + this.assertStatsbeatCall(439, "Throttle_Count"); + return true; + } + return false; + }, "Waiting for fetch sender and SDK Stats count to be called") as any) + }); -// this.testCaseAsync({ -// name: "Statsbeat increments success count for beacon sender", -// useFakeTimers: true, -// stepDelay: 100, -// steps: [ -// () => { -// const config = this.createSenderConfig(TransportType.Beacon); -// const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + this.testCaseAsync({ + name: "SDK Stats increments success count for beacon sender", + useFakeTimers: true, + stepDelay: 100, + steps: [ + () => { + const config = this.createSenderConfig(TransportType.Beacon); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; -// let sendBeaconCalled = false; -// this.hookSendBeacon((url: string) => { -// sendBeaconCalled = true; -// return true; -// }); -// QUnit.assert.ok(isBeaconsSupported(), "Beacon API is supported"); -// this.processTelemetryAndFlush(sender, telemetryItem); -// } -// ].concat(PollingAssert.createPollingAssert(() => { -// if (this.statsbeatCountSpy.called) { -// this.assertStatsbeatCall(200, "Request_Success_Count"); -// return true; -// } -// return false; -// }, "Waiting for beacon sender and Statsbeat count to be called") as any) -// }); + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + let sendBeaconCalled = false; + this.hookSendBeacon((url: string) => { + sendBeaconCalled = true; + return true; + }); + QUnit.assert.ok(isBeaconsSupported(), "Beacon API is supported"); + this.processTelemetryAndFlush(sender, telemetryItem); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + return true; + } + return false; + }, "Waiting for beacon sender and SDK Stats count to be called") as any) + }); -// this.testCaseAsync({ -// name: "Statsbeat increments success count for xhr sender", -// useFakeTimers: true, -// useFakeServer: true, -// stepDelay: 100, -// fakeServerAutoRespond: true, -// steps: [ -// () => { -// let window = getWindow(); -// let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? -// let config = this.createSenderConfig(TransportType.Xhr) && {disableSendBeaconSplit: true}; -// const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); -// console.log("xhr sender called", this._getXhrRequests().length); + this.testCaseAsync({ + name: "SDK Stats increments success count for xhr sender", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + fakeServerAutoRespond: true, + steps: [ + () => { + let window = getWindow(); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? + let config = this.createSenderConfig(TransportType.Xhr) && {disableSendBeaconSplit: true}; + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + console.log("xhr sender called", this._getXhrRequests().length); -// const telemetryItem: ITelemetryItem = { -// name: "fake item", -// iKey: "testIkey2;ingestionendpoint=testUrl1", -// baseType: "some type", -// baseData: {} -// }; -// this.processTelemetryAndFlush(sender, telemetryItem); -// QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); -// console.log("xhr sender is called", this._getXhrRequests().length); -// (window as any).XMLHttpRequest = fakeXMLHttpRequest; + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + this.processTelemetryAndFlush(sender, telemetryItem); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); + console.log("xhr sender is called", this._getXhrRequests().length); + (window as any).XMLHttpRequest = fakeXMLHttpRequest; -// } -// ].concat(PollingAssert.createPollingAssert(() => { -// if (this.statsbeatCountSpy.called) { -// this.assertStatsbeatCall(200, "Request_Success_Count"); -// console.log("Statsbeat count called with success count for xhr sender"); -// return true; -// } -// return false; -// }, "Waiting for xhr sender and Statsbeat count to be called", 60, 1000) as any) -// }); -// } -// } \ No newline at end of file + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + console.log("SDK Stats count called with success count for xhr sender"); + return true; + } + return false; + }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000) as any) + }); +} +} diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts index 477265d46..8f64142fd 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts @@ -1,11 +1,11 @@ import { SenderTests } from "./Sender.tests"; import { SampleTests } from "./Sample.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; -// import { StatsbeatTests } from "./StatsBeat.tests"; +import { StatsbeatTests } from "./StatsBeat.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new SenderTests().registerTests(); new SampleTests().registerTests(); - // new StatsbeatTests().registerTests(); + new StatsbeatTests().registerTests(); } \ No newline at end of file diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index fef4337fa..d71b2cd5d 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -1,366 +1,366 @@ -// import * as sinon from "sinon"; -// import { Assert, AITestClass } from "@microsoft/ai-test-framework"; -// import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; -// import { IStatsMgr } from "../../../../src/JavaScriptSDK.Interfaces/IStatsMgr"; -// import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; -// import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; -// import { createStatsMgr } from "../../../../src/JavaScriptSDK/StatsBeat"; -// import { IStatsBeatState } from "../../../../src/JavaScriptSDK.Interfaces/IStatsBeat"; -// import { eStatsType } from "../../../../src/JavaScriptSDK.Enums/StatsType"; -// import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; -// import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; -// import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; -// import { FeatureOptInMode } from "../../../../src/JavaScriptSDK.Enums/FeatureOptInEnums"; - -// const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes - -// export class StatsBeatTests extends AITestClass { -// private _core: AppInsightsCore; -// private _config: IConfiguration; -// private _statsMgr: IStatsMgr; -// private _trackSpy: sinon.SinonSpy; - -// constructor(emulateIe: boolean) { -// super("StatsBeatTests", emulateIe); -// } - -// public testInitialize() { -// let _self = this; -// super.testInitialize(); +import * as sinon from "sinon"; +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; +import { IStatsMgr } from "../../../../src/interfaces/ai/IStatsMgr"; +import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; +import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; +import { createStatsMgr } from "../../../../src/core/StatsBeat"; +import { IStatsBeatState } from "../../../../src/interfaces/ai/IStatsBeat"; +import { eStatsType } from "../../../../src/enums/ai/StatsType"; +import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; +import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; +import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; +import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; + +const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes + +export class StatsBeatTests extends AITestClass { + private _core: AppInsightsCore; + private _config: IConfiguration; + private _statsMgr: IStatsMgr; + private _trackSpy: sinon.SinonSpy; + + constructor(emulateIe: boolean) { + super("StatsBeatTests", emulateIe); + } + + public testInitialize() { + let _self = this; + super.testInitialize(); -// _self._config = { -// instrumentationKey: "Test-iKey", -// disableInstrumentationKeyValidation: true, -// _sdk: { -// stats: { -// shrtInt: STATS_COLLECTION_SHORT_INTERVAL, -// endCfg: [ -// { -// type: 0, -// keyMap: [ -// { -// key: "stats-key1", -// match: [ "https://example.endpoint.com" ] -// } -// ] -// } -// ] -// } -// } -// }; + _self._config = { + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true, + _sdk: { + stats: { + shrtInt: STATS_COLLECTION_SHORT_INTERVAL, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + match: [ "https://example.endpoint.com" ] + } + ] + } + ] + } + } + }; -// _self._statsMgr = createStatsMgr(); -// _self._core = new AppInsightsCore(); -// // _self._statsMgr.init(_self._core, { -// // feature: "StatsBeat", -// // getCfg: (core, cfg) => { -// // return cfg?._sdk?.stats; -// // } -// // }); + _self._statsMgr = createStatsMgr(); + _self._core = new AppInsightsCore(); + // _self._statsMgr.init(_self._core, { + // feature: "StatsBeat", + // getCfg: (core, cfg) => { + // return cfg?._sdk?.stats; + // } + // }); -// // Create spy for tracking telemetry -// _self._trackSpy = this.sandbox.spy(_self._core, "track"); -// } - -// public testCleanup() { -// super.testCleanup(); -// this._core = null as any; -// this._statsMgr = null as any; -// } - -// public registerTests() { - -// this.testCase({ -// name: "StatsBeat: Initialization", -// test: () => { -// // Test with no initialization -// Assert.equal(false, this._statsMgr.enabled, "StatsBeat should not be initialized by default"); + // Create spy for tracking telemetry + _self._trackSpy = this.sandbox.spy(_self._core, "track"); + } + + public testCleanup() { + super.testCleanup(); + this._core = null as any; + this._statsMgr = null as any; + } + + public registerTests() { + + this.testCase({ + name: "SDK Stats: Initialization", + test: () => { + // Test with no initialization + Assert.equal(false, this._statsMgr.enabled, "SDK Stats manager should not be initialized by default"); -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// Assert.equal(null, this._statsMgr.newInst(statsBeatState), "StatsBeat should not be created before initialization"); - -// // Initialize -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); -// Assert.equal(true, this._statsMgr.enabled, "StatsBeat should be initialized after initialization"); - -// let newInst = this._statsMgr.newInst(statsBeatState); -// Assert.ok(!!newInst, "StatsBeat should be created after initialization"); -// Assert.equal(true, newInst.enabled, "StatsBeat should be enabled after initialization"); -// Assert.equal("https://example.endpoint.com", newInst.endpoint); -// Assert.equal(0, newInst.type); -// } -// }); - -// this.testCase({ -// name: "StatsBeat: count method tracks request metrics", -// useFakeTimers: true, -// test: () => { -// // Initialize StatsBeat -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + Assert.equal(null, this._statsMgr.newInst(statsBeatState), "SDK Stats should not be created before initialization"); + + // Initialize + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); + Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); + + let newInst = this._statsMgr.newInst(statsBeatState); + Assert.ok(!!newInst, "SDK Stats should be created after initialization"); + Assert.equal(true, newInst.enabled, "SDK Stats should be enabled after initialization"); + Assert.equal("https://example.endpoint.com", newInst.endpoint); + Assert.equal(0, newInst.type); + } + }); + + this.testCase({ + name: "SDK Stats: count method tracks request metrics", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); -// // Create mock payload data with timing information -// const payloadData = { -// urlString: "https://example.endpoint.com", -// data: "testData", -// headers: {}, -// timeout: 0, -// disableXhrSync: false, -// statsBeatData: { -// startTime: "2023-10-01T00:00:00Z" // Simulated start time -// } -// } as IPayloadData; + // Create mock payload data with timing information + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsBeatData: { + startTime: "2023-10-01T00:00:00Z" // Simulated start time + } + } as IPayloadData; -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// let statsBeat = this._statsMgr.newInst(statsBeatState); - -// // Test successful request -// statsBeat.count(200, payloadData, "https://example.endpoint.com"); + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); + + // Test successful request + statsBeat.count(200, payloadData, "https://example.endpoint.com"); -// // Test failed request -// statsBeat.count(500, payloadData, "https://example.endpoint.com"); + // Test failed request + statsBeat.count(500, payloadData, "https://example.endpoint.com"); -// // Test throttled request -// statsBeat.count(429, payloadData, "https://example.endpoint.com"); + // Test throttled request + statsBeat.count(429, payloadData, "https://example.endpoint.com"); -// // Verify that trackStatsbeats is called when the timer fires -// this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + // Verify that trackStatsbeats is called when the timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); -// // Verify that track was called -// Assert.ok(this._trackSpy.called, "track should be called when statsbeat timer fires"); + // Verify that track was called + Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); -// // When the timer fires, multiple metrics should be sent -// Assert.ok(this._trackSpy.callCount >= 3, "Multiple metrics should be tracked"); -// } -// }); - -// this.testCase({ -// name: "StatsBeat: countException method tracks exceptions", -// useFakeTimers: true, -// test: () => { -// // Initialize StatsBeat -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// let statsBeat = this._statsMgr.newInst(statsBeatState); + // When the timer fires, multiple metrics should be sent + Assert.ok(this._trackSpy.callCount >= 3, "Multiple metrics should be tracked"); + } + }); + + this.testCase({ + name: "SDK Stats: countException method tracks exceptions", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); + + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); -// // Count an exception -// statsBeat.countException("https://example.endpoint.com", "NetworkError"); + // Count an exception + statsBeat.countException("https://example.endpoint.com", "NetworkError"); -// // Verify that trackStatsbeats is called when the timer fires -// this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + // Verify that trackStatsbeats is called when the timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); -// // Verify that track was called -// Assert.ok(this._trackSpy.called, "track should be called when statsbeat timer fires"); + // Verify that track was called + Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); -// // Check that exception metrics are tracked -// let foundExceptionMetric = false; -// for (let i = 0; i < this._trackSpy.callCount; i++) { -// const call = this._trackSpy.getCall(i); -// const item: ITelemetryItem = call.args[0]; -// if (item.baseData && -// item.baseData.properties && -// item.baseData.properties.exceptionType === "NetworkError") { -// foundExceptionMetric = true; -// break; -// } -// } + // Check that exception metrics are tracked + let foundExceptionMetric = false; + for (let i = 0; i < this._trackSpy.callCount; i++) { + const call = this._trackSpy.getCall(i); + const item: ITelemetryItem = call.args[0]; + if (item.baseData && + item.baseData.properties && + item.baseData.properties.exceptionType === "NetworkError") { + foundExceptionMetric = true; + break; + } + } -// Assert.ok(foundExceptionMetric, "Exception metrics should be tracked"); -// } -// }); - -// this.testCase({ -// name: "StatsBeat: does not send metrics for different endpoints", -// useFakeTimers: true, -// test: () => { -// // Initialize StatsBeat for a specific endpoint -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); + Assert.ok(foundExceptionMetric, "Exception metrics should be tracked"); + } + }); + + this.testCase({ + name: "SDK Stats: does not send metrics for different endpoints", + useFakeTimers: true, + test: () => { + // Initialize SDK Stats manager for a specific endpoint + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); -// // Create mock payload data -// const payloadData = { -// urlString: "https://example.endpoint.com", -// data: "testData", -// headers: {}, -// timeout: 0, -// disableXhrSync: false, -// statsBeatData: { -// startTime: Date.now() -// } -// } as IPayloadData; + // Create mock payload data + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsBeatData: { + startTime: Date.now() + } + } as IPayloadData; -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; -// let statsBeat = this._statsMgr.newInst(statsBeatState); - -// // Set up spies to check internal calls -// const countSpy = this.sandbox.spy(statsBeat, "count"); + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); + + // Set up spies to check internal calls + const countSpy = this.sandbox.spy(statsBeat, "count"); -// // Count metrics for a different endpoint -// statsBeat.count(200, payloadData, "https://different.endpoint.com"); - -// // Verify that trackStatsbeats is called when the timer fires -// this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); -// // The count method was called, but it should return early -// Assert.equal(1, countSpy.callCount, "count method should be called"); -// Assert.equal(0, this._trackSpy.callCount, "track should not be called for different endpoint"); -// } -// }); - -// this.testCase({ -// name: "StatsBeat: test dynamic configuration changes", -// useFakeTimers: true, -// test: () => { -// // Setup core with statsbeat enabled -// this._core.initialize(this._config, [new ChannelPlugin()]); -// // Initialize StatsBeat for a specific endpoint -// this._statsMgr.init(this._core, { -// feature: "StatsBeat", -// getCfg: (core, cfg) => { -// return cfg?._sdk?.stats; -// } -// }); -// this._core.setStatsMgr(this._statsMgr); - -// let statsBeatState: IStatsBeatState = { -// cKey: "Test-iKey", -// endpoint: "https://example.endpoint.com", -// sdkVer: "1.0.0", -// type: eStatsType.SDK -// }; - -// // Verify that statsbeat is created -// const statsbeat = this._core.getStatsBeat(statsBeatState); -// Assert.ok(!!statsbeat, "Statsbeat should be created"); + // Count metrics for a different endpoint + statsBeat.count(200, payloadData, "https://different.endpoint.com"); + + // Verify that trackStatsbeats is called when the timer fires + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + // The count method was called, but it should return early + Assert.equal(1, countSpy.callCount, "count method should be called"); + Assert.equal(0, this._trackSpy.callCount, "track should not be called for different endpoint"); + } + }); + + this.testCase({ + name: "SDK Stats: test dynamic configuration changes", + useFakeTimers: true, + test: () => { + // Setup core with statsbeat enabled + this._core.initialize(this._config, [new ChannelPlugin()]); + // Initialize SDK Stats manager for a specific endpoint + this._statsMgr.init(this._core, { + feature: "StatsBeat", + getCfg: (core, cfg) => { + return cfg?._sdk?.stats; + } + }); + this._core.setStatsMgr(this._statsMgr); + + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + + // Verify that SDK Stats is created + const statsbeat = this._core.getStatsBeat(statsBeatState); + Assert.ok(!!statsbeat, "Statsbeat should be created"); -// // Explicitly disable statsbeat -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; -// this.clock.tick(1); // Allow time for config changes to propagate + // Explicitly disable SDK Stats + this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; + this.clock.tick(1); // Allow time for config changes to propagate -// // Verify that statsbeat is removed -// const updatedStatsbeat = this._core.getStatsBeat(statsBeatState); -// Assert.ok(!updatedStatsbeat, "Statsbeat should be removed when disabled"); + // Verify that SDK Stats is removed + const updatedStatsbeat = this._core.getStatsBeat(statsBeatState); + Assert.ok(!updatedStatsbeat, "SDK Stats should be removed when disabled"); -// // Re-enable statsbeat -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; -// this.clock.tick(1); // Allow time for config changes to propagate + // Re-enable SDK Stats + this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; + this.clock.tick(1); // Allow time for config changes to propagate -// // Verify that statsbeat is created again -// const reenabledStatsbeat = this._core.getStatsBeat(statsBeatState); -// Assert.ok(reenabledStatsbeat, "Statsbeat should be recreated when re-enabled"); + // Verify that SDK Stats is created again + const reenabledStatsbeat = this._core.getStatsBeat(statsBeatState); + Assert.ok(reenabledStatsbeat, "SDK Stats should be recreated when re-enabled"); -// // Test that statsbeat is not created when disabled with undefined -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.none; -// this.clock.tick(1); // Allow time for config changes to propagate + // Test that SDK Stats is not created when disabled with undefined + this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.none; + this.clock.tick(1); // Allow time for config changes to propagate -// // Verify that statsbeat is removed -// Assert.ok(!this._core.getStatsBeat(statsBeatState), "Statsbeat should be removed when disabled"); + // Verify that SDK Stats is removed + Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); -// // Re-enable statsbeat -// this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; -// this.clock.tick(1); // Allow time for config changes to propagate + // Re-enable SDK Stats + this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; + this.clock.tick(1); // Allow time for config changes to propagate -// // Verify that statsbeat is created again -// Assert.ok(!!this._core.getStatsBeat(statsBeatState), "Statsbeat should be recreated when re-enabled"); + // Verify that SDK Stats is created again + Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should be recreated when re-enabled"); -// // Test that statsbeat is not created when disabled with null value -// this._core.config.featureOptIn["StatsBeat"].mode = null; -// this.clock.tick(1); // Allow time for config changes to propagate + // Test that SDK Stats is not created when disabled with null value + this._core.config.featureOptIn["StatsBeat"].mode = null; + this.clock.tick(1); // Allow time for config changes to propagate -// // Verify that statsbeat is removed -// Assert.ok(!this._core.getStatsBeat(statsBeatState), "Statsbeat should be removed when disabled"); -// } -// }); -// } -// } - -// class ChannelPlugin implements IPlugin { -// public isFlushInvoked = false; -// public isTearDownInvoked = false; -// public isResumeInvoked = false; -// public isPauseInvoked = false; - -// public identifier = "Sender"; -// public priority: number = 1001; - -// constructor() { -// this.processTelemetry = this._processTelemetry.bind(this); -// } + // Verify that SDK Stats is removed + Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); + } + }); + } +} + +class ChannelPlugin implements IPlugin { + public isFlushInvoked = false; + public isTearDownInvoked = false; + public isResumeInvoked = false; + public isPauseInvoked = false; + + public identifier = "Sender"; + public priority: number = 1001; + + constructor() { + this.processTelemetry = this._processTelemetry.bind(this); + } -// public pause(): void { -// this.isPauseInvoked = true; -// } - -// public resume(): void { -// this.isResumeInvoked = true; -// } - -// public teardown(): void { -// this.isTearDownInvoked = true; -// } - -// flush(async?: boolean, callBack?: () => void): void { -// this.isFlushInvoked = true; -// if (callBack) { -// callBack(); -// } -// } - -// public processTelemetry(env: ITelemetryItem) {} - -// setNextPlugin(next: any) { -// // no next setup -// } - -// public initialize = (config: IConfiguration, core: IAppInsightsCore, plugin: IPlugin[]) => { -// } - -// private _processTelemetry(env: ITelemetryItem) { -// } -// } - -// class CustomTestError extends Error { -// constructor(message = "") { -// super(message); -// this.name = "CustomTestError"; -// this.message = message + " -- test error."; -// } -// } \ No newline at end of file + public pause(): void { + this.isPauseInvoked = true; + } + + public resume(): void { + this.isResumeInvoked = true; + } + + public teardown(): void { + this.isTearDownInvoked = true; + } + + flush(async?: boolean, callBack?: () => void): void { + this.isFlushInvoked = true; + if (callBack) { + callBack(); + } + } + + public processTelemetry(env: ITelemetryItem) {} + + setNextPlugin(next: any) { + // no next setup + } + + public initialize = (config: IConfiguration, core: IAppInsightsCore, plugin: IPlugin[]) => { + } + + private _processTelemetry(env: ITelemetryItem) { + } +} + +class CustomTestError extends Error { + constructor(message = "") { + super(message); + this.name = "CustomTestError"; + this.message = message + " -- test error."; + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index e3d2307ef..87aa458c0 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -12,7 +12,7 @@ import { EventsDiscardedReasonTests } from "./ai/EventsDiscardedReason.Tests"; import { W3cTraceParentTests } from "./trace/W3cTraceParentTests"; import { DynamicConfigTests } from "./config/DynamicConfig.Tests"; import { SendPostManagerTests } from "./ai/SendPostManager.Tests"; -// import { StatsBeatTests } from "./StatsBeat.Tests"; +import { StatsBeatTests } from "./ai/StatsBeat.Tests"; import { OTelTraceApiTests } from "./trace/traceState.Tests"; import { CommonUtilsTests } from "./OpenTelemetry/commonUtils.Tests"; import { OpenTelemetryErrorsTests } from "./OpenTelemetry/errors.Tests"; @@ -63,8 +63,8 @@ export function runTests() { new W3cTraceStateTests().registerTests(); new TraceUtilsTests().registerTests(); new OTelNegativeTests().registerTests(); - // new StatsBeatTests(false).registerTests(); - // new StatsBeatTests(true).registerTests(); + new StatsBeatTests(false).registerTests(); + new StatsBeatTests(true).registerTests(); new SendPostManagerTests().registerTests(); // Application Insights Common tests (merged from AppInsightsCommon) From a56505dca2b47f22028630fb4101f3d5d6565c29 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 18 Jun 2026 13:56:24 -0700 Subject: [PATCH 05/51] test: address SDK Stats unit test review feedback - Initialize the core before init-ing the stats manager against the same core instance so the manager actually enables (channel + core tests) - Match the stats endCfg to the Sender's endpoint so metrics are tracked - Pass an IStatsBeatState to getStatsBeat() when wiring the count spy - Fix the xhr test config built with '&&' that discarded the Sender config - Guard the dynamic-config test against double core initialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/StatsBeat.tests.ts | 24 +++++++++++++------ .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 24 ++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index 22abd0556..8bbfca9ee 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -62,7 +62,8 @@ export class StatsbeatTests extends AITestClass { keyMap: [ { key: "stats-key1", - match: [ "https://example.endpoint.com" ] + // Match the Sender's endpoint so SDK Stats are tracked for it + match: [ config.endpointUrl ] } ] } @@ -73,18 +74,26 @@ export class StatsbeatTests extends AITestClass { }; let statsMgr = createStatsMgr(); - // Initialize - let unloadHook = statsMgr.init(this._core, { + // Initialize the core first, then init the manager against that same (now initialized) + // core so it can enable itself (createStatsMgr().init() only enables once the core is initialized). + core.initialize(coreConfig, [sender]); + let unloadHook = statsMgr.init(core, { feature: "StatsBeat", getCfg: (core, cfg) => { return cfg?._sdk?.stats; } }); - - core.initialize(coreConfig, [sender]); core.setStatsMgr(statsMgr); + this._statsMgrUnloadHook = unloadHook; + + let statsBeatState: IStatsBeatState = { + cKey: instrumentationKey, + endpoint: config.endpointUrl, + sdkVer: "1.0.0", + type: eStatsType.SDK + }; - this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(), "count"); + this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(statsBeatState), "count"); this.trackSpy = this.sandbox.spy(core, "track"); this.onDone(() => { @@ -293,7 +302,8 @@ export class StatsbeatTests extends AITestClass { () => { let window = getWindow(); let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? - let config = this.createSenderConfig(TransportType.Xhr) && {disableSendBeaconSplit: true}; + let config: any = this.createSenderConfig(TransportType.Xhr); + config.disableSendBeaconSplit = true; const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); console.log("xhr sender called", this._getXhrRequests().length); diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index d71b2cd5d..cb1c465e3 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -31,6 +31,11 @@ export class StatsBeatTests extends AITestClass { _self._config = { instrumentationKey: "Test-iKey", disableInstrumentationKeyValidation: true, + featureOptIn: { + "StatsBeat": { + mode: FeatureOptInMode.enable + } + }, _sdk: { stats: { shrtInt: STATS_COLLECTION_SHORT_INTERVAL, @@ -51,13 +56,11 @@ export class StatsBeatTests extends AITestClass { _self._statsMgr = createStatsMgr(); _self._core = new AppInsightsCore(); - // _self._statsMgr.init(_self._core, { - // feature: "StatsBeat", - // getCfg: (core, cfg) => { - // return cfg?._sdk?.stats; - // } - // }); - + // Initialize the core once here (with a minimal channel plugin) so the stats manager + // can be enabled when init() is called - createStatsMgr().init() only hooks config + // changes and enables the manager when the core is already initialized. + _self._core.initialize(_self._config, [new ChannelPlugin()]); + // Create spy for tracking telemetry _self._trackSpy = this.sandbox.spy(_self._core, "track"); } @@ -249,8 +252,11 @@ export class StatsBeatTests extends AITestClass { name: "SDK Stats: test dynamic configuration changes", useFakeTimers: true, test: () => { - // Setup core with statsbeat enabled - this._core.initialize(this._config, [new ChannelPlugin()]); + // Setup core with statsbeat enabled (guard against re-initialization since the + // core is now initialized in testInitialize()) + if (!this._core.isInitialized()) { + this._core.initialize(this._config, [new ChannelPlugin()]); + } // Initialize SDK Stats manager for a specific endpoint this._statsMgr.init(this._core, { feature: "StatsBeat", From c9fb76b42021c5c011a0e6eceb2d791c4da3004d Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 18 Jun 2026 14:07:14 -0700 Subject: [PATCH 06/51] test: read SDK Stats config from top-level config.stats IConfiguration exposes 'stats' (IStatsBeatConfig) at the top level, not under a '_sdk' wrapper. Update the SDK Stats unit tests to set config.stats and read cfg.stats so they compile against IConfiguration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/StatsBeat.tests.ts | 62 +++++++++---------- .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 38 ++++++------ 2 files changed, 47 insertions(+), 53 deletions(-) diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index 8bbfca9ee..7f9444c47 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -53,22 +53,20 @@ export class StatsbeatTests extends AITestClass { const core = new AppInsightsCore(); const coreConfig = { instrumentationKey, - _sdk: { - stats: { - shrtInt: 900, - endCfg: [ - { - type: 0, - keyMap: [ - { - key: "stats-key1", - // Match the Sender's endpoint so SDK Stats are tracked for it - match: [ config.endpointUrl ] - } - ] - } - ] - } + stats: { + shrtInt: 900, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + // Match the Sender's endpoint so SDK Stats are tracked for it + match: [ config.endpointUrl ] + } + ] + } + ] }, extensionConfig: { [sender.identifier]: config } }; @@ -80,7 +78,7 @@ export class StatsbeatTests extends AITestClass { let unloadHook = statsMgr.init(core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); core.setStatsMgr(statsMgr); @@ -156,21 +154,19 @@ export class StatsbeatTests extends AITestClass { mode: FeatureOptInMode.enable } }, - _sdk: { - stats: { - shrtInt: 900, - endCfg: [ - { - type: 0, - keyMap: [ - { - key: "stats-key1", - match: [ "https://example.endpoint.com" ] - } - ] - } - ] - } + stats: { + shrtInt: 900, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + match: [ "https://example.endpoint.com" ] + } + ] + } + ] }, }; @@ -178,7 +174,7 @@ export class StatsbeatTests extends AITestClass { this._statsMgrUnloadHook = this._statsMgr.init(this._core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); let statsBeatState: IStatsBeatState = { diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index cb1c465e3..ca968c9d3 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -36,21 +36,19 @@ export class StatsBeatTests extends AITestClass { mode: FeatureOptInMode.enable } }, - _sdk: { - stats: { - shrtInt: STATS_COLLECTION_SHORT_INTERVAL, - endCfg: [ - { - type: 0, - keyMap: [ - { - key: "stats-key1", - match: [ "https://example.endpoint.com" ] - } - ] - } - ] - } + stats: { + shrtInt: STATS_COLLECTION_SHORT_INTERVAL, + endCfg: [ + { + type: 0, + keyMap: [ + { + key: "stats-key1", + match: [ "https://example.endpoint.com" ] + } + ] + } + ] } }; @@ -91,7 +89,7 @@ export class StatsBeatTests extends AITestClass { this._statsMgr.init(this._core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); @@ -112,7 +110,7 @@ export class StatsBeatTests extends AITestClass { this._statsMgr.init(this._core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); @@ -164,7 +162,7 @@ export class StatsBeatTests extends AITestClass { this._statsMgr.init(this._core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); @@ -210,7 +208,7 @@ export class StatsBeatTests extends AITestClass { this._statsMgr.init(this._core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); @@ -261,7 +259,7 @@ export class StatsBeatTests extends AITestClass { this._statsMgr.init(this._core, { feature: "StatsBeat", getCfg: (core, cfg) => { - return cfg?._sdk?.stats; + return cfg?.stats; } }); this._core.setStatsMgr(this._statsMgr); From adce17f445ed92f84fcf1093726e8789b88ed430 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 18 Jun 2026 14:28:28 -0700 Subject: [PATCH 07/51] test: fix SDK Stats timer ticks, none/null opt-in expectations, core size budget - Tick the real short interval (shrtInt*1000 ms) so the stats timer fires in the count/countException tests - FeatureOptInMode.none and a null mode fall back to the SDK default (enabled), so assert SDK Stats stays enabled rather than being removed - Bump core size budget to 135 KB raw / 55 KB deflate to cover the re-enabled getStatsBeat/setStatsMgr core APIs (was 133/54) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Unit/src/ai/AppInsightsCoreSize.Tests.ts | 8 +++--- .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 26 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index 367df5462..d154f576d 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,10 +51,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 133; - private readonly MAX_BUNDLE_SIZE = 133; - private readonly MAX_RAW_DEFLATE_SIZE = 54; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 54; + private readonly MAX_RAW_SIZE = 135; + private readonly MAX_BUNDLE_SIZE = 135; + private readonly MAX_RAW_DEFLATE_SIZE = 55; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 55; private readonly rawFilePath = "../dist/es5/index.min.js"; private readonly prodFilePath = "../browser/es5/applicationinsights-core-js.min.js"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index ca968c9d3..e56eed4fe 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -144,7 +144,7 @@ export class StatsBeatTests extends AITestClass { statsBeat.count(429, payloadData, "https://example.endpoint.com"); // Verify that trackStatsbeats is called when the timer fires - this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // Verify that track was called Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); @@ -178,7 +178,7 @@ export class StatsBeatTests extends AITestClass { statsBeat.countException("https://example.endpoint.com", "NetworkError"); // Verify that trackStatsbeats is called when the timer fires - this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // Verify that track was called Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); @@ -239,7 +239,7 @@ export class StatsBeatTests extends AITestClass { statsBeat.count(200, payloadData, "https://different.endpoint.com"); // Verify that trackStatsbeats is called when the timer fires - this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL + 1); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // The count method was called, but it should return early Assert.equal(1, countSpy.callCount, "count method should be called"); Assert.equal(0, this._trackSpy.callCount, "track should not be called for different endpoint"); @@ -291,26 +291,24 @@ export class StatsBeatTests extends AITestClass { const reenabledStatsbeat = this._core.getStatsBeat(statsBeatState); Assert.ok(reenabledStatsbeat, "SDK Stats should be recreated when re-enabled"); - // Test that SDK Stats is not created when disabled with undefined + // FeatureOptInMode.none falls back to the SDK default state (enabled), so SDK Stats stays enabled this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.none; this.clock.tick(1); // Allow time for config changes to propagate - // Verify that SDK Stats is removed - Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); + // Verify that SDK Stats remains enabled (none defaults to enabled) + Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is none (defaults to enabled)"); - // Re-enable SDK Stats - this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; + // Explicitly disable again before testing the null case + this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; this.clock.tick(1); // Allow time for config changes to propagate - - // Verify that SDK Stats is created again - Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should be recreated when re-enabled"); + Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); - // Test that SDK Stats is not created when disabled with null value + // A null mode also falls back to the SDK default state (enabled) this._core.config.featureOptIn["StatsBeat"].mode = null; this.clock.tick(1); // Allow time for config changes to propagate - // Verify that SDK Stats is removed - Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); + // Verify that SDK Stats is recreated (null defaults to enabled) + Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is null (defaults to enabled)"); } }); } From 515494dc598c4147e264ae157ed46c332bdc5368 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 18 Jun 2026 14:55:06 -0700 Subject: [PATCH 08/51] test: wire stats manager to core in channel init test The 'SDK Stats initializes when stats is true' test initialized the stats manager but never called core.setStatsMgr(), so core.getStatsBeat() returned null. Register the manager with the core so the instance is created. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/StatsBeat.tests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index 7f9444c47..105e00446 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -177,6 +177,7 @@ export class StatsbeatTests extends AITestClass { return cfg?.stats; } }); + this._core.setStatsMgr(this._statsMgr); let statsBeatState: IStatsBeatState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", From bf53f68c1c89f4419a2b7a6315e226f4c79676a8 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 18 Jun 2026 15:21:21 -0700 Subject: [PATCH 09/51] test: bump AISKU size budget for SDK Stats enablement Enabling SDK Stats from AISKU pulls in the re-enabled core getStatsBeat/setStatsMgr APIs and stats manager wiring, growing the AISKU bundle. Bump the budget to 178 KB / 72 KB deflate (was 175/71). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index 3193c7b28..1a79df0b3 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -54,10 +54,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 175; - private readonly MAX_BUNDLE_SIZE = 175; - private readonly MAX_RAW_DEFLATE_SIZE = 71; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 71; + private readonly MAX_RAW_SIZE = 178; + private readonly MAX_BUNDLE_SIZE = 178; + private readonly MAX_RAW_DEFLATE_SIZE = 72; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 72; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.4.1"; From 5fd01d9a0e12a0d608a067aedbaf6bd03d6e1db9 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:35:32 -0700 Subject: [PATCH 10/51] test: bump core and AISKU bundle-size budgets for SDK Stats The new SDK Stats code in AppInsightsCore pushes the core bundle past its 135 KB budget and the AISKU bundle past its 178 KB / 72 KB budgets. Bump core to 137 KB and AISKU to 181 KB / 73 KB to match measured sizes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 8 ++++---- .../Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index 799b6c47e..95fdd2eae 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -54,10 +54,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 178; - private readonly MAX_BUNDLE_SIZE = 178; - private readonly MAX_RAW_DEFLATE_SIZE = 72; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 72; + private readonly MAX_RAW_SIZE = 181; + private readonly MAX_BUNDLE_SIZE = 181; + private readonly MAX_RAW_DEFLATE_SIZE = 73; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 73; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.4.2"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index b75be44af..9ba08baa5 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,8 +51,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 135; - private readonly MAX_BUNDLE_SIZE = 135; + private readonly MAX_RAW_SIZE = 137; + private readonly MAX_BUNDLE_SIZE = 137; private readonly MAX_RAW_DEFLATE_SIZE = 55; private readonly MAX_BUNDLE_DEFLATE_SIZE = 55; private readonly rawFilePath = "../dist/es5/index.min.js"; From 04410c8026e7f07c4da78c7901bb04ec1ae62c70 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:48:23 -0700 Subject: [PATCH 11/51] fix: seed SDK Stats defaults into the global config for dynamic config support Replace the detached config snapshot in createSdkStatsMgrConfig with the setDf/ref pattern so SDK Stats defaults live in the single global config (overridable via CDN/dynamic config and the SKUs) and runtime changes are tracked. Updates IStatsMgrConfig.getCfg to receive IWatchDetails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/StatsBeat.tests.ts | 8 +-- .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 20 ++++---- shared/AppInsightsCore/src/core/StatsBeat.ts | 49 ++++++++++++------- .../src/interfaces/ai/IStatsMgr.ts | 10 +++- 4 files changed, 52 insertions(+), 35 deletions(-) diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index 105e00446..b206cc075 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -77,8 +77,8 @@ export class StatsbeatTests extends AITestClass { core.initialize(coreConfig, [sender]); let unloadHook = statsMgr.init(core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); core.setStatsMgr(statsMgr); @@ -173,8 +173,8 @@ export class StatsbeatTests extends AITestClass { this._core.initialize(config, [this._sender]); this._statsMgrUnloadHook = this._statsMgr.init(this._core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); this._core.setStatsMgr(this._statsMgr); diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index e56eed4fe..ddb8a501b 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -88,8 +88,8 @@ export class StatsBeatTests extends AITestClass { // Initialize this._statsMgr.init(this._core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); @@ -109,8 +109,8 @@ export class StatsBeatTests extends AITestClass { // Initialize SDK Stats manager this._statsMgr.init(this._core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); @@ -161,8 +161,8 @@ export class StatsBeatTests extends AITestClass { // Initialize SDK Stats manager this._statsMgr.init(this._core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); @@ -207,8 +207,8 @@ export class StatsBeatTests extends AITestClass { // Initialize SDK Stats manager for a specific endpoint this._statsMgr.init(this._core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); @@ -258,8 +258,8 @@ export class StatsBeatTests extends AITestClass { // Initialize SDK Stats manager for a specific endpoint this._statsMgr.init(this._core, { feature: "StatsBeat", - getCfg: (core, cfg) => { - return cfg?.stats; + getCfg: (core, details) => { + return details.cfg?.stats; } }); this._core.setStatsMgr(this._statsMgr); diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts index 035e13359..0d65f7efc 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/StatsBeat.ts @@ -4,6 +4,7 @@ import { ITimerHandler, arrForEach, isNumber, makeGlobRegex, objDefineProps, scheduleTimeout, strIndexOf, strLower, utcNow } from "@nevware21/ts-utils"; +import { cfgDfMerge } from "../config/ConfigDefaultHelpers"; import { onConfigChange } from "../config/DynamicConfig"; import { STR_EMPTY } from "../constants/InternalConstants"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; @@ -16,6 +17,8 @@ import { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap, IStatsBeatState, IStats import { IStatsMgr, IStatsMgrConfig } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; +import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; +import { IWatchDetails } from "../interfaces/config/IDynamicWatcher"; import { isFeatureEnabled } from "../utils/HelperFuncs"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes @@ -463,7 +466,7 @@ export function createStatsMgr(): IStatsMgr { // Call the getCfg function to get the latest configuration for the statsbeat instance // This should also evaluate the throttling level and other settings for the statsbeat instance // to determine if it should be enabled or not. - _statsBeatConfig = statsConfig.getCfg(core, details.cfg); + _statsBeatConfig = statsConfig.getCfg(core, details); if (_statsBeatConfig) { _isMgrEnabled = true; _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed @@ -543,6 +546,26 @@ export function createStatsMgr(): IStatsMgr { }); } +/** + * The default {@link IStatsBeatConfig} values for SDK Stats collection. These are seeded into the + * single global config (via {@link IWatchDetails.setDf}) so they remain dynamic and can be overridden + * at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). + */ +const _sdkStatsDefaults: IConfigDefaults = { + stats: cfgDfMerge({ + mode: eStatsEndpointType.SdkStats, + // The destination iKey / endpoint are resolved per-event in _track based on the mode, so the + // default key map only needs to match all endpoints. A full key map (including explicit keys / + // urls) may be supplied via config.stats.endCfg to override this. + endCfg: [{ + type: eStatsType.SDK, + keyMap: [{ + match: ["*"] + }] + }] + }) +}; + /** * Create the default {@link IStatsMgrConfig} used to enable the SDK Stats collection. By default the * resulting events are routed to the distro-owned SDK Stats ingestion endpoint @@ -556,24 +579,12 @@ export function createStatsMgr(): IStatsMgr { export function createSdkStatsMgrConfig(): IStatsMgrConfig { return { feature: STATS_SDK_FEATURE, - getCfg: (_core: IAppInsightsCore, cfg: CfgType): IStatsBeatConfig => { - // Read any CDN / dynamic config overrides. Accessing these inside the (dynamic) config - // change handler registers the dependency so changes are picked up at runtime. - let userCfg: IStatsBeatConfig = (cfg && cfg.stats) || {}; - - return { - mode: userCfg.mode || eStatsEndpointType.SdkStats, - shrtInt: userCfg.shrtInt, - // The destination iKey / endpoint are resolved per-event in _track based on the mode, - // so the default key map only needs to match all endpoints. A full key map (including - // explicit keys / urls) may be supplied via config.stats.endCfg to override this. - endCfg: userCfg.endCfg || [{ - type: eStatsType.SDK, - keyMap: [{ - match: ["*"] - }] - }] - }; + getCfg: (_core: IAppInsightsCore, details: IWatchDetails): IStatsBeatConfig => { + // Seed the SDK Stats defaults into the single global config so they remain dynamic and can be + // overridden via the CDN / dynamic config or by the SKU. Return a live reference to the nested + // stats config so the dependency is registered and runtime changes are picked up. + details.setDf(details.cfg, _sdkStatsDefaults as IConfigDefaults); + return details.ref(details.cfg, "stats"); } }; } diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index 1913a3ac9..ad67fa5c3 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { IWatchDetails } from "../config/IDynamicWatcher"; import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "./IStatsBeat"; @@ -25,12 +26,17 @@ export interface IStatsMgrConfig, cfg: CfgType) => IStatsBeatConfig | undefined | null; + getCfg: (core: IAppInsightsCore, details: IWatchDetails) => IStatsBeatConfig | undefined | null; } /** From 5f9c7800c49def9ff5a954b50b68d8716b595e5f Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:04:25 -0700 Subject: [PATCH 12/51] test: bump core and AISKU deflate budgets for SDK Stats dynamic-config refactor The setDf/ref refactor in createSdkStatsMgrConfig pushes the core deflate size just past 55 KB (55.01 KB) and the AISKU deflate budget; bump core deflate 55 -> 56 KB and AISKU deflate 73 -> 74 KB to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 4 ++-- .../Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index 95fdd2eae..675f5b2fb 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -56,8 +56,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: export class AISKUSizeCheck extends AITestClass { private readonly MAX_RAW_SIZE = 181; private readonly MAX_BUNDLE_SIZE = 181; - private readonly MAX_RAW_DEFLATE_SIZE = 73; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 73; + private readonly MAX_RAW_DEFLATE_SIZE = 74; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 74; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.4.2"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index 9ba08baa5..c57bf2b50 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -53,8 +53,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: export class AppInsightsCoreSizeCheck extends AITestClass { private readonly MAX_RAW_SIZE = 137; private readonly MAX_BUNDLE_SIZE = 137; - private readonly MAX_RAW_DEFLATE_SIZE = 55; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 55; + private readonly MAX_RAW_DEFLATE_SIZE = 56; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; private readonly rawFilePath = "../dist/es5/index.min.js"; private readonly prodFilePath = "../browser/es5/applicationinsights-core-js.min.js"; From ff5f5fa0454ac579ff9c094134f3c781297044c0 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:13:58 -0700 Subject: [PATCH 13/51] refactor(stats): remove createSdkStatsMgrConfig, read SDK Stats config from the global config The SDK Stats manager now references the single global config (config.stats) directly and picks up dynamic/CDN changes at runtime, gated behind the sdkStats feature flag (enabled by default, opt-out via featureOptIn). Removes the createSdkStatsMgrConfig factory and IStatsMgrConfig interface; init is now init(core, featureName?). Updates AISKU and StatsBeat tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 9 +- .../Tests/Unit/src/StatsBeat.tests.ts | 94 +++++++++---------- .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 61 ++++-------- shared/AppInsightsCore/src/core/StatsBeat.ts | 55 ++++------- shared/AppInsightsCore/src/index.ts | 4 +- .../src/interfaces/ai/IStatsMgr.ts | 46 ++------- 6 files changed, 94 insertions(+), 175 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index f6a50f928..1b852cedd 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -15,7 +15,7 @@ import { ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, IThrottleInterval, IThrottleLimit, IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, PropertiesPluginIdentifier, ThrottleMgr, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, - createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsMgrConfig, createSdkStatsNotifCbk, createStatsMgr, + createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsNotifCbk, createStatsMgr, createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, removePageHideEventListener, removePageUnloadEventListener, useSpan @@ -398,13 +398,14 @@ export class AppInsightsSku implements IApplicationInsights()); + let statsHook = statsMgr.init(_core); if (statsHook) { _core.addUnloadHook(statsHook); } diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts index b206cc075..7ba4e0740 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts @@ -56,7 +56,7 @@ export class StatsbeatTests extends AITestClass { stats: { shrtInt: 900, endCfg: [ - { + { type: 0, keyMap: [ { @@ -75,12 +75,7 @@ export class StatsbeatTests extends AITestClass { // Initialize the core first, then init the manager against that same (now initialized) // core so it can enable itself (createStatsMgr().init() only enables once the core is initialized). core.initialize(coreConfig, [sender]); - let unloadHook = statsMgr.init(core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); + let unloadHook = statsMgr.init(core, "StatsBeat"); core.setStatsMgr(statsMgr); this._statsMgrUnloadHook = unloadHook; @@ -154,10 +149,10 @@ export class StatsbeatTests extends AITestClass { mode: FeatureOptInMode.enable } }, - stats: { + stats: { shrtInt: 900, endCfg: [ - { + { type: 0, keyMap: [ { @@ -167,23 +162,18 @@ export class StatsbeatTests extends AITestClass { ] } ] - }, + } }; this._core.initialize(config, [this._sender]); - this._statsMgrUnloadHook = this._statsMgr.init(this._core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); + this._statsMgrUnloadHook = this._statsMgr.init(this._core, "StatsBeat"); this._core.setStatsMgr(this._statsMgr); let statsBeatState: IStatsBeatState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK - }; + }; const statsbeat = this._core.getStatsBeat(statsBeatState); @@ -289,41 +279,41 @@ export class StatsbeatTests extends AITestClass { }); - this.testCaseAsync({ - name: "SDK Stats increments success count for xhr sender", - useFakeTimers: true, - useFakeServer: true, - stepDelay: 100, - fakeServerAutoRespond: true, - steps: [ - () => { - let window = getWindow(); - let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? - let config: any = this.createSenderConfig(TransportType.Xhr); - config.disableSendBeaconSplit = true; - const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); - console.log("xhr sender called", this._getXhrRequests().length); + this.testCaseAsync({ + name: "SDK Stats increments success count for xhr sender", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + fakeServerAutoRespond: true, + steps: [ + () => { + let window = getWindow(); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? + let config: any = this.createSenderConfig(TransportType.Xhr); + config.disableSendBeaconSplit = true; + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + console.log("xhr sender called", this._getXhrRequests().length); - const telemetryItem: ITelemetryItem = { - name: "fake item", - iKey: "testIkey2;ingestionendpoint=testUrl1", - baseType: "some type", - baseData: {} - }; - this.processTelemetryAndFlush(sender, telemetryItem); - QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); - console.log("xhr sender is called", this._getXhrRequests().length); - (window as any).XMLHttpRequest = fakeXMLHttpRequest; + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + this.processTelemetryAndFlush(sender, telemetryItem); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); + console.log("xhr sender is called", this._getXhrRequests().length); + (window as any).XMLHttpRequest = fakeXMLHttpRequest; - } - ].concat(PollingAssert.createPollingAssert(() => { - if (this.statsbeatCountSpy.called) { - this.assertStatsbeatCall(200, "Request_Success_Count"); - console.log("SDK Stats count called with success count for xhr sender"); - return true; - } - return false; - }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000) as any) - }); -} + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.statsbeatCountSpy.called) { + this.assertStatsbeatCall(200, "Request_Success_Count"); + console.log("SDK Stats count called with success count for xhr sender"); + return true; + } + return false; + }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000) as any) + }); + } } diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index ddb8a501b..14fe6a57a 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -39,7 +39,7 @@ export class StatsBeatTests extends AITestClass { stats: { shrtInt: STATS_COLLECTION_SHORT_INTERVAL, endCfg: [ - { + { type: 0, keyMap: [ { @@ -86,12 +86,7 @@ export class StatsBeatTests extends AITestClass { Assert.equal(null, this._statsMgr.newInst(statsBeatState), "SDK Stats should not be created before initialization"); // Initialize - this._statsMgr.init(this._core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); + this._statsMgr.init(this._core, "StatsBeat"); Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); let newInst = this._statsMgr.newInst(statsBeatState); @@ -107,12 +102,7 @@ export class StatsBeatTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager - this._statsMgr.init(this._core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); + this._statsMgr.init(this._core, "StatsBeat"); // Create mock payload data with timing information const payloadData = { @@ -131,7 +121,7 @@ export class StatsBeatTests extends AITestClass { endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK - }; + }; let statsBeat = this._statsMgr.newInst(statsBeatState); // Test successful request @@ -159,20 +149,15 @@ export class StatsBeatTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager - this._statsMgr.init(this._core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); + this._statsMgr.init(this._core, "StatsBeat"); let statsBeatState: IStatsBeatState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK - }; - let statsBeat = this._statsMgr.newInst(statsBeatState); + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); // Count an exception statsBeat.countException("https://example.endpoint.com", "NetworkError"); @@ -188,8 +173,8 @@ export class StatsBeatTests extends AITestClass { for (let i = 0; i < this._trackSpy.callCount; i++) { const call = this._trackSpy.getCall(i); const item: ITelemetryItem = call.args[0]; - if (item.baseData && - item.baseData.properties && + if (item.baseData && + item.baseData.properties && item.baseData.properties.exceptionType === "NetworkError") { foundExceptionMetric = true; break; @@ -205,12 +190,7 @@ export class StatsBeatTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager for a specific endpoint - this._statsMgr.init(this._core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); + this._statsMgr.init(this._core, "StatsBeat"); // Create mock payload data const payloadData = { @@ -229,8 +209,8 @@ export class StatsBeatTests extends AITestClass { endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK - }; - let statsBeat = this._statsMgr.newInst(statsBeatState); + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); // Set up spies to check internal calls const countSpy = this.sandbox.spy(statsBeat, "count"); @@ -256,20 +236,15 @@ export class StatsBeatTests extends AITestClass { this._core.initialize(this._config, [new ChannelPlugin()]); } // Initialize SDK Stats manager for a specific endpoint - this._statsMgr.init(this._core, { - feature: "StatsBeat", - getCfg: (core, details) => { - return details.cfg?.stats; - } - }); - this._core.setStatsMgr(this._statsMgr); + this._statsMgr.init(this._core, "StatsBeat"); + this._core.setStatsMgr(this._statsMgr); let statsBeatState: IStatsBeatState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK - }; + }; // Verify that SDK Stats is created const statsbeat = this._core.getStatsBeat(statsBeatState); @@ -361,8 +336,8 @@ class ChannelPlugin implements IPlugin { class CustomTestError extends Error { constructor(message = "") { - super(message); - this.name = "CustomTestError"; - this.message = message + " -- test error."; + super(message); + this.name = "CustomTestError"; + this.message = message + " -- test error."; } } diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts index 0d65f7efc..bbc936029 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/StatsBeat.ts @@ -14,11 +14,10 @@ import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { INetworkStatsbeat } from "../interfaces/ai/INetworkStatsbeat"; import { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap, IStatsBeatState, IStatsEndpointConfig } from "../interfaces/ai/IStatsBeat"; -import { IStatsMgr, IStatsMgrConfig } from "../interfaces/ai/IStatsMgr"; +import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; -import { IWatchDetails } from "../interfaces/config/IDynamicWatcher"; import { isFeatureEnabled } from "../utils/HelperFuncs"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes @@ -448,7 +447,7 @@ export function createStatsMgr(): IStatsMgr { // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again - function _init(core: IAppInsightsCore, statsConfig: IStatsMgrConfig, featureName?: string) { + function _init(core: IAppInsightsCore, featureName?: string) { if (_core) { // If the core is already set, then just return with an empty unload hook _throwInternal(safeGetLogger(core), eLoggingSeverity.WARNING, _eInternalMessageId.StatsBeatManagerException, "StatsBeat manager is already initialized"); @@ -457,16 +456,18 @@ export function createStatsMgr(): IStatsMgr { _core = core; if (core && core.isInitialized()) { - // Start listening for configuration changes from the core config, within a config change handler - // This will support the scenario where the config is changed after the statsbeat has been created + // Start listening for configuration changes from the single global config, within a config + // change handler. This supports the scenario where the config is changed after the manager + // has been created (including CDN / dynamic config updates). return onConfigChange(core.config, (details) => { - // Check the feature state again to see if it has changed + // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) _isMgrEnabled = false; - if (statsConfig && isFeatureEnabled(statsConfig.feature, details.cfg, true) === true) { - // Call the getCfg function to get the latest configuration for the statsbeat instance - // This should also evaluate the throttling level and other settings for the statsbeat instance - // to determine if it should be enabled or not. - _statsBeatConfig = statsConfig.getCfg(core, details); + if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { + // Seed the SDK Stats defaults into the single global config so they remain dynamic and + // can be overridden via the CDN / dynamic config or by the SKU, then reference the live + // nested stats config so the dependency is registered and runtime changes are picked up. + details.setDf(details.cfg, _sdkStatsDefaults as IConfigDefaults); + _statsBeatConfig = details.ref(details.cfg, "stats"); if (_statsBeatConfig) { _isMgrEnabled = true; _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed @@ -548,8 +549,12 @@ export function createStatsMgr(): IStatsMgr { /** * The default {@link IStatsBeatConfig} values for SDK Stats collection. These are seeded into the - * single global config (via {@link IWatchDetails.setDf}) so they remain dynamic and can be overridden - * at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). + * single global config (via {@link IWatchDetails.setDf}) by the manager so they remain dynamic and + * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). By default + * the events are routed to the distro-owned SDK Stats ingestion endpoint (`stats.monitor.azure.com` + * / `eu.stats.monitor.azure.com`); setting `config.stats.mode` to {@link eStatsEndpointType.Breeze} + * routes SDK Stats to the legacy breeze endpoint instead. SDK Stats are enabled by default and can be + * opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. */ const _sdkStatsDefaults: IConfigDefaults = { stats: cfgDfMerge({ @@ -565,27 +570,3 @@ const _sdkStatsDefaults: IConfigDefaults = { }] }) }; - -/** - * Create the default {@link IStatsMgrConfig} used to enable the SDK Stats collection. By default the - * resulting events are routed to the distro-owned SDK Stats ingestion endpoint - * (`stats.monitor.azure.com` / `eu.stats.monitor.azure.com`). The destination can be changed at - * runtime via the CDN / dynamic config by setting `config.stats` (an {@link IStatsBeatConfig}) - for - * example setting `config.stats.mode` to {@link eStatsEndpointType.Breeze} routes SDK Stats to the - * legacy breeze endpoint instead. SDK Stats are enabled by default and can be opted-out using the - * `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. - * @returns The {@link IStatsMgrConfig} to pass to {@link IStatsMgr.init}. - */ -export function createSdkStatsMgrConfig(): IStatsMgrConfig { - return { - feature: STATS_SDK_FEATURE, - getCfg: (_core: IAppInsightsCore, details: IWatchDetails): IStatsBeatConfig => { - // Seed the SDK Stats defaults into the single global config so they remain dynamic and can be - // overridden via the CDN / dynamic config or by the SKU. Return a live reference to the nested - // stats config so the dependency is registered and runtime changes are picked up. - details.setDf(details.cfg, _sdkStatsDefaults as IConfigDefaults); - return details.ref(details.cfg, "stats"); - } - }; -} - diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 690d6636b..b3c97601a 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -42,9 +42,9 @@ export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IIn export { SenderPostManager } from "./core/SenderPostManager"; export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState} from "./interfaces/ai/IStatsBeat"; export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; -export { IStatsMgr, IStatsMgrConfig } from "./interfaces/ai/IStatsMgr"; +export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { - createStatsMgr, createSdkStatsMgrConfig, getStatsEndpoint, getStatsBreezeIKey, + createStatsMgr, getStatsEndpoint, getStatsBreezeIKey, STATS_SDK_IKEY, STATS_SDK_ENDPOINT_NON_EU, STATS_SDK_ENDPOINT_EU, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, STATS_BREEZE_IKEY_NON_EU, STATS_BREEZE_IKEY_EU } from "./core/StatsBeat"; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index ad67fa5c3..8e1057e43 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -1,44 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { IWatchDetails } from "../config/IDynamicWatcher"; import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; -import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "./IStatsBeat"; +import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; import { IUnloadHook } from "./IUnloadHook"; -/** - * The interface for the Stats manager, which is passed to the StatsBeat instance - * during initialization. It provides an abstractions to allow the StatsBeat instance to - * access the configuration and state of the StatsBeat manager. - */ -export interface IStatsMgrConfig { - - /** - * Identifies the feature name used for this instance to determine if the StatsBeat instance - * should be initialized or not. This is used to identify the feature that this instance of the - */ - feature: string; - - /** - * A function to obtain the current configuration for the StatsBeat instance, this callback - * is called in a dynamic config context, so when any of the configuration values change, - * tis function will be called again to obtain the latest configuration values. - * This should also evaluate any throttling level and other settings for the statsbeat instance - * to determine if it should be enabled or not and return the appropriate configuration object. - * @param core - The core instance associated with the StatsBeat manager. - * @param details - The dynamic config watcher details for the single global config. Use - * {@link IWatchDetails.setDf} to seed any defaults into the global config (so they remain dynamic - * and can be overridden by the CDN / SKU) and {@link IWatchDetails.ref} to obtain a live reference - * to the nested config so runtime changes are tracked. Read the required values from - * {@link IWatchDetails.cfg}. - * @returns The configuration object that should be used to initialize / reinitialize the StatsBeat instance. - * It may return null if the StatsBeat instance should not be initialized or reinitialized, if the manager - * is already initialized and null is returned, the StatsBeat instance will be disabled. - */ - getCfg: (core: IAppInsightsCore, details: IWatchDetails) => IStatsBeatConfig | undefined | null; -} - /** * The Interface which defines the StatsBeat manager, which is responsible for creating and * managing the StatsBeat instance. @@ -52,14 +19,19 @@ export interface IStatsMgr { readonly enabled: boolean; /** - * Initialize and associate this manager with the provided core instance and configuration. + * Initialize and associate this manager with the provided core instance. The manager reads its + * configuration directly from the single global config (`config.stats`) and gates itself behind + * the SDK Stats feature flag, so any changes made via the CDN / dynamic config are picked up at + * runtime. * @param core - The core instance to associate with this manager. - * @param isEnabled - + * @param featureName - The optional featureOptIn name used to gate the manager. Defaults to the + * SDK Stats feature (`STATS_SDK_FEATURE`) which is enabled by default and can be opted-out via the + * `featureOptIn` configuration. * @returns The unload hook for the stats beat manager, which can be used to unload * and disable the manager. This may return null if the manager cannot be initialized. * @remarks This method should be called only once, and it may throw an error if called multiple times. */ - init: (core: IAppInsightsCore, cfg: IStatsMgrConfig) => IUnloadHook | null; + init: (core: IAppInsightsCore, featureName?: string) => IUnloadHook | null; /** * Returns a new {@link IStatsBeat} instance for the current state which includes the endpoint. From 1a3fbb0582c1de4517ced2b7d51d9f3de23f8cde Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Sat, 18 Jul 2026 14:13:49 -0700 Subject: [PATCH 14/51] Read SDK Stats config from cfg/v1.json and gate on the enabled flag Instead of embedding the ingestion URL, fetch the runtime SDK Stats config (data.stats.monitor.azure.com / eu-data.stats.monitor.azure.com cfg/v1.json), check the enabled flag, and route events to the returned url + /v2/track when enabled. Adds the OneCollector path constant as scaffolding for future 1DS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 2 +- .../Tests/Unit/src/StatsBeat.tests.ts | 8 + .../Unit/src/ai/AppInsightsCoreSize.Tests.ts | 4 +- .../Tests/Unit/src/ai/StatsBeat.Tests.ts | 90 ++++++++- shared/AppInsightsCore/src/core/StatsBeat.ts | 186 +++++++++++++++--- .../AppInsightsCore/src/enums/ai/StatsType.ts | 5 +- shared/AppInsightsCore/src/index.ts | 8 +- .../src/interfaces/ai/IStatsBeat.ts | 38 ++++ 8 files changed, 309 insertions(+), 32 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 1b852cedd..296c2dac5 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -401,7 +401,7 @@ export class AppInsightsSku implements IApplicationInsights void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + }, endCfg: [ { type: 0, @@ -151,6 +156,9 @@ export class StatsbeatTests extends AITestClass { }, stats: { shrtInt: 900, + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + }, endCfg: [ { type: 0, diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index c57bf2b50..d16e353ba 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,8 +51,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 137; - private readonly MAX_BUNDLE_SIZE = 137; + private readonly MAX_RAW_SIZE = 138; + private readonly MAX_BUNDLE_SIZE = 138; private readonly MAX_RAW_DEFLATE_SIZE = 56; private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; private readonly rawFilePath = "../dist/es5/index.min.js"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts index 14fe6a57a..a2cd8eca5 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts @@ -4,7 +4,7 @@ import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; import { IStatsMgr } from "../../../../src/interfaces/ai/IStatsMgr"; import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; -import { createStatsMgr } from "../../../../src/core/StatsBeat"; +import { createStatsMgr, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/StatsBeat"; import { IStatsBeatState } from "../../../../src/interfaces/ai/IStatsBeat"; import { eStatsType } from "../../../../src/enums/ai/StatsType"; import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; @@ -38,6 +38,11 @@ export class StatsBeatTests extends AITestClass { }, stats: { shrtInt: STATS_COLLECTION_SHORT_INTERVAL, + // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests + // do not attempt a real network fetch of the cfg/v1.json endpoint. + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + }, endCfg: [ { type: 0, @@ -286,6 +291,89 @@ export class StatsBeatTests extends AITestClass { Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is null (defaults to enabled)"); } }); + + this.testCase({ + name: "SDK Stats: routes events to the remote configured SDK Stats endpoint", + useFakeTimers: true, + test: () => { + this._statsMgr.init(this._core, "StatsBeat"); + + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsBeatData: { + startTime: Date.now() + } + } as IPayloadData; + + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); + statsBeat.count(200, payloadData, "https://example.endpoint.com"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); + + // The host returned by the remote configuration (data.stats.monitor.azure.com) should be + // combined with the /v2/track path to form the SDK Stats ingestion endpoint. + let foundEndpoint = false; + for (let i = 0; i < this._trackSpy.callCount; i++) { + const item: ITelemetryItem = this._trackSpy.getCall(i).args[0]; + if (item.data && item.data[STATS_SDK_ENDPOINT_KEY] === "https://data.stats.monitor.azure.com/v2/track") { + foundEndpoint = true; + break; + } + } + + Assert.ok(foundEndpoint, "SDK Stats events should be routed to the remote configured ingestion endpoint"); + } + }); + + this.testCase({ + name: "SDK Stats: does not send when the remote configuration is disabled", + useFakeTimers: true, + test: () => { + // Override the remote SDK Stats configuration to report collection as disabled + this._core.config.stats.overrideCfgFn = (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: false, url: "data.stats.monitor.azure.com" }); + }; + this.clock.tick(1); // Allow the config change to propagate + + this._statsMgr.init(this._core, "StatsBeat"); + + const payloadData = { + urlString: "https://example.endpoint.com", + data: "testData", + headers: {}, + timeout: 0, + disableXhrSync: false, + statsBeatData: { + startTime: Date.now() + } + } as IPayloadData; + + let statsBeatState: IStatsBeatState = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0", + type: eStatsType.SDK + }; + let statsBeat = this._statsMgr.newInst(statsBeatState); + statsBeat.count(200, payloadData, "https://example.endpoint.com"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "track should not be called when the remote configuration disables SDK Stats"); + } + }); } } diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/StatsBeat.ts index bbc936029..ed2322b76 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/StatsBeat.ts @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrForEach, isNumber, makeGlobRegex, objDefineProps, scheduleTimeout, strIndexOf, strLower, utcNow + ITimerHandler, arrForEach, isNumber, makeGlobRegex, objDefineProps, scheduleTimeout, strEndsWith, strIndexOf, strLower, strStartsWith, + strSubstring, utcNow } from "@nevware21/ts-utils"; import { cfgDfMerge } from "../config/ConfigDefaultHelpers"; import { onConfigChange } from "../config/DynamicConfig"; +import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constants"; import { STR_EMPTY } from "../constants/InternalConstants"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; @@ -13,12 +16,15 @@ import { eStatsEndpointType, eStatsType } from "../enums/ai/StatsType"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { INetworkStatsbeat } from "../interfaces/ai/INetworkStatsbeat"; -import { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap, IStatsBeatState, IStatsEndpointConfig } from "../interfaces/ai/IStatsBeat"; +import { + IStatsBeat, IStatsBeatCfgResult, IStatsBeatConfig, IStatsBeatKeyMap, IStatsBeatState, IStatsEndpointConfig +} from "../interfaces/ai/IStatsBeat"; import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; -import { isFeatureEnabled } from "../utils/HelperFuncs"; +import { getJSON, isFetchSupported, isXhrSupported } from "../utils/EnvUtils"; +import { getResponseText, isFeatureEnabled, openXhr } from "../utils/HelperFuncs"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes const STATS_MIN_INTERVAL_SECONDS = 60; // 1 minute @@ -34,13 +40,14 @@ const STATSBEAT_TYPE = "Browser"; export const STATS_SDK_IKEY = "00000000-0000-0000-0000-000000000000"; /** - * Distro-owned SDK statistics ingestion endpoints. SDK Stats events are routed here instead of - * to the customer's breeze endpoint. The EU endpoint is used when the customer's endpoint maps - * to an EU data-boundary region, otherwise the non-EU endpoint is used. - * @see https://github.com/microsoft/opentelemetry-distro-dotnet + * SDK Stats config endpoints (`cfg/v1.json`). The `{ ver, enabled, url }` JSON is read at runtime to + * gate collection and resolve the ingestion host. EU endpoint is used for EU data-boundary regions. */ -export const STATS_SDK_ENDPOINT_NON_EU = "https://stats.monitor.azure.com/v2/track"; -export const STATS_SDK_ENDPOINT_EU = "https://eu.stats.monitor.azure.com/v2/track"; +export const STATS_SDK_CFG_URL_NON_EU = "https://data.stats.monitor.azure.com/cfg/v1.json"; +export const STATS_SDK_CFG_URL_EU = "https://eu-data.stats.monitor.azure.com/cfg/v1.json"; + +/** Ingestion path for future 1DS (OneCollector) SDK Stats; the AI SKU uses {@link DEFAULT_BREEZE_PATH}. */ +export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; /** * The Microsoft-owned instrumentation keys used when reporting SDK statistics to the legacy @@ -107,14 +114,94 @@ function _isEuEndpoint(endpoint: string): boolean { } /** - * Determine the distro-owned SDK Stats ingestion endpoint for the provided customer endpoint. - * When the region maps to an EU region the EU endpoint is returned, otherwise (including unknown - * regions) the non-EU endpoint is returned. - * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. - * @returns The SDK Stats ingestion endpoint URL. + * Returns the SDK Stats config URL (`cfg/v1.json`) for the endpoint (EU vs non-EU). */ -export function getStatsEndpoint(endpoint: string): string { - return _isEuEndpoint(endpoint) ? STATS_SDK_ENDPOINT_EU : STATS_SDK_ENDPOINT_NON_EU; +export function getStatsCfgUrl(endpoint: string): string { + return _isEuEndpoint(endpoint) ? STATS_SDK_CFG_URL_EU : STATS_SDK_CFG_URL_NON_EU; +} + +/** Parse the SDK Stats config JSON (`{ ver, enabled, url }`); null if empty or unparseable. */ +function _parseStatsCfg(response: string): IStatsBeatCfgResult { + let result: IStatsBeatCfgResult = null; + let json = getJSON(); + if (response && json) { + try { + let cfg = json.parse(response); + if (cfg) { + result = { + enabled: !!cfg.enabled, + url: cfg.url + }; + } + } catch (e) { + // Unparseable -> no config available + } + } + + return result; +} + +/** Default SDK Stats config fetch (fetch, else XHR); calls oncomplete with the parsed config or null. */ +function _defaultStatsCfgFetch(cfgUrl: string, oncomplete: (result: IStatsBeatCfgResult) => void): void { + function _complete(response?: string) { + try { + oncomplete(response ? _parseStatsCfg(response) : null); + } catch (e) { + // Ignore callback errors + } + } + + try { + if (isFetchSupported()) { + let init: RequestInit = { method: "GET" }; + init[DisabledPropertyName] = true; + + doAwaitResponse(fetch(cfgUrl, init), (result) => { + let response = result.value; + if (!result.rejected && response && response.ok) { + doAwaitResponse(response.text(), (res) => { + _complete(res.rejected ? null : res.value); + }); + } else { + _complete(); + } + }); + } else if (isXhrSupported()) { + // openXhr marks the request disabled so it isn't self-tracked + let xhr = openXhr("GET", cfgUrl, false, true, false, 10000); + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + _complete(xhr.status >= 200 && xhr.status < 400 ? getResponseText(xhr) : null); + } + }; + xhr.onerror = () => { + _complete(); + }; + xhr.ontimeout = () => { + _complete(); + }; + xhr.send(); + } else { + _complete(); + } + } catch (e) { + _complete(); + } +} + +/** Build the ingestion endpoint from the config host: `https://` + {@link DEFAULT_BREEZE_PATH}. */ +function _buildStatsEndpoint(host: string): string { + let endpoint: string = null; + if (host) { + let base = strStartsWith(strLower(host), "http") ? host : "https://" + host; + if (strEndsWith(base, "/")) { + base = strSubstring(base, 0, base.length - 1); + } + + endpoint = base + DEFAULT_BREEZE_PATH; + } + + return endpoint; } /** @@ -444,6 +531,8 @@ export function createStatsMgr(): IStatsMgr { let _core: IAppInsightsCore; // The core instance that is used to send telemetry let _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; let _statsBeatConfig: IStatsBeatConfig; + // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. + let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IStatsBeatCfgResult } } = {}; // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again @@ -480,6 +569,35 @@ export function createStatsMgr(): IStatsMgr { } } + /** + * Resolve the remote SDK Stats config for the endpoint, starting a fetch on first use. Returns + * null until resolved (or on failure) so the caller skips sending. + */ + function _resolveStatsCfg(endpoint: string): IStatsBeatCfgResult { + let cfgUrl = getStatsCfgUrl(endpoint); + let entry = _cfgCache[cfgUrl]; + if (!entry) { + entry = _cfgCache[cfgUrl] = { pending: false, result: null }; + } + + if (!entry.result && !entry.pending) { + entry.pending = true; + let fetchFn = (_statsBeatConfig && _statsBeatConfig.overrideCfgFn) || _defaultStatsCfgFetch; + try { + fetchFn(cfgUrl, (result) => { + entry.pending = false; + // null on failure so a later interval retries + entry.result = result; + }); + } catch (e) { + // Reset so a later interval retries + entry.pending = false; + } + } + + return entry.result; + } + function _track(statsBeat: IStatsBeat, statsBeatEvent: ITelemetryItem) { if (_isMgrEnabled && _statsBeatConfig) { let endpoint = statsBeat.endpoint; @@ -500,11 +618,26 @@ export function createStatsMgr(): IStatsMgr { // map does not specify one. Breeze mode uses the Microsoft-owned breeze SDK Stats // iKey (region dependent); the SDK Stats endpoint uses the placeholder iKey. let iKey = (keyMap && keyMap.key) || (useBreeze ? getStatsBreezeIKey(endpoint) : STATS_SDK_IKEY); + + // Destination host / enabled state come from the remote config, unless the key + // map sets an explicit url. Breeze mode sends to the customer endpoint (no redirect). + let url = keyMap && keyMap.url; + if (!url && !useBreeze) { + let cfgResult = _resolveStatsCfg(endpoint); + if (!cfgResult || !cfgResult.enabled) { + // Not resolved yet, or disabled -> skip + return; + } + + url = _buildStatsEndpoint(cfgResult.url); + if (!url) { + // Enabled but no usable host -> skip (don't leak to the customer endpoint) + return; + } + } + statsBeatEvent.iKey = iKey; - // Resolve the destination SDK Stats ingestion endpoint. When sending to the legacy - // breeze endpoint there is no redirect (the event is sent to the customer's endpoint). - let url = (keyMap && keyMap.url) || (useBreeze ? null : getStatsEndpoint(endpoint)); if (url) { // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the // event away from the customer's breeze endpoint. This marker is removed by the @@ -523,6 +656,11 @@ export function createStatsMgr(): IStatsMgr { let instance: IStatsBeat = null; if (_isMgrEnabled) { + // Prefetch the remote config (SDK Stats mode) so it's ready by the first interval + if (state && state.endpoint && _statsBeatConfig.mode !== eStatsEndpointType.Breeze) { + _resolveStatsCfg(state.endpoint); + } + let callbacks: _IMgrCallbacks = { start: (cb: () => void) => { return scheduleTimeout(cb, _shortInterval); @@ -551,10 +689,12 @@ export function createStatsMgr(): IStatsMgr { * The default {@link IStatsBeatConfig} values for SDK Stats collection. These are seeded into the * single global config (via {@link IWatchDetails.setDf}) by the manager so they remain dynamic and * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). By default - * the events are routed to the distro-owned SDK Stats ingestion endpoint (`stats.monitor.azure.com` - * / `eu.stats.monitor.azure.com`); setting `config.stats.mode` to {@link eStatsEndpointType.Breeze} - * routes SDK Stats to the legacy breeze endpoint instead. SDK Stats are enabled by default and can be - * opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. + * the events are routed to the distro-owned SDK Stats ingestion endpoint, whose host (and whether + * collection is enabled) is read at runtime from the SDK Stats configuration + * (`data.stats.monitor.azure.com` / `eu-data.stats.monitor.azure.com`); setting `config.stats.mode` + * to {@link eStatsEndpointType.Breeze} routes SDK Stats to the legacy breeze endpoint instead. SDK + * Stats are enabled by default and can be opted-out using the `featureOptIn` configuration with the + * {@link STATS_SDK_FEATURE} name. */ const _sdkStatsDefaults: IConfigDefaults = { stats: cfgDfMerge({ diff --git a/shared/AppInsightsCore/src/enums/ai/StatsType.ts b/shared/AppInsightsCore/src/enums/ai/StatsType.ts index 04fcdd96e..2d968d5d6 100644 --- a/shared/AppInsightsCore/src/enums/ai/StatsType.ts +++ b/shared/AppInsightsCore/src/enums/ai/StatsType.ts @@ -16,8 +16,9 @@ export type StatsType = number | eStatsType; */ export const enum eStatsEndpointType { /** - * Send SDK Stats to the distro-owned SDK Stats ingestion endpoint (stats.monitor.azure.com). - * This is the default. + * Send SDK Stats to the distro-owned SDK Stats ingestion endpoint. The destination host (and + * whether collection is enabled) is read at runtime from the SDK Stats configuration + * (`data.stats.monitor.azure.com` / `eu-data.stats.monitor.azure.com`). This is the default. */ SdkStats = 0, diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index b3c97601a..dee0ea595 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -40,12 +40,14 @@ export { parseResponse } from "./core/ResponseHelpers"; export { IXDomainRequest, IBackendResponse } from "./interfaces/ai/IXDomainRequest"; export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IInternalXhrOverride } from "./interfaces/ai/ISenderPostManager"; export { SenderPostManager } from "./core/SenderPostManager"; -export { IStatsBeat, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState} from "./interfaces/ai/IStatsBeat"; +export { + IStatsBeat, IStatsBeatCfgResult, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState, StatsBeatCfgFetchFn +} from "./interfaces/ai/IStatsBeat"; export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { - createStatsMgr, getStatsEndpoint, getStatsBreezeIKey, - STATS_SDK_IKEY, STATS_SDK_ENDPOINT_NON_EU, STATS_SDK_ENDPOINT_EU, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, + createStatsMgr, getStatsCfgUrl, getStatsBreezeIKey, + STATS_SDK_IKEY, STATS_SDK_CFG_URL_NON_EU, STATS_SDK_CFG_URL_EU, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, STATS_BREEZE_IKEY_NON_EU, STATS_BREEZE_IKEY_EU } from "./core/StatsBeat"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts index 9ffcf36d9..73e0fbde1 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsBeat.ts @@ -113,6 +113,36 @@ export interface IStatsEndpointConfig { keyMap?: IStatsBeatKeyMap[] } +/** + * The parsed result of the remote SDK Stats configuration (`cfg/v1.json`). It identifies whether + * SDK Stats collection is currently enabled and the host that matching events should be sent to. + * @since 3.3.7 + */ +export interface IStatsBeatCfgResult { + /** + * Identifies whether SDK Stats collection is enabled. When false the SDK Stats events are not + * sent (the feature is effectively disabled by the distro-owned configuration). + */ + enabled: boolean; + + /** + * The host (or full URL) that the SDK Stats events should be sent to. The ingestion path (e.g. + * `/v2/track`) is appended by the SDK, so this generally only carries the host (for example + * `data.stats.monitor.azure.com`). + */ + url: string; +} + +/** + * The signature of the function used to fetch (and parse) the remote SDK Stats configuration. It is + * primarily used to allow the fetch implementation to be overridden (for testing or advanced + * scenarios) via {@link IStatsBeatConfig.overrideCfgFn}. + * @param cfgUrl - The SDK Stats configuration URL (`cfg/v1.json`) to fetch. + * @param oncomplete - The callback to invoke with the parsed configuration, or null on any failure. + * @since 3.3.7 + */ +export type StatsBeatCfgFetchFn = (cfgUrl: string, oncomplete: (result: IStatsBeatCfgResult | null) => void) => void; + /** * The configuration for the stats beat definition * @since 3.3.7 @@ -138,4 +168,12 @@ export interface IStatsBeatConfig { * This is used to identify the endpoints that are supported by the stats beat plugin. */ endCfg?: IStatsEndpointConfig[]; + + /** + * Optional override for the function used to fetch the remote SDK Stats configuration + * (`cfg/v1.json`). When not provided the default fetch / XHR based implementation is used. This + * is primarily intended for testing or advanced scenarios where the configuration needs to be + * resolved through a custom mechanism. + */ + overrideCfgFn?: StatsBeatCfgFetchFn; } From 2a5a430fad3e06e781601b4b1a3db850c7f91bc8 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Sat, 18 Jul 2026 16:50:04 -0700 Subject: [PATCH 15/51] Rename internal StatsBeat to Internal SDK Stats Eliminate the legacy 'StatsBeat' name in favor of 'Internal SDK Stats' to distinguish it from the customer-facing SdkStats feature. Interfaces, types, enum members, locals and private functions rename freely (erased or mangled); the two surviving property names were shortened (getStatsBeat -> getSdkStats, statsBeatData -> statsData), so the minified bundle size is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Manual/testVersionConflict.html | 2 +- ...eat.tests.ts => InternalSdkStats.tests.ts} | 60 +++---- .../Tests/Unit/src/aichannel.tests.ts | 6 +- .../src/SendBuffer.ts | 2 +- .../src/Sender.ts | 59 +++--- ...eat.Tests.ts => InternalSdkStats.Tests.ts} | 112 ++++++------ .../Tests/Unit/src/aiunittests.ts | 6 +- .../src/core/AppInsightsCore.ts | 50 +++--- .../{StatsBeat.ts => InternalSdkStats.ts} | 169 +++++++++--------- .../src/enums/ai/LoggingEnums.ts | 4 +- shared/AppInsightsCore/src/index.ts | 6 +- .../src/interfaces/ai/IAppInsightsCore.ts | 6 +- .../src/interfaces/ai/IConfiguration.ts | 8 +- .../{IStatsBeat.ts => IInternalSdkStats.ts} | 18 +- ...atsbeat.ts => IInternalSdkStatsNetwork.ts} | 2 +- .../src/interfaces/ai/IStatsMgr.ts | 14 +- 16 files changed, 262 insertions(+), 262 deletions(-) rename channels/applicationinsights-channel-js/Tests/Unit/src/{StatsBeat.tests.ts => InternalSdkStats.tests.ts} (82%) rename shared/AppInsightsCore/Tests/Unit/src/ai/{StatsBeat.Tests.ts => InternalSdkStats.Tests.ts} (76%) rename shared/AppInsightsCore/src/core/{StatsBeat.ts => InternalSdkStats.ts} (80%) rename shared/AppInsightsCore/src/interfaces/ai/{IStatsBeat.ts => IInternalSdkStats.ts} (91%) rename shared/AppInsightsCore/src/interfaces/ai/{INetworkStatsbeat.ts => IInternalSdkStatsNetwork.ts} (93%) diff --git a/AISKU/Tests/Manual/testVersionConflict.html b/AISKU/Tests/Manual/testVersionConflict.html index 1e1b5e997..2eb37aea1 100644 --- a/AISKU/Tests/Manual/testVersionConflict.html +++ b/AISKU/Tests/Manual/testVersionConflict.html @@ -20,7 +20,7 @@ // onInit: null, // Once the application insights instance has loaded and initialized this callback function will be called with 1 argument -- the sdk instance (DO NOT ADD anything to the sdk.queue -- As they won't get called) sri: true, // Custom optional value to specify whether fetching the snippet from integrity file and do integrity check cfg: { // Application Insights Configuration - disableStatsBeat: false, + disableInternalSdkStats: false, connectionString: "YOUR_INSTRUMENTATION_KEY", } }); diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts similarity index 82% rename from channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts rename to channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts index 4d6f6a957..eed298774 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/StatsBeat.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -1,16 +1,16 @@ import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; -import { AppInsightsCore, createStatsMgr, eStatsType, FeatureOptInMode, getWindow, IPayloadData, IStatsBeatState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; +import { AppInsightsCore, createStatsMgr, eStatsType, FeatureOptInMode, getWindow, IPayloadData, IInternalSdkStatsState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; import { Sender } from "../../../src/Sender"; import { SinonSpy, SinonStub } from "sinon"; import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; -export class StatsbeatTests extends AITestClass { +export class InternalSdkStatsTests extends AITestClass { private _core: AppInsightsCore; private _sender: Sender; private _statsMgr: IStatsMgr; private _statsMgrUnloadHook: IUnloadHook | null; - private statsbeatCountSpy: SinonSpy; + private internalSdkStatsCountSpy: SinonSpy; private fetchStub: sinon.SinonStub; private beaconStub: sinon.SinonStub; private trackSpy: SinonSpy; @@ -34,8 +34,8 @@ export class StatsbeatTests extends AITestClass { this._statsMgrUnloadHook.rm(); this._statsMgrUnloadHook = null; } - if (this.statsbeatCountSpy) { - this.statsbeatCountSpy.restore(); + if (this.internalSdkStatsCountSpy) { + this.internalSdkStatsCountSpy.restore(); } if (this.fetchStub) { this.fetchStub.restore(); @@ -80,18 +80,18 @@ export class StatsbeatTests extends AITestClass { // Initialize the core first, then init the manager against that same (now initialized) // core so it can enable itself (createStatsMgr().init() only enables once the core is initialized). core.initialize(coreConfig, [sender]); - let unloadHook = statsMgr.init(core, "StatsBeat"); + let unloadHook = statsMgr.init(core, "InternalSdkStats"); core.setStatsMgr(statsMgr); this._statsMgrUnloadHook = unloadHook; - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: instrumentationKey, endpoint: config.endpointUrl, sdkVer: "1.0.0", type: eStatsType.SDK }; - this.statsbeatCountSpy = this.sandbox.spy(core.getStatsBeat(statsBeatState), "count"); + this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); this.trackSpy = this.sandbox.spy(core, "track"); this.onDone(() => { @@ -130,17 +130,17 @@ export class StatsbeatTests extends AITestClass { } catch (e) { QUnit.assert.ok(false, "Unexpected error during telemetry processing"); } - this.clock.tick(900000); // Simulate time passing for statsbeat to be sent + this.clock.tick(900000); // Simulate time passing for internalSdkStats to be sent } - private assertStatsbeatCall(statusCode: number, eventName: string) { - Assert.equal(this.statsbeatCountSpy.callCount, 1, "SDK Stats count should be called once"); - Assert.equal(this.statsbeatCountSpy.firstCall.args[0], statusCode, `Statsbeat count should be called with status ${statusCode}`); - const data = JSON.stringify(this.statsbeatCountSpy.firstCall.args[1]); + private assertInternalSdkStatsCall(statusCode: number, eventName: string) { + Assert.equal(this.internalSdkStatsCountSpy.callCount, 1, "SDK Stats count should be called once"); + Assert.equal(this.internalSdkStatsCountSpy.firstCall.args[0], statusCode, `InternalSdkStats count should be called with status ${statusCode}`); + const data = JSON.stringify(this.internalSdkStatsCountSpy.firstCall.args[1]); Assert.ok(data.includes("startTime"), "SDK Stats count should be called with startTime set"); - const statsbeatEvent = this.trackSpy.firstCall.args[0]; - Assert.equal(statsbeatEvent.baseType, "MetricData", "SDK Stats event should be of type MetricData"); - Assert.equal(statsbeatEvent.baseData.name, eventName, `Statsbeat event should be of type ${eventName}`); + const internalSdkStatsEvent = this.trackSpy.firstCall.args[0]; + Assert.equal(internalSdkStatsEvent.baseType, "MetricData", "SDK Stats event should be of type MetricData"); + Assert.equal(internalSdkStatsEvent.baseData.name, eventName, `InternalSdkStats event should be of type ${eventName}`); } public registerTests() { @@ -150,7 +150,7 @@ export class StatsbeatTests extends AITestClass { const config = { instrumentationKey: "Test-iKey", featureOptIn: { - "StatsBeat": { + "InternalSdkStats": { mode: FeatureOptInMode.enable } }, @@ -174,19 +174,19 @@ export class StatsbeatTests extends AITestClass { }; this._core.initialize(config, [this._sender]); - this._statsMgrUnloadHook = this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgrUnloadHook = this._statsMgr.init(this._core, "InternalSdkStats"); this._core.setStatsMgr(this._statsMgr); - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - const statsbeat = this._core.getStatsBeat(statsBeatState); + const internalSdkStats = this._core.getSdkStats(internalSdkStatsState); - QUnit.assert.ok(statsbeat, "SDK Stats is initialized"); - QUnit.assert.ok(statsbeat.enabled, "SDK Stats is marked as initialized"); + QUnit.assert.ok(internalSdkStats, "SDK Stats is initialized"); + QUnit.assert.ok(internalSdkStats.enabled, "SDK Stats is marked as initialized"); } }); @@ -215,8 +215,8 @@ export class StatsbeatTests extends AITestClass { } ].concat(PollingAssert.createPollingAssert(() => { - if (this.statsbeatCountSpy.called && this.fetchStub.called) { - this.assertStatsbeatCall(200, "Request_Success_Count"); + if (this.internalSdkStatsCountSpy.called && this.fetchStub.called) { + this.assertInternalSdkStatsCall(200, "Request_Success_Count"); return true; } return false; @@ -246,8 +246,8 @@ export class StatsbeatTests extends AITestClass { this.processTelemetryAndFlush(sender, telemetryItem); } ].concat(PollingAssert.createPollingAssert(() => { - if (this.statsbeatCountSpy.called && this.fetchStub.called) { - this.assertStatsbeatCall(439, "Throttle_Count"); + if (this.internalSdkStatsCountSpy.called && this.fetchStub.called) { + this.assertInternalSdkStatsCall(439, "Throttle_Count"); return true; } return false; @@ -278,8 +278,8 @@ export class StatsbeatTests extends AITestClass { this.processTelemetryAndFlush(sender, telemetryItem); } ].concat(PollingAssert.createPollingAssert(() => { - if (this.statsbeatCountSpy.called) { - this.assertStatsbeatCall(200, "Request_Success_Count"); + if (this.internalSdkStatsCountSpy.called) { + this.assertInternalSdkStatsCall(200, "Request_Success_Count"); return true; } return false; @@ -315,8 +315,8 @@ export class StatsbeatTests extends AITestClass { } ].concat(PollingAssert.createPollingAssert(() => { - if (this.statsbeatCountSpy.called) { - this.assertStatsbeatCall(200, "Request_Success_Count"); + if (this.internalSdkStatsCountSpy.called) { + this.assertInternalSdkStatsCall(200, "Request_Success_Count"); console.log("SDK Stats count called with success count for xhr sender"); return true; } diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts index 8f64142fd..105f350c2 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/aichannel.tests.ts @@ -1,11 +1,11 @@ -import { SenderTests } from "./Sender.tests"; +import { SenderTests } from "./Sender.tests"; import { SampleTests } from "./Sample.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; -import { StatsbeatTests } from "./StatsBeat.tests"; +import { InternalSdkStatsTests } from "./InternalSdkStats.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new SenderTests().registerTests(); new SampleTests().registerTests(); - new StatsbeatTests().registerTests(); + new InternalSdkStatsTests().registerTests(); } \ No newline at end of file diff --git a/channels/applicationinsights-channel-js/src/SendBuffer.ts b/channels/applicationinsights-channel-js/src/SendBuffer.ts index 961eca1f0..e1d282646 100644 --- a/channels/applicationinsights-channel-js/src/SendBuffer.ts +++ b/channels/applicationinsights-channel-js/src/SendBuffer.ts @@ -102,7 +102,7 @@ abstract class BaseSendBuffer { if (!isNullOrUndefined(_maxRetryCnt)) { if (payload.cnt > _maxRetryCnt) { // TODO: add log here on dropping payloads - // will log statsbeat exception later here + // will log internalSdkStats exception later here return; } diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index bb29a984f..663d0eff8 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -2,15 +2,14 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { ActiveStatus, BaseTelemetryPlugin, BreezeChannelIdentifier, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, EventDataType, ExceptionDataType, IAppInsightsCore, IBackendResponse, IChannelControls, IConfig, IConfigDefaults, IConfiguration, IDiagnosticLogger, - IEnvelope, IInternalOfflineSupport, INotificationManager, IOfflineListener, IPayloadData, IPlugin, IProcessTelemetryContext, - IProcessTelemetryUnloadContext, ISample, IStatsBeatState, IStatsEventData, IStorageBuffer, ITelemetryItem, ITelemetryPluginChain, - ITelemetryUnloadState, IXDomainRequest, - IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, - RequestDataType, RequestHeaders, STATS_SDK_ENDPOINT_KEY, SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, - TransportType, - _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, - createOfflineListener, createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, - eStatsType, formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, + IEnvelope, IInternalOfflineSupport, IInternalSdkStatsState, INotificationManager, IOfflineListener, IPayloadData, IPlugin, + IProcessTelemetryContext, IProcessTelemetryUnloadContext, ISample, IStatsEventData, IStorageBuffer, ITelemetryItem, + ITelemetryPluginChain, ITelemetryUnloadState, IXDomainRequest, IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, + PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, RequestDataType, RequestHeaders, STATS_SDK_ENDPOINT_KEY, + SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, TransportType, _ISendPostMgrConfig, + _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createOfflineListener, + createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, eStatsType, + formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-core-js"; @@ -36,7 +35,7 @@ const FetchSyncRequestSizeLimitBytes = 65000; // approx 64kb (the current Edge, interface IInternalPayloadData extends IPayloadData { oriPayload: IInternalStorageItem[]; retryCnt?: number; - statsBeatData?: IStatsEventData; + statsData?: IStatsEventData; } @@ -524,7 +523,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (statsEndpoint) { // Route SDK Stats directly to the SDK Stats ingestion endpoint, bypassing the // customer send buffer so it is never mixed with customer telemetry. - _sendStatsBeat(payload, statsEndpoint); + _sendInternalSdkStats(payload, statsEndpoint); } else { // flush if we would exceed the max-size limit by adding this item const buffer = _self._buffer; @@ -694,8 +693,8 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } - function _getStatsBeat() { - let statsBeatConfig: IStatsBeatState = { + function _getSdkStats() { + let internalSdkStatsConfig: IInternalSdkStatsState = { cKey: _self._senderConfig.instrumentationKey, endpoint: _endpointUrl, sdkVer: EnvelopeCreator.Version, @@ -706,7 +705,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // During page unload the core may have been cleared and some async events may not have been sent yet // resulting in the core being null. In this case we don't want to create a SDK Stats instance - return core && core.getStatsBeat ? core.getStatsBeat(statsBeatConfig) : null; + return core && core.getSdkStats ? core.getSdkStats(internalSdkStatsConfig) : null; } /** @@ -717,7 +716,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { * @param payloadStr - The serialized envelope to send. * @param statsEndpoint - The SDK Stats ingestion endpoint URL to send the payload to. */ - function _sendStatsBeat(payloadStr: string, statsEndpoint: string) { + function _sendInternalSdkStats(payloadStr: string, statsEndpoint: string) { try { let sendInterface = _httpInterface; if (sendInterface && payloadStr && statsEndpoint) { @@ -740,11 +739,11 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { * @param status - The resulting status code of the send. * @param payload - The payload that was sent. */ - function _countStatsBeat(status: number, payload?: IPayloadData) { + function _countInternalSdkStats(status: number, payload?: IPayloadData) { if (payload && payload.urlString === _endpointUrl) { - let statsbeat = _getStatsBeat(); - if (statsbeat) { - statsbeat.count(status, payload, _endpointUrl); + let internalSdkStats = _getSdkStats(); + if (internalSdkStats) { + internalSdkStats.count(status, payload, _endpointUrl); } } } @@ -777,19 +776,19 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } if (payload && payload.urlString === _endpointUrl) { const responseText = _getResponseText(xdr); - let statsbeat = _getStatsBeat(); - if (statsbeat) { + let internalSdkStats = _getSdkStats(); + if (internalSdkStats) { if (xdr && (responseText + "" === "200" || responseText === "")) { _consecutiveErrors = 0; - statsbeat.count(200, payload, _endpointUrl); + internalSdkStats.count(200, payload, _endpointUrl); } else { const results = parseResponse(responseText); if (results && results.itemsReceived && results.itemsReceived > results.itemsAccepted && !_isRetryDisabled) { - statsbeat.count(206, payload, _endpointUrl); + internalSdkStats.count(206, payload, _endpointUrl); } else { - statsbeat.count(499, payload, _endpointUrl); + internalSdkStats.count(499, payload, _endpointUrl); } } } @@ -803,7 +802,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - _countStatsBeat(response.status, payload); + _countInternalSdkStats(response.status, payload); return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, xhrOnComplete: (request: XMLHttpRequest, oncomplete: OnCompleteCallback, payload?: IPayloadData) => { @@ -812,13 +811,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return; } if (request.readyState === 4) { - _countStatsBeat(request.status, payload); + _countInternalSdkStats(request.status, payload); } return _xhrReadyStateChange(request, payloadArr, payloadArr.length); }, beaconOnRetry: (data: IPayloadData, onComplete: OnCompleteCallback, canSend: (payload: IPayloadData, oncomplete: OnCompleteCallback, sync?: boolean) => boolean) => { - _countStatsBeat(499, data); + _countInternalSdkStats(499, data); return _onBeaconRetry(data, onComplete, canSend); } @@ -1084,13 +1083,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true, urlOverride?: string): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { - _countStatsBeat(status, payloadData); + _countInternalSdkStats(status, payloadData); return _getOnComplete(payload, status, headers, response); }; let payloadData = _getPayload(payload, urlOverride); if (payloadData) { - payloadData.statsBeatData = {startTime: dateNow()}; + payloadData.statsData = {startTime: dateNow()}; } let sendPostFunc: SendPOSTFunction = sendInterface && sendInterface.sendPOST; @@ -1441,7 +1440,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let core = _self.core; if (core) { // During page unload the core may have been cleared and some async events may not have been sent yet - // resulting in the core being null. In this case we don't want to create a statsbeat instance + // resulting in the core being null. In this case we don't want to create a internalSdkStats instance if (core[func]) { result = core[func](); diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts similarity index 76% rename from shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts rename to shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index a2cd8eca5..91c467f83 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/StatsBeat.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -4,8 +4,8 @@ import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; import { IStatsMgr } from "../../../../src/interfaces/ai/IStatsMgr"; import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; -import { createStatsMgr, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/StatsBeat"; -import { IStatsBeatState } from "../../../../src/interfaces/ai/IStatsBeat"; +import { createStatsMgr, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/InternalSdkStats"; +import { IInternalSdkStatsState } from "../../../../src/interfaces/ai/IInternalSdkStats"; import { eStatsType } from "../../../../src/enums/ai/StatsType"; import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; @@ -14,14 +14,14 @@ import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes -export class StatsBeatTests extends AITestClass { +export class InternalSdkStatsTests extends AITestClass { private _core: AppInsightsCore; private _config: IConfiguration; private _statsMgr: IStatsMgr; private _trackSpy: sinon.SinonSpy; constructor(emulateIe: boolean) { - super("StatsBeatTests", emulateIe); + super("InternalSdkStatsTests", emulateIe); } public testInitialize() { @@ -32,7 +32,7 @@ export class StatsBeatTests extends AITestClass { instrumentationKey: "Test-iKey", disableInstrumentationKeyValidation: true, featureOptIn: { - "StatsBeat": { + "InternalSdkStats": { mode: FeatureOptInMode.enable } }, @@ -82,19 +82,19 @@ export class StatsBeatTests extends AITestClass { // Test with no initialization Assert.equal(false, this._statsMgr.enabled, "SDK Stats manager should not be initialized by default"); - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - Assert.equal(null, this._statsMgr.newInst(statsBeatState), "SDK Stats should not be created before initialization"); + Assert.equal(null, this._statsMgr.newInst(internalSdkStatsState), "SDK Stats should not be created before initialization"); // Initialize - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); - let newInst = this._statsMgr.newInst(statsBeatState); + let newInst = this._statsMgr.newInst(internalSdkStatsState); Assert.ok(!!newInst, "SDK Stats should be created after initialization"); Assert.equal(true, newInst.enabled, "SDK Stats should be enabled after initialization"); Assert.equal("https://example.endpoint.com", newInst.endpoint); @@ -107,7 +107,7 @@ export class StatsBeatTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); // Create mock payload data with timing information const payloadData = { @@ -116,29 +116,29 @@ export class StatsBeatTests extends AITestClass { headers: {}, timeout: 0, disableXhrSync: false, - statsBeatData: { + statsData: { startTime: "2023-10-01T00:00:00Z" // Simulated start time } } as IPayloadData; - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - let statsBeat = this._statsMgr.newInst(statsBeatState); + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); // Test successful request - statsBeat.count(200, payloadData, "https://example.endpoint.com"); + internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); // Test failed request - statsBeat.count(500, payloadData, "https://example.endpoint.com"); + internalSdkStats.count(500, payloadData, "https://example.endpoint.com"); // Test throttled request - statsBeat.count(429, payloadData, "https://example.endpoint.com"); + internalSdkStats.count(429, payloadData, "https://example.endpoint.com"); - // Verify that trackStatsbeats is called when the timer fires + // Verify that trackInternalSdkStatss is called when the timer fires this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // Verify that track was called @@ -154,20 +154,20 @@ export class StatsBeatTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - let statsBeat = this._statsMgr.newInst(statsBeatState); + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); // Count an exception - statsBeat.countException("https://example.endpoint.com", "NetworkError"); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); - // Verify that trackStatsbeats is called when the timer fires + // Verify that trackInternalSdkStatss is called when the timer fires this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // Verify that track was called @@ -195,7 +195,7 @@ export class StatsBeatTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager for a specific endpoint - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); // Create mock payload data const payloadData = { @@ -204,26 +204,26 @@ export class StatsBeatTests extends AITestClass { headers: {}, timeout: 0, disableXhrSync: false, - statsBeatData: { + statsData: { startTime: Date.now() } } as IPayloadData; - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - let statsBeat = this._statsMgr.newInst(statsBeatState); + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); // Set up spies to check internal calls - const countSpy = this.sandbox.spy(statsBeat, "count"); + const countSpy = this.sandbox.spy(internalSdkStats, "count"); // Count metrics for a different endpoint - statsBeat.count(200, payloadData, "https://different.endpoint.com"); + internalSdkStats.count(200, payloadData, "https://different.endpoint.com"); - // Verify that trackStatsbeats is called when the timer fires + // Verify that trackInternalSdkStatss is called when the timer fires this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // The count method was called, but it should return early Assert.equal(1, countSpy.callCount, "count method should be called"); @@ -235,16 +235,16 @@ export class StatsBeatTests extends AITestClass { name: "SDK Stats: test dynamic configuration changes", useFakeTimers: true, test: () => { - // Setup core with statsbeat enabled (guard against re-initialization since the + // Setup core with internalSdkStats enabled (guard against re-initialization since the // core is now initialized in testInitialize()) if (!this._core.isInitialized()) { this._core.initialize(this._config, [new ChannelPlugin()]); } // Initialize SDK Stats manager for a specific endpoint - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); this._core.setStatsMgr(this._statsMgr); - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", @@ -252,43 +252,43 @@ export class StatsBeatTests extends AITestClass { }; // Verify that SDK Stats is created - const statsbeat = this._core.getStatsBeat(statsBeatState); - Assert.ok(!!statsbeat, "Statsbeat should be created"); + const internalSdkStats = this._core.getSdkStats(internalSdkStatsState); + Assert.ok(!!internalSdkStats, "InternalSdkStats should be created"); // Explicitly disable SDK Stats - this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.disable; this.clock.tick(1); // Allow time for config changes to propagate // Verify that SDK Stats is removed - const updatedStatsbeat = this._core.getStatsBeat(statsBeatState); - Assert.ok(!updatedStatsbeat, "SDK Stats should be removed when disabled"); + const updatedInternalSdkStats = this._core.getSdkStats(internalSdkStatsState); + Assert.ok(!updatedInternalSdkStats, "SDK Stats should be removed when disabled"); // Re-enable SDK Stats - this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.enable; + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.enable; this.clock.tick(1); // Allow time for config changes to propagate // Verify that SDK Stats is created again - const reenabledStatsbeat = this._core.getStatsBeat(statsBeatState); - Assert.ok(reenabledStatsbeat, "SDK Stats should be recreated when re-enabled"); + const reenabledInternalSdkStats = this._core.getSdkStats(internalSdkStatsState); + Assert.ok(reenabledInternalSdkStats, "SDK Stats should be recreated when re-enabled"); // FeatureOptInMode.none falls back to the SDK default state (enabled), so SDK Stats stays enabled - this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.none; + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.none; this.clock.tick(1); // Allow time for config changes to propagate // Verify that SDK Stats remains enabled (none defaults to enabled) - Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is none (defaults to enabled)"); + Assert.ok(!!this._core.getSdkStats(internalSdkStatsState), "SDK Stats should remain enabled when mode is none (defaults to enabled)"); // Explicitly disable again before testing the null case - this._core.config.featureOptIn["StatsBeat"].mode = FeatureOptInMode.disable; + this._core.config.featureOptIn["InternalSdkStats"].mode = FeatureOptInMode.disable; this.clock.tick(1); // Allow time for config changes to propagate - Assert.ok(!this._core.getStatsBeat(statsBeatState), "SDK Stats should be removed when disabled"); + Assert.ok(!this._core.getSdkStats(internalSdkStatsState), "SDK Stats should be removed when disabled"); // A null mode also falls back to the SDK default state (enabled) - this._core.config.featureOptIn["StatsBeat"].mode = null; + this._core.config.featureOptIn["InternalSdkStats"].mode = null; this.clock.tick(1); // Allow time for config changes to propagate // Verify that SDK Stats is recreated (null defaults to enabled) - Assert.ok(!!this._core.getStatsBeat(statsBeatState), "SDK Stats should remain enabled when mode is null (defaults to enabled)"); + Assert.ok(!!this._core.getSdkStats(internalSdkStatsState), "SDK Stats should remain enabled when mode is null (defaults to enabled)"); } }); @@ -296,7 +296,7 @@ export class StatsBeatTests extends AITestClass { name: "SDK Stats: routes events to the remote configured SDK Stats endpoint", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); const payloadData = { urlString: "https://example.endpoint.com", @@ -304,19 +304,19 @@ export class StatsBeatTests extends AITestClass { headers: {}, timeout: 0, disableXhrSync: false, - statsBeatData: { + statsData: { startTime: Date.now() } } as IPayloadData; - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - let statsBeat = this._statsMgr.newInst(statsBeatState); - statsBeat.count(200, payloadData, "https://example.endpoint.com"); + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); @@ -347,7 +347,7 @@ export class StatsBeatTests extends AITestClass { }; this.clock.tick(1); // Allow the config change to propagate - this._statsMgr.init(this._core, "StatsBeat"); + this._statsMgr.init(this._core, "InternalSdkStats"); const payloadData = { urlString: "https://example.endpoint.com", @@ -355,19 +355,19 @@ export class StatsBeatTests extends AITestClass { headers: {}, timeout: 0, disableXhrSync: false, - statsBeatData: { + statsData: { startTime: Date.now() } } as IPayloadData; - let statsBeatState: IStatsBeatState = { + let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", type: eStatsType.SDK }; - let statsBeat = this._statsMgr.newInst(statsBeatState); - statsBeat.count(200, payloadData, "https://example.endpoint.com"); + let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); + internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index 0cd4c5e91..e476342e7 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -12,7 +12,7 @@ import { EventsDiscardedReasonTests } from "./ai/EventsDiscardedReason.Tests"; import { W3cTraceParentTests } from "./trace/W3cTraceParentTests"; import { DynamicConfigTests } from "./config/DynamicConfig.Tests"; import { SendPostManagerTests } from "./ai/SendPostManager.Tests"; -import { StatsBeatTests } from "./ai/StatsBeat.Tests"; +import { InternalSdkStatsTests } from "./ai/InternalSdkStats.Tests"; import { OTelTraceApiTests } from "./trace/traceState.Tests"; import { CommonUtilsTests } from "./OpenTelemetry/commonUtils.Tests"; import { OpenTelemetryErrorsTests } from "./OpenTelemetry/errors.Tests"; @@ -64,8 +64,8 @@ export function runTests() { new W3cTraceStateTests().registerTests(); new TraceUtilsTests().registerTests(); new OTelNegativeTests().registerTests(); - new StatsBeatTests(false).registerTests(); - new StatsBeatTests(true).registerTests(); + new InternalSdkStatsTests(false).registerTests(); + new InternalSdkStatsTests(true).registerTests(); new SendPostManagerTests().registerTests(); new SdkStatsNotificationCbkTests().registerTests(); diff --git a/shared/AppInsightsCore/src/core/AppInsightsCore.ts b/shared/AppInsightsCore/src/core/AppInsightsCore.ts index e66f1ac17..19a0ea295 100644 --- a/shared/AppInsightsCore/src/core/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/core/AppInsightsCore.ts @@ -28,11 +28,11 @@ import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { ICookieMgr } from "../interfaces/ai/ICookieMgr"; import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; import { IDistributedTraceContext } from "../interfaces/ai/IDistributedTraceContext"; +import { IInternalSdkStats, IInternalSdkStatsState } from "../interfaces/ai/IInternalSdkStats"; import { INotificationListener } from "../interfaces/ai/INotificationListener"; import { INotificationManager } from "../interfaces/ai/INotificationManager"; import { IPerfManager } from "../interfaces/ai/IPerfManager"; import { IProcessTelemetryContext, IProcessTelemetryUpdateContext } from "../interfaces/ai/IProcessTelemetryContext"; -import { IStatsBeat, IStatsBeatState } from "../interfaces/ai/IStatsBeat"; import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "../interfaces/ai/ITelemetryInitializers"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; @@ -79,10 +79,10 @@ const maxAttributeCount = 128; // const strPluginUnloadFailed = "Failed to unload plugin"; // /** -// * Default StatsBeatMgr configuration +// * Default InternalSdkStatsMgr configuration // * @internal // */ -// const defaultStatsCfg: IConfigDefaults = objDeepFreeze({ +// const defaultStatsCfg: IConfigDefaults = objDeepFreeze({ // shrtInt: UNDEFINED_VALUE, // endCfg: cfgDfMerge([]) // }); @@ -385,7 +385,7 @@ export class AppInsightsCore im let _logger: IDiagnosticLogger; let _eventQueue: ITelemetryItem[]; let _notificationManager: INotificationManager | null | undefined; - let _statsBeat: IStatsBeat | null; + let _internalSdkStats: IInternalSdkStats | null; let _statsMgr: IStatsMgr | null; let _perfManager: IPerfManager | null; let _cfgPerfManager: IPerfManager | null; @@ -626,30 +626,30 @@ export class AppInsightsCore im _perfManager = perfMgr; }; - _self.getStatsBeat = (statsBeatState: IStatsBeatState) => { + _self.getSdkStats = (internalSdkStatsState: IInternalSdkStatsState) => { // create a new SDK Stats instance if not initialized yet or the endpoint is different // otherwise, return the existing one, or null - if (statsBeatState) { + if (internalSdkStatsState) { if (_statsMgr && _statsMgr.enabled) { - if (_statsBeat && _statsBeat.endpoint !== statsBeatState.endpoint) { + if (_internalSdkStats && _internalSdkStats.endpoint !== internalSdkStatsState.endpoint) { // Different endpoint, so unload the existing and create a new one - _statsBeat.enabled = false; - _statsBeat = null; + _internalSdkStats.enabled = false; + _internalSdkStats = null; } - if (!_statsBeat) { + if (!_internalSdkStats) { // Create a new SDK Stats instance - _statsBeat = _statsMgr.newInst(statsBeatState); + _internalSdkStats = _statsMgr.newInst(internalSdkStatsState); } - } else if (_statsBeat) { + } else if (_internalSdkStats) { // Disable and remove any previously created SDK Stats instance - _statsBeat.enabled = false; - _statsBeat = null; + _internalSdkStats.enabled = false; + _internalSdkStats = null; } // Return the current SDK Stats instance or null if not created - return _statsBeat; + return _internalSdkStats; } // Return null as no SDK Stats state was provided @@ -659,9 +659,9 @@ export class AppInsightsCore im _self.setStatsMgr = (statsMgr: IStatsMgr) => { if (_statsMgr && _statsMgr !== statsMgr) { // Disable any previously created SDK Stats instance - if (_statsBeat) { - _statsBeat.enabled = false; - _statsBeat = null; + if (_internalSdkStats) { + _internalSdkStats.enabled = false; + _internalSdkStats = null; } } @@ -911,10 +911,10 @@ export class AppInsightsCore im let processUnloadCtx = createProcessTelemetryUnloadContext(_getPluginChain(), _self); processUnloadCtx.onComplete(() => { - if (_statsBeat) { + if (_internalSdkStats) { // Disable any SDK Stats instance - _statsBeat.enabled = false; - _statsBeat = null; + _internalSdkStats.enabled = false; + _internalSdkStats = null; } _hookContainer.run(_self.logger); @@ -1317,11 +1317,11 @@ export class AppInsightsCore im runTargetUnload(_notificationManager, false); _notificationManager = null; _perfManager = null; - if (_statsBeat) { + if (_internalSdkStats) { // Disable any SDK Stats instance - _statsBeat.enabled = false; + _internalSdkStats.enabled = false; } - _statsBeat = null; + _internalSdkStats = null; _statsMgr = null; _cfgPerfManager = null; runTargetUnload(_cookieManager, false); @@ -1736,7 +1736,7 @@ export class AppInsightsCore im return null; } - public getStatsBeat(statsBeatState: IStatsBeatState): IStatsBeat { + public getSdkStats(internalSdkStatsState: IInternalSdkStatsState): IInternalSdkStats { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging return null; } diff --git a/shared/AppInsightsCore/src/core/StatsBeat.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts similarity index 80% rename from shared/AppInsightsCore/src/core/StatsBeat.ts rename to shared/AppInsightsCore/src/core/InternalSdkStats.ts index ed2322b76..15816775d 100644 --- a/shared/AppInsightsCore/src/core/StatsBeat.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -15,10 +15,11 @@ import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums" import { eStatsEndpointType, eStatsType } from "../enums/ai/StatsType"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; -import { INetworkStatsbeat } from "../interfaces/ai/INetworkStatsbeat"; import { - IStatsBeat, IStatsBeatCfgResult, IStatsBeatConfig, IStatsBeatKeyMap, IStatsBeatState, IStatsEndpointConfig -} from "../interfaces/ai/IStatsBeat"; + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsKeyMap, IInternalSdkStatsState, + IStatsEndpointConfig +} from "../interfaces/ai/IInternalSdkStats"; +import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; @@ -28,8 +29,8 @@ import { getResponseText, isFeatureEnabled, openXhr } from "../utils/HelperFuncs const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes const STATS_MIN_INTERVAL_SECONDS = 60; // 1 minute -const STATSBEAT_LANGUAGE = "JavaScript"; -const STATSBEAT_TYPE = "Browser"; +const STATS_LANGUAGE = "JavaScript"; +const STATS_TYPE = "Browser"; /** * The placeholder instrumentation key used when reporting SDK statistics to the distro-owned @@ -121,8 +122,8 @@ export function getStatsCfgUrl(endpoint: string): string { } /** Parse the SDK Stats config JSON (`{ ver, enabled, url }`); null if empty or unparseable. */ -function _parseStatsCfg(response: string): IStatsBeatCfgResult { - let result: IStatsBeatCfgResult = null; +function _parseStatsCfg(response: string): IInternalSdkStatsCfgResult { + let result: IInternalSdkStatsCfgResult = null; let json = getJSON(); if (response && json) { try { @@ -142,7 +143,7 @@ function _parseStatsCfg(response: string): IStatsBeatCfgResult { } /** Default SDK Stats config fetch (fetch, else XHR); calls oncomplete with the parsed config or null. */ -function _defaultStatsCfgFetch(cfgUrl: string, oncomplete: (result: IStatsBeatCfgResult) => void): void { +function _defaultStatsCfgFetch(cfgUrl: string, oncomplete: (result: IInternalSdkStatsCfgResult) => void): void { function _complete(response?: string) { try { oncomplete(response ? _parseStatsCfg(response) : null); @@ -217,7 +218,7 @@ export function getStatsBreezeIKey(endpoint: string): string { /** - * An internal interface to allow the IStatsBeat instance to call back to the manager for + * An internal interface to allow the IInternalSdkStats instance to call back to the manager for * critical tasks, like starting the timer, sending the events and to inform the manager * that this instance is stopping. This is used to ensure that the manager is able to * track and control the lifecycle of the instance. @@ -225,7 +226,7 @@ export function getStatsBreezeIKey(endpoint: string): string { */ interface _IMgrCallbacks { /** - * Provides a callback to the manager to start a timer for the statsbeat instance. + * Provides a callback to the manager to start a timer for the internalSdkStats instance. * This is used to ensure that the manager is able to control the lifecycle of the instance * @param cb - The callback to call when the timer is started * @returns A handle to the timer that was started, this can be used to cancel the timer if needed @@ -233,12 +234,12 @@ interface _IMgrCallbacks { start: (cb: () => void) => ITimerHandler; /** - * Provides a callback to the manager to send the statsbeat event to the core. + * Provides a callback to the manager to send the internalSdkStats event to the core. * This is used to ensure that the manager is able to control the lifecycle of the instance - * @param statsbeatEvent - The statsbeat event to send to the core + * @param internalSdkStatsEvent - The internalSdkStats event to send to the core * @param endpoint - The endpoint to send the event to */ - track: (statsBeat: IStatsBeat, statsbeatEvent: ITelemetryItem) => void; + track: (internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) => void; } /** @@ -271,11 +272,11 @@ function _isMatchEndpoint(endpoint: string, urlMatch: string): boolean { } /** - * Creates a new INetworkStatsbeat instance with the specified host. - * @param host - The host for the INetworkStatsbeat instance. - * @returns A new INetworkStatsbeat instance. + * Creates a new IInternalSdkStatsNetwork instance with the specified host. + * @param host - The host for the IInternalSdkStatsNetwork instance. + * @returns A new IInternalSdkStatsNetwork instance. */ -function _createNetworkStatsbeat(host: string): INetworkStatsbeat { +function _createInternalSdkStatsNetwork(host: string): IInternalSdkStatsNetwork { return { host, totalRequest: 0, @@ -289,30 +290,30 @@ function _createNetworkStatsbeat(host: string): INetworkStatsbeat { } /** - * Creates a new IStatsBeat instance with the specified manager callbacks and statsbeat state. - * @param mgr - The manager callbacks to use for the IStatsBeat instance. - * @param statsBeatStats - The statsbeat state to use for the IStatsBeat instance. - * @returns A new IStatsBeat instance. + * Creates a new IInternalSdkStats instance with the specified manager callbacks and internalSdkStats state. + * @param mgr - The manager callbacks to use for the IInternalSdkStats instance. + * @param internalSdkStatsStats - The internalSdkStats state to use for the IInternalSdkStats instance. + * @returns A new IInternalSdkStats instance. */ -function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): IStatsBeat { - let _networkCounter: INetworkStatsbeat = _createNetworkStatsbeat(statsBeatStats.endpoint); +function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IInternalSdkStatsState): IInternalSdkStats { + let _networkCounter: IInternalSdkStatsNetwork = _createInternalSdkStatsNetwork(internalSdkStatsStats.endpoint); let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. - let _isEnabled: boolean = true; // Flag to check if statsbeat is enabled or not + let _isEnabled: boolean = true; // Flag to check if internalSdkStats is enabled or not function _setupTimer() { if (_isEnabled && !_timeoutHandle) { _timeoutHandle = mgr.start(() => { _timeoutHandle = null; - trackStatsbeats(); + trackInternalSdkStatss(); }); } } - function trackStatsbeats() { + function trackInternalSdkStatss() { if (_isEnabled) { _trackSendRequestDuration(); _trackSendRequestsCount(); - _networkCounter = _createNetworkStatsbeat(_networkCounter.host); + _networkCounter = _createInternalSdkStatsNetwork(_networkCounter.host); _timeoutHandle && _timeoutHandle.cancel(); _timeoutHandle = null; } @@ -320,9 +321,9 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): /** * This is a simple helper that checks if the currently reporting endpoint is the same as this instance was - * created with. This is used to ensure that we only send statsbeat events to the endpoint that was used + * created with. This is used to ensure that we only send internalSdkStats events to the endpoint that was used * when the instance was created. This is important as the endpoint can change during the lifetime of the - * instance and we don't want to send statsbeat events to the wrong endpoint. + * instance and we don't want to send internalSdkStats events to the wrong endpoint. * @param endpoint * @returns true if the endpoint is the same as the one used to create the instance, false otherwise */ @@ -331,26 +332,26 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): } /** - * Attempt to send statsbeat events to the server. This is done by creating a new event and sending it to the core. + * Attempt to send internalSdkStats events to the server. This is done by creating a new event and sending it to the core. * The event is created with the name and value passed in, and any additional properties are added to the event as well. * This will only send the event when - * - the statsbeat is enabled - * - the statsbeat key is set for the current endpoint + * - the internalSdkStats is enabled + * - the internalSdkStats key is set for the current endpoint * - the value is greater than 0 * @param name - The name of the event to send * @param val - The value of the event to send * @param properties - Optional additional properties to add to the event */ - function _sendStatsbeats(name: string, val: number, properties?: { [name: string]: any }) { + function _sendInternalSdkStatss(name: string, val: number, properties?: { [name: string]: any }) { if (_isEnabled && val && val > 0){ // Add extra properties let baseProperties = { "rp": "unknown", "attach": "Manual", - "cikey": statsBeatStats.cKey, - "os": STATSBEAT_TYPE, - "language": STATSBEAT_LANGUAGE, - "version": statsBeatStats.sdkVer || "unknown", + "cikey": internalSdkStatsStats.cKey, + "os": STATS_TYPE, + "language": STATS_LANGUAGE, + "version": internalSdkStatsStats.sdkVer || "unknown", "endpoint": "breeze", "host": _networkCounter.host } as { [key: string]: any }; @@ -374,7 +375,7 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): } } - let statsbeatEvent: ITelemetryItem = { + let internalSdkStatsEvent: ITelemetryItem = { name: name, baseData: { name: name, @@ -386,7 +387,7 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): // The destination iKey and (optional) SDK Stats ingestion endpoint are resolved and // stamped by the manager (see _track) based on the current (dynamic) configuration. - mgr.track(statsBeat, statsbeatEvent); + mgr.track(internalSdkStats, internalSdkStatsEvent); } } @@ -395,32 +396,32 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): if (_networkCounter.totalRequest > 0 ) { let averageRequestExecutionTime = _networkCounter.requestDuration / totalRequest; - _sendStatsbeats("Request_Duration", averageRequestExecutionTime); + _sendInternalSdkStatss("Request_Duration", averageRequestExecutionTime); } } function _trackSendRequestsCount() { var currentCounter = _networkCounter; - _sendStatsbeats("Request_Success_Count", currentCounter.success); + _sendInternalSdkStatss("Request_Success_Count", currentCounter.success); for (const code in currentCounter.failure) { const count = currentCounter.failure[code]; - _sendStatsbeats("failure", count, { statusCode: code }); + _sendInternalSdkStatss("failure", count, { statusCode: code }); } for (const code in currentCounter.retry) { const count = currentCounter.retry[code]; - _sendStatsbeats("retry", count, { statusCode: code }); + _sendInternalSdkStatss("retry", count, { statusCode: code }); } for (const code in currentCounter.exception) { const count = currentCounter.exception[code]; - _sendStatsbeats("exception", count, { exceptionType: code }); + _sendInternalSdkStatss("exception", count, { exceptionType: code }); } for (const code in currentCounter.throttle) { const count = currentCounter.throttle[code]; - _sendStatsbeats("Throttle_Count", count, { statusCode: code }); + _sendInternalSdkStatss("Throttle_Count", count, { statusCode: code }); } } @@ -434,16 +435,16 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): } } - // THE statsbeat instance being created and returned - let statsBeat: IStatsBeat = { + // THE internalSdkStats instance being created and returned + let internalSdkStats: IInternalSdkStats = { enabled: !!_isEnabled, endpoint: STR_EMPTY, type: eStatsType.SDK, count: (status: number, payloadData: IPayloadData, endpoint: string) => { if (_isEnabled && _checkEndpoint(endpoint)) { - if (payloadData && (payloadData as any)["statsBeatData"] && (payloadData as any)["statsBeatData"]["startTime"]) { + if (payloadData && (payloadData as any)["statsData"] && (payloadData as any)["statsData"]["startTime"]) { _networkCounter.totalRequest = (_networkCounter.totalRequest || 0) + 1; - _networkCounter.requestDuration += utcNow() - (payloadData as any)["statsBeatData"]["startTime"]; + _networkCounter.requestDuration += utcNow() - (payloadData as any)["statsData"]["startTime"]; } let retryArray = [401, 403, 408, 429, 500, 502, 503, 504]; @@ -471,17 +472,17 @@ function _createStatsBeat(mgr: _IMgrCallbacks, statsBeatStats: IStatsBeatState): }; // Make the properties readonly / reactive to changes - return objDefineProps(statsBeat, { + return objDefineProps(internalSdkStats, { enabled: { g: () => _isEnabled, s: _setEnabled }, - type: { g: () => statsBeatStats.type }, + type: { g: () => internalSdkStatsStats.type }, endpoint: { g: () => _networkCounter.host } }); } -function _getEndpointCfg(statsBeatConfig: IStatsBeatConfig, type: eStatsType): IStatsEndpointConfig { +function _getEndpointCfg(internalSdkStatsConfig: IInternalSdkStatsConfig, type: eStatsType): IStatsEndpointConfig { let endpointCfg: IStatsEndpointConfig = null; - if (statsBeatConfig && statsBeatConfig.endCfg) { - arrForEach(statsBeatConfig.endCfg, (value) => { + if (internalSdkStatsConfig && internalSdkStatsConfig.endCfg) { + arrForEach(internalSdkStatsConfig.endCfg, (value) => { if (value.type === type) { endpointCfg = value; return -1; // Stop the loop if we found a match @@ -493,16 +494,16 @@ function _getEndpointCfg(statsBeatConfig: IStatsBeatConfig, type: eStatsType): I } /** - * This function retrieves the matching {@link IStatsBeatKeyMap} entry for the given endpoint from + * This function retrieves the matching {@link IInternalSdkStatsKeyMap} entry for the given endpoint from * the provided endpoint configuration. It iterates through the key maps and checks if the endpoint * matches any of the configured URL patterns. If a match is found, the corresponding key map entry * (which carries the optional instrumentation key and SDK Stats ingestion endpoint URL) is returned. * @param endpointCfg - The endpoint configuration to search. * @param endpoint - The endpoint to check against the URLs in the configuration. - * @returns The matching {@link IStatsBeatKeyMap} entry, or null if no match is found. + * @returns The matching {@link IInternalSdkStatsKeyMap} entry, or null if no match is found. */ -function _getKeyMap(endpointCfg: IStatsEndpointConfig, endpoint: string): IStatsBeatKeyMap | null { - let matched: IStatsBeatKeyMap = null; +function _getKeyMap(endpointCfg: IStatsEndpointConfig, endpoint: string): IInternalSdkStatsKeyMap | null { + let matched: IInternalSdkStatsKeyMap = null; if (endpointCfg.keyMap) { arrForEach(endpointCfg.keyMap, (keyMap) => { if (keyMap.match) { @@ -527,19 +528,19 @@ function _getKeyMap(endpointCfg: IStatsEndpointConfig, endpoint: string): IStats } export function createStatsMgr(): IStatsMgr { - let _isMgrEnabled: boolean = false; // Flag to check if statsbeat is enabled or not + let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not let _core: IAppInsightsCore; // The core instance that is used to send telemetry let _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; - let _statsBeatConfig: IStatsBeatConfig; + let _internalSdkStatsConfig: IInternalSdkStatsConfig; // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. - let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IStatsBeatCfgResult } } = {}; + let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = {}; // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again function _init(core: IAppInsightsCore, featureName?: string) { if (_core) { // If the core is already set, then just return with an empty unload hook - _throwInternal(safeGetLogger(core), eLoggingSeverity.WARNING, _eInternalMessageId.StatsBeatManagerException, "StatsBeat manager is already initialized"); + _throwInternal(safeGetLogger(core), eLoggingSeverity.WARNING, _eInternalMessageId.InternalSdkStatsManagerException, "InternalSdkStats manager is already initialized"); return null; } @@ -556,12 +557,12 @@ export function createStatsMgr(): IStatsMgr { // can be overridden via the CDN / dynamic config or by the SKU, then reference the live // nested stats config so the dependency is registered and runtime changes are picked up. details.setDf(details.cfg, _sdkStatsDefaults as IConfigDefaults); - _statsBeatConfig = details.ref(details.cfg, "stats"); - if (_statsBeatConfig) { + _internalSdkStatsConfig = details.ref(details.cfg, "stats"); + if (_internalSdkStatsConfig) { _isMgrEnabled = true; _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed - if (isNumber(_statsBeatConfig.shrtInt) && _statsBeatConfig.shrtInt > STATS_MIN_INTERVAL_SECONDS) { - _shortInterval = _statsBeatConfig.shrtInt * 1000; // Convert to milliseconds + if (isNumber(_internalSdkStatsConfig.shrtInt) && _internalSdkStatsConfig.shrtInt > STATS_MIN_INTERVAL_SECONDS) { + _shortInterval = _internalSdkStatsConfig.shrtInt * 1000; // Convert to milliseconds } } } @@ -573,7 +574,7 @@ export function createStatsMgr(): IStatsMgr { * Resolve the remote SDK Stats config for the endpoint, starting a fetch on first use. Returns * null until resolved (or on failure) so the caller skips sending. */ - function _resolveStatsCfg(endpoint: string): IStatsBeatCfgResult { + function _resolveStatsCfg(endpoint: string): IInternalSdkStatsCfgResult { let cfgUrl = getStatsCfgUrl(endpoint); let entry = _cfgCache[cfgUrl]; if (!entry) { @@ -582,7 +583,7 @@ export function createStatsMgr(): IStatsMgr { if (!entry.result && !entry.pending) { entry.pending = true; - let fetchFn = (_statsBeatConfig && _statsBeatConfig.overrideCfgFn) || _defaultStatsCfgFetch; + let fetchFn = (_internalSdkStatsConfig && _internalSdkStatsConfig.overrideCfgFn) || _defaultStatsCfgFetch; try { fetchFn(cfgUrl, (result) => { entry.pending = false; @@ -598,21 +599,21 @@ export function createStatsMgr(): IStatsMgr { return entry.result; } - function _track(statsBeat: IStatsBeat, statsBeatEvent: ITelemetryItem) { - if (_isMgrEnabled && _statsBeatConfig) { - let endpoint = statsBeat.endpoint; + function _track(internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) { + if (_isMgrEnabled && _internalSdkStatsConfig) { + let endpoint = internalSdkStats.endpoint; // Fetching the matching key map for the endpoint here to support the scenario where the // endpoint is changed after the SDK Stats instance is created. This will ensure that the // correct key / destination is used for the endpoint, and avoids tracking the event if the // endpoint is not in the config. - let endpointCfg = _getEndpointCfg(_statsBeatConfig, statsBeat.type); + let endpointCfg = _getEndpointCfg(_internalSdkStatsConfig, internalSdkStats.type); if (endpointCfg) { let keyMap = _getKeyMap(endpointCfg, endpoint); // Only send the event if the endpoint matched a configured key map (or the event type // is non-zero, preserving the legacy behaviour for explicitly typed stats events). - if (keyMap || statsBeat.type) { - let useBreeze = _statsBeatConfig.mode === eStatsEndpointType.Breeze; + if (keyMap || internalSdkStats.type) { + let useBreeze = _internalSdkStatsConfig.mode === eStatsEndpointType.Breeze; // Resolve the iKey to use, falling back to the mode default when the matched key // map does not specify one. Breeze mode uses the Microsoft-owned breeze SDK Stats @@ -636,28 +637,28 @@ export function createStatsMgr(): IStatsMgr { } } - statsBeatEvent.iKey = iKey; + internalSdkStatsEvent.iKey = iKey; if (url) { // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the // event away from the customer's breeze endpoint. This marker is removed by the // channel before the event is serialized. - statsBeatEvent.data = statsBeatEvent.data || {}; - statsBeatEvent.data[STATS_SDK_ENDPOINT_KEY] = url; + internalSdkStatsEvent.data = internalSdkStatsEvent.data || {}; + internalSdkStatsEvent.data[STATS_SDK_ENDPOINT_KEY] = url; } - _core.track(statsBeatEvent); + _core.track(internalSdkStatsEvent); } } } } - function _createInstance(state: IStatsBeatState): IStatsBeat { - let instance: IStatsBeat = null; + function _createInstance(state: IInternalSdkStatsState): IInternalSdkStats { + let instance: IInternalSdkStats = null; if (_isMgrEnabled) { // Prefetch the remote config (SDK Stats mode) so it's ready by the first interval - if (state && state.endpoint && _statsBeatConfig.mode !== eStatsEndpointType.Breeze) { + if (state && state.endpoint && _internalSdkStatsConfig.mode !== eStatsEndpointType.Breeze) { _resolveStatsCfg(state.endpoint); } @@ -668,7 +669,7 @@ export function createStatsMgr(): IStatsMgr { track: _track }; - instance = _createStatsBeat(callbacks, state); + instance = _createInternalSdkStats(callbacks, state); } return instance; @@ -686,7 +687,7 @@ export function createStatsMgr(): IStatsMgr { } /** - * The default {@link IStatsBeatConfig} values for SDK Stats collection. These are seeded into the + * The default {@link IInternalSdkStatsConfig} values for SDK Stats collection. These are seeded into the * single global config (via {@link IWatchDetails.setDf}) by the manager so they remain dynamic and * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). By default * the events are routed to the distro-owned SDK Stats ingestion endpoint, whose host (and whether @@ -697,7 +698,7 @@ export function createStatsMgr(): IStatsMgr { * {@link STATS_SDK_FEATURE} name. */ const _sdkStatsDefaults: IConfigDefaults = { - stats: cfgDfMerge({ + stats: cfgDfMerge({ mode: eStatsEndpointType.SdkStats, // The destination iKey / endpoint are resolved per-event in _track based on the mode, so the // default key map only needs to match all endpoints. A full key map (including explicit keys / diff --git a/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts b/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts index 28b16bd8f..60cb3a611 100644 --- a/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts +++ b/shared/AppInsightsCore/src/enums/ai/LoggingEnums.ts @@ -127,8 +127,8 @@ export const enum _eInternalMessageId { CdnDeprecation = 110, SdkLdrUpdate = 111, InitPromiseException = 112, - StatsBeatManagerException = 113, - StatsBeatException = 114, + InternalSdkStatsManagerException = 113, + InternalSdkStatsException = 114, AttributeError = 115, SpanError = 116, TraceError = 117, diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index dee0ea595..1ad74ae56 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -41,15 +41,15 @@ export { IXDomainRequest, IBackendResponse } from "./interfaces/ai/IXDomainReque export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IInternalXhrOverride } from "./interfaces/ai/ISenderPostManager"; export { SenderPostManager } from "./core/SenderPostManager"; export { - IStatsBeat, IStatsBeatCfgResult, IStatsBeatConfig, IStatsBeatKeyMap as IStatsBeatEndpoints, IStatsBeatState, StatsBeatCfgFetchFn -} from "./interfaces/ai/IStatsBeat"; + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsKeyMap as IInternalSdkStatsEndpoints, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn +} from "./interfaces/ai/IInternalSdkStats"; export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { createStatsMgr, getStatsCfgUrl, getStatsBreezeIKey, STATS_SDK_IKEY, STATS_SDK_CFG_URL_NON_EU, STATS_SDK_CFG_URL_EU, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, STATS_BREEZE_IKEY_NON_EU, STATS_BREEZE_IKEY_EU -} from "./core/StatsBeat"; +} from "./core/InternalSdkStats"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; export { isArray, isTypeof, isUndefined, isNullOrUndefined, isStrictUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, diff --git a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts index aa23c2327..e0394381d 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IAppInsightsCore.ts @@ -11,11 +11,11 @@ import { IChannelControls } from "./IChannelControls"; import { IConfiguration } from "./IConfiguration"; import { ICookieMgr } from "./ICookieMgr"; import { IDiagnosticLogger } from "./IDiagnosticLogger"; +import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; import { INotificationListener } from "./INotificationListener"; import { INotificationManager } from "./INotificationManager"; import { IPerfManagerProvider } from "./IPerfManager"; import { IProcessTelemetryContext } from "./IProcessTelemetryContext"; -import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; import { IStatsMgr } from "./IStatsMgr"; import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "./ITelemetryInitializers"; import { ITelemetryItem } from "./ITelemetryItem"; @@ -123,10 +123,10 @@ export interface IAppInsightsCore void) => void; +export type InternalSdkStatsCfgFetchFn = (cfgUrl: string, oncomplete: (result: IInternalSdkStatsCfgResult | null) => void) => void; /** * The configuration for the stats beat definition * @since 3.3.7 */ -export interface IStatsBeatConfig { +export interface IInternalSdkStatsConfig { /** * The short collection interval in seconds to send the stats beat events. * Default: 15 min @@ -175,5 +175,5 @@ export interface IStatsBeatConfig { * is primarily intended for testing or advanced scenarios where the configuration needs to be * resolved through a custom mechanism. */ - overrideCfgFn?: StatsBeatCfgFetchFn; + overrideCfgFn?: InternalSdkStatsCfgFetchFn; } diff --git a/shared/AppInsightsCore/src/interfaces/ai/INetworkStatsbeat.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStatsNetwork.ts similarity index 93% rename from shared/AppInsightsCore/src/interfaces/ai/INetworkStatsbeat.ts rename to shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStatsNetwork.ts index bb9232886..395e5442d 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/INetworkStatsbeat.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStatsNetwork.ts @@ -6,7 +6,7 @@ * and sending statistics about network requests. It is used to track the performance * and usage of network requests, and to identify any issues or errors that may occur. */ -export interface INetworkStatsbeat { +export interface IInternalSdkStatsNetwork { host: string; totalRequest: number; success: number; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index 8e1057e43..99176dce1 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -3,12 +3,12 @@ import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; -import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; +import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; import { IUnloadHook } from "./IUnloadHook"; /** - * The Interface which defines the StatsBeat manager, which is responsible for creating and - * managing the StatsBeat instance. + * The Interface which defines the InternalSdkStats manager, which is responsible for creating and + * managing the InternalSdkStats instance. * @since 3.3.7 */ export interface IStatsMgr { @@ -34,12 +34,12 @@ export interface IStatsMgr { init: (core: IAppInsightsCore, featureName?: string) => IUnloadHook | null; /** - * Returns a new {@link IStatsBeat} instance for the current state which includes the endpoint. + * Returns a new {@link IInternalSdkStats} instance for the current state which includes the endpoint. * This method should be called only after the manager has been initialized and the - * {@link IStatsBeatConfig} has been set, otherwise it will return null. + * {@link IInternalSdkStatsConfig} has been set, otherwise it will return null. * @param state - The current state of the stats beat manager. * @returns A new instance of the stats beat or null if the manager or the configuration does not support - * the {@link IStatsBeatState}. + * the {@link IInternalSdkStatsState}. */ - newInst: (state: IStatsBeatState) => IStatsBeat; + newInst: (state: IInternalSdkStatsState) => IInternalSdkStats; } \ No newline at end of file From ad933831d21f7438fe2d1c81ab8b456e63c13c2e Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 20 Jul 2026 12:49:19 -0700 Subject: [PATCH 16/51] Use cfg/v1.json as sole authority for SDK Stats enabled state and destination Remove the breeze-endpoint mode and key-map/endCfg routing so SDK Stats collection is gated solely on the remote cfg enabled flag and sent to the cfg-provided host. Parsing is fail-closed (enabled === true, url must be a string). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/InternalSdkStats.tests.ts | 27 +-- .../Unit/src/ai/AppInsightsCoreSize.Tests.ts | 4 +- .../Unit/src/ai/InternalSdkStats.Tests.ts | 40 ++-- .../src/core/InternalSdkStats.ts | 210 ++++-------------- .../AppInsightsCore/src/enums/ai/StatsType.ts | 22 -- shared/AppInsightsCore/src/index.ts | 9 +- .../src/interfaces/ai/IConfiguration.ts | 6 +- .../src/interfaces/ai/IInternalSdkStats.ts | 65 +----- 8 files changed, 77 insertions(+), 306 deletions(-) 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 eed298774..29e526739 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -59,19 +59,7 @@ export class InternalSdkStatsTests extends AITestClass { // do not depend on a network fetch of the cfg/v1.json endpoint. overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); - }, - endCfg: [ - { - type: 0, - keyMap: [ - { - key: "stats-key1", - // Match the Sender's endpoint so SDK Stats are tracked for it - match: [ config.endpointUrl ] - } - ] - } - ] + } }, extensionConfig: { [sender.identifier]: config } }; @@ -158,18 +146,7 @@ export class InternalSdkStatsTests extends AITestClass { shrtInt: 900, overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); - }, - endCfg: [ - { - type: 0, - keyMap: [ - { - key: "stats-key1", - match: [ "https://example.endpoint.com" ] - } - ] - } - ] + } } }; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index d16e353ba..c57bf2b50 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -51,8 +51,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 138; - private readonly MAX_BUNDLE_SIZE = 138; + private readonly MAX_RAW_SIZE = 137; + private readonly MAX_BUNDLE_SIZE = 137; private readonly MAX_RAW_DEFLATE_SIZE = 56; private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; private readonly rawFilePath = "../dist/es5/index.min.js"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 91c467f83..a6df170be 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -42,18 +42,7 @@ export class InternalSdkStatsTests extends AITestClass { // do not attempt a real network fetch of the cfg/v1.json endpoint. overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); - }, - endCfg: [ - { - type: 0, - keyMap: [ - { - key: "stats-key1", - match: [ "https://example.endpoint.com" ] - } - ] - } - ] + } } }; @@ -138,7 +127,7 @@ export class InternalSdkStatsTests extends AITestClass { // Test throttled request internalSdkStats.count(429, payloadData, "https://example.endpoint.com"); - // Verify that trackInternalSdkStatss is called when the timer fires + // Verify that track is called when the collection timer fires this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // Verify that track was called @@ -167,7 +156,7 @@ export class InternalSdkStatsTests extends AITestClass { // Count an exception internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); - // Verify that trackInternalSdkStatss is called when the timer fires + // Verify that track is called when the collection timer fires this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // Verify that track was called @@ -223,7 +212,7 @@ export class InternalSdkStatsTests extends AITestClass { // Count metrics for a different endpoint internalSdkStats.count(200, payloadData, "https://different.endpoint.com"); - // Verify that trackInternalSdkStatss is called when the timer fires + // Verify that track is called when the collection timer fires this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); // The count method was called, but it should return early Assert.equal(1, countSpy.callCount, "count method should be called"); @@ -374,6 +363,27 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal(0, this._trackSpy.callCount, "track should not be called when the remote configuration disables SDK Stats"); } }); + + this.testCase({ + name: "SDK Stats: manager enables by default when config.stats is not provided", + test: () => { + // A core without an explicit config.stats should still enable the manager, because the + // manager seeds an (empty) stats config default. + let core = new AppInsightsCore(); + core.initialize({ + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true + } as IConfiguration, [new ChannelPlugin()]); + + let statsMgr = createStatsMgr(); + let hook = statsMgr.init(core, "InternalSdkStats"); + + Assert.equal(true, statsMgr.enabled, "Manager should be enabled by default via the seeded stats config"); + + hook && hook.rm(); + core.unload(false); + } + }); } } diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 15816775d..df7741451 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -3,7 +3,7 @@ import { doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrForEach, isNumber, makeGlobRegex, objDefineProps, scheduleTimeout, strEndsWith, strIndexOf, strLower, strStartsWith, + ITimerHandler, arrForEach, isNumber, isString, objDefineProps, scheduleTimeout, strEndsWith, strIndexOf, strLower, strStartsWith, strSubstring, utcNow } from "@nevware21/ts-utils"; import { cfgDfMerge } from "../config/ConfigDefaultHelpers"; @@ -12,12 +12,11 @@ import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constant import { STR_EMPTY } from "../constants/InternalConstants"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; -import { eStatsEndpointType, eStatsType } from "../enums/ai/StatsType"; +import { eStatsType } from "../enums/ai/StatsType"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { - IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsKeyMap, IInternalSdkStatsState, - IStatsEndpointConfig + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsState } from "../interfaces/ai/IInternalSdkStats"; import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; @@ -50,15 +49,6 @@ export const STATS_SDK_CFG_URL_EU = "https://eu-data.stats.monitor.azure.com/cfg /** Ingestion path for future 1DS (OneCollector) SDK Stats; the AI SKU uses {@link DEFAULT_BREEZE_PATH}. */ export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; -/** - * The Microsoft-owned instrumentation keys used when reporting SDK statistics to the legacy - * breeze ingestion endpoints (the customer's own breeze endpoint is used as the host, only the - * iKey differs so the data lands in the Microsoft SDK Stats resource). The EU key is used when - * the customer's endpoint maps to an EU data-boundary region, otherwise the non-EU key is used. - */ -export const STATS_BREEZE_IKEY_NON_EU = "c4a29126-a7cb-47e5-b348-11414998b11e"; -export const STATS_BREEZE_IKEY_EU = "7dc56bab-3c0c-4e9f-9ebb-d1acadee8d0f"; - /** * The transient marker key, set on the {@link ITelemetryItem.data} of a SDK Stats event, that * carries the destination SDK Stats ingestion endpoint. The sending channel reads this value to @@ -130,8 +120,9 @@ function _parseStatsCfg(response: string): IInternalSdkStatsCfgResult { let cfg = json.parse(response); if (cfg) { result = { - enabled: !!cfg.enabled, - url: cfg.url + // Fail-closed: only treat as enabled when explicitly true + enabled: cfg.enabled === true, + url: isString(cfg.url) ? cfg.url : null }; } } catch (e) { @@ -205,17 +196,6 @@ function _buildStatsEndpoint(host: string): string { return endpoint; } -/** - * Determine the Microsoft-owned instrumentation key to use when reporting SDK Stats to the legacy - * breeze endpoint for the provided customer endpoint. When the region maps to an EU region the EU - * key is returned, otherwise (including unknown regions) the non-EU key is returned. - * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. - * @returns The breeze SDK Stats instrumentation key. - */ -export function getStatsBreezeIKey(endpoint: string): string { - return _isEuEndpoint(endpoint) ? STATS_BREEZE_IKEY_EU : STATS_BREEZE_IKEY_NON_EU; -} - /** * An internal interface to allow the IInternalSdkStats instance to call back to the manager for @@ -242,35 +222,6 @@ interface _IMgrCallbacks { track: (internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) => void; } -/** - * This function checks if the provided endpoint matches the provided urlMatch. It - * compares the endpoint with the urlMatch in a case-insensitive manner and also checks - * if the endpoint is a substring of the urlMatch. The urlMatch can also be a regex - * pattern, in which case it will be checked against the endpoint using regex. - * @param endpoint - The endpoint to check against the URL. - * @param urlMatch - The URL to check against the endpoint. - * @returns true if the URL matches the endpoint, false otherwise. - */ -function _isMatchEndpoint(endpoint: string, urlMatch: string): boolean { - let lwrUrl = strLower(urlMatch); - - // Check if the endpoint is a substring of the URL - if (strIndexOf(endpoint, lwrUrl) !== -1) { - return true; - } - - // If it looks like a regex pattern, check if the endpoint matches the regex - if (strIndexOf(lwrUrl, "*") != -1 || strIndexOf(lwrUrl, "?") != -1) { - // Check if the endpoint is a regex pattern - let regex = makeGlobRegex(lwrUrl); - if (regex.test(endpoint)) { - return true; - } - } - - return false; -} - /** * Creates a new IInternalSdkStatsNetwork instance with the specified host. * @param host - The host for the IInternalSdkStatsNetwork instance. @@ -304,12 +255,12 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn if (_isEnabled && !_timeoutHandle) { _timeoutHandle = mgr.start(() => { _timeoutHandle = null; - trackInternalSdkStatss(); + trackInternalSdkStats(); }); } } - function trackInternalSdkStatss() { + function trackInternalSdkStats() { if (_isEnabled) { _trackSendRequestDuration(); _trackSendRequestsCount(); @@ -479,54 +430,6 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn }); } -function _getEndpointCfg(internalSdkStatsConfig: IInternalSdkStatsConfig, type: eStatsType): IStatsEndpointConfig { - let endpointCfg: IStatsEndpointConfig = null; - if (internalSdkStatsConfig && internalSdkStatsConfig.endCfg) { - arrForEach(internalSdkStatsConfig.endCfg, (value) => { - if (value.type === type) { - endpointCfg = value; - return -1; // Stop the loop if we found a match - } - }); - } - - return endpointCfg; -} - -/** - * This function retrieves the matching {@link IInternalSdkStatsKeyMap} entry for the given endpoint from - * the provided endpoint configuration. It iterates through the key maps and checks if the endpoint - * matches any of the configured URL patterns. If a match is found, the corresponding key map entry - * (which carries the optional instrumentation key and SDK Stats ingestion endpoint URL) is returned. - * @param endpointCfg - The endpoint configuration to search. - * @param endpoint - The endpoint to check against the URLs in the configuration. - * @returns The matching {@link IInternalSdkStatsKeyMap} entry, or null if no match is found. - */ -function _getKeyMap(endpointCfg: IStatsEndpointConfig, endpoint: string): IInternalSdkStatsKeyMap | null { - let matched: IInternalSdkStatsKeyMap = null; - if (endpointCfg.keyMap) { - arrForEach(endpointCfg.keyMap, (keyMap) => { - if (keyMap.match) { - arrForEach(keyMap.match, (url) => { - if (_isMatchEndpoint(url, endpoint)) { - matched = keyMap; - - // Stop the loop if we found a match - return -1; - } - }); - } - - if (matched) { - // Stop the loop if we found a match - return -1; - } - }); - } - - return matched; -} - export function createStatsMgr(): IStatsMgr { let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not let _core: IAppInsightsCore; // The core instance that is used to send telemetry @@ -601,55 +504,29 @@ export function createStatsMgr(): IStatsMgr { function _track(internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) { if (_isMgrEnabled && _internalSdkStatsConfig) { - let endpoint = internalSdkStats.endpoint; - - // Fetching the matching key map for the endpoint here to support the scenario where the - // endpoint is changed after the SDK Stats instance is created. This will ensure that the - // correct key / destination is used for the endpoint, and avoids tracking the event if the - // endpoint is not in the config. - let endpointCfg = _getEndpointCfg(_internalSdkStatsConfig, internalSdkStats.type); - if (endpointCfg) { - let keyMap = _getKeyMap(endpointCfg, endpoint); - // Only send the event if the endpoint matched a configured key map (or the event type - // is non-zero, preserving the legacy behaviour for explicitly typed stats events). - if (keyMap || internalSdkStats.type) { - let useBreeze = _internalSdkStatsConfig.mode === eStatsEndpointType.Breeze; - - // Resolve the iKey to use, falling back to the mode default when the matched key - // map does not specify one. Breeze mode uses the Microsoft-owned breeze SDK Stats - // iKey (region dependent); the SDK Stats endpoint uses the placeholder iKey. - let iKey = (keyMap && keyMap.key) || (useBreeze ? getStatsBreezeIKey(endpoint) : STATS_SDK_IKEY); - - // Destination host / enabled state come from the remote config, unless the key - // map sets an explicit url. Breeze mode sends to the customer endpoint (no redirect). - let url = keyMap && keyMap.url; - if (!url && !useBreeze) { - let cfgResult = _resolveStatsCfg(endpoint); - if (!cfgResult || !cfgResult.enabled) { - // Not resolved yet, or disabled -> skip - return; - } - - url = _buildStatsEndpoint(cfgResult.url); - if (!url) { - // Enabled but no usable host -> skip (don't leak to the customer endpoint) - return; - } - } + // The remote cfg file is the sole authority for whether collection is enabled and where + // to send the events. Re-resolved here (rather than cached on the instance) to support the + // endpoint changing after the instance was created. + let cfgResult = _resolveStatsCfg(internalSdkStats.endpoint); + if (!cfgResult || !cfgResult.enabled) { + // Not resolved yet, or disabled -> skip + return; + } - internalSdkStatsEvent.iKey = iKey; + let url = _buildStatsEndpoint(cfgResult.url); + if (!url) { + // Enabled but no usable host -> skip + return; + } - if (url) { - // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the - // event away from the customer's breeze endpoint. This marker is removed by the - // channel before the event is serialized. - internalSdkStatsEvent.data = internalSdkStatsEvent.data || {}; - internalSdkStatsEvent.data[STATS_SDK_ENDPOINT_KEY] = url; - } + internalSdkStatsEvent.iKey = STATS_SDK_IKEY; + // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the event + // away from the customer's breeze endpoint. This marker is removed by the channel before + // the event is serialized. + internalSdkStatsEvent.data = internalSdkStatsEvent.data || {}; + internalSdkStatsEvent.data[STATS_SDK_ENDPOINT_KEY] = url; - _core.track(internalSdkStatsEvent); - } - } + _core.track(internalSdkStatsEvent); } } @@ -657,8 +534,8 @@ export function createStatsMgr(): IStatsMgr { let instance: IInternalSdkStats = null; if (_isMgrEnabled) { - // Prefetch the remote config (SDK Stats mode) so it's ready by the first interval - if (state && state.endpoint && _internalSdkStatsConfig.mode !== eStatsEndpointType.Breeze) { + // Prefetch the remote config so it's ready by the first interval + if (state && state.endpoint) { _resolveStatsCfg(state.endpoint); } @@ -689,25 +566,14 @@ export function createStatsMgr(): IStatsMgr { /** * The default {@link IInternalSdkStatsConfig} values for SDK Stats collection. These are seeded into the * single global config (via {@link IWatchDetails.setDf}) by the manager so they remain dynamic and - * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). By default - * the events are routed to the distro-owned SDK Stats ingestion endpoint, whose host (and whether - * collection is enabled) is read at runtime from the SDK Stats configuration - * (`data.stats.monitor.azure.com` / `eu-data.stats.monitor.azure.com`); setting `config.stats.mode` - * to {@link eStatsEndpointType.Breeze} routes SDK Stats to the legacy breeze endpoint instead. SDK - * Stats are enabled by default and can be opted-out using the `featureOptIn` configuration with the - * {@link STATS_SDK_FEATURE} name. + * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). The events + * are routed to the distro-owned SDK Stats ingestion endpoint, whose host (and whether collection is + * enabled) is read at runtime from the SDK Stats configuration (`data.stats.monitor.azure.com` / + * `eu-data.stats.monitor.azure.com`). SDK Stats are enabled by default and can be opted-out using the + * `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. */ const _sdkStatsDefaults: IConfigDefaults = { - stats: cfgDfMerge({ - mode: eStatsEndpointType.SdkStats, - // The destination iKey / endpoint are resolved per-event in _track based on the mode, so the - // default key map only needs to match all endpoints. A full key map (including explicit keys / - // urls) may be supplied via config.stats.endCfg to override this. - endCfg: [{ - type: eStatsType.SDK, - keyMap: [{ - match: ["*"] - }] - }] - }) + // Seeding an (empty) stats object enables the manager by default; the destination and enabled + // state are resolved per-event from the remote SDK Stats configuration. + stats: cfgDfMerge({}) }; diff --git a/shared/AppInsightsCore/src/enums/ai/StatsType.ts b/shared/AppInsightsCore/src/enums/ai/StatsType.ts index 2d968d5d6..3828a9c9f 100644 --- a/shared/AppInsightsCore/src/enums/ai/StatsType.ts +++ b/shared/AppInsightsCore/src/enums/ai/StatsType.ts @@ -9,25 +9,3 @@ export const enum eStatsType { export type StatsType = number | eStatsType; -/** - * Identifies which ingestion endpoint the SDK Stats events are sent to. This is configurable via - * the SDK configuration (and therefore the CDN / dynamic config) so the destination can be changed - * at runtime. - */ -export const enum eStatsEndpointType { - /** - * Send SDK Stats to the distro-owned SDK Stats ingestion endpoint. The destination host (and - * whether collection is enabled) is read at runtime from the SDK Stats configuration - * (`data.stats.monitor.azure.com` / `eu-data.stats.monitor.azure.com`). This is the default. - */ - SdkStats = 0, - - /** - * Send SDK Stats to the legacy breeze ingestion endpoint (the customer's own breeze endpoint - * host, using the Microsoft-owned SDK Stats instrumentation key). - */ - Breeze = 1, -} - -export type StatsEndpointType = number | eStatsEndpointType; - diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 1ad74ae56..128430660 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -19,7 +19,7 @@ export { IUnloadHook, ILegacyUnloadHook } from "./interfaces/ai/IUnloadHook"; export { eEventsDiscardedReason, EventsDiscardedReason, eBatchDiscardedReason, BatchDiscardedReason } from "./enums/ai/EventsDiscardedReason"; export { eDependencyTypes, DependencyTypes } from "./enums/ai/DependencyTypes"; export { SendRequestReason } from "./enums/ai/SendRequestReason"; -export { StatsType, eStatsType, StatsEndpointType, eStatsEndpointType } from "./enums/ai/StatsType"; +export { StatsType, eStatsType } from "./enums/ai/StatsType"; export { TelemetryUpdateReason } from "./enums/ai/TelemetryUpdateReason"; export { TelemetryUnloadReason } from "./enums/ai/TelemetryUnloadReason"; export { eUrlRedactionOptions, UrlRedactionOptions } from "./enums/ai/UrlRedactionOptions" @@ -41,14 +41,13 @@ export { IXDomainRequest, IBackendResponse } from "./interfaces/ai/IXDomainReque export { _ISenderOnComplete, _ISendPostMgrConfig, _ITimeoutOverrideWrapper, _IInternalXhrOverride } from "./interfaces/ai/ISenderPostManager"; export { SenderPostManager } from "./core/SenderPostManager"; export { - IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsKeyMap as IInternalSdkStatsEndpoints, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn } from "./interfaces/ai/IInternalSdkStats"; export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { - createStatsMgr, getStatsCfgUrl, getStatsBreezeIKey, - STATS_SDK_IKEY, STATS_SDK_CFG_URL_NON_EU, STATS_SDK_CFG_URL_EU, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE, - STATS_BREEZE_IKEY_NON_EU, STATS_BREEZE_IKEY_EU + createStatsMgr, getStatsCfgUrl, + STATS_SDK_IKEY, STATS_SDK_CFG_URL_NON_EU, STATS_SDK_CFG_URL_EU, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE } from "./core/InternalSdkStats"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; export { diff --git a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts index 596d29ec8..6b0b2b221 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts @@ -251,9 +251,9 @@ export interface IConfiguration extends IOTelConfig { /** * [Optional] Configuration for the SDK Stats (internal SDK statistics) collection. This may be - * supplied / overridden via the CDN / dynamic config to change the SDK Stats behaviour at runtime, - * for example setting `stats.mode` to {@link eStatsEndpointType.Breeze} routes SDK Stats to the - * legacy breeze endpoint instead of the distro-owned SDK Stats endpoint. + * supplied / overridden via the CDN / dynamic config to change the SDK Stats behaviour at runtime. + * Whether collection is enabled and the destination host are read at runtime from the SDK Stats + * configuration (`cfg/v1.json`). */ stats?: IInternalSdkStatsConfig; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index f1a1263ed..0b986c36b 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { StatsEndpointType, StatsType } from "../../enums/ai/StatsType"; +import { StatsType } from "../../enums/ai/StatsType"; import { IPayloadData } from "./IXHROverride"; /** @@ -70,49 +70,6 @@ export interface IInternalSdkStatsState { type?: StatsType; } -/** - * The configuration for the collection of supported endpoints - * @since 3.3.7 - */ -export interface IInternalSdkStatsKeyMap { - /** - * The key to used to for any matching endpoints. - */ - key?: string; - - /** - * The SDK Stats ingestion endpoint URL that matching events should be redirected to. When - * omitted, matching events are sent to the customer's configured (breeze) endpoint instead. - */ - url?: string; - - /** - * An array of string URLs that are supported by the endpoint, - * the string values are used to compar against the endpoint URL - * in a case insensitive manner. The values may also contain wildcards - * characters "*", "**" and "?" to match any number of characters using - * a glob style pattern. - */ - match: string[]; -} - -/** - * The configuration for the stats beat plugin, which is used to track the performance and usage of the SDK. - * It is used to identify any issues or errors that may occur, and to provide insights into the usage of the SDK. - * @since 3.3.7 - */ -export interface IStatsEndpointConfig { - /** - * Identifies the key(s) associated with the endpoints for the type of stats event. - */ - type: StatsType; - - /** - * The matching endpoints. - */ - keyMap?: IInternalSdkStatsKeyMap[] -} - /** * The parsed result of the remote SDK Stats configuration (`cfg/v1.json`). It identifies whether * SDK Stats collection is currently enabled and the host that matching events should be sent to. @@ -126,9 +83,8 @@ export interface IInternalSdkStatsCfgResult { enabled: boolean; /** - * The host (or full URL) that the SDK Stats events should be sent to. The ingestion path (e.g. - * `/v2/track`) is appended by the SDK, so this generally only carries the host (for example - * `data.stats.monitor.azure.com`). + * The host that the SDK Stats events should be sent to (host only, for example + * `data.stats.monitor.azure.com`). The ingestion path (`/v2/track`) is appended by the SDK. */ url: string; } @@ -154,21 +110,6 @@ export interface IInternalSdkStatsConfig { */ shrtInt?: number; - /** - * Identifies which ingestion endpoint the SDK Stats events are sent to. When set to - * {@link eStatsEndpointType.Breeze} the events are sent to the legacy breeze endpoint, otherwise - * they are sent to the distro-owned SDK Stats endpoint. This is configurable via the CDN / - * dynamic config so the destination can be changed at runtime. - * Default: {@link eStatsEndpointType.SdkStats} - */ - mode?: StatsEndpointType; - - /** - * The Endpoint configurations for the stats beat plugin. - * This is used to identify the endpoints that are supported by the stats beat plugin. - */ - endCfg?: IStatsEndpointConfig[]; - /** * Optional override for the function used to fetch the remote SDK Stats configuration * (`cfg/v1.json`). When not provided the default fetch / XHR based implementation is used. This From f45b3b3e17ee4edf8d7ee58dbf27b210b715b35a Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 20 Jul 2026 13:26:59 -0700 Subject: [PATCH 17/51] Refactor SDK Stats config handling to use local vars instead of stored config + ref() Address PR review: drop the persistent _internalSdkStatsConfig field and the details.ref() hack. Copy the override fetch fn and interval into local vars in the config-change handler, making the override fetch fn a dynamic leaf so merged runtime updates still re-run the handler. Seed the stats default via a plain object (not cfgDfMerge) so setDf no longer marks stats as a reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core/InternalSdkStats.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index df7741451..94bdbd353 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -6,7 +6,6 @@ import { ITimerHandler, arrForEach, isNumber, isString, objDefineProps, scheduleTimeout, strEndsWith, strIndexOf, strLower, strStartsWith, strSubstring, utcNow } from "@nevware21/ts-utils"; -import { cfgDfMerge } from "../config/ConfigDefaultHelpers"; import { onConfigChange } from "../config/DynamicConfig"; import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constants"; import { STR_EMPTY } from "../constants/InternalConstants"; @@ -16,7 +15,7 @@ import { eStatsType } from "../enums/ai/StatsType"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { - IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsState + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn } from "../interfaces/ai/IInternalSdkStats"; import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; @@ -434,7 +433,7 @@ export function createStatsMgr(): IStatsMgr { let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not let _core: IAppInsightsCore; // The core instance that is used to send telemetry let _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; - let _internalSdkStatsConfig: IInternalSdkStatsConfig; + let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = {}; @@ -455,17 +454,23 @@ export function createStatsMgr(): IStatsMgr { return onConfigChange(core.config, (details) => { // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) _isMgrEnabled = false; + _statsCfgFetchFn = null; if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and - // can be overridden via the CDN / dynamic config or by the SKU, then reference the live - // nested stats config so the dependency is registered and runtime changes are picked up. + // can be overridden via the CDN / dynamic config or by the SKU. details.setDf(details.cfg, _sdkStatsDefaults as IConfigDefaults); - _internalSdkStatsConfig = details.ref(details.cfg, "stats"); - if (_internalSdkStatsConfig) { + // Read the nested stats config directly (registers the dynamic dependency on the + // stats object) and copy the individual values into local (minifiable) variables + // instead of holding the config object and repeatedly reading its properties. + let statsCfg = details.cfg.stats; + if (statsCfg) { _isMgrEnabled = true; + // Make the override fetch fn a dynamic property before snapshotting it so a later + // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. + _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed - if (isNumber(_internalSdkStatsConfig.shrtInt) && _internalSdkStatsConfig.shrtInt > STATS_MIN_INTERVAL_SECONDS) { - _shortInterval = _internalSdkStatsConfig.shrtInt * 1000; // Convert to milliseconds + if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > STATS_MIN_INTERVAL_SECONDS) { + _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds } } } @@ -486,7 +491,7 @@ export function createStatsMgr(): IStatsMgr { if (!entry.result && !entry.pending) { entry.pending = true; - let fetchFn = (_internalSdkStatsConfig && _internalSdkStatsConfig.overrideCfgFn) || _defaultStatsCfgFetch; + let fetchFn = _statsCfgFetchFn || _defaultStatsCfgFetch; try { fetchFn(cfgUrl, (result) => { entry.pending = false; @@ -503,7 +508,7 @@ export function createStatsMgr(): IStatsMgr { } function _track(internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) { - if (_isMgrEnabled && _internalSdkStatsConfig) { + if (_isMgrEnabled) { // The remote cfg file is the sole authority for whether collection is enabled and where // to send the events. Re-resolved here (rather than cached on the instance) to support the // endpoint changing after the instance was created. @@ -574,6 +579,8 @@ export function createStatsMgr(): IStatsMgr { */ const _sdkStatsDefaults: IConfigDefaults = { // Seeding an (empty) stats object enables the manager by default; the destination and enabled - // state are resolved per-event from the remote SDK Stats configuration. - stats: cfgDfMerge({}) + // state are resolved per-event from the remote SDK Stats configuration. A plain object (rather + // than cfgDfMerge) is used so setDf seeds and makes the stats property dynamic without marking + // it as a reference (avoiding the in-place reference side effect). + stats: {} }; From d40a558c44c93f7e5bb790a1659f4140cc2044bb Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 20 Jul 2026 15:09:04 -0700 Subject: [PATCH 18/51] Remove IConfigDefaults cast by pinning onConfigChange to IConfiguration Address PR review: the setDf cast was only needed because the generic CfgType propagated into onConfigChange. Pin onConfigChange to IConfiguration (we only touch IConfiguration-level config) so setDf infers IConfiguration and the cast is no longer required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- shared/AppInsightsCore/src/core/InternalSdkStats.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 94bdbd353..a88720f34 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -451,14 +451,14 @@ export function createStatsMgr(): IStatsMgr { // Start listening for configuration changes from the single global config, within a config // change handler. This supports the scenario where the config is changed after the manager // has been created (including CDN / dynamic config updates). - return onConfigChange(core.config, (details) => { + return onConfigChange(core.config, (details) => { // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) _isMgrEnabled = false; _statsCfgFetchFn = null; if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and // can be overridden via the CDN / dynamic config or by the SKU. - details.setDf(details.cfg, _sdkStatsDefaults as IConfigDefaults); + details.setDf(details.cfg, _sdkStatsDefaults); // Read the nested stats config directly (registers the dynamic dependency on the // stats object) and copy the individual values into local (minifiable) variables // instead of holding the config object and repeatedly reading its properties. From 7ba04a4e7716eddb8c71f698635c6df3ce649aea Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 21 Jul 2026 11:22:21 -0700 Subject: [PATCH 19/51] Remove leftover commented-out _sdk/IInternalSdkConfiguration scaffolding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/cfgsynchelper.tests.ts | 5 ----- .../src/core/AppInsightsCore.ts | 18 ------------------ .../src/interfaces/ai/IConfiguration.ts | 18 ------------------ .../src/utils/DataCacheHelper.ts | 2 +- 4 files changed, 1 insertion(+), 42 deletions(-) diff --git a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts index 6b974d459..65e22aa48 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts @@ -101,11 +101,6 @@ export class CfgSyncHelperTests extends AITestClass { extensions:[{isFlushInvoked:false,isTearDownInvoked:false,isResumeInvoked:false,isPauseInvoked:false,identifier:"Sender",priority:1001}], channels:[], extensionConfig:{}, - //_sdk: { - // stats: { - // endCfg: [] - // } - //}, traceHdrMode: 3, sdkStats: { int: 900000 diff --git a/shared/AppInsightsCore/src/core/AppInsightsCore.ts b/shared/AppInsightsCore/src/core/AppInsightsCore.ts index 19a0ea295..1dae41f72 100644 --- a/shared/AppInsightsCore/src/core/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/core/AppInsightsCore.ts @@ -78,23 +78,6 @@ const maxInitTimeout = 50000; const maxAttributeCount = 128; // const strPluginUnloadFailed = "Failed to unload plugin"; -// /** -// * Default InternalSdkStatsMgr configuration -// * @internal -// */ -// const defaultStatsCfg: IConfigDefaults = objDeepFreeze({ -// shrtInt: UNDEFINED_VALUE, -// endCfg: cfgDfMerge([]) -// }); - -// /** -// * Default SDK initialization configuration -// * @internal -// */ -// const defaultSdkConfig: IConfigDefaults = objDeepFreeze({ -// stats: { rdOnly: true, mrg: true, v: defaultStatsCfg } -// }); - /** * The default settings for the config. * WE MUST include all defaults here to ensure that the config is created with all of the properties @@ -129,7 +112,6 @@ const defaultConfig: IConfigDefaults = objDeepFreeze({ serviceName: null, suppressTracing: false }) - // _sdk: { rdOnly: true, ref: true, v: defaultSdkConfig } }); function _getDefaultConfig(core: IAppInsightsCore): IConfigDefaults { diff --git a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts index 6b0b2b221..d6973980c 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IConfiguration.ts @@ -257,12 +257,6 @@ export interface IConfiguration extends IOTelConfig { */ stats?: IInternalSdkStatsConfig; - ///** - // * [Optional] Internal SDK configuration for developers - // * @internal - // */ - //_sdk?: IInternalSdkConfiguration; - /** * [Optional] Controls if the SDK should look for the `traceparent` and/or `tracestate` values from * the service timing headers or meta tags from the initial page load. @@ -276,15 +270,3 @@ export interface IConfiguration extends IOTelConfig { */ sdkStats?: ISdkStatsConfig; } - -///** -// * Internal SDK configuration options -// * @internal -// */ -//export interface IInternalSdkConfiguration { -// /** -// * [Optional] Enable Internal InternalSdkStats -// * @internal -// */ -// stats?: IInternalSdkStatsConfig; -//} diff --git a/shared/AppInsightsCore/src/utils/DataCacheHelper.ts b/shared/AppInsightsCore/src/utils/DataCacheHelper.ts index 043f47a2a..8b577f77c 100644 --- a/shared/AppInsightsCore/src/utils/DataCacheHelper.ts +++ b/shared/AppInsightsCore/src/utils/DataCacheHelper.ts @@ -6,7 +6,7 @@ import { STR_EMPTY } from "../constants/InternalConstants"; import { normalizeJsName } from "./HelperFuncs"; import { newId } from "./RandomHelper"; -const version = "3.4.3"; +const version = "#version#"; let instanceName = "." + newId(6); let _dataUid = 0; From 9bd13ace4565fb2abdde219073c4da4ca904f366 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 21 Jul 2026 12:20:01 -0700 Subject: [PATCH 20/51] Remove unused StatsType/eStatsType from SDK Stats The type field was never read for logic (getSdkStats isolates instances by endpoint, not type) and customer stats are handled separately via createSdkStatsNotifCbk, so the enum is no longer needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .aiAutoMinify.json | 1 - .../Tests/Unit/src/InternalSdkStats.tests.ts | 4 +--- .../applicationinsights-channel-js/src/Sender.ts | 9 ++++----- .../Tests/Unit/src/ai/InternalSdkStats.Tests.ts | 9 --------- shared/AppInsightsCore/src/core/InternalSdkStats.ts | 3 --- shared/AppInsightsCore/src/enums/ai/StatsType.ts | 11 ----------- shared/AppInsightsCore/src/index.ts | 1 - .../src/interfaces/ai/IInternalSdkStats.ts | 12 ------------ 8 files changed, 5 insertions(+), 45 deletions(-) delete mode 100644 shared/AppInsightsCore/src/enums/ai/StatsType.ts diff --git a/.aiAutoMinify.json b/.aiAutoMinify.json index 5c3314d09..406527b2a 100644 --- a/.aiAutoMinify.json +++ b/.aiAutoMinify.json @@ -22,7 +22,6 @@ "eLoggingSeverity", "_eInternalMessageId", "SendRequestReason", - "eStatsType", "TelemetryUnloadReason", "TelemetryUpdateReason", "eTraceHeadersMode", 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 29e526739..a003d18d4 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -1,5 +1,5 @@ import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; -import { AppInsightsCore, createStatsMgr, eStatsType, FeatureOptInMode, getWindow, IPayloadData, IInternalSdkStatsState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; +import { AppInsightsCore, createStatsMgr, FeatureOptInMode, getWindow, IPayloadData, IInternalSdkStatsState, IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; import { Sender } from "../../../src/Sender"; import { SinonSpy, SinonStub } from "sinon"; import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; @@ -76,7 +76,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: instrumentationKey, endpoint: config.endpointUrl, sdkVer: "1.0.0", - type: eStatsType.SDK }; this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); @@ -157,7 +156,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; const internalSdkStats = this._core.getSdkStats(internalSdkStatsState); diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 663d0eff8..13a0bd14d 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -8,10 +8,10 @@ import { PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, RequestDataType, RequestHeaders, STATS_SDK_ENDPOINT_KEY, SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, TransportType, _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createOfflineListener, - createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, eStatsType, - formatErrorMessageXdr, formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, - isFetchSupported, isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, - prependTransports, runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix + createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, formatErrorMessageXdr, + formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, + isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, + runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-core-js"; import { IPromise, createPromise, doAwait, doAwaitResponse } from "@nevware21/ts-async"; import { @@ -698,7 +698,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { cKey: _self._senderConfig.instrumentationKey, endpoint: _endpointUrl, sdkVer: EnvelopeCreator.Version, - type: eStatsType.SDK }; let core = _self.core; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index a6df170be..075c053a6 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -6,7 +6,6 @@ import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; import { createStatsMgr, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/InternalSdkStats"; import { IInternalSdkStatsState } from "../../../../src/interfaces/ai/IInternalSdkStats"; -import { eStatsType } from "../../../../src/enums/ai/StatsType"; import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; @@ -75,7 +74,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; Assert.equal(null, this._statsMgr.newInst(internalSdkStatsState), "SDK Stats should not be created before initialization"); @@ -87,7 +85,6 @@ export class InternalSdkStatsTests extends AITestClass { Assert.ok(!!newInst, "SDK Stats should be created after initialization"); Assert.equal(true, newInst.enabled, "SDK Stats should be enabled after initialization"); Assert.equal("https://example.endpoint.com", newInst.endpoint); - Assert.equal(0, newInst.type); } }); @@ -114,7 +111,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); @@ -149,7 +145,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); @@ -202,7 +197,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); @@ -237,7 +231,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; // Verify that SDK Stats is created @@ -302,7 +295,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); @@ -353,7 +345,6 @@ export class InternalSdkStatsTests extends AITestClass { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", sdkVer: "1.0.0", - type: eStatsType.SDK }; let internalSdkStats = this._statsMgr.newInst(internalSdkStatsState); internalSdkStats.count(200, payloadData, "https://example.endpoint.com"); diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index a88720f34..8a792fbd0 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -11,7 +11,6 @@ import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constant import { STR_EMPTY } from "../constants/InternalConstants"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; -import { eStatsType } from "../enums/ai/StatsType"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { @@ -389,7 +388,6 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn let internalSdkStats: IInternalSdkStats = { enabled: !!_isEnabled, endpoint: STR_EMPTY, - type: eStatsType.SDK, count: (status: number, payloadData: IPayloadData, endpoint: string) => { if (_isEnabled && _checkEndpoint(endpoint)) { if (payloadData && (payloadData as any)["statsData"] && (payloadData as any)["statsData"]["startTime"]) { @@ -424,7 +422,6 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn // Make the properties readonly / reactive to changes return objDefineProps(internalSdkStats, { enabled: { g: () => _isEnabled, s: _setEnabled }, - type: { g: () => internalSdkStatsStats.type }, endpoint: { g: () => _networkCounter.host } }); } diff --git a/shared/AppInsightsCore/src/enums/ai/StatsType.ts b/shared/AppInsightsCore/src/enums/ai/StatsType.ts deleted file mode 100644 index 3828a9c9f..000000000 --- a/shared/AppInsightsCore/src/enums/ai/StatsType.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// @skip-file-minify - -export const enum eStatsType { - SDK = 0, - CLIENT = 1, -} - -export type StatsType = number | eStatsType; - diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 128430660..c3b716aa4 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -19,7 +19,6 @@ export { IUnloadHook, ILegacyUnloadHook } from "./interfaces/ai/IUnloadHook"; export { eEventsDiscardedReason, EventsDiscardedReason, eBatchDiscardedReason, BatchDiscardedReason } from "./enums/ai/EventsDiscardedReason"; export { eDependencyTypes, DependencyTypes } from "./enums/ai/DependencyTypes"; export { SendRequestReason } from "./enums/ai/SendRequestReason"; -export { StatsType, eStatsType } from "./enums/ai/StatsType"; export { TelemetryUpdateReason } from "./enums/ai/TelemetryUpdateReason"; export { TelemetryUnloadReason } from "./enums/ai/TelemetryUnloadReason"; export { eUrlRedactionOptions, UrlRedactionOptions } from "./enums/ai/UrlRedactionOptions" diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index 0b986c36b..dff84a61f 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { StatsType } from "../../enums/ai/StatsType"; import { IPayloadData } from "./IXHROverride"; /** @@ -22,12 +21,6 @@ export interface IInternalSdkStats { */ endpoint: string; - /** - * Returns the StatsType for this instance of the stats beat. - * @returns The current stats type. - */ - type: StatsType; - /** * Count the number of events sent to the endpoint with the given status code. * @param status - The status code of the event. @@ -63,11 +56,6 @@ export interface IInternalSdkStatsState { * The current Sdk version. */ sdkVer?: string; - - /** - * The type of the stats event. - */ - type?: StatsType; } /** From ed3ca0ee0bc159cd91b3792f6636d1229ba530ba Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 21 Jul 2026 12:53:13 -0700 Subject: [PATCH 21/51] Reduce Internal SDK Stats minified size (-377 bytes raw, -127 bytes gzipped) Refactor InternalSdkStats and SdkStatsNotificationCbk to shrink the core minified bundle: objForEachKey merge, shared count/inc helpers, hoisted repeated string literals, arrIndexOf for EU lookup, reuse MetricDataType. Core min.js: raw 139595 -> 139218 (-377 B), gzip 56693 -> 56566 (-127 B). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core/InternalSdkStats.ts | 102 ++++++++---------- .../src/core/SdkStatsNotificationCbk.ts | 57 ++++------ 2 files changed, 64 insertions(+), 95 deletions(-) diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 8a792fbd0..65c30326f 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -3,8 +3,8 @@ import { doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrForEach, isNumber, isString, objDefineProps, scheduleTimeout, strEndsWith, strIndexOf, strLower, strStartsWith, - strSubstring, utcNow + ITimerHandler, arrIndexOf, isNumber, isString, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, strIndexOf, strLower, + strStartsWith, strSubstring, utcNow } from "@nevware21/ts-utils"; import { onConfigChange } from "../config/DynamicConfig"; import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constants"; @@ -21,6 +21,7 @@ import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; +import { MetricDataType } from "../telemetry/ai/DataTypes"; import { getJSON, isFetchSupported, isXhrSupported } from "../utils/EnvUtils"; import { getResponseText, isFeatureEnabled, openXhr } from "../utils/HelperFuncs"; @@ -80,7 +81,7 @@ function _isEuEndpoint(endpoint: string): boolean { // Strip the scheme let schemeIdx = strIndexOf(host, "://"); if (schemeIdx !== -1) { - host = host.substring(schemeIdx + 3); + host = strSubstring(host, schemeIdx + 3); } // Extract the leading host label, e.g. "westeurope-5" from "westeurope-5.in.applicationinsights.azure.com/" @@ -88,15 +89,10 @@ function _isEuEndpoint(endpoint: string): boolean { // Remove any trailing region replica suffix, e.g. "westeurope-5" => "westeurope" let dashIdx = strIndexOf(label, "-"); if (dashIdx !== -1) { - label = label.substring(0, dashIdx); + label = strSubstring(label, 0, dashIdx); } - arrForEach(STATS_EU_REGIONS, (region) => { - if (region === label) { - isEU = true; - return -1; - } - }); + isEU = arrIndexOf(STATS_EU_REGIONS, label) !== -1; } return isEU; @@ -280,6 +276,10 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn return _networkCounter.host === endpoint; } + function _inc(counter: { [key: string]: number }, key: string | number) { + counter[key] = (counter[key] || 0) + 1; + } + /** * Attempt to send internalSdkStats events to the server. This is done by creating a new event and sending it to the core. * The event is created with the name and value passed in, and any additional properties are added to the event as well. @@ -305,24 +305,13 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn "host": _networkCounter.host } as { [key: string]: any }; - // Manually merge properties instead of using spread syntax - let combinedProps: { [key: string]: any } = { "host": _networkCounter.host }; - - // Add properties if present - if (properties) { - for (let key in properties) { - if (properties.hasOwnProperty(key)) { - combinedProps[key] = properties[key]; - } - } - } - - // Add base properties - for (let key in baseProperties) { - if (baseProperties.hasOwnProperty(key)) { - combinedProps[key] = baseProperties[key]; - } - } + let combinedProps: { [key: string]: any } = {}; + objForEachKey(properties, (key, value) => { + combinedProps[key] = value; + }); + objForEachKey(baseProperties, (key, value) => { + combinedProps[key] = value; + }); let internalSdkStatsEvent: ITelemetryItem = { name: name, @@ -331,7 +320,7 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn average: val, properties: combinedProps }, - baseType: "MetricData" + baseType: MetricDataType }; // The destination iKey and (optional) SDK Stats ingestion endpoint are resolved and @@ -342,36 +331,27 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn function _trackSendRequestDuration() { var totalRequest = _networkCounter.totalRequest; - - if (_networkCounter.totalRequest > 0 ) { - let averageRequestExecutionTime = _networkCounter.requestDuration / totalRequest; - _sendInternalSdkStatss("Request_Duration", averageRequestExecutionTime); + + if (totalRequest > 0 ) { + _sendInternalSdkStatss("Request_Duration", _networkCounter.requestDuration / totalRequest); + } + } + + function _sendCounts(counts: { [code: string]: number }, name: string, codeKey: string) { + for (const code in counts) { + let props: { [key: string]: any } = {}; + props[codeKey] = code; + _sendInternalSdkStatss(name, counts[code], props); } } function _trackSendRequestsCount() { var currentCounter = _networkCounter; _sendInternalSdkStatss("Request_Success_Count", currentCounter.success); - - for (const code in currentCounter.failure) { - const count = currentCounter.failure[code]; - _sendInternalSdkStatss("failure", count, { statusCode: code }); - } - - for (const code in currentCounter.retry) { - const count = currentCounter.retry[code]; - _sendInternalSdkStatss("retry", count, { statusCode: code }); - } - - for (const code in currentCounter.exception) { - const count = currentCounter.exception[code]; - _sendInternalSdkStatss("exception", count, { exceptionType: code }); - } - - for (const code in currentCounter.throttle) { - const count = currentCounter.throttle[code]; - _sendInternalSdkStatss("Throttle_Count", count, { statusCode: code }); - } + _sendCounts(currentCounter.failure, "failure", "statusCode"); + _sendCounts(currentCounter.retry, "retry", "statusCode"); + _sendCounts(currentCounter.exception, "exception", "exceptionType"); + _sendCounts(currentCounter.throttle, "Throttle_Count", "statusCode"); } function _setEnabled(isEnabled: boolean) { @@ -390,9 +370,11 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn endpoint: STR_EMPTY, count: (status: number, payloadData: IPayloadData, endpoint: string) => { if (_isEnabled && _checkEndpoint(endpoint)) { - if (payloadData && (payloadData as any)["statsData"] && (payloadData as any)["statsData"]["startTime"]) { - _networkCounter.totalRequest = (_networkCounter.totalRequest || 0) + 1; - _networkCounter.requestDuration += utcNow() - (payloadData as any)["statsData"]["startTime"]; + let statsData = payloadData && (payloadData as any)["statsData"]; + let startTime = statsData && statsData["startTime"]; + if (startTime) { + _networkCounter.totalRequest++; + _networkCounter.requestDuration += utcNow() - startTime; } let retryArray = [401, 403, 408, 429, 500, 502, 503, 504]; @@ -401,11 +383,11 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn if (status >= 200 && status < 300) { _networkCounter.success++; } else if (retryArray.indexOf(status) !== -1) { - _networkCounter.retry[status] = (_networkCounter.retry[status] || 0) + 1; + _inc(_networkCounter.retry, status); } else if (throttleArray.indexOf(status) !== -1) { - _networkCounter.throttle[status] = (_networkCounter.throttle[status] || 0) + 1; + _inc(_networkCounter.throttle, status); } else if (status !== 307 && status !== 308) { - _networkCounter.failure[status] = (_networkCounter.failure[status] || 0) + 1; + _inc(_networkCounter.failure, status); } _setupTimer(); @@ -413,7 +395,7 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn }, countException: (endpoint: string, exceptionType: string) => { if (_isEnabled && _checkEndpoint(endpoint)) { - _networkCounter.exception[exceptionType] = (_networkCounter.exception[exceptionType] || 0) + 1; + _inc(_networkCounter.exception, exceptionType); _setupTimer(); } } diff --git a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts index 7421996dc..e7d4e1eb1 100644 --- a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts +++ b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts @@ -11,23 +11,15 @@ var MET_SUCCESS = "Item_Success_Count"; var MET_DROPPED = "Item_Dropped_Count"; var MET_RETRY = "Item_Retry_Count"; var DROP_CLIENT_EXCEPTION = "CLIENT_EXCEPTION"; +var DEFAULT_TEL_TYPE = "CUSTOM_EVENT"; // Top-level event name for AI stats. Matches the standard AI event naming // (Microsoft.ApplicationInsights..). var AI_STATS_PREFIX = "Microsoft.ApplicationInsights."; var AI_STATS_SUFFIX = "SdkStats"; -// Removes all own keys from an object in place (used to reset accumulators without re-allocating). -function _clearObj(obj: { [key: string]: any }): void { - for (var key in obj) { - if (objHasOwn(obj, key)) { - delete obj[key]; - } - } -} - // Map baseType to spec telemetryType values var _typeMap: { [key: string]: string } = { - "EventData": "CUSTOM_EVENT", + "EventData": DEFAULT_TEL_TYPE, "MetricData": "CUSTOM_METRIC", "RemoteDependencyData": "DEPENDENCY", "ExceptionData": "EXCEPTION", @@ -36,9 +28,9 @@ var _typeMap: { [key: string]: string } = { "MessageData": "TRACE", "RequestData": "REQUEST", "AvailabilityData": "AVAILABILITY", - "PageActionData": "CUSTOM_EVENT", - "ContentUpdateData": "CUSTOM_EVENT", - "PageUnloadData": "CUSTOM_EVENT" + "PageActionData": DEFAULT_TEL_TYPE, + "ContentUpdateData": DEFAULT_TEL_TYPE, + "PageUnloadData": DEFAULT_TEL_TYPE }; /** @@ -109,7 +101,7 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin function _getTelType(item: ITelemetryItem): string { var bt = item.baseType; - return (bt && objHasOwn(_typeMap, bt) && _typeMap[bt]) || "CUSTOM_EVENT"; + return (bt && objHasOwn(_typeMap, bt) && _typeMap[bt]) || DEFAULT_TEL_TYPE; } function _isSdkStatsMetric(item: ITelemetryItem): boolean { @@ -117,21 +109,26 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin return item.name === _statsEventName; } - function _incSuccess(items: ITelemetryItem[]) { - if (_unloaded || !items || !items.length) { - return; - } + // Iterates items (skipping our own emitted stats) and increments target[telType]; returns true if any counted. + function _incCounts(target: { [telType: string]: number }, items: ITelemetryItem[]): boolean { var changed = false; for (var i = 0; i < items.length; i++) { if (!_isSdkStatsMetric(items[i])) { var t = _getTelType(items[i]); if (!isUnsafePropKey(t)) { - _successCounts[t] = (_successCounts[t] || 0) + 1; + target[t] = (target[t] || 0) + 1; changed = true; } } } - if (changed) { + return changed; + } + + function _incSuccess(items: ITelemetryItem[]) { + if (_unloaded || !items || !items.length) { + return; + } + if (_incCounts(_successCounts, items)) { _ensureTimer(); } } @@ -147,17 +144,7 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin if (!bucket) { bucket = counters[code] = objCreate(null); } - var changed = false; - for (var i = 0; i < items.length; i++) { - if (!_isSdkStatsMetric(items[i])) { - var t = _getTelType(items[i]); - if (!isUnsafePropKey(t)) { - bucket[t] = (bucket[t] || 0) + 1; - changed = true; - } - } - } - if (changed) { + if (_incCounts(bucket, items)) { _ensureTimer(); } } @@ -244,10 +231,10 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin _flushBucketed(_droppedCounts, MET_DROPPED, "dropCode"); _flushBucketed(_retryCounts, MET_RETRY, "retryCode"); - // Reset accumulators in place to avoid allocating new null-prototype objects each flush - _clearObj(_successCounts); - _clearObj(_droppedCounts); - _clearObj(_retryCounts); + // Reset accumulators for the next interval + _successCounts = objCreate(null); + _droppedCounts = objCreate(null); + _retryCounts = objCreate(null); } return { From 1adb831c8c0f4ca9c33f5b29e7494a8b6b7eee10 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 21 Jul 2026 13:04:56 -0700 Subject: [PATCH 22/51] Unload core in InternalSdkStats test cleanup to prevent hook leaks testCleanup nulled the initialized AppInsightsCore without unloading it, leaking hooks/timers/config watchers into subsequent tests. Call core.unload(false) when initialized, matching the core test convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/ai/InternalSdkStats.Tests.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 075c053a6..979ebe8b9 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -58,6 +58,9 @@ export class InternalSdkStatsTests extends AITestClass { public testCleanup() { super.testCleanup(); + if (this._core && this._core.isInitialized()) { + this._core.unload(false); + } this._core = null as any; this._statsMgr = null as any; } From 44e6681ef6e2b06890830d7e03f94de25c024ae0 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 21 Jul 2026 13:38:49 -0700 Subject: [PATCH 23/51] Fix CodeQL prototype-pollution alert in SDK stats counters Revert the _incCounts extraction so success/bucket counts assign directly to their objCreate(null) counters. The extracted target parameter hid the null-prototype provenance, causing CodeQL js/prototype-polluting-assignment to flag target[t]. Direct assignment restores the safe-sink recognition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core/SdkStatsNotificationCbk.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts index e7d4e1eb1..49dd5f201 100644 --- a/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts +++ b/shared/AppInsightsCore/src/core/SdkStatsNotificationCbk.ts @@ -109,26 +109,22 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin return item.name === _statsEventName; } - // Iterates items (skipping our own emitted stats) and increments target[telType]; returns true if any counted. - function _incCounts(target: { [telType: string]: number }, items: ITelemetryItem[]): boolean { + function _incSuccess(items: ITelemetryItem[]) { + if (_unloaded || !items || !items.length) { + return; + } var changed = false; for (var i = 0; i < items.length; i++) { if (!_isSdkStatsMetric(items[i])) { var t = _getTelType(items[i]); if (!isUnsafePropKey(t)) { - target[t] = (target[t] || 0) + 1; + // _successCounts is a null-prototype object (objCreate(null)) so this cannot pollute Object.prototype + _successCounts[t] = (_successCounts[t] || 0) + 1; changed = true; } } } - return changed; - } - - function _incSuccess(items: ITelemetryItem[]) { - if (_unloaded || !items || !items.length) { - return; - } - if (_incCounts(_successCounts, items)) { + if (changed) { _ensureTimer(); } } @@ -144,7 +140,18 @@ export function createSdkStatsNotifCbk(core: IAppInsightsCore, sdkVersion: strin if (!bucket) { bucket = counters[code] = objCreate(null); } - if (_incCounts(bucket, items)) { + var changed = false; + for (var i = 0; i < items.length; i++) { + if (!_isSdkStatsMetric(items[i])) { + var t = _getTelType(items[i]); + if (!isUnsafePropKey(t)) { + // bucket is a null-prototype object (objCreate(null)) so this cannot pollute Object.prototype + bucket[t] = (bucket[t] || 0) + 1; + changed = true; + } + } + } + if (changed) { _ensureTimer(); } } From dbb7b7b76906f06a453fc65785a9c6ccc702cad5 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 4 Aug 2026 15:48:47 -0700 Subject: [PATCH 24/51] Address Copilot review feedback on SDK Stats - Type _defaultStatsCfgFetch / _parseStatsCfg as nullable (IInternalSdkStatsCfgResult | null) to match the actual null-on-failure contract - Do not forward customer configured headers when the send url is overridden for the SDK Stats endpoint - Use a numeric startTime in the InternalSdkStats count() test so the duration arithmetic is valid Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- channels/applicationinsights-channel-js/src/Sender.ts | 5 ++++- .../Tests/Unit/src/ai/InternalSdkStats.Tests.ts | 2 +- shared/AppInsightsCore/src/core/InternalSdkStats.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 13a0bd14d..ae65c29b8 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -1128,7 +1128,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _getPayload(payload: IInternalStorageItem[], urlOverride?: string): IInternalPayloadData { if (isArray(payload) && payload.length > 0) { let batch = _self._buffer.batchPayloads(payload); - let headers = _getHeaders(); + // When the destination url is overridden (SDK Stats ingestion endpoint) the customer + // configured headers must NOT be forwarded, they may contain authorization or other + // sensitive values that are only intended for the customer's own endpoint. + let headers = urlOverride ? {} : _getHeaders(); let payloadData: IInternalPayloadData = { data: batch, urlString: urlOverride || _endpointUrl, diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 979ebe8b9..507909026 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -106,7 +106,7 @@ export class InternalSdkStatsTests extends AITestClass { timeout: 0, disableXhrSync: false, statsData: { - startTime: "2023-10-01T00:00:00Z" // Simulated start time + startTime: Date.now() // Simulated start time (numeric, used in duration arithmetic) } } as IPayloadData; diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 65c30326f..250875160 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -106,7 +106,7 @@ export function getStatsCfgUrl(endpoint: string): string { } /** Parse the SDK Stats config JSON (`{ ver, enabled, url }`); null if empty or unparseable. */ -function _parseStatsCfg(response: string): IInternalSdkStatsCfgResult { +function _parseStatsCfg(response: string): IInternalSdkStatsCfgResult | null { let result: IInternalSdkStatsCfgResult = null; let json = getJSON(); if (response && json) { @@ -128,7 +128,7 @@ function _parseStatsCfg(response: string): IInternalSdkStatsCfgResult { } /** Default SDK Stats config fetch (fetch, else XHR); calls oncomplete with the parsed config or null. */ -function _defaultStatsCfgFetch(cfgUrl: string, oncomplete: (result: IInternalSdkStatsCfgResult) => void): void { +function _defaultStatsCfgFetch(cfgUrl: string, oncomplete: (result: IInternalSdkStatsCfgResult | null) => void): void { function _complete(response?: string) { try { oncomplete(response ? _parseStatsCfg(response) : null); From 769c93a2d0994c994d465c72f5ec1c71ff947eff Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 4 Aug 2026 16:21:54 -0700 Subject: [PATCH 25/51] Source SDK Stats config url from dynamic config instead of hardcoding Removes the hardcoded STATS_SDK_CFG_URL_NON_EU / STATS_SDK_CFG_URL_EU constants and instead reads the SDK Stats config url from the dynamic config (config.stats.cfgUrl). When no url has been configured no SDK Stats are collected or sent. - cfgUrl is registered as a dynamic property so the value delivered by the CDN configuration after initialization re-triggers the handler - the EU data-boundary url is derived by inserting the 'eu-' host prefix into the configured url, so only a single url needs to be configured - getStatsCfgUrl() now takes the configured url and returns null when none is available - _cfgCache uses objCreate(null) as it is now keyed by a config supplied value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 23 +++--- .../Tests/Unit/src/InternalSdkStats.tests.ts | 3 + .../Unit/src/ai/InternalSdkStats.Tests.ts | 81 ++++++++++++++++++- .../src/core/InternalSdkStats.ts | 73 +++++++++++------ shared/AppInsightsCore/src/index.ts | 2 +- .../src/interfaces/ai/IInternalSdkStats.ts | 14 ++++ 6 files changed, 160 insertions(+), 36 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 296c2dac5..09cf6130c 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -11,14 +11,14 @@ import { IAutoExceptionTelemetry, IChannelControls, IConfig, IConfigDefaults, IConfiguration, ICookieMgr, ICustomProperties, IDependencyTelemetry, IDiagnosticLogger, IDistributedTraceContext, IDynamicConfigHandler, IEventTelemetry, IExceptionTelemetry, ILoadedPlugin, IMetricTelemetry, INotificationManager, IOTelApi, IOTelSpanOptions, IPageViewPerformanceTelemetry, IPageViewTelemetry, IPlugin, - IReadableSpan, IRequestHeaders, ISdkStatsNotifCbk, ISpanScope, ITelemetryContext as Common_ITelemetryContext, ITelemetryInitializerHandler, - ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, IThrottleInterval, IThrottleLimit, IThrottleMgrConfig, ITraceApi, ITraceProvider, - ITraceTelemetry, IUnloadHook, OTelTimeInput, PropertiesPluginIdentifier, ThrottleMgr, UnloadHandler, WatcherFunction, - _eInternalMessageId, _throwInternal, addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, - createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsNotifCbk, createStatsMgr, - 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, createStatsMgr, + createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, + isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, + removePageHideEventListener, removePageUnloadEventListener, useSpan } from "@microsoft/applicationinsights-core-js"; import { AjaxPlugin as DependenciesPlugin, DependencyInitializerFunction, DependencyListenerFunction, IDependencyInitializerHandler, @@ -66,7 +66,7 @@ const CDN_USAGE = "CdnUsage"; const SDK_LOADER_VER = "SdkLoaderVer"; const ZIP_PAYLOAD = "zipPayload"; const SDK_STATS = "SdkStats"; -var _sdkVersion = "#version#"; +var _sdkVersion = '3.4.3'; const default_limit = { samplingRate: 100, @@ -400,8 +400,9 @@ export class AppInsightsSku implements IApplicationInsights void) => { @@ -143,6 +145,7 @@ export class InternalSdkStatsTests extends AITestClass { }, stats: { shrtInt: 900, + cfgUrl: "https://data.stats.monitor.azure.com/cfg/v1.json", overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); } diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 507909026..3fb2c45b6 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -4,7 +4,7 @@ import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; import { IStatsMgr } from "../../../../src/interfaces/ai/IStatsMgr"; import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; -import { createStatsMgr, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/InternalSdkStats"; +import { createStatsMgr, getStatsCfgUrl, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/InternalSdkStats"; import { IInternalSdkStatsState } from "../../../../src/interfaces/ai/IInternalSdkStats"; import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; @@ -12,6 +12,7 @@ import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes +const STATS_TEST_CFG_URL = "https://data.stats.monitor.azure.com/cfg/v1.json"; export class InternalSdkStatsTests extends AITestClass { private _core: AppInsightsCore; @@ -37,6 +38,8 @@ export class InternalSdkStatsTests extends AITestClass { }, stats: { shrtInt: STATS_COLLECTION_SHORT_INTERVAL, + // The config url gates collection, without it nothing is collected or sent + cfgUrl: STATS_TEST_CFG_URL, // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests // do not attempt a real network fetch of the cfg/v1.json endpoint. overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { @@ -378,6 +381,82 @@ export class InternalSdkStatsTests extends AITestClass { core.unload(false); } }); + + this.testCase({ + name: "SDK Stats: does not send when no cfgUrl has been configured", + useFakeTimers: true, + test: () => { + // Remove the configured cfg url, without it there is nothing to resolve so nothing is sent + this._core.config.stats.cfgUrl = null; + this.clock.tick(1); // Allow the config change to propagate + + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "track should not be called when no cfgUrl is configured"); + } + }); + + this.testCase({ + name: "SDK Stats: starts sending once the cfgUrl arrives from the dynamic config", + useFakeTimers: true, + test: () => { + // Simulate the CDN configuration arriving after initialization by starting without a url + this._core.config.stats.cfgUrl = null; + this.clock.tick(1); + + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent before the cfgUrl is available"); + + // The CDN / dynamic config now supplies the url + this._core.config.stats.cfgUrl = STATS_TEST_CFG_URL; + this.clock.tick(1); // Allow the config change to propagate + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "SDK Stats should be sent once the cfgUrl is supplied by the config"); + } + }); + + this.testCase({ + name: "SDK Stats: getStatsCfgUrl derives the EU url and requires a configured url", + test: () => { + Assert.equal(null, getStatsCfgUrl("https://westeurope.in.applicationinsights.azure.com/", null), + "No configured url should resolve to null"); + Assert.equal(null, getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/", undefined), + "No configured url should resolve to null"); + + Assert.equal(STATS_TEST_CFG_URL, getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "A non-EU endpoint should use the configured url as-is"); + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU endpoint should have the eu- prefix inserted in front of the host"); + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://westeurope-5.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU region replica endpoint should also resolve to the EU url"); + Assert.equal("eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://northeurope.in.applicationinsights.azure.com/", "data.stats.monitor.azure.com/cfg/v1.json"), + "A configured url without a scheme should still get the eu- prefix"); + } + }); } } diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 250875160..7acceae9e 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -3,8 +3,8 @@ import { doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrIndexOf, isNumber, isString, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, strIndexOf, strLower, - strStartsWith, strSubstring, utcNow + ITimerHandler, arrIndexOf, isNumber, isString, objCreate, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, strIndexOf, + strLower, strStartsWith, strSubstring, utcNow } from "@nevware21/ts-utils"; import { onConfigChange } from "../config/DynamicConfig"; import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constants"; @@ -38,12 +38,8 @@ const STATS_TYPE = "Browser"; */ export const STATS_SDK_IKEY = "00000000-0000-0000-0000-000000000000"; -/** - * SDK Stats config endpoints (`cfg/v1.json`). The `{ ver, enabled, url }` JSON is read at runtime to - * gate collection and resolve the ingestion host. EU endpoint is used for EU data-boundary regions. - */ -export const STATS_SDK_CFG_URL_NON_EU = "https://data.stats.monitor.azure.com/cfg/v1.json"; -export const STATS_SDK_CFG_URL_EU = "https://eu-data.stats.monitor.azure.com/cfg/v1.json"; +/** The host prefix added to the configured SDK Stats config url for EU data-boundary regions. */ +const STATS_EU_HOST_PREFIX = "eu-"; /** Ingestion path for future 1DS (OneCollector) SDK Stats; the AI SKU uses {@link DEFAULT_BREEZE_PATH}. */ export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; @@ -99,10 +95,26 @@ function _isEuEndpoint(endpoint: string): boolean { } /** - * Returns the SDK Stats config URL (`cfg/v1.json`) for the endpoint (EU vs non-EU). + * Returns the SDK Stats config URL (`cfg/v1.json`) for the endpoint, derived from the configured + * base url. For EU data-boundary endpoints the {@link STATS_EU_HOST_PREFIX} is inserted in front of + * the host, e.g. `https://data.stats...` => `https://eu-data.stats...`. + * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. + * @param cfgUrl - The configured (non-EU) SDK Stats config url, when not supplied null is returned. + * @returns The config url to fetch, or null when no url has been configured. */ -export function getStatsCfgUrl(endpoint: string): string { - return _isEuEndpoint(endpoint) ? STATS_SDK_CFG_URL_EU : STATS_SDK_CFG_URL_NON_EU; +export function getStatsCfgUrl(endpoint: string, cfgUrl: string): string { + let result: string = null; + if (cfgUrl) { + result = cfgUrl; + if (_isEuEndpoint(endpoint)) { + // Insert the EU prefix in front of the host (after any scheme) + let schemeIdx = strIndexOf(cfgUrl, "://"); + let hostIdx = schemeIdx !== -1 ? schemeIdx + 3 : 0; + result = strSubstring(cfgUrl, 0, hostIdx) + STATS_EU_HOST_PREFIX + strSubstring(cfgUrl, hostIdx); + } + } + + return result; } /** Parse the SDK Stats config JSON (`{ ver, enabled, url }`); null if empty or unparseable. */ @@ -413,8 +425,12 @@ export function createStatsMgr(): IStatsMgr { let _core: IAppInsightsCore; // The core instance that is used to send telemetry let _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; + // The configured SDK Stats config url (cfg/v1.json), sourced from the dynamic config. When it is + // not supplied (no CDN / host configuration) SDK Stats are not collected or sent. + let _statsCfgUrl: string; // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. - let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = {}; + // Null-prototype so the config supplied url can never be used to pollute Object.prototype. + let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = objCreate(null); // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again @@ -434,6 +450,7 @@ export function createStatsMgr(): IStatsMgr { // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) _isMgrEnabled = false; _statsCfgFetchFn = null; + _statsCfgUrl = null; if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and // can be overridden via the CDN / dynamic config or by the SKU. @@ -447,6 +464,9 @@ export function createStatsMgr(): IStatsMgr { // Make the override fetch fn a dynamic property before snapshotting it so a later // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); + // Same for the config url, it is normally delivered by the CDN configuration after + // initialization has completed, so this handler must re-run when it arrives. + _statsCfgUrl = details.set(statsCfg, "cfgUrl", statsCfg.cfgUrl); _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > STATS_MIN_INTERVAL_SECONDS) { _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds @@ -459,10 +479,16 @@ export function createStatsMgr(): IStatsMgr { /** * Resolve the remote SDK Stats config for the endpoint, starting a fetch on first use. Returns - * null until resolved (or on failure) so the caller skips sending. + * null until resolved (or on failure, or when no config url has been configured) so the caller + * skips sending. */ function _resolveStatsCfg(endpoint: string): IInternalSdkStatsCfgResult { - let cfgUrl = getStatsCfgUrl(endpoint); + let cfgUrl = getStatsCfgUrl(endpoint, _statsCfgUrl); + if (!cfgUrl) { + // No configured SDK Stats config url -> nothing to resolve and nothing is sent + return null; + } + let entry = _cfgCache[cfgUrl]; if (!entry) { entry = _cfgCache[cfgUrl] = { pending: false, result: null }; @@ -550,16 +576,17 @@ export function createStatsMgr(): IStatsMgr { /** * The default {@link IInternalSdkStatsConfig} values for SDK Stats collection. These are seeded into the * single global config (via {@link IWatchDetails.setDf}) by the manager so they remain dynamic and - * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). The events - * are routed to the distro-owned SDK Stats ingestion endpoint, whose host (and whether collection is - * enabled) is read at runtime from the SDK Stats configuration (`data.stats.monitor.azure.com` / - * `eu-data.stats.monitor.azure.com`). SDK Stats are enabled by default and can be opted-out using the - * `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. + * can be overridden at runtime via the CDN / dynamic config or by the SKU (AISKU / 1DS). No config + * url is defaulted, the `stats.cfgUrl` value is expected to be delivered by the CDN / dynamic + * configuration, and until it is, no SDK Stats are collected or sent. The events are routed to the + * distro-owned SDK Stats ingestion endpoint, whose host (and whether collection is enabled) is read + * at runtime from the SDK Stats configuration identified by that url. SDK Stats can also be + * opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. */ const _sdkStatsDefaults: IConfigDefaults = { - // Seeding an (empty) stats object enables the manager by default; the destination and enabled - // state are resolved per-event from the remote SDK Stats configuration. A plain object (rather - // than cfgDfMerge) is used so setDf seeds and makes the stats property dynamic without marking - // it as a reference (avoiding the in-place reference side effect). + // Seeding an (empty) stats object allows the manager to run; the config url, destination and + // enabled state are all resolved from the dynamic / remote SDK Stats configuration. A plain + // object (rather than cfgDfMerge) is used so setDf seeds and makes the stats property dynamic + // without marking it as a reference (avoiding the in-place reference side effect). stats: {} }; diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index c3b716aa4..019a718f1 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -46,7 +46,7 @@ export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { createStatsMgr, getStatsCfgUrl, - STATS_SDK_IKEY, STATS_SDK_CFG_URL_NON_EU, STATS_SDK_CFG_URL_EU, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE + STATS_SDK_IKEY, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE } from "./core/InternalSdkStats"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; export { diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index dff84a61f..700815674 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -98,6 +98,20 @@ export interface IInternalSdkStatsConfig { */ shrtInt?: number; + /** + * The url of the remote SDK Stats configuration (`cfg/v1.json`) that identifies whether SDK Stats + * collection is currently enabled and the host that the events should be sent to. This value is + * normally delivered by the CDN / dynamic configuration rather than being hard coded by the SDK. + * + * When the customer endpoint maps to an EU data-boundary region the `eu-` prefix is automatically + * inserted in front of the host, e.g. `https://data.stats.monitor.azure.com/cfg/v1.json` becomes + * `https://eu-data.stats.monitor.azure.com/cfg/v1.json`. + * + * When not supplied no SDK Stats are collected or sent. + * @default undefined + */ + cfgUrl?: string; + /** * Optional override for the function used to fetch the remote SDK Stats configuration * (`cfg/v1.json`). When not provided the default fetch / XHR based implementation is used. This From ae02d607d3ab4cae05d6bf24600a110f48545c44 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 5 Aug 2026 16:56:08 -0700 Subject: [PATCH 26/51] Persist SDK Stats across page loads Store in-progress counters in session storage, default exports to a one-hour interval, and reschedule active windows when dynamic configuration changes. Preserve counters while remote configuration is unresolved and require the SDK Stats config URL and instrumentation key before sending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 4 +- .../Tests/Unit/src/InternalSdkStats.tests.ts | 25 ++ .../Unit/src/ai/InternalSdkStats.Tests.ts | 306 +++++++++++++++++- .../src/core/InternalSdkStats.ts | 237 +++++++++++--- shared/AppInsightsCore/src/index.ts | 2 +- .../src/interfaces/ai/IInternalSdkStats.ts | 11 +- 6 files changed, 535 insertions(+), 50 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 09cf6130c..c972d4b89 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -401,8 +401,8 @@ export class AppInsightsSku implements IApplicationInsights void) => { @@ -146,6 +170,7 @@ export class InternalSdkStatsTests extends AITestClass { stats: { shrtInt: 900, cfgUrl: "https://data.stats.monitor.azure.com/cfg/v1.json", + iKey: "Stats-Test-iKey", overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); } diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 3fb2c45b6..9ea83ab4e 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -13,6 +13,37 @@ import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes const STATS_TEST_CFG_URL = "https://data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_IKEY = "Stats-Test-iKey"; +function _clearStatsStorage() { + try { + let storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; + if (storage) { + let keys: string[] = []; + for (let lp = 0; lp < storage.length; lp++) { + let key = storage.key(lp); + if (key && key.indexOf("Test-iKey:") === 0) { + keys.push(key); + } + } + + for (let lp = 0; lp < keys.length; lp++) { + storage.removeItem(keys[lp]); + } + } + } catch (e) { + // Session storage may be unavailable. + } +} + +function _readStatsStorage(cKey: string, endpoint: string): any { + try { + let raw = sessionStorage.getItem(cKey + ":" + endpoint); + let value = raw ? JSON.parse(raw) : null; + return value ? { st: value[0], cnt: value[1] } : null; + } catch (e) { + return null; + } +} export class InternalSdkStatsTests extends AITestClass { private _core: AppInsightsCore; @@ -27,7 +58,9 @@ export class InternalSdkStatsTests extends AITestClass { public testInitialize() { let _self = this; super.testInitialize(); - + + _clearStatsStorage(); + _self._config = { instrumentationKey: "Test-iKey", disableInstrumentationKeyValidation: true, @@ -40,6 +73,7 @@ export class InternalSdkStatsTests extends AITestClass { shrtInt: STATS_COLLECTION_SHORT_INTERVAL, // The config url gates collection, without it nothing is collected or sent cfgUrl: STATS_TEST_CFG_URL, + iKey: STATS_TEST_IKEY, // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests // do not attempt a real network fetch of the cfg/v1.json endpoint. overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { @@ -66,6 +100,7 @@ export class InternalSdkStatsTests extends AITestClass { } this._core = null as any; this._statsMgr = null as any; + _clearStatsStorage(); } public registerTests() { @@ -315,6 +350,7 @@ export class InternalSdkStatsTests extends AITestClass { for (let i = 0; i < this._trackSpy.callCount; i++) { const item: ITelemetryItem = this._trackSpy.getCall(i).args[0]; if (item.data && item.data[STATS_SDK_ENDPOINT_KEY] === "https://data.stats.monitor.azure.com/v2/track") { + Assert.equal(STATS_TEST_IKEY, item.iKey, "SDK Stats should use the configured instrumentation key"); foundEndpoint = true; break; } @@ -436,6 +472,70 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: does not send until an instrumentation key is configured", + useFakeTimers: true, + test: () => { + this._core.config.stats.iKey = null; + this.clock.tick(1); + + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent without an instrumentation key"); + Assert.equal(1, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], + "Counters should remain persisted"); + + this._core.config.stats.iKey = STATS_TEST_IKEY; + this.clock.tick(1); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "Persisted counters should be sent after the instrumentation key is configured"); + } + }); + + this.testCase({ + name: "SDK Stats: retains persisted counters while remote config is unresolved", + useFakeTimers: true, + test: () => { + let completeFetch: (result: { enabled: boolean, url: string } | null) => void; + this._core.config.stats.overrideCfgFn = (_cfgUrl, oncomplete) => { + completeFetch = oncomplete; + }; + this.clock.tick(1); + + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent before remote config resolves"); + Assert.equal(1, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], + "Unsent counters should remain persisted"); + + completeFetch({ enabled: true, url: "data.stats.monitor.azure.com" }); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "Persisted counters should be sent on the next interval"); + Assert.equal(undefined, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], + "Counters should reset after they are processed"); + } + }); + this.testCase({ name: "SDK Stats: getStatsCfgUrl derives the EU url and requires a configured url", test: () => { @@ -457,6 +557,210 @@ export class InternalSdkStatsTests extends AITestClass { "A configured url without a scheme should still get the eu- prefix"); } }); + + this.testCase({ + name: "SDK Stats: counters are persisted to session storage", + useFakeTimers: true, + test: () => { + this._statsMgr.init(this._core, "InternalSdkStats"); + // Move off the fake timer epoch. + this.clock.tick(1000); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + + internalSdkStats.count(200, { statsData: { startTime: Date.now() } } as any, "https://example.endpoint.com"); + internalSdkStats.count(400, { statsData: { startTime: Date.now() } } as any, "https://example.endpoint.com"); + internalSdkStats.count(500, { statsData: { startTime: Date.now() } } as any, "https://example.endpoint.com"); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + let stored = _readStatsStorage("Test-iKey", "https://example.endpoint.com"); + Assert.ok(!!stored, "The SDK Stats state should be persisted to session storage"); + Assert.equal(1000, stored.st, "The collection window start should be persisted"); + Assert.equal(1, stored.cnt.success, "The success count should be persisted"); + Assert.equal(3, stored.cnt.totalRequest, "The total request count should be persisted"); + Assert.equal(1, stored.cnt.failure["400"], "The failure count should be persisted"); + Assert.equal(1, stored.cnt.retry["500"], "The retry count should be persisted"); + Assert.equal(1, stored.cnt.exception["NetworkError"], "The exception count should be persisted"); + } + }); + + this.testCase({ + name: "SDK Stats: a new instance resumes the persisted counters and collection window", + useFakeTimers: true, + test: () => { + this._statsMgr.init(this._core, "InternalSdkStats"); + + let state = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }; + + // First page load. + let first = this._statsMgr.newInst(state); + first.countException("https://example.endpoint.com", "NetworkError"); + first.enabled = false; + + let windowStart = _readStatsStorage("Test-iKey", "https://example.endpoint.com").st; + + this.clock.tick((STATS_COLLECTION_SHORT_INTERVAL - 10) * 1000); + + // Second page load resumes the window. + let second = this._statsMgr.newInst(state); + Assert.equal(windowStart, _readStatsStorage("Test-iKey", "https://example.endpoint.com").st, + "The collection window should not be restarted by a new instance"); + + second.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(11 * 1000); + + Assert.ok(this._trackSpy.called, "The resumed window should complete after the remaining time"); + + let exceptionCount = 0; + for (let i = 0; i < this._trackSpy.callCount; i++) { + const item: ITelemetryItem = this._trackSpy.getCall(i).args[0]; + if (item.name === "exception") { + exceptionCount = item.baseData.average; + } + } + + Assert.equal(2, exceptionCount, "The counts from both instances should be accumulated into one window"); + } + }); + + this.testCase({ + name: "SDK Stats: the persisted counters are reset once the window is sent", + useFakeTimers: true, + test: () => { + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "The window should have been sent"); + + let stored = _readStatsStorage("Test-iKey", "https://example.endpoint.com"); + Assert.ok(!!stored, "The reset state should still be persisted"); + Assert.equal(0, stored.cnt.success, "The success count should be reset after sending"); + Assert.equal(undefined, stored.cnt.exception["NetworkError"], "The exception counts should be reset after sending"); + } + }); + + this.testCase({ + name: "SDK Stats: separate endpoints do not share persisted counters", + useFakeTimers: true, + test: () => { + this._statsMgr.init(this._core, "InternalSdkStats"); + + let first = this._statsMgr.newInst({ cKey: "Test-iKey", endpoint: "https://one.endpoint.com", sdkVer: "1.0.0" }); + let second = this._statsMgr.newInst({ cKey: "Test-iKey", endpoint: "https://two.endpoint.com", sdkVer: "1.0.0" }); + + first.countException("https://one.endpoint.com", "NetworkError"); + second.countException("https://two.endpoint.com", "NetworkError"); + second.countException("https://two.endpoint.com", "NetworkError"); + + Assert.equal(1, _readStatsStorage("Test-iKey", "https://one.endpoint.com").cnt.exception["NetworkError"], + "The first endpoint should only have its own counts"); + Assert.equal(2, _readStatsStorage("Test-iKey", "https://two.endpoint.com").cnt.exception["NetworkError"], + "The second endpoint should only have its own counts"); + } + }); + + this.testCase({ + name: "SDK Stats: defaults to a one hour collection interval", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + core.initialize({ + instrumentationKey: "Test-iKey", + disableInstrumentationKeyValidation: true, + stats: { + cfgUrl: STATS_TEST_CFG_URL, + iKey: STATS_TEST_IKEY, + overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + } + } + } as IConfiguration, [new ChannelPlugin()]); + + let trackSpy = this.sandbox.spy(core, "track"); + let statsMgr = createStatsMgr(); + let hook = statsMgr.init(core, "InternalSdkStats"); + + let internalSdkStats = statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://hourly.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://hourly.endpoint.com", "NetworkError"); + + this.clock.tick(60 * 60 * 1000 - 1); + Assert.equal(0, trackSpy.callCount, "Nothing should be sent before the one hour interval elapses"); + + this.clock.tick(2); + Assert.ok(trackSpy.called, "The stats should be sent once the one hour interval elapses"); + + hook && hook.rm(); + core.unload(false); + } + }); + + this.testCase({ + name: "SDK Stats: accepts a positive interval below one minute from dynamic config", + useFakeTimers: true, + test: () => { + this._core.config.stats.shrtInt = 1; + this.clock.tick(1); // Allow the config change to propagate + + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://short.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://short.endpoint.com", "NetworkError"); + + this.clock.tick(1001); + + Assert.ok(this._trackSpy.called, "A positive dynamic interval below one minute should be honored"); + } + }); + + this.testCase({ + name: "SDK Stats: reschedules an active window when the dynamic interval changes", + useFakeTimers: true, + test: () => { + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://dynamic.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://dynamic.endpoint.com", "NetworkError"); + + this.clock.tick(100 * 1000); + this._core.config.stats.shrtInt = 120; + this.clock.tick(1); + + this.clock.tick(20 * 1000 - 2); + Assert.equal(0, this._trackSpy.callCount, "The updated interval should not fire early"); + + this.clock.tick(2); + Assert.ok(this._trackSpy.called, "The active window should use the updated interval"); + } + }); } } diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 7acceae9e..502f5be6e 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -13,6 +13,7 @@ import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; +import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; import { IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn } from "../interfaces/ai/IInternalSdkStats"; @@ -24,20 +25,13 @@ import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; import { MetricDataType } from "../telemetry/ai/DataTypes"; import { getJSON, isFetchSupported, isXhrSupported } from "../utils/EnvUtils"; import { getResponseText, isFeatureEnabled, openXhr } from "../utils/HelperFuncs"; +import { utlGetSessionStorage, utlSetSessionStorage } from "../utils/StorageHelperFuncs"; -const STATS_COLLECTION_SHORT_INTERVAL: number = 900000; // 15 minutes -const STATS_MIN_INTERVAL_SECONDS = 60; // 1 minute +/** Default collection interval in seconds; override with `stats.shrtInt`. */ +const STATS_COLLECTION_INTERVAL_SECONDS = 3600; // 1 hour const STATS_LANGUAGE = "JavaScript"; const STATS_TYPE = "Browser"; -/** - * The placeholder instrumentation key used when reporting SDK statistics to the distro-owned - * SDK Stats ingestion endpoint. The endpoint does not require authentication, the placeholder - * key only satisfies the connection-string / envelope iKey requirement and is ignored - * server-side. This matches the convention used by the Microsoft OpenTelemetry distros. - */ -export const STATS_SDK_IKEY = "00000000-0000-0000-0000-000000000000"; - /** The host prefix added to the configured SDK Stats config url for EU data-boundary regions. */ const STATS_EU_HOST_PREFIX = "eu-"; @@ -215,9 +209,16 @@ interface _IMgrCallbacks { * Provides a callback to the manager to start a timer for the internalSdkStats instance. * This is used to ensure that the manager is able to control the lifecycle of the instance * @param cb - The callback to call when the timer is started + * @param delay - The delay (in milliseconds) before the callback should be called * @returns A handle to the timer that was started, this can be used to cancel the timer if needed */ - start: (cb: () => void) => ITimerHandler; + start: (cb: () => void, delay: number) => ITimerHandler; + + /** Returns the current collection interval in milliseconds. */ + interval: () => number; + + /** Registers for collection interval changes. */ + watchInterval: (cb: () => void) => () => void; /** * Provides a callback to the manager to send the internalSdkStats event to the core. @@ -225,7 +226,7 @@ interface _IMgrCallbacks { * @param internalSdkStatsEvent - The internalSdkStats event to send to the core * @param endpoint - The endpoint to send the event to */ - track: (internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) => void; + track: (internalSdkStats: IInternalSdkStats, internalSdkStatsEvent?: ITelemetryItem) => boolean | null; } /** @@ -238,41 +239,139 @@ function _createInternalSdkStatsNetwork(host: string): IInternalSdkStatsNetwork host, totalRequest: 0, success: 0, - throttle: {}, - failure: {}, - retry: {}, - exception: {}, + // Untrusted keys must not reach Object.prototype. + throttle: objCreate(null), + failure: objCreate(null), + retry: objCreate(null), + exception: objCreate(null), requestDuration: 0 }; } +/** SDK Stats state persisted as `[windowStart, counters]`. */ +type _IStatsStore = [number, IInternalSdkStatsNetwork]; + +/** Validates persisted counts and restores them to a null-prototype object. */ +function _reviveCounts(src: any): { [key: string]: number } { + let result: { [key: string]: number } = objCreate(null); + objForEachKey(src, (key, value) => { + value = +value; + if (value > 0) { + result[key] = value; + } + }); + + return result; +} + +/** Restores validated counters from session storage. */ +function _reviveCounters(host: string, cnt: any): IInternalSdkStatsNetwork { + let counter = _createInternalSdkStatsNetwork(host); + if (cnt) { + counter.totalRequest = +cnt.totalRequest || 0; + counter.success = +cnt.success || 0; + counter.requestDuration = +cnt.requestDuration || 0; + counter.throttle = _reviveCounts(cnt.throttle); + counter.failure = _reviveCounts(cnt.failure); + counter.retry = _reviveCounts(cnt.retry); + counter.exception = _reviveCounts(cnt.exception); + } + + return counter; +} + +function _loadStore(logger: IDiagnosticLogger, key: string): _IStatsStore { + try { + let json = getJSON(); + let raw = json && utlGetSessionStorage(logger, key); + if (raw) { + let parsed = json.parse(raw); + if (parsed[0] >= 0) { + return parsed; + } + } + } catch (e) { + // Start a new window. + } + + return null; +} + +function _saveStore(logger: IDiagnosticLogger, key: string, store: _IStatsStore) { + let json = getJSON(); + if (json) { + utlSetSessionStorage(logger, key, json.stringify(store)); + } +} + /** * Creates a new IInternalSdkStats instance with the specified manager callbacks and internalSdkStats state. * @param mgr - The manager callbacks to use for the IInternalSdkStats instance. * @param internalSdkStatsStats - The internalSdkStats state to use for the IInternalSdkStats instance. * @returns A new IInternalSdkStats instance. */ -function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IInternalSdkStatsState): IInternalSdkStats { - let _networkCounter: IInternalSdkStatsNetwork = _createInternalSdkStatsNetwork(internalSdkStatsStats.endpoint); +function _createInternalSdkStats( + mgr: _IMgrCallbacks, internalSdkStatsStats: IInternalSdkStatsState, logger: IDiagnosticLogger +): IInternalSdkStats { + // Isolate counters by customer key and endpoint. + let _storeKey = (internalSdkStatsStats.cKey || STR_EMPTY) + ":" + internalSdkStatsStats.endpoint; + let _store = _loadStore(logger, _storeKey); + // Start a new window only when the first value is recorded. + let _windowStart = _store ? _store[0] : -1; + let _networkCounter: IInternalSdkStatsNetwork = _reviveCounters(internalSdkStatsStats.endpoint, _store && _store[1]); let _timeoutHandle: ITimerHandler; // Handle to the timer for sending telemetry. This way, we would not send telemetry when system sleep. let _isEnabled: boolean = true; // Flag to check if internalSdkStats is enabled or not + let _removeIntervalListener: () => void; + + function _startWindow() { + if (_windowStart < 0) { + _windowStart = utcNow(); + } + } + + function _persist() { + _saveStore(logger, _storeKey, [_windowStart, _networkCounter]); + } + + /** Returns the remaining time in the current window. */ + function _remaining() { + let interval = mgr.interval(); + let elapsed = utcNow() - _windowStart; + let remaining = interval; + if (elapsed >= 0) { + remaining = interval - elapsed; + } + + return remaining > 0 ? remaining : 0; + } function _setupTimer() { - if (_isEnabled && !_timeoutHandle) { + if (_isEnabled && _windowStart >= 0 && !_timeoutHandle) { _timeoutHandle = mgr.start(() => { _timeoutHandle = null; trackInternalSdkStats(); - }); + }, _remaining()); } } function trackInternalSdkStats() { if (_isEnabled) { - _trackSendRequestDuration(); - _trackSendRequestsCount(); - _networkCounter = _createInternalSdkStatsNetwork(_networkCounter.host); - _timeoutHandle && _timeoutHandle.cancel(); - _timeoutHandle = null; + let ready = mgr.track(internalSdkStats); + if (ready !== null) { + if (ready) { + _trackSendRequestDuration(); + _trackSendRequestsCount(); + } + _networkCounter = _createInternalSdkStatsNetwork(_networkCounter.host); + // Persist the reset state; restart the window on the next value. + _windowStart = -1; + _persist(); + } else { + // Keep the counters and retry during the next interval. + _windowStart = utcNow(); + _persist(); + _setupTimer(); + } } } @@ -373,9 +472,21 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn _timeoutHandle.cancel(); _timeoutHandle = null; } + if (_removeIntervalListener) { + _removeIntervalListener(); + _removeIntervalListener = null; + } } } + _removeIntervalListener = mgr.watchInterval(() => { + if (_timeoutHandle) { + _timeoutHandle.cancel(); + _timeoutHandle = null; + } + _setupTimer(); + }); + // THE internalSdkStats instance being created and returned let internalSdkStats: IInternalSdkStats = { enabled: !!_isEnabled, @@ -402,12 +513,17 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn _inc(_networkCounter.failure, status); } + // Persist in case the page unloads before the interval ends. + _startWindow(); + _persist(); _setupTimer(); } }, countException: (endpoint: string, exceptionType: string) => { if (_isEnabled && _checkEndpoint(endpoint)) { _inc(_networkCounter.exception, exceptionType); + _startWindow(); + _persist(); _setupTimer(); } } @@ -423,14 +539,16 @@ function _createInternalSdkStats(mgr: _IMgrCallbacks, internalSdkStatsStats: IIn export function createStatsMgr(): IStatsMgr { let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not let _core: IAppInsightsCore; // The core instance that is used to send telemetry - let _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; + let _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; + let _statsIKey: string; // The configured SDK Stats config url (cfg/v1.json), sourced from the dynamic config. When it is // not supplied (no CDN / host configuration) SDK Stats are not collected or sent. let _statsCfgUrl: string; // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. // Null-prototype so the config supplied url can never be used to pollute Object.prototype. let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = objCreate(null); + let _intervalListeners: Array<() => void> = []; // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again @@ -447,9 +565,11 @@ export function createStatsMgr(): IStatsMgr { // change handler. This supports the scenario where the config is changed after the manager // has been created (including CDN / dynamic config updates). return onConfigChange(core.config, (details) => { + let previousInterval = _shortInterval; // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) _isMgrEnabled = false; _statsCfgFetchFn = null; + _statsIKey = null; _statsCfgUrl = null; if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and @@ -464,15 +584,22 @@ export function createStatsMgr(): IStatsMgr { // Make the override fetch fn a dynamic property before snapshotting it so a later // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); + _statsIKey = details.set(statsCfg, "iKey", statsCfg.iKey); // Same for the config url, it is normally delivered by the CDN configuration after // initialization has completed, so this handler must re-run when it arrives. _statsCfgUrl = details.set(statsCfg, "cfgUrl", statsCfg.cfgUrl); - _shortInterval = STATS_COLLECTION_SHORT_INTERVAL; // Reset to the default in-case the config is removed / changed - if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > STATS_MIN_INTERVAL_SECONDS) { + _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; // Reset to the default in-case the config is removed / changed + if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > 0) { _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds } } } + + if (_shortInterval !== previousInterval) { + for (let lp = 0; lp < _intervalListeners.length; lp++) { + _intervalListeners[lp](); + } + } }); } } @@ -512,32 +639,52 @@ export function createStatsMgr(): IStatsMgr { return entry.result; } - function _track(internalSdkStats: IInternalSdkStats, internalSdkStatsEvent: ITelemetryItem) { + function _track(internalSdkStats: IInternalSdkStats, internalSdkStatsEvent?: ITelemetryItem): boolean | null { if (_isMgrEnabled) { + if (!_statsIKey) { + return null; + } + // The remote cfg file is the sole authority for whether collection is enabled and where // to send the events. Re-resolved here (rather than cached on the instance) to support the // endpoint changing after the instance was created. let cfgResult = _resolveStatsCfg(internalSdkStats.endpoint); - if (!cfgResult || !cfgResult.enabled) { - // Not resolved yet, or disabled -> skip - return; + if (!cfgResult) { + return null; + } + if (!cfgResult.enabled) { + return false; } let url = _buildStatsEndpoint(cfgResult.url); if (!url) { - // Enabled but no usable host -> skip - return; + return null; } - internalSdkStatsEvent.iKey = STATS_SDK_IKEY; - // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the event - // away from the customer's breeze endpoint. This marker is removed by the channel before - // the event is serialized. - internalSdkStatsEvent.data = internalSdkStatsEvent.data || {}; - internalSdkStatsEvent.data[STATS_SDK_ENDPOINT_KEY] = url; + if (internalSdkStatsEvent) { + internalSdkStatsEvent.iKey = _statsIKey; + // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the event + // away from the customer's breeze endpoint. This marker is removed by the channel before + // the event is serialized. + internalSdkStatsEvent.data = internalSdkStatsEvent.data || {}; + internalSdkStatsEvent.data[STATS_SDK_ENDPOINT_KEY] = url; - _core.track(internalSdkStatsEvent); + _core.track(internalSdkStatsEvent); + } + return true; } + + return false; + } + + function _watchInterval(cb: () => void): () => void { + _intervalListeners.push(cb); + return () => { + let idx = arrIndexOf(_intervalListeners, cb); + if (idx !== -1) { + _intervalListeners.splice(idx, 1); + } + }; } function _createInstance(state: IInternalSdkStatsState): IInternalSdkStats { @@ -550,13 +697,15 @@ export function createStatsMgr(): IStatsMgr { } let callbacks: _IMgrCallbacks = { - start: (cb: () => void) => { - return scheduleTimeout(cb, _shortInterval); + start: (cb: () => void, delay: number) => { + return scheduleTimeout(cb, delay); }, + interval: () => _shortInterval, + watchInterval: _watchInterval, track: _track }; - instance = _createInternalSdkStats(callbacks, state); + instance = _createInternalSdkStats(callbacks, state, safeGetLogger(_core)); } return instance; diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 019a718f1..8908513f8 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -46,7 +46,7 @@ export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { createStatsMgr, getStatsCfgUrl, - STATS_SDK_IKEY, STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE + STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE } from "./core/InternalSdkStats"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; export { diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index 700815674..be04a9a6c 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -93,8 +93,9 @@ export type InternalSdkStatsCfgFetchFn = (cfgUrl: string, oncomplete: (result: I */ export interface IInternalSdkStatsConfig { /** - * The short collection interval in seconds to send the stats beat events. - * Default: 15 min + * Collection interval in seconds. Counters persist in session storage across page loads. + * Non-positive values are ignored and the default is used instead. + * @default 3600 (1 hour) */ shrtInt?: number; @@ -112,6 +113,12 @@ export interface IInternalSdkStatsConfig { */ cfgUrl?: string; + /** + * Instrumentation key used for SDK Stats events. When omitted, SDK Stats are not sent. + * @default undefined + */ + iKey?: string; + /** * Optional override for the function used to fetch the remote SDK Stats configuration * (`cfg/v1.json`). When not provided the default fetch / XHR based implementation is used. This From c0544104e184fc092b7431a3366ef114f74b3aa0 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 5 Aug 2026 17:19:35 -0700 Subject: [PATCH 27/51] Reduce SDK Stats EU region literals Use a compact prefix regex for EU data-boundary region detection while preserving coverage for all previously supported regions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/ai/InternalSdkStats.Tests.ts | 11 +++++++++++ shared/AppInsightsCore/src/core/InternalSdkStats.ts | 9 +++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 9ea83ab4e..94f904061 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -552,6 +552,17 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", getStatsCfgUrl("https://westeurope-5.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), "An EU region replica endpoint should also resolve to the EU url"); + + let euRegions = [ + "francecentral", "francesouth", "germanywestcentral", "norwayeast", + "norwaywest", "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "ukwest" + ]; + for (let lp = 0; lp < euRegions.length; lp++) { + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://" + euRegions[lp] + ".in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + euRegions[lp] + " should resolve to the EU url"); + } + Assert.equal("eu-data.stats.monitor.azure.com/cfg/v1.json", getStatsCfgUrl("https://northeurope.in.applicationinsights.azure.com/", "data.stats.monitor.azure.com/cfg/v1.json"), "A configured url without a scheme should still get the eu- prefix"); diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 502f5be6e..af717deb8 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -51,11 +51,8 @@ export const STATS_SDK_ENDPOINT_KEY = "_sdkStatsEndpoint"; */ export const STATS_SDK_FEATURE = "sdkStats"; -// EU data-boundary regions, mirrors the EU region set used by the Azure Monitor OpenTelemetry exporter -const STATS_EU_REGIONS = [ - "francecentral", "francesouth", "germanywestcentral", "northeurope", "norwayeast", "norwaywest", - "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "ukwest", "westeurope" -]; +// Prefixes for EU data-boundary regions used by the Azure Monitor OpenTelemetry exporter +const STATS_EU_REGION_PATTERN = /^(france|germany|northeurope|norway|sweden|switzerland|uk|westeurope)/; /** * Determine whether the provided customer endpoint maps to an EU data-boundary region. The region @@ -82,7 +79,7 @@ function _isEuEndpoint(endpoint: string): boolean { label = strSubstring(label, 0, dashIdx); } - isEU = arrIndexOf(STATS_EU_REGIONS, label) !== -1; + isEU = STATS_EU_REGION_PATTERN.test(label); } return isEU; From aa1c074433b97d6a146d62d463abe9736a39403a Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 5 Aug 2026 17:31:43 -0700 Subject: [PATCH 28/51] Cache SDK Stats endpoint resolution Cache the resolved config URL per customer endpoint and invalidate the cache when the dynamic cfgUrl changes, avoiding repeated EU-region resolution for each metric. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Unit/src/ai/InternalSdkStats.Tests.ts | 33 +++++++++++++++++++ .../src/core/InternalSdkStats.ts | 14 +++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 94f904061..fa17ea347 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -397,6 +397,39 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: invalidates the endpoint cache when cfgUrl changes", + useFakeTimers: true, + test: () => { + let fetchedUrls: string[] = []; + this._core.config.stats.overrideCfgFn = (cfgUrl, oncomplete) => { + fetchedUrls.push(cfgUrl); + oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + }; + this.clock.tick(1); + + this._statsMgr.init(this._core, "InternalSdkStats"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://westeurope.in.applicationinsights.azure.com", + sdkVer: "1.0.0" + }); + + Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", fetchedUrls[0], + "The initial endpoint should use the configured EU url"); + + this._core.config.stats.cfgUrl = "https://next.stats.monitor.azure.com/cfg/v1.json"; + this.clock.tick(1); + + internalSdkStats.countException("https://westeurope.in.applicationinsights.azure.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal("https://eu-next.stats.monitor.azure.com/cfg/v1.json", fetchedUrls[1], + "The updated cfgUrl should replace the cached endpoint"); + } + }); + this.testCase({ name: "SDK Stats: manager enables by default when config.stats is not provided", test: () => { diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index af717deb8..5dc8b826c 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -545,6 +545,7 @@ export function createStatsMgr(): IStatsMgr { // Resolved remote config cached per cfg URL (EU / non-EU); tracks in-flight fetch and last result. // Null-prototype so the config supplied url can never be used to pollute Object.prototype. let _cfgCache: { [cfgUrl: string]: { pending: boolean, result: IInternalSdkStatsCfgResult } } = objCreate(null); + let _endpointCfgCache: { [endpoint: string]: string } = objCreate(null); let _intervalListeners: Array<() => void> = []; // Lazily initialize the manager and start listening for configuration changes @@ -563,6 +564,7 @@ export function createStatsMgr(): IStatsMgr { // has been created (including CDN / dynamic config updates). return onConfigChange(core.config, (details) => { let previousInterval = _shortInterval; + let previousCfgUrl = _statsCfgUrl; // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) _isMgrEnabled = false; _statsCfgFetchFn = null; @@ -592,6 +594,10 @@ export function createStatsMgr(): IStatsMgr { } } + if (_statsCfgUrl !== previousCfgUrl) { + _endpointCfgCache = objCreate(null); + } + if (_shortInterval !== previousInterval) { for (let lp = 0; lp < _intervalListeners.length; lp++) { _intervalListeners[lp](); @@ -607,7 +613,13 @@ export function createStatsMgr(): IStatsMgr { * skips sending. */ function _resolveStatsCfg(endpoint: string): IInternalSdkStatsCfgResult { - let cfgUrl = getStatsCfgUrl(endpoint, _statsCfgUrl); + let cfgUrl = _endpointCfgCache[endpoint]; + if (!cfgUrl) { + cfgUrl = getStatsCfgUrl(endpoint, _statsCfgUrl); + if (cfgUrl) { + _endpointCfgCache[endpoint] = cfgUrl; + } + } if (!cfgUrl) { // No configured SDK Stats config url -> nothing to resolve and nothing is sent return null; From d7e823cbf3ab2bef4078987261ab0b6a71ca7014 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 6 Aug 2026 13:22:21 -0700 Subject: [PATCH 29/51] Report snippet version in SDK Stats version Store only the snippet version in dynamic stats config and append it as the snp component when Sender creates SDK Stats. Keep redirected SDK Stats transport completions isolated so they cannot count themselves or replace the customer endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts | 27 ++++++++ AISKU/src/AISku.ts | 4 ++ .../Tests/Unit/src/InternalSdkStats.tests.ts | 61 ++++++++++++++++++- .../src/Sender.ts | 28 +++++++-- .../src/interfaces/ai/IInternalSdkStats.ts | 3 + 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index 884092241..a39963900 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -40,6 +40,7 @@ export class SdkStatsFeatureTests extends AITestClass { this._testSdkStatsDynamicEnableDisable(); this._testSdkStatsConfigDefaults(); this._testSdkStatsDynamicConfigChanges(); + this._testSnippetSdkVersion(); } private _createAi(configOverrides?: Partial): AppInsightsSku { @@ -303,4 +304,30 @@ export class SdkStatsFeatureTests extends AITestClass { } }); } + + private _testSnippetSdkVersion() { + this.testCase({ + name: "SdkStatsFeature: snippet version is included in the SDK Stats version", + useFakeTimers: true, + test: () => { + let config = { + connectionString: TestConnectionString, + stats: {}, + extensionConfig: { + ["AppInsightsCfgSyncPlugin"]: { + syncMode: ICfgSyncMode.Receive, + cfgUrl: "" + } + } + } as IConfiguration & IConfig; + let ai = new AppInsightsSku({ config: config, sv: "6", queue: [] } as any); + ai.loadAppInsights(); + this._ai = ai; + this.clock.tick(1); + + Assert.equal("6", ai.config.stats.snp, "SDK Stats config should contain snippet version 6"); + } + }); + } + } diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index c972d4b89..a17b059a5 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -427,6 +427,10 @@ export class AppInsightsSku implements IApplicationInsights void) => { @@ -101,7 +105,7 @@ export class InternalSdkStatsTests extends AITestClass { let internalSdkStatsState: IInternalSdkStatsState = { cKey: instrumentationKey, endpoint: config.endpointUrl, - sdkVer: "1.0.0", + sdkVer: "javascript:3.4.3:snp6", }; this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); @@ -154,9 +158,13 @@ export class InternalSdkStatsTests extends AITestClass { const internalSdkStatsEvent = this.trackSpy.firstCall.args[0]; Assert.equal(internalSdkStatsEvent.baseType, "MetricData", "SDK Stats event should be of type MetricData"); Assert.equal(internalSdkStatsEvent.baseData.name, eventName, `InternalSdkStats event should be of type ${eventName}`); + Assert.equal("javascript:3.4.3:snp6", internalSdkStatsEvent.baseData.properties.version, + "SDK Stats should use the configured SDK version"); } public registerTests() { + let redirectedSender: Sender; + this.testCase({ name: "SDK Stats initializes when stats is true", test: () => { @@ -193,6 +201,55 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCaseAsync({ + name: "SDK Stats redirected sends do not count themselves or replace the customer endpoint", + useFakeTimers: true, + useFakeServer: true, + stepDelay: 100, + steps: [ + () => { + this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { + return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); + }); + + const config = this.createSenderConfig(TransportType.Fetch); + redirectedSender = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000").sender; + + redirectedSender.processTelemetry({ + name: "sdk-stat", + baseType: "MetricData", + baseData: { + name: "sdk-stat", + average: 1, + properties: {} + }, + data: { + [STATS_SDK_ENDPOINT_KEY]: "https://stats.test/v2/track" + } + }, null); + } + ].concat(PollingAssert.createPollingAssert(() => { + if (this.fetchStub.called) { + Assert.equal(0, this.internalSdkStatsCountSpy.callCount, "The redirected send should not count itself"); + redirectedSender.processTelemetry({ + name: "customer-event", + baseType: "EventData", + baseData: {} + }, null); + redirectedSender.flush(); + return true; + } + return false; + }, "Waiting for the redirected SDK Stats send") as any).concat(PollingAssert.createPollingAssert(() => { + if (this.fetchStub.callCount >= 2) { + let request = this.fetchStub.secondCall.args[0] as Request; + Assert.ok(request.url.indexOf("https://test") === 0, "Customer telemetry should still use the customer endpoint"); + return true; + } + return false; + }, "Waiting for the customer telemetry send") as any) + }); + this.testCaseAsync({ name: "SDK Stats increments success count when fetch sender is called once", useFakeTimers: true, diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index ae65c29b8..7a36aa245 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -694,10 +694,12 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } function _getSdkStats() { + let statsCfg = _self.core.config.stats; + let snp = statsCfg && statsCfg.snp; let internalSdkStatsConfig: IInternalSdkStatsState = { cKey: _self._senderConfig.instrumentationKey, endpoint: _endpointUrl, - sdkVer: EnvelopeCreator.Version, + sdkVer: "javascript:" + EnvelopeCreator.Version + (snp ? ":snp" + snp : "") }; let core = _self.core; @@ -747,6 +749,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } } + function _isSdkStatsPayload(payload?: IPayloadData): boolean { + return !!(payload && payload.urlString !== _endpointUrl); + } + function _xdrOnLoad (xdr: IXDomainRequest, payload: IInternalStorageItem[]) { const responseText = _getResponseText(xdr); if (xdr && (responseText + "" === "200" || responseText === "")) { @@ -773,6 +779,11 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } + if (_isSdkStatsPayload(payload)) { + let responseText = _getResponseText(xdr); + oncomplete(xdr && (responseText + "" === "200" || responseText === "") ? 200 : 499, {}); + return; + } if (payload && payload.urlString === _endpointUrl) { const responseText = _getResponseText(xdr); let internalSdkStats = _getSdkStats(); @@ -801,6 +812,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } + if (_isSdkStatsPayload(payload)) { + onComplete(response.status, {}, resValue); + return; + } _countInternalSdkStats(response.status, payload); return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, @@ -810,6 +825,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return; } if (request.readyState === 4) { + if (_isSdkStatsPayload(payload)) { + oncomplete(request.status, {}); + return; + } _countInternalSdkStats(request.status, payload); } @@ -1082,9 +1101,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true, urlOverride?: string): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { - _countInternalSdkStats(status, payloadData); - - return _getOnComplete(payload, status, headers, response); + if (!urlOverride) { + _countInternalSdkStats(status, payloadData); + return _getOnComplete(payload, status, headers, response); + } }; let payloadData = _getPayload(payload, urlOverride); if (payloadData) { diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index be04a9a6c..3d0299af6 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -119,6 +119,9 @@ export interface IInternalSdkStatsConfig { */ iKey?: string; + /** Snippet version appended to the SDK Stats version. */ + snp?: string; + /** * Optional override for the function used to fetch the remote SDK Stats configuration * (`cfg/v1.json`). When not provided the default fetch / XHR based implementation is used. This From 0792aba7965bfb3204fae92b41684f3b4cd27681 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 6 Aug 2026 15:25:06 -0700 Subject: [PATCH 30/51] Isolate SDK Stats from customer pipeline Create SDK Stats through a SKU-provided core factory so customer initializers, plugins, headers, buffering, and endpoints cannot affect internal telemetry. AISKU supplies a minimal AppInsightsCore and Sender pipeline, while the manager observes the customer core only for dynamic configuration and recreates or unloads the isolated core as its endpoint and iKey change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 13 +- .../Tests/Unit/src/InternalSdkStats.tests.ts | 96 +++------- .../src/Sender.ts | 124 +++---------- .../Unit/src/ai/InternalSdkStats.Tests.ts | 167 ++++++++++++++---- .../src/core/InternalSdkStats.ts | 147 ++++++++------- shared/AppInsightsCore/src/index.ts | 4 +- .../src/interfaces/ai/IStatsMgr.ts | 17 +- 7 files changed, 304 insertions(+), 264 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index a17b059a5..1dc08cd12 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -406,7 +406,18 @@ export class AppInsightsSku implements IApplicationInsights(_core); + let statsHook = statsMgr.init(_core, (statsConfig) => { + try { + let statsCore = new AppInsightsCore(); + (statsConfig as IConfiguration & IConfig).maxBatchInterval = 1; + statsCore.initialize(statsConfig as IConfiguration & IConfig, [new Sender()]); + return statsCore; + } catch (e) { + _throwInternal(_core.logger, eLoggingSeverity.WARNING, + _eInternalMessageId.InternalSdkStatsManagerException, "Failed to create SDK Stats core"); + return null; + } + }); if (statsHook) { _core.addUnloadHook(statsHook); } 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 fadf4a30f..faf8fa479 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -1,7 +1,7 @@ import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; import { - AppInsightsCore, createStatsMgr, FeatureOptInMode, getWindow, IPayloadData, IInternalSdkStatsState, IStatsMgr, ITelemetryItem, - IUnloadHook, STATS_SDK_ENDPOINT_KEY, TransportType + AppInsightsCore, createStatsMgr, FeatureOptInMode, getWindow, IAppInsightsCore, IConfiguration, IPayloadData, IInternalSdkStatsState, + IStatsMgr, ITelemetryItem, IUnloadHook, TransportType } from "@microsoft/applicationinsights-core-js"; import { Sender } from "../../../src/Sender"; import { SinonSpy, SinonStub } from "sinon"; @@ -37,7 +37,7 @@ export class InternalSdkStatsTests extends AITestClass { private internalSdkStatsCountSpy: SinonSpy; private fetchStub: sinon.SinonStub; private beaconStub: sinon.SinonStub; - private trackSpy: SinonSpy; + private statsCore: IAppInsightsCore; public testInitialize() { _clearStatsStorage(); @@ -69,11 +69,22 @@ export class InternalSdkStatsTests extends AITestClass { if (this.beaconStub) { this.beaconStub.restore(); } - if (this.trackSpy) { - this.trackSpy.restore(); + if (this.statsCore) { + this.statsCore.unload(false); } } + private createStatsCore(config: IConfiguration): IAppInsightsCore { + this.statsCore = { + isInitialized: () => true, + track: (item: ITelemetryItem) => { + }, + unload: () => { + } + } as any; + return this.statsCore; + } + private initializeCoreAndSender(config: any, instrumentationKey: string) { const sender = new Sender(); const core = new AppInsightsCore(); @@ -98,7 +109,7 @@ export class InternalSdkStatsTests extends AITestClass { // Initialize the core first, then init the manager against that same (now initialized) // core so it can enable itself (createStatsMgr().init() only enables once the core is initialized). core.initialize(coreConfig, [sender]); - let unloadHook = statsMgr.init(core, "InternalSdkStats"); + let unloadHook = statsMgr.init(core, (config) => this.createStatsCore(config), "InternalSdkStats"); core.setStatsMgr(statsMgr); this._statsMgrUnloadHook = unloadHook; @@ -109,8 +120,6 @@ export class InternalSdkStatsTests extends AITestClass { }; this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); - this.trackSpy = this.sandbox.spy(core, "track"); - this.onDone(() => { sender.teardown(); }); @@ -150,21 +159,14 @@ export class InternalSdkStatsTests extends AITestClass { this.clock.tick(900000); // Simulate time passing for internalSdkStats to be sent } - private assertInternalSdkStatsCall(statusCode: number, eventName: string) { + private assertInternalSdkStatsCall(statusCode: number) { Assert.equal(this.internalSdkStatsCountSpy.callCount, 1, "SDK Stats count should be called once"); Assert.equal(this.internalSdkStatsCountSpy.firstCall.args[0], statusCode, `InternalSdkStats count should be called with status ${statusCode}`); const data = JSON.stringify(this.internalSdkStatsCountSpy.firstCall.args[1]); Assert.ok(data.includes("startTime"), "SDK Stats count should be called with startTime set"); - const internalSdkStatsEvent = this.trackSpy.firstCall.args[0]; - Assert.equal(internalSdkStatsEvent.baseType, "MetricData", "SDK Stats event should be of type MetricData"); - Assert.equal(internalSdkStatsEvent.baseData.name, eventName, `InternalSdkStats event should be of type ${eventName}`); - Assert.equal("javascript:3.4.3:snp6", internalSdkStatsEvent.baseData.properties.version, - "SDK Stats should use the configured SDK version"); } public registerTests() { - let redirectedSender: Sender; - this.testCase({ name: "SDK Stats initializes when stats is true", test: () => { @@ -183,10 +185,13 @@ export class InternalSdkStatsTests extends AITestClass { oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); } } + }; this._core.initialize(config, [this._sender]); - this._statsMgrUnloadHook = this._statsMgr.init(this._core, "InternalSdkStats"); + this._statsMgrUnloadHook = this._statsMgr.init( + this._core, (statsConfig) => this.createStatsCore(statsConfig), "InternalSdkStats" + ); this._core.setStatsMgr(this._statsMgr); let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", @@ -201,55 +206,6 @@ export class InternalSdkStatsTests extends AITestClass { } }); - this.testCaseAsync({ - name: "SDK Stats redirected sends do not count themselves or replace the customer endpoint", - useFakeTimers: true, - useFakeServer: true, - stepDelay: 100, - steps: [ - () => { - this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { - return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); - }); - - const config = this.createSenderConfig(TransportType.Fetch); - redirectedSender = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000").sender; - - redirectedSender.processTelemetry({ - name: "sdk-stat", - baseType: "MetricData", - baseData: { - name: "sdk-stat", - average: 1, - properties: {} - }, - data: { - [STATS_SDK_ENDPOINT_KEY]: "https://stats.test/v2/track" - } - }, null); - } - ].concat(PollingAssert.createPollingAssert(() => { - if (this.fetchStub.called) { - Assert.equal(0, this.internalSdkStatsCountSpy.callCount, "The redirected send should not count itself"); - redirectedSender.processTelemetry({ - name: "customer-event", - baseType: "EventData", - baseData: {} - }, null); - redirectedSender.flush(); - return true; - } - return false; - }, "Waiting for the redirected SDK Stats send") as any).concat(PollingAssert.createPollingAssert(() => { - if (this.fetchStub.callCount >= 2) { - let request = this.fetchStub.secondCall.args[0] as Request; - Assert.ok(request.url.indexOf("https://test") === 0, "Customer telemetry should still use the customer endpoint"); - return true; - } - return false; - }, "Waiting for the customer telemetry send") as any) - }); - this.testCaseAsync({ name: "SDK Stats increments success count when fetch sender is called once", useFakeTimers: true, @@ -276,7 +232,7 @@ export class InternalSdkStatsTests extends AITestClass { } ].concat(PollingAssert.createPollingAssert(() => { if (this.internalSdkStatsCountSpy.called && this.fetchStub.called) { - this.assertInternalSdkStatsCall(200, "Request_Success_Count"); + this.assertInternalSdkStatsCall(200); return true; } return false; @@ -307,7 +263,7 @@ export class InternalSdkStatsTests extends AITestClass { } ].concat(PollingAssert.createPollingAssert(() => { if (this.internalSdkStatsCountSpy.called && this.fetchStub.called) { - this.assertInternalSdkStatsCall(439, "Throttle_Count"); + this.assertInternalSdkStatsCall(439); return true; } return false; @@ -339,7 +295,7 @@ export class InternalSdkStatsTests extends AITestClass { } ].concat(PollingAssert.createPollingAssert(() => { if (this.internalSdkStatsCountSpy.called) { - this.assertInternalSdkStatsCall(200, "Request_Success_Count"); + this.assertInternalSdkStatsCall(200); return true; } return false; @@ -376,7 +332,7 @@ export class InternalSdkStatsTests extends AITestClass { } ].concat(PollingAssert.createPollingAssert(() => { if (this.internalSdkStatsCountSpy.called) { - this.assertInternalSdkStatsCall(200, "Request_Success_Count"); + this.assertInternalSdkStatsCall(200); console.log("SDK Stats count called with success count for xhr sender"); return true; } diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 7a36aa245..defda7bec 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -5,13 +5,13 @@ import { IEnvelope, IInternalOfflineSupport, IInternalSdkStatsState, INotificationManager, IOfflineListener, IPayloadData, IPlugin, IProcessTelemetryContext, IProcessTelemetryUnloadContext, ISample, IStatsEventData, IStorageBuffer, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, IXDomainRequest, IXHROverride, MetricDataType, OnCompleteCallback, PageViewDataType, - PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, RequestDataType, RequestHeaders, STATS_SDK_ENDPOINT_KEY, - SampleRate, SendPOSTFunction, SendRequestReason, SenderPostManager, TraceDataType, TransportType, _ISendPostMgrConfig, - _ISenderOnComplete, _eInternalMessageId, _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createOfflineListener, - createProcessTelemetryContext, createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, formatErrorMessageXdr, - formatErrorMessageXhr, getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, - isInternalApplicationInsightsEndpoint, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, - runTargetUnload, utlCanUseSessionStorage, utlSetStoragePrefix + PageViewPerformanceDataType, ProcessLegacy, RemoteDependencyDataType, RequestDataType, RequestHeaders, SampleRate, SendPOSTFunction, + SendRequestReason, SenderPostManager, TraceDataType, TransportType, _ISendPostMgrConfig, _ISenderOnComplete, _eInternalMessageId, + _throwInternal, _warnToConsole, arrForEach, cfgDfBoolean, cfgDfValidate, createOfflineListener, createProcessTelemetryContext, + createUniqueNamespace, dateNow, dumpObj, eLoggingSeverity, eRequestHeaders, formatErrorMessageXdr, formatErrorMessageXhr, + getExceptionName, getIEVersion, isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, isInternalApplicationInsightsEndpoint, + isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, runTargetUnload, + utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-core-js"; import { IPromise, createPromise, doAwait, doAwaitResponse } from "@nevware21/ts-async"; import { @@ -189,7 +189,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let _sendPostMgr: SenderPostManager; let _retryCodes: number[]; let _zipPayload: boolean; - let _httpInterface: IXHROverride; // The resolved http sender, captured so SDK Stats can be sent to their own endpoint dynamicProto(Sender, this, (_self, _base) => { @@ -455,8 +454,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { httpInterface = _alwaysUseCustomSend? customInterface : (httpInterface || customInterface || xhrInterface); - _httpInterface = httpInterface; - _self._sender = (payload: IInternalStorageItem[], isAsync: boolean) => { return _doSend(httpInterface, payload, isAsync); }; @@ -502,16 +499,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return; } - // SDK Stats events carry the destination SDK Stats ingestion endpoint - // so they can be redirected away from the customer's breeze endpoint. Extract and - // remove the marker so it is not serialized into the outgoing envelope. - let statsEndpoint: string; - let data = telemetryItem.data; - if (data && data[STATS_SDK_ENDPOINT_KEY]) { - statsEndpoint = data[STATS_SDK_ENDPOINT_KEY]; - delete data[STATS_SDK_ENDPOINT_KEY]; - } - let aiEnvelope = _getEnvelope(telemetryItem, diagLogger); if (!aiEnvelope) { return; @@ -520,27 +507,21 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // check if the incoming payload is too large, truncate if necessary const payload: string = _serializer.serialize(aiEnvelope); - if (statsEndpoint) { - // Route SDK Stats directly to the SDK Stats ingestion endpoint, bypassing the - // customer send buffer so it is never mixed with customer telemetry. - _sendInternalSdkStats(payload, statsEndpoint); - } else { - // flush if we would exceed the max-size limit by adding this item - const buffer = _self._buffer; - _checkMaxSize(payload); - let payloadItem = { - item: payload, - cnt: 0, // inital cnt will always be 0 - bT: telemetryItem.baseType, // store baseType for SDK stats telemetryType mapping - iN: telemetryItem.name // store name so SDK stats can self-filter its own metrics - } as IInternalStorageItem; - - // enqueue the payload - buffer.enqueue(payloadItem); - - // ensure an invocation timeout is set - _setupTimer(); - } + // flush if we would exceed the max-size limit by adding this item + const buffer = _self._buffer; + _checkMaxSize(payload); + let payloadItem = { + item: payload, + cnt: 0, // inital cnt will always be 0 + bT: telemetryItem.baseType, // store baseType for SDK stats telemetryType mapping + iN: telemetryItem.name // store name so SDK stats can self-filter its own metrics + } as IInternalStorageItem; + + // enqueue the payload + buffer.enqueue(payloadItem); + + // ensure an invocation timeout is set + _setupTimer(); } catch (e) { _throwInternal(diagLogger, @@ -709,30 +690,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return core && core.getSdkStats ? core.getSdkStats(internalSdkStatsConfig) : null; } - /** - * Send a single serialized SDK Stats payload to the provided SDK Stats ingestion - * endpoint. SDK Stats are routed to the distro-owned endpoint instead of the customer's breeze - * endpoint and therefore bypass the normal send buffer. The send reuses the resolved http - * sender and is sent immediately (SDK Stats events are infrequent and low volume). - * @param payloadStr - The serialized envelope to send. - * @param statsEndpoint - The SDK Stats ingestion endpoint URL to send the payload to. - */ - function _sendInternalSdkStats(payloadStr: string, statsEndpoint: string) { - try { - let sendInterface = _httpInterface; - if (sendInterface && payloadStr && statsEndpoint) { - let payloadItem = { - item: payloadStr, - cnt: 0 - } as IInternalStorageItem; - // markAsSent=false so the main send buffer is never touched, statsUrl redirects the POST - _doSend(sendInterface, [payloadItem], true, false, statsEndpoint); - } - } catch (e) { - // SDK Stats are best-effort and must never break customer telemetry - } - } - /** * Record the result of a send against the SDK Stats instance for the customer * endpoint. Sends to the SDK Stats ingestion endpoint itself are intentionally skipped (the @@ -749,10 +706,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } } - function _isSdkStatsPayload(payload?: IPayloadData): boolean { - return !!(payload && payload.urlString !== _endpointUrl); - } - function _xdrOnLoad (xdr: IXDomainRequest, payload: IInternalStorageItem[]) { const responseText = _getResponseText(xdr); if (xdr && (responseText + "" === "200" || responseText === "")) { @@ -779,11 +732,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - if (_isSdkStatsPayload(payload)) { - let responseText = _getResponseText(xdr); - oncomplete(xdr && (responseText + "" === "200" || responseText === "") ? 200 : 499, {}); - return; - } if (payload && payload.urlString === _endpointUrl) { const responseText = _getResponseText(xdr); let internalSdkStats = _getSdkStats(); @@ -812,10 +760,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (!payloadArr) { return; } - if (_isSdkStatsPayload(payload)) { - onComplete(response.status, {}, resValue); - return; - } _countInternalSdkStats(response.status, payload); return _checkResponsStatus(response.status, payloadArr, response.url, payloadArr.length, response.statusText, resValue || ""); }, @@ -825,10 +769,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return; } if (request.readyState === 4) { - if (_isSdkStatsPayload(payload)) { - oncomplete(request.status, {}); - return; - } _countInternalSdkStats(request.status, payload); } @@ -1099,14 +1039,12 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } } - function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true, urlOverride?: string): void | IPromise { + function _doSend(sendInterface: IXHROverride, payload: IInternalStorageItem[], isAsync: boolean, markAsSent: boolean = true): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { - if (!urlOverride) { - _countInternalSdkStats(status, payloadData); - return _getOnComplete(payload, status, headers, response); - } + _countInternalSdkStats(status, payloadData); + return _getOnComplete(payload, status, headers, response); }; - let payloadData = _getPayload(payload, urlOverride); + let payloadData = _getPayload(payload); if (payloadData) { payloadData.statsData = {startTime: dateNow()}; } @@ -1145,16 +1083,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { return null; } - function _getPayload(payload: IInternalStorageItem[], urlOverride?: string): IInternalPayloadData { + function _getPayload(payload: IInternalStorageItem[]): IInternalPayloadData { if (isArray(payload) && payload.length > 0) { let batch = _self._buffer.batchPayloads(payload); - // When the destination url is overridden (SDK Stats ingestion endpoint) the customer - // configured headers must NOT be forwarded, they may contain authorization or other - // sensitive values that are only intended for the customer's own endpoint. - let headers = urlOverride ? {} : _getHeaders(); + let headers = _getHeaders(); let payloadData: IInternalPayloadData = { data: batch, - urlString: urlOverride || _endpointUrl, + urlString: _endpointUrl, headers: headers, disableXhrSync: _disableXhr, disableFetchKeepAlive: !_fetchKeepAlive, @@ -1543,7 +1478,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { function _initDefaults() { _self._sender = null; _self._buffer = null; - _httpInterface = null; _self._appId = null; _self._sample = null; _headers = {}; diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index fa17ea347..1085b0a28 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -4,7 +4,7 @@ import { IPayloadData } from "../../../../src/interfaces/ai/IXHROverride"; import { IStatsMgr } from "../../../../src/interfaces/ai/IStatsMgr"; import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; -import { createStatsMgr, getStatsCfgUrl, STATS_SDK_ENDPOINT_KEY } from "../../../../src/core/InternalSdkStats"; +import { createStatsMgr, getStatsCfgUrl } from "../../../../src/core/InternalSdkStats"; import { IInternalSdkStatsState } from "../../../../src/interfaces/ai/IInternalSdkStats"; import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; @@ -50,6 +50,9 @@ export class InternalSdkStatsTests extends AITestClass { private _config: IConfiguration; private _statsMgr: IStatsMgr; private _trackSpy: sinon.SinonSpy; + private _rootTrackSpy: sinon.SinonSpy; + private _statsCoreConfigs: IConfiguration[]; + private _statsCores: AppInsightsCore[]; constructor(emulateIe: boolean) { super("InternalSdkStatsTests", emulateIe); @@ -84,13 +87,15 @@ export class InternalSdkStatsTests extends AITestClass { _self._statsMgr = createStatsMgr(); _self._core = new AppInsightsCore(); + _self._statsCoreConfigs = []; + _self._statsCores = []; // Initialize the core once here (with a minimal channel plugin) so the stats manager // can be enabled when init() is called - createStatsMgr().init() only hooks config // changes and enables the manager when the core is already initialized. _self._core.initialize(_self._config, [new ChannelPlugin()]); - // Create spy for tracking telemetry - _self._trackSpy = this.sandbox.spy(_self._core, "track"); + _self._trackSpy = this.sandbox.spy(); + _self._rootTrackSpy = this.sandbox.spy(_self._core, "track"); } public testCleanup() { @@ -98,11 +103,32 @@ export class InternalSdkStatsTests extends AITestClass { if (this._core && this._core.isInitialized()) { this._core.unload(false); } + for (let lp = 0; lp < this._statsCores.length; lp++) { + this._statsCores[lp].isInitialized() && this._statsCores[lp].unload(false); + } this._core = null as any; this._statsMgr = null as any; + this._statsCores = []; _clearStatsStorage(); } + private _createStatsCore(config: IConfiguration): IAppInsightsCore { + let core = new AppInsightsCore(); + core.initialize(config, [new ChannelPlugin()]); + let track = core.track; + this.sandbox.stub(core, "track").callsFake((item: ITelemetryItem) => { + this._trackSpy(item); + track.call(core, item); + }); + this._statsCoreConfigs.push(config); + this._statsCores.push(core); + return core; + } + + private _initStatsMgr(core: IAppInsightsCore = this._core, featureName: string = "InternalSdkStats") { + return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName); + } + public registerTests() { this.testCase({ @@ -119,7 +145,7 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal(null, this._statsMgr.newInst(internalSdkStatsState), "SDK Stats should not be created before initialization"); // Initialize - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); Assert.equal(true, this._statsMgr.enabled, "SDK Stats manager should be initialized after initialization"); let newInst = this._statsMgr.newInst(internalSdkStatsState); @@ -134,7 +160,7 @@ export class InternalSdkStatsTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); // Create mock payload data with timing information const payloadData = { @@ -180,7 +206,7 @@ export class InternalSdkStatsTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", @@ -220,7 +246,7 @@ export class InternalSdkStatsTests extends AITestClass { useFakeTimers: true, test: () => { // Initialize SDK Stats manager for a specific endpoint - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); // Create mock payload data const payloadData = { @@ -265,7 +291,7 @@ export class InternalSdkStatsTests extends AITestClass { this._core.initialize(this._config, [new ChannelPlugin()]); } // Initialize SDK Stats manager for a specific endpoint - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); this._core.setStatsMgr(this._statsMgr); let internalSdkStatsState: IInternalSdkStatsState = { @@ -319,7 +345,7 @@ export class InternalSdkStatsTests extends AITestClass { name: "SDK Stats: routes events to the remote configured SDK Stats endpoint", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); const payloadData = { urlString: "https://example.endpoint.com", @@ -343,20 +369,17 @@ export class InternalSdkStatsTests extends AITestClass { this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); Assert.ok(this._trackSpy.called, "track should be called when SDK Stats timer fires"); + Assert.equal(0, this._rootTrackSpy.callCount, "SDK Stats should not use the customer core"); + Assert.equal(STATS_TEST_IKEY, this._statsCoreConfigs[0].instrumentationKey, + "The isolated core should use the SDK Stats instrumentation key"); + Assert.equal("https://data.stats.monitor.azure.com/v2/track", this._statsCoreConfigs[0].endpointUrl, + "The isolated core should use the remote configured endpoint"); + Assert.equal(1, this._statsCoreConfigs.length, "One isolated core should handle the SDK Stats batch"); - // The host returned by the remote configuration (data.stats.monitor.azure.com) should be - // combined with the /v2/track path to form the SDK Stats ingestion endpoint. - let foundEndpoint = false; for (let i = 0; i < this._trackSpy.callCount; i++) { const item: ITelemetryItem = this._trackSpy.getCall(i).args[0]; - if (item.data && item.data[STATS_SDK_ENDPOINT_KEY] === "https://data.stats.monitor.azure.com/v2/track") { - Assert.equal(STATS_TEST_IKEY, item.iKey, "SDK Stats should use the configured instrumentation key"); - foundEndpoint = true; - break; - } + Assert.equal(STATS_TEST_IKEY, item.iKey, "SDK Stats should use the configured instrumentation key"); } - - Assert.ok(foundEndpoint, "SDK Stats events should be routed to the remote configured ingestion endpoint"); } }); @@ -370,7 +393,7 @@ export class InternalSdkStatsTests extends AITestClass { }; this.clock.tick(1); // Allow the config change to propagate - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); const payloadData = { urlString: "https://example.endpoint.com", @@ -397,6 +420,52 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: customer telemetry initializers cannot inspect or modify SDK Stats", + useFakeTimers: true, + test: () => { + let initializerCalls = 0; + this._core.addTelemetryInitializer(() => { + initializerCalls++; + throw new Error("Customer initializer should not receive SDK Stats"); + }); + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "The isolated core should receive SDK Stats"); + Assert.equal(0, initializerCalls, "Customer telemetry initializers should not receive SDK Stats"); + Assert.equal(0, this._rootTrackSpy.callCount, "The customer core should not track SDK Stats"); + } + }); + + this.testCase({ + name: "SDK Stats: unloading the manager unloads the isolated core", + useFakeTimers: true, + test: () => { + let hook = this._initStatsMgr(); + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + let unloadSpy = this.sandbox.spy(this._statsCores[0], "unload"); + hook.rm(); + + Assert.equal(1, unloadSpy.callCount, "The isolated core should be unloaded with the manager"); + } + }); + this.testCase({ name: "SDK Stats: invalidates the endpoint cache when cfgUrl changes", useFakeTimers: true, @@ -408,7 +477,7 @@ export class InternalSdkStatsTests extends AITestClass { }; this.clock.tick(1); - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -442,7 +511,7 @@ export class InternalSdkStatsTests extends AITestClass { } as IConfiguration, [new ChannelPlugin()]); let statsMgr = createStatsMgr(); - let hook = statsMgr.init(core, "InternalSdkStats"); + let hook = statsMgr.init(core, (config) => this._createStatsCore(config), "InternalSdkStats"); Assert.equal(true, statsMgr.enabled, "Manager should be enabled by default via the seeded stats config"); @@ -451,6 +520,33 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: recreates the isolated core when its configuration changes", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + let unloadSpy = this.sandbox.spy(this._statsCores[0], "unload"); + this._core.config.stats.iKey = "Updated-Stats-iKey"; + this.clock.tick(1); + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(1, unloadSpy.callCount, "The previous isolated core should be unloaded"); + Assert.equal(2, this._statsCoreConfigs.length, "A new isolated core should be created"); + Assert.equal("Updated-Stats-iKey", this._statsCoreConfigs[1].instrumentationKey, + "The new isolated core should use the updated configuration"); + } + }); + this.testCase({ name: "SDK Stats: does not send when no cfgUrl has been configured", useFakeTimers: true, @@ -459,7 +555,7 @@ export class InternalSdkStatsTests extends AITestClass { this._core.config.stats.cfgUrl = null; this.clock.tick(1); // Allow the config change to propagate - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -482,7 +578,7 @@ export class InternalSdkStatsTests extends AITestClass { this._core.config.stats.cfgUrl = null; this.clock.tick(1); - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -512,7 +608,7 @@ export class InternalSdkStatsTests extends AITestClass { this._core.config.stats.iKey = null; this.clock.tick(1); - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -545,7 +641,7 @@ export class InternalSdkStatsTests extends AITestClass { }; this.clock.tick(1); - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -557,6 +653,7 @@ export class InternalSdkStatsTests extends AITestClass { this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); Assert.equal(0, this._trackSpy.callCount, "Nothing should be sent before remote config resolves"); + Assert.equal(0, this._statsCoreConfigs.length, "The isolated core should not be created before config resolves"); Assert.equal(1, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], "Unsent counters should remain persisted"); @@ -564,6 +661,7 @@ export class InternalSdkStatsTests extends AITestClass { this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); Assert.ok(this._trackSpy.called, "Persisted counters should be sent on the next interval"); + Assert.equal(1, this._statsCoreConfigs.length, "The isolated core should be created after config resolves"); Assert.equal(undefined, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], "Counters should reset after they are processed"); } @@ -606,7 +704,7 @@ export class InternalSdkStatsTests extends AITestClass { name: "SDK Stats: counters are persisted to session storage", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); // Move off the fake timer epoch. this.clock.tick(1000); @@ -636,7 +734,7 @@ export class InternalSdkStatsTests extends AITestClass { name: "SDK Stats: a new instance resumes the persisted counters and collection window", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let state = { cKey: "Test-iKey", @@ -680,7 +778,7 @@ export class InternalSdkStatsTests extends AITestClass { name: "SDK Stats: the persisted counters are reset once the window is sent", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -704,7 +802,7 @@ export class InternalSdkStatsTests extends AITestClass { name: "SDK Stats: separate endpoints do not share persisted counters", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let first = this._statsMgr.newInst({ cKey: "Test-iKey", endpoint: "https://one.endpoint.com", sdkVer: "1.0.0" }); let second = this._statsMgr.newInst({ cKey: "Test-iKey", endpoint: "https://two.endpoint.com", sdkVer: "1.0.0" }); @@ -739,7 +837,7 @@ export class InternalSdkStatsTests extends AITestClass { let trackSpy = this.sandbox.spy(core, "track"); let statsMgr = createStatsMgr(); - let hook = statsMgr.init(core, "InternalSdkStats"); + let hook = statsMgr.init(core, (config) => this._createStatsCore(config), "InternalSdkStats"); let internalSdkStats = statsMgr.newInst({ cKey: "Test-iKey", @@ -752,7 +850,8 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal(0, trackSpy.callCount, "Nothing should be sent before the one hour interval elapses"); this.clock.tick(2); - Assert.ok(trackSpy.called, "The stats should be sent once the one hour interval elapses"); + Assert.ok(this._trackSpy.called, "The stats should be sent once the one hour interval elapses"); + Assert.equal(0, trackSpy.callCount, "The customer core should not send SDK Stats"); hook && hook.rm(); core.unload(false); @@ -766,7 +865,7 @@ export class InternalSdkStatsTests extends AITestClass { this._core.config.stats.shrtInt = 1; this.clock.tick(1); // Allow the config change to propagate - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", @@ -785,7 +884,7 @@ export class InternalSdkStatsTests extends AITestClass { name: "SDK Stats: reschedules an active window when the dynamic interval changes", useFakeTimers: true, test: () => { - this._statsMgr.init(this._core, "InternalSdkStats"); + this._initStatsMgr(); let internalSdkStats = this._statsMgr.newInst({ cKey: "Test-iKey", diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 5dc8b826c..aeccb47da 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -18,7 +18,7 @@ import { IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn } from "../interfaces/ai/IInternalSdkStats"; import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; -import { IStatsMgr } from "../interfaces/ai/IStatsMgr"; +import { CreateStatsCoreFn, IStatsMgr } from "../interfaces/ai/IStatsMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; @@ -38,13 +38,6 @@ const STATS_EU_HOST_PREFIX = "eu-"; /** Ingestion path for future 1DS (OneCollector) SDK Stats; the AI SKU uses {@link DEFAULT_BREEZE_PATH}. */ export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; -/** - * The transient marker key, set on the {@link ITelemetryItem.data} of a SDK Stats event, that - * carries the destination SDK Stats ingestion endpoint. The sending channel reads this value to - * redirect the event to the SDK Stats endpoint and removes it before serializing the event. - */ -export const STATS_SDK_ENDPOINT_KEY = "_sdkStatsEndpoint"; - /** * The default feature name used to gate the SDK Stats manager. SDK Stats is enabled by default * and can be opted-out via the featureOptIn configuration using this name. @@ -535,7 +528,9 @@ function _createInternalSdkStats( export function createStatsMgr(): IStatsMgr { let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not - let _core: IAppInsightsCore; // The core instance that is used to send telemetry + let _core: IAppInsightsCore; // The customer core observed for configuration and endpoint changes + let _createStatsCore: CreateStatsCoreFn; + let _statsCore: IAppInsightsCore; let _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; let _statsIKey: string; @@ -548,9 +543,32 @@ export function createStatsMgr(): IStatsMgr { let _endpointCfgCache: { [endpoint: string]: string } = objCreate(null); let _intervalListeners: Array<() => void> = []; + function _unloadStatsCore() { + let statsCore = _statsCore; + _statsCore = null; + statsCore && statsCore.unload(false); + } + + function _getStatsCore(endpoint: string): IAppInsightsCore { + if (_statsCore && _statsCore.config.endpointUrl !== endpoint) { + _unloadStatsCore(); + } + + if (!_statsCore) { + _statsCore = _createStatsCore({ + instrumentationKey: _statsIKey, + endpointUrl: endpoint + }); + } + + return _statsCore; + } + // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again - function _init(core: IAppInsightsCore, featureName?: string) { + function _init( + core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string + ) { if (_core) { // If the core is already set, then just return with an empty unload hook _throwInternal(safeGetLogger(core), eLoggingSeverity.WARNING, _eInternalMessageId.InternalSdkStatsManagerException, "InternalSdkStats manager is already initialized"); @@ -558,53 +576,66 @@ export function createStatsMgr(): IStatsMgr { } _core = core; - if (core && core.isInitialized()) { - // Start listening for configuration changes from the single global config, within a config - // change handler. This supports the scenario where the config is changed after the manager - // has been created (including CDN / dynamic config updates). - return onConfigChange(core.config, (details) => { - let previousInterval = _shortInterval; - let previousCfgUrl = _statsCfgUrl; - // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) - _isMgrEnabled = false; - _statsCfgFetchFn = null; - _statsIKey = null; - _statsCfgUrl = null; - if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { - // Seed the SDK Stats defaults into the single global config so they remain dynamic and - // can be overridden via the CDN / dynamic config or by the SKU. - details.setDf(details.cfg, _sdkStatsDefaults); - // Read the nested stats config directly (registers the dynamic dependency on the - // stats object) and copy the individual values into local (minifiable) variables - // instead of holding the config object and repeatedly reading its properties. - let statsCfg = details.cfg.stats; - if (statsCfg) { - _isMgrEnabled = true; - // Make the override fetch fn a dynamic property before snapshotting it so a later - // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. - _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); - _statsIKey = details.set(statsCfg, "iKey", statsCfg.iKey); - // Same for the config url, it is normally delivered by the CDN configuration after - // initialization has completed, so this handler must re-run when it arrives. - _statsCfgUrl = details.set(statsCfg, "cfgUrl", statsCfg.cfgUrl); - _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; // Reset to the default in-case the config is removed / changed - if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > 0) { - _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds - } + _createStatsCore = createStatsCore; + // Start listening for configuration changes from the single global config, within a config + // change handler. This supports the scenario where the config is changed after the manager + // has been created (including CDN / dynamic config updates). + let configHook = onConfigChange(core.config, (details) => { + let previousInterval = _shortInterval; + let previousCfgUrl = _statsCfgUrl; + let previousIKey = _statsIKey; + // Re-evaluate the feature flag on every config change (enabled by default, opt-out via featureOptIn) + _isMgrEnabled = false; + _statsCfgFetchFn = null; + _statsIKey = null; + _statsCfgUrl = null; + if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { + // Seed the SDK Stats defaults into the single global config so they remain dynamic and + // can be overridden via the CDN / dynamic config or by the SKU. + details.setDf(details.cfg, _sdkStatsDefaults); + // Read the nested stats config directly (registers the dynamic dependency on the + // stats object) and copy the individual values into local (minifiable) variables + // instead of holding the config object and repeatedly reading its properties. + let statsCfg = details.cfg.stats; + if (statsCfg) { + _isMgrEnabled = true; + // Make the override fetch fn a dynamic property before snapshotting it so a later + // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. + _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); + _statsIKey = details.set(statsCfg, "iKey", statsCfg.iKey); + // Same for the config url, it is normally delivered by the CDN configuration after + // initialization has completed, so this handler must re-run when it arrives. + _statsCfgUrl = details.set(statsCfg, "cfgUrl", statsCfg.cfgUrl); + _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; // Reset to the default in-case the config is removed / changed + if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > 0) { + _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds } } + } - if (_statsCfgUrl !== previousCfgUrl) { - _endpointCfgCache = objCreate(null); - } + if (!_isMgrEnabled || _statsCfgUrl !== previousCfgUrl || _statsIKey !== previousIKey) { + _unloadStatsCore(); + } - if (_shortInterval !== previousInterval) { - for (let lp = 0; lp < _intervalListeners.length; lp++) { - _intervalListeners[lp](); - } + if (_statsCfgUrl !== previousCfgUrl) { + _endpointCfgCache = objCreate(null); + } + + if (_shortInterval !== previousInterval) { + for (let lp = 0; lp < _intervalListeners.length; lp++) { + _intervalListeners[lp](); } - }); - } + } + }); + + return { + rm: () => { + configHook.rm(); + _unloadStatsCore(); + _createStatsCore = null; + _core = null; + } + }; } /** @@ -666,19 +697,13 @@ export function createStatsMgr(): IStatsMgr { } let url = _buildStatsEndpoint(cfgResult.url); - if (!url) { + let statsCore = _getStatsCore(url); + if (!statsCore) { return null; } if (internalSdkStatsEvent) { - internalSdkStatsEvent.iKey = _statsIKey; - // Carry the SDK Stats ingestion endpoint so the sending channel can redirect the event - // away from the customer's breeze endpoint. This marker is removed by the channel before - // the event is serialized. - internalSdkStatsEvent.data = internalSdkStatsEvent.data || {}; - internalSdkStatsEvent.data[STATS_SDK_ENDPOINT_KEY] = url; - - _core.track(internalSdkStatsEvent); + statsCore.track(internalSdkStatsEvent); } return true; } diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 8908513f8..45fcbcdbd 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -43,10 +43,10 @@ export { IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn } from "./interfaces/ai/IInternalSdkStats"; export { IStatsEventData } from "./interfaces/ai/IStatsEventData"; -export { IStatsMgr } from "./interfaces/ai/IStatsMgr"; +export { CreateStatsCoreFn, IStatsMgr } from "./interfaces/ai/IStatsMgr"; export { createStatsMgr, getStatsCfgUrl, - STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_ENDPOINT_KEY, STATS_SDK_FEATURE + STATS_SDK_ONECOLLECTOR_PATH, STATS_SDK_FEATURE } from "./core/InternalSdkStats"; export { createSdkStatsNotifCbk, ISdkStatsConfig, ISdkStatsNotifCbk } from "./core/SdkStatsNotificationCbk"; export { diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index 99176dce1..a01413d5c 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -6,6 +6,15 @@ import { IConfiguration } from "./IConfiguration"; import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; import { IUnloadHook } from "./IUnloadHook"; +/** + * Creates and initializes the isolated core used to send SDK Stats. + * @param config - The SDK Stats configuration containing the resolved iKey and endpoint. + * Implementations must handle creation errors and return null rather than throw. + * @returns The initialized core instance, or null when an SDK Stats pipeline cannot be created. + * @since 3.3.7 + */ +export type CreateStatsCoreFn = (config: IConfiguration) => IAppInsightsCore | null; + /** * The Interface which defines the InternalSdkStats manager, which is responsible for creating and * managing the InternalSdkStats instance. @@ -24,6 +33,8 @@ export interface IStatsMgr { * the SDK Stats feature flag, so any changes made via the CDN / dynamic config are picked up at * runtime. * @param core - The core instance to associate with this manager. + * @param createStatsCore - Creates an isolated, initialized core for SDK Stats. The callback lets + * each SKU select the appropriate channel and plugins without using the customer's pipeline. * @param featureName - The optional featureOptIn name used to gate the manager. Defaults to the * SDK Stats feature (`STATS_SDK_FEATURE`) which is enabled by default and can be opted-out via the * `featureOptIn` configuration. @@ -31,7 +42,11 @@ export interface IStatsMgr { * and disable the manager. This may return null if the manager cannot be initialized. * @remarks This method should be called only once, and it may throw an error if called multiple times. */ - init: (core: IAppInsightsCore, featureName?: string) => IUnloadHook | null; + init: ( + core: IAppInsightsCore, + createStatsCore: CreateStatsCoreFn, + featureName?: string + ) => IUnloadHook | null; /** * Returns a new {@link IInternalSdkStats} instance for the current state which includes the endpoint. From 9ebe2b7df870464f8f9f41d152b0a8bea50cfb9c Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:19:29 -0700 Subject: [PATCH 31/51] test: update core bundle size budget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c471708-eb77-4878-8738-55114be7bab1 --- .../Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index c57bf2b50..90ac1ad7c 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -52,7 +52,7 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: export class AppInsightsCoreSizeCheck extends AITestClass { private readonly MAX_RAW_SIZE = 137; - private readonly MAX_BUNDLE_SIZE = 137; + private readonly MAX_BUNDLE_SIZE = 138; private readonly MAX_RAW_DEFLATE_SIZE = 56; private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; private readonly rawFilePath = "../dist/es5/index.min.js"; From 27e737bef9c5ac2ed5bbafe5fc07367741ba48f4 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:04 -0700 Subject: [PATCH 32/51] test: provide config on SDK Stats core mock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c471708-eb77-4878-8738-55114be7bab1 --- .../Tests/Unit/src/InternalSdkStats.tests.ts | 1 + 1 file changed, 1 insertion(+) 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 faf8fa479..b8d8ac8ef 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -76,6 +76,7 @@ export class InternalSdkStatsTests extends AITestClass { private createStatsCore(config: IConfiguration): IAppInsightsCore { this.statsCore = { + config, isInitialized: () => true, track: (item: ITelemetryItem) => { }, From cfafa53743f93ae853875627f425d5c709039bab Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:17:35 -0700 Subject: [PATCH 33/51] test: update AISKU bundle size budget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c471708-eb77-4878-8738-55114be7bab1 --- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index dfb038abf..4d13a1bc1 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -55,7 +55,7 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: export class AISKUSizeCheck extends AITestClass { private readonly MAX_RAW_SIZE = 181; - private readonly MAX_BUNDLE_SIZE = 181; + private readonly MAX_BUNDLE_SIZE = 182; private readonly MAX_RAW_DEFLATE_SIZE = 74; private readonly MAX_BUNDLE_DEFLATE_SIZE = 74; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; From a2315b2af6dfd313e9e06ac71326334c18308575 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:33:41 -0700 Subject: [PATCH 34/51] Add dynamic SDK Stats sampling Sample each generated network SDK Stats metric using the live dynamic configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b6ab3bc2-aaf3-4801-ace2-68a4f63b80ea --- .../Unit/src/ai/InternalSdkStats.Tests.ts | 65 +++++++++++++++++++ .../src/core/InternalSdkStats.ts | 34 +++++++--- .../src/interfaces/ai/IInternalSdkStats.ts | 10 +++ 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 1085b0a28..1712db0bc 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -520,6 +520,71 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: sampling percentage dynamically controls generated telemetry", + useFakeTimers: true, + test: () => { + this._core.config.stats.samplingPercentage = 0; + this.clock.tick(1); + this._initStatsMgr(); + + let state = { + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }; + + Assert.equal(true, this._statsMgr.enabled, "A zero sampling percentage should not disable collection"); + let internalSdkStats = this._statsMgr.newInst(state); + Assert.ok(internalSdkStats, "SDK Stats should still be collected at zero percent"); + internalSdkStats.countException(state.endpoint, "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(0, this._trackSpy.callCount, "A zero sampling percentage should drop generated SDK Stats"); + Assert.equal(0, this._statsCoreConfigs.length, "No isolated core should be needed when every item is sampled out"); + + this._core.config.stats.samplingPercentage = 100; + this.clock.tick(1); + + internalSdkStats.countException(state.endpoint, "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.ok(this._trackSpy.called, "A dynamic sampling change should allow generated SDK Stats"); + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "SDK Stats should include the configured sampling percentage"); + + let trackCount = this._trackSpy.callCount; + this._core.config.stats.samplingPercentage = 0; + this.clock.tick(1); + internalSdkStats.countException(state.endpoint, "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(true, this._statsMgr.enabled, "Sampling changes should not disable collection"); + Assert.equal(trackCount, this._trackSpy.callCount, "A dynamic zero sampling percentage should drop generated SDK Stats"); + } + }); + + this.testCase({ + name: "SDK Stats: invalid sampling percentage defaults to 100 percent", + useFakeTimers: true, + test: () => { + this._core.config.stats.samplingPercentage = 101; + this.clock.tick(1); + this._initStatsMgr(); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "Invalid sampling percentages should use the default"); + } + }); + this.testCase({ name: "SDK Stats: recreates the isolated core when its configuration changes", useFakeTimers: true, diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index aeccb47da..24cfa29b3 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -3,11 +3,11 @@ import { doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrIndexOf, isNumber, isString, objCreate, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, strIndexOf, - strLower, strStartsWith, strSubstring, utcNow + ITimerHandler, arrIndexOf, isNumber, isString, mathRandom, objCreate, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, + strIndexOf, strLower, strStartsWith, strSubstring, utcNow } from "@nevware21/ts-utils"; import { onConfigChange } from "../config/DynamicConfig"; -import { DEFAULT_BREEZE_PATH, DisabledPropertyName } from "../constants/Constants"; +import { DEFAULT_BREEZE_PATH, DisabledPropertyName, SampleRate } from "../constants/Constants"; import { STR_EMPTY } from "../constants/InternalConstants"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; @@ -531,6 +531,7 @@ export function createStatsMgr(): IStatsMgr { let _core: IAppInsightsCore; // The customer core observed for configuration and endpoint changes let _createStatsCore: CreateStatsCoreFn; let _statsCore: IAppInsightsCore; + let _sampleRate = 100; let _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; let _statsIKey: string; @@ -589,6 +590,7 @@ export function createStatsMgr(): IStatsMgr { _statsCfgFetchFn = null; _statsIKey = null; _statsCfgUrl = null; + _sampleRate = 100; if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and // can be overridden via the CDN / dynamic config or by the SKU. @@ -598,7 +600,6 @@ export function createStatsMgr(): IStatsMgr { // instead of holding the config object and repeatedly reading its properties. let statsCfg = details.cfg.stats; if (statsCfg) { - _isMgrEnabled = true; // Make the override fetch fn a dynamic property before snapshotting it so a later // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); @@ -606,6 +607,11 @@ export function createStatsMgr(): IStatsMgr { // Same for the config url, it is normally delivered by the CDN configuration after // initialization has completed, so this handler must re-run when it arrives. _statsCfgUrl = details.set(statsCfg, "cfgUrl", statsCfg.cfgUrl); + let sampleRate = details.set(statsCfg, "samplingPercentage", statsCfg.samplingPercentage); + if (isNumber(sampleRate) && sampleRate >= 0 && sampleRate <= 100) { + _sampleRate = sampleRate; + } + _isMgrEnabled = true; _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; // Reset to the default in-case the config is removed / changed if (isNumber(statsCfg.shrtInt) && statsCfg.shrtInt > 0) { _shortInterval = statsCfg.shrtInt * 1000; // Convert to milliseconds @@ -696,13 +702,21 @@ export function createStatsMgr(): IStatsMgr { return false; } - let url = _buildStatsEndpoint(cfgResult.url); - let statsCore = _getStatsCore(url); - if (!statsCore) { - return null; - } + let statsCore: IAppInsightsCore; + if (!internalSdkStatsEvent && _sampleRate > 0) { + // Validate the pipeline before the counters are reset. At 0% no pipeline is required. + statsCore = _getStatsCore(_buildStatsEndpoint(cfgResult.url)); + if (!statsCore) { + return null; + } + } else if (internalSdkStatsEvent && + (_sampleRate >= 100 || (_sampleRate > 0 && mathRandom() * 100 < _sampleRate))) { + statsCore = _getStatsCore(_buildStatsEndpoint(cfgResult.url)); + if (!statsCore) { + return null; + } - if (internalSdkStatsEvent) { + internalSdkStatsEvent[SampleRate] = _sampleRate; statsCore.track(internalSdkStatsEvent); } return true; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index 3d0299af6..98be213ee 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -92,6 +92,16 @@ export type InternalSdkStatsCfgFetchFn = (cfgUrl: string, oncomplete: (result: I * @since 3.3.7 */ export interface IInternalSdkStatsConfig { + /** + * Percentage of generated SDK Stats telemetry that is sent. Each generated SDK Stats item is + * sampled independently using the current dynamic value. Included telemetry is stamped with this + * value so the ingestion service can account for the sampling rate. + * + * Values must be between 0 and 100. Invalid values use the default. + * @default 100 + */ + samplingPercentage?: number; + /** * Collection interval in seconds. Counters persist in session storage across page loads. * Non-positive values are ignored and the default is used instead. From 634a52cd473ae432589cf24274e035d27f341f87 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 15:32:39 -0700 Subject: [PATCH 35/51] test: update core raw bundle size budget Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index 90ac1ad7c..d16e353ba 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 = 137; + private readonly MAX_RAW_SIZE = 138; private readonly MAX_BUNDLE_SIZE = 138; private readonly MAX_RAW_DEFLATE_SIZE = 56; private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; From 106c3b482fa5a14794ad388dbea7eb6225b93966 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 15:51:43 -0700 Subject: [PATCH 36/51] test: avoid advancing shared SDK stats clock Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/InternalSdkStats.tests.ts | 1 - 1 file changed, 1 deletion(-) 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 b8d8ac8ef..f47bf996c 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -157,7 +157,6 @@ export class InternalSdkStatsTests extends AITestClass { } catch (e) { QUnit.assert.ok(false, "Unexpected error during telemetry processing"); } - this.clock.tick(900000); // Simulate time passing for internalSdkStats to be sent } private assertInternalSdkStatsCall(statusCode: number) { From 5324caa2f71910e05cb935faf1b5ce6901986326 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 16:15:35 -0700 Subject: [PATCH 37/51] feat: default SDK stats sampling to 100 percent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Unit/src/ai/InternalSdkStats.Tests.ts | 23 +++++++++++++++++++ .../src/core/InternalSdkStats.ts | 14 +++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 1712db0bc..ea867b81f 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -520,6 +520,29 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: sampling percentage defaults to 100 percent", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + + Assert.equal(100, this._core.config.stats.samplingPercentage, + "The default should be applied to the dynamic SDK Stats config"); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(1, this._trackSpy.callCount, "The default should send every generated SDK Stats item"); + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "SDK Stats should include the default sampling percentage"); + } + }); + this.testCase({ name: "SDK Stats: sampling percentage dynamically controls generated telemetry", useFakeTimers: true, diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 24cfa29b3..16f02eeb8 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -15,7 +15,7 @@ import { IAppInsightsCore } from "../interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; import { - IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn + IInternalSdkStats, IInternalSdkStatsCfgResult, IInternalSdkStatsConfig, IInternalSdkStatsState, InternalSdkStatsCfgFetchFn } from "../interfaces/ai/IInternalSdkStats"; import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; import { CreateStatsCoreFn, IStatsMgr } from "../interfaces/ai/IStatsMgr"; @@ -30,6 +30,7 @@ import { utlGetSessionStorage, utlSetSessionStorage } from "../utils/StorageHelp /** Default collection interval in seconds; override with `stats.shrtInt`. */ const STATS_COLLECTION_INTERVAL_SECONDS = 3600; // 1 hour const STATS_LANGUAGE = "JavaScript"; +const STATS_SAMPLING_PERCENTAGE = 100; const STATS_TYPE = "Browser"; /** The host prefix added to the configured SDK Stats config url for EU data-boundary regions. */ @@ -531,7 +532,7 @@ export function createStatsMgr(): IStatsMgr { let _core: IAppInsightsCore; // The customer core observed for configuration and endpoint changes let _createStatsCore: CreateStatsCoreFn; let _statsCore: IAppInsightsCore; - let _sampleRate = 100; + let _sampleRate = STATS_SAMPLING_PERCENTAGE; let _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; let _statsIKey: string; @@ -590,7 +591,7 @@ export function createStatsMgr(): IStatsMgr { _statsCfgFetchFn = null; _statsIKey = null; _statsCfgUrl = null; - _sampleRate = 100; + _sampleRate = STATS_SAMPLING_PERCENTAGE; if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and // can be overridden via the CDN / dynamic config or by the SKU. @@ -600,6 +601,7 @@ export function createStatsMgr(): IStatsMgr { // instead of holding the config object and repeatedly reading its properties. let statsCfg = details.cfg.stats; if (statsCfg) { + details.setDf(statsCfg, _sdkStatsConfigDefaults); // Make the override fetch fn a dynamic property before snapshotting it so a later // merged (CDN / updateCfg) change to it re-runs this handler and refreshes the local. _statsCfgFetchFn = details.set(statsCfg, "overrideCfgFn", statsCfg.overrideCfgFn); @@ -781,9 +783,13 @@ export function createStatsMgr(): IStatsMgr { * opted-out using the `featureOptIn` configuration with the {@link STATS_SDK_FEATURE} name. */ const _sdkStatsDefaults: IConfigDefaults = { - // Seeding an (empty) stats object allows the manager to run; the config url, destination and + // Seeding a stats object allows the manager to apply its nested defaults; the config url, destination and // enabled state are all resolved from the dynamic / remote SDK Stats configuration. A plain // object (rather than cfgDfMerge) is used so setDf seeds and makes the stats property dynamic // without marking it as a reference (avoiding the in-place reference side effect). stats: {} }; + +const _sdkStatsConfigDefaults: IConfigDefaults = { + samplingPercentage: STATS_SAMPLING_PERCENTAGE +}; From c0f279e5edf286352ec78b043e172fca4a4ac742 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 17:06:52 -0700 Subject: [PATCH 38/51] fix: guard SDK stats persistence serialization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Unit/src/ai/InternalSdkStats.Tests.ts | 32 +++++++++++++++++++ .../src/core/InternalSdkStats.ts | 10 ++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index ea867b81f..204de89b3 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -818,6 +818,38 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: serialization failures do not break counter updates", + useFakeTimers: true, + test: () => { + this._initStatsMgr(); + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + let stringifyStub = this.sandbox.stub(JSON, "stringify").callsFake(() => { + throw new Error("Serialization failed"); + }); + let didThrow = false; + + try { + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + } catch (e) { + didThrow = true; + } + stringifyStub.restore(); + + Assert.equal(false, didThrow, "A serialization failure should not escape SDK Stats"); + Assert.equal(1, stringifyStub.callCount, "SDK Stats should attempt to persist the updated counters"); + + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + let stored = _readStatsStorage("Test-iKey", "https://example.endpoint.com"); + Assert.equal(2, stored.cnt.exception["NetworkError"], + "Counters updated before the serialization failure should be persisted by the next update"); + } + }); + this.testCase({ name: "SDK Stats: a new instance resumes the persisted counters and collection window", useFakeTimers: true, diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 16f02eeb8..890ef4701 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -289,9 +289,13 @@ function _loadStore(logger: IDiagnosticLogger, key: string): _IStatsStore { } function _saveStore(logger: IDiagnosticLogger, key: string, store: _IStatsStore) { - let json = getJSON(); - if (json) { - utlSetSessionStorage(logger, key, json.stringify(store)); + try { + let json = getJSON(); + if (json) { + utlSetSessionStorage(logger, key, json.stringify(store)); + } + } catch (e) { + // Persistence is best-effort; serialization can fail when built-in prototypes are modified. } } From 4fd4a1abd72701c8461f55b0c17ee2c766e5a4d4 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 17:09:10 -0700 Subject: [PATCH 39/51] docs: correct SDK stats API introduction versions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/interfaces/ai/IInternalSdkStats.ts | 10 +++++----- shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts index 98be213ee..76d89f4d2 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IInternalSdkStats.ts @@ -6,7 +6,7 @@ import { IPayloadData } from "./IXHROverride"; /** * The interface for the stats beat plugin, which is responsible for collecting and sending statistics about the SDK. * It is used to track the performance and usage of the SDK, and to identify any issues or errors that may occur. - * @since 3.3.7 + * @since 3.4.4 */ export interface IInternalSdkStats { /** @@ -39,7 +39,7 @@ export interface IInternalSdkStats { /** * The configuration passed to the stats beat plugin to record statistics about the SDK - * @since 3.3.7 + * @since 3.4.4 */ export interface IInternalSdkStatsState { /** @@ -61,7 +61,7 @@ export interface IInternalSdkStatsState { /** * The parsed result of the remote SDK Stats configuration (`cfg/v1.json`). It identifies whether * SDK Stats collection is currently enabled and the host that matching events should be sent to. - * @since 3.3.7 + * @since 3.4.4 */ export interface IInternalSdkStatsCfgResult { /** @@ -83,13 +83,13 @@ export interface IInternalSdkStatsCfgResult { * scenarios) via {@link IInternalSdkStatsConfig.overrideCfgFn}. * @param cfgUrl - The SDK Stats configuration URL (`cfg/v1.json`) to fetch. * @param oncomplete - The callback to invoke with the parsed configuration, or null on any failure. - * @since 3.3.7 + * @since 3.4.4 */ export type InternalSdkStatsCfgFetchFn = (cfgUrl: string, oncomplete: (result: IInternalSdkStatsCfgResult | null) => void) => void; /** * The configuration for the stats beat definition - * @since 3.3.7 + * @since 3.4.4 */ export interface IInternalSdkStatsConfig { /** diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index a01413d5c..8a1050bd2 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -11,7 +11,7 @@ import { IUnloadHook } from "./IUnloadHook"; * @param config - The SDK Stats configuration containing the resolved iKey and endpoint. * Implementations must handle creation errors and return null rather than throw. * @returns The initialized core instance, or null when an SDK Stats pipeline cannot be created. - * @since 3.3.7 + * @since 3.4.4 */ export type CreateStatsCoreFn = (config: IConfiguration) => IAppInsightsCore | null; From 78452823dd898c77082b296f567445ec0df6f544 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 17:15:36 -0700 Subject: [PATCH 40/51] test: use SDK stats test endpoint Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/InternalSdkStats.tests.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 f47bf996c..b3c14b889 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -8,6 +8,9 @@ import { SinonSpy, SinonStub } from "sinon"; import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; +const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_HOST = "tst-data.stats.monitor.azure.com"; + function _clearStatsStorage() { try { let storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; @@ -94,13 +97,13 @@ export class InternalSdkStatsTests extends AITestClass { stats: { shrtInt: 900, // The config url gates collection, without it nothing is collected or sent - cfgUrl: "https://data.stats.monitor.azure.com/cfg/v1.json", + cfgUrl: STATS_TEST_CFG_URL, iKey: "Stats-Test-iKey", snp: "6", // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests // do not depend on a network fetch of the cfg/v1.json endpoint. overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { - oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + oncomplete({ enabled: true, url: STATS_TEST_HOST }); } }, extensionConfig: { [sender.identifier]: config } @@ -179,10 +182,10 @@ export class InternalSdkStatsTests extends AITestClass { }, stats: { shrtInt: 900, - cfgUrl: "https://data.stats.monitor.azure.com/cfg/v1.json", + cfgUrl: STATS_TEST_CFG_URL, iKey: "Stats-Test-iKey", overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { - oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + oncomplete({ enabled: true, url: STATS_TEST_HOST }); } } From 271a2338f66e38f900e91ed867ab4e43ebdd89f8 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 17:17:32 -0700 Subject: [PATCH 41/51] test: use SDK stats test host in core tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Unit/src/ai/InternalSdkStats.Tests.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 204de89b3..2c2806aea 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -12,7 +12,8 @@ import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes -const STATS_TEST_CFG_URL = "https://data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; +const STATS_TEST_HOST = "tst-data.stats.monitor.azure.com"; const STATS_TEST_IKEY = "Stats-Test-iKey"; function _clearStatsStorage() { try { @@ -80,7 +81,7 @@ export class InternalSdkStatsTests extends AITestClass { // Resolve the remote SDK Stats configuration synchronously (as enabled) so the tests // do not attempt a real network fetch of the cfg/v1.json endpoint. overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { - oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + oncomplete({ enabled: true, url: STATS_TEST_HOST }); } } }; @@ -372,7 +373,7 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal(0, this._rootTrackSpy.callCount, "SDK Stats should not use the customer core"); Assert.equal(STATS_TEST_IKEY, this._statsCoreConfigs[0].instrumentationKey, "The isolated core should use the SDK Stats instrumentation key"); - Assert.equal("https://data.stats.monitor.azure.com/v2/track", this._statsCoreConfigs[0].endpointUrl, + Assert.equal("https://" + STATS_TEST_HOST + "/v2/track", this._statsCoreConfigs[0].endpointUrl, "The isolated core should use the remote configured endpoint"); Assert.equal(1, this._statsCoreConfigs.length, "One isolated core should handle the SDK Stats batch"); @@ -389,7 +390,7 @@ export class InternalSdkStatsTests extends AITestClass { test: () => { // Override the remote SDK Stats configuration to report collection as disabled this._core.config.stats.overrideCfgFn = (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { - oncomplete({ enabled: false, url: "data.stats.monitor.azure.com" }); + oncomplete({ enabled: false, url: STATS_TEST_HOST }); }; this.clock.tick(1); // Allow the config change to propagate @@ -473,7 +474,7 @@ export class InternalSdkStatsTests extends AITestClass { let fetchedUrls: string[] = []; this._core.config.stats.overrideCfgFn = (cfgUrl, oncomplete) => { fetchedUrls.push(cfgUrl); - oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + oncomplete({ enabled: true, url: STATS_TEST_HOST }); }; this.clock.tick(1); @@ -485,7 +486,7 @@ export class InternalSdkStatsTests extends AITestClass { sdkVer: "1.0.0" }); - Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", fetchedUrls[0], + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", fetchedUrls[0], "The initial endpoint should use the configured EU url"); this._core.config.stats.cfgUrl = "https://next.stats.monitor.azure.com/cfg/v1.json"; @@ -745,7 +746,7 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal(1, _readStatsStorage("Test-iKey", "https://example.endpoint.com").cnt.exception["NetworkError"], "Unsent counters should remain persisted"); - completeFetch({ enabled: true, url: "data.stats.monitor.azure.com" }); + completeFetch({ enabled: true, url: STATS_TEST_HOST }); this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); Assert.ok(this._trackSpy.called, "Persisted counters should be sent on the next interval"); @@ -765,10 +766,10 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal(STATS_TEST_CFG_URL, getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), "A non-EU endpoint should use the configured url as-is"); - Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", getStatsCfgUrl("https://westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), "An EU endpoint should have the eu- prefix inserted in front of the host"); - Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", getStatsCfgUrl("https://westeurope-5.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), "An EU region replica endpoint should also resolve to the EU url"); @@ -777,13 +778,13 @@ export class InternalSdkStatsTests extends AITestClass { "norwaywest", "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "ukwest" ]; for (let lp = 0; lp < euRegions.length; lp++) { - Assert.equal("https://eu-data.stats.monitor.azure.com/cfg/v1.json", + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", getStatsCfgUrl("https://" + euRegions[lp] + ".in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), euRegions[lp] + " should resolve to the EU url"); } - Assert.equal("eu-data.stats.monitor.azure.com/cfg/v1.json", - getStatsCfgUrl("https://northeurope.in.applicationinsights.azure.com/", "data.stats.monitor.azure.com/cfg/v1.json"), + Assert.equal("eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("https://northeurope.in.applicationinsights.azure.com/", "tst-data.stats.monitor.azure.com/cfg/v1.json"), "A configured url without a scheme should still get the eu- prefix"); } }); @@ -950,7 +951,7 @@ export class InternalSdkStatsTests extends AITestClass { cfgUrl: STATS_TEST_CFG_URL, iKey: STATS_TEST_IKEY, overrideCfgFn: (_cfgUrl: string, oncomplete: (result: { enabled: boolean, url: string } | null) => void) => { - oncomplete({ enabled: true, url: "data.stats.monitor.azure.com" }); + oncomplete({ enabled: true, url: STATS_TEST_HOST }); } } } as IConfiguration, [new ChannelPlugin()]); From 66e2bcb5c972297a2fc6459f452f352d4bc08ca2 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 1 Sep 2026 18:34:30 -0700 Subject: [PATCH 42/51] feat: add dynamic feature throttling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts | 33 ++++++++++++- AISKU/src/AISku.ts | 29 +++++++---- docs/ThrottleMgr.md | 32 +++++++++++++ .../Unit/src/ai/InternalSdkStats.Tests.ts | 24 +++++++++- .../Tests/Unit/src/ai/ThrottleMgr.tests.ts | 44 +++++++++++++++++ .../src/core/InternalSdkStats.ts | 7 ++- .../src/diagnostics/ThrottleMgr.ts | 48 ++++++++++++++++++- shared/AppInsightsCore/src/index.ts | 2 +- .../src/interfaces/ai/IConfig.ts | 4 +- .../src/interfaces/ai/IStatsMgr.ts | 5 +- .../src/interfaces/ai/IThrottleMgr.ts | 11 ++++- 11 files changed, 218 insertions(+), 21 deletions(-) diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index a39963900..55aeba5cd 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -1,6 +1,6 @@ import { ApplicationInsights, IConfig, IConfiguration } from '../../../src/applicationinsights-web'; import { AITestClass, Assert } from '@microsoft/ai-test-framework'; -import { FeatureOptInMode, ISdkStatsNotifCbk, onConfigChange } from '@microsoft/applicationinsights-core-js'; +import { FeatureOptInMode, ISdkStatsNotifCbk, onConfigChange, STATS_SDK_FEATURE } from '@microsoft/applicationinsights-core-js'; import { AppInsightsSku } from '../../../src/AISku'; import { ICfgSyncMode } from '@microsoft/applicationinsights-cfgsync-js'; @@ -37,6 +37,7 @@ export class SdkStatsFeatureTests extends AITestClass { public registerTests() { this._testSdkStatsEnabledByDefault(); this._testSdkStatsDisabledViaFeatureOptIn(); + this._testCustomerSdkStatsIgnoresInternalThrottle(); this._testSdkStatsDynamicEnableDisable(); this._testSdkStatsConfigDefaults(); this._testSdkStatsDynamicConfigChanges(); @@ -133,6 +134,34 @@ export class SdkStatsFeatureTests extends AITestClass { }); } + private _testCustomerSdkStatsIgnoresInternalThrottle() { + this.testCase({ + name: "SdkStatsFeature: dedicated SDK Stats throttle does not disable customer SDK Stats", + useFakeTimers: true, + test: () => { + let ai = this._createAi({ + throttleMgrCfg: { + [STATS_SDK_FEATURE]: { + disabled: false, + limit: { + samplingRate: 0 + } + } + } + }); + this.clock.tick(1); + + Assert.ok(this._findSdkStatsListener(ai), + "Customer SDK Stats should remain enabled when dedicated SDK Stats are throttled"); + Assert.equal(null, ai["core"].getSdkStats({ + cKey: TestInstrumentationKey, + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }), "Dedicated SDK Stats should be disabled by its throttle"); + } + }); + } + private _testSdkStatsDynamicEnableDisable() { this.testCase({ name: "SdkStatsFeature: disabling SdkStats feature dynamically removes the listener", @@ -221,6 +250,8 @@ export class SdkStatsFeatureTests extends AITestClass { let config = ai.config; Assert.ok(config.sdkStats, "sdkStats config should exist after initialization"); Assert.equal(900000, config.sdkStats!.int, "int should default to 900000 (15 minutes)"); + Assert.equal(100, config.throttleMgrCfg![STATS_SDK_FEATURE].limit!.samplingRate, + "Dedicated SDK Stats throttle should use the legacy default sampling rate"); } }); diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 1dc08cd12..acaf4b918 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -13,9 +13,10 @@ import { IMetricTelemetry, INotificationManager, IOTelApi, IOTelSpanOptions, IPageViewPerformanceTelemetry, IPageViewTelemetry, IPlugin, IReadableSpan, IRequestHeaders, ISdkStatsNotifCbk, ISpanScope, ITelemetryContext as Common_ITelemetryContext, ITelemetryInitializerHandler, ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, IThrottleInterval, IThrottleLimit, - IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, PropertiesPluginIdentifier, ThrottleMgr, - UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, - cfgDfValidate, createDynamicConfig, createOTelApi, createProcessTelemetryContext, createSdkStatsNotifCbk, createStatsMgr, + IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, + PropertiesPluginIdentifier, STATS_SDK_FEATURE, ThrottleMgr, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, + addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, createDynamicConfig, createOTelApi, + createProcessTelemetryContext, createSdkStatsNotifCbk, createStatsMgr, createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, removePageHideEventListener, removePageUnloadEventListener, useSpan @@ -84,6 +85,14 @@ const default_throttle_config = { interval: cfgDfMerge(default_interval) } as IThrottleMgrConfig; +const sdk_stats_throttle_config = { + disabled: false, + limit: cfgDfMerge({ + samplingRate: 100, + maxSendNumber: 1 + }) +} as IThrottleMgrConfig; + // We need to include all properties that we only reference that we want to be dynamically updatable here // So they are converted even when not specified in the passed configuration const defaultConfigValues: IConfigDefaults = { @@ -102,12 +111,13 @@ const defaultConfigValues: IConfigDefaults = { sdkStats: cfgDfMerge({ int: 900000 }), - throttleMgrCfg: cfgDfMerge<{[key:number]: IThrottleMgrConfig}>( + throttleMgrCfg: cfgDfMerge<{[key: string]: IThrottleMgrConfig}>( { [_eInternalMessageId.DefaultThrottleMsgKey]:cfgDfMerge(default_throttle_config), [_eInternalMessageId.InstrumentationKeyDeprecation]:cfgDfMerge(default_throttle_config), [_eInternalMessageId.SdkLdrUpdate]:cfgDfMerge(default_throttle_config), - [_eInternalMessageId.CdnDeprecation]:cfgDfMerge(default_throttle_config) + [_eInternalMessageId.CdnDeprecation]:cfgDfMerge(default_throttle_config), + [STATS_SDK_FEATURE]: cfgDfMerge(sdk_stats_throttle_config) } ), extensionConfig: cfgDfMerge<{[key: string]: any}>({ @@ -398,6 +408,10 @@ export class AppInsightsSku implements IApplicationInsights properties.context }); - if (!_throttleMgr){ - _throttleMgr = new ThrottleMgr(_core); - } let sdkSrc = _findSdkSourceFile(); if (sdkSrc && _self.context) { _self.context.internal.sdkSrc = sdkSrc; diff --git a/docs/ThrottleMgr.md b/docs/ThrottleMgr.md index b0e68f8fb..74e619a14 100644 --- a/docs/ThrottleMgr.md +++ b/docs/ThrottleMgr.md @@ -84,3 +84,35 @@ throttleMgr.isTriggered(instrumentationKeyDeprecation); throttleMgr.sendMessage(instrumentationKeyDeprecation, "Instrumentation Key Deprecation Message"); ``` + +### Throttle Feature Rollout + +`canUseFeature()` combines `featureOptIn` with an optional throttle configuration that uses the same +string key. It does not send an internal message. The sampling decision remains stable until the +configured sampling rate changes. + +When the named throttle configuration is missing or has `disabled: true`, only the `featureOptIn` +state is used. Set `disabled: false` to apply `limit.samplingRate`, where `1000000` is 100%. + +```javascript +let coreCfg = { + featureOptIn: { + sdkStats: { mode: 3 } + }, + throttleMgrCfg: { + sdkStats: { + disabled: false, + limit: { + samplingRate: 1000000 + } + } + } +}; + +core.initialize(coreCfg, [sender]); +const throttleMgr = new ThrottleMgr(core); + +if (throttleMgr.canUseFeature("sdkStats", true)) { + // Initialize the feature. +} +``` diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 2c2806aea..7f1265f25 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -126,8 +126,12 @@ export class InternalSdkStatsTests extends AITestClass { return core; } - private _initStatsMgr(core: IAppInsightsCore = this._core, featureName: string = "InternalSdkStats") { - return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName); + private _initStatsMgr( + core: IAppInsightsCore = this._core, + featureName: string = "InternalSdkStats", + canUseFeature?: (feature: string, sdkDefaultState?: boolean) => boolean + ) { + return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName, canUseFeature); } public registerTests() { @@ -521,6 +525,22 @@ export class InternalSdkStatsTests extends AITestClass { } }); + this.testCase({ + name: "SDK Stats: manager honors the SKU feature throttle", + test: () => { + let featureChecks = 0; + this._initStatsMgr(this._core, "InternalSdkStats", (feature, sdkDefaultState) => { + featureChecks++; + Assert.equal("InternalSdkStats", feature, "The configured feature name should be checked"); + Assert.equal(true, sdkDefaultState, "SDK Stats should remain enabled by default"); + return false; + }); + + Assert.equal(false, this._statsMgr.enabled, "The manager should be disabled when the SKU throttles the feature"); + Assert.equal(1, featureChecks, "The SKU feature throttle should be checked during initialization"); + } + }); + this.testCase({ name: "SDK Stats: sampling percentage defaults to 100 percent", useFakeTimers: true, diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts index 8a0da73dd..6f4817e58 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts @@ -3,6 +3,7 @@ import { AppInsightsCore } from "../../../../src/core/AppInsightsCore"; import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; import { IConfiguration } from "../../../../src/interfaces/ai/IConfiguration"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; +import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; import { _eInternalMessageId } from "../../../../src/enums/ai/LoggingEnums"; import { ThrottleMgr } from "../../../../src/diagnostics/ThrottleMgr"; import { SinonSpy } from "sinon"; @@ -110,6 +111,49 @@ export class ThrottleMgrTest extends AITestClass { public registerTests() { + this.testCase({ + name: "ThrottleMgrTest: canUseFeature combines feature opt-in with stable dynamic throttling", + useFakeTimers: true, + test: () => { + let feature = this._msgId + "Feature"; + let coreCfg = { + instrumentationKey: "test", + featureOptIn: { + [feature]: { mode: FeatureOptInMode.enable } + }, + throttleMgrCfg: { + [feature]: { + disabled: false, + limit: { + samplingRate: 0 + } + } + } + } as IConfiguration & IConfig; + this._core.initialize(coreCfg, [this._channel]); + + let throttleMgr = new ThrottleMgr(this._core); + Assert.equal(undefined, throttleMgr.getConfig()[this._msgId], + "A feature key that begins with digits should not configure an internal message"); + Assert.equal(false, throttleMgr.canUseFeature(feature, true), "A zero sampling rate should throttle the feature"); + Assert.equal(false, throttleMgr.canUseFeature(feature, true), "Repeated checks should retain the sampling decision"); + + this._core.config.throttleMgrCfg[feature].limit.samplingRate = 1000000; + this.clock.tick(1); + Assert.equal(true, throttleMgr.canUseFeature(feature, true), "A 100 percent sampling rate should allow the feature"); + + this._core.config.featureOptIn[feature].mode = FeatureOptInMode.disable; + this.clock.tick(1); + Assert.equal(false, throttleMgr.canUseFeature(feature, true), "Feature opt-out should take precedence over throttling"); + + this._core.config.featureOptIn[feature].mode = FeatureOptInMode.enable; + this._core.config.throttleMgrCfg[feature].disabled = true; + this.clock.tick(1); + Assert.equal(true, throttleMgr.canUseFeature(feature, true), "A disabled throttle should leave the feature enabled"); + Assert.equal(0, this.loggingSpy.callCount, "Feature checks should not emit internal messages"); + } + }); + this.testCase({ name: "ThrottleMgrTest: Default config should be set from root", test: () => { diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index 890ef4701..e06138c33 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -19,6 +19,7 @@ import { } from "../interfaces/ai/IInternalSdkStats"; import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; import { CreateStatsCoreFn, IStatsMgr } from "../interfaces/ai/IStatsMgr"; +import { CanUseFeatureFn } from "../interfaces/ai/IThrottleMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; @@ -573,7 +574,7 @@ export function createStatsMgr(): IStatsMgr { // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again function _init( - core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string + core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string, canUseFeature?: CanUseFeatureFn ) { if (_core) { // If the core is already set, then just return with an empty unload hook @@ -596,7 +597,9 @@ export function createStatsMgr(): IStatsMgr { _statsIKey = null; _statsCfgUrl = null; _sampleRate = STATS_SAMPLING_PERCENTAGE; - if (isFeatureEnabled(featureName || STATS_SDK_FEATURE, details.cfg, true) === true) { + let statsFeature = featureName || STATS_SDK_FEATURE; + let isEnabled = canUseFeature ? canUseFeature(statsFeature, true) : isFeatureEnabled(statsFeature, details.cfg, true) === true; + if (isEnabled) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and // can be overridden via the CDN / dynamic config or by the SKU. details.setDf(details.cfg, _sdkStatsDefaults); diff --git a/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts b/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts index 4f669fd4b..78cf040f7 100644 --- a/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts +++ b/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts @@ -10,6 +10,7 @@ import { IConfig } from "../interfaces/ai/IConfig"; import { IConfiguration } from "../interfaces/ai/IConfiguration"; import { IDiagnosticLogger } from "../interfaces/ai/IDiagnosticLogger"; import { IThrottleInterval, IThrottleLocalStorageObj, IThrottleMgrConfig, IThrottleResult } from "../interfaces/ai/IThrottleMgr"; +import { isFeatureEnabled } from "../utils/HelperFuncs"; import { randomValue } from "../utils/RandomHelper"; import { utlCanUseLocalStorage, utlGetLocalStorage, utlSetLocalStorage } from "../utils/StorageHelperFuncs"; @@ -23,6 +24,7 @@ interface SendMsgParameter { export class ThrottleMgr { public canThrottle: (msgId: _eInternalMessageId | number) => boolean; + public canUseFeature: (feature: string, sdkDefaultState?: boolean) => boolean; public sendMessage: (msgId: _eInternalMessageId, message: string, severity?: eLoggingSeverity) => IThrottleResult | null; public getConfig: () => IThrottleMgrConfig; public isTriggered: (msgId: _eInternalMessageId | number) => boolean; // this function is to get previous triggered status @@ -39,6 +41,8 @@ export class ThrottleMgr { let _config: {[msgKey: number]: IThrottleMgrConfig}; let _localStorageObj: {[msgKey: number]: IThrottleLocalStorageObj | null | undefined}; let _isTriggered: {[msgKey: number]: boolean}; //_isTriggered is to make sure that we only trigger throttle once a day + let _featureSampleRates: {[feature: string]: number}; + let _featureSamples: {[feature: string]: boolean}; let _namePrefix: string; let _queue: {[msgKey: number]: Array}; let _isReady: boolean = false; @@ -67,6 +71,39 @@ export class ThrottleMgr { return _canThrottle(cfg, _canUseLocalStorage, localObj); } + /** + * Check whether a feature is enabled and sampled in by its named throttle configuration. + * A missing or disabled throttle configuration leaves the feature opt-in decision unchanged. + * The sampling decision is cached until the configured sampling rate changes. + * @param feature - The featureOptIn and throttleMgrCfg key. + * @param sdkDefaultState - The default state when featureOptIn does not define the feature. + * @returns true when the feature is enabled and allowed by its throttle configuration. + */ + _self.canUseFeature = (feature: string, sdkDefaultState?: boolean): boolean => { + if (!feature || isFeatureEnabled(feature, core.config, sdkDefaultState) !== true) { + return false; + } + + let coreConfig = core.config as IConfiguration & IConfig; + let throttleConfig = coreConfig.throttleMgrCfg && coreConfig.throttleMgrCfg[feature]; + if (!throttleConfig || throttleConfig.disabled) { + return true; + } + + let limit = throttleConfig.limit; + let samplingRate = limit && limit.samplingRate; + if (isNullOrUndefined(samplingRate)) { + samplingRate = 100; + } + + if (_featureSampleRates[feature] !== samplingRate) { + _featureSampleRates[feature] = samplingRate; + _featureSamples[feature] = _isSampledIn(samplingRate); + } + + return _featureSamples[feature]; + } + /** * Check if throttle is triggered on current day(UTC) * if canThrottle returns false, isTriggered will return false @@ -199,6 +236,8 @@ export class ThrottleMgr { function _initConfig() { _logger = safeGetLogger(core); _isTriggered = {}; + _featureSampleRates = {}; + _featureSamples = {}; _localStorageObj = {}; _queue = {}; _config = {}; @@ -211,7 +250,10 @@ export class ThrottleMgr { let configMgr = coreConfig.throttleMgrCfg || {}; objForEachKey(configMgr, (key, cfg) => { - _setCfgByKey(parseInt(key), cfg) + let msgId = parseInt(key); + if (msgId + "" === key) { + _setCfgByKey(msgId, cfg); + } }); })); @@ -393,6 +435,10 @@ export class ThrottleMgr { return false; } + function _isSampledIn(samplingRate: number) { + return samplingRate >= 1000000 || (samplingRate > 0 && randomValue(999999) < samplingRate); + } + function _getLocalStorageObjByKey(key: _eInternalMessageId | number) { try { let curObj = _localStorageObj[key]; diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index 45fcbcdbd..bad79ac75 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -360,7 +360,7 @@ export { // Throttle manager interfaces export { - IThrottleLimit, IThrottleInterval, IThrottleMgrConfig, + CanUseFeatureFn, IThrottleLimit, IThrottleInterval, IThrottleMgrConfig, IThrottleLocalStorageObj, IThrottleResult } from "./interfaces/ai/IThrottleMgr"; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts b/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts index 3e643f147..83e1154e0 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts @@ -382,9 +382,9 @@ export interface IConfig { addIntEndpoints?: boolean; /** - * [Optional] Sets throttle mgr configuration by key + * [Optional] Sets throttle manager configuration by numeric message ID or string feature name. */ - throttleMgrCfg?: {[key: number]: IThrottleMgrConfig}; + throttleMgrCfg?: {[key: string]: IThrottleMgrConfig}; /** * [Optional] Specifies a Highest Priority custom endpoint URL where telemetry data will be sent. diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index 8a1050bd2..e2133126e 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -4,6 +4,7 @@ import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; +import { CanUseFeatureFn } from "./IThrottleMgr"; import { IUnloadHook } from "./IUnloadHook"; /** @@ -38,6 +39,7 @@ export interface IStatsMgr { * @param featureName - The optional featureOptIn name used to gate the manager. Defaults to the * SDK Stats feature (`STATS_SDK_FEATURE`) which is enabled by default and can be opted-out via the * `featureOptIn` configuration. + * @param canUseFeature - Optional combined feature opt-in and throttling check supplied by the SKU. * @returns The unload hook for the stats beat manager, which can be used to unload * and disable the manager. This may return null if the manager cannot be initialized. * @remarks This method should be called only once, and it may throw an error if called multiple times. @@ -45,7 +47,8 @@ export interface IStatsMgr { init: ( core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, - featureName?: string + featureName?: string, + canUseFeature?: CanUseFeatureFn ) => IUnloadHook | null; /** diff --git a/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts index 12a836235..363c87b2b 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts @@ -51,8 +51,9 @@ export interface IThrottleInterval { export interface IThrottleMgrConfig { /** - * Identifies if throttle is disabled - * Default: false + * Identifies if throttling is disabled. + * For messages, no messages are sent. For feature checks, the featureOptIn state is used without sampling. + * Default: false */ disabled?: boolean; @@ -105,3 +106,9 @@ export interface IThrottleResult { */ throttleNum: number; } + +/** + * Determines whether a dynamically configured feature may be used. + * @since 3.4.4 + */ +export type CanUseFeatureFn = (feature: string, sdkDefaultState?: boolean) => boolean; From 58984f57db1fe524ff20617b1f1779eff38bd973 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 2 Sep 2026 14:26:40 -0700 Subject: [PATCH 43/51] fix: persist SDK stats feature throttling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts | 4 +- AISKU/src/AISku.ts | 15 +- docs/ThrottleMgr.md | 17 +- .../Unit/src/ai/InternalSdkStats.Tests.ts | 63 ++++- .../Tests/Unit/src/ai/ThrottleMgr.tests.ts | 105 +++++++- .../src/core/InternalSdkStats.ts | 42 +-- .../src/diagnostics/ThrottleMgr.ts | 244 ++++++++++-------- shared/AppInsightsCore/src/index.ts | 2 +- .../src/interfaces/ai/IStatsMgr.ts | 6 +- .../src/interfaces/ai/IThrottleMgr.ts | 6 +- 10 files changed, 328 insertions(+), 176 deletions(-) diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index 55aeba5cd..ce357b128 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -153,11 +153,11 @@ export class SdkStatsFeatureTests extends AITestClass { Assert.ok(this._findSdkStatsListener(ai), "Customer SDK Stats should remain enabled when dedicated SDK Stats are throttled"); - Assert.equal(null, ai["core"].getSdkStats({ + Assert.ok(ai["core"].getSdkStats({ cKey: TestInstrumentationKey, endpoint: "https://example.endpoint.com", sdkVer: "1.0.0" - }), "Dedicated SDK Stats should be disabled by its throttle"); + }), "The throttle should apply to SDK Stats operations without disabling the manager"); } }); } diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index acaf4b918..c7bea99e2 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -13,13 +13,12 @@ import { IMetricTelemetry, INotificationManager, IOTelApi, IOTelSpanOptions, IPageViewPerformanceTelemetry, IPageViewTelemetry, IPlugin, IReadableSpan, IRequestHeaders, ISdkStatsNotifCbk, ISpanScope, ITelemetryContext as Common_ITelemetryContext, ITelemetryInitializerHandler, ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, IThrottleInterval, IThrottleLimit, - IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, - PropertiesPluginIdentifier, STATS_SDK_FEATURE, ThrottleMgr, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, - addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, createDynamicConfig, createOTelApi, - createProcessTelemetryContext, createSdkStatsNotifCbk, createStatsMgr, - createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, - isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, parseConnectionString, proxyAssign, proxyFunctions, - removePageHideEventListener, removePageUnloadEventListener, useSpan + IThrottleMgrConfig, ITraceApi, ITraceProvider, ITraceTelemetry, IUnloadHook, OTelTimeInput, PropertiesPluginIdentifier, + STATS_SDK_FEATURE, ThrottleMgr, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, addPageHideEventListener, + addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, createDynamicConfig, createOTelApi, createProcessTelemetryContext, + createSdkStatsNotifCbk, createStatsMgr, createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, + isArray, isFeatureEnabled, isFunction, isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, + parseConnectionString, proxyAssign, proxyFunctions, removePageHideEventListener, removePageUnloadEventListener, useSpan } from "@microsoft/applicationinsights-core-js"; import { AjaxPlugin as DependenciesPlugin, DependencyInitializerFunction, DependencyListenerFunction, IDependencyInitializerHandler, @@ -431,7 +430,7 @@ export class AppInsightsSku implements IApplicationInsights { + // Perform the feature operation. +}, true); ``` diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 7f1265f25..6314ec73b 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -10,6 +10,7 @@ import { ITelemetryItem } from "../../../../src/interfaces/ai/ITelemetryItem"; import { IPlugin } from "../../../../src/interfaces/ai/ITelemetryPlugin"; import { IAppInsightsCore } from "../../../../src/interfaces/ai/IAppInsightsCore"; import { FeatureOptInMode } from "../../../../src/enums/ai/FeatureOptInEnums"; +import { UseFeatureFn } from "../../../../src/interfaces/ai/IThrottleMgr"; const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; @@ -129,9 +130,9 @@ export class InternalSdkStatsTests extends AITestClass { private _initStatsMgr( core: IAppInsightsCore = this._core, featureName: string = "InternalSdkStats", - canUseFeature?: (feature: string, sdkDefaultState?: boolean) => boolean + useFeature?: UseFeatureFn ) { - return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName, canUseFeature); + return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName, useFeature); } public registerTests() { @@ -526,18 +527,62 @@ export class InternalSdkStatsTests extends AITestClass { }); this.testCase({ - name: "SDK Stats: manager honors the SKU feature throttle", + name: "SDK Stats: manager delegates generated events to the SKU feature throttle", + useFakeTimers: true, test: () => { - let featureChecks = 0; - this._initStatsMgr(this._core, "InternalSdkStats", (feature, sdkDefaultState) => { - featureChecks++; + let featureUses = 0; + this._initStatsMgr(this._core, "InternalSdkStats", (feature, callback, sdkDefaultState) => { + featureUses++; Assert.equal("InternalSdkStats", feature, "The configured feature name should be checked"); Assert.equal(true, sdkDefaultState, "SDK Stats should remain enabled by default"); - return false; + callback(); + return { + isThrottled: true, + throttleNum: 1 + }; + }); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" + }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + + Assert.equal(true, this._statsMgr.enabled, "The feature throttle should not disable dynamic manager configuration"); + Assert.ok(featureUses > 0, "Generated SDK Stats events should use the feature throttle"); + Assert.ok(this._trackSpy.called, "The feature callback should perform the SDK Stats operation"); + } + }); + + this.testCase({ + name: "SDK Stats: queued feature operations retain their sampling percentage", + useFakeTimers: true, + test: () => { + let featureCallback: () => void; + this._initStatsMgr(this._core, "InternalSdkStats", (_feature, callback) => { + featureCallback = callback; + return null; + }); + + let internalSdkStats = this._statsMgr.newInst({ + cKey: "Test-iKey", + endpoint: "https://example.endpoint.com", + sdkVer: "1.0.0" }); + internalSdkStats.countException("https://example.endpoint.com", "NetworkError"); + this.clock.tick(STATS_COLLECTION_SHORT_INTERVAL * 1000 + 1); + Assert.ok(featureCallback, "The generated event should be queued by the feature throttle"); + Assert.equal(0, this._trackSpy.callCount, "The queued SDK Stats event should not be sent early"); - Assert.equal(false, this._statsMgr.enabled, "The manager should be disabled when the SKU throttles the feature"); - Assert.equal(1, featureChecks, "The SKU feature throttle should be checked during initialization"); + this._core.config.stats.samplingPercentage = 1; + this.clock.tick(1); + featureCallback(); + + Assert.equal(1, this._trackSpy.callCount, "The feature callback should send the queued event"); + Assert.equal(100, this._trackSpy.firstCall.args[0].sampleRate, + "The queued event should retain the sampling percentage used for its sampling decision"); } }); diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts index 6f4817e58..9d9c3a612 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/ThrottleMgr.tests.ts @@ -112,10 +112,16 @@ export class ThrottleMgrTest extends AITestClass { public registerTests() { this.testCase({ - name: "ThrottleMgrTest: canUseFeature combines feature opt-in with stable dynamic throttling", + name: "ThrottleMgrTest: useFeature queues callbacks and uses persisted feature throttling", useFakeTimers: true, test: () => { let feature = this._msgId + "Feature"; + let featureStorageName = "appInsightsThrottle-feature-" + feature; + let date = new Date(); + window.localStorage[featureStorageName] = JSON.stringify({ + date: date, + count: 5 + }); let coreCfg = { instrumentationKey: "test", featureOptIn: { @@ -125,7 +131,11 @@ export class ThrottleMgrTest extends AITestClass { [feature]: { disabled: false, limit: { - samplingRate: 0 + samplingRate: 1000000, + maxSendNumber: 2 + }, + interval: { + dayInterval: 1 } } } @@ -135,25 +145,91 @@ export class ThrottleMgrTest extends AITestClass { let throttleMgr = new ThrottleMgr(this._core); Assert.equal(undefined, throttleMgr.getConfig()[this._msgId], "A feature key that begins with digits should not configure an internal message"); - Assert.equal(false, throttleMgr.canUseFeature(feature, true), "A zero sampling rate should throttle the feature"); - Assert.equal(false, throttleMgr.canUseFeature(feature, true), "Repeated checks should retain the sampling decision"); - - this._core.config.throttleMgrCfg[feature].limit.samplingRate = 1000000; - this.clock.tick(1); - Assert.equal(true, throttleMgr.canUseFeature(feature, true), "A 100 percent sampling rate should allow the feature"); + let featureUses = 0; + Assert.equal(null, throttleMgr.useFeature(feature, () => { + featureUses++; + }, true), "The feature operation should be queued until the throttle manager is ready"); + Assert.equal(0, featureUses, "The queued callback should not run early"); + + Assert.equal(true, throttleMgr.onReadyState(true), "The queued feature operation should be flushed"); + Assert.equal(2, featureUses, "The persisted count and maxSendNumber should control callback invocations"); + let storedFeature = JSON.parse(window.localStorage[featureStorageName]); + Assert.equal(0, storedFeature.count, "The persisted feature count should reset after triggering"); + compareDates(date, storedFeature.date); + compareDates(date, storedFeature.preTriggerDate); + Assert.equal(undefined, window.localStorage["appInsightsThrottle-" + feature], + "Feature state should use a separate storage key from numeric messages"); + + let result = throttleMgr.useFeature(feature, () => { + featureUses++; + }, true); + Assert.deepEqual({ + isThrottled: false, + throttleNum: 0 + }, result, "The feature operation should only trigger once per day"); + Assert.equal(2, featureUses, "A second feature callback should not run on the same day"); + storedFeature = JSON.parse(window.localStorage[featureStorageName]); + Assert.equal(1, storedFeature.count, "Suppressed feature operations should be persisted"); this._core.config.featureOptIn[feature].mode = FeatureOptInMode.disable; this.clock.tick(1); - Assert.equal(false, throttleMgr.canUseFeature(feature, true), "Feature opt-out should take precedence over throttling"); + Assert.equal(null, throttleMgr.useFeature(feature, () => { + featureUses++; + }, true), "Feature opt-out should take precedence over throttling"); this._core.config.featureOptIn[feature].mode = FeatureOptInMode.enable; this._core.config.throttleMgrCfg[feature].disabled = true; this.clock.tick(1); - Assert.equal(true, throttleMgr.canUseFeature(feature, true), "A disabled throttle should leave the feature enabled"); + result = throttleMgr.useFeature(feature, () => { + featureUses++; + }, true); + Assert.deepEqual({ + isThrottled: false, + throttleNum: 0 + }, result, "A disabled throttle should not invoke the feature operation"); + Assert.equal(2, featureUses, "A disabled throttle should suppress feature callbacks"); Assert.equal(0, this.loggingSpy.callCount, "Feature checks should not emit internal messages"); } }); + this.testCase({ + name: "ThrottleMgrTest: queued feature callbacks use the latest dynamic throttle config", + useFakeTimers: true, + test: () => { + let feature = "dynamicFeature"; + this._core.initialize({ + instrumentationKey: "test", + featureOptIn: { + [feature]: { mode: FeatureOptInMode.enable } + }, + throttleMgrCfg: {} + } as IConfiguration & IConfig, [this._channel]); + + let throttleMgr = new ThrottleMgr(this._core); + let featureUses = 0; + throttleMgr.useFeature(feature, () => { + featureUses++; + }, true); + + this._core.updateCfg({ + throttleMgrCfg: { + [feature]: { + disabled: false, + limit: { + samplingRate: 0 + }, + interval: { + dayInterval: 1 + } + } + } + }); + this.clock.tick(1); + Assert.equal(true, throttleMgr.onReadyState(true), "The feature callback should be removed from the queue"); + Assert.equal(0, featureUses, "The latest zero sampling rate should suppress the queued callback"); + } + }); + this.testCase({ name: "ThrottleMgrTest: Default config should be set from root", test: () => { @@ -444,7 +520,8 @@ export class ThrottleMgrTest extends AITestClass { Assert.ok(target && target.length === 1, "target should contain queue"); let queue = target[0][this._msgKey]; Assert.deepEqual(queue.length,1, "should have 1 item"); - Assert.equal(queue[0].msgID, this._msgId, "should be correct msgId"); + Assert.equal(queue[0].key, this._msgId, "should be correct msgId"); + Assert.equal("function", typeof queue[0].callback, "should queue a callback"); throttleMgr.onReadyState(true); target = throttleMgr["_getDbgPlgTargets"](); @@ -507,10 +584,12 @@ export class ThrottleMgrTest extends AITestClass { Assert.ok(target && target.length === 1, "target should contain queue"); let queue = target[0][this._msgKey]; Assert.deepEqual(queue.length,1, "should have 1 item"); - Assert.equal(queue[0].msgID, this._msgId, "should be correct msgId"); + Assert.equal(queue[0].key, this._msgId, "should be correct msgId"); + Assert.equal("function", typeof queue[0].callback, "should queue a callback"); queue = target[0][msgId]; Assert.deepEqual(queue.length,1, "should have 1 item test1"); - Assert.equal(queue[0].msgID, msgId, "should be correct msgId test1"); + Assert.equal(queue[0].key, msgId, "should be correct msgId test1"); + Assert.equal("function", typeof queue[0].callback, "should queue a callback test1"); throttleMgr.onReadyState(true); target = throttleMgr["_getDbgPlgTargets"](); diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index e06138c33..f508ef7a4 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -19,8 +19,8 @@ import { } from "../interfaces/ai/IInternalSdkStats"; import { IInternalSdkStatsNetwork } from "../interfaces/ai/IInternalSdkStatsNetwork"; import { CreateStatsCoreFn, IStatsMgr } from "../interfaces/ai/IStatsMgr"; -import { CanUseFeatureFn } from "../interfaces/ai/IThrottleMgr"; import { ITelemetryItem } from "../interfaces/ai/ITelemetryItem"; +import { UseFeatureFn } from "../interfaces/ai/IThrottleMgr"; import { IPayloadData } from "../interfaces/ai/IXHROverride"; import { IConfigDefaults } from "../interfaces/config/IConfigDefaults"; import { MetricDataType } from "../telemetry/ai/DataTypes"; @@ -536,7 +536,9 @@ export function createStatsMgr(): IStatsMgr { let _isMgrEnabled: boolean = false; // Flag to check if internalSdkStats is enabled or not let _core: IAppInsightsCore; // The customer core observed for configuration and endpoint changes let _createStatsCore: CreateStatsCoreFn; + let _featureName: string; let _statsCore: IAppInsightsCore; + let _useFeature: UseFeatureFn; let _sampleRate = STATS_SAMPLING_PERCENTAGE; let _shortInterval = STATS_COLLECTION_INTERVAL_SECONDS * 1000; let _statsCfgFetchFn: InternalSdkStatsCfgFetchFn; @@ -574,7 +576,7 @@ export function createStatsMgr(): IStatsMgr { // Lazily initialize the manager and start listening for configuration changes // This is also required to handle "unloading" and then re-initializing again function _init( - core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string, canUseFeature?: CanUseFeatureFn + core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string, useFeature?: UseFeatureFn ) { if (_core) { // If the core is already set, then just return with an empty unload hook @@ -584,6 +586,8 @@ export function createStatsMgr(): IStatsMgr { _core = core; _createStatsCore = createStatsCore; + _featureName = featureName || STATS_SDK_FEATURE; + _useFeature = useFeature; // Start listening for configuration changes from the single global config, within a config // change handler. This supports the scenario where the config is changed after the manager // has been created (including CDN / dynamic config updates). @@ -597,9 +601,7 @@ export function createStatsMgr(): IStatsMgr { _statsIKey = null; _statsCfgUrl = null; _sampleRate = STATS_SAMPLING_PERCENTAGE; - let statsFeature = featureName || STATS_SDK_FEATURE; - let isEnabled = canUseFeature ? canUseFeature(statsFeature, true) : isFeatureEnabled(statsFeature, details.cfg, true) === true; - if (isEnabled) { + if (isFeatureEnabled(_featureName, details.cfg, true) === true) { // Seed the SDK Stats defaults into the single global config so they remain dynamic and // can be overridden via the CDN / dynamic config or by the SKU. details.setDf(details.cfg, _sdkStatsDefaults); @@ -648,6 +650,8 @@ export function createStatsMgr(): IStatsMgr { configHook.rm(); _unloadStatsCore(); _createStatsCore = null; + _featureName = null; + _useFeature = null; _core = null; } }; @@ -711,22 +715,30 @@ export function createStatsMgr(): IStatsMgr { return false; } - let statsCore: IAppInsightsCore; - if (!internalSdkStatsEvent && _sampleRate > 0) { + let sampleRate = _sampleRate; + if (!internalSdkStatsEvent && sampleRate > 0) { // Validate the pipeline before the counters are reset. At 0% no pipeline is required. - statsCore = _getStatsCore(_buildStatsEndpoint(cfgResult.url)); + let statsCore = _getStatsCore(_buildStatsEndpoint(cfgResult.url)); if (!statsCore) { return null; } } else if (internalSdkStatsEvent && - (_sampleRate >= 100 || (_sampleRate > 0 && mathRandom() * 100 < _sampleRate))) { - statsCore = _getStatsCore(_buildStatsEndpoint(cfgResult.url)); - if (!statsCore) { - return null; + (sampleRate >= 100 || (sampleRate > 0 && mathRandom() * 100 < sampleRate))) { + let trackStats = () => { + let currentCfg = _resolveStatsCfg(internalSdkStats.endpoint); + if (currentCfg && currentCfg.enabled) { + let statsCore = _getStatsCore(_buildStatsEndpoint(currentCfg.url)); + if (statsCore) { + internalSdkStatsEvent[SampleRate] = sampleRate; + statsCore.track(internalSdkStatsEvent); + } + } + }; + if (_useFeature) { + _useFeature(_featureName, trackStats, true); + } else { + trackStats(); } - - internalSdkStatsEvent[SampleRate] = _sampleRate; - statsCore.track(internalSdkStatsEvent); } return true; } diff --git a/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts b/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts index 78cf040f7..e08647d7d 100644 --- a/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts +++ b/shared/AppInsightsCore/src/diagnostics/ThrottleMgr.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { arrForEach, arrIndexOf, isNullOrUndefined, mathFloor, mathMin, objForEachKey, strTrim } from "@nevware21/ts-utils"; +import { arrForEach, arrIndexOf, isNullOrUndefined, mathFloor, mathMin, objCreate, objForEachKey, strTrim } from "@nevware21/ts-utils"; import { onConfigChange } from "../config/DynamicConfig"; import { _throwInternal, safeGetLogger } from "../diagnostics/DiagnosticLogger"; import { _eInternalMessageId, eLoggingSeverity } from "../enums/ai/LoggingEnums"; @@ -15,16 +15,18 @@ import { randomValue } from "../utils/RandomHelper"; import { utlCanUseLocalStorage, utlGetLocalStorage, utlSetLocalStorage } from "../utils/StorageHelperFuncs"; const THROTTLE_STORAGE_PREFIX = "appInsightsThrottle"; +const FEATURE_STORAGE_PREFIX = "feature-"; -interface SendMsgParameter { - msgID: _eInternalMessageId, - message: string, - severity?: eLoggingSeverity +interface ThrottleQueueItem { + key: _eInternalMessageId | number | string; + callback: () => void; + isFeature: boolean; + sdkDefaultState?: boolean; } export class ThrottleMgr { public canThrottle: (msgId: _eInternalMessageId | number) => boolean; - public canUseFeature: (feature: string, sdkDefaultState?: boolean) => boolean; + public useFeature: (feature: string, callback: () => void, sdkDefaultState?: boolean) => IThrottleResult | null; public sendMessage: (msgId: _eInternalMessageId, message: string, severity?: eLoggingSeverity) => IThrottleResult | null; public getConfig: () => IThrottleMgrConfig; public isTriggered: (msgId: _eInternalMessageId | number) => boolean; // this function is to get previous triggered status @@ -39,14 +41,12 @@ export class ThrottleMgr { let _canUseLocalStorage: boolean; let _logger: IDiagnosticLogger | null | undefined; let _config: {[msgKey: number]: IThrottleMgrConfig}; - let _localStorageObj: {[msgKey: number]: IThrottleLocalStorageObj | null | undefined}; - let _isTriggered: {[msgKey: number]: boolean}; //_isTriggered is to make sure that we only trigger throttle once a day - let _featureSampleRates: {[feature: string]: number}; - let _featureSamples: {[feature: string]: boolean}; + let _featureConfig: {[feature: string]: IThrottleMgrConfig}; + let _localStorageObj: {[key: string]: IThrottleLocalStorageObj | null | undefined}; + let _isTriggered: {[key: string]: boolean}; //_isTriggered is to make sure that we only trigger throttle once a day let _namePrefix: string; - let _queue: {[msgKey: number]: Array}; + let _queue: {[key: string]: Array}; let _isReady: boolean = false; - let _isSpecificDaysGiven: boolean = false; _initConfig(); @@ -72,36 +72,18 @@ export class ThrottleMgr { } /** - * Check whether a feature is enabled and sampled in by its named throttle configuration. - * A missing or disabled throttle configuration leaves the feature opt-in decision unchanged. - * The sampling decision is cached until the configured sampling rate changes. + * Run a feature operation when enabled by featureOptIn and allowed by its named throttle configuration. * @param feature - The featureOptIn and throttleMgrCfg key. + * @param callback - The feature operation to run when allowed. * @param sdkDefaultState - The default state when featureOptIn does not define the feature. - * @returns true when the feature is enabled and allowed by its throttle configuration. + * @returns The result of applying the throttle, or null when queued or disabled by featureOptIn. */ - _self.canUseFeature = (feature: string, sdkDefaultState?: boolean): boolean => { - if (!feature || isFeatureEnabled(feature, core.config, sdkDefaultState) !== true) { - return false; + _self.useFeature = (feature: string, callback: () => void, sdkDefaultState?: boolean): IThrottleResult | null => { + if (!feature || !callback || isFeatureEnabled(feature, core.config, sdkDefaultState) !== true) { + return null; } - let coreConfig = core.config as IConfiguration & IConfig; - let throttleConfig = coreConfig.throttleMgrCfg && coreConfig.throttleMgrCfg[feature]; - if (!throttleConfig || throttleConfig.disabled) { - return true; - } - - let limit = throttleConfig.limit; - let samplingRate = limit && limit.samplingRate; - if (isNullOrUndefined(samplingRate)) { - samplingRate = 100; - } - - if (_featureSampleRates[feature] !== samplingRate) { - _featureSampleRates[feature] = samplingRate; - _featureSamples[feature] = _isSampledIn(samplingRate); - } - - return _featureSamples[feature]; + return _flushCallback(feature, callback, true, true, sdkDefaultState); } /** @@ -128,20 +110,7 @@ export class ThrottleMgr { * @returns if message queue is flushed */ _self.flush = (msgId: _eInternalMessageId | number): boolean => { - try { - let queue = _getQueueByKey(msgId); - if (queue && queue.length > 0) { - let items = queue.slice(0); - _queue[msgId] = [] - arrForEach(items, (item: SendMsgParameter) => { - _flushMessage(item.msgID, item.message, item.severity, false); - }); - return true; - } - } catch(err) { - // eslint-disable-next-line no-empty - } - return false; + return _flushQueue(_getMapKey(msgId, false)); } /** @@ -153,7 +122,7 @@ export class ThrottleMgr { if (_queue) { let result = true; objForEachKey(_queue, (key) => { - let isFlushed = _self.flush(parseInt(key)); + let isFlushed = _flushQueue(key); result = result && isFlushed; }); return result; @@ -181,37 +150,53 @@ export class ThrottleMgr { } _self.sendMessage = (msgID: _eInternalMessageId | number, message: string, severity?: eLoggingSeverity): IThrottleResult | null => { - return _flushMessage(msgID, message, severity, true); - + return _flushCallback(msgID, () => { + _sendMessage(msgID, _logger, message, severity); + }, true, false); } - function _flushMessage(msgID: _eInternalMessageId | number, message: string, severity?: eLoggingSeverity, saveUnsentMsg?: boolean) { + function _flushCallback( + key: _eInternalMessageId | number | string, callback: () => void, saveUnsent?: boolean, isFeature?: boolean, sdkDefaultState?: boolean + ): IThrottleResult | null { + if (isFeature && isFeatureEnabled(key + "", core.config, sdkDefaultState) !== true) { + return null; + } + if (_isReady) { - let isSampledIn = _canSampledIn(msgID); + let cfg = _getCfgByKey(key, isFeature); + if (isFeature && !cfg) { + callback(); + return { + isThrottled: true, + throttleNum: 1 + }; + } + + let isSampledIn = _canSampledIn(cfg); if (!isSampledIn) { - return; + return null; } - let cfg = _getCfgByKey(msgID); - let localStorageObj = _getLocalStorageObjByKey(msgID); + let localStorageObj = _getLocalStorageObjByKey(key, isFeature); let canThrottle = _canThrottle(cfg, _canUseLocalStorage, localStorageObj); let throttled = false; let number = 0; - let isTriggered = _isTrigger(msgID); + let isTriggered = _isTrigger(key, isFeature); + let mapKey = _getMapKey(key, isFeature); try { if (canThrottle && !isTriggered) { number = mathMin(cfg.limit.maxSendNumber, localStorageObj.count + 1); localStorageObj.count = 0; throttled = true; - _isTriggered[msgID] = true; + _isTriggered[mapKey] = true; localStorageObj.preTriggerDate = new Date(); } else { - _isTriggered[msgID] = canThrottle; + _isTriggered[mapKey] = canThrottle; localStorageObj.count += 1; } - let localStorageName = _getLocalStorageName(msgID); + let localStorageName = _getLocalStorageName(key, _namePrefix, isFeature); _resetLocalStorage(_logger, localStorageName, localStorageObj); for (let i = 0; i < number; i++) { - _sendMessage(msgID, _logger, message, severity); + callback(); } } catch(e) { // eslint-disable-next-line no-empty @@ -221,26 +206,43 @@ export class ThrottleMgr { throttleNum: number } as IThrottleResult; } else { - if (!!saveUnsentMsg) { - let queue = _getQueueByKey(msgID); + if (!!saveUnsent) { + let queue = _getQueueByKey(key, isFeature); queue.push({ - msgID: msgID, - message: message, - severity: severity - } as SendMsgParameter); + key: key, + callback: callback, + isFeature: !!isFeature, + sdkDefaultState: sdkDefaultState + }); } } return null; } + + function _flushQueue(mapKey: string): boolean { + try { + let queue = _queue[mapKey]; + if (queue && queue.length > 0) { + let items = queue.slice(0); + _queue[mapKey] = []; + arrForEach(items, (item: ThrottleQueueItem) => { + _flushCallback(item.key, item.callback, false, item.isFeature, item.sdkDefaultState); + }); + return true; + } + } catch(err) { + // eslint-disable-next-line no-empty + } + return false; + } function _initConfig() { _logger = safeGetLogger(core); - _isTriggered = {}; - _featureSampleRates = {}; - _featureSamples = {}; - _localStorageObj = {}; - _queue = {}; - _config = {}; + _isTriggered = objCreate(null); + _localStorageObj = objCreate(null); + _queue = objCreate(null); + _config = objCreate(null); + _featureConfig = objCreate(null); _setCfgByKey(_eInternalMessageId.DefaultThrottleMsgKey); _namePrefix = !isNullOrUndefined(namePrefix)? namePrefix : ""; @@ -250,34 +252,49 @@ export class ThrottleMgr { let configMgr = coreConfig.throttleMgrCfg || {}; objForEachKey(configMgr, (key, cfg) => { + _setCfgByKey(key, cfg, true); let msgId = parseInt(key); if (msgId + "" === key) { - _setCfgByKey(msgId, cfg); + _setCfgByKey(msgId, cfg, false); } }); })); } - function _getCfgByKey(msgID: _eInternalMessageId | number) { - return _config[msgID] || _config[_eInternalMessageId.DefaultThrottleMsgKey]; + function _getCfgByKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { + if (isFeature) { + let feature = key + ""; + let coreConfig = core.config as IConfiguration & IConfig; + let featureCfg = coreConfig.throttleMgrCfg && coreConfig.throttleMgrCfg[feature]; + if (featureCfg) { + _setCfgByKey(feature, featureCfg, true); + } else { + delete _featureConfig[feature]; + } + return _featureConfig[feature]; + } + return _config[+key] || _config[_eInternalMessageId.DefaultThrottleMsgKey]; } - function _setCfgByKey(msgID: _eInternalMessageId | number, config?: IThrottleMgrConfig) { + function _setCfgByKey(key: _eInternalMessageId | number | string, config?: IThrottleMgrConfig, isFeature?: boolean) { try { let cfg = config || {}; let curCfg = {} as IThrottleMgrConfig; curCfg.disabled = !!cfg.disabled; let configInterval = cfg.interval || {}; - _isSpecificDaysGiven = configInterval?.daysOfMonth && configInterval?.daysOfMonth.length > 0; curCfg.interval = _getIntervalConfig(configInterval); let limit = { - samplingRate: cfg.limit?.samplingRate || 100, + samplingRate: !isNullOrUndefined(cfg.limit?.samplingRate) ? cfg.limit.samplingRate : 100, // dafault: every time sent only 1 event maxSendNumber: cfg.limit?.maxSendNumber || 1 }; curCfg.limit = limit; - _config[msgID] = curCfg; + if (isFeature) { + _featureConfig[key + ""] = curCfg; + } else { + _config[+key] = curCfg; + } } catch (e) { // eslint-disable-next-line no-empty @@ -288,14 +305,14 @@ export class ThrottleMgr { interval = interval || {}; let monthInterval = interval?.monthInterval; let dayInterval = interval?.dayInterval; + let isSpecificDaysGiven = interval?.daysOfMonth && interval?.daysOfMonth.length > 0; // default: send data every 3 month each year if (isNullOrUndefined(monthInterval) && isNullOrUndefined(dayInterval)) { interval.monthInterval = 3; - if (!_isSpecificDaysGiven) { + if (!isSpecificDaysGiven) { // default: send data on 28th interval.daysOfMonth = [28]; - _isSpecificDaysGiven = true; } } interval = { @@ -319,7 +336,7 @@ export class ThrottleMgr { } let dayCheck = 1; - if (_isSpecificDaysGiven) { + if (interval?.daysOfMonth && interval.daysOfMonth.length > 0) { dayCheck = arrIndexOf(interval.daysOfMonth, curDate.getUTCDate()); } else if (interval?.dayInterval) { let daySpan = mathFloor((curDate.getTime() - date.getTime()) / 86400000); @@ -331,10 +348,10 @@ export class ThrottleMgr { return false; } - function _getLocalStorageName(msgKey: _eInternalMessageId | number, prefix?: string) { + function _getLocalStorageName(key: _eInternalMessageId | number | string, prefix?: string, isFeature?: boolean) { let fix = !isNullOrUndefined(prefix)? prefix : ""; - if (msgKey) { - return THROTTLE_STORAGE_PREFIX + fix + "-" + msgKey; + if (key) { + return THROTTLE_STORAGE_PREFIX + fix + "-" + (isFeature ? FEATURE_STORAGE_PREFIX : "") + key; } return null; } @@ -425,29 +442,29 @@ export class ThrottleMgr { // NOTE: config.limit.samplingRate is set to 4 decimal places, // so config.limit.samplingRate = 1 means 0.0001% - function _canSampledIn(msgID: _eInternalMessageId) { + function _canSampledIn(cfg: IThrottleMgrConfig) { try { - let cfg = _getCfgByKey(msgID) - return randomValue(1000000) <= cfg.limit.samplingRate; + return cfg.limit.samplingRate > 0 && randomValue(1000000) <= cfg.limit.samplingRate; } catch (e) { // eslint-disable-next-line no-empty } return false; } - function _isSampledIn(samplingRate: number) { - return samplingRate >= 1000000 || (samplingRate > 0 && randomValue(999999) < samplingRate); + function _getMapKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { + return (isFeature ? FEATURE_STORAGE_PREFIX : "") + key; } - function _getLocalStorageObjByKey(key: _eInternalMessageId | number) { + function _getLocalStorageObjByKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { try { - let curObj = _localStorageObj[key]; + let mapKey = _getMapKey(key, isFeature); + let curObj = _localStorageObj[mapKey]; if (!curObj) { - let localStorageName = _getLocalStorageName(key, _namePrefix); + let localStorageName = _getLocalStorageName(key, _namePrefix, isFeature); curObj = _getLocalStorageObj(utlGetLocalStorage(_logger, localStorageName), _logger, localStorageName); - _localStorageObj[key] = curObj; + _localStorageObj[mapKey] = curObj; } - return _localStorageObj[key]; + return _localStorageObj[mapKey]; } catch (e) { // eslint-disable-next-line no-empty @@ -455,25 +472,24 @@ export class ThrottleMgr { return null; } - function _isTrigger(key: _eInternalMessageId | number) { - let isTrigger = _isTriggered[key]; - if (isNullOrUndefined(isTrigger)) { - isTrigger = false; - let localStorageObj = _getLocalStorageObjByKey(key); - if (localStorageObj) { - isTrigger = _isTriggeredOnCurDate(localStorageObj.preTriggerDate); - } - _isTriggered[key] = isTrigger; + function _isTrigger(key: _eInternalMessageId | number | string, isFeature?: boolean) { + let mapKey = _getMapKey(key, isFeature); + let isTrigger = false; + let localStorageObj = _getLocalStorageObjByKey(key, isFeature); + if (localStorageObj) { + isTrigger = _isTriggeredOnCurDate(localStorageObj.preTriggerDate); } - return _isTriggered[key]; + _isTriggered[mapKey] = isTrigger; + return isTrigger; } - function _getQueueByKey(key: _eInternalMessageId | number) { - _queue = _queue || {}; - if (isNullOrUndefined(_queue[key])) { - _queue[key] = []; + function _getQueueByKey(key: _eInternalMessageId | number | string, isFeature?: boolean) { + _queue = _queue || objCreate(null); + let mapKey = _getMapKey(key, isFeature); + if (isNullOrUndefined(_queue[mapKey])) { + _queue[mapKey] = []; } - return _queue[key]; + return _queue[mapKey]; } } } diff --git a/shared/AppInsightsCore/src/index.ts b/shared/AppInsightsCore/src/index.ts index bad79ac75..88c91133b 100644 --- a/shared/AppInsightsCore/src/index.ts +++ b/shared/AppInsightsCore/src/index.ts @@ -360,7 +360,7 @@ export { // Throttle manager interfaces export { - CanUseFeatureFn, IThrottleLimit, IThrottleInterval, IThrottleMgrConfig, + UseFeatureFn, IThrottleLimit, IThrottleInterval, IThrottleMgrConfig, IThrottleLocalStorageObj, IThrottleResult } from "./interfaces/ai/IThrottleMgr"; diff --git a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts index e2133126e..e9fc95592 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IStatsMgr.ts @@ -4,7 +4,7 @@ import { IAppInsightsCore } from "./IAppInsightsCore"; import { IConfiguration } from "./IConfiguration"; import { IInternalSdkStats, IInternalSdkStatsState } from "./IInternalSdkStats"; -import { CanUseFeatureFn } from "./IThrottleMgr"; +import { UseFeatureFn } from "./IThrottleMgr"; import { IUnloadHook } from "./IUnloadHook"; /** @@ -39,7 +39,7 @@ export interface IStatsMgr { * @param featureName - The optional featureOptIn name used to gate the manager. Defaults to the * SDK Stats feature (`STATS_SDK_FEATURE`) which is enabled by default and can be opted-out via the * `featureOptIn` configuration. - * @param canUseFeature - Optional combined feature opt-in and throttling check supplied by the SKU. + * @param useFeature - Optional feature operation throttle supplied by the SKU. * @returns The unload hook for the stats beat manager, which can be used to unload * and disable the manager. This may return null if the manager cannot be initialized. * @remarks This method should be called only once, and it may throw an error if called multiple times. @@ -48,7 +48,7 @@ export interface IStatsMgr { core: IAppInsightsCore, createStatsCore: CreateStatsCoreFn, featureName?: string, - canUseFeature?: CanUseFeatureFn + useFeature?: UseFeatureFn ) => IUnloadHook | null; /** diff --git a/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts b/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts index 363c87b2b..b15181bdc 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IThrottleMgr.ts @@ -52,7 +52,7 @@ export interface IThrottleMgrConfig { /** * Identifies if throttling is disabled. - * For messages, no messages are sent. For feature checks, the featureOptIn state is used without sampling. + * When true, no messages are sent and no feature callbacks are invoked. * Default: false */ disabled?: boolean; @@ -108,7 +108,7 @@ export interface IThrottleResult { } /** - * Determines whether a dynamically configured feature may be used. + * Runs a dynamically configured feature operation when its throttle allows it. * @since 3.4.4 */ -export type CanUseFeatureFn = (feature: string, sdkDefaultState?: boolean) => boolean; +export type UseFeatureFn = (feature: string, callback: () => void, sdkDefaultState?: boolean) => IThrottleResult | null; From 627cb4b8d13732383c9378f1aff7bdfb2a05759f Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 3 Sep 2026 13:48:37 -0700 Subject: [PATCH 44/51] fix: preserve SDK version placeholder Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AISKU/src/AISku.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index c7bea99e2..281a09d49 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -66,7 +66,7 @@ const CDN_USAGE = "CdnUsage"; const SDK_LOADER_VER = "SdkLoaderVer"; const ZIP_PAYLOAD = "zipPayload"; const SDK_STATS = "SdkStats"; -var _sdkVersion = '3.4.3'; +var _sdkVersion = "#version#"; const default_limit = { samplingRate: 100, From c2da799fe7d9e94384fd00502ad368bdcb89f588 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 3 Sep 2026 14:08:15 -0700 Subject: [PATCH 45/51] fix: preserve numeric throttle config keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- shared/AppInsightsCore/src/interfaces/ai/IConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts b/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts index 83e1154e0..2024bea41 100644 --- a/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts +++ b/shared/AppInsightsCore/src/interfaces/ai/IConfig.ts @@ -384,7 +384,7 @@ export interface IConfig { /** * [Optional] Sets throttle manager configuration by numeric message ID or string feature name. */ - throttleMgrCfg?: {[key: string]: IThrottleMgrConfig}; + throttleMgrCfg?: {[key: number | string]: IThrottleMgrConfig}; /** * [Optional] Specifies a Highest Priority custom endpoint URL where telemetry data will be sent. From c26c05f56a474e9237129e6744c585d768c978b4 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Thu, 3 Sep 2026 14:24:14 -0700 Subject: [PATCH 46/51] test: allow core bundle size variance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts index d16e353ba..ad6d7ab0e 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/AppInsightsCoreSize.Tests.ts @@ -52,7 +52,7 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: export class AppInsightsCoreSizeCheck extends AITestClass { private readonly MAX_RAW_SIZE = 138; - private readonly MAX_BUNDLE_SIZE = 138; + private readonly MAX_BUNDLE_SIZE = 139; private readonly MAX_RAW_DEFLATE_SIZE = 56; private readonly MAX_BUNDLE_DEFLATE_SIZE = 56; private readonly rawFilePath = "../dist/es5/index.min.js"; From f3f477bfcc79665434e1164d82f8626d16bd073a Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:25:41 -0700 Subject: [PATCH 47/51] perf: reduce SDK Stats bundle size Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7c3532d-f1bf-45a6-859b-a0aad283c3c0 --- AISKU/src/AISku.ts | 31 +++++------- .../Unit/src/ai/InternalSdkStats.Tests.ts | 12 +++++ .../src/core/InternalSdkStats.ts | 47 ++----------------- 3 files changed, 30 insertions(+), 60 deletions(-) diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 281a09d49..1a08602cd 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -416,25 +416,20 @@ export class AppInsightsSku implements IApplicationInsights(_core, (statsConfig) => { - try { - let statsCore = new AppInsightsCore(); - (statsConfig as IConfiguration & IConfig).maxBatchInterval = 1; - statsCore.initialize(statsConfig as IConfiguration & IConfig, [new Sender()]); - return statsCore; - } catch (e) { - _throwInternal(_core.logger, eLoggingSeverity.WARNING, - _eInternalMessageId.InternalSdkStatsManagerException, "Failed to create SDK Stats core"); - return null; - } - }, STATS_SDK_FEATURE, _throttleMgr.useFeature); - if (statsHook) { - _core.addUnloadHook(statsHook); + let statsMgr = createStatsMgr(); + _core.setStatsMgr(statsMgr); + _core.addUnloadHook(statsMgr.init(_core, (statsConfig) => { + try { + let statsCore = new AppInsightsCore(); + (statsConfig as IConfiguration & IConfig).maxBatchInterval = 1; + statsCore.initialize(statsConfig as IConfiguration & IConfig, [new Sender()]); + return statsCore; + } catch (e) { + _throwInternal(_core.logger, eLoggingSeverity.WARNING, + _eInternalMessageId.InternalSdkStatsManagerException, "Failed to create SDK Stats core"); + return null; } - } + }, STATS_SDK_FEATURE, _throttleMgr.useFeature)); // Initialize the initial OTel API _otelApi = _initOTel(_self, "aisku", _onEnd, _onException); diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 6314ec73b..82a7a1907 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -837,6 +837,18 @@ export class InternalSdkStatsTests extends AITestClass { Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", getStatsCfgUrl("https://westeurope-5.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), "An EU region replica endpoint should also resolve to the EU url"); + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("HTTPS://WESTEUROPE.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "EU endpoint matching should be case insensitive"); + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU endpoint without a scheme should resolve to the EU url"); + Assert.equal("https://eu-tst-data.stats.monitor.azure.com/cfg/v1.json", + getStatsCfgUrl("custom://westeurope.in.applicationinsights.azure.com/", STATS_TEST_CFG_URL), + "An EU endpoint with a custom scheme should resolve to the EU url"); + Assert.equal(STATS_TEST_CFG_URL, + getStatsCfgUrl("https://eastus.in.applicationinsights.azure.com/westeurope", STATS_TEST_CFG_URL), + "An EU region outside the host prefix should not resolve to the EU url"); let euRegions = [ "francecentral", "francesouth", "germanywestcentral", "norwayeast", diff --git a/shared/AppInsightsCore/src/core/InternalSdkStats.ts b/shared/AppInsightsCore/src/core/InternalSdkStats.ts index f508ef7a4..956b88293 100644 --- a/shared/AppInsightsCore/src/core/InternalSdkStats.ts +++ b/shared/AppInsightsCore/src/core/InternalSdkStats.ts @@ -4,7 +4,7 @@ import { doAwaitResponse } from "@nevware21/ts-async"; import { ITimerHandler, arrIndexOf, isNumber, isString, mathRandom, objCreate, objDefineProps, objForEachKey, scheduleTimeout, strEndsWith, - strIndexOf, strLower, strStartsWith, strSubstring, utcNow + strLower, strStartsWith, strSubstring, utcNow } from "@nevware21/ts-utils"; import { onConfigChange } from "../config/DynamicConfig"; import { DEFAULT_BREEZE_PATH, DisabledPropertyName, SampleRate } from "../constants/Constants"; @@ -34,9 +34,6 @@ const STATS_LANGUAGE = "JavaScript"; const STATS_SAMPLING_PERCENTAGE = 100; const STATS_TYPE = "Browser"; -/** The host prefix added to the configured SDK Stats config url for EU data-boundary regions. */ -const STATS_EU_HOST_PREFIX = "eu-"; - /** Ingestion path for future 1DS (OneCollector) SDK Stats; the AI SKU uses {@link DEFAULT_BREEZE_PATH}. */ export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; @@ -47,42 +44,11 @@ export const STATS_SDK_ONECOLLECTOR_PATH = "/OneCollector/1.0"; export const STATS_SDK_FEATURE = "sdkStats"; // Prefixes for EU data-boundary regions used by the Azure Monitor OpenTelemetry exporter -const STATS_EU_REGION_PATTERN = /^(france|germany|northeurope|norway|sweden|switzerland|uk|westeurope)/; - -/** - * Determine whether the provided customer endpoint maps to an EU data-boundary region. The region - * is extracted from the host (the leading host label, with any region replica suffix removed) and - * matched against the known EU data-boundary regions. - * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. - * @returns true when the endpoint maps to an EU region, false otherwise (including unknown regions). - */ -function _isEuEndpoint(endpoint: string): boolean { - let isEU = false; - if (endpoint) { - let host = strLower(endpoint); - // Strip the scheme - let schemeIdx = strIndexOf(host, "://"); - if (schemeIdx !== -1) { - host = strSubstring(host, schemeIdx + 3); - } - - // Extract the leading host label, e.g. "westeurope-5" from "westeurope-5.in.applicationinsights.azure.com/" - let label = host.split("/")[0].split(".")[0]; - // Remove any trailing region replica suffix, e.g. "westeurope-5" => "westeurope" - let dashIdx = strIndexOf(label, "-"); - if (dashIdx !== -1) { - label = strSubstring(label, 0, dashIdx); - } - - isEU = STATS_EU_REGION_PATTERN.test(label); - } - - return isEU; -} +const STATS_EU_REGION_PATTERN = /^(?:[^:]+:\/\/)?(?:france|germany|northeurope|norway|sweden|switzerland|uk|westeurope)/i; /** * Returns the SDK Stats config URL (`cfg/v1.json`) for the endpoint, derived from the configured - * base url. For EU data-boundary endpoints the {@link STATS_EU_HOST_PREFIX} is inserted in front of + * base url. For EU data-boundary endpoints an `eu-` prefix is inserted in front of * the host, e.g. `https://data.stats...` => `https://eu-data.stats...`. * @param endpoint - The customer breeze endpoint that the SDK Stats are being collected for. * @param cfgUrl - The configured (non-EU) SDK Stats config url, when not supplied null is returned. @@ -92,11 +58,8 @@ export function getStatsCfgUrl(endpoint: string, cfgUrl: string): string { let result: string = null; if (cfgUrl) { result = cfgUrl; - if (_isEuEndpoint(endpoint)) { - // Insert the EU prefix in front of the host (after any scheme) - let schemeIdx = strIndexOf(cfgUrl, "://"); - let hostIdx = schemeIdx !== -1 ? schemeIdx + 3 : 0; - result = strSubstring(cfgUrl, 0, hostIdx) + STATS_EU_HOST_PREFIX + strSubstring(cfgUrl, hostIdx); + if (STATS_EU_REGION_PATTERN.test(endpoint)) { + result = cfgUrl.replace(/^([^:]+:\/\/)?/, "$1eu-"); } } From ebe361dc3fcb340214ec0d2321cd94d5c48c5e19 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:05:11 -0700 Subject: [PATCH 48/51] test: allow AISKU bundle size variance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a1bb7ab-2536-4a8d-b9f7-003e8ccfa267 --- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index 4d13a1bc1..dee0d065e 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -55,7 +55,7 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: export class AISKUSizeCheck extends AITestClass { private readonly MAX_RAW_SIZE = 181; - private readonly MAX_BUNDLE_SIZE = 182; + private readonly MAX_BUNDLE_SIZE = 183; private readonly MAX_RAW_DEFLATE_SIZE = 74; private readonly MAX_BUNDLE_DEFLATE_SIZE = 74; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; From f522598dfd28885e2e7a53ca832f4757d37979cf Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 23 Sep 2026 20:37:02 -0700 Subject: [PATCH 49/51] Fix SDK Stats send limit for three-month window Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts | 17 +++++++++++++++++ AISKU/src/AISku.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index ce357b128..5b54c92d1 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -252,6 +252,9 @@ export class SdkStatsFeatureTests extends AITestClass { Assert.equal(900000, config.sdkStats!.int, "int should default to 900000 (15 minutes)"); Assert.equal(100, config.throttleMgrCfg![STATS_SDK_FEATURE].limit!.samplingRate, "Dedicated SDK Stats throttle should use the legacy default sampling rate"); + Assert.equal(92 * 24 * 60 * 60 * 1000 / config.sdkStats!.int, + config.throttleMgrCfg![STATS_SDK_FEATURE].limit!.maxSendNumber, + "Dedicated SDK Stats throttle should cover 15-minute intervals across the longest three-month window"); } }); @@ -262,6 +265,13 @@ export class SdkStatsFeatureTests extends AITestClass { let ai = this._createAi({ sdkStats: { int: 60000 + }, + throttleMgrCfg: { + [STATS_SDK_FEATURE]: { + limit: { + maxSendNumber: 48 + } + } } }); this.clock.tick(1); @@ -269,6 +279,13 @@ export class SdkStatsFeatureTests extends AITestClass { let config = ai.config; Assert.ok(config.sdkStats, "sdkStats config should exist"); Assert.equal(60000, config.sdkStats!.int, "User-provided int should be preserved"); + Assert.equal(48, config.throttleMgrCfg![STATS_SDK_FEATURE].limit!.maxSendNumber, + "User-provided maxSendNumber should be preserved"); + + config.throttleMgrCfg![STATS_SDK_FEATURE].limit!.maxSendNumber = 192; + this.clock.tick(1); + Assert.equal(192, config.throttleMgrCfg![STATS_SDK_FEATURE].limit!.maxSendNumber, + "maxSendNumber should support runtime updates"); } }); } diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 1a08602cd..cfc377087 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -88,7 +88,7 @@ const sdk_stats_throttle_config = { disabled: false, limit: cfgDfMerge({ samplingRate: 100, - maxSendNumber: 1 + maxSendNumber: 8832 // Longest three-month window: 92 days * 24 hours * 4 reports per hour }) } as IThrottleMgrConfig; From 43c059925fe91f60123e33f104757da1fbdd7032 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 23 Sep 2026 21:06:20 -0700 Subject: [PATCH 50/51] Test AISKU SDK Stats dynamic configuration initialization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts index 5b54c92d1..e0cd0b629 100644 --- a/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts +++ b/AISKU/Tests/Unit/src/SdkStatsFeature.tests.ts @@ -1,6 +1,8 @@ import { ApplicationInsights, IConfig, IConfiguration } from '../../../src/applicationinsights-web'; import { AITestClass, Assert } from '@microsoft/ai-test-framework'; -import { FeatureOptInMode, ISdkStatsNotifCbk, onConfigChange, STATS_SDK_FEATURE } from '@microsoft/applicationinsights-core-js'; +import { + _eInternalMessageId, FeatureOptInMode, ISdkStatsNotifCbk, onConfigChange, STATS_SDK_FEATURE +} from '@microsoft/applicationinsights-core-js'; import { AppInsightsSku } from '../../../src/AISku'; import { ICfgSyncMode } from '@microsoft/applicationinsights-cfgsync-js'; @@ -41,6 +43,7 @@ export class SdkStatsFeatureTests extends AITestClass { this._testSdkStatsDynamicEnableDisable(); this._testSdkStatsConfigDefaults(); this._testSdkStatsDynamicConfigChanges(); + this._testInternalSdkStatsDynamicConfigInitialization(); this._testSnippetSdkVersion(); } @@ -353,6 +356,89 @@ export class SdkStatsFeatureTests extends AITestClass { }); } + private _testInternalSdkStatsDynamicConfigInitialization() { + this.testCase({ + name: "SdkStatsFeature: internal SDK Stats initializes when dynamic configuration arrives after loadAppInsights", + useFakeTimers: true, + useFakeServer: true, + test: () => { + const cfgUrl = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; + const statsHost = "tst-data.stats.monitor.azure.com"; + const statsIKey = "000e0000-e000-0000-a000-000000000000"; + const state = { + cKey: TestInstrumentationKey, + endpoint: "https://dynamic-config.example.com/v2/track", + sdkVer: "1.0.0" + }; + const storageKey = state.cKey + ":" + state.endpoint; + sessionStorage.removeItem(storageKey); + this.onDone(() => sessionStorage.removeItem(storageKey)); + this.hookSendBeacon(() => {}); + this.hookFetch((resolve) => resolve(new Response("{}", { status: 200 }))); + let xhrSendSpy = this.sandbox.spy(XMLHttpRequest.prototype, "send"); + + let ai = this._createAi(); + this.clock.tick(1); + + Assert.ok(ai.config.stats, "loadAppInsights should seed the dynamic stats configuration"); + Assert.equal(undefined, ai.config.stats.cfgUrl, "The SDK Stats config URL should not be supplied initially"); + Assert.equal(undefined, ai.config.stats.iKey, "The SDK Stats instrumentation key should not be supplied initially"); + let stats = ai.core.getSdkStats(state); + Assert.ok(stats && stats.enabled, "loadAppInsights should initialize the internal SDK Stats manager"); + stats.countException(state.endpoint, "NetworkError"); + this.clock.tick(1000); + Assert.equal(0, this.activeXhrRequests.length, "SDK Stats should not send before configuration arrives"); + + let fetchedUrls: string[] = []; + ai.updateCfg({ + stats: { + shrtInt: 1, + cfgUrl: cfgUrl, + iKey: statsIKey, + overrideCfgFn: (url, oncomplete) => { + fetchedUrls.push(url); + oncomplete({ enabled: true, url: statsHost }); + } + }, + throttleMgrCfg: { + [_eInternalMessageId.DefaultThrottleMsgKey]: { disabled: false }, + [STATS_SDK_FEATURE]: { + limit: { samplingRate: 1000000 }, + interval: { dayInterval: 1 } + } + } + }); + this.clock.tick(1002); + + Assert.deepEqual([cfgUrl], fetchedUrls, + "The existing manager should use the dynamically supplied config fetcher and URL"); + Assert.strictEqual(stats, ai.core.getSdkStats(state), + "Configuration should initialize sending without replacing the stats instance"); + let requests = this.activeXhrRequests; + Assert.equal(1, requests.length, "The isolated SDK Stats sender should send the buffered counter"); + Assert.equal("https://" + statsHost + "/v2/track", requests[0].url, "SDK Stats should use the remote-configured endpoint"); + let payload = JSON.parse(xhrSendSpy.firstCall.args[0]); + Assert.equal(statsIKey, payload[0].iKey, "SDK Stats should use the dynamically supplied instrumentation key"); + Assert.equal("exception", payload[0].data.baseData.metrics[0].name, "The buffered exception should be reported"); + Assert.equal(1, payload[0].data.baseData.metrics[0].value, "The buffered exception count should be preserved"); + requests[0].respond(200, {}, ""); + + ai.config.featureOptIn = { + [STATS_SDK_FEATURE]: { mode: FeatureOptInMode.disable } + }; + this.clock.tick(1); + Assert.equal(null, ai.core.getSdkStats(state), + "Disabling the internal feature dynamically should remove its stats instance"); + Assert.equal(false, stats.enabled, "The previous stats instance should be stopped"); + + ai.config.featureOptIn[STATS_SDK_FEATURE].mode = FeatureOptInMode.enable; + this.clock.tick(1); + let reenabledStats = ai.core.getSdkStats(state); + Assert.ok(reenabledStats && reenabledStats.enabled, "Re-enabling the internal feature should initialize stats again"); + } + }); + } + private _testSnippetSdkVersion() { this.testCase({ name: "SdkStatsFeature: snippet version is included in the SDK Stats version", From 6ca7fcece31e6de0bcfeaf02c4d7c30134976c00 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 23 Sep 2026 21:15:25 -0700 Subject: [PATCH 51/51] Use SDK Stats test cleanup and async polling helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Unit/src/InternalSdkStats.tests.ts | 294 +++++++----------- .../Unit/src/ai/InternalSdkStats.Tests.ts | 31 +- 2 files changed, 114 insertions(+), 211 deletions(-) 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 b3c14b889..c6087cd4b 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/InternalSdkStats.tests.ts @@ -1,84 +1,37 @@ import { AITestClass, Assert, PollingAssert } from "@microsoft/ai-test-framework"; import { - AppInsightsCore, createStatsMgr, FeatureOptInMode, getWindow, IAppInsightsCore, IConfiguration, IPayloadData, IInternalSdkStatsState, - IStatsMgr, ITelemetryItem, IUnloadHook, TransportType + AppInsightsCore, createStatsMgr, FeatureOptInMode, IAppInsightsCore, IConfiguration, IInternalSdkStatsState, + IStatsMgr, ITelemetryItem, TransportType } from "@microsoft/applicationinsights-core-js"; import { Sender } from "../../../src/Sender"; -import { SinonSpy, SinonStub } from "sinon"; +import { SinonSpy } from "sinon"; import { ISenderConfig } from "../../../types/applicationinsights-channel-js"; import { isBeaconsSupported } from "@microsoft/applicationinsights-core-js"; const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; const STATS_TEST_HOST = "tst-data.stats.monitor.azure.com"; -function _clearStatsStorage() { - try { - let storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; - if (storage) { - let keys: string[] = []; - for (let lp = 0; lp < storage.length; lp++) { - let key = storage.key(lp); - if (key && key.indexOf("000e0000-e000-0000-a000-000000000000:") === 0) { - keys.push(key); - } - } - - for (let lp = 0; lp < keys.length; lp++) { - storage.removeItem(keys[lp]); - } - } - } catch (e) { - // Session storage may be unavailable. - } -} - export class InternalSdkStatsTests extends AITestClass { private _core: AppInsightsCore; private _sender: Sender; private _statsMgr: IStatsMgr; - private _statsMgrUnloadHook: IUnloadHook | null; private internalSdkStatsCountSpy: SinonSpy; - private fetchStub: sinon.SinonStub; - private beaconStub: sinon.SinonStub; - private statsCore: IAppInsightsCore; public testInitialize() { - _clearStatsStorage(); this._core = new AppInsightsCore(); this._sender = new Sender(); this._statsMgr = createStatsMgr(); - } - - public testFinishedCleanup() { - _clearStatsStorage(); - if (this._sender && this._sender.isInitialized()) { - this._sender.pause(); - this._sender._buffer.clear(); - this._sender.teardown(); - } - this._sender = null; - this._core = null; - this._statsMgr = null; - if (this._statsMgrUnloadHook) { - this._statsMgrUnloadHook.rm(); - this._statsMgrUnloadHook = null; - } - if (this.internalSdkStatsCountSpy) { - this.internalSdkStatsCountSpy.restore(); - } - if (this.fetchStub) { - this.fetchStub.restore(); - } - if (this.beaconStub) { - this.beaconStub.restore(); - } - if (this.statsCore) { - this.statsCore.unload(false); - } + this.onDone(() => { + if (this._core.isInitialized()) { + this._sender.pause(); + this._sender._buffer.clear(); + this._core.unload(false); + } + }); } private createStatsCore(config: IConfiguration): IAppInsightsCore { - this.statsCore = { + return { config, isInitialized: () => true, track: (item: ITelemetryItem) => { @@ -86,12 +39,18 @@ export class InternalSdkStatsTests extends AITestClass { unload: () => { } } as any; - return this.statsCore; } - private initializeCoreAndSender(config: any, instrumentationKey: string) { + private initializeCoreAndSender(config: Partial, instrumentationKey: string) { const sender = new Sender(); const core = new AppInsightsCore(); + this.onDone(() => { + if (core.isInitialized()) { + sender.pause(); + sender._buffer.clear(); + core.unload(false); + } + }); const coreConfig = { instrumentationKey, stats: { @@ -114,24 +73,20 @@ export class InternalSdkStatsTests extends AITestClass { // core so it can enable itself (createStatsMgr().init() only enables once the core is initialized). core.initialize(coreConfig, [sender]); let unloadHook = statsMgr.init(core, (config) => this.createStatsCore(config), "InternalSdkStats"); + core.addUnloadHook(unloadHook); core.setStatsMgr(statsMgr); - this._statsMgrUnloadHook = unloadHook; let internalSdkStatsState: IInternalSdkStatsState = { cKey: instrumentationKey, endpoint: config.endpointUrl, - sdkVer: "javascript:3.4.3:snp6", + sdkVer: "javascript:3.4.3:snp6" }; this.internalSdkStatsCountSpy = this.sandbox.spy(core.getSdkStats(internalSdkStatsState), "count"); - this.onDone(() => { - sender.teardown(); - }); - return { core, sender, statsMgr, unloadHook }; } - private createSenderConfig(transportType: TransportType) { + private createSenderConfig(transportType: TransportType): Partial { return { endpointUrl: "https://test", emitLineDelimitedJson: false, @@ -154,12 +109,8 @@ export class InternalSdkStatsTests extends AITestClass { } private processTelemetryAndFlush(sender: Sender, telemetryItem: ITelemetryItem) { - try { - sender.processTelemetry(telemetryItem, null); - sender.flush(); - } catch (e) { - QUnit.assert.ok(false, "Unexpected error during telemetry processing"); - } + sender.processTelemetry(telemetryItem, null); + sender.flush(); } private assertInternalSdkStatsCall(statusCode: number) { @@ -192,14 +143,14 @@ export class InternalSdkStatsTests extends AITestClass { }; this._core.initialize(config, [this._sender]); - this._statsMgrUnloadHook = this._statsMgr.init( + this._core.addUnloadHook(this._statsMgr.init( this._core, (statsConfig) => this.createStatsCore(statsConfig), "InternalSdkStats" - ); + )); this._core.setStatsMgr(this._statsMgr); let internalSdkStatsState: IInternalSdkStatsState = { cKey: "Test-iKey", endpoint: "https://example.endpoint.com", - sdkVer: "1.0.0", + sdkVer: "1.0.0" }; const internalSdkStats = this._core.getSdkStats(internalSdkStatsState); @@ -209,138 +160,113 @@ export class InternalSdkStatsTests extends AITestClass { } }); - this.testCaseAsync({ + this.testCase({ name: "SDK Stats increments success count when fetch sender is called once", useFakeTimers: true, useFakeServer: true, - stepDelay: 100, - steps: [ - () => { - this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { // only fetch is supported to stub, why? - return Promise.resolve(new Response("{}", { status: 200, statusText: "OK" })); - }); - - const config = this.createSenderConfig(TransportType.Fetch); - const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); - - const telemetryItem: ITelemetryItem = { - name: "fake item", - iKey: "testIkey2;ingestionendpoint=testUrl1", - baseType: "some type", - baseData: {} - }; + test: () => { + const fetchCalls = this.hookFetch((resolve) => { + resolve(new Response("{}", { status: 200, statusText: "OK" })); + }); + + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; - this.processTelemetryAndFlush(sender, telemetryItem); - - } - ].concat(PollingAssert.createPollingAssert(() => { - if (this.internalSdkStatsCountSpy.called && this.fetchStub.called) { + this.processTelemetryAndFlush(sender, telemetryItem); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called && fetchCalls.length > 0; + }, "Waiting for fetch sender and SDK Stats count to be called", 30, 100)).add(() => { + Assert.equal(1, fetchCalls.length, "Fetch sender should be called once"); this.assertInternalSdkStatsCall(200); - return true; - } - return false; - }, "Waiting for fetch sender and SDK Stats count to be called") as any) + }); + } }); - this.testCaseAsync({ + this.testCase({ name: "SDK Stats increments throttle count when fetch sender is called with status 439", useFakeTimers: true, - stepDelay: 100, - steps: [ - () => { - this.fetchStub = this.sandbox.stub(window, "fetch").callsFake(() => { - return Promise.resolve(new Response("{}", { status: 439, statusText: "Too Many Requests" })); - }); - - const config = this.createSenderConfig(TransportType.Fetch); - const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); - - const telemetryItem: ITelemetryItem = { - name: "fake item", - iKey: "testIkey2;ingestionendpoint=testUrl1", - baseType: "some type", - baseData: {} - }; + test: () => { + const fetchCalls = this.hookFetch((resolve) => { + resolve(new Response("{}", { status: 439, statusText: "Too Many Requests" })); + }); + + const config = this.createSenderConfig(TransportType.Fetch); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; - this.processTelemetryAndFlush(sender, telemetryItem); - } - ].concat(PollingAssert.createPollingAssert(() => { - if (this.internalSdkStatsCountSpy.called && this.fetchStub.called) { + this.processTelemetryAndFlush(sender, telemetryItem); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called && fetchCalls.length > 0; + }, "Waiting for fetch sender and SDK Stats count to be called", 30, 100)).add(() => { + Assert.equal(1, fetchCalls.length, "Fetch sender should be called once"); this.assertInternalSdkStatsCall(439); - return true; - } - return false; - }, "Waiting for fetch sender and SDK Stats count to be called") as any) + }); + } }); - this.testCaseAsync({ + this.testCase({ name: "SDK Stats increments success count for beacon sender", useFakeTimers: true, - stepDelay: 100, - steps: [ - () => { - const config = this.createSenderConfig(TransportType.Beacon); - const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); - - const telemetryItem: ITelemetryItem = { - name: "fake item", - iKey: "testIkey2;ingestionendpoint=testUrl1", - baseType: "some type", - baseData: {} - }; - let sendBeaconCalled = false; - this.hookSendBeacon((url: string) => { - sendBeaconCalled = true; - return true; - }); - QUnit.assert.ok(isBeaconsSupported(), "Beacon API is supported"); - this.processTelemetryAndFlush(sender, telemetryItem); - } - ].concat(PollingAssert.createPollingAssert(() => { - if (this.internalSdkStatsCountSpy.called) { + test: () => { + const config = this.createSenderConfig(TransportType.Beacon); + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + const beaconCalls = this.hookSendBeacon(() => {}); + QUnit.assert.ok(isBeaconsSupported(), "Beacon API is supported"); + this.processTelemetryAndFlush(sender, telemetryItem); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called && beaconCalls.length > 0; + }, "Waiting for beacon sender and SDK Stats count to be called", 30, 100)).add(() => { + Assert.equal(1, beaconCalls.length, "Beacon sender should be called once"); this.assertInternalSdkStatsCall(200); - return true; - } - return false; - }, "Waiting for beacon sender and SDK Stats count to be called") as any) + }); + } }); - this.testCaseAsync({ + this.testCase({ name: "SDK Stats increments success count for xhr sender", useFakeTimers: true, useFakeServer: true, - stepDelay: 100, fakeServerAutoRespond: true, - steps: [ - () => { - let window = getWindow(); - let fakeXMLHttpRequest = (window as any).XMLHttpRequest; // why we do this? - let config: any = this.createSenderConfig(TransportType.Xhr); - config.disableSendBeaconSplit = true; - const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); - console.log("xhr sender called", this._getXhrRequests().length); - - const telemetryItem: ITelemetryItem = { - name: "fake item", - iKey: "testIkey2;ingestionendpoint=testUrl1", - baseType: "some type", - baseData: {} - }; - this.processTelemetryAndFlush(sender, telemetryItem); - QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); - console.log("xhr sender is called", this._getXhrRequests().length); - (window as any).XMLHttpRequest = fakeXMLHttpRequest; - - } - ].concat(PollingAssert.createPollingAssert(() => { - if (this.internalSdkStatsCountSpy.called) { + test: () => { + let config: Partial = this.createSenderConfig(TransportType.Xhr); + config.disableSendBeaconSplit = true; + const { sender } = this.initializeCoreAndSender(config, "000e0000-e000-0000-a000-000000000000"); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "testIkey2;ingestionendpoint=testUrl1", + baseType: "some type", + baseData: {} + }; + this.processTelemetryAndFlush(sender, telemetryItem); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender is called"); + return this._asyncQueue().add(PollingAssert.asyncTaskPollingAssert(() => { + return this.internalSdkStatsCountSpy.called; + }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000)).add(() => { this.assertInternalSdkStatsCall(200); - console.log("SDK Stats count called with success count for xhr sender"); - return true; - } - return false; - }, "Waiting for xhr sender and SDK Stats count to be called", 60, 1000) as any) + }); + } }); } } diff --git a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts index 82a7a1907..f8eb378e7 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/ai/InternalSdkStats.Tests.ts @@ -16,27 +16,6 @@ const STATS_COLLECTION_SHORT_INTERVAL: number = 900; // 15 minutes const STATS_TEST_CFG_URL = "https://tst-data.stats.monitor.azure.com/cfg/v1.json"; const STATS_TEST_HOST = "tst-data.stats.monitor.azure.com"; const STATS_TEST_IKEY = "Stats-Test-iKey"; -function _clearStatsStorage() { - try { - let storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; - if (storage) { - let keys: string[] = []; - for (let lp = 0; lp < storage.length; lp++) { - let key = storage.key(lp); - if (key && key.indexOf("Test-iKey:") === 0) { - keys.push(key); - } - } - - for (let lp = 0; lp < keys.length; lp++) { - storage.removeItem(keys[lp]); - } - } - } catch (e) { - // Session storage may be unavailable. - } -} - function _readStatsStorage(cKey: string, endpoint: string): any { try { let raw = sessionStorage.getItem(cKey + ":" + endpoint); @@ -64,8 +43,6 @@ export class InternalSdkStatsTests extends AITestClass { let _self = this; super.testInitialize(); - _clearStatsStorage(); - _self._config = { instrumentationKey: "Test-iKey", disableInstrumentationKeyValidation: true, @@ -100,8 +77,7 @@ export class InternalSdkStatsTests extends AITestClass { _self._rootTrackSpy = this.sandbox.spy(_self._core, "track"); } - public testCleanup() { - super.testCleanup(); + public testFinishedCleanup() { if (this._core && this._core.isInitialized()) { this._core.unload(false); } @@ -111,7 +87,6 @@ export class InternalSdkStatsTests extends AITestClass { this._core = null as any; this._statsMgr = null as any; this._statsCores = []; - _clearStatsStorage(); } private _createStatsCore(config: IConfiguration): IAppInsightsCore { @@ -132,7 +107,9 @@ export class InternalSdkStatsTests extends AITestClass { featureName: string = "InternalSdkStats", useFeature?: UseFeatureFn ) { - return this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName, useFeature); + let hook = this._statsMgr.init(core, (config) => this._createStatsCore(config), featureName, useFeature); + core.addUnloadHook(hook); + return hook; } public registerTests() {