Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/nine-beans-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

Support masking for li_fat_id loaded from cookies
15 changes: 15 additions & 0 deletions packages/browser/src/__tests__/utils/event-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import {
getBrowserLanguage,
getBrowserLanguagePrefix,
getCampaignParams,
getEventProperties,
getTimezone,
getTimezoneOffset,
} from '../../utils/event-utils'
import * as globals from '../../utils/globals'
import { cookieStore } from '../../storage'
import { PostHogConfig } from '../../types'

describe(`event-utils`, () => {
describe('properties', () => {
Expand Down Expand Up @@ -86,4 +89,16 @@ describe(`event-utils`, () => {
expect(languagePrefix).toBe('pt')
})
})

describe('getCampaignParams', () => {
it('should mask cookie params', () => {
const mockedCookieStore = jest.spyOn(cookieStore, '_get')
mockedCookieStore.mockReturnValue('SOME_SECRET')
const params = getCampaignParams({ mask_personal_data_properties: true } as PostHogConfig)
// li_fat_id should come from cookie
expect(params['li_fat_id']).toEqual('<masked>')
// gclid does not come from cookie
expect(params['gclid']).toEqual(null)
})
})
})
57 changes: 39 additions & 18 deletions packages/browser/src/__tests__/web-experiments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import { PostHogPersistence } from '../posthog-persistence'
import { WebExperiment } from '../web-experiments-types'
import { RequestRouter } from '../utils/request-router'
import { ConsentManager } from '../consent'
import resetAllMocks = jest.resetAllMocks

let mockLocation: jest.Mock

jest.mock('../utils/globals', () => {
const original = jest.requireActual('../utils/globals')
mockLocation = jest.fn().mockReturnValue({
protocol: 'http:',
host: 'localhost',
pathname: '/',
search: '',
hash: '',
href: 'http://localhost/',
})
return {
...original,
get location() {
return mockLocation()
},
}
})

describe('Web Experimentation', () => {
let webExperiment: WebExperiments
Expand Down Expand Up @@ -105,10 +126,15 @@ describe('Web Experimentation', () => {
},
} as unknown as WebExperiment

const simulateFeatureFlags: jest.Mock = jest.fn()
let cachedFlags

const simulateFeatureFlags = (flags) => {
cachedFlags = flags
webExperiment.onFeatureFlags(Object.keys(flags))
}

beforeEach(() => {
let cachedFlags = {}
resetAllMocks()
persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence
posthog = makePostHog({
config: {
Expand All @@ -130,16 +156,18 @@ describe('Web Experimentation', () => {
return cachedFlags[key]
},
})

simulateFeatureFlags.mockImplementation((flags) => {
cachedFlags = flags
webExperiment.onFeatureFlags(Object.keys(flags))
})
cachedFlags = {}

posthog.requestRouter = new RequestRouter(posthog)
webExperiment = new WebExperiments(posthog)
})

afterEach(() => {
posthog = undefined
webExperiment = undefined
cachedFlags = undefined
})

function createTestDocument() {
const elParent = document.createElement('span')
elParent.innerHTML = 'original'
Expand All @@ -156,10 +184,7 @@ describe('Web Experimentation', () => {
const webExperiment = new WebExperiments(posthog)
const elParent = createTestDocument()

WebExperiments.getWindowLocation = () => {
// eslint-disable-next-line compat/compat
return new URL(testLocation) as unknown as Location
}
mockLocation.mockReturnValue(new URL(testLocation) as unknown as Location)

webExperiment.getWebExperimentsAndEvaluateDisplayLogic(false)
expect(elParent.innerHTML).toEqual(expectedInnerHTML)
Expand Down Expand Up @@ -284,19 +309,15 @@ describe('Web Experimentation', () => {

const webExperiment = new WebExperiments(posthog)
const elParent = createTestDocument()
const original = WebExperiments.getWindowLocation

WebExperiments.getWindowLocation = () => {
// eslint-disable-next-line compat/compat
return new URL(
mockLocation.mockReturnValue(
new URL(
'https://example.com/landing-page?__experiment_id=3&__experiment_variant=variant-sign-up'
) as unknown as Location
}
)

// This forces a preview of 'variant-sign-up', ignoring real flags.
webExperiment.previewWebExperiment()

WebExperiments.getWindowLocation = original
expect(elParent.innerHTML).toEqual('Sign me up')
expect(posthog.capture).not.toHaveBeenCalled()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ export const setAllPersonProfilePropertiesAsPersonPropertiesForFlags = (posthog:
posthog.config.mask_personal_data_properties,
posthog.config.custom_personal_data_properties
),
getCampaignParams(
posthog.config.custom_campaign_params,
posthog.config.mask_personal_data_properties,
posthog.config.custom_personal_data_properties
),
getCampaignParams(posthog.config),
getReferrerInfo()
)
const personProperties: Record<string, string> = {}
Expand Down
10 changes: 4 additions & 6 deletions packages/browser/src/posthog-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ export class PostHogPersistence {
let store: PersistentStore
// We handle storage type in a case-insensitive way for backwards compatibility
const storage_type = config['persistence'].toLowerCase() as Lowercase<PostHogConfig['persistence']>
if (storage_type === 'localstorage' && localStore._is_supported()) {
if (config.cookieless_mode === 'always') {
store = memoryStore
} else if (storage_type === 'localstorage' && localStore._is_supported()) {
store = localStore
} else if (storage_type === 'localstorage+cookie' && localPlusCookieStore._is_supported()) {
store = localPlusCookieStore
Expand Down Expand Up @@ -243,11 +245,7 @@ export class PostHogPersistence {

update_campaign_params(): void {
if (!this._campaign_params_saved) {
const campaignParams = getCampaignParams(
this._config.custom_campaign_params,
this._config.mask_personal_data_properties,
this._config.custom_personal_data_properties
)
const campaignParams = getCampaignParams(this._config)
// only save campaign params if there were any
if (!isEmptyObject(stripEmptyProperties(campaignParams))) {
this.register(campaignParams)
Expand Down
35 changes: 20 additions & 15 deletions packages/browser/src/utils/event-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { convertToURL, getQueryParam, maskQueryParams } from './request-utils'
import { convertToURL, getQueryParam, maskParams, maskQueryParams } from './request-utils'
import { isNull, stripLeadingDollar } from '@posthog/core'
import { Properties } from '../types'
import { PostHogConfig, Properties } from '../types'
import Config from '../config'
import { each, extend, extendArray, stripEmptyProperties } from './index'
import { document, location, userAgent, window } from './globals'
Expand Down Expand Up @@ -78,32 +78,34 @@ export const COOKIE_CAMPAIGN_PARAMS = [
'li_fat_id', // linkedin
]

export function getCampaignParams(
customTrackedParams?: string[],
maskPersonalDataProperties?: boolean,
customPersonalDataProperties?: string[] | undefined
): Record<string, string> {
export function getCampaignParams(config: PostHogConfig): Record<string, string> {
if (!document) {
return {}
}

const paramsToMask = maskPersonalDataProperties
? extendArray([], PERSONAL_DATA_CAMPAIGN_PARAMS, customPersonalDataProperties || [])
const paramsToMask = config.mask_personal_data_properties
? extendArray([], PERSONAL_DATA_CAMPAIGN_PARAMS, config.custom_personal_data_properties || [])
: []

// Initially get campaign params from the URL
const urlCampaignParams = _getCampaignParamsFromUrl(
maskQueryParams(document.URL, paramsToMask, MASKED),
customTrackedParams
)
const urlCampaignParams = _getCampaignParamsFromUrl(document.URL, config.custom_campaign_params)

// But we can also get some of them from the cookie store
// For example: https://learn.microsoft.com/en-us/linkedin/marketing/conversions/enabling-first-party-cookies?view=li-lms-2025-05#reading-li_fat_id-from-cookies
const cookieCampaignParams = _getCampaignParamsFromCookie()

// set all missing campaign params to null, so that initial person properties can't be overridden later
const nullCampaignParams: Record<string, null> = {}
each(urlCampaignParams, function (_, key) {
nullCampaignParams[key] = null
})

// Prefer the values found in the urlCampaignParams if possible
// `extend` will override the values if found in the second argument
return extend(cookieCampaignParams, urlCampaignParams)
let campaignParams = extend(nullCampaignParams, cookieCampaignParams, stripEmptyProperties(urlCampaignParams))

campaignParams = maskParams(campaignParams, paramsToMask, MASKED)

return campaignParams
}

function _getCampaignParamsFromUrl(url: string, customParams?: string[]): Record<string, string> {
Expand All @@ -121,6 +123,9 @@ function _getCampaignParamsFromUrl(url: string, customParams?: string[]): Record
function _getCampaignParamsFromCookie(): Record<string, string> {
const params: Record<string, any> = {}
each(COOKIE_CAMPAIGN_PARAMS, function (kwkey) {
// TODO is this correct? I think we're making an assumption of the shape of the cookie here
// see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/enabling-first-party-cookies?view=li-lms-2025-05#reading-li_fat_id-from-cookies
// this really need a test where we set document.cookie to a real value seen in the wild
const kw = cookieStore._get(kwkey)
params[kwkey] = kw ? kw : null
})
Expand Down
18 changes: 17 additions & 1 deletion packages/browser/src/utils/request-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const getQueryParam = function (url: string, param: string): string {
}
}

// replace any query params in the url with the provided mask value. Tries to keep the URL as instant as possible,
// replace any query params in the url with the provided mask value. Tries to keep the URL as intact as possible,
Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: typo: "instant" should be "intact"

Suggested change
// replace any query params in the url with the provided mask value. Tries to keep the URL as intact as possible,
// replace any query params in the url with the provided mask value. Tries to keep the URL as intact as possible,

// including preserving malformed text in most cases
export const maskQueryParams = function <T extends string | undefined>(
url: T,
Expand Down Expand Up @@ -119,6 +119,22 @@ export const maskQueryParams = function <T extends string | undefined>(
return result as any
}

export function maskParams(
params: Record<string, string>,
maskedParams: string[] | undefined,
mask: string
): Record<string, string> {
const newParams: Record<string, string> = {}
each(params, function (value: string, key: string) {
if (maskedParams && maskedParams.includes(key) && value) {
newParams[key] = mask
} else {
newParams[key] = value
}
})
return newParams
}

export const _getHashParam = function (hash: string, param: string): string | null {
const matches = hash.match(new RegExp(param + '=([^&]*)'))
return matches ? matches[1] : null
Expand Down
20 changes: 7 additions & 13 deletions packages/browser/src/web-experiments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PostHog } from './posthog-core'
import { navigator, window } from './utils/globals'
import { navigator, window, location } from './utils/globals'
import {
WebExperiment,
WebExperimentsCallback,
Expand Down Expand Up @@ -73,7 +73,6 @@ export class WebExperiments {
}

previewWebExperiment() {
const location = WebExperiments.getWindowLocation()
if (location?.search) {
const experimentID = getQueryParam(location?.search, '__experiment_id')
const variant = getQueryParam(location?.search, '__experiment_variant')
Expand Down Expand Up @@ -126,7 +125,7 @@ export class WebExperiments {
} else if (webExperiment.variants) {
for (const variant in webExperiment.variants) {
const testVariant = webExperiment.variants[variant]
const matchTest = WebExperiments._matchesTestVariant(testVariant)
const matchTest = this._matchesTestVariant(testVariant)
if (matchTest) {
this._applyTransforms(webExperiment.name, variant, testVariant.transforms)
}
Expand Down Expand Up @@ -175,19 +174,18 @@ export class WebExperiments {
)
}
}
private static _matchesTestVariant(testVariant: WebExperimentVariant) {
private _matchesTestVariant(testVariant: WebExperimentVariant) {
if (isNullish(testVariant.conditions)) {
return false
}
return WebExperiments._matchUrlConditions(testVariant) && WebExperiments._matchUTMConditions(testVariant)
return this._matchUrlConditions(testVariant) && this._matchUTMConditions(testVariant)
}

private static _matchUrlConditions(testVariant: WebExperimentVariant): boolean {
private _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'](
Expand All @@ -201,15 +199,11 @@ export class WebExperiments {
return false
}

public static getWindowLocation(): Location | undefined {
return window?.location
}

private static _matchUTMConditions(testVariant: WebExperimentVariant): boolean {
private _matchUTMConditions(testVariant: WebExperimentVariant): boolean {
if (isNullish(testVariant.conditions) || isNullish(testVariant.conditions?.utm)) {
return true
}
const campaignParams = getCampaignParams()
const campaignParams = getCampaignParams(this._instance.config)
if (campaignParams['utm_source']) {
// eslint-disable-next-line compat/compat
const utmCampaignMatched = testVariant.conditions?.utm?.utm_campaign
Expand Down
Loading