Skip to content

Commit

Permalink
feat(experiments): Apply no-code experiments to the webpage. (#1409)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Phanatic authored Sep 13, 2024
1 parent 72cf846 commit 1e18eea
Show file tree
Hide file tree
Showing 6 changed files with 584 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/*
Expand Down Expand Up @@ -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:',
Expand Down Expand Up @@ -242,6 +244,7 @@ export class PostHog {
pageViewManager: PageViewManager
featureFlags: PostHogFeatureFlags
surveys: PostHogSurveys
experiments: WebExperiments
toolbar: Toolbar
exceptions: PostHogExceptions
consent: ConsentManager
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<JsonType>
export type JsonRecord = { [key: string]: JsonType }
export type JsonType = string | number | boolean | null | JsonRecord | Array<JsonType>

/** A feature that isn't publicly available yet.*/
export interface EarlyAccessFeature {
Expand Down
37 changes: 37 additions & 0 deletions src/web-experiments-types.ts
Original file line number Diff line number Diff line change
@@ -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<string, WebExperimentVariant>
}

export type WebExperimentsCallback = (webExperiments: WebExperiment[]) => void
276 changes: 276 additions & 0 deletions src/web-experiments.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<h1>hello world</h1>',
},
],
},
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<Element>
}

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', '<h1>hello world</h1>')
})
})

function makePostHog(ph: Partial<PostHog>): PostHog {
return {
get_distinct_id() {
return 'distinctid'
},
...ph,
} as unknown as PostHog
}
})
Loading

0 comments on commit 1e18eea

Please sign in to comment.