spanDetail.tsx 19 KB

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