25a1ed1209
* 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
334 lines
9.1 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|
|
}
|