replayDataUtils.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import invariant from 'invariant';
  2. import {duration} from 'moment';
  3. import {transformCrumbs} from 'sentry/components/events/interfaces/breadcrumbs/utils';
  4. import {t} from 'sentry/locale';
  5. import type {
  6. BreadcrumbTypeDefault,
  7. BreadcrumbTypeInit,
  8. BreadcrumbTypeNavigation,
  9. Crumb,
  10. RawCrumb,
  11. } from 'sentry/types/breadcrumbs';
  12. import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
  13. import isValidDate from 'sentry/utils/date/isValidDate';
  14. import getMinMax from 'sentry/utils/getMinMax';
  15. import type {
  16. ReplayCrumb,
  17. ReplayError,
  18. ReplayRecord,
  19. ReplaySpan,
  20. } from 'sentry/views/replays/types';
  21. export function mapResponseToReplayRecord(apiResponse: any): ReplayRecord {
  22. // Marshal special fields into tags
  23. const user = Object.fromEntries(
  24. Object.entries(apiResponse.user)
  25. .filter(([key, value]) => key !== 'display_name' && value)
  26. .map(([key, value]) => [`user.${key}`, [value]])
  27. );
  28. const unorderedTags: ReplayRecord['tags'] = {
  29. ...apiResponse.tags,
  30. ...(apiResponse.browser?.name ? {'browser.name': [apiResponse.browser.name]} : {}),
  31. ...(apiResponse.browser?.version
  32. ? {'browser.version': [apiResponse.browser.version]}
  33. : {}),
  34. ...(apiResponse.device?.brand ? {'device.brand': [apiResponse.device.brand]} : {}),
  35. ...(apiResponse.device?.family ? {'device.family': [apiResponse.device.family]} : {}),
  36. ...(apiResponse.device?.model_id
  37. ? {'device.model_id': [apiResponse.device.model_id]}
  38. : {}),
  39. ...(apiResponse.device?.name ? {'device.name': [apiResponse.device.name]} : {}),
  40. ...(apiResponse.platform ? {platform: [apiResponse.platform]} : {}),
  41. ...(apiResponse.releases ? {releases: [...apiResponse.releases]} : {}),
  42. ...(apiResponse.replay_type ? {replayType: [apiResponse.replay_type]} : {}),
  43. ...(apiResponse.os?.name ? {'os.name': [apiResponse.os.name]} : {}),
  44. ...(apiResponse.os?.version ? {'os.version': [apiResponse.os.version]} : {}),
  45. ...(apiResponse.sdk?.name ? {'sdk.name': [apiResponse.sdk.name]} : {}),
  46. ...(apiResponse.sdk?.version ? {'sdk.version': [apiResponse.sdk.version]} : {}),
  47. ...user,
  48. };
  49. // Sort the tags by key
  50. const tags = Object.keys(unorderedTags)
  51. .sort()
  52. .reduce((acc, key) => {
  53. acc[key] = unorderedTags[key];
  54. return acc;
  55. }, {});
  56. const startedAt = new Date(apiResponse.started_at);
  57. invariant(isValidDate(startedAt), 'replay.started_at is invalid');
  58. const finishedAt = new Date(apiResponse.finished_at);
  59. invariant(isValidDate(finishedAt), 'replay.finished_at is invalid');
  60. return {
  61. ...apiResponse,
  62. ...(apiResponse.started_at ? {started_at: startedAt} : {}),
  63. ...(apiResponse.finished_at ? {finished_at: finishedAt} : {}),
  64. ...(apiResponse.duration !== undefined
  65. ? {duration: duration(apiResponse.duration * 1000)}
  66. : {}),
  67. tags,
  68. };
  69. }
  70. export function breadcrumbFactory(
  71. replayRecord: ReplayRecord,
  72. errors: ReplayError[],
  73. rawCrumbs: ReplayCrumb[],
  74. spans: ReplaySpan[]
  75. ): Crumb[] {
  76. const UNWANTED_CRUMB_CATEGORIES = ['ui.focus', 'ui.blur'];
  77. const initialUrl = replayRecord.urls?.[0] ?? replayRecord.tags.url?.join(', ');
  78. const initBreadcrumb = {
  79. type: BreadcrumbType.INIT,
  80. timestamp: replayRecord.started_at.toISOString(),
  81. level: BreadcrumbLevelType.INFO,
  82. message: initialUrl,
  83. data: {
  84. action: 'replay-init',
  85. label: t('Start recording'),
  86. url: initialUrl,
  87. },
  88. } as BreadcrumbTypeInit;
  89. const errorCrumbs: RawCrumb[] = errors.map(error => ({
  90. type: BreadcrumbType.ERROR,
  91. level: BreadcrumbLevelType.ERROR,
  92. category: 'issue',
  93. message: error.title,
  94. data: {
  95. label: error['error.type'].join(''),
  96. eventId: error.id,
  97. groupId: error['issue.id'] || 1,
  98. groupShortId: error.issue,
  99. project: error['project.name'],
  100. },
  101. timestamp: error.timestamp,
  102. }));
  103. const spanCrumbs: (BreadcrumbTypeDefault | BreadcrumbTypeNavigation)[] = spans
  104. .filter(span =>
  105. ['navigation.navigate', 'navigation.reload', 'largest-contentful-paint'].includes(
  106. span.op
  107. )
  108. )
  109. .sort((a, b) => a.startTimestamp - b.startTimestamp)
  110. .map(span => {
  111. if (span.op.startsWith('navigation')) {
  112. const [, action] = span.op.split('.');
  113. try {
  114. return {
  115. category: 'default',
  116. type: BreadcrumbType.NAVIGATION,
  117. timestamp: new Date(span.startTimestamp * 1000).toISOString(),
  118. level: BreadcrumbLevelType.INFO,
  119. message: span.description,
  120. action,
  121. data: {
  122. to: span.description,
  123. label:
  124. action === 'reload'
  125. ? t('Reload')
  126. : action === 'navigate'
  127. ? t('Page load')
  128. : t('Navigation'),
  129. ...span.data,
  130. },
  131. };
  132. } catch {
  133. return null;
  134. }
  135. }
  136. try {
  137. return {
  138. type: BreadcrumbType.DEBUG,
  139. timestamp: new Date(span.startTimestamp * 1000).toISOString(),
  140. level: BreadcrumbLevelType.INFO,
  141. category: 'default',
  142. data: {
  143. action: span.op,
  144. ...span.data,
  145. label: span.op === 'largest-contentful-paint' ? t('LCP') : span.op,
  146. },
  147. };
  148. } catch {
  149. return null;
  150. }
  151. })
  152. .filter(Boolean) as (BreadcrumbTypeDefault | BreadcrumbTypeNavigation)[];
  153. const hasPageLoad = spans.find(span => span.op === 'navigation.navigate');
  154. const rawCrumbsWithTimestamp: RawCrumb[] = rawCrumbs
  155. .filter(crumb => {
  156. return (
  157. !UNWANTED_CRUMB_CATEGORIES.includes(crumb.category || '') &&
  158. // Explicitly include replay breadcrumbs to ensure we have valid UI for them
  159. (!crumb.category?.startsWith('replay') ||
  160. crumb.category === 'replay.mutations') &&
  161. (crumb.category !== 'ui.slowClickDetected' ||
  162. (crumb.data as Record<string, any>)?.timeAfterClickMs >= 3000)
  163. );
  164. })
  165. .map(crumb => {
  166. if (crumb.category === 'replay.mutations') {
  167. const crumbData = crumb.data as Record<string, unknown>;
  168. try {
  169. return {
  170. ...crumb,
  171. type: crumbData.limit ? BreadcrumbType.ERROR : BreadcrumbType.WARNING,
  172. level: crumbData.limit
  173. ? BreadcrumbLevelType.FATAL
  174. : BreadcrumbLevelType.WARNING,
  175. timestamp: new Date(crumb.timestamp * 1000).toISOString(),
  176. };
  177. } catch {
  178. return null;
  179. }
  180. }
  181. try {
  182. return {
  183. ...crumb,
  184. type: BreadcrumbType.DEFAULT,
  185. timestamp: new Date(crumb.timestamp * 1000).toISOString(),
  186. };
  187. } catch {
  188. return null;
  189. }
  190. })
  191. .filter(Boolean) as RawCrumb[];
  192. // TODO(replay): The important parts of transformCrumbs should be brought into
  193. // here, we're hydrating our data and should have more control over the process.
  194. const result = transformCrumbs([
  195. ...(spans.length && !hasPageLoad ? [initBreadcrumb] : []),
  196. ...rawCrumbsWithTimestamp,
  197. ...errorCrumbs,
  198. ...spanCrumbs,
  199. ]);
  200. return result.sort((a, b) => +new Date(a.timestamp || 0) - +new Date(b.timestamp || 0));
  201. }
  202. export function spansFactory(spans: ReplaySpan[]) {
  203. return spans
  204. .sort((a, b) => a.startTimestamp - b.startTimestamp)
  205. .map(span => ({
  206. ...span,
  207. id: `${span.description ?? span.op}-${span.startTimestamp}-${span.endTimestamp}`,
  208. timestamp: span.startTimestamp * 1000,
  209. }));
  210. }
  211. /**
  212. * We need to figure out the real start and end timestamps based on when
  213. * first and last bits of data were collected. In milliseconds.
  214. *
  215. * @deprecated Once the backend returns the corrected timestamps, this is not needed.
  216. */
  217. export function replayTimestamps(
  218. replayRecord: ReplayRecord,
  219. rrwebEvents: {timestamp: number}[],
  220. rawCrumbs: {timestamp: number}[],
  221. rawSpanData: {endTimestamp: number; op: string; startTimestamp: number}[]
  222. ) {
  223. const rrwebTimestamps = rrwebEvents.map(event => event.timestamp).filter(Boolean);
  224. const breadcrumbTimestamps = rawCrumbs
  225. .map(rawCrumb => rawCrumb.timestamp)
  226. .filter(Boolean);
  227. const rawSpanDataFiltered = rawSpanData.filter(
  228. ({op}) => op !== 'largest-contentful-paint'
  229. );
  230. const spanStartTimestamps = rawSpanDataFiltered.map(span => span.startTimestamp);
  231. const spanEndTimestamps = rawSpanDataFiltered.map(span => span.endTimestamp);
  232. // Calculate min/max of each array individually, to prevent extra allocations.
  233. // Also using `getMinMax()` so we can handle any huge arrays.
  234. const {min: minRRWeb, max: maxRRWeb} = getMinMax(rrwebTimestamps);
  235. const {min: minCrumbs, max: maxCrumbs} = getMinMax(breadcrumbTimestamps);
  236. const {min: minSpanStarts} = getMinMax(spanStartTimestamps);
  237. const {max: maxSpanEnds} = getMinMax(spanEndTimestamps);
  238. return {
  239. startTimestampMs: Math.min(
  240. replayRecord.started_at.getTime(),
  241. minRRWeb,
  242. minCrumbs * 1000,
  243. minSpanStarts * 1000
  244. ),
  245. endTimestampMs: Math.max(
  246. replayRecord.finished_at.getTime(),
  247. maxRRWeb,
  248. maxCrumbs * 1000,
  249. maxSpanEnds * 1000
  250. ),
  251. };
  252. }