customMetricsEventData.tsx 14 KB

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