spanDetail.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import map from 'lodash/map';
  5. import {Client} from 'sentry/api';
  6. import Feature from 'sentry/components/acl/feature';
  7. import Alert from 'sentry/components/alert';
  8. import Button from 'sentry/components/button';
  9. import DateTime from 'sentry/components/dateTime';
  10. import DiscoverButton from 'sentry/components/discoverButton';
  11. import FileSize from 'sentry/components/fileSize';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import Link from 'sentry/components/links/link';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import {
  16. ErrorDot,
  17. ErrorLevel,
  18. ErrorMessageContent,
  19. ErrorMessageTitle,
  20. ErrorTitle,
  21. } from 'sentry/components/performance/waterfall/rowDetails';
  22. import Pill from 'sentry/components/pill';
  23. import Pills from 'sentry/components/pills';
  24. import {
  25. generateIssueEventTarget,
  26. generateTraceTarget,
  27. } from 'sentry/components/quickTrace/utils';
  28. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/globalSelectionHeader';
  29. import {IconAnchor, IconWarning} from 'sentry/icons';
  30. import {t, tn} from 'sentry/locale';
  31. import space from 'sentry/styles/space';
  32. import {Organization} from 'sentry/types';
  33. import {EventTransaction} from 'sentry/types/event';
  34. import {assert} from 'sentry/types/utils';
  35. import {defined} from 'sentry/utils';
  36. import EventView from 'sentry/utils/discover/eventView';
  37. import {generateEventSlug} from 'sentry/utils/discover/urls';
  38. import getDynamicText from 'sentry/utils/getDynamicText';
  39. import {QuickTraceEvent, TraceError} from 'sentry/utils/performance/quickTrace/types';
  40. import withApi from 'sentry/utils/withApi';
  41. import * as SpanEntryContext from './context';
  42. import InlineDocs from './inlineDocs';
  43. import {ParsedTraceType, ProcessedSpanType, rawSpanKeys, RawSpanType} from './types';
  44. import {getTraceDateTimeRange, isGapSpan, isOrphanSpan, scrollToSpan} from './utils';
  45. const DEFAULT_ERRORS_VISIBLE = 5;
  46. const SIZE_DATA_KEYS = ['Encoded Body Size', 'Decoded Body Size', 'Transfer Size'];
  47. type TransactionResult = {
  48. 'project.name': string;
  49. transaction: string;
  50. 'trace.span': string;
  51. id: string;
  52. };
  53. type Props = WithRouterProps & {
  54. api: Client;
  55. organization: Organization;
  56. event: Readonly<EventTransaction>;
  57. span: Readonly<ProcessedSpanType>;
  58. isRoot: boolean;
  59. trace: Readonly<ParsedTraceType>;
  60. childTransactions: QuickTraceEvent[] | null;
  61. relatedErrors: TraceError[] | null;
  62. scrollToHash: (hash: string) => void;
  63. };
  64. type State = {
  65. errorsOpened: boolean;
  66. };
  67. class SpanDetail extends React.Component<Props, State> {
  68. state: State = {
  69. errorsOpened: false,
  70. };
  71. renderTraversalButton(): React.ReactNode {
  72. if (!this.props.childTransactions) {
  73. // TODO: Amend size to use theme when we eventually refactor LoadingIndicator
  74. // 12px is consistent with theme.iconSizes['xs'] but theme returns a string.
  75. return (
  76. <StyledDiscoverButton size="xsmall" disabled>
  77. <StyledLoadingIndicator size={12} />
  78. </StyledDiscoverButton>
  79. );
  80. }
  81. if (this.props.childTransactions.length <= 0) {
  82. return (
  83. <StyledDiscoverButton size="xsmall" disabled>
  84. {t('No Children')}
  85. </StyledDiscoverButton>
  86. );
  87. }
  88. const {span, trace, event, organization} = this.props;
  89. assert(!isGapSpan(span));
  90. if (this.props.childTransactions.length === 1) {
  91. // Note: This is rendered by this.renderSpanChild() as a dedicated row
  92. return null;
  93. }
  94. const orgFeatures = new Set(organization.features);
  95. const {start, end} = getTraceDateTimeRange({
  96. start: trace.traceStartTimestamp,
  97. end: trace.traceEndTimestamp,
  98. });
  99. const childrenEventView = EventView.fromSavedQuery({
  100. id: undefined,
  101. name: `Children from Span ID ${span.span_id}`,
  102. fields: [
  103. 'transaction',
  104. 'project',
  105. 'trace.span',
  106. 'transaction.duration',
  107. 'timestamp',
  108. ],
  109. orderby: '-timestamp',
  110. query: `event.type:transaction trace:${span.trace_id} trace.parent_span:${span.span_id}`,
  111. projects: orgFeatures.has('global-views')
  112. ? [ALL_ACCESS_PROJECTS]
  113. : [Number(event.projectID)],
  114. version: 2,
  115. start,
  116. end,
  117. });
  118. return (
  119. <StyledDiscoverButton
  120. data-test-id="view-child-transactions"
  121. size="xsmall"
  122. to={childrenEventView.getResultsViewUrlTarget(organization.slug)}
  123. >
  124. {t('View Children')}
  125. </StyledDiscoverButton>
  126. );
  127. }
  128. renderSpanChild(): React.ReactNode {
  129. const {childTransactions} = this.props;
  130. if (!childTransactions || childTransactions.length !== 1) {
  131. return null;
  132. }
  133. const childTransaction = childTransactions[0];
  134. const transactionResult: TransactionResult = {
  135. 'project.name': childTransaction.project_slug,
  136. transaction: childTransaction.transaction,
  137. 'trace.span': childTransaction.span_id,
  138. id: childTransaction.event_id,
  139. };
  140. const eventSlug = generateSlug(transactionResult);
  141. const viewChildButton = (
  142. <SpanEntryContext.Consumer>
  143. {({getViewChildTransactionTarget}) => {
  144. const to = getViewChildTransactionTarget({
  145. ...transactionResult,
  146. eventSlug,
  147. });
  148. if (!to) {
  149. return null;
  150. }
  151. return (
  152. <StyledButton data-test-id="view-child-transaction" size="xsmall" to={to}>
  153. {t('View Transaction')}
  154. </StyledButton>
  155. );
  156. }}
  157. </SpanEntryContext.Consumer>
  158. );
  159. return (
  160. <Row title="Child Transaction" extra={viewChildButton}>
  161. {`${transactionResult.transaction} (${transactionResult['project.name']})`}
  162. </Row>
  163. );
  164. }
  165. renderTraceButton() {
  166. const {span, organization, event} = this.props;
  167. if (isGapSpan(span)) {
  168. return null;
  169. }
  170. return (
  171. <StyledButton size="xsmall" to={generateTraceTarget(event, organization)}>
  172. {t('View Trace')}
  173. </StyledButton>
  174. );
  175. }
  176. renderOrphanSpanMessage() {
  177. const {span} = this.props;
  178. if (!isOrphanSpan(span)) {
  179. return null;
  180. }
  181. return (
  182. <Alert system type="info" icon={<IconWarning size="md" />}>
  183. {t(
  184. 'This is a span that has no parent span within this transaction. It has been attached to the transaction root span by default.'
  185. )}
  186. </Alert>
  187. );
  188. }
  189. toggleErrors = () => {
  190. this.setState(({errorsOpened}) => ({errorsOpened: !errorsOpened}));
  191. };
  192. renderSpanErrorMessage() {
  193. const {span, organization, relatedErrors} = this.props;
  194. const {errorsOpened} = this.state;
  195. if (!relatedErrors || relatedErrors.length <= 0 || isGapSpan(span)) {
  196. return null;
  197. }
  198. const visibleErrors = errorsOpened
  199. ? relatedErrors
  200. : relatedErrors.slice(0, DEFAULT_ERRORS_VISIBLE);
  201. return (
  202. <Alert system type="error" icon={<IconWarning size="md" />}>
  203. <ErrorMessageTitle>
  204. {tn(
  205. 'An error event occurred in this transaction.',
  206. '%s error events occurred in this transaction.',
  207. relatedErrors.length
  208. )}
  209. </ErrorMessageTitle>
  210. <ErrorMessageContent>
  211. {visibleErrors.map(error => (
  212. <React.Fragment key={error.event_id}>
  213. <ErrorDot level={error.level} />
  214. <ErrorLevel>{error.level}</ErrorLevel>
  215. <ErrorTitle>
  216. <Link to={generateIssueEventTarget(error, organization)}>
  217. {error.title}
  218. </Link>
  219. </ErrorTitle>
  220. </React.Fragment>
  221. ))}
  222. </ErrorMessageContent>
  223. {relatedErrors.length > DEFAULT_ERRORS_VISIBLE && (
  224. <ErrorToggle size="xsmall" onClick={this.toggleErrors}>
  225. {errorsOpened ? t('Show less') : t('Show more')}
  226. </ErrorToggle>
  227. )}
  228. </Alert>
  229. );
  230. }
  231. partitionSizes(data) {
  232. const sizeKeys = SIZE_DATA_KEYS.reduce((keys, key) => {
  233. if (data.hasOwnProperty(key)) {
  234. keys[key] = data[key];
  235. }
  236. return keys;
  237. }, {});
  238. const nonSizeKeys = {...data};
  239. SIZE_DATA_KEYS.forEach(key => delete nonSizeKeys[key]);
  240. return {
  241. sizeKeys,
  242. nonSizeKeys,
  243. };
  244. }
  245. renderSpanDetails() {
  246. const {span, event, location, organization, scrollToHash} = this.props;
  247. if (isGapSpan(span)) {
  248. return (
  249. <SpanDetails>
  250. <InlineDocs
  251. platform={event.sdk?.name || ''}
  252. orgSlug={organization.slug}
  253. projectSlug={event?.projectSlug ?? ''}
  254. />
  255. </SpanDetails>
  256. );
  257. }
  258. const startTimestamp: number = span.start_timestamp;
  259. const endTimestamp: number = span.timestamp;
  260. const duration = (endTimestamp - startTimestamp) * 1000;
  261. const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`;
  262. const unknownKeys = Object.keys(span).filter(key => {
  263. return !rawSpanKeys.has(key as any);
  264. });
  265. const {sizeKeys, nonSizeKeys} = this.partitionSizes(span?.data ?? {});
  266. const allZeroSizes = SIZE_DATA_KEYS.map(key => sizeKeys[key]).every(
  267. value => value === 0
  268. );
  269. return (
  270. <React.Fragment>
  271. {this.renderOrphanSpanMessage()}
  272. {this.renderSpanErrorMessage()}
  273. <SpanDetails>
  274. <table className="table key-value">
  275. <tbody>
  276. <Row
  277. title={
  278. isGapSpan(span) ? (
  279. <SpanIdTitle>Span ID</SpanIdTitle>
  280. ) : (
  281. <SpanIdTitle
  282. onClick={scrollToSpan(span.span_id, scrollToHash, location)}
  283. >
  284. Span ID
  285. <StyledIconAnchor />
  286. </SpanIdTitle>
  287. )
  288. }
  289. extra={this.renderTraversalButton()}
  290. >
  291. {span.span_id}
  292. </Row>
  293. <Row title="Parent Span ID">{span.parent_span_id || ''}</Row>
  294. {this.renderSpanChild()}
  295. <Row title="Trace ID" extra={this.renderTraceButton()}>
  296. {span.trace_id}
  297. </Row>
  298. <Row title="Description">{span?.description ?? ''}</Row>
  299. <Row title="Status">{span.status || ''}</Row>
  300. <Row title="Start Date">
  301. {getDynamicText({
  302. fixed: 'Mar 16, 2020 9:10:12 AM UTC',
  303. value: (
  304. <React.Fragment>
  305. <DateTime date={startTimestamp * 1000} />
  306. {` (${startTimestamp})`}
  307. </React.Fragment>
  308. ),
  309. })}
  310. </Row>
  311. <Row title="End Date">
  312. {getDynamicText({
  313. fixed: 'Mar 16, 2020 9:10:13 AM UTC',
  314. value: (
  315. <React.Fragment>
  316. <DateTime date={endTimestamp * 1000} />
  317. {` (${endTimestamp})`}
  318. </React.Fragment>
  319. ),
  320. })}
  321. </Row>
  322. <Row title="Duration">{durationString}</Row>
  323. <Row title="Operation">{span.op || ''}</Row>
  324. <Row title="Same Process as Parent">
  325. {span.same_process_as_parent !== undefined
  326. ? String(span.same_process_as_parent)
  327. : null}
  328. </Row>
  329. <Feature
  330. organization={organization}
  331. features={['organizations:performance-suspect-spans-view']}
  332. >
  333. <Row title="Span Group">
  334. {defined(span.hash) ? String(span.hash) : null}
  335. </Row>
  336. <Row title="Span Exclusive Time">
  337. {defined(span.exclusive_time)
  338. ? `${Number(span.exclusive_time.toFixed(3)).toLocaleString()}ms`
  339. : null}
  340. </Row>
  341. </Feature>
  342. <Tags span={span} />
  343. {allZeroSizes && (
  344. <TextTr>
  345. The following sizes were not collected for security reasons. Check if
  346. the host serves the appropriate
  347. <ExternalLink href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin">
  348. <span className="val-string">Timing-Allow-Origin</span>
  349. </ExternalLink>
  350. header. You may have to enable this collection manually.
  351. </TextTr>
  352. )}
  353. {map(sizeKeys, (value, key) => (
  354. <Row title={key} key={key}>
  355. <React.Fragment>
  356. <FileSize bytes={value} />
  357. {value >= 1024 && (
  358. <span>{` (${JSON.stringify(value, null, 4) || ''} B)`}</span>
  359. )}
  360. </React.Fragment>
  361. </Row>
  362. ))}
  363. {map(nonSizeKeys, (value, key) => (
  364. <Row title={key} key={key}>
  365. {JSON.stringify(value, null, 4) || ''}
  366. </Row>
  367. ))}
  368. {unknownKeys.map(key => (
  369. <Row title={key} key={key}>
  370. {JSON.stringify(span[key], null, 4) || ''}
  371. </Row>
  372. ))}
  373. </tbody>
  374. </table>
  375. </SpanDetails>
  376. </React.Fragment>
  377. );
  378. }
  379. render() {
  380. return (
  381. <SpanDetailContainer
  382. data-component="span-detail"
  383. onClick={event => {
  384. // prevent toggling the span detail
  385. event.stopPropagation();
  386. }}
  387. >
  388. {this.renderSpanDetails()}
  389. </SpanDetailContainer>
  390. );
  391. }
  392. }
  393. const StyledDiscoverButton = styled(DiscoverButton)`
  394. position: absolute;
  395. top: ${space(0.75)};
  396. right: ${space(0.5)};
  397. `;
  398. const StyledButton = styled(Button)`
  399. position: absolute;
  400. top: ${space(0.75)};
  401. right: ${space(0.5)};
  402. `;
  403. export const SpanDetailContainer = styled('div')`
  404. border-bottom: 1px solid ${p => p.theme.border};
  405. cursor: auto;
  406. `;
  407. export const SpanDetails = styled('div')`
  408. padding: ${space(2)};
  409. `;
  410. const ValueTd = styled('td')`
  411. position: relative;
  412. `;
  413. const StyledLoadingIndicator = styled(LoadingIndicator)`
  414. display: flex;
  415. align-items: center;
  416. height: ${space(2)};
  417. margin: 0;
  418. `;
  419. const StyledText = styled('p')`
  420. font-size: ${p => p.theme.fontSizeMedium};
  421. margin: ${space(2)} ${space(0)};
  422. `;
  423. const TextTr = ({children}) => (
  424. <tr>
  425. <td className="key" />
  426. <ValueTd className="value">
  427. <StyledText>{children}</StyledText>
  428. </ValueTd>
  429. </tr>
  430. );
  431. const ErrorToggle = styled(Button)`
  432. margin-top: ${space(0.75)};
  433. `;
  434. const SpanIdTitle = styled('a')`
  435. display: flex;
  436. color: ${p => p.theme.textColor};
  437. :hover {
  438. color: ${p => p.theme.textColor};
  439. }
  440. `;
  441. const StyledIconAnchor = styled(IconAnchor)`
  442. display: block;
  443. color: ${p => p.theme.gray300};
  444. margin-left: ${space(1)};
  445. `;
  446. export const Row = ({
  447. title,
  448. keep,
  449. children,
  450. extra = null,
  451. }: {
  452. title: JSX.Element | string | null;
  453. children: JSX.Element | string | null;
  454. keep?: boolean;
  455. extra?: React.ReactNode;
  456. }) => {
  457. if (!keep && !children) {
  458. return null;
  459. }
  460. return (
  461. <tr>
  462. <td className="key">{title}</td>
  463. <ValueTd className="value">
  464. <pre className="val">
  465. <span className="val-string">{children}</span>
  466. </pre>
  467. {extra}
  468. </ValueTd>
  469. </tr>
  470. );
  471. };
  472. export const Tags = ({span}: {span: RawSpanType}) => {
  473. const tags: {[tag_name: string]: string} | undefined = span?.tags;
  474. if (!tags) {
  475. return null;
  476. }
  477. const keys = Object.keys(tags);
  478. if (keys.length <= 0) {
  479. return null;
  480. }
  481. return (
  482. <tr>
  483. <td className="key">Tags</td>
  484. <td className="value">
  485. <Pills style={{padding: '8px'}}>
  486. {keys.map((key, index) => (
  487. <Pill key={index} name={key} value={String(tags[key]) || ''} />
  488. ))}
  489. </Pills>
  490. </td>
  491. </tr>
  492. );
  493. };
  494. function generateSlug(result: TransactionResult): string {
  495. return generateEventSlug({
  496. id: result.id,
  497. 'project.name': result['project.name'],
  498. });
  499. }
  500. export default withApi(withRouter(SpanDetail));