diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 9f2d195b3..f40a3ad69 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -22,7 +22,7 @@ "eslint-config-next": "13.1.6", "hls.js": "^1.5.15", "next": "13.5.6", - "posthog-js": "1.166.0", + "posthog-js": "1.194.6", "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 3d74c2fe6..8c16f2c3b 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -42,7 +42,7 @@ export default function App({ Component, pageProps }: AppProps) { {/* CSP - useful for testing our documented recommendations. NOTE: Unsafe is only needed for nextjs pre-loading */} = 0.6'} dev: false + /core-js@3.39.0: + resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==} + requiresBuild: true + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2312,9 +2317,10 @@ packages: source-map-js: 1.2.1 dev: true - /posthog-js@1.166.0: - resolution: {integrity: sha512-om7rhbgP3OzJDJ3wdrp6cuKVWC+7iiFeVv+g8uaZPLI6zvzuaNdq4a3s0ltnfBpROlZrWw4oJknoVwj7I3hVTQ==} + /posthog-js@1.194.6: + resolution: {integrity: sha512-5g5n7FjWLha/QWVTeWeMErGff21v4/V3wYCZ2z8vAbHaCyHkaDBEbuM756jMFBQMsq3HJcDX9mlxi2HhAHxq2A==} dependencies: + core-js: 3.39.0 fflate: 0.4.8 preact: 10.24.1 web-vitals: 4.2.3 diff --git a/src/__tests__/cookieless.test.ts b/src/__tests__/cookieless.test.ts new file mode 100644 index 000000000..da1f55ba2 --- /dev/null +++ b/src/__tests__/cookieless.test.ts @@ -0,0 +1,88 @@ +import { defaultPostHog } from './helpers/posthog-instance' +import type { PostHogConfig } from '../types' +import { uuidv7 } from '../uuidv7' + +describe('cookieless', () => { + const eventName = 'custom_event' + const eventProperties = { + event: 'prop', + } + const identifiedDistinctId = 'user-1' + const setup = (config: Partial = {}, token: string = uuidv7()) => { + const beforeSendMock = jest.fn().mockImplementation((e) => e) + const posthog = defaultPostHog().init(token, { ...config, before_send: beforeSendMock }, token)! + posthog.debug() + return { posthog, beforeSendMock } + } + + it('should send events with the sentinel distinct id', () => { + const { posthog, beforeSendMock } = setup({ + persistence: 'memory', + __preview_experimental_cookieless_mode: true, + }) + + posthog.capture(eventName, eventProperties) + expect(beforeSendMock).toBeCalledTimes(1) + let event = beforeSendMock.mock.calls[0][0] + expect(event.properties.distinct_id).toBe('$posthog_cklsh') + expect(event.properties.$anon_distinct_id).toBe(undefined) + expect(event.properties.$device_id).toBe(null) + expect(event.properties.$session_id).toBe(undefined) + expect(event.properties.$window_id).toBe(undefined) + expect(event.properties.$cklsh_mode).toEqual(true) + expect(document.cookie).toBe('') + + // simulate user giving cookie consent + posthog.set_config({ persistence: 'localStorage+cookie' }) + + // send an event after consent + posthog.capture(eventName, eventProperties) + expect(beforeSendMock).toBeCalledTimes(2) + event = beforeSendMock.mock.calls[1][0] + expect(event.properties.distinct_id).toBe('$posthog_cklsh') + expect(event.properties.$anon_distinct_id).toBe(undefined) + expect(event.properties.$device_id).toBe(null) + expect(event.properties.$session_id).toBe(undefined) + expect(event.properties.$window_id).toBe(undefined) + expect(event.properties.$cklsh_mode).toEqual(true) + expect(document.cookie).not.toBe('') + + // a user identifying + posthog.identify(identifiedDistinctId) + expect(beforeSendMock).toBeCalledTimes(3) + event = beforeSendMock.mock.calls[2][0] + expect(event.properties.distinct_id).toBe(identifiedDistinctId) + expect(event.properties.$anon_distinct_id).toBe('$posthog_cklsh') + expect(event.properties.$device_id).toBe(null) + expect(event.properties.$session_id).toBe(undefined) + expect(event.properties.$window_id).toBe(undefined) + expect(event.properties.$cklsh_mode).toEqual(true) + + // an event after identifying + posthog.capture(eventName, eventProperties) + expect(beforeSendMock).toBeCalledTimes(4) + event = beforeSendMock.mock.calls[3][0] + expect(event.properties.distinct_id).toBe(identifiedDistinctId) + expect(event.properties.$anon_distinct_id).toBe(undefined) + expect(event.properties.$device_id).toBe(null) + expect(event.properties.$session_id).toBe(undefined) + expect(event.properties.$window_id).toBe(undefined) + expect(event.properties.$cklsh_mode).toEqual(true) + + // reset + posthog.reset() + posthog.set_config({ persistence: 'memory' }) + + // an event after reset + posthog.capture(eventName, eventProperties) + expect(beforeSendMock).toBeCalledTimes(5) + event = beforeSendMock.mock.calls[4][0] + expect(event.properties.distinct_id).toBe('$posthog_cklsh') + expect(event.properties.$anon_distinct_id).toBe(undefined) + expect(event.properties.$device_id).toBe(null) + expect(event.properties.$session_id).toBe(undefined) + expect(event.properties.$window_id).toBe(undefined) + expect(event.properties.$cklsh_mode).toEqual(true) + expect(document.cookie).toBe('') + }) +}) diff --git a/src/constants.ts b/src/constants.ts index 89cda8f3b..34b7a8bad 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -49,6 +49,13 @@ export const ENABLE_PERSON_PROCESSING = '$epp' export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__' export const TOOLBAR_CONTAINER_CLASS = 'toolbar-global-fade-container' +/** + * PREVIEW - MAY CHANGE WITHOUT WARNING - DO NOT USE IN PRODUCTION + * Sentinel value for distinct id, device id, session id. Signals that the server should generate the value + * */ +export const COOKIELESS_SENTINEL_VALUE = '$posthog_cklsh' +export const COOKIELESS_MODE_FLAG_PROPERTY = '$cklsh_mode' + export const WEB_EXPERIMENTS = '$web_experiments' // These are properties that are reserved and will not be automatically included in events diff --git a/src/entrypoints/tracing-headers.ts b/src/entrypoints/tracing-headers.ts index 06abc4e01..18acbe3a0 100644 --- a/src/entrypoints/tracing-headers.ts +++ b/src/entrypoints/tracing-headers.ts @@ -2,13 +2,15 @@ import { SessionIdManager } from '../sessionid' import { patch } from '../extensions/replay/rrweb-plugins/patch' import { assignableWindow, window } from '../utils/globals' -const addTracingHeaders = (sessionManager: SessionIdManager, req: Request) => { - const { sessionId, windowId } = sessionManager.checkAndGetSessionAndWindowId(true) - req.headers.set('X-POSTHOG-SESSION-ID', sessionId) - req.headers.set('X-POSTHOG-WINDOW-ID', windowId) +const addTracingHeaders = (sessionManager: SessionIdManager | undefined, req: Request) => { + if (sessionManager) { + const { sessionId, windowId } = sessionManager.checkAndGetSessionAndWindowId(true) + req.headers.set('X-POSTHOG-SESSION-ID', sessionId) + req.headers.set('X-POSTHOG-WINDOW-ID', windowId) + } } -const patchFetch = (sessionManager: SessionIdManager): (() => void) => { +const patchFetch = (sessionManager?: SessionIdManager): (() => void) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return patch(window, 'fetch', (originalFetch: typeof fetch) => { @@ -24,7 +26,7 @@ const patchFetch = (sessionManager: SessionIdManager): (() => void) => { }) } -const patchXHR = (sessionManager: SessionIdManager): (() => void) => { +const patchXHR = (sessionManager?: SessionIdManager): (() => void) => { return patch( // we can assert this is present because we've checked previously window!.XMLHttpRequest.prototype, diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 030501aab..822fdb413 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -449,6 +449,9 @@ export class SessionRecording { logger.error('started without valid sessionManager') throw new Error(LOGGER_PREFIX + ' started without valid sessionManager. This is a bug.') } + if (this.instance.config.__preview_experimental_cookieless_mode) { + throw new Error(LOGGER_PREFIX + ' cannot be used with __preview_experimental_cookieless_mode.') + } // we know there's a sessionManager, so don't need to start without a session id const { sessionId, windowId } = this.sessionManager.checkAndGetSessionAndWindowId() diff --git a/src/extensions/tracing-headers.ts b/src/extensions/tracing-headers.ts index b8867af5c..d3706dd65 100644 --- a/src/extensions/tracing-headers.ts +++ b/src/extensions/tracing-headers.ts @@ -37,12 +37,11 @@ export class TracingHeaders { } private _startCapturing = () => { - // NB: we can assert sessionManager is present only because we've checked previously if (isUndefined(this._restoreXHRPatch)) { - assignableWindow.__PosthogExtensions__?.tracingHeadersPatchFns?._patchXHR(this.instance.sessionManager!) + assignableWindow.__PosthogExtensions__?.tracingHeadersPatchFns?._patchXHR(this.instance.sessionManager) } if (isUndefined(this._restoreFetchPatch)) { - assignableWindow.__PosthogExtensions__?.tracingHeadersPatchFns?._patchFetch(this.instance.sessionManager!) + assignableWindow.__PosthogExtensions__?.tracingHeadersPatchFns?._patchFetch(this.instance.sessionManager) } } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index c2bced9f5..ddede2ea3 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -19,6 +19,8 @@ import { PEOPLE_DISTINCT_ID_KEY, USER_STATE, ENABLE_PERSON_PROCESSING, + COOKIELESS_SENTINEL_VALUE, + COOKIELESS_MODE_FLAG_PROPERTY, } from './constants' import { SessionRecording } from './extensions/replay/sessionrecording' import { Decide } from './decide' @@ -429,16 +431,20 @@ export class PostHog { this._retryQueue = new RetryQueue(this) this.__request_queue = [] - this.sessionManager = new SessionIdManager(this) - this.sessionPropsManager = new SessionPropsManager(this.sessionManager, this.persistence) + if (!this.config.__preview_experimental_cookieless_mode) { + 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() + if (!this.config.__preview_experimental_cookieless_mode) { + this.sessionRecording = new SessionRecording(this) + this.sessionRecording.startIfEnabledOrStop() + } if (!this.config.disable_scroll_properties) { this.scrollManager.startMeasuringScrollPosition() @@ -507,10 +513,18 @@ export class PostHog { this.featureFlags.receivedFeatureFlags({ featureFlags: activeFlags, featureFlagPayloads }) } - if (!this.get_distinct_id()) { + if (this.config.__preview_experimental_cookieless_mode) { + this.register_once( + { + distinct_id: COOKIELESS_SENTINEL_VALUE, + $device_id: null, + }, + '' + ) + } else if (!this.get_distinct_id()) { // There is no need to set the distinct id // or the device id if something was already stored - // in the persitence + // in the persistence const uuid = this.config.get_device_id(uuidv7()) this.register_once( @@ -915,6 +929,11 @@ export class PostHog { let properties = { ...event_properties } properties['token'] = this.config.token + if (this.config.__preview_experimental_cookieless_mode) { + // Set a flag to tell the plugin server to use cookieless server hash mode + properties[COOKIELESS_MODE_FLAG_PROPERTY] = true + } + if (event_name === '$snapshot') { const persistenceProps = { ...this.persistence.properties(), ...this.sessionPersistence.properties() } properties['distinct_id'] = persistenceProps.distinct_id @@ -1533,14 +1552,24 @@ export class PostHog { this.surveys?.reset() this.persistence?.set_property(USER_STATE, 'anonymous') this.sessionManager?.resetSessionId() - const uuid = this.config.get_device_id(uuidv7()) - this.register_once( - { - distinct_id: uuid, - $device_id: reset_device_id ? uuid : device_id, - }, - '' - ) + if (this.config.__preview_experimental_cookieless_mode) { + this.register_once( + { + distinct_id: COOKIELESS_SENTINEL_VALUE, + $device_id: null, + }, + '' + ) + } else { + const uuid = this.config.get_device_id(uuidv7()) + this.register_once( + { + distinct_id: uuid, + $device_id: reset_device_id ? uuid : device_id, + }, + '' + ) + } } /** diff --git a/src/sessionid.ts b/src/sessionid.ts index b9b7c63c0..bab3e88d5 100644 --- a/src/sessionid.ts +++ b/src/sessionid.ts @@ -37,6 +37,9 @@ export class SessionIdManager { if (!instance.persistence) { throw new Error('SessionIdManager requires a PostHogPersistence instance') } + if (instance.config.__preview_experimental_cookieless_mode) { + throw new Error('SessionIdManager cannot be used with __preview_experimental_cookieless_mode') + } this.config = instance.config this.persistence = instance.persistence @@ -215,6 +218,11 @@ export class SessionIdManager { * @param {Number} timestamp (optional) Defaults to the current time. The timestamp to be stored with the sessionId (used when determining if a new sessionId should be generated) */ checkAndGetSessionAndWindowId(readOnly = false, _timestamp: number | null = null) { + if (this.config.__preview_experimental_cookieless_mode) { + throw new Error( + 'checkAndGetSessionAndWindowId should not be called in __preview_experimental_cookieless_mode' + ) + } const timestamp = _timestamp || new Date().getTime() // eslint-disable-next-line prefer-const diff --git a/src/types.ts b/src/types.ts index 303b501b1..02d2d709b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -344,6 +344,12 @@ export interface PostHogConfig { * enables the new RemoteConfig approach to loading config instead of decide * */ __preview_remote_config?: boolean + + /** + * PREVIEW - MAY CHANGE WITHOUT WARNING - DO NOT USE IN PRODUCTION + * whether to send a sentinel value for distinct id, device id, and session id, which will be replaced server-side by a cookieless hash + * */ + __preview_experimental_cookieless_mode?: boolean } export interface OptInOutCapturingOptions { diff --git a/src/utils/globals.ts b/src/utils/globals.ts index a2e2479fa..487d78193 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -79,8 +79,8 @@ interface PostHogExtensions { onINP: (metric: any) => void } tracingHeadersPatchFns?: { - _patchFetch: (sessionManager: SessionIdManager) => () => void - _patchXHR: (sessionManager: any) => () => void + _patchFetch: (sessionManager?: SessionIdManager) => () => void + _patchXHR: (sessionManager?: SessionIdManager) => () => void } initDeadClicksAutocapture?: ( ph: PostHog,