customMetricsEventData.tsx 14 KB

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