package com.suno.android.common_analytics.managers import android.os.Bundle import com.google.firebase.Firebase import com.google.firebase.analytics.analytics import com.segment.analytics.kotlin.core.Analytics import com.suno.android.common_analytics.APP_EVENT_NAME import com.suno.android.common_analytics.ActionName import com.suno.android.common_analytics.ActionType import com.suno.android.common_analytics.Category import com.suno.android.common_analytics.ElementType import com.suno.android.common_analytics.listening_source.ListeningSourceContext import com.suno.android.common_analytics.payloads.AnalyticEvent 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.SunoLogger import com.suno.android.common_core_utils.environment.AnalyticsIdManager import com.suno.android.common_core_utils.helpers.AppLifecycleManager import com.suno.android.common_core_utils.helpers.SunoAppLifecycleEvent import com.suno.android.common_data.user.UserService import com.suno.android.common_networking.remote.session.User import com.suno.android.gating.statsig.FeatureGate import com.suno.android.gating.statsig.StatsigFeatureDataSource 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.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.serializer import java.util.UUID import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton enum class NewSongCause { VANILLA, // PlayNewSong AUTO_ADVANCE, // AutoRepeatPlayNewSong AUTO_REPEAT, // AutoRepeatPlaySong SKIP_BACKWARD, // BackwardPlayNewSong SKIP_FORWARD, // ForwardPlayNewSong SEEK_TO_START, // BackwardRepeatSong } data class Interval( val startTime: Long, val endTime: Long, ) interface AnalyticsManager { fun reset() fun attachToAppLifecycleEventFlow( appLifecycleManager: AppLifecycleManager, ) // Playback Analytics // See https://docs.google.com/document/d/1n72SbgeW_XwhaW61vzrGoEwwd2UxKhkoE-FI27DGGVY fun trackPlayNewSong( clipId: String, cause: NewSongCause, isOwned: Boolean, listeningSource: ListeningSourceContext? = null, ) fun trackPlayNewSongPauseSong( interval: Interval, cause: NewSongCause, listeningSource: ListeningSourceContext? = null, ) fun trackSongEnd( interval: Interval, ) fun trackPlaySong() fun trackPauseSong( interval: Interval, ) fun trackSeekProgressBarPauseSong( interval: Interval, ) fun trackSeekProgressBarPlaySong() fun trackBackwardRepeatSong() fun trackTapLike( clipId: String, ) fun trackGenerationFailed( reason: String?, ) fun trackScreenVisit( screenName: String?, ) fun trackMviEvent( eventName: String, source: String, method: String? = null, elementType: String? = null, elementId: String? = null, context: String? = null, ) fun trackMviEffect( effectName: String, source: String, elementType: String? = null, elementId: String? = null, context: String? = null, ) fun trackFirebaseEvent( name: String, params: Bundle?, ) fun analyticDebugLogTailFlow(): Flow } data class PlaybackAnalyticsState( val songSessionId: String = UUID.randomUUID().toString(), val previousSongSessionId: String? = null, val playingClipId: String? = null, val playingClipIsOwned: Boolean? = null, ) @Serializable data class PlaybackAnalyticsContext( @SerialName("songSessionId") val songSessionId: String, @SerialName("previousSongSessionId") val previousSongSessionId: String?, @SerialName("startTime") val startTime: Double?, @SerialName("endTime") val endTime: Double?, @SerialName("playDuration") val playDuration: Double?, @SerialName("isUserSongOwner") val isUserSongOwner: Boolean?, ) @Serializable data class ErrorContext( @SerialName("reason") val reason: String?, ) @Singleton class AnalyticsManagerImpl @Inject constructor( loggerFactory: SunoLogger.Factory, @ApplicationCoroutineScope private val applicationScope: CoroutineScope, @DispatcherIO private val dispatcherIO: CoroutineDispatcher, private val analytics: Analytics, analyticsIdManager: AnalyticsIdManager, private val statsigManager: StatsigFeatureDataSource, ) : AnalyticsManager, UserService { private val logger = loggerFactory.create(this@AnalyticsManagerImpl) private val retainedUserId: MutableStateFlow = MutableStateFlow(null) private val playbackAnalyticsState: MutableStateFlow = MutableStateFlow( PlaybackAnalyticsState(), ) private val anonymousIdStateFlow = analyticsIdManager.getAnonymousIdStateFlow() private val sessionIdStateFlow = analyticsIdManager.getSessionIdStateFlow() private val analyticsChannel = Channel(ANALYTICS_CHANNEL_CAPACITY) private val _analyticDebugLogTailFlow = MutableSharedFlow() private val prettyJson by lazy { Json { prettyPrint = true } } private val isNewLoggingGateEnabled by lazy { statsigManager.checkGate(FeatureGate.NEW_LOGGING) } init { startBackgroundProcessor() } override suspend fun setUserInfo( user: User?, ) { logger.d { "setting analytics userId to `${user?.id}`" } val userId = user?.id if (userId == null) { analytics.reset() retainedUserId.value = null } else { analytics.identify( userId = userId, ) retainedUserId.value = userId } } override fun reset() { analytics.reset() retainedUserId.value = null } 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: AnalyticEvent, ) { when (queuedEvent) { is AnalyticEvent.Raw -> track(queuedEvent) is AnalyticEvent.Typed -> track(queuedEvent) } } private fun queueAnalyticsEvent( event: AnalyticEvent, ) { val result = analyticsChannel.trySend(event) if (!result.isSuccess) { logger.w { "Failed to queue analytics event: ${result.exceptionOrNull()}" } } } private suspend fun track( properties: T, serializer: KSerializer, ) { val jsonElement = Json.encodeToJsonElement(serializer, properties) if (jsonElement is JsonObject) { analytics.track( name = APP_EVENT_NAME, properties = jsonElement, ) if (BuildConfig.IS_STAFF) { val prettyJsonString = prettyJson.encodeToString(jsonElement) _analyticDebugLogTailFlow.emit(prettyJsonString) } logger.d { prettyJson.encodeToString(jsonElement) } } else { throw IllegalArgumentException("Properties must serialize to a JsonObject") } } private suspend inline fun track( properties: T, ) where T : Any { val serializer = Json.serializersModule.serializer() track(properties, serializer) } override fun trackMviEffect( effectName: String, source: String, elementType: String?, elementId: String?, context: String?, ) { logger.println { "Analytics Tracking Effect: $effectName" } queueAnalyticsEvent( event = prefilledAnalyticsEvent( actionNameString = effectName, ).copy( source = source, elementType = elementType, elementId = elementId, context = context, ), ) } override fun trackMviEvent( eventName: String, source: String, method: String?, elementType: String?, elementId: String?, context: String?, ) { logger.println { "Analytics Tracking Event: $eventName" } queueAnalyticsEvent( event = prefilledAnalyticsEvent( actionNameString = eventName, ).copy( source = source, method = method, elementType = elementType, elementId = elementId, context = context, ), ) } override fun analyticDebugLogTailFlow(): Flow = _analyticDebugLogTailFlow override fun trackFirebaseEvent( name: String, params: Bundle?, ) { val analytics = Firebase.analytics analytics.logEvent(name, params) } override fun attachToAppLifecycleEventFlow( appLifecycleManager: AppLifecycleManager, ) { val sema = Semaphore(0) appLifecycleManager.appProcessEventFlow() .onSubscription { sema.release() }.onEach { sunoAppLifecycleEvent -> when (sunoAppLifecycleEvent) { is SunoAppLifecycleEvent.OnAppForegrounded -> { trackAppForeground() } SunoAppLifecycleEvent.OnAppBackgrounded -> { trackAppBackground() } SunoAppLifecycleEvent.OnAppDestroy -> { trackAppClose() } SunoAppLifecycleEvent.OnAppFirstLaunch -> { trackAppLaunch() } } }.catch { exception -> logger.e(exception) } .flowOn(dispatcherIO) .launchIn(applicationScope) // Wait for subscription to become active before returning sema.tryAcquire(5, TimeUnit.SECONDS) } private fun prefilledAnalyticsEvent( actionName: ActionName, ): AnalyticEvent.Typed = AnalyticEvent.Typed( actionName = actionName, userId = retainedUserId.value, anonymousId = anonymousIdStateFlow.value, sessionId = sessionIdStateFlow.value, ) private fun prefilledAnalyticsEvent( actionNameString: String, ): AnalyticEvent.Raw = AnalyticEvent.Raw( actionName = actionNameString, userId = retainedUserId.value, anonymousId = anonymousIdStateFlow.value, sessionId = sessionIdStateFlow.value, ) private fun trackAppLaunch() { queueAnalyticsEvent( event = prefilledAnalyticsEvent(ActionName.AppLaunch), ) } private fun makePlaybackAnalyticsContextJson( interval: Interval? = null, ): JsonElement { val context = playbackAnalyticsState.value.let { c -> val (startTime, endTime, duration) = interval?.let { interval -> Triple( interval.startTime / 1000.0, interval.endTime / 1000.0, (interval.endTime - interval.startTime) / 1000.0, ) } ?: Triple(null, null, null) PlaybackAnalyticsContext( songSessionId = c.songSessionId, previousSongSessionId = c.previousSongSessionId, startTime = startTime, endTime = endTime, playDuration = duration, isUserSongOwner = c.playingClipIsOwned, ) } return Json.encodeToJsonElement(context) } private fun makeListeningSourceContextJson( listeningSource: ListeningSourceContext, ) = Json.encodeToJsonElement(listeningSource) private fun newSongSession( clipId: String, isOwned: Boolean, ) { playbackAnalyticsState.update { oldState -> oldState.copy( previousSongSessionId = oldState.songSessionId, songSessionId = UUID.randomUUID().toString(), playingClipId = clipId, playingClipIsOwned = isOwned, ) } } private fun trackSongAnalyticsEvent( actionName: ActionName, interval: Interval? = null, overrideClipId: String? = null, listeningSource: ListeningSourceContext? = null, ) { val playbackContext = makePlaybackAnalyticsContextJson(interval) val listeningSourceContext = listeningSource?.let { makeListeningSourceContextJson(it) as? JsonObject } // Merge listening source context into playback context if given val metadata: JsonElement = when { playbackContext as? JsonObject != null && listeningSourceContext != null -> JsonObject(playbackContext + listeningSourceContext) else -> { playbackContext } } queueAnalyticsEvent( event = prefilledAnalyticsEvent(actionName).copy( elementType = ElementType.None, elementId = overrideClipId ?: playbackAnalyticsState.value.playingClipId, category = Category.AudioPlayer, actionType = ActionType.Event, context = metadata.toString(), ), ) } override fun trackPlayNewSong( clipId: String, cause: NewSongCause, isOwned: Boolean, listeningSource: ListeningSourceContext?, ) { if (cause != NewSongCause.SEEK_TO_START) { newSongSession(clipId, isOwned) } trackSongAnalyticsEvent( actionName = when (cause) { NewSongCause.VANILLA -> ActionName.PlayNewSong NewSongCause.AUTO_ADVANCE -> ActionName.AutoRepeatPlayNewSong NewSongCause.AUTO_REPEAT -> ActionName.AutoRepeatPlaySong NewSongCause.SKIP_FORWARD -> ActionName.ForwardPlayNewSong NewSongCause.SKIP_BACKWARD -> ActionName.BackwardPlayNewSong NewSongCause.SEEK_TO_START -> ActionName.BackwardRepeatSong }, listeningSource = listeningSource, ) } override fun trackPlayNewSongPauseSong( interval: Interval, cause: NewSongCause, listeningSource: ListeningSourceContext?, ) { trackSongAnalyticsEvent( actionName = when (cause) { NewSongCause.VANILLA -> ActionName.PlayNewSongPauseSong NewSongCause.SKIP_FORWARD -> ActionName.ForwardPausePreSong NewSongCause.SKIP_BACKWARD -> ActionName.BackwardPauseSong NewSongCause.AUTO_ADVANCE -> ActionName.SongEnd NewSongCause.AUTO_REPEAT -> ActionName.SongEnd NewSongCause.SEEK_TO_START -> ActionName.BackwardPauseSong }, interval = interval, listeningSource = listeningSource, ) } override fun trackSongEnd( interval: Interval, ) { trackSongAnalyticsEvent(ActionName.SongEnd, interval) } override fun trackPlaySong() { trackSongAnalyticsEvent(ActionName.PlaySong) } override fun trackPauseSong( interval: Interval, ) { trackSongAnalyticsEvent(ActionName.PauseSong, interval) } override fun trackSeekProgressBarPauseSong( interval: Interval, ) { trackSongAnalyticsEvent(ActionName.SeekProgressBarPauseSong, interval) } override fun trackSeekProgressBarPlaySong() { trackSongAnalyticsEvent(ActionName.SeekProgressBarPlaySong) } override fun trackBackwardRepeatSong() { trackSongAnalyticsEvent( actionName = ActionName.BackwardRepeatSong, ) } override fun trackTapLike( clipId: String, ) { queueAnalyticsEvent( event = prefilledAnalyticsEvent( actionName = ActionName.SongLikeTapped, ).copy( elementType = ElementType.Button, elementId = clipId, category = Category.AudioPlayer, actionType = ActionType.Tap, ), ) } override fun trackScreenVisit( screenName: String?, ) { logger.println { "Analytics Tracking Screen Visit: $screenName" } if (isNewLoggingGateEnabled) { screenName?.let { queueAnalyticsEvent( event = prefilledAnalyticsEvent( actionNameString = screenName + SCREEN_VISIT_SUFFIX, ), ) } } else { queueAnalyticsEvent( event = prefilledAnalyticsEvent( actionName = ActionName.ScreenVisit, ).copy( source = screenName ?: "UNKNOWN", ), ) } } private fun trackAppBackground() { queueAnalyticsEvent( event = prefilledAnalyticsEvent(ActionName.AppBackground), ) } private fun trackAppForeground() { queueAnalyticsEvent( event = prefilledAnalyticsEvent(ActionName.AppForeground), ) } private fun trackAppClose() { queueAnalyticsEvent( event = prefilledAnalyticsEvent(ActionName.AppClose), ) } override fun trackGenerationFailed( reason: String?, ) { queueAnalyticsEvent( event = prefilledAnalyticsEvent(ActionName.GenerationFailed).copy( context = Json.encodeToString(ErrorContext(reason = reason)), ), ) } private companion object { private const val SCREEN_VISIT_SUFFIX = "_viewed" private const val ANALYTICS_CHANNEL_CAPACITY = 1000 } }