-
Notifications
You must be signed in to change notification settings - Fork 80
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to be more specific with the schema? What schema does rrweb use? |
||
source: number; | ||
[key: string]: any; | ||
}; | ||
[key: string]: any; | ||
} | ||
import { InternalPlugin } from '../InternalPlugin'; | ||
import { Session } from '../../sessions/SessionManager'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reaching the batch limit seems to be the primary way we are flushing the session replay events. Is it possible to improve how frequently we are reporting this, such as on an interval? What happens to the remaining replay events when the window is closed? |
||
} | ||
}, | ||
...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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sessionId is already in the metadata |
||
}); | ||
} | ||
} catch (error) { | ||
// If recording fails, add the events back to be retried later | ||
this.events = [...eventsToSend, ...this.events]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On the backend, are you enforcing a limit on number of events within a single SessionReplayEvent? If so, you'll go over the limit with this approach |
||
} | ||
} | ||
|
||
/** | ||
* 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]; | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is type? Is it an index?