Files
bsky-social-app/src/state/feed-feedback.tsx
T
Eric Bailey 25a1ed1209 [APP-1782] Analytics migration (#9734)
* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

* Refactor logging in feed-feedback, add debug logging to metrics client

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

* [APP-1782] Migrate to new analytics APIs (#9735)

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests
2026-01-22 15:33:45 -06:00

334 lines
9.1 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import {AppState, type AppStateStatus} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants'
import {
type FeedSourceFeedInfo,
type FeedSourceInfo,
isFeedSourceFeedInfo,
} from '#/state/queries/feed'
import {
type FeedDescriptor,
type FeedPostSliceItem,
} from '#/state/queries/post-feed'
import {getItemsForFeedback} from '#/view/com/posts/PostFeed'
import {useAnalytics} from '#/analytics'
import {useAgent} from './session'
export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS]
export const THIRD_PARTY_ALLOWED_INTERACTIONS = new Set<
AppBskyFeedDefs.Interaction['event']
>([
// These are explicit actions and are therefore fine to send.
'app.bsky.feed.defs#requestLess',
'app.bsky.feed.defs#requestMore',
// These can be inferred from the firehose and are therefore fine to send.
'app.bsky.feed.defs#interactionLike',
'app.bsky.feed.defs#interactionQuote',
'app.bsky.feed.defs#interactionReply',
'app.bsky.feed.defs#interactionRepost',
// This can be inferred from pagination requests for everything except the very last page
// so it is fine to send. It is crucial for third party algorithmic feeds to receive these.
'app.bsky.feed.defs#interactionSeen',
])
export type StateContext = {
enabled: boolean
onItemSeen: (item: any) => void
sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
feedDescriptor: FeedDescriptor | undefined
feedSourceInfo: FeedSourceInfo | undefined
}
const stateContext = createContext<StateContext>({
enabled: false,
onItemSeen: (_item: any) => {},
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
feedDescriptor: undefined,
feedSourceInfo: undefined,
})
stateContext.displayName = 'FeedFeedbackContext'
export function useFeedFeedback(
feedSourceInfo: FeedSourceInfo | undefined,
hasSession: boolean,
) {
const ax = useAnalytics()
const logger = ax.logger.useChild(ax.logger.Context.FeedFeedback)
const agent = useAgent()
const feed =
!!feedSourceInfo && isFeedSourceFeedInfo(feedSourceInfo)
? feedSourceInfo
: undefined
const isDiscover = isDiscoverFeed(feed?.feedDescriptor)
const acceptsInteractions = Boolean(isDiscover || feed?.acceptsInteractions)
const proxyDid = feed?.view?.did
const enabled =
Boolean(feed) && Boolean(proxyDid) && acceptsInteractions && hasSession
const queue = useRef<Set<string>>(new Set())
const history = useRef<
// Use a WeakSet so that we don't need to clear it.
// This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
>(new WeakSet())
const flushEvents = useCallback(
(stats: AggregatedStats | null, feedDescriptor: string) => {
if (stats === null) {
return
}
if (stats.clickthroughCount > 0) {
ax.metric('feed:clickthrough', {
count: stats.clickthroughCount,
feed: feedDescriptor,
})
stats.clickthroughCount = 0
}
if (stats.engagedCount > 0) {
ax.metric('feed:engaged', {
count: stats.engagedCount,
feed: feedDescriptor,
})
stats.engagedCount = 0
}
if (stats.seenCount > 0) {
ax.metric('feed:seen', {
count: stats.seenCount,
feed: feedDescriptor,
})
stats.seenCount = 0
}
},
[ax],
)
const aggregatedStats = useRef<AggregatedStats | null>(null)
const throttledFlushAggregatedStats = useMemo(
() =>
throttle(
() =>
flushEvents(
aggregatedStats.current,
feed?.feedDescriptor ?? 'unknown',
),
45e3,
{
leading: true, // The outer call is already throttled somewhat.
trailing: true,
},
),
[feed?.feedDescriptor, flushEvents],
)
const sendToFeedNoDelay = useCallback(() => {
const interactions = Array.from(queue.current).map(toInteraction)
queue.current.clear()
const interactionsToSend = interactions.filter(
interaction =>
interaction.event &&
isInteractionAllowed(enabled, feed, interaction.event),
)
if (interactionsToSend.length === 0) {
return
}
// Send to the feed
agent.app.bsky.feed
.sendInteractions(
{interactions: interactionsToSend},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${proxyDid}#bsky_fg`,
},
},
)
.catch(() => {}) // ignore upstream errors
if (aggregatedStats.current === null) {
aggregatedStats.current = createAggregatedStats()
}
sendOrAggregateInteractionsForStats(
aggregatedStats.current,
interactionsToSend,
)
throttledFlushAggregatedStats()
logger.debug('flushed')
}, [agent, throttledFlushAggregatedStats, proxyDid, enabled, feed])
const sendToFeed = useMemo(
() =>
throttle(sendToFeedNoDelay, 10e3, {
leading: false,
trailing: true,
}),
[sendToFeedNoDelay],
)
useEffect(() => {
if (!enabled) {
return
}
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'background') {
sendToFeed.flush()
}
})
return () => sub.remove()
}, [enabled, sendToFeed])
const onItemSeen = useCallback(
(feedItem: any) => {
if (!enabled) {
return
}
const items = getItemsForFeedback(feedItem)
for (const {item: postItem, feedContext, reqId} of items) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext,
reqId,
}),
)
sendToFeed()
}
}
},
[enabled, sendToFeed],
)
const sendInteraction = useCallback(
(interaction: AppBskyFeedDefs.Interaction) => {
if (!enabled) {
return
}
logger.debug('sendInteraction', {
...interaction,
})
if (!history.current.has(interaction)) {
history.current.add(interaction)
queue.current.add(toString(interaction))
sendToFeed()
}
},
[enabled, sendToFeed],
)
return useMemo(() => {
return {
enabled,
// pass this method to the <List> onItemSeen
onItemSeen,
// call on various events
// queues the event to be sent with the throttled sendToFeed call
sendInteraction,
feedDescriptor: feed?.feedDescriptor,
feedSourceInfo: typeof feed === 'object' ? feed : undefined,
}
}, [enabled, onItemSeen, sendInteraction, feed])
}
export const FeedFeedbackProvider = stateContext.Provider
export function useFeedFeedbackContext() {
return useContext(stateContext)
}
// TODO
// We will introduce a permissions framework for 3p feeds to
// take advantage of the feed feedback API. Until that's in
// place, we're hardcoding it to the discover feed.
// -prf
export function isDiscoverFeed(feed?: FeedDescriptor) {
return !!feed && FEEDBACK_FEEDS.includes(feed)
}
function isInteractionAllowed(
enabled: boolean,
feed: FeedSourceFeedInfo | undefined,
interaction: AppBskyFeedDefs.Interaction['event'],
) {
if (!enabled || !feed) {
return false
}
const isDiscover = isDiscoverFeed(feed.feedDescriptor)
return isDiscover ? true : THIRD_PARTY_ALLOWED_INTERACTIONS.has(interaction)
}
function toString(interaction: AppBskyFeedDefs.Interaction): string {
return `${interaction.item}|${interaction.event}|${
interaction.feedContext || ''
}|${interaction.reqId || ''}`
}
function toInteraction(str: string): AppBskyFeedDefs.Interaction {
const [item, event, feedContext, reqId] = str.split('|')
return {item, event, feedContext, reqId}
}
type AggregatedStats = {
clickthroughCount: number
engagedCount: number
seenCount: number
}
function createAggregatedStats(): AggregatedStats {
return {
clickthroughCount: 0,
engagedCount: 0,
seenCount: 0,
}
}
function sendOrAggregateInteractionsForStats(
stats: AggregatedStats,
interactions: AppBskyFeedDefs.Interaction[],
) {
for (let interaction of interactions) {
switch (interaction.event) {
// 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':
case 'app.bsky.feed.defs#clickthroughReposter': {
stats.clickthroughCount++
break
}
case 'app.bsky.feed.defs#interactionLike':
case 'app.bsky.feed.defs#interactionQuote':
case 'app.bsky.feed.defs#interactionReply':
case 'app.bsky.feed.defs#interactionRepost':
case 'app.bsky.feed.defs#interactionShare': {
stats.engagedCount++
break
}
case 'app.bsky.feed.defs#interactionSeen': {
stats.seenCount++
break
}
}
}
}