Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 119 additions & 38 deletions implementations/react-native-sdk/components/AnalyticsEventDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,108 @@ import { useOptimization } from '@contentful/optimization-react-native'
interface AnalyticsEvent {
type: string
componentId?: string
viewDurationMs?: number
componentViewId?: string
timestamp: number
}

function isValidEvent(event: unknown): event is { type: string; componentId?: unknown } {
function isValidEvent(event: unknown): event is {
type: string
componentId?: unknown
viewDurationMs?: unknown
componentViewId?: unknown
} {
return (
event !== null && typeof event === 'object' && 'type' in event && typeof event.type === 'string'
)
}

interface ComponentStats {
count: number
latestViewDurationMs: number | undefined
latestComponentViewId: string | undefined
}

// Module-level stores that persist across unmount/remount cycles within the same
// app session. Cleared naturally when the app process restarts (relaunchCleanApp).
let persistedEvents: AnalyticsEvent[] = []
let persistedComponentStats: Record<string, ComponentStats> = {}

// Callback to trigger a re-render when mounted; null when unmounted.
let rerender: (() => void) | null = null

// Active subscription kept alive across unmounts to capture cleanup events.
let activeSubscription: { unsubscribe: () => void } | null = null

function buildEvent(event: {
type: string
componentId?: unknown
viewDurationMs?: unknown
componentViewId?: unknown
}): AnalyticsEvent {
const { type, componentId, viewDurationMs, componentViewId } = event
const newEvent: AnalyticsEvent = { type, timestamp: Date.now() }

if (componentId && typeof componentId === 'string') {
newEvent.componentId = componentId
}
if (typeof viewDurationMs === 'number') {
newEvent.viewDurationMs = viewDurationMs
}
if (typeof componentViewId === 'string') {
newEvent.componentViewId = componentViewId
}

return newEvent
}

function updateComponentStats(newEvent: AnalyticsEvent): void {
if (!newEvent.componentId || newEvent.type !== 'component') return

const { componentId: cid } = newEvent
const { [cid]: existing } = persistedComponentStats
persistedComponentStats = {
...persistedComponentStats,
[cid]: {
count: (existing?.count ?? 0) + 1,
latestViewDurationMs: newEvent.viewDurationMs ?? existing?.latestViewDurationMs,
latestComponentViewId: newEvent.componentViewId ?? existing?.latestComponentViewId,
},
}
}

function processEvent(event: unknown): void {
if (!isValidEvent(event)) return

const newEvent = buildEvent(event)
persistedEvents = [newEvent, ...persistedEvents]
updateComponentStats(newEvent)
rerender?.()
}

export function AnalyticsEventDisplay(): React.JSX.Element {
const sdk = useOptimization()
const [events, setEvents] = useState<AnalyticsEvent[]>([])
const [, setTick] = useState(0)

useEffect(() => {
const handleEvent = (event: unknown): void => {
if (isValidEvent(event)) {
const { type, componentId } = event
const newEvent: AnalyticsEvent = {
type,
timestamp: Date.now(),
}

if (componentId && typeof componentId === 'string') {
newEvent.componentId = componentId
}

setEvents((prev) => [newEvent, ...prev])
}
rerender = () => {
setTick((n) => n + 1)
}

const subscription = sdk.states.eventStream.subscribe(handleEvent)
// (Re)subscribe when SDK instance changes (e.g. after reset).
activeSubscription?.unsubscribe()
activeSubscription = sdk.states.eventStream.subscribe(processEvent)

return () => {
subscription.unsubscribe()
rerender = null
// Intentionally keep subscription alive to capture events emitted
// during sibling component cleanup (e.g. final view tracking events).
}
}, [sdk])

const events = persistedEvents
const componentStats = persistedComponentStats

if (events.length === 0) {
return (
<View testID="analytics-events-container">
Expand All @@ -57,26 +122,42 @@ export function AnalyticsEventDisplay(): React.JSX.Element {
<View testID="analytics-events-container" style={{ padding: 10 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 5 }}>Analytics Events</Text>
<Text testID="events-count">Events: {events.length}</Text>
{events.map((event, index) => {
const accessibilityLabel = `${event.type} - Component: ${event.componentId ?? 'none'}`
const testID = event.componentId
? `event-${event.type}-${event.componentId}`
: `event-${event.type}-${index}`
return (
<View
key={`${event.timestamp}-${index}`}
testID={testID}
accessibilityLabel={accessibilityLabel}
accessible={true}
style={{ marginTop: 5 }}
>
<Text>
{event.type}
{event.componentId ? ` - Component: ${event.componentId}` : ''}
</Text>
</View>
)
})}

{events
.filter((event) => event.type !== 'component')
.map((event, index) => {
const accessibilityLabel = `${event.type} - Component: ${event.componentId ?? 'none'} - Duration: ${event.viewDurationMs ?? 'none'}`
const testID = event.componentId
? `event-${event.type}-${event.componentId}`
: `event-${event.type}-${index}`
return (
<View
key={`${event.timestamp}-${index}`}
testID={testID}
accessibilityLabel={accessibilityLabel}
accessible={true}
style={{ marginTop: 5 }}
>
<Text>
{event.type}
{event.componentId ? ` - Component: ${event.componentId}` : ''}
{event.viewDurationMs !== undefined ? ` - ${event.viewDurationMs}ms` : ''}
</Text>
</View>
)
})}

{Object.entries(componentStats).map(([cid, stats]) => (
<View key={`stats-${cid}`} testID={`component-stats-${cid}`}>
<Text testID={`event-count-${cid}`}>Count: {stats.count}</Text>
<Text testID={`event-duration-${cid}`}>
Duration: {stats.latestViewDurationMs ?? 'N/A'}
</Text>
<Text testID={`event-view-id-${cid}`}>
ViewId: {stats.latestComponentViewId ?? 'N/A'}
</Text>
</View>
))}
</View>
)
}
9 changes: 3 additions & 6 deletions implementations/react-native-sdk/e2e/analytics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,14 @@ describe('Analytics Events', () => {
})

it('should track component impression events for visible entries', async () => {
// Wait for the app to load
const analyticsTitle = element(by.text('Analytics Events'))
await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT)

// Wait until the analytics stream has emitted events.
await waitForEventsCountAtLeast(1)

// Look for component events with entry IDs
// The merge tag entry should trigger a component event
// Use waitFor().whileElement().scroll() pattern to scroll until element is visible
await waitFor(element(by.id('event-component-1MwiFl4z7gkwqGYdvCmr8c')))
// With periodic tracking, per-component stats are rendered with unique testIDs.
// Use the component-stats summary element to verify the merge tag entry was tracked.
await waitFor(element(by.id('component-stats-1MwiFl4z7gkwqGYdvCmr8c')))
.toBeVisible()
.whileElement(by.id('main-scroll-view'))
.scroll(500, 'down')
Expand Down
Loading
Loading