Skip to content

Commit

Permalink
feat(session-replay): supporting string session id (#930)
Browse files Browse the repository at this point in the history
  • Loading branch information
junjie-amplitude authored Dec 6, 2024
2 parents 5d774fb + deecad4 commit eac7db8
Show file tree
Hide file tree
Showing 15 changed files with 171 additions and 63 deletions.
50 changes: 35 additions & 15 deletions packages/plugin-session-replay-browser/src/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Event } from '@amplitude/analytics-types';
import { StoreType } from '@amplitude/session-replay-browser';

export type MaskLevel =
Expand Down Expand Up @@ -26,4 +27,5 @@ export interface SessionReplayOptions {
shouldInlineStylesheet?: boolean;
performanceConfig?: SessionReplayPerformanceConfig;
storeType?: StoreType;
customSessionId?: (event: Event) => string | undefined;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class SessionReplayJoinedConfigGenerator {
this.remoteConfigFetch = remoteConfigFetch;
}

async generateJoinedConfig(sessionId?: number): Promise<SessionReplayJoinedConfig> {
async generateJoinedConfig(sessionId?: string | number): Promise<SessionReplayJoinedConfig> {
const config: SessionReplayJoinedConfig = { ...this.localConfig };
// Special case here as optOut is implemented via getter/setter
config.optOut = this.localConfig.optOut;
Expand Down
2 changes: 1 addition & 1 deletion packages/session-replay-browser/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export interface SessionReplayRemoteConfigFetch {
}

export interface SessionReplayJoinedConfigGenerator {
generateJoinedConfig: (sessionId?: number) => Promise<SessionReplayJoinedConfig>;
generateJoinedConfig: (sessionId?: string | number) => Promise<SessionReplayJoinedConfig>;
}

export interface SessionReplayVersion {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ export abstract class BaseEventsStore<KeyType> implements EventsStore<KeyType> {
}

abstract addEventToCurrentSequence(
sessionId: number,
sessionId: string | number,
event: string,
): Promise<SendingSequencesReturn<KeyType> | undefined>;
abstract getSequencesToSend(): Promise<SendingSequencesReturn<KeyType>[] | undefined>;
abstract storeCurrentSequence(sessionId: number): Promise<SendingSequencesReturn<KeyType> | undefined>;
abstract storeSendingEvents(sessionId: number, events: Events): Promise<KeyType | undefined>;
abstract storeSendingEvents(sessionId: string | number, events: Events): Promise<KeyType | undefined>;
abstract cleanUpSessionEventsStore(sessionId: number, sequenceId: KeyType): Promise<void>;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getGlobalScope } from '@amplitude/analytics-client-common';

interface TaskQueue {
event: eventWithTime;
sessionId: number;
sessionId: string | number;
}

const DEFAULT_TIMEOUT = 2000;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 7 additions & 4 deletions packages/session-replay-browser/src/events/events-idb-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface SessionReplayDB extends DBSchema {
sequencesToSend: {
key: number;
value: Omit<SendingSequencesReturn<number>, 'sequenceId'>;
indexes: { sessionId: number };
indexes: { sessionId: string | number };
};
}

Expand Down Expand Up @@ -105,7 +105,7 @@ export class SessionReplayEventsIDBStore extends BaseEventsStore<number> {
static async new(
type: EventType,
args: Omit<InstanceArgs, 'db'>,
sessionId?: number,
sessionId?: string | number,
): Promise<SessionReplayEventsIDBStore | undefined> {
try {
const dbSuffix = type === 'replay' ? '' : `_${type}`;
Expand Down Expand Up @@ -173,7 +173,10 @@ export class SessionReplayEventsIDBStore extends BaseEventsStore<number> {
events: currentSequenceData.events,
});

await this.db.put<'sessionCurrentSequence'>(currentSequenceKey, { sessionId, events: [] });
await this.db.put<'sessionCurrentSequence'>(currentSequenceKey, {
sessionId,
events: [],
});

return {
...currentSequenceData,
Expand Down Expand Up @@ -251,7 +254,7 @@ export class SessionReplayEventsIDBStore extends BaseEventsStore<number> {
}
};

transitionFromKeyValStore = async (sessionId?: number) => {
transitionFromKeyValStore = async (sessionId?: string | number) => {
try {
const keyValDb = await keyValDatabaseExists();
if (!keyValDb) {
Expand Down
4 changes: 2 additions & 2 deletions packages/session-replay-browser/src/events/events-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const createEventsManager = async <Type extends EventType>({
type: Type;
minInterval?: number;
maxInterval?: number;
sessionId?: number;
sessionId?: string | number;
payloadBatcher?: PayloadBatcher;
storeType: StoreType;
}): Promise<AmplitudeSessionReplayEventsManager<Type, string>> => {
Expand Down Expand Up @@ -65,7 +65,7 @@ export const createEventsManager = async <Type extends EventType>({
sequenceId,
}: {
events: string[];
sessionId: number;
sessionId: string | number;
deviceId: string;
sequenceId?: number;
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { Events, SendingSequencesReturn } from '../typings/session-replay';
import { BaseEventsStore } from './base-events-store';

export class InMemoryEventsStore extends BaseEventsStore<number> {
private finalizedSequences: Record<number, { sessionId: number; events: string[] }> = {};
private sequences: Record<number, string[]> = {};
private finalizedSequences: Record<number, { sessionId: string | number; events: string[] }> = {};
private sequences: Record<string | number, string[]> = {};
private sequenceId = 0;

private resetCurrentSequence(sessionId: number) {
private resetCurrentSequence(sessionId: string | number) {
this.sequences[sessionId] = [];
}

private addSequence(sessionId: number): SendingSequencesReturn<number> {
private addSequence(sessionId: string | number): SendingSequencesReturn<number> {
const sequenceId = this.sequenceId++;
const events = [...this.sequences[sessionId]];
this.finalizedSequences[sequenceId] = { sessionId, events };
Expand All @@ -26,7 +26,7 @@ export class InMemoryEventsStore extends BaseEventsStore<number> {
}));
}

async storeCurrentSequence(sessionId: number): Promise<SendingSequencesReturn<number> | undefined> {
async storeCurrentSequence(sessionId: string | number): Promise<SendingSequencesReturn<number> | undefined> {
if (!this.sequences[sessionId]) {
return undefined;
}
Expand Down
20 changes: 11 additions & 9 deletions packages/session-replay-browser/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/session-replay-browser/src/hooks/click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
};
Expand Down
8 changes: 4 additions & 4 deletions packages/session-replay-browser/src/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading

0 comments on commit eac7db8

Please sign in to comment.