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 +}