From 40a0bd8664b8fc4f1c62554b6bbfcef2116b6934 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Mon, 13 Jul 2026 11:52:29 -0700 Subject: [PATCH] fix(integrations): report matching result count in directory search analytics Co-authored-by: Claude --- .../integrationListDirectory.spec.tsx | 36 +++++++ .../integrationListDirectory.tsx | 96 +++++++++---------- 2 files changed, 83 insertions(+), 49 deletions(-) diff --git a/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx b/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx index e3ac16b3c371..18f15c13d6f4 100644 --- a/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx @@ -10,8 +10,11 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {trackAnalytics} from 'sentry/utils/analytics'; import IntegrationListDirectory from 'sentry/views/settings/organizationIntegrations/integrationListDirectory'; +jest.mock('sentry/utils/analytics'); + const mockResponse = (mocks: Array<[string, unknown]>) => { mocks.forEach(([url, body]) => MockApiClient.addMockResponse({url, body})); }; @@ -70,6 +73,39 @@ describe('IntegrationListDirectory', () => { expect(screen.getByText('Bitbucket')).toBeInTheDocument(); expect(screen.getByText('La Croix Monitor')).toBeInTheDocument(); }); + + it('tracks searches with the number of results shown', async () => { + const {router} = render(, {organization}); + expect(await screen.findByRole('textbox', {name: 'Filter'})).toBeInTheDocument(); + + await userEvent.type(screen.getByRole('textbox', {name: 'Filter'}), 'it'); + await userEvent.keyboard('{enter}'); + + expect(trackAnalytics).toHaveBeenLastCalledWith( + 'integrations.directory_item_searched', + expect.objectContaining({search_term: 'it', num_results: 2}) + ); + + router.navigate('/mock-pathname/?category=unpublished'); + await userEvent.type(screen.getByRole('textbox', {name: 'Filter'}), 'it'); + await userEvent.keyboard('{enter}'); + + expect(trackAnalytics).toHaveBeenLastCalledWith( + 'integrations.directory_item_searched', + expect.objectContaining({search_term: 'it', num_results: 1}) + ); + + // The legacy webhook row renders as a result, so it counts as one + router.navigate('/mock-pathname/'); + await userEvent.type(screen.getByRole('textbox', {name: 'Filter'}), 'legacy'); + await userEvent.keyboard('{enter}'); + + expect(screen.getByText('Webhooks (Legacy)')).toBeInTheDocument(); + expect(trackAnalytics).toHaveBeenLastCalledWith( + 'integrations.directory_item_searched', + expect.objectContaining({search_term: 'legacy', num_results: 1}) + ); + }); }); describe('Legacy webhook entry', () => { diff --git a/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx b/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx index fa683598fba2..fec3ef088699 100644 --- a/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx @@ -2,7 +2,6 @@ import {Fragment, useCallback, useEffect, useMemo} from 'react'; import {useSearchParams} from 'react-router-dom'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; import startCase from 'lodash/startCase'; import {DocIntegrationAvatar, SentryAppAvatar} from '@sentry/scraps/avatar'; @@ -31,7 +30,6 @@ import type { SentryApp, SentryAppInstallation, } from 'sentry/types/integrations'; -import type {Organization} from 'sentry/types/organization'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {uniq} from 'sentry/utils/array/uniq'; import { @@ -62,20 +60,35 @@ const FirstPartyIntegrationAlert = OverrideOrDefault({ defaultComponent: () => null, }); +const WEBHOOK_ROW_CATEGORIES = ['notification action']; + /** - * Debounce the tracking of integration search events to avoid spamming the - * analytics endpoint. + * Everything the directory renders for a search: the matching integrations + * plus the synthetic legacy webhook row. Search analytics report numResults + * so the count must stay in sync with what is shown. */ -const TEXT_SEARCH_ANALYTICS_DEBOUNCE_IN_MS = 1000; -const debouncedTrackIntegrationSearch = debounce( - (props: {num_results: number; organization: Organization; search_term: string}) => { - trackIntegrationAnalytics('integrations.directory_item_searched', { - view: 'integrations_directory', - ...props, - }); - }, - TEXT_SEARCH_ANALYTICS_DEBOUNCE_IN_MS -); +function getDisplayedResults( + list: AppOrProviderOrPlugin[], + search: string, + category: string, + hasLegacyWebhooks: boolean +) { + const term = search.toLowerCase(); + const matches = list.filter( + integration => + integration.name.toLowerCase().includes(term) && + (!category || getCategoriesForIntegration(integration).includes(category)) + ); + const showLegacyWebhookRow = + hasLegacyWebhooks && + t('Webhooks (Legacy)').toLowerCase().includes(term) && + (!category || WEBHOOK_ROW_CATEGORIES.includes(category)); + return { + matches, + showLegacyWebhookRow, + numResults: matches.length + (showLegacyWebhookRow ? 1 : 0), + }; +} function useIntegrationList() { const queryOptions = {staleTime: 0}; @@ -221,34 +234,19 @@ export default function IntegrationListDirectory() { const category = decodeScalar(location.query.category) ?? ''; const search = decodeScalar(location.query.search) ?? ''; - const webhookName = t('Webhooks (Legacy)'); - const webhookCategories = ['notification action']; - const showLegacyWebhookRow = - !!legacyWebhooks && - (!search || webhookName.toLowerCase().includes(search.toLowerCase())) && - (!category || webhookCategories.includes(category)); - - const displayList = useMemo(() => { - let listToDisplay = [...list]; + const legacyWebhookProjectCount = legacyWebhooks?.projects?.length ?? 0; - if (search) { - listToDisplay = list.filter(integration => - integration.name.toLowerCase().includes(search.toLowerCase()) - ); - } - - if (category) { - listToDisplay = listToDisplay.filter(integration => - getCategoriesForIntegration(integration).includes(category) - ); - } - - return sortIntegrations({ - list: listToDisplay, - sentryAppInstalls: appInstalls, - integrationInstalls: integrations, - }); - }, [list, appInstalls, integrations, category, search]); + const {displayList, showLegacyWebhookRow} = useMemo(() => { + const results = getDisplayedResults(list, search, category, !!legacyWebhooks); + return { + displayList: sortIntegrations({ + list: results.matches, + sentryAppInstalls: appInstalls, + integrationInstalls: integrations, + }), + showLegacyWebhookRow: results.showLegacyWebhookRow, + }; + }, [list, search, category, legacyWebhooks, appInstalls, integrations]); const getAppInstall = useCallback( (app: SentryApp) => appInstalls.find(i => i.app.slug === app.slug), @@ -283,14 +281,16 @@ export default function IntegrationListDirectory() { {replace: true} ); if (newSearch) { - debouncedTrackIntegrationSearch({ + trackIntegrationAnalytics('integrations.directory_item_searched', { + view: 'integrations_directory', search_term: newSearch, - num_results: list.length, + num_results: getDisplayedResults(list, newSearch, category, !!legacyWebhooks) + .numResults, organization, }); } }, - [location, navigate, organization, list.length] + [location, navigate, organization, list, category, legacyWebhooks] ); /** @@ -456,12 +456,10 @@ export default function IntegrationListDirectory() { type="firstParty" slug="legacy-webhooks" displayName={t('Webhooks (Legacy)')} - status={ - legacyWebhooks.projects?.length ? 'Installed' : 'Not Installed' - } + status={legacyWebhookProjectCount ? 'Installed' : 'Not Installed'} publishStatus="published" - configurations={legacyWebhooks.projects?.length ?? 0} - categories={webhookCategories} + configurations={legacyWebhookProjectCount} + categories={WEBHOOK_ROW_CATEGORIES} customIcon={} /> )}