Skip to content

Commit

Permalink
✨ [devext] add a live replay tab (#2247)
Browse files Browse the repository at this point in the history
* ♻️ [devext] move margin to Columns component

For the Replay tab, we don't want any margin, so this commit moves the
margin from the base Tabs to the Columns component used by the other
tabs.

* ♻️ [devext] extract an Alert component

* ✨ [devext] add a live replay tab

* 👌 extract sandbox version to a separate constant
  • Loading branch information
BenoitZugmeyer authored May 16, 2023
1 parent 69332db commit 0a0e00a
Show file tree
Hide file tree
Showing 8 changed files with 542 additions and 18 deletions.
32 changes: 32 additions & 0 deletions developer-extension/src/panel/components/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
import React from 'react'
import { Alert as MantineAlert, Center, Group, MantineProvider, Space } from '@mantine/core'

export function Alert({
level,
title,
message,
button,
}: {
level: 'warning' | 'error'
title?: string
message: string
button?: ReactNode
}) {
const color = level === 'warning' ? ('orange' as const) : ('red' as const)
return (
<Center mt="xl">
<MantineAlert color={color} title={title}>
{message}
{button && (
<>
<Space h="sm" />
<MantineProvider theme={{ components: { Button: { defaultProps: { color } } } }}>
<Group position="right">{button}</Group>
</MantineProvider>
</>
)}
</MantineAlert>
</Center>
)
}
27 changes: 12 additions & 15 deletions developer-extension/src/panel/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Alert, Button, Center, Group, MantineProvider } from '@mantine/core'
import { Button, MantineProvider } from '@mantine/core'
import { useColorScheme } from '@mantine/hooks'
import React, { Suspense, useState } from 'react'
import { listenDisconnectEvent } from '../disconnectEvent'
import { Alert } from './alert'

import { Panel } from './panel'

Expand Down Expand Up @@ -30,19 +31,15 @@ export function App() {

function DisconnectAlert() {
return (
<Center
sx={{
height: '100vh',
}}
>
<Alert title="Extension disconnected!" color="red">
The extension has been disconnected. This can happen after an update.
<Group position="right">
<Button onClick={() => location.reload()} color="red">
Reload extension
</Button>
</Group>
</Alert>
</Center>
<Alert
level="error"
title="Extension disconnected!"
message="The extension has been disconnected. This can happen after an update."
button={
<Button onClick={() => location.reload()} color="red">
Reload extension
</Button>
}
/>
)
}
6 changes: 5 additions & 1 deletion developer-extension/src/panel/components/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { Grid, Space, Title } from '@mantine/core'
import React from 'react'

export function Columns({ children }: { children: React.ReactNode }) {
return <Grid>{children}</Grid>
return (
<Grid mt="sm" mx="sm">
{children}
</Grid>
)
}

function Column({ children, title }: { children: React.ReactNode; title: string }) {
Expand Down
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
3 changes: 1 addition & 2 deletions developer-extension/src/panel/components/tabBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ export function TabBase({ top, children }: TabBaseProps) {
</Container>
</>
)}
<Container fluid sx={{ flex: 1, overflowY: 'auto', margin: 0 }}>
{!top && <Space h="sm" />}
<Container fluid sx={{ flex: 1, overflowY: 'auto', padding: 0, margin: 0 }}>
{children}
</Container>
</Flex>
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,170 @@
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 sandboxOrigin = 'https://session-replay-datadoghq.com'
// To follow web-ui development, this version will need to be manually updated from time to time.
// When doing that, be sure to update types and implement any protocol changes.
const sandboxVersion = '0.68.0'
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 sandboxUrl = `${sandboxOrigin}/${sandboxVersion}/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 {
// Ignore other messages for now.
}
}
}

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 0a0e00a

Please sign in to comment.