From 54a78c3129804592b6dce4c6f80ad43d74c49f8a Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Jul 2026 16:45:47 +0200 Subject: [PATCH] fix: skip exposure events when no identifier resolves trackExposureEvent emitted exposures with identifier: null for anonymous users; exposures without an assignment unit are unanalyzable. Now skips with a log, mirroring getExperimentFlag. Explicit opts.identifier still records an exposure; trackEvent is unaffected. --- flagsmith-core.ts | 7 ++++++- test/events.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ types.d.ts | 3 ++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 6184fc6..a3deaed 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -983,9 +983,14 @@ const Flagsmith = class { }) => { // No-op when events are disabled, mirroring enableAnalytics: false. if (!this.eventProcessor) return; + const identifier = opts?.identifier ?? this.evaluationContext.identity?.identifier ?? null; + if (!identifier) { + this.log(`Flagsmith: trackExposureEvent called for "${featureName}" without an identity; call identify() (optionally with transient: true) or pass opts.identifier. No exposure recorded.`); + return; + } this.eventProcessor.trackExposureEvent({ featureName, - identifier: opts?.identifier ?? this.evaluationContext.identity?.identifier ?? null, + identifier, value: opts?.value ?? null, traits: resolveTraitValues(opts?.traits ?? this.evaluationContext.identity?.traits), metadata: opts?.metadata ?? null, diff --git a/test/events.test.ts b/test/events.test.ts index f4f68f9..c3e673e 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -99,6 +99,47 @@ describe('trackEvent', () => { }); }); +describe('trackExposureEvent', () => { + test('skips the exposure when no identity resolves', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig()); + await flagsmith.init(initConfig); // anonymous + + flagsmith.trackExposureEvent('font_size', { value: 'control' }); + await flagsmith.flushEvents(); + + expect(eventCalls(mockFetch)).toHaveLength(0); + }); + + test('explicit identifier records the exposure without a context identity', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig()); + await flagsmith.init(initConfig); // anonymous + + flagsmith.trackExposureEvent('font_size', { identifier: 'anon-device-1', value: 'control' }); + await flagsmith.flushEvents(); + + const events = JSON.parse(eventCalls(mockFetch)[0][1].body).events; + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ + event: FLAG_EXPOSURE_EVENT, + feature_name: 'font_size', + identifier: 'anon-device-1', + value: 'control', + })); + }); + + test('trackEvent still sends anonymous events with identifier null', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig()); + await flagsmith.init(initConfig); // anonymous + + flagsmith.trackEvent('purchase'); + await flagsmith.flushEvents(); + + const events = JSON.parse(eventCalls(mockFetch)[0][1].body).events; + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ event: 'purchase', identifier: null })); + }); +}); + describe('getExperimentFlag', () => { test('returns the flag and fires one $flag_exposure when identified and source is SERVER', async () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })); diff --git a/types.d.ts b/types.d.ts index c379967..1075a71 100644 --- a/types.d.ts +++ b/types.d.ts @@ -307,7 +307,8 @@ T extends string = string /** * Record that an identity was exposed to a flag/variant (emits the reserved * "$flag_exposure" event). No-op when events are disabled (enableEvents is - * not set). + * not set). Skipped (with a log) when no identifier resolves — identify() + * first (optionally with transient: true) or pass opts.identifier. * @experimental @internal */ trackExposureEvent: (featureName: string, opts?: {