Skip to content

Commit 467dc54

Browse files
authored
feat: Record exposures under local evaluation (#318)
Since Flagsmith/flagsmith#8532, the environment document carries `metadata.experiment` on feature states. The SDK dropped it when building engine models, so under local evaluation `flag.experiment` was always undefined and `getExperimentFlag` never recorded an exposure. ### Changes - `FeatureStateModel` keeps `metadata.experiment`; the mapper adds it to the engine context's feature metadata for environment and segment feature states. Identity overrides carry none, as on the API. - `Flags.fromEvaluationResult` sets `experiment`, and also `variant`, which local evaluation dropped until now, so local exposures carry the variant key. - Locally evaluated environment flags carry no experiment, matching the API, which only sends it for identity flags. ### Tests `getExperimentFlag` under local evaluation: an enrolled identity records one exposure with its variant and experiment ID; an identity outside the rollout, one with an identity override, and a feature with no experiment record none. Environment flags carry no experiment. Also checked against a local Flagsmith with the events pipeline: local and remote evaluation give the same value, variant and experiment, and one exposure reached the warehouse. ### Not included Bumping `engine-test-data` to v3.13.0, which adds the experiment cases: it also adds two numeric trait cases (Flagsmith/engine-test-data#63) the engine already fails, to be fixed separately.
1 parent ee89f5a commit 467dc54

8 files changed

Lines changed: 235 additions & 23 deletions

File tree

‎flagsmith-engine/evaluation/evaluationContext/mappers.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,7 @@ function mapEnvironmentModelToEvaluationContext(
8282
value: fs.getValue(),
8383
variants: mapFeatureStateVariants(fs),
8484
priority: fs.featureSegment?.priority,
85-
metadata: {
86-
id: fs.feature.id
87-
}
85+
metadata: mapFeatureStateMetadata(fs, isEnvironmentEvaluation)
8886
};
8987
}
9088

@@ -104,9 +102,7 @@ function mapEnvironmentModelToEvaluationContext(
104102
value: fs.getValue(),
105103
variants: mapFeatureStateVariants(fs),
106104
priority: fs.featureSegment?.priority,
107-
metadata: {
108-
id: fs.feature.id
109-
}
105+
metadata: mapFeatureStateMetadata(fs, isEnvironmentEvaluation)
110106
}))
111107
: [],
112108
metadata: {
@@ -154,6 +150,19 @@ function mapIdentityModelToIdentityContext(
154150
return identityContext;
155151
}
156152

153+
/**
154+
* Only an identity is enrolled in an experiment, so environment evaluation leaves it out.
155+
*/
156+
function mapFeatureStateMetadata(
157+
fs: FeatureStateModel,
158+
isEnvironmentEvaluation: boolean
159+
): SDKFeatureMetadata {
160+
return {
161+
id: fs.feature.id,
162+
...(!isEnvironmentEvaluation && fs.experiment && { experiment: fs.experiment })
163+
};
164+
}
165+
157166
function mapFeatureStateVariants(fs: FeatureStateModel) {
158167
return fs.multivariateFeatureStateValues?.length > 0
159168
? fs.multivariateFeatureStateValues.map(mv => ({

‎flagsmith-engine/evaluation/models.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type {
99
SegmentMetadata
1010
} from './evaluationResult/evaluationResult.types.js';
1111

12+
import type { FeatureStateExperiment } from '../features/models.js';
13+
1214
import type {
1315
FeatureContext,
1416
EnvironmentContext,
@@ -26,6 +28,8 @@ export enum SegmentSource {
2628
// Feature types
2729
export interface SDKFeatureMetadata extends FeatureMetadata {
2830
id: number;
31+
/** The running experiment the feature state belongs to, if any. */
32+
experiment?: FeatureStateExperiment;
2933
}
3034

3135
export interface FeatureContextWithMetadata<T extends FeatureMetadata = FeatureMetadata>

‎flagsmith-engine/features/models.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,22 @@ export class MultivariateFeatureStateValueModel {
4848
}
4949
}
5050

51+
/**
52+
* The running experiment a feature state belongs to, as the environment document reports it.
53+
*/
54+
export interface FeatureStateExperiment {
55+
id: number;
56+
name: string;
57+
in_experiment: boolean;
58+
}
59+
5160
export class FeatureStateModel {
5261
feature: FeatureModel;
5362
enabled: boolean;
5463
djangoID: number;
5564
featurestateUUID: string = uuidv4();
5665
featureSegment?: FeatureSegment;
66+
experiment?: FeatureStateExperiment;
5767
private value: any;
5868
multivariateFeatureStateValues: MultivariateFeatureStateValueModel[] = [];
5969

‎flagsmith-engine/features/util.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export function buildFeatureStateModel(featuresStateModelJSON: any): FeatureStat
2323
? buildFeatureSegment(featuresStateModelJSON.feature_segment)
2424
: undefined;
2525

26+
featureStateModel.experiment = featuresStateModelJSON.metadata?.experiment ?? undefined;
27+
2628
const multivariateFeatureStateValues = featuresStateModelJSON.multivariate_feature_state_values
2729
? featuresStateModelJSON.multivariate_feature_state_values.map((fsv: any) => {
2830
const featureOption = new MultivariateFeatureOptionModel(

‎sdk/index.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,6 @@ export class Flagsmith {
289289
* Flags are fetched exactly as {@link getIdentityFlags} fetches them, so traits are upserted and
290290
* the identity cache is used when one is configured.
291291
*
292-
* Experiment metadata is only produced by remote identity evaluation. Local evaluation and
293-
* offline mode never carry it, so no exposure is ever recorded for them.
294-
*
295292
* @param featureName the name of the feature to evaluate.
296293
* @param identifier a unique identifier for the identity in the current environment.
297294
* @param traits? a dictionary of traits to add / update on the identity in Flagsmith.
@@ -330,6 +327,12 @@ export class Flagsmith {
330327
);
331328
return flag;
332329
}
330+
if (flag.variant === undefined) {
331+
this.logger.debug(
332+
`Not recording an exposure for "${featureName}": the flag has no variant.`
333+
);
334+
return flag;
335+
}
333336

334337
this.trackExposureEvent(featureName, {
335338
identifier: identifier,

‎sdk/models.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class DefaultFlag extends BaseFlag {
4343
}
4444

4545
/**
46-
* A running experiment on a feature, as reported by a remote identity evaluation.
46+
* A running experiment on a feature, as reported by an identity evaluation.
4747
*/
4848
export class ExperimentMetadata {
4949
/**
@@ -110,11 +110,11 @@ export class Flag extends BaseFlag {
110110
*/
111111
reason?: string;
112112
/**
113-
* The variant key this identity was bucketed into. Set by remote evaluation only.
113+
* The variant key this identity was bucketed into. Set by identity evaluation only.
114114
*/
115115
variant?: string;
116116
/**
117-
* The experiment running on this feature, if any. Set by remote identity evaluation only.
117+
* The experiment running on this feature, if any. Set by identity evaluation only.
118118
*/
119119
experiment?: ExperimentMetadata;
120120

@@ -196,7 +196,9 @@ export class Flags {
196196
value: flag.value ?? null,
197197
featureId: flagMetadataId,
198198
featureName: flag.name,
199-
reason: flag.reason
199+
reason: flag.reason,
200+
variant: flag.variant ?? undefined,
201+
experiment: ExperimentMetadata.fromAPIMetadata(flag.metadata)
200202
});
201203
}
202204
return new Flags({
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{
2+
"api_key": "B62qaMZNwfiqT76p38ggrQ",
3+
"name": "Test Environment",
4+
"project": {
5+
"name": "Test project",
6+
"organisation": {
7+
"feature_analytics": false,
8+
"name": "Test Org",
9+
"id": 1,
10+
"persist_trait_data": true,
11+
"stop_serving_flags": false
12+
},
13+
"id": 1,
14+
"hide_disabled_flags": false,
15+
"segments": [
16+
{
17+
"id": 7,
18+
"name": "Checkout experiment rollout",
19+
"feature_states": [
20+
{
21+
"multivariate_feature_state_values": [
22+
{
23+
"id": 3,
24+
"multivariate_feature_option": {
25+
"id": 3,
26+
"value": "new checkout",
27+
"key": "treatment"
28+
},
29+
"percentage_allocation": 100,
30+
"mv_fs_value_uuid": "3e3e3e3e-3e3e-3e3e-3e3e-3e3e3e3e3e3e"
31+
}
32+
],
33+
"feature_state_value": "control",
34+
"django_id": 2,
35+
"featurestate_uuid": "c2c2c2c2-c2c2-c2c2-c2c2-c2c2c2c2c2c2",
36+
"feature": { "name": "checkout", "type": "MULTIVARIATE", "id": 10 },
37+
"feature_segment": { "priority": 0 },
38+
"enabled": true,
39+
"metadata": {
40+
"experiment": {
41+
"id": 42,
42+
"name": "Checkout experiment",
43+
"in_experiment": true
44+
}
45+
}
46+
}
47+
],
48+
"rules": [
49+
{
50+
"type": "ALL",
51+
"conditions": [{ "operator": "EQUAL", "property_": "plan", "value": "premium" }],
52+
"rules": []
53+
}
54+
]
55+
}
56+
]
57+
},
58+
"segment_overrides": [],
59+
"id": 1,
60+
"feature_states": [
61+
{
62+
"multivariate_feature_state_values": [],
63+
"feature_state_value": "control",
64+
"django_id": 1,
65+
"featurestate_uuid": "c1c1c1c1-c1c1-c1c1-c1c1-c1c1c1c1c1c1",
66+
"feature": { "name": "checkout", "type": "MULTIVARIATE", "id": 10 },
67+
"feature_segment": null,
68+
"enabled": true,
69+
"metadata": {
70+
"experiment": { "id": 42, "name": "Checkout experiment", "in_experiment": false }
71+
}
72+
},
73+
{
74+
"multivariate_feature_state_values": [],
75+
"feature_state_value": "hello",
76+
"django_id": 3,
77+
"featurestate_uuid": "c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3",
78+
"feature": { "name": "plain", "type": "STANDARD", "id": 11 },
79+
"feature_segment": null,
80+
"enabled": true
81+
}
82+
],
83+
"identity_overrides": [
84+
{
85+
"identifier": "user_overridden",
86+
"identity_features": [
87+
{
88+
"multivariate_feature_state_values": [],
89+
"feature_state_value": "overridden checkout",
90+
"featurestate_uuid": "c4c4c4c4-c4c4-c4c4-c4c4-c4c4c4c4c4c4",
91+
"feature": { "name": "checkout", "type": "MULTIVARIATE", "id": 10 },
92+
"enabled": true
93+
}
94+
]
95+
}
96+
]
97+
}

‎tests/sdk/flagsmith-experiments.test.ts‎

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { DefaultFlag, Flag } from '../../sdk/models.js';
44
import { FlagsmithConfig } from '../../sdk/types.js';
55
import { getUserAgent } from '../../sdk/utils.js';
66
import { SDK_VERSION } from '../../sdk/version.js';
7-
import { fetch } from './fetchMock.js';
7+
import { fetch, fetchImpl, identitiesJSON } from './fetchMock.js';
88
import { flagsmith, postedEvents, TestCache } from './utils.js';
9+
import { readFileSync } from 'fs';
10+
11+
const environmentWithExperimentJSON = readFileSync(
12+
__dirname + '/data/environment-with-experiment.json',
13+
'utf-8'
14+
);
915

1016
vi.mock('../../sdk/polling_manager');
1117

@@ -179,6 +185,24 @@ test.each([
179185
expect(postedEvents()).toHaveLength(0);
180186
});
181187

188+
test('getExperimentFlag records no exposure when the flag has no variant', async () => {
189+
const identities = JSON.parse(identitiesJSON);
190+
delete identities.flags.find((f: any) => f.feature.name === 'some_feature').variant;
191+
fetch.mockImplementation((url, options) =>
192+
String(url).includes('/identities')
193+
? Promise.resolve(new Response(JSON.stringify(identities), { status: 200 }))
194+
: fetchImpl(url, options)
195+
);
196+
const flg = experimentsFlagsmith();
197+
198+
const flag = (await flg.getExperimentFlag('some_feature', 'user-123')) as Flag;
199+
await flg.flushEvents();
200+
201+
expect(flag.experiment?.inExperiment).toBe(true);
202+
expect(flag.variant).toBeUndefined();
203+
expect(postedEvents()).toHaveLength(0);
204+
});
205+
182206
test('getExperimentFlag still returns the flag when the identity is outside the rollout', async () => {
183207
const flg = experimentsFlagsmith();
184208

@@ -201,19 +225,80 @@ test('getExperimentFlag records no exposure for a feature served by the default
201225
expect(postedEvents()).toHaveLength(0);
202226
});
203227

204-
test('getExperimentFlag records no exposure when evaluating locally', async () => {
205-
const flg = experimentsFlagsmith({
206-
environmentKey: 'ser.key',
207-
enableLocalEvaluation: true
208-
});
228+
/**
229+
* A locally evaluating client whose environment document runs an experiment on `checkout`. Its
230+
* rollout segment is identities on the premium plan, who are served the `treatment` variant.
231+
* `user_overridden` has an identity override for `checkout`, and `plain` has no experiment.
232+
*/
233+
function localExperimentsFlagsmith() {
234+
fetch.mockImplementation((url, options) =>
235+
String(url).includes('/environment-document')
236+
? Promise.resolve(new Response(environmentWithExperimentJSON, { status: 200 }))
237+
: fetchImpl(url, options)
238+
);
239+
return experimentsFlagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true });
240+
}
209241

210-
const flag = (await flg.getExperimentFlag('some_feature', 'user-123')) as Flag;
242+
test('getExperimentFlag records an exposure for an enrolled identity when evaluating locally', async () => {
243+
const flg = localExperimentsFlagsmith();
244+
245+
const flag = (await flg.getExperimentFlag('checkout', 'user-123', { plan: 'premium' })) as Flag;
211246
await flg.flushEvents();
212247

213-
expect(flag.enabled).toBe(true);
214-
expect(flag.variant).toBeUndefined();
248+
expect(flag.value).toBe('new checkout');
249+
expect(flag.variant).toBe('treatment');
250+
expect(flag.experiment).toEqual({ id: 42, name: 'Checkout experiment', inExperiment: true });
251+
expect(postedEvents()).toEqual([
252+
expect.objectContaining({
253+
event: FLAG_EXPOSURE_EVENT,
254+
feature_name: 'checkout',
255+
identifier: 'user-123',
256+
value: 'treatment',
257+
traits: { plan: 'premium' },
258+
metadata: { experiment_id: 42, sdk_version: SDK_VERSION }
259+
})
260+
]);
261+
});
262+
263+
test.each([
264+
[
265+
'the identity is outside the rollout',
266+
'checkout',
267+
'user-123',
268+
'free',
269+
'control',
270+
{ id: 42, name: 'Checkout experiment', inExperiment: false }
271+
],
272+
[
273+
'the identity has an identity override',
274+
'checkout',
275+
'user_overridden',
276+
'premium',
277+
'overridden checkout',
278+
undefined
279+
],
280+
['the feature has no experiment', 'plain', 'user-123', 'premium', 'hello', undefined]
281+
])(
282+
'getExperimentFlag records no exposure when evaluating locally and %s',
283+
async (_name, featureName, identifier, plan, expectedValue, expectedExperiment) => {
284+
const flg = localExperimentsFlagsmith();
285+
286+
const flag = (await flg.getExperimentFlag(featureName, identifier, { plan })) as Flag;
287+
await flg.flushEvents();
288+
289+
expect(flag.value).toBe(expectedValue);
290+
expect(flag.experiment).toEqual(expectedExperiment);
291+
expect(postedEvents()).toHaveLength(0);
292+
}
293+
);
294+
295+
test('environment flags carry no experiment when evaluating locally', async () => {
296+
const flg = localExperimentsFlagsmith();
297+
298+
const flag = (await flg.getEnvironmentFlags()).getFlag('checkout') as Flag;
299+
300+
expect(flag.value).toBe('control');
215301
expect(flag.experiment).toBeUndefined();
216-
expect(postedEvents()).toHaveLength(0);
217302
});
218303

219304
test('getExperimentFlag records one exposure per identity', async () => {

0 commit comments

Comments
 (0)