Skip to content

Commit

Permalink
✨ [devext] add a live replay tab
Browse files Browse the repository at this point in the history
  • Loading branch information
BenoitZugmeyer committed May 15, 2023
1 parent dfe3909 commit 0223ff1
Show file tree
Hide file tree
Showing 4 changed files with 489 additions and 0 deletions.
8 changes: 8 additions & 0 deletions developer-extension/src/panel/components/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -42,6 +44,9 @@ export function Panel() {
<Tabs.Tab value={PanelTabs.Infos}>
<Text>Infos</Text>
</Tabs.Tab>
<Tabs.Tab value={PanelTabs.Replay}>
<Text>Live replay</Text>
</Tabs.Tab>
<Tabs.Tab
value={PanelTabs.Settings}
rightSection={
Expand All @@ -61,6 +66,9 @@ export function Panel() {
<Tabs.Panel value={PanelTabs.Infos} sx={{ flex: 1, minHeight: 0 }}>
<InfosTab />
</Tabs.Panel>
<Tabs.Panel value={PanelTabs.Replay} sx={{ flex: 1, minHeight: 0 }}>
<ReplayTab />
</Tabs.Panel>
<Tabs.Panel value={PanelTabs.Settings} sx={{ flex: 1, minHeight: 0 }}>
<SettingsTab
settings={settings}
Expand Down
81 changes: 81 additions & 0 deletions developer-extension/src/panel/components/tabs/replayTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Box, Button } from '@mantine/core'
import React, { useEffect, useRef, useState } from 'react'
import { TabBase } from '../tabBase'
import type { SessionReplayPlayerStatus } from '../../sessionReplayPlayer/startSessionReplayPlayer'
import { startSessionReplayPlayer } from '../../sessionReplayPlayer/startSessionReplayPlayer'
import { evalInWindow } from '../../evalInWindow'
import { createLogger } from '../../../common/logger'
import { Alert } from '../alert'
import { useSdkInfos } from '../../hooks/useSdkInfos'

const logger = createLogger('replayTab')

export function ReplayTab() {
const infos = useSdkInfos()
if (!infos) {
return <Alert level="error" message="No RUM SDK present in the page." />
}

if (!infos.cookie?.rum) {
return <Alert level="error" message="No RUM session." />
}

if (infos.cookie.rum === '0') {
return <Alert level="error" message="RUM session sampled out." />
}

if (infos.cookie.rum === '2') {
return <Alert level="error" message="RUM session plan does not include replay." />
}

return <Player />
}

function Player() {
const frameRef = useRef<HTMLIFrameElement | null>(null)
const [playerStatus, setPlayerStatus] = useState<SessionReplayPlayerStatus>('loading')

useEffect(() => {
startSessionReplayPlayer(frameRef.current!, setPlayerStatus)
}, [])

return (
<TabBase>
<Box
component="iframe"
ref={frameRef}
sx={{
height: '100%',
width: '100%',
display: playerStatus === 'ready' ? 'block' : 'none',
border: 'none',
}}
></Box>
{playerStatus === 'waiting-for-full-snapshot' && <WaitingForFullSnapshot />}
</TabBase>
)
}

function WaitingForFullSnapshot() {
return (
<Alert
level="warning"
message="Waiting for a full snapshot to be generated..."
button={
<Button onClick={generateFullSnapshot} color="orange">
Generate Full Snapshot
</Button>
}
/>
)
}

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)
})
}
Original file line number Diff line number Diff line change
@@ -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<MessageBridgeUp>) {
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++
},
}
}
Loading

0 comments on commit 0223ff1

Please sign in to comment.