diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc053f..6d2627d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 23.12.5 + +* Mitigated an issue where the SDK was not emptying the async queue explicity when closing a browser + ## 23.12.4 * Enhanced userAgentData detection for bot filtering diff --git a/cypress/e2e/async_queue.cy.js b/cypress/e2e/async_queue.cy.js new file mode 100644 index 0000000..0ceb60c --- /dev/null +++ b/cypress/e2e/async_queue.cy.js @@ -0,0 +1,328 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper.js"); + +function initMain(clear) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true, + test_mode: true, + clear_stored_id: clear + }); +} + +function event(number) { + return { + key: `event_${number}`, + segmentation: { + id: number + } + }; +}; + + +// All the tests below checks if the functions are working correctly +// Currently tests for 'beforeunload' and 'unload' events has to be done manually by using the throttling option of the browser +describe("Test Countly.q related methods and processes", () => { + // For this tests we disable the internal heatbeat and use processAsyncQueue and sendEventsForced + // So we are able to test if those functions work as intented: + // processAsyncQueue should send events from .q to event queue + // sendEventsForced should send events from event queue to request queue (it also calls processAsyncQueue) + it("Check processAsyncQueue and sendEventsForced works as expected", () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Process the .q (should send things to the event queue) + Countly._internals.processAsyncQueue(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + cy.fetch_local_request_queue().then((rq) => { + // Check that nothing sent to request queue + expect(rq.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that events are now in event queue + expect(eq.length).to.equal(4); + + // Send events from event queue to request queue + Countly._internals.sendEventsForced(); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + }); + }); + }); + //This test is same with the ones above but this time we use change_id to trigger processAsyncQueue + it('Check changing device ID without merge empties the .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Trigger processAsyncQueue by changing device ID without merge + Countly.change_id("new_user_id", false); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue has new device ID's orientation event + expect(eq.length).to.equal(1); + expect(eq[0].key).to.equal("[CLY]_orientation"); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue (second request is begin session for new device ID) + expect(rq.length).to.equal(2); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + // check begin session + expect(rq[1].begin_session).to.equal(1); + }); + }); + }); + }); + }); + // This test checks if clear_stored_id set to true during init we call processAsyncQueue (it sends events from .q to event queue and then to request queue) + it('Check clear_stored_id set to true empties the .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat + Countly.noHeartBeat = true; + Countly.q = []; + localStorage.setItem("YOUR_APP_KEY/cly_id", "old_user_id"); // Set old device ID for clear_stored_id to work + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + // Init the SDK with clear_stored_id set to true + initMain(true); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty because processAsyncQueue sends events from .q to event queue and then to request queue + expect(rq.length).to.equal(0); + + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + // This test checks if calling user_details triggers processAsyncQueue (it sends events from .q to event queue and then to request queue) + it('Check sending user details empties .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Trigger processAsyncQueue by adding user details + Countly.user_details({name: "test_user"}); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue (second request is user details) + expect(rq.length).to.equal(2); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + // check user details + const user_details = JSON.parse(rq[1].user_details); + expect(user_details.name).to.equal("test_user"); + }); + }); + }); + }); + }); + // This Test checks if calling userData.save triggers processAsyncQueue (it sends events from .q to event queue and then to request queue) + it('Check sending custom user info empties .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Trigger processAsyncQueue by saving UserData + Countly.userData.set("name", "test_user"); + Countly.userData.save(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue (second request is user details) + expect(rq.length).to.equal(2); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + // check user data + const user_details = JSON.parse(rq[1].user_details); + expect(user_details.custom.name).to.equal("test_user"); + }); + }); + }); + }); + }); + // This test check if the heartbeat is processing the .q (executes processAsyncQueue) + it('Check if heatbeat is processing .q', () => { + hp.haltAndClearStorage(() => { + // init the SDK + Countly.q = []; + initMain(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that the event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that the request queue is empty + expect(rq.length).to.equal(0); + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + // Wait for heartBeat to process the .q + cy.wait(1500).then(() => { + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty as all must be in request queue + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/modules/Constants.js b/modules/Constants.js index d565a83..0ef2b4f 100644 --- a/modules/Constants.js +++ b/modules/Constants.js @@ -104,7 +104,7 @@ var healthCheckCounterEnum = Object.freeze({ errorMessage: "cly_hc_error_message", }); -var SDK_VERSION = "23.12.4"; +var SDK_VERSION = "23.12.5"; var SDK_NAME = "javascript_native_web"; // Using this on document.referrer would return an array with 17 elements in it. The 12th element (array[11]) would be the path we are looking for. Others would be things like password and such (use https://regex101.com/ to check more) diff --git a/modules/CountlyClass.js b/modules/CountlyClass.js index 2d56f9d..64bf65c 100644 --- a/modules/CountlyClass.js +++ b/modules/CountlyClass.js @@ -191,6 +191,8 @@ class CountlyClass { log(logLevelEnums.DEBUG, "initialize, No device ID type info from the previous session, falling back to DEVELOPER_SUPPLIED, for event flushing"); deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; } + // process async queue before sending events + processAsyncQueue(); sendEventsForced(); // set them back to their initial values this.device_id = undefined; @@ -511,7 +513,12 @@ class CountlyClass { notifyLoaders(); setTimeout(function () { - heartBeat(); + if (!Countly.noHeartBeat) { + heartBeat(); + } else { + log(logLevelEnums.WARNING, "initialize, Heartbeat disabled. This is for testing purposes only!"); + } + if (self.remote_config) { self.fetch_remote_config(self.remote_config); } @@ -533,6 +540,8 @@ class CountlyClass { this.halt = function () { log(logLevelEnums.WARNING, "halt, Resetting Countly"); Countly.i = undefined; + Countly.q = []; + Countly.noHeartBeat = undefined; global = !Countly.i; sessionStarted = false; apiPath = "/i"; @@ -995,6 +1004,8 @@ class CountlyClass { // eslint-disable-next-line eqeqeq if (this.device_id != newId) { if (!merge) { + // process async queue before sending events + processAsyncQueue(); // empty event queue sendEventsForced(); // end current session @@ -1247,7 +1258,10 @@ class CountlyClass { this.user_details = function (user) { log(logLevelEnums.INFO, "user_details, Trying to add user details: ", user); if (this.check_consent(featureEnums.USERS)) { - sendEventsForced(); // flush events to event queue to prevent a drill issue + // process async queue before sending events + processAsyncQueue(); + // flush events to event queue to prevent a drill issue + sendEventsForced(); log(logLevelEnums.INFO, "user_details, flushed the event queue"); // truncating user values and custom object key value pairs user.name = truncateSingleValue(user.name, self.maxValueSize, "user_details", log); @@ -1450,7 +1464,10 @@ class CountlyClass { save: function () { log(logLevelEnums.INFO, "[userData] save, Saving changes to user's custom property"); if (self.check_consent(featureEnums.USERS)) { - sendEventsForced(); // flush events to event queue to prevent a drill issue + // process async queue before sending events + processAsyncQueue(); + // flush events to event queue to prevent a drill issue + sendEventsForced(); log(logLevelEnums.INFO, "user_details, flushed the event queue"); toRequestQueue({ user_details: JSON.stringify({ custom: customData }) }); } @@ -1801,6 +1818,8 @@ class CountlyClass { this.start_time(); // end session on unload add_event_listener(window, "beforeunload", function () { + // process async queue before sending events + processAsyncQueue(); // empty the event queue sendEventsForced(); self.end_session(); @@ -3693,40 +3712,9 @@ class CountlyClass { } hasPulse = true; - var i = 0; // process queue if (global && typeof Countly.q !== "undefined" && Countly.q.length > 0) { - var req; - var q = Countly.q; - Countly.q = []; - for (i = 0; i < q.length; i++) { - req = q[i]; - log(logLevelEnums.DEBUG, "Processing queued call", req); - if (typeof req === "function") { - req(); - } - else if (Array.isArray(req) && req.length > 0) { - var inst = self; - var arg = 0; - // check if it is meant for other tracker - if (Countly.i[req[arg]]) { - inst = Countly.i[req[arg]]; - arg++; - } - if (typeof inst[req[arg]] === "function") { - inst[req[arg]].apply(inst, req.slice(arg + 1)); - } - else if (req[arg].indexOf("userData.") === 0) { - var userdata = req[arg].replace("userData.", ""); - if (typeof inst.userData[userdata] === "function") { - inst.userData[userdata].apply(inst, req.slice(arg + 1)); - } - } - else if (typeof Countly[req[arg]] === "function") { - Countly[req[arg]].apply(Countly, req.slice(arg + 1)); - } - } - } + processAsyncQueue(); } // extend session if needed @@ -3785,6 +3773,48 @@ class CountlyClass { setTimeout(heartBeat, beatInterval); } + /** + * Process queued calls + * @memberof Countly._internals + */ + function processAsyncQueue() { + const q = Countly.q; + Countly.q = []; + for (let i = 0; i < q.length; i++) { + let req = q[i]; + log(logLevelEnums.DEBUG, "Processing queued calls:" + req); + if (typeof req === "function") { + req(); + } + else if (Array.isArray(req) && req.length > 0) { + var inst = self; + var arg = 0; + // check if it is meant for other tracker + try { + if (Countly.i[req[arg]]) { + inst = Countly.i[req[arg]]; + arg++; + } + } catch (error) { + // possibly first init and no other instance + log(logLevelEnums.DEBUG, "No instance found for the provided key while processing async queue"); + } + if (typeof inst[req[arg]] === "function") { + inst[req[arg]].apply(inst, req.slice(arg + 1)); + } + else if (req[arg].indexOf("userData.") === 0) { + var userdata = req[arg].replace("userData.", ""); + if (typeof inst.userData[userdata] === "function") { + inst.userData[userdata].apply(inst, req.slice(arg + 1)); + } + } + else if (typeof Countly[req[arg]] === "function") { + Countly[req[arg]].apply(Countly, req.slice(arg + 1)); + } + } + } + } + /** * Get device ID, stored one, or generate new one * @memberof Countly._internals @@ -4631,6 +4661,7 @@ class CountlyClass { getRequestQueue: getRequestQueue, getEventQueue: getEventQueue, sendFetchRequest: sendFetchRequest, + processAsyncQueue: processAsyncQueue, makeNetworkRequest: makeNetworkRequest, /** * Clear queued data diff --git a/package.json b/package.json index 0105d8c..6e86b99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "countly-sdk-js", - "version": "23.12.4", + "version": "23.12.5", "description": "Countly JavaScript SDK", "type": "module", "main": "Countly.js",