Skip to content

Commit

Permalink
Technical Analytics: Milestone 2 - Add Ability To Log Feature Flags (o…
Browse files Browse the repository at this point in the history
…ppia#5240)

<!-- READ ME FIRST: Please fill in the explanation section below and
check off every point from the Essential Checklist! -->
## Explanation
<!--
- Explain what your PR does. If this PR fixes an existing bug, please
include
- "Fixes #bugnum:" in the explanation so that GitHub can auto-close the
issue
  - when this PR is merged.
  -->
When merged, this PR will:
- Create a `FeatureFlagLogger.kt` file that aggregates and logs all
feature flags.
- Add a `FeatureFlagLoggerTest.kt` to test the FeatureFlagLogger.
- Add logging logic to the `ApplicationLifecycleObserver.kt` file so
that it is logged when the app is in foreground.

### Screenshot of the FeatureFlagContext log in the Event Logs page
<p align="center">
<img
src="https://github.com/oppia/oppia-android/assets/18438114/894f9251-4b59-41f7-b2b2-3124e430d5bd"
width="280" alt="Screenshot of the Event Logs page showing the
FeatureFlagsEventContext being logged"/>
</p>

## Essential Checklist
<!-- Please tick the relevant boxes by putting an "x" in them. -->
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

## For UI-specific PRs only
<!-- Delete these section if this PR does not include UI-related
changes. -->
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- For PRs introducing new UI elements or color changes, both light and
dark mode screenshots must be included
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing

---------

Co-authored-by: Adhiambo Peres <[email protected]>
Co-authored-by: Ben Henning <[email protected]>
  • Loading branch information
3 people authored Jun 17, 2024
1 parent add9f74 commit b9d9dff
Show file tree
Hide file tree
Showing 18 changed files with 860 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import javax.inject.Inject
import javax.inject.Singleton

private const val SESSION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.session_id"
private const val APP_SESSION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.app_session_id"
private const val INSTALLATION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.installation_id"

/** Controller that handles logging identifiers related operations. */
Expand All @@ -34,14 +35,22 @@ class LoggingIdentifierController @Inject constructor(
private val installationRandomSeed = baseRandom.nextLong()
private val sessionRandomSeed = baseRandom.nextLong()
private val learnerRandomSeed = baseRandom.nextLong()
private val appSessionRandomSeed = baseRandom.nextLong()
private val installationIdRandom by lazy { Random(installationRandomSeed) }
private val sessionIdRandom by lazy { Random(sessionRandomSeed) }
private val learnerIdRandom by lazy { Random(learnerRandomSeed) }
private val appSessionIdRandom by lazy { Random(appSessionRandomSeed) }

private val sessionId by lazy { MutableStateFlow(computeSessionId()) }
private val appSessionId by lazy { MutableStateFlow(computeAppSessionId()) }
private val sessionIdDataProvider by lazy {
dataProviders.run { sessionId.convertToAutomaticDataProvider(SESSION_ID_DATA_PROVIDER_ID) }
}
private val appSessionIdDataProvider by lazy {
dataProviders.run {
appSessionId.convertToAutomaticDataProvider(APP_SESSION_ID_DATA_PROVIDER_ID)
}
}
private val installationIdStore by lazy {
persistentCacheStoreFactory.create(
cacheName = "device_context_database", DeviceContextDatabase.getDefaultInstance()
Expand Down Expand Up @@ -101,6 +110,14 @@ class LoggingIdentifierController @Inject constructor(
*/
fun getSessionId(): DataProvider<String> = sessionIdDataProvider

/**
* Returns an in-memory data provider pointing to a class variable of [appSessionId].
*
* This ID is unique to each app session. A session starts when the app is opened and ends when
* the app is destroyed by the Android system.
*/
fun getAppSessionId(): DataProvider<String> = appSessionIdDataProvider

/**
* Returns the [StateFlow] backing the current session ID indicated by [getSessionId].
*
Expand All @@ -110,6 +127,15 @@ class LoggingIdentifierController @Inject constructor(
*/
fun getSessionIdFlow(): StateFlow<String> = sessionId

/**
* Returns the [StateFlow] backing the current app session ID indicated by [getAppSessionId].
*
* Where the [DataProvider] returned by [getAppSessionId] can be composed by domain controllers or
* observed by the UI layer, the [StateFlow] returned by this method can be observed in background
* contexts.
*/
fun getAppSessionIdFlow(): StateFlow<String> = appSessionId

/**
* Regenerates [sessionId] and notifies the data provider.
*
Expand All @@ -123,6 +149,8 @@ class LoggingIdentifierController @Inject constructor(

private fun computeSessionId(): String = sessionIdRandom.randomUuid().toString()

private fun computeAppSessionId(): String = appSessionIdRandom.randomUuid().toString()

private fun computeInstallationId(): String {
return machineLocale.run {
MessageDigest.getInstance("SHA-1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ApplicationLifecycleObserver @Inject constructor(
private val profileManagementController: ProfileManagementController,
private val oppiaLogger: OppiaLogger,
private val performanceMetricsLogger: PerformanceMetricsLogger,
private val featureFlagsLogger: FeatureFlagsLogger,
private val performanceMetricsController: PerformanceMetricsController,
private val cpuPerformanceSnapshotter: CpuPerformanceSnapshotter,
@LearnerAnalyticsInactivityLimitMillis private val inactivityLimitMillis: Long,
Expand Down Expand Up @@ -84,6 +85,7 @@ class ApplicationLifecycleObserver @Inject constructor(
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
application.registerActivityLifecycleCallbacks(this)
logApplicationStartupMetrics()
logAllFeatureFlags()
cpuPerformanceSnapshotter.initialiseSnapshotter()
}

Expand Down Expand Up @@ -172,6 +174,22 @@ class ApplicationLifecycleObserver @Inject constructor(
}
}

private fun logAllFeatureFlags() {
CoroutineScope(backgroundDispatcher).launch {
// TODO(#5341): Replace appSessionId generation to the modified Twitter snowflake algorithm.
val appSessionId = loggingIdentifierController.getAppSessionIdFlow().value
featureFlagsLogger.logAllFeatureFlags(appSessionId)
}.invokeOnCompletion { failure ->
if (failure != null) {
oppiaLogger.e(
"ActivityLifecycleObserver",
"Encountered error while logging feature flags.",
failure
)
}
}
}

private fun logAppInForegroundTime() {
CoroutineScope(backgroundDispatcher).launch {
val sessionId = loggingIdentifierController.getSessionIdFlow().value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ kt_android_library(
deps = [
":application_lifecycle_listener",
":cpu_performance_snapshotter",
":feature_flags_logger",
":learner_analytics_inactivity_limit_millis",
":performance_metrics_controller",
"//domain/src/main/java/org/oppia/android/domain/oppialogger:logging_identifier_controller",
Expand Down Expand Up @@ -191,6 +192,20 @@ kt_android_library(
visibility = ["//:oppia_api_visibility"],
)

kt_android_library(
name = "feature_flags_logger",
srcs = [
"FeatureFlagsLogger.kt",
],
visibility = ["//:oppia_api_visibility"],
deps = [
"//:dagger",
"//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
"//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:controller",
"//third_party:javax_inject_javax_inject",
],
)

kt_android_library(
name = "data_controller",
srcs = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package org.oppia.android.domain.oppialogger.analytics

import org.oppia.android.app.model.EventLog
import org.oppia.android.app.model.EventLog.FeatureFlagItemContext
import org.oppia.android.app.model.EventLog.FeatureFlagListContext
import org.oppia.android.util.platformparameter.APP_AND_OS_DEPRECATION
import org.oppia.android.util.platformparameter.DOWNLOADS_SUPPORT
import org.oppia.android.util.platformparameter.EDIT_ACCOUNTS_OPTIONS_UI
import org.oppia.android.util.platformparameter.ENABLE_NPS_SURVEY
import org.oppia.android.util.platformparameter.ENABLE_ONBOARDING_FLOW_V2
import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION
import org.oppia.android.util.platformparameter.EXTRA_TOPIC_TABS_UI
import org.oppia.android.util.platformparameter.EnableAppAndOsDeprecation
import org.oppia.android.util.platformparameter.EnableDownloadsSupport
import org.oppia.android.util.platformparameter.EnableEditAccountsOptionsUi
import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi
import org.oppia.android.util.platformparameter.EnableFastLanguageSwitchingInLesson
import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention
import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics
import org.oppia.android.util.platformparameter.EnableLoggingLearnerStudyIds
import org.oppia.android.util.platformparameter.EnableNpsSurvey
import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2
import org.oppia.android.util.platformparameter.EnablePerformanceMetricsCollection
import org.oppia.android.util.platformparameter.EnableSpotlightUi
import org.oppia.android.util.platformparameter.FAST_LANGUAGE_SWITCHING_IN_LESSON
import org.oppia.android.util.platformparameter.INTERACTION_CONFIG_CHANGE_STATE_RETENTION
import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS
import org.oppia.android.util.platformparameter.LOGGING_LEARNER_STUDY_IDS
import org.oppia.android.util.platformparameter.PlatformParameterValue
import org.oppia.android.util.platformparameter.SPOTLIGHT_UI
import javax.inject.Inject
import javax.inject.Singleton

/**
* Convenience logger for feature flags.
*
* This logger is meant to be used for feature flag-related logging on every app launch. It is
* primarily used within the ApplicationLifecycleObserver to log the status of feature flags in a
* given app session.
*/
@Singleton
class FeatureFlagsLogger @Inject constructor(
private val analyticsController: AnalyticsController,
@EnableDownloadsSupport
private val enableDownloadsSupport: PlatformParameterValue<Boolean>,
@EnableExtraTopicTabsUi
private val enableExtraTopicTabsUi: PlatformParameterValue<Boolean>,
@EnableLearnerStudyAnalytics
private val enableLearnerStudyAnalytics: PlatformParameterValue<Boolean>,
@EnableFastLanguageSwitchingInLesson
private val enableFastLanguageSwitchingInLesson: PlatformParameterValue<Boolean>,
@EnableLoggingLearnerStudyIds
private val enableLoggingLearnerStudyIds: PlatformParameterValue<Boolean>,
@EnableEditAccountsOptionsUi
private val enableEditAccountsOptionsUi: PlatformParameterValue<Boolean>,
@EnablePerformanceMetricsCollection
private val enablePerformanceMetricsCollection: PlatformParameterValue<Boolean>,
@EnableSpotlightUi
private val enableSpotlightUi: PlatformParameterValue<Boolean>,
@EnableInteractionConfigChangeStateRetention
private val enableInteractionConfigChangeStateRetention: PlatformParameterValue<Boolean>,
@EnableAppAndOsDeprecation
private val enableAppAndOsDeprecation: PlatformParameterValue<Boolean>,
@EnableNpsSurvey
private val enableNpsSurvey: PlatformParameterValue<Boolean>,
@EnableOnboardingFlowV2
private val enableOnboardingFlowV2: PlatformParameterValue<Boolean>
) {
/**
* A variable containing a list of all the feature flags in the app.
*
* @return a list of key-value pairs of [String] and [PlatformParameterValue]
*/
private var featureFlagItemMap: Map<String, PlatformParameterValue<Boolean>> = mapOf(
DOWNLOADS_SUPPORT to enableDownloadsSupport,
EXTRA_TOPIC_TABS_UI to enableExtraTopicTabsUi,
LEARNER_STUDY_ANALYTICS to enableLearnerStudyAnalytics,
FAST_LANGUAGE_SWITCHING_IN_LESSON to enableFastLanguageSwitchingInLesson,
LOGGING_LEARNER_STUDY_IDS to enableLoggingLearnerStudyIds,
EDIT_ACCOUNTS_OPTIONS_UI to enableEditAccountsOptionsUi,
ENABLE_PERFORMANCE_METRICS_COLLECTION to enablePerformanceMetricsCollection,
SPOTLIGHT_UI to enableSpotlightUi,
INTERACTION_CONFIG_CHANGE_STATE_RETENTION to enableInteractionConfigChangeStateRetention,
APP_AND_OS_DEPRECATION to enableAppAndOsDeprecation,
ENABLE_NPS_SURVEY to enableNpsSurvey,
ENABLE_ONBOARDING_FLOW_V2 to enableOnboardingFlowV2
)

/**
* This method can be used to override the featureFlagItemMap and sets its value to the given map.
*
* @param featureFlagItemMap denotes the map of feature flag names to their corresponding
* [PlatformParameterValue]s
*/
fun setFeatureFlagItemMap(featureFlagItemMap: Map<String, PlatformParameterValue<Boolean>>) {
this.featureFlagItemMap = featureFlagItemMap
}

/**
* This method logs the name, enabled status and sync status of all feature flags to Firebase.
*
* @param appSessionId denotes the id of the current appInForeground session
*/
fun logAllFeatureFlags(appSessionId: String) {
val featureFlagItemList = mutableListOf<FeatureFlagItemContext>()
for (flag in featureFlagItemMap) {
featureFlagItemList.add(
createFeatureFlagItemContext(flag)
)
}

// TODO(#5341): Set the UUID value for this context
val featureFlagContext = FeatureFlagListContext.newBuilder()
.setAppSessionId(appSessionId)
.addAllFeatureFlags(featureFlagItemList)
.build()

analyticsController.logLowPriorityEvent(
EventLog.Context.newBuilder()
.setFeatureFlagListContext(featureFlagContext)
.build(),
profileId = null
)
}

/**
* Creates an [EventLog] context for the feature flags to be logged.
*
* @param flagDetails denotes the key-value pair of the feature flag name and its corresponding
* [PlatformParameterValue]
* @return an [EventLog.Context] for the feature flags to be logged
*/
private fun createFeatureFlagItemContext(
flagDetails: Map.Entry<String, PlatformParameterValue<Boolean>>,
): FeatureFlagItemContext {
return FeatureFlagItemContext.newBuilder()
.setFlagName(flagDetails.key)
.setFlagEnabledState(flagDetails.value.value)
.setFlagSyncStatus(flagDetails.value.syncStatus)
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,58 @@ class LoggingIdentifierControllerTest {
assertThat(sessionIdFlow.value).isEqualTo("59aea8d4-af4b-3249-b889-dfeba06d0495")
}

@Test
fun testGetAppSessionId_initialState_returnsRandomId() {
val appSessionIdProvider = loggingIdentifierController.getAppSessionId()

val appSessionId = monitorFactory.waitForNextSuccessfulResult(appSessionIdProvider)
assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14")
}

@Test
fun testGetAppSessionId_secondCall_returnsSameRandomId() {
monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getAppSessionId())

val sessionIdProvider = loggingIdentifierController.getAppSessionId()

// The second call should return the same ID (since the ID doesn't automatically change).
val appSessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider)
assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14")
}

@Test
fun testGetAppSessionIdFlow_initialState_returnsFlowWithRandomId() {
val appSessionIdFlow = loggingIdentifierController.getAppSessionIdFlow()

val appSessionId = appSessionIdFlow.waitForLatestValue()
assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14")
}

@Test
fun testGetAppSessionIdFlow_secondCall_returnsFlowWithSameRandomId() {
loggingIdentifierController.getSessionIdFlow().waitForLatestValue()

val appSessionIdFlow = loggingIdentifierController.getAppSessionIdFlow()

// The second call should return the same ID (since the ID doesn't automatically change).
val appSessionId = appSessionIdFlow.waitForLatestValue()
assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14")
}

@Test
fun testGetAppSessionId_onSecondAppOpen_returnsDifferentRandomId() {
monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getAppSessionId())

// Simulate a second app open.
TestLoggingIdentifierModule.applicationIdSeed = SECOND_APP_OPEN_APPLICATION_ID
setUpNewTestApplicationComponent()

// The app session ID should be different on the second app open.
val appSessionIdProvider = loggingIdentifierController.getAppSessionId()
val appSessionId = monitorFactory.waitForNextSuccessfulResult(appSessionIdProvider)
assertThat(appSessionId).isEqualTo("c9d50545-33dc-3231-a1db-6a2672498c74")
}

private fun <T : MessageLite> writeFileCache(cacheName: String, value: T) {
getCacheFile(cacheName).writeBytes(value.toByteArray())
}
Expand Down
Loading

0 comments on commit b9d9dff

Please sign in to comment.