Add search event analytics (#9949)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
DS Boyce
2026-03-03 12:49:38 -08:00
committed by GitHub
parent 37a82761f5
commit 7dc3b4fa8e
10 changed files with 291 additions and 120 deletions
+136 -11
View File
@@ -5,6 +5,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
import {useCallOnce} from '#/lib/once'
import {cleanError} from '#/lib/strings/errors'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search'
@@ -25,6 +26,7 @@ import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {SearchError} from '#/components/SearchError'
import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
let SearchResults = ({
@@ -161,7 +163,12 @@ function EmptyState({
)
}
function NoResultsText({query}: {query: string}) {
function NoResultsText({
query,
}: {
sort?: 'top' | 'latest' | 'people' | 'feeds'
query: string
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -185,7 +192,7 @@ function NoResultsText({query}: {query: string}) {
})}
to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}>
read about how to use search filters
read about how to use search filters.
</InlineLinkText>
.
</Trans>
@@ -214,6 +221,7 @@ let SearchScreenPostResults = ({
sort?: 'top' | 'latest'
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false)
@@ -277,6 +285,19 @@ let SearchScreenPostResults = ({
const closeAllActiveElements = useCloseAllActiveElements()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const fireTracking = useCallOnce(() => {
if (sort) {
// ts only
ax.metric('search:results:loaded', {
tab: sort,
initialCount: items.length,
})
}
})
if (isFetched && sort) {
fireTracking()
}
const showSignIn = () => {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: 'none'})
@@ -292,7 +313,7 @@ let SearchScreenPostResults = ({
<SearchError title={l`Search is currently unavailable when logged out`}>
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
<Trans>
<InlineLinkText label={l`Sign in`} to={'#'} onPress={showSignIn}>
<InlineLinkText label={l`Sign in`} to="#" onPress={showSignIn}>
Sign in
</InlineLinkText>
<Text style={t.atoms.text_contrast_medium}> or </Text>
@@ -315,7 +336,7 @@ let SearchScreenPostResults = ({
return error ? (
<EmptyState
messageText={l`We're sorry, but your search could not be completed. Please try again in a few minutes.`}
messageText={l`Were sorry, but your search could not be completed. Please try again in a few minutes.`}
error={cleanError(error)}
/>
) : (
@@ -325,9 +346,17 @@ let SearchScreenPostResults = ({
{posts.length ? (
<List
data={items}
renderItem={({item}: {item: SearchResultSlice}) => {
renderItem={({
item,
index,
}: {
item: SearchResultSlice
index: number
}) => {
if (item.type === 'post') {
return <Post post={item.post} />
return (
<SearchPost from={sort} position={index} post={item.post} />
)
} else {
return null
}
@@ -363,6 +392,29 @@ let SearchScreenPostResults = ({
}
SearchScreenPostResults = memo(SearchScreenPostResults)
function SearchPost({
from,
position,
post,
}: {
from: Metrics['search:result:press']['tab']
position: Metrics['search:result:press']['position']
post: AppBskyFeedDefs.PostView
}) {
const ax = useAnalytics()
const onBeforePress = useCallback(() => {
ax.metric('search:result:press', {
tab: from,
resultType: 'post',
position,
uri: post.uri,
})
}, [ax, from, position, post])
return <Post post={post} onBeforePress={onBeforePress} />
}
let SearchScreenUserResults = ({
query,
active,
@@ -370,6 +422,7 @@ let SearchScreenUserResults = ({
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const {hasSession} = useSession()
const [isPTR, setIsPTR] = useState(false)
@@ -403,6 +456,16 @@ let SearchScreenUserResults = ({
return results?.pages.flatMap(page => page.actors) || []
}, [results])
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'people',
initialCount: profiles.length,
})
})
if (isFetched) {
fireTracking()
}
if (error) {
return (
<EmptyState
@@ -417,9 +480,13 @@ let SearchScreenUserResults = ({
{profiles.length ? (
<List
data={profiles}
renderItem={({item}: {item: bsky.profile.AnyProfileView}) => (
<ProfileCardWithFollowBtn profile={item} />
)}
renderItem={({
item,
index,
}: {
item: bsky.profile.AnyProfileView
index: number
}) => <SearchScreenProfileButton position={index} profile={item} />}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
refreshing={isPTR}
onRefresh={() => void onPullToRefresh()}
@@ -442,6 +509,26 @@ let SearchScreenUserResults = ({
}
SearchScreenUserResults = memo(SearchScreenUserResults)
function SearchScreenProfileButton({
position,
profile,
}: {
position: number
profile: bsky.profile.AnyProfileView
}) {
const ax = useAnalytics()
const handlePress = () => {
ax.metric('search:result:press', {
tab: 'people',
resultType: 'profile',
position,
uri: profile.did,
})
}
return <ProfileCardWithFollowBtn profile={profile} onPress={handlePress} />
}
let SearchScreenFeedsResults = ({
query,
active,
@@ -449,6 +536,7 @@ let SearchScreenFeedsResults = ({
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {data: results, isFetched} = usePopularFeedsSearch({
@@ -456,12 +544,28 @@ let SearchScreenFeedsResults = ({
enabled: active,
})
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'feeds',
initialCount: results?.length ?? 0,
})
})
if (isFetched) {
fireTracking()
}
return isFetched && results ? (
<>
{results.length ? (
<List
data={results}
renderItem={({item}: {item: AppBskyFeedDefs.GeneratorView}) => (
renderItem={({
item,
index,
}: {
item: AppBskyFeedDefs.GeneratorView
index: number
}) => (
<View
style={[
a.border_t,
@@ -469,7 +573,7 @@ let SearchScreenFeedsResults = ({
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default view={item} />
<SearchFeedCard position={index} view={item} />
</View>
)}
keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri}
@@ -485,3 +589,24 @@ let SearchScreenFeedsResults = ({
)
}
SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
function SearchFeedCard({
position,
view,
}: {
position: number
view: AppBskyFeedDefs.GeneratorView
}) {
const ax = useAnalytics()
const handleOnPress = () => {
ax.metric('search:result:press', {
tab: 'feeds',
resultType: 'feed',
position,
uri: view.uri,
})
}
return <FeedCard.Default view={view} onPress={handleOnPress} />
}
+13 -5
View File
@@ -38,6 +38,7 @@ import {Button, ButtonText} from '#/components/Button'
import {SearchInput} from '#/components/forms/SearchInput'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {account, useStorage} from '#/storage'
import type * as bsky from '#/types/bsky'
@@ -79,6 +80,7 @@ export function SearchScreenShell({
inputPlaceholder?: string
isExplore?: boolean
}) {
const ax = useAnalytics()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
@@ -225,9 +227,15 @@ export function SearchScreenShell({
}
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
const onSubmit = useCallback(() => {
navigateToItem(searchText)
}, [navigateToItem, searchText])
const onSubmit = useCallback(
(source: 'typed' | 'autocomplete') => () => {
ax.metric('search:query', {
source,
})
navigateToItem(searchText)
},
[ax, navigateToItem, searchText],
)
const onAutocompleteResultPress = useCallback(() => {
if (IS_WEB) {
@@ -367,7 +375,7 @@ export function SearchScreenShell({
onFocus={onSearchInputFocus}
onChangeText={onChangeText}
onClearText={onPressClearQuery}
onSubmitEditing={onSubmit}
onSubmitEditing={onSubmit('typed')}
placeholder={
inputPlaceholder ?? l`Search for posts, users, or feeds`
}
@@ -420,7 +428,7 @@ export function SearchScreenShell({
isAutocompleteFetching={isAutocompleteFetching}
autocompleteData={autocompleteData}
searchText={searchText}
onSubmit={onSubmit}
onSubmit={onSubmit('autocomplete')}
onResultPress={onAutocompleteResultPress}
onProfileClick={handleProfileClick}
/>
@@ -9,6 +9,7 @@ import {SearchLinkCard} from '#/view/shell/desktop/Search'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
import {atoms as a, native} from '#/alf'
import * as Layout from '#/components/Layout'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
let AutocompleteResults = ({
@@ -26,6 +27,7 @@ let AutocompleteResults = ({
onResultPress: () => void
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}): React.ReactNode => {
const ax = useAnalytics()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
return (
@@ -51,12 +53,16 @@ let AutocompleteResults = ({
}
style={a.border_b}
/>
{autocompleteData?.map(item => (
{autocompleteData?.map((item, index) => (
<SearchProfileCard
key={item.did}
profile={item}
moderationOpts={moderationOpts}
onPress={() => {
ax.metric('search:autocomplete:press', {
profileDid: item.did,
position: index,
})
onProfileClick(item)
onResultPress()
}}
+22 -11
View File
@@ -1,8 +1,6 @@
import {Pressable, ScrollView, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {createHitslop, HITSLOP_10} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
@@ -19,6 +17,7 @@ import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function SearchHistory({
@@ -36,7 +35,8 @@ export function SearchHistory({
onRemoveItemClick: (item: string) => void
onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void
}) {
const {_} = useLingui()
const ax = useAnalytics()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
return (
@@ -47,7 +47,7 @@ export function SearchHistory({
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
<View style={[a.px_lg, a.pt_sm]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>Recent Searches</Trans>
<Trans>Recent searches</Trans>
</Text>
</View>
)}
@@ -66,12 +66,18 @@ export function SearchHistory({
a.gap_xl,
]}>
{moderationOpts &&
selectedProfiles.map(profile => (
selectedProfiles.map((profile, index) => (
<RecentProfileItem
key={profile.did}
profile={profile}
moderationOpts={moderationOpts}
onPress={() => onProfileClick(profile)}
onPress={() => {
ax.metric('search:recent:press', {
profileDid: profile.did,
position: index,
})
onProfileClick(profile)
}}
onRemove={() => onRemoveProfileClick(profile)}
/>
))}
@@ -86,13 +92,18 @@ export function SearchHistory({
<View key={index} style={[a.flex_row, a.align_center]}>
<Pressable
accessibilityRole="button"
onPress={() => onItemClick(historyItem)}
onPress={() => {
ax.metric('search:query', {
source: 'history',
})
onItemClick(historyItem)
}}
hitSlop={HITSLOP_10}
style={[a.flex_1, a.py_sm]}>
<Text style={[a.text_md]}>{historyItem}</Text>
</Pressable>
<Button
label={_(msg`Remove ${historyItem}`)}
label={l`Remove ${historyItem}`}
onPress={() => onRemoveItemClick(historyItem)}
size="small"
variant="ghost"
@@ -120,7 +131,7 @@ function RecentProfileItem({
onPress: () => void
onRemove: () => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const width = 80
const moderation = moderateProfile(profile, moderationOpts)
@@ -165,7 +176,7 @@ function RecentProfileItem({
</View>
</Link>
<Button
label={_(msg`Remove profile`)}
label={l`Remove profile`}
hitSlop={createHitslop(6)}
size="tiny"
variant="outline"