diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 51a14297b..0fd03f7b4 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -273,11 +273,11 @@ export class AppInsightsSku implements IApplicationInsights { _self.snippet = snippet; - _self.flush = (async: boolean = true, callBack?: () => void) => { + _self.flush = (isAsync: boolean = true, callBack?: () => void) => { let result: void | IPromise; doPerf(_core, () => "AISKU.flush", () => { - if (async && !callBack) { + if (isAsync && !callBack) { result = createPromise((resolve) => { callBack = resolve; }); @@ -294,23 +294,23 @@ export class AppInsightsSku implements IApplicationInsights { arrForEach(_core.getChannels(), channel => { if (channel) { waiting++; - channel.flush(async, flushDone); + channel.flush(isAsync, flushDone); } }); // decrement the initial "waiting" flushDone(); - }, null, async); + }, null, isAsync); return result; }; - _self.onunloadFlush = (async: boolean = true) => { + _self.onunloadFlush = (isAsync: boolean = true) => { arrForEach(_core.getChannels(), (channel: IChannelControls & Sender) => { if (channel.onunloadFlush) { channel.onunloadFlush(); } else { - channel.flush(async); + channel.flush(isAsync); } }); }; diff --git a/AISKULight/src/index.ts b/AISKULight/src/index.ts index 903107c73..fabb7bcbb 100644 --- a/AISKULight/src/index.ts +++ b/AISKULight/src/index.ts @@ -164,9 +164,9 @@ export class ApplicationInsights { /** * Immediately send all batched telemetry - * @param async - Should the flush be performed asynchronously + * @param isAsync - Should the flush be performed asynchronously */ - public flush(async: boolean = true) { + public flush(isAsync: boolean = true) { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } diff --git a/RELEASES.md b/RELEASES.md index b06956798..e2f37db6f 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -2,6 +2,34 @@ > Note: ES3/IE8 compatibility will be removed in the future v3.x.x releases (scheduled for mid-late 2022), so if you need to retain ES3 compatibility you will need to remain on the 2.x.x versions of the SDK or your runtime will need install polyfill's to your ES3 environment before loading / initializing the SDK. +## Unreleased Changes + +### Potential breaking changes + +This release contains a potential breaking change to the `flush` method signature in the `IChannelControls` interface. The parameter name has been changed from `async` to `isAsync` to avoid potential conflicts with the `async` keyword. + +**Interface change:** +```typescript +// Before: +flush(async: boolean = true, callBack?: (flushComplete?: boolean) => void): void | IPromise; + +// After: +flush(isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise; +``` + +**This is only a breaking change if you rely on named parameters.** If you have custom channels or plugins that implement the `IChannelControls` interface directly and rely on passing named parameters, you will need to update the parameter name from `async` to `isAsync` in your implementation. + +### Changelog + +- #2628 Fix flush method root cause - handle async callbacks in _doSend with proper error handling + - **Potential breaking change**: Renamed `flush` method parameter from `async` to `isAsync` in `IChannelControls` interface to avoid potential keyword conflicts (only affects code that relies on named parameters) + - Fixed return type of `flush` method to properly include `boolean` when callbacks complete synchronously + - Fixed root cause where `_doSend()` couldn't handle asynchronous callbacks from `preparePayload()` when compression is enabled + - `await applicationInsights.flush()` now works correctly with compression enabled + - Added proper error handling and promise rejection propagation through async callback chains + - Improved handling of both synchronous and asynchronous callback execution patterns + - No polling overhead - uses direct callback invocation for better performance + ## 3.3.9 (June 25th, 2025) This release contains an important fix for a change introduced in v3.3.7 that caused the `autoCaptureHandler` to incorrectly evaluate elements within `trackElementsType`, resulting in some click events not being auto-captured. See more details [here](https://github.com/microsoft/ApplicationInsights-JS/issues/2589). diff --git a/channels/1ds-post-js/src/PostChannel.ts b/channels/1ds-post-js/src/PostChannel.ts index c5b40ebbf..6a7998d6b 100644 --- a/channels/1ds-post-js/src/PostChannel.ts +++ b/channels/1ds-post-js/src/PostChannel.ts @@ -520,14 +520,14 @@ export class PostChannel extends BaseTelemetryPlugin implements IChannelControls }; - _self.flush = (async: boolean = true, callback?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise => { + _self.flush = (isAsync: boolean = true, callback?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise => { let result: IPromise; if (!_paused) { sendReason = sendReason || SendRequestReason.ManualFlush; - if (async) { + if (isAsync) { if (!callback) { result = createPromise((resolve) => { @@ -1211,7 +1211,7 @@ export class PostChannel extends BaseTelemetryPlugin implements IChannelControls * you DO NOT pass a callback function then a [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) * will be returned which will resolve once the flush is complete. The actual implementation of the `IPromise` * will be a native Promise (if supported) or the default as supplied by [ts-async library](https://github.com/nevware21/ts-async) - * @param async - send data asynchronously when true + * @param isAsync - send data asynchronously when true * @param callBack - if specified, notify caller when send is complete, the channel should return true to indicate to the caller that it will be called. * If the caller doesn't return true the caller should assume that it may never be called. * @param sendReason - specify the reason that you are calling "flush" defaults to ManualFlush (1) if not specified @@ -1219,9 +1219,9 @@ export class PostChannel extends BaseTelemetryPlugin implements IChannelControls * should assume that any provided callback will never be called, Nothing or if occurring asynchronously a * [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) which will be resolved once the unload is complete, * the [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) will only be returned when no callback is provided - * and async is true. + * and isAsync is true. */ - public flush(async: boolean = true, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise { + public flush(isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise { // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging } diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts index a4e09ac84..d780b6c12 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts @@ -7,6 +7,7 @@ import { ITelemetryItem, AppInsightsCore, ITelemetryPlugin, DiagnosticLogger, No import { ArraySendBuffer, SessionStorageSendBuffer } from "../../../src/SendBuffer"; import { IInternalStorageItem, ISenderConfig } from "../../../src/Interfaces"; import { createAsyncResolvedPromise } from "@nevware21/ts-async"; +import { isPromiseLike, isUndefined } from "@nevware21/ts-utils"; import { SinonSpy } from 'sinon'; @@ -4173,6 +4174,181 @@ export class SenderTests extends AITestClass { QUnit.assert.equal(1024, appInsightsEnvelope.tags["ai.operation.name"].length, "The ai.operation.name should have been truncated to the maximum"); } }); + + this.testCase({ + name: "flush method handles synchronous callback execution", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + this._sender.initialize({ + instrumentationKey: 'abc', + endpointUrl: 'https://example.com', + isBeaconApiDisabled: true + }, core, []); + this.onDone(() => { + this._sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: 'test item', + iKey: 'iKey', + baseType: 'some type', + baseData: {} + }; + + // Add some telemetry to flush + this._sender.processTelemetry(telemetryItem); + + // Test sync flush with callback + let callbackCalled = false; + let callbackResult: boolean; + const result = this._sender.flush(false, (success) => { + callbackCalled = true; + callbackResult = success; + }); + + QUnit.assert.equal(typeof result, 'boolean', "flush should return boolean when callback provided"); + QUnit.assert.equal(result, true, "flush should return true when callback will be called"); + QUnit.assert.equal(callbackCalled, true, "callback should be called synchronously"); + QUnit.assert.equal(callbackResult, true, "callback should receive success=true"); + } + }); + + this.testCase({ + name: "flush method handles asynchronous callback execution without callback", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + this._sender.initialize({ + instrumentationKey: 'abc', + endpointUrl: 'https://example.com', + isBeaconApiDisabled: true + }, core, []); + this.onDone(() => { + this._sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: 'test item', + iKey: 'iKey', + baseType: 'some type', + baseData: {} + }; + + // Add some telemetry to flush + this._sender.processTelemetry(telemetryItem); + + // Test async flush without callback - should return promise-like + const result = this._sender.flush(true); + + // Check if result is promise-like (has then method) + QUnit.assert.ok(isPromiseLike(result), "flush should return promise-like object when async=true and no callback"); + } + }); + + this.testCase({ + name: "flush method handles asynchronous callback execution with callback", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + this._sender.initialize({ + instrumentationKey: 'abc', + endpointUrl: 'https://example.com', + isBeaconApiDisabled: true + }, core, []); + this.onDone(() => { + this._sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: 'test item', + iKey: 'iKey', + baseType: 'some type', + baseData: {} + }; + + // Add some telemetry to flush + this._sender.processTelemetry(telemetryItem); + + // Test async flush with callback + let callbackCalled = false; + let callbackResult: boolean; + const result = this._sender.flush(true, (success) => { + callbackCalled = true; + callbackResult = success; + }); + + QUnit.assert.equal(typeof result, 'boolean', "flush should return boolean when callback provided"); + QUnit.assert.equal(result, true, "flush should return true when callback will be called"); + QUnit.assert.equal(callbackCalled, true, "callback should be called synchronously even when async=true"); + QUnit.assert.equal(callbackResult, true, "callback should receive success=true"); + } + }); + + this.testCase({ + name: "flush method returns correct boolean result for sync operation", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + this._sender.initialize({ + instrumentationKey: 'abc', + endpointUrl: 'https://example.com', + isBeaconApiDisabled: true + }, core, []); + this.onDone(() => { + this._sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: 'test item', + iKey: 'iKey', + baseType: 'some type', + baseData: {} + }; + + // Add some telemetry to flush + this._sender.processTelemetry(telemetryItem); + + // Test sync flush without callback - should return undefined/void + const result = this._sender.flush(false); + + QUnit.assert.ok(isUndefined(result), "flush should return undefined when sync=true and no callback"); + } + }); + + this.testCase({ + name: "flush method handles paused state correctly", + useFakeTimers: true, + test: () => { + let core = new AppInsightsCore(); + this._sender.initialize({ + instrumentationKey: 'abc', + endpointUrl: 'https://example.com', + isBeaconApiDisabled: true + }, core, []); + this.onDone(() => { + this._sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: 'test item', + iKey: 'iKey', + baseType: 'some type', + baseData: {} + }; + + // Add some telemetry to flush + this._sender.processTelemetry(telemetryItem); + + // Pause the sender + this._sender.pause(); + + // Test flush when paused - should return undefined + const result = this._sender.flush(true); + + QUnit.assert.ok(isUndefined(result), "flush should return undefined when sender is paused"); + } + }); } } diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index e32fb69c8..6f08dcda4 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -14,7 +14,7 @@ import { isArray, isBeaconsSupported, isFeatureEnabled, isFetchSupported, isNullOrUndefined, mergeEvtNamespace, objExtend, onConfigChange, parseResponse, prependTransports, runTargetUnload } from "@microsoft/applicationinsights-core-js"; -import { IPromise } from "@nevware21/ts-async"; +import { IPromise, createPromise, doAwaitResponse, doAwait } from "@nevware21/ts-async"; import { ITimerHandler, getInst, isFunction, isNumber, isPromiseLike, isString, isTruthy, mathFloor, mathMax, mathMin, objDeepFreeze, objDefine, scheduleTimeout @@ -210,13 +210,30 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } }; - _self.flush = (isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason) => { + _self.flush = (isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise => { if (!_paused) { // Clear the normal schedule timer as we are going to try and flush ASAP _clearScheduledTimer(); try { - return _self.triggerSend(isAsync, null, sendReason || SendRequestReason.ManualFlush); + let result = _self.triggerSend(isAsync, null, sendReason || SendRequestReason.ManualFlush); + + // Handles non-promise and always called if the returned promise resolves or rejects + return doAwaitResponse(result as any, (rsp) => { + if (callBack) { + callBack(!rsp.rejected); + return true; + } + + // When async=true and no callback, return a promise + if (isAsync) { + return createPromise((resolve) => { + resolve(!rsp.rejected); + }); + } + + return result; + }); } catch (e) { _throwInternal(_self.diagLog(), eLoggingSeverity.CRITICAL, _eInternalMessageId.FlushFailed, @@ -539,10 +556,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { /** * Immediately send buffered data - * @param async - Indicates if the events should be sent asynchronously + * @param isAsync - Indicates if the events should be sent asynchronously * @param forcedSender - Indicates the forcedSender, undefined if not passed */ - _self.triggerSend = (async = true, forcedSender?: SenderFunction, sendReason?: SendRequestReason) => { + _self.triggerSend = (isAsync = true, forcedSender?: SenderFunction, sendReason?: SendRequestReason) => { let result: void | IPromise; if (!_paused) { try { @@ -554,13 +571,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (buffer.count() > 0) { const payload = buffer.getItems(); - _notifySendRequest(sendReason||SendRequestReason.Undefined, async); + _notifySendRequest(sendReason||SendRequestReason.Undefined, isAsync); // invoke send if (forcedSender) { - result = forcedSender.call(_self, payload, async); + result = forcedSender.call(_self, payload, isAsync); } else { - result = _self._sender(payload, async); + result = _self._sender(payload, isAsync); } } @@ -1010,9 +1027,28 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _self._buffer.markAsSent(payload); } + let result: void | IPromise; + let callbackExecuted = false; + let resolveFn: any; + let rejectFn: any; + _sendPostMgr.preparePayload((processedPayload: IPayloadData) => { - return sendPostFunc(processedPayload, onComplete, !isAsync); + result = sendPostFunc(processedPayload, onComplete, !isAsync); + callbackExecuted = true; + if (resolveFn) { + doAwait(result as any, resolveFn, rejectFn); + } }, _zipPayload, payloadData, !isAsync); + + if (callbackExecuted) { + return result; + } + + // Callback was not executed synchronously, so we need to return a promise + return createPromise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); } return null; } @@ -1436,7 +1472,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { * you DO NOT pass a callback function then a [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) * will be returned which will resolve once the flush is complete. The actual implementation of the `IPromise` * will be a native Promise (if supported) or the default as supplied by [ts-async library](https://github.com/nevware21/ts-async) - * @param async - send data asynchronously when true + * @param isAsync - send data asynchronously when true * @param callBack - if specified, notify caller when send is complete, the channel should return true to indicate to the caller that it will be called. * If the caller doesn't return true the caller should assume that it may never be called. * @param sendReason - specify the reason that you are calling "flush" defaults to ManualFlush (1) if not specified @@ -1444,9 +1480,9 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { * should assume that any provided callback will never be called, Nothing or if occurring asynchronously a * [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) which will be resolved once the unload is complete, * the [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) will only be returned when no callback is provided - * and async is true. + * and isAsync is true. */ - public flush(async: boolean = true, callBack?: (flushComplete?: boolean) => void): void | IPromise { + public flush(isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } @@ -1482,13 +1518,13 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { * an [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) that will resolve once the * send is complete. The actual implementation of the `IPromise` will be a native Promise (if supported) or the default * as supplied by [ts-async library](https://github.com/nevware21/ts-async) - * @param async - Indicates if the events should be sent asynchronously + * @param isAsync - Indicates if the events should be sent asynchronously * @param forcedSender - Indicates the forcedSender, undefined if not passed * @returns - Nothing or optionally, if occurring asynchronously a [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) * which will be resolved (or reject) once the send is complete, the [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) - * should only be returned when async is true. + * should only be returned when isAsync is true. */ - public triggerSend(async = true, forcedSender?: SenderFunction, sendReason?: SendRequestReason): void | IPromise { + public triggerSend(isAsync = true, forcedSender?: SenderFunction, sendReason?: SendRequestReason): void | IPromise { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IChannelControls.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IChannelControls.ts index 3ea691b65..038874ba5 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IChannelControls.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IChannelControls.ts @@ -83,7 +83,7 @@ export interface IChannelControls extends ITelemetryPlugin { * you DO NOT pass a callback function then a [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) * will be returned which will resolve once the flush is complete. The actual implementation of the `IPromise` * will be a native Promise (if supported) or the default as supplied by [ts-async library](https://github.com/nevware21/ts-async) - * @param async - send data asynchronously when true + * @param isAsync - send data asynchronously when true * @param callBack - if specified, notify caller when send is complete, the channel should return true to indicate to the caller that it will be called. * If the caller doesn't return true the caller should assume that it may never be called. * @param sendReason - specify the reason that you are calling "flush" defaults to ManualFlush (1) if not specified @@ -91,9 +91,9 @@ export interface IChannelControls extends ITelemetryPlugin { * should assume that any provided callback will never be called, Nothing or if occurring asynchronously a * [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) which will be resolved once the unload is complete, * the [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) will only be returned when no callback is provided - * and async is true. + * and isAsync is true. */ - flush?(async: boolean, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise; + flush?(isAsync: boolean, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): boolean | void | IPromise; /** * Get offline support