customMetricsEventData.tsx 14 KB

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