From 02e82de0b7f9836a7843fd50ee6ec48e6e71482a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 28 Aug 2026 17:48:42 +0000 Subject: [PATCH] fix(core): keep events buffered when a WaitingPlugin is added before init A WaitingPlugin added right after createClient() is deferred into pluginsToAdd and only registered from onReady(). init() then set running to true and drained the pending buffer unconditionally, discarding the pause the plugin had just requested, so events were enriched with the plugin's pre-resume() state. init() now leaves processing paused while waiting plugins are registered, and pauseEventProcessing() arms its auto-resume timeout even when processing is already stopped so the 30s safety valve still applies pre-init. resumeEventProcessing() defers the buffer drain to init() until isReady. Fixes #1308 Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/analytics.ts | 27 ++++---- .../src/plugins/__tests__/Waiting.test.ts | 61 +++++++++++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 9f15e81dc..79a886c3a 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -334,10 +334,14 @@ export class SegmentClient { ]); await this.onReady(); this.isReady.value = true; - // Set running to true to start event processing - await this.store.running.set(true); - // Process all pending events - await this.processPendingEvents(); + if (this.waitingPlugins.size === 0) { + // Set running to true to start event processing + await this.store.running.set(true); + } + if (this.store.running.get()) { + // Process all pending events + await this.processPendingEvents(); + } // Trigger manual flush this.flushPolicyExecuter.manualFlush(); } catch (error) { @@ -1131,15 +1135,11 @@ export class SegmentClient { * @param timeout - Milliseconds to wait before auto-resuming (default: 30000) */ pauseEventProcessing(timeout = 30000) { - // IMPORTANT: ignore repeated pauses - const running = this.store.running.get(); - if (!running) { - return; + if (this.store.running.get()) { + // Fire-and-forget: state is updated synchronously in-memory, persistence happens async + void this.store.running.set(false); } - // Fire-and-forget: state is updated synchronously in-memory, persistence happens async - void this.store.running.set(false); - // Only set timeout if not already set (prevents multiple waiting plugins from overwriting) if (!this.resumeTimeoutId) { this.resumeTimeoutId = setTimeout(async () => { @@ -1164,6 +1164,9 @@ export class SegmentClient { this.resumeTimeoutId = undefined; } await this.store.running.set(true); - await this.processPendingEvents(); + if (this.isReady.value) { + // Before init the timeline isn't configured yet, init() drains the buffer instead + await this.processPendingEvents(); + } } } diff --git a/packages/core/src/plugins/__tests__/Waiting.test.ts b/packages/core/src/plugins/__tests__/Waiting.test.ts index 31171ef34..eec43ec00 100644 --- a/packages/core/src/plugins/__tests__/Waiting.test.ts +++ b/packages/core/src/plugins/__tests__/Waiting.test.ts @@ -12,6 +12,8 @@ import { } from '../../test-helpers'; import { TrackEventType } from '../../types'; +jest.mock('../../api'); + jest.useFakeTimers(); // Type for accessing internal client properties in tests @@ -526,6 +528,65 @@ describe('WaitingPlugin', () => { expect(await client.running.get(true)).toBe(true); }); + test('WaitingPlugin added before init keeps events buffered until resume', async () => { + // Mirrors the real createClient() flow: the plugin is added before init() finishes + const preInitStore = new MockSegmentStore({ + isReady: true, + running: false, + }); + + const client = new SegmentClient({ + config: baseConfig, + logger: getMockLogger(), + store: preInitStore, + }); + + const plugin = new ManualResumeWaitingPlugin(); + client.add({ plugin }); + + client.track('Application Opened'); + await Promise.resolve(); + + expect(plugin.tracked).toBe(false); + + await client.init(); + + expect(await client.running.get(true)).toBe(false); + expect(plugin.tracked).toBe(false); + + await plugin.resume(); + + expect(await client.running.get(true)).toBe(true); + expect(plugin.tracked).toBe(true); + expect(await preInitStore.pendingEvents.get(true)).toHaveLength(0); + }); + + test('WaitingPlugin added before init still force resumes on timeout', async () => { + const preInitStore = new MockSegmentStore({ + isReady: true, + running: false, + }); + + const client = new SegmentClient({ + config: baseConfig, + logger: getMockLogger(), + store: preInitStore, + }); + + const plugin = new ManualResumeWaitingPlugin(); + client.add({ plugin }); + + client.track('Application Opened'); + await client.init(); + + expect(plugin.tracked).toBe(false); + + await jest.advanceTimersByTimeAsync(30000); + + expect(await client.running.get(true)).toBe(true); + expect(plugin.tracked).toBe(true); + }); + test('pending events queue is capped at maxPendingEvents', async () => { const client = new SegmentClient({ config: baseConfig,