customMetricsTable.tsx 8.3 KB

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