Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions flagsmith-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ const Flagsmith = class {
}

if (!options?.skipAnalytics && !skipAnalytics) {
this.evaluateFlag(key, "VALUE");
this.evaluateFlag(key, "VALUE", flag);
}

if (res === null && typeof options?.fallback !== 'undefined') {
Expand Down Expand Up @@ -829,13 +829,13 @@ const Flagsmith = class {
res = true;
}
if ((usingNewOptions && !options.skipAnalytics) || !options) {
this.evaluateFlag(key, "ENABLED");
this.evaluateFlag(key, "ENABLED", flag);
}
if(this.sentryClient) {
try {
this.sentryClient.getIntegrationByName(
"FeatureFlags",
)?.addFeatureFlag?.(key, res);
)?.addFeatureFlag?.(flag?.name || normalizeFlagKey(key), res);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description of the PR mentions nothing about Sentry... why are we changing this too?

} catch (e) {
console.error(e)
}
Expand Down Expand Up @@ -950,15 +950,20 @@ const Flagsmith = class {
}
}

private evaluateFlag =(key: string, method: 'VALUE' | 'ENABLED') => {
private evaluateFlag =(key: string, method: 'VALUE' | 'ENABLED', flag?: IFlagsmithFeature | null) => {
// The API resolves analytics by exact feature name, so always report the
// feature's own name rather than whatever casing the caller happened to use.
// Without this, getValue('My Flag') and getValue('my_flag') report as two
// separate features and neither resolves unless it matches the name exactly.
const analyticsKey = flag?.name || normalizeFlagKey(key);
if (this.datadogRum) {
if (!this.datadogRum!.client!.addFeatureFlagEvaluation) {
console.error('Flagsmith: Your datadog RUM client does not support the function addFeatureFlagEvaluation, please update it.');
} else {
if (method === 'VALUE') {
this.datadogRum!.client!.addFeatureFlagEvaluation(FLAGSMITH_CONFIG_ANALYTICS_KEY + key, this.getValue(key, {}, true));
this.datadogRum!.client!.addFeatureFlagEvaluation(FLAGSMITH_CONFIG_ANALYTICS_KEY + analyticsKey, this.getValue(key, {}, true));
} else {
this.datadogRum!.client!.addFeatureFlagEvaluation(FLAGSMITH_FLAG_ANALYTICS_KEY + key, this.hasFeature(key, true));
this.datadogRum!.client!.addFeatureFlagEvaluation(FLAGSMITH_FLAG_ANALYTICS_KEY + analyticsKey, this.hasFeature(key, true));
}
}
}
Expand All @@ -968,10 +973,10 @@ const Flagsmith = class {
if (!this.evaluationEvent[this.evaluationContext.environment.apiKey]) {
this.evaluationEvent[this.evaluationContext.environment.apiKey] = {};
}
if (this.evaluationEvent[this.evaluationContext.environment.apiKey][key] === undefined) {
this.evaluationEvent[this.evaluationContext.environment.apiKey][key] = 0;
if (this.evaluationEvent[this.evaluationContext.environment.apiKey][analyticsKey] === undefined) {
this.evaluationEvent[this.evaluationContext.environment.apiKey][analyticsKey] = 0;
}
this.evaluationEvent[this.evaluationContext.environment.apiKey][key] += 1;
this.evaluationEvent[this.evaluationContext.environment.apiKey][analyticsKey] += 1;
}

this.updateEventStorage();
Expand Down Expand Up @@ -1010,8 +1015,11 @@ const Flagsmith = class {
this.log(`Flagsmith: trackExposureEvent called for "${featureName}" without an identity; call identify() (optionally with transient: true) or pass opts.identifier. No exposure recorded.`);
return;
}
// As with evaluateFlag, report the feature's own name so that exposures for the
// same flag aggregate regardless of the casing the caller used.
const key = normalizeFlagKey(featureName);
Comment on lines +1018 to +1020

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Zaimwa9 thoughts on this?

this.eventProcessor.trackExposureEvent({
featureName,
featureName: this.flags?.[key]?.name || key,
identifier,
value: opts?.value ?? null,
traits: resolveTraitValues(opts?.traits ?? this.evaluationContext.identity?.traits),
Expand Down
41 changes: 40 additions & 1 deletion test/feature-name-casing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IInitConfig } from '../types';
import { getFlagsmith } from './test-constants';
import { delay, getFlagsmith } from './test-constants';

const mixedCaseFlags = [
{
Expand All @@ -14,6 +14,14 @@ const mixedCaseFlags = [
},
];

const instances: any[] = [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const instances: any[] = [];
const flagsmithInstances: any[] = [];

Can we not be more specific with the typing here too? Something like:

Suggested change
const instances: any[] = [];
const flagsmithInstances: Flagsmith[] = [];


afterEach(() => {
// enableAnalytics starts an interval that nothing public tears down.
instances.forEach((flagsmith) => clearInterval(flagsmith.analyticsInterval));
instances.length = 0;
});

async function initWithMixedCaseFlags(config: Partial<IInitConfig> = {}) {
const { flagsmith, initConfig, mockFetch } = getFlagsmith(config);
mockFetch.mockImplementation(async (url: string) => {
Expand All @@ -26,9 +34,15 @@ async function initWithMixedCaseFlags(config: Partial<IInitConfig> = {}) {
throw new Error('Please mock the call to ' + url);
});
await flagsmith.init(initConfig);
instances.push(flagsmith);
return { flagsmith, mockFetch };
}

const postedAnalytics = (mockFetch: jest.Mock) => {
const call = mockFetch.mock.calls.find(([url]: [string]) => url.includes('analytics/flags'));
return call ? JSON.parse(call[1].body) : null;
};

describe('feature name casing', () => {
test('exposes the original feature name on each flag', async () => {
const { flagsmith } = await initWithMixedCaseFlags();
Expand All @@ -44,4 +58,29 @@ describe('feature name casing', () => {
expect(flagsmith.getValue('myfeatureflag')).toBe('on');
expect(flagsmith.hasFeature('MYFEATUREFLAG')).toBe(true);
});

test('records analytics against the canonical feature name, whatever casing the caller used', async () => {
const { flagsmith, mockFetch } = await initWithMixedCaseFlags({ enableAnalytics: true });
await delay(1); // evaluationEvent is restored from storage asynchronously

flagsmith.getValue('myfeatureflag');
flagsmith.getValue('MyFeatureFlag');
flagsmith.hasFeature('MYFEATUREFLAG');

// @ts-ignore internal, normally driven by an interval
await flagsmith.analyticsFlags();
expect(postedAnalytics(mockFetch)).toEqual({ MyFeatureFlag: 3 });
});

test('buckets unknown flags under the normalised key rather than the caller casing', async () => {
const { flagsmith, mockFetch } = await initWithMixedCaseFlags({ enableAnalytics: true });
await delay(1);

flagsmith.getValue('Does Not Exist');
flagsmith.getValue('does_not_exist');

// @ts-ignore internal, normally driven by an interval
await flagsmith.analyticsFlags();
expect(postedAnalytics(mockFetch)).toEqual({ does_not_exist: 2 });
});
Comment on lines +75 to +85

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('buckets unknown flags under the normalised key rather than the caller casing', async () => {
const { flagsmith, mockFetch } = await initWithMixedCaseFlags({ enableAnalytics: true });
await delay(1);
flagsmith.getValue('Does Not Exist');
flagsmith.getValue('does_not_exist');
// @ts-ignore internal, normally driven by an interval
await flagsmith.analyticsFlags();
expect(postedAnalytics(mockFetch)).toEqual({ does_not_exist: 2 });
});

});
Loading