projectMetrics.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  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 {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  7. import {openModal} from 'sentry/actionCreators/modal';
  8. import Tag from 'sentry/components/badge/tag';
  9. import {Button, LinkButton} from 'sentry/components/button';
  10. import {openConfirmModal} from 'sentry/components/confirm';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import Link from 'sentry/components/links/link';
  13. import {PanelTable} from 'sentry/components/panels/panelTable';
  14. import SearchBar from 'sentry/components/searchBar';
  15. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  16. import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
  17. import {Tooltip} from 'sentry/components/tooltip';
  18. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  19. import {IconArrow, IconDelete, IconEdit, IconWarning} from 'sentry/icons';
  20. import {t, tct} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import type {MetricMeta} from 'sentry/types/metrics';
  23. import type {Organization} from 'sentry/types/organization';
  24. import type {Project} from 'sentry/types/project';
  25. import {
  26. DEFAULT_METRICS_CARDINALITY_LIMIT,
  27. METRICS_DOCS_URL,
  28. } from 'sentry/utils/metrics/constants';
  29. import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
  30. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  31. import {formatMRI} from 'sentry/utils/metrics/mri';
  32. import {useBlockMetric} from 'sentry/utils/metrics/useBlockMetric';
  33. import {useMetricsCardinality} from 'sentry/utils/metrics/useMetricsCardinality';
  34. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  35. import {decodeScalar} from 'sentry/utils/queryString';
  36. import routeTitleGen from 'sentry/utils/routeTitle';
  37. import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
  38. import {useNavigate} from 'sentry/utils/useNavigate';
  39. import useOrganization from 'sentry/utils/useOrganization';
  40. import {useMetricsOnboardingSidebar} from 'sentry/views/metrics/ddmOnboarding/useMetricsOnboardingSidebar';
  41. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  42. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  43. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  44. import {useAccess} from 'sentry/views/settings/projectMetrics/access';
  45. import {BlockButton} from 'sentry/views/settings/projectMetrics/blockButton';
  46. import {CardinalityLimit} from 'sentry/views/settings/projectMetrics/cardinalityLimit';
  47. import {
  48. MetricsExtractionRuleEditModal,
  49. modalCss,
  50. } from 'sentry/views/settings/projectMetrics/metricsExtractionRuleEditModal';
  51. import {
  52. type MetricsExtractionRule,
  53. useDeleteMetricsExtractionRules,
  54. useMetricsExtractionRules,
  55. } from 'sentry/views/settings/projectMetrics/utils/api';
  56. type Props = {
  57. organization: Organization;
  58. project: Project;
  59. } & RouteComponentProps<{projectId: string}, {}>;
  60. enum BlockingStatusTab {
  61. ACTIVE = 'active',
  62. DISABLED = 'disabled',
  63. }
  64. type MetricWithCardinality = MetricMeta & {cardinality: number};
  65. function ProjectMetrics({project, location}: Props) {
  66. const organization = useOrganization();
  67. const metricsMeta = useMetricsMeta(
  68. {projects: [parseInt(project.id, 10)]},
  69. ['custom'],
  70. false
  71. );
  72. const metricsCardinality = useMetricsCardinality({
  73. project,
  74. });
  75. const sortedMeta = useMemo(() => {
  76. if (!metricsMeta.data) {
  77. return [];
  78. }
  79. if (!metricsCardinality.data) {
  80. return metricsMeta.data.map(meta => ({...meta, cardinality: 0}));
  81. }
  82. return metricsMeta.data
  83. .map(({mri, ...rest}) => {
  84. return {
  85. mri,
  86. cardinality: metricsCardinality.data[mri] ?? 0,
  87. ...rest,
  88. };
  89. })
  90. .sort((a, b) => {
  91. return b.cardinality - a.cardinality;
  92. }) as MetricWithCardinality[];
  93. }, [metricsCardinality.data, metricsMeta.data]);
  94. const query = decodeScalar(location.query.query, '').trim();
  95. const metrics = sortedMeta.filter(
  96. ({mri, type, unit}) =>
  97. mri.includes(query) ||
  98. getReadableMetricType(type).includes(query) ||
  99. unit.includes(query)
  100. );
  101. const isLoading = metricsMeta.isLoading || metricsCardinality.isLoading;
  102. const navigate = useNavigate();
  103. const debouncedSearch = useMemo(
  104. () =>
  105. debounce(
  106. (searchQuery: string) =>
  107. navigate({
  108. pathname: location.pathname,
  109. query: {...location.query, query: searchQuery},
  110. }),
  111. DEFAULT_DEBOUNCE_DURATION
  112. ),
  113. [location.pathname, location.query, navigate]
  114. );
  115. const {activateSidebar} = useMetricsOnboardingSidebar();
  116. const [selectedTab, setSelectedTab] = useState(BlockingStatusTab.ACTIVE);
  117. const hasExtractionRules = hasCustomMetricsExtractionRules(organization);
  118. const extractionRulesQuery = useMetricsExtractionRules(organization.slug, project.slug);
  119. const deleteExtractionRulesMutation = useDeleteMetricsExtractionRules(
  120. organization.slug,
  121. project.slug
  122. );
  123. return (
  124. <Fragment>
  125. <SentryDocumentTitle title={routeTitleGen(t('Metrics'), project.slug, false)} />
  126. <SettingsPageHeader
  127. title={t('Metrics')}
  128. action={
  129. <Button
  130. priority="primary"
  131. onClick={() => {
  132. Sentry.metrics.increment('ddm.add_custom_metric', 1, {
  133. tags: {
  134. referrer: 'settings',
  135. },
  136. });
  137. activateSidebar();
  138. }}
  139. size="sm"
  140. >
  141. {t('Add Metric')}
  142. </Button>
  143. }
  144. />
  145. <TextBlock>
  146. {tct(
  147. `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].`,
  148. {
  149. link: <ExternalLink href={METRICS_DOCS_URL} />,
  150. }
  151. )}
  152. </TextBlock>
  153. <PermissionAlert project={project} />
  154. <CardinalityLimit project={project} />
  155. {hasExtractionRules && (
  156. <Fragment>
  157. <ExtractionRulesSearchWrapper>
  158. <h6>{t('Metric Extraction Rules')}</h6>
  159. <LinkButton
  160. to={`/settings/projects/${project.slug}/metrics/extract-metric`}
  161. priority="primary"
  162. size="sm"
  163. >
  164. {t('Add Extraction Rule')}
  165. </LinkButton>
  166. </ExtractionRulesSearchWrapper>
  167. <MetricsExtractionTable
  168. isLoading={extractionRulesQuery.isLoading}
  169. onDelete={rule =>
  170. openConfirmModal({
  171. onConfirm: () =>
  172. deleteExtractionRulesMutation.mutate(
  173. {metricsExtractionRules: [rule]},
  174. {
  175. onSuccess: () => {
  176. addSuccessMessage(t('Metric extraction rule deleted'));
  177. },
  178. onError: () => {
  179. addErrorMessage(t('Failed to delete metric extraction rule'));
  180. },
  181. }
  182. ),
  183. message: t('Are you sure you want to delete this extraction rule?'),
  184. confirmText: t('Delete Extraction Rule'),
  185. })
  186. }
  187. onEdit={rule => {
  188. openModal(
  189. props => (
  190. <MetricsExtractionRuleEditModal
  191. project={project}
  192. metricExtractionRule={rule}
  193. {...props}
  194. />
  195. ),
  196. {modalCss}
  197. );
  198. }}
  199. extractionRules={extractionRulesQuery.data ?? []}
  200. />
  201. </Fragment>
  202. )}
  203. <SearchWrapper>
  204. <h6>{t('Emitted Metrics')}</h6>
  205. <SearchBar
  206. placeholder={t('Search Metrics')}
  207. onChange={debouncedSearch}
  208. query={query}
  209. size="sm"
  210. />
  211. </SearchWrapper>
  212. <Tabs value={selectedTab} onChange={setSelectedTab}>
  213. <TabList>
  214. <TabList.Item key={BlockingStatusTab.ACTIVE}>{t('Active')}</TabList.Item>
  215. <TabList.Item key={BlockingStatusTab.DISABLED}>{t('Disabled')}</TabList.Item>
  216. </TabList>
  217. <TabPanelsWrapper>
  218. <TabPanels.Item key={BlockingStatusTab.ACTIVE}>
  219. <MetricsTable
  220. metrics={metrics.filter(
  221. ({blockingStatus}) => !blockingStatus[0]?.isBlocked
  222. )}
  223. isLoading={isLoading}
  224. query={query}
  225. project={project}
  226. />
  227. </TabPanels.Item>
  228. <TabPanels.Item key={BlockingStatusTab.DISABLED}>
  229. <MetricsTable
  230. metrics={metrics.filter(({blockingStatus}) => blockingStatus[0]?.isBlocked)}
  231. isLoading={isLoading}
  232. query={query}
  233. project={project}
  234. />
  235. </TabPanels.Item>
  236. </TabPanelsWrapper>
  237. </Tabs>
  238. </Fragment>
  239. );
  240. }
  241. interface MetricsExtractionTableProps {
  242. extractionRules: MetricsExtractionRule[];
  243. isLoading: boolean;
  244. onDelete: (rule: MetricsExtractionRule) => void;
  245. onEdit: (rule: MetricsExtractionRule) => void;
  246. }
  247. function MetricsExtractionTable({
  248. extractionRules,
  249. isLoading,
  250. onDelete,
  251. onEdit,
  252. }: MetricsExtractionTableProps) {
  253. return (
  254. <ExtractionRulesPanelTable
  255. headers={[
  256. <Cell key="spanAttribute">
  257. <IconArrow size="xs" direction="down" />
  258. {t('Span attribute')}
  259. </Cell>,
  260. <Cell right key="type">
  261. {t('Type')}
  262. </Cell>,
  263. <Cell right key="unit">
  264. {t('Unit')}
  265. </Cell>,
  266. <Cell right key="filters">
  267. {t('Filters')}
  268. </Cell>,
  269. <Cell right key="tags">
  270. {t('Tags')}
  271. </Cell>,
  272. <Cell right key="actions">
  273. {t('Actions')}
  274. </Cell>,
  275. ]}
  276. emptyMessage={t('You have not created any extraction rules yet.')}
  277. isEmpty={extractionRules.length === 0}
  278. isLoading={isLoading}
  279. >
  280. {extractionRules
  281. .toSorted((a, b) => a?.spanAttribute?.localeCompare(b?.spanAttribute))
  282. .map(rule => (
  283. <Fragment key={rule.spanAttribute + rule.type + rule.unit}>
  284. <Cell>{rule.spanAttribute}</Cell>
  285. <Cell right>
  286. <Tag>{getReadableMetricType(rule.type)}</Tag>
  287. </Cell>
  288. <Cell right>
  289. <Tag>{rule.unit}</Tag>
  290. </Cell>
  291. <Cell right>
  292. {rule.conditions.length ? (
  293. <Button priority="link" onClick={() => onEdit(rule)}>
  294. {rule.conditions.length}
  295. </Button>
  296. ) : (
  297. <NoValue>{t('(none)')}</NoValue>
  298. )}
  299. </Cell>
  300. <Cell right>
  301. {rule.tags.length ? (
  302. <Button priority="link" onClick={() => onEdit(rule)}>
  303. {rule.tags.length}
  304. </Button>
  305. ) : (
  306. <NoValue>{t('(none)')}</NoValue>
  307. )}
  308. </Cell>
  309. <Cell right>
  310. <Button
  311. aria-label={t('Delete rule')}
  312. size="xs"
  313. icon={<IconDelete />}
  314. borderless
  315. onClick={() => onDelete(rule)}
  316. />
  317. <Button
  318. aria-label={t('Edit rule')}
  319. size="xs"
  320. icon={<IconEdit />}
  321. borderless
  322. onClick={() => onEdit(rule)}
  323. />
  324. </Cell>
  325. </Fragment>
  326. ))}
  327. </ExtractionRulesPanelTable>
  328. );
  329. }
  330. interface MetricsTableProps {
  331. isLoading: boolean;
  332. metrics: MetricWithCardinality[];
  333. project: Project;
  334. query: string;
  335. }
  336. function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
  337. const blockMetricMutation = useBlockMetric(project);
  338. const {hasAccess} = useAccess({access: ['project:write'], project});
  339. const cardinalityLimit =
  340. project.relayCustomMetricCardinalityLimit ?? DEFAULT_METRICS_CARDINALITY_LIMIT;
  341. return (
  342. <MetricsPanelTable
  343. headers={[
  344. t('Metric'),
  345. <Cell right key="cardinality">
  346. <IconArrow size="xs" direction="down" />
  347. {t('Cardinality')}
  348. </Cell>,
  349. <Cell right key="type">
  350. {t('Type')}
  351. </Cell>,
  352. <Cell right key="unit">
  353. {t('Unit')}
  354. </Cell>,
  355. <Cell right key="actions">
  356. {t('Actions')}
  357. </Cell>,
  358. ]}
  359. emptyMessage={
  360. query
  361. ? t('No metrics match the query.')
  362. : t('There are no custom metrics to display.')
  363. }
  364. isEmpty={metrics.length === 0}
  365. isLoading={isLoading}
  366. >
  367. {metrics.map(({mri, type, unit, cardinality, blockingStatus}) => {
  368. const isBlocked = blockingStatus[0]?.isBlocked;
  369. const isCardinalityLimited = cardinality >= cardinalityLimit;
  370. return (
  371. <Fragment key={mri}>
  372. <Cell>
  373. <Link
  374. to={`/settings/projects/${project.slug}/metrics/${encodeURIComponent(
  375. mri
  376. )}`}
  377. >
  378. {middleEllipsis(formatMRI(mri), 65, /\.|-|_/)}
  379. </Link>
  380. </Cell>
  381. <Cell right>
  382. {isCardinalityLimited && (
  383. <Tooltip
  384. title={tct(
  385. 'The tag cardinality of this metric exceeded our limit of [cardinalityLimit], which led to the data being dropped',
  386. {cardinalityLimit}
  387. )}
  388. >
  389. <StyledIconWarning size="sm" color="red300" />
  390. </Tooltip>
  391. )}
  392. {cardinality}
  393. </Cell>
  394. <Cell right>
  395. <Tag>{getReadableMetricType(type)}</Tag>
  396. </Cell>
  397. <Cell right>
  398. <Tag>{unit}</Tag>
  399. </Cell>
  400. <Cell right>
  401. <BlockButton
  402. size="xs"
  403. hasAccess={hasAccess}
  404. disabled={blockMetricMutation.isLoading}
  405. isBlocked={isBlocked}
  406. blockTarget="metric"
  407. onConfirm={() => {
  408. blockMetricMutation.mutate({
  409. mri,
  410. operationType: isBlocked ? 'unblockMetric' : 'blockMetric',
  411. });
  412. }}
  413. />
  414. </Cell>
  415. </Fragment>
  416. );
  417. })}
  418. </MetricsPanelTable>
  419. );
  420. }
  421. const TabPanelsWrapper = styled(TabPanels)`
  422. margin-top: ${space(2)};
  423. `;
  424. const SearchWrapper = styled('div')`
  425. display: flex;
  426. justify-content: space-between;
  427. align-items: flex-start;
  428. margin-top: ${space(4)};
  429. margin-bottom: ${space(0)};
  430. & > h6 {
  431. margin: 0;
  432. }
  433. `;
  434. const ExtractionRulesSearchWrapper = styled(SearchWrapper)`
  435. margin-bottom: ${space(1)};
  436. `;
  437. const MetricsPanelTable = styled(PanelTable)`
  438. grid-template-columns: 1fr repeat(4, min-content);
  439. `;
  440. const ExtractionRulesPanelTable = styled(PanelTable)`
  441. grid-template-columns: 1fr repeat(5, min-content);
  442. `;
  443. const Cell = styled('div')<{right?: boolean}>`
  444. display: flex;
  445. align-items: center;
  446. align-self: stretch;
  447. gap: ${space(0.5)};
  448. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  449. `;
  450. const StyledIconWarning = styled(IconWarning)`
  451. margin-top: ${space(0.5)};
  452. &:hover {
  453. cursor: pointer;
  454. }
  455. `;
  456. const NoValue = styled('span')`
  457. color: ${p => p.theme.subText};
  458. `;
  459. export default ProjectMetrics;