index.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import {Fragment, useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as qs from 'query-string';
  4. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  5. import HookOrDefault from 'sentry/components/hookOrDefault';
  6. import LoadingError from 'sentry/components/loadingError';
  7. import LoadingIndicator from 'sentry/components/loadingIndicator';
  8. import Panel from 'sentry/components/panels/panel';
  9. import {t} from 'sentry/locale';
  10. import type {SimilarItem} from 'sentry/stores/groupingStore';
  11. import GroupingStore from 'sentry/stores/groupingStore';
  12. import {space} from 'sentry/styles/space';
  13. import type {Project} from 'sentry/types/project';
  14. import {useLocation} from 'sentry/utils/useLocation';
  15. import {useNavigate} from 'sentry/utils/useNavigate';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import {useParams} from 'sentry/utils/useParams';
  18. import usePrevious from 'sentry/utils/usePrevious';
  19. import List from './list';
  20. type Props = {
  21. project: Project;
  22. };
  23. type ItemState = {
  24. filtered: SimilarItem[];
  25. pageLinks: string | null;
  26. similar: SimilarItem[];
  27. };
  28. const DataConsentBanner = HookOrDefault({
  29. hookName: 'component:data-consent-banner',
  30. defaultComponent: null,
  31. });
  32. function SimilarStackTrace({project}: Props) {
  33. const location = useLocation();
  34. const organization = useOrganization();
  35. const params = useParams<{groupId: string; orgId: string}>();
  36. const [items, setItems] = useState<ItemState>({
  37. similar: [],
  38. filtered: [],
  39. pageLinks: null,
  40. });
  41. const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
  42. const navigate = useNavigate();
  43. const prevLocationSearch = usePrevious(location.search);
  44. const hasSimilarityFeature = project.features.includes('similarity-view');
  45. const hasSimilarityEmbeddingsFeature =
  46. project.features.includes('similarity-embeddings') ||
  47. location.query.similarityEmbeddings === '1';
  48. // Use reranking by default (assuming the `seer.similarity.similar_issues.use_reranking`
  49. // backend option is using its default value of `True`). This is just so we can turn it off
  50. // on demand to see if/how that changes the results.
  51. const useReranking = String(location.query.useReranking !== '0');
  52. const fetchData = useCallback(() => {
  53. setStatus('loading');
  54. const reqs: Parameters<typeof GroupingStore.onFetch>[0] = [];
  55. if (hasSimilarityEmbeddingsFeature) {
  56. reqs.push({
  57. endpoint: `/organizations/${organization.slug}/issues/${params.groupId}/similar-issues-embeddings/?${qs.stringify(
  58. {
  59. k: 10,
  60. threshold: 0.01,
  61. useReranking,
  62. }
  63. )}`,
  64. dataKey: 'similar',
  65. });
  66. } else if (hasSimilarityFeature) {
  67. reqs.push({
  68. endpoint: `/organizations/${organization.slug}/issues/${params.groupId}/similar/?${qs.stringify(
  69. {
  70. ...location.query,
  71. limit: 50,
  72. }
  73. )}`,
  74. dataKey: 'similar',
  75. });
  76. }
  77. GroupingStore.onFetch(reqs);
  78. }, [
  79. location.query,
  80. params.groupId,
  81. organization.slug,
  82. hasSimilarityFeature,
  83. hasSimilarityEmbeddingsFeature,
  84. useReranking,
  85. ]);
  86. const onGroupingChange = useCallback(
  87. ({
  88. mergedParent,
  89. similarItems: updatedSimilarItems,
  90. filteredSimilarItems: updatedFilteredSimilarItems,
  91. similarLinks: updatedSimilarLinks,
  92. loading,
  93. error,
  94. }) => {
  95. if (updatedSimilarItems) {
  96. setItems({
  97. similar: updatedSimilarItems,
  98. filtered: updatedFilteredSimilarItems,
  99. pageLinks: updatedSimilarLinks,
  100. });
  101. setStatus(error ? 'error' : loading ? 'loading' : 'ready');
  102. return;
  103. }
  104. if (mergedParent && mergedParent !== params.groupId) {
  105. // Merge success, since we can't specify target, we need to redirect to new parent
  106. navigate(`/organizations/${organization.slug}/issues/${mergedParent}/similar/`);
  107. }
  108. },
  109. [navigate, params.groupId, organization.slug]
  110. );
  111. useEffect(() => {
  112. fetchData();
  113. }, [fetchData]);
  114. useEffect(() => {
  115. if (prevLocationSearch !== location.search) {
  116. fetchData();
  117. }
  118. }, [fetchData, prevLocationSearch, location.search]);
  119. useEffect(() => {
  120. const unsubscribe = GroupingStore.listen(onGroupingChange, undefined);
  121. return () => {
  122. unsubscribe();
  123. };
  124. }, [onGroupingChange]);
  125. const handleMerge = useCallback(() => {
  126. if (!params) {
  127. return;
  128. }
  129. // You need at least 1 similarItem OR filteredSimilarItems to be able to merge,
  130. // so `firstIssue` should always exist from one of those lists.
  131. //
  132. // Similar issues API currently does not return issues across projects,
  133. // so we can assume that the first issues project slug is the project in
  134. // scope
  135. const [firstIssue] = items.similar.length ? items.similar : items.filtered;
  136. GroupingStore.onMerge({
  137. params,
  138. query: location.query.query as string,
  139. projectId: firstIssue.issue.project.slug,
  140. });
  141. }, [params, location.query, items]);
  142. const hasSimilarItems =
  143. (hasSimilarityFeature || hasSimilarityEmbeddingsFeature) &&
  144. (items.similar.length > 0 || items.filtered.length > 0);
  145. return (
  146. <Fragment>
  147. <HeaderWrapper>
  148. <Title>{t('Issues with a similar stack trace')}</Title>
  149. <small>
  150. {t(
  151. 'This is an experimental feature. Data may not be immediately available while we process merges.'
  152. )}
  153. </small>
  154. </HeaderWrapper>
  155. {status === 'loading' && <LoadingIndicator />}
  156. {status === 'error' && (
  157. <LoadingError
  158. message={t('Unable to load similar issues, please try again later')}
  159. onRetry={fetchData}
  160. />
  161. )}
  162. {status === 'ready' && !hasSimilarItems && !hasSimilarityEmbeddingsFeature && (
  163. <Panel>
  164. <EmptyStateWarning>
  165. <p>{t("There don't seem to be any similar issues.")}</p>
  166. </EmptyStateWarning>
  167. </Panel>
  168. )}
  169. {status === 'ready' && !hasSimilarItems && hasSimilarityEmbeddingsFeature && (
  170. <Panel>
  171. <EmptyStateWarning>
  172. <p>
  173. {t(
  174. "There don't seem to be any similar issues. This can occur when the issue has no stacktrace or in-app frames."
  175. )}
  176. </p>
  177. </EmptyStateWarning>
  178. </Panel>
  179. )}
  180. {status === 'ready' && hasSimilarItems && !hasSimilarityEmbeddingsFeature && (
  181. <List
  182. items={items.similar}
  183. filteredItems={items.filtered}
  184. onMerge={handleMerge}
  185. orgId={organization.slug}
  186. project={project}
  187. groupId={params.groupId}
  188. pageLinks={items.pageLinks}
  189. location={location}
  190. />
  191. )}
  192. {status === 'ready' && hasSimilarItems && hasSimilarityEmbeddingsFeature && (
  193. <List
  194. items={items.similar.concat(items.filtered)}
  195. filteredItems={[]}
  196. onMerge={handleMerge}
  197. orgId={organization.slug}
  198. project={project}
  199. groupId={params.groupId}
  200. pageLinks={items.pageLinks}
  201. location={location}
  202. />
  203. )}
  204. <DataConsentBanner source="grouping" />
  205. </Fragment>
  206. );
  207. }
  208. export default SimilarStackTrace;
  209. const Title = styled('h4')`
  210. font-size: ${p => p.theme.fontSizeLarge};
  211. margin-bottom: ${space(0.75)};
  212. `;
  213. const HeaderWrapper = styled('div')`
  214. margin-bottom: ${space(2)};
  215. small {
  216. color: ${p => p.theme.subText};
  217. }
  218. `;