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 b311baac4..50c564d9c 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts @@ -120,6 +120,7 @@ export class SenderTests extends AITestClass { QUnit.assert.equal(10000, defaultSenderConfig.eventsLimitInMem, "Channel default eventsLimitInMem config is set"); QUnit.assert.equal(undefined, defaultSenderConfig.httpXHROverride, "Channel default httpXHROverride config is set"); QUnit.assert.equal(false, defaultSenderConfig.alwaysUseXhrOverride, "Channel default alwaysUseXhrOverride config is set"); + QUnit.assert.equal(false, defaultSenderConfig.disableSendBeaconSplit, "Channel default disableSendBeaconSplit config is set"); //check dynamic config core.config.extensionConfig = core.config.extensionConfig? core.config.extensionConfig : {}; @@ -134,7 +135,8 @@ export class SenderTests extends AITestClass { disableXhr: true, samplingPercentage: 90, customHeaders: [{header: "header1",value:"value1"}], - alwaysUseXhrOverride: true + alwaysUseXhrOverride: true, + disableSendBeaconSplit: true } core.config.extensionConfig[id] = config; this.clock.tick(1); @@ -149,6 +151,7 @@ export class SenderTests extends AITestClass { QUnit.assert.equal(90, curSenderConfig.samplingPercentage, "Channel samplingPercentage config is dynamically set"); QUnit.assert.deepEqual([{header: "header1",value:"value1"}], curSenderConfig.customHeaders, "Channel customHeaders config is dynamically set"); QUnit.assert.deepEqual(true, curSenderConfig.alwaysUseXhrOverride, "Channel alwaysUseXhrOverride config is dynamically set"); + QUnit.assert.equal(true, curSenderConfig.disableSendBeaconSplit, "Channel disableSendBeaconSplit config is dynamically set"); core.config.extensionConfig[this._sender.identifier].emitLineDelimitedJson = undefined; core.config.extensionConfig[this._sender.identifier].endpointUrl = undefined; @@ -1584,6 +1587,270 @@ export class SenderTests extends AITestClass { } }); + this.testCase({ + name: "disableBeaconSplit is set to true, xhr should be used to send data diretly instead of splitting payloads.", + useFakeTimers: true, + test: () => { + let window = getWindow(); + let fetchstub = this.sandbox.stub((window as any), "fetch"); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; + let sessionStorage = window.sessionStorage; + QUnit.assert.ok(sessionStorage, "sessionStorage API is supported"); + sessionStorage.clear(); + + let sendBeaconCalled = false; + this.hookSendBeacon((url: string) => { + sendBeaconCalled = true; + return false; + }); + + const sender = new Sender(); + const cr = new AppInsightsCore(); + + sender.initialize({ + instrumentationKey: "abc", + extensionConfig: { + [sender.identifier]: { + disableSendBeaconSplit: true, + onunloadDisableFetch: true, + disableXhr: true + // to make sure beacon is used + } + } + + }, cr, []); + this.onDone(() => { + sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: "fake item", + iKey: "iKey", + baseType: "some type", + baseData: { + largePayload: new Array(64 + 1).join("test") + } + }; + + let buffer = sender._buffer; + + QUnit.assert.ok(isBeaconApiSupported(), "Beacon API is supported"); + QUnit.assert.equal(false, sendBeaconCalled, "Beacon API was not called before"); + QUnit.assert.equal(0, this._getXhrRequests().length, "xhr sender was not called before"); + QUnit.assert.equal(0, buffer.getItems().length, "sender buffer should be clear"); + + try { + sender.processTelemetry(telemetryItem, null); + QUnit.assert.equal(1, buffer.getItems().length, "sender buffer should have one payload"); + let bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 1, "sender buffer should have one payload"); + let sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(0, sentItems.length, "sent buffer should have zero payload"); + sender.onunloadFlush(); + } catch(e) { + QUnit.assert.ok(false); + } + + this.clock.tick(5); + + QUnit.assert.equal(true, sendBeaconCalled, "Beacon API should be called"); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender should be called"); + let xhrRequest = this._getXhrRequests()[0]; + QUnit.assert.equal(false, fetchstub.called, "fetch sender is not called"); + QUnit.assert.equal(0, buffer.getItems().length, "sender buffer should not have one payload"); + QUnit.assert.equal(0, buffer.count(), "sender buffer should not have any payload"); + let bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 0, "sender buffer should be clear payload"); + let sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(1, sentItems.length, "sent buffer should have only one payload"); + + this.sendJsonResponse(xhrRequest, {}, 200); + bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 0, "sender buffer should be clear payload"); + sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(0, sentItems.length, "sent buffer should have no payload"); + + (window as any).XMLHttpRequest = fakeXMLHttpRequest; + sessionStorage.clear(); + + } + }); + + this.testCase({ + name: "disableBeaconSplit is set to false, xhr should not be called to send small payload.", + test: () => { + let window = getWindow(); + let fetchstub = this.sandbox.stub((window as any), "fetch"); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; + let sessionStorage = window.sessionStorage; + QUnit.assert.ok(sessionStorage, "sessionStorage API is supported"); + sessionStorage.clear(); + + let sendBeaconCalled = 0; + this.hookSendBeacon((url: string) => { + sendBeaconCalled += 1; + return true; + }); + + const sender = new Sender(); + const cr = new AppInsightsCore(); + + sender.initialize({ + instrumentationKey: "abc", + extensionConfig: { + [sender.identifier]: { + onunloadDisableFetch: true, + disableXhr: true + // to make sure beacon is used + } + } + + }, cr, []); + this.onDone(() => { + sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: "item", + iKey: "iKey", + baseType: "type", + baseData: {} + }; + + + let buffer = sender._buffer; + + QUnit.assert.ok(isBeaconApiSupported(), "Beacon API is supported"); + QUnit.assert.equal(0, sendBeaconCalled, "Beacon API was not called before"); + QUnit.assert.equal(0, this._getXhrRequests().length, "xhr sender was not called before"); + QUnit.assert.equal(0, buffer.getItems().length, "sender buffer should be clear"); + + try { + sender.processTelemetry(telemetryItem, null); + QUnit.assert.equal(1, buffer.getItems().length, "sender buffer should have one payload"); + let bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 1, "sender buffer should have one payload"); + let sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(0, sentItems.length, "sent buffer should have zero payload"); + sender.onunloadFlush(); + } catch(e) { + QUnit.assert.ok(false); + } + + QUnit.assert.equal(1, sendBeaconCalled, "Beacon API should be called"); + QUnit.assert.equal(0, this._getXhrRequests().length, "xhr sender should be called"); + QUnit.assert.equal(false, fetchstub.called, "fetch sender is not called"); + QUnit.assert.equal(0, buffer.getItems().length, "sender buffer should not have one payload"); + QUnit.assert.equal(0, buffer.count(), "sender buffer should not have any payload"); + let bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 0, "sender buffer should be clear payload"); + let sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(0, sentItems.length, "sent buffer should not have one payload"); + + (window as any).XMLHttpRequest = fakeXMLHttpRequest; + sessionStorage.clear(); + + } + }); + + this.testCase({ + name: "disableBeaconSplit is set to false, xhr should be called to send large payload.", + useFakeTimers: true, + test: () => { + let window = getWindow(); + let fetchstub = this.sandbox.stub((window as any), "fetch"); + let fakeXMLHttpRequest = (window as any).XMLHttpRequest; + let sessionStorage = window.sessionStorage; + QUnit.assert.ok(sessionStorage, "sessionStorage API is supported"); + sessionStorage.clear(); + + let sendBeaconCalled = 0; + this.hookSendBeacon((url: string) => { + sendBeaconCalled += 1; + if (sendBeaconCalled == 2) { + return true; + } + return false; + }); + + const sender = new Sender(); + const cr = new AppInsightsCore(); + + sender.initialize({ + instrumentationKey: "abc", + extensionConfig: { + [sender.identifier]: { + onunloadDisableFetch: true, + disableXhr: true + // to make sure beacon is used + } + } + + }, cr, []); + this.onDone(() => { + sender.teardown(); + }); + + const telemetryItem: ITelemetryItem = { + name: "item", + iKey: "iKey", + baseType: "type", + baseData: {} + }; + + const telemetryItem1: ITelemetryItem = { + name: "item", + iKey: "iKey1", + baseType: "type", + baseData: {} + }; + + + let buffer = sender._buffer; + + QUnit.assert.ok(isBeaconApiSupported(), "Beacon API is supported"); + QUnit.assert.equal(0, sendBeaconCalled, "Beacon API was not called before"); + QUnit.assert.equal(0, this._getXhrRequests().length, "xhr sender was not called before"); + QUnit.assert.equal(0, buffer.getItems().length, "sender buffer should be clear"); + + try { + sender.processTelemetry(telemetryItem, null); + sender.processTelemetry(telemetryItem1, null); + QUnit.assert.equal(2, buffer.getItems().length, "sender buffer should have one payload"); + let bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 2, "sender buffer should have one payload"); + let sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(0, sentItems.length, "sent buffer should have zero payload"); + sender.onunloadFlush(); + } catch(e) { + QUnit.assert.ok(false); + } + this.clock.tick(5); + + QUnit.assert.equal(3, sendBeaconCalled, "Beacon API should be called 3 times"); + QUnit.assert.equal(1, this._getXhrRequests().length, "xhr sender should be called"); + let xhrRequest = this._getXhrRequests()[0]; + QUnit.assert.equal(false, fetchstub.called, "fetch sender is not called"); + QUnit.assert.equal(0, buffer.getItems().length, "sender buffer should not have one payload"); + QUnit.assert.equal(0, buffer.count(), "sender buffer should not have any payload"); + let bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 0, "sender buffer should be clear payload"); + let sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(1, sentItems.length, "sent buffer should have one payload"); + QUnit.assert.ok(sentItems[0].indexOf("iKey1") >= 0, "sent buffer should have ikey1 payload"); + + this.sendJsonResponse(xhrRequest, {}, 200); + bufferItems = JSON.parse(sessionStorage.getItem("AI_buffer") as any); + QUnit.assert.equal(bufferItems.length, 0, "sender buffer should have no payload test1"); + sentItems = JSON.parse(sessionStorage.getItem("AI_sentBuffer") as any); + QUnit.assert.equal(0, sentItems.length, "sent buffer should have zero payload test1"); + + (window as any).XMLHttpRequest = fakeXMLHttpRequest; + sessionStorage.clear(); + + } + }); + this.testCase({ name: 'FetchAPI is used when isBeaconApiDisabled flag is true and disableXhr flag is true , use fetch sender.', test: () => { diff --git a/channels/applicationinsights-channel-js/src/Interfaces.ts b/channels/applicationinsights-channel-js/src/Interfaces.ts index cb9ad6cca..ed428f234 100644 --- a/channels/applicationinsights-channel-js/src/Interfaces.ts +++ b/channels/applicationinsights-channel-js/src/Interfaces.ts @@ -116,6 +116,13 @@ export interface ISenderConfig { */ alwaysUseXhrOverride?: boolean; + /** + * [Optional] Disable events splitting during sendbeacon. + * Default: false + * @since 3.0.6 + */ + disableSendBeaconSplit?: boolean; + /** * [Optional] Either an array or single value identifying the requested TransportType type that should be used. * This is used during initialization to identify the requested send transport, it will be ignored if a httpXHROverride is provided. @@ -128,6 +135,8 @@ export interface ISenderConfig { * is provided and alwaysUseXhrOverride is true. */ unloadTransports?: number | number[]; + + } export interface IBackendResponse { diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index ae52000da..b41b20c3d 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -15,7 +15,7 @@ import { useXDomainRequest } from "@microsoft/applicationinsights-core-js"; import { IPromise, createPromise, doAwaitResponse } from "@nevware21/ts-async"; -import { ITimerHandler, isNumber, isString, isTruthy, objDeepFreeze, objDefine, scheduleTimeout } from "@nevware21/ts-utils"; +import { ITimerHandler, isNumber, isTruthy, objDeepFreeze, objDefine, scheduleTimeout } from "@nevware21/ts-utils"; import { DependencyEnvelopeCreator, EventEnvelopeCreator, ExceptionEnvelopeCreator, MetricEnvelopeCreator, PageViewEnvelopeCreator, PageViewPerformanceEnvelopeCreator, TraceEnvelopeCreator @@ -74,6 +74,7 @@ const defaultAppInsightsChannelConfig: IConfigDefaults = objDeepF enableSessionStorageBuffer: cfgDfBoolean(true), isRetryDisabled: cfgDfBoolean(), isBeaconApiDisabled: cfgDfBoolean(true), + disableSendBeaconSplit: cfgDfBoolean(), disableXhr: cfgDfBoolean(), onunloadDisableFetch: cfgDfBoolean(), onunloadDisableBeacon: cfgDfBoolean(), @@ -187,6 +188,8 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { let _disableXhr: boolean; let _fetchKeepAlive: boolean; let _xhrSend: SenderFunction; + let _fallbackSend: SenderFunction; + let _disableBeaconSplit: boolean; dynamicProto(Sender, this, (_self, _base) => { @@ -342,6 +345,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _sessionStorageUsed = canUseSessionStorage; _bufferOverrideUsed = bufferOverride; _fetchKeepAlive = !senderConfig.onunloadDisableFetch && isFetchSupported(true); + _disableBeaconSplit = !!senderConfig.disableSendBeaconSplit; _self._sample = new Sample(senderConfig.samplingPercentage, diagLog); @@ -376,6 +380,9 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _xhrSend = (payload: string[], isAsync: boolean) => { return _doSend(xhrInterface, payload, isAsync); }; + _fallbackSend = (payload: string[], isAsync: boolean) => { // for fallback send, should NOT mark payload as sent again! + return _doSend(xhrInterface, payload, isAsync, false); + }; httpInterface = _alwaysUseCustomSend? customInterface : (httpInterface || customInterface || xhrInterface); @@ -705,7 +712,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { response && _self._onError(payload, response); } - function _doSend(sendInterface: IXHROverride, payload: string[], isAsync: boolean): void | IPromise { + function _doSend(sendInterface: IXHROverride, payload: string[], isAsync: boolean, markAsSent: boolean = true): void | IPromise { let onComplete = (status: number, headers: {[headerName: string]: string;}, response?: string) => { return _getOnComplete(payload, status, headers, response); } @@ -715,7 +722,10 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (sendPostFunc && payloadData) { // *********************************************************************************************** // mark payload as sent at the beginning of calling each send function - _self._buffer.markAsSent(payload); + if (markAsSent) { + _self._buffer.markAsSent(payload); + } + return sendPostFunc(payloadData, onComplete, !isAsync); } return null; @@ -862,18 +872,27 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } - function _doBeaconSend(payload: string, oncomplete?: OnCompleteCallback) { + function _doBeaconSend(payload: string[], oncomplete?: OnCompleteCallback) { const nav = getNavigator(); const url = _endpointUrl; + const buffer = _self._buffer; // Chrome only allows CORS-safelisted values for the sendBeacon data argument // see: https://bugs.chromium.org/p/chromium/issues/detail?id=720283 - const plainTextBatch = new Blob([payload], { type: "text/plain;charset=UTF-8" }); + const batch = buffer.batchPayloads(payload); + + // Chrome only allows CORS-safelisted values for the sendBeacon data argument + // see: https://bugs.chromium.org/p/chromium/issues/detail?id=720283 + const plainTextBatch = new Blob([batch], { type: "text/plain;charset=UTF-8" }); // The sendBeacon method returns true if the user agent is able to successfully queue the data for transfer. Otherwise it returns false. const queued = nav.sendBeacon(url, plainTextBatch); + if (queued) { - oncomplete(200, {}, payload) + // Should NOT pass onComplete directly since onComplete will always be called at full batch level + //buffer.markAsSent(payload); + // no response from beaconSender, clear buffer + _self._onSuccess(payload, payload.length); } return queued; @@ -888,27 +907,26 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { */ function _beaconSender(payload: IPayloadData, oncomplete: OnCompleteCallback, sync?: boolean) { let internalPayload = payload as IInternalPayloadData; - let data = internalPayload && internalPayload.data; - if (isString(data) && data.length > 0) { + let data = internalPayload && internalPayload.oriPayload; + if (isArray(data) && data.length > 0) { // The sendBeacon method returns true if the user agent is able to successfully queue the data for transfer. Otherwise it returns false. if (!_doBeaconSend(data, oncomplete)) { - // Failed to send entire payload so try and split data and try to send as much events as possible - let droppedPayload: string[] = []; - let oriPayload = internalPayload.oriPayload; - if (oriPayload.length > 0) { + if (!_disableBeaconSplit) { + // Failed to send entire payload so try and split data and try to send as much events as possible + let droppedPayload: string[] = []; for (let lp = 0; lp < data.length; lp++) { - const thePayload = payload[lp]; - const batch = _self._buffer.batchPayloads(thePayload); - - if (!_doBeaconSend(batch, oncomplete)) { + const thePayload = data[lp]; + if (!_doBeaconSend([thePayload], oncomplete)) { // Can't send anymore, so split the batch and drop the rest droppedPayload.push(thePayload); } } - } - - if (droppedPayload.length > 0) { - _xhrSend && _xhrSend(droppedPayload, true); + if (droppedPayload.length > 0) { + _fallbackSend && _fallbackSend(droppedPayload, true); + _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.TransmissionFailed, ". " + "Failed to send telemetry with Beacon API, retried with normal sender."); + } + } else { + _fallbackSend && _fallbackSend(data, true); _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.TransmissionFailed, ". " + "Failed to send telemetry with Beacon API, retried with normal sender."); } } @@ -934,6 +952,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { // If the environment has locked down the XMLHttpRequest (preventExtensions and/or freeze), this would // cause the request to fail and we no telemetry would be sent } + xhr.open("POST", endPointUrl, !sync); xhr.setRequestHeader("Content-type", "application/json"); @@ -966,7 +985,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { }); } - xhr.send(payload.data); return thePromise; @@ -991,7 +1009,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _beaconSender(payloadData, onComplete, !isAsync); } else { // Payload is going to be too big so just try and send via XHR - _xhrSend && _xhrSend(payload, true); + _fallbackSend && _fallbackSend(payload, true); _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.TransmissionFailed, ". " + "Failed to send telemetry with Beacon API, retried with xhrSender."); } } @@ -1350,7 +1368,9 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _namePrefix = UNDEFINED_VALUE; _disableXhr = false; _fetchKeepAlive = false; + _disableBeaconSplit = false; _xhrSend = null; + _fallbackSend = null; objDefine(_self, "_senderConfig", { g: function() {