diff --git a/packages/plugin-session-replay-browser/src/session-replay.ts b/packages/plugin-session-replay-browser/src/session-replay.ts index e33f78477..85604f2d8 100644 --- a/packages/plugin-session-replay-browser/src/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/session-replay.ts @@ -48,7 +48,7 @@ export class SessionReplayPlugin implements EnrichmentPlugin { instanceName: this.config.instanceName, deviceId: this.config.deviceId, optOut: this.config.optOut, - sessionId: this.config.sessionId, + sessionId: this.options.customSessionId ? undefined : this.config.sessionId, loggerProvider: this.config.loggerProvider, logLevel: this.config.logLevel, flushMaxRetries: this.config.flushMaxRetries, @@ -74,21 +74,41 @@ export class SessionReplayPlugin implements EnrichmentPlugin { async execute(event: Event) { try { - // On event, synchronize the session id to the what's on the browserConfig (source of truth) - // Choosing not to read from event object here, concerned about offline/delayed events messing up the state stored - // in SR. - if (this.config.sessionId && this.config.sessionId !== sessionReplay.getSessionId()) { - await sessionReplay.setSessionId(this.config.sessionId).promise; - } - // Treating config.sessionId as source of truth, if the event's session id doesn't match, the - // event is not of the current session (offline/late events). In that case, don't tag the events - if (this.config.sessionId && this.config.sessionId === event.session_id) { - const sessionRecordingProperties = sessionReplay.getSessionReplayProperties(); - event.event_properties = { - ...event.event_properties, - ...sessionRecordingProperties, - }; + if (this.options.customSessionId) { + const sessionId = this.options.customSessionId(event); + if (sessionId) { + // On event, synchronize the session id to the custom session id from the event. This may + // suffer from offline/delayed events messing up the state stored + if (sessionId !== sessionReplay.getSessionId()) { + await sessionReplay.setSessionId(sessionId).promise; + } + + const sessionRecordingProperties = sessionReplay.getSessionReplayProperties(); + event.event_properties = { + ...event.event_properties, + ...sessionRecordingProperties, + }; + } + } else { + // On event, synchronize the session id to the what's on the browserConfig (source of truth) + // Choosing not to read from event object here, concerned about offline/delayed events messing up the state stored + // in SR. + const sessionId: string | number | undefined = this.config.sessionId; + if (sessionId && sessionId !== sessionReplay.getSessionId()) { + await sessionReplay.setSessionId(sessionId).promise; + } + + // Treating config.sessionId as source of truth, if the event's session id doesn't match, the + // event is not of the current session (offline/late events). In that case, don't tag the events + if (sessionId && sessionId === event.session_id) { + const sessionRecordingProperties = sessionReplay.getSessionReplayProperties(); + event.event_properties = { + ...event.event_properties, + ...sessionRecordingProperties, + }; + } } + return Promise.resolve(event); } catch (error) { this.config.loggerProvider.error(`Session Replay: Failed to enrich event due to ${(error as Error).message}`); diff --git a/packages/plugin-session-replay-browser/src/typings/session-replay.ts b/packages/plugin-session-replay-browser/src/typings/session-replay.ts index 317baa1a6..7a58e7c0e 100644 --- a/packages/plugin-session-replay-browser/src/typings/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/typings/session-replay.ts @@ -1,3 +1,4 @@ +import { Event } from '@amplitude/analytics-types'; import { StoreType } from '@amplitude/session-replay-browser'; export type MaskLevel = @@ -26,4 +27,5 @@ export interface SessionReplayOptions { shouldInlineStylesheet?: boolean; performanceConfig?: SessionReplayPerformanceConfig; storeType?: StoreType; + customSessionId?: (event: Event) => string | undefined; } diff --git a/packages/plugin-session-replay-browser/test/session-replay.test.ts b/packages/plugin-session-replay-browser/test/session-replay.test.ts index 49437fbd7..e7101fa05 100644 --- a/packages/plugin-session-replay-browser/test/session-replay.test.ts +++ b/packages/plugin-session-replay-browser/test/session-replay.test.ts @@ -1,4 +1,4 @@ -import { BrowserClient, BrowserConfig, LogLevel, Logger, Plugin } from '@amplitude/analytics-types'; +import { BrowserClient, BrowserConfig, LogLevel, Logger, Plugin, Event } from '@amplitude/analytics-types'; import * as sessionReplayBrowser from '@amplitude/session-replay-browser'; import { SessionReplayPlugin, sessionReplayPlugin } from '../src/session-replay'; import { VERSION } from '../src/version'; @@ -304,6 +304,84 @@ describe('SessionReplayPlugin', () => { }); await sessionReplay.teardown?.(); }); + + test('should update the session id on any event when using custom session id', async () => { + const sessionReplay = sessionReplayPlugin({ + customSessionId: (event: Event) => { + const event_properties = event.event_properties as { [key: string]: any }; + if (!event_properties) { + return; + } + return event_properties['custom_session_id'] as string | undefined; + }, + }); + await sessionReplay.setup({ ...mockConfig }); + getSessionId.mockReturnValueOnce('test_122'); + const event = { + event_type: 'event_type', + event_properties: { + property_a: true, + property_b: 123, + custom_session_id: 'test_123', + }, + session_id: 124, + }; + + await sessionReplay.execute(event); + expect(setSessionId).toHaveBeenCalledTimes(1); + expect(setSessionId).toHaveBeenCalledWith('test_123'); + }); + + test('should not update the session id when using custom session id and it does not change', async () => { + const sessionReplay = sessionReplayPlugin({ + customSessionId: (event: Event) => { + const event_properties = event.event_properties as { [key: string]: any }; + if (!event_properties) { + return; + } + return event_properties['custom_session_id'] as string | undefined; + }, + }); + await sessionReplay.setup({ ...mockConfig }); + getSessionId.mockReturnValueOnce('test_123'); + const event = { + event_type: 'event_type', + event_properties: { + property_a: true, + property_b: 123, + custom_session_id: 'test_123', + }, + session_id: 124, + }; + + await sessionReplay.execute(event); + expect(setSessionId).not.toHaveBeenCalled(); + }); + + test('should do nothing when the custom session id cannot be found', async () => { + const sessionReplay = sessionReplayPlugin({ + customSessionId: (event: Event) => { + const event_properties = event.event_properties as { [key: string]: any }; + if (!event_properties) { + return; + } + return event_properties['custom_session_id'] as string | undefined; + }, + }); + await sessionReplay.setup({ ...mockConfig }); + const event = { + event_type: 'event_type', + event_properties: { + property_a: true, + property_b: 123, + }, + session_id: 124, + }; + + const enrichedEvent = await sessionReplay.execute(event); + expect(setSessionId).not.toHaveBeenCalled(); + expect(enrichedEvent).toEqual(event); + }); }); describe('getSessionReplayProperties', () => { diff --git a/packages/session-replay-browser/src/config/joined-config.ts b/packages/session-replay-browser/src/config/joined-config.ts index 19554d66b..7c74d2d05 100644 --- a/packages/session-replay-browser/src/config/joined-config.ts +++ b/packages/session-replay-browser/src/config/joined-config.ts @@ -46,7 +46,7 @@ export class SessionReplayJoinedConfigGenerator { this.remoteConfigFetch = remoteConfigFetch; } - async generateJoinedConfig(sessionId?: number): Promise { + async generateJoinedConfig(sessionId?: string | number): Promise { const config: SessionReplayJoinedConfig = { ...this.localConfig }; // Special case here as optOut is implemented via getter/setter config.optOut = this.localConfig.optOut; diff --git a/packages/session-replay-browser/src/config/types.ts b/packages/session-replay-browser/src/config/types.ts index ed22ba448..55f602379 100644 --- a/packages/session-replay-browser/src/config/types.ts +++ b/packages/session-replay-browser/src/config/types.ts @@ -67,7 +67,7 @@ export interface SessionReplayRemoteConfigFetch { } export interface SessionReplayJoinedConfigGenerator { - generateJoinedConfig: (sessionId?: number) => Promise; + generateJoinedConfig: (sessionId?: string | number) => Promise; } export interface SessionReplayVersion { diff --git a/packages/session-replay-browser/src/events/base-events-store.ts b/packages/session-replay-browser/src/events/base-events-store.ts index 8f22c9cda..44cfb5cfc 100644 --- a/packages/session-replay-browser/src/events/base-events-store.ts +++ b/packages/session-replay-browser/src/events/base-events-store.ts @@ -29,12 +29,12 @@ export abstract class BaseEventsStore implements EventsStore { } abstract addEventToCurrentSequence( - sessionId: number, + sessionId: string | number, event: string, ): Promise | undefined>; abstract getSequencesToSend(): Promise[] | undefined>; abstract storeCurrentSequence(sessionId: number): Promise | undefined>; - abstract storeSendingEvents(sessionId: number, events: Events): Promise; + abstract storeSendingEvents(sessionId: string | number, events: Events): Promise; abstract cleanUpSessionEventsStore(sessionId: number, sequenceId: KeyType): Promise; /** diff --git a/packages/session-replay-browser/src/events/event-compressor.ts b/packages/session-replay-browser/src/events/event-compressor.ts index 4581ec023..600e73356 100644 --- a/packages/session-replay-browser/src/events/event-compressor.ts +++ b/packages/session-replay-browser/src/events/event-compressor.ts @@ -6,7 +6,7 @@ import { getGlobalScope } from '@amplitude/analytics-client-common'; interface TaskQueue { event: eventWithTime; - sessionId: number; + sessionId: string | number; } const DEFAULT_TIMEOUT = 2000; @@ -46,7 +46,7 @@ export class EventCompressor { } // Add an event to the task queue if idle callback is supported or compress the event directly - public enqueueEvent(event: eventWithTime, sessionId: number): void { + public enqueueEvent(event: eventWithTime, sessionId: string | number): void { if (this.canUseIdleCallback && this.config.performanceConfig?.enabled) { this.config.loggerProvider.debug('Enqueuing event for processing during idle time.'); this.taskQueue.push({ event, sessionId }); @@ -86,7 +86,7 @@ export class EventCompressor { return JSON.stringify(packedEvent); }; - public addCompressedEvent = (event: eventWithTime, sessionId: number) => { + public addCompressedEvent = (event: eventWithTime, sessionId: string | number) => { const compressedEvent = this.compressEvent(event); if (this.eventsManager && this.deviceId) { diff --git a/packages/session-replay-browser/src/events/events-idb-store.ts b/packages/session-replay-browser/src/events/events-idb-store.ts index 558156b10..38e03451d 100644 --- a/packages/session-replay-browser/src/events/events-idb-store.ts +++ b/packages/session-replay-browser/src/events/events-idb-store.ts @@ -18,7 +18,7 @@ export interface SessionReplayDB extends DBSchema { sequencesToSend: { key: number; value: Omit, 'sequenceId'>; - indexes: { sessionId: number }; + indexes: { sessionId: string | number }; }; } @@ -105,7 +105,7 @@ export class SessionReplayEventsIDBStore extends BaseEventsStore { static async new( type: EventType, args: Omit, - sessionId?: number, + sessionId?: string | number, ): Promise { try { const dbSuffix = type === 'replay' ? '' : `_${type}`; @@ -173,7 +173,10 @@ export class SessionReplayEventsIDBStore extends BaseEventsStore { events: currentSequenceData.events, }); - await this.db.put<'sessionCurrentSequence'>(currentSequenceKey, { sessionId, events: [] }); + await this.db.put<'sessionCurrentSequence'>(currentSequenceKey, { + sessionId, + events: [], + }); return { ...currentSequenceData, @@ -251,7 +254,7 @@ export class SessionReplayEventsIDBStore extends BaseEventsStore { } }; - transitionFromKeyValStore = async (sessionId?: number) => { + transitionFromKeyValStore = async (sessionId?: string | number) => { try { const keyValDb = await keyValDatabaseExists(); if (!keyValDb) { diff --git a/packages/session-replay-browser/src/events/events-manager.ts b/packages/session-replay-browser/src/events/events-manager.ts index 4e79b8ee9..8a143db38 100644 --- a/packages/session-replay-browser/src/events/events-manager.ts +++ b/packages/session-replay-browser/src/events/events-manager.ts @@ -24,7 +24,7 @@ export const createEventsManager = async ({ type: Type; minInterval?: number; maxInterval?: number; - sessionId?: number; + sessionId?: string | number; payloadBatcher?: PayloadBatcher; storeType: StoreType; }): Promise> => { @@ -65,7 +65,7 @@ export const createEventsManager = async ({ sequenceId, }: { events: string[]; - sessionId: number; + sessionId: string | number; deviceId: string; sequenceId?: number; }) => { diff --git a/packages/session-replay-browser/src/events/events-memory-store.ts b/packages/session-replay-browser/src/events/events-memory-store.ts index 5f0ed015a..5c79aae12 100644 --- a/packages/session-replay-browser/src/events/events-memory-store.ts +++ b/packages/session-replay-browser/src/events/events-memory-store.ts @@ -2,15 +2,15 @@ import { Events, SendingSequencesReturn } from '../typings/session-replay'; import { BaseEventsStore } from './base-events-store'; export class InMemoryEventsStore extends BaseEventsStore { - private finalizedSequences: Record = {}; - private sequences: Record = {}; + private finalizedSequences: Record = {}; + private sequences: Record = {}; private sequenceId = 0; - private resetCurrentSequence(sessionId: number) { + private resetCurrentSequence(sessionId: string | number) { this.sequences[sessionId] = []; } - private addSequence(sessionId: number): SendingSequencesReturn { + private addSequence(sessionId: string | number): SendingSequencesReturn { const sequenceId = this.sequenceId++; const events = [...this.sequences[sessionId]]; this.finalizedSequences[sequenceId] = { sessionId, events }; @@ -26,7 +26,7 @@ export class InMemoryEventsStore extends BaseEventsStore { })); } - async storeCurrentSequence(sessionId: number): Promise | undefined> { + async storeCurrentSequence(sessionId: string | number): Promise | undefined> { if (!this.sequences[sessionId]) { return undefined; } diff --git a/packages/session-replay-browser/src/helpers.ts b/packages/session-replay-browser/src/helpers.ts index 7c5d9ec75..001808232 100644 --- a/packages/session-replay-browser/src/helpers.ts +++ b/packages/session-replay-browser/src/helpers.ts @@ -1,12 +1,14 @@ import { getGlobalScope } from '@amplitude/analytics-client-common'; -import { KB_SIZE, MASK_TEXT_CLASS, UNMASK_TEXT_CLASS } from './constants'; -import { DEFAULT_MASK_LEVEL, MaskLevel, PrivacyConfig, SessionReplayJoinedConfig } from './config/types'; -import { getInputType } from '@amplitude/rrweb-snapshot'; import { ServerZone } from '@amplitude/analytics-types'; +import { getInputType } from '@amplitude/rrweb-snapshot'; +import { DEFAULT_MASK_LEVEL, MaskLevel, PrivacyConfig, SessionReplayJoinedConfig } from './config/types'; import { - SESSION_REPLAY_EU_URL as SESSION_REPLAY_EU_SERVER_URL, + KB_SIZE, + MASK_TEXT_CLASS, + SESSION_REPLAY_EU_URL, SESSION_REPLAY_SERVER_URL, - SESSION_REPLAY_STAGING_URL as SESSION_REPLAY_STAGING_SERVER_URL, + SESSION_REPLAY_STAGING_URL, + UNMASK_TEXT_CLASS, } from './constants'; import { StorageData } from './typings/session-replay'; @@ -109,7 +111,7 @@ export const generateHashCode = function (str: string) { return hash; }; -export const isSessionInSample = function (sessionId: number, sampleRate: number) { +export const isSessionInSample = function (sessionId: string | number, sampleRate: number) { const hashNumber = generateHashCode(sessionId.toString()); const absHash = Math.abs(hashNumber); const absHashMultiply = absHash * 31; @@ -122,17 +124,17 @@ export const getCurrentUrl = () => { return globalScope?.location ? globalScope.location.href : ''; }; -export const generateSessionReplayId = (sessionId: number, deviceId: string): string => { +export const generateSessionReplayId = (sessionId: string | number, deviceId: string): string => { return `${deviceId}/${sessionId}`; }; export const getServerUrl = (serverZone?: keyof typeof ServerZone): string => { if (serverZone === ServerZone.STAGING) { - return SESSION_REPLAY_STAGING_SERVER_URL; + return SESSION_REPLAY_STAGING_URL; } if (serverZone === ServerZone.EU) { - return SESSION_REPLAY_EU_SERVER_URL; + return SESSION_REPLAY_EU_URL; } return SESSION_REPLAY_SERVER_URL; diff --git a/packages/session-replay-browser/src/hooks/click.ts b/packages/session-replay-browser/src/hooks/click.ts index 821bcf059..a70abd6be 100644 --- a/packages/session-replay-browser/src/hooks/click.ts +++ b/packages/session-replay-browser/src/hooks/click.ts @@ -21,7 +21,7 @@ export type ClickEvent = { export type ClickEventWithCount = ClickEvent & { count: number }; type Options = { - sessionId: number; + sessionId: string | number; deviceIdFn: () => string | undefined; eventsManager: AmplitudeSessionReplayEventsManager<'interaction', string>; }; diff --git a/packages/session-replay-browser/src/identifiers.ts b/packages/session-replay-browser/src/identifiers.ts index a12a573ba..a71122a9c 100644 --- a/packages/session-replay-browser/src/identifiers.ts +++ b/packages/session-replay-browser/src/identifiers.ts @@ -2,11 +2,11 @@ import { generateSessionReplayId } from './helpers'; import { SessionIdentifiers as ISessionIdentifiers } from './typings/session-replay'; export class SessionIdentifiers implements ISessionIdentifiers { - deviceId?: string | undefined; - sessionId?: number | undefined; - sessionReplayId?: string | undefined; + deviceId?: string; + sessionId?: string | number; + sessionReplayId?: string; - constructor({ sessionId, deviceId }: { sessionId?: number; deviceId?: string }) { + constructor({ sessionId, deviceId }: { sessionId?: string | number; deviceId?: string }) { this.deviceId = deviceId; this.sessionId = sessionId; diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index b0195fbf1..97708603d 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -147,11 +147,11 @@ export class SessionReplay implements AmplitudeSessionReplay { this.initialize(true); } - setSessionId(sessionId: number, deviceId?: string) { + setSessionId(sessionId: string | number, deviceId?: string) { return returnWrapper(this.asyncSetSessionId(sessionId, deviceId)); } - async asyncSetSessionId(sessionId: number, deviceId?: string) { + async asyncSetSessionId(sessionId: string | number, deviceId?: string) { const previousSessionId = this.identifiers && this.identifiers.sessionId; if (previousSessionId) { this.sendEvents(previousSessionId); @@ -230,7 +230,7 @@ export class SessionReplay implements AmplitudeSessionReplay { }); }; - sendEvents(sessionId?: number) { + sendEvents(sessionId?: string | number) { const sessionIdToSend = sessionId || this.identifiers?.sessionId; const deviceId = this.getDeviceId(); this.eventsManager && diff --git a/packages/session-replay-browser/src/typings/session-replay.ts b/packages/session-replay-browser/src/typings/session-replay.ts index c4f06a2f8..81cb3c13e 100644 --- a/packages/session-replay-browser/src/typings/session-replay.ts +++ b/packages/session-replay-browser/src/typings/session-replay.ts @@ -20,8 +20,8 @@ export type EventType = 'replay' | 'interaction'; export interface SessionReplayDestinationSessionMetadata { type: EventType; - sessionId: number; - deviceId?: string; + sessionId: string | number; + deviceId: string | undefined; version?: SessionReplayVersion; } @@ -41,7 +41,7 @@ export interface SessionReplayDestinationContext extends SessionReplayDestinatio export interface SendingSequencesReturn { sequenceId: KeyType; - sessionId: number; + sessionId: string | number; events: Events; } @@ -53,25 +53,28 @@ export interface EventsStore { /** * Moves current sequence of events to long term storage and resets short term storage. */ - storeCurrentSequence(sessionId: number): Promise | undefined>; + storeCurrentSequence(sessionId: string | number): Promise | undefined>; /** * Adds events to the current IDB sequence. Returns events that should be * sent to the track destination right away if should split events is true. */ - addEventToCurrentSequence(sessionId: number, event: string): Promise | undefined>; + addEventToCurrentSequence( + sessionId: string | number, + event: string, + ): Promise | undefined>; /** * Returns the sequence id associated with the events batch. * @returns the new sequence id or undefined if it cannot be determined or on any error. */ - storeSendingEvents(sessionId: number, events: Events): Promise; + storeSendingEvents(sessionId: string | number, events: Events): Promise; /** * Permanently removes the events batch for the session/sequence pair. */ - cleanUpSessionEventsStore(sessionId: number, sequenceId?: KeyType): Promise; + cleanUpSessionEventsStore(sessionId: string | number, sequenceId?: KeyType): Promise; } export interface SessionIdentifiers { deviceId?: string; - sessionId?: number; + sessionId?: string | number; sessionReplayId?: string; } @@ -79,8 +82,8 @@ export type SessionReplayOptions = Omit AmplitudeReturn; - setSessionId: (sessionId: number, deviceId?: string) => AmplitudeReturn; - getSessionId: () => number | undefined; + setSessionId: (sessionId: string | number, deviceId?: string) => AmplitudeReturn; + getSessionId: () => string | number | undefined; getSessionReplayProperties: () => { [key: string]: boolean | string | null }; flush: (useRetry: boolean) => Promise; shutdown: () => void; @@ -115,14 +118,14 @@ export interface SessionReplayEventsManager { event, deviceId, }: { - sessionId: number; + sessionId: string | number; event: { type: Type; data: Event }; deviceId: string; }): void; /** * Move events in short term storage to long term storage and send immediately to the track destination. */ - sendCurrentSequenceEvents({ sessionId, deviceId }: { sessionId: number; deviceId: string }): void; + sendCurrentSequenceEvents({ sessionId, deviceId }: { sessionId: string | number; deviceId: string }): void; /** * Flush the track destination queue immediately. This should invoke sends for all the events in the queue. */