From af220a304f90544ad6491c914951f8bc91fcf31b Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 16 Jun 2026 17:09:14 -0700 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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";