From 02573c670f612a6602e079d2d4f408610581b9be Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 3 Mar 2026 15:08:54 +0700 Subject: [PATCH 01/18] feat: collect-and-flush-analytics-evaluation-events --- flagsmith-core.ts | 79 ++++++++++++++ test/analytics-pipeline.test.ts | 183 ++++++++++++++++++++++++++++++++ types.d.ts | 28 +++++ 3 files changed, 290 insertions(+) create mode 100644 test/analytics-pipeline.test.ts diff --git a/flagsmith-core.ts b/flagsmith-core.ts index d81d05e..6e13017 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -9,6 +9,8 @@ import { IFlagsmithResponse, IFlagsmithTrait, IInitConfig, + IPipelineEvent, + IPipelineEventBatch, ISentryClient, IState, ITraits, @@ -265,6 +267,35 @@ const Flagsmith = class { } }; + flushPipelineAnalytics = async () => { + if (!this.evaluationAnalyticsUrl || this.pipelineEvents.length === 0 || !this.evaluationContext.environment) { + return; + } + + const eventsToSend = this.pipelineEvents; + this.pipelineEvents = []; + + const batch: IPipelineEventBatch = { + events: eventsToSend, + sdk_version: SDK_VERSION, + environment_key: this.evaluationContext.environment.apiKey, + }; + + try { + await this.getJSON(this.evaluationAnalyticsUrl + 'v1/analytics/batch', 'POST', JSON.stringify(batch)); + this.log('Pipeline analytics: flush successful'); + } catch (err) { + // Re-queue failed events (prepend so they're sent first on next flush) + this.pipelineEvents = eventsToSend.concat(this.pipelineEvents); + const isExceedingBuffer = this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer; + if (isExceedingBuffer) { + const excessCount = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; + this.pipelineEvents = this.pipelineEvents.slice(excessCount); + } + this.log('Pipeline analytics: flush failed, events re-queued', err); + } + }; + datadogRum: IDatadogRum | null = null; loadingState: LoadingState = {isLoading: true, isFetching: true, error: null, source: FlagSource.NONE} canUseStorage = false @@ -290,6 +321,10 @@ 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 async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -308,6 +343,7 @@ const Flagsmith = class { enableDynatrace, enableLogs, environmentID, + evaluationAnalyticsConfig, eventSourceUrl= "https://realtime.flagsmith.com/", fetch: fetchImplementation, headers, @@ -441,6 +477,10 @@ const Flagsmith = class { } } + if (evaluationAnalyticsConfig) { + this.initPipelineAnalytics(evaluationAnalyticsConfig); + } + //If the user specified default flags emit a changed event immediately if (cacheFlags) { if (AsyncStorage && this.canUseStorage) { @@ -916,9 +956,48 @@ const Flagsmith = class { } this.evaluationEvent[this.evaluationContext.environment.apiKey][key] += 1; } + + if (this.evaluationAnalyticsUrl) { + this.recordPipelineEvent(key, method); + } + this.updateEventStorage(); }; + private initPipelineAnalytics(config: NonNullable) { + if (this.pipelineAnalyticsInterval) { + clearInterval(this.pipelineAnalyticsInterval); + } + this.evaluationAnalyticsUrl = ensureTrailingSlash(config.analyticsServerUrl); + this.evaluationAnalyticsMaxBuffer = config.maxBuffer ?? 1000; + this.pipelineEvents = []; + this.pipelineAnalyticsInterval = setInterval( + this.flushPipelineAnalytics, + config.flushInterval ?? this.ticks!, + ); + } + + private recordPipelineEvent(key: string, method: 'VALUE' | 'ENABLED') { + const flagKey = key.toLowerCase().replace(/ /g, '_'); + const flag = this.flags && this.flags[flagKey]; + const event: IPipelineEvent = { + type: method, + flag_key: flagKey, + value: flag ? (method === 'ENABLED' ? flag.enabled : flag.value) : null, + identity_id: this.evaluationContext.identity?.identifier ?? null, + timestamp: Math.floor(Date.now() / 1000), + traits: this.evaluationContext.identity?.traits ?? null, + custom: flag ? { id: flag.id, enabled: flag.enabled, value: flag.value } : null, + }; + this.pipelineEvents.push(event); + + const isExceedingBuffer = this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer; + if (isExceedingBuffer) { + const excessCount = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; + this.pipelineEvents = this.pipelineEvents.slice(excessCount); + } + } + private setLoadingState(loadingState: LoadingState) { if (!deepEqual(loadingState, this.loadingState)) { this.loadingState = { ...loadingState }; diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts new file mode 100644 index 0000000..0482caa --- /dev/null +++ b/test/analytics-pipeline.test.ts @@ -0,0 +1,183 @@ +import { getFlagsmith, delay, environmentID, testIdentity } from './test-constants'; +import { promises as fs } from 'fs'; + +const pipelineUrl = 'http://localhost:8080/'; + +function mockFetchWithPipeline(mockFetch: jest.Mock) { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes('v1/analytics/batch')) { + return { status: 202, text: () => Promise.resolve('') }; + } + if (url.includes('analytics/flags')) { + return { status: 200, text: () => Promise.resolve('{}') }; + } + if (url.includes('identities')) { + return { status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8') }; + } + if (url.includes('flags')) { + return { status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8') }; + } + throw new Error('Unmocked URL: ' + url); + }); +} + +function getPipelineCalls(mockFetch: jest.Mock) { + return mockFetch.mock.calls.filter( + ([url]: [string]) => url.includes('v1/analytics/batch') + ); +} + +describe('Pipeline Analytics', () => { + + test('should not send pipeline events when evaluationAnalyticsConfig is not set', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith(); + await flagsmith.init(initConfig); + + flagsmith.getValue('hero'); + flagsmith.hasFeature('font_size'); + + expect(getPipelineCalls(mockFetch)).toHaveLength(0); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(0); + }); + + test('should buffer events and flush with correct shape and headers', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 100, + }, + }); + mockFetchWithPipeline(mockFetch); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + flagsmith.hasFeature('hero'); + + await delay(150); + + const calls = getPipelineCalls(mockFetch); + expect(calls).toHaveLength(1); + + const body = JSON.parse(calls[0][1].body); + expect(body.sdk_version).toBe('11.0.0'); + expect(body.environment_key).toBe(environmentID); + expect(body.events).toHaveLength(2); + + const valueEvent = body.events[0]; + expect(valueEvent.type).toBe('VALUE'); + expect(valueEvent.flag_key).toBe('font_size'); + expect(valueEvent.value).toBe(16); + expect(valueEvent.identity_id).toBeNull(); + expect(valueEvent.timestamp).toBeGreaterThan(0); + expect(valueEvent.custom).toEqual({ id: 6149, enabled: true, value: 16 }); + + const enabledEvent = body.events[1]; + expect(enabledEvent.type).toBe('ENABLED'); + expect(enabledEvent.flag_key).toBe('hero'); + expect(enabledEvent.value).toBe(true); + + const headers = calls[0][1].headers; + expect(headers['X-Environment-Key']).toBe(environmentID); + expect(headers['Content-Type']).toBe('application/json; charset=utf-8'); + expect(headers['Flagsmith-SDK-User-Agent']).toMatch(/^flagsmith-js-sdk\//); + }); + + test('should include identity and full traits when identified', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 100, + }, + identity: testIdentity, + }); + mockFetchWithPipeline(mockFetch); + await flagsmith.init(initConfig); + + flagsmith.getValue('hero'); + + await delay(150); + + const calls = getPipelineCalls(mockFetch); + const event = JSON.parse(calls[0][1].body).events[0]; + + expect(event.identity_id).toBe(testIdentity); + expect(event.traits).toEqual({ + number_trait: { value: 1 }, + string_trait: { value: 'Example' }, + }); + }); + + test('should cap buffer at maxBuffer and skip events when skipAnalytics is used', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + maxBuffer: 3, + flushInterval: 60000, + }, + }); + mockFetchWithPipeline(mockFetch); + await flagsmith.init(initConfig); + + flagsmith.getValue('hero', { skipAnalytics: true }); + flagsmith.hasFeature('font_size', { skipAnalytics: true }); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(0); + + flagsmith.getValue('hero'); + flagsmith.getValue('font_size'); + flagsmith.getValue('json_value'); + flagsmith.getValue('number_value'); + flagsmith.getValue('off_value'); + + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(3); + // @ts-ignore + expect(flagsmith.pipelineEvents[0].flag_key).toBe('json_value'); + // @ts-ignore + expect(flagsmith.pipelineEvents[2].flag_key).toBe('off_value'); + }); + + test('should re-queue on failure and coexist with standard analytics', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + enableAnalytics: true, + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 60000, + }, + }); + + mockFetch.mockImplementation(async (url: string) => { + if (url.includes('v1/analytics/batch')) { + return { status: 500, text: () => Promise.resolve('Server Error') }; + } + if (url.includes('analytics/flags')) { + return { status: 200, text: () => Promise.resolve('{}') }; + } + if (url.includes('flags')) { + return { status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8') }; + } + throw new Error('Unmocked URL: ' + url); + }); + + await flagsmith.init(initConfig); + + flagsmith.getValue('hero'); + flagsmith.getValue('font_size'); + + // @ts-ignore + expect(flagsmith.evaluationEvent[environmentID]['hero']).toBe(1); + // @ts-ignore + expect(flagsmith.evaluationEvent[environmentID]['font_size']).toBe(1); + + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(2); + + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(2); + // @ts-ignore + expect(flagsmith.pipelineEvents[0].flag_key).toBe('hero'); + }); +}); diff --git a/types.d.ts b/types.d.ts index f6c8f8d..828f594 100644 --- a/types.d.ts +++ b/types.d.ts @@ -131,6 +131,34 @@ export interface IInitConfig = string, T * Customer application metadata */ applicationMetadata?: ApplicationMetadata; + /** + * Configuration for the evaluation analytics pipeline. When provided, + * individual flag evaluation events are buffered and sent to the pipeline endpoint. + */ + evaluationAnalyticsConfig?: { + /** URL of the pipeline server (e.g. 'https://analytics.flagsmith.com/'). */ + analyticsServerUrl: string; + /** Maximum events to buffer in memory before dropping oldest. Default 1000. */ + maxBuffer?: number; + /** Flush interval in milliseconds. Default 10000 (10s). */ + flushInterval?: number; + }; +} + +export interface IPipelineEvent { + type: string; + flag_key: string; + value: any; + identity_id?: string | null; + timestamp: number; + traits?: { [key: string]: null | TraitEvaluationContext } | null; + custom?: Record | null; +} + +export interface IPipelineEventBatch { + events: IPipelineEvent[]; + sdk_version: string; + environment_key: string; } export interface IFlagsmithResponse { From 966faeebf4275f10004c2d848287dbfa19878775 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 3 Mar 2026 16:32:39 +0700 Subject: [PATCH 02/18] fix: race-condition-and-cleanup --- flagsmith-core.ts | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 6e13017..5978561 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -66,6 +66,7 @@ type Config = { const FLAGSMITH_CONFIG_ANALYTICS_KEY = "flagsmith_value_"; const FLAGSMITH_FLAG_ANALYTICS_KEY = "flagsmith_enabled_"; const FLAGSMITH_TRAIT_ANALYTICS_KEY = "flagsmith_trait_"; +const DEFAULT_PIPELINE_FLUSH_INTERVAL = 10000; const Flagsmith = class { _trigger?:(()=>void)|null= null @@ -268,10 +269,11 @@ const Flagsmith = class { }; flushPipelineAnalytics = async () => { - if (!this.evaluationAnalyticsUrl || this.pipelineEvents.length === 0 || !this.evaluationContext.environment) { + if (this.isPipelineFlushing || !this.evaluationAnalyticsUrl || this.pipelineEvents.length === 0 || !this.evaluationContext.environment) { return; } + this.isPipelineFlushing = true; const eventsToSend = this.pipelineEvents; this.pipelineEvents = []; @@ -282,10 +284,20 @@ const Flagsmith = class { }; try { - await this.getJSON(this.evaluationAnalyticsUrl + 'v1/analytics/batch', 'POST', JSON.stringify(batch)); + const res = await _fetch(this.evaluationAnalyticsUrl + 'v1/analytics/batch', { + method: 'POST', + body: JSON.stringify(batch), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Environment-Key': this.evaluationContext.environment.apiKey, + ...(SDK_VERSION ? { 'Flagsmith-SDK-User-Agent': `flagsmith-js-sdk/${SDK_VERSION}` } : {}), + }, + }); + if (!res.status || res.status < 200 || res.status >= 300) { + throw new Error(`Pipeline analytics: unexpected status ${res.status}`); + } this.log('Pipeline analytics: flush successful'); } catch (err) { - // Re-queue failed events (prepend so they're sent first on next flush) this.pipelineEvents = eventsToSend.concat(this.pipelineEvents); const isExceedingBuffer = this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer; if (isExceedingBuffer) { @@ -293,6 +305,8 @@ const Flagsmith = class { this.pipelineEvents = this.pipelineEvents.slice(excessCount); } this.log('Pipeline analytics: flush failed, events re-queued', err); + } finally { + this.isPipelineFlushing = false; } }; @@ -325,6 +339,7 @@ const Flagsmith = class { evaluationAnalyticsMaxBuffer: number = 1000 pipelineEvents: IPipelineEvent[] = [] pipelineAnalyticsInterval: ReturnType | null = null + isPipelineFlushing = false async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -479,6 +494,8 @@ const Flagsmith = class { if (evaluationAnalyticsConfig) { this.initPipelineAnalytics(evaluationAnalyticsConfig); + } else { + this.stopPipelineAnalytics(); } //If the user specified default flags emit a changed event immediately @@ -965,18 +982,25 @@ const Flagsmith = class { }; private initPipelineAnalytics(config: NonNullable) { - if (this.pipelineAnalyticsInterval) { - clearInterval(this.pipelineAnalyticsInterval); - } + this.stopPipelineAnalytics(); this.evaluationAnalyticsUrl = ensureTrailingSlash(config.analyticsServerUrl); this.evaluationAnalyticsMaxBuffer = config.maxBuffer ?? 1000; this.pipelineEvents = []; this.pipelineAnalyticsInterval = setInterval( this.flushPipelineAnalytics, - config.flushInterval ?? this.ticks!, + config.flushInterval ?? DEFAULT_PIPELINE_FLUSH_INTERVAL, ); } + private stopPipelineAnalytics() { + if (this.pipelineAnalyticsInterval) { + clearInterval(this.pipelineAnalyticsInterval); + this.pipelineAnalyticsInterval = null; + } + this.evaluationAnalyticsUrl = null; + this.pipelineEvents = []; + } + private recordPipelineEvent(key: string, method: 'VALUE' | 'ENABLED') { const flagKey = key.toLowerCase().replace(/ /g, '_'); const flag = this.flags && this.flags[flagKey]; @@ -986,7 +1010,9 @@ const Flagsmith = class { value: flag ? (method === 'ENABLED' ? flag.enabled : flag.value) : null, identity_id: this.evaluationContext.identity?.identifier ?? null, timestamp: Math.floor(Date.now() / 1000), - traits: this.evaluationContext.identity?.traits ?? null, + traits: this.evaluationContext.identity?.traits + ? JSON.parse(JSON.stringify(this.evaluationContext.identity.traits)) + : null, custom: flag ? { id: flag.id, enabled: flag.enabled, value: flag.value } : null, }; this.pipelineEvents.push(event); From 180c55d6ada65643ddec3385c9d5355b8f2b97b8 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 3 Mar 2026 16:36:25 +0700 Subject: [PATCH 03/18] feat: added-pull-request-template --- .github/pull_request_template.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b28bcbb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +Thanks for submitting a PR! Please check the boxes below: + +- [ ] I have read the [Contributing Guide](/Flagsmith/flagsmith/blob/main/CONTRIBUTING.md). +- [ ] I have added information to `docs/` if required so people know about the feature. +- [ ] I have filled in the "Changes" section below. +- [ ] I have filled in the "How did you test this code" section below. + +## Changes + +Contributes to + + + +_Please describe._ + +## How did you test this code? + + + +_Please describe._ From 3dbf39f19b32705764fd060508e4a9c389cc2ff3 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 3 Mar 2026 17:13:55 +0700 Subject: [PATCH 04/18] feat: added-publish-internal-workflow-oidc-compatible --- .github/workflows/publish-internal.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/publish-internal.yml diff --git a/.github/workflows/publish-internal.yml b/.github/workflows/publish-internal.yml new file mode 100644 index 0000000..b15424c --- /dev/null +++ b/.github/workflows/publish-internal.yml @@ -0,0 +1,25 @@ +name: Publish Internal NPM Package + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + package: + runs-on: ubuntu-latest + name: Publish Internal NPM Package + + steps: + - name: Cloning repo + uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + registry-url: 'https://registry.npmjs.org' + + - run: npm i + - run: npm run build && npm test && cd ./lib/flagsmith/ && npm publish --tag internal --access public && cd ../../lib/react-native-flagsmith && npm publish --tag internal --access public From bb88e0c2a5a8e4b3705bfcf1d74ee8177169f499 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 14:26:20 +0700 Subject: [PATCH 05/18] feat: remap-events-to-latest-schema --- flagsmith-core.ts | 30 +++++++++++++++++--------- test/analytics-pipeline.test.ts | 38 ++++++++++++++++++--------------- types.d.ts | 15 +++++++------ 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 5978561..2623261 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -981,15 +981,20 @@ const Flagsmith = class { this.updateEventStorage(); }; + private pipelineFlushInterval: number = DEFAULT_PIPELINE_FLUSH_INTERVAL; + private initPipelineAnalytics(config: NonNullable) { this.stopPipelineAnalytics(); this.evaluationAnalyticsUrl = ensureTrailingSlash(config.analyticsServerUrl); this.evaluationAnalyticsMaxBuffer = config.maxBuffer ?? 1000; + this.pipelineFlushInterval = config.flushInterval ?? DEFAULT_PIPELINE_FLUSH_INTERVAL; this.pipelineEvents = []; - this.pipelineAnalyticsInterval = setInterval( - this.flushPipelineAnalytics, - config.flushInterval ?? DEFAULT_PIPELINE_FLUSH_INTERVAL, - ); + if (this.pipelineFlushInterval > 0) { + this.pipelineAnalyticsInterval = setInterval( + this.flushPipelineAnalytics, + this.pipelineFlushInterval, + ); + } } private stopPipelineAnalytics() { @@ -1005,15 +1010,16 @@ const Flagsmith = class { const flagKey = key.toLowerCase().replace(/ /g, '_'); const flag = this.flags && this.flags[flagKey]; const event: IPipelineEvent = { - type: method, - flag_key: flagKey, - value: flag ? (method === 'ENABLED' ? flag.enabled : flag.value) : null, - identity_id: this.evaluationContext.identity?.identifier ?? null, - timestamp: Math.floor(Date.now() / 1000), + event_id: flagKey, + event_type: 'flag_evaluation', + evaluated_at: Math.floor(Date.now() / 1000), + identity_identifier: this.evaluationContext.identity?.identifier ?? null, + enabled: flag ? flag.enabled : null, + value: flag ? flag.value : null, traits: this.evaluationContext.identity?.traits ? JSON.parse(JSON.stringify(this.evaluationContext.identity.traits)) : null, - custom: flag ? { id: flag.id, enabled: flag.enabled, value: flag.value } : null, + metadata: flag ? { id: flag.id } : null, }; this.pipelineEvents.push(event); @@ -1022,6 +1028,10 @@ const Flagsmith = class { const excessCount = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; this.pipelineEvents = this.pipelineEvents.slice(excessCount); } + + if (this.pipelineFlushInterval === 0) { + this.flushPipelineAnalytics(); + } } private setLoadingState(loadingState: LoadingState) { diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 0482caa..2d3a574 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -1,4 +1,4 @@ -import { getFlagsmith, delay, environmentID, testIdentity } from './test-constants'; +import { getFlagsmith, environmentID, testIdentity } from './test-constants'; import { promises as fs } from 'fs'; const pipelineUrl = 'http://localhost:8080/'; @@ -45,7 +45,7 @@ describe('Pipeline Analytics', () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith({ evaluationAnalyticsConfig: { analyticsServerUrl: pipelineUrl, - flushInterval: 100, + flushInterval: 60000, }, }); mockFetchWithPipeline(mockFetch); @@ -54,7 +54,8 @@ describe('Pipeline Analytics', () => { flagsmith.getValue('font_size'); flagsmith.hasFeature('hero'); - await delay(150); + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); const calls = getPipelineCalls(mockFetch); expect(calls).toHaveLength(1); @@ -65,17 +66,19 @@ describe('Pipeline Analytics', () => { expect(body.events).toHaveLength(2); const valueEvent = body.events[0]; - expect(valueEvent.type).toBe('VALUE'); - expect(valueEvent.flag_key).toBe('font_size'); + expect(valueEvent.event_id).toBe('font_size'); + expect(valueEvent.event_type).toBe('flag_evaluation'); expect(valueEvent.value).toBe(16); - expect(valueEvent.identity_id).toBeNull(); - expect(valueEvent.timestamp).toBeGreaterThan(0); - expect(valueEvent.custom).toEqual({ id: 6149, enabled: true, value: 16 }); + expect(valueEvent.enabled).toBe(true); + expect(valueEvent.identity_identifier).toBeNull(); + expect(valueEvent.evaluated_at).toBeGreaterThan(0); + expect(valueEvent.metadata).toEqual({ id: 6149 }); const enabledEvent = body.events[1]; - expect(enabledEvent.type).toBe('ENABLED'); - expect(enabledEvent.flag_key).toBe('hero'); - expect(enabledEvent.value).toBe(true); + expect(enabledEvent.event_id).toBe('hero'); + expect(enabledEvent.event_type).toBe('flag_evaluation'); + expect(enabledEvent.enabled).toBe(true); + expect(enabledEvent.value).toBe(flagsmith.getValue('hero')); const headers = calls[0][1].headers; expect(headers['X-Environment-Key']).toBe(environmentID); @@ -87,7 +90,7 @@ describe('Pipeline Analytics', () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith({ evaluationAnalyticsConfig: { analyticsServerUrl: pipelineUrl, - flushInterval: 100, + flushInterval: 60000, }, identity: testIdentity, }); @@ -96,12 +99,13 @@ describe('Pipeline Analytics', () => { flagsmith.getValue('hero'); - await delay(150); + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); const calls = getPipelineCalls(mockFetch); const event = JSON.parse(calls[0][1].body).events[0]; - expect(event.identity_id).toBe(testIdentity); + expect(event.identity_identifier).toBe(testIdentity); expect(event.traits).toEqual({ number_trait: { value: 1 }, string_trait: { value: 'Example' }, @@ -133,9 +137,9 @@ describe('Pipeline Analytics', () => { // @ts-ignore expect(flagsmith.pipelineEvents).toHaveLength(3); // @ts-ignore - expect(flagsmith.pipelineEvents[0].flag_key).toBe('json_value'); + expect(flagsmith.pipelineEvents[0].event_id).toBe('json_value'); // @ts-ignore - expect(flagsmith.pipelineEvents[2].flag_key).toBe('off_value'); + expect(flagsmith.pipelineEvents[2].event_id).toBe('off_value'); }); test('should re-queue on failure and coexist with standard analytics', async () => { @@ -178,6 +182,6 @@ describe('Pipeline Analytics', () => { // @ts-ignore expect(flagsmith.pipelineEvents).toHaveLength(2); // @ts-ignore - expect(flagsmith.pipelineEvents[0].flag_key).toBe('hero'); + expect(flagsmith.pipelineEvents[0].event_id).toBe('hero'); }); }); diff --git a/types.d.ts b/types.d.ts index 828f594..4de1c39 100644 --- a/types.d.ts +++ b/types.d.ts @@ -140,19 +140,20 @@ export interface IInitConfig = string, T analyticsServerUrl: string; /** Maximum events to buffer in memory before dropping oldest. Default 1000. */ maxBuffer?: number; - /** Flush interval in milliseconds. Default 10000 (10s). */ + /** Flush interval in milliseconds. Set to 0 to flush immediately after each evaluation. Default 10000 (10s). */ flushInterval?: number; }; } export interface IPipelineEvent { - type: string; - flag_key: string; - value: any; - identity_id?: string | null; - timestamp: number; + event_id: string; // flag_name or event_name + event_type: 'flag_evaluation' | 'custom_event'; + evaluated_at: number; + identity_identifier: string | null; + enabled?: boolean | null; + value: IFlagsmithValue; traits?: { [key: string]: null | TraitEvaluationContext } | null; - custom?: Record | null; + metadata?: Record | null; } export interface IPipelineEventBatch { From f2998b0630cd3a96c583d2138944eea885bf0ac8 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 15:31:24 +0700 Subject: [PATCH 06/18] feat: cleaned-up-endpoint-fetch-mock --- test/analytics-pipeline.test.ts | 42 ++++++--------------------------- test/functions.test.ts | 2 +- test/test-constants.ts | 6 +++++ 3 files changed, 14 insertions(+), 36 deletions(-) diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 2d3a574..675263b 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -1,25 +1,6 @@ import { getFlagsmith, environmentID, testIdentity } from './test-constants'; -import { promises as fs } from 'fs'; - -const pipelineUrl = 'http://localhost:8080/'; - -function mockFetchWithPipeline(mockFetch: jest.Mock) { - mockFetch.mockImplementation(async (url: string) => { - if (url.includes('v1/analytics/batch')) { - return { status: 202, text: () => Promise.resolve('') }; - } - if (url.includes('analytics/flags')) { - return { status: 200, text: () => Promise.resolve('{}') }; - } - if (url.includes('identities')) { - return { status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8') }; - } - if (url.includes('flags')) { - return { status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8') }; - } - throw new Error('Unmocked URL: ' + url); - }); -} + +const pipelineUrl = 'https://analytics.flagsmith.com/'; function getPipelineCalls(mockFetch: jest.Mock) { return mockFetch.mock.calls.filter( @@ -28,7 +9,6 @@ function getPipelineCalls(mockFetch: jest.Mock) { } describe('Pipeline Analytics', () => { - test('should not send pipeline events when evaluationAnalyticsConfig is not set', async () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith(); await flagsmith.init(initConfig); @@ -48,7 +28,6 @@ describe('Pipeline Analytics', () => { flushInterval: 60000, }, }); - mockFetchWithPipeline(mockFetch); await flagsmith.init(initConfig); flagsmith.getValue('font_size'); @@ -71,7 +50,7 @@ describe('Pipeline Analytics', () => { expect(valueEvent.value).toBe(16); expect(valueEvent.enabled).toBe(true); expect(valueEvent.identity_identifier).toBeNull(); - expect(valueEvent.evaluated_at).toBeGreaterThan(0); + expect(valueEvent.evaluated_at).toBeDefined(); expect(valueEvent.metadata).toEqual({ id: 6149 }); const enabledEvent = body.events[1]; @@ -94,7 +73,6 @@ describe('Pipeline Analytics', () => { }, identity: testIdentity, }); - mockFetchWithPipeline(mockFetch); await flagsmith.init(initConfig); flagsmith.getValue('hero'); @@ -113,14 +91,13 @@ describe('Pipeline Analytics', () => { }); test('should cap buffer at maxBuffer and skip events when skipAnalytics is used', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + const { flagsmith, initConfig } = getFlagsmith({ evaluationAnalyticsConfig: { analyticsServerUrl: pipelineUrl, maxBuffer: 3, flushInterval: 60000, }, }); - mockFetchWithPipeline(mockFetch); await flagsmith.init(initConfig); flagsmith.getValue('hero', { skipAnalytics: true }); @@ -151,17 +128,12 @@ describe('Pipeline Analytics', () => { }, }); - mockFetch.mockImplementation(async (url: string) => { + const original = mockFetch.getMockImplementation() as jest.Mock; + mockFetch.mockImplementation(async (url: string, options: any) => { if (url.includes('v1/analytics/batch')) { return { status: 500, text: () => Promise.resolve('Server Error') }; } - if (url.includes('analytics/flags')) { - return { status: 200, text: () => Promise.resolve('{}') }; - } - if (url.includes('flags')) { - return { status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8') }; - } - throw new Error('Unmocked URL: ' + url); + return original(url, options); }); await flagsmith.init(initConfig); diff --git a/test/functions.test.ts b/test/functions.test.ts index e84e2ac..36dc5a3 100644 --- a/test/functions.test.ts +++ b/test/functions.test.ts @@ -7,7 +7,7 @@ describe('Flagsmith.functions', () => { }); test('should use a fallback when the feature is undefined', async () => { const onChange = jest.fn() - const {flagsmith,initConfig, AsyncStorage,mockFetch} = getFlagsmith({onChange}) + const { flagsmith,initConfig } = getFlagsmith({onChange}) await flagsmith.init(initConfig); expect(flagsmith.getValue("deleted_feature",{fallback:"foo"})).toBe("foo"); diff --git a/test/test-constants.ts b/test/test-constants.ts index 9cbf08d..c9bde1c 100644 --- a/test/test-constants.ts +++ b/test/test-constants.ts @@ -76,6 +76,12 @@ export function getFlagsmith(config: Partial = {}) { const flagsmith = createFlagsmithInstance(); const AsyncStorage = new MockAsyncStorage(); const mockFetch = jest.fn(async (url, options) => { + if (url.includes('v1/analytics/batch')) { + return {status: 202, text: () => Promise.resolve('')} + } + if (url.includes('analytics/flags')) { + return {status: 200, text: () => Promise.resolve('{}')} + } switch (url) { case 'https://edge.api.flagsmith.com/api/v1/flags/': return {status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8')} From 9e9c844a7e5af2ddf631939fe1fe149f0378b581 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 15:53:45 +0700 Subject: [PATCH 07/18] feat: cleaned-up-unused-variables-and-types --- flagsmith-core.ts | 40 ++++++++++++++++++++++------------------ types.d.ts | 2 +- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 2623261..a2fae2d 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -269,10 +269,13 @@ const Flagsmith = class { }; flushPipelineAnalytics = async () => { - if (this.isPipelineFlushing || !this.evaluationAnalyticsUrl || this.pipelineEvents.length === 0 || !this.evaluationContext.environment) { + const isEvaluationEnabled = this.evaluationAnalyticsUrl && this.evaluationContext.environment; + const isReadyToFlush = this.pipelineEvents.length > 0 && (!this.isPipelineFlushing || this.pipelineFlushInterval === 0); + if (!isEvaluationEnabled || !isReadyToFlush) { return; } + const environmentKey = this.evaluationContext.environment!.apiKey; this.isPipelineFlushing = true; const eventsToSend = this.pipelineEvents; this.pipelineEvents = []; @@ -280,7 +283,7 @@ const Flagsmith = class { const batch: IPipelineEventBatch = { events: eventsToSend, sdk_version: SDK_VERSION, - environment_key: this.evaluationContext.environment.apiKey, + environment_key: environmentKey, }; try { @@ -289,7 +292,7 @@ const Flagsmith = class { body: JSON.stringify(batch), headers: { 'Content-Type': 'application/json; charset=utf-8', - 'X-Environment-Key': this.evaluationContext.environment.apiKey, + 'X-Environment-Key': environmentKey, ...(SDK_VERSION ? { 'Flagsmith-SDK-User-Agent': `flagsmith-js-sdk/${SDK_VERSION}` } : {}), }, }); @@ -299,11 +302,7 @@ const Flagsmith = class { this.log('Pipeline analytics: flush successful'); } catch (err) { this.pipelineEvents = eventsToSend.concat(this.pipelineEvents); - const isExceedingBuffer = this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer; - if (isExceedingBuffer) { - const excessCount = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; - this.pipelineEvents = this.pipelineEvents.slice(excessCount); - } + this.trimPipelineBuffer(); this.log('Pipeline analytics: flush failed, events re-queued', err); } finally { this.isPipelineFlushing = false; @@ -975,7 +974,7 @@ const Flagsmith = class { } if (this.evaluationAnalyticsUrl) { - this.recordPipelineEvent(key, method); + this.recordPipelineEvent(key); } this.updateEventStorage(); @@ -994,6 +993,7 @@ const Flagsmith = class { this.flushPipelineAnalytics, this.pipelineFlushInterval, ); + this.pipelineAnalyticsInterval?.unref?.(); } } @@ -1006,28 +1006,32 @@ const Flagsmith = class { this.pipelineEvents = []; } - private recordPipelineEvent(key: string, method: 'VALUE' | 'ENABLED') { + private trimPipelineBuffer() { + if (this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer) { + const excess = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; + this.pipelineEvents = this.pipelineEvents.slice(excess); + } + } + + // 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) { const flagKey = key.toLowerCase().replace(/ /g, '_'); const flag = this.flags && this.flags[flagKey]; const event: IPipelineEvent = { event_id: flagKey, event_type: 'flag_evaluation', - evaluated_at: Math.floor(Date.now() / 1000), + evaluated_at: new Date().toISOString(), identity_identifier: this.evaluationContext.identity?.identifier ?? null, enabled: flag ? flag.enabled : null, value: flag ? flag.value : null, traits: this.evaluationContext.identity?.traits - ? JSON.parse(JSON.stringify(this.evaluationContext.identity.traits)) + ? { ...this.evaluationContext.identity.traits } : null, metadata: flag ? { id: flag.id } : null, }; this.pipelineEvents.push(event); - - const isExceedingBuffer = this.pipelineEvents.length > this.evaluationAnalyticsMaxBuffer; - if (isExceedingBuffer) { - const excessCount = this.pipelineEvents.length - this.evaluationAnalyticsMaxBuffer; - this.pipelineEvents = this.pipelineEvents.slice(excessCount); - } + this.trimPipelineBuffer(); if (this.pipelineFlushInterval === 0) { this.flushPipelineAnalytics(); diff --git a/types.d.ts b/types.d.ts index 4de1c39..7bfab61 100644 --- a/types.d.ts +++ b/types.d.ts @@ -148,7 +148,7 @@ export interface IInitConfig = string, T export interface IPipelineEvent { event_id: string; // flag_name or event_name event_type: 'flag_evaluation' | 'custom_event'; - evaluated_at: number; + evaluated_at: string; identity_identifier: string | null; enabled?: boolean | null; value: IFlagsmithValue; From afea3525ebc440cff8bca908f972c5dcf0235f35 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 16:06:12 +0700 Subject: [PATCH 08/18] feat: added-page-url-in-metadata --- flagsmith-core.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index a2fae2d..68c8332 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1028,7 +1028,10 @@ const Flagsmith = class { traits: this.evaluationContext.identity?.traits ? { ...this.evaluationContext.identity.traits } : null, - metadata: flag ? { id: flag.id } : null, + metadata: { + ...(flag ? { id: flag.id } : {}), + ...(typeof window !== 'undefined' && window.location ? { page_url: window.location.href } : {}), + }, }; this.pipelineEvents.push(event); this.trimPipelineBuffer(); From c7faf1641c457b17161dc2c5f99c463b22f0a47b Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 18:31:17 +0700 Subject: [PATCH 09/18] feat: sync-payload-with-expected-rust --- flagsmith-core.ts | 4 ++-- test/analytics-pipeline.test.ts | 1 - types.d.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 68c8332..adb76f4 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -282,7 +282,6 @@ const Flagsmith = class { const batch: IPipelineEventBatch = { events: eventsToSend, - sdk_version: SDK_VERSION, environment_key: environmentKey, }; @@ -1021,7 +1020,7 @@ const Flagsmith = class { const event: IPipelineEvent = { event_id: flagKey, event_type: 'flag_evaluation', - evaluated_at: new Date().toISOString(), + evaluated_at: Date.now(), identity_identifier: this.evaluationContext.identity?.identifier ?? null, enabled: flag ? flag.enabled : null, value: flag ? flag.value : null, @@ -1031,6 +1030,7 @@ const Flagsmith = class { metadata: { ...(flag ? { id: flag.id } : {}), ...(typeof window !== 'undefined' && window.location ? { page_url: window.location.href } : {}), + ...(SDK_VERSION ? { sdk_version: SDK_VERSION } : {}), }, }; this.pipelineEvents.push(event); diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 675263b..168f740 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -40,7 +40,6 @@ describe('Pipeline Analytics', () => { expect(calls).toHaveLength(1); const body = JSON.parse(calls[0][1].body); - expect(body.sdk_version).toBe('11.0.0'); expect(body.environment_key).toBe(environmentID); expect(body.events).toHaveLength(2); diff --git a/types.d.ts b/types.d.ts index 7bfab61..dddfc89 100644 --- a/types.d.ts +++ b/types.d.ts @@ -148,7 +148,7 @@ export interface IInitConfig = string, T export interface IPipelineEvent { event_id: string; // flag_name or event_name event_type: 'flag_evaluation' | 'custom_event'; - evaluated_at: string; + evaluated_at: number; identity_identifier: string | null; enabled?: boolean | null; value: IFlagsmithValue; @@ -158,7 +158,6 @@ export interface IPipelineEvent { export interface IPipelineEventBatch { events: IPipelineEvent[]; - sdk_version: string; environment_key: string; } From 7e7dd4c40f23595efb2cdcbc987e956f6ff11585 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 18:41:21 +0700 Subject: [PATCH 10/18] feat: removed-pipeline-action-and-template --- .github/pull_request_template.md | 21 --------------------- .github/workflows/publish-internal.yml | 25 ------------------------- 2 files changed, 46 deletions(-) delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/publish-internal.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index b28bcbb..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,21 +0,0 @@ -Thanks for submitting a PR! Please check the boxes below: - -- [ ] I have read the [Contributing Guide](/Flagsmith/flagsmith/blob/main/CONTRIBUTING.md). -- [ ] I have added information to `docs/` if required so people know about the feature. -- [ ] I have filled in the "Changes" section below. -- [ ] I have filled in the "How did you test this code" section below. - -## Changes - -Contributes to - - - -_Please describe._ - -## How did you test this code? - - - -_Please describe._ diff --git a/.github/workflows/publish-internal.yml b/.github/workflows/publish-internal.yml deleted file mode 100644 index b15424c..0000000 --- a/.github/workflows/publish-internal.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Publish Internal NPM Package - -on: - workflow_dispatch: - -permissions: - id-token: write - contents: read - -jobs: - package: - runs-on: ubuntu-latest - name: Publish Internal NPM Package - - steps: - - name: Cloning repo - uses: actions/checkout@v5 - - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - registry-url: 'https://registry.npmjs.org' - - - run: npm i - - run: npm run build && npm test && cd ./lib/flagsmith/ && npm publish --tag internal --access public && cd ../../lib/react-native-flagsmith && npm publish --tag internal --access public From ee8143a4edd0c8b3d3b627a50ceafd7f08d0e553 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 19:08:37 +0700 Subject: [PATCH 11/18] feat: removed-test-asserting-data-depending-on-ci --- test/analytics-pipeline.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 168f740..29cfe25 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -50,7 +50,7 @@ describe('Pipeline Analytics', () => { expect(valueEvent.enabled).toBe(true); expect(valueEvent.identity_identifier).toBeNull(); expect(valueEvent.evaluated_at).toBeDefined(); - expect(valueEvent.metadata).toEqual({ id: 6149 }); + expect(valueEvent.metadata).toEqual(expect.objectContaining({ id: 6149 })); const enabledEvent = body.events[1]; expect(enabledEvent.event_id).toBe('hero'); From fa34dc0b659492859e1334d3f4245c882bd2590f Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 20:41:22 +0700 Subject: [PATCH 12/18] feat: suffix-internal-version-in-action --- .github/workflows/publish-internal.yml | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/publish-internal.yml diff --git a/.github/workflows/publish-internal.yml b/.github/workflows/publish-internal.yml new file mode 100644 index 0000000..531e30a --- /dev/null +++ b/.github/workflows/publish-internal.yml @@ -0,0 +1,40 @@ +name: Publish Internal NPM Package + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + package: + runs-on: ubuntu-latest + name: Publish Internal NPM Package + + steps: + - name: Cloning repo + uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + registry-url: 'https://registry.npmjs.org' + + - run: npm i + + - name: Set internal version + run: | + VERSION=$(node -p "require('./lib/flagsmith/package.json').version") + INTERNAL="${VERSION}-internal.${{ github.run_number }}" + echo "Publishing version: $INTERNAL" + cd lib/flagsmith && npm version $INTERNAL --no-git-tag-version + cd ../../lib/react-native-flagsmith && npm version $INTERNAL --no-git-tag-version + + - name: Build and test + run: npm run build && npm test + + - name: Publish + run: | + cd lib/flagsmith && npm publish --tag internal --access public + cd ../../lib/react-native-flagsmith && npm publish --tag internal --access public From 2ee7d8049603eeb35fc1f38ee1259d02f8b00bda Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Mar 2026 20:42:18 +0700 Subject: [PATCH 13/18] feat: removing-internal-action-bis --- .github/workflows/publish-internal.yml | 40 -------------------------- 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/publish-internal.yml diff --git a/.github/workflows/publish-internal.yml b/.github/workflows/publish-internal.yml deleted file mode 100644 index 531e30a..0000000 --- a/.github/workflows/publish-internal.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Publish Internal NPM Package - -on: - workflow_dispatch: - -permissions: - id-token: write - contents: read - -jobs: - package: - runs-on: ubuntu-latest - name: Publish Internal NPM Package - - steps: - - name: Cloning repo - uses: actions/checkout@v5 - - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - registry-url: 'https://registry.npmjs.org' - - - run: npm i - - - name: Set internal version - run: | - VERSION=$(node -p "require('./lib/flagsmith/package.json').version") - INTERNAL="${VERSION}-internal.${{ github.run_number }}" - echo "Publishing version: $INTERNAL" - cd lib/flagsmith && npm version $INTERNAL --no-git-tag-version - cd ../../lib/react-native-flagsmith && npm version $INTERNAL --no-git-tag-version - - - name: Build and test - run: npm run build && npm test - - - name: Publish - run: | - cd lib/flagsmith && npm publish --tag internal --access public - cd ../../lib/react-native-flagsmith && npm publish --tag internal --access public From 7f288cc6a49a1057ee236ed8f7967b03fce9e7d9 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 6 Mar 2026 16:22:08 +0700 Subject: [PATCH 14/18] feat: deduplicate-events-using-a-field-fingerprint --- flagsmith-core.ts | 8 +++++ test/analytics-pipeline.test.ts | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index adb76f4..a386703 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -279,6 +279,7 @@ const Flagsmith = class { this.isPipelineFlushing = true; const eventsToSend = this.pipelineEvents; this.pipelineEvents = []; + this.pipelineRecordedKeys.clear(); const batch: IPipelineEventBatch = { events: eventsToSend, @@ -338,6 +339,7 @@ const Flagsmith = class { pipelineEvents: IPipelineEvent[] = [] pipelineAnalyticsInterval: ReturnType | null = null isPipelineFlushing = false + pipelineRecordedKeys: Map = new Map() async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -1003,6 +1005,7 @@ const Flagsmith = class { } this.evaluationAnalyticsUrl = null; this.pipelineEvents = []; + this.pipelineRecordedKeys.clear(); } private trimPipelineBuffer() { @@ -1017,6 +1020,11 @@ const Flagsmith = class { private recordPipelineEvent(key: string) { const flagKey = key.toLowerCase().replace(/ /g, '_'); const flag = this.flags && this.flags[flagKey]; + const fingerprint = `${this.evaluationContext.identity?.identifier ?? 'none'}|${flag?.enabled ?? false}|${flag?.value ?? 'null'}`; + if (this.pipelineRecordedKeys.get(flagKey) === fingerprint) { + return; + } + this.pipelineRecordedKeys.set(flagKey, fingerprint); const event: IPipelineEvent = { event_id: flagKey, event_type: 'flag_evaluation', diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 29cfe25..6158bfd 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -118,6 +118,63 @@ describe('Pipeline Analytics', () => { expect(flagsmith.pipelineEvents[2].event_id).toBe('off_value'); }); + test('should deduplicate repeated evaluations with same result per flush window', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 60000, + }, + identity: testIdentity, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + flagsmith.getValue('font_size'); + flagsmith.getValue('font_size'); + flagsmith.hasFeature('font_size'); + flagsmith.hasFeature('font_size'); + + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(1); + // @ts-ignore + expect(flagsmith.pipelineEvents[0].event_id).toBe('font_size'); + + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); + flagsmith.getValue('font_size'); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(1); + + flagsmith.getValue('hero'); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(2); + }); + + test('should record new event when evaluation result changes for same key', async () => { + const { flagsmith, initConfig } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 60000, + }, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(1); + + flagsmith.getValue('font_size'); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(1); + + await flagsmith.identify(testIdentity); + flagsmith.getValue('font_size'); + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(2); + // @ts-ignore + expect(flagsmith.pipelineEvents[1].identity_identifier).toBe(testIdentity); + }); + test('should re-queue on failure and coexist with standard analytics', async () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith({ enableAnalytics: true, From 9bc48ca95f0aa880b5b988abb1eb46dca1919481 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 6 Mar 2026 17:34:43 +0700 Subject: [PATCH 15/18] feat: temporarily-empty-strings-for-undefined-strings --- flagsmith-core.ts | 2 +- test/analytics-pipeline.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index a386703..446c2a0 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1029,7 +1029,7 @@ const Flagsmith = class { event_id: flagKey, event_type: 'flag_evaluation', evaluated_at: Date.now(), - identity_identifier: this.evaluationContext.identity?.identifier ?? null, + identity_identifier: this.evaluationContext.identity?.identifier ?? '', enabled: flag ? flag.enabled : null, value: flag ? flag.value : null, traits: this.evaluationContext.identity?.traits diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 6158bfd..bdb6236 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -48,7 +48,7 @@ describe('Pipeline Analytics', () => { expect(valueEvent.event_type).toBe('flag_evaluation'); expect(valueEvent.value).toBe(16); expect(valueEvent.enabled).toBe(true); - expect(valueEvent.identity_identifier).toBeNull(); + expect(valueEvent.identity_identifier).toBe(''); expect(valueEvent.evaluated_at).toBeDefined(); expect(valueEvent.metadata).toEqual(expect.objectContaining({ id: 6149 })); From 86ec20c79fc5b2cfee0866f1f03a8aac1df32e71 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 9 Mar 2026 12:12:35 +0700 Subject: [PATCH 16/18] feat: added-experimental-and-hidden-tags --- types.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/types.d.ts b/types.d.ts index dddfc89..de3c682 100644 --- a/types.d.ts +++ b/types.d.ts @@ -132,15 +132,15 @@ export interface IInitConfig = string, T */ applicationMetadata?: ApplicationMetadata; /** + * @experimental Internal use only — API may change without notice. * Configuration for the evaluation analytics pipeline. When provided, * individual flag evaluation events are buffered and sent to the pipeline endpoint. + * @hidden */ + /** @internal */ evaluationAnalyticsConfig?: { - /** URL of the pipeline server (e.g. 'https://analytics.flagsmith.com/'). */ analyticsServerUrl: string; - /** Maximum events to buffer in memory before dropping oldest. Default 1000. */ maxBuffer?: number; - /** Flush interval in milliseconds. Set to 0 to flush immediately after each evaluation. Default 10000 (10s). */ flushInterval?: number; }; } From 6d5d3768d38a39b460b3e5f669c47f18dadca6a5 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 9 Mar 2026 16:05:16 +0700 Subject: [PATCH 17/18] feat: removed-identifier-non-nullable-workaround --- flagsmith-core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 446c2a0..a386703 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1029,7 +1029,7 @@ const Flagsmith = class { event_id: flagKey, event_type: 'flag_evaluation', evaluated_at: Date.now(), - identity_identifier: this.evaluationContext.identity?.identifier ?? '', + identity_identifier: this.evaluationContext.identity?.identifier ?? null, enabled: flag ? flag.enabled : null, value: flag ? flag.value : null, traits: this.evaluationContext.identity?.traits From cd372258549a2ba5b6ab02348607a26b28593228 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 9 Mar 2026 16:05:16 +0700 Subject: [PATCH 18/18] feat: removed-identifier-non-nullable-workaround --- flagsmith-core.ts | 2 +- test/analytics-pipeline.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 446c2a0..a386703 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1029,7 +1029,7 @@ const Flagsmith = class { event_id: flagKey, event_type: 'flag_evaluation', evaluated_at: Date.now(), - identity_identifier: this.evaluationContext.identity?.identifier ?? '', + identity_identifier: this.evaluationContext.identity?.identifier ?? null, enabled: flag ? flag.enabled : null, value: flag ? flag.value : null, traits: this.evaluationContext.identity?.traits diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index bdb6236..6158bfd 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -48,7 +48,7 @@ describe('Pipeline Analytics', () => { expect(valueEvent.event_type).toBe('flag_evaluation'); expect(valueEvent.value).toBe(16); expect(valueEvent.enabled).toBe(true); - expect(valueEvent.identity_identifier).toBe(''); + expect(valueEvent.identity_identifier).toBeNull(); expect(valueEvent.evaluated_at).toBeDefined(); expect(valueEvent.metadata).toEqual(expect.objectContaining({ id: 6149 }));