From 2d8de1ddc3d9916a598b14411c879e651b66adf3 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Tue, 13 Jan 2026 02:42:26 -0800 Subject: [PATCH] Standardize metadata for client events in feeds (#9653) --- .../PostControls/BookmarkButton.tsx | 16 +++- .../PostControls/PostMenu/PostMenuItems.tsx | 26 ++++++ .../PostControls/PostMenu/index.tsx | 3 + .../PostControls/ShareMenu/index.tsx | 24 +++++- src/components/PostControls/index.tsx | 20 ++++- src/logger/metrics.ts | 86 +++++++++++++++++-- .../components/ThreadItemAnchor.tsx | 12 +++ src/state/feed-feedback.tsx | 21 +---- src/state/queries/post.ts | 2 - src/view/com/posts/PostFeedItem.tsx | 22 ++++- 10 files changed, 194 insertions(+), 38 deletions(-) diff --git a/src/components/PostControls/BookmarkButton.tsx b/src/components/PostControls/BookmarkButton.tsx index f729515202..ac391a1ae9 100644 --- a/src/components/PostControls/BookmarkButton.tsx +++ b/src/components/PostControls/BookmarkButton.tsx @@ -8,6 +8,7 @@ import type React from 'react' import {useCleanError} from '#/lib/hooks/useCleanError' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/post-shadow' +import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' import {useRequireAuth} from '#/state/session' import {useTheme} from '#/alf' @@ -32,6 +33,7 @@ export const BookmarkButton = memo(function BookmarkButton({ const {mutateAsync: bookmark} = useBookmarkMutation() const cleanError = useCleanError() const requireAuth = useRequireAuth() + const {feedDescriptor} = useFeedFeedbackContext() const {viewer} = post const isBookmarked = !!viewer?.bookmarked @@ -50,7 +52,12 @@ export const BookmarkButton = memo(function BookmarkButton({ post, }) - logger.metric('post:bookmark', {logContext}) + logger.metric('post:bookmark', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) toast.show( @@ -85,7 +92,12 @@ export const BookmarkButton = memo(function BookmarkButton({ uri: post.uri, }) - logger.metric('post:unbookmark', {logContext}) + logger.metric('post:unbookmark', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) toast.show( diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index f8b410be97..ce93e81ab1 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -98,6 +98,7 @@ let PostMenuItems = ({ richText, threadgateRecord, onShowLess, + logContext, }: { testID: string post: Shadow @@ -111,6 +112,7 @@ let PostMenuItems = ({ timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' }): React.ReactNode => { const {hasSession, currentAccount} = useSession() const {_} = useLingui() @@ -210,9 +212,21 @@ let PostMenuItems = ({ try { if (isThreadMuted) { unmuteThread() + logger.metric('post:unmute', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) Toast.show(_(msg`You will now receive notifications for this thread`)) } else { muteThread() + logger.metric('post:mute', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) Toast.show( _(msg`You will no longer receive notifications for this thread`), ) @@ -272,6 +286,12 @@ let PostMenuItems = ({ feedContext: postFeedContext, reqId: postReqId, }) + logger.metric('post:showMore', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) Toast.show( _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), ) @@ -284,6 +304,12 @@ let PostMenuItems = ({ feedContext: postFeedContext, reqId: postReqId, }) + logger.metric('post:showLess', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) if (onShowLess) { onShowLess({ item: postUri, diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 950bc4f6d9..f418587a5c 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -29,6 +29,7 @@ let PostMenuButton = ({ threadgateRecord, onShowLess, hitSlop, + logContext, }: { testID: string post: Shadow @@ -41,6 +42,7 @@ let PostMenuButton = ({ threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void hitSlop?: Insets + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' }): React.ReactNode => { const {_} = useLingui() @@ -87,6 +89,7 @@ let PostMenuButton = ({ timestamp={timestamp} threadgateRecord={threadgateRecord} onShowLess={onShowLess} + logContext={logContext} /> )} diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx index 6127ca41db..3fd9583113 100644 --- a/src/components/PostControls/ShareMenu/index.tsx +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -16,6 +16,7 @@ import {useGate} from '#/lib/statsig/statsig' import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/post-shadow' +import {useFeedFeedbackContext} from '#/state/feed-feedback' import {EventStopper} from '#/view/com/util/EventStopper' import {native} from '#/alf' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' @@ -35,6 +36,7 @@ let ShareMenuButton = ({ threadgateRecord, onShare, hitSlop, + logContext, }: { testID: string post: Shadow @@ -45,9 +47,11 @@ let ShareMenuButton = ({ threadgateRecord?: AppBskyFeedThreadgate.Record onShare: () => void hitSlop?: Insets + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' }): React.ReactNode => { const {_} = useLingui() const gate = useGate() + const {feedDescriptor} = useFeedFeedbackContext() const ShareIcon = gate('alt_share_icon') ? ArrowShareRightIcon @@ -65,13 +69,27 @@ let ShareMenuButton = ({ setTimeout(menuControl.open) logger.metric( - 'share:open', - {context: big ? 'thread' : 'feed'}, + 'post:share', + { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + postContext: big ? 'thread' : 'feed', + }, {statsig: true}, ) }, }), - [menuControl, setHasBeenOpen, big], + [ + menuControl, + setHasBeenOpen, + big, + logContext, + feedDescriptor, + post.uri, + post.author.did, + ], ) const onNativeLongPress = () => { diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index ec0016815f..45688c2617 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -13,6 +13,7 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {logger} from '#/logger' import {type Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' import { @@ -174,6 +175,12 @@ let PostControls = ({ feedContext, reqId, }) + logger.metric('post:clickQuotePost', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) openComposer({ quote: post, onPost: onPostReply, @@ -217,7 +224,16 @@ let PostControls = ({ testID="replyBtn" onPress={ !replyDisabled - ? () => requireAuth(() => onPressReply()) + ? () => + requireAuth(() => { + logger.metric('post:clickReply', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) + onPressReply() + }) : undefined } label={_( @@ -315,6 +331,7 @@ let PostControls = ({ left: secondaryControlSpacingStyles.gap / 2, right: secondaryControlSpacingStyles.gap / 2, }} + logContext={logContext} /> diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 949c883b7c..dddca72a17 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -176,13 +176,19 @@ export type MetricEvents = { 'feed:suggestion:press': { feedUrl: string } - 'feed:showMore': { - feed: string - feedContext: string + 'post:showMore': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } - 'feed:showLess': { - feed: string - feedContext: string + 'post:showLess': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } 'feed:clickthrough': { feed: string @@ -257,15 +263,70 @@ export type MetricEvents = { logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' feedDescriptor?: string } - 'post:mute': {} - 'post:unmute': {} + 'post:mute': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:unmute': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } 'post:pin': {} 'post:unpin': {} 'post:bookmark': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } 'post:unbookmark': { + uri: string + authorDid: string logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickReply': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickQuotePost': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickthroughAuthor': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickthroughItem': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } + 'post:clickthroughEmbed': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number } 'post:view': { uri: string @@ -565,7 +626,14 @@ export type MetricEvents = { 'live:view:profile': {subject: string} 'live:view:post': {subject: string; feed?: string} - 'share:open': {context: 'feed' | 'thread'} + 'post:share': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + postContext: 'feed' | 'thread' + position?: number + } 'share:press:copyLink': {} 'share:press:nativeShare': {} 'share:press:openDmSearch': {} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 7785afe2ca..fbed7965b1 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -281,6 +281,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ ]) const onOpenAuthor = () => { + logger.metric('post:clickthroughAuthor', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'PostThreadItem', + feedDescriptor: feedFeedback.feedDescriptor, + }) if (postSource) { feedFeedback.sendInteraction({ item: post.uri, @@ -292,6 +298,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ } const onOpenEmbed = () => { + logger.metric('post:clickthroughEmbed', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'PostThreadItem', + feedDescriptor: feedFeedback.feedDescriptor, + }) if (postSource) { feedFeedback.sendInteraction({ item: post.uri, diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 1d719978a5..28f13fb5f2 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -137,7 +137,6 @@ export function useFeedFeedback( sendOrAggregateInteractionsForStats( aggregatedStats.current, interactionsToSend, - feed?.feedDescriptor ?? 'unknown', ) throttledFlushAggregatedStats() logger.debug('flushed') @@ -274,28 +273,10 @@ function createAggregatedStats(): AggregatedStats { function sendOrAggregateInteractionsForStats( stats: AggregatedStats, interactions: AppBskyFeedDefs.Interaction[], - feed: string, ) { for (let interaction of interactions) { switch (interaction.event) { - // Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them. - // This lets us send the feed context together with them. - case 'app.bsky.feed.defs#requestLess': { - logger.metric('feed:showLess', { - feed, - feedContext: interaction.feedContext ?? '', - }) - break - } - case 'app.bsky.feed.defs#requestMore': { - logger.metric('feed:showMore', { - feed, - feedContext: interaction.feedContext ?? '', - }) - break - } - - // The rest of the events are aggregated and sent later in batches. + // The events are aggregated and sent later in batches. case 'app.bsky.feed.defs#clickthroughAuthor': case 'app.bsky.feed.defs#clickthroughEmbed': case 'app.bsky.feed.defs#clickthroughItem': diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 09ced9874e..8fdfc584b6 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -373,7 +373,6 @@ function useThreadMuteMutation() { {uri: string} // the root post's uri >({ mutationFn: ({uri}) => { - logger.metric('post:mute', {}) return agent.api.app.bsky.graph.muteThread({root: uri}) }, }) @@ -383,7 +382,6 @@ function useThreadUnmuteMutation() { const agent = useAgent() return useMutation<{}, Error, {uri: string}>({ mutationFn: ({uri}) => { - logger.metric('post:unmute', {}) return agent.api.app.bsky.graph.unmuteThread({root: uri}) }, }) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 244ebde05f..5b6ce7841a 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -21,6 +21,7 @@ import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {countLines} from '#/lib/strings/helpers' +import {logger} from '#/logger' import { POST_TOMBSTONE, type Shadow, @@ -173,7 +174,8 @@ let FeedItemInner = ({ const urip = new AtUri(post.uri) return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] }, [post.uri, post.author]) - const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext() + const {sendInteraction, feedSourceInfo, feedDescriptor} = + useFeedFeedbackContext() const onPressReply = () => { sendInteraction({ @@ -209,6 +211,12 @@ let FeedItemInner = ({ feedContext, reqId, }) + logger.metric('post:clickthroughAuthor', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor, + }) } const onOpenReposter = () => { @@ -227,6 +235,12 @@ let FeedItemInner = ({ feedContext, reqId, }) + logger.metric('post:clickthroughEmbed', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor, + }) } const onBeforePress = () => { @@ -236,6 +250,12 @@ let FeedItemInner = ({ feedContext, reqId, }) + logger.metric('post:clickthroughItem', { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor, + }) unstableCacheProfileView(queryClient, post.author) setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { feedSourceInfo,