customMetricsEventData.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import {Fragment, useMemo} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import MarkLine from 'sentry/components/charts/components/markLine';
  5. import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
  6. import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util';
  7. import type {
  8. MetricsSummary,
  9. MetricsSummaryItem,
  10. } from 'sentry/components/events/interfaces/spans/types';
  11. import {Hovercard} from 'sentry/components/hovercard';
  12. import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable';
  13. import Link from 'sentry/components/links/link';
  14. import {normalizeDateTimeString} from 'sentry/components/organizations/pageFilters/parse';
  15. import {IconInfo} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {
  19. MetricsQueryApiResponseLastMeta,
  20. MetricType,
  21. MRI,
  22. Organization,
  23. } from 'sentry/types';
  24. import {defined} from 'sentry/utils';
  25. import {getDefaultAggregate, getMetricsUrl} from 'sentry/utils/metrics';
  26. import {hasCustomMetrics} from 'sentry/utils/metrics/features';
  27. import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
  28. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  29. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  30. import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
  31. import {middleEllipsis} from 'sentry/utils/middleEllipsis';
  32. import type {Color} from 'sentry/utils/theme';
  33. import useOrganization from 'sentry/utils/useOrganization';
  34. import {MetricChart} from 'sentry/views/metrics/chart/chart';
  35. import type {Series} from 'sentry/views/metrics/chart/types';
  36. import {getChartTimeseries} from 'sentry/views/metrics/widget';
  37. import {getSampleChartSymbol} from 'sentry/views/starfish/views/spanSummaryPage/sampleList/durationChart/getSampleChartSymbol';
  38. import {
  39. type SectionCardKeyValueList,
  40. TraceDrawerComponents,
  41. } from '../performance/newTraceDetails/traceDrawer/details/styles';
  42. function flattenMetricsSummary(
  43. metricsSummary: MetricsSummary
  44. ): {item: MetricsSummaryItem; mri: MRI}[] {
  45. return (
  46. Object.entries(metricsSummary) as [
  47. keyof MetricsSummary,
  48. MetricsSummary[keyof MetricsSummary],
  49. ][]
  50. ).flatMap(([mri, items]) => (items || []).map(item => ({item, mri})));
  51. }
  52. function tagToQuery(tagKey: string, tagValue: string) {
  53. return `${tagKey}:"${tagValue}"`;
  54. }
  55. const HALF_HOUR_IN_MS = 30 * 60 * 1000;
  56. interface DataRow {
  57. chartUnit: string;
  58. metricType: MetricType;
  59. metricUnit: string;
  60. mri: MRI;
  61. scalingFactor: number;
  62. summaryItem: MetricsSummaryItem;
  63. chartSeries?: Series;
  64. deviation?: number;
  65. deviationPercent?: number;
  66. itemAvg?: number;
  67. totalAvg?: number;
  68. }
  69. export function CustomMetricsEventData({
  70. metricsSummary,
  71. startTimestamp,
  72. projectId,
  73. }: {
  74. projectId: string;
  75. startTimestamp: number;
  76. metricsSummary?: MetricsSummary;
  77. }) {
  78. const hasNewTagsUI = useHasNewTagsUI();
  79. const organization = useOrganization();
  80. const start = new Date(startTimestamp * 1000 - HALF_HOUR_IN_MS).toISOString();
  81. const end = new Date(startTimestamp * 1000 + HALF_HOUR_IN_MS).toISOString();
  82. const metricsSummaryEntries = useMemo(
  83. () => (metricsSummary ? flattenMetricsSummary(metricsSummary) : []),
  84. [metricsSummary]
  85. );
  86. const queries = useMemo(
  87. () =>
  88. metricsSummaryEntries.map((entry, index) => ({
  89. mri: entry.mri,
  90. name: index.toString(),
  91. op: getDefaultAggregate(entry.mri),
  92. query: Object.entries(entry.item.tags ?? {})
  93. .map(([tagKey, tagValue]) => tagToQuery(tagKey, tagValue))
  94. .join(' '),
  95. })),
  96. [metricsSummaryEntries]
  97. );
  98. const {data} = useMetricsQuery(queries, {
  99. projects: [parseInt(projectId, 10)],
  100. datetime: {start, end, period: null, utc: true},
  101. environments: [],
  102. });
  103. const chartSeries = useMemo(
  104. () =>
  105. data
  106. ? data.data.flatMap((entry, index) => {
  107. // Splitting the response to treat it like individual requests
  108. // TODO: improve utils for metric series generation
  109. return getChartTimeseries(
  110. {...data, data: [entry], meta: [data.meta[index]]},
  111. [queries[index]],
  112. {
  113. showQuerySymbol: false,
  114. }
  115. );
  116. })
  117. : [],
  118. [data, queries]
  119. );
  120. const dataRows = useMemo(
  121. () =>
  122. metricsSummaryEntries
  123. .map<DataRow>((entry, index) => {
  124. const entryData = data?.data?.[index][0];
  125. const dataMeta = data?.meta?.[index];
  126. const lastMeta = dataMeta?.[
  127. dataMeta?.length - 1
  128. ] as MetricsQueryApiResponseLastMeta;
  129. const parsedMRI = parseMRI(entry.mri);
  130. const type = parsedMRI?.type || 'c';
  131. const unit = parsedMRI?.unit || 'none';
  132. const summaryItem = entry.item;
  133. const scalingFactor = lastMeta?.scaling_factor || 1;
  134. const totalAvg = entryData?.totals;
  135. const itemAvg =
  136. summaryItem.sum && summaryItem.count
  137. ? summaryItem.sum / summaryItem.count
  138. : undefined;
  139. const deviation =
  140. itemAvg && totalAvg ? itemAvg - totalAvg / scalingFactor : undefined;
  141. const deviationPercent =
  142. deviation && totalAvg ? deviation / (totalAvg / scalingFactor) : undefined;
  143. return {
  144. mri: entry.mri,
  145. itemAvg,
  146. totalAvg,
  147. scalingFactor,
  148. chartSeries: chartSeries[index],
  149. chartUnit: lastMeta?.unit ?? 'none',
  150. metricType: type,
  151. metricUnit: unit,
  152. summaryItem: summaryItem,
  153. deviation,
  154. deviationPercent,
  155. };
  156. })
  157. .sort((a, b) => {
  158. // Counters should be on bottom
  159. if (a.metricType === 'c' && b.metricType !== 'c') {
  160. return 1;
  161. }
  162. if (a.metricType !== 'c' && b.metricType === 'c') {
  163. return -1;
  164. }
  165. // Sort by highest absolute deviation
  166. return Math.abs(b.deviationPercent || 0) - Math.abs(a.deviationPercent || 0);
  167. }),
  168. [chartSeries, data?.data, data?.meta, metricsSummaryEntries]
  169. );
  170. if (!hasCustomMetrics(organization) || metricsSummaryEntries.length === 0) {
  171. return null;
  172. }
  173. if (hasNewTagsUI) {
  174. const items: SectionCardKeyValueList = [];
  175. dataRows.forEach(dataRow => {
  176. const {mri, summaryItem} = dataRow;
  177. const name = formatMRI(mri);
  178. items.push({
  179. key: `metric-${name}`,
  180. subject: name,
  181. value: (
  182. <TraceDrawerComponents.CopyableCardValueWithLink
  183. value={
  184. <Fragment>
  185. <ValueRenderer dataRow={dataRow} />{' '}
  186. <DeviationRenderer dataRow={dataRow} startTimestamp={startTimestamp} />
  187. <br />
  188. <TagsRenderer tags={dataRow.summaryItem.tags} />
  189. </Fragment>
  190. }
  191. linkText={t('View Metric')}
  192. linkTarget={getMetricsUrl(organization.slug, {
  193. start: normalizeDateTimeString(start),
  194. end: normalizeDateTimeString(end),
  195. interval: '10s',
  196. widgets: [
  197. {
  198. mri: mri,
  199. displayType: MetricDisplayType.LINE,
  200. op: getDefaultAggregate(mri),
  201. query: Object.entries(summaryItem.tags ?? {})
  202. .map(([tagKey, tagValue]) => tagToQuery(tagKey, tagValue))
  203. .join(' '),
  204. },
  205. ],
  206. })}
  207. />
  208. ),
  209. });
  210. });
  211. return (
  212. <TraceDrawerComponents.SectionCard
  213. title={t('Emitted Metrics')}
  214. items={items}
  215. sortAlphabetically
  216. />
  217. );
  218. }
  219. return (
  220. <table className="table key-value">
  221. <tbody>
  222. <tr>
  223. <td className="key">{t('Emitted Metrics')}</td>
  224. <td className="value">
  225. <pre>
  226. {dataRows.map((dataRow, index) => (
  227. <Fragment key={index}>
  228. <MetricRenderer
  229. dataRow={dataRow}
  230. start={start}
  231. end={end}
  232. organization={organization}
  233. />
  234. <br />
  235. <ValueRenderer dataRow={dataRow} />{' '}
  236. <DeviationRenderer dataRow={dataRow} startTimestamp={startTimestamp} />
  237. <br />
  238. <TagsRenderer tags={dataRow.summaryItem.tags} />
  239. <br />
  240. <br />
  241. </Fragment>
  242. ))}
  243. </pre>
  244. </td>
  245. </tr>
  246. </tbody>
  247. </table>
  248. );
  249. }
  250. function MetricRenderer({
  251. dataRow,
  252. start,
  253. end,
  254. organization,
  255. }: {
  256. dataRow: DataRow;
  257. end: string;
  258. organization: Organization;
  259. start: string;
  260. }) {
  261. const {mri, summaryItem} = dataRow;
  262. return (
  263. <Fragment>
  264. {t('Name:')}{' '}
  265. <Link
  266. to={getMetricsUrl(organization.slug, {
  267. start: normalizeDateTimeString(start),
  268. end: normalizeDateTimeString(end),
  269. interval: '10s',
  270. widgets: [
  271. {
  272. mri: mri,
  273. displayType: MetricDisplayType.LINE,
  274. op: getDefaultAggregate(mri),
  275. query: Object.entries(summaryItem.tags ?? {})
  276. .map(([tagKey, tagValue]) => tagToQuery(tagKey, tagValue))
  277. .join(' '),
  278. },
  279. ],
  280. })}
  281. >
  282. {formatMRI(mri)}
  283. </Link>
  284. </Fragment>
  285. );
  286. }
  287. function ValueRenderer({dataRow}: {dataRow: DataRow}) {
  288. const {mri, summaryItem} = dataRow;
  289. const parsedMRI = parseMRI(mri);
  290. const unit = parsedMRI?.unit ?? 'none';
  291. const type = parsedMRI?.type ?? 'c';
  292. // For counters the other stats offer little value, so we only show the count
  293. if (type === 'c' || !summaryItem.count) {
  294. return t('Count: %s', formatMetricUsingUnit(summaryItem.count, 'none'));
  295. }
  296. const avg = summaryItem.sum && summaryItem.count && summaryItem.sum / summaryItem.count;
  297. return (
  298. <ValueWrapper>
  299. {t('Value:')} {formatMetricUsingUnit(avg, unit) ?? t('(none)')}
  300. {summaryItem.count > 1 && (
  301. <ValuesHovercard
  302. bodyClassName="hovercard-body"
  303. skipWrapper
  304. body={
  305. <Fragment>
  306. <StyledKeyValueTable>
  307. <KeyValueTableRow keyName="count" value={summaryItem.count} />
  308. <KeyValueTableRow
  309. keyName="min"
  310. value={formatMetricUsingUnit(summaryItem.min, unit)}
  311. />
  312. <KeyValueTableRow
  313. keyName="max"
  314. value={formatMetricUsingUnit(summaryItem.max, unit)}
  315. />
  316. <KeyValueTableRow
  317. keyName="avg"
  318. value={formatMetricUsingUnit(avg, unit)}
  319. />
  320. </StyledKeyValueTable>
  321. </Fragment>
  322. }
  323. >
  324. <IconInfo size="sm" color="gray300" />
  325. </ValuesHovercard>
  326. )}
  327. </ValueWrapper>
  328. );
  329. }
  330. function DeviationRenderer({
  331. dataRow,
  332. startTimestamp,
  333. }: {
  334. dataRow: DataRow;
  335. startTimestamp: number;
  336. }) {
  337. const {
  338. mri,
  339. totalAvg,
  340. itemAvg,
  341. deviation,
  342. deviationPercent,
  343. chartUnit,
  344. chartSeries,
  345. scalingFactor,
  346. } = dataRow;
  347. const theme = useTheme();
  348. const parsedMRI = parseMRI(mri);
  349. const type = parsedMRI?.type ?? 'c';
  350. if (
  351. !defined(totalAvg) ||
  352. !defined(itemAvg) ||
  353. !defined(deviation) ||
  354. !defined(deviationPercent) ||
  355. type === 'c'
  356. ) {
  357. return null;
  358. }
  359. const totals = totalAvg / scalingFactor;
  360. const isPositive = deviation > 0;
  361. const isNeutral = Math.abs(deviationPercent) < 0.03;
  362. const valueColor: Color = isNeutral ? 'gray300' : isPositive ? 'red300' : 'green300';
  363. const sign = deviation === 0 ? '±' : isPositive ? '+' : '';
  364. const symbol = isNeutral ? '' : isPositive ? '▲' : '▼';
  365. return (
  366. <ChartHovercard
  367. bodyClassName="hovercard-body"
  368. showUnderline
  369. underlineColor={valueColor}
  370. header={
  371. <Fragment>
  372. <HoverCardHeading>{`avg(${middleEllipsis(formatMRI(mri), 40, /\.|-|_/)})`}</HoverCardHeading>
  373. <HoverCardSubheading>{t("Span's start time -/+ 30 min")}</HoverCardSubheading>
  374. </Fragment>
  375. }
  376. body={
  377. chartSeries && (
  378. <MetricChart
  379. displayType={MetricDisplayType.LINE}
  380. series={[
  381. {
  382. ...chartSeries,
  383. markLine: MarkLine({
  384. data: [
  385. {
  386. valueDim: 'y',
  387. type: 'average',
  388. yAxis: totalAvg,
  389. },
  390. ],
  391. lineStyle: {
  392. color: theme.gray400,
  393. },
  394. emphasis: {disabled: true},
  395. }),
  396. },
  397. ]}
  398. additionalSeries={[
  399. ScatterSeries({
  400. xAxisIndex: 0,
  401. yAxisIndex: 0,
  402. z: 10,
  403. data: [
  404. {
  405. value: [startTimestamp * 1000, itemAvg * (scalingFactor || 1)],
  406. label: {
  407. show: true,
  408. position: 'right',
  409. borderColor: 'transparent',
  410. backgroundColor: theme.background,
  411. borderRadius: 6,
  412. padding: 4,
  413. color: theme[valueColor],
  414. formatter: params => {
  415. return `${formatMetricUsingUnit(
  416. (params.data as any).value[1],
  417. chartUnit || 'none'
  418. )}`;
  419. },
  420. },
  421. },
  422. ],
  423. ...getSampleChartSymbol(itemAvg, totals, theme),
  424. symbolSize: 14,
  425. animation: false,
  426. silent: true,
  427. }),
  428. ]}
  429. height={160}
  430. />
  431. )
  432. }
  433. >
  434. <DeviationValue textColor={valueColor}>
  435. {symbol} {sign}
  436. {formatMetricUsingUnit(deviationPercent * 100, 'percent')}
  437. </DeviationValue>
  438. </ChartHovercard>
  439. );
  440. }
  441. const STANDARD_TAGS = ['environment', 'release', 'transaction'];
  442. function TagsRenderer({tags}: {tags: Record<string, string> | null}) {
  443. const tagString = Object.entries(tags || {})
  444. .filter(([tagKey]) => !STANDARD_TAGS.includes(tagKey))
  445. .reduce((acc, [tagKey, tagValue], index) => {
  446. if (index > 0) {
  447. acc += ', ';
  448. }
  449. acc += `${tagKey}:${tagValue}`;
  450. return acc;
  451. }, '');
  452. if (tagString === '') {
  453. return (
  454. <Fragment>
  455. {t('Tags:')} <NoValue>{t('(none)')}</NoValue>
  456. </Fragment>
  457. );
  458. }
  459. return t('Tags: %s', tagString);
  460. }
  461. const ChartHovercard = styled(Hovercard)`
  462. width: 450px;
  463. `;
  464. const ValueCell = styled('div')`
  465. display: flex;
  466. align-items: center;
  467. font-family: ${p => p.theme.text.familyMono};
  468. `;
  469. const NoValue = styled('span')`
  470. color: ${p => p.theme.gray300};
  471. `;
  472. const ValueWrapper = styled(ValueCell)`
  473. display: inline-grid;
  474. grid-template-columns: max-content max-content;
  475. gap: ${space(1)};
  476. align-items: center;
  477. `;
  478. const DeviationValue = styled('span')<{
  479. textColor: Color;
  480. }>`
  481. color: ${p => p.theme[p.textColor]};
  482. cursor: default;
  483. `;
  484. const HoverCardHeading = styled('div')`
  485. font-size: ${p => p.theme.fontSizeLarge};
  486. padding-bottom: ${space(0.5)};
  487. `;
  488. const HoverCardSubheading = styled('div')`
  489. font-size: ${p => p.theme.fontSizeSmall};
  490. color: ${p => p.theme.subText};
  491. `;
  492. const ValuesHovercard = styled(Hovercard)`
  493. width: 200px;
  494. & .hovercard-body {
  495. padding: ${space(0.5)};
  496. }
  497. `;
  498. const StyledKeyValueTable = styled(KeyValueTable)`
  499. margin-bottom: 0;
  500. `;