customMetricsEventData.tsx 14 KB

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