queryBuilder.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import memoize from 'lodash/memoize';
  5. import {navigateTo} from 'sentry/actionCreators/navigation';
  6. import {Button} from 'sentry/components/button';
  7. import {HeaderTitle} from 'sentry/components/charts/styles';
  8. import {CompactSelect} from 'sentry/components/compactSelect';
  9. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  10. import {BooleanOperator} from 'sentry/components/searchSyntax/parser';
  11. import SmartSearchBar, {SmartSearchBarProps} from 'sentry/components/smartSearchBar';
  12. import Tag from 'sentry/components/tag';
  13. import TextOverflow from 'sentry/components/textOverflow';
  14. import {IconLightning, IconReleases, IconSettings} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {MetricMeta, MRI, SavedSearchType, TagCollection} from 'sentry/types';
  18. import {
  19. defaultMetricDisplayType,
  20. getReadableMetricType,
  21. isAllowedOp,
  22. isCustomMetric,
  23. isMeasurement,
  24. isTransactionDuration,
  25. MetricDisplayType,
  26. MetricsQuery,
  27. MetricsQuerySubject,
  28. MetricWidgetQueryParams,
  29. stringifyMetricWidget,
  30. } from 'sentry/utils/metrics';
  31. import {formatMRI, getUseCaseFromMRI, parseMRI} from 'sentry/utils/metrics/mri';
  32. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  33. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  34. import useApi from 'sentry/utils/useApi';
  35. import useKeyPress from 'sentry/utils/useKeyPress';
  36. import useOrganization from 'sentry/utils/useOrganization';
  37. import usePageFilters from 'sentry/utils/usePageFilters';
  38. import useRouter from 'sentry/utils/useRouter';
  39. type QueryBuilderProps = {
  40. displayType: MetricDisplayType;
  41. isEdit: boolean;
  42. // TODO(ddm): move display type out of the query builder
  43. metricsQuery: MetricsQuerySubject;
  44. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  45. projects: number[];
  46. powerUserMode?: boolean;
  47. };
  48. const isShownByDefault = (metric: MetricMeta) =>
  49. isMeasurement(metric) || isCustomMetric(metric) || isTransactionDuration(metric);
  50. function stopPropagation(e: React.MouseEvent) {
  51. e.stopPropagation();
  52. }
  53. export const QueryBuilder = memo(function QueryBuilder({
  54. metricsQuery,
  55. projects,
  56. displayType,
  57. powerUserMode,
  58. onChange,
  59. isEdit,
  60. }: QueryBuilderProps) {
  61. const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(projects);
  62. const router = useRouter();
  63. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  64. const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names
  65. useEffect(() => {
  66. if (mriModeKeyPressed && !powerUserMode) {
  67. setMriMode(!mriMode);
  68. }
  69. // eslint-disable-next-line react-hooks/exhaustive-deps
  70. }, [mriModeKeyPressed, powerUserMode]);
  71. const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects);
  72. const displayedMetrics = useMemo(() => {
  73. if (mriMode) {
  74. return meta;
  75. }
  76. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  77. return meta
  78. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  79. .sort(metric => (isSelected(metric) ? -1 : 1));
  80. }, [meta, metricsQuery.mri, mriMode]);
  81. const selectedMeta = useMemo(() => {
  82. return meta.find(metric => metric.mri === metricsQuery.mri);
  83. }, [meta, metricsQuery.mri]);
  84. // Reset the query data if the selected metric is no longer available
  85. useEffect(() => {
  86. if (
  87. metricsQuery.mri &&
  88. !isMetaLoading &&
  89. !displayedMetrics.find(metric => metric.mri === metricsQuery.mri)
  90. ) {
  91. onChange({mri: '' as MRI, op: '', groupBy: []});
  92. }
  93. }, [isMetaLoading, displayedMetrics, metricsQuery.mri, onChange]);
  94. const stringifiedMetricWidget = stringifyMetricWidget(metricsQuery);
  95. const readableType = getReadableMetricType(parseMRI(metricsQuery.mri)?.type);
  96. if (!isEdit) {
  97. return (
  98. <QueryBuilderWrapper>
  99. <WidgetTitle>
  100. <TextOverflow>{metricsQuery.title || stringifiedMetricWidget}</TextOverflow>
  101. </WidgetTitle>
  102. </QueryBuilderWrapper>
  103. );
  104. }
  105. return (
  106. <QueryBuilderWrapper>
  107. <QueryBuilderRow>
  108. <WrapPageFilterBar>
  109. <CompactSelect
  110. searchable
  111. sizeLimit={100}
  112. triggerProps={{prefix: t('Metric'), size: 'sm'}}
  113. options={displayedMetrics.map(metric => ({
  114. label: mriMode ? metric.mri : formatMRI(metric.mri),
  115. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  116. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  117. value: metric.mri,
  118. trailingItems: mriMode
  119. ? undefined
  120. : ({isFocused}) => (
  121. <Fragment>
  122. {isFocused && isCustomMetric({mri: metric.mri}) && (
  123. <Button
  124. borderless
  125. size="zero"
  126. icon={<IconSettings />}
  127. aria-label={t('Metric Settings')}
  128. onPointerDown={() => {
  129. // not using onClick to beat the dropdown listener
  130. navigateTo(
  131. `/settings/projects/:projectId/metrics/${encodeURIComponent(
  132. metric.mri
  133. )}`,
  134. router
  135. );
  136. }}
  137. />
  138. )}
  139. <Tag tooltipText={t('Type')}>
  140. {getReadableMetricType(metric.type)}
  141. </Tag>
  142. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  143. </Fragment>
  144. ),
  145. }))}
  146. value={metricsQuery.mri}
  147. onChange={option => {
  148. const availableOps =
  149. meta
  150. .find(metric => metric.mri === option.value)
  151. ?.operations.filter(isAllowedOp) ?? [];
  152. // @ts-expect-error .op is an operation
  153. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  154. ? metricsQuery.op
  155. : availableOps?.[0];
  156. Sentry.metrics.increment('ddm.widget.metric', 1, {
  157. tags: {
  158. display: displayType ?? defaultMetricDisplayType,
  159. type: readableType,
  160. operation: selectedOp,
  161. isGrouped: !!metricsQuery.groupBy?.length,
  162. isFiltered: !!metricsQuery.query,
  163. },
  164. });
  165. onChange({
  166. mri: option.value,
  167. op: selectedOp,
  168. groupBy: undefined,
  169. focusedSeries: undefined,
  170. displayType: getWidgetDisplayType(option.value, selectedOp),
  171. });
  172. }}
  173. />
  174. <CompactSelect
  175. triggerProps={{prefix: t('Op'), size: 'sm'}}
  176. options={
  177. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  178. label: op,
  179. value: op,
  180. })) ?? []
  181. }
  182. disabled={!metricsQuery.mri}
  183. value={metricsQuery.op}
  184. onChange={option => {
  185. Sentry.metrics.increment('ddm.widget.operation', 1, {
  186. tags: {
  187. display: displayType ?? defaultMetricDisplayType,
  188. type: readableType,
  189. operation: option.value,
  190. isGrouped: !!metricsQuery.groupBy?.length,
  191. isFiltered: !!metricsQuery.query,
  192. },
  193. });
  194. onChange({
  195. op: option.value,
  196. });
  197. }}
  198. />
  199. <CompactSelect
  200. multiple
  201. triggerProps={{prefix: t('Group by'), size: 'sm'}}
  202. options={tags.map(tag => ({
  203. label: tag.key,
  204. value: tag.key,
  205. trailingItems: (
  206. <Fragment>
  207. {tag.key === 'release' && <IconReleases size="xs" />}
  208. {tag.key === 'transaction' && <IconLightning size="xs" />}
  209. </Fragment>
  210. ),
  211. }))}
  212. disabled={!metricsQuery.mri}
  213. value={metricsQuery.groupBy}
  214. onChange={options => {
  215. Sentry.metrics.increment('ddm.widget.group', 1, {
  216. tags: {
  217. display: displayType ?? defaultMetricDisplayType,
  218. type: readableType,
  219. operation: metricsQuery.op,
  220. isGrouped: !!metricsQuery.groupBy?.length,
  221. isFiltered: !!metricsQuery.query,
  222. },
  223. });
  224. onChange({
  225. groupBy: options.map(o => o.value),
  226. focusedSeries: undefined,
  227. });
  228. }}
  229. />
  230. <CompactSelect
  231. triggerProps={{prefix: t('Display'), size: 'sm'}}
  232. value={displayType ?? defaultMetricDisplayType}
  233. options={[
  234. {
  235. value: MetricDisplayType.LINE,
  236. label: t('Line'),
  237. },
  238. {
  239. value: MetricDisplayType.AREA,
  240. label: t('Area'),
  241. },
  242. {
  243. value: MetricDisplayType.BAR,
  244. label: t('Bar'),
  245. },
  246. ]}
  247. onChange={({value}) => {
  248. Sentry.metrics.increment('ddm.widget.display', 1, {
  249. tags: {
  250. display: value,
  251. type: readableType,
  252. operation: metricsQuery.op,
  253. isGrouped: !!metricsQuery.groupBy?.length,
  254. isFiltered: !!metricsQuery.query,
  255. },
  256. });
  257. onChange({displayType: value});
  258. }}
  259. />
  260. </WrapPageFilterBar>
  261. </QueryBuilderRow>
  262. {/* Stop propagation so widget does not get selected immediately */}
  263. <QueryBuilderRow onClick={stopPropagation}>
  264. <MetricSearchBar
  265. // TODO(aknaus): clean up projectId type in ddm
  266. projectIds={projects.map(id => id.toString())}
  267. mri={metricsQuery.mri}
  268. disabled={!metricsQuery.mri}
  269. onChange={query => {
  270. Sentry.metrics.increment('ddm.widget.filter', 1, {
  271. tags: {
  272. display: displayType ?? defaultMetricDisplayType,
  273. type: readableType,
  274. operation: metricsQuery.op,
  275. isGrouped: !!metricsQuery.groupBy?.length,
  276. isFiltered: !!query,
  277. },
  278. });
  279. onChange({query});
  280. }}
  281. query={metricsQuery.query}
  282. />
  283. </QueryBuilderRow>
  284. </QueryBuilderWrapper>
  285. );
  286. });
  287. interface MetricSearchBarProps extends Partial<SmartSearchBarProps> {
  288. onChange: (value: string) => void;
  289. projectIds: string[];
  290. disabled?: boolean;
  291. mri?: MRI;
  292. query?: string;
  293. }
  294. const EMPTY_ARRAY = [];
  295. const EMPTY_SET = new Set<never>();
  296. const DISSALLOWED_LOGICAL_OPERATORS = new Set([BooleanOperator.OR]);
  297. export function MetricSearchBar({
  298. mri,
  299. disabled,
  300. onChange,
  301. query,
  302. projectIds,
  303. ...props
  304. }: MetricSearchBarProps) {
  305. const org = useOrganization();
  306. const api = useApi();
  307. const {selection} = usePageFilters();
  308. const projectIdNumbers = useMemo(
  309. () => projectIds.map(id => parseInt(id, 10)),
  310. [projectIds]
  311. );
  312. const {data: tags = EMPTY_ARRAY, isLoading} = useMetricsTags(mri, projectIdNumbers);
  313. const supportedTags: TagCollection = useMemo(
  314. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  315. [tags]
  316. );
  317. const fetchTagValues = useMemo(() => {
  318. const fn = memoize((tagKey: string) => {
  319. // clear response from cache after 10 seconds
  320. setTimeout(() => {
  321. fn.cache.delete(tagKey);
  322. }, 10000);
  323. return api.requestPromise(`/organizations/${org.slug}/metrics/tags/${tagKey}/`, {
  324. query: {
  325. metric: mri,
  326. useCase: getUseCaseFromMRI(mri),
  327. project: selection.projects,
  328. },
  329. });
  330. });
  331. return fn;
  332. }, [api, mri, org.slug, selection.projects]);
  333. const getTagValues = useCallback(
  334. async (tag: any, search: string) => {
  335. const tagsValues = await fetchTagValues(tag.key);
  336. return tagsValues
  337. .filter(
  338. tv =>
  339. tv.value !== '' &&
  340. tv.value.toLocaleLowerCase().includes(search.toLocaleLowerCase())
  341. )
  342. .map(tv => tv.value);
  343. },
  344. [fetchTagValues]
  345. );
  346. const handleChange = useCallback(
  347. (value: string, {validSearch} = {validSearch: true}) => {
  348. if (validSearch) {
  349. onChange(value);
  350. }
  351. },
  352. [onChange]
  353. );
  354. return (
  355. <WideSearchBar
  356. disabled={disabled}
  357. maxMenuHeight={220}
  358. organization={org}
  359. onGetTagValues={getTagValues}
  360. supportedTags={supportedTags}
  361. // don't highlight tags while loading as we don't know yet if they are supported
  362. highlightUnsupportedTags={!isLoading}
  363. disallowedLogicalOperators={DISSALLOWED_LOGICAL_OPERATORS}
  364. disallowFreeText
  365. onClose={handleChange}
  366. onSearch={handleChange}
  367. placeholder={t('Filter by tags')}
  368. query={query}
  369. savedSearchType={SavedSearchType.METRIC}
  370. durationKeys={EMPTY_SET}
  371. percentageKeys={EMPTY_SET}
  372. numericKeys={EMPTY_SET}
  373. dateKeys={EMPTY_SET}
  374. booleanKeys={EMPTY_SET}
  375. sizeKeys={EMPTY_SET}
  376. textOperatorKeys={EMPTY_SET}
  377. {...props}
  378. />
  379. );
  380. }
  381. function getWidgetDisplayType(
  382. mri: MetricsQuery['mri'],
  383. op: MetricsQuery['op']
  384. ): MetricDisplayType {
  385. if (mri?.startsWith('c') || op === 'count') {
  386. return MetricDisplayType.BAR;
  387. }
  388. return MetricDisplayType.LINE;
  389. }
  390. const QueryBuilderWrapper = styled('div')`
  391. display: flex;
  392. flex-grow: 1;
  393. flex-direction: column;
  394. `;
  395. const QueryBuilderRow = styled('div')`
  396. padding: ${space(1)};
  397. padding-bottom: 0;
  398. `;
  399. const WideSearchBar = styled(SmartSearchBar)`
  400. width: 100%;
  401. opacity: ${p => (p.disabled ? '0.6' : '1')};
  402. `;
  403. const WrapPageFilterBar = styled(PageFilterBar)`
  404. max-width: max-content;
  405. height: auto;
  406. flex-wrap: wrap;
  407. `;
  408. const WidgetTitle = styled(HeaderTitle)`
  409. padding-left: ${space(2)};
  410. padding-top: ${space(1.5)};
  411. padding-right: ${space(1)};
  412. `;