From ba57cd029300fbcaebe71f501b29b0d8dd5a613a Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 7 Feb 2024 15:11:56 +0000 Subject: [PATCH 1/6] feat: Add dynamic routing of ingestion endpoints (#986) --- src/__tests__/decide.js | 2 + .../exception-observer.test.ts | 2 + .../replay/sessionrecording.test.ts | 10 ++- src/__tests__/extensions/toolbar.test.ts | 3 + src/__tests__/featureflags.js | 14 ++-- src/__tests__/posthog-core.js | 25 +++--- src/__tests__/posthog-core.loaded.js | 6 +- src/__tests__/surveys.test.ts | 3 + src/__tests__/utils/request-router.test.ts | 79 +++++++++++++++++++ src/decide.ts | 12 +-- src/extensions/cloud.ts | 1 - src/extensions/exception-autocapture/index.ts | 5 +- src/extensions/replay/sessionrecording.ts | 17 ++-- src/extensions/sentry-integration.ts | 6 +- src/extensions/toolbar.ts | 12 +-- src/posthog-core.ts | 19 +++-- src/posthog-featureflags.ts | 7 +- src/posthog-surveys.ts | 2 +- src/types.ts | 5 +- src/utils/request-router.ts | 71 +++++++++++++++++ 20 files changed, 230 insertions(+), 71 deletions(-) create mode 100644 src/__tests__/utils/request-router.test.ts delete mode 100644 src/extensions/cloud.ts create mode 100644 src/utils/request-router.ts diff --git a/src/__tests__/decide.js b/src/__tests__/decide.js index 1b04482c7..ad1562765 100644 --- a/src/__tests__/decide.js +++ b/src/__tests__/decide.js @@ -2,6 +2,7 @@ import { autocapture } from '../autocapture' import { Decide } from '../decide' import { _base64Encode } from '../utils' import { PostHogPersistence } from '../posthog-persistence' +import { RequestRouter } from '../utils/request-router' const expectDecodedSendRequest = (send_request, data) => { const lastCall = send_request.mock.calls[send_request.mock.calls.length - 1] @@ -49,6 +50,7 @@ describe('Decide', () => { setReloadingPaused: jest.fn(), _startReloadTimer: jest.fn(), }, + requestRouter: new RequestRouter({ config: given.config }), _hasBootstrappedFeatureFlags: jest.fn(), getGroups: () => ({ organization: '5' }), })) diff --git a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts index e2bd50be9..53a86766b 100644 --- a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts +++ b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts @@ -3,6 +3,7 @@ import { PostHog } from '../../../posthog-core' import { DecideResponse, PostHogConfig } from '../../../types' import { ExceptionObserver } from '../../../extensions/exception-autocapture' import { window } from '../../../utils/globals' +import { RequestRouter } from '../../../utils/request-router' describe('Exception Observer', () => { let exceptionObserver: ExceptionObserver @@ -17,6 +18,7 @@ describe('Exception Observer', () => { config: mockConfig, get_distinct_id: jest.fn(() => 'mock-distinct-id'), capture: mockCapture, + requestRouter: new RequestRouter({ config: mockConfig } as any), } exceptionObserver = new ExceptionObserver(mockPostHogInstance as PostHog) }) diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index f79796d29..c76fb3fdc 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -24,6 +24,7 @@ import { SessionRecording, } from '../../../extensions/replay/sessionrecording' import { assignableWindow } from '../../../utils/globals' +import { RequestRouter } from '../../../utils/request-router' // Type and source defined here designate a non-user-generated recording event @@ -117,6 +118,7 @@ describe('SessionRecording', () => { onFeatureFlagsCallback = cb }, sessionManager: sessionManager, + requestRouter: new RequestRouter({ config } as any), _addCaptureHook: jest.fn(), } as unknown as PostHog @@ -537,7 +539,7 @@ describe('SessionRecording', () => { }, { method: 'POST', - endpoint: '/s/', + _url: 'https://test.com/s/', _noTruncate: true, _batchKey: 'recordings', _metrics: expect.anything(), @@ -574,7 +576,7 @@ describe('SessionRecording', () => { }, { method: 'POST', - endpoint: '/s/', + _url: 'https://test.com/s/', _noTruncate: true, _batchKey: 'recordings', _metrics: expect.anything(), @@ -658,7 +660,7 @@ describe('SessionRecording', () => { }, { method: 'POST', - endpoint: '/s/', + _url: 'https://test.com/s/', _noTruncate: true, _batchKey: 'recordings', _metrics: expect.anything(), @@ -1283,7 +1285,7 @@ describe('SessionRecording', () => { _batchKey: 'recordings', _metrics: { rrweb_full_snapshot: false }, _noTruncate: true, - endpoint: '/s/', + _url: 'https://test.com/s/', method: 'POST', } ) diff --git a/src/__tests__/extensions/toolbar.test.ts b/src/__tests__/extensions/toolbar.test.ts index c938cdaaa..c5cd49cfe 100644 --- a/src/__tests__/extensions/toolbar.test.ts +++ b/src/__tests__/extensions/toolbar.test.ts @@ -3,6 +3,7 @@ import { _isString, _isUndefined } from '../../utils/type-utils' import { PostHog } from '../../posthog-core' import { PostHogConfig, ToolbarParams } from '../../types' import { assignableWindow, window } from '../../utils/globals' +import { RequestRouter } from '../../utils/request-router' jest.mock('../../utils', () => ({ ...jest.requireActual('../../utils'), @@ -25,6 +26,8 @@ describe('Toolbar', () => { api_host: 'http://api.example.com', token: 'test_token', } as unknown as PostHogConfig, + requestRouter: new RequestRouter(instance), + set_config: jest.fn(), } as unknown as PostHog toolbar = new Toolbar(instance) diff --git a/src/__tests__/featureflags.js b/src/__tests__/featureflags.js index 061de4b74..825a97b30 100644 --- a/src/__tests__/featureflags.js +++ b/src/__tests__/featureflags.js @@ -2,6 +2,7 @@ import { PostHogFeatureFlags, parseFeatureFlagDecideResponse, filterActiveFeatureFlags } from '../posthog-featureflags' import { PostHogPersistence } from '../posthog-persistence' +import { RequestRouter } from '../utils/request-router' jest.useFakeTimers() jest.spyOn(global, 'setTimeout') @@ -12,6 +13,7 @@ describe('featureflags', () => { const config = { token: 'random fake token', persistence: 'memory', + api_host: 'https://app.posthog.com', } given('instance', () => ({ config, @@ -19,6 +21,7 @@ describe('featureflags', () => { getGroups: () => {}, _prepare_callback: (callback) => callback, persistence: new PostHogPersistence(config), + requestRouter: new RequestRouter({ config }), register: (props) => given.instance.persistence.register(props), unregister: (key) => given.instance.persistence.unregister(key), get_property: (key) => given.instance.persistence.props[key], @@ -320,20 +323,13 @@ describe('featureflags', () => { earlyAccessFeatures: [EARLY_ACCESS_FEATURE_FIRST], })) - beforeEach(() => { - given.instance.config = { - ...given.instance.config, - api_host: 'https://decide.com', - } - }) - it('getEarlyAccessFeatures requests early access features if not present', () => { given.featureFlags.getEarlyAccessFeatures((data) => { expect(data).toEqual([EARLY_ACCESS_FEATURE_FIRST]) }) expect(given.instance._send_request).toHaveBeenCalledWith( - 'https://decide.com/api/early_access_features/?token=random fake token', + 'https://app.posthog.com/api/early_access_features/?token=random fake token', {}, { method: 'GET' }, expect.any(Function) @@ -359,7 +355,7 @@ describe('featureflags', () => { }) expect(given.instance._send_request).toHaveBeenCalledWith( - 'https://decide.com/api/early_access_features/?token=random fake token', + 'https://app.posthog.com/api/early_access_features/?token=random fake token', {}, { method: 'GET' }, expect.any(Function) diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 9408874bb..c3bd28abd 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -39,7 +39,10 @@ describe('posthog core', () => { given('overrides', () => ({ __loaded: true, - config: given.config, + config: { + api_host: 'https://app.posthog.com', + ...given.config, + }, persistence: { remove_event_timer: jest.fn(), properties: jest.fn(), @@ -254,7 +257,7 @@ describe('posthog core', () => { }) it('sends payloads to overriden endpoint if given', () => { - given.lib.capture('event-name', { foo: 'bar', length: 0 }, { endpoint: '/s/' }) + given.lib.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) expect(given.lib._send_request).toHaveBeenCalledWith( 'https://app.posthog.com/s/', expect.any(Object), @@ -263,9 +266,9 @@ describe('posthog core', () => { ) }) - it('sends payloads to overriden endpoint, even if alternative endpoint is set', () => { + it('sends payloads to overriden _url, even if alternative endpoint is set', () => { given.lib._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) - given.lib.capture('event-name', { foo: 'bar', length: 0 }, { endpoint: '/s/' }) + given.lib.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) expect(given.lib._send_request).toHaveBeenCalledWith( 'https://app.posthog.com/s/', expect.any(Object), @@ -741,15 +744,6 @@ describe('posthog core', () => { expect(given.overrides._send_request.mock.calls.length).toBe(0) // No outgoing requests }) - it('sanitizes api_host urls', () => { - given('config', () => ({ - api_host: 'https://example.com/custom/', - })) - given.subject() - - expect(given.lib.config.api_host).toBe('https://example.com/custom') - }) - it('does not set __loaded_recorder_version flag if recording script has not been included', () => { given('overrides', () => ({ __loaded_recorder_version: undefined, @@ -979,7 +973,10 @@ describe('posthog core', () => { describe('subsequent capture calls', () => { given('overrides', () => ({ __loaded: true, - config: given.config, + config: { + api_host: 'https://app.posthog.com', + ...given.config, + }, persistence: new PostHogPersistence(given.config), sessionPersistence: new PostHogPersistence(given.config), _requestQueue: { diff --git a/src/__tests__/posthog-core.loaded.js b/src/__tests__/posthog-core.loaded.js index 99f7da655..e96e8f9e8 100644 --- a/src/__tests__/posthog-core.loaded.js +++ b/src/__tests__/posthog-core.loaded.js @@ -1,5 +1,6 @@ import { PostHog } from '../posthog-core' import { PostHogPersistence } from '../posthog-persistence' +import { RequestRouter } from '../utils/request-router' jest.useFakeTimers() @@ -21,11 +22,12 @@ describe('loaded() with flags', () => { _startReloadTimer: jest.fn(), receivedFeatureFlags: jest.fn(), }, + requestRouter: new RequestRouter({ config: given.config }), _start_queue_if_opted_in: jest.fn(), persistence: new PostHogPersistence(given.config), _send_request: jest.fn((host, data, header, callback) => callback({ status: 200 })), })) - given('config', () => ({ loaded: jest.fn(), persistence: 'memory' })) + given('config', () => ({ loaded: jest.fn(), persistence: 'memory', api_host: 'https://app.posthog.com' })) describe('toggling flag reloading', () => { given('config', () => ({ @@ -36,6 +38,7 @@ describe('loaded() with flags', () => { }, 100) }, persistence: 'memory', + api_host: 'https://app.posthog.com', })) given('overrides', () => ({ @@ -44,6 +47,7 @@ describe('loaded() with flags', () => { _send_request: jest.fn((host, data, header, callback) => setTimeout(() => callback({ status: 200 }), 1000)), _start_queue_if_opted_in: jest.fn(), persistence: new PostHogPersistence(given.config), + requestRouter: new RequestRouter({ config: given.config }), })) beforeEach(() => { diff --git a/src/__tests__/surveys.test.ts b/src/__tests__/surveys.test.ts index 8f0896bb1..484ca17c2 100644 --- a/src/__tests__/surveys.test.ts +++ b/src/__tests__/surveys.test.ts @@ -5,6 +5,8 @@ import { SurveyType, SurveyQuestionType, Survey } from '../posthog-surveys-types import { PostHogPersistence } from '../posthog-persistence' import { PostHog } from '../posthog-core' import { DecideResponse, PostHogConfig, Properties } from '../types' +import { window } from '../utils/globals' +import { RequestRouter } from '../utils/request-router' import { assignableWindow } from '../utils/globals' describe('surveys', () => { @@ -60,6 +62,7 @@ describe('surveys', () => { config: config, _prepare_callback: (callback: any) => callback, persistence: new PostHogPersistence(config), + requestRouter: new RequestRouter({ config } as any), register: (props: Properties) => instance.persistence?.register(props), unregister: (key: string) => instance.persistence?.unregister(key), get_property: (key: string) => instance.persistence?.props[key], diff --git a/src/__tests__/utils/request-router.test.ts b/src/__tests__/utils/request-router.test.ts new file mode 100644 index 000000000..2bff23418 --- /dev/null +++ b/src/__tests__/utils/request-router.test.ts @@ -0,0 +1,79 @@ +import { RequestRouter, RequestRouterTarget } from '../../utils/request-router' + +describe('request-router', () => { + const router = (api_host = 'https://app.posthog.com', ui_host?: string) => { + return new RequestRouter({ + config: { + api_host, + ui_host, + __preview_ingestion_endpoints: true, + }, + } as any) + } + + const testCases: [string, RequestRouterTarget, string][] = [ + // US domain + ['https://app.posthog.com', 'ui', 'https://app.posthog.com'], + ['https://app.posthog.com', 'capture_events', 'https://us-c.i.posthog.com'], + ['https://app.posthog.com', 'capture_recordings', 'https://us-s.i.posthog.com'], + ['https://app.posthog.com', 'decide', 'https://us-d.i.posthog.com'], + ['https://app.posthog.com', 'assets', 'https://us-assets.i.posthog.com'], + ['https://app.posthog.com', 'api', 'https://us-api.i.posthog.com'], + // US domain via app domain + ['https://us.posthog.com', 'ui', 'https://us.posthog.com'], + ['https://us.posthog.com', 'capture_events', 'https://us-c.i.posthog.com'], + ['https://us.posthog.com', 'capture_recordings', 'https://us-s.i.posthog.com'], + ['https://us.posthog.com', 'decide', 'https://us-d.i.posthog.com'], + ['https://us.posthog.com', 'assets', 'https://us-assets.i.posthog.com'], + ['https://us.posthog.com', 'api', 'https://us-api.i.posthog.com'], + + // EU domain + ['https://eu.posthog.com', 'ui', 'https://eu.posthog.com'], + ['https://eu.posthog.com', 'capture_events', 'https://eu-c.i.posthog.com'], + ['https://eu.posthog.com', 'capture_recordings', 'https://eu-s.i.posthog.com'], + ['https://eu.posthog.com', 'decide', 'https://eu-d.i.posthog.com'], + ['https://eu.posthog.com', 'assets', 'https://eu-assets.i.posthog.com'], + ['https://eu.posthog.com', 'api', 'https://eu-api.i.posthog.com'], + + // custom domain + ['https://my-custom-domain.com', 'ui', 'https://my-custom-domain.com'], + ['https://my-custom-domain.com', 'capture_events', 'https://my-custom-domain.com'], + ['https://my-custom-domain.com', 'capture_recordings', 'https://my-custom-domain.com'], + ['https://my-custom-domain.com', 'decide', 'https://my-custom-domain.com'], + ['https://my-custom-domain.com', 'assets', 'https://my-custom-domain.com'], + ['https://my-custom-domain.com', 'api', 'https://my-custom-domain.com'], + ] + + it.each(testCases)( + 'should create the appropriate endpoints for host %s and target %s', + (host, target, expectation) => { + expect(router(host).endpointFor(target)).toEqual(expectation) + } + ) + + it('should sanitize the api_host values', () => { + expect(router('https://app.posthog.com/').endpointFor('decide', '/decide?v=3')).toEqual( + 'https://us-d.i.posthog.com/decide?v=3' + ) + + expect(router('https://example.com/').endpointFor('decide', '/decide?v=3')).toEqual( + 'https://example.com/decide?v=3' + ) + }) + + it('should use the ui_host if provided', () => { + expect(router('https://my.domain.com/', 'https://app.posthog.com/').endpointFor('ui')).toEqual( + 'https://app.posthog.com' + ) + }) + + it('should react to config changes', () => { + const mockPostHog = { config: { api_host: 'https://app.posthog.com', __preview_ingestion_endpoints: true } } + + const router = new RequestRouter(mockPostHog as any) + expect(router.endpointFor('capture_events')).toEqual('https://us-c.i.posthog.com') + + mockPostHog.config.api_host = 'https://eu.posthog.com' + expect(router.endpointFor('capture_events')).toEqual('https://eu-c.i.posthog.com') + }) +}) diff --git a/src/decide.ts b/src/decide.ts index 8da838f00..280b6e449 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -35,7 +35,7 @@ export class Decide { const encoded_data = _base64Encode(json_data) this.instance._send_request( - `${this.instance.config.api_host}/decide/?v=3`, + this.instance.requestRouter.endpointFor('decide', '/decide/?v=3'), { data: encoded_data, verbose: true }, { method: 'POST' }, (response) => this.parseDecideResponse(response as DecideResponse) @@ -76,7 +76,7 @@ export class Decide { const surveysGenerator = window?.extendPostHogWithSurveys if (response['surveys'] && !surveysGenerator) { - loadScript(this.instance.config.api_host + `/static/surveys.js`, (err) => { + loadScript(this.instance.requestRouter.endpointFor('assets', '/static/surveys.js'), (err) => { if (err) { return logger.error(`Could not load surveys script`, err) } @@ -95,7 +95,7 @@ export class Decide { !!response['autocaptureExceptions'] && _isUndefined(exceptionAutoCaptureAddedToWindow) ) { - loadScript(this.instance.config.api_host + `/static/exception-autocapture.js`, (err) => { + loadScript(this.instance.requestRouter.endpointFor('assets', '/static/exception-autocapture.js'), (err) => { if (err) { return logger.error(`Could not load exception autocapture script`, err) } @@ -108,12 +108,8 @@ export class Decide { if (response['siteApps']) { if (this.instance.config.opt_in_site_apps) { - const apiHost = this.instance.config.api_host for (const { id, url } of response['siteApps']) { - const scriptUrl = [ - apiHost, - apiHost[apiHost.length - 1] === '/' && url[0] === '/' ? url.substring(1) : url, - ].join('') + const scriptUrl = this.instance.requestRouter.endpointFor('assets', url) assignableWindow[`__$$ph_site_app_${id}`] = this.instance diff --git a/src/extensions/cloud.ts b/src/extensions/cloud.ts deleted file mode 100644 index fbaf9b831..000000000 --- a/src/extensions/cloud.ts +++ /dev/null @@ -1 +0,0 @@ -export const POSTHOG_MANAGED_HOSTS = ['https://app.posthog.com', 'https://eu.posthog.com'] diff --git a/src/extensions/exception-autocapture/index.ts b/src/extensions/exception-autocapture/index.ts index 17a97e6ce..f97151ad5 100644 --- a/src/extensions/exception-autocapture/index.ts +++ b/src/extensions/exception-autocapture/index.ts @@ -7,8 +7,6 @@ import { isPrimitive } from './type-checking' import { _isArray, _isObject, _isUndefined } from '../../utils/type-utils' import { logger } from '../../utils/logger' -const EXCEPTION_INGESTION_ENDPOINT = '/e/' - export const extendPostHog = (instance: PostHog, response: DecideResponse) => { const exceptionObserver = new ExceptionObserver(instance) exceptionObserver.afterDecideResponse(response) @@ -129,7 +127,7 @@ export class ExceptionObserver { const propertiesToSend = { ...properties, ...errorProperties } - const posthogHost = this.instance.config.ui_host || this.instance.config.api_host + const posthogHost = this.instance.requestRouter.endpointFor('ui') errorProperties.$exception_personURL = posthogHost + '/person/' + this.instance.get_distinct_id() this.sendExceptionEvent(propertiesToSend) @@ -141,7 +139,6 @@ export class ExceptionObserver { sendExceptionEvent(properties: { [key: string]: any }) { this.instance.capture('$exception', properties, { method: 'POST', - endpoint: EXCEPTION_INGESTION_ENDPOINT, _noTruncate: true, _batchKey: 'exceptionEvent', }) diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index e6be439c2..5310f277b 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -412,13 +412,16 @@ export class SessionRecording { // imported) or matches the requested recorder version, don't load script. Otherwise, remotely import // recorder.js from cdn since it hasn't been loaded. if (this.instance.__loaded_recorder_version !== this.recordingVersion) { - loadScript(this.instance.config.api_host + `/static/${recorderJS}?v=${Config.LIB_VERSION}`, (err) => { - if (err) { - return logger.error(`Could not load ${recorderJS}`, err) - } + loadScript( + this.instance.requestRouter.endpointFor('assets', `/static/${recorderJS}?v=${Config.LIB_VERSION}`), + (err) => { + if (err) { + return logger.error(`Could not load ${recorderJS}`, err) + } - this._onScriptLoaded() - }) + this._onScriptLoaded() + } + ) } else { this._onScriptLoaded() } @@ -835,7 +838,7 @@ export class SessionRecording { // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings. this.instance.capture('$snapshot', properties, { method: 'POST', - endpoint: this._endpoint, + _url: this.instance.requestRouter.endpointFor('capture_recordings', this._endpoint), _noTruncate: true, _batchKey: SESSION_RECORDING_BATCH_KEY, _metrics: { diff --git a/src/extensions/sentry-integration.ts b/src/extensions/sentry-integration.ts index 914cf7a55..73d160c01 100644 --- a/src/extensions/sentry-integration.ts +++ b/src/extensions/sentry-integration.ts @@ -64,8 +64,8 @@ export class SentryIntegration implements _SentryIntegration { if (event.level !== 'error' || !_posthog.__loaded) return event if (!event.tags) event.tags = {} - const host = _posthog.config.ui_host || _posthog.config.api_host - event.tags['PostHog Person URL'] = host + '/person/' + _posthog.get_distinct_id() + const personUrl = _posthog.requestRouter.endpointFor('ui', '/person/' + _posthog.get_distinct_id()) + event.tags['PostHog Person URL'] = personUrl if (_posthog.sessionRecordingStarted()) { event.tags['PostHog Recording URL'] = _posthog.get_session_replay_url({ withTimestamp: true }) } @@ -82,7 +82,7 @@ export class SentryIntegration implements _SentryIntegration { // PostHog Exception Properties, $exception_message: exceptions[0]?.value, $exception_type: exceptions[0]?.type, - $exception_personURL: host + '/person/' + _posthog.get_distinct_id(), + $exception_personURL: personUrl, // Sentry Exception Properties $sentry_event_id: event.event_id, $sentry_exception: event.exception, diff --git a/src/extensions/toolbar.ts b/src/extensions/toolbar.ts index 4aa6be883..1f9e97358 100644 --- a/src/extensions/toolbar.ts +++ b/src/extensions/toolbar.ts @@ -1,7 +1,6 @@ import { _register_event, _try, loadScript } from '../utils' import { PostHog } from '../posthog-core' import { DecideResponse, ToolbarParams } from '../types' -import { POSTHOG_MANAGED_HOSTS } from './cloud' import { _getHashParam } from '../utils/request-utils' import { logger } from '../utils/logger' import { window, document, assignableWindow } from '../utils/globals' @@ -126,21 +125,22 @@ export class Toolbar { // only load the toolbar once, even if there are multiple instances of PostHogLib assignableWindow['_postHogToolbarLoaded'] = true - const host = this.instance.config.api_host // toolbar.js is served from the PostHog CDN, this has a TTL of 24 hours. // the toolbar asset includes a rotating "token" that is valid for 5 minutes. const fiveMinutesInMillis = 5 * 60 * 1000 // this ensures that we bust the cache periodically const timestampToNearestFiveMinutes = Math.floor(Date.now() / fiveMinutesInMillis) * fiveMinutesInMillis - const toolbarUrl = `${host}${host.endsWith('/') ? '' : '/'}static/toolbar.js?t=${timestampToNearestFiveMinutes}` + const toolbarUrl = this.instance.requestRouter.endpointFor( + 'assets', + `/static/toolbar.js?t=${timestampToNearestFiveMinutes}` + ) const disableToolbarMetrics = - !POSTHOG_MANAGED_HOSTS.includes(this.instance.config.api_host) && - this.instance.config.advanced_disable_toolbar_metrics + this.instance.requestRouter.region === 'custom' && this.instance.config.advanced_disable_toolbar_metrics const toolbarParams = { token: this.instance.config.token, ...params, - apiURL: host, // defaults to api_host from the instance config if nothing else set + apiURL: this.instance.requestRouter.endpointFor('api'), // defaults to api_host from the instance config if nothing else set ...(disableToolbarMetrics ? { instrument: false } : {}), } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index ce30edf80..dcf8d9d95 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -24,6 +24,7 @@ import { compressData, decideCompression } from './compression' import { addParamsToURL, encodePostData, request } from './send-request' import { RetryQueue } from './retry-queue' import { SessionIdManager } from './sessionid' +import { RequestRouter } from './utils/request-router' import { AutocaptureConfig, CaptureOptions, @@ -292,6 +293,7 @@ export class PostHog { sessionPersistence?: PostHogPersistence sessionManager?: SessionIdManager sessionPropsManager?: SessionPropsManager + requestRouter: RequestRouter _requestQueue?: RequestQueue _retryQueue?: RetryQueue @@ -337,6 +339,7 @@ export class PostHog { this.pageViewManager = new PageViewManager(this) this.surveys = new PostHogSurveys(this) this.rateLimiter = new RateLimiter() + this.requestRouter = new RequestRouter(this) // NOTE: See the property definition for deprecation notice this.people = { @@ -564,6 +567,10 @@ export class PostHog { if (response.elementsChainAsString) { this.elementsChainAsString = response.elementsChainAsString } + + if (response.__preview_ingestion_endpoints) { + this.config.__preview_ingestion_endpoints = response.__preview_ingestion_endpoints + } } _loaded(): void { @@ -920,7 +927,7 @@ export class PostHog { logger.info('send', data) const jsonData = JSON.stringify(data) - const url = this.config.api_host + (options.endpoint || this.analyticsDefaultEndpoint) + const url = options._url ?? this.requestRouter.endpointFor('capture_events', this.analyticsDefaultEndpoint) const has_unique_traits = options !== __NOOPTIONS @@ -1545,9 +1552,8 @@ export class PostHog { if (!this.sessionManager) { return '' } - const host = this.config.ui_host || this.config.api_host const { sessionId, sessionStartTimestamp } = this.sessionManager.checkAndGetSessionAndWindowId(true) - let url = host + '/replay/' + sessionId + let url = this.requestRouter.endpointFor('ui', '/replay/' + sessionId) if (options?.withTimestamp && sessionStartTimestamp) { const LOOK_BACK = options.timestampLookBack ?? 10 if (!sessionStartTimestamp) { @@ -1754,13 +1760,6 @@ export class PostHog { this.config.disable_persistence = this.config.disable_cookie } - // We assume the api_host is without a trailing slash in most places throughout the codebase - this.config.api_host = this.config.api_host.replace(/\/$/, '') - - // us.posthog.com is only for the web app, so we don't allow that to be used as a capture endpoint - if (this.config.api_host === 'https://us.posthog.com') { - this.config.api_host = 'https://app.posthog.com' - } this.persistence?.update_config(this.config) this.sessionPersistence?.update_config(this.config) diff --git a/src/posthog-featureflags.ts b/src/posthog-featureflags.ts index 924134252..24418428c 100644 --- a/src/posthog-featureflags.ts +++ b/src/posthog-featureflags.ts @@ -190,7 +190,7 @@ export class PostHogFeatureFlags { const encoded_data = _base64Encode(json_data) this.instance._send_request( - this.instance.config.api_host + '/decide/?v=3', + this.instance.requestRouter.endpointFor('decide', '/decide/?v=3'), { data: encoded_data }, { method: 'POST' }, this.instance._prepare_callback((response) => { @@ -357,7 +357,10 @@ export class PostHogFeatureFlags { if (!existing_early_access_features || force_reload) { this.instance._send_request( - `${this.instance.config.api_host}/api/early_access_features/?token=${this.instance.config.token}`, + this.instance.requestRouter.endpointFor( + 'api', + `/api/early_access_features/?token=${this.instance.config.token}` + ), {}, { method: 'GET' }, (response) => { diff --git a/src/posthog-surveys.ts b/src/posthog-surveys.ts index 3ac9de094..73b5b1f9c 100644 --- a/src/posthog-surveys.ts +++ b/src/posthog-surveys.ts @@ -22,7 +22,7 @@ export class PostHogSurveys { const existingSurveys = this.instance.get_property(SURVEYS) if (!existingSurveys || forceReload) { this.instance._send_request( - `${this.instance.config.api_host}/api/surveys/?token=${this.instance.config.token}`, + this.instance.requestRouter.endpointFor('api', `/api/surveys/?token=${this.instance.config.token}`), {}, { method: 'GET' }, (response) => { diff --git a/src/types.ts b/src/types.ts index 3ef5cd638..f97f22a1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -142,6 +142,8 @@ export interface PostHogConfig { disable_scroll_properties?: boolean // Let the pageview scroll stats use a custom css selector for the root element, e.g. `main` scroll_root_selector?: string | string[] + /** WARNING: This is an experimental option not meant for public use. */ + __preview_ingestion_endpoints?: boolean } export interface OptInOutCapturingOptions { @@ -204,10 +206,10 @@ export interface XHROptions { export interface CaptureOptions extends XHROptions { $set?: Properties /** used with $identify */ $set_once?: Properties /** used with $identify */ + _url?: string /** Used to override the desired endpoint for the captured event */ _batchKey?: string /** key of queue, e.g. 'sessionRecording' vs 'event' */ _metrics?: Properties _noTruncate?: boolean /** if set, overrides and disables config.properties_string_max_length */ - endpoint?: string /** defaults to '/e/' */ send_instantly?: boolean /** if set skips the batched queue */ timestamp?: Date } @@ -281,6 +283,7 @@ export interface DecideResponse { toolbarVersion: 'toolbar' /** @deprecated, moved to toolbarParams */ isAuthenticated: boolean siteApps: { id: number; url: string }[] + __preview_ingestion_endpoints?: boolean } export type FeatureFlagsCallback = (flags: string[], variants: Record) => void diff --git a/src/utils/request-router.ts b/src/utils/request-router.ts new file mode 100644 index 000000000..1aaf31502 --- /dev/null +++ b/src/utils/request-router.ts @@ -0,0 +1,71 @@ +import { PostHog } from '../posthog-core' + +/** + * The request router helps simplify the logic to determine which endpoints should be called for which things + * The basic idea is that for a given region (US or EU), we have a set of endpoints that we should call depending + * on the type of request (events, replays, decide, etc.) and handle overrides that may come from configs or the decide endpoint + */ + +export enum RequestRouterRegion { + US = 'us', + EU = 'eu', + CUSTOM = 'custom', +} + +export type RequestRouterTarget = 'ui' | 'capture_events' | 'capture_recordings' | 'decide' | 'assets' | 'api' + +export class RequestRouter { + instance: PostHog + + constructor(instance: PostHog) { + this.instance = instance + } + + get apiHost(): string { + return this.instance.config.api_host.replace(/\/$/, '') + } + get uiHost(): string | undefined { + return this.instance.config.ui_host?.replace(/\/$/, '') + } + + get region(): RequestRouterRegion { + switch (this.apiHost) { + case 'https://app.posthog.com': + case 'https://us.posthog.com': + return RequestRouterRegion.US + case 'https://eu.posthog.com': + return RequestRouterRegion.EU + default: + return RequestRouterRegion.CUSTOM + } + } + + endpointFor(target: RequestRouterTarget, path: string = ''): string { + if (path) { + path = path[0] === '/' ? path : `/${path}` + } + + if (target === 'ui') { + return (this.uiHost || this.apiHost) + path + } + + if (!this.instance.config.__preview_ingestion_endpoints || this.region === RequestRouterRegion.CUSTOM) { + return this.apiHost + path + } + + const suffix = 'i.posthog.com' + path + + switch (target) { + case 'capture_events': + return `https://${this.region}-c.${suffix}` + case 'capture_recordings': + return `https://${this.region}-s.${suffix}` + case 'decide': + return `https://${this.region}-d.${suffix}` + case 'assets': + return `https://${this.region}-assets.${suffix}` + case 'api': + return `https://${this.region}-api.${suffix}` + } + } +} From cde7a8a469f5d13f1db555f6979a918adf01a944 Mon Sep 17 00:00:00 2001 From: benjackwhite Date: Wed, 7 Feb 2024 15:12:39 +0000 Subject: [PATCH 2/6] chore: Bump version to 1.105.4 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e16c71f4b..11fdb11ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.105.4 - 2024-02-07 + +- feat: Add dynamic routing of ingestion endpoints (#986) +- Update CHANGELOG.md (#1004) + ## 1.105.3 - 2024-02-07 identical to 1.105.1 - bug in CI scripts diff --git a/package.json b/package.json index 12c92e267..38f87b918 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.105.3", + "version": "1.105.4", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From 6236d48a1154ea411a71d82e993b35527a3348c3 Mon Sep 17 00:00:00 2001 From: David Newell Date: Thu, 8 Feb 2024 11:21:34 +0000 Subject: [PATCH 3/6] chore: improve template to account for backwards compatibility (#1007) --- .github/pull_request_template.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9df249338..ba2df9d0b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,3 +5,4 @@ ## Checklist - [ ] Tests for new code (see [advice on the tests we use](https://github.com/PostHog/posthog-js#tiers-of-testing)) - [ ] Accounted for the impact of any changes across different browsers +- [ ] Accounted for backwards compatibility of any changes (no breaking changes in posthog-js!) From 0a48cd2f9ade9036166cd7dfc2101b2c90187860 Mon Sep 17 00:00:00 2001 From: David Newell Date: Thu, 8 Feb 2024 12:51:03 +0000 Subject: [PATCH 4/6] feat: account for persistence for canvas recording (#1006) --- playground/nextjs/package.json | 2 +- playground/nextjs/pages/canvas.tsx | 2 +- playground/nextjs/pnpm-lock.yaml | 13 +++++-- .../replay/sessionrecording.test.ts | 34 +++++++++++----- src/constants.ts | 1 + src/extensions/replay/sessionrecording.ts | 39 ++++++++++--------- 6 files changed, 57 insertions(+), 34 deletions(-) diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 277cc23c4..248f09b3f 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -16,7 +16,7 @@ "eslint": "8.34.0", "eslint-config-next": "13.1.6", "next": "13.5.6", - "posthog-js": "^1.88.1", + "posthog-js": "^1.103.1", "react": "18.2.0", "react-dom": "18.2.0", "typescript": "4.9.5" diff --git a/playground/nextjs/pages/canvas.tsx b/playground/nextjs/pages/canvas.tsx index f1fdfa586..7c96cd0a8 100644 --- a/playground/nextjs/pages/canvas.tsx +++ b/playground/nextjs/pages/canvas.tsx @@ -2,7 +2,7 @@ import React from 'react' import Head from 'next/head' import { useEffect, useRef } from 'react' -export default function Home() { +export default function Canvas() { const ref = useRef(null) useEffect(() => { diff --git a/playground/nextjs/pnpm-lock.yaml b/playground/nextjs/pnpm-lock.yaml index c4473ad29..7aebcd79b 100644 --- a/playground/nextjs/pnpm-lock.yaml +++ b/playground/nextjs/pnpm-lock.yaml @@ -27,8 +27,8 @@ dependencies: specifier: 13.5.6 version: 13.5.6(react-dom@18.2.0)(react@18.2.0) posthog-js: - specifier: ^1.88.1 - version: 1.100.0 + specifier: ^1.103.1 + version: 1.105.4 react: specifier: 18.2.0 version: 18.2.0 @@ -1880,10 +1880,15 @@ packages: source-map-js: 1.0.2 dev: false - /posthog-js@1.100.0: - resolution: {integrity: sha512-r2XZEiHQ9mBK7D1G9k57I8uYZ2kZTAJ0OCX6K/OOdCWN8jKPhw3h5F9No5weilP6eVAn+hrsy7NvPV7SCX7gMg==} + /posthog-js@1.105.4: + resolution: {integrity: sha512-hazxQYi4nxSqktu0Hh1xCV+sJCpN8mp5E5Ei/cfEa2nsb13xQbzn81Lf3VIDA0xMU1mXxNRStntlY267eQVC/w==} dependencies: fflate: 0.4.8 + preact: 10.19.4 + dev: false + + /preact@10.19.4: + resolution: {integrity: sha512-dwaX5jAh0Ga8uENBX1hSOujmKWgx9RtL80KaKUFLc6jb4vCEAc3EeZ0rnQO/FO4VgjfPMfoLFWnNG8bHuZ9VLw==} dev: false /prelude-ls@1.2.1: diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index c76fb3fdc..afc18094b 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -7,6 +7,7 @@ import { SESSION_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE, + SESSION_RECORDING_CANVAS_RECORDING, } from '../../../constants' import { SessionIdManager } from '../../../sessionid' import { @@ -294,6 +295,22 @@ describe('SessionRecording', () => { expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(true) }) + it('stores true in persistence if canvas is enabled from the server', () => { + posthog.persistence?.register({ [SESSION_RECORDING_CANVAS_RECORDING]: undefined }) + + sessionRecording.afterDecideResponse( + makeDecideResponse({ + sessionRecording: { endpoint: '/s/', recordCanvas: true, canvasFps: 6, canvasQuality: '0.2' }, + }) + ) + + expect(posthog.get_property(SESSION_RECORDING_CANVAS_RECORDING)).toEqual({ + enabled: true, + fps: 6, + quality: '0.2', + }) + }) + it('stores false in persistence if recording is not enabled from the server', () => { posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) @@ -424,16 +441,15 @@ describe('SessionRecording', () => { describe('canvas', () => { it('passes the remote config to rrweb', () => { - sessionRecording.startRecordingIfEnabled() + posthog.persistence?.register({ + [SESSION_RECORDING_CANVAS_RECORDING]: { + enabled: true, + fps: 6, + quality: 0.2, + }, + }) - sessionRecording.afterDecideResponse( - makeDecideResponse({ - sessionRecording: { endpoint: '/s/', recordCanvas: true, canvasFps: 6, canvasQuality: '0.2' }, - }) - ) - expect(sessionRecording['_recordCanvas']).toStrictEqual(true) - expect(sessionRecording['_canvasFps']).toStrictEqual(6) - expect(sessionRecording['_canvasQuality']).toStrictEqual(0.2) + sessionRecording.startRecordingIfEnabled() sessionRecording['_onScriptLoaded']() expect(assignableWindow.rrwebRecord).toHaveBeenCalledWith( diff --git a/src/constants.ts b/src/constants.ts index 7c0fda8de..8423864c3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,6 +15,7 @@ export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled export const CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE = '$console_log_recording_enabled_server_side' export const SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE = '$session_recording_recorder_version_server_side' // follows rrweb versioning export const SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE = '$session_recording_network_payload_capture' +export const SESSION_RECORDING_CANVAS_RECORDING = '$session_recording_canvas_recording' export const SESSION_ID = '$sesid' export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled' export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags' diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 5310f277b..240d40ccd 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -3,6 +3,7 @@ import { SESSION_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, + SESSION_RECORDING_CANVAS_RECORDING, SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE, } from '../../constants' import { @@ -123,9 +124,6 @@ export class SessionRecording { private _linkedFlag: string | null = null private _sampleRate: number | null = null private _minimumDuration: number | null = null - private _recordCanvas: boolean = false - private _canvasFps: number | null = null - private _canvasQuality: number | null = null private _fullSnapshotTimer?: number @@ -172,6 +170,17 @@ export class SessionRecording { return enabled_client_side ?? enabled_server_side } + private get canvasRecording(): { enabled: boolean; fps: number; quality: number } | undefined { + const canvasRecording_server_side = this.instance.get_property(SESSION_RECORDING_CANVAS_RECORDING) + return canvasRecording_server_side && canvasRecording_server_side.fps && canvasRecording_server_side.quality + ? { + enabled: canvasRecording_server_side.enabled, + fps: canvasRecording_server_side.fps, + quality: canvasRecording_server_side.quality, + } + : undefined + } + private get recordingVersion() { const recordingVersion_server_side = this.instance.get_property(SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE) const recordingVersion_client_side = this.instance.config.session_recording?.recorderVersion @@ -318,6 +327,11 @@ export class SessionRecording { capturePerformance: response.capturePerformance, ...response.sessionRecording?.networkPayloadCapture, }, + [SESSION_RECORDING_CANVAS_RECORDING]: { + enabled: response.sessionRecording?.recordCanvas, + fps: response.sessionRecording?.canvasFps, + quality: response.sessionRecording?.canvasQuality, + }, }) } @@ -328,19 +342,6 @@ export class SessionRecording { const receivedMinimumDuration = response.sessionRecording?.minimumDurationMilliseconds this._minimumDuration = _isUndefined(receivedMinimumDuration) ? null : receivedMinimumDuration - const receivedRecordCanvas = response.sessionRecording?.recordCanvas - this._recordCanvas = - _isUndefined(receivedRecordCanvas) || _isNull(receivedRecordCanvas) ? false : receivedRecordCanvas - - const receivedCanvasFps = response.sessionRecording?.canvasFps - this._canvasFps = _isUndefined(receivedCanvasFps) ? null : receivedCanvasFps - - const receivedCanvasQuality = response.sessionRecording?.canvasQuality - this._canvasQuality = - _isUndefined(receivedCanvasQuality) || _isNull(receivedCanvasQuality) - ? null - : parseFloat(receivedCanvasQuality) - this._linkedFlag = response.sessionRecording?.linkedFlag || null if (response.sessionRecording?.endpoint) { @@ -549,10 +550,10 @@ export class SessionRecording { } } - if (this._recordCanvas && !_isNull(this._canvasFps) && !_isNull(this._canvasQuality)) { + if (this.canvasRecording && this.canvasRecording.enabled) { sessionRecordingOptions.recordCanvas = true - sessionRecordingOptions.sampling = { canvas: this._canvasFps } - sessionRecordingOptions.dataURLOptions = { type: 'image/webp', quality: this._canvasQuality } + sessionRecordingOptions.sampling = { canvas: this.canvasRecording.fps } + sessionRecordingOptions.dataURLOptions = { type: 'image/webp', quality: this.canvasRecording.quality } } if (!this.rrwebRecord) { From 3632db2037db97cda119bdeecdf25972a542b3f7 Mon Sep 17 00:00:00 2001 From: daibhin Date: Thu, 8 Feb 2024 12:51:39 +0000 Subject: [PATCH 5/6] chore: Bump version to 1.105.5 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11fdb11ef..4a0462b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.105.5 - 2024-02-08 + +- feat: account for persistence for canvas recording (#1006) +- chore: improve template to account for backwards compatibility (#1007) + ## 1.105.4 - 2024-02-07 - feat: Add dynamic routing of ingestion endpoints (#986) diff --git a/package.json b/package.json index 38f87b918..0256edb07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.105.4", + "version": "1.105.5", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From 33c65fba91861b6c99904da7dc9a9c59a06f6da0 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Thu, 8 Feb 2024 13:26:55 +0000 Subject: [PATCH 6/6] chore: test stopping and starting (#1009) --- cypress/e2e/session-recording.cy.ts | 81 ++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index 6b033503f..e4dfbd2b6 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -3,6 +3,38 @@ import { _isNull } from '../../src/utils/type-utils' import { start } from '../support/setup' +function ensureRecordingIsStopped() { + cy.get('[data-cy-input]') + .type('hello posthog!') + .wait(250) + .then(() => { + cy.phCaptures({ full: true }).then((captures) => { + // should be no captured data + expect(captures.map((c) => c.event)).to.deep.equal([]) + }) + }) +} + +function ensureActivitySendsSnapshots() { + cy.get('[data-cy-input]') + .type('hello posthog!') + .wait('@session-recording') + .then(() => { + cy.phCaptures({ full: true }).then((captures) => { + expect(captures.map((c) => c.event)).to.deep.equal(['$snapshot']) + expect(captures[0]['properties']['$snapshot_data']).to.have.length.above(14).and.below(39) + // a meta and then a full snapshot + expect(captures[0]['properties']['$snapshot_data'][0].type).to.equal(4) // meta + expect(captures[0]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot + expect(captures[0]['properties']['$snapshot_data'][2].type).to.equal(5) // custom event with options + // Making a set from the rest should all be 3 - incremental snapshots + expect(new Set(captures[0]['properties']['$snapshot_data'].slice(3).map((s) => s.type))).to.deep.equal( + new Set([3]) + ) + }) + }) +} + describe('Session recording', () => { describe('array.full.js', () => { it('captures session events', () => { @@ -57,26 +89,39 @@ describe('Session recording', () => { }) it('captures session events', () => { + cy.phCaptures({ full: true }).then((captures) => { + // should be a pageview at the beginning + expect(captures.map((c) => c.event)).to.deep.equal(['$pageview']) + }) + cy.resetPhCaptures() + + let startingSessionId: string | null = null + cy.posthog().then((ph) => { + startingSessionId = ph.get_session_id() + }) + cy.get('[data-cy-input]').type('hello world! ') cy.wait(500) - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait('@session-recording') - .then(() => { - cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview and a $snapshot - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) - expect(captures[1]['properties']['$snapshot_data']).to.have.length.above(33).and.below(38) - // a meta and then a full snapshot - expect(captures[1]['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(captures[1]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot - expect(captures[1]['properties']['$snapshot_data'][2].type).to.equal(5) // custom event with options - // Making a set from the rest should all be 3 - incremental snapshots - expect( - new Set(captures[1]['properties']['$snapshot_data'].slice(3).map((s) => s.type)) - ).to.deep.equal(new Set([3])) - }) - }) + ensureActivitySendsSnapshots() + cy.posthog().then((ph) => { + ph.stopSessionRecording() + }) + cy.resetPhCaptures() + ensureRecordingIsStopped() + + // restarting recording + cy.posthog().then((ph) => { + ph.startSessionRecording() + }) + ensureActivitySendsSnapshots() + + // the session id is not rotated by stopping and starting the recording + cy.posthog().then((ph) => { + const secondSessionId = ph.get_session_id() + expect(startingSessionId).not.to.be.null + expect(secondSessionId).not.to.be.null + expect(secondSessionId).to.equal(startingSessionId) + }) }) it('captures snapshots when the mouse moves', () => {