diff --git a/src/apiClient.ts b/src/apiClient.ts index 5e0a2fe76..ebc37dd3f 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -56,7 +56,10 @@ export default function APIClient( const millis: number = parseNumber(mpInstance._Helpers.getFeatureFlag( Constants.FeatureFlags.EventBatchingIntervalMillis ) as string); - this.uploader = new BatchUploader(mpInstance, millis); + const quickMillis: number = parseNumber(mpInstance._Helpers.getFeatureFlag( + Constants.FeatureFlags.QuickBatchIntervalMillis + ) as string); + this.uploader = new BatchUploader(mpInstance, millis, quickMillis); } this.uploader.queueEvent(event); diff --git a/src/batchUploader.ts b/src/batchUploader.ts index 54acfb5ac..1243dd8e1 100644 --- a/src/batchUploader.ts +++ b/src/batchUploader.ts @@ -36,6 +36,7 @@ export class BatchUploader { static readonly CONTENT_TYPE: string = 'text/plain;charset=UTF-8'; static readonly MINIMUM_INTERVAL_MILLIS: number = 500; uploadIntervalMillis: number; + quickBatchIntervalMillis: number; eventsQueuedForProcessing: SDKEvent[]; batchesQueuedForProcessing: Batch[]; mpInstance: IMParticleWebSDKInstance; @@ -53,14 +54,17 @@ export class BatchUploader { * @param {IMParticleWebSDKInstance} mpInstance - the mParticle SDK instance * @param {number} uploadInterval - the desired upload interval in milliseconds */ - constructor(mpInstance: IMParticleWebSDKInstance, uploadInterval: number) { + constructor(mpInstance: IMParticleWebSDKInstance, uploadInterval: number, quickBatchIntervalMillis: number = 2000) { this.mpInstance = mpInstance; this.uploadIntervalMillis = uploadInterval; + this.quickBatchIntervalMillis = quickBatchIntervalMillis; this.batchingEnabled = uploadInterval >= BatchUploader.MINIMUM_INTERVAL_MILLIS; if (this.uploadIntervalMillis < BatchUploader.MINIMUM_INTERVAL_MILLIS) { this.uploadIntervalMillis = BatchUploader.MINIMUM_INTERVAL_MILLIS; } + + const { getFeatureFlag } = this.mpInstance._Helpers; // Events will be queued during `queueEvents` method this.eventsQueuedForProcessing = []; @@ -104,6 +108,18 @@ export class BatchUploader { ? new FetchUploader(this.uploadUrl) : new XHRUploader(this.uploadUrl); + // TODO: Create Feature Flag for Quick Batch Interval + if (this.quickBatchIntervalMillis < this.uploadIntervalMillis) { + setTimeout(() => { + if (getFeatureFlag(Constants.FeatureFlags.AstBackgroundEvents)) { + const event = this.createBackgroundASTEvent(); + this.queueEvent(event); + } + + this.prepareAndUpload(false, false); + }, this.quickBatchIntervalMillis); + } + this.triggerUploadInterval(true, false); this.addEventListeners(); } diff --git a/src/constants.ts b/src/constants.ts index ee8d4b721..910932094 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -129,6 +129,7 @@ const Constants = { maxCookieSize: 3000, // Number of bytes for cookie size to not exceed aliasMaxWindow: 90, // Max age of Alias request startTime, in days uploadInterval: 0, // Maximum milliseconds in between batch uploads, below 500 will mean immediate upload. The server returns this as a string, but we are using it as a number internally + quickBatchIntervalMillis: 2000, // Maximum milliseconds in between quick batch uploads }, DefaultBaseUrls: { v1SecureServiceUrl: 'jssdks.mparticle.com/v1/JS/', @@ -194,6 +195,7 @@ const Constants = { // - 'roktonly' → capture only Rokt-related IDs CaptureIntegrationSpecificIdsV2: 'captureIntegrationSpecificIdsV2', AstBackgroundEvents: 'astBackgroundEvents', + QuickBatchIntervalMillis: 'quickBatchIntervalMillis', }, DefaultInstance: 'default_instance', CCPAPurpose: 'data_sale_opt_out', diff --git a/src/store.ts b/src/store.ts index 637d57d3c..5e90a1537 100644 --- a/src/store.ts +++ b/src/store.ts @@ -147,6 +147,7 @@ export interface IFeatureFlags { captureIntegrationSpecificIds?: boolean; captureIntegrationSpecificIdsV2?: string; astBackgroundEvents?: boolean; + quickBatchIntervalMillis?: number; } // Temporary Interface until Store can be refactored as a class @@ -773,7 +774,8 @@ export function processFlags(config: SDKInitConfig): IFeatureFlags { AudienceAPI, CaptureIntegrationSpecificIds, CaptureIntegrationSpecificIdsV2, - AstBackgroundEvents + AstBackgroundEvents, + QuickBatchIntervalMillis, } = Constants.FeatureFlags; if (!config.flags) { @@ -794,6 +796,7 @@ export function processFlags(config: SDKInitConfig): IFeatureFlags { flags[CaptureIntegrationSpecificIds] = config.flags[CaptureIntegrationSpecificIds] === 'True'; flags[CaptureIntegrationSpecificIdsV2] = (config.flags[CaptureIntegrationSpecificIdsV2] || 'none'); flags[AstBackgroundEvents] = config.flags[AstBackgroundEvents] === 'True'; + flags[QuickBatchIntervalMillis] = config.flags[QuickBatchIntervalMillis] || Constants.DefaultConfig.quickBatchIntervalMillis; return flags; } diff --git a/test/jest/batchUploader.spec.ts b/test/jest/batchUploader.spec.ts index 944d2a7a0..34b579148 100644 --- a/test/jest/batchUploader.spec.ts +++ b/test/jest/batchUploader.spec.ts @@ -30,7 +30,7 @@ describe('BatchUploader', () => { } } as unknown as IMParticleWebSDKInstance; - batchUploader = new BatchUploader(mockMPInstance, 1000); + batchUploader = new BatchUploader(mockMPInstance, 1000, 2000); }); afterEach(() => { diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index 81513029e..31f107e3d 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -39,4 +39,5 @@ import './tests-identityApiClient'; import './tests-integration-capture'; import './tests-batchUploader_4'; import './tests-identity'; +import './tests-quick-batch'; diff --git a/test/src/tests-batchUploader_3.ts b/test/src/tests-batchUploader_3.ts index 9009250ee..e5888659a 100644 --- a/test/src/tests-batchUploader_3.ts +++ b/test/src/tests-batchUploader_3.ts @@ -19,7 +19,6 @@ const enableBatchingConfigFlags = { }; describe('batch uploader', () => { - let mockServer; let clock; beforeEach(() => { diff --git a/test/src/tests-quick-batch.ts b/test/src/tests-quick-batch.ts new file mode 100644 index 000000000..95eb562a7 --- /dev/null +++ b/test/src/tests-quick-batch.ts @@ -0,0 +1,411 @@ +import sinon from 'sinon'; +import { urls, apiKey, MPConfig, testMPID } from './config/constants'; +import { + IMParticleInstanceManager, + SDKProduct, +} from '../../src/sdkRuntimeModels'; +import Utils from './config/utils'; +import { expect } from 'chai'; +import _BatchValidator from '../../src/mockBatchCreator'; +import fetchMock from 'fetch-mock/esm/client'; +import { ProductActionType } from '../../src/types'; +const { fetchMockSuccess, waitForCondition, hasIdentifyReturned } = Utils; + +declare global { + interface Window { + mParticle: IMParticleInstanceManager; + } +} + +describe.only('Quick Batch', () => { + let clock; + let beaconSpy; + + beforeEach(() => { + fetchMock.restore(); + fetchMock.config.overwriteRoutes = true; + fetchMockSuccess(urls.identify, { + mpid: testMPID, + is_logged_in: false, + }); + fetchMock.post(urls.events, 200); + + clock = sinon.useFakeTimers({ + now: new Date().getTime(), + shouldAdvanceTime: true, + }); + + beaconSpy = sinon.spy(navigator, 'sendBeacon'); + }); + + afterEach(() => { + window.localStorage.clear(); + fetchMock.restore(); + clock.restore(); + sinon.restore(); + }); + + describe('Feature Flag Configuration', () => { + it('should respect quickBatch feature flag enabled/disabled'); + it('should use configured quickBatchIntervalMillis'); + it('should fall back to default interval when not configured'); + }); + + describe('Timer Behavior', () => { + it('should fire quick batch after configured delay', async () => { + window.mParticle._resetForTests(MPConfig); + fetchMock.resetHistory(); + + window.mParticle.config.flags = { + quickBatchIntervalMillis: 2000, + eventBatchingIntervalMillis: 5000, + }; + + window.mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned) + + window.mParticle.logEvent('Test Event'); + + let uploads = fetchMock.calls(urls.events); + expect(uploads.length, 'No uploads should have been made yet').to.equal(0); + + // Advance time by a second to ensure the quick batch has NOT been fired yet + clock.tick(1000); + + // First call should be the identify call, but should not have any events + expect(fetchMock.calls().length, 'Identify call should have been made').to.equal(1); + const firstCall = JSON.parse(fetchMock.calls()[0][1].body as string); + // console.log('firstCall:', firstCall); + expect(firstCall, 'First call should be to identify').to.have.property('known_identities'); + + + // Advance time by the configured quick batch delay to ensure the quick batch has been fired + clock.tick(2000); + + console.log('t-2000 fetchMock.calls:', fetchMock.calls()); + + + // Second call should now have an AST, Session Start and the test event + expect(fetchMock.calls().length, 'Quick Batch should have fired').to.equal(2); + const batch = JSON.parse(fetchMock.calls()[1][1].body as string); + expect(batch).to.have.property('events'); + + expect(batch.events.length).to.equal(3); + + // Verify the uploaded batch contains our test event + expect(batch.events[2].event_type).to.equal('custom_event'); + expect(batch.events[2].data.event_name).to.equal('Test Event'); + }); + + it('should only fire once per BatchUploader instance', async () => { + window.mParticle._resetForTests(MPConfig); + fetchMock.resetHistory(); + + window.mParticle.config.flags = { + quickBatchIntervalMillis: 1000, + eventBatchingIntervalMillis: 5000, + }; + + window.mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned) + + window.mParticle.logEvent('Test Event'); + + // Verify no uploads yet + let uploads = fetchMock.calls(urls.events); + expect(uploads.length, 'No uploads should have been made yet').to.equal(0); + + // Wait for quick batch to fire (1000ms) + clock.tick(1000); + + // Should have identify call + quick batch call + expect(fetchMock.calls().length, 'Quick batch should have fired').to.equal(2); + const quickBatchCall = JSON.parse(fetchMock.calls()[1][1].body as string); + expect(quickBatchCall).to.have.property('events'); + expect(quickBatchCall.events.length).to.equal(3); // AST, Session Start, Test Event + + // Wait another 2 seconds (total 3000ms) - should NOT trigger another quick batch + clock.tick(2000); + + // Should still only have 2 calls (identify + quick batch) + expect(fetchMock.calls().length, 'No additional quick batch should have fired').to.equal(2); + + // Add another event to test regular batch functionality + window.mParticle.logEvent('Second Event'); + + // Wait for regular batch interval to fire (total 5000ms) + clock.tick(5000); + + // Should now have 3 calls (identify + quick batch + regular batch) + expect(fetchMock.calls().length, 'Regular batch should have fired').to.equal(3); + + const regularBatchCall = JSON.parse(fetchMock.calls()[2][1].body as string); + expect(regularBatchCall).to.have.property('events'); + expect(regularBatchCall.events.length).to.equal(1); + expect(regularBatchCall.events[0].event_type).to.equal('custom_event'); + expect(regularBatchCall.events[0].data.event_name).to.equal('Second Event'); + }); + + it('should NOT fire when uploadInterval is less than quickBatchInterval', async () => { + window.mParticle._resetForTests(MPConfig); + fetchMock.resetHistory(); + + window.mParticle.config.flags = { + quickBatchIntervalMillis: 5000, + eventBatchingIntervalMillis: 3000, + }; + + window.mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned) + + window.mParticle.logEvent('Test Event'); + + // Verify no uploads yet + let uploads = fetchMock.calls(urls.events); + expect(uploads.length, 'No uploads should have been made yet').to.equal(0); + + // Wait for regular batch to fire (3000ms) - should fire before quick batch + clock.tick(3000); + + // Should have identify call + regular batch call (no quick batch) + expect(fetchMock.calls().length, 'Regular batch should have fired').to.equal(2); + const regularBatchCall = JSON.parse(fetchMock.calls()[1][1].body as string); + + console.log('regularBatchCall:', regularBatchCall.events.map(event => event.data.event_name)); + + expect(regularBatchCall).to.have.property('events'); + expect(regularBatchCall.events.length).to.equal(3); + expect(regularBatchCall.events[0].event_type).to.equal('session_start'); + expect(regularBatchCall.events[1].event_type).to.equal('application_state_transition'); + expect(regularBatchCall.events[2].event_type).to.equal('custom_event'); + expect(regularBatchCall.events[2].data.event_name).to.equal('Test Event'); + + + // Log another event to confirm it is not fired during the quick batch + window.mParticle.logEvent('Second Event'); + + // Wait longer than quick batch interval (5000ms total) - should still only have 2 calls + clock.tick(5000); + + // Should still only have 2 calls (identify + regular batch, no quick batch) + expect(fetchMock.calls().length, 'Quick batch should not have fired again').to.equal(2); + }); + }); + + describe('AST Event Integration', () => { + it('should include AST event in quick batch when astBackgroundEvents enabled', async () => { + window.mParticle._resetForTests(MPConfig); + fetchMock.resetHistory(); + + window.mParticle.config.flags = { + quickBatchIntervalMillis: 2000, + eventBatchingIntervalMillis: 5000, + astBackgroundEvents: 'True', + }; + + window.mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned) + + window.mParticle.logEvent('Test Event'); + + // Verify no uploads yet + let uploads = fetchMock.calls(urls.events); + expect(uploads.length, 'No uploads should have been made yet').to.equal(0); + + // Wait for quick batch to fire (2000ms) + clock.tick(2000); + + // Should have identify call + quick batch call + expect(fetchMock.calls().length, 'Quick batch should have fired').to.equal(2); + const quickBatchCall = JSON.parse(fetchMock.calls()[1][1].body as string); + expect(quickBatchCall).to.have.property('events'); + + // console.log('quickBatchCall:', quickBatchCall.events.map(event => event.event_type)); + + // Should have 4 events: AST Background, Session Start, Application State Transition, Test Event + expect(quickBatchCall.events.length).to.equal(4); + + const batch1 = quickBatchCall.events[0]; + const batch2 = quickBatchCall.events[1]; + const batch3 = quickBatchCall.events[2]; + const batch4 = quickBatchCall.events[3]; + + console.log('batch1:', batch1); + console.log('batch2:', batch2); + console.log('batch3:', batch3); + console.log('batch4:', batch4); + + // Verify Session Start is included + expect(batch1.event_type).to.equal('session_start'); + + // Verify Application State Transition is included + expect(batch2.event_type).to.equal('application_state_transition'); + expect(batch2.data.application_transition_type).to.equal('application_initialized'); + + // Verify Test Event is included + expect(batch3.event_type).to.equal('custom_event'); + expect(batch3.data.event_name).to.equal('Test Event'); + + // Verify AST Background event is the last event + expect(batch4.event_type).to.equal('application_state_transition'); + expect(batch4.data.application_transition_type).to.equal('application_background'); + + }); + + it('should not include AST event when astBackgroundEvents disabled', async () => { + window.mParticle._resetForTests(MPConfig); + fetchMock.resetHistory(); + + window.mParticle.config.flags = { + quickBatchIntervalMillis: 2000, + eventBatchingIntervalMillis: 5000, + astBackgroundEvents: 'False', + }; + + window.mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned) + + window.mParticle.logEvent('Test Event'); + + // Verify no uploads yet + let uploads = fetchMock.calls(urls.events); + expect(uploads.length, 'No uploads should have been made yet').to.equal(0); + + // Wait for quick batch to fire (2000ms) + clock.tick(2000); + + // Should have identify call + quick batch call + expect(fetchMock.calls().length, 'Quick batch should have fired').to.equal(2); + const quickBatchCall = JSON.parse(fetchMock.calls()[1][1].body as string); + expect(quickBatchCall).to.have.property('events'); + + // Should have 3 events: Session Start, Application State Transition, Test Event + expect(quickBatchCall.events.length).to.equal(3); + + const batch1 = quickBatchCall.events[0]; + const batch2 = quickBatchCall.events[1]; + const batch3 = quickBatchCall.events[2]; + + console.log('batch1:', batch1); + console.log('batch2:', batch2); + console.log('batch3:', batch3); + + // Verify Session Start is included + expect(batch1.event_type).to.equal('session_start'); + + // Verify Application State Transition is included + expect(batch2.event_type).to.equal('application_state_transition'); + expect(batch2.data.application_transition_type).to.equal('application_initialized'); + + // Verify Test Event is included + expect(batch3.event_type).to.equal('custom_event'); + expect(batch3.data.event_name).to.equal('Test Event'); + + // Verify AST Background event is not included + expect(quickBatchCall.events.find(event => event.event_type === 'application_state_transition' && event.data.application_transition_type === 'application_background')).to.be.undefined; + }); + }); + + describe('Priority Event Interaction', () => { + it.only('should fire quick batch independently of priority event when Commerce event triggers during quick batch window', async () => { + window.mParticle._resetForTests(MPConfig); + fetchMock.resetHistory(); + + window.mParticle.config.flags = { + quickBatchIntervalMillis: 2000, + eventBatchingIntervalMillis: 5000, + astBackgroundEvents: 'True', + }; + + window.mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned) + + window.mParticle.logEvent('Test Event'); + + // Verify no uploads yet + let uploads = fetchMock.calls(urls.events); + expect(uploads.length, 'No uploads should have been made yet').to.equal(0); + + // Trigger Commerce event (priority event) during quick batch window + const product: SDKProduct = window.mParticle.eCommerce.createProduct('Test Product', 'test-sku', 100); + window.mParticle.eCommerce.logProductAction( + ProductActionType.Purchase, + product, + null, // attrs + null, // customFlags + { + Id: 'test-transaction', + Revenue: 100, + } + ); + + // Should have identify call + priority batch call (Commerce event triggered immediate upload) + expect(fetchMock.calls().length, 'Priority batch should have fired').to.equal(2); + const priorityBatchCall = JSON.parse(fetchMock.calls()[1][1].body as string); + expect(priorityBatchCall).to.have.property('events'); + + console.log('priorityBatchCall:', priorityBatchCall.events.map(event => event.event_type)); + + // Should have multiple events including AST Background event + expect(priorityBatchCall.events.length).to.equal(4); + + const batch1 = priorityBatchCall.events[0]; + const batch2 = priorityBatchCall.events[1]; + const batch3 = priorityBatchCall.events[2]; + const batch4 = priorityBatchCall.events[3]; + + // Verify Session Start is included + expect(batch1.event_type).to.equal('session_start'); + + // Verify Application State Transition is included + expect(batch2.event_type).to.equal('application_state_transition'); + expect(batch2.data.application_transition_type).to.equal('application_initialized'); + + // Verify Test Event is included + expect(batch3.event_type).to.equal('custom_event'); + expect(batch3.data.event_name).to.equal('Test Event'); + + // Verify Purchase Event is included + expect(batch4.event_type).to.equal('commerce_event'); + expect(batch4.data).to.have.property('product_action'); + + // Verify AST Background event is NOT included in priority batch + const astBackgroundEvent = priorityBatchCall.events.find(event => + event.event_type === 'application_state_transition' && + event.data.application_transition_type === 'application_background' + ); + expect(astBackgroundEvent, 'AST Background event should NOT be included in priority batch').to.be.undefined; + + // Wait 2 seconds (within quick batch window) + clock.tick(2000); + + // Should have identify call + quick batch call + expect(fetchMock.calls().length, 'Quick batch should have fired after priority event').to.equal(3); + + // Verify AST Background event is included in quick batch + const quickBatchCall = JSON.parse(fetchMock.calls()[2][1].body as string); + expect(quickBatchCall).to.have.property('events'); + expect(quickBatchCall.events.length).to.equal(1); + + expect(quickBatchCall.events[0].event_type).to.equal('application_state_transition'); + expect(quickBatchCall.events[0].data.application_transition_type).to.equal('application_background'); + }); + + it('should fire quick batch independently of priority event when UserIdentityChange triggers during quick batch window'); + it('should clear quick batch timer after priority event injection'); + it('should not duplicate AST events in subsequent batches'); + }); + + describe('Background State Handling', () => { + it('should clear quick batch timer on visibilitychange to hidden'); + it('should clear quick batch timer on beforeunload'); + it('should clear quick batch timer on pagehide'); + it('should not execute quick batch after timer cleared by background event'); + }); + + describe('Error Handling', () => { + it('should handle quick batch upload failures gracefully'); + it('should continue normal batch processing after quick batch failure'); + it('should handle invalid configuration gracefully'); + }); +}); \ No newline at end of file