import * as Sentry from '@sentry/react'; import memoize from 'lodash/memoize'; import {duration} from 'moment'; import type {Crumb} from 'sentry/types/breadcrumbs'; import {BreadcrumbType} from 'sentry/types/breadcrumbs'; import { breadcrumbFactory, replayTimestamps, rrwebEventListFactory, spansFactory, } from 'sentry/utils/replays/replayDataUtils'; import splitAttachmentsByType from 'sentry/utils/replays/splitAttachmentsByType'; import {EventType} from 'sentry/utils/replays/types'; import type { MemorySpan, NetworkSpan, RecordingEvent, RecordingOptions, ReplayCrumb, ReplayError, ReplayRecord, ReplaySpan, } from 'sentry/views/replays/types'; interface ReplayReaderParams { /** * Loaded segment data * * This is a mix of rrweb data, breadcrumbs and spans/transactions sorted by time * All three types are mixed together. */ attachments: unknown[] | undefined; errors: ReplayError[] | undefined; /** * The root Replay event, created at the start of the browser session. */ replayRecord: ReplayRecord | undefined; } type RequiredNotNull<T> = { [P in keyof T]: NonNullable<T[P]>; }; export default class ReplayReader { static factory({attachments, replayRecord, errors}: ReplayReaderParams) { if (!attachments || !replayRecord || !errors) { return null; } try { return new ReplayReader({attachments, replayRecord, errors}); } catch (err) { Sentry.captureException(err); // If something happens then we don't really know if it's the attachments // array or errors array to blame (it's probably attachments though). // Either way we can use the replayRecord to show some metadata, and then // put an error message below it. return new ReplayReader({ attachments: [], errors: [], replayRecord, }); } } private constructor({ attachments, replayRecord, errors, }: RequiredNotNull<ReplayReaderParams>) { const {rawBreadcrumbs, rawRRWebEvents, rawNetworkSpans, rawMemorySpans} = splitAttachmentsByType(attachments); const spans = [...rawMemorySpans, ...rawNetworkSpans] as ReplaySpan[]; // TODO(replays): We should get correct timestamps from the backend instead // of having to fix them up here. const {startTimestampMs, endTimestampMs} = replayTimestamps( replayRecord, rawRRWebEvents as RecordingEvent[], rawBreadcrumbs as ReplayCrumb[], spans ); replayRecord.started_at = new Date(startTimestampMs); replayRecord.finished_at = new Date(endTimestampMs); replayRecord.duration = duration( replayRecord.finished_at.getTime() - replayRecord.started_at.getTime() ); this.rawErrors = errors; this.sortedSpans = spansFactory(spans); this.breadcrumbs = breadcrumbFactory( replayRecord, errors, rawBreadcrumbs as ReplayCrumb[], this.sortedSpans ); this.rrwebEvents = rrwebEventListFactory( replayRecord, rawRRWebEvents as RecordingEvent[] ); this.replayRecord = replayRecord; } private rawErrors: ReplayError[]; private sortedSpans: ReplaySpan[]; private replayRecord: ReplayRecord; private rrwebEvents: RecordingEvent[]; private breadcrumbs: Crumb[]; /** * @returns Duration of Replay (milliseonds) */ getDurationMs = () => { return this.replayRecord.duration.asMilliseconds(); }; getReplay = () => { return this.replayRecord; }; getRRWebEvents = () => { return this.rrwebEvents; }; getCrumbsWithRRWebNodes = memoize(() => this.breadcrumbs.filter( crumb => crumb.data && typeof crumb.data === 'object' && 'nodeId' in crumb.data ) ); getUserActionCrumbs = memoize(() => { const USER_ACTIONS = [ BreadcrumbType.ERROR, BreadcrumbType.INIT, BreadcrumbType.NAVIGATION, BreadcrumbType.UI, BreadcrumbType.USER, ]; return this.breadcrumbs.filter(crumb => USER_ACTIONS.includes(crumb.type)); }); getConsoleCrumbs = memoize(() => this.breadcrumbs.filter(crumb => ['console', 'issue'].includes(crumb.category || '')) ); getRawErrors = memoize(() => this.rawErrors); getNonConsoleCrumbs = memoize(() => this.breadcrumbs.filter(crumb => crumb.category !== 'console') ); getNavCrumbs = memoize(() => this.breadcrumbs.filter(crumb => [BreadcrumbType.INIT, BreadcrumbType.NAVIGATION].includes(crumb.type) ) ); getNetworkSpans = memoize(() => this.sortedSpans.filter(isNetworkSpan)); getMemorySpans = memoize(() => this.sortedSpans.filter(isMemorySpan)); sdkConfig = memoize(() => { const found = this.rrwebEvents.find( event => event.type === EventType.Custom && event.data.tag === 'options' ) as undefined | RecordingOptions; return found?.data?.payload; }); isNetworkDetailsSetup = memoize(() => { const config = this.sdkConfig(); if (config) { return this.sdkConfig()?.networkDetailHasUrls; } // Network data was added in JS SDK 7.50.0 while sdkConfig was added in v7.51.1 // So even if we don't have the config object, we should still fallback and // look for spans with network data, as that means things are setup! return this.getNetworkSpans().some( span => Object.keys(span.data.request?.headers || {}).length || Object.keys(span.data.response?.headers || {}).length ); }); } const isMemorySpan = (span: ReplaySpan): span is MemorySpan => { return span.op === 'memory'; }; const isNetworkSpan = (span: ReplaySpan): span is NetworkSpan => { return span.op?.startsWith('navigation.') || span.op?.startsWith('resource.'); };