customMetricsTable.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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. project,
  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. project.relayCustomMetricCardinalityLimit ?? DEFAULT_METRICS_CARDINALITY_LIMIT;
  137. return (
  138. <MetricsPanelTable
  139. headers={[
  140. t('Metric'),
  141. <Cell right key="cardinality">
  142. <IconArrow size="xs" direction="down" />
  143. {t('Cardinality')}
  144. </Cell>,
  145. <Cell right key="type">
  146. {t('Type')}
  147. </Cell>,
  148. <Cell right key="unit">
  149. {t('Unit')}
  150. </Cell>,
  151. <Cell right key="actions">
  152. {t('Actions')}
  153. </Cell>,
  154. ]}
  155. emptyMessage={
  156. query
  157. ? t('No metrics match the query.')
  158. : t('There are no custom metrics to display.')
  159. }
  160. isEmpty={metrics.length === 0}
  161. isLoading={isLoading}
  162. >
  163. {metrics.map(({mri, type, unit, cardinality, blockingStatus}) => {
  164. const isBlocked = blockingStatus[0]?.isBlocked;
  165. const isCardinalityLimited = cardinality >= cardinalityLimit;
  166. return (
  167. <Fragment key={mri}>
  168. <Cell>
  169. <Link
  170. to={`/settings/projects/${project.slug}/metrics/${encodeURIComponent(
  171. mri
  172. )}`}
  173. >
  174. {middleEllipsis(formatMRI(mri), 65, /\.|-|_/)}
  175. </Link>
  176. </Cell>
  177. <Cell right>
  178. {isCardinalityLimited && (
  179. <Tooltip
  180. title={tct(
  181. 'The tag cardinality of this metric exceeded our limit of [cardinalityLimit], which led to the data being dropped',
  182. {cardinalityLimit}
  183. )}
  184. >
  185. <StyledIconWarning size="sm" color="red300" />
  186. </Tooltip>
  187. )}
  188. {cardinality}
  189. </Cell>
  190. <Cell right>
  191. <Tag>{getReadableMetricType(type)}</Tag>
  192. </Cell>
  193. <Cell right>
  194. <Tag>{unit}</Tag>
  195. </Cell>
  196. <Cell right>
  197. <BlockButton
  198. size="xs"
  199. hasAccess={hasAccess}
  200. disabled={blockMetricMutation.isLoading}
  201. isBlocked={isBlocked}
  202. blockTarget="metric"
  203. onConfirm={() => {
  204. blockMetricMutation.mutate({
  205. mri,
  206. operationType: isBlocked ? 'unblockMetric' : 'blockMetric',
  207. });
  208. }}
  209. />
  210. </Cell>
  211. </Fragment>
  212. );
  213. })}
  214. </MetricsPanelTable>
  215. );
  216. }
  217. const SearchWrapper = styled('div')`
  218. display: flex;
  219. justify-content: space-between;
  220. align-items: flex-start;
  221. gap: ${space(1)};
  222. margin-top: ${space(4)};
  223. margin-bottom: ${space(0)};
  224. & > h6 {
  225. margin: 0;
  226. }
  227. `;
  228. const MetricsPanelTable = styled(PanelTable)`
  229. margin-top: ${space(2)};
  230. grid-template-columns: 1fr repeat(4, min-content);
  231. `;
  232. const Cell = styled('div')<{right?: boolean}>`
  233. display: flex;
  234. align-items: center;
  235. align-self: stretch;
  236. gap: ${space(0.5)};
  237. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  238. `;
  239. const StyledIconWarning = styled(IconWarning)`
  240. margin-top: ${space(0.5)};
  241. &:hover {
  242. cursor: pointer;
  243. }
  244. `;
  245. const Title = styled('div')`
  246. display: flex;
  247. align-items: center;
  248. flex-wrap: wrap;
  249. gap: ${space(0.5)};
  250. margin-bottom: ${space(3)};
  251. & > h6 {
  252. margin: 0;
  253. }
  254. `;