Browse Source

feat(similarity-embedding): Make similarity embeddings API call (#64482)

Call similarity embeddings API and show Would Group column

Co-authored-by: Scott Cooper <scttcper@gmail.com>
Jodi Jang 1 year ago
parent
commit
96a147c3c9

+ 13 - 5
static/app/stores/groupingStore.tsx

@@ -47,7 +47,7 @@ type State = {
   unmergeState: Map<any, any>;
 };
 
-type ScoreMap = Record<string, number | null>;
+type ScoreMap = Record<string, number | null | string>;
 
 type ApiFingerprint = {
   id: string;
@@ -88,18 +88,20 @@ export type SimilarItem = {
   aggregate?: {
     exception: number;
     message: number;
+    shouldBeGrouped?: string;
   };
   score?: Record<string, number | null>;
   scoresByInterface?: {
     exception: Array<[string, number | null]>;
     message: Array<[string, any | null]>;
+    shouldBeGrouped?: Array<[string, string | null]>;
   };
 };
 
 type ResponseProcessors = {
   merged: (item: ApiFingerprint[]) => Fingerprint[];
   similar: (data: [Group, ScoreMap]) => {
-    aggregate: Record<string, number>;
+    aggregate: Record<string, number | string>;
     isBelowThreshold: boolean;
     issue: Group;
     score: ScoreMap;
@@ -328,8 +330,15 @@ const storeConfig: GroupingStoreDefinition = {
         return newItems;
       },
       similar: ([issue, scoreMap]) => {
+        // Check which similarity endpoint is being used
+        const hasSimilarityEmbeddingsFeature = requests[0]?.endpoint.includes(
+          'similar-issues-embeddings'
+        );
+
         // Hide items with a low scores
-        const isBelowThreshold = checkBelowThreshold(scoreMap);
+        const isBelowThreshold = hasSimilarityEmbeddingsFeature
+          ? false
+          : checkBelowThreshold(scoreMap);
 
         // List of scores indexed by interface (i.e., exception and message)
         // Note: for v2, the interface is always "similarity". When v2 is
@@ -357,8 +366,7 @@ const storeConfig: GroupingStoreDefinition = {
             const scores = allScores.filter(([, score]) => score !== null);
 
             const avg = scores.reduce((sum, [, score]) => sum + score, 0) / scores.length;
-
-            acc[interfaceName] = avg;
+            acc[interfaceName] = hasSimilarityEmbeddingsFeature ? scores[0][1] : avg;
             return acc;
           }, {});
 

+ 124 - 6
static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx

@@ -1,5 +1,4 @@
 import {GroupsFixture} from 'sentry-fixture/groups';
-import {OrganizationFixture} from 'sentry-fixture/organization';
 import {ProjectFixture} from 'sentry-fixture/project';
 import {RouterContextFixture} from 'sentry-fixture/routerContextFixture';
 import {RouterFixture} from 'sentry-fixture/routerFixture';
@@ -147,16 +146,70 @@ describe('Issues Similar View', function () {
     );
     renderGlobalModal();
 
-    await userEvent.click(await screen.findByTestId('similar-item-row'));
+    await selectNthSimilarItem(0);
     expect(screen.getByText('Merge (1)')).toBeInTheDocument();
 
     // Correctly show "Merge (0)" when the item is un-clicked
-    await userEvent.click(await screen.findByTestId('similar-item-row'));
+    await selectNthSimilarItem(0);
     expect(screen.getByText('Merge (0)')).toBeInTheDocument();
   });
+});
+
+describe('Issues Similar Embeddings View', function () {
+  let mock;
+
+  const project = ProjectFixture({
+    features: ['similarity-view', 'similarity-embeddings'],
+  });
+
+  const routerContext = RouterContextFixture([
+    {
+      router: {
+        ...RouterFixture(),
+        params: {orgId: 'org-slug', projectId: 'project-slug', groupId: 'group-id'},
+      },
+    },
+  ]);
+
+  const similarEmbeddingsScores = [
+    {exception: 0.9987, message: 0.3748, shouldBeGrouped: 'Yes'},
+    {exception: 0.9985, message: 0.3738, shouldBeGrouped: 'Yes'},
+    {exception: 0.7384, message: 0.3743, shouldBeGrouped: 'No'},
+    {exception: 0.3849, message: 0.4738, shouldBeGrouped: 'No'},
+  ];
+
+  const mockData = {
+    simlarEmbeddings: GroupsFixture().map((issue, i) => [
+      issue,
+      similarEmbeddingsScores[i],
+    ]),
+  };
+
+  const router = RouterFixture();
 
-  it('renders all filtered issues with issues-similarity-embeddings flag', async function () {
-    const features = ['issues-similarity-embeddings'];
+  beforeEach(function () {
+    mock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/issues/group-id/similar-issues-embeddings/?k=5&threshold=0.99',
+      body: mockData.simlarEmbeddings,
+    });
+  });
+
+  afterEach(() => {
+    MockApiClient.clearMockResponses();
+    jest.clearAllMocks();
+  });
+
+  const selectNthSimilarItem = async (index: number) => {
+    const items = await screen.findAllByTestId('similar-item-row');
+
+    const item = items.at(index);
+
+    expect(item).toBeDefined();
+
+    await userEvent.click(item!);
+  };
+
+  it('renders with mocked data', async function () {
     render(
       <GroupSimilarIssues
         project={project}
@@ -167,7 +220,7 @@ describe('Issues Similar View', function () {
         routes={router.routes}
         route={{}}
       />,
-      {context: routerContext, organization: OrganizationFixture({features})}
+      {context: routerContext}
     );
 
     expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
@@ -175,5 +228,70 @@ describe('Issues Similar View', function () {
     await waitFor(() => expect(mock).toHaveBeenCalled());
 
     expect(screen.queryByText('Show 3 issues below threshold')).not.toBeInTheDocument();
+    expect(screen.queryByText('Would Group')).toBeInTheDocument();
+  });
+
+  it('can merge and redirect to new parent', async function () {
+    const merge = MockApiClient.addMockResponse({
+      method: 'PUT',
+      url: '/projects/org-slug/project-slug/issues/',
+      body: {
+        merge: {children: ['123'], parent: '321'},
+      },
+    });
+
+    render(
+      <GroupSimilarIssues
+        project={project}
+        params={{orgId: 'org-slug', groupId: 'group-id'}}
+        location={router.location}
+        router={router}
+        routeParams={router.params}
+        routes={router.routes}
+        route={{}}
+      />,
+      {context: routerContext}
+    );
+    renderGlobalModal();
+
+    await selectNthSimilarItem(0);
+    await userEvent.click(await screen.findByRole('button', {name: 'Merge (1)'}));
+    await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+
+    await waitFor(() => {
+      expect(merge).toHaveBeenCalledWith(
+        '/projects/org-slug/project-slug/issues/',
+        expect.objectContaining({
+          data: {merge: 1},
+        })
+      );
+    });
+
+    expect(MockNavigate).toHaveBeenCalledWith(
+      '/organizations/org-slug/issues/321/similar/'
+    );
+  });
+
+  it('correctly shows merge count', async function () {
+    render(
+      <GroupSimilarIssues
+        project={project}
+        params={{orgId: 'org-slug', groupId: 'group-id'}}
+        location={router.location}
+        router={router}
+        routeParams={router.params}
+        routes={router.routes}
+        route={{}}
+      />,
+      {context: routerContext}
+    );
+    renderGlobalModal();
+
+    await selectNthSimilarItem(0);
+    expect(screen.getByText('Merge (1)')).toBeInTheDocument();
+
+    // Correctly show "Merge (0)" when the item is un-clicked
+    await selectNthSimilarItem(0);
+    expect(screen.getByText('Merge (0)')).toBeInTheDocument();
   });
 });

+ 22 - 9
static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx

@@ -15,7 +15,6 @@ import GroupingStore from 'sentry/stores/groupingStore';
 import {space} from 'sentry/styles/space';
 import type {Project} from 'sentry/types';
 import {useNavigate} from 'sentry/utils/useNavigate';
-import useOrganization from 'sentry/utils/useOrganization';
 import usePrevious from 'sentry/utils/usePrevious';
 
 import List from './list';
@@ -49,9 +48,8 @@ function SimilarStackTrace({params, location, project}: Props) {
   const navigate = useNavigate();
   const prevLocationSearch = usePrevious(location.search);
   const hasSimilarityFeature = project.features.includes('similarity-view');
-  const organization = useOrganization();
-  const hasSimilarityEmbeddingsFeature = organization?.features?.includes(
-    'issues-similarity-embeddings'
+  const hasSimilarityEmbeddingsFeature = project.features.includes(
+    'similarity-embeddings'
   );
 
   const fetchData = useCallback(() => {
@@ -59,7 +57,17 @@ function SimilarStackTrace({params, location, project}: Props) {
 
     const reqs: Parameters<typeof GroupingStore.onFetch>[0] = [];
 
-    if (hasSimilarityFeature) {
+    if (hasSimilarityEmbeddingsFeature) {
+      reqs.push({
+        endpoint: `/organizations/${orgId}/issues/${groupId}/similar-issues-embeddings/?${qs.stringify(
+          {
+            k: 5,
+            threshold: 0.99,
+          }
+        )}`,
+        dataKey: 'similar',
+      });
+    } else if (hasSimilarityFeature) {
       reqs.push({
         endpoint: `/organizations/${orgId}/issues/${groupId}/similar/?${qs.stringify({
           ...location.query,
@@ -70,7 +78,13 @@ function SimilarStackTrace({params, location, project}: Props) {
     }
 
     GroupingStore.onFetch(reqs);
-  }, [location.query, groupId, orgId, hasSimilarityFeature]);
+  }, [
+    location.query,
+    groupId,
+    orgId,
+    hasSimilarityFeature,
+    hasSimilarityEmbeddingsFeature,
+  ]);
 
   const onGroupingChange = useCallback(
     ({
@@ -137,7 +151,8 @@ function SimilarStackTrace({params, location, project}: Props) {
   }, [params, location.query, items]);
 
   const hasSimilarItems =
-    hasSimilarityFeature && (items.similar.length > 0 || items.filtered.length > 0);
+    (hasSimilarityFeature || hasSimilarityEmbeddingsFeature) &&
+    (items.similar.length > 0 || items.filtered.length > 0);
 
   return (
     <Layout.Body>
@@ -171,7 +186,6 @@ function SimilarStackTrace({params, location, project}: Props) {
             onMerge={handleMerge}
             orgId={orgId}
             project={project}
-            organization={organization}
             groupId={groupId}
             pageLinks={items.pageLinks}
           />
@@ -183,7 +197,6 @@ function SimilarStackTrace({params, location, project}: Props) {
             onMerge={handleMerge}
             orgId={orgId}
             project={project}
-            organization={organization}
             groupId={groupId}
             pageLinks={items.pageLinks}
           />

+ 24 - 11
static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx

@@ -22,11 +22,11 @@ type Props = {
   groupId: Group['id'];
   issue: Group;
   orgId: Organization['id'];
-  organization: Organization;
   project: Project;
   aggregate?: {
     exception: number;
     message: number;
+    shouldBeGrouped?: string;
   };
   score?: Record<string, any>;
   scoresByInterface?: {
@@ -95,12 +95,14 @@ class Item extends Component<Props, State> {
   };
 
   render() {
-    const {aggregate, scoresByInterface, issue, organization} = this.props;
+    const {aggregate, scoresByInterface, issue, project} = this.props;
     const {visible, busy} = this.state;
-    const similarInterfaces = ['exception', 'message'];
-    const hasSimilarityEmbeddingsFeature = organization?.features?.includes(
-      'issues-similarity-embeddings'
+    const hasSimilarityEmbeddingsFeature = project.features.includes(
+      'similarity-embeddings'
     );
+    const similarInterfaces = hasSimilarityEmbeddingsFeature
+      ? ['exception', 'message', 'shouldBeGrouped']
+      : ['exception', 'message'];
 
     if (!visible) {
       return null;
@@ -141,10 +143,17 @@ class Item extends Component<Props, State> {
           {similarInterfaces.map(interfaceName => {
             const avgScore = aggregate?.[interfaceName];
             const scoreList = scoresByInterface?.[interfaceName] || [];
-            // Check for valid number (and not NaN)
-            const scoreValue =
-              typeof avgScore === 'number' && !Number.isNaN(avgScore) ? avgScore : 0;
 
+            // If hasSimilarityEmbeddingsFeature is on, avgScore can be a string
+            let scoreValue = avgScore;
+            if (
+              (typeof avgScore !== 'string' && hasSimilarityEmbeddingsFeature) ||
+              !hasSimilarityEmbeddingsFeature
+            ) {
+              // Check for valid number (and not NaN)
+              scoreValue =
+                typeof avgScore === 'number' && !Number.isNaN(avgScore) ? avgScore : 0;
+            }
             return (
               <Column key={interfaceName}>
                 {!hasSimilarityEmbeddingsFeature && (
@@ -154,7 +163,11 @@ class Item extends Component<Props, State> {
                     <ScoreBar vertical score={Math.round(scoreValue * 5)} />
                   </Hovercard>
                 )}
-                {hasSimilarityEmbeddingsFeature && <div>{scoreValue.toFixed(4)}</div>}
+                {hasSimilarityEmbeddingsFeature && (
+                  <div>
+                    {typeof scoreValue === 'number' ? scoreValue.toFixed(4) : scoreValue}
+                  </div>
+                )}
               </Column>
             );
           })}
@@ -181,8 +194,8 @@ const Columns = styled('div')`
   display: flex;
   align-items: center;
   flex-shrink: 0;
-  min-width: 300px;
-  width: 300px;
+  min-width: 350px;
+  width: 350px;
 `;
 
 const columnStyle = css`

+ 3 - 6
static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/list.tsx

@@ -24,7 +24,6 @@ type Props = {
   items: Array<SimilarItem>;
   onMerge: () => void;
   orgId: Organization['id'];
-  organization: Organization;
   pageLinks: string | null;
   project: Project;
 } & DefaultProps;
@@ -45,7 +44,6 @@ function List({
   orgId,
   groupId,
   project,
-  organization,
   items,
   filteredItems = [],
   pageLinks,
@@ -56,8 +54,8 @@ function List({
   const hasHiddenItems = !!filteredItems.length;
   const hasResults = items.length > 0 || hasHiddenItems;
   const itemsWithFiltered = items.concat(showAllItems ? filteredItems : []);
-  const hasSimilarityEmbeddingsFeature = organization?.features?.includes(
-    'issues-similarity-embeddings'
+  const hasSimilarityEmbeddingsFeature = project.features.includes(
+    'similarity-embeddings'
   );
 
   if (!hasResults) {
@@ -72,7 +70,7 @@ function List({
         </Header>
       )}
       <Panel>
-        <Toolbar onMerge={onMerge} />
+        <Toolbar onMerge={onMerge} project={project} />
 
         <PanelBody>
           {itemsWithFiltered.map(item => (
@@ -81,7 +79,6 @@ function List({
               orgId={orgId}
               groupId={groupId}
               project={project}
-              organization={organization}
               {...item}
             />
           ))}

+ 11 - 3
static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/toolbar.tsx

@@ -8,9 +8,11 @@ import ToolbarHeader from 'sentry/components/toolbarHeader';
 import {t} from 'sentry/locale';
 import GroupingStore from 'sentry/stores/groupingStore';
 import {space} from 'sentry/styles/space';
+import type {Project} from 'sentry/types';
 
 type Props = {
   onMerge: () => void;
+  project?: Project;
 };
 
 const initialState = {
@@ -40,8 +42,11 @@ class SimilarToolbar extends Component<Props, State> {
   listener = GroupingStore.listen(this.onGroupChange, undefined);
 
   render() {
-    const {onMerge} = this.props;
+    const {onMerge, project} = this.props;
     const {mergeCount} = this.state;
+    const hasSimilarityEmbeddingsFeature = project?.features.includes(
+      'similarity-embeddings'
+    );
 
     return (
       <PanelHeader hasButtons>
@@ -59,6 +64,9 @@ class SimilarToolbar extends Component<Props, State> {
           <StyledToolbarHeader>{t('Events')}</StyledToolbarHeader>
           <StyledToolbarHeader>{t('Exception')}</StyledToolbarHeader>
           <StyledToolbarHeader>{t('Message')}</StyledToolbarHeader>
+          {hasSimilarityEmbeddingsFeature && (
+            <StyledToolbarHeader>{t('Would Group')}</StyledToolbarHeader>
+          )}
         </Columns>
       </PanelHeader>
     );
@@ -70,8 +78,8 @@ const Columns = styled('div')`
   display: flex;
   align-items: center;
   flex-shrink: 0;
-  min-width: 300px;
-  width: 300px;
+  min-width: 350px;
+  width: 350px;
 `;
 
 const StyledToolbarHeader = styled(ToolbarHeader)`