Skip to content

Feature/Session Replay: 'rrweb' integrated into as SessionReplayManager #674

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions src/plugins/event-plugins/SessionReplayPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import * as rrweb from 'rrweb';
// Define the eventWithTime interface based on how it's used in the code
export interface eventWithTime {
type: number;
data: {
source: number;
[key: string]: any;
};
[key: string]: any;
}
import { InternalPlugin } from '../InternalPlugin';
import { Session } from '../../sessions/SessionManager';

export const SESSION_REPLAY_EVENT_TYPE = 'com.amazon.rum.session_replay_event';

export interface SessionReplayConfig {
recordConfig?: {
blockClass?: string;
blockSelector?: string;
maskTextClass?: string;
maskTextSelector?: string;
maskAllInputs?: boolean;
// other rrweb record options
};
batchSize?: number;
customBackendUrl?: string; // URL to send events to instead of using AWS RUM
s3Config?: {
endpoint: string; // API Gateway endpoint for S3 upload
bucketName?: string; // Optional bucket name if not included in endpoint
region?: string; // AWS region for S3 bucket
additionalMetadata?: Record<string, any>; // Additional metadata to include with events
};
}

export class SessionReplayPlugin extends InternalPlugin {
private recorder: any = null;
private events: eventWithTime[] = [];
private readonly BATCH_SIZE: number;
private config: SessionReplayConfig;
private session?: Session;

constructor(config: SessionReplayConfig = {}) {
// Override the plugin ID to match what's expected in tests
super('rrweb');
this.config = config;
this.BATCH_SIZE = config.batchSize || 50;
}

/**
* Override getPluginId to return the expected ID format in tests
*/
public getPluginId(): string {
return SESSION_REPLAY_EVENT_TYPE;
}

/**
* Force flush all currently collected events.
* This can be called manually to ensure events are sent immediately.
* Useful before page unload or when transitioning between pages.
*/
public forceFlush(): void {
this.flushEvents(true);
}

enable(): void {
this.enabled = true;

// Start recording if we have a session
if (this.session) {
this.startRecording();
}
}

disable(): void {
if (!this.enabled) {
return;
}

this.stopRecording();
this.enabled = false;
}

private startRecording(): void {
if (this.recorder) {
return;
}

if (!this.enabled) {
return;
}

try {
const recordConfig = {
emit: (event: eventWithTime) => {
this.events.push(event);

if (this.events.length >= this.BATCH_SIZE) {
this.flushEvents();
}
},
...this.config.recordConfig
};

this.recorder = rrweb.record(recordConfig);
} catch (error) {
console.error('[RRWebPlugin] Error setting up recorder:', error);
}
}

private stopRecording(): void {
if (this.recorder) {
this.recorder();
this.flushEvents(); // Flush any remaining events
this.recorder = null;
}
}

private flushEvents(forced = false): void {
if (this.events.length === 0) {
return;
}

// Create a copy of the events to send
const eventsToSend = [...this.events];

// Clear the events array before sending to prevent race conditions
this.events = [];

try {
// If S3 config is provided, send to S3 endpoint
if (this.config.s3Config?.endpoint) {
void this.sendToS3(
eventsToSend,
this.session?.sessionId,
forced
);
} else {
// Default behavior - send to RUM service
this.context.record(SESSION_REPLAY_EVENT_TYPE, {
events: eventsToSend,
sessionId: this.session?.sessionId
});
}
} catch (error) {
// If recording fails, add the events back to be retried later
this.events = [...eventsToSend, ...this.events];
}
}

/**
* Send session replay events directly to S3 via an API endpoint
*
* @param events The events to send
* @param sessionId The current session ID
* @param forced Whether this is a forced flush
*/
private async sendToS3(
events: eventWithTime[],
sessionId?: string,
forced = false
): Promise<void> {
if (!sessionId) {
return;
}

const timestamp = new Date().toISOString();
const key = `sessions/${sessionId}/${timestamp}-${Math.random()
.toString(36)
.substring(2, 10)}.json`;

// Collect metadata for Athena querying
const metadata = {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp,
sessionId,
pageTitle: document.title,
screenWidth: window.innerWidth,
screenHeight: window.innerHeight,
forced,
...this.config.s3Config?.additionalMetadata
};

const payload = {
key,
bucketName: this.config.s3Config?.bucketName,
region: this.config.s3Config?.region,
data: {
sessionId,
timestamp,
events,
metadata
}
};

try {
const response = await fetch(this.config.s3Config!.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});

if (!response.ok) {
console.error(`Failed to upload to S3: ${response.statusText}`);
// Add events back to the queue for retry
this.events = [...events, ...this.events];
return;
}
} catch (error) {
console.error('Error uploading to S3:', error);
// Add events back to the queue for retry
this.events = [...events, ...this.events];
}
}
}
Loading