fieldRenderers.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  1. import {Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import partial from 'lodash/partial';
  6. import {Button} from 'sentry/components/button';
  7. import Count from 'sentry/components/count';
  8. import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
  9. import Duration from 'sentry/components/duration';
  10. import FileSize from 'sentry/components/fileSize';
  11. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  12. import UserBadge from 'sentry/components/idBadge/userBadge';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import Link from 'sentry/components/links/link';
  15. import {RowRectangle} from 'sentry/components/performance/waterfall/rowBar';
  16. import {pickBarColor, toPercent} from 'sentry/components/performance/waterfall/utils';
  17. import {Tooltip} from 'sentry/components/tooltip';
  18. import UserMisery from 'sentry/components/userMisery';
  19. import Version from 'sentry/components/version';
  20. import {IconDownload} from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import {AvatarProject, IssueAttachment, Organization, Project} from 'sentry/types';
  24. import {defined, isUrl} from 'sentry/utils';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import EventView, {EventData, MetaType} from 'sentry/utils/discover/eventView';
  27. import {
  28. AGGREGATIONS,
  29. getAggregateAlias,
  30. getSpanOperationName,
  31. isEquation,
  32. isRelativeSpanOperationBreakdownField,
  33. RATE_UNIT_LABELS,
  34. SPAN_OP_BREAKDOWN_FIELDS,
  35. SPAN_OP_RELATIVE_BREAKDOWN_FIELD,
  36. } from 'sentry/utils/discover/fields';
  37. import {getShortEventId} from 'sentry/utils/events';
  38. import {
  39. formatAbbreviatedNumber,
  40. formatFloat,
  41. formatPercentage,
  42. } from 'sentry/utils/formatters';
  43. import getDynamicText from 'sentry/utils/getDynamicText';
  44. import Projects from 'sentry/utils/projects';
  45. import toArray from 'sentry/utils/toArray';
  46. import {QuickContextHoverWrapper} from 'sentry/views/discover/table/quickContext/quickContextWrapper';
  47. import {ContextType} from 'sentry/views/discover/table/quickContext/utils';
  48. import {
  49. filterToLocationQuery,
  50. SpanOperationBreakdownFilter,
  51. stringToFilter,
  52. } from 'sentry/views/performance/transactionSummary/filter';
  53. import {PercentChangeCell} from 'sentry/views/starfish/components/tableCells/percentChangeCell';
  54. import {TimeSpentCell} from 'sentry/views/starfish/components/tableCells/timeSpentCell';
  55. import {SpanMetricsFields} from 'sentry/views/starfish/types';
  56. import {decodeScalar} from '../queryString';
  57. import ArrayValue from './arrayValue';
  58. import {
  59. BarContainer,
  60. Container,
  61. FieldDateTime,
  62. FieldShortId,
  63. FlexContainer,
  64. NumberContainer,
  65. OverflowFieldShortId,
  66. OverflowLink,
  67. UserIcon,
  68. VersionContainer,
  69. } from './styles';
  70. import TeamKeyTransactionField from './teamKeyTransactionField';
  71. /**
  72. * Types, functions and definitions for rendering fields in discover results.
  73. */
  74. export type RenderFunctionBaggage = {
  75. location: Location;
  76. organization: Organization;
  77. eventView?: EventView;
  78. projectSlug?: string;
  79. unit?: string;
  80. };
  81. type RenderFunctionOptions = {
  82. enableOnClick?: boolean;
  83. };
  84. type FieldFormatterRenderFunction = (
  85. field: string,
  86. data: EventData,
  87. baggage?: RenderFunctionBaggage
  88. ) => React.ReactNode;
  89. type FieldFormatterRenderFunctionPartial = (
  90. data: EventData,
  91. baggage: RenderFunctionBaggage
  92. ) => React.ReactNode;
  93. type FieldFormatter = {
  94. isSortable: boolean;
  95. renderFunc: FieldFormatterRenderFunction;
  96. };
  97. type FieldFormatters = {
  98. array: FieldFormatter;
  99. boolean: FieldFormatter;
  100. date: FieldFormatter;
  101. duration: FieldFormatter;
  102. integer: FieldFormatter;
  103. number: FieldFormatter;
  104. percent_change: FieldFormatter;
  105. percentage: FieldFormatter;
  106. rate: FieldFormatter;
  107. size: FieldFormatter;
  108. string: FieldFormatter;
  109. };
  110. export type FieldTypes = keyof FieldFormatters;
  111. const DEFAULT_RATE_SIG_DIGITS = 3;
  112. const EmptyValueContainer = styled('span')`
  113. color: ${p => p.theme.gray300};
  114. `;
  115. const emptyValue = <EmptyValueContainer>{t('(no value)')}</EmptyValueContainer>;
  116. const emptyStringValue = <EmptyValueContainer>{t('(empty string)')}</EmptyValueContainer>;
  117. export function nullableValue(value: string | null): string | React.ReactElement {
  118. switch (value) {
  119. case null:
  120. return emptyValue;
  121. case '':
  122. return emptyStringValue;
  123. default:
  124. return value;
  125. }
  126. }
  127. export const SIZE_UNITS = {
  128. bit: 1 / 8,
  129. byte: 1,
  130. kibibyte: 1024,
  131. mebibyte: 1024 ** 2,
  132. gibibyte: 1024 ** 3,
  133. tebibyte: 1024 ** 4,
  134. pebibyte: 1024 ** 5,
  135. exbibyte: 1024 ** 6,
  136. kilobyte: 1000,
  137. megabyte: 1000 ** 2,
  138. gigabyte: 1000 ** 3,
  139. terabyte: 1000 ** 4,
  140. petabyte: 1000 ** 5,
  141. exabyte: 1000 ** 6,
  142. };
  143. export const ABYTE_UNITS = [
  144. 'kilobyte',
  145. 'megabyte',
  146. 'gigabyte',
  147. 'terabyte',
  148. 'petabyte',
  149. 'exabyte',
  150. ];
  151. export const DURATION_UNITS = {
  152. nanosecond: 1 / 1000 ** 2,
  153. microsecond: 1 / 1000,
  154. millisecond: 1,
  155. second: 1000,
  156. minute: 1000 * 60,
  157. hour: 1000 * 60 * 60,
  158. day: 1000 * 60 * 60 * 24,
  159. week: 1000 * 60 * 60 * 24 * 7,
  160. };
  161. export const PERCENTAGE_UNITS = ['ratio', 'percent'];
  162. /**
  163. * A mapping of field types to their rendering function.
  164. * This mapping is used when a field is not defined in SPECIAL_FIELDS
  165. * and the field is not being coerced to a link.
  166. *
  167. * This mapping should match the output sentry.utils.snuba:get_json_type
  168. */
  169. export const FIELD_FORMATTERS: FieldFormatters = {
  170. boolean: {
  171. isSortable: true,
  172. renderFunc: (field, data) => {
  173. const value = data[field] ? t('true') : t('false');
  174. return <Container>{value}</Container>;
  175. },
  176. },
  177. date: {
  178. isSortable: true,
  179. renderFunc: (field, data, baggage) => (
  180. <Container>
  181. {data[field]
  182. ? getDynamicText({
  183. value: (
  184. <FieldDateTime
  185. date={data[field]}
  186. year
  187. seconds
  188. timeZone
  189. utc={decodeScalar(baggage?.location?.query?.utc) === 'true'}
  190. />
  191. ),
  192. fixed: 'timestamp',
  193. })
  194. : emptyValue}
  195. </Container>
  196. ),
  197. },
  198. duration: {
  199. isSortable: true,
  200. renderFunc: (field, data, baggage) => {
  201. const {unit} = baggage ?? {};
  202. return (
  203. <NumberContainer>
  204. {typeof data[field] === 'number' ? (
  205. <Duration
  206. seconds={(data[field] * ((unit && DURATION_UNITS[unit]) ?? 1)) / 1000}
  207. fixedDigits={2}
  208. abbreviation
  209. />
  210. ) : (
  211. emptyValue
  212. )}
  213. </NumberContainer>
  214. );
  215. },
  216. },
  217. rate: {
  218. isSortable: true,
  219. renderFunc: (field, data, baggage) => {
  220. const {unit} = baggage ?? {};
  221. const renderedUnit = unit ? RATE_UNIT_LABELS[unit] : '';
  222. const formattedNumber = `${formatAbbreviatedNumber(
  223. data[field],
  224. DEFAULT_RATE_SIG_DIGITS
  225. )}${renderedUnit}`;
  226. return <NumberContainer>{formattedNumber}</NumberContainer>;
  227. },
  228. },
  229. integer: {
  230. isSortable: true,
  231. renderFunc: (field, data) => (
  232. <NumberContainer>
  233. {typeof data[field] === 'number' ? <Count value={data[field]} /> : emptyValue}
  234. </NumberContainer>
  235. ),
  236. },
  237. number: {
  238. isSortable: true,
  239. renderFunc: (field, data) => (
  240. <NumberContainer>
  241. {typeof data[field] === 'number' ? formatFloat(data[field], 4) : emptyValue}
  242. </NumberContainer>
  243. ),
  244. },
  245. percentage: {
  246. isSortable: true,
  247. renderFunc: (field, data) => (
  248. <NumberContainer>
  249. {typeof data[field] === 'number' ? formatPercentage(data[field]) : emptyValue}
  250. </NumberContainer>
  251. ),
  252. },
  253. size: {
  254. isSortable: true,
  255. renderFunc: (field, data, baggage) => {
  256. const {unit} = baggage ?? {};
  257. return (
  258. <NumberContainer>
  259. {unit && SIZE_UNITS[unit] && typeof data[field] === 'number' ? (
  260. <FileSize
  261. bytes={data[field] * SIZE_UNITS[unit]}
  262. base={ABYTE_UNITS.includes(unit) ? 10 : 2}
  263. />
  264. ) : (
  265. emptyValue
  266. )}
  267. </NumberContainer>
  268. );
  269. },
  270. },
  271. string: {
  272. isSortable: true,
  273. renderFunc: (field, data) => {
  274. // Some fields have long arrays in them, only show the tail of the data.
  275. const value = Array.isArray(data[field])
  276. ? data[field].slice(-1)
  277. : defined(data[field])
  278. ? data[field]
  279. : emptyValue;
  280. if (isUrl(value)) {
  281. return (
  282. <Container>
  283. <ExternalLink href={value} data-test-id="group-tag-url">
  284. {value}
  285. </ExternalLink>
  286. </Container>
  287. );
  288. }
  289. return <Container>{nullableValue(value)}</Container>;
  290. },
  291. },
  292. array: {
  293. isSortable: true,
  294. renderFunc: (field, data) => {
  295. const value = toArray(data[field]);
  296. return <ArrayValue value={value} />;
  297. },
  298. },
  299. percent_change: {
  300. isSortable: true,
  301. renderFunc: (fieldName, data) => {
  302. return <PercentChangeCell deltaValue={data[fieldName]} />;
  303. },
  304. },
  305. };
  306. type SpecialFieldRenderFunc = (
  307. data: EventData,
  308. baggage: RenderFunctionBaggage
  309. ) => React.ReactNode;
  310. type SpecialField = {
  311. renderFunc: SpecialFieldRenderFunc;
  312. sortField: string | null;
  313. };
  314. type SpecialFields = {
  315. attachments: SpecialField;
  316. 'count_unique(user)': SpecialField;
  317. 'error.handled': SpecialField;
  318. id: SpecialField;
  319. issue: SpecialField;
  320. 'issue.id': SpecialField;
  321. minidump: SpecialField;
  322. 'profile.id': SpecialField;
  323. project: SpecialField;
  324. release: SpecialField;
  325. replayId: SpecialField;
  326. team_key_transaction: SpecialField;
  327. 'timestamp.to_day': SpecialField;
  328. 'timestamp.to_hour': SpecialField;
  329. trace: SpecialField;
  330. 'trend_percentage()': SpecialField;
  331. user: SpecialField;
  332. 'user.display': SpecialField;
  333. };
  334. const DownloadCount = styled('span')`
  335. padding-left: ${space(0.75)};
  336. `;
  337. const RightAlignedContainer = styled('span')`
  338. margin-left: auto;
  339. margin-right: 0;
  340. `;
  341. /**
  342. * "Special fields" either do not map 1:1 to an single column in the event database,
  343. * or they require custom UI formatting that can't be handled by the datatype formatters.
  344. */
  345. const SPECIAL_FIELDS: SpecialFields = {
  346. // This is a custom renderer for a field outside discover
  347. // TODO - refactor code and remove from this file or add ability to query for attachments in Discover
  348. attachments: {
  349. sortField: null,
  350. renderFunc: (data, {organization, projectSlug}) => {
  351. const attachments: Array<IssueAttachment> = data.attachments;
  352. const items: MenuItemProps[] = attachments
  353. .filter(attachment => attachment.type !== 'event.minidump')
  354. .map(attachment => ({
  355. key: attachment.id,
  356. label: attachment.name,
  357. onAction: () =>
  358. window.open(
  359. `/api/0/projects/${organization.slug}/${projectSlug}/events/${attachment.event_id}/attachments/${attachment.id}/?download=1`
  360. ),
  361. }));
  362. return (
  363. <RightAlignedContainer>
  364. <DropdownMenu
  365. position="left"
  366. size="xs"
  367. triggerProps={{
  368. showChevron: false,
  369. icon: (
  370. <Fragment>
  371. <IconDownload color="gray500" size="sm" />
  372. <DownloadCount>{items.length}</DownloadCount>
  373. </Fragment>
  374. ),
  375. }}
  376. items={items}
  377. />
  378. </RightAlignedContainer>
  379. );
  380. },
  381. },
  382. minidump: {
  383. sortField: null,
  384. renderFunc: (data, {organization, projectSlug}) => {
  385. const attachments: Array<IssueAttachment & {url: string}> = data.attachments;
  386. const minidump = attachments.find(
  387. attachment => attachment.type === 'event.minidump'
  388. );
  389. return (
  390. <RightAlignedContainer>
  391. <Button
  392. size="xs"
  393. disabled={!minidump}
  394. onClick={
  395. minidump
  396. ? () => {
  397. window.open(
  398. `/api/0/projects/${organization.slug}/${projectSlug}/events/${minidump.event_id}/attachments/${minidump.id}/?download=1`
  399. );
  400. }
  401. : undefined
  402. }
  403. >
  404. <IconDownload color="gray500" size="sm" />
  405. <DownloadCount>{minidump ? 1 : 0}</DownloadCount>
  406. </Button>
  407. </RightAlignedContainer>
  408. );
  409. },
  410. },
  411. id: {
  412. sortField: 'id',
  413. renderFunc: data => {
  414. const id: string | unknown = data?.id;
  415. if (typeof id !== 'string') {
  416. return null;
  417. }
  418. return <Container>{getShortEventId(id)}</Container>;
  419. },
  420. },
  421. trace: {
  422. sortField: 'trace',
  423. renderFunc: data => {
  424. const id: string | unknown = data?.trace;
  425. if (typeof id !== 'string') {
  426. return emptyValue;
  427. }
  428. return <Container>{getShortEventId(id)}</Container>;
  429. },
  430. },
  431. 'issue.id': {
  432. sortField: 'issue.id',
  433. renderFunc: (data, {organization}) => {
  434. const target = {
  435. pathname: `/organizations/${organization.slug}/issues/${data['issue.id']}/`,
  436. };
  437. return (
  438. <Container>
  439. <OverflowLink to={target} aria-label={data['issue.id']}>
  440. {data['issue.id']}
  441. </OverflowLink>
  442. </Container>
  443. );
  444. },
  445. },
  446. replayId: {
  447. sortField: 'replayId',
  448. renderFunc: data => {
  449. const replayId = data?.replayId;
  450. if (typeof replayId !== 'string' || !replayId) {
  451. return emptyValue;
  452. }
  453. return <Container>{getShortEventId(replayId)}</Container>;
  454. },
  455. },
  456. 'profile.id': {
  457. sortField: 'profile.id',
  458. renderFunc: data => {
  459. const id: string | unknown = data?.['profile.id'];
  460. if (typeof id !== 'string') {
  461. return emptyValue;
  462. }
  463. return <Container>{getShortEventId(id)}</Container>;
  464. },
  465. },
  466. issue: {
  467. sortField: null,
  468. renderFunc: (data, {organization}) => {
  469. const issueID = data['issue.id'];
  470. if (!issueID) {
  471. return (
  472. <Container>
  473. <FieldShortId shortId={`${data.issue}`} />
  474. </Container>
  475. );
  476. }
  477. const target = {
  478. pathname: `/organizations/${organization.slug}/issues/${issueID}/`,
  479. };
  480. return (
  481. <Container>
  482. <QuickContextHoverWrapper
  483. dataRow={data}
  484. contextType={ContextType.ISSUE}
  485. organization={organization}
  486. >
  487. <StyledLink to={target} aria-label={issueID}>
  488. <OverflowFieldShortId shortId={`${data.issue}`} />
  489. </StyledLink>
  490. </QuickContextHoverWrapper>
  491. </Container>
  492. );
  493. },
  494. },
  495. project: {
  496. sortField: 'project',
  497. renderFunc: (data, {organization}) => {
  498. let slugs: string[] | undefined = undefined;
  499. let projectIds: number[] | undefined = undefined;
  500. if (typeof data.project === 'number') {
  501. projectIds = [data.project];
  502. } else {
  503. slugs = [data.project];
  504. }
  505. return (
  506. <Container>
  507. <Projects orgId={organization.slug} slugs={slugs} projectIds={projectIds}>
  508. {({projects}) => {
  509. let project: Project | AvatarProject | undefined;
  510. if (typeof data.project === 'number') {
  511. project = projects.find(p => p.id === data.project.toString());
  512. } else {
  513. project = projects.find(p => p.slug === data.project);
  514. }
  515. return (
  516. <ProjectBadge
  517. project={project ? project : {slug: data.project}}
  518. avatarSize={16}
  519. />
  520. );
  521. }}
  522. </Projects>
  523. </Container>
  524. );
  525. },
  526. },
  527. user: {
  528. sortField: 'user',
  529. renderFunc: data => {
  530. if (data.user) {
  531. const [key, value] = data.user.split(':');
  532. const userObj = {
  533. id: '',
  534. name: '',
  535. email: '',
  536. username: '',
  537. ip_address: '',
  538. };
  539. userObj[key] = value;
  540. const badge = <UserBadge user={userObj} hideEmail avatarSize={16} />;
  541. return <Container>{badge}</Container>;
  542. }
  543. return <Container>{emptyValue}</Container>;
  544. },
  545. },
  546. 'user.display': {
  547. sortField: 'user.display',
  548. renderFunc: data => {
  549. if (data['user.display']) {
  550. const userObj = {
  551. id: '',
  552. name: data['user.display'],
  553. email: '',
  554. username: '',
  555. ip_address: '',
  556. };
  557. const badge = <UserBadge user={userObj} hideEmail avatarSize={16} />;
  558. return <Container>{badge}</Container>;
  559. }
  560. return <Container>{emptyValue}</Container>;
  561. },
  562. },
  563. 'count_unique(user)': {
  564. sortField: 'count_unique(user)',
  565. renderFunc: data => {
  566. const count = data.count_unique_user ?? data['count_unique(user)'];
  567. if (typeof count === 'number') {
  568. return (
  569. <FlexContainer>
  570. <NumberContainer>
  571. <Count value={count} />
  572. </NumberContainer>
  573. <UserIcon size="md" />
  574. </FlexContainer>
  575. );
  576. }
  577. return <Container>{emptyValue}</Container>;
  578. },
  579. },
  580. release: {
  581. sortField: 'release',
  582. renderFunc: (data, {organization}) =>
  583. data.release ? (
  584. <VersionContainer>
  585. <QuickContextHoverWrapper
  586. dataRow={data}
  587. contextType={ContextType.RELEASE}
  588. organization={organization}
  589. >
  590. <Version version={data.release} truncate />
  591. </QuickContextHoverWrapper>
  592. </VersionContainer>
  593. ) : (
  594. <Container>{emptyValue}</Container>
  595. ),
  596. },
  597. 'error.handled': {
  598. sortField: 'error.handled',
  599. renderFunc: data => {
  600. const values = data['error.handled'];
  601. // Transactions will have null, and default events have no handled attributes.
  602. if (values === null || values?.length === 0) {
  603. return <Container>{emptyValue}</Container>;
  604. }
  605. const value = Array.isArray(values) ? values : [values];
  606. return (
  607. <Container>
  608. {value.every(v => [1, null].includes(v)) ? 'true' : 'false'}
  609. </Container>
  610. );
  611. },
  612. },
  613. team_key_transaction: {
  614. sortField: null,
  615. renderFunc: (data, {organization}) => (
  616. <TeamKeyTransactionField
  617. isKeyTransaction={(data.team_key_transaction ?? 0) !== 0}
  618. organization={organization}
  619. projectSlug={data.project}
  620. transactionName={data.transaction}
  621. />
  622. ),
  623. },
  624. 'trend_percentage()': {
  625. sortField: 'trend_percentage()',
  626. renderFunc: data => (
  627. <NumberContainer>
  628. {typeof data.trend_percentage === 'number'
  629. ? formatPercentage(data.trend_percentage - 1)
  630. : emptyValue}
  631. </NumberContainer>
  632. ),
  633. },
  634. 'timestamp.to_hour': {
  635. sortField: 'timestamp.to_hour',
  636. renderFunc: data => (
  637. <Container>
  638. {getDynamicText({
  639. value: <FieldDateTime date={data['timestamp.to_hour']} year timeZone />,
  640. fixed: 'timestamp.to_hour',
  641. })}
  642. </Container>
  643. ),
  644. },
  645. 'timestamp.to_day': {
  646. sortField: 'timestamp.to_day',
  647. renderFunc: data => (
  648. <Container>
  649. {getDynamicText({
  650. value: <FieldDateTime date={data['timestamp.to_day']} dateOnly year utc />,
  651. fixed: 'timestamp.to_day',
  652. })}
  653. </Container>
  654. ),
  655. },
  656. };
  657. type SpecialFunctionFieldRenderer = (
  658. fieldName: string
  659. ) => (data: EventData, baggage: RenderFunctionBaggage) => React.ReactNode;
  660. type SpecialFunctions = {
  661. time_spent_percentage: SpecialFunctionFieldRenderer;
  662. user_misery: SpecialFunctionFieldRenderer;
  663. };
  664. /**
  665. * "Special functions" are functions whose values either do not map 1:1 to a single column,
  666. * or they require custom UI formatting that can't be handled by the datatype formatters.
  667. */
  668. const SPECIAL_FUNCTIONS: SpecialFunctions = {
  669. user_misery: fieldName => data => {
  670. const userMiseryField = fieldName;
  671. if (!(userMiseryField in data)) {
  672. return <NumberContainer>{emptyValue}</NumberContainer>;
  673. }
  674. const userMisery = data[userMiseryField];
  675. if (userMisery === null || isNaN(userMisery)) {
  676. return <NumberContainer>{emptyValue}</NumberContainer>;
  677. }
  678. const projectThresholdConfig = 'project_threshold_config';
  679. let countMiserableUserField: string = '';
  680. let miseryLimit: number | undefined = parseInt(
  681. userMiseryField.split('(').pop()?.slice(0, -1) || '',
  682. 10
  683. );
  684. if (isNaN(miseryLimit)) {
  685. countMiserableUserField = 'count_miserable(user)';
  686. if (projectThresholdConfig in data) {
  687. miseryLimit = data[projectThresholdConfig][1];
  688. } else {
  689. miseryLimit = undefined;
  690. }
  691. } else {
  692. countMiserableUserField = `count_miserable(user,${miseryLimit})`;
  693. }
  694. const uniqueUsers = data['count_unique(user)'];
  695. let miserableUsers: number | undefined;
  696. if (countMiserableUserField in data) {
  697. const countMiserableMiseryLimit = parseInt(
  698. userMiseryField.split('(').pop()?.slice(0, -1) || '',
  699. 10
  700. );
  701. miserableUsers =
  702. countMiserableMiseryLimit === miseryLimit ||
  703. (isNaN(countMiserableMiseryLimit) && projectThresholdConfig)
  704. ? data[countMiserableUserField]
  705. : undefined;
  706. }
  707. return (
  708. <BarContainer>
  709. <UserMisery
  710. bars={10}
  711. barHeight={20}
  712. miseryLimit={miseryLimit}
  713. totalUsers={uniqueUsers}
  714. userMisery={userMisery}
  715. miserableUsers={miserableUsers}
  716. />
  717. </BarContainer>
  718. );
  719. },
  720. time_spent_percentage: fieldName => data => {
  721. return (
  722. <TimeSpentCell
  723. percentage={data[fieldName]}
  724. total={data[`sum(${SpanMetricsFields.SPAN_SELF_TIME})`]}
  725. />
  726. );
  727. },
  728. };
  729. /**
  730. * Get the sort field name for a given field if it is special or fallback
  731. * to the generic type formatter.
  732. */
  733. export function getSortField(
  734. field: string,
  735. tableMeta: MetaType | undefined
  736. ): string | null {
  737. if (SPECIAL_FIELDS.hasOwnProperty(field)) {
  738. return SPECIAL_FIELDS[field as keyof typeof SPECIAL_FIELDS].sortField;
  739. }
  740. if (!tableMeta) {
  741. return field;
  742. }
  743. if (isEquation(field)) {
  744. return field;
  745. }
  746. for (const alias in AGGREGATIONS) {
  747. if (field.startsWith(alias)) {
  748. return AGGREGATIONS[alias].isSortable ? field : null;
  749. }
  750. }
  751. const fieldType = tableMeta[field];
  752. if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) {
  753. return FIELD_FORMATTERS[fieldType as keyof typeof FIELD_FORMATTERS].isSortable
  754. ? field
  755. : null;
  756. }
  757. return null;
  758. }
  759. const isDurationValue = (data: EventData, field: string): boolean => {
  760. return field in data && typeof data[field] === 'number';
  761. };
  762. export const spanOperationRelativeBreakdownRenderer = (
  763. data: EventData,
  764. {location, organization, eventView}: RenderFunctionBaggage,
  765. options?: RenderFunctionOptions
  766. ): React.ReactNode => {
  767. const {enableOnClick = true} = options ?? {};
  768. const sumOfSpanTime = SPAN_OP_BREAKDOWN_FIELDS.reduce(
  769. (prev, curr) => (isDurationValue(data, curr) ? prev + data[curr] : prev),
  770. 0
  771. );
  772. const cumulativeSpanOpBreakdown = Math.max(sumOfSpanTime, data['transaction.duration']);
  773. if (
  774. SPAN_OP_BREAKDOWN_FIELDS.every(field => !isDurationValue(data, field)) ||
  775. cumulativeSpanOpBreakdown === 0
  776. ) {
  777. return FIELD_FORMATTERS.duration.renderFunc(SPAN_OP_RELATIVE_BREAKDOWN_FIELD, data);
  778. }
  779. let otherPercentage = 1;
  780. let orderedSpanOpsBreakdownFields;
  781. const sortingOnField = eventView?.sorts?.[0]?.field;
  782. if (sortingOnField && (SPAN_OP_BREAKDOWN_FIELDS as string[]).includes(sortingOnField)) {
  783. orderedSpanOpsBreakdownFields = [
  784. sortingOnField,
  785. ...SPAN_OP_BREAKDOWN_FIELDS.filter(op => op !== sortingOnField),
  786. ];
  787. } else {
  788. orderedSpanOpsBreakdownFields = SPAN_OP_BREAKDOWN_FIELDS;
  789. }
  790. return (
  791. <RelativeOpsBreakdown data-test-id="relative-ops-breakdown">
  792. {orderedSpanOpsBreakdownFields.map(field => {
  793. if (!isDurationValue(data, field)) {
  794. return null;
  795. }
  796. const operationName = getSpanOperationName(field) ?? 'op';
  797. const spanOpDuration: number = data[field];
  798. const widthPercentage = spanOpDuration / cumulativeSpanOpBreakdown;
  799. otherPercentage = otherPercentage - widthPercentage;
  800. if (widthPercentage === 0) {
  801. return null;
  802. }
  803. return (
  804. <div key={operationName} style={{width: toPercent(widthPercentage || 0)}}>
  805. <Tooltip
  806. title={
  807. <div>
  808. <div>{operationName}</div>
  809. <div>
  810. <Duration
  811. seconds={spanOpDuration / 1000}
  812. fixedDigits={2}
  813. abbreviation
  814. />
  815. </div>
  816. </div>
  817. }
  818. containerDisplayMode="block"
  819. >
  820. <RectangleRelativeOpsBreakdown
  821. style={{
  822. backgroundColor: pickBarColor(operationName),
  823. cursor: enableOnClick ? 'pointer' : 'default',
  824. }}
  825. onClick={event => {
  826. if (!enableOnClick) {
  827. return;
  828. }
  829. event.stopPropagation();
  830. const filter = stringToFilter(operationName);
  831. if (filter === SpanOperationBreakdownFilter.NONE) {
  832. return;
  833. }
  834. trackAnalytics('performance_views.relative_breakdown.selection', {
  835. action: filter,
  836. organization,
  837. });
  838. browserHistory.push({
  839. pathname: location.pathname,
  840. query: {
  841. ...location.query,
  842. ...filterToLocationQuery(filter),
  843. },
  844. });
  845. }}
  846. />
  847. </Tooltip>
  848. </div>
  849. );
  850. })}
  851. <div key="other" style={{width: toPercent(otherPercentage || 0)}}>
  852. <Tooltip title={<div>{t('Other')}</div>} containerDisplayMode="block">
  853. <OtherRelativeOpsBreakdown />
  854. </Tooltip>
  855. </div>
  856. </RelativeOpsBreakdown>
  857. );
  858. };
  859. const RelativeOpsBreakdown = styled('div')`
  860. position: relative;
  861. display: flex;
  862. `;
  863. const RectangleRelativeOpsBreakdown = styled(RowRectangle)`
  864. position: relative;
  865. width: 100%;
  866. `;
  867. const OtherRelativeOpsBreakdown = styled(RectangleRelativeOpsBreakdown)`
  868. background-color: ${p => p.theme.gray100};
  869. `;
  870. const StyledLink = styled(Link)`
  871. max-width: 100%;
  872. `;
  873. /**
  874. * Get the field renderer for the named field and metadata
  875. *
  876. * @param {String} field name
  877. * @param {object} metadata mapping.
  878. * @param {boolean} isAlias convert the name with getAggregateAlias
  879. * @returns {Function}
  880. */
  881. export function getFieldRenderer(
  882. field: string,
  883. meta: MetaType,
  884. isAlias: boolean = true
  885. ): FieldFormatterRenderFunctionPartial {
  886. if (SPECIAL_FIELDS.hasOwnProperty(field)) {
  887. return SPECIAL_FIELDS[field].renderFunc;
  888. }
  889. if (isRelativeSpanOperationBreakdownField(field)) {
  890. return spanOperationRelativeBreakdownRenderer;
  891. }
  892. const fieldName = isAlias ? getAggregateAlias(field) : field;
  893. const fieldType = meta[fieldName];
  894. for (const alias in SPECIAL_FUNCTIONS) {
  895. if (fieldName.startsWith(alias)) {
  896. return SPECIAL_FUNCTIONS[alias](fieldName);
  897. }
  898. }
  899. if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) {
  900. return partial(FIELD_FORMATTERS[fieldType].renderFunc, fieldName);
  901. }
  902. return partial(FIELD_FORMATTERS.string.renderFunc, fieldName);
  903. }
  904. type FieldTypeFormatterRenderFunctionPartial = (
  905. data: EventData,
  906. baggage?: RenderFunctionBaggage
  907. ) => React.ReactNode;
  908. /**
  909. * Get the field renderer for the named field only based on its type from the given
  910. * metadata.
  911. *
  912. * @param {String} field name
  913. * @param {object} metadata mapping.
  914. * @param {boolean} isAlias convert the name with getAggregateAlias
  915. * @returns {Function}
  916. */
  917. export function getFieldFormatter(
  918. field: string,
  919. meta: MetaType,
  920. isAlias: boolean = true
  921. ): FieldTypeFormatterRenderFunctionPartial {
  922. const fieldName = isAlias ? getAggregateAlias(field) : field;
  923. const fieldType = meta[fieldName];
  924. if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) {
  925. return partial(FIELD_FORMATTERS[fieldType].renderFunc, fieldName);
  926. }
  927. return partial(FIELD_FORMATTERS.string.renderFunc, fieldName);
  928. }