utils.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  1. import {browserHistory} from 'react-router';
  2. import {Theme} from '@emotion/react';
  3. import {Location} from 'history';
  4. import isNumber from 'lodash/isNumber';
  5. import isString from 'lodash/isString';
  6. import maxBy from 'lodash/maxBy';
  7. import set from 'lodash/set';
  8. import moment from 'moment';
  9. import {lightenBarColor} from 'sentry/components/performance/waterfall/utils';
  10. import {Organization} from 'sentry/types';
  11. import {
  12. AggregateEntrySpans,
  13. AggregateEventTransaction,
  14. EntrySpans,
  15. EntryType,
  16. EventTransaction,
  17. } from 'sentry/types/event';
  18. import {assert} from 'sentry/types/utils';
  19. import {trackAnalytics} from 'sentry/utils/analytics';
  20. import {MobileVital, WebVital} from 'sentry/utils/fields';
  21. import {TraceError, TraceFullDetailed} from 'sentry/utils/performance/quickTrace/types';
  22. import {VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
  23. import {MERGE_LABELS_THRESHOLD_PERCENT} from './constants';
  24. import SpanTreeModel from './spanTreeModel';
  25. import {
  26. AggregateSpanType,
  27. EnhancedSpan,
  28. GapSpanType,
  29. OrphanSpanType,
  30. OrphanTreeDepth,
  31. ParsedTraceType,
  32. ProcessedSpanType,
  33. RawSpanType,
  34. SpanType,
  35. TraceContextType,
  36. TreeDepthType,
  37. } from './types';
  38. export const isValidSpanID = (maybeSpanID: any) =>
  39. isString(maybeSpanID) && maybeSpanID.length > 0;
  40. export type SpanBoundsType = {endTimestamp: number; startTimestamp: number};
  41. export type SpanGeneratedBoundsType =
  42. | {isSpanVisibleInView: boolean; type: 'TRACE_TIMESTAMPS_EQUAL'}
  43. | {isSpanVisibleInView: boolean; type: 'INVALID_VIEW_WINDOW'}
  44. | {
  45. isSpanVisibleInView: boolean;
  46. start: number;
  47. type: 'TIMESTAMPS_EQUAL';
  48. width: number;
  49. }
  50. | {
  51. end: number;
  52. isSpanVisibleInView: boolean;
  53. start: number;
  54. type: 'TIMESTAMPS_REVERSED';
  55. }
  56. | {
  57. end: number;
  58. isSpanVisibleInView: boolean;
  59. start: number;
  60. type: 'TIMESTAMPS_STABLE';
  61. };
  62. export type SpanViewBoundsType = {
  63. isSpanVisibleInView: boolean;
  64. left: undefined | number;
  65. warning: undefined | string;
  66. width: undefined | number;
  67. };
  68. const normalizeTimestamps = (spanBounds: SpanBoundsType): SpanBoundsType => {
  69. const {startTimestamp, endTimestamp} = spanBounds;
  70. if (startTimestamp > endTimestamp) {
  71. return {startTimestamp: endTimestamp, endTimestamp: startTimestamp};
  72. }
  73. return spanBounds;
  74. };
  75. enum TimestampStatus {
  76. STABLE = 0,
  77. REVERSED = 1,
  78. EQUAL = 2,
  79. }
  80. export enum SpanSubTimingMark {
  81. SPAN_START = 0,
  82. SPAN_END = 1,
  83. HTTP_REQUEST_START = 'http.request.request_start',
  84. HTTP_RESPONSE_START = 'http.request.response_start',
  85. }
  86. export enum SpanSubTimingName {
  87. WAIT_TIME = 'Wait Time',
  88. REQUEST_TIME = 'Request Time',
  89. RESPONSE_TIME = 'Response Time',
  90. }
  91. const HTTP_DATA_KEYS = [
  92. 'http.request.redirect_start',
  93. 'http.request.fetch_start',
  94. 'http.request.domain_lookup_start',
  95. 'http.request.domain_lookup_end',
  96. 'http.request.connect_start',
  97. 'http.request.secure_connection_start',
  98. 'http.request.connection_end',
  99. 'http.request.request_start',
  100. 'http.request.response_start',
  101. 'http.request.response_end',
  102. ];
  103. const HIDDEN_DATA_KEYS = HTTP_DATA_KEYS;
  104. const TIMING_DATA_KEYS = [
  105. SpanSubTimingMark.HTTP_REQUEST_START,
  106. SpanSubTimingMark.HTTP_RESPONSE_START,
  107. ];
  108. export const isSpanDataKeyTiming = (key: string) => {
  109. return TIMING_DATA_KEYS.includes(key as SpanSubTimingMark);
  110. };
  111. export const isHiddenDataKey = (key: string) => {
  112. return HIDDEN_DATA_KEYS.includes(key);
  113. };
  114. /**
  115. * Affected spans (hatching when spans have errors) may only apply to a sub timing,
  116. * as is the case for http overhead issues (only time before the request start matters)..
  117. */
  118. export const shouldLimitAffectedToTiming = (timing: SubTimingInfo) => {
  119. return timing.endMark === SpanSubTimingMark.HTTP_REQUEST_START; // Sub timing spanning between start and request start.
  120. };
  121. export const parseSpanTimestamps = (spanBounds: SpanBoundsType): TimestampStatus => {
  122. const startTimestamp: number = spanBounds.startTimestamp;
  123. const endTimestamp: number = spanBounds.endTimestamp;
  124. if (startTimestamp < endTimestamp) {
  125. return TimestampStatus.STABLE;
  126. }
  127. if (startTimestamp === endTimestamp) {
  128. return TimestampStatus.EQUAL;
  129. }
  130. return TimestampStatus.REVERSED;
  131. };
  132. // given the start and end trace timestamps, and the view window, we want to generate a function
  133. // that'll output the relative %'s for the width and placements relative to the left-hand side.
  134. //
  135. // The view window (viewStart and viewEnd) are percentage values (between 0% and 100%), they correspond to the window placement
  136. // between the start and end trace timestamps.
  137. export const boundsGenerator = (bounds: {
  138. // unix timestamp
  139. traceEndTimestamp: number;
  140. traceStartTimestamp: number;
  141. // in [0, 1]
  142. viewEnd: number;
  143. // unix timestamp
  144. viewStart: number; // in [0, 1]
  145. }) => {
  146. const {viewStart, viewEnd} = bounds;
  147. const {startTimestamp: traceStartTimestamp, endTimestamp: traceEndTimestamp} =
  148. normalizeTimestamps({
  149. startTimestamp: bounds.traceStartTimestamp,
  150. endTimestamp: bounds.traceEndTimestamp,
  151. });
  152. // viewStart and viewEnd are percentage values (%) of the view window relative to the left
  153. // side of the trace view minimap
  154. // invariant: viewStart <= viewEnd
  155. // duration of the entire trace in seconds
  156. const traceDuration = traceEndTimestamp - traceStartTimestamp;
  157. const viewStartTimestamp = traceStartTimestamp + viewStart * traceDuration;
  158. const viewEndTimestamp = traceEndTimestamp - (1 - viewEnd) * traceDuration;
  159. const viewDuration = viewEndTimestamp - viewStartTimestamp;
  160. return (spanBounds: SpanBoundsType): SpanGeneratedBoundsType => {
  161. // TODO: alberto.... refactor so this is impossible 😠
  162. if (traceDuration <= 0) {
  163. return {
  164. type: 'TRACE_TIMESTAMPS_EQUAL',
  165. isSpanVisibleInView: true,
  166. };
  167. }
  168. if (viewDuration <= 0) {
  169. return {
  170. type: 'INVALID_VIEW_WINDOW',
  171. isSpanVisibleInView: true,
  172. };
  173. }
  174. const {startTimestamp, endTimestamp} = normalizeTimestamps(spanBounds);
  175. const timestampStatus = parseSpanTimestamps(spanBounds);
  176. const start = (startTimestamp - viewStartTimestamp) / viewDuration;
  177. const end = (endTimestamp - viewStartTimestamp) / viewDuration;
  178. const isSpanVisibleInView = end > 0 && start < 1;
  179. switch (timestampStatus) {
  180. case TimestampStatus.EQUAL: {
  181. return {
  182. type: 'TIMESTAMPS_EQUAL',
  183. start,
  184. width: 1,
  185. // a span bar is visible even if they're at the extreme ends of the view selection.
  186. // these edge cases are:
  187. // start == end == 0, and
  188. // start == end == 1
  189. isSpanVisibleInView: end >= 0 && start <= 1,
  190. };
  191. }
  192. case TimestampStatus.REVERSED: {
  193. return {
  194. type: 'TIMESTAMPS_REVERSED',
  195. start,
  196. end,
  197. isSpanVisibleInView,
  198. };
  199. }
  200. case TimestampStatus.STABLE: {
  201. return {
  202. type: 'TIMESTAMPS_STABLE',
  203. start,
  204. end,
  205. isSpanVisibleInView,
  206. };
  207. }
  208. default: {
  209. const _exhaustiveCheck: never = timestampStatus;
  210. return _exhaustiveCheck;
  211. }
  212. }
  213. };
  214. };
  215. export function generateRootSpan(trace: ParsedTraceType): RawSpanType {
  216. const rootSpan: RawSpanType = {
  217. trace_id: trace.traceID,
  218. span_id: trace.rootSpanID,
  219. parent_span_id: trace.parentSpanID,
  220. start_timestamp: trace.traceStartTimestamp,
  221. timestamp: trace.traceEndTimestamp,
  222. op: trace.op,
  223. description: trace.description,
  224. data: {},
  225. status: trace.rootSpanStatus,
  226. hash: trace.hash,
  227. exclusive_time: trace.exclusiveTime,
  228. };
  229. return rootSpan;
  230. }
  231. // start and end are assumed to be unix timestamps with fractional seconds
  232. export function getTraceDateTimeRange(input: {end: number; start: number}): {
  233. end: string;
  234. start: string;
  235. } {
  236. const start = moment
  237. .unix(input.start)
  238. .subtract(12, 'hours')
  239. .utc()
  240. .format('YYYY-MM-DDTHH:mm:ss.SSS');
  241. const end = moment
  242. .unix(input.end)
  243. .add(12, 'hours')
  244. .utc()
  245. .format('YYYY-MM-DDTHH:mm:ss.SSS');
  246. return {
  247. start,
  248. end,
  249. };
  250. }
  251. export function isGapSpan(span: ProcessedSpanType): span is GapSpanType {
  252. if ('type' in span) {
  253. return span.type === 'gap';
  254. }
  255. return false;
  256. }
  257. export function isOrphanSpan(span: ProcessedSpanType): span is OrphanSpanType {
  258. if ('type' in span) {
  259. if (span.type === 'orphan') {
  260. return true;
  261. }
  262. if (span.type === 'gap') {
  263. return span.isOrphan;
  264. }
  265. }
  266. return false;
  267. }
  268. export function getSpanID(span: ProcessedSpanType, defaultSpanID: string = ''): string {
  269. if (isGapSpan(span)) {
  270. return defaultSpanID;
  271. }
  272. return span.span_id;
  273. }
  274. export function getSpanOperation(span: ProcessedSpanType): string | undefined {
  275. if (isGapSpan(span)) {
  276. return undefined;
  277. }
  278. return span.op;
  279. }
  280. export function getSpanTraceID(span: ProcessedSpanType): string {
  281. if (isGapSpan(span)) {
  282. return 'gap-span';
  283. }
  284. return span.trace_id;
  285. }
  286. export function getSpanParentSpanID(span: ProcessedSpanType): string | undefined {
  287. if (isGapSpan(span)) {
  288. return 'gap-span';
  289. }
  290. return span.parent_span_id;
  291. }
  292. interface SubTimingDefinition {
  293. colorLighten: number;
  294. endMark: SpanSubTimingMark;
  295. name: string;
  296. startMark: SpanSubTimingMark;
  297. }
  298. export interface SubTimingInfo extends SubTimingDefinition {
  299. color: string;
  300. duration: number;
  301. endTimestamp: number;
  302. startTimestamp: number;
  303. }
  304. const SPAN_SUB_TIMINGS: Record<string, SubTimingDefinition[]> = {
  305. 'http.client': [
  306. {
  307. startMark: SpanSubTimingMark.SPAN_START,
  308. endMark: SpanSubTimingMark.HTTP_REQUEST_START,
  309. name: SpanSubTimingName.WAIT_TIME,
  310. colorLighten: 0.5,
  311. },
  312. {
  313. startMark: SpanSubTimingMark.HTTP_REQUEST_START,
  314. endMark: SpanSubTimingMark.HTTP_RESPONSE_START,
  315. name: SpanSubTimingName.REQUEST_TIME,
  316. colorLighten: 0.25,
  317. },
  318. {
  319. startMark: SpanSubTimingMark.HTTP_RESPONSE_START,
  320. endMark: SpanSubTimingMark.SPAN_END,
  321. name: SpanSubTimingName.RESPONSE_TIME,
  322. colorLighten: 0,
  323. },
  324. ],
  325. };
  326. export function subTimingMarkToTime(span: RawSpanType, mark: SpanSubTimingMark) {
  327. if (mark === SpanSubTimingMark.SPAN_START) {
  328. return span.start_timestamp;
  329. }
  330. if (mark === SpanSubTimingMark.SPAN_END) {
  331. return span.timestamp;
  332. }
  333. return (span as any).data?.[mark] as number | undefined;
  334. }
  335. export function getSpanSubTimings(span: ProcessedSpanType): SubTimingInfo[] | null {
  336. if (span.type) {
  337. return null; // narrow to RawSpanType
  338. }
  339. const op = getSpanOperation(span);
  340. if (!op) {
  341. return null;
  342. }
  343. const timingDefinitions = SPAN_SUB_TIMINGS[op];
  344. if (!timingDefinitions) {
  345. return null;
  346. }
  347. const timings: SubTimingInfo[] = [];
  348. const spanStart = subTimingMarkToTime(span, SpanSubTimingMark.SPAN_START);
  349. const spanEnd = subTimingMarkToTime(span, SpanSubTimingMark.SPAN_END);
  350. const TEN_MS = 0.001;
  351. for (const def of timingDefinitions) {
  352. const start = subTimingMarkToTime(span, def.startMark);
  353. const end = subTimingMarkToTime(span, def.endMark);
  354. if (
  355. !start ||
  356. !end ||
  357. !spanStart ||
  358. !spanEnd ||
  359. start < spanStart - TEN_MS ||
  360. end > spanEnd + TEN_MS
  361. ) {
  362. return null;
  363. }
  364. timings.push({
  365. ...def,
  366. duration: end - start,
  367. startTimestamp: start,
  368. endTimestamp: end,
  369. color: lightenBarColor(getSpanOperation(span), def.colorLighten),
  370. });
  371. }
  372. return timings;
  373. }
  374. export function formatSpanTreeLabel(span: ProcessedSpanType): string | undefined {
  375. const label = span?.description ?? getSpanID(span);
  376. if (!isGapSpan(span)) {
  377. if (span.op === 'http.client') {
  378. try {
  379. return decodeURIComponent(label);
  380. } catch {
  381. // Do nothing
  382. }
  383. }
  384. }
  385. return label;
  386. }
  387. export function getTraceContext(
  388. event: Readonly<EventTransaction | AggregateEventTransaction>
  389. ): TraceContextType | undefined {
  390. return event?.contexts?.trace;
  391. }
  392. export function parseTrace(
  393. event: Readonly<EventTransaction | AggregateEventTransaction>
  394. ): ParsedTraceType {
  395. const spanEntry = event.entries.find(
  396. (entry: EntrySpans | AggregateEntrySpans | any): entry is EntrySpans => {
  397. return entry.type === EntryType.SPANS;
  398. }
  399. );
  400. const spans: Array<RawSpanType | AggregateSpanType> = spanEntry?.data ?? [];
  401. const traceContext = getTraceContext(event);
  402. const traceID = (traceContext && traceContext.trace_id) || '';
  403. const rootSpanID = (traceContext && traceContext.span_id) || '';
  404. const rootSpanOpName = (traceContext && traceContext.op) || 'transaction';
  405. const description = traceContext && traceContext.description;
  406. const parentSpanID = traceContext && traceContext.parent_span_id;
  407. const rootSpanStatus = traceContext && traceContext.status;
  408. const hash = traceContext && traceContext.hash;
  409. const exclusiveTime = traceContext && traceContext.exclusive_time;
  410. if (!spanEntry || spans.length <= 0) {
  411. return {
  412. op: rootSpanOpName,
  413. childSpans: {},
  414. traceStartTimestamp: event.startTimestamp,
  415. traceEndTimestamp: event.endTimestamp,
  416. traceID,
  417. rootSpanID,
  418. rootSpanStatus,
  419. parentSpanID,
  420. spans: [],
  421. description,
  422. hash,
  423. exclusiveTime,
  424. };
  425. }
  426. // any span may be a parent of another span
  427. const potentialParents = new Set(
  428. spans.map(span => {
  429. return span.span_id;
  430. })
  431. );
  432. // the root transaction span is a parent of all other spans
  433. potentialParents.add(rootSpanID);
  434. // we reduce spans to become an object mapping span ids to their children
  435. const init: ParsedTraceType = {
  436. op: rootSpanOpName,
  437. childSpans: {},
  438. traceStartTimestamp: event.startTimestamp,
  439. traceEndTimestamp: event.endTimestamp,
  440. traceID,
  441. rootSpanID,
  442. rootSpanStatus,
  443. parentSpanID,
  444. spans,
  445. description,
  446. hash,
  447. exclusiveTime,
  448. };
  449. const reduced: ParsedTraceType = spans.reduce((acc, inputSpan) => {
  450. let span: SpanType = inputSpan;
  451. const parentSpanId = getSpanParentSpanID(span);
  452. const hasParent = parentSpanId && potentialParents.has(parentSpanId);
  453. if (!isValidSpanID(parentSpanId) || !hasParent) {
  454. // this span is considered an orphan with respect to the spans within this transaction.
  455. // although the span is an orphan, it's still a descendant of this transaction,
  456. // so we set its parent span id to be the root transaction span's id
  457. span.parent_span_id = rootSpanID;
  458. span = {
  459. type: 'orphan',
  460. ...span,
  461. } as OrphanSpanType;
  462. }
  463. assert(span.parent_span_id);
  464. // get any span children whose parent_span_id is equal to span.parent_span_id,
  465. // otherwise start with an empty array
  466. const spanChildren: Array<SpanType> = acc.childSpans[span.parent_span_id] ?? [];
  467. spanChildren.push(span);
  468. set(acc.childSpans, span.parent_span_id, spanChildren);
  469. // set trace start & end timestamps based on given span's start and end timestamps
  470. if (!acc.traceStartTimestamp || span.start_timestamp < acc.traceStartTimestamp) {
  471. acc.traceStartTimestamp = span.start_timestamp;
  472. }
  473. // establish trace end timestamp
  474. const hasEndTimestamp = isNumber(span.timestamp);
  475. if (!acc.traceEndTimestamp) {
  476. if (hasEndTimestamp) {
  477. acc.traceEndTimestamp = span.timestamp;
  478. return acc;
  479. }
  480. acc.traceEndTimestamp = span.start_timestamp;
  481. return acc;
  482. }
  483. if (hasEndTimestamp && span.timestamp! > acc.traceEndTimestamp) {
  484. acc.traceEndTimestamp = span.timestamp;
  485. return acc;
  486. }
  487. if (span.start_timestamp > acc.traceEndTimestamp) {
  488. acc.traceEndTimestamp = span.start_timestamp;
  489. }
  490. return acc;
  491. }, init);
  492. // sort span children
  493. Object.values(reduced.childSpans).forEach(spanChildren => {
  494. spanChildren.sort(sortSpans);
  495. });
  496. return reduced;
  497. }
  498. function sortSpans(firstSpan: SpanType, secondSpan: SpanType) {
  499. // orphan spans come after non-orphan spans.
  500. if (isOrphanSpan(firstSpan) && !isOrphanSpan(secondSpan)) {
  501. // sort secondSpan before firstSpan
  502. return 1;
  503. }
  504. if (!isOrphanSpan(firstSpan) && isOrphanSpan(secondSpan)) {
  505. // sort firstSpan before secondSpan
  506. return -1;
  507. }
  508. // sort spans by their start timestamp in ascending order
  509. if (firstSpan.start_timestamp < secondSpan.start_timestamp) {
  510. // sort firstSpan before secondSpan
  511. return -1;
  512. }
  513. if (firstSpan.start_timestamp === secondSpan.start_timestamp) {
  514. return 0;
  515. }
  516. // sort secondSpan before firstSpan
  517. return 1;
  518. }
  519. export function isOrphanTreeDepth(
  520. treeDepth: TreeDepthType
  521. ): treeDepth is OrphanTreeDepth {
  522. if (typeof treeDepth === 'number') {
  523. return false;
  524. }
  525. return treeDepth?.type === 'orphan';
  526. }
  527. export function unwrapTreeDepth(treeDepth: TreeDepthType): number {
  528. if (isOrphanTreeDepth(treeDepth)) {
  529. return treeDepth.depth;
  530. }
  531. return treeDepth;
  532. }
  533. export function isEventFromBrowserJavaScriptSDK(
  534. event: EventTransaction | AggregateEventTransaction
  535. ): boolean {
  536. const sdkName = event.sdk?.name;
  537. if (!sdkName) {
  538. return false;
  539. }
  540. // based on https://github.com/getsentry/sentry-javascript/blob/master/packages/browser/src/version.ts
  541. return [
  542. 'sentry.javascript.browser',
  543. 'sentry.javascript.react',
  544. 'sentry.javascript.gatsby',
  545. 'sentry.javascript.ember',
  546. 'sentry.javascript.vue',
  547. 'sentry.javascript.angular',
  548. 'sentry.javascript.angular-ivy',
  549. 'sentry.javascript.nextjs',
  550. 'sentry.javascript.electron',
  551. 'sentry.javascript.remix',
  552. 'sentry.javascript.svelte',
  553. 'sentry.javascript.sveltekit',
  554. ].includes(sdkName.toLowerCase());
  555. }
  556. // Durationless ops from: https://github.com/getsentry/sentry-javascript/blob/0defcdcc2dfe719343efc359d58c3f90743da2cd/packages/apm/src/integrations/tracing.ts#L629-L688
  557. // PerformanceMark: Duration is 0 as per https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMark
  558. // PerformancePaintTiming: Duration is 0 as per https://developer.mozilla.org/en-US/docs/Web/API/PerformancePaintTiming
  559. export const durationlessBrowserOps = ['mark', 'paint'];
  560. type Measurements = {
  561. [name: string]: {
  562. failedThreshold: boolean;
  563. timestamp: number;
  564. value: number | undefined;
  565. };
  566. };
  567. export type VerticalMark = {
  568. failedThreshold: boolean;
  569. marks: Measurements;
  570. };
  571. function hasFailedThreshold(marks: Measurements): boolean {
  572. const names = Object.keys(marks);
  573. const records = Object.values(VITAL_DETAILS).filter(vital =>
  574. names.includes(vital.slug)
  575. );
  576. return records.some(record => {
  577. const {value} = marks[record.slug];
  578. if (typeof value === 'number' && typeof record.poorThreshold === 'number') {
  579. return value >= record.poorThreshold;
  580. }
  581. return false;
  582. });
  583. }
  584. export function getMeasurements(
  585. event: EventTransaction | TraceFullDetailed | AggregateEventTransaction,
  586. generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType
  587. ): Map<number, VerticalMark> {
  588. const startTimestamp =
  589. (event as EventTransaction).startTimestamp ||
  590. (event as TraceFullDetailed).start_timestamp;
  591. if (!event.measurements || !startTimestamp) {
  592. return new Map();
  593. }
  594. // Note: CLS and INP should not be included here, since they are not timeline-based measurements.
  595. const allowedVitals = new Set<string>([
  596. WebVital.FCP,
  597. WebVital.FP,
  598. WebVital.FID,
  599. WebVital.LCP,
  600. WebVital.TTFB,
  601. MobileVital.TIME_TO_FULL_DISPLAY,
  602. MobileVital.TIME_TO_INITIAL_DISPLAY,
  603. ]);
  604. const measurements = Object.keys(event.measurements)
  605. .filter(name => allowedVitals.has(`measurements.${name}`))
  606. .map(name => {
  607. const associatedMeasurement = event.measurements![name];
  608. return {
  609. name,
  610. // Time timestamp is in seconds, but the measurement value is given in ms so convert it here
  611. timestamp: startTimestamp + associatedMeasurement.value / 1000,
  612. value: associatedMeasurement ? associatedMeasurement.value : undefined,
  613. };
  614. });
  615. const mergedMeasurements = new Map<number, VerticalMark>();
  616. measurements.forEach(measurement => {
  617. const name = measurement.name;
  618. const value = measurement.value;
  619. const bounds = generateBounds({
  620. startTimestamp: measurement.timestamp,
  621. endTimestamp: measurement.timestamp,
  622. });
  623. // This condition will never be hit, since we're using the same value for start and end in generateBounds
  624. // I've put this condition here to prevent the TS linter from complaining
  625. if (bounds.type !== 'TIMESTAMPS_EQUAL') {
  626. return;
  627. }
  628. const roundedPos = Math.round(bounds.start * 100);
  629. // Compare this position with the position of the other measurements, to determine if
  630. // they are close enough to be bucketed together
  631. for (const [otherPos] of mergedMeasurements) {
  632. const positionDelta = Math.abs(otherPos - roundedPos);
  633. if (positionDelta <= MERGE_LABELS_THRESHOLD_PERCENT) {
  634. const verticalMark = mergedMeasurements.get(otherPos)!;
  635. const {poorThreshold} = VITAL_DETAILS[`measurements.${name}`];
  636. verticalMark.marks = {
  637. ...verticalMark.marks,
  638. [name]: {
  639. value,
  640. timestamp: measurement.timestamp,
  641. failedThreshold: value ? value >= poorThreshold : false,
  642. },
  643. };
  644. if (!verticalMark.failedThreshold) {
  645. verticalMark.failedThreshold = hasFailedThreshold(verticalMark.marks);
  646. }
  647. mergedMeasurements.set(otherPos, verticalMark);
  648. return;
  649. }
  650. }
  651. const {poorThreshold} = VITAL_DETAILS[`measurements.${name}`];
  652. const marks = {
  653. [name]: {
  654. value,
  655. timestamp: measurement.timestamp,
  656. failedThreshold: value ? value >= poorThreshold : false,
  657. },
  658. };
  659. mergedMeasurements.set(roundedPos, {
  660. marks,
  661. failedThreshold: hasFailedThreshold(marks),
  662. });
  663. });
  664. return mergedMeasurements;
  665. }
  666. export function getMeasurementBounds(
  667. timestamp: number,
  668. generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType
  669. ): SpanViewBoundsType {
  670. const bounds = generateBounds({
  671. startTimestamp: timestamp,
  672. endTimestamp: timestamp,
  673. });
  674. switch (bounds.type) {
  675. case 'TRACE_TIMESTAMPS_EQUAL':
  676. case 'INVALID_VIEW_WINDOW': {
  677. return {
  678. warning: undefined,
  679. left: undefined,
  680. width: undefined,
  681. isSpanVisibleInView: bounds.isSpanVisibleInView,
  682. };
  683. }
  684. case 'TIMESTAMPS_EQUAL': {
  685. return {
  686. warning: undefined,
  687. left: bounds.start,
  688. width: 0.00001,
  689. isSpanVisibleInView: bounds.isSpanVisibleInView,
  690. };
  691. }
  692. case 'TIMESTAMPS_REVERSED': {
  693. return {
  694. warning: undefined,
  695. left: bounds.start,
  696. width: bounds.end - bounds.start,
  697. isSpanVisibleInView: bounds.isSpanVisibleInView,
  698. };
  699. }
  700. case 'TIMESTAMPS_STABLE': {
  701. return {
  702. warning: void 0,
  703. left: bounds.start,
  704. width: bounds.end - bounds.start,
  705. isSpanVisibleInView: bounds.isSpanVisibleInView,
  706. };
  707. }
  708. default: {
  709. const _exhaustiveCheck: never = bounds;
  710. return _exhaustiveCheck;
  711. }
  712. }
  713. }
  714. export function scrollToSpan(
  715. spanId: string,
  716. scrollToHash: (hash: string) => void,
  717. location: Location,
  718. organization: Organization
  719. ) {
  720. return (e: React.MouseEvent<Element>) => {
  721. // do not use the default anchor behaviour
  722. // because it will be hidden behind the minimap
  723. e.preventDefault();
  724. const hash = spanTargetHash(spanId);
  725. scrollToHash(hash);
  726. // TODO(txiao): This is causing a rerender of the whole page,
  727. // which can be slow.
  728. //
  729. // make sure to update the location
  730. browserHistory.push({
  731. ...location,
  732. hash,
  733. });
  734. trackAnalytics('performance_views.event_details.anchor_span', {
  735. organization,
  736. span_id: spanId,
  737. });
  738. };
  739. }
  740. export function spanTargetHash(spanId: string): string {
  741. return `#span-${spanId}`;
  742. }
  743. export function transactionTargetHash(spanId: string): string {
  744. return `#txn-${spanId}`;
  745. }
  746. export function getSiblingGroupKey(span: SpanType, occurrence?: number): string {
  747. if (occurrence !== undefined) {
  748. return `${span.op}.${span.description}.${occurrence}`;
  749. }
  750. return `${span.op}.${span.description}`;
  751. }
  752. export function getSpanGroupTimestamps(spanGroup: EnhancedSpan[]) {
  753. return spanGroup.reduce(
  754. (acc, spanGroupItem) => {
  755. const {start_timestamp, timestamp} = spanGroupItem.span;
  756. let newStartTimestamp = acc.startTimestamp;
  757. let newEndTimestamp = acc.endTimestamp;
  758. if (start_timestamp < newStartTimestamp) {
  759. newStartTimestamp = start_timestamp;
  760. }
  761. if (newEndTimestamp < timestamp) {
  762. newEndTimestamp = timestamp;
  763. }
  764. return {
  765. startTimestamp: newStartTimestamp,
  766. endTimestamp: newEndTimestamp,
  767. };
  768. },
  769. {
  770. startTimestamp: spanGroup[0].span.start_timestamp,
  771. endTimestamp: spanGroup[0].span.timestamp,
  772. }
  773. );
  774. }
  775. export function getSpanGroupBounds(
  776. spanGroup: EnhancedSpan[],
  777. generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType
  778. ): SpanViewBoundsType {
  779. const {startTimestamp, endTimestamp} = getSpanGroupTimestamps(spanGroup);
  780. const bounds = generateBounds({
  781. startTimestamp,
  782. endTimestamp,
  783. });
  784. switch (bounds.type) {
  785. case 'TRACE_TIMESTAMPS_EQUAL':
  786. case 'INVALID_VIEW_WINDOW': {
  787. return {
  788. warning: void 0,
  789. left: void 0,
  790. width: void 0,
  791. isSpanVisibleInView: bounds.isSpanVisibleInView,
  792. };
  793. }
  794. case 'TIMESTAMPS_EQUAL': {
  795. return {
  796. warning: void 0,
  797. left: bounds.start,
  798. width: 0.00001,
  799. isSpanVisibleInView: bounds.isSpanVisibleInView,
  800. };
  801. }
  802. case 'TIMESTAMPS_REVERSED':
  803. case 'TIMESTAMPS_STABLE': {
  804. return {
  805. warning: void 0,
  806. left: bounds.start,
  807. width: bounds.end - bounds.start,
  808. isSpanVisibleInView: bounds.isSpanVisibleInView,
  809. };
  810. }
  811. default: {
  812. const _exhaustiveCheck: never = bounds;
  813. return _exhaustiveCheck;
  814. }
  815. }
  816. }
  817. export function getCumulativeAlertLevelFromErrors(
  818. errors?: Pick<TraceError, 'level'>[]
  819. ): keyof Theme['alert'] | undefined {
  820. const highestErrorLevel = maxBy(errors || [], error => ERROR_LEVEL_WEIGHTS[error.level])
  821. ?.level;
  822. if (!highestErrorLevel) {
  823. return undefined;
  824. }
  825. return ERROR_LEVEL_TO_ALERT_TYPE[highestErrorLevel];
  826. }
  827. // Maps the known error levels to an Alert component types
  828. const ERROR_LEVEL_TO_ALERT_TYPE: Record<TraceError['level'], keyof Theme['alert']> = {
  829. fatal: 'error',
  830. error: 'error',
  831. default: 'error',
  832. warning: 'warning',
  833. sample: 'info',
  834. info: 'info',
  835. unknown: 'muted',
  836. };
  837. // Allows sorting errors according to their level of severity
  838. const ERROR_LEVEL_WEIGHTS: Record<TraceError['level'], number> = {
  839. fatal: 5,
  840. error: 4,
  841. default: 4,
  842. warning: 3,
  843. sample: 2,
  844. info: 1,
  845. unknown: 0,
  846. };
  847. /**
  848. * Formats start and end unix timestamps by inserting a leading and trailing zero if needed, so they can have the same length
  849. */
  850. export function getFormattedTimeRangeWithLeadingAndTrailingZero(
  851. start: number,
  852. end: number
  853. ) {
  854. const startStrings = String(start).split('.');
  855. const endStrings = String(end).split('.');
  856. if (startStrings.length !== 2 || endStrings.length !== 2) {
  857. return {
  858. start: String(start),
  859. end: String(end),
  860. };
  861. }
  862. const newTimestamps = startStrings.reduce<{
  863. end: string[];
  864. start: string[];
  865. }>(
  866. (acc, startString, index) => {
  867. if (startString.length > endStrings[index].length) {
  868. acc.start.push(startString);
  869. acc.end.push(
  870. index === 0
  871. ? endStrings[index].padStart(startString.length, '0')
  872. : endStrings[index].padEnd(startString.length, '0')
  873. );
  874. return acc;
  875. }
  876. acc.start.push(
  877. index === 0
  878. ? startString.padStart(endStrings[index].length, '0')
  879. : startString.padEnd(endStrings[index].length, '0')
  880. );
  881. acc.end.push(endStrings[index]);
  882. return acc;
  883. },
  884. {start: [], end: []}
  885. );
  886. return {
  887. start: newTimestamps.start.join('.'),
  888. end: newTimestamps.end.join('.'),
  889. };
  890. }
  891. export function groupShouldBeHidden(
  892. group: SpanTreeModel[],
  893. focusedSpanIDs: Set<string> | undefined
  894. ) {
  895. if (!focusedSpanIDs) {
  896. return false;
  897. }
  898. // If none of the spans in this group are focused, the group should be hidden
  899. return !group.some(spanModel => focusedSpanIDs.has(spanModel.span.span_id));
  900. }