diff --git a/flagsmith-core.ts b/flagsmith-core.ts index d81d05e..b95ca5a 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -9,6 +9,8 @@ import { IFlagsmithResponse, IFlagsmithTrait, IInitConfig, + IPipelineEvent, + IPipelineEventBatch, ISentryClient, IState, ITraits, @@ -34,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; @@ -64,6 +71,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 @@ -265,6 +273,46 @@ const Flagsmith = class { } }; + flushPipelineAnalytics = async () => { + 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.slice(0, this.evaluationAnalyticsMaxBuffer); + this.pipelineEvents = this.pipelineEvents.slice(this.evaluationAnalyticsMaxBuffer); + this.pipelineRecordedKeys.clear(); + + const batch: IPipelineEventBatch = { + events: eventsToSend, + environment_key: environmentKey, + }; + + try { + 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': environmentKey, + ...(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) { + this.pipelineEvents = eventsToSend.concat(this.pipelineEvents); + this.log('Pipeline analytics: flush failed, events re-queued', err); + } finally { + this.isPipelineFlushing = false; + } + }; + datadogRum: IDatadogRum | null = null; loadingState: LoadingState = {isLoading: true, isFetching: true, error: null, source: FlagSource.NONE} canUseStorage = false @@ -290,6 +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} + 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 { @@ -308,6 +362,7 @@ const Flagsmith = class { enableDynatrace, enableLogs, environmentID, + evaluationAnalyticsConfig, eventSourceUrl= "https://realtime.flagsmith.com/", fetch: fetchImplementation, headers, @@ -441,6 +496,12 @@ const Flagsmith = class { } } + if (evaluationAnalyticsConfig) { + this.initPipelineAnalytics(evaluationAnalyticsConfig); + } else { + this.stopPipelineAnalytics(); + } + //If the user specified default flags emit a changed event immediately if (cacheFlags) { if (AsyncStorage && this.canUseStorage) { @@ -916,9 +977,118 @@ const Flagsmith = class { } this.evaluationEvent[this.evaluationContext.environment.apiKey][key] += 1; } + + if (this.evaluationAnalyticsUrl) { + this.recordPipelineEvent(key); + } + 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 = []; + if (this.pipelineFlushInterval > 0) { + this.pipelineAnalyticsInterval = setInterval( + this.flushPipelineAnalytics, + this.pipelineFlushInterval, + ); + this.pipelineAnalyticsInterval?.unref?.(); + } + } + + private stopPipelineAnalytics() { + if (this.pipelineAnalyticsInterval) { + clearInterval(this.pipelineAnalyticsInterval); + this.pipelineAnalyticsInterval = null; + } + this.evaluationAnalyticsUrl = null; + this.pipelineEvents = []; + 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]; + 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 = this.buildAnalyticEvent(PipelineEventType.FLAG_EVALUATION, flagKey, { + enabled: flag ? flag.enabled : null, + value: flag ? flag.value : null, + 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) { + this.flushPipelineAnalytics(); + } + } + private setLoadingState(loadingState: LoadingState) { if (!deepEqual(loadingState, this.loadingState)) { this.loadingState = { ...loadingState }; 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 new file mode 100644 index 0000000..05d2e13 --- /dev/null +++ b/test/analytics-pipeline.test.ts @@ -0,0 +1,371 @@ +import { getFlagsmith, environmentID, testIdentity } from './test-constants'; +import { PipelineEventType } from '../lib/flagsmith'; + +const pipelineUrl = 'https://analytics.flagsmith.com/'; + +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: 60000, + }, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('font_size'); + flagsmith.hasFeature('hero'); + + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); + + const calls = getPipelineCalls(mockFetch); + expect(calls).toHaveLength(1); + + const body = JSON.parse(calls[0][1].body); + expect(body.environment_key).toBe(environmentID); + expect(body.events).toHaveLength(2); + + const valueEvent = body.events[0]; + expect(valueEvent.event_id).toBe('font_size'); + expect(valueEvent.event_type).toBe('flag_evaluation'); + expect(valueEvent.value).toBe(16); + expect(valueEvent.enabled).toBe(true); + expect(valueEvent.identity_identifier).toBeNull(); + expect(valueEvent.evaluated_at).toBeDefined(); + expect(valueEvent.metadata).toEqual(expect.objectContaining({ id: 6149 })); + + const enabledEvent = body.events[1]; + 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); + 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: 60000, + }, + identity: testIdentity, + }); + await flagsmith.init(initConfig); + + flagsmith.getValue('hero'); + + // @ts-ignore + await flagsmith.flushPipelineAnalytics(); + + const calls = getPipelineCalls(mockFetch); + const event = JSON.parse(calls[0][1].body).events[0]; + + expect(event.identity_identifier).toBe(testIdentity); + expect(event.traits).toEqual({ + number_trait: { value: 1 }, + string_trait: { value: 'Example' }, + }); + }); + + test('should flush excess events when buffer exceeds maxBuffer and skip events when skipAnalytics is used', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + maxBuffer: 3, + flushInterval: 60000, + }, + }); + 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'); + + const calls = mockFetch.mock.calls.filter(([url]: [string, any]) => url.includes('v1/analytics/batch')); + expect(calls).toHaveLength(1); + const flushedBatch = JSON.parse(calls[0][1].body).events; + expect(flushedBatch).toHaveLength(3); + expect(flushedBatch[0].event_id).toBe('hero'); + expect(flushedBatch[2].event_id).toBe('json_value'); + + // @ts-ignore — remaining events kept for next flush + expect(flagsmith.pipelineEvents).toHaveLength(2); + // @ts-ignore + expect(flagsmith.pipelineEvents[0].event_id).toBe('number_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, + evaluationAnalyticsConfig: { + analyticsServerUrl: pipelineUrl, + flushInterval: 60000, + }, + }); + + 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') }; + } + return original(url, options); + }); + + 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].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/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')} diff --git a/types.d.ts b/types.d.ts index f6c8f8d..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 @@ -131,6 +131,34 @@ export interface IInitConfig = string, T * Customer application metadata */ 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?: { + analyticsServerUrl: string; + maxBuffer?: number; + flushInterval?: number; + }; +} + +export interface IPipelineEvent { + event_id: string; // flag_name or event_name + event_type: PipelineEventType; + evaluated_at: number; + identity_identifier: string | null; + enabled?: boolean | null; + value: IFlagsmithValue; + traits?: { [key: string]: null | TraitEvaluationContext } | null; + metadata?: Record | null; +} + +export interface IPipelineEventBatch { + events: IPipelineEvent[]; + environment_key: string; } export interface IFlagsmithResponse { @@ -271,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 */