diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bb35569d..79d6b341b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,39 @@
+## 1.191.0 - 2024-11-28
+
+- feat: different default and max idle period (#1558)
+
+## 1.190.2 - 2024-11-27
+
+- fix: patch to angular detection in rrweb (#1566)
+- chore: fix changelog (#1564)
+
+## 1.190.1 - 2024-11-27
+
+- fix: catch errors detecting dialog state when recording (#1562)
+
+## 1.190.0 - 2024-11-27
+
+- feat: Add initial person info to cookie when using localPlusCookieStore (#1559)
+
+## 1.189.1 - 2024-11-27
+
+- feat: hog site functions (#1546)
+
+## 1.189.0 - 2024-11-26
+
+- feat: Add better npm import, and script entrypoint for customizations (#1550)
+
+## 1.188.1 - 2024-11-26
+
+- fix: safari support requires we don't use (#1553)
+- fix: endless capturing /s/ (#1551)
+- chore: make platform more specific (#1549)
+
+## 1.188.0 - 2024-11-22
+
+- fix(surveys): Process feature_flag_keys on Survey object (#1548)
+- chore: sentry integration fixes (#1544)
+
## 1.187.2 - 2024-11-20
- fix: improve ES6 bundling (#1542)
diff --git a/package.json b/package.json
index 8b5e20c78..cf3a35df7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "posthog-js",
- "version": "1.187.2",
+ "version": "1.191.0",
"description": "Posthog-js allows you to automatically capture usage and send events to PostHog.",
"repository": "https://github.com/PostHog/posthog-js",
"author": "hey@posthog.com",
diff --git a/patches/@rrweb__record@2.0.0-alpha.17.patch b/patches/@rrweb__record@2.0.0-alpha.17.patch
index 9f5ab8084..c3b75e0cd 100644
--- a/patches/@rrweb__record@2.0.0-alpha.17.patch
+++ b/patches/@rrweb__record@2.0.0-alpha.17.patch
@@ -1,23 +1,28 @@
diff --git a/dist/record.js b/dist/record.js
-index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db438943a0b2 100644
+index 46ec389fefb698243008b39db65470dbdf0a3857..70db907755d68b08232e25e1b255a974f56f3c65 100644
--- a/dist/record.js
+++ b/dist/record.js
-@@ -26,6 +26,14 @@ const testableMethods$1 = {
+@@ -26,6 +26,19 @@ const testableMethods$1 = {
Element: [],
MutationObserver: ["constructor"]
};
+const isFunction = (x) => typeof x === 'function';
+const isAngularZonePatchedFunction = (x) => {
-+ if (!isFunction(x)) {
-+ return false;
++ try {
++ if (!isFunction(x)) {
++ return false;
++ }
++ const prototypeKeys = Object.getOwnPropertyNames(x.prototype || {});
++ return prototypeKeys.some((key) => key.indexOf('__zone'));
++ } catch {
++ // we've seen some intermittent problems in Safari since introducing this check
++ return false
+ }
-+ const prototypeKeys = Object.getOwnPropertyNames(x.prototype || {});
-+ return prototypeKeys.some((key) => key.indexOf('__zone'));
+}
const untaintedBasePrototype$1 = {};
function getUntaintedPrototype$1(key) {
if (untaintedBasePrototype$1[key])
-@@ -54,7 +62,7 @@ function getUntaintedPrototype$1(key) {
+@@ -54,7 +67,7 @@ function getUntaintedPrototype$1(key) {
}
)
);
@@ -26,7 +31,20 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db43
untaintedBasePrototype$1[key] = defaultObj.prototype;
return defaultObj.prototype;
}
-@@ -246,6 +254,9 @@ function isCSSImportRule(rule2) {
+@@ -65,10 +78,10 @@ function getUntaintedPrototype$1(key) {
+ if (!win) return defaultObj.prototype;
+ const untaintedObject = win[key].prototype;
+ document.body.removeChild(iframeEl);
+- if (!untaintedObject) return defaultPrototype;
++ if (!untaintedObject) return defaultObj.prototype;
+ return untaintedBasePrototype$1[key] = untaintedObject;
+ } catch {
+- return defaultPrototype;
++ return defaultObj.prototype;
+ }
+ }
+ const untaintedAccessorCache$1 = {};
+@@ -246,6 +259,9 @@ function isCSSImportRule(rule2) {
function isCSSStyleRule(rule2) {
return "selectorText" in rule2;
}
@@ -36,7 +54,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db43
class Mirror {
constructor() {
__publicField$1(this, "idNodeMap", /* @__PURE__ */ new Map());
-@@ -809,9 +820,14 @@ function serializeElementNode(n2, options) {
+@@ -809,9 +825,14 @@ function serializeElementNode(n2, options) {
}
}
if (tagName === "link" && inlineStylesheet) {
@@ -54,7 +72,24 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db43
let cssText = null;
if (stylesheet) {
cssText = stringifyStylesheet(stylesheet);
-@@ -1116,7300 +1132,227 @@ function serializeNodeWithId(n2, options) {
+@@ -855,7 +876,15 @@ function serializeElementNode(n2, options) {
+ }
+ }
+ if (tagName === "dialog" && n2.open) {
+- attributes.rr_open_mode = n2.matches("dialog:modal") ? "modal" : "non-modal";
++ try {
++ attributes.rr_open_mode = n2.matches("dialog:modal") ? "modal" : "non-modal";
++ } catch {
++ // likely this is safari not able to deal with the `:modal` selector
++ // we can't detect whether the dialog is modal or non-modal open, so have to guess
++ // hopefully this is only safari 15.4 and 15.5
++ attributes.rr_open_mode = "modal"
++ attributes.ph_rr_could_not_detect_modal = true
++ }
+ }
+ if (tagName === "canvas" && recordCanvas) {
+ if (n2.__context === "2d") {
+@@ -1116,7300 +1145,227 @@ function serializeNodeWithId(n2, options) {
keepIframeSrcFn
};
if (serializedNode.type === NodeType$2.Element && serializedNode.tagName === "textarea" && serializedNode.attributes.value !== void 0) ;
@@ -7564,7 +7599,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db43
class BaseRRNode {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
constructor(..._args) {
-@@ -8507,7 +1450,7 @@ function getUntaintedPrototype(key) {
+@@ -8507,7 +1463,7 @@ function getUntaintedPrototype(key) {
}
)
);
@@ -7573,7 +7608,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db43
untaintedBasePrototype[key] = defaultObj.prototype;
return defaultObj.prototype;
}
-@@ -11382,11 +4325,19 @@ class CanvasManager {
+@@ -11382,11 +4338,19 @@ class CanvasManager {
let rafId;
const getCanvas = () => {
const matchedCanvas = [];
@@ -7598,7 +7633,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db43
return matchedCanvas;
};
const takeCanvasSnapshots = (timestamp) => {
-@@ -11407,13 +4358,20 @@ class CanvasManager {
+@@ -11407,13 +4371,20 @@ class CanvasManager {
context.clear(context.COLOR_BUFFER_BIT);
}
}
diff --git a/playground/nextjs/pages/index.tsx b/playground/nextjs/pages/index.tsx
index f2a72d5fb..77f0f3ff0 100644
--- a/playground/nextjs/pages/index.tsx
+++ b/playground/nextjs/pages/index.tsx
@@ -2,7 +2,7 @@
import { useActiveFeatureFlags, usePostHog } from 'posthog-js/react'
import { useEffect, useState } from 'react'
import { cookieConsentGiven, PERSON_PROCESSING_MODE } from '@/src/posthog'
-import { setAllPersonProfilePropertiesAsPersonPropertiesForFlags } from 'posthog-js/lib/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags'
+import { setAllPersonProfilePropertiesAsPersonPropertiesForFlags } from 'posthog-js/lib/src/customizations'
import { STORED_PERSON_PROPERTIES_KEY } from '../../../src/constants'
export default function Home() {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c0a2b7098..fa8253331 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@rrweb/record@2.0.0-alpha.17':
- hash: vxikte2wlhu3ltbso5lzopaskq
+ hash: viz5vmxcmz5nggemb4cjgrd3iy
path: patches/@rrweb__record@2.0.0-alpha.17.patch
'@rrweb/rrweb-plugin-console-record@2.0.0-alpha.17':
hash: ytsspyi7p3hvqcq64vqb7wb6bu
@@ -74,7 +74,7 @@ devDependencies:
version: 12.1.1(rollup@4.24.0)(tslib@2.5.0)(typescript@5.5.4)
'@rrweb/record':
specifier: 2.0.0-alpha.17
- version: 2.0.0-alpha.17(patch_hash=vxikte2wlhu3ltbso5lzopaskq)
+ version: 2.0.0-alpha.17(patch_hash=viz5vmxcmz5nggemb4cjgrd3iy)
'@rrweb/rrweb-plugin-console-record':
specifier: 2.0.0-alpha.17
version: 2.0.0-alpha.17(patch_hash=ytsspyi7p3hvqcq64vqb7wb6bu)(rrweb@2.0.0-alpha.17)
@@ -2870,7 +2870,7 @@ packages:
dev: true
optional: true
- /@rrweb/record@2.0.0-alpha.17(patch_hash=vxikte2wlhu3ltbso5lzopaskq):
+ /@rrweb/record@2.0.0-alpha.17(patch_hash=viz5vmxcmz5nggemb4cjgrd3iy):
resolution: {integrity: sha512-Je+lzjeWMF8/I0IDoXFzkGPKT8j7AkaBup5YcwUHlkp18VhLVze416MvI6915teE27uUA2ScXMXzG0Yiu5VTIw==}
dependencies:
'@rrweb/types': 2.0.0-alpha.17
diff --git a/src/__tests__/decide.test.ts b/src/__tests__/decide.test.ts
index 534d64c65..026f6d0b5 100644
--- a/src/__tests__/decide.test.ts
+++ b/src/__tests__/decide.test.ts
@@ -1,7 +1,6 @@
import { Decide } from '../decide'
import { PostHogPersistence } from '../posthog-persistence'
import { RequestRouter } from '../utils/request-router'
-import { expectScriptToExist, expectScriptToNotExist } from './helpers/script-utils'
import { PostHog } from '../posthog-core'
import { DecideResponse, PostHogConfig, Properties } from '../types'
import '../entrypoints/external-scripts-loader'
@@ -246,35 +245,5 @@ describe('Decide', () => {
expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse)
expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled()
})
-
- it('runs site apps if opted in', () => {
- posthog.config = {
- api_host: 'https://test.com',
- opt_in_site_apps: true,
- persistence: 'memory',
- } as PostHogConfig
-
- subject({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] } as DecideResponse)
-
- expectScriptToExist('https://test.com/site_app/1/tokentoken/hash/')
- })
-
- it('does not run site apps code if not opted in', () => {
- ;(window as any).POSTHOG_DEBUG = true
- // don't technically need to run this but this test assumes opt_in_site_apps is false, let's make that explicit
- posthog.config = {
- api_host: 'https://test.com',
- opt_in_site_apps: false,
- persistence: 'memory',
- } as unknown as PostHogConfig
-
- subject({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] } as DecideResponse)
-
- expect(console.error).toHaveBeenCalledWith(
- '[PostHog.js]',
- 'PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.'
- )
- expectScriptToNotExist('https://test.com/site_app/1/tokentoken/hash/')
- })
})
})
diff --git a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts
index b206acd62..07a51086c 100644
--- a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts
+++ b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts
@@ -228,6 +228,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => {
$dead_click_selection_changed_timeout: true,
$ce_version: 1,
$el_text: 'text',
+ $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"',
$elements: [
{
$el_text: 'text',
@@ -270,6 +271,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => {
$dead_click_selection_changed_timeout: false,
$ce_version: 1,
$el_text: 'text',
+ $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"',
$elements: [
{
$el_text: 'text',
@@ -312,6 +314,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => {
$dead_click_selection_changed_timeout: false,
$ce_version: 1,
$el_text: 'text',
+ $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"',
$elements: [
{
$el_text: 'text',
@@ -354,6 +357,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => {
$dead_click_selection_changed_timeout: false,
$ce_version: 1,
$el_text: 'text',
+ $elements_chain: 'body:text="text"nth-child="2"nth-of-type="1"',
$elements: [
{
$el_text: 'text',
diff --git a/src/__tests__/extensions/replay/config.test.ts b/src/__tests__/extensions/replay/config.test.ts
index 9f89b5840..20b2faec3 100644
--- a/src/__tests__/extensions/replay/config.test.ts
+++ b/src/__tests__/extensions/replay/config.test.ts
@@ -75,34 +75,58 @@ describe('config', () => {
{
name: 'https://app.posthog.com/api/feature_flag/',
},
+ undefined,
],
[
{
- name: 'https://app.posthog.com/s/',
+ name: 'https://app.posthog.com/s/?ip=1&ver=123',
},
undefined,
+ undefined,
],
[
{
- name: 'https://app.posthog.com/e/',
+ name: 'https://app.posthog.com/e/?ip=1&ver=123',
},
undefined,
+ undefined,
],
[
{
- name: 'https://app.posthog.com/i/v0/e/',
+ name: 'https://app.posthog.com/i/v0/e/?ip=1&ver=123',
},
undefined,
+ undefined,
],
[
{
// even an imaginary future world of rust session replay capture
- name: 'https://app.posthog.com/i/v0/s/',
+ name: 'https://app.posthog.com/i/v0/s/?ip=1&ver=123',
},
undefined,
+ undefined,
],
- ])('ignores ingestion paths', (capturedRequest, expected) => {
- const networkOptions = buildNetworkRequestOptions(defaultConfig(), {})
+ [
+ {
+ // using a relative path as a reverse proxy api host
+ name: 'https://app.posthog.com/ingest/s/?ip=1&ver=123',
+ },
+ undefined,
+ '/ingest',
+ ],
+ [
+ {
+ // using a reverse proxy with a path
+ name: 'https://app.posthog.com/ingest/s/?ip=1&ver=123',
+ },
+ undefined,
+ 'https://app.posthog.com/ingest',
+ ],
+ ])('ignores ingestion paths', (capturedRequest, expected, apiHost?: string) => {
+ const networkOptions = buildNetworkRequestOptions(
+ { ...defaultConfig(), api_host: apiHost || 'https://us.posthog.com' },
+ {}
+ )
const x = networkOptions.maskRequestFn!(capturedRequest as CapturedNetworkRequest)
expect(x).toEqual(expected)
})
diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts
index 53199fbf8..a3bd307ec 100644
--- a/src/__tests__/extensions/replay/sessionrecording.test.ts
+++ b/src/__tests__/extensions/replay/sessionrecording.test.ts
@@ -235,7 +235,11 @@ describe('SessionRecording', () => {
const postHogPersistence = new PostHogPersistence(config)
postHogPersistence.clear()
- sessionManager = new SessionIdManager(config, postHogPersistence, sessionIdGeneratorMock, windowIdGeneratorMock)
+ sessionManager = new SessionIdManager(
+ { config, persistence: postHogPersistence, register: jest.fn() } as unknown as PostHog,
+ sessionIdGeneratorMock,
+ windowIdGeneratorMock
+ )
// add capture hook returns an unsubscribe function
removeCaptureHookMock = jest.fn()
@@ -1130,7 +1134,11 @@ describe('SessionRecording', () => {
let unsubscribeCallback: () => void
beforeEach(() => {
- sessionManager = new SessionIdManager(config, new PostHogPersistence(config))
+ sessionManager = new SessionIdManager({
+ config,
+ persistence: new PostHogPersistence(config),
+ register: jest.fn(),
+ } as unknown as PostHog)
posthog.sessionManager = sessionManager
mockCallback = jest.fn()
@@ -1216,7 +1224,11 @@ describe('SessionRecording', () => {
describe('with a real session id manager', () => {
beforeEach(() => {
- sessionManager = new SessionIdManager(config, new PostHogPersistence(config))
+ sessionManager = new SessionIdManager({
+ config,
+ persistence: new PostHogPersistence(config),
+ register: jest.fn(),
+ } as unknown as PostHog)
posthog.sessionManager = sessionManager
sessionRecording.startIfEnabledOrStop()
diff --git a/src/__tests__/personProcessing.test.ts b/src/__tests__/personProcessing.test.ts
index 5bb11e653..a5abee831 100644
--- a/src/__tests__/personProcessing.test.ts
+++ b/src/__tests__/personProcessing.test.ts
@@ -37,6 +37,7 @@ jest.mock('../utils/globals', () => {
const orig = jest.requireActual('../utils/globals')
const mockURLGetter = jest.fn()
const mockReferrerGetter = jest.fn()
+ let mockedCookieVal = ''
return {
...orig,
mockURLGetter,
@@ -50,6 +51,12 @@ jest.mock('../utils/globals', () => {
get URL() {
return mockURLGetter()
},
+ get cookie() {
+ return mockedCookieVal
+ },
+ set cookie(value: string) {
+ mockedCookieVal = value
+ },
},
get location() {
const url = mockURLGetter()
@@ -62,7 +69,7 @@ jest.mock('../utils/globals', () => {
})
// eslint-disable-next-line @typescript-eslint/no-require-imports
-const { mockURLGetter, mockReferrerGetter } = require('../utils/globals')
+const { mockURLGetter, mockReferrerGetter, document } = require('../utils/globals')
describe('person processing', () => {
const distinctId = '123'
@@ -70,6 +77,7 @@ describe('person processing', () => {
console.error = jest.fn()
mockReferrerGetter.mockReturnValue('https://referrer.com')
mockURLGetter.mockReturnValue('https://example.com?utm_source=foo')
+ document.cookie = ''
})
const setup = async (
@@ -268,6 +276,75 @@ describe('person processing', () => {
})
})
+ it('should preserve initial referrer info across subdomain', async () => {
+ const persistenceName = uuidv7()
+
+ // arrange
+ const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup(
+ 'identified_only',
+ undefined,
+ persistenceName
+ )
+ mockReferrerGetter.mockReturnValue('https://referrer1.com')
+ mockURLGetter.mockReturnValue('https://example1.com/pathname1?utm_source=foo1')
+
+ // act
+ // subdomain 1
+ posthog1.register({ testProp: 'foo' })
+ posthog1.capture('event s1')
+
+ // clear localstorage, but not cookies, to simulate changing subdomain
+ window.localStorage.clear()
+
+ // subdomain 2
+ const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup(
+ 'identified_only',
+ undefined,
+ persistenceName
+ )
+
+ mockReferrerGetter.mockReturnValue('https://referrer2.com')
+ mockURLGetter.mockReturnValue('https://example2.com/pathname2?utm_source=foo2')
+ posthog2.capture('event s2 before identify')
+ posthog2.identify(distinctId)
+ posthog2.capture('event s2 after identify')
+
+ // assert
+ const eventS1 = beforeSendMock1.mock.calls[0]
+ const eventS2Before = beforeSendMock2.mock.calls[0]
+ const eventS2Identify = beforeSendMock2.mock.calls[1]
+ const eventS2After = beforeSendMock2.mock.calls[2]
+
+ expect(eventS1[0].$set_once).toEqual(undefined)
+ expect(eventS1[0].properties.testProp).toEqual('foo')
+
+ expect(eventS2Before[0].$set_once).toEqual(undefined)
+ // most properties are lost across subdomain, that's intentional as we don't want to save too many things in cookies
+ expect(eventS2Before[0].properties.testProp).toEqual(undefined)
+
+ expect(eventS2Identify[0].event).toEqual('$identify')
+ expect(eventS2Identify[0].$set_once).toEqual({
+ ...INITIAL_CAMPAIGN_PARAMS_NULL,
+ $initial_current_url: 'https://example1.com/pathname1?utm_source=foo1',
+ $initial_host: 'example1.com',
+ $initial_pathname: '/pathname1',
+ $initial_referrer: 'https://referrer1.com',
+ $initial_referring_domain: 'referrer1.com',
+ $initial_utm_source: 'foo1',
+ })
+
+ expect(eventS2After[0].event).toEqual('event s2 after identify')
+ expect(eventS2After[0].$set_once).toEqual({
+ ...INITIAL_CAMPAIGN_PARAMS_NULL,
+ $initial_current_url: 'https://example1.com/pathname1?utm_source=foo1',
+ $initial_host: 'example1.com',
+ $initial_pathname: '/pathname1',
+ $initial_referrer: 'https://referrer1.com',
+ $initial_referring_domain: 'referrer1.com',
+ $initial_utm_source: 'foo1',
+ })
+ })
+
it('should include initial referrer info in identify event if always', async () => {
// arrange
const { posthog, beforeSendMock } = await setup('always')
diff --git a/src/__tests__/posthog-persistence.test.ts b/src/__tests__/posthog-persistence.test.ts
index 086246e24..98b02b357 100644
--- a/src/__tests__/posthog-persistence.test.ts
+++ b/src/__tests__/posthog-persistence.test.ts
@@ -1,6 +1,6 @@
///
import { PostHogPersistence } from '../posthog-persistence'
-import { SESSION_ID, USER_STATE } from '../constants'
+import { INITIAL_PERSON_INFO, SESSION_ID, USER_STATE } from '../constants'
import { PostHogConfig } from '../types'
import Mock = jest.Mock
import { PostHog } from '../posthog-core'
@@ -120,13 +120,13 @@ describe('persistence', () => {
it('should migrate data from cookies to localStorage', () => {
const lib = new PostHogPersistence(makePostHogConfig('bla', 'cookie'))
lib.register_once({ distinct_id: 'testy', test_prop: 'test_value' }, undefined, undefined)
- expect(document.cookie).toEqual(
+ expect(document.cookie).toContain(
'ph__posthog=%7B%22distinct_id%22%3A%22testy%22%2C%22test_prop%22%3A%22test_value%22%7D'
)
const lib2 = new PostHogPersistence(makePostHogConfig('bla', 'localStorage+cookie'))
- expect(document.cookie).toEqual('ph__posthog=%7B%22distinct_id%22%3A%22testy%22%7D')
+ expect(document.cookie).toContain('ph__posthog=%7B%22distinct_id%22%3A%22testy%22%7D')
lib2.register({ test_prop2: 'test_val', distinct_id: 'test2' })
- expect(document.cookie).toEqual('ph__posthog=%7B%22distinct_id%22%3A%22test2%22%7D')
+ expect(document.cookie).toContain('ph__posthog=%7B%22distinct_id%22%3A%22test2%22%7D')
expect(lib2.props).toEqual({ distinct_id: 'test2', test_prop: 'test_value', test_prop2: 'test_val' })
lib2.remove()
expect(localStorage.getItem('ph__posthog')).toEqual(null)
@@ -160,6 +160,15 @@ describe('persistence', () => {
})}`
)
+ lib.register({ [INITIAL_PERSON_INFO]: { u: 'https://www.example.com', r: 'https://www.referrer.com' } })
+ expect(document.cookie).toContain(
+ `ph__posthog=${encode({
+ distinct_id: 'test',
+ $sesid: [1000, 'sid', 2000],
+ $initial_person_info: { u: 'https://www.example.com', r: 'https://www.referrer.com' },
+ })}`
+ )
+
// Clear localstorage to simulate being on a different domain
localStorage.clear()
@@ -168,6 +177,7 @@ describe('persistence', () => {
expect(newLib.props).toEqual({
distinct_id: 'test',
$sesid: [1000, 'sid', 2000],
+ $initial_person_info: { u: 'https://www.example.com', r: 'https://www.referrer.com' },
})
})
diff --git a/src/__tests__/sessionid.test.ts b/src/__tests__/sessionid.test.ts
index ffcd92b8b..720e908af 100644
--- a/src/__tests__/sessionid.test.ts
+++ b/src/__tests__/sessionid.test.ts
@@ -1,10 +1,11 @@
-import { SessionIdManager } from '../sessionid'
+import { DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS, MAX_SESSION_IDLE_TIMEOUT_SECONDS, SessionIdManager } from '../sessionid'
import { SESSION_ID } from '../constants'
import { sessionStore } from '../storage'
import { uuid7ToTimestampMs, uuidv7 } from '../uuidv7'
import { BootstrapConfig, PostHogConfig, Properties } from '../types'
import { PostHogPersistence } from '../posthog-persistence'
import { assignableWindow } from '../utils/globals'
+import { PostHog } from '../posthog-core'
jest.mock('../uuidv7')
jest.mock('../storage')
@@ -13,14 +14,22 @@ describe('Session ID manager', () => {
let timestamp: number | undefined
let now: number
let timestampOfSessionStart: number
+ let registerMock: jest.Mock
+
const config: Partial = {
persistence_name: 'persistance-name',
}
let persistence: { props: Properties } & Partial
- const sessionIdMgr = (phPersistence: Partial) =>
- new SessionIdManager(config, phPersistence as PostHogPersistence)
+ const sessionIdMgr = (phPersistence: Partial) => {
+ registerMock = jest.fn()
+ return new SessionIdManager({
+ config,
+ persistence: phPersistence as PostHogPersistence,
+ register: registerMock,
+ } as unknown as PostHog)
+ }
const originalDate = Date
@@ -70,7 +79,11 @@ describe('Session ID manager', () => {
const bootstrap: BootstrapConfig = {
sessionID: bootstrapSessionId,
}
- const sessionIdManager = new SessionIdManager({ ...config, bootstrap }, persistence as PostHogPersistence)
+ const sessionIdManager = new SessionIdManager({
+ config: { ...config, bootstrap },
+ persistence: persistence as PostHogPersistence,
+ register: jest.fn(),
+ } as unknown as PostHog)
// act
const { sessionId, sessionStartTimestamp } = sessionIdManager.checkAndGetSessionAndWindowId(false, now)
@@ -79,6 +92,15 @@ describe('Session ID manager', () => {
expect(sessionId).toEqual(bootstrapSessionId)
expect(sessionStartTimestamp).toEqual(timestamp)
})
+
+ it('registers the session timeout as an event property', () => {
+ config.session_idle_timeout_seconds = 8 * 60 * 60
+ const sessionIdManager = sessionIdMgr(persistence)
+ sessionIdManager.checkAndGetSessionAndWindowId(undefined, timestamp)
+ expect(registerMock).toHaveBeenCalledWith({
+ $configured_session_timeout_ms: config.session_idle_timeout_seconds * 1000,
+ })
+ })
})
describe('stored session data', () => {
@@ -317,12 +339,13 @@ describe('Session ID manager', () => {
describe('custom session_idle_timeout_seconds', () => {
const mockSessionManager = (timeout: number | undefined) =>
- new SessionIdManager(
- {
+ new SessionIdManager({
+ config: {
session_idle_timeout_seconds: timeout,
},
- persistence as PostHogPersistence
- )
+ persistence: persistence as PostHogPersistence,
+ register: jest.fn(),
+ } as unknown as PostHog)
beforeEach(() => {
console.warn = jest.fn()
@@ -336,10 +359,14 @@ describe('Session ID manager', () => {
expect(console.warn).toBeCalledTimes(1)
expect(mockSessionManager(30 * 60 - 1)['_sessionTimeoutMs']).toEqual((30 * 60 - 1) * 1000)
expect(console.warn).toBeCalledTimes(1)
- expect(mockSessionManager(30 * 60 + 1)['_sessionTimeoutMs']).toEqual(30 * 60 * 1000)
+ expect(mockSessionManager(MAX_SESSION_IDLE_TIMEOUT_SECONDS + 1)['_sessionTimeoutMs']).toEqual(
+ MAX_SESSION_IDLE_TIMEOUT_SECONDS * 1000
+ )
expect(console.warn).toBeCalledTimes(2)
// @ts-expect-error - test invalid input
- expect(mockSessionManager('foobar')['_sessionTimeoutMs']).toEqual(30 * 60 * 1000)
+ expect(mockSessionManager('foobar')['_sessionTimeoutMs']).toEqual(
+ DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS * 1000
+ )
expect(console.warn).toBeCalledTimes(3)
})
})
diff --git a/src/__tests__/site-apps.ts b/src/__tests__/site-apps.ts
new file mode 100644
index 000000000..6c670eeb4
--- /dev/null
+++ b/src/__tests__/site-apps.ts
@@ -0,0 +1,336 @@
+import { SiteApps } from '../site-apps'
+import { PostHogPersistence } from '../posthog-persistence'
+import { RequestRouter } from '../utils/request-router'
+import { PostHog } from '../posthog-core'
+import { DecideResponse, PostHogConfig, Properties, CaptureResult } from '../types'
+import { assignableWindow } from '../utils/globals'
+import { logger } from '../utils/logger'
+import '../entrypoints/external-scripts-loader'
+import { isFunction } from '../utils/type-utils'
+
+jest.mock('../utils/logger', () => ({
+ logger: {
+ error: jest.fn(),
+ },
+}))
+
+describe('SiteApps', () => {
+ let posthog: PostHog
+ let siteAppsInstance: SiteApps
+
+ const defaultConfig: Partial = {
+ token: 'testtoken',
+ api_host: 'https://test.com',
+ persistence: 'memory',
+ }
+
+ beforeEach(() => {
+ // Clean the JSDOM to prevent interdependencies between tests
+ document.body.innerHTML = ''
+ document.head.innerHTML = ''
+ jest.spyOn(window.console, 'error').mockImplementation()
+
+ // Reset assignableWindow properties
+ assignableWindow.__PosthogExtensions__ = {
+ loadSiteApp: jest.fn().mockImplementation((_instance, _url, callback) => {
+ // Simulate async loading
+ setTimeout(() => {
+ const id = _url.split('/').pop()
+ if (isFunction(assignableWindow[`__$$ph_site_app_${id}_callback`])) {
+ assignableWindow[`__$$ph_site_app_${id}_callback`]()
+ }
+ callback()
+ }, 0)
+ }),
+ }
+
+ posthog = {
+ config: { ...defaultConfig },
+ persistence: new PostHogPersistence(defaultConfig as PostHogConfig),
+ register: (props: Properties) => posthog.persistence!.register(props),
+ unregister: (key: string) => posthog.persistence!.unregister(key),
+ get_property: (key: string) => posthog.persistence!.props[key],
+ capture: jest.fn(),
+ _addCaptureHook: jest.fn(),
+ _afterDecideResponse: jest.fn(),
+ get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'),
+ _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })),
+ featureFlags: {
+ receivedFeatureFlags: jest.fn(),
+ setReloadingPaused: jest.fn(),
+ _startReloadTimer: jest.fn(),
+ },
+ requestRouter: new RequestRouter({ config: defaultConfig } as unknown as PostHog),
+ _hasBootstrappedFeatureFlags: jest.fn(),
+ getGroups: () => ({ organization: '5' }),
+ } as unknown as PostHog
+
+ siteAppsInstance = new SiteApps(posthog)
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('constructor', () => {
+ it('sets enabled to true when opt_in_site_apps is true and advanced_disable_decide is false', () => {
+ posthog.config = {
+ ...defaultConfig,
+ opt_in_site_apps: true,
+ advanced_disable_decide: false,
+ } as PostHogConfig
+
+ siteAppsInstance = new SiteApps(posthog)
+
+ expect(siteAppsInstance.enabled).toBe(true)
+ })
+
+ it('sets enabled to false when opt_in_site_apps is false', () => {
+ posthog.config = {
+ ...defaultConfig,
+ opt_in_site_apps: false,
+ advanced_disable_decide: false,
+ } as PostHogConfig
+
+ siteAppsInstance = new SiteApps(posthog)
+
+ expect(siteAppsInstance.enabled).toBe(false)
+ })
+
+ it('sets enabled to false when advanced_disable_decide is true', () => {
+ posthog.config = {
+ ...defaultConfig,
+ opt_in_site_apps: true,
+ advanced_disable_decide: true,
+ } as PostHogConfig
+
+ siteAppsInstance = new SiteApps(posthog)
+
+ expect(siteAppsInstance.enabled).toBe(false)
+ })
+
+ it('initializes missedInvocations, loaded, appsLoading correctly', () => {
+ expect(siteAppsInstance.missedInvocations).toEqual([])
+ expect(siteAppsInstance.loaded).toBe(false)
+ expect(siteAppsInstance.appsLoading).toEqual(new Set())
+ })
+ })
+
+ describe('init', () => {
+ it('adds eventCollector as a capture hook', () => {
+ siteAppsInstance.init()
+
+ expect(posthog._addCaptureHook).toHaveBeenCalledWith(expect.any(Function))
+ })
+ })
+
+ describe('eventCollector', () => {
+ it('does nothing if enabled is false', () => {
+ siteAppsInstance.enabled = false
+ siteAppsInstance.eventCollector('event_name', {} as CaptureResult)
+
+ expect(siteAppsInstance.missedInvocations.length).toBe(0)
+ })
+
+ it('collects event if enabled and loaded is false', () => {
+ siteAppsInstance.enabled = true
+ siteAppsInstance.loaded = false
+
+ const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult
+
+ jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' })
+
+ siteAppsInstance.eventCollector('test_event', eventPayload)
+
+ expect(siteAppsInstance.globalsForEvent).toHaveBeenCalledWith(eventPayload)
+ expect(siteAppsInstance.missedInvocations).toEqual([{ some: 'globals' }])
+ })
+
+ it('trims missedInvocations to last 990 when exceeding 1000', () => {
+ siteAppsInstance.enabled = true
+ siteAppsInstance.loaded = false
+
+ siteAppsInstance.missedInvocations = new Array(1000).fill({})
+
+ const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult
+
+ jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' })
+
+ siteAppsInstance.eventCollector('test_event', eventPayload)
+
+ expect(siteAppsInstance.missedInvocations.length).toBe(991)
+ expect(siteAppsInstance.missedInvocations[0]).toEqual({})
+ expect(siteAppsInstance.missedInvocations[990]).toEqual({ some: 'globals' })
+ })
+ })
+
+ describe('globalsForEvent', () => {
+ it('throws an error if event is undefined', () => {
+ expect(() => siteAppsInstance.globalsForEvent(undefined as any)).toThrow('Event payload is required')
+ })
+
+ it('constructs globals object correctly', () => {
+ jest.spyOn(posthog, 'get_property').mockImplementation((key) => {
+ if (key === '$groups') {
+ return { groupType: 'groupId' }
+ } else if (key === '$stored_group_properties') {
+ return { groupType: { prop1: 'value1' } }
+ } else if (key === '$stored_person_properties') {
+ return { personProp: 'personValue' }
+ }
+ })
+
+ const eventPayload = {
+ uuid: 'test_uuid',
+ event: 'test_event',
+ properties: {
+ prop1: 'value1',
+ distinct_id: 'test_distinct_id',
+ $elements_chain: 'elements_chain_value',
+ },
+ $set: { setProp: 'setValue' },
+ $set_once: { setOnceProp: 'setOnceValue' },
+ } as CaptureResult
+
+ const globals = siteAppsInstance.globalsForEvent(eventPayload)
+
+ expect(globals).toEqual({
+ event: {
+ uuid: 'test_uuid',
+ event: 'test_event',
+ properties: {
+ $elements_chain: 'elements_chain_value',
+ prop1: 'value1',
+ distinct_id: 'test_distinct_id',
+ $set: { setProp: 'setValue' },
+ $set_once: { setOnceProp: 'setOnceValue' },
+ },
+ elements_chain: 'elements_chain_value',
+ distinct_id: 'test_distinct_id',
+ },
+ person: {
+ properties: { personProp: 'personValue' },
+ },
+ groups: {
+ groupType: {
+ id: 'groupId',
+ type: 'groupType',
+ properties: { prop1: 'value1' },
+ },
+ },
+ })
+ })
+ })
+
+ describe('afterDecideResponse', () => {
+ it('sets loaded to true and enabled to false when response is undefined', () => {
+ siteAppsInstance.afterDecideResponse(undefined)
+
+ expect(siteAppsInstance.loaded).toBe(true)
+ expect(siteAppsInstance.enabled).toBe(false)
+ })
+
+ it('loads site apps when enabled and opt_in_site_apps is true', (done) => {
+ posthog.config.opt_in_site_apps = true
+ siteAppsInstance.enabled = true
+ const response = {
+ siteApps: [
+ { id: '1', url: '/site_app/1' },
+ { id: '2', url: '/site_app/2' },
+ ],
+ } as DecideResponse
+
+ siteAppsInstance.afterDecideResponse(response)
+
+ expect(siteAppsInstance.appsLoading.size).toBe(2)
+ expect(siteAppsInstance.loaded).toBe(false)
+
+ // Wait for the simulated async loading to complete
+ setTimeout(() => {
+ expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledTimes(2)
+ expect(siteAppsInstance.appsLoading.size).toBe(0)
+ expect(siteAppsInstance.loaded).toBe(true)
+ done()
+ }, 10)
+ })
+
+ it('does not load site apps when enabled is false', () => {
+ siteAppsInstance.enabled = false
+ posthog.config.opt_in_site_apps = false
+ const response = {
+ siteApps: [{ id: '1', url: '/site_app/1' }],
+ } as DecideResponse
+
+ siteAppsInstance.afterDecideResponse(response)
+
+ expect(siteAppsInstance.loaded).toBe(true)
+ expect(siteAppsInstance.enabled).toBe(false)
+ expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).not.toHaveBeenCalled()
+ })
+
+ it('clears missedInvocations when all apps are loaded', (done) => {
+ posthog.config.opt_in_site_apps = true
+ siteAppsInstance.enabled = true
+ siteAppsInstance.missedInvocations = [{ some: 'data' }]
+ const response = {
+ siteApps: [{ id: '1', url: '/site_app/1' }],
+ } as DecideResponse
+
+ siteAppsInstance.afterDecideResponse(response)
+
+ // Wait for the simulated async loading to complete
+ setTimeout(() => {
+ expect(siteAppsInstance.loaded).toBe(true)
+ expect(siteAppsInstance.missedInvocations).toEqual([])
+ done()
+ }, 10)
+ })
+
+ it('sets assignableWindow properties for each site app', () => {
+ posthog.config.opt_in_site_apps = true
+ siteAppsInstance.enabled = true
+ const response = {
+ siteApps: [{ id: '1', url: '/site_app/1' }],
+ } as DecideResponse
+
+ siteAppsInstance.afterDecideResponse(response)
+
+ expect(assignableWindow['__$$ph_site_app_1_posthog']).toBe(posthog)
+ expect(typeof assignableWindow['__$$ph_site_app_1_missed_invocations']).toBe('function')
+ expect(typeof assignableWindow['__$$ph_site_app_1_callback']).toBe('function')
+ expect(assignableWindow.__PosthogExtensions__?.loadSiteApp).toHaveBeenCalledWith(
+ posthog,
+ '/site_app/1',
+ expect.any(Function)
+ )
+ })
+
+ it('logs error if site apps are disabled but response contains site apps', () => {
+ posthog.config.opt_in_site_apps = false
+ siteAppsInstance.enabled = false
+ const response = {
+ siteApps: [{ id: '1', url: '/site_app/1' }],
+ } as DecideResponse
+
+ siteAppsInstance.afterDecideResponse(response)
+
+ expect(logger.error).toHaveBeenCalledWith(
+ 'PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.'
+ )
+ expect(siteAppsInstance.loaded).toBe(true)
+ })
+
+ it('sets loaded to true if response.siteApps is empty', () => {
+ siteAppsInstance.enabled = true
+ posthog.config.opt_in_site_apps = true
+ const response = {
+ siteApps: [],
+ } as DecideResponse
+
+ siteAppsInstance.afterDecideResponse(response)
+
+ expect(siteAppsInstance.loaded).toBe(true)
+ expect(siteAppsInstance.enabled).toBe(false)
+ })
+ })
+})
diff --git a/src/__tests__/surveys.test.ts b/src/__tests__/surveys.test.ts
index defe94d2b..6a4fa6916 100644
--- a/src/__tests__/surveys.test.ts
+++ b/src/__tests__/surveys.test.ts
@@ -122,6 +122,44 @@ describe('surveys', () => {
} as unknown as Survey,
]
+ const surveysWithFeatureFlagKeys: Survey[] = [
+ {
+ name: 'survey with feature flags',
+ id: 'survey-with-flags',
+ description: 'survey with feature flags description',
+ type: SurveyType.Popover,
+ questions: [{ type: SurveyQuestionType.Open, question: 'what do you think?' }],
+ feature_flag_keys: [
+ { key: 'flag1', value: 'linked-flag-key' },
+ { key: 'flag2', value: 'survey-targeting-flag-key' },
+ ],
+ start_date: new Date().toISOString(),
+ end_date: null,
+ } as unknown as Survey,
+ {
+ name: 'survey with disabled feature flags',
+ id: 'survey-with-disabled-flags',
+ description: 'survey with disabled feature flags description',
+ type: SurveyType.Popover,
+ questions: [{ type: SurveyQuestionType.Open, question: 'why not?' }],
+ feature_flag_keys: [
+ { key: 'flag1', value: 'linked-flag-key2' },
+ { key: 'flag2', value: 'survey-targeting-flag-key2' },
+ ],
+ start_date: new Date().toISOString(),
+ end_date: null,
+ } as unknown as Survey,
+ {
+ name: 'survey without feature flags',
+ id: 'survey-without-flags',
+ description: 'survey without feature flags description',
+ type: SurveyType.Popover,
+ questions: [{ type: SurveyQuestionType.Open, question: 'any thoughts?' }],
+ start_date: new Date().toISOString(),
+ end_date: null,
+ } as unknown as Survey,
+ ]
+
beforeEach(() => {
surveysResponse = { surveys: firstSurveys }
@@ -652,6 +690,22 @@ describe('surveys', () => {
expect(data).toEqual([activeSurvey, surveyWithSelector, surveyWithEverything])
})
})
+
+ it('returns only surveys with enabled feature flags', () => {
+ surveysResponse = { surveys: surveysWithFeatureFlagKeys }
+
+ surveys.getActiveMatchingSurveys((data) => {
+ // Should include:
+ // - survey with enabled flags (survey-with-flags)
+ // - survey without flags (survey-without-flags)
+ // Should NOT include:
+ // - survey with disabled flags (survey-with-disabled-flags)
+ expect(data.length).toBe(2)
+ expect(data.map((s) => s.id)).toContain('survey-with-flags')
+ expect(data.map((s) => s.id)).toContain('survey-without-flags')
+ expect(data.map((s) => s.id)).not.toContain('survey-with-disabled-flags')
+ })
+ })
})
describe('shuffling questions', () => {
@@ -1172,4 +1226,63 @@ describe('surveys', () => {
)
})
})
+
+ describe('checkFlags', () => {
+ it('should return true when no feature flags are specified', () => {
+ const survey = { id: '123', questions: [] } as Survey
+ const result = surveys.checkFlags(survey)
+ expect(result).toBe(true)
+ })
+
+ it('should return true when all feature flags are enabled', () => {
+ const survey = {
+ id: '123',
+ questions: [],
+ feature_flag_keys: [
+ { key: 'flag1', value: 'flag-1' },
+ { key: 'flag2', value: 'flag-2' },
+ ],
+ } as Survey
+
+ jest.spyOn(instance.featureFlags, 'isFeatureEnabled').mockImplementation(() => true)
+
+ const result = surveys.checkFlags(survey)
+ expect(result).toBe(true)
+ })
+
+ it('should return false when any feature flag is disabled', () => {
+ const survey = {
+ id: '123',
+ questions: [],
+ feature_flag_keys: [
+ { key: 'flag1', value: 'flag-1' },
+ { key: 'flag2', value: 'flag-2' },
+ ],
+ } as Survey
+
+ jest.spyOn(instance.featureFlags, 'isFeatureEnabled').mockImplementation((flag) =>
+ flag === 'flag-1' ? true : false
+ )
+
+ const result = surveys.checkFlags(survey)
+ expect(result).toBe(false)
+ })
+
+ it('should ignore feature flags with missing key or value', () => {
+ const survey = {
+ id: '123',
+ questions: [],
+ feature_flag_keys: [
+ { key: '', value: 'flag-1' },
+ { key: 'flag2', value: '' },
+ { key: 'flag3', value: 'flag-3' },
+ ],
+ } as Survey
+
+ jest.spyOn(instance.featureFlags, 'isFeatureEnabled').mockImplementation(() => true)
+
+ const result = surveys.checkFlags(survey)
+ expect(result).toBe(true)
+ })
+ })
})
diff --git a/src/__tests__/utils/number-utils.test.ts b/src/__tests__/utils/number-utils.test.ts
index 306d8e64b..0c6c6cd20 100644
--- a/src/__tests__/utils/number-utils.test.ts
+++ b/src/__tests__/utils/number-utils.test.ts
@@ -10,14 +10,78 @@ jest.mock('../../utils/logger', () => ({
describe('number-utils', () => {
describe('clampToRange', () => {
it.each([
- // [value, result, min, max, expected result, test description]
- ['returns max when value is not a number', null, 10, 100, 100],
- ['returns max when value is not a number', 'not-a-number', 10, 100, 100],
- ['returns max when value is greater than max', 150, 10, 100, 100],
- ['returns min when value is less than min', 5, 10, 100, 10],
- ['returns the value when it is within the range', 50, 10, 100, 50],
- ])('%s', (_description, value, min, max, expected) => {
- const result = clampToRange(value, min, max, 'Test Label')
+ [
+ 'returns max when value is not a number',
+ {
+ value: null,
+ min: 10,
+ max: 100,
+ expected: 100,
+ fallback: undefined,
+ },
+ ],
+ [
+ 'returns max when value is not a number',
+ {
+ value: 'not-a-number',
+ min: 10,
+ max: 100,
+ expected: 100,
+ fallback: undefined,
+ },
+ ],
+ [
+ 'returns max when value is greater than max',
+ {
+ value: 150,
+ min: 10,
+ max: 100,
+ expected: 100,
+ fallback: undefined,
+ },
+ ],
+ [
+ 'returns min when value is less than min',
+ {
+ value: 5,
+ min: 10,
+ max: 100,
+ expected: 10,
+ fallback: undefined,
+ },
+ ],
+ [
+ 'returns the value when it is within the range',
+ {
+ value: 50,
+ min: 10,
+ max: 100,
+ expected: 50,
+ fallback: undefined,
+ },
+ ],
+ [
+ 'returns the fallback value when provided is not valid',
+ {
+ value: 'invalid',
+ min: 10,
+ max: 100,
+ expected: 20,
+ fallback: 20,
+ },
+ ],
+ [
+ 'returns the max value when fallback is not valid',
+ {
+ value: 'invalid',
+ min: 10,
+ max: 75,
+ expected: 75,
+ fallback: '20',
+ },
+ ],
+ ])('%s', (_description, { value, min, max, expected, fallback }) => {
+ const result = clampToRange(value, min, max, 'Test Label', fallback)
expect(result).toBe(expected)
})
diff --git a/src/autocapture.ts b/src/autocapture.ts
index cf9289e3a..a4cb4078f 100644
--- a/src/autocapture.ts
+++ b/src/autocapture.ts
@@ -213,13 +213,10 @@ export function autocapturePropertiesForElement(
const props = extend(
getDefaultProperties(e.type),
- elementsChainAsString
- ? {
- $elements_chain: getElementsChainString(elementsJson),
- }
- : {
- $elements: elementsJson,
- },
+ // Sending "$elements" is deprecated. Only one client on US cloud uses this.
+ !elementsChainAsString ? { $elements: elementsJson } : {},
+ // Always send $elements_chain, as it's needed downstream in site app filtering
+ { $elements_chain: getElementsChainString(elementsJson) },
elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {},
externalHref && e.type === 'click' ? { $external_click_url: externalHref } : {},
autocaptureAugmentProperties
diff --git a/src/customizations/index.ts b/src/customizations/index.ts
new file mode 100644
index 000000000..7cb66f40b
--- /dev/null
+++ b/src/customizations/index.ts
@@ -0,0 +1,2 @@
+export * from './setAllPersonProfilePropertiesAsPersonPropertiesForFlags'
+export * from './before-send'
diff --git a/src/decide.ts b/src/decide.ts
index 9ccb59819..88c7ab92d 100644
--- a/src/decide.ts
+++ b/src/decide.ts
@@ -3,7 +3,7 @@ import { Compression, DecideResponse } from './types'
import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './constants'
import { logger } from './utils/logger'
-import { document, assignableWindow } from './utils/globals'
+import { document } from './utils/globals'
export class Decide {
constructor(private readonly instance: PostHog) {
@@ -64,20 +64,5 @@ export class Decide {
}
this.instance._afterDecideResponse(response)
-
- if (response['siteApps']) {
- if (this.instance.config.opt_in_site_apps) {
- for (const { id, url } of response['siteApps']) {
- assignableWindow[`__$$ph_site_app_${id}`] = this.instance
- assignableWindow.__PosthogExtensions__?.loadSiteApp?.(this.instance, url, (err) => {
- if (err) {
- return logger.error(`Error while initializing PostHog app with config id ${id}`, err)
- }
- })
- }
- } else if (response['siteApps'].length > 0) {
- logger.error('PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.')
- }
- }
}
}
diff --git a/src/entrypoints/customizations.full.ts b/src/entrypoints/customizations.full.ts
new file mode 100644
index 000000000..eb17c13c0
--- /dev/null
+++ b/src/entrypoints/customizations.full.ts
@@ -0,0 +1,7 @@
+// this file is called customizations.full.ts because it includes all customizations
+// this naming scheme allows us to create a lighter version in the future with only the most popular customizations
+// without breaking backwards compatibility
+
+import * as customizations from '../customizations'
+import { assignableWindow } from '../utils/globals'
+assignableWindow.posthogCustomizations = customizations
diff --git a/src/extensions/exception-autocapture/stack-trace.ts b/src/extensions/exception-autocapture/stack-trace.ts
index 868ac2744..4c2e4a565 100644
--- a/src/extensions/exception-autocapture/stack-trace.ts
+++ b/src/extensions/exception-autocapture/stack-trace.ts
@@ -63,7 +63,7 @@ const GECKO_PRIORITY = 50
function createFrame(filename: string, func: string, lineno?: number, colno?: number): StackFrame {
const frame: StackFrame = {
- platform: 'javascript',
+ platform: 'web:javascript',
filename,
function: func === '' ? UNKNOWN_FUNCTION : func,
in_app: true, // All browser frames are considered in_app
diff --git a/src/extensions/replay/config.ts b/src/extensions/replay/config.ts
index 466a2d7b9..d4bc96c4f 100644
--- a/src/extensions/replay/config.ts
+++ b/src/extensions/replay/config.ts
@@ -96,9 +96,20 @@ const removeAuthorizationHeader = (data: CapturedNetworkRequest): CapturedNetwor
const POSTHOG_PATHS_TO_IGNORE = ['/s/', '/e/', '/i/']
// want to ignore posthog paths when capturing requests, or we can get trapped in a loop
// because calls to PostHog would be reported using a call to PostHog which would be reported....
-const ignorePostHogPaths = (data: CapturedNetworkRequest): CapturedNetworkRequest | undefined => {
+const ignorePostHogPaths = (
+ data: CapturedNetworkRequest,
+ apiHostConfig: PostHogConfig['api_host']
+): CapturedNetworkRequest | undefined => {
const url = convertToURL(data.name)
- if (url && url.pathname && POSTHOG_PATHS_TO_IGNORE.some((path) => url.pathname.indexOf(path) === 0)) {
+
+ // we need to account for api host config as e.g. pathname could be /ingest/s/ and we want to ignore that
+ let replaceValue = apiHostConfig.indexOf('http') === 0 ? convertToURL(apiHostConfig)?.pathname : apiHostConfig
+ if (replaceValue === '/') {
+ replaceValue = ''
+ }
+ const pathname = url?.pathname.replace(replaceValue || '', '')
+
+ if (url && pathname && POSTHOG_PATHS_TO_IGNORE.some((path) => pathname.indexOf(path) === 0)) {
return undefined
}
return data
@@ -211,7 +222,7 @@ export const buildNetworkRequestOptions = (
const payloadLimiter = limitPayloadSize(config)
const enforcedCleaningFn: NetworkRecordOptions['maskRequestFn'] = (d: CapturedNetworkRequest) =>
- payloadLimiter(ignorePostHogPaths(removeAuthorizationHeader(d)))
+ payloadLimiter(ignorePostHogPaths(removeAuthorizationHeader(d), instanceConfig.api_host))
const hasDeprecatedMaskFunction = isFunction(instanceConfig.session_recording.maskNetworkRequestFn)
diff --git a/src/extensions/replay/sessionrecording-utils.ts b/src/extensions/replay/sessionrecording-utils.ts
index 0122bae9d..02270bdf3 100644
--- a/src/extensions/replay/sessionrecording-utils.ts
+++ b/src/extensions/replay/sessionrecording-utils.ts
@@ -11,7 +11,7 @@ export function circularReferenceReplacer() {
if (isObject(value)) {
// `this` is the object that value is contained in,
// i.e., its direct parent.
- while (ancestors.length > 0 && ancestors.at(-1) !== this) {
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop()
}
if (ancestors.includes(value)) {
diff --git a/src/extensions/sentry-integration.ts b/src/extensions/sentry-integration.ts
index 940655dd0..2e14db7d1 100644
--- a/src/extensions/sentry-integration.ts
+++ b/src/extensions/sentry-integration.ts
@@ -14,6 +14,7 @@
* @param {string} [organization] Optional: The Sentry organization, used to send a direct link from PostHog to Sentry
* @param {Number} [projectId] Optional: The Sentry project id, used to send a direct link from PostHog to Sentry
* @param {string} [prefix] Optional: Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
+ * @param {SeverityLevel[] | '*'} [severityAllowList] Optional: send events matching the provided levels. Use '*' to send all events (default: ['error'])
*/
import { PostHog } from '../posthog-core'
@@ -38,14 +39,14 @@ type _SentryException = any
type _SentryEventProcessor = any
type _SentryHub = any
-interface _SentryIntegrationClass {
+interface _SentryIntegration {
name: string
- setupOnce(addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, getCurrentHub: () => _SentryHub): void
+ processEvent(event: _SentryEvent): _SentryEvent
}
-interface _SentryIntegration {
+interface _SentryIntegrationClass {
name: string
- processEvent(event: _SentryEvent): _SentryEvent
+ setupOnce(addGlobalEventProcessor: (callback: _SentryEventProcessor) => void, getCurrentHub: () => _SentryHub): void
}
interface SentryExceptionProperties {
@@ -61,13 +62,6 @@ export type SentryIntegrationOptions = {
organization?: string
projectId?: number
prefix?: string
- /**
- * By default, only errors are sent to PostHog. You can set this to '*' to send all events.
- * Or to an error of SeverityLevel to only send events matching the provided levels.
- * e.g. ['error', 'fatal'] to send only errors and fatals
- * e.g. ['error'] to send only errors -- the default when omitted
- * e.g. '*' to send all events
- */
severityAllowList?: SeverityLevel[] | '*'
}
@@ -107,7 +101,6 @@ export function createEventProcessor(
$exception_list: any
$exception_personURL: string
$exception_level: SeverityLevel
- $level: SeverityLevel
} = {
// PostHog Exception Properties,
$exception_message: exceptions[0]?.value || event.message,
@@ -121,7 +114,6 @@ export function createEventProcessor(
$sentry_exception_message: exceptions[0]?.value || event.message,
$sentry_exception_type: exceptions[0]?.type,
$sentry_tags: event.tags,
- $level: event.level,
}
if (organization && projectId) {
@@ -164,13 +156,6 @@ export class SentryIntegration implements _SentryIntegrationClass {
organization?: string,
projectId?: number,
prefix?: string,
- /**
- * By default, only errors are sent to PostHog. You can set this to '*' to send all events.
- * Or to an error of SeverityLevel to only send events matching the provided levels.
- * e.g. ['error', 'fatal'] to send only errors and fatals
- * e.g. ['error'] to send only errors -- the default when omitted
- * e.g. '*' to send all events
- */
severityAllowList?: SeverityLevel[] | '*'
) {
// setupOnce gets called by Sentry when it intializes the plugin
diff --git a/src/posthog-core.ts b/src/posthog-core.ts
index db362d2af..6269f8f7f 100644
--- a/src/posthog-core.ts
+++ b/src/posthog-core.ts
@@ -80,6 +80,7 @@ import { ExceptionObserver } from './extensions/exception-autocapture'
import { WebVitalsAutocapture } from './extensions/web-vitals'
import { WebExperiments } from './web-experiments'
import { PostHogExceptions } from './posthog-exceptions'
+import { SiteApps } from './site-apps'
import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture'
/*
@@ -258,6 +259,7 @@ export class PostHog {
sessionManager?: SessionIdManager
sessionPropsManager?: SessionPropsManager
requestRouter: RequestRouter
+ siteApps?: SiteApps
autocapture?: Autocapture
heatmaps?: Heatmaps
webVitalsAutocapture?: WebVitalsAutocapture
@@ -427,11 +429,14 @@ export class PostHog {
this._retryQueue = new RetryQueue(this)
this.__request_queue = []
- this.sessionManager = new SessionIdManager(this.config, this.persistence)
+ this.sessionManager = new SessionIdManager(this)
this.sessionPropsManager = new SessionPropsManager(this.sessionManager, this.persistence)
new TracingHeaders(this).startIfEnabledOrStop()
+ this.siteApps = new SiteApps(this)
+ this.siteApps?.init()
+
this.sessionRecording = new SessionRecording(this)
this.sessionRecording.startIfEnabledOrStop()
@@ -562,6 +567,7 @@ export class PostHog {
: 'always',
})
+ this.siteApps?.afterDecideResponse(response)
this.sessionRecording?.afterDecideResponse(response)
this.autocapture?.afterDecideResponse(response)
this.heatmaps?.afterDecideResponse(response)
diff --git a/src/posthog-persistence.ts b/src/posthog-persistence.ts
index 131c85ed6..0a5546c00 100644
--- a/src/posthog-persistence.ts
+++ b/src/posthog-persistence.ts
@@ -224,7 +224,7 @@ export class PostHogPersistence {
const campaignParams = Info.campaignParams(this.config.custom_campaign_params)
// only save campaign params if there were any
if (!isEmptyObject(stripEmptyProperties(campaignParams))) {
- this.register(Info.campaignParams(this.config.custom_campaign_params))
+ this.register(campaignParams)
}
this.campaign_params_saved = true
}
diff --git a/src/posthog-surveys-types.ts b/src/posthog-surveys-types.ts
index 4c815732a..8313093b1 100644
--- a/src/posthog-surveys-types.ts
+++ b/src/posthog-surveys-types.ts
@@ -149,6 +149,12 @@ export interface Survey {
name: string
description: string
type: SurveyType
+ feature_flag_keys:
+ | {
+ key: string
+ value?: string
+ }[]
+ | null
linked_flag_key: string | null
targeting_flag_key: string | null
internal_targeting_flag_key: string | null
diff --git a/src/posthog-surveys.ts b/src/posthog-surveys.ts
index f7c90952b..4b25ef9bb 100644
--- a/src/posthog-surveys.ts
+++ b/src/posthog-surveys.ts
@@ -172,7 +172,12 @@ export class PostHogSurveys {
// get all the surveys that have been activated so far with user actions.
const activatedSurveys: string[] | undefined = this._surveyEventReceiver?.getSurveys()
const targetingMatchedSurveys = conditionMatchedSurveys.filter((survey) => {
- if (!survey.linked_flag_key && !survey.targeting_flag_key && !survey.internal_targeting_flag_key) {
+ if (
+ !survey.linked_flag_key &&
+ !survey.targeting_flag_key &&
+ !survey.internal_targeting_flag_key &&
+ !survey.feature_flag_keys?.length
+ ) {
return true
}
const linkedFlagCheck = survey.linked_flag_key
@@ -199,9 +204,13 @@ export class PostHogSurveys {
survey.internal_targeting_flag_key && !overrideInternalTargetingFlagCheck
? this.instance.featureFlags.isFeatureEnabled(survey.internal_targeting_flag_key)
: true
-
+ const flagsCheck = this.checkFlags(survey)
return (
- linkedFlagCheck && targetingFlagCheck && internalTargetingFlagCheck && eventBasedTargetingFlagCheck
+ linkedFlagCheck &&
+ targetingFlagCheck &&
+ internalTargetingFlagCheck &&
+ eventBasedTargetingFlagCheck &&
+ flagsCheck
)
})
@@ -209,6 +218,18 @@ export class PostHogSurveys {
}, forceReload)
}
+ checkFlags(survey: Survey): boolean {
+ if (!survey.feature_flag_keys?.length) {
+ return true
+ }
+
+ return survey.feature_flag_keys.every(({ key, value }) => {
+ if (!key || !value) {
+ return true
+ }
+ return this.instance.featureFlags.isFeatureEnabled(value)
+ })
+ }
getNextSurveyStep(survey: Survey, currentQuestionIndex: number, response: string | string[] | number | null) {
const question = survey.questions[currentQuestionIndex]
const nextQuestionIndex = currentQuestionIndex + 1
diff --git a/src/sessionid.ts b/src/sessionid.ts
index 9c65d83eb..eecccbccc 100644
--- a/src/sessionid.ts
+++ b/src/sessionid.ts
@@ -9,10 +9,12 @@ import { isArray, isNumber, isUndefined } from './utils/type-utils'
import { logger } from './utils/logger'
import { clampToRange } from './utils/number-utils'
+import { PostHog } from './posthog-core'
-const MAX_SESSION_IDLE_TIMEOUT = 30 * 60 // 30 minutes
-const MIN_SESSION_IDLE_TIMEOUT = 60 // 1 minute
-const SESSION_LENGTH_LIMIT = 24 * 3600 * 1000 // 24 hours
+export const DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS = 30 * 60 // 30 minutes
+export const MAX_SESSION_IDLE_TIMEOUT_SECONDS = 10 * 60 * 60 // 10 hours
+const MIN_SESSION_IDLE_TIMEOUT_SECONDS = 60 // 1 minute
+const SESSION_LENGTH_LIMIT_MILLISECONDS = 24 * 3600 * 1000 // 24 hours
export class SessionIdManager {
private readonly _sessionIdGenerator: () => string
@@ -32,14 +34,13 @@ export class SessionIdManager {
// we track activity so we can end the session proactively when it has passed the idle timeout
private _enforceIdleTimeout: ReturnType | undefined
- constructor(
- config: Partial,
- persistence: PostHogPersistence,
- sessionIdGenerator?: () => string,
- windowIdGenerator?: () => string
- ) {
- this.config = config
- this.persistence = persistence
+ constructor(instance: PostHog, sessionIdGenerator?: () => string, windowIdGenerator?: () => string) {
+ if (!instance.persistence) {
+ throw new Error('SessionIdManager requires a PostHogPersistence instance')
+ }
+
+ this.config = instance.config
+ this.persistence = instance.persistence
this._windowId = undefined
this._sessionId = undefined
this._sessionStartTimestamp = null
@@ -47,17 +48,20 @@ export class SessionIdManager {
this._sessionIdGenerator = sessionIdGenerator || uuidv7
this._windowIdGenerator = windowIdGenerator || uuidv7
- const persistenceName = config['persistence_name'] || config['token']
+ const persistenceName = this.config['persistence_name'] || this.config['token']
- const desiredTimeout = config['session_idle_timeout_seconds'] || MAX_SESSION_IDLE_TIMEOUT
+ const desiredTimeout = this.config['session_idle_timeout_seconds'] || DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS
this._sessionTimeoutMs =
clampToRange(
desiredTimeout,
- MIN_SESSION_IDLE_TIMEOUT,
- MAX_SESSION_IDLE_TIMEOUT,
- 'session_idle_timeout_seconds'
+ MIN_SESSION_IDLE_TIMEOUT_SECONDS,
+ MAX_SESSION_IDLE_TIMEOUT_SECONDS,
+ 'session_idle_timeout_seconds',
+ DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS
) * 1000
+ instance.register({ $configured_session_timeout_ms: this._sessionTimeoutMs })
+
this._window_id_storage_key = 'ph_' + persistenceName + '_window_id'
this._primary_window_exists_storage_key = 'ph_' + persistenceName + '_primary_window_exists'
@@ -221,7 +225,7 @@ export class SessionIdManager {
const sessionPastMaximumLength =
isNumber(startTimestamp) &&
startTimestamp > 0 &&
- Math.abs(timestamp - startTimestamp) > SESSION_LENGTH_LIMIT
+ Math.abs(timestamp - startTimestamp) > SESSION_LENGTH_LIMIT_MILLISECONDS
let valuesChanged = false
const noSessionId = !sessionId
diff --git a/src/site-apps.ts b/src/site-apps.ts
new file mode 100644
index 000000000..73414ace1
--- /dev/null
+++ b/src/site-apps.ts
@@ -0,0 +1,117 @@
+import { PostHog } from './posthog-core'
+import { CaptureResult, DecideResponse } from './types'
+import { assignableWindow } from './utils/globals'
+import { logger } from './utils/logger'
+import { isArray } from './utils/type-utils'
+
+export class SiteApps {
+ instance: PostHog
+ enabled: boolean
+ missedInvocations: Record[]
+ loaded: boolean
+ appsLoading: Set
+
+ constructor(instance: PostHog) {
+ this.instance = instance
+ // can't use if site apps are disabled, or if we're not asking /decide for site apps
+ this.enabled = !!this.instance.config.opt_in_site_apps && !this.instance.config.advanced_disable_decide
+ // events captured between loading posthog-js and the site app; up to 1000 events
+ this.missedInvocations = []
+ // capture events until loaded
+ this.loaded = false
+ this.appsLoading = new Set()
+ }
+
+ eventCollector(_eventName: string, eventPayload?: CaptureResult | undefined) {
+ if (!this.enabled) {
+ return
+ }
+ if (!this.loaded && eventPayload) {
+ const globals = this.globalsForEvent(eventPayload)
+ this.missedInvocations.push(globals)
+ if (this.missedInvocations.length > 1000) {
+ this.missedInvocations = this.missedInvocations.slice(10)
+ }
+ }
+ }
+
+ init() {
+ this.instance?._addCaptureHook(this.eventCollector.bind(this))
+ }
+
+ globalsForEvent(event: CaptureResult): Record {
+ if (!event) {
+ throw new Error('Event payload is required')
+ }
+ const groups: Record> = {}
+ const groupIds = this.instance.get_property('$groups') || []
+ const groupProperties = this.instance.get_property('$stored_group_properties') || {}
+ for (const [type, properties] of Object.entries(groupProperties)) {
+ groups[type] = { id: groupIds[type], type, properties }
+ }
+ const { $set_once, $set, ..._event } = event
+ const globals = {
+ event: {
+ ..._event,
+ properties: {
+ ...event.properties,
+ ...($set ? { $set: { ...(event.properties?.$set ?? {}), ...$set } } : {}),
+ ...($set_once ? { $set_once: { ...(event.properties?.$set_once ?? {}), ...$set_once } } : {}),
+ },
+ elements_chain: event.properties?.['$elements_chain'] ?? '',
+ // TODO:
+ // - elements_chain_href: '',
+ // - elements_chain_texts: [] as string[],
+ // - elements_chain_ids: [] as string[],
+ // - elements_chain_elements: [] as string[],
+ distinct_id: event.properties?.['distinct_id'],
+ },
+ person: {
+ properties: this.instance.get_property('$stored_person_properties'),
+ },
+ groups,
+ }
+ return globals
+ }
+
+ afterDecideResponse(response?: DecideResponse): void {
+ if (isArray(response?.siteApps) && response.siteApps.length > 0) {
+ if (this.enabled && this.instance.config.opt_in_site_apps) {
+ const checkIfAllLoaded = () => {
+ // Stop collecting events once all site apps are loaded
+ if (this.appsLoading.size === 0) {
+ this.loaded = true
+ this.missedInvocations = []
+ }
+ }
+ for (const { id, url } of response['siteApps']) {
+ // TODO: if we have opted out and "type" is "site_destination", ignore it... but do include "site_app" types
+ this.appsLoading.add(id)
+ assignableWindow[`__$$ph_site_app_${id}_posthog`] = this.instance
+ assignableWindow[`__$$ph_site_app_${id}_missed_invocations`] = () => this.missedInvocations
+ assignableWindow[`__$$ph_site_app_${id}_callback`] = () => {
+ this.appsLoading.delete(id)
+ checkIfAllLoaded()
+ }
+ assignableWindow.__PosthogExtensions__?.loadSiteApp?.(this.instance, url, (err) => {
+ if (err) {
+ this.appsLoading.delete(id)
+ checkIfAllLoaded()
+ return logger.error(`Error while initializing PostHog app with config id ${id}`, err)
+ }
+ })
+ }
+ } else if (response['siteApps'].length > 0) {
+ logger.error('PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.')
+ this.loaded = true
+ } else {
+ this.loaded = true
+ }
+ } else {
+ this.loaded = true
+ this.enabled = false
+ }
+ }
+
+ // TODO: opting out of stuff should disable this
+}
diff --git a/src/storage.ts b/src/storage.ts
index 9f67f19f3..d9d2c9632 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -1,6 +1,12 @@
import { extend } from './utils'
import { PersistentStore, Properties } from './types'
-import { DISTINCT_ID, ENABLE_PERSON_PROCESSING, SESSION_ID, SESSION_RECORDING_IS_SAMPLED } from './constants'
+import {
+ DISTINCT_ID,
+ ENABLE_PERSON_PROCESSING,
+ INITIAL_PERSON_INFO,
+ SESSION_ID,
+ SESSION_RECORDING_IS_SAMPLED,
+} from './constants'
import { isNull, isUndefined } from './utils/type-utils'
import { logger } from './utils/logger'
@@ -248,7 +254,13 @@ export const localStore: PersistentStore = {
// Use localstorage for most data but still use cookie for COOKIE_PERSISTED_PROPERTIES
// This solves issues with cookies having too much data in them causing headers too large
// Also makes sure we don't have to send a ton of data to the server
-const COOKIE_PERSISTED_PROPERTIES = [DISTINCT_ID, SESSION_ID, SESSION_RECORDING_IS_SAMPLED, ENABLE_PERSON_PROCESSING]
+const COOKIE_PERSISTED_PROPERTIES = [
+ DISTINCT_ID,
+ SESSION_ID,
+ SESSION_RECORDING_IS_SAMPLED,
+ ENABLE_PERSON_PROCESSING,
+ INITIAL_PERSON_INFO,
+]
export const localPlusCookieStore: PersistentStore = {
...localStore,
diff --git a/src/types.ts b/src/types.ts
index d7994968a..3044e10df 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -523,7 +523,7 @@ export interface DecideResponse {
editorParams?: ToolbarParams /** @deprecated, renamed to toolbarParams, still present on older API responses */
toolbarVersion: 'toolbar' /** @deprecated, moved to toolbarParams */
isAuthenticated: boolean
- siteApps: { id: number; url: string }[]
+ siteApps: { id: string; url: string }[]
heatmaps?: boolean
defaultIdentifiedOnly?: boolean
captureDeadClicks?: boolean
diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts
index 120b3f5ba..ff72bd017 100644
--- a/src/utils/event-utils.ts
+++ b/src/utils/event-utils.ts
@@ -164,8 +164,8 @@ export const Info = {
initialPersonInfo: function (): Record {
// we're being a bit more economical with bytes here because this is stored in the cookie
return {
- r: this.referrer(),
- u: location?.href,
+ r: this.referrer().substring(0, 1000),
+ u: location?.href.substring(0, 1000),
}
},
diff --git a/src/utils/number-utils.ts b/src/utils/number-utils.ts
index 7c59b67d0..a1c1dafb7 100644
--- a/src/utils/number-utils.ts
+++ b/src/utils/number-utils.ts
@@ -1,15 +1,26 @@
import { isNumber } from './type-utils'
import { logger } from './logger'
-export function clampToRange(value: unknown, min: number, max: number, label?: string): number {
+/**
+ * Clamps a value to a range.
+ * @param value the value to clamp
+ * @param min the minimum value
+ * @param max the maximum value
+ * @param label if provided then enables logging and prefixes all logs with labels
+ * @param fallbackValue if provided then returns this value if the value is not a valid number
+ */
+export function clampToRange(value: unknown, min: number, max: number, label?: string, fallbackValue?: number): number {
if (min > max) {
logger.warn('min cannot be greater than max.')
min = max
}
if (!isNumber(value)) {
- label && logger.warn(label + ' must be a number. Defaulting to max value:' + max)
- return max
+ label &&
+ logger.warn(
+ label + ' must be a number. using max or fallback. max: ' + max + ', fallback: ' + fallbackValue
+ )
+ return clampToRange(fallbackValue || max, min, max, label)
} else if (value > max) {
label && logger.warn(label + ' cannot be greater than max: ' + max + '. Using max value instead.')
return max