From 02573c670f612a6602e079d2d4f408610581b9be Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 3 Mar 2026 15:08:54 +0700 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 })); From 0e819eb306802baeeed0266b84478d9ef1556530 Mon Sep 17 00:00:00 2001 From: Talisson Date: Wed, 25 Mar 2026 13:17:05 -0300 Subject: [PATCH 19/22] feat: add trackEvent for custom pipeline events (#384) --- flagsmith-core.ts | 89 ++++++++++++++----- index.ts | 2 +- test/analytics-pipeline.test.ts | 151 ++++++++++++++++++++++++++++++++ types.d.ts | 15 +++- 4 files changed, 233 insertions(+), 24 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index b2446e0..b95ca5a 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; @@ -333,12 +338,12 @@ 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() + 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() async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { @@ -1007,8 +1012,49 @@ const Flagsmith = class { this.pipelineRecordedKeys.clear(); } + 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 getEventMetadata(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 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]; @@ -1017,22 +1063,25 @@ const Flagsmith = class { return; } this.pipelineRecordedKeys.set(flagKey, fingerprint); - const event: IPipelineEvent = { - event_id: flagKey, - event_type: '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) { + this.flushPipelineAnalytics(); + } + } + + trackEvent = (eventName: string, metadata?: Record) => { + if (!this.evaluationAnalyticsUrl || !eventName) { + return; + } + const event = this.buildAnalyticEvent(PipelineEventType.CUSTOM_EVENT, eventName, { + extraMetadata: metadata, + }); this.pipelineEvents.push(event); if (this.pipelineFlushInterval === 0 || this.pipelineEvents.length >= this.evaluationAnalyticsMaxBuffer) { 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 0d1b3cb..05d2e13 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/'; @@ -218,3 +219,153 @@ 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 === PipelineEventType.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: PipelineEventType.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); + }); + + 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); + }); + + 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 + 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'); + + const custom = getCustomEvents(flagsmith); + expect(custom).toHaveLength(2); + expect(custom[0].identity_identifier).toBeNull(); + expect(custom[1].identity_identifier).toBe(testIdentity); + }); + + 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('instant_event'); + + 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 === PipelineEventType.CUSTOM_EVENT); + expect(custom).toHaveLength(1); + expect(custom[0].event_id).toBe('instant_event'); + }); + + 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('tracks with null identity after logout', async () => { + const { flagsmith, initConfig } = getFlagsmith(defaultPipelineConfig); + await flagsmith.init(initConfig); + + await flagsmith.identify(testIdentity); + flagsmith.trackEvent('while_identified'); + expect(getCustomEvents(flagsmith)[0].identity_identifier).toBe(testIdentity); + + await flagsmith.logout(); + + flagsmith.trackEvent('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 de3c682..09b48d2 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; @@ -299,6 +299,15 @@ 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 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; /** * The stored identity of the user */ From dce93fc91f5371ba9dc1fbdd16c1af3baffc3bcc Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 26 May 2026 14:02:41 +0200 Subject: [PATCH 20/22] feat: make flag evaluation tracking opt-in via autoTrackEvaluations When evaluationAnalyticsConfig is provided, flag evaluation events are now gated behind autoTrackEvaluations (defaults to true). Setting it to false suppresses automatic evaluation events while keeping trackEvent() available for custom events. --- flagsmith-core.ts | 4 +- test/analytics-pipeline.test.ts | 66 +++++++++++++++++++++++++++++++++ types.d.ts | 1 + 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index b95ca5a..2a4feee 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -343,6 +343,7 @@ const Flagsmith = class { private pipelineEvents: IPipelineEvent[] = [] private pipelineAnalyticsInterval: ReturnType | null = null private isPipelineFlushing = false + private autoTrackEvaluations: boolean = true private pipelineRecordedKeys: Map = new Map() async init(config: IInitConfig) { const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); @@ -978,7 +979,7 @@ const Flagsmith = class { this.evaluationEvent[this.evaluationContext.environment.apiKey][key] += 1; } - if (this.evaluationAnalyticsUrl) { + if (this.evaluationAnalyticsUrl && this.autoTrackEvaluations) { this.recordPipelineEvent(key); } @@ -990,6 +991,7 @@ const Flagsmith = class { private initPipelineAnalytics(config: NonNullable) { this.stopPipelineAnalytics(); this.evaluationAnalyticsUrl = ensureTrailingSlash(config.analyticsServerUrl); + this.autoTrackEvaluations = config.autoTrackEvaluations ?? true; this.evaluationAnalyticsMaxBuffer = config.maxBuffer ?? 1000; this.pipelineFlushInterval = config.flushInterval ?? DEFAULT_PIPELINE_FLUSH_INTERVAL; this.pipelineEvents = []; diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 05d2e13..7f5f529 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -369,3 +369,69 @@ describe('trackEvent (custom events)', () => { expect(custom[custom.length - 1].identity_identifier).toBeNull(); }); }); + +describe('autoTrackEvaluations', () => { + test('suppresses flag evaluation events when set to false', async () => { + const { flagsmith, initConfig } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + autoTrackEvaluations: false, + flushInterval: 60000, + }, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + flagsmith.hasFeature('hero'); + + // @ts-ignore + expect(flagsmith.pipelineEvents).toHaveLength(0); + }); + + test('still allows trackEvent when autoTrackEvaluations is false', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + autoTrackEvaluations: false, + flushInterval: 60000, + }, + identity: testIdentity, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + flagsmith.trackEvent('checkout', { item: 'shoes' }); + + const custom = getCustomEvents(flagsmith); + expect(custom).toHaveLength(1); + expect(custom[0].event_id).toBe('checkout'); + + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); + const calls = getPipelineCalls(mockFetch); + expect(calls).toHaveLength(1); + + const body = JSON.parse(calls[0][1].body); + expect(body.events).toHaveLength(1); + expect(body.events[0].event_type).toBe(PipelineEventType.CUSTOM_EVENT); + }); + + test('records flag evaluations by default (autoTrackEvaluations omitted)', async () => { + const { flagsmith, initConfig } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 60000, + }, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + + // @ts-ignore + const evalEvents = flagsmith.pipelineEvents.filter( + (e: any) => e.event_type === PipelineEventType.FLAG_EVALUATION + ); + expect(evalEvents).toHaveLength(1); + expect(evalEvents[0].event_id).toBe('font_size'); + }); +}); diff --git a/types.d.ts b/types.d.ts index 09b48d2..c8f518a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -140,6 +140,7 @@ export interface IInitConfig = string, T /** @internal */ evaluationAnalyticsConfig?: { analyticsServerUrl: string; + autoTrackEvaluations?: boolean; maxBuffer?: number; flushInterval?: number; }; From 22b98e8a42462bce1fd8bd71f579a8097a5fadb3 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 26 May 2026 16:04:35 +0200 Subject: [PATCH 21/22] feat: add value parameter to trackEvent --- flagsmith-core.ts | 3 ++- test/analytics-pipeline.test.ts | 6 +++--- types.d.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 2a4feee..fcb6913 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -1077,11 +1077,12 @@ const Flagsmith = class { } } - trackEvent = (eventName: string, metadata?: Record) => { + trackEvent = (eventName: string, value?: any, metadata?: Record) => { if (!this.evaluationAnalyticsUrl || !eventName) { return; } const event = this.buildAnalyticEvent(PipelineEventType.CUSTOM_EVENT, eventName, { + value, extraMetadata: metadata, }); this.pipelineEvents.push(event); diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 7f5f529..27cee88 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -239,7 +239,7 @@ describe('trackEvent (custom events)', () => { }); await flagsmith.init(initConfig); - flagsmith.trackEvent('checkout', { item: 'shoes', price: 99 }); + flagsmith.trackEvent('checkout', null, { item: 'shoes', price: 99 }); // @ts-ignore await flagsmith.flushPipelineAnalytics(); @@ -286,7 +286,7 @@ describe('trackEvent (custom events)', () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith(defaultPipelineConfig); await flagsmith.init(initConfig); - flagsmith.trackEvent('page_view', { page: '/home' }); + flagsmith.trackEvent('page_view', null, { page: '/home' }); flagsmith.trackEvent('signup'); const custom = getCustomEvents(flagsmith); @@ -400,7 +400,7 @@ describe('autoTrackEvaluations', () => { await flagsmith.init(initConfig); flagsmith.getValue('font_size'); - flagsmith.trackEvent('checkout', { item: 'shoes' }); + flagsmith.trackEvent('checkout', null, { item: 'shoes' }); const custom = getCustomEvents(flagsmith); expect(custom).toHaveLength(1); diff --git a/types.d.ts b/types.d.ts index c8f518a..b4306e5 100644 --- a/types.d.ts +++ b/types.d.ts @@ -308,7 +308,7 @@ T extends string = string * @internal * @hidden */ - trackEvent: (eventName: string, metadata?: Record) => void; + trackEvent: (eventName: string, value?: any, metadata?: Record) => void; /** * The stored identity of the user */ From 20cab8032fe652b254bb65570d2ad17973ca61e5 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 26 May 2026 17:25:05 +0200 Subject: [PATCH 22/22] fix: use analyticsServerUrl as full endpoint without appending path --- flagsmith-core.ts | 4 ++-- test/analytics-pipeline.test.ts | 6 +++--- test/test-constants.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index fcb6913..74721f8 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -292,7 +292,7 @@ const Flagsmith = class { }; try { - const res = await _fetch(this.evaluationAnalyticsUrl + 'v1/analytics/batch', { + const res = await _fetch(this.evaluationAnalyticsUrl!, { method: 'POST', body: JSON.stringify(batch), headers: { @@ -990,7 +990,7 @@ const Flagsmith = class { private initPipelineAnalytics(config: NonNullable) { this.stopPipelineAnalytics(); - this.evaluationAnalyticsUrl = ensureTrailingSlash(config.analyticsServerUrl); + this.evaluationAnalyticsUrl = config.analyticsServerUrl; this.autoTrackEvaluations = config.autoTrackEvaluations ?? true; this.evaluationAnalyticsMaxBuffer = config.maxBuffer ?? 1000; this.pipelineFlushInterval = config.flushInterval ?? DEFAULT_PIPELINE_FLUSH_INTERVAL; diff --git a/test/analytics-pipeline.test.ts b/test/analytics-pipeline.test.ts index 27cee88..0c3fa37 100644 --- a/test/analytics-pipeline.test.ts +++ b/test/analytics-pipeline.test.ts @@ -5,7 +5,7 @@ const pipelineUrl = 'https://analytics.flagsmith.com/'; function getPipelineCalls(mockFetch: jest.Mock) { return mockFetch.mock.calls.filter( - ([url]: [string]) => url.includes('v1/analytics/batch') + ([url]: [string]) => url.includes(pipelineUrl) ); } @@ -111,7 +111,7 @@ describe('Pipeline Analytics', () => { flagsmith.getValue('number_value'); flagsmith.getValue('off_value'); - const calls = mockFetch.mock.calls.filter(([url]: [string, any]) => url.includes('v1/analytics/batch')); + const calls = mockFetch.mock.calls.filter(([url]: [string, any]) => url.includes(pipelineUrl)); expect(calls).toHaveLength(1); const flushedBatch = JSON.parse(calls[0][1].body).events; expect(flushedBatch).toHaveLength(3); @@ -192,7 +192,7 @@ describe('Pipeline Analytics', () => { const original = mockFetch.getMockImplementation() as jest.Mock; mockFetch.mockImplementation(async (url: string, options: any) => { - if (url.includes('v1/analytics/batch')) { + if (url.includes(pipelineUrl)) { return { status: 500, text: () => Promise.resolve('Server Error') }; } return original(url, options); diff --git a/test/test-constants.ts b/test/test-constants.ts index c9bde1c..b0d0526 100644 --- a/test/test-constants.ts +++ b/test/test-constants.ts @@ -76,7 +76,7 @@ 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')) { + if (url.includes('analytics.flagsmith.com')) { return {status: 202, text: () => Promise.resolve('')} } if (url.includes('analytics/flags')) {