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