From 803634c8172b861c25b6d268e4701eb78561eeea Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 17 Mar 2026 13:46:03 -0300 Subject: [PATCH 01/11] feat: add trackEvent to IFlagsmith interface Add trackEvent(eventName, metadata?) to the public type definitions, allowing custom events to be sent through the evaluation analytics pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- types.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/types.d.ts b/types.d.ts index de3c682..6419b3a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -299,6 +299,14 @@ T extends string = string * Set a key value set of traits for a given user, triggers a call to get flags */ setTraits: (traits: ITraits) => Promise; + /** + * Track a custom event through the evaluation analytics pipeline. + * Requires `evaluationAnalyticsConfig` to be set; no-op otherwise. + * Events tracked before `identify()` are buffered and sent once identity is set. + * @experimental Internal use only — API may change without notice. + * @hidden + */ + trackEvent: (eventName: string, metadata?: Record) => void; /** * The stored identity of the user */ From 52b4aa2f5ecd828dbeea3f77c16c8f7fc8c50851 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 17 Mar 2026 13:46:18 -0300 Subject: [PATCH 02/11] feat: implement trackEvent for custom pipeline events Add trackEvent method that sends custom_event through the analytics pipeline batch endpoint. Events tracked before identify() are buffered in pendingCustomEvents and drained once identity is set, preserving original timestamps. Pending events are cleared on logout. Includes helper methods (getPageUrl, currentTraitsSnapshot, sdkMetadata, buildCustomEvent) to keep the implementation clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index b2446e0..db91847 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -339,6 +339,7 @@ const Flagsmith = class { pipelineAnalyticsInterval: ReturnType | null = null isPipelineFlushing = false pipelineRecordedKeys: Map = new Map() + pendingCustomEvents: Array<{ eventName: string; metadata?: Record; timestamp: number }> = [] async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -653,6 +654,18 @@ const Flagsmith = class { ) ); } + // Drain pending custom events now that identity is set + if (this.pendingCustomEvents.length > 0 && this.evaluationAnalyticsUrl) { + for (const pending of this.pendingCustomEvents) { + const event = this.buildCustomEvent(pending.eventName, userId ?? null, pending.metadata, pending.timestamp); + this.pipelineEvents.push(event); + } + this.pendingCustomEvents = []; + this.trimPipelineBuffer(); + if (this.pipelineFlushInterval === 0) { + this.flushPipelineAnalytics(); + } + } if (this.initialised) { return this.getFlags(); } @@ -685,6 +698,7 @@ const Flagsmith = class { logout() { this.identity = null this.evaluationContext.identity = null; + this.pendingCustomEvents = []; if (this.initialised) { return this.getFlags(); } @@ -1004,9 +1018,36 @@ const Flagsmith = class { } this.evaluationAnalyticsUrl = null; this.pipelineEvents = []; + this.pendingCustomEvents = []; this.pipelineRecordedKeys.clear(); } + private trimPipelineBuffer() { + if (this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer) { + const excess = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; + this.pipelineEvents = this.pipelineEvents.slice(excess); + } + } + + private currentTraitsSnapshot() { + return this.evaluationContext.identity?.traits + ? { ...this.evaluationContext.identity.traits } + : null; + } + + private getPageUrl(): string | null { + return typeof window !== 'undefined' && window.location ? window.location.href : null; + } + + private sdkMetadata(extra?: Record): Record { + const pageUrl = this.getPageUrl(); + return { + ...(extra || {}), + ...(pageUrl ? { page_url: pageUrl } : {}), + ...(SDK_VERSION ? { sdk_version: SDK_VERSION } : {}), + }; + } + // Pipeline event schema — must match the pipeline server's Event struct. // To update: 1) IPipelineEvent in types.d.ts 2) event object below 3) tests in test/analytics-pipeline.test.ts private recordPipelineEvent(key: string) { @@ -1040,6 +1081,38 @@ const Flagsmith = class { } } + private buildCustomEvent(eventName: string, identityIdentifier: string | null, metadata?: Record, timestamp?: number): IPipelineEvent { + return { + event_id: eventName, + event_type: 'custom_event', + evaluated_at: timestamp ?? Date.now(), + identity_identifier: identityIdentifier, + enabled: null, + value: null, + traits: this.currentTraitsSnapshot(), + metadata: this.sdkMetadata(metadata), + }; + } + + trackEvent = (eventName: string, metadata?: Record) => { + if (!this.evaluationAnalyticsUrl || !eventName) { + return; + } + if (!this.evaluationContext.identity?.identifier) { + if (this.pendingCustomEvents.length < this.evaluationAnalyticsMaxBuffer) { + this.pendingCustomEvents.push({ eventName, metadata, timestamp: Date.now() }); + } + return; + } + const event = this.buildCustomEvent(eventName, this.evaluationContext.identity.identifier, metadata); + this.pipelineEvents.push(event); + this.trimPipelineBuffer(); + + if (this.pipelineFlushInterval === 0) { + this.flushPipelineAnalytics(); + } + } + private setLoadingState(loadingState: LoadingState) { if (!deepEqual(loadingState, this.loadingState)) { this.loadingState = { ...loadingState }; From b11ac3de7b6eebe28d346116f310df695db868d3 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 17 Mar 2026 13:46:49 -0300 Subject: [PATCH 03/11] test: add trackEvent test cases Cover custom event shape, no-op without config, no-op with empty name, queue-before-identify with drain, immediate flush when flushInterval is 0, no deduplication, timestamp preservation, cleanup on logout, and pending buffer maxBuffer cap. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/analytics-pipeline.test.ts | 186 ++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 0d1b3cb..da7d935 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -218,3 +218,189 @@ describe('Pipeline Analytics', () => { expect(flagsmith.pipelineEvents[0].event_id).toBe('hero'); }); }); + +const defaultPipelineConfig = { + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 60000, + }, +}; + +function getCustomEvents(flagsmith: any) { + return flagsmith.pipelineEvents.filter((e: any) => e.event_type === 'custom_event'); +} + +describe('trackEvent (custom events)', () => { + test('sends custom_event with correct shape', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + ...defaultPipelineConfig, + identity: testIdentity, + }); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('checkout', { item: 'shoes', price: 99 }); + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); + + const calls = getPipelineCalls(mockFetch); + expect(calls).toHaveLength(1); + + const event = JSON.parse(calls[0][1].body).events[0]; + expect(event).toEqual(expect.objectContaining({ + event_id: 'checkout', + event_type: 'custom_event', + enabled: null, + value: null, + identity_identifier: testIdentity, + })); + expect(event.evaluated_at).toEqual(expect.any(Number)); + expect(event.metadata).toEqual(expect.objectContaining({ item: 'shoes', price: 99 })); + expect(event.metadata.sdk_version).toBeDefined(); + }); + + test('no-ops when evaluationAnalyticsConfig is not set', async () => { + const { flagsmith, initConfig } = getFlagsmith(); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('checkout'); + + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(0); + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(0); + }); + + test('no-ops when eventName is empty', async () => { + const { flagsmith, initConfig } = getFlagsmith({ + ...defaultPipelineConfig, + identity: testIdentity, + }); + await flagsmith.init(initConfig); + + flagsmith.trackEvent(''); + + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(0); + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(0); + }); + + test('queues events before identify and drains on identify', async () => { + const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('page_view', { page: '/home' }); + flagsmith.trackEvent('signup'); + + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(2); + expect(getCustomEvents(flagsmith)).toHaveLength(0); + + await flagsmith.identify(testIdentity); + + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(0); + const custom = getCustomEvents(flagsmith); + expect(custom).toHaveLength(2); + expect(custom[0].event_id).toBe('page_view'); + expect(custom[0].identity_identifier).toBe(testIdentity); + expect(custom[1].event_id).toBe('signup'); + expect(custom[1].identity_identifier).toBe(testIdentity); + }); + + test('flushes immediately on identify when flushInterval is 0', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 0, + }, + }); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('pre_identify_action'); + expect(getPipelineCalls(mockFetch)).toHaveLength(0); + + await flagsmith.identify(testIdentity); + + const calls = getPipelineCalls(mockFetch); + expect(calls.length).toBeGreaterThanOrEqual(1); + + const allEvents = calls.flatMap( + ([, opts]: [string, any]) => JSON.parse(opts.body).events + ); + const custom = allEvents.filter((e: any) => e.event_type === 'custom_event'); + expect(custom).toHaveLength(1); + expect(custom[0].event_id).toBe('pre_identify_action'); + expect(custom[0].identity_identifier).toBe(testIdentity); + }); + + test('does not deduplicate - each call produces a distinct event', async () => { + const { flagsmith, initConfig } = getFlagsmith({ + ...defaultPipelineConfig, + identity: testIdentity, + }); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('click'); + flagsmith.trackEvent('click'); + flagsmith.trackEvent('click'); + + expect(getCustomEvents(flagsmith)).toHaveLength(3); + }); + + test('preserves original timestamps for queued events', async () => { + const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + await flagsmith.init(initConfig); + + const trackTime = 1700000000000; + const identifyTime = 1700000005000; + const dateSpy = jest.spyOn(Date, 'now'); + + dateSpy.mockReturnValue(trackTime); + flagsmith.trackEvent('early_event'); + + dateSpy.mockReturnValue(identifyTime); + await flagsmith.identify(testIdentity); + dateSpy.mockRestore(); + + const custom = getCustomEvents(flagsmith); + expect(custom).toHaveLength(1); + expect(custom[0].evaluated_at).toBe(trackTime); + }); + + test('clears pending events on logout', async () => { + const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('pre_login_action'); + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(1); + + await flagsmith.logout(); + + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(0); + }); + + test('respects maxBuffer for pending events', async () => { + const { flagsmith, initConfig } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + maxBuffer: 2, + flushInterval: 60000, + }, + }); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('event_1'); + flagsmith.trackEvent('event_2'); + flagsmith.trackEvent('event_3'); + + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(2); + // @ts-ignore + expect(flagsmith.pendingCustomEvents[0].eventName).toBe('event_1'); + // @ts-ignore + expect(flagsmith.pendingCustomEvents[1].eventName).toBe('event_2'); + }); +}); From 6041894e69d630c5eee017a2fea130f4fac721f1 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 17 Mar 2026 17:21:18 -0300 Subject: [PATCH 04/11] refactor: replace Record with Record in trackEvent Use the stricter Record for user-provided metadata, which forces consumers to narrow values before using them. Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 8 ++++---- types.d.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index db91847..2c63385 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -339,7 +339,7 @@ const Flagsmith = class { pipelineAnalyticsInterval: ReturnType | null = null isPipelineFlushing = false pipelineRecordedKeys: Map = new Map() - pendingCustomEvents: Array<{ eventName: string; metadata?: Record; timestamp: number }> = [] + pendingCustomEvents: Array<{ eventName: string; metadata?: Record; timestamp: number }> = [] async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -1039,7 +1039,7 @@ const Flagsmith = class { return typeof window !== 'undefined' && window.location ? window.location.href : null; } - private sdkMetadata(extra?: Record): Record { + private sdkMetadata(extra?: Record): Record { const pageUrl = this.getPageUrl(); return { ...(extra || {}), @@ -1081,7 +1081,7 @@ const Flagsmith = class { } } - private buildCustomEvent(eventName: string, identityIdentifier: string | null, metadata?: Record, timestamp?: number): IPipelineEvent { + private buildCustomEvent(eventName: string, identityIdentifier: string | null, metadata?: Record, timestamp?: number): IPipelineEvent { return { event_id: eventName, event_type: 'custom_event', @@ -1094,7 +1094,7 @@ const Flagsmith = class { }; } - trackEvent = (eventName: string, metadata?: Record) => { + trackEvent = (eventName: string, metadata?: Record) => { if (!this.evaluationAnalyticsUrl || !eventName) { return; } diff --git a/types.d.ts b/types.d.ts index 6419b3a..9b87302 100644 --- a/types.d.ts +++ b/types.d.ts @@ -306,7 +306,7 @@ T extends string = string * @experimental Internal use only — API may change without notice. * @hidden */ - trackEvent: (eventName: string, metadata?: Record) => void; + trackEvent: (eventName: string, metadata?: Record) => void; /** * The stored identity of the user */ From be511606c6c8adb9ee1a7399737a5a216b28d4aa Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 17 Mar 2026 17:27:24 -0300 Subject: [PATCH 05/11] refactor: add PipelineEventType enum for event_type constants Replace string literals 'flag_evaluation' and 'custom_event' with PipelineEventType.FLAG_EVALUATION and PipelineEventType.CUSTOM_EVENT to avoid magic strings and ensure type safety. Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 9 +++++++-- types.d.ts | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 2c63385..41a3f6c 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -36,6 +36,11 @@ export enum FlagSource { "SERVER" = "SERVER", } +export enum PipelineEventType { + FLAG_EVALUATION = 'flag_evaluation', + CUSTOM_EVENT = 'custom_event', +} + export type LikeFetch = (input: Partial, init?: Partial) => Promise> let _fetch: LikeFetch; @@ -1060,7 +1065,7 @@ const Flagsmith = class { this.pipelineRecordedKeys.set(flagKey, fingerprint); const event: IPipelineEvent = { event_id: flagKey, - event_type: 'flag_evaluation', + event_type: PipelineEventType.FLAG_EVALUATION, evaluated_at: Date.now(), identity_identifier: this.evaluationContext.identity?.identifier ?? null, enabled: flag ? flag.enabled : null, @@ -1084,7 +1089,7 @@ const Flagsmith = class { private buildCustomEvent(eventName: string, identityIdentifier: string | null, metadata?: Record, timestamp?: number): IPipelineEvent { return { event_id: eventName, - event_type: 'custom_event', + event_type: PipelineEventType.CUSTOM_EVENT, evaluated_at: timestamp ?? Date.now(), identity_identifier: identityIdentifier, enabled: null, diff --git a/types.d.ts b/types.d.ts index 9b87302..08f7a47 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,5 +1,5 @@ import { EvaluationContext, IdentityEvaluationContext, TraitEvaluationContext } from "./evaluation-context"; -import { FlagSource } from "./flagsmith-core"; +import { FlagSource, PipelineEventType } from "./flagsmith-core"; type IFlagsmithValue = T @@ -85,7 +85,7 @@ export type ISentryClient = { } | undefined; -export { FlagSource }; +export { FlagSource, PipelineEventType }; export declare type LoadingState = { error: Error | null, // Current error, resets on next attempt to fetch flags @@ -147,7 +147,7 @@ export interface IInitConfig = string, T export interface IPipelineEvent { event_id: string; // flag_name or event_name - event_type: 'flag_evaluation' | 'custom_event'; + event_type: PipelineEventType; evaluated_at: number; identity_identifier: string | null; enabled?: boolean | null; From bdfe485da0a657c5a25f37e4f997bcb0d1e95fe6 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 17 Mar 2026 17:32:39 -0300 Subject: [PATCH 06/11] fix: use PipelineEventType enum in tests and export from index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 'custom_event' string literals with PipelineEventType.CUSTOM_EVENT in test assertions and helpers - Export PipelineEventType from index.ts for consumer access - Add test for identify → logout → track cycle to verify state resets Co-Authored-By: Claude Opus 4.6 (1M context) --- index.ts | 2 +- test/analytics-pipeline.test.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 4d3c3cf..21dcac4 100644 --- a/index.ts +++ b/index.ts @@ -19,4 +19,4 @@ export default flagsmith; export const createFlagsmithInstance = ():IFlagsmith=>{ return core({ AsyncStorage, fetch:_fetch, eventSource:_EventSource}) } -export { FlagSource } from './flagsmith-core'; +export { FlagSource, PipelineEventType } from './flagsmith-core'; diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index da7d935..cab3487 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -1,4 +1,5 @@ import { getFlagsmith, environmentID, testIdentity } from './test-constants'; +import { PipelineEventType } from '../lib/flagsmith'; const pipelineUrl = 'https://analytics.flagsmith.com/'; @@ -227,7 +228,7 @@ const defaultPipelineConfig = { }; function getCustomEvents(flagsmith: any) { - return flagsmith.pipelineEvents.filter((e: any) => e.event_type === 'custom_event'); + return flagsmith.pipelineEvents.filter((e: any) => e.event_type === PipelineEventType.CUSTOM_EVENT); } describe('trackEvent (custom events)', () => { @@ -248,7 +249,7 @@ describe('trackEvent (custom events)', () => { const event = JSON.parse(calls[0][1].body).events[0]; expect(event).toEqual(expect.objectContaining({ event_id: 'checkout', - event_type: 'custom_event', + event_type: PipelineEventType.CUSTOM_EVENT, enabled: null, value: null, identity_identifier: testIdentity, @@ -328,7 +329,7 @@ describe('trackEvent (custom events)', () => { const allEvents = calls.flatMap( ([, opts]: [string, any]) => JSON.parse(opts.body).events ); - const custom = allEvents.filter((e: any) => e.event_type === 'custom_event'); + const custom = allEvents.filter((e: any) => e.event_type === PipelineEventType.CUSTOM_EVENT); expect(custom).toHaveLength(1); expect(custom[0].event_id).toBe('pre_identify_action'); expect(custom[0].identity_identifier).toBe(testIdentity); @@ -403,4 +404,26 @@ describe('trackEvent (custom events)', () => { // @ts-ignore expect(flagsmith.pendingCustomEvents[1].eventName).toBe('event_2'); }); + + test('buffers again after identify then logout', async () => { + const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + await flagsmith.init(initConfig); + + // Identify → track goes direct to pipeline + await flagsmith.identify(testIdentity); + flagsmith.trackEvent('while_identified'); + expect(getCustomEvents(flagsmith)).toHaveLength(1); + + // Logout → clears pending, identity gone + await flagsmith.logout(); + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(0); + + // Track after logout → should buffer again (no identity) + flagsmith.trackEvent('after_logout'); + // @ts-ignore + expect(flagsmith.pendingCustomEvents).toHaveLength(1); + // @ts-ignore + expect(flagsmith.pendingCustomEvents[0].eventName).toBe('after_logout'); + }); }); From ae9b072f283ff0bbbdf47d0fd8cbac691d96e5d5 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 23 Mar 2026 10:24:17 -0300 Subject: [PATCH 07/11] refactor: make pipeline analytics properties private Mark evaluationAnalyticsUrl, evaluationAnalyticsMaxBuffer, pipelineEvents, pipelineAnalyticsInterval, isPipelineFlushing, pipelineRecordedKeys, and pendingCustomEvents as private to prevent external access. Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 41a3f6c..7bd46bf 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -338,13 +338,13 @@ const Flagsmith = class { sentryClient: ISentryClient | null = null withTraits?: ITraits|null= null cacheOptions = {ttl:0, skipAPI: false, loadStale: false, storageKey: undefined as string|undefined} - evaluationAnalyticsUrl: string | null = null - evaluationAnalyticsMaxBuffer: number = 1000 - pipelineEvents: IPipelineEvent[] = [] - pipelineAnalyticsInterval: ReturnType | null = null - isPipelineFlushing = false - pipelineRecordedKeys: Map = new Map() - pendingCustomEvents: Array<{ eventName: string; metadata?: Record; timestamp: number }> = [] + private evaluationAnalyticsUrl: string | null = null + private evaluationAnalyticsMaxBuffer: number = 1000 + private pipelineEvents: IPipelineEvent[] = [] + private pipelineAnalyticsInterval: ReturnType | null = null + private isPipelineFlushing = false + private pipelineRecordedKeys: Map = new Map() + private pendingCustomEvents: Array<{ eventName: string; metadata?: Record; timestamp: number }> = [] async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { From 6d112cc4803df970923307c837bc4edb78360228 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 23 Mar 2026 10:31:55 -0300 Subject: [PATCH 08/11] refactor: remove pendingCustomEvents, track events immediately Custom events are now sent immediately with the current identity (or null if anonymous), instead of being buffered until identify() is called. This prevents events from being silently lost when identify() is never called and supports anonymous tracking use cases (e.g. public pages, pre-login). Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 23 +------ test/analytics-pipeline.test.ts | 116 ++++++++------------------------ types.d.ts | 2 +- 3 files changed, 31 insertions(+), 110 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 7bd46bf..8d10469 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -344,7 +344,6 @@ const Flagsmith = class { private pipelineAnalyticsInterval: ReturnType | null = null private isPipelineFlushing = false private pipelineRecordedKeys: Map = new Map() - private pendingCustomEvents: Array<{ eventName: string; metadata?: Record; timestamp: number }> = [] async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -659,18 +658,6 @@ const Flagsmith = class { ) ); } - // Drain pending custom events now that identity is set - if (this.pendingCustomEvents.length > 0 && this.evaluationAnalyticsUrl) { - for (const pending of this.pendingCustomEvents) { - const event = this.buildCustomEvent(pending.eventName, userId ?? null, pending.metadata, pending.timestamp); - this.pipelineEvents.push(event); - } - this.pendingCustomEvents = []; - this.trimPipelineBuffer(); - if (this.pipelineFlushInterval === 0) { - this.flushPipelineAnalytics(); - } - } if (this.initialised) { return this.getFlags(); } @@ -703,7 +690,6 @@ const Flagsmith = class { logout() { this.identity = null this.evaluationContext.identity = null; - this.pendingCustomEvents = []; if (this.initialised) { return this.getFlags(); } @@ -1023,7 +1009,6 @@ const Flagsmith = class { } this.evaluationAnalyticsUrl = null; this.pipelineEvents = []; - this.pendingCustomEvents = []; this.pipelineRecordedKeys.clear(); } @@ -1103,13 +1088,7 @@ const Flagsmith = class { if (!this.evaluationAnalyticsUrl || !eventName) { return; } - if (!this.evaluationContext.identity?.identifier) { - if (this.pendingCustomEvents.length < this.evaluationAnalyticsMaxBuffer) { - this.pendingCustomEvents.push({ eventName, metadata, timestamp: Date.now() }); - } - return; - } - const event = this.buildCustomEvent(eventName, this.evaluationContext.identity.identifier, metadata); + const event = this.buildCustomEvent(eventName, this.evaluationContext.identity?.identifier ?? null, metadata); this.pipelineEvents.push(event); this.trimPipelineBuffer(); diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index cab3487..05d2e13 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -267,8 +267,6 @@ describe('trackEvent (custom events)', () => { // @ts-ignore expect(flagsmith.pipelineEvents).toHaveLength(0); - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(0); }); test('no-ops when eventName is empty', async () => { @@ -282,46 +280,54 @@ describe('trackEvent (custom events)', () => { // @ts-ignore expect(flagsmith.pipelineEvents).toHaveLength(0); - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(0); }); - test('queues events before identify and drains on identify', async () => { - const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + test('tracks events with null identity when not identified', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith(defaultPipelineConfig); await flagsmith.init(initConfig); flagsmith.trackEvent('page_view', { page: '/home' }); flagsmith.trackEvent('signup'); + const custom = getCustomEvents(flagsmith); + expect(custom).toHaveLength(2); + expect(custom[0].event_id).toBe('page_view'); + expect(custom[0].identity_identifier).toBeNull(); + expect(custom[0].metadata).toEqual(expect.objectContaining({ page: '/home' })); + expect(custom[1].event_id).toBe('signup'); + expect(custom[1].identity_identifier).toBeNull(); + // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(2); - expect(getCustomEvents(flagsmith)).toHaveLength(0); + await flagsmith.flushPipelineAnalytics(); + const calls = getPipelineCalls(mockFetch); + expect(calls).toHaveLength(1); + }); + test('tracks events with identity after identify', async () => { + const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + await flagsmith.init(initConfig); + + flagsmith.trackEvent('anonymous_action'); await flagsmith.identify(testIdentity); + flagsmith.trackEvent('identified_action'); - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(0); const custom = getCustomEvents(flagsmith); expect(custom).toHaveLength(2); - expect(custom[0].event_id).toBe('page_view'); - expect(custom[0].identity_identifier).toBe(testIdentity); - expect(custom[1].event_id).toBe('signup'); + expect(custom[0].identity_identifier).toBeNull(); expect(custom[1].identity_identifier).toBe(testIdentity); }); - test('flushes immediately on identify when flushInterval is 0', async () => { + test('flushes immediately when flushInterval is 0', async () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith({ evaluationAnalyticsConfig: { analyticsServerUrl: pipelineUrl, flushInterval: 0, }, + identity: testIdentity, }); await flagsmith.init(initConfig); - flagsmith.trackEvent('pre_identify_action'); - expect(getPipelineCalls(mockFetch)).toHaveLength(0); - - await flagsmith.identify(testIdentity); + flagsmith.trackEvent('instant_event'); const calls = getPipelineCalls(mockFetch); expect(calls.length).toBeGreaterThanOrEqual(1); @@ -331,8 +337,7 @@ describe('trackEvent (custom events)', () => { ); const custom = allEvents.filter((e: any) => e.event_type === PipelineEventType.CUSTOM_EVENT); expect(custom).toHaveLength(1); - expect(custom[0].event_id).toBe('pre_identify_action'); - expect(custom[0].identity_identifier).toBe(testIdentity); + expect(custom[0].event_id).toBe('instant_event'); }); test('does not deduplicate - each call produces a distinct event', async () => { @@ -349,81 +354,18 @@ describe('trackEvent (custom events)', () => { expect(getCustomEvents(flagsmith)).toHaveLength(3); }); - test('preserves original timestamps for queued events', async () => { + test('tracks with null identity after logout', async () => { const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); await flagsmith.init(initConfig); - const trackTime = 1700000000000; - const identifyTime = 1700000005000; - const dateSpy = jest.spyOn(Date, 'now'); - - dateSpy.mockReturnValue(trackTime); - flagsmith.trackEvent('early_event'); - - dateSpy.mockReturnValue(identifyTime); - await flagsmith.identify(testIdentity); - dateSpy.mockRestore(); - - const custom = getCustomEvents(flagsmith); - expect(custom).toHaveLength(1); - expect(custom[0].evaluated_at).toBe(trackTime); - }); - - test('clears pending events on logout', async () => { - const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); - await flagsmith.init(initConfig); - - flagsmith.trackEvent('pre_login_action'); - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(1); - - await flagsmith.logout(); - - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(0); - }); - - test('respects maxBuffer for pending events', async () => { - const { flagsmith, initConfig } = getFlagsmith({ - evaluationAnalyticsConfig: { - analyticsServerUrl: pipelineUrl, - maxBuffer: 2, - flushInterval: 60000, - }, - }); - await flagsmith.init(initConfig); - - flagsmith.trackEvent('event_1'); - flagsmith.trackEvent('event_2'); - flagsmith.trackEvent('event_3'); - - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(2); - // @ts-ignore - expect(flagsmith.pendingCustomEvents[0].eventName).toBe('event_1'); - // @ts-ignore - expect(flagsmith.pendingCustomEvents[1].eventName).toBe('event_2'); - }); - - test('buffers again after identify then logout', async () => { - const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); - await flagsmith.init(initConfig); - - // Identify → track goes direct to pipeline await flagsmith.identify(testIdentity); flagsmith.trackEvent('while_identified'); - expect(getCustomEvents(flagsmith)).toHaveLength(1); + expect(getCustomEvents(flagsmith)[0].identity_identifier).toBe(testIdentity); - // Logout → clears pending, identity gone await flagsmith.logout(); - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(0); - // Track after logout → should buffer again (no identity) flagsmith.trackEvent('after_logout'); - // @ts-ignore - expect(flagsmith.pendingCustomEvents).toHaveLength(1); - // @ts-ignore - expect(flagsmith.pendingCustomEvents[0].eventName).toBe('after_logout'); + const custom = getCustomEvents(flagsmith); + expect(custom[custom.length - 1].identity_identifier).toBeNull(); }); }); diff --git a/types.d.ts b/types.d.ts index 08f7a47..1e49ff0 100644 --- a/types.d.ts +++ b/types.d.ts @@ -302,7 +302,7 @@ T extends string = string /** * Track a custom event through the evaluation analytics pipeline. * Requires `evaluationAnalyticsConfig` to be set; no-op otherwise. - * Events tracked before `identify()` are buffered and sent once identity is set. + * Events are sent with the current identity (or null if anonymous). * @experimental Internal use only — API may change without notice. * @hidden */ From 3879f715913f8da4b47ce8caf627319cb866aa6a Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 23 Mar 2026 11:03:06 -0300 Subject: [PATCH 09/11] refactor: unify event builders and rename helpers Extract shared buildAnalyticEvent method that handles both FLAG_EVALUATION and CUSTOM_EVENT types, replacing the separate buildCustomEvent and inline event construction in recordPipelineEvent. Rename sdkMetadata to getEventMetadata (includes page_url, not just SDK info) and reuse currentTraitsSnapshot in the flag evaluation path. Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 58 +++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 8d10469..202ceaa 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1029,7 +1029,7 @@ const Flagsmith = class { return typeof window !== 'undefined' && window.location ? window.location.href : null; } - private sdkMetadata(extra?: Record): Record { + private getEventMetadata(extra?: Record): Record { const pageUrl = this.getPageUrl(); return { ...(extra || {}), @@ -1040,6 +1040,28 @@ const Flagsmith = class { // Pipeline event schema — must match the pipeline server's Event struct. // To update: 1) IPipelineEvent in types.d.ts 2) event object below 3) tests in test/analytics-pipeline.test.ts + private buildAnalyticEvent( + eventType: PipelineEventType, + eventId: string, + options?: { + enabled?: boolean | null; + value?: any; + extraMetadata?: Record; + timestamp?: number; + }, + ): IPipelineEvent { + return { + event_id: eventId, + event_type: eventType, + evaluated_at: options?.timestamp ?? Date.now(), + identity_identifier: this.evaluationContext.identity?.identifier ?? null, + enabled: options?.enabled ?? null, + value: options?.value ?? null, + traits: this.currentTraitsSnapshot(), + metadata: this.getEventMetadata(options?.extraMetadata), + }; + } + private recordPipelineEvent(key: string) { const flagKey = key.toLowerCase().replace(/ /g, '_'); const flag = this.flags && this.flags[flagKey]; @@ -1048,22 +1070,11 @@ const Flagsmith = class { return; } this.pipelineRecordedKeys.set(flagKey, fingerprint); - const event: IPipelineEvent = { - event_id: flagKey, - event_type: PipelineEventType.FLAG_EVALUATION, - evaluated_at: Date.now(), - identity_identifier: this.evaluationContext.identity?.identifier ?? null, + const event = this.buildAnalyticEvent(PipelineEventType.FLAG_EVALUATION, flagKey, { enabled: flag ? flag.enabled : null, value: flag ? flag.value : null, - traits: this.evaluationContext.identity?.traits - ? { ...this.evaluationContext.identity.traits } - : null, - metadata: { - ...(flag ? { id: flag.id } : {}), - ...(typeof window !== 'undefined' && window.location ? { page_url: window.location.href } : {}), - ...(SDK_VERSION ? { sdk_version: SDK_VERSION } : {}), - }, - }; + extraMetadata: flag ? { id: flag.id } : undefined, + }); this.pipelineEvents.push(event); if (this.pipelineFlushInterval === 0 || this.pipelineEvents.length >= this.evaluationAnalyticsMaxBuffer) { @@ -1071,24 +1082,13 @@ const Flagsmith = class { } } - private buildCustomEvent(eventName: string, identityIdentifier: string | null, metadata?: Record, timestamp?: number): IPipelineEvent { - return { - event_id: eventName, - event_type: PipelineEventType.CUSTOM_EVENT, - evaluated_at: timestamp ?? Date.now(), - identity_identifier: identityIdentifier, - enabled: null, - value: null, - traits: this.currentTraitsSnapshot(), - metadata: this.sdkMetadata(metadata), - }; - } - trackEvent = (eventName: string, metadata?: Record) => { if (!this.evaluationAnalyticsUrl || !eventName) { return; } - const event = this.buildCustomEvent(eventName, this.evaluationContext.identity?.identifier ?? null, metadata); + const event = this.buildAnalyticEvent(PipelineEventType.CUSTOM_EVENT, eventName, { + extraMetadata: metadata, + }); this.pipelineEvents.push(event); this.trimPipelineBuffer(); From 284934628f8cf28197bb75be29b11e86bd7371ab Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 24 Mar 2026 09:27:32 -0300 Subject: [PATCH 10/11] fix: add missing @internal tag to trackEvent JSDoc Co-Authored-By: Claude Opus 4.6 (1M context) --- types.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types.d.ts b/types.d.ts index 1e49ff0..09b48d2 100644 --- a/types.d.ts +++ b/types.d.ts @@ -304,6 +304,7 @@ T extends string = string * Requires `evaluationAnalyticsConfig` to be set; no-op otherwise. * Events are sent with the current identity (or null if anonymous). * @experimental Internal use only — API may change without notice. + * @internal * @hidden */ trackEvent: (eventName: string, metadata?: Record) => void; From ca2ed55085aad48ebdb3ebe1b1c28a8fa7a73418 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 24 Mar 2026 14:18:17 -0300 Subject: [PATCH 11/11] fix: remove trimPipelineBuffer and flush on buffer overflow Remove trimPipelineBuffer, aligning with parent branch (36066b2). Add buffer overflow flush condition to trackEvent, matching the approach in recordPipelineEvent. Co-Authored-By: Claude Opus 4.6 (1M context) --- flagsmith-core.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 202ceaa..b95ca5a 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1012,13 +1012,6 @@ const Flagsmith = class { this.pipelineRecordedKeys.clear(); } - private trimPipelineBuffer() { - if (this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer) { - const excess = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; - this.pipelineEvents = this.pipelineEvents.slice(excess); - } - } - private currentTraitsSnapshot() { return this.evaluationContext.identity?.traits ? { ...this.evaluationContext.identity.traits } @@ -1090,9 +1083,8 @@ const Flagsmith = class { extraMetadata: metadata, }); this.pipelineEvents.push(event); - this.trimPipelineBuffer(); - if (this.pipelineFlushInterval === 0) { + if (this.pipelineFlushInterval === 0 || this.pipelineEvents.length >= this.evaluationAnalyticsMaxBuffer) { this.flushPipelineAnalytics(); } }