package com.suno.android.common_analytics.hooks import com.segment.analytics.kotlin.core.Analytics import com.suno.android.common_analytics.APP_EVENT_NAME import com.suno.android.common_analytics.ElementType import com.suno.android.common_analytics.PLATFORM import com.suno.android.common_analytics.providers.DeviceVolumeProvider import com.suno.android.common_core_utils.ApplicationCoroutineScope import com.suno.android.common_core_utils.BuildConfig import com.suno.android.common_core_utils.DispatcherIO import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_networking.remote.session.User import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement import java.util.UUID import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import kotlin.time.Duration import kotlin.time.DurationUnit /** * Manages analytics tracking for hook events. */ interface HookAnalyticsManager { /** * Tracks a hook playback event and provides additional hook state data matching the event. */ fun track( event: HookAnalyticsPlaybackEvent, ) /** * Gets the current hook session ID for cross-referencing with clip analytics. */ fun getCurrentSessionId(): Id? fun analyticDebugLogTailFlow(): Flow } private const val ANALYTICS_CHANNEL_CAPACITY = 1000 class HookAnalyticsManagerImpl @Inject constructor( loggerFactory: SunoLogger.Factory, @ApplicationCoroutineScope private val applicationScope: CoroutineScope, @DispatcherIO private val dispatcherIO: CoroutineDispatcher, private val analytics: Analytics, private val userSessionRepository: UserSessionRepository, private val deviceVolumeProvider: DeviceVolumeProvider, ) : HookAnalyticsManager { private val logger = loggerFactory.create(this@HookAnalyticsManagerImpl) private val currentSession = AtomicReference(null) private val currentUser = AtomicReference(null) private val analyticsChannel = Channel(ANALYTICS_CHANNEL_CAPACITY) private val _analyticDebugLogTailFlow = MutableSharedFlow() private val prettyJson by lazy { Json { prettyPrint = true } } init { startBackgroundProcessor() observeCurrentUser() } private fun startBackgroundProcessor() { applicationScope.launch(dispatcherIO) { analyticsChannel.consumeAsFlow().collect { queuedEvent -> runCatching { processAnalyticsEvent(queuedEvent) }.onFailure { logger.e(it) { "Failed to process analytics event" } } } } } private suspend fun processAnalyticsEvent( queuedEvent: QueuedHookAnalyticsEvent, ) { val userId = currentUser.get().id ?: return val event = HookAnalyticsEvent( actionName = queuedEvent.action.actionName, userId = userId, elementType = ElementType.Hooks, elementId = queuedEvent.hookId.value, context = queuedEvent.context, ) val jsonElement = Json.encodeToJsonElement(event) as? JsonObject ?: return analytics.track( name = APP_EVENT_NAME, properties = jsonElement, ) if (BuildConfig.IS_STAFF) { val prettyJsonString = prettyJson.encodeToString(jsonElement) _analyticDebugLogTailFlow.emit(prettyJsonString) } logger.d { "processAnalyticsEvent (thread: ${Thread.currentThread().name}) - " + "${queuedEvent.action.actionName} for hook id: ${queuedEvent.hookId.value}\n " + prettyJson.encodeToString(jsonElement) } } private fun observeCurrentUser() { userSessionRepository.sessionConfigurationStateFlow() .map { it.user } .filterNotNull() .distinctUntilChanged() .onEach { user -> currentUser.set(user) } .catch { logger.e(it) } .launchIn(applicationScope) } @Suppress("LongMethod") override fun track( event: HookAnalyticsPlaybackEvent, ) { val analyticContext = when (event) { is HookAnalyticsPlaybackEvent.PlayNewHook -> { startNewSession() createHookAnalyticsContext( hook = event.hook, startTimeMillis = Duration.ZERO, endTimeMillis = Duration.ZERO, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) } is HookAnalyticsPlaybackEvent.PauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.PlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.AutoRepeatPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.AutoRepeatPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = Duration.ZERO, endTimeMillis = Duration.ZERO, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.ScrollUpPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.ScrollDownPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.ScrollUpPlayNewHook -> { startNewSession() createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) } is HookAnalyticsPlaybackEvent.ScrollDownPlayNewHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapSongPillPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.CloseClipPlayerPlayHook -> { startNewSession() createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, previousClipSessionId = event.previousClipSessionId, timeToFirstFrame = event.timeToFirstFrame, ) } is HookAnalyticsPlaybackEvent.TapTabPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapTabPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.TapCreateSongPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.CloseCreateSongPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapCreateHookPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.CloseCreateHookPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapProfilePauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.TapBackPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapBackPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.ForegroundAppPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.BackgroundAppPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.OpenDeeplinkPlayNewHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = Duration.ZERO, endTimeMillis = Duration.ZERO, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.OpenDeeplinkPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.CloseCoverPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapExtendPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) is HookAnalyticsPlaybackEvent.CloseExtendPlayHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playStartMillis, isHookMuted = event.isHookMuted, timeToFirstFrame = event.timeToFirstFrame, ) is HookAnalyticsPlaybackEvent.TapCoverPauseHook -> createHookAnalyticsContext( hook = event.hook, startTimeMillis = event.playStartMillis, endTimeMillis = event.playEndMillis, isHookMuted = event.isHookMuted, ) } analyticContext?.let { context -> queueAnalyticsEvent( hookId = event.hook.hookId, action = event.action, context = context, ) } } private fun queueAnalyticsEvent( hookId: Id, action: HookAnalyticsAction, context: HookAnalyticsContext, ) { val queuedEvent = QueuedHookAnalyticsEvent( hookId = hookId, action = action, context = context, ) val result = analyticsChannel.trySend(queuedEvent) if (!result.isSuccess) { logger.w { "Failed to queue analytics event: ${result.exceptionOrNull()}" } } } override fun getCurrentSessionId(): Id? = currentSession.get()?.hookSessionId override fun analyticDebugLogTailFlow(): Flow = _analyticDebugLogTailFlow private fun startNewSession(): HookAnalyticsSession { val previousSession = currentSession.get() return HookAnalyticsSession( hookSessionId = Id(UUID.randomUUID().toString()), previousHookSessionId = previousSession?.hookSessionId, ).also { newSession -> logger.d { "Started new session: $newSession" } currentSession.set(newSession) } } private fun createHookAnalyticsContext( hook: LocalHookData, startTimeMillis: Duration, endTimeMillis: Duration, isHookMuted: Boolean, previousClipSessionId: Id? = null, timeToFirstFrame: Duration? = null, ): HookAnalyticsContext? { val session = currentSession.get() ?: return null val isUserHookOwner = hook.creator.handle?.let { creatorHandle -> creatorHandle.handle == currentUser.get().handle } ?: false return HookAnalyticsContext( startTimeSecs = startTimeMillis.toDouble(DurationUnit.SECONDS), endTimeSecs = endTimeMillis.toDouble(DurationUnit.SECONDS), playDurationSecs = (endTimeMillis - startTimeMillis) .toDouble(DurationUnit.SECONDS) .coerceAtLeast(0.0), hookSessionId = session.hookSessionId.value, previousHookSessionId = session.previousHookSessionId?.value, previousClipSessionId = previousClipSessionId?.value, volume = deviceVolumeProvider.getCurrentStreamMusicVolume(), platform = PLATFORM, isUserHookOwner = isUserHookOwner, clickSourceUrl = null, recommendationItemId = hook.recommendationItemId?.value, isMuted = isHookMuted, timeToFirstFrameSecs = timeToFirstFrame?.toDouble(DurationUnit.SECONDS), ) } } private data class QueuedHookAnalyticsEvent( val hookId: Id, val action: HookAnalyticsAction, val context: HookAnalyticsContext, ) data class HookAnalyticsSession( val hookSessionId: Id, val previousHookSessionId: Id?, )