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.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.clips.LocalClipData import com.suno.android.common_data.user.UserSessionRepository 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 javax.inject.Singleton import kotlin.time.Duration import kotlin.time.DurationUnit /** * Manages analytics tracking for hook-clip transition events. */ interface HookClipAnalyticsManager { /** * Tracks when song playback starts when opening clip player from hook. */ fun trackOpenClipPlayerPlaySong( clip: LocalClipData, previousHookSessionId: Id, ) /** * Tracks when song playback pauses when closing clip player. * @param playDurationMillis total accumulated playback duration, accounting for seeking */ fun trackCloseClipPlayerPauseSong( clip: LocalClipData, playStartMillis: Duration, playEndMillis: Duration, playDurationMillis: Duration, ) /** * Gets the current clip session ID for cross-referencing with hook analytics. */ fun getCurrentSessionId(): Id? fun analyticDebugLogTailFlow(): Flow } private const val ANALYTICS_CHANNEL_CAPACITY = 1000 @Singleton class HookClipAnalyticsManagerImpl @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, ) : HookClipAnalyticsManager { private val logger = loggerFactory.create(this@HookClipAnalyticsManagerImpl) private val currentSession = AtomicReference(null) private val currentUserId = AtomicReference(null) private val analyticsChannel = Channel(ANALYTICS_CHANNEL_CAPACITY) private val _analyticDebugLogTailFlow = MutableSharedFlow() private val prettyJson by lazy { Json { prettyPrint = true } } init { startBackgroundProcessor() observeCurrentUserId() } 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: QueuedClipAnalyticsEvent, ) { val event = HookClipAnalyticsEvent( actionName = queuedEvent.action.actionName, 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 clip session id: ${queuedEvent.context.clipSessionId}\n" + prettyJson.encodeToString(jsonElement) } } private fun observeCurrentUserId() { userSessionRepository.sessionConfigurationStateFlow() .map { it.user } .filterNotNull() .distinctUntilChanged() .onEach { user -> user.id?.let { userId -> analytics.identify(userId = userId) currentUserId.set(userId) } } .catch { logger.e(it) } .launchIn(applicationScope) } override fun trackOpenClipPlayerPlaySong( clip: LocalClipData, previousHookSessionId: Id, ) { startNewSession(previousHookSessionId) createHookClipAnalyticsContext( clip = clip, startTimeMillis = Duration.ZERO, endTimeMillis = Duration.ZERO, previousHookSessionId = previousHookSessionId, )?.let { context -> queueAnalyticsEvent( action = HookClipAnalyticsAction.OpenClipPlayerPlaySong, context = context, ) } } override fun trackCloseClipPlayerPauseSong( clip: LocalClipData, playStartMillis: Duration, playEndMillis: Duration, playDurationMillis: Duration, ) { val session = currentSession.get() ?: return val isUserClipOwner = clip.artistUserId?.value == currentUserId.get() HookClipAnalyticsContext( startTimeSecs = playStartMillis.toDouble(DurationUnit.SECONDS), endTimeSecs = playEndMillis.toDouble(DurationUnit.SECONDS), playDurationSecs = playDurationMillis.toDouble(DurationUnit.SECONDS).coerceAtLeast(0.0), clipSessionId = session.clipSessionId.value, previousHookSessionId = session.previousHookSessionId.value, volume = deviceVolumeProvider.getCurrentStreamMusicVolume(), platform = PLATFORM, isUserClipOwner = isUserClipOwner, clickSourceUrl = null, ).let { context -> queueAnalyticsEvent( action = HookClipAnalyticsAction.CloseClipPlayerPauseSong, context = context, ) } } override fun getCurrentSessionId(): Id? = currentSession.get()?.clipSessionId override fun analyticDebugLogTailFlow(): Flow = _analyticDebugLogTailFlow private fun startNewSession( previousHookSessionId: Id, ): HookClipAnalyticsSession = HookClipAnalyticsSession( clipSessionId = Id(UUID.randomUUID().toString()), previousHookSessionId = previousHookSessionId, ).also { newSession -> logger.d { "Started new clip session: $newSession" } currentSession.set(newSession) } private fun createHookClipAnalyticsContext( clip: LocalClipData, startTimeMillis: Duration, endTimeMillis: Duration, previousHookSessionId: Id?, ): HookClipAnalyticsContext? { val session = currentSession.get() ?: return null val isUserClipOwner = clip.artistUserId?.value == currentUserId.get() return HookClipAnalyticsContext( startTimeSecs = startTimeMillis.toDouble(DurationUnit.SECONDS), endTimeSecs = endTimeMillis.toDouble(DurationUnit.SECONDS), playDurationSecs = (endTimeMillis - startTimeMillis) .toDouble(DurationUnit.SECONDS) .coerceAtLeast(0.0), clipSessionId = session.clipSessionId.value, previousHookSessionId = previousHookSessionId?.value, volume = deviceVolumeProvider.getCurrentStreamMusicVolume(), platform = PLATFORM, isUserClipOwner = isUserClipOwner, clickSourceUrl = null, ) } private fun queueAnalyticsEvent( action: HookClipAnalyticsAction, context: HookClipAnalyticsContext, ) { val queuedEvent = QueuedClipAnalyticsEvent( action = action, context = context, ) val result = analyticsChannel.trySend(queuedEvent) if (!result.isSuccess) { logger.w { "Failed to queue analytics event: ${result.exceptionOrNull()}" } } } } private data class QueuedClipAnalyticsEvent( val action: HookClipAnalyticsAction, val context: HookClipAnalyticsContext, ) data class HookClipAnalyticsSession( val clipSessionId: Id, val previousHookSessionId: Id, )