Skip to content

Commit

Permalink
feat(web-analytics): Add flag to send server hash instead of distinct…
Browse files Browse the repository at this point in the history
… id (#1490)

* Add flag to send server hash instead of distinct id

* Use SENTINEL_COOKIELESS_SERVER_HASH for session ids too

* WIP

* Driveby

* Package version

* Rename the sentinel const

* Comments and naming

* Send null for session id and window id

* typescript

* bump posthog in next playground

* Add a test

* Revert get device id changes

* Add a comment

* Remove cklsh from next playground

* Handle reset

* Don't enable session id manager or replay when in cookieless mode

* Make tracing headers support optional SessionIdManager

* Revert session duration

* Remove unnecessary comment

* Remove check
  • Loading branch information
robbie-c authored Dec 16, 2024
1 parent 520da49 commit e0c85ee
Show file tree
Hide file tree
Showing 12 changed files with 179 additions and 31 deletions.
2 changes: 1 addition & 1 deletion playground/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion playground/nextjs/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function App({ Component, pageProps }: AppProps) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* CSP - useful for testing our documented recommendations. NOTE: Unsafe is only needed for nextjs pre-loading */}
<meta
http-equiv="Content-Security-Policy"
httpEquiv="Content-Security-Policy"
content={`
default-src 'self';
connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host;
Expand Down
14 changes: 10 additions & 4 deletions playground/nextjs/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 88 additions & 0 deletions src/__tests__/cookieless.test.ts
Original file line number Diff line number Diff line change
@@ -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<PostHogConfig> = {}, 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('')
})
})
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/entrypoints/tracing-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/extensions/replay/sessionrecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,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()
Expand Down
5 changes: 2 additions & 3 deletions src/extensions/tracing-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
57 changes: 43 additions & 14 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { RemoteConfigLoader } from './remote-config'
Expand Down Expand Up @@ -432,16 +434,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()
Expand Down Expand Up @@ -510,10 +516,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(
Expand Down Expand Up @@ -924,6 +938,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
Expand Down Expand Up @@ -1542,14 +1561,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,
},
''
)
}
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/sessionid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,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 {
Expand Down
4 changes: 2 additions & 2 deletions src/utils/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit e0c85ee

Please sign in to comment.