From 1e18eea968d1501c9bab874d771774cff84adb0b Mon Sep 17 00:00:00 2001 From: Phani Raj Date: Fri, 13 Sep 2024 14:02:48 -0500 Subject: [PATCH] feat(experiments): Apply no-code experiments to the webpage. (#1409) This PR introduces a new extension to posthog-js called web-experiments which allows posthog to apply no-code experiments to elements on a web page. This PR needs PostHog/posthog#24872 to merge so it can function. --- src/constants.ts | 2 + src/posthog-core.ts | 5 + src/types.ts | 4 +- src/web-experiments-types.ts | 37 +++++ src/web-experiments.test.ts | 276 +++++++++++++++++++++++++++++++++++ src/web-experiments.ts | 261 +++++++++++++++++++++++++++++++++ 6 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 src/web-experiments-types.ts create mode 100644 src/web-experiments.test.ts create mode 100644 src/web-experiments.ts diff --git a/src/constants.ts b/src/constants.ts index f9e608e17..fc3154879 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -43,6 +43,8 @@ export const INITIAL_PERSON_INFO = '$initial_person_info' export const ENABLE_PERSON_PROCESSING = '$epp' export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__' +export const WEB_EXPERIMENTS = '$web_experiments' + // These are properties that are reserved and will not be automatically included in events export const PERSISTENCE_RESERVED_PROPERTIES = [ PEOPLE_DISTINCT_ID_KEY, diff --git a/src/posthog-core.ts b/src/posthog-core.ts index adce9188c..aa9dbba86 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -77,6 +77,7 @@ import { TracingHeaders } from './extensions/tracing-headers' import { ConsentManager } from './consent' import { ExceptionObserver } from './extensions/exception-autocapture' import { WebVitalsAutocapture } from './extensions/web-vitals' +import { WebExperiments } from './web-experiments' import { PostHogExceptions } from './posthog-exceptions' /* @@ -139,6 +140,7 @@ export const defaultConfig = (): PostHogConfig => ({ upgrade: false, disable_session_recording: false, disable_persistence: false, + disable_web_experiments: true, // disabled in beta. disable_surveys: false, enable_recording_console_log: undefined, // When undefined, it falls back to the server-side setting secure_cookie: window?.location?.protocol === 'https:', @@ -242,6 +244,7 @@ export class PostHog { pageViewManager: PageViewManager featureFlags: PostHogFeatureFlags surveys: PostHogSurveys + experiments: WebExperiments toolbar: Toolbar exceptions: PostHogExceptions consent: ConsentManager @@ -297,6 +300,7 @@ export class PostHog { this.scrollManager = new ScrollManager(this) this.pageViewManager = new PageViewManager(this) this.surveys = new PostHogSurveys(this) + this.experiments = new WebExperiments(this) this.exceptions = new PostHogExceptions(this) this.rateLimiter = new RateLimiter(this) this.requestRouter = new RequestRouter(this) @@ -537,6 +541,7 @@ export class PostHog { this.sessionRecording?.afterDecideResponse(response) this.autocapture?.afterDecideResponse(response) this.heatmaps?.afterDecideResponse(response) + this.experiments?.afterDecideResponse(response) this.surveys?.afterDecideResponse(response) this.webVitalsAutocapture?.afterDecideResponse(response) this.exceptions?.afterDecideResponse(response) diff --git a/src/types.ts b/src/types.ts index bedd03c98..1558c0a32 100644 --- a/src/types.ts +++ b/src/types.ts @@ -157,6 +157,7 @@ export interface PostHogConfig { /** @deprecated - use `disable_persistence` instead */ disable_cookie?: boolean disable_surveys: boolean + disable_web_experiments: boolean /** If set, posthog-js will never load external scripts such as those needed for Session Replay or Surveys. */ disable_external_dependency_loading?: boolean enable_recording_console_log?: boolean @@ -432,7 +433,8 @@ export interface ToolbarParams { export type SnippetArrayItem = [method: string, ...args: any[]] -export type JsonType = string | number | boolean | null | { [key: string]: JsonType } | Array +export type JsonRecord = { [key: string]: JsonType } +export type JsonType = string | number | boolean | null | JsonRecord | Array /** A feature that isn't publicly available yet.*/ export interface EarlyAccessFeature { diff --git a/src/web-experiments-types.ts b/src/web-experiments-types.ts new file mode 100644 index 000000000..25175d809 --- /dev/null +++ b/src/web-experiments-types.ts @@ -0,0 +1,37 @@ +export interface WebExperimentTransform { + attributes?: + | { + name: string + value: string + }[] + selector?: string + text?: string + html?: string + imgUrl?: string + className?: string +} + +export type WebExperimentUrlMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains' + +export interface WebExperimentVariant { + conditions?: { + url?: string + urlMatchType?: WebExperimentUrlMatchType + utm?: { + utm_source?: string + utm_medium?: string + utm_campaign?: string + utm_term?: string + } + } + variant_name: string + transforms: WebExperimentTransform[] +} +export interface WebExperiment { + id: number + name: string + feature_flag_key?: string + variants: Record +} + +export type WebExperimentsCallback = (webExperiments: WebExperiment[]) => void diff --git a/src/web-experiments.test.ts b/src/web-experiments.test.ts new file mode 100644 index 000000000..c4d84cf63 --- /dev/null +++ b/src/web-experiments.test.ts @@ -0,0 +1,276 @@ +import { WebExperiments } from './web-experiments' +import { PostHog } from './posthog-core' +import { DecideResponse, PostHogConfig } from './types' +import { PostHogPersistence } from './posthog-persistence' +import { WebExperiment } from './web-experiments-types' +import { RequestRouter } from './utils/request-router' +import { ConsentManager } from './consent' + +describe('Web Experimentation', () => { + let webExperiment: WebExperiments + let posthog: PostHog + let persistence: PostHogPersistence + let experimentsResponse: { status?: number; experiments?: WebExperiment[] } + const signupButtonWebExperimentWithFeatureFlag = { + id: 3, + name: 'Signup button test', + feature_flag_key: 'signup-button-test', + variants: { + Signup: { + transforms: [ + { + selector: '#set-user-properties', + text: 'Sign me up', + html: 'Sign me up', + }, + ], + }, + 'Send-it': { + transforms: [ + { + selector: '#set-user-properties', + text: 'Send it', + html: 'Send it', + }, + ], + }, + 'css-transform': { + transforms: [ + { + selector: '#set-user-properties', + className: 'primary', + }, + ], + }, + 'innerhtml-transform': { + transforms: [ + { + selector: '#set-user-properties', + html: '

hello world

', + }, + ], + }, + control: { + transforms: [ + { + selector: '#set-user-properties', + text: 'Sign up', + html: 'Sign up', + }, + ], + }, + }, + } as unknown as WebExperiment + + const buttonWebExperimentWithUrlConditions = { + id: 3, + name: 'Signup button test', + variants: { + Signup: { + conditions: { + url: 'https://example.com/Signup', + urlMatchType: 'exact', + }, + transforms: [ + { + selector: '#set-user-properties', + text: 'Sign me up', + html: 'Sign me up', + }, + ], + }, + 'Send-it': { + conditions: { url: 'regex-url', urlMatchType: 'regex' }, + transforms: [ + { + selector: '#set-user-properties', + text: 'Send it', + html: 'Send it', + }, + ], + }, + control: { + conditions: { url: 'checkout', urlMatchType: 'icontains' }, + transforms: [ + { + selector: '#set-user-properties', + text: 'Sign up', + html: 'Sign up', + }, + ], + }, + }, + } as unknown as WebExperiment + + beforeEach(() => { + persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence + posthog = makePostHog({ + config: { + disable_web_experiments: false, + api_host: 'https://test.com', + token: 'testtoken', + autocapture: true, + region: 'us-east-1', + } as unknown as PostHogConfig, + persistence: persistence, + get_property: jest.fn(), + _send_request: jest + .fn() + .mockImplementation(({ callback }) => callback({ statusCode: 200, json: experimentsResponse })), + consent: { isOptedOut: () => true } as unknown as ConsentManager, + }) + + posthog.requestRouter = new RequestRouter(posthog) + webExperiment = new WebExperiments(posthog) + }) + + function createTestDocument() { + // eslint-disable-next-line no-restricted-globals + const elTarget = document.createElement('img') + elTarget.id = 'primary_button' + // eslint-disable-next-line no-restricted-globals + const elParent = document.createElement('span') + elParent.innerText = 'unassigned' + elParent.className = 'unassigned' + elParent.appendChild(elTarget) + // eslint-disable-next-line no-restricted-globals + document.querySelectorAll = function () { + return [elParent] as unknown as NodeListOf + } + + return elParent + } + + function testUrlMatch(testLocation: string, expectedText: string) { + experimentsResponse = { + experiments: [buttonWebExperimentWithUrlConditions], + } + const webExperiment = new WebExperiments(posthog) + const elParent = createTestDocument() + + WebExperiments.getWindowLocation = () => { + // eslint-disable-next-line compat/compat + return new URL(testLocation) as unknown as Location + } + + webExperiment.getWebExperimentsAndEvaluateDisplayLogic(false) + expect(elParent.innerText).toEqual(expectedText) + } + + function assertElementChanged(variant: string, expectedProperty: string, value: string) { + const elParent = createTestDocument() + webExperiment = new WebExperiments(posthog) + webExperiment.afterDecideResponse({ + featureFlags: { + 'signup-button-test': variant, + }, + } as unknown as DecideResponse) + + switch (expectedProperty) { + case 'className': + expect(elParent.className).toEqual(value) + break + + case 'innerText': + expect(elParent.innerText).toEqual(value) + break + + case 'innerHTML': + expect(elParent.innerHTML).toEqual(value) + break + } + } + + describe('url match conditions', () => { + it('exact location match', () => { + const testLocation = 'https://example.com/Signup' + const expectedText = 'Sign me up' + testUrlMatch(testLocation, expectedText) + }) + + it('regex location match', () => { + const testLocation = 'https://regex-url.com/test' + const expectedText = 'Send it' + testUrlMatch(testLocation, expectedText) + }) + + it('icontains location match', () => { + const testLocation = 'https://example.com/checkout' + const expectedText = 'Sign up' + testUrlMatch(testLocation, expectedText) + }) + }) + + describe('utm match conditions', () => { + it('can disqualify on utm terms', () => { + const buttonWebExperimentWithUTMConditions = buttonWebExperimentWithUrlConditions + buttonWebExperimentWithUTMConditions.variants['Signup'].conditions = { + utm: { + utm_campaign: 'marketing', + utm_medium: 'desktop', + }, + } + const testLocation = 'https://example.com/landing-page?utm_campaign=marketing&utm_medium=mobile' + const expectedText = 'unassigned' + testUrlMatch(testLocation, expectedText) + }) + }) + + describe('with feature flags', () => { + it('experiments are disabled by default', async () => { + const expResponse = { + experiments: [signupButtonWebExperimentWithFeatureFlag], + } + const disabledPostHog = makePostHog({ + config: { + api_host: 'https://test.com', + token: 'testtoken', + autocapture: true, + region: 'us-east-1', + } as unknown as PostHogConfig, + persistence: persistence, + get_property: jest.fn(), + _send_request: jest + .fn() + .mockImplementation(({ callback }) => callback({ statusCode: 200, json: expResponse })), + consent: { isOptedOut: () => true } as unknown as ConsentManager, + }) + + posthog.requestRouter = new RequestRouter(disabledPostHog) + webExperiment = new WebExperiments(disabledPostHog) + assertElementChanged('control', 'innerText', 'unassigned') + }) + + it('can set text of Span Element', async () => { + experimentsResponse = { + experiments: [signupButtonWebExperimentWithFeatureFlag], + } + + assertElementChanged('control', 'innerText', 'Sign up') + }) + + it('can set className of Span Element', async () => { + experimentsResponse = { + experiments: [signupButtonWebExperimentWithFeatureFlag], + } + + assertElementChanged('css-transform', 'className', 'primary') + }) + + it('can set innerHtml of Span Element', async () => { + experimentsResponse = { + experiments: [signupButtonWebExperimentWithFeatureFlag], + } + assertElementChanged('innerhtml-transform', 'innerHTML', '

hello world

') + }) + }) + + function makePostHog(ph: Partial): PostHog { + return { + get_distinct_id() { + return 'distinctid' + }, + ...ph, + } as unknown as PostHog + } +}) diff --git a/src/web-experiments.ts b/src/web-experiments.ts new file mode 100644 index 000000000..2499c5450 --- /dev/null +++ b/src/web-experiments.ts @@ -0,0 +1,261 @@ +import { PostHog } from './posthog-core' +import { DecideResponse } from './types' +import { window } from './utils/globals' +import { + WebExperiment, + WebExperimentsCallback, + WebExperimentTransform, + WebExperimentUrlMatchType, + WebExperimentVariant, +} from './web-experiments-types' +import { WEB_EXPERIMENTS } from './constants' +import { isNullish } from './utils/type-utils' +import { isUrlMatchingRegex } from './utils/request-utils' +import { logger } from './utils/logger' +import { Info } from './utils/event-utils' + +export const webExperimentUrlValidationMap: Record< + WebExperimentUrlMatchType, + (conditionsUrl: string, location: Location) => boolean +> = { + icontains: (conditionsUrl, location) => + !!window && location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1, + not_icontains: (conditionsUrl, location) => + !!window && location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) === -1, + regex: (conditionsUrl, location) => !!window && isUrlMatchingRegex(location.href, conditionsUrl), + not_regex: (conditionsUrl, location) => !!window && !isUrlMatchingRegex(location.href, conditionsUrl), + exact: (conditionsUrl, location) => location.href === conditionsUrl, + is_not: (conditionsUrl, location) => location.href !== conditionsUrl, +} + +export class WebExperiments { + instance: PostHog + private _featureFlags?: Record + private _flagToExperiments?: Map + + constructor(instance: PostHog) { + this.instance = instance + const appFeatureFLags = (flags: string[]) => { + this.applyFeatureFlagChanges(flags) + } + + if (this.instance.onFeatureFlags) { + this.instance.onFeatureFlags(appFeatureFLags) + } + this._flagToExperiments = new Map() + } + + applyFeatureFlagChanges(flags: string[]) { + WebExperiments.logInfo('applying feature flags', flags) + if (isNullish(this._flagToExperiments) || this.instance.config.disable_web_experiments) { + return + } + + flags.forEach((flag) => { + if (this._flagToExperiments && this._flagToExperiments?.has(flag)) { + const selectedVariant = this.instance.getFeatureFlag(flag) as unknown as string + const webExperiment = this._flagToExperiments?.get(flag) + if (selectedVariant && webExperiment?.variants[selectedVariant]) { + WebExperiments.applyTransforms( + webExperiment.name, + selectedVariant, + webExperiment.variants[selectedVariant].transforms + ) + } + } + }) + } + + afterDecideResponse(response: DecideResponse) { + this._featureFlags = response.featureFlags + + this.loadIfEnabled() + } + + loadIfEnabled() { + if (this.instance.config.disable_web_experiments) { + return + } + + this.getWebExperimentsAndEvaluateDisplayLogic() + } + + public getWebExperimentsAndEvaluateDisplayLogic = (forceReload: boolean = false): void => { + this.getWebExperiments((webExperiments) => { + WebExperiments.logInfo(`retrieved web experiments from the server`) + this._flagToExperiments = new Map() + webExperiments.forEach((webExperiment) => { + if ( + webExperiment.feature_flag_key && + this._featureFlags && + this._featureFlags[webExperiment.feature_flag_key] + ) { + if (this._flagToExperiments) { + WebExperiments.logInfo( + `setting flag key `, + webExperiment.feature_flag_key, + ` to web experiment `, + webExperiment + ) + this._flagToExperiments?.set(webExperiment.feature_flag_key, webExperiment) + } + + const selectedVariant = this._featureFlags[webExperiment.feature_flag_key] as unknown as string + if (selectedVariant && webExperiment.variants[selectedVariant]) { + WebExperiments.applyTransforms( + webExperiment.name, + selectedVariant, + webExperiment.variants[selectedVariant].transforms + ) + } + } else if (webExperiment.variants) { + for (const variant in webExperiment.variants) { + const testVariant = webExperiment.variants[variant] + const matchTest = WebExperiments.matchesTestVariant(testVariant) + if (matchTest) { + WebExperiments.applyTransforms(webExperiment.name, variant, testVariant.transforms) + } + } + } + }) + }, forceReload) + } + + public getWebExperiments(callback: WebExperimentsCallback, forceReload: boolean) { + if (this.instance.config.disable_web_experiments) { + return callback([]) + } + + const existingWebExperiments = this.instance.get_property(WEB_EXPERIMENTS) + if (existingWebExperiments && !forceReload) { + return callback(existingWebExperiments) + } + + this.instance._send_request({ + url: this.instance.requestRouter.endpointFor( + 'api', + `/api/web_experiments/?token=${this.instance.config.token}` + ), + method: 'GET', + transport: 'XHR', + callback: (response) => { + if (response.statusCode !== 200 || !response.json) { + return callback([]) + } + const webExperiments = response.json.experiments || [] + return callback(webExperiments) + }, + }) + } + + private static matchesTestVariant(testVariant: WebExperimentVariant) { + if (isNullish(testVariant.conditions)) { + return false + } + return WebExperiments.matchUrlConditions(testVariant) && WebExperiments.matchUTMConditions(testVariant) + } + + private static matchUrlConditions(testVariant: WebExperimentVariant): boolean { + if (isNullish(testVariant.conditions) || isNullish(testVariant.conditions?.url)) { + return true + } + + const location = WebExperiments.getWindowLocation() + if (location) { + const urlCheck = testVariant.conditions?.url + ? webExperimentUrlValidationMap[testVariant.conditions?.urlMatchType ?? 'icontains']( + testVariant.conditions.url, + location + ) + : true + return urlCheck + } + + return false + } + + public static getWindowLocation(): Location | undefined { + return window?.location + } + + private static matchUTMConditions(testVariant: WebExperimentVariant): boolean { + if (isNullish(testVariant.conditions) || isNullish(testVariant.conditions?.utm)) { + return true + } + const campaignParams = Info.campaignParams() + if (campaignParams['utm_source']) { + // eslint-disable-next-line compat/compat + const utmCampaignMatched = testVariant.conditions?.utm?.utm_campaign + ? testVariant.conditions?.utm?.utm_campaign == campaignParams['utm_campaign'] + : true + + const utmSourceMatched = testVariant.conditions?.utm?.utm_source + ? testVariant.conditions?.utm?.utm_source == campaignParams['utm_source'] + : true + + const utmMediumMatched = testVariant.conditions?.utm?.utm_medium + ? testVariant.conditions?.utm?.utm_medium == campaignParams['utm_medium'] + : true + + const utmTermMatched = testVariant.conditions?.utm?.utm_term + ? testVariant.conditions?.utm?.utm_term == campaignParams['utm_term'] + : true + + return utmCampaignMatched && utmMediumMatched && utmTermMatched && utmSourceMatched + } + + return false + } + + private static logInfo(msg: string, ...args: any[]) { + logger.info(`[WebExperiments] ${msg}`, args) + } + + private static applyTransforms(experiment: string, variant: string, transforms: WebExperimentTransform[]) { + transforms.forEach((transform) => { + if (transform.selector) { + WebExperiments.logInfo( + `applying transform of variant ${variant} for experiment ${experiment} `, + transform + ) + // eslint-disable-next-line no-restricted-globals + const elements = document?.querySelectorAll(transform.selector) + elements?.forEach((element) => { + const htmlElement = element as HTMLElement + if (transform.attributes) { + transform.attributes.forEach((attribute) => { + switch (attribute.name) { + case 'text': + htmlElement.innerText = attribute.value + break + + case 'html': + htmlElement.innerHTML = attribute.value + break + + case 'cssClass': + htmlElement.className = attribute.value + break + + default: + htmlElement.setAttribute(attribute.name, attribute.value) + } + }) + } + + if (transform.text) { + htmlElement.innerText = transform.text + } + + if (transform.html) { + htmlElement.innerHTML = transform.html + } + + if (transform.className) { + htmlElement.className = transform.className + } + }) + } + }) + } +}