diff --git a/.eslintrc.js b/.eslintrc.js index 0d07187e5..02b03cf7f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,7 +10,6 @@ const rules = { 'no-prototype-builtins': 'off', 'no-empty': 'off', 'no-console': 'error', - 'no-restricted-globals': ['error', 'document', 'window'], } const extend = [ @@ -54,6 +53,13 @@ module.exports = { }, }, overrides: [ + { + files: 'src/**/*', + rules: { + ...rules, + 'no-restricted-globals': ['error', 'document', 'window'], + }, + }, { files: 'src/__tests__/**/*', // the same set of config as in the root diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e58fcfa9..9044ea8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.90.1 - 2023-11-15 + +- fix: seek subdomain correctly (#888) + ## 1.90.0 - 2023-11-15 - fix(surveys): prioritize question button text field and thank you countdown is not automatic (#893) diff --git a/package.json b/package.json index 06bdc2898..a5506b0a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.90.0", + "version": "1.90.1", "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", diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index c82747c24..c74dd883f 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -19,6 +19,7 @@ import { RECORDING_MAX_EVENT_SIZE, SessionRecording, } from '../../../extensions/replay/sessionrecording' +import { assignableWindow } from '../../../utils/globals' // Type and source defined here designate a non-user-generated recording event @@ -52,8 +53,8 @@ describe('SessionRecording', () => { let onFeatureFlagsCallback: ((flags: string[]) => void) | null beforeEach(() => { - ;(window as any).rrwebRecord = jest.fn() - ;(window as any).rrwebConsoleRecord = { + assignableWindow.rrwebRecord = jest.fn() + assignableWindow.rrwebConsoleRecord = { getRecordConsolePlugin: jest.fn(), } @@ -193,7 +194,7 @@ describe('SessionRecording', () => { beforeEach(() => { jest.spyOn(sessionRecording, 'startRecordingIfEnabled') ;(loadScript as any).mockImplementation((_path: any, callback: any) => callback()) - ;(window as any).rrwebRecord = jest.fn(({ emit }) => { + assignableWindow.rrwebRecord = jest.fn(({ emit }) => { _emit = emit return () => {} }) @@ -277,11 +278,11 @@ describe('SessionRecording', () => { describe('recording', () => { beforeEach(() => { const mockFullSnapshot = jest.fn() - ;(window as any).rrwebRecord = jest.fn(({ emit }) => { + assignableWindow.rrwebRecord = jest.fn(({ emit }) => { _emit = emit return () => {} }) - ;(window as any).rrwebRecord.takeFullSnapshot = mockFullSnapshot + assignableWindow.rrwebRecord.takeFullSnapshot = mockFullSnapshot ;(loadScript as any).mockImplementation((_path: any, callback: any) => callback()) }) @@ -356,7 +357,7 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/', sampleRate: '0.50' }, }) ) - const emitValues = [] + const emitValues: string[] = [] let lastSessionId = sessionRecording['sessionId'] for (let i = 0; i < 100; i++) { @@ -368,7 +369,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionId']).not.toBe(lastSessionId) lastSessionId = sessionRecording['sessionId'] - emitValues.push(sessionRecording['status']) + emitValues.push(sessionRecording.status) } // the random number generator won't always be exactly 0.5, but it should be close @@ -384,7 +385,7 @@ describe('SessionRecording', () => { // maskAllInputs should change from default // someUnregisteredProp should not be present - expect((window as any).rrwebRecord).toHaveBeenCalledWith({ + expect(assignableWindow.rrwebRecord).toHaveBeenCalledWith({ emit: expect.anything(), maskAllInputs: false, blockClass: 'ph-no-capture', @@ -680,7 +681,7 @@ describe('SessionRecording', () => { sessionRecording.startRecordingIfEnabled() - expect((window as any).rrwebConsoleRecord.getRecordConsolePlugin).not.toHaveBeenCalled() + expect(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin).not.toHaveBeenCalled() }) it('if enabled, plugin is used', () => { @@ -688,7 +689,7 @@ describe('SessionRecording', () => { sessionRecording.startRecordingIfEnabled() - expect((window as any).rrwebConsoleRecord.getRecordConsolePlugin).toHaveBeenCalled() + expect(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin).toHaveBeenCalled() }) }) @@ -710,32 +711,32 @@ describe('SessionRecording', () => { sessionIdGeneratorMock.mockImplementation(() => 'newSessionId') windowIdGeneratorMock.mockImplementation(() => 'newWindowId') _emit(createIncrementalSnapshot()) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalled() }) it('sends a full snapshot if there is a new window id and the event is not type FullSnapshot or Meta', () => { sessionIdGeneratorMock.mockImplementation(() => 'old-session-id') windowIdGeneratorMock.mockImplementation(() => 'newWindowId') _emit(createIncrementalSnapshot()) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalled() }) it('does not send a full snapshot if there is a new session/window id and the event is type FullSnapshot or Meta', () => { sessionIdGeneratorMock.mockImplementation(() => 'newSessionId') windowIdGeneratorMock.mockImplementation(() => 'newWindowId') _emit(createIncrementalSnapshot({ type: META_EVENT_TYPE })) - expect((window as any).rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() }) it('does not send a full snapshot if there is not a new session or window id', () => { - ;(window as any).rrwebRecord.takeFullSnapshot.mockClear() + assignableWindow.rrwebRecord.takeFullSnapshot.mockClear() sessionIdGeneratorMock.mockImplementation(() => 'old-session-id') windowIdGeneratorMock.mockImplementation(() => 'old-window-id') sessionManager.resetSessionId() _emit(createIncrementalSnapshot()) - expect((window as any).rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() + expect(assignableWindow.rrwebRecord.takeFullSnapshot).not.toHaveBeenCalled() }) }) @@ -843,7 +844,7 @@ describe('SessionRecording', () => { it('takes a full snapshot for the first _emit', () => { emitAtDateTime(startingDate) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) }) it('does not take a full snapshot for the second _emit', () => { @@ -857,7 +858,7 @@ describe('SessionRecording', () => { startingDate.getMinutes() + 1 ) ) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) }) it('does not change session id for a second _emit', () => { @@ -899,7 +900,7 @@ describe('SessionRecording', () => { startingDate.getMinutes() + 2 ) ) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) }) it('sends a full snapshot if the session is rotated because session has been inactive for 30 minutes', () => { @@ -925,7 +926,7 @@ describe('SessionRecording', () => { emitAtDateTime(inactivityThresholdLater) expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) it('sends a full snapshot if the session is rotated because max time has passed', () => { @@ -950,7 +951,7 @@ describe('SessionRecording', () => { emitAtDateTime(moreThanADayLater) expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) }) @@ -960,7 +961,7 @@ describe('SessionRecording', () => { const lastActivityTimestamp = sessionRecording['_lastActivityTimestamp'] expect(lastActivityTimestamp).toBeGreaterThan(0) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(0) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(0) _emit({ event: 123, @@ -973,7 +974,7 @@ describe('SessionRecording', () => { expect(sessionRecording['isIdle']).toEqual(false) expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) _emit({ event: 123, @@ -985,7 +986,7 @@ describe('SessionRecording', () => { }) expect(sessionRecording['isIdle']).toEqual(false) expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) // this triggers idle state and isn't a user interaction so does not take a full snapshot _emit({ @@ -998,7 +999,7 @@ describe('SessionRecording', () => { }) expect(sessionRecording['isIdle']).toEqual(true) expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) // this triggers idle state _and_ is a user interaction, so we take a full snapshot _emit({ @@ -1013,7 +1014,7 @@ describe('SessionRecording', () => { expect(sessionRecording['_lastActivityTimestamp']).toEqual( lastActivityTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 2000 ) - expect((window as any).rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) }) }) @@ -1046,7 +1047,7 @@ describe('SessionRecording', () => { describe('buffering minimum duration', () => { beforeEach(() => { - ;(window as any).rrwebRecord = jest.fn(({ emit }) => { + assignableWindow.rrwebRecord = jest.fn(({ emit }) => { _emit = emit return () => {} }) diff --git a/src/__tests__/extensions/toolbar.test.ts b/src/__tests__/extensions/toolbar.test.ts index 27f63b418..c938cdaaa 100644 --- a/src/__tests__/extensions/toolbar.test.ts +++ b/src/__tests__/extensions/toolbar.test.ts @@ -2,7 +2,7 @@ import { Toolbar } from '../../extensions/toolbar' import { _isString, _isUndefined } from '../../utils/type-utils' import { PostHog } from '../../posthog-core' import { PostHogConfig, ToolbarParams } from '../../types' -import { window } from '../../utils/globals' +import { assignableWindow, window } from '../../utils/globals' jest.mock('../../utils', () => ({ ...jest.requireActual('../../utils'), @@ -31,8 +31,8 @@ describe('Toolbar', () => { }) beforeEach(() => { - ;(window as any).ph_load_toolbar = jest.fn() - delete (window as any)['_postHogToolbarLoaded'] + assignableWindow.ph_load_toolbar = jest.fn() + delete assignableWindow['_postHogToolbarLoaded'] }) describe('maybeLoadToolbar', () => { @@ -40,7 +40,8 @@ describe('Toolbar', () => { getItem: jest.fn(), setItem: jest.fn(), } - const history = { replaceState: jest.fn() } + const storage = localStorage as unknown as Storage + const history = { replaceState: jest.fn() } as unknown as History const defaultHashState = { action: 'ph_authorize', @@ -71,7 +72,7 @@ describe('Toolbar', () => { .join('&') } - const aLocation = (hash?: string) => { + const aLocation = (hash?: string): Location => { if (_isUndefined(hash)) { hash = withHash(withHashParamsFrom()) } @@ -80,7 +81,7 @@ describe('Toolbar', () => { hash: `#${hash}`, pathname: 'pathname', search: '?search', - } + } as Location } beforeEach(() => { @@ -91,7 +92,7 @@ describe('Toolbar', () => { it('should initialize the toolbar when the hash state contains action "ph_authorize"', () => { // the default hash state in the test setup contains the action "ph_authorize" - toolbar.maybeLoadToolbar(aLocation(), localStorage, history) + toolbar.maybeLoadToolbar(aLocation(), storage, history) expect(toolbar.loadToolbar).toHaveBeenCalledWith({ ...toolbarParams, @@ -105,7 +106,7 @@ describe('Toolbar', () => { localStorage.getItem.mockImplementation(() => JSON.stringify(toolbarParams)) const hashState = { ...defaultHashState, action: undefined } - toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom(hashState))), localStorage, history) + toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom(hashState))), storage, history) expect(toolbar.loadToolbar).toHaveBeenCalledWith({ ...toolbarParams, @@ -114,14 +115,14 @@ describe('Toolbar', () => { }) it('should NOT initialize the toolbar when the activation query param does not exist', () => { - expect(toolbar.maybeLoadToolbar(aLocation(''), localStorage, history)).toEqual(false) + expect(toolbar.maybeLoadToolbar(aLocation(''), storage, history)).toEqual(false) expect(toolbar.loadToolbar).not.toHaveBeenCalled() }) it('should return false when parsing invalid JSON from fragment state', () => { expect( - toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom('literally'))), localStorage, history) + toolbar.maybeLoadToolbar(aLocation(withHash(withHashParamsFrom('literally'))), storage, history) ).toEqual(false) expect(toolbar.loadToolbar).not.toHaveBeenCalled() }) @@ -129,7 +130,7 @@ describe('Toolbar', () => { it('should work if calling toolbar params `__posthog`', () => { toolbar.maybeLoadToolbar( aLocation(withHash(withHashParamsFrom(defaultHashState, '__posthog'))), - localStorage, + storage, history ) expect(toolbar.loadToolbar).toHaveBeenCalledWith({ ...toolbarParams, ...defaultHashState, source: 'url' }) @@ -138,7 +139,7 @@ describe('Toolbar', () => { it('should use the apiURL in the hash if available', () => { toolbar.maybeLoadToolbar( aLocation(withHash(withHashParamsFrom({ ...defaultHashState, apiURL: 'blabla' }))), - localStorage, + storage, history ) @@ -154,7 +155,7 @@ describe('Toolbar', () => { describe('load and close toolbar', () => { it('should persist for next time', () => { expect(toolbar.loadToolbar(toolbarParams)).toBe(true) - expect(JSON.parse((window as any).localStorage.getItem('_postHogToolbarParams'))).toEqual({ + expect(JSON.parse(window.localStorage.getItem('_postHogToolbarParams') ?? '')).toEqual({ ...toolbarParams, apiURL: 'http://api.example.com', }) @@ -162,7 +163,7 @@ describe('Toolbar', () => { it('should load if not previously loaded', () => { expect(toolbar.loadToolbar(toolbarParams)).toBe(true) - expect((window as any).ph_load_toolbar).toHaveBeenCalledWith( + expect(assignableWindow.ph_load_toolbar).toHaveBeenCalledWith( { ...toolbarParams, apiURL: 'http://api.example.com' }, instance ) @@ -181,7 +182,7 @@ describe('Toolbar', () => { it('should load if not previously loaded', () => { expect(toolbar.loadToolbar(minimalToolbarParams)).toBe(true) - expect((window as any).ph_load_toolbar).toHaveBeenCalledWith( + expect(assignableWindow.ph_load_toolbar).toHaveBeenCalledWith( { ...minimalToolbarParams, apiURL: 'http://api.example.com', diff --git a/src/__tests__/storage.js b/src/__tests__/storage.js deleted file mode 100644 index edb229c4e..000000000 --- a/src/__tests__/storage.js +++ /dev/null @@ -1,45 +0,0 @@ -import { resetSessionStorageSupported, sessionStore } from '../storage' - -describe('sessionStore', () => { - it('stores objects as strings', () => { - sessionStore.set('foo', { bar: 'baz' }) - expect(sessionStore.get('foo')).toEqual('{"bar":"baz"}') - }) - it('stores and retrieves an object untouched', () => { - const obj = { bar: 'baz' } - sessionStore.set('foo', obj) - expect(sessionStore.parse('foo')).toEqual(obj) - }) - it('stores and retrieves a string untouched', () => { - const str = 'hey hey' - sessionStore.set('foo', str) - expect(sessionStore.parse('foo')).toEqual(str) - }) - it('returns null if the key does not exist', () => { - expect(sessionStore.parse('baz')).toEqual(null) - }) - it('remove deletes an item from storage', () => { - const str = 'hey hey' - sessionStore.set('foo', str) - expect(sessionStore.parse('foo')).toEqual(str) - sessionStore.remove('foo') - expect(sessionStore.parse('foo')).toEqual(null) - }) - - describe('sessionStore.is_supported', () => { - beforeEach(() => { - // Reset the sessionStorageSupported before each test. Otherwise, we'd just be testing the cached value. - // eslint-disable-next-line no-unused-vars - resetSessionStorageSupported() - }) - it('returns false if sessionStorage is undefined', () => { - const sessionStorage = global.window.sessionStorage - delete global.window.sessionStorage - expect(sessionStore.is_supported()).toEqual(false) - global.window.sessionStorage = sessionStorage - }) - it('returns true by default', () => { - expect(sessionStore.is_supported()).toEqual(true) - }) - }) -}) diff --git a/src/__tests__/storage.test.ts b/src/__tests__/storage.test.ts new file mode 100644 index 000000000..f36e5094f --- /dev/null +++ b/src/__tests__/storage.test.ts @@ -0,0 +1,86 @@ +import { window } from '../../src/utils/globals' +import { resetSessionStorageSupported, seekFirstNonPublicSubDomain, sessionStore } from '../storage' + +describe('sessionStore', () => { + describe('seekFirstNonPublicSubDomain', () => { + const mockDocumentDotCookie = { + value_: '', + + get cookie() { + return this.value_ + }, + + set cookie(value) { + //needs to refuse known public suffixes, like a browser would + // value arrives like dmn_chk_1699961248575=1;domain=.uk + const domain = value.split('domain=') + if (['.uk', '.com', '.au', '.com.au', '.co.uk'].includes(domain[1])) return + this.value_ += value + ';' + }, + } + test.each([ + { + candidate: 'www.google.co.uk', + expected: 'google.co.uk', + }, + { + candidate: 'www.google.com', + expected: 'google.com', + }, + { + candidate: 'www.google.com.au', + expected: 'google.com.au', + }, + { + candidate: 'localhost', + expected: '', + }, + ])(`%s subdomain check`, ({ candidate, expected }) => { + expect(seekFirstNonPublicSubDomain(candidate, mockDocumentDotCookie as unknown as Document)).toEqual( + expected + ) + }) + }) + + it('stores objects as strings', () => { + sessionStore.set('foo', { bar: 'baz' }) + expect(sessionStore.get('foo')).toEqual('{"bar":"baz"}') + }) + it('stores and retrieves an object untouched', () => { + const obj = { bar: 'baz' } + sessionStore.set('foo', obj) + expect(sessionStore.parse('foo')).toEqual(obj) + }) + it('stores and retrieves a string untouched', () => { + const str = 'hey hey' + sessionStore.set('foo', str) + expect(sessionStore.parse('foo')).toEqual(str) + }) + it('returns null if the key does not exist', () => { + expect(sessionStore.parse('baz')).toEqual(null) + }) + it('remove deletes an item from storage', () => { + const str = 'hey hey' + sessionStore.set('foo', str) + expect(sessionStore.parse('foo')).toEqual(str) + sessionStore.remove('foo') + expect(sessionStore.parse('foo')).toEqual(null) + }) + + describe('sessionStore.is_supported', () => { + beforeEach(() => { + // Reset the sessionStorageSupported before each test. Otherwise, we'd just be testing the cached value. + // eslint-disable-next-line no-unused-vars + resetSessionStorageSupported() + }) + it('returns false if sessionStorage is undefined', () => { + const sessionStorage = (window as any).sessionStorage + delete (window as any).sessionStorage + expect(sessionStore.is_supported()).toEqual(false) + ;(window as any).sessionStorage = sessionStorage + }) + it('returns true by default', () => { + expect(sessionStore.is_supported()).toEqual(true) + }) + }) +}) diff --git a/src/__tests__/utils/event-utils.test.ts b/src/__tests__/utils/event-utils.test.ts index 1e996c227..4dd7027e5 100644 --- a/src/__tests__/utils/event-utils.test.ts +++ b/src/__tests__/utils/event-utils.test.ts @@ -1,8 +1,6 @@ import { _info } from '../../utils/event-utils' import * as globals from '../../utils/globals' -jest.mock('../../utils/globals') - describe(`event-utils`, () => { describe('properties', () => { it('should have $host and $pathname in properties', () => { diff --git a/src/constants.ts b/src/constants.ts index ec9cbddc9..7c0fda8de 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,6 +14,7 @@ export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_si export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled_server_side' 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_ID = '$sesid' export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled' export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags' diff --git a/src/decide.ts b/src/decide.ts index 4c0c648f9..e8b05ae21 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -6,7 +6,7 @@ import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './con import { _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' -import { window, document } from './utils/globals' +import { window, document, assignableWindow } from './utils/globals' export class Decide { instance: PostHog @@ -113,7 +113,7 @@ export class Decide { apiHost[apiHost.length - 1] === '/' && url[0] === '/' ? url.substring(1) : url, ].join('') - ;(window as any)[`__$$ph_site_app_${id}`] = this.instance + assignableWindow[`__$$ph_site_app_${id}`] = this.instance loadScript(scriptUrl, (err) => { if (err) { diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 0a3cff8b5..9f4fd8cfd 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -2,6 +2,7 @@ import { CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_IS_SAMPLED, + SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE, } from '../../constants' import { @@ -21,7 +22,7 @@ import { _timestamp, loadScript } from '../../utils' import { _isBoolean, _isFunction, _isNull, _isNumber, _isObject, _isString, _isUndefined } from '../../utils/type-utils' import { logger } from '../../utils/logger' -import { window } from '../../utils/globals' +import { assignableWindow, window } from '../../utils/globals' import { buildNetworkRequestOptions } from './config' const BASE_ENDPOINT = '/s/' @@ -92,7 +93,6 @@ export class SessionRecording { private receivedDecide: boolean private rrwebRecord: rrwebRecord | undefined private isIdle = false - private _networkPayloadCapture: Pick | undefined = undefined private _linkedFlagSeen: boolean = false private _lastActivityTimestamp: number = Date.now() @@ -148,6 +148,19 @@ export class SessionRecording { return recordingVersion_client_side || recordingVersion_server_side || 'v1' } + private get networkPayloadCapture(): Pick | undefined { + const networkPayloadCapture_server_side = this.instance.get_property(SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE) + const networkPayloadCapture_client_side = { + recordHeaders: this.instance.config.session_recording?.recordHeaders, + recordBody: this.instance.config.session_recording?.recordBody, + } + const headersEnabled = + networkPayloadCapture_client_side?.recordHeaders || networkPayloadCapture_server_side?.recordHeaders + const bodyEnabled = + networkPayloadCapture_client_side?.recordBody || networkPayloadCapture_server_side?.recordBody + return headersEnabled || bodyEnabled ? { recordHeaders: headersEnabled, recordBody: bodyEnabled } : undefined + } + /** * defaults to buffering mode until a decide response is received * once a decide response is received status can be disabled, active or sampled @@ -252,11 +265,10 @@ export class SessionRecording { [SESSION_RECORDING_ENABLED_SERVER_SIDE]: !!response['sessionRecording'], [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: response.sessionRecording?.consoleLogRecordingEnabled, [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: response.sessionRecording?.recorderVersion, + [SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: response.sessionRecording?.networkPayloadCapture, }) } - this._networkPayloadCapture = response.sessionRecording?.networkPayloadCapture - const receivedSampleRate = response.sessionRecording?.sampleRate this._sampleRate = _isUndefined(receivedSampleRate) || _isNull(receivedSampleRate) ? null : parseFloat(receivedSampleRate) @@ -469,17 +481,15 @@ export class SessionRecording { const plugins = [] - if ((window as any).rrwebConsoleRecord && this.isConsoleLogCaptureEnabled) { - plugins.push((window as any).rrwebConsoleRecord.getRecordConsolePlugin()) + if (assignableWindow.rrwebConsoleRecord && this.isConsoleLogCaptureEnabled) { + plugins.push(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin()) } - if (this._networkPayloadCapture) { - if (_isFunction((window as any).getRecordNetworkPlugin)) { - plugins.push( - (window as any).getRecordNetworkPlugin( - buildNetworkRequestOptions(this.instance.config, this._networkPayloadCapture) - ) + if (this.networkPayloadCapture && _isFunction(assignableWindow.getRecordNetworkPlugin)) { + plugins.push( + assignableWindow.getRecordNetworkPlugin( + buildNetworkRequestOptions(this.instance.config, this.networkPayloadCapture) ) - } + ) } this.stopRrweb = this.rrwebRecord({ diff --git a/src/extensions/toolbar.ts b/src/extensions/toolbar.ts index cb29f1b93..4aa6be883 100644 --- a/src/extensions/toolbar.ts +++ b/src/extensions/toolbar.ts @@ -4,7 +4,7 @@ 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 } from '../utils/globals' +import { window, document, assignableWindow } from '../utils/globals' // TRICKY: Many web frameworks will modify the route on load, potentially before posthog is initialized. // To get ahead of this we grab it as soon as the posthog-js is parsed @@ -120,11 +120,11 @@ export class Toolbar { } loadToolbar(params?: ToolbarParams): boolean { - if (!window || (window as any)['_postHogToolbarLoaded']) { + if (!window || assignableWindow['_postHogToolbarLoaded']) { return false } // only load the toolbar once, even if there are multiple instances of PostHogLib - ;(window as any)['_postHogToolbarLoaded'] = true + 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. @@ -152,12 +152,12 @@ export class Toolbar { logger.error('Failed to load toolbar', err) return } - ;((window as any)['ph_load_toolbar'] || (window as any)['ph_load_editor'])(toolbarParams, this.instance) + ;(assignableWindow['ph_load_toolbar'] || assignableWindow['ph_load_editor'])(toolbarParams, this.instance) }) // Turbolinks doesn't fire an onload event but does replace the entire body, including the toolbar. // Thus, we ensure the toolbar is only loaded inside the body, and then reloaded on turbolinks:load. _register_event(window, 'turbolinks:load', () => { - ;(window as any)['_postHogToolbarLoaded'] = false + assignableWindow['_postHogToolbarLoaded'] = false this.loadToolbar(toolbarParams) }) return true diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 8fc8147dd..cbc1e0fc1 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -9,7 +9,7 @@ import { _safewrap_class, isCrossDomainCookie, } from './utils' -import { window } from './utils/globals' +import { assignableWindow, window } from './utils/globals' import { autocapture } from './autocapture' import { PostHogFeatureFlags } from './posthog-featureflags' import { PostHogPersistence } from './posthog-persistence' @@ -2104,7 +2104,7 @@ const override_ph_init_func = function () { ;(posthog_master as any) = instance if (init_type === InitType.INIT_SNIPPET) { - ;(window as any)[PRIMARY_INSTANCE_NAME] = posthog_master + assignableWindow[PRIMARY_INSTANCE_NAME] = posthog_master } extend_mp() return instance @@ -2148,10 +2148,10 @@ const add_dom_loaded_handler = function () { export function init_from_snippet(): void { init_type = InitType.INIT_SNIPPET - if (_isUndefined((window as any).posthog)) { - ;(window as any).posthog = [] + if (_isUndefined(assignableWindow.posthog)) { + assignableWindow.posthog = [] } - posthog_master = (window as any).posthog + posthog_master = assignableWindow.posthog if (posthog_master['__loaded'] || (posthog_master['config'] && posthog_master['persistence'])) { // lib has already been loaded at least once; we don't want to override the global object this time so bomb early diff --git a/src/storage.ts b/src/storage.ts index 8f6a0fb78..bac722397 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -6,7 +6,71 @@ import { _isNull, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { window, document } from './utils/globals' +const Y1970 = 'Thu, 01 Jan 1970 00:00:00 GMT' + +/** + * Browsers don't offer a way to check if something is a public suffix + * e.g. `.com.au`, `.io`, `.org.uk` + * + * But they do reject cookies set on public suffixes + * Setting a cookie on `.co.uk` would mean it was sent for every `.co.uk` site visited + * + * So, we can use this to check if a domain is a public suffix + * by trying to set a cookie on a subdomain of the provided hostname + * until the browser accepts it + * + * inspired by https://github.com/AngusFu/browser-root-domain + */ +export function seekFirstNonPublicSubDomain(hostname: string, cookieJar = document): string { + if (!cookieJar) { + return '' + } + if (['localhost', '127.0.0.1'].includes(hostname)) return '' + + const list = hostname.split('.') + let len = list.length + const key = 'dmn_chk_' + +new Date() + const R = new RegExp('(^|;)\\s*' + key + '=1') + + while (len--) { + const candidate = list.slice(len).join('.') + const candidateCookieValue = key + '=1;domain=.' + candidate + + // try to set cookie + cookieJar.cookie = candidateCookieValue + + if (R.test(cookieJar.cookie)) { + // the cookie was accepted by the browser, remove the test cookie + cookieJar.cookie = candidateCookieValue + ';expires=' + Y1970 + return candidate + } + } + return '' +} + const DOMAIN_MATCH_REGEX = /[a-z0-9][a-z0-9-]+\.[a-z]{2,}$/i +const originalCookieDomainFn = (hostname: string): string => { + const matches = hostname.match(DOMAIN_MATCH_REGEX) + return matches ? matches[0] : '' +} + +export function chooseCookieDomain(hostname: string, cross_subdomain: boolean | undefined): string { + if (cross_subdomain) { + // NOTE: Could we use this for cross domain tracking? + let matchedSubDomain = seekFirstNonPublicSubDomain(hostname) + + if (!matchedSubDomain) { + const originalMatch = originalCookieDomainFn(hostname) + if (originalMatch !== matchedSubDomain) { + logger.info('Warning: cookie subdomain discovery mismatch', originalMatch, matchedSubDomain) + } + matchedSubDomain = originalMatch + } + + return matchedSubDomain ? '; domain=.' + matchedSubDomain : '' + } + return '' +} // Methods partially borrowed from quirksmode.org/js/cookies.html export const cookieStore: PersistentStore = { @@ -52,17 +116,10 @@ export const cookieStore: PersistentStore = { return } try { - let cdomain = '', - expires = '', + let expires = '', secure = '' - if (cross_subdomain) { - // NOTE: Could we use this for cross domain tracking? - const matches = document?.location.hostname.match(DOMAIN_MATCH_REGEX), - domain = matches ? matches[0] : '' - - cdomain = domain ? '; domain=.' + domain : '' - } + const cdomain = chooseCookieDomain(document.location.hostname, cross_subdomain) if (days) { const date = new Date() diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts index 3e166aeab..6e202a678 100644 --- a/src/utils/event-utils.ts +++ b/src/utils/event-utils.ts @@ -3,7 +3,7 @@ import { _isNull, _isUndefined } from './type-utils' import { Properties } from '../types' import Config from '../config' import { _each, _extend, _includes, _strip_empty_properties, _timestamp } from './index' -import { document, window, userAgent } from './globals' +import { document, window, userAgent, assignableWindow } from './globals' /** * Safari detection turns out to be complicted. For e.g. https://stackoverflow.com/a/29696509 @@ -270,7 +270,7 @@ export const _info = { _strip_empty_properties({ $os: os_name, $os_version: os_version, - $browser: _info.browser(userAgent, navigator.vendor, (window as any).opera), + $browser: _info.browser(userAgent, navigator.vendor, assignableWindow.opera), $device: _info.device(userAgent), $device_type: _info.deviceType(userAgent), }), @@ -279,7 +279,7 @@ export const _info = { $host: window?.location.host, $pathname: window?.location.pathname, $raw_user_agent: userAgent.length > 1000 ? userAgent.substring(0, 997) + '...' : userAgent, - $browser_version: _info.browserVersion(userAgent, navigator.vendor, (window as any).opera), + $browser_version: _info.browserVersion(userAgent, navigator.vendor, assignableWindow.opera), $browser_language: _info.browserLanguage(), $screen_height: window?.screen.height, $screen_width: window?.screen.width, @@ -303,10 +303,10 @@ export const _info = { _strip_empty_properties({ $os: os_name, $os_version: os_version, - $browser: _info.browser(userAgent, navigator.vendor, (window as any).opera), + $browser: _info.browser(userAgent, navigator.vendor, assignableWindow.opera), }), { - $browser_version: _info.browserVersion(userAgent, navigator.vendor, (window as any).opera), + $browser_version: _info.browserVersion(userAgent, navigator.vendor, assignableWindow.opera), } ) }, diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 523176d82..d90d41708 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -9,5 +9,6 @@ export const win: (Window & typeof globalThis) | undefined = typeof window !== ' const navigator = win?.navigator export const document = win?.document export const userAgent = navigator?.userAgent +export const assignableWindow: Window & typeof globalThis & Record = win ?? ({} as any) export { win as window } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 71c309e70..6bc11befb 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,13 +1,13 @@ import Config from '../config' import { _isUndefined } from './type-utils' -import { window } from './globals' +import { assignableWindow, window } from './globals' const LOGGER_PREFIX = '[PostHog.js]' export const logger = { _log: (level: 'log' | 'warn' | 'error', ...args: any[]) => { if ( window && - (Config.DEBUG || (window as any).POSTHOG_DEBUG) && + (Config.DEBUG || assignableWindow.POSTHOG_DEBUG) && !_isUndefined(window.console) && window.console ) { diff --git a/testcafe/e2e.spec.js b/testcafe/e2e.spec.js index 8f92b356f..4ce39936b 100644 --- a/testcafe/e2e.spec.js +++ b/testcafe/e2e.spec.js @@ -1,6 +1,7 @@ import { t } from 'testcafe' import { retryUntilResults, queryAPI, initPosthog, captureLogger, staticFilesMock } from './helpers' +// eslint-disable-next-line no-undef fixture('posthog.js capture') .page('http://localhost:8000/playground/cypress-full/index.html') .requestHooks(captureLogger, staticFilesMock) @@ -8,6 +9,7 @@ fixture('posthog.js capture') const browserLogs = await t.getBrowserConsoleMessages() Object.keys(browserLogs).forEach((level) => { browserLogs[level].forEach((line) => { + // eslint-disable-next-line no-console console.log(`Browser ${level}:`, line) }) }) diff --git a/testcafe/helpers.js b/testcafe/helpers.js index 1b149c941..8bc1f0c33 100644 --- a/testcafe/helpers.js +++ b/testcafe/helpers.js @@ -5,12 +5,14 @@ import fetch from 'node-fetch' // NOTE: These tests are run against a dedicated test project in PostHog cloud // but can be overridden to call a local API when running locally +// eslint-disable-next-line no-undef +const currentEnv = process.env const { POSTHOG_PROJECT_KEY, POSTHOG_API_KEY, POSTHOG_API_HOST = 'https://app.posthog.com', POSTHOG_API_PROJECT = '11213', -} = process.env +} = currentEnv const HEADERS = { Authorization: `Bearer ${POSTHOG_API_KEY}` } @@ -26,18 +28,20 @@ export const captureLogger = RequestLogger(/ip=1/, { export const staticFilesMock = RequestMock() .onRequestTo(/array.full.js/) .respond((req, res) => { + // eslint-disable-next-line no-undef const arrayjs = fs.readFileSync(path.resolve(__dirname, '../dist/array.full.js')) res.setBody(arrayjs) }) .onRequestTo(/playground/) .respond((req, res) => { + // eslint-disable-next-line no-undef const html = fs.readFileSync(path.resolve(__dirname, '../playground/cypress-full/index.html')) res.setBody(html) }) export const initPosthog = (config) => { return ClientFunction((configParams = {}) => { - var testSessionId = Math.round(Math.random() * 10000000000).toString() + const testSessionId = Math.round(Math.random() * 10000000000).toString() configParams.debug = true window.posthog.init(configParams.api_key, configParams) window.posthog.register({ @@ -70,6 +74,7 @@ export async function retryUntilResults(operation, target_results, limit = 6, de if (results.length >= target_results) { resolve(results) } else { + // eslint-disable-next-line no-console console.log(`Expected ${target_results} results, got ${results.length} (attempt ${count})`) attempt(count + 1, resolve, reject) } @@ -78,6 +83,8 @@ export async function retryUntilResults(operation, target_results, limit = 6, de }, delay) } + // new Promise isn't supported in IE11, but we don't care in these tests + // eslint-disable-next-line compat/compat return new Promise((...args) => attempt(0, ...args)) } @@ -90,6 +97,7 @@ export async function queryAPI(testSessionId) { const data = await response.text() if (!response.ok) { + // eslint-disable-next-line no-console console.error('Bad Response', response.status, data) throw new Error('Bad Response') }