diff --git a/developer-extension/src/panel/components/panel.tsx b/developer-extension/src/panel/components/panel.tsx
index 6137ed0aa9..719495c9c1 100644
--- a/developer-extension/src/panel/components/panel.tsx
+++ b/developer-extension/src/panel/components/panel.tsx
@@ -8,11 +8,13 @@ import type { Settings } from './tabs/settingsTab'
import { SettingsTab } from './tabs/settingsTab'
import { InfosTab } from './tabs/infosTab'
import { EventTab } from './tabs/eventsTab'
+import { ReplayTab } from './tabs/replayTab'
const enum PanelTabs {
Events = 'events',
Infos = 'infos',
Settings = 'settings',
+ Replay = 'replay',
}
export function Panel() {
@@ -42,6 +44,9 @@ export function Panel() {
Infos
+
+ Live replay
+
+
+
+
+ }
+
+ if (!infos.cookie?.rum) {
+ return
+ }
+
+ if (infos.cookie.rum === '0') {
+ return
+ }
+
+ if (infos.cookie.rum === '2') {
+ return
+ }
+
+ return
+}
+
+function Player() {
+ const frameRef = useRef(null)
+ const [playerStatus, setPlayerStatus] = useState('loading')
+
+ useEffect(() => {
+ startSessionReplayPlayer(frameRef.current!, setPlayerStatus)
+ }, [])
+
+ return (
+
+
+ {playerStatus === 'waiting-for-full-snapshot' && }
+
+ )
+}
+
+function WaitingForFullSnapshot() {
+ return (
+
+ Generate Full Snapshot
+
+ }
+ />
+ )
+}
+
+function generateFullSnapshot() {
+ // Restart to make sure we have a fresh Full Snapshot
+ evalInWindow(`
+ DD_RUM.stopSessionReplayRecording()
+ DD_RUM.startSessionReplayRecording()
+ `).catch((error) => {
+ logger.error('While restarting recording:', error)
+ })
+}
diff --git a/developer-extension/src/panel/sessionReplayPlayer/startSessionReplayPlayer.ts b/developer-extension/src/panel/sessionReplayPlayer/startSessionReplayPlayer.ts
new file mode 100644
index 0000000000..f0f6121e11
--- /dev/null
+++ b/developer-extension/src/panel/sessionReplayPlayer/startSessionReplayPlayer.ts
@@ -0,0 +1,167 @@
+import type { BrowserRecord } from '../../../../packages/rum/src/types'
+import { IncrementalSource, RecordType } from '../../../../packages/rum/src/types'
+import { createLogger } from '../../common/logger'
+import { listenSdkMessages } from '../backgroundScriptConnection'
+import type { MessageBridgeUp } from './types'
+import { MessageBridgeDownType } from './types'
+
+const sandboxLogger = createLogger('sandbox')
+
+export type SessionReplayPlayerStatus = 'loading' | 'waiting-for-full-snapshot' | 'ready'
+
+const sandboxParams = new URLSearchParams({
+ staticContext: JSON.stringify({
+ tabId: 'xxx',
+ origin: location.origin,
+ featureFlags: {
+ // Allows to easily inspect the DOM in the sandbox
+ rum_session_replay_iframe_interactive: true,
+
+ // Use the service worker
+ rum_session_replay_service_worker: true,
+ rum_session_replay_service_worker_debug: false,
+
+ rum_session_replay_disregard_origin: true,
+ },
+ }),
+})
+const sandboxOrigin = 'https://session-replay-datadoghq.com'
+const sandboxUrl = `${sandboxOrigin}/0.68.0/index.html?${String(sandboxParams)}`
+
+export function startSessionReplayPlayer(
+ iframe: HTMLIFrameElement,
+ onStatusChange: (status: SessionReplayPlayerStatus) => void
+) {
+ let status: SessionReplayPlayerStatus = 'loading'
+ const bufferedRecords = createRecordBuffer()
+
+ const messageBridge = createMessageBridge(iframe, () => {
+ const records = bufferedRecords.consume()
+ if (records.length > 0) {
+ status = 'ready'
+ onStatusChange(status)
+ records.forEach((record) => messageBridge.sendRecord(record))
+ } else {
+ status = 'waiting-for-full-snapshot'
+ onStatusChange(status)
+ }
+ })
+
+ const stopListeningToSdkMessages = listenSdkMessages((message) => {
+ if (message.type === 'record') {
+ const record = message.payload.record
+ if (status === 'loading') {
+ bufferedRecords.add(record)
+ } else if (status === 'waiting-for-full-snapshot') {
+ if (isFullSnapshotStart(record)) {
+ status = 'ready'
+ onStatusChange(status)
+ messageBridge.sendRecord(record)
+ }
+ } else {
+ messageBridge.sendRecord(record)
+ }
+ }
+ })
+
+ iframe.src = sandboxUrl
+
+ return {
+ stop() {
+ messageBridge.stop()
+ stopListeningToSdkMessages()
+ },
+ }
+}
+
+function createRecordBuffer() {
+ const records: BrowserRecord[] = []
+
+ return {
+ add(record: BrowserRecord) {
+ // Make sure 'records' starts with a FullSnapshot
+ if (isFullSnapshotStart(record)) {
+ records.length = 0
+ records.push(record)
+ } else if (records.length > 0) {
+ records.push(record)
+ }
+ },
+ consume(): BrowserRecord[] {
+ return records.splice(0, records.length)
+ },
+ }
+}
+
+function isFullSnapshotStart(record: BrowserRecord) {
+ // All FullSnapshot start with a "Meta" record. The FullSnapshot record comes in third position
+ return record.type === RecordType.Meta
+}
+
+function normalizeRecord(record: BrowserRecord) {
+ if (record.type === RecordType.IncrementalSnapshot && record.data.source === IncrementalSource.MouseMove) {
+ return {
+ ...record,
+ data: {
+ ...record.data,
+ position: record.data.positions[0],
+ },
+ }
+ }
+ return record
+}
+
+function createMessageBridge(iframe: HTMLIFrameElement, onReady: () => void) {
+ let nextMessageOrderId = 1
+
+ function globalMessageListener(event: MessageEvent) {
+ if (event.origin === sandboxOrigin) {
+ const message = event.data
+ if (message.type === 'log') {
+ if (message.level === 'error') {
+ sandboxLogger.error(message.message)
+ } else {
+ sandboxLogger.log(message.message)
+ }
+ } else if (message.type === 'error') {
+ sandboxLogger.error(
+ `${message.serialisedError.name}: ${message.serialisedError.message}`,
+ message.serialisedError.stack
+ )
+ } else if (message.type === 'ready') {
+ onReady()
+ } else {
+ // sandboxLogger.log('Unhandled message type:', message)
+ }
+ }
+ }
+
+ window.addEventListener('message', globalMessageListener)
+ return {
+ stop: () => {
+ window.removeEventListener('message', globalMessageListener)
+ },
+
+ sendRecord(record: BrowserRecord) {
+ iframe.contentWindow!.postMessage(
+ {
+ type: MessageBridgeDownType.RECORDS,
+ records: [
+ {
+ ...normalizeRecord(record),
+ viewId: 'xxx',
+ orderId: nextMessageOrderId,
+ isSeeking: false,
+ shouldWaitForIt: false,
+ segmentSource: 'browser',
+ },
+ ],
+ sentAt: Date.now(),
+ },
+ sandboxOrigin
+ )
+
+ nextMessageOrderId++
+ },
+ }
+}
diff --git a/developer-extension/src/panel/sessionReplayPlayer/types.ts b/developer-extension/src/panel/sessionReplayPlayer/types.ts
new file mode 100644
index 0000000000..6138675cc5
--- /dev/null
+++ b/developer-extension/src/panel/sessionReplayPlayer/types.ts
@@ -0,0 +1,233 @@
+// Those types are coming from the Web-UI Session Replay Player. Please keep them as close as
+// possible to the original types.
+
+import type { BrowserRecord, RecordType } from '../../../../packages/rum/src/types'
+
+export enum MessageBridgeUpType {
+ READY = 'ready',
+ RECORD_APPLIED = 'record_applied',
+ LOG = 'log',
+ ERROR = 'error',
+ METRIC_TIMING = 'timing',
+ METRIC_INCREMENT = 'increment',
+ CAPABILITIES = 'capabilities',
+ SERVICE_WORKER_ACTIVATED = 'service_worker_activated',
+ RENDERER_DIMENSIONS = 'renderer_dimensions',
+ ELEMENT_POSITION = 'element_position',
+}
+
+export enum MessageBridgeDownType {
+ RECORDS = 'records',
+ RESET = 'reset',
+ ELEMENT_POSITION = 'element_position',
+}
+
+export enum MessageBridgeUpLogLevel {
+ LOG = 'log',
+ DEBUG = 'debug',
+ WARN = 'warn',
+ ERROR = 'error',
+}
+
+export interface Dimensions {
+ width: number
+ height: number
+}
+
+export type StaticFrameContext = {
+ origin: string
+ featureFlags: { [flagName: string]: boolean }
+ tabId: string
+ // Optional params will only be true to avoid confusions with stringified 'false'
+ isHot?: true
+ /**
+ * If the HTMLRenderers should report the offset dimensions to the parent window or not
+ */
+ reportDimensions?: true
+
+ /**
+ * If the HTMLRenderers should be rendered to full height without cropping in the viewport
+ */
+ isFullHeight?: true
+
+ /**
+ * If animations and transitions should be disabled in the HTMLRenderers
+ */
+ areAnimationsDisabled?: true
+}
+
+/**
+ * For message types that benefit from verbose context (RUM logs and errors), the
+ * following properties are reserved so context can be added at each level of the message
+ * bridges without worry of conflicts. For simplicity, this type is shared across message
+ * bridges, even though, for example, the service worker will never implement other contexts.
+ */
+export type MessageBridgeVerboseContext = {
+ sessionReplayContext?: {
+ viewId?: string
+ sessionId?: string
+ }
+ isolationSandboxContext?: {
+ pageUrl?: string
+ }
+ serviceWorkerContext?: {
+ registrationUrl?: string
+ version?: string
+ fetchUrl?: string
+ destination?: string
+ }
+} & Record
+
+type RawMessageBridgeUp =
+ | MessageBridgeUpReady
+ | MessageBridgeUpRecordApplied
+ | MessageBridgeUpLog
+ | MessageBridgeUpError
+ | MessageBridgeUpTiming
+ | MessageBridgeUpIncrement
+ | MessageBridgeUpCapabilities
+ | MessageBridgeUpServiceWorkerActivated
+ | MessageBridgeUpRendererDimensions
+ | MessageBridgeUpElementPosition
+
+export type MessageBridgeUp = {
+ sentAt: number
+ tabId: string
+ viewId?: string
+} & RawMessageBridgeUp
+
+/**
+ * Message send by the sanboxing when iframe is ready
+ */
+export type MessageBridgeUpReady = {
+ type: MessageBridgeUpType.READY
+}
+
+/**
+ * Message send by the sanboxing when a record has been applied
+ */
+export type MessageBridgeUpRecordApplied = {
+ type: MessageBridgeUpType.RECORD_APPLIED
+ /** OrderId of the Record applied */
+ orderId: number
+ /** Type of the Record applied */
+ recordType: RecordType
+}
+
+/**
+ * Message send by the sanboxing when a log is sent
+ */
+export type MessageBridgeUpLog = {
+ type: MessageBridgeUpType.LOG
+ level: MessageBridgeUpLogLevel
+ message: string
+ context?: { [key: string]: any }
+}
+
+/**
+ * Message send by the sanboxing iframe when there is an error
+ */
+export type MessageBridgeUpError = {
+ type: MessageBridgeUpType.ERROR
+ serialisedError: SerialisedError
+ context?: { [key: string]: any }
+}
+
+/**
+ * Message send by the sanboxing iframe with a custom timing
+ */
+export type MessageBridgeUpTiming = {
+ type: MessageBridgeUpType.METRIC_TIMING
+ name: string
+ duration: number
+ context?: { [key: string]: any }
+}
+
+/**
+ * Message send by the sanboxing iframe with a custom count
+ */
+export type MessageBridgeUpIncrement = {
+ type: MessageBridgeUpType.METRIC_INCREMENT
+ name: string
+ value: number
+ context?: { [key: string]: any }
+}
+
+export type MessageBridgeUpCapabilities = {
+ type: MessageBridgeUpType.CAPABILITIES
+ capabilities: {
+ SERVICE_WORKER: boolean
+ THIRD_PARTY_STORAGE: boolean
+ }
+}
+
+export type MessageBridgeUpServiceWorkerActivated = {
+ type: MessageBridgeUpType.SERVICE_WORKER_ACTIVATED
+}
+
+export type MessageBridgeUpRendererDimensions = {
+ type: MessageBridgeUpType.RENDERER_DIMENSIONS
+ dimensions: Dimensions
+}
+
+export type ElementPositionResponse = {
+ cssSelector: string
+ position: Omit
+}
+
+export type MessageBridgeUpElementPosition = {
+ type: MessageBridgeUpType.ELEMENT_POSITION
+ positions: ElementPositionResponse[]
+}
+
+type MessageBridgeMetadata = {
+ sentAt: number
+}
+
+export type MessageBridgeDown = MessageBridgeDownRecords | MessageBridgeDownReset | MessageBridgeDownElementPosition
+
+export type MessageBridgeDownReset = {
+ type: MessageBridgeDownType.RESET
+} & MessageBridgeMetadata
+
+export type MessageBridgeDownRecords = {
+ type: MessageBridgeDownType.RECORDS
+ records: RecordWithMetadata[]
+} & MessageBridgeMetadata
+
+export type MessageBridgeDownElementPosition = {
+ type: MessageBridgeDownType.ELEMENT_POSITION
+ cssSelectors: string[]
+} & MessageBridgeMetadata
+
+export type SerialisedError = {
+ name: string
+ message: string
+ stack?: string
+}
+
+export type RecordWithSegmentData = T & {
+ viewId: string
+ segmentSource: 'browser' | undefined
+}
+
+export type RecordWithMetadata = RecordWithSegmentData & {
+ /**
+ * index of the record inside the view
+ */
+ orderId: number
+ /**
+ * Is the player preparing a seek?
+ * when seeking we might have to apply record before actually
+ * showing the content to the user
+ * this flag inform us this behavior
+ */
+ isSeeking: boolean
+ /**
+ * Should we apply back pressure on the timer when applying this record
+ * This is used by the renderer of the sandbox
+ * By default, it's only true for FS
+ * (meta also block the timer but it's in the main app not in the sandbox)
+ */
+ shouldWaitForIt: boolean // 'DARY... LEGENDARY
+}