From 4213ffae62087a83f0f7d9abe83a7ff484aae481 Mon Sep 17 00:00:00 2001 From: Robbie Date: Thu, 2 Nov 2023 09:45:54 +0000 Subject: [PATCH] feat(web-analytics): Add client-side session params (#869) --- playground/nextjs/package.json | 46 +++++------ playground/nextjs/pages/_app.tsx | 1 + playground/nextjs/yarn.lock | 8 +- src/__tests__/session-props.test.ts | 120 ++++++++++++++++++++++++++++ src/constants.ts | 4 +- src/posthog-core.ts | 12 +++ src/session-props.ts | 88 ++++++++++++++++++++ src/types.ts | 1 + 8 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/session-props.test.ts create mode 100644 src/session-props.ts diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 0faedc7d7..9ad4aeead 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -1,25 +1,25 @@ { - "name": "nextjs", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@lottiefiles/react-lottie-player": "^3.5.3", - "@next/font": "13.1.6", - "@types/node": "18.13.0", - "@types/react": "18.0.28", - "@types/react-dom": "18.0.10", - "eslint": "8.34.0", - "eslint-config-next": "13.1.6", - "next": "13.1.6", - "posthog-js": "^1.45.1", - "react": "18.2.0", - "react-dom": "18.2.0", - "typescript": "4.9.5" - } + "name": "nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@lottiefiles/react-lottie-player": "^3.5.3", + "@next/font": "13.1.6", + "@types/node": "18.13.0", + "@types/react": "18.0.28", + "@types/react-dom": "18.0.10", + "eslint": "8.34.0", + "eslint-config-next": "13.1.6", + "next": "13.1.6", + "posthog-js": "^1.87.5", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "4.9.5" + } } diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx index 32764f904..7f0601849 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -14,6 +14,7 @@ if (typeof window !== 'undefined') { recordCrossOriginIframes: true, }, debug: true, + __preview_send_client_session_params: true, }) ;(window as any).posthog = posthog } diff --git a/playground/nextjs/yarn.lock b/playground/nextjs/yarn.lock index fbcf80bf6..72ec20f59 100644 --- a/playground/nextjs/yarn.lock +++ b/playground/nextjs/yarn.lock @@ -1770,10 +1770,10 @@ postcss@8.4.14: picocolors "^1.0.0" source-map-js "^1.0.2" -posthog-js@^1.45.1: - version "1.73.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.73.1.tgz#8b994e595ab8e847bc9b55707c9cd0b22f655352" - integrity sha512-eN08SkMdOG14TbUWek/7it4nCwcclK4MklCemq4/JK+rdsVqCWJXnYG2EdUOMoLv8wZOZ0yKIdc0vLI/ctrT/w== +posthog-js@^1.87.5: + version "1.87.5" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.87.5.tgz#a279fc2016984008f2378a451bbbe33d9c89b857" + integrity sha512-GvSOX9oA1iPPaZSwFkuA333PDDo9yCKj4yqFYjxf/dU2mBIOuIMjdPLTiCvoVmsf2UL/2/9c7AwlnFAG4iRZuQ== dependencies: fflate "^0.4.1" diff --git a/src/__tests__/session-props.test.ts b/src/__tests__/session-props.test.ts new file mode 100644 index 000000000..7876fc5af --- /dev/null +++ b/src/__tests__/session-props.test.ts @@ -0,0 +1,120 @@ +import { SessionPropsManager } from '../session-props' +import { SessionIdManager } from '../sessionid' +import { PostHogPersistence } from '../posthog-persistence' + +describe('Session Props Manager', () => { + const createSessionPropsManager = () => { + const onSessionId = jest.fn() + const generateProps = jest.fn() + const persistenceRegister = jest.fn() + const sessionIdManager = { + onSessionId, + } as unknown as SessionIdManager + const persistence = { + register: persistenceRegister, + props: {}, + } as unknown as PostHogPersistence + const sessionPropsManager = new SessionPropsManager(sessionIdManager, persistence, generateProps) + + return { + onSessionId, + sessionPropsManager, + persistence, + sessionIdManager, + generateProps, + persistenceRegister, + } + } + + it('should register a callback with the session id manager', () => { + const { onSessionId } = createSessionPropsManager() + expect(onSessionId).toHaveBeenCalledTimes(1) + }) + + it('should update persistence with client session props', () => { + // arrange + const utmSource = 'some-utm-source' + const sessionId = 'session-id' + const { onSessionId, generateProps, persistenceRegister } = createSessionPropsManager() + generateProps.mockReturnValue({ utm_source: utmSource }) + const callback = onSessionId.mock.calls[0][0] + + // act + callback(sessionId) + + //assert + expect(generateProps).toHaveBeenCalledTimes(1) + + expect(persistenceRegister).toBeCalledWith({ + $client_session_props: { + props: { + utm_source: 'some-utm-source', + }, + sessionId: 'session-id', + }, + }) + }) + + it('should not update client session props when session id stays the same', () => { + // arrange + const sessionId1 = 'session-id-1' + const { onSessionId, persistence, generateProps, persistenceRegister } = createSessionPropsManager() + persistence.props = { + $client_session_props: { + props: {}, + sessionId: sessionId1, + }, + } + const callback = onSessionId.mock.calls[0][0] + + // act + callback(sessionId1) + + //assert + expect(generateProps).toHaveBeenCalledTimes(0) + expect(persistenceRegister).toHaveBeenCalledTimes(0) + }) + + it('should update client session props when session id changes', () => { + // arrange + const sessionId1 = 'session-id-1' + const sessionId2 = 'session-id-2' + + const { onSessionId, persistence, generateProps, persistenceRegister } = createSessionPropsManager() + persistence.props = { + $client_session_props: { + props: {}, + sessionId: sessionId1, + }, + } + const callback = onSessionId.mock.calls[0][0] + + // act + callback(sessionId2) + + //assert + expect(generateProps).toHaveBeenCalledTimes(1) + expect(persistenceRegister).toHaveBeenCalledTimes(1) + }) + + it('should return client session props', () => { + // arrange + const { persistence, sessionPropsManager } = createSessionPropsManager() + persistence.props = { + $client_session_props: { + props: { + utm_source: 'some-utm-source', + }, + sessionId: 'session-id', + }, + } + + // act + const properties = sessionPropsManager.getSessionProps() + + //assert + expect(properties).toEqual({ + $client_session_utm_source: 'some-utm-source', + }) + }) +}) diff --git a/src/constants.ts b/src/constants.ts index 3bc4ea96d..ec9cbddc9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -24,8 +24,9 @@ export const SURVEYS = '$surveys' export const FLAG_CALL_REPORTED = '$flag_call_reported' export const USER_STATE = '$user_state' export const POSTHOG_QUOTA_LIMITED = '$posthog_quota_limited' +export const CLIENT_SESSION_PROPS = '$client_session_props' -// These are propertties that are reserved and will not be automatically included in events +// These are properties that are reserved and will not be automatically included in events export const PERSISTENCE_RESERVED_PROPERTIES = [ PEOPLE_DISTINCT_ID_KEY, ALIAS_ID_KEY, @@ -41,4 +42,5 @@ export const PERSISTENCE_RESERVED_PROPERTIES = [ STORED_PERSON_PROPERTIES_KEY, SURVEYS, FLAG_CALL_REPORTED, + CLIENT_SESSION_PROPS, ] diff --git a/src/posthog-core.ts b/src/posthog-core.ts index db65306ae..5f45bb463 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -57,6 +57,7 @@ import { _isArray, _isEmptyObject, _isFunction, _isObject, _isString, _isUndefin import { _info } from './utils/event-utils' import { logger } from './utils/logger' import { document, userAgent } from './utils/globals' +import { SessionPropsManager } from './session-props' /* SIMPLE STYLE GUIDE: @@ -271,6 +272,7 @@ export class PostHog { persistence?: PostHogPersistence sessionPersistence?: PostHogPersistence sessionManager?: SessionIdManager + sessionPropsManager?: SessionPropsManager _requestQueue?: RequestQueue _retryQueue?: RetryQueue @@ -437,6 +439,7 @@ export class PostHog { this.__request_queue = [] this.sessionManager = new SessionIdManager(this.config, this.persistence) + this.sessionPropsManager = new SessionPropsManager(this.sessionManager, this.persistence) this.sessionPersistence = this.config.persistence === 'sessionStorage' ? this.persistence @@ -934,6 +937,15 @@ export class PostHog { properties['$window_id'] = windowId } + if ( + this.sessionPropsManager && + this.config.__preview_send_client_session_params && + (event_name === '$pageview' || event_name === '$pageleave' || event_name === '$autocapture') + ) { + const sessionProps = this.sessionPropsManager.getSessionProps() + properties = _extend(properties, sessionProps) + } + if (this.config.__preview_measure_pageview_stats) { let performanceProperties: Record = {} if (event_name === '$pageview') { diff --git a/src/session-props.ts b/src/session-props.ts new file mode 100644 index 000000000..b84453f4c --- /dev/null +++ b/src/session-props.ts @@ -0,0 +1,88 @@ +/* Client-side session parameters. These are primarily used by web analytics, + * which relies on these for session analytics without the plugin server being + * available for the person level set-once properties. Obviously not consistent + * between client-side events and server-side events but this is acceptable + * as web analytics only uses client-side. + * + * These have the same lifespan as a session_id + */ +import { window } from './utils/globals' +import { _info } from './utils/event-utils' +import { SessionIdManager } from './sessionid' +import { PostHogPersistence } from './posthog-persistence' +import { CLIENT_SESSION_PROPS } from './constants' + +interface SessionSourceProps { + initialPathName: string + referringDomain: string // Is actually host, but named domain for internal consistency. Should contain a port if there is one. + utm_medium?: string + utm_source?: string + utm_campaign?: string + utm_content?: string + utm_term?: string +} + +interface StoredSessionSourceProps { + sessionId: string + props: SessionSourceProps +} + +export const generateSessionSourceParams = (): SessionSourceProps => { + return { + initialPathName: window.location.pathname, + referringDomain: _info.referringDomain(), + ..._info.campaignParams(), + } +} + +export class SessionPropsManager { + private readonly _sessionIdManager: SessionIdManager + private readonly _persistence: PostHogPersistence + private readonly _sessionSourceParamGenerator: typeof generateSessionSourceParams + + constructor( + sessionIdManager: SessionIdManager, + persistence: PostHogPersistence, + sessionSourceParamGenerator?: typeof generateSessionSourceParams + ) { + this._sessionIdManager = sessionIdManager + this._persistence = persistence + this._sessionSourceParamGenerator = sessionSourceParamGenerator || generateSessionSourceParams + + this._sessionIdManager.onSessionId(this._onSessionIdCallback) + } + + _getStoredProps(): StoredSessionSourceProps | undefined { + return this._persistence.props[CLIENT_SESSION_PROPS] + } + + _onSessionIdCallback = (sessionId: string) => { + const stored = this._getStoredProps() + if (stored && stored.sessionId === sessionId) { + return + } + + const newProps: StoredSessionSourceProps = { + sessionId, + props: this._sessionSourceParamGenerator(), + } + this._persistence.register({ [CLIENT_SESSION_PROPS]: newProps }) + } + + getSessionProps() { + const p = this._getStoredProps()?.props + if (!p) { + return {} + } + + return { + $client_session_referring_host: p.referringDomain, + $client_session_initial_pathname: p.initialPathName, + $client_session_utm_source: p.utm_source, + $client_session_utm_campaign: p.utm_campaign, + $client_session_utm_medium: p.utm_medium, + $client_session_utm_content: p.utm_content, + $client_session_utm_term: p.utm_term, + } + } +} diff --git a/src/types.ts b/src/types.ts index 06b57b55b..ed607a8ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,7 @@ export interface PostHogConfig { } segment?: any __preview_measure_pageview_stats?: boolean + __preview_send_client_session_params?: boolean } export interface OptInOutCapturingOptions {