projectMetrics.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import {Fragment, useMemo, useState} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import * as Sentry from '@sentry/react';
  5. import debounce from 'lodash/debounce';
  6. import Tag from 'sentry/components/badge/tag';
  7. import {Button} from 'sentry/components/button';
  8. import ExternalLink from 'sentry/components/links/externalLink';
  9. import Link from 'sentry/components/links/link';
  10. import {PanelTable} from 'sentry/components/panels/panelTable';
  11. import SearchBar from 'sentry/components/searchBar';
  12. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  13. import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  16. import {IconArrow, IconWarning} from 'sentry/icons';
  17. import {t, tct} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import type {MetricMeta} from 'sentry/types/metrics';
  20. import type {Organization} from 'sentry/types/organization';
  21. import type {Project} from 'sentry/types/project';
  22. import {
  23. DEFAULT_METRICS_CARDINALITY_LIMIT,
  24. METRICS_DOCS_URL,
  25. } from 'sentry/utils/metrics/constants';
  26. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  27. import {formatMRI} from 'sentry/utils/metrics/mri';
  28. import {useBlockMetric} from 'sentry/utils/metrics/useBlockMetric';
  29. import {useMetricsCardinality} from 'sentry/utils/metrics/useMetricsCardinality';
  30. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  31. import {decodeScalar} from 'sentry/utils/queryString';
  32. import routeTitleGen from 'sentry/utils/routeTitle';
  33. import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
  34. import {useNavigate} from 'sentry/utils/useNavigate';
  35. import {useMetricsOnboardingSidebar} from 'sentry/views/metrics/ddmOnboarding/useMetricsOnboardingSidebar';
  36. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  37. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  38. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  39. import {useAccess} from 'sentry/views/settings/projectMetrics/access';
  40. import {BlockButton} from 'sentry/views/settings/projectMetrics/blockButton';
  41. import {CardinalityLimit} from 'sentry/views/settings/projectMetrics/cardinalityLimit';
  42. type Props = {
  43. organization: Organization;
  44. project: Project;
  45. } & RouteComponentProps<{projectId: string}, {}>;
  46. enum BlockingStatusTab {
  47. ACTIVE = 'active',
  48. DISABLED = 'disabled',
  49. }
  50. type MetricWithCardinality = MetricMeta & {cardinality: number};
  51. function ProjectMetrics({project, location}: Props) {
  52. const metricsMeta = useMetricsMeta(
  53. {projects: [parseInt(project.id, 10)]},
  54. ['custom'],
  55. false
  56. );
  57. const metricsCardinality = useMetricsCardinality({
  58. project,
  59. });
  60. const sortedMeta = useMemo(() => {
  61. if (!metricsMeta.data) {
  62. return [];
  63. }
  64. if (!metricsCardinality.data) {
  65. return metricsMeta.data.map(meta => ({...meta, cardinality: 0}));
  66. }
  67. return metricsMeta.data
  68. .map(({mri, ...rest}) => {
  69. return {
  70. mri,
  71. cardinality: metricsCardinality.data[mri] ?? 0,
  72. ...rest,
  73. };
  74. })
  75. .sort((a, b) => {
  76. return b.cardinality - a.cardinality;
  77. }) as MetricWithCardinality[];
  78. }, [metricsCardinality.data, metricsMeta.data]);
  79. const query = decodeScalar(location.query.query, '').trim();
  80. const metrics = sortedMeta.filter(
  81. ({mri, type, unit}) =>
  82. mri.includes(query) ||
  83. getReadableMetricType(type).includes(query) ||
  84. unit.includes(query)
  85. );
  86. const isLoading = metricsMeta.isLoading || metricsCardinality.isLoading;
  87. const navigate = useNavigate();
  88. const debouncedSearch = useMemo(
  89. () =>
  90. debounce(
  91. (searchQuery: string) =>
  92. navigate({
  93. pathname: location.pathname,
  94. query: {...location.query, query: searchQuery},
  95. }),
  96. DEFAULT_DEBOUNCE_DURATION
  97. ),
  98. [location.pathname, location.query, navigate]
  99. );
  100. const {activateSidebar} = useMetricsOnboardingSidebar();
  101. const [selectedTab, setSelectedTab] = useState(BlockingStatusTab.ACTIVE);
  102. return (
  103. <Fragment>
  104. <SentryDocumentTitle title={routeTitleGen(t('Metrics'), project.slug, false)} />
  105. <SettingsPageHeader
  106. title={t('Metrics')}
  107. action={
  108. <Button
  109. priority="primary"
  110. onClick={() => {
  111. Sentry.metrics.increment('ddm.add_custom_metric', 1, {
  112. tags: {
  113. referrer: 'settings',
  114. },
  115. });
  116. activateSidebar();
  117. }}
  118. size="sm"
  119. >
  120. {t('Add Metric')}
  121. </Button>
  122. }
  123. />
  124. <TextBlock>
  125. {tct(
  126. `Metrics are numerical values that can track anything about your environment over time, from latency to error rates to user signups. To learn more about metrics, [link:read the docs].`,
  127. {
  128. link: <ExternalLink href={METRICS_DOCS_URL} />,
  129. }
  130. )}
  131. </TextBlock>
  132. <PermissionAlert project={project} />
  133. <CardinalityLimit project={project} />
  134. <SearchWrapper>
  135. <h6>{t('Emitted Metrics')}</h6>
  136. <SearchBar
  137. placeholder={t('Search Metrics')}
  138. onChange={debouncedSearch}
  139. query={query}
  140. size="sm"
  141. />
  142. </SearchWrapper>
  143. <Tabs value={selectedTab} onChange={setSelectedTab}>
  144. <TabList>
  145. <TabList.Item key={BlockingStatusTab.ACTIVE}>{t('Active')}</TabList.Item>
  146. <TabList.Item key={BlockingStatusTab.DISABLED}>{t('Disabled')}</TabList.Item>
  147. </TabList>
  148. <TabPanelsWrapper>
  149. <TabPanels.Item key={BlockingStatusTab.ACTIVE}>
  150. <MetricsTable
  151. metrics={metrics.filter(
  152. ({blockingStatus}) => !blockingStatus[0]?.isBlocked
  153. )}
  154. isLoading={isLoading}
  155. query={query}
  156. project={project}
  157. />
  158. </TabPanels.Item>
  159. <TabPanels.Item key={BlockingStatusTab.DISABLED}>
  160. <MetricsTable
  161. metrics={metrics.filter(({blockingStatus}) => blockingStatus[0]?.isBlocked)}
  162. isLoading={isLoading}
  163. query={query}
  164. project={project}
  165. />
  166. </TabPanels.Item>
  167. </TabPanelsWrapper>
  168. </Tabs>
  169. </Fragment>
  170. );
  171. }
  172. interface MetricsTableProps {
  173. isLoading: boolean;
  174. metrics: MetricWithCardinality[];
  175. project: Project;
  176. query: string;
  177. }
  178. function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
  179. const blockMetricMutation = useBlockMetric(project);
  180. const {hasAccess} = useAccess({access: ['project:write']});
  181. const cardinalityLimit =
  182. project.relayCustomMetricCardinalityLimit ?? DEFAULT_METRICS_CARDINALITY_LIMIT;
  183. return (
  184. <StyledPanelTable
  185. headers={[
  186. t('Metric'),
  187. <Cell right key="cardinality">
  188. <IconArrow size="xs" direction="down" />
  189. {t('Cardinality')}
  190. </Cell>,
  191. <Cell right key="type">
  192. {t('Type')}
  193. </Cell>,
  194. <Cell right key="unit">
  195. {t('Unit')}
  196. </Cell>,
  197. <Cell right key="actions">
  198. {t('Actions')}
  199. </Cell>,
  200. ]}
  201. emptyMessage={
  202. query
  203. ? t('No metrics match the query.')
  204. : t('There are no custom metrics to display.')
  205. }
  206. isEmpty={metrics.length === 0}
  207. isLoading={isLoading}
  208. >
  209. {metrics.map(({mri, type, unit, cardinality, blockingStatus}) => {
  210. const isBlocked = blockingStatus[0]?.isBlocked;
  211. const isCardinalityLimited = cardinality >= cardinalityLimit;
  212. return (
  213. <Fragment key={mri}>
  214. <Cell>
  215. <Link
  216. to={`/settings/projects/${project.slug}/metrics/${encodeURIComponent(
  217. mri
  218. )}`}
  219. >
  220. {middleEllipsis(formatMRI(mri), 65, /\.|-|_/)}
  221. </Link>
  222. </Cell>
  223. <Cell right>
  224. {isCardinalityLimited && (
  225. <Tooltip
  226. title={tct(
  227. 'The tag cardinality of this metric exceeded our limit of [cardinalityLimit], which led to the data being dropped',
  228. {cardinalityLimit}
  229. )}
  230. >
  231. <StyledIconWarning size="sm" color="red300" />
  232. </Tooltip>
  233. )}
  234. {cardinality}
  235. </Cell>
  236. <Cell right>
  237. <Tag>{getReadableMetricType(type)}</Tag>
  238. </Cell>
  239. <Cell right>
  240. <Tag>{unit}</Tag>
  241. </Cell>
  242. <Cell right>
  243. <BlockButton
  244. size="xs"
  245. hasAccess={hasAccess}
  246. disabled={blockMetricMutation.isLoading}
  247. isBlocked={isBlocked}
  248. blockTarget="metric"
  249. onConfirm={() => {
  250. blockMetricMutation.mutate({
  251. mri,
  252. operationType: isBlocked ? 'unblockMetric' : 'blockMetric',
  253. });
  254. }}
  255. />
  256. </Cell>
  257. </Fragment>
  258. );
  259. })}
  260. </StyledPanelTable>
  261. );
  262. }
  263. const TabPanelsWrapper = styled(TabPanels)`
  264. margin-top: ${space(2)};
  265. `;
  266. const SearchWrapper = styled('div')`
  267. display: flex;
  268. justify-content: space-between;
  269. align-items: flex-start;
  270. margin-top: ${space(4)};
  271. margin-bottom: ${space(0)};
  272. & > h6 {
  273. margin: 0;
  274. }
  275. `;
  276. const StyledPanelTable = styled(PanelTable)`
  277. grid-template-columns: 1fr repeat(4, min-content);
  278. `;
  279. const Cell = styled('div')<{right?: boolean}>`
  280. display: flex;
  281. align-items: center;
  282. align-self: stretch;
  283. gap: ${space(0.5)};
  284. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  285. `;
  286. const StyledIconWarning = styled(IconWarning)`
  287. margin-top: ${space(0.5)};
  288. &:hover {
  289. cursor: pointer;
  290. }
  291. `;
  292. export default ProjectMetrics;