From d1ad80be164057408f9f848cc6843ef4abcb170a Mon Sep 17 00:00:00 2001 From: Robbie Date: Wed, 19 Jun 2024 10:06:41 +0100 Subject: [PATCH] feat: Allow bootstrapping session id (#1251) * Allow bootstrapping session id * Add test for bootstrapping session id --- src/__tests__/sessionid.ts | 21 +++++++++++++++++++-- src/__tests__/test-uuid.test.ts | 20 ++++++++++++++++++-- src/sessionid.ts | 11 ++++++++++- src/types.ts | 8 ++++++++ src/uuidv7.ts | 14 ++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/__tests__/sessionid.ts b/src/__tests__/sessionid.ts index 077679494..34e336424 100644 --- a/src/__tests__/sessionid.ts +++ b/src/__tests__/sessionid.ts @@ -1,8 +1,8 @@ import { SessionIdManager } from '../sessionid' import { SESSION_ID } from '../constants' import { sessionStore } from '../storage' -import { uuidv7 } from '../uuidv7' -import { PostHogConfig, Properties } from '../types' +import { uuidv7, uuid7ToTimestampMs } from '../uuidv7' +import { BootstrapConfig, PostHogConfig, Properties } from '../types' import { PostHogPersistence } from '../posthog-persistence' import { assignableWindow } from '../utils/globals' @@ -38,6 +38,7 @@ describe('Session ID manager', () => { ;(sessionStore.is_supported as jest.Mock).mockReturnValue(true) jest.spyOn(global, 'Date').mockImplementation(() => new originalDate(now)) ;(uuidv7 as jest.Mock).mockReturnValue('newUUID') + ;(uuid7ToTimestampMs as jest.Mock).mockReturnValue(timestamp) }) describe('new session id manager', () => { @@ -64,6 +65,22 @@ describe('Session ID manager', () => { }) expect(sessionStore.set).toHaveBeenCalledWith('ph_persistance-name_window_id', 'newUUID') }) + + it('should allow bootstrapping of the session id', () => { + // arrange + const bootstrapSessionId = 'bootstrap-session-id' + const bootstrap: BootstrapConfig = { + sessionID: bootstrapSessionId, + } + const sessionIdManager = new SessionIdManager({ ...config, bootstrap }, persistence as PostHogPersistence) + + // act + const { sessionId, sessionStartTimestamp } = sessionIdManager.checkAndGetSessionAndWindowId(false, now) + + // assert + expect(sessionId).toEqual(bootstrapSessionId) + expect(sessionStartTimestamp).toEqual(timestamp) + }) }) describe('stored session data', () => { diff --git a/src/__tests__/test-uuid.test.ts b/src/__tests__/test-uuid.test.ts index fc884460c..da798e197 100644 --- a/src/__tests__/test-uuid.test.ts +++ b/src/__tests__/test-uuid.test.ts @@ -1,8 +1,24 @@ -import { uuidv7 } from '../uuidv7' - +import { uuid7ToTimestampMs, uuidv7 } from '../uuidv7' +const TEN_SECONDS = 10_000 describe('uuid', () => { it('should be a uuid when requested', () => { expect(uuidv7()).toHaveLength(36) expect(uuidv7()).not.toEqual(uuidv7()) }) + describe('uuid7ToTimestampMs', () => { + it('should convert a UUIDv7 generated with uuidv7() to a timestamp', () => { + const uuid = uuidv7() + const timestamp = uuid7ToTimestampMs(uuid) + const now = Date.now() + expect(typeof timestamp).toBe('number') + expect(timestamp).toBeLessThan(now + TEN_SECONDS) + expect(timestamp).toBeGreaterThan(now - TEN_SECONDS) + }) + it('should convert a known UUIDv7 to a known timestamp', () => { + const uuid = '01902c33-4925-7f20-818a-4095f9251383' + const timestamp = uuid7ToTimestampMs(uuid) + const expected = new Date('Tue, 18 Jun 2024 16:34:36.965 GMT').getTime() + expect(timestamp).toBe(expected) + }) + }) }) diff --git a/src/sessionid.ts b/src/sessionid.ts index d3e1772d7..dbc4586d4 100644 --- a/src/sessionid.ts +++ b/src/sessionid.ts @@ -2,7 +2,7 @@ import { PostHogPersistence } from './posthog-persistence' import { SESSION_ID } from './constants' import { sessionStore } from './storage' import { PostHogConfig, SessionIdChangedCallback } from './types' -import { uuidv7 } from './uuidv7' +import { uuid7ToTimestampMs, uuidv7 } from './uuidv7' import { window } from './utils/globals' import { isArray, isNumber, isUndefined } from './utils/type-utils' @@ -76,6 +76,15 @@ export class SessionIdManager { sessionStore.set(this._primary_window_exists_storage_key, true) } + if (this.config.bootstrap?.sessionID) { + try { + const sessionStartTimestamp = uuid7ToTimestampMs(this.config.bootstrap.sessionID) + this._setSessionId(this.config.bootstrap.sessionID, new Date().getTime(), sessionStartTimestamp) + } catch (e) { + logger.error('Invalid sessionID in bootstrap', e) + } + } + this._listenToReloadWindow() } diff --git a/src/types.ts b/src/types.ts index 4d736687b..4be1ae3aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,14 @@ export interface BootstrapConfig { isIdentifiedID?: boolean featureFlags?: Record featureFlagPayloads?: Record + /** + * Optionally provide a sessionID, this is so that you can provide an existing sessionID here to continue a user's session across a domain or device. It MUST be: + * - unique to this user + * - a valid UUID v7 + * - the timestamp part must be <= the timestamp of the first event in the session + * - the timestamp of the last event in the session must be < the timestamp part + 24 hours + * **/ + sessionID?: string } export interface PostHogConfig { diff --git a/src/uuidv7.ts b/src/uuidv7.ts index 0c6bf0785..7bcaf6c2c 100644 --- a/src/uuidv7.ts +++ b/src/uuidv7.ts @@ -252,3 +252,17 @@ export const uuidv7 = (): string => uuidv7obj().toString() /** Generates a UUIDv7 object. */ const uuidv7obj = (): UUID => (defaultGenerator || (defaultGenerator = new V7Generator())).generate() + +export const uuid7ToTimestampMs = (uuid: string): number => { + // remove hyphens + const hex = uuid.replace(/-/g, '') + // ensure that it's a version 7 UUID + if (hex.length !== 32) { + throw new Error('Not a valid UUID') + } + if (hex[12] !== '7') { + throw new Error('Not a UUIDv7') + } + // the first 6 bytes are the timestamp, which means that we can read only the first 12 hex characters + return parseInt(hex.substring(0, 12), 16) +}