diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index a97a4b4050..d6610f7611 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -71,6 +71,11 @@ declare global { analytics?: { track(event: string, proeprties?: ITelemetryTrackProperties): void; }; + featureFlags?: { + getAll: () => FeatureFlags; + getVariant: (name: string) => string | boolean | undefined; + override: (name: string, value: string) => void; + }; } } @@ -579,6 +584,7 @@ export interface IUserResponse { firstName?: string; lastName?: string; email?: string; + createdAt?: string; globalRole?: { name: IRole; id: string; @@ -599,7 +605,6 @@ export interface IUser extends IUserResponse { isOwner: boolean; inviteAcceptUrl?: string; fullName?: string; - createdAt?: string; } export interface IVersionNotificationSettings { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index ff8c3c3d71..5d2e8bbac4 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -325,6 +325,7 @@ export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOV export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED'; export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH'; export const LOCAL_STORAGE_THEME = 'N8N_THEME'; +export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES'; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const HIRING_BANNER = ` diff --git a/packages/editor-ui/src/stores/posthog.test.ts b/packages/editor-ui/src/stores/posthog.test.ts new file mode 100644 index 0000000000..a7fe4b4cc9 --- /dev/null +++ b/packages/editor-ui/src/stores/posthog.test.ts @@ -0,0 +1,150 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { usePostHog } from './posthog'; +import { useUsersStore } from './users'; +import { useSettingsStore } from './settings'; +import { IN8nUISettings } from '@/Interface'; +import { useRootStore } from './n8nRootStore'; +import { useTelemetryStore } from './telemetry'; + +const DEFAULT_POSTHOG_SETTINGS: IN8nUISettings['posthog'] = { + enabled: true, + apiHost: 'host', + apiKey: 'key', + autocapture: false, + disableSessionRecording: true, + debug: false, +}; +const CURRENT_USER_ID = '1'; +const CURRENT_INSTANCE_ID = '456'; + +function setSettings(overrides?: Partial) { + useSettingsStore().setSettings({ + posthog: DEFAULT_POSTHOG_SETTINGS, + instanceId: CURRENT_INSTANCE_ID, + ...overrides, + } as IN8nUISettings); + + useRootStore().setInstanceId(CURRENT_INSTANCE_ID); +} + +function setCurrentUser() { + useUsersStore().addUsers([ + { + id: CURRENT_USER_ID, + isPending: false, + createdAt: '2023-03-17T14:01:36.432Z', + }, + ]); + + useUsersStore().currentUserId = CURRENT_USER_ID; +} + +function resetStores() { + useSettingsStore().$reset(); + useUsersStore().$reset(); +} + +function setup() { + setActivePinia(createPinia()); + window.posthog = { + init: () => {}, + identify: () => {}, + }; + + const telemetryStore = useTelemetryStore(); + + vi.spyOn(window.posthog, 'init'); + vi.spyOn(window.posthog, 'identify'); + vi.spyOn(window.Storage.prototype, 'setItem'); + vi.spyOn(telemetryStore, 'track'); +} + +describe('Posthog store', () => { + describe('should not init', () => { + beforeEach(() => { + setup(); + }); + + it('should not init if posthog is not enabled', () => { + setSettings({ posthog: { ...DEFAULT_POSTHOG_SETTINGS, enabled: false } }); + setCurrentUser(); + const posthog = usePostHog(); + posthog.init(); + + expect(window.posthog?.init).not.toHaveBeenCalled(); + }); + + it('should not init if user is not logged in', () => { + setSettings(); + const posthog = usePostHog(); + posthog.init(); + + expect(window.posthog?.init).not.toHaveBeenCalled(); + }); + + afterEach(() => { + resetStores(); + }); + }); + + describe('should init posthog', () => { + beforeEach(() => { + setup(); + setSettings(); + setCurrentUser(); + }); + + it('should init store with serverside flags', () => { + const TEST = 'test'; + const flags = { + [TEST]: 'variant', + }; + const posthog = usePostHog(); + posthog.init(flags); + + expect(posthog.getVariant('test')).toEqual(flags[TEST]); + expect(window.posthog?.init).toHaveBeenCalled(); + }); + + it('should identify user', () => { + const posthog = usePostHog(); + posthog.init(); + + const userId = `${CURRENT_INSTANCE_ID}#${CURRENT_USER_ID}`; + expect(window.posthog?.identify).toHaveBeenCalledWith(userId, { + created_at_timestamp: 1679061696432, + instance_id: CURRENT_INSTANCE_ID, + }); + }); + + it('sets override feature flags', () => { + const TEST = 'test'; + const flags = { + [TEST]: 'variant', + }; + const posthog = usePostHog(); + posthog.init(flags); + + window.featureFlags?.override(TEST, 'override'); + + expect(posthog.getVariant('test')).toEqual('override'); + expect(window.posthog?.init).toHaveBeenCalled(); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'N8N_EXPERIMENT_OVERRIDES', + JSON.stringify({ test: 'override' }), + ); + + window.featureFlags?.override('other_test', 'override'); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'N8N_EXPERIMENT_OVERRIDES', + JSON.stringify({ test: 'override', other_test: 'override' }), + ); + }); + + afterEach(() => { + resetStores(); + window.localStorage.clear(); + window.featureFlags = undefined; + }); + }); +}); diff --git a/packages/editor-ui/src/stores/posthog.ts b/packages/editor-ui/src/stores/posthog.ts index 64be6e7157..0537fee242 100644 --- a/packages/editor-ui/src/stores/posthog.ts +++ b/packages/editor-ui/src/stores/posthog.ts @@ -4,7 +4,11 @@ import { useUsersStore } from '@/stores/users'; import { useRootStore } from '@/stores/n8nRootStore'; import { useSettingsStore } from '@/stores/settings'; import { FeatureFlags } from 'n8n-workflow'; -import { EXPERIMENTS_TO_TRACK, ONBOARDING_EXPERIMENT } from '@/constants'; +import { + EXPERIMENTS_TO_TRACK, + LOCAL_STORAGE_EXPERIMENT_OVERRIDES, + ONBOARDING_EXPERIMENT, +} from '@/constants'; import { useTelemetryStore } from './telemetry'; import { useSegment } from './segment'; import { debounce } from 'lodash-es'; @@ -23,6 +27,8 @@ export const usePostHog = defineStore('posthog', () => { const featureFlags: Ref = ref(null); const trackedDemoExp: Ref = ref({}); + const overrides: Ref> = ref({}); + const reset = () => { window.posthog?.reset?.(); featureFlags.value = null; @@ -37,6 +43,33 @@ export const usePostHog = defineStore('posthog', () => { return getVariant(experiment) === variant; }; + if (!window.featureFlags) { + // for testing + const cachedOverrdies = localStorage.getItem(LOCAL_STORAGE_EXPERIMENT_OVERRIDES); + if (cachedOverrdies) { + try { + overrides.value = JSON.parse(cachedOverrdies); + } catch (e) {} + } + + window.featureFlags = { + // since features are evaluated serverside, regular posthog mechanism to override clientside does not work + override: (name: string, value: string | boolean) => { + overrides.value[name] = value; + featureFlags.value = { + ...featureFlags.value, + [name]: value, + }; + try { + localStorage.setItem(LOCAL_STORAGE_EXPERIMENT_OVERRIDES, JSON.stringify(overrides.value)); + } catch (e) {} + }, + + getVariant, + getAll: () => featureFlags.value || {}, + }; + } + const identify = () => { const instanceId = rootStore.instanceId; const user = usersStore.currentUser; @@ -51,6 +84,13 @@ export const usePostHog = defineStore('posthog', () => { window.posthog?.identify?.(id, traits); }; + const addExperimentOverrides = () => { + featureFlags.value = { + ...featureFlags.value, + ...overrides.value, + }; + }; + const init = (evaluatedFeatureFlags?: FeatureFlags) => { if (!window.posthog) { return; @@ -86,10 +126,12 @@ export const usePostHog = defineStore('posthog', () => { featureFlags: evaluatedFeatureFlags, }; trackExperiments(evaluatedFeatureFlags); + addExperimentOverrides(); } else { // depend on client side evaluation if serverside evaluation fails window.posthog?.onFeatureFlags?.((keys: string[], map: FeatureFlags) => { featureFlags.value = map; + addExperimentOverrides(); trackExperiments(map); }); } @@ -101,6 +143,10 @@ export const usePostHog = defineStore('posthog', () => { const trackExperiment = (featureFlags: FeatureFlags, name: string) => { const variant = featureFlags[name]; + if (!variant || trackedDemoExp.value[name] === variant) { + return; + } + telemetryStore.track(EVENTS.IS_PART_OF_EXPERIMENT, { name, variant, diff --git a/packages/editor-ui/src/stores/settings.ts b/packages/editor-ui/src/stores/settings.ts index ee790a7571..d08bf9c548 100644 --- a/packages/editor-ui/src/stores/settings.ts +++ b/packages/editor-ui/src/stores/settings.ts @@ -179,13 +179,19 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { setSettings(settings: IN8nUISettings): void { this.settings = settings; this.userManagement = settings.userManagement; - this.userManagement.showSetupOnFirstLoad = !!settings.userManagement.showSetupOnFirstLoad; + if (this.userManagement) { + this.userManagement.showSetupOnFirstLoad = !!settings.userManagement.showSetupOnFirstLoad; + } this.api = settings.publicApi; this.onboardingCallPromptEnabled = settings.onboardingCallPromptEnabled; - this.ldap.loginEnabled = settings.sso.ldap.loginEnabled; - this.ldap.loginLabel = settings.sso.ldap.loginLabel; - this.saml.loginEnabled = settings.sso.saml.loginEnabled; - this.saml.loginLabel = settings.sso.saml.loginLabel; + if (settings.sso?.ldap) { + this.ldap.loginEnabled = settings.sso.ldap.loginEnabled; + this.ldap.loginLabel = settings.sso.ldap.loginLabel; + } + if (settings.sso?.saml) { + this.saml.loginEnabled = settings.sso.saml.loginEnabled; + this.saml.loginLabel = settings.sso.saml.loginLabel; + } }, async getSettings(): Promise { const rootStore = useRootStore();