diff --git a/packages/session-replay-browser/package.json b/packages/session-replay-browser/package.json index 156a90781..ac1c15ba3 100644 --- a/packages/session-replay-browser/package.json +++ b/packages/session-replay-browser/package.json @@ -31,7 +31,8 @@ "publish": "node ../../scripts/publish/upload-to-s3.js", "test": "jest", "typecheck": "tsc -p ./tsconfig.json", - "version": "yarn add @amplitude/analytics-types@\">=1 <3\" @amplitude/analytics-client-common@\">=1 <3\" @amplitude/analytics-core@\">=1 <3\"" + "version": "yarn add @amplitude/analytics-types@\">=1 <3\" @amplitude/analytics-client-common@\">=1 <3\" @amplitude/analytics-core@\">=1 <3\"", + "version-file": "node -p \"'export const VERSION = \\'' + require('./package.json').version + '\\';'\" > src/version.ts" }, "bugs": { "url": "https://github.com/amplitude/Amplitude-TypeScript/issues" diff --git a/packages/session-replay-browser/src/config.ts b/packages/session-replay-browser/src/config.ts index 4f6482d4e..8acd5dc28 100644 --- a/packages/session-replay-browser/src/config.ts +++ b/packages/session-replay-browser/src/config.ts @@ -2,6 +2,7 @@ import { FetchTransport } from '@amplitude/analytics-client-common'; import { Config, Logger } from '@amplitude/analytics-core'; import { LogLevel } from '@amplitude/analytics-types'; import { SessionReplayConfig as ISessionReplayConfig, SessionReplayOptions } from './typings/session-replay'; +import { DEFAULT_SAMPLE_RATE } from './constants'; export const getDefaultConfig = () => ({ flushMaxRetries: 2, @@ -29,7 +30,7 @@ export class SessionReplayConfig extends Config implements ISessionReplayConfig : defaultConfig.flushMaxRetries; this.apiKey = apiKey; - this.sampleRate = options.sampleRate || 1; + this.sampleRate = options.sampleRate || DEFAULT_SAMPLE_RATE; this.deviceId = options.deviceId; this.sessionId = options.sessionId; diff --git a/packages/session-replay-browser/src/constants.ts b/packages/session-replay-browser/src/constants.ts index a1350cff6..415860376 100644 --- a/packages/session-replay-browser/src/constants.ts +++ b/packages/session-replay-browser/src/constants.ts @@ -6,6 +6,7 @@ export const DEFAULT_EVENT_PROPERTY_PREFIX = '[Amplitude]'; export const DEFAULT_SESSION_REPLAY_PROPERTY = `${DEFAULT_EVENT_PROPERTY_PREFIX} Session Recorded`; export const DEFAULT_SESSION_START_EVENT = 'session_start'; export const DEFAULT_SESSION_END_EVENT = 'session_end'; +export const DEFAULT_SAMPLE_RATE = 0; export const BLOCK_CLASS = 'amp-block'; export const MASK_TEXT_CLASS = 'amp-mask'; diff --git a/packages/session-replay-browser/src/helpers.ts b/packages/session-replay-browser/src/helpers.ts index 3070fb1ca..d4581e788 100644 --- a/packages/session-replay-browser/src/helpers.ts +++ b/packages/session-replay-browser/src/helpers.ts @@ -1,3 +1,4 @@ +import { getGlobalScope } from '@amplitude/analytics-client-common'; import { UNMASK_TEXT_CLASS } from './constants'; export const maskInputFn = (text: string, element: HTMLElement) => { @@ -25,3 +26,8 @@ export const isSessionInSample = function (sessionId: number, sampleRate: number const mod = absHashMultiply % 100; return mod / 100 < sampleRate; }; + +export const getCurrentUrl = () => { + const globalScope = getGlobalScope(); + return globalScope?.location ? globalScope.location.href : ''; +}; diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 912fd42d5..ff314e035 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -6,6 +6,7 @@ import { pack, record } from 'rrweb'; import { SessionReplayConfig } from './config'; import { BLOCK_CLASS, + DEFAULT_SAMPLE_RATE, DEFAULT_SESSION_REPLAY_PROPERTY, MASK_TEXT_CLASS, MAX_EVENT_LIST_SIZE_IN_BYTES, @@ -17,7 +18,7 @@ import { STORAGE_PREFIX, defaultSessionStore, } from './constants'; -import { isSessionInSample, maskInputFn } from './helpers'; +import { isSessionInSample, maskInputFn, getCurrentUrl } from './helpers'; import { MAX_RETRIES_EXCEEDED_MESSAGE, MISSING_API_KEY_MESSAGE, @@ -37,6 +38,7 @@ import { SessionReplayContext, SessionReplayOptions, } from './typings/session-replay'; +import { VERSION } from './version'; export class SessionReplay implements AmplitudeSessionReplay { name = '@amplitude/session-replay-browser'; @@ -127,7 +129,7 @@ export class SessionReplay implements AmplitudeSessionReplay { this.stopRecordingEvents = null; } catch (error) { const typedError = error as Error; - this.loggerProvider.error(`Error occurred while stopping recording: ${typedError.toString()}`); + this.loggerProvider.warn(`Error occurred while stopping recording: ${typedError.toString()}`); } const sessionIdToSend = sessionId || this.config?.sessionId; if (this.events.length && sessionIdToSend) { @@ -183,7 +185,7 @@ export class SessionReplay implements AmplitudeSessionReplay { getShouldRecord() { if (!this.config) { - this.loggerProvider.warn(`Session is not being recorded due to lack of config, please call sessionReplay.init.`); + this.loggerProvider.error(`Session is not being recorded due to lack of config, please call sessionReplay.init.`); return false; } const globalScope = getGlobalScope(); @@ -269,7 +271,7 @@ export class SessionReplay implements AmplitudeSessionReplay { recordCanvas: false, errorHandler: (error) => { const typedError = error as Error; - this.loggerProvider.error('Error while recording: ', typedError.toString()); + this.loggerProvider.warn('Error while recording: ', typedError.toString()); return true; }, @@ -357,6 +359,10 @@ export class SessionReplay implements AmplitudeSessionReplay { await Promise.all(list.map((context) => this.send(context, useRetry))); } + getSampleRate() { + return this.config?.sampleRate || DEFAULT_SAMPLE_RATE; + } + getServerUrl() { if (this.config?.serverZone === ServerZone.EU) { return SESSION_REPLAY_EU_SERVER_URL; @@ -383,6 +389,9 @@ export class SessionReplay implements AmplitudeSessionReplay { if (!deviceId) { return this.completeRequest({ context, err: MISSING_DEVICE_ID_MESSAGE }); } + const url = getCurrentUrl(); + const version = VERSION; + const sampleRate = this.getSampleRate(); const urlParams = new URLSearchParams({ device_id: deviceId, @@ -394,12 +403,16 @@ export class SessionReplay implements AmplitudeSessionReplay { version: 1, events: context.events, }; + try { const options: RequestInit = { headers: { 'Content-Type': 'application/json', Accept: '*/*', Authorization: `Bearer ${apiKey}`, + 'X-Client-Version': version, + 'X-Client-Url': url, + 'X-Client-Sample-Rate': `${sampleRate}`, }, body: JSON.stringify(payload), method: 'POST', @@ -457,7 +470,7 @@ export class SessionReplay implements AmplitudeSessionReplay { return storedReplaySessionContexts; } catch (e) { - this.loggerProvider.error(`${STORAGE_FAILURE}: ${e as string}`); + this.loggerProvider.warn(`${STORAGE_FAILURE}: ${e as string}`); } return undefined; } @@ -485,7 +498,7 @@ export class SessionReplay implements AmplitudeSessionReplay { }; }); } catch (e) { - this.loggerProvider.error(`${STORAGE_FAILURE}: ${e as string}`); + this.loggerProvider.warn(`${STORAGE_FAILURE}: ${e as string}`); } } @@ -520,14 +533,14 @@ export class SessionReplay implements AmplitudeSessionReplay { return sessionMap; }); } catch (e) { - this.loggerProvider.error(`${STORAGE_FAILURE}: ${e as string}`); + this.loggerProvider.warn(`${STORAGE_FAILURE}: ${e as string}`); } } completeRequest({ context, err, success }: { context: SessionReplayContext; err?: string; success?: string }) { context.sessionId && this.cleanUpSessionEventsStore(context.sessionId, context.sequenceId); if (err) { - this.loggerProvider.error(err); + this.loggerProvider.warn(err); } else if (success) { this.loggerProvider.log(success); } diff --git a/packages/session-replay-browser/src/version.ts b/packages/session-replay-browser/src/version.ts new file mode 100644 index 000000000..8f7fb4cf8 --- /dev/null +++ b/packages/session-replay-browser/src/version.ts @@ -0,0 +1 @@ +export const VERSION = '0.2.5'; diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index d595190db..ea56eb23a 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -3,11 +3,12 @@ import * as AnalyticsClientCommon from '@amplitude/analytics-client-common'; import { LogLevel, Logger, ServerZone } from '@amplitude/analytics-types'; import * as IDBKeyVal from 'idb-keyval'; import * as RRWeb from 'rrweb'; -import { DEFAULT_SESSION_REPLAY_PROPERTY, SESSION_REPLAY_SERVER_URL } from '../src/constants'; +import { DEFAULT_SAMPLE_RATE, DEFAULT_SESSION_REPLAY_PROPERTY, SESSION_REPLAY_SERVER_URL } from '../src/constants'; import * as Helpers from '../src/helpers'; import { UNEXPECTED_ERROR_MESSAGE, getSuccessMessage } from '../src/messages'; import { SessionReplay } from '../src/session-replay'; import { IDBStore, RecordingStatus, SessionReplayConfig, SessionReplayOptions } from '../src/typings/session-replay'; +import { VERSION } from '../src/version'; jest.mock('idb-keyval'); type MockedIDBKeyVal = jest.Mocked; @@ -37,6 +38,7 @@ describe('SessionReplayPlugin', () => { const { get, update } = IDBKeyVal as MockedIDBKeyVal; const { record } = RRWeb as MockedRRWeb; let originalFetch: typeof global.fetch; + let globalSpy: jest.SpyInstance; const mockLoggerProvider: MockedLogger = { error: jest.fn(), log: jest.fn(), @@ -73,7 +75,7 @@ describe('SessionReplayPlugin', () => { status: 200, }), ) as jest.Mock; - jest.spyOn(AnalyticsClientCommon, 'getGlobalScope').mockReturnValue(mockGlobalScope); + globalSpy = jest.spyOn(AnalyticsClientCommon, 'getGlobalScope').mockReturnValue(mockGlobalScope); }); afterEach(() => { jest.resetAllMocks(); @@ -254,7 +256,14 @@ describe('SessionReplayPlugin', () => { const sessionReplay = new SessionReplay(); await sessionReplay.init(apiKey, mockOptions).promise; sessionReplay.getShouldRecord = () => false; + const result = sessionReplay.getSessionRecordingProperties(); + expect(result).toEqual({}); + }); + test('should return an default sample rate if not set', async () => { + const sessionReplay = new SessionReplay(); + await sessionReplay.init(apiKey, mockOptions).promise; + sessionReplay.getShouldRecord = () => false; const result = sessionReplay.getSessionRecordingProperties(); expect(result).toEqual({}); }); @@ -632,7 +641,7 @@ describe('SessionReplayPlugin', () => { }); describe('stopRecordingAndSendEvents', () => { - test('it should catch errors', async () => { + test('it should catch errors as warnings', async () => { const sessionReplay = new SessionReplay(); await sessionReplay.init(apiKey, mockOptions).promise; const mockStopRecordingEvents = jest.fn().mockImplementation(() => { @@ -641,7 +650,7 @@ describe('SessionReplayPlugin', () => { sessionReplay.stopRecordingEvents = mockStopRecordingEvents; sessionReplay.stopRecordingAndSendEvents(); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalled(); + expect(mockLoggerProvider.warn).toHaveBeenCalled(); }); test('it should send events for passed session', async () => { const sessionReplay = new SessionReplay(); @@ -838,7 +847,7 @@ describe('SessionReplayPlugin', () => { const recordArg = record.mock.calls[0][0]; const errorHandlerReturn = recordArg?.errorHandler && recordArg?.errorHandler(new Error('test error')); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalled(); + expect(mockLoggerProvider.warn).toHaveBeenCalled(); expect(errorHandlerReturn).toBe(true); }); }); @@ -1006,6 +1015,15 @@ describe('SessionReplayPlugin', () => { }); }); + describe('getSampleRate', () => { + test('should return default value if no config set', () => { + const sessionReplay = new SessionReplay(); + sessionReplay.config = undefined; + const sampleRate = sessionReplay.getSampleRate(); + expect(sampleRate).toEqual(DEFAULT_SAMPLE_RATE); + }); + }); + describe('send', () => { test('should not send anything if api key not set', async () => { const sessionReplay = new SessionReplay(); @@ -1020,7 +1038,7 @@ describe('SessionReplayPlugin', () => { await sessionReplay.send(context); expect(fetch).not.toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalled(); + expect(mockLoggerProvider.warn).toHaveBeenCalled(); }); test('should not send anything if device id not set', async () => { const sessionReplay = new SessionReplay(); @@ -1035,7 +1053,7 @@ describe('SessionReplayPlugin', () => { await sessionReplay.send(context); expect(fetch).not.toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalled(); + expect(mockLoggerProvider.warn).toHaveBeenCalled(); }); test('should make a request correctly', async () => { const sessionReplay = new SessionReplay(); @@ -1054,7 +1072,14 @@ describe('SessionReplayPlugin', () => { 'https://api-sr.amplitude.com/sessions/v2/track?device_id=1a2b3c&session_id=123&seq_number=1', { body: JSON.stringify({ version: 1, events: [mockEventString] }), - headers: { Accept: '*/*', 'Content-Type': 'application/json', Authorization: 'Bearer static_key' }, + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + Authorization: 'Bearer static_key', + 'X-Client-Sample-Rate': `${DEFAULT_SAMPLE_RATE}`, + 'X-Client-Url': '', + 'X-Client-Version': VERSION, + }, method: 'POST', }, ); @@ -1077,7 +1102,14 @@ describe('SessionReplayPlugin', () => { 'https://api-sr.eu.amplitude.com/sessions/v2/track?device_id=1a2b3c&session_id=123&seq_number=1', { body: JSON.stringify({ version: 1, events: [mockEventString] }), - headers: { Accept: '*/*', 'Content-Type': 'application/json', Authorization: 'Bearer static_key' }, + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + Authorization: 'Bearer static_key', + 'X-Client-Sample-Rate': `${DEFAULT_SAMPLE_RATE}`, + 'X-Client-Url': '', + 'X-Client-Version': VERSION, + }, method: 'POST', }, ); @@ -1235,9 +1267,9 @@ describe('SessionReplayPlugin', () => { await runScheduleTimers(); expect(fetch).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(mockLoggerProvider.error.mock.calls[0][0]).toEqual('API Failure'); + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual('API Failure'); }); test('should not retry for 400 error', async () => { (fetch as jest.Mock) @@ -1261,7 +1293,7 @@ describe('SessionReplayPlugin', () => { await runScheduleTimers(); expect(fetch).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); }); test('should not retry for 413 error', async () => { (fetch as jest.Mock) @@ -1282,7 +1314,7 @@ describe('SessionReplayPlugin', () => { await runScheduleTimers(); expect(fetch).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); }); test('should handle retry for 500 error', async () => { (fetch as jest.Mock) @@ -1353,9 +1385,9 @@ describe('SessionReplayPlugin', () => { await runScheduleTimers(); expect(fetch).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(mockLoggerProvider.error.mock.calls[0][0]).toEqual(UNEXPECTED_ERROR_MESSAGE); + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual(UNEXPECTED_ERROR_MESSAGE); }); }); @@ -1366,9 +1398,9 @@ describe('SessionReplayPlugin', () => { get.mockImplementationOnce(() => Promise.reject('error')); await sessionReplay.getAllSessionEventsFromStore(); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(mockLoggerProvider.error.mock.calls[0][0]).toEqual( + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual( 'Failed to store session replay events in IndexedDB: error', ); }); @@ -1498,9 +1530,9 @@ describe('SessionReplayPlugin', () => { update.mockImplementationOnce(() => Promise.reject('error')); await sessionReplay.cleanUpSessionEventsStore(123, 1); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(mockLoggerProvider.error.mock.calls[0][0]).toEqual( + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual( 'Failed to store session replay events in IndexedDB: error', ); }); @@ -1657,9 +1689,9 @@ describe('SessionReplayPlugin', () => { await sessionReplay.storeEventsForSession([mockEventString], 0, mockOptions.sessionId as number); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockLoggerProvider.error).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(mockLoggerProvider.error.mock.calls[0][0]).toEqual( + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual( 'Failed to store session replay events in IndexedDB: error', ); }); @@ -1762,4 +1794,22 @@ describe('SessionReplayPlugin', () => { }); }); }); + + describe('getCurrentUrl', () => { + test('returns url if exists', () => { + globalSpy.mockImplementation(() => ({ + location: { + href: 'https://www.amplitude.com', + }, + })); + const url = Helpers.getCurrentUrl(); + expect(url).toEqual('https://www.amplitude.com'); + }); + + test('returns empty string if url does not exist', () => { + globalSpy.mockImplementation(() => undefined); + const url = Helpers.getCurrentUrl(); + expect(url).toEqual(''); + }); + }); });