diff --git a/package.json b/package.json index 2d8088eee..16b4a0457 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,6 @@ "fflate": "^0.4.1" }, "devDependencies": { - "eslint-config-posthog-js": "link:./eslint-rules", - "eslint-plugin-posthog-js": "link:./eslint-rules", "@babel/core": "7.18.9", "@babel/preset-env": "7.18.9", "@babel/preset-typescript": "^7.18.6", @@ -57,9 +55,11 @@ "babel-jest": "^26.6.3", "cypress": "10.3.1", "eslint": "8.20.0", + "eslint-config-posthog-js": "link:./eslint-rules", "eslint-config-prettier": "^8.5.0", "eslint-plugin-compat": "^4.1.4", "eslint-plugin-jest": "^27.2.3", + "eslint-plugin-posthog-js": "link:./eslint-rules", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.30.1", "eslint-plugin-react-hooks": "^4.6.0", @@ -74,7 +74,7 @@ "localStorage": "1.0.4", "msw": "^1.2.1", "node-fetch": "^2.6.1", - "posthog-js": "link:.", + "posthog-js": "file:.yalc/posthog-js", "prettier": "^2.7.1", "rollup": "^2.77.0", "rollup-plugin-dts": "^4.2.2", diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 0faedc7d7..91241fc48 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": "file:.yalc/posthog-js", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "4.9.5" + } } diff --git a/src/__tests__/sessionid.js b/src/__tests__/sessionid.js index 54bcd0496..fcfacb6af 100644 --- a/src/__tests__/sessionid.js +++ b/src/__tests__/sessionid.js @@ -38,9 +38,21 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'newUUID', sessionStartTimestamp: given.timestamp, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'newUUID', given.timestamp], + [SESSION_ID]: [ + given.timestamp, + 'newUUID', + given.timestamp, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) @@ -51,9 +63,21 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'newUUID', sessionStartTimestamp: given.timestamp, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'newUUID', given.timestamp], + [SESSION_ID]: [ + given.timestamp, + 'newUUID', + given.timestamp, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) @@ -72,9 +96,21 @@ describe('Session ID manager', () => { windowId: 'oldWindowID', sessionId: 'oldSessionID', sessionStartTimestamp: given.timestampOfSessionStart, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'oldSessionID', given.timestampOfSessionStart], + [SESSION_ID]: [ + given.timestamp, + 'oldSessionID', + given.timestampOfSessionStart, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) }) @@ -90,9 +126,21 @@ describe('Session ID manager', () => { windowId: 'oldWindowID', sessionId: 'oldSessionID', sessionStartTimestamp: sessionStart, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [oldTimestamp, 'oldSessionID', sessionStart], + [SESSION_ID]: [ + oldTimestamp, + 'oldSessionID', + sessionStart, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) }) @@ -102,9 +150,21 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'oldSessionID', sessionStartTimestamp: given.timestampOfSessionStart, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'oldSessionID', given.timestampOfSessionStart], + [SESSION_ID]: [ + given.timestamp, + 'oldSessionID', + given.timestampOfSessionStart, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) @@ -117,9 +177,21 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'newUUID', sessionStartTimestamp: given.timestamp, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'newUUID', given.timestamp], + [SESSION_ID]: [ + given.timestamp, + 'newUUID', + given.timestamp, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) @@ -134,10 +206,22 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'newUUID', sessionStartTimestamp: given.timestamp, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'newUUID', given.timestamp], + [SESSION_ID]: [ + given.timestamp, + 'newUUID', + given.timestamp, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) @@ -153,10 +237,22 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'newUUID', sessionStartTimestamp: given.timestamp, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'newUUID', given.timestamp], + [SESSION_ID]: [ + given.timestamp, + 'newUUID', + given.timestamp, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) @@ -170,9 +266,21 @@ describe('Session ID manager', () => { windowId: 'newUUID', sessionId: 'newUUID', sessionStartTimestamp: now, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.now, 'newUUID', given.now], + [SESSION_ID]: [ + given.now, + 'newUUID', + given.now, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) }) @@ -182,9 +290,21 @@ describe('Session ID manager', () => { windowId: 'oldWindowID', sessionId: 'oldSessionID', sessionStartTimestamp: given.timestamp, + sessionSourceParams: { + initialPathName: '/', + referringDomain: '$direct', + }, }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [given.timestamp, 'oldSessionID', given.timestamp], + [SESSION_ID]: [ + given.timestamp, + 'oldSessionID', + given.timestamp, + { + initialPathName: '/', + referringDomain: '$direct', + }, + ], }) }) }) @@ -211,22 +331,41 @@ describe('Session ID manager', () => { describe('session id storage', () => { it('stores and retrieves a session id and timestamp', () => { - given.sessionIdManager._setSessionId('newSessionId', 1603107460000, 1603107460000) + given.sessionIdManager._setSessionId('newSessionId', 1603107460000, 1603107460000, { + initialPathName: '/some/path/name', + referringDomain: 'referring.example.com', + }) expect(given.persistence.register).toHaveBeenCalledWith({ - [SESSION_ID]: [1603107460000, 'newSessionId', 1603107460000], + [SESSION_ID]: [ + 1603107460000, + 'newSessionId', + 1603107460000, + { + initialPathName: '/some/path/name', + referringDomain: 'referring.example.com', + }, + ], }) - expect(given.sessionIdManager._getSessionId()).toEqual([1603107460000, 'newSessionId', 1603107460000]) + expect(given.sessionIdManager._getSessionId()).toEqual([ + 1603107460000, + 'newSessionId', + 1603107460000, + { + initialPathName: '/some/path/name', + referringDomain: 'referring.example.com', + }, + ]) }) }) describe('reset session id', () => { it('clears the existing session id', () => { given.sessionIdManager.resetSessionId() - expect(given.persistence.register).toHaveBeenCalledWith({ [SESSION_ID]: [null, null, null] }) + expect(given.persistence.register).toHaveBeenCalledWith({ [SESSION_ID]: [null, null, null, null] }) }) it('a new session id is generated when called', () => { - given('storedSessionIdData', () => [null, null, null]) - expect(given.sessionIdManager._getSessionId()).toEqual([null, null, null]) + given('storedSessionIdData', () => [null, null, null, null]) + expect(given.sessionIdManager._getSessionId()).toEqual([null, null, null, null]) expect(given.subject).toMatchObject({ windowId: 'newUUID', sessionId: 'newUUID', diff --git a/src/posthog-core.ts b/src/posthog-core.ts index db65306ae..5ca66c8b0 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -929,9 +929,21 @@ export class PostHog { const infoProperties = _info.properties() if (this.sessionManager) { - const { sessionId, windowId } = this.sessionManager.checkAndGetSessionAndWindowId() + const { sessionId, windowId, sessionSourceParams } = this.sessionManager.checkAndGetSessionAndWindowId() properties['$session_id'] = sessionId properties['$window_id'] = windowId + if ( + sessionSourceParams && + (event_name === '$pageview' || event_name === '$pageleave' || event_name === '$autocapture') + ) { + properties['$client_session_referring_host'] = sessionSourceParams.referringDomain + properties['$client_session_initial_pathname'] = sessionSourceParams.initialPathName + properties['$client_session_utm_source'] = sessionSourceParams.utmSource + properties['$client_session_utm_campaign'] = sessionSourceParams.utmCampaign + properties['$client_session_utm_medium'] = sessionSourceParams.utmMedium + properties['$client_session_utm_content'] = sessionSourceParams.utmContent + properties['$client_session_utm_term'] = sessionSourceParams.utmTerm + } } if (this.config.__preview_measure_pageview_stats) { diff --git a/src/sessionid.ts b/src/sessionid.ts index fca582a16..9fdec3e99 100644 --- a/src/sessionid.ts +++ b/src/sessionid.ts @@ -7,14 +7,32 @@ import { window } from './utils/globals' import { _isArray, _isNumber, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' +import { _info } from './utils/event-utils' 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 +/* 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. + * + * These have the same lifespan as a session_id + */ +interface SessionSourceParams { + initialPathName: string + referringDomain?: string // Is actually host, but named domain for internal consistency. Should contain a port if there is one. + utmMedium?: string + utmSource?: string + utmCampaign?: string + utmContent?: string + utmTerm?: string +} + export class SessionIdManager { private readonly _sessionIdGenerator: () => string private readonly _windowIdGenerator: () => string + private readonly _sessionSourceParamGenerator: () => SessionSourceParams private config: Partial private persistence: PostHogPersistence private _windowId: string | null | undefined @@ -22,6 +40,7 @@ export class SessionIdManager { private readonly _window_id_storage_key: string private readonly _primary_window_exists_storage_key: string private _sessionStartTimestamp: number | null + private _sessionSourceParams: SessionSourceParams | null | undefined private _sessionActivityTimestamp: number | null private readonly _sessionTimeoutMs: number @@ -31,7 +50,8 @@ export class SessionIdManager { config: Partial, persistence: PostHogPersistence, sessionIdGenerator?: () => string, - windowIdGenerator?: () => string + windowIdGenerator?: () => string, + sessionSourceParamGenerator?: () => SessionSourceParams ) { this.config = config this.persistence = persistence @@ -39,8 +59,10 @@ export class SessionIdManager { this._sessionId = undefined this._sessionStartTimestamp = null this._sessionActivityTimestamp = null + this._sessionSourceParams = null this._sessionIdGenerator = sessionIdGenerator || uuidv7 this._windowIdGenerator = windowIdGenerator || uuidv7 + this._sessionSourceParamGenerator = sessionSourceParamGenerator || generateSessionSourceParams const persistenceName = config['persistence_name'] || config['token'] let desiredTimeout = config['session_idle_timeout_seconds'] || MAX_SESSION_IDLE_TIMEOUT @@ -129,41 +151,60 @@ export class SessionIdManager { private _setSessionId( sessionId: string | null, sessionActivityTimestamp: number | null, - sessionStartTimestamp: number | null + sessionStartTimestamp: number | null, + sessionSourceParams: SessionSourceParams | null ): void { if ( sessionId !== this._sessionId || sessionActivityTimestamp !== this._sessionActivityTimestamp || - sessionStartTimestamp !== this._sessionStartTimestamp + sessionStartTimestamp !== this._sessionStartTimestamp || + sessionSourceParams !== this._sessionSourceParams ) { this._sessionStartTimestamp = sessionStartTimestamp this._sessionActivityTimestamp = sessionActivityTimestamp this._sessionId = sessionId + this._sessionSourceParams = sessionSourceParams this.persistence.register({ - [SESSION_ID]: [sessionActivityTimestamp, sessionId, sessionStartTimestamp], + [SESSION_ID]: [sessionActivityTimestamp, sessionId, sessionStartTimestamp, sessionSourceParams], }) } } - private _getSessionId(): [number, string, number] { - if (this._sessionId && this._sessionActivityTimestamp && this._sessionStartTimestamp) { - return [this._sessionActivityTimestamp, this._sessionId, this._sessionStartTimestamp] + private _getSessionId(): [number, string, number, SessionSourceParams] { + if ( + this._sessionId && + this._sessionActivityTimestamp && + this._sessionStartTimestamp && + this._sessionSourceParams + ) { + return [ + this._sessionActivityTimestamp, + this._sessionId, + this._sessionStartTimestamp, + this._sessionSourceParams, + ] } const sessionId = this.persistence.props[SESSION_ID] - if (_isArray(sessionId) && sessionId.length === 2) { - // Storage does not yet have a session start time. Add the last activity timestamp as the start time - sessionId.push(sessionId[0]) + if (_isArray(sessionId)) { + if (sessionId.length === 2) { + // Storage does not yet have a session start time. Add the last activity timestamp as the start time + sessionId.push(sessionId[0]) + } + if (sessionId.length === 3) { + // Storage does not yet have a session source params, use the generator function + sessionId.push(generateSessionSourceParams()) + } } - return sessionId || [0, null, 0] + return sessionId || [0, null, 0, {}] } // Resets the session id by setting it to null. On the subsequent call to checkAndGetSessionAndWindowId, // new ids will be generated. resetSessionId(): void { - this._setSessionId(null, null, null) + this._setSessionId(null, null, null, null) } /* @@ -200,7 +241,7 @@ export class SessionIdManager { const timestamp = _timestamp || new Date().getTime() // eslint-disable-next-line prefer-const - let [lastTimestamp, sessionId, startTimestamp] = this._getSessionId() + let [lastTimestamp, sessionId, startTimestamp, sessionSourceParams] = this._getSessionId() let windowId = this._getWindowId() const sessionPastMaximumLength = @@ -213,6 +254,7 @@ export class SessionIdManager { sessionId = this._sessionIdGenerator() windowId = this._windowIdGenerator() startTimestamp = timestamp + sessionSourceParams = this._sessionSourceParamGenerator() valuesChanged = true } else if (!windowId) { windowId = this._windowIdGenerator() @@ -223,7 +265,7 @@ export class SessionIdManager { const sessionStartTimestamp = startTimestamp === 0 ? new Date().getTime() : startTimestamp this._setWindowId(windowId) - this._setSessionId(sessionId, newTimestamp, sessionStartTimestamp) + this._setSessionId(sessionId, newTimestamp, sessionStartTimestamp, sessionSourceParams) if (valuesChanged) { this._sessionIdChangedHandlers.forEach((handler) => handler(sessionId, windowId)) @@ -233,6 +275,23 @@ export class SessionIdManager { sessionId, windowId, sessionStartTimestamp, + sessionSourceParams, } } } + +const generateSessionSourceParams = (): SessionSourceParams => { + const params: SessionSourceParams = { + initialPathName: window.location.pathname, + referringDomain: _info.referringDomain(), + } + if (typeof URLSearchParams !== 'undefined') { + const search = new URLSearchParams(window.location.search) + params.utmSource = search.get('utm_source') ?? undefined + params.utmCampaign = search.get('utm_campaign') ?? undefined + params.utmMedium = search.get('utm_medium') ?? undefined + params.utmTerm = search.get('utm_term') ?? undefined + params.utmContent = search.get('utm_content') ?? undefined + } + return params +}