Skip to content

Commit

Permalink
Emit web_experiment_applied event and do not render experiments for bots
Browse files Browse the repository at this point in the history
  • Loading branch information
Phani Raj committed Sep 29, 2024
1 parent 61b32c8 commit aec8cff
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 23 deletions.
46 changes: 36 additions & 10 deletions src/__tests__/web-experiments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('Web Experimentation', () => {
transforms: [
{
selector: '#set-user-properties',
className: 'primary',
css: 'font-size:40px',
},
],
},
Expand Down Expand Up @@ -114,6 +114,7 @@ describe('Web Experimentation', () => {
} as unknown as PostHogConfig,
persistence: persistence,
get_property: jest.fn(),
capture: jest.fn(),
_send_request: jest
.fn()
.mockImplementation(({ callback }) => callback({ statusCode: 200, json: experimentsResponse })),
Expand All @@ -130,8 +131,8 @@ describe('Web Experimentation', () => {
elTarget.id = 'primary_button'
// eslint-disable-next-line no-restricted-globals
const elParent = document.createElement('span')
elParent.innerText = 'unassigned'
elParent.className = 'unassigned'
elParent.innerText = 'original'
elParent.className = 'original'
elParent.appendChild(elTarget)
// eslint-disable-next-line no-restricted-globals
document.querySelectorAll = function () {
Expand Down Expand Up @@ -167,8 +168,8 @@ describe('Web Experimentation', () => {
} as unknown as DecideResponse)

switch (expectedProperty) {
case 'className':
expect(elParent.className).toEqual(value)
case 'css':
expect(elParent.getAttribute('style')).toEqual(value)
break

case 'innerText':
Expand All @@ -181,6 +182,24 @@ describe('Web Experimentation', () => {
}
}

describe('bot detection', () => {
it('does not apply web experiment if viewer is a bot', () => {
experimentsResponse = {
experiments: [buttonWebExperimentWithUrlConditions],
}
const webExperiment = new WebExperiments(posthog)
webExperiment._is_bot = () => true
const elParent = createTestDocument()

webExperiment.afterDecideResponse({
featureFlags: {
'signup-button-test': 'Sign me up',
},
} as unknown as DecideResponse)
expect(elParent.innerText).toEqual('original')
})
})

describe('url match conditions', () => {
it('exact location match', () => {
const testLocation = 'https://example.com/Signup'
Expand Down Expand Up @@ -211,7 +230,7 @@ describe('Web Experimentation', () => {
},
}
const testLocation = 'https://example.com/landing-page?utm_campaign=marketing&utm_medium=mobile'
const expectedText = 'unassigned'
const expectedText = 'original'
testUrlMatch(testLocation, expectedText)
})
})
Expand All @@ -238,7 +257,7 @@ describe('Web Experimentation', () => {

posthog.requestRouter = new RequestRouter(disabledPostHog)
webExperiment = new WebExperiments(disabledPostHog)
assertElementChanged('control', 'innerText', 'unassigned')
assertElementChanged('control', 'innerText', 'original')
})

it('can set text of Span Element', async () => {
Expand All @@ -247,17 +266,24 @@ describe('Web Experimentation', () => {
}

assertElementChanged('control', 'innerText', 'Sign up')
expect(posthog.capture).toHaveBeenCalledWith('$web_experiment_applied', {
$web_experiment_document_url:
'https://example.com/landing-page?utm_campaign=marketing&utm_medium=mobile',
$web_experiment_elements_modified: 1,
$web_experiment_name: 'Signup button test',
$web_experiment_variant: 'control',
})
})

it('can set className of Span Element', async () => {
it('can set css of Span Element', async () => {
experimentsResponse = {
experiments: [signupButtonWebExperimentWithFeatureFlag],
}

assertElementChanged('css-transform', 'className', 'primary')
assertElementChanged('css-transform', 'css', 'font-size:40px')
})

it('can set innerHtml of Span Element', async () => {
it('can set innerHTML of Span Element', async () => {
experimentsResponse = {
experiments: [signupButtonWebExperimentWithFeatureFlag],
}
Expand Down
2 changes: 1 addition & 1 deletion src/web-experiments-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface WebExperimentTransform {
text?: string
html?: string
imgUrl?: string
className?: string
css?: string
}

export type WebExperimentUrlMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains'
Expand Down
90 changes: 78 additions & 12 deletions src/web-experiments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PostHog } from './posthog-core'
import { DecideResponse } from './types'
import { window } from './utils/globals'
import { location, navigator, window } from './utils/globals'
import {
WebExperiment,
WebExperimentsCallback,
Expand All @@ -13,6 +13,7 @@ import { isNullish } from './utils/type-utils'
import { isUrlMatchingRegex } from './utils/request-utils'
import { logger } from './utils/logger'
import { Info } from './utils/event-utils'
import { isLikelyBot } from './utils/blocked-uas'

export const webExperimentUrlValidationMap: Record<
WebExperimentUrlMatchType,
Expand Down Expand Up @@ -46,17 +47,17 @@ export class WebExperiments {
}

applyFeatureFlagChanges(flags: string[]) {
WebExperiments.logInfo('applying feature flags', flags)
if (isNullish(this._flagToExperiments) || this.instance.config.disable_web_experiments) {
return
}

WebExperiments.logInfo('applying feature flags', flags)
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(
this.applyTransforms(
webExperiment.name,
selectedVariant,
webExperiment.variants[selectedVariant].transforms
Expand All @@ -67,9 +68,31 @@ export class WebExperiments {
}

afterDecideResponse(response: DecideResponse) {
this._featureFlags = response.featureFlags
if (this._is_bot()) {
WebExperiments.logInfo('Refusing to render web experiment since the viewer is a likely bot')
return
}

this._featureFlags = response.featureFlags
this.loadIfEnabled()
this.previewWebExperiment()
}

previewWebExperiment() {
// eslint-disable-next-line compat/compat
const params = new URLSearchParams(location?.search)
const experimentID = params.get('__experiment_id')
const variant = params.get('__experiment_variant')
WebExperiments.logInfo(`previewing web experiments ${experimentID} && ${variant}`)
if (experimentID && variant) {
this.getWebExperiments(
(webExperiments) => {
this.showPreviewWebExperiment(parseInt(experimentID), variant, webExperiments)
},
false,
true
)
}
}

loadIfEnabled() {
Expand All @@ -84,6 +107,7 @@ export class WebExperiments {
this.getWebExperiments((webExperiments) => {
WebExperiments.logInfo(`retrieved web experiments from the server`)
this._flagToExperiments = new Map<string, WebExperiment>()

webExperiments.forEach((webExperiment) => {
if (
webExperiment.feature_flag_key &&
Expand All @@ -102,7 +126,7 @@ export class WebExperiments {

const selectedVariant = this._featureFlags[webExperiment.feature_flag_key] as unknown as string
if (selectedVariant && webExperiment.variants[selectedVariant]) {
WebExperiments.applyTransforms(
this.applyTransforms(
webExperiment.name,
selectedVariant,
webExperiment.variants[selectedVariant].transforms
Expand All @@ -113,16 +137,16 @@ export class WebExperiments {
const testVariant = webExperiment.variants[variant]
const matchTest = WebExperiments.matchesTestVariant(testVariant)
if (matchTest) {
WebExperiments.applyTransforms(webExperiment.name, variant, testVariant.transforms)
this.applyTransforms(webExperiment.name, variant, testVariant.transforms)
}
}
}
})
}, forceReload)
}

public getWebExperiments(callback: WebExperimentsCallback, forceReload: boolean) {
if (this.instance.config.disable_web_experiments) {
public getWebExperiments(callback: WebExperimentsCallback, forceReload: boolean, previewing?: boolean) {
if (this.instance.config.disable_web_experiments && !previewing) {
return callback([])
}

Expand All @@ -148,6 +172,19 @@ export class WebExperiments {
})
}

private showPreviewWebExperiment(experimentID: number, variant: string, webExperiments: WebExperiment[]) {
const previewExperiments = webExperiments.filter((exp) => exp.id === experimentID)
if (previewExperiments && previewExperiments.length > 0) {
WebExperiments.logInfo(
`Previewing web experiment [${previewExperiments[0].name}] with variant [${variant}]`
)
this.applyTransforms(
previewExperiments[0].name,
variant,
previewExperiments[0].variants[variant].transforms
)
}
}
private static matchesTestVariant(testVariant: WebExperimentVariant) {
if (isNullish(testVariant.conditions)) {
return false
Expand Down Expand Up @@ -211,17 +248,25 @@ export class WebExperiments {
logger.info(`[WebExperiments] ${msg}`, args)
}

private static applyTransforms(experiment: string, variant: string, transforms: WebExperimentTransform[]) {
private applyTransforms(experiment: string, variant: string, transforms: WebExperimentTransform[]) {
if (this._is_bot()) {
WebExperiments.logInfo('Refusing to render web experiment since the viewer is a likely bot')
return
}

transforms.forEach((transform) => {
if (transform.selector) {
WebExperiments.logInfo(
`applying transform of variant ${variant} for experiment ${experiment} `,
transform
)

let elementsModified = 0
// eslint-disable-next-line no-restricted-globals
const elements = document?.querySelectorAll(transform.selector)
elements?.forEach((element) => {
const htmlElement = element as HTMLElement
elementsModified += 1
if (transform.attributes) {
transform.attributes.forEach((attribute) => {
switch (attribute.name) {
Expand All @@ -248,14 +293,35 @@ export class WebExperiments {
}

if (transform.html) {
htmlElement.innerHTML = transform.html
if (htmlElement.parentElement) {
htmlElement.parentElement.innerHTML = transform.html
} else {
htmlElement.innerHTML = transform.html
}
}

if (transform.className) {
htmlElement.className = transform.className
if (transform.css) {
htmlElement.setAttribute('style', transform.css)
}
})

if (this.instance && this.instance.capture) {
this.instance.capture('$web_experiment_applied', {
$web_experiment_name: experiment,
$web_experiment_variant: variant,
$web_experiment_document_url: WebExperiments.getWindowLocation()?.href,
$web_experiment_elements_modified: elementsModified,
})
}
}
})
}

_is_bot(): boolean | undefined {
if (navigator && this.instance) {
return isLikelyBot(navigator, this.instance.config.custom_blocked_useragents)
} else {
return undefined
}
}
}

0 comments on commit aec8cff

Please sign in to comment.