customMetricsTable.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import {Fragment, useMemo, useState} from 'react';
  2. import {Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import Tag from 'sentry/components/badge/tag';
  5. import {PanelTable} from 'sentry/components/panels/panelTable';
  6. import SearchBar from 'sentry/components/searchBar';
  7. import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
  8. import {Tooltip} from 'sentry/components/tooltip';
  9. import {IconArrow} from 'sentry/icons';
  10. import {IconWarning} from 'sentry/icons/iconWarning';
  11. import {t, tct} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {MetricMeta} from 'sentry/types/metrics';
  14. import type {Project} from 'sentry/types/project';
  15. import {isExtractedCustomMetric} from 'sentry/utils/metrics';
  16. import {DEFAULT_METRICS_CARDINALITY_LIMIT} from 'sentry/utils/metrics/constants';
  17. import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
  18. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  19. import {formatMRI} from 'sentry/utils/metrics/mri';
  20. import {useBlockMetric} from 'sentry/utils/metrics/useBlockMetric';
  21. import {useMetricsCardinality} from 'sentry/utils/metrics/useMetricsCardinality';
  22. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  23. import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import {useAccess} from 'sentry/views/settings/projectMetrics/access';
  26. import {BlockButton} from 'sentry/views/settings/projectMetrics/blockButton';
  27. import {useSearchQueryParam} from 'sentry/views/settings/projectMetrics/utils/useSearchQueryParam';
  28. type Props = {
  29. project: Project;
  30. };
  31. enum BlockingStatusTab {
  32. ACTIVE = 'active',
  33. DISABLED = 'disabled',
  34. }
  35. type MetricWithCardinality = MetricMeta & {cardinality: number};
  36. export function CustomMetricsTable({project}: Props) {
  37. const organization = useOrganization();
  38. const [selectedTab, setSelectedTab] = useState(BlockingStatusTab.ACTIVE);
  39. const [query, setQuery] = useSearchQueryParam('metricsQuery');
  40. const metricsMeta = useMetricsMeta(
  41. {projects: [parseInt(project.id, 10)]},
  42. ['custom'],
  43. false
  44. );
  45. const metricsCardinality = useMetricsCardinality({
  46. projects: [parseInt(project.id, 10)],
  47. });
  48. const isLoading = metricsMeta.isLoading || metricsCardinality.isLoading;
  49. const sortedMeta = useMemo(() => {
  50. if (!metricsMeta.data) {
  51. return [];
  52. }
  53. // Do not show internal extracted metrics in this table
  54. const filteredMeta = metricsMeta.data.filter(meta => !isExtractedCustomMetric(meta));
  55. if (!metricsCardinality.data) {
  56. return filteredMeta.map(meta => ({...meta, cardinality: 0}));
  57. }
  58. return filteredMeta
  59. .map(({mri, ...rest}) => {
  60. return {
  61. mri,
  62. cardinality: metricsCardinality.data[mri] ?? 0,
  63. ...rest,
  64. };
  65. })
  66. .sort((a, b) => {
  67. return b.cardinality - a.cardinality;
  68. }) as MetricWithCardinality[];
  69. }, [metricsCardinality.data, metricsMeta.data]);
  70. const metrics = sortedMeta.filter(
  71. ({mri, type, unit}) =>
  72. mri.includes(query) ||
  73. getReadableMetricType(type).includes(query) ||
  74. unit.includes(query)
  75. );
  76. // If we have custom metrics extraction rules,
  77. // we only show the custom metrics table if the project has custom metrics
  78. if (hasCustomMetricsExtractionRules(organization) && metricsMeta.data.length === 0) {
  79. return null;
  80. }
  81. return (
  82. <Fragment>
  83. <SearchWrapper>
  84. <Title>
  85. <h6>{t('Emitted Metrics')}</h6>
  86. {hasCustomMetricsExtractionRules(organization) && (
  87. <Tag type="warning">{t('deprecated')}</Tag>
  88. )}
  89. </Title>
  90. <SearchBar
  91. placeholder={t('Search Metrics')}
  92. onChange={setQuery}
  93. query={query}
  94. size="sm"
  95. />
  96. </SearchWrapper>
  97. <Tabs value={selectedTab} onChange={setSelectedTab}>
  98. <TabList>
  99. <TabList.Item key={BlockingStatusTab.ACTIVE}>{t('Active')}</TabList.Item>
  100. <TabList.Item key={BlockingStatusTab.DISABLED}>{t('Disabled')}</TabList.Item>
  101. </TabList>
  102. <TabPanels>
  103. <TabPanels.Item key={BlockingStatusTab.ACTIVE}>
  104. <MetricsTable
  105. metrics={metrics.filter(
  106. ({blockingStatus}) => !blockingStatus[0]?.isBlocked
  107. )}
  108. isLoading={isLoading}
  109. query={query}
  110. project={project}
  111. />
  112. </TabPanels.Item>
  113. <TabPanels.Item key={BlockingStatusTab.DISABLED}>
  114. <MetricsTable
  115. metrics={metrics.filter(({blockingStatus}) => blockingStatus[0]?.isBlocked)}
  116. isLoading={isLoading}
  117. query={query}
  118. project={project}
  119. />
  120. </TabPanels.Item>
  121. </TabPanels>
  122. </Tabs>
  123. </Fragment>
  124. );
  125. }
  126. interface MetricsTableProps {
  127. isLoading: boolean;
  128. metrics: MetricWithCardinality[];
  129. project: Project;
  130. query: string;
  131. }
  132. function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
  133. const blockMetricMutation = useBlockMetric(project);
  134. const {hasAccess} = useAccess({access: ['project:write'], project});
  135. const cardinalityLimit =
  136. // Retrive limit from BE
  137. project.relayCustomMetricCardinalityLimit ?? DEFAULT_METRICS_CARDINALITY_LIMIT;
  138. return (
  139. <MetricsPanelTable
  140. headers={[
  141. t('Metric'),
  142. <Cell right key="cardinality">
  143. <IconArrow size="xs" direction="down" />
  144. {t('Cardinality')}
  145. </Cell>,
  146. <Cell right key="type">
  147. {t('Type')}
  148. </Cell>,
  149. <Cell right key="unit">
  150. {t('Unit')}
  151. </Cell>,
  152. <Cell right key="actions">
  153. {t('Actions')}
  154. </Cell>,
  155. ]}
  156. emptyMessage={
  157. query
  158. ? t('No metrics match the query.')
  159. : t('There are no custom metrics to display.')
  160. }
  161. isEmpty={metrics.length === 0}
  162. isLoading={isLoading}
  163. >
  164. {metrics.map(({mri, type, unit, cardinality, blockingStatus}) => {
  165. const isBlocked = blockingStatus[0]?.isBlocked;
  166. const isCardinalityLimited = cardinality >= cardinalityLimit;
  167. return (
  168. <Fragment key={mri}>
  169. <Cell>
  170. <Link
  171. to={`/settings/projects/${project.slug}/metrics/${encodeURIComponent(
  172. mri
  173. )}`}
  174. >
  175. {middleEllipsis(formatMRI(mri), 65, /\.|-|_/)}
  176. </Link>
  177. </Cell>
  178. <Cell right>
  179. {isCardinalityLimited && (
  180. <Tooltip
  181. title={tct(
  182. 'The tag cardinality of this metric exceeded our limit of [cardinalityLimit], which led to the data being dropped',
  183. {cardinalityLimit}
  184. )}
  185. >
  186. <StyledIconWarning size="sm" color="red300" />
  187. </Tooltip>
  188. )}
  189. {cardinality}
  190. </Cell>
  191. <Cell right>
  192. <Tag>{getReadableMetricType(type)}</Tag>
  193. </Cell>
  194. <Cell right>
  195. <Tag>{unit}</Tag>
  196. </Cell>
  197. <Cell right>
  198. <BlockButton
  199. size="xs"
  200. hasAccess={hasAccess}
  201. disabled={blockMetricMutation.isLoading}
  202. isBlocked={isBlocked}
  203. blockTarget="metric"
  204. onConfirm={() => {
  205. blockMetricMutation.mutate({
  206. mri,
  207. operationType: isBlocked ? 'unblockMetric' : 'blockMetric',
  208. });
  209. }}
  210. />
  211. </Cell>
  212. </Fragment>
  213. );
  214. })}
  215. </MetricsPanelTable>
  216. );
  217. }
  218. const SearchWrapper = styled('div')`
  219. display: flex;
  220. justify-content: space-between;
  221. align-items: flex-start;
  222. gap: ${space(1)};
  223. margin-top: ${space(4)};
  224. margin-bottom: ${space(0)};
  225. & > h6 {
  226. margin: 0;
  227. }
  228. `;
  229. const MetricsPanelTable = styled(PanelTable)`
  230. margin-top: ${space(2)};
  231. grid-template-columns: 1fr repeat(4, min-content);
  232. `;
  233. const Cell = styled('div')<{right?: boolean}>`
  234. display: flex;
  235. align-items: center;
  236. align-self: stretch;
  237. gap: ${space(0.5)};
  238. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  239. `;
  240. const StyledIconWarning = styled(IconWarning)`
  241. margin-top: ${space(0.5)};
  242. &:hover {
  243. cursor: pointer;
  244. }
  245. `;
  246. const Title = styled('div')`
  247. display: flex;
  248. align-items: center;
  249. flex-wrap: wrap;
  250. gap: ${space(0.5)};
  251. margin-bottom: ${space(3)};
  252. & > h6 {
  253. margin: 0;
  254. }
  255. `;