eventCustomPerformanceMetrics.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import styled from '@emotion/styled';
  2. import type {Location} from 'history';
  3. import {SectionHeading} from 'sentry/components/charts/styles';
  4. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  5. import Panel from 'sentry/components/panels/panel';
  6. import {IconEllipsis} from 'sentry/icons';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import type {Event} from 'sentry/types/event';
  10. import type {Organization} from 'sentry/types/organization';
  11. import EventView from 'sentry/utils/discover/eventView';
  12. import {
  13. DURATION_UNITS,
  14. FIELD_FORMATTERS,
  15. PERCENTAGE_UNITS,
  16. SIZE_UNITS,
  17. } from 'sentry/utils/discover/fieldRenderers';
  18. import {isCustomMeasurement} from 'sentry/views/dashboards/utils';
  19. import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
  20. import {Tooltip} from '../tooltip';
  21. export enum EventDetailPageSource {
  22. PERFORMANCE = 'performance',
  23. DISCOVER = 'discover',
  24. }
  25. type Props = {
  26. event: Event;
  27. location: Location;
  28. organization: Organization;
  29. isHomepage?: boolean;
  30. source?: EventDetailPageSource;
  31. };
  32. export function isNotMarkMeasurement(field: string) {
  33. return !field.startsWith('mark.');
  34. }
  35. export function isNotPerformanceScoreMeasurement(field: string) {
  36. return !field.startsWith('score.');
  37. }
  38. export default function EventCustomPerformanceMetrics({
  39. event,
  40. location,
  41. organization,
  42. source,
  43. isHomepage,
  44. }: Props) {
  45. const measurementNames = Object.keys(event.measurements ?? {})
  46. .filter(name => isCustomMeasurement(`measurements.${name}`))
  47. .filter(isNotMarkMeasurement)
  48. .filter(isNotPerformanceScoreMeasurement)
  49. .sort();
  50. if (measurementNames.length === 0) {
  51. return null;
  52. }
  53. return (
  54. <Container>
  55. <SectionHeading>{t('Custom Performance Metrics')}</SectionHeading>
  56. <Measurements>
  57. {measurementNames.map(name => {
  58. return (
  59. <EventCustomPerformanceMetric
  60. key={name}
  61. event={event}
  62. name={name}
  63. location={location}
  64. organization={organization}
  65. source={source}
  66. isHomepage={isHomepage}
  67. />
  68. );
  69. })}
  70. </Measurements>
  71. </Container>
  72. );
  73. }
  74. type EventCustomPerformanceMetricProps = Props & {
  75. name: string;
  76. };
  77. export function getFieldTypeFromUnit(unit: any) {
  78. if (unit) {
  79. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  80. if (DURATION_UNITS[unit]) {
  81. return 'duration';
  82. }
  83. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  84. if (SIZE_UNITS[unit]) {
  85. return 'size';
  86. }
  87. if (PERCENTAGE_UNITS.includes(unit)) {
  88. return 'percentage';
  89. }
  90. if (unit === 'none') {
  91. return 'integer';
  92. }
  93. return 'string';
  94. }
  95. return 'number';
  96. }
  97. export function EventCustomPerformanceMetric({
  98. event,
  99. name,
  100. location,
  101. organization,
  102. source,
  103. isHomepage,
  104. }: EventCustomPerformanceMetricProps) {
  105. const {value, unit} = event.measurements?.[name] ?? {};
  106. if (value === null) {
  107. return null;
  108. }
  109. const fieldType = getFieldTypeFromUnit(unit);
  110. const renderValue = fieldType === 'string' ? `${value} ${unit}` : value;
  111. const rendered = fieldType
  112. ? FIELD_FORMATTERS[fieldType].renderFunc(
  113. name,
  114. {[name]: renderValue},
  115. {location, organization, unit}
  116. )
  117. : renderValue;
  118. function generateLinkWithQuery(query: string) {
  119. const eventView = EventView.fromLocation(location);
  120. eventView.query = query;
  121. switch (source) {
  122. case EventDetailPageSource.PERFORMANCE:
  123. return transactionSummaryRouteWithQuery({
  124. organization,
  125. transaction: event.title,
  126. projectID: event.projectID,
  127. query: {query},
  128. });
  129. case EventDetailPageSource.DISCOVER:
  130. default:
  131. return eventView.getResultsViewUrlTarget(organization.slug, isHomepage);
  132. }
  133. }
  134. // Some custom perf metrics have units.
  135. // These custom perf metrics need to be adjusted to the correct value.
  136. let customMetricValue = value;
  137. if (typeof value === 'number' && unit && customMetricValue) {
  138. if (Object.keys(SIZE_UNITS).includes(unit)) {
  139. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  140. customMetricValue *= SIZE_UNITS[unit];
  141. } else if (Object.keys(DURATION_UNITS).includes(unit)) {
  142. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  143. customMetricValue *= DURATION_UNITS[unit];
  144. }
  145. }
  146. return (
  147. <StyledPanel>
  148. <div>
  149. <div>{name}</div>
  150. <ValueRow>
  151. <Value>{rendered}</Value>
  152. </ValueRow>
  153. </div>
  154. <StyledDropdownMenuControl
  155. items={[
  156. {
  157. key: 'includeEvents',
  158. label: t('Show events with this value'),
  159. to: generateLinkWithQuery(`measurements.${name}:${customMetricValue}`),
  160. },
  161. {
  162. key: 'excludeEvents',
  163. label: t('Hide events with this value'),
  164. to: generateLinkWithQuery(`!measurements.${name}:${customMetricValue}`),
  165. },
  166. {
  167. key: 'includeGreaterThanEvents',
  168. label: t('Show events with values greater than'),
  169. to: generateLinkWithQuery(`measurements.${name}:>${customMetricValue}`),
  170. },
  171. {
  172. key: 'includeLessThanEvents',
  173. label: t('Show events with values less than'),
  174. to: generateLinkWithQuery(`measurements.${name}:<${customMetricValue}`),
  175. },
  176. ]}
  177. triggerProps={{
  178. 'aria-label': t('Widget actions'),
  179. size: 'xs',
  180. borderless: true,
  181. showChevron: false,
  182. icon: <IconEllipsis direction="down" size="sm" />,
  183. }}
  184. position="bottom-end"
  185. />
  186. </StyledPanel>
  187. );
  188. }
  189. export function TraceEventCustomPerformanceMetric({
  190. event,
  191. name,
  192. location,
  193. organization,
  194. source,
  195. isHomepage,
  196. }: EventCustomPerformanceMetricProps) {
  197. const {value, unit} = event.measurements?.[name] ?? {};
  198. if (value === null) {
  199. return null;
  200. }
  201. const fieldType = getFieldTypeFromUnit(unit);
  202. const renderValue = fieldType === 'string' ? `${value} ${unit}` : value;
  203. const rendered = fieldType
  204. ? FIELD_FORMATTERS[fieldType].renderFunc(
  205. name,
  206. {[name]: renderValue},
  207. {location, organization, unit}
  208. )
  209. : renderValue;
  210. function generateLinkWithQuery(query: string) {
  211. const eventView = EventView.fromLocation(location);
  212. eventView.query = query;
  213. switch (source) {
  214. case EventDetailPageSource.PERFORMANCE:
  215. return transactionSummaryRouteWithQuery({
  216. organization,
  217. transaction: event.title,
  218. projectID: event.projectID,
  219. query: {query},
  220. });
  221. case EventDetailPageSource.DISCOVER:
  222. default:
  223. return eventView.getResultsViewUrlTarget(organization.slug, isHomepage);
  224. }
  225. }
  226. // Some custom perf metrics have units.
  227. // These custom perf metrics need to be adjusted to the correct value.
  228. let customMetricValue = value;
  229. if (typeof value === 'number' && unit && customMetricValue) {
  230. if (Object.keys(SIZE_UNITS).includes(unit)) {
  231. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  232. customMetricValue *= SIZE_UNITS[unit];
  233. } else if (Object.keys(DURATION_UNITS).includes(unit)) {
  234. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  235. customMetricValue *= DURATION_UNITS[unit];
  236. }
  237. }
  238. return (
  239. <TraceStyledPanel>
  240. <Tooltip title={name} showOnlyOnOverflow>
  241. <StyledMeasurementsName>{name}</StyledMeasurementsName>
  242. </Tooltip>
  243. <div>{rendered}</div>
  244. <div>
  245. <StyledDropdownMenuControl
  246. size="xs"
  247. items={[
  248. {
  249. key: 'includeEvents',
  250. label: t('Show events with this value'),
  251. to: generateLinkWithQuery(`measurements.${name}:${customMetricValue}`),
  252. },
  253. {
  254. key: 'excludeEvents',
  255. label: t('Hide events with this value'),
  256. to: generateLinkWithQuery(`!measurements.${name}:${customMetricValue}`),
  257. },
  258. {
  259. key: 'includeGreaterThanEvents',
  260. label: t('Show events with values greater than'),
  261. to: generateLinkWithQuery(`measurements.${name}:>${customMetricValue}`),
  262. },
  263. {
  264. key: 'includeLessThanEvents',
  265. label: t('Show events with values less than'),
  266. to: generateLinkWithQuery(`measurements.${name}:<${customMetricValue}`),
  267. },
  268. ]}
  269. triggerProps={{
  270. 'aria-label': t('Widget actions'),
  271. size: 'xs',
  272. borderless: true,
  273. showChevron: false,
  274. icon: <IconEllipsis direction="down" size="sm" />,
  275. }}
  276. position="bottom-end"
  277. />
  278. </div>
  279. </TraceStyledPanel>
  280. );
  281. }
  282. const Measurements = styled('div')`
  283. display: grid;
  284. grid-column-gap: ${space(1)};
  285. `;
  286. const Container = styled('div')`
  287. font-size: ${p => p.theme.fontSizeMedium};
  288. margin-bottom: ${space(4)};
  289. `;
  290. const TraceStyledPanel = styled(Panel)`
  291. margin-bottom: 0;
  292. display: flex;
  293. align-items: center;
  294. max-width: fit-content;
  295. font-size: ${p => p.theme.fontSizeSmall};
  296. gap: ${space(0.5)};
  297. > :not(:last-child) {
  298. padding: 0 ${space(1)};
  299. }
  300. `;
  301. const ValueRow = styled('div')`
  302. display: flex;
  303. align-items: center;
  304. `;
  305. const Value = styled('span')`
  306. font-size: ${p => p.theme.fontSizeExtraLarge};
  307. `;
  308. const StyledPanel = styled(Panel)`
  309. padding: ${space(1)} ${space(1.5)};
  310. margin-bottom: ${space(1)};
  311. display: flex;
  312. `;
  313. const StyledDropdownMenuControl = styled(DropdownMenu)`
  314. display: block;
  315. margin-left: auto;
  316. `;
  317. const StyledMeasurementsName = styled('div')`
  318. max-width: 200px;
  319. ${p => p.theme.overflowEllipsis};
  320. `;