projectMetrics.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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, LinkButton} 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 {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
  27. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  28. import {formatMRI} from 'sentry/utils/metrics/mri';
  29. import {useBlockMetric} from 'sentry/utils/metrics/useBlockMetric';
  30. import {useMetricsCardinality} from 'sentry/utils/metrics/useMetricsCardinality';
  31. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  32. import {decodeScalar} from 'sentry/utils/queryString';
  33. import routeTitleGen from 'sentry/utils/routeTitle';
  34. import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
  35. import {useNavigate} from 'sentry/utils/useNavigate';
  36. import useOrganization from 'sentry/utils/useOrganization';
  37. import {useMetricsOnboardingSidebar} from 'sentry/views/metrics/ddmOnboarding/useMetricsOnboardingSidebar';
  38. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  39. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  40. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  41. import {useAccess} from 'sentry/views/settings/projectMetrics/access';
  42. import {BlockButton} from 'sentry/views/settings/projectMetrics/blockButton';
  43. import {CardinalityLimit} from 'sentry/views/settings/projectMetrics/cardinalityLimit';
  44. type Props = {
  45. organization: Organization;
  46. project: Project;
  47. } & RouteComponentProps<{projectId: string}, {}>;
  48. enum BlockingStatusTab {
  49. ACTIVE = 'active',
  50. DISABLED = 'disabled',
  51. }
  52. type MetricWithCardinality = MetricMeta & {cardinality: number};
  53. function ProjectMetrics({project, location}: Props) {
  54. const organization = useOrganization();
  55. const metricsMeta = useMetricsMeta(
  56. {projects: [parseInt(project.id, 10)]},
  57. ['custom'],
  58. false
  59. );
  60. const metricsCardinality = useMetricsCardinality({
  61. project,
  62. });
  63. const sortedMeta = useMemo(() => {
  64. if (!metricsMeta.data) {
  65. return [];
  66. }
  67. if (!metricsCardinality.data) {
  68. return metricsMeta.data.map(meta => ({...meta, cardinality: 0}));
  69. }
  70. return metricsMeta.data
  71. .map(({mri, ...rest}) => {
  72. return {
  73. mri,
  74. cardinality: metricsCardinality.data[mri] ?? 0,
  75. ...rest,
  76. };
  77. })
  78. .sort((a, b) => {
  79. return b.cardinality - a.cardinality;
  80. }) as MetricWithCardinality[];
  81. }, [metricsCardinality.data, metricsMeta.data]);
  82. const query = decodeScalar(location.query.query, '').trim();
  83. const metrics = sortedMeta.filter(
  84. ({mri, type, unit}) =>
  85. mri.includes(query) ||
  86. getReadableMetricType(type).includes(query) ||
  87. unit.includes(query)
  88. );
  89. const isLoading = metricsMeta.isLoading || metricsCardinality.isLoading;
  90. const navigate = useNavigate();
  91. const debouncedSearch = useMemo(
  92. () =>
  93. debounce(
  94. (searchQuery: string) =>
  95. navigate({
  96. pathname: location.pathname,
  97. query: {...location.query, query: searchQuery},
  98. }),
  99. DEFAULT_DEBOUNCE_DURATION
  100. ),
  101. [location.pathname, location.query, navigate]
  102. );
  103. const {activateSidebar} = useMetricsOnboardingSidebar();
  104. const [selectedTab, setSelectedTab] = useState(BlockingStatusTab.ACTIVE);
  105. const hasExtractionRules = hasCustomMetricsExtractionRules(organization);
  106. return (
  107. <Fragment>
  108. <SentryDocumentTitle title={routeTitleGen(t('Metrics'), project.slug, false)} />
  109. <SettingsPageHeader
  110. title={t('Metrics')}
  111. action={
  112. <Button
  113. priority="primary"
  114. onClick={() => {
  115. Sentry.metrics.increment('ddm.add_custom_metric', 1, {
  116. tags: {
  117. referrer: 'settings',
  118. },
  119. });
  120. activateSidebar();
  121. }}
  122. size="sm"
  123. >
  124. {t('Add Metric')}
  125. </Button>
  126. }
  127. />
  128. <TextBlock>
  129. {tct(
  130. `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].`,
  131. {
  132. link: <ExternalLink href={METRICS_DOCS_URL} />,
  133. }
  134. )}
  135. </TextBlock>
  136. <PermissionAlert project={project} />
  137. <CardinalityLimit project={project} />
  138. {hasExtractionRules && (
  139. <Fragment>
  140. <ExtractionRulesSearchWrapper>
  141. <h6>{t('Metric Extraction Rules')}</h6>
  142. <LinkButton
  143. to={`/settings/projects/${project.slug}/metrics/extract-metric`}
  144. priority="primary"
  145. size="sm"
  146. >
  147. {t('Add Extraction Rule')}
  148. </LinkButton>
  149. </ExtractionRulesSearchWrapper>
  150. <MetricsExtractionTable isLoading={false} extractionRules={[]} />
  151. </Fragment>
  152. )}
  153. <SearchWrapper>
  154. <h6>{t('Emitted Metrics')}</h6>
  155. <SearchBar
  156. placeholder={t('Search Metrics')}
  157. onChange={debouncedSearch}
  158. query={query}
  159. size="sm"
  160. />
  161. </SearchWrapper>
  162. <Tabs value={selectedTab} onChange={setSelectedTab}>
  163. <TabList>
  164. <TabList.Item key={BlockingStatusTab.ACTIVE}>{t('Active')}</TabList.Item>
  165. <TabList.Item key={BlockingStatusTab.DISABLED}>{t('Disabled')}</TabList.Item>
  166. </TabList>
  167. <TabPanelsWrapper>
  168. <TabPanels.Item key={BlockingStatusTab.ACTIVE}>
  169. <MetricsTable
  170. metrics={metrics.filter(
  171. ({blockingStatus}) => !blockingStatus[0]?.isBlocked
  172. )}
  173. isLoading={isLoading}
  174. query={query}
  175. project={project}
  176. />
  177. </TabPanels.Item>
  178. <TabPanels.Item key={BlockingStatusTab.DISABLED}>
  179. <MetricsTable
  180. metrics={metrics.filter(({blockingStatus}) => blockingStatus[0]?.isBlocked)}
  181. isLoading={isLoading}
  182. query={query}
  183. project={project}
  184. />
  185. </TabPanels.Item>
  186. </TabPanelsWrapper>
  187. </Tabs>
  188. </Fragment>
  189. );
  190. }
  191. interface MetricsExtractionTableProps {
  192. extractionRules: never[];
  193. isLoading: boolean;
  194. }
  195. function MetricsExtractionTable({
  196. extractionRules,
  197. isLoading,
  198. }: MetricsExtractionTableProps) {
  199. return (
  200. <StyledPanelTable
  201. headers={[
  202. t('Span attribute'),
  203. <Cell right key="type">
  204. {t('Type')}
  205. </Cell>,
  206. <Cell right key="unit">
  207. {t('Unit')}
  208. </Cell>,
  209. <Cell right key="tags">
  210. {t('Tags')}
  211. </Cell>,
  212. <Cell right key="actions">
  213. {t('Actions')}
  214. </Cell>,
  215. ]}
  216. emptyMessage={t('You have not created any extraction rules yet.')}
  217. isEmpty={extractionRules.length === 0}
  218. isLoading={isLoading}
  219. />
  220. );
  221. }
  222. interface MetricsTableProps {
  223. isLoading: boolean;
  224. metrics: MetricWithCardinality[];
  225. project: Project;
  226. query: string;
  227. }
  228. function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
  229. const blockMetricMutation = useBlockMetric(project);
  230. const {hasAccess} = useAccess({access: ['project:write'], project});
  231. const cardinalityLimit =
  232. project.relayCustomMetricCardinalityLimit ?? DEFAULT_METRICS_CARDINALITY_LIMIT;
  233. return (
  234. <StyledPanelTable
  235. headers={[
  236. t('Metric'),
  237. <Cell right key="cardinality">
  238. <IconArrow size="xs" direction="down" />
  239. {t('Cardinality')}
  240. </Cell>,
  241. <Cell right key="type">
  242. {t('Type')}
  243. </Cell>,
  244. <Cell right key="unit">
  245. {t('Unit')}
  246. </Cell>,
  247. <Cell right key="actions">
  248. {t('Actions')}
  249. </Cell>,
  250. ]}
  251. emptyMessage={
  252. query
  253. ? t('No metrics match the query.')
  254. : t('There are no custom metrics to display.')
  255. }
  256. isEmpty={metrics.length === 0}
  257. isLoading={isLoading}
  258. >
  259. {metrics.map(({mri, type, unit, cardinality, blockingStatus}) => {
  260. const isBlocked = blockingStatus[0]?.isBlocked;
  261. const isCardinalityLimited = cardinality >= cardinalityLimit;
  262. return (
  263. <Fragment key={mri}>
  264. <Cell>
  265. <Link
  266. to={`/settings/projects/${project.slug}/metrics/${encodeURIComponent(
  267. mri
  268. )}`}
  269. >
  270. {middleEllipsis(formatMRI(mri), 65, /\.|-|_/)}
  271. </Link>
  272. </Cell>
  273. <Cell right>
  274. {isCardinalityLimited && (
  275. <Tooltip
  276. title={tct(
  277. 'The tag cardinality of this metric exceeded our limit of [cardinalityLimit], which led to the data being dropped',
  278. {cardinalityLimit}
  279. )}
  280. >
  281. <StyledIconWarning size="sm" color="red300" />
  282. </Tooltip>
  283. )}
  284. {cardinality}
  285. </Cell>
  286. <Cell right>
  287. <Tag>{getReadableMetricType(type)}</Tag>
  288. </Cell>
  289. <Cell right>
  290. <Tag>{unit}</Tag>
  291. </Cell>
  292. <Cell right>
  293. <BlockButton
  294. size="xs"
  295. hasAccess={hasAccess}
  296. disabled={blockMetricMutation.isLoading}
  297. isBlocked={isBlocked}
  298. blockTarget="metric"
  299. onConfirm={() => {
  300. blockMetricMutation.mutate({
  301. mri,
  302. operationType: isBlocked ? 'unblockMetric' : 'blockMetric',
  303. });
  304. }}
  305. />
  306. </Cell>
  307. </Fragment>
  308. );
  309. })}
  310. </StyledPanelTable>
  311. );
  312. }
  313. const TabPanelsWrapper = styled(TabPanels)`
  314. margin-top: ${space(2)};
  315. `;
  316. const SearchWrapper = styled('div')`
  317. display: flex;
  318. justify-content: space-between;
  319. align-items: flex-start;
  320. margin-top: ${space(4)};
  321. margin-bottom: ${space(0)};
  322. & > h6 {
  323. margin: 0;
  324. }
  325. `;
  326. const ExtractionRulesSearchWrapper = styled(SearchWrapper)`
  327. margin-bottom: ${space(1)};
  328. `;
  329. const StyledPanelTable = styled(PanelTable)`
  330. grid-template-columns: 1fr repeat(4, min-content);
  331. `;
  332. const Cell = styled('div')<{right?: boolean}>`
  333. display: flex;
  334. align-items: center;
  335. align-self: stretch;
  336. gap: ${space(0.5)};
  337. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  338. `;
  339. const StyledIconWarning = styled(IconWarning)`
  340. margin-top: ${space(0.5)};
  341. &:hover {
  342. cursor: pointer;
  343. }
  344. `;
  345. export default ProjectMetrics;