customMetricsEventData.tsx 14 KB

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