diff --git a/.circleci/config.yml b/.circleci/config.yml index 2b85225f9c..4d2aa82dc0 100755 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -601,8 +601,6 @@ jobs: - run: name: Check for External Config Update command: | - git checkout main - git pull origin main cp .env.sample .env touch ./experimenter/fetch-summary.txt env GITHUB_BEARER_TOKEN="${GH_EXTERNAL_CONFIG_TOKEN}" make fetch_external_resources FETCH_ARGS="--summary fetch-summary.txt" @@ -610,14 +608,14 @@ jobs: echo -e "\nCircle CI Task: ${CIRCLE_BUILD_URL}" >> /tmp/pr-body.txt if python3 ./experimenter/bin/should-pr.py then - git checkout -B external-config + git checkout -B mibrahim-external-config-test git add . git commit -m 'chore(nimbus): Update External Configs' - if (($((git diff external-config origin/external-config || git diff HEAD~1) | wc -c) > 0)) + if (($((git diff mibrahim-external-config-test origin/mibrahim-external-config-test || git diff HEAD~1) | wc -c) > 0)) then - git push origin external-config -f - gh pr create -t "chore(nimbus): Update External Configs" -F /tmp/pr-body.txt --base main --head external-config --repo mozilla/experimenter || \ - gh pr edit external-config -F /tmp/pr-body.txt + git push origin mibrahim-external-config-test -f + gh pr create -t "chore(nimbus): Update External Configs" -F /tmp/pr-body.txt --base main --head mibrahim-external-config-test --repo mozilla/experimenter || \ + gh pr edit mibrahim-external-config-test -F /tmp/pr-body.txt else echo "Changes already committed, skipping" fi @@ -886,3 +884,9 @@ workflows: filters: branches: only: main + - update_external_configs: + name: Update External Configs + filters: + branches: + ignore: + - main diff --git a/experimenter/experimenter/features/manifests/apps.yaml b/experimenter/experimenter/features/manifests/apps.yaml index 9b5aa979df..b2149b94ab 100644 --- a/experimenter/experimenter/features/manifests/apps.yaml +++ b/experimenter/experimenter/features/manifests/apps.yaml @@ -18,6 +18,8 @@ fenix: - "main" - "beta" - "release" + targeting_files: + - "mobile/android/fenix/app/src/main/java/org/mozilla/fenix/experiments/RecordedNimbusContext.kt" firefox_ios: slug: "ios" @@ -42,6 +44,8 @@ firefox_ios: branch_re: 'release/v(?P\d+)(?:\.(?P\d+))?' tag_re: 'firefox-v(?P\d+)\.(?P\d+)' - type: "branched" + targeting_files: + - "firefox-ios/Client/Experiments/RecordedNimbusContext.swift" monitor_cirrus: slug: "monitor-web" @@ -85,6 +89,8 @@ firefox_desktop: - "esr115" - "esr128" - "esr140" + targeting_files: + - "toolkit/components/nimbus/lib/TargetingContextRecorder.sys.mjs" experimenter_cirrus: slug: "experimenter" diff --git a/experimenter/experimenter/features/manifests/fenix/.ref-cache.yaml b/experimenter/experimenter/features/manifests/fenix/.ref-cache.yaml index 3244d5bd86..213aceb2f1 100644 --- a/experimenter/experimenter/features/manifests/fenix/.ref-cache.yaml +++ b/experimenter/experimenter/features/manifests/fenix/.ref-cache.yaml @@ -1,3 +1,3 @@ beta: 511b1cd099f9120d0753fcc110df4a355f7a88c3 -main: 403431c6882c8de419338a19469f862f22f8095b +main: 6f66908ea0c0f0b51c134ff1e6646a0c7fb8e82b release: c7fa3c91990bac266ee99a9e31863c202469f369 diff --git a/experimenter/experimenter/features/manifests/fenix/RecordedNimbusContext.kt b/experimenter/experimenter/features/manifests/fenix/RecordedNimbusContext.kt new file mode 100644 index 0000000000..6f300e3f69 --- /dev/null +++ b/experimenter/experimenter/features/manifests/fenix/RecordedNimbusContext.kt @@ -0,0 +1,283 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.experiments + +import android.content.Context +import android.os.Build +import androidx.annotation.VisibleForTesting +import mozilla.components.support.locale.LocaleManager +import mozilla.components.support.locale.LocaleManager.getSystemDefault +import mozilla.components.support.utils.ext.packageManagerCompatHelper +import org.json.JSONArray +import org.json.JSONObject +import org.mozilla.experiments.nimbus.NIMBUS_DATA_DIR +import org.mozilla.experiments.nimbus.NimbusDeviceInfo +import org.mozilla.experiments.nimbus.internal.JsonObject +import org.mozilla.experiments.nimbus.internal.RecordedContext +import org.mozilla.experiments.nimbus.internal.getCalculatedAttributes +import org.mozilla.fenix.GleanMetrics.NimbusSystem +import org.mozilla.fenix.GleanMetrics.Pings +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.home.pocket.ContentRecommendationsFeatureHelper +import org.mozilla.fenix.termsofuse.experimentation.TermsOfUseAdvancedTargetingHelper +import org.mozilla.fenix.termsofuse.experimentation.utils.DefaultTermsOfUseDataProvider +import org.mozilla.fenix.utils.Settings +import java.io.File + +/** + * The following constants are string constants of the keys that appear in the [EVENT_QUERIES] map. + */ +const val DAYS_OPENED_IN_LAST_28 = "days_opened_in_last_28" + +/** + * [EVENT_QUERIES] is a map of keys to Nimbus SDK EventStore queries. + */ +private val EVENT_QUERIES = mapOf( + DAYS_OPENED_IN_LAST_28 to "'events.app_opened'|eventCountNonZero('Days', 28, 0)", +) + +/** + * The RecordedNimbusContext class inherits from an internal Nimbus interface that provides methods + * for obtaining a JSON value for the object and recording the object's value to Glean. Its JSON + * value is loaded into the Nimbus targeting context. + * + * The value recorded to Glean is used to automate population sizing. Any additions to this object + * require a new data review for the `nimbus_system.recorded_nimbus_context` metric. + */ +@Suppress("complexity:LongParameterList") +class RecordedNimbusContext( + val isFirstRun: Boolean, + private val eventQueries: Map = mapOf(), + private var eventQueryValues: Map = mapOf(), + val utmSource: String, + val utmMedium: String, + val utmCampaign: String, + val utmTerm: String, + val utmContent: String, + val androidSdkVersion: String = Build.VERSION.SDK_INT.toString(), + val appVersion: String?, + val locale: String, + val daysSinceInstall: Int?, + val daysSinceUpdate: Int?, + val language: String?, + val region: String?, + val deviceManufacturer: String = Build.MANUFACTURER, + val deviceModel: String = Build.MODEL, + val userAcceptedTou: Boolean, + val noShortcutsOrStoriesOptOuts: Boolean, + val addonIds: List, + val touPoints: Int?, +) : RecordedContext { + /** + * [getEventQueries] is called by the Nimbus SDK Rust code to retrieve the map of event + * queries. The are then executed against the Nimbus SDK's EventStore to retrieve their values. + * + * @return Map + */ + override fun getEventQueries(): Map { + return eventQueries + } + + /** + * [record] is called when experiment enrollments are evolved. It should apply the + * [RecordedNimbusContext]'s values to a [NimbusSystem.RecordedNimbusContextObject] instance, + * and use that instance to record the values to Glean. + */ + override fun record() { + val eventQueryValuesObject = NimbusSystem.RecordedNimbusContextObjectItemEventQueryValuesObject( + daysOpenedInLast28 = eventQueryValues[DAYS_OPENED_IN_LAST_28]?.toInt(), + ) + NimbusSystem.recordedNimbusContext.set( + NimbusSystem.RecordedNimbusContextObject( + isFirstRun = isFirstRun, + eventQueryValues = eventQueryValuesObject, + installReferrerResponseUtmSource = utmSource, + installReferrerResponseUtmMedium = utmMedium, + installReferrerResponseUtmCampaign = utmCampaign, + installReferrerResponseUtmTerm = utmTerm, + installReferrerResponseUtmContent = utmContent, + androidSdkVersion = androidSdkVersion, + appVersion = appVersion, + locale = locale, + daysSinceInstall = daysSinceInstall, + daysSinceUpdate = daysSinceUpdate, + language = language, + region = region, + deviceManufacturer = deviceManufacturer, + deviceModel = deviceModel, + userAcceptedTou = userAcceptedTou, + noShortcutsOrStoriesOptOuts = noShortcutsOrStoriesOptOuts, + addonIds = NimbusSystem.RecordedNimbusContextObjectAddonIds(addonIds.toMutableList()), + touPoints = touPoints, + ), + ) + Pings.nimbus.submit() + } + + /** + * [setEventQueryValues] is called by the Nimbus SDK Rust code after the event queries have been + * executed. The [eventQueryValues] should be written back to the Kotlin object. + * + * @param [eventQueryValues] The values for each query after they have been executed in the + * Nimbus SDK Rust environment. + */ + override fun setEventQueryValues(eventQueryValues: Map) { + this.eventQueryValues = eventQueryValues + } + + /** + * [toJson] is called by the Nimbus SDK Rust code after the event queries have been executed, + * and before experiment enrollments have been evolved. The value returned from this method + * will be applied directly to the Nimbus targeting context, and its keys/values take + * precedence over those in the main Nimbus targeting context. + * + * @return JSONObject + */ + override fun toJson(): JsonObject { + val obj = JSONObject( + mapOf( + "is_first_run" to isFirstRun, + "events" to JSONObject(eventQueryValues), + "install_referrer_response_utm_source" to utmSource, + "install_referrer_response_utm_medium" to utmMedium, + "install_referrer_response_utm_campaign" to utmCampaign, + "install_referrer_response_utm_term" to utmTerm, + "install_referrer_response_utm_content" to utmContent, + "android_sdk_version" to androidSdkVersion, + "app_version" to appVersion, + "locale" to locale, + "days_since_install" to daysSinceInstall, + "days_since_update" to daysSinceUpdate, + "language" to language, + "region" to region, + "device_manufacturer" to deviceManufacturer, + "device_model" to deviceModel, + "user_accepted_tou" to userAcceptedTou, + "no_shortcuts_or_stories_opt_outs" to noShortcutsOrStoriesOptOuts, + "addon_ids" to JSONArray(addonIds), + "tou_points" to touPoints, + ), + ) + return obj + } + + /** + * Companion object for RecordedNimbusContext + */ + companion object { + + /** + * Creates a RecordedNimbusContext instance, populated with the application-defined + * eventQueries + * + * @return RecordedNimbusContext + */ + fun create( + context: Context, + isFirstRun: Boolean, + ): RecordedNimbusContext { + val settings = context.settings() + val langTag = LocaleManager.getCurrentLocale(context) + ?.toLanguageTag() ?: getSystemDefault().toLanguageTag() + val termsOfUseAdvancedTargetingHelper = TermsOfUseAdvancedTargetingHelper( + DefaultTermsOfUseDataProvider(settings), + langTag, + ) + + val packageInfo = + context.packageManagerCompatHelper.getPackageInfoCompat(context.packageName, 0) + val deviceInfo = NimbusDeviceInfo.default() + val db = File(context.applicationInfo.dataDir, NIMBUS_DATA_DIR) + val calculatedAttributes = getCalculatedAttributes( + packageInfo.firstInstallTime, + db.path, + deviceInfo.localeTag, + ) + + return RecordedNimbusContext( + isFirstRun = isFirstRun, + eventQueries = EVENT_QUERIES, + utmSource = settings.utmSource, + utmMedium = settings.utmMedium, + utmCampaign = settings.utmCampaign, + utmTerm = settings.utmTerm, + utmContent = settings.utmContent, + appVersion = packageInfo.versionName, + locale = deviceInfo.localeTag, + daysSinceInstall = calculatedAttributes.daysSinceInstall, + daysSinceUpdate = calculatedAttributes.daysSinceUpdate, + language = calculatedAttributes.language, + region = calculatedAttributes.region, + userAcceptedTou = settings.hasAcceptedTermsOfService, + noShortcutsOrStoriesOptOuts = settings.noShortcutsOrStoriesOptOuts(context), + addonIds = getFormattedAddons(settings), + touPoints = termsOfUseAdvancedTargetingHelper.getTouPoints(), + ) + } + + @VisibleForTesting + internal fun getFormattedAddons(settings: Settings): List = + settings.installedAddonsList.split(",").map { it.trim() } + + /** + * Checks whether an eligible user has opted out of any sponsored top sites or stories. + * + * @return `true` if the user has opted out of any sponsored top sites or stories, + * `false` otherwise. + */ + private fun Settings.noShortcutsOrStoriesOptOuts(context: Context) = + !optedOutOfSponsoredTopSites() && !optedOutOfSponsoredStories(context) + + /** + * Checks whether an eligible user has opted out of the sponsored top sites feature. + * + * This is not entirely self evident from the API descriptions, please note: + * [Settings.showContileFeature] indicates whether the sponsored shortcuts are shown. + * [Settings.showTopSitesFeature] indicates whether the feature should be shown at all. + */ + private fun Settings.optedOutOfSponsoredTopSites() = + !showContileFeature || !showTopSitesFeature + + private fun Settings.optedOutOfSponsoredStories(context: Context) = + isEligibleForStories(context) && (!showPocketSponsoredStories || !showPocketRecommendationsFeature) + + private fun isEligibleForStories(context: Context): Boolean = + ContentRecommendationsFeatureHelper.isContentRecommendationsFeatureEnabled(context) + + /** + * Creates a RecordedNimbusContext instance for test purposes + * + * @return RecordedNimbusContext + */ + @VisibleForTesting + internal fun createForTest( + isFirstRun: Boolean = false, + eventQueries: Map = EVENT_QUERIES, + eventQueryValues: Map = mapOf(), + addonIds: List = emptyList(), + ): RecordedNimbusContext { + return RecordedNimbusContext( + isFirstRun = isFirstRun, + eventQueries = eventQueries, + eventQueryValues = eventQueryValues, + utmSource = "", + utmMedium = "", + utmCampaign = "", + utmTerm = "", + utmContent = "", + appVersion = "", + locale = "", + daysSinceInstall = 5, + daysSinceUpdate = 0, + language = "en", + region = "US", + userAcceptedTou = true, + noShortcutsOrStoriesOptOuts = true, + addonIds = addonIds, + touPoints = 3, + ) + } + } +} diff --git a/experimenter/experimenter/features/manifests/fenix/beta.fml.yaml b/experimenter/experimenter/features/manifests/fenix/beta.fml.yaml index b9eab6f736..0c9dda88e3 100644 --- a/experimenter/experimenter/features/manifests/fenix/beta.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/beta.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -799,6 +798,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1129,8 +1135,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/developer.fml.yaml b/experimenter/experimenter/features/manifests/fenix/developer.fml.yaml index b7a566bd60..7b0d654322 100644 --- a/experimenter/experimenter/features/manifests/fenix/developer.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/developer.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -825,6 +824,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1155,8 +1161,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/experimenter.yaml b/experimenter/experimenter/features/manifests/fenix/experimenter.yaml index 19a424f05f..5086ab142b 100644 --- a/experimenter/experimenter/features/manifests/fenix/experimenter.yaml +++ b/experimenter/experimenter/features/manifests/fenix/experimenter.yaml @@ -548,6 +548,14 @@ private-browsing-lock: type: boolean description: | Whether or not to enable private browsing mode lock. +private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + hasExposure: true + exposureDescription: '' + variables: + enabled: + type: boolean + description: If true, Private Mode and Stories Entry Point Experiment is enabled. remote-search-configuration: description: Feature to use search configurations from remote servers. hasExposure: true diff --git a/experimenter/experimenter/features/manifests/fenix/nightly.fml.yaml b/experimenter/experimenter/features/manifests/fenix/nightly.fml.yaml index f61ef22dde..ba79d9e1d0 100644 --- a/experimenter/experimenter/features/manifests/fenix/nightly.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/nightly.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -822,6 +821,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1152,8 +1158,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/release.fml.yaml b/experimenter/experimenter/features/manifests/fenix/release.fml.yaml index 2414a5ea4c..0426b0398b 100644 --- a/experimenter/experimenter/features/manifests/fenix/release.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/release.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -799,6 +798,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1129,8 +1135,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/v150.0.0/RecordedNimbusContext.kt b/experimenter/experimenter/features/manifests/fenix/v150.0.0/RecordedNimbusContext.kt new file mode 100644 index 0000000000..6f300e3f69 --- /dev/null +++ b/experimenter/experimenter/features/manifests/fenix/v150.0.0/RecordedNimbusContext.kt @@ -0,0 +1,283 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.experiments + +import android.content.Context +import android.os.Build +import androidx.annotation.VisibleForTesting +import mozilla.components.support.locale.LocaleManager +import mozilla.components.support.locale.LocaleManager.getSystemDefault +import mozilla.components.support.utils.ext.packageManagerCompatHelper +import org.json.JSONArray +import org.json.JSONObject +import org.mozilla.experiments.nimbus.NIMBUS_DATA_DIR +import org.mozilla.experiments.nimbus.NimbusDeviceInfo +import org.mozilla.experiments.nimbus.internal.JsonObject +import org.mozilla.experiments.nimbus.internal.RecordedContext +import org.mozilla.experiments.nimbus.internal.getCalculatedAttributes +import org.mozilla.fenix.GleanMetrics.NimbusSystem +import org.mozilla.fenix.GleanMetrics.Pings +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.home.pocket.ContentRecommendationsFeatureHelper +import org.mozilla.fenix.termsofuse.experimentation.TermsOfUseAdvancedTargetingHelper +import org.mozilla.fenix.termsofuse.experimentation.utils.DefaultTermsOfUseDataProvider +import org.mozilla.fenix.utils.Settings +import java.io.File + +/** + * The following constants are string constants of the keys that appear in the [EVENT_QUERIES] map. + */ +const val DAYS_OPENED_IN_LAST_28 = "days_opened_in_last_28" + +/** + * [EVENT_QUERIES] is a map of keys to Nimbus SDK EventStore queries. + */ +private val EVENT_QUERIES = mapOf( + DAYS_OPENED_IN_LAST_28 to "'events.app_opened'|eventCountNonZero('Days', 28, 0)", +) + +/** + * The RecordedNimbusContext class inherits from an internal Nimbus interface that provides methods + * for obtaining a JSON value for the object and recording the object's value to Glean. Its JSON + * value is loaded into the Nimbus targeting context. + * + * The value recorded to Glean is used to automate population sizing. Any additions to this object + * require a new data review for the `nimbus_system.recorded_nimbus_context` metric. + */ +@Suppress("complexity:LongParameterList") +class RecordedNimbusContext( + val isFirstRun: Boolean, + private val eventQueries: Map = mapOf(), + private var eventQueryValues: Map = mapOf(), + val utmSource: String, + val utmMedium: String, + val utmCampaign: String, + val utmTerm: String, + val utmContent: String, + val androidSdkVersion: String = Build.VERSION.SDK_INT.toString(), + val appVersion: String?, + val locale: String, + val daysSinceInstall: Int?, + val daysSinceUpdate: Int?, + val language: String?, + val region: String?, + val deviceManufacturer: String = Build.MANUFACTURER, + val deviceModel: String = Build.MODEL, + val userAcceptedTou: Boolean, + val noShortcutsOrStoriesOptOuts: Boolean, + val addonIds: List, + val touPoints: Int?, +) : RecordedContext { + /** + * [getEventQueries] is called by the Nimbus SDK Rust code to retrieve the map of event + * queries. The are then executed against the Nimbus SDK's EventStore to retrieve their values. + * + * @return Map + */ + override fun getEventQueries(): Map { + return eventQueries + } + + /** + * [record] is called when experiment enrollments are evolved. It should apply the + * [RecordedNimbusContext]'s values to a [NimbusSystem.RecordedNimbusContextObject] instance, + * and use that instance to record the values to Glean. + */ + override fun record() { + val eventQueryValuesObject = NimbusSystem.RecordedNimbusContextObjectItemEventQueryValuesObject( + daysOpenedInLast28 = eventQueryValues[DAYS_OPENED_IN_LAST_28]?.toInt(), + ) + NimbusSystem.recordedNimbusContext.set( + NimbusSystem.RecordedNimbusContextObject( + isFirstRun = isFirstRun, + eventQueryValues = eventQueryValuesObject, + installReferrerResponseUtmSource = utmSource, + installReferrerResponseUtmMedium = utmMedium, + installReferrerResponseUtmCampaign = utmCampaign, + installReferrerResponseUtmTerm = utmTerm, + installReferrerResponseUtmContent = utmContent, + androidSdkVersion = androidSdkVersion, + appVersion = appVersion, + locale = locale, + daysSinceInstall = daysSinceInstall, + daysSinceUpdate = daysSinceUpdate, + language = language, + region = region, + deviceManufacturer = deviceManufacturer, + deviceModel = deviceModel, + userAcceptedTou = userAcceptedTou, + noShortcutsOrStoriesOptOuts = noShortcutsOrStoriesOptOuts, + addonIds = NimbusSystem.RecordedNimbusContextObjectAddonIds(addonIds.toMutableList()), + touPoints = touPoints, + ), + ) + Pings.nimbus.submit() + } + + /** + * [setEventQueryValues] is called by the Nimbus SDK Rust code after the event queries have been + * executed. The [eventQueryValues] should be written back to the Kotlin object. + * + * @param [eventQueryValues] The values for each query after they have been executed in the + * Nimbus SDK Rust environment. + */ + override fun setEventQueryValues(eventQueryValues: Map) { + this.eventQueryValues = eventQueryValues + } + + /** + * [toJson] is called by the Nimbus SDK Rust code after the event queries have been executed, + * and before experiment enrollments have been evolved. The value returned from this method + * will be applied directly to the Nimbus targeting context, and its keys/values take + * precedence over those in the main Nimbus targeting context. + * + * @return JSONObject + */ + override fun toJson(): JsonObject { + val obj = JSONObject( + mapOf( + "is_first_run" to isFirstRun, + "events" to JSONObject(eventQueryValues), + "install_referrer_response_utm_source" to utmSource, + "install_referrer_response_utm_medium" to utmMedium, + "install_referrer_response_utm_campaign" to utmCampaign, + "install_referrer_response_utm_term" to utmTerm, + "install_referrer_response_utm_content" to utmContent, + "android_sdk_version" to androidSdkVersion, + "app_version" to appVersion, + "locale" to locale, + "days_since_install" to daysSinceInstall, + "days_since_update" to daysSinceUpdate, + "language" to language, + "region" to region, + "device_manufacturer" to deviceManufacturer, + "device_model" to deviceModel, + "user_accepted_tou" to userAcceptedTou, + "no_shortcuts_or_stories_opt_outs" to noShortcutsOrStoriesOptOuts, + "addon_ids" to JSONArray(addonIds), + "tou_points" to touPoints, + ), + ) + return obj + } + + /** + * Companion object for RecordedNimbusContext + */ + companion object { + + /** + * Creates a RecordedNimbusContext instance, populated with the application-defined + * eventQueries + * + * @return RecordedNimbusContext + */ + fun create( + context: Context, + isFirstRun: Boolean, + ): RecordedNimbusContext { + val settings = context.settings() + val langTag = LocaleManager.getCurrentLocale(context) + ?.toLanguageTag() ?: getSystemDefault().toLanguageTag() + val termsOfUseAdvancedTargetingHelper = TermsOfUseAdvancedTargetingHelper( + DefaultTermsOfUseDataProvider(settings), + langTag, + ) + + val packageInfo = + context.packageManagerCompatHelper.getPackageInfoCompat(context.packageName, 0) + val deviceInfo = NimbusDeviceInfo.default() + val db = File(context.applicationInfo.dataDir, NIMBUS_DATA_DIR) + val calculatedAttributes = getCalculatedAttributes( + packageInfo.firstInstallTime, + db.path, + deviceInfo.localeTag, + ) + + return RecordedNimbusContext( + isFirstRun = isFirstRun, + eventQueries = EVENT_QUERIES, + utmSource = settings.utmSource, + utmMedium = settings.utmMedium, + utmCampaign = settings.utmCampaign, + utmTerm = settings.utmTerm, + utmContent = settings.utmContent, + appVersion = packageInfo.versionName, + locale = deviceInfo.localeTag, + daysSinceInstall = calculatedAttributes.daysSinceInstall, + daysSinceUpdate = calculatedAttributes.daysSinceUpdate, + language = calculatedAttributes.language, + region = calculatedAttributes.region, + userAcceptedTou = settings.hasAcceptedTermsOfService, + noShortcutsOrStoriesOptOuts = settings.noShortcutsOrStoriesOptOuts(context), + addonIds = getFormattedAddons(settings), + touPoints = termsOfUseAdvancedTargetingHelper.getTouPoints(), + ) + } + + @VisibleForTesting + internal fun getFormattedAddons(settings: Settings): List = + settings.installedAddonsList.split(",").map { it.trim() } + + /** + * Checks whether an eligible user has opted out of any sponsored top sites or stories. + * + * @return `true` if the user has opted out of any sponsored top sites or stories, + * `false` otherwise. + */ + private fun Settings.noShortcutsOrStoriesOptOuts(context: Context) = + !optedOutOfSponsoredTopSites() && !optedOutOfSponsoredStories(context) + + /** + * Checks whether an eligible user has opted out of the sponsored top sites feature. + * + * This is not entirely self evident from the API descriptions, please note: + * [Settings.showContileFeature] indicates whether the sponsored shortcuts are shown. + * [Settings.showTopSitesFeature] indicates whether the feature should be shown at all. + */ + private fun Settings.optedOutOfSponsoredTopSites() = + !showContileFeature || !showTopSitesFeature + + private fun Settings.optedOutOfSponsoredStories(context: Context) = + isEligibleForStories(context) && (!showPocketSponsoredStories || !showPocketRecommendationsFeature) + + private fun isEligibleForStories(context: Context): Boolean = + ContentRecommendationsFeatureHelper.isContentRecommendationsFeatureEnabled(context) + + /** + * Creates a RecordedNimbusContext instance for test purposes + * + * @return RecordedNimbusContext + */ + @VisibleForTesting + internal fun createForTest( + isFirstRun: Boolean = false, + eventQueries: Map = EVENT_QUERIES, + eventQueryValues: Map = mapOf(), + addonIds: List = emptyList(), + ): RecordedNimbusContext { + return RecordedNimbusContext( + isFirstRun = isFirstRun, + eventQueries = eventQueries, + eventQueryValues = eventQueryValues, + utmSource = "", + utmMedium = "", + utmCampaign = "", + utmTerm = "", + utmContent = "", + appVersion = "", + locale = "", + daysSinceInstall = 5, + daysSinceUpdate = 0, + language = "en", + region = "US", + userAcceptedTou = true, + noShortcutsOrStoriesOptOuts = true, + addonIds = addonIds, + touPoints = 3, + ) + } + } +} diff --git a/experimenter/experimenter/features/manifests/fenix/v150.0.0/beta.fml.yaml b/experimenter/experimenter/features/manifests/fenix/v150.0.0/beta.fml.yaml index b9eab6f736..0c9dda88e3 100644 --- a/experimenter/experimenter/features/manifests/fenix/v150.0.0/beta.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/v150.0.0/beta.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -799,6 +798,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1129,8 +1135,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/v150.0.0/developer.fml.yaml b/experimenter/experimenter/features/manifests/fenix/v150.0.0/developer.fml.yaml index b7a566bd60..7b0d654322 100644 --- a/experimenter/experimenter/features/manifests/fenix/v150.0.0/developer.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/v150.0.0/developer.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -825,6 +824,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1155,8 +1161,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/v150.0.0/experimenter.yaml b/experimenter/experimenter/features/manifests/fenix/v150.0.0/experimenter.yaml index 19a424f05f..5086ab142b 100644 --- a/experimenter/experimenter/features/manifests/fenix/v150.0.0/experimenter.yaml +++ b/experimenter/experimenter/features/manifests/fenix/v150.0.0/experimenter.yaml @@ -548,6 +548,14 @@ private-browsing-lock: type: boolean description: | Whether or not to enable private browsing mode lock. +private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + hasExposure: true + exposureDescription: '' + variables: + enabled: + type: boolean + description: If true, Private Mode and Stories Entry Point Experiment is enabled. remote-search-configuration: description: Feature to use search configurations from remote servers. hasExposure: true diff --git a/experimenter/experimenter/features/manifests/fenix/v150.0.0/nightly.fml.yaml b/experimenter/experimenter/features/manifests/fenix/v150.0.0/nightly.fml.yaml index f61ef22dde..ba79d9e1d0 100644 --- a/experimenter/experimenter/features/manifests/fenix/v150.0.0/nightly.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/v150.0.0/nightly.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -822,6 +821,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1152,8 +1158,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/fenix/v150.0.0/release.fml.yaml b/experimenter/experimenter/features/manifests/fenix/v150.0.0/release.fml.yaml index 2414a5ea4c..0426b0398b 100644 --- a/experimenter/experimenter/features/manifests/fenix/v150.0.0/release.fml.yaml +++ b/experimenter/experimenter/features/manifests/fenix/v150.0.0/release.fml.yaml @@ -281,7 +281,6 @@ features: description: This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default. type: Map default: - header: true top-sites: true jump-back-in: true bookmarks: true @@ -799,6 +798,13 @@ features: Whether or not to enable private browsing mode lock. type: Boolean default: true + private-mode-and-stories-entry-point: + description: Feature to enable the Private Mode and Stories Entry Point Experiment. + variables: + enabled: + description: If true, Private Mode and Stories Entry Point Experiment is enabled. + type: Boolean + default: false remote-search-configuration: description: Feature to use search configurations from remote servers. variables: @@ -1129,8 +1135,6 @@ enums: description: The sites the user has bookmarked. collections: description: The collections section of the homepage. - header: - description: The header of the homescreen. jump-back-in: description: The tabs the user was looking immediately before being interrupted. pocket: diff --git a/experimenter/experimenter/features/manifests/firefox-desktop/.ref-cache.yaml b/experimenter/experimenter/features/manifests/firefox-desktop/.ref-cache.yaml index abe0b2ec98..0981010046 100644 --- a/experimenter/experimenter/features/manifests/firefox-desktop/.ref-cache.yaml +++ b/experimenter/experimenter/features/manifests/firefox-desktop/.ref-cache.yaml @@ -2,5 +2,5 @@ beta: 511b1cd099f9120d0753fcc110df4a355f7a88c3 esr115: 10db4a72f4b04abe8440033f63a85b9c2ec2346f esr128: ed38f9209e39bd7ad247c81a7c20c99c874e0a62 esr140: d44e76f6e4c24f560aa2b509fdd5a3ea93615528 -main: de19af4f95d37f675d5d4ca13cd5af7bce1b4f14 +main: 6f66908ea0c0f0b51c134ff1e6646a0c7fb8e82b release: c7fa3c91990bac266ee99a9e31863c202469f369 diff --git a/experimenter/experimenter/features/manifests/firefox-desktop/TargetingContextRecorder.sys.mjs b/experimenter/experimenter/features/manifests/firefox-desktop/TargetingContextRecorder.sys.mjs new file mode 100644 index 0000000000..09ed120f11 --- /dev/null +++ b/experimenter/experimenter/features/manifests/firefox-desktop/TargetingContextRecorder.sys.mjs @@ -0,0 +1,401 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ASRouterTargeting: + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + ClientID: "resource://gre/modules/ClientID.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", +}); + +const { PREF_INVALID, PREF_STRING, PREF_INT, PREF_BOOL } = Ci.nsIPrefBranch; +const PREF_TYPES = Object.freeze({ + [PREF_STRING]: "Ci.nsIPrefBranch.PREF_STRING", + [PREF_INT]: "Ci.nsIPrefBranch.PREF_INT", + [PREF_BOOL]: "Ci.nsIPrefBranch.PREF_BOOL", +}); + +/** + * Return a function that returns specific keys of an object. + * + * All values will be awaited, so objects containing promises will be flattened + * into objects. + * + * Any exceptions encountered will not prevent the key from being recorded in + * the metric. + * + * @param {string[]} keys - The keys to include. + * @returns The function. + */ +function pick(...keys) { + const identity = x => x; + return pickWith(Object.fromEntries(keys.map(key => [key, identity]))); +} + +/** + * Return a function that returns a specific keys of an object, with transforms. + * + * All values will be awaited, as will their transform functions, so objects + * containing promises will be flattened into objects. + * + * Any exceptions encountered will not prevent the key from being recorded in + * the metric. + * + * @param {Record any>} shape + * A mapping of keys to transformation functions. + * + * @returns The function. + */ +function pickWith(shape) { + return async function (object) { + const transformed = {}; + if (typeof object !== "undefined" && object !== null) { + for (const [key, transform] of Object.entries(shape)) { + try { + transformed[key] = await transform(await object[key]); + } catch (ex) {} + } + } + return transformed; + }; +} + +/** + * Assert that the attribute matches the given type (via typeof). + * + * @param {string} expectedType + * The expected type. + * If the attribute is not of this type, this function will throw. + * @param {any} attribute + * The value whose type is to be checked. + * + * @returns The attribute. + */ +function assertType(expectedType, attribute) { + const type = typeof attribute; + + if (type !== expectedType) { + throw new Error(`Expected ${expectedType} but got ${type} instead`); + } + + return attribute; +} + +/** + * Transforms that assert that the type of the attribute matches an expected + * type. + */ +const typeAssertions = { + integer: attribute => + assertType("number", attribute) && Number.isSafeInteger(attribute), + string: attribute => assertType("string", attribute), + boolean: attribute => assertType("boolean", attribute), + quantity: attribute => Math.floor(assertType("number", attribute)), + array: attribute => { + if (!Array.isArray(attribute)) { + throw new Error(`Expected Array but got ${typeof attribute} instead`); + } + + return attribute; + }, + // NB: Date methods will throw if called on a non-Date object. We can't simply + // use `attribute instanceof Date` because the Date constructor might be from + // a different context (and thus the expression would evaluate to false). + date: attribute => Date.prototype.toUTCString.call(attribute), +}; + +/** + * This contains the set of all top-level targeting attributes in the Nimbus + * Targeting context and optional transforms functions that will be applied + * before the value is recorded. + */ +export const ATTRIBUTE_TRANSFORMS = Object.freeze({ + activeExperiments: typeAssertions.array, + activeRollouts: typeAssertions.array, + addonsInfo: addonsInfo => ({ + addons: Object.keys(addonsInfo?.addons ?? {}).sort(), + hasInstalledAddons: !!addonsInfo?.hasInstalledAddons, + }), + addressesSaved: typeAssertions.quantity, + archBits: typeAssertions.quantity, + attributionData: pick("medium", "source", "ua"), + browserSettings: pickWith({ + update: pick("channel"), + }), + buildId: typeAssertions.integer, + currentDate: typeAssertions.date, + defaultPDFHandler: pick("knownBrowser", "registered"), + distributionId: typeAssertions.string, + doesAppNeedPin: typeAssertions.boolean, + enrollmentsMap: enrollmentsMap => + Object.entries(enrollmentsMap).map(([experimentSlug, branchSlug]) => ({ + experimentSlug, + branchSlug, + })), + firefoxVersion: typeAssertions.quantity, + hasActiveEnterprisePolicies: typeAssertions.boolean, + homePageSettings: pick("isCustomUrl", "isDefault", "isLocked", "isWebExt"), + isDefaultHandler: pick("html", "pdf"), + isDefaultBrowser: typeAssertions.boolean, + isFirstStartup: typeAssertions.boolean, + isFxAEnabled: typeAssertions.boolean, + isFxASignedIn: typeAssertions.boolean, + isMSIX: typeAssertions.boolean, + locale: typeAssertions.string, + memoryMB: typeAssertions.quantity, + os: pick( + "isLinux", + "isMac", + "isWindow", + "windowsBuildNumber", + "windowsVersion" + ), + primaryResolution: pick("height", "width"), + profileAgeCreated: typeAssertions.quantity, + region: typeAssertions.string, + totalBookmarksCount: typeAssertions.quantity, + userMonthlyActivity: userMonthlyActivity => + userMonthlyActivity.map(([numberOfURLsVisited, date]) => ({ + numberOfURLsVisited, + date, + })), + // userPrefersReducedMotion can only be false in xpcshell tests because it + // uses a stubbed nsIXULAppInfo (/testing/modules/AppInfo.sys.mjs). + userPrefersReducedMotion: userPrefersReducedMotion => + userPrefersReducedMotion ?? false, + usesFirefoxSync: typeAssertions.boolean, + version: typeAssertions.string, +}); + +/** + * Transform a targeting context attribute name to the name that Glean expects + * for the corresponding metric. + * + * Glean metrics are defined in `snake_case` and are translated to `camelCase` + * for JavaScript. Most of our targeting attributes and their Glean metric + * equivalent have names that line up cleanly, but this falls apart when the + * targeting attribute has a name with an all-uppercase acronym. + * + * For example, the metric corresponding to the `defaultPDFHandler` attribute + * has the name `default_pdf_handler` in the metrics.yaml which would become + * `defaultPdfhandler` in JavaScript. + * + * @param {string} The attribute name. + * @returns {string} The metric name. + */ +export function normalizeAttributeName(attr) { + switch (attr) { + case "isFxAEnabled": // Would transform to `isFxAenabled`. + case "isFxASignedIn": // Would transform to `isFxAsignedIn`. + return attr; + + case "defaultPDFHandler": + // Would transform to `defaultPdfhandler`. + return "defaultPdfHandler"; + + default: + return attr.replaceAll(/[A-Z]+/g, substr => { + return `${substr[0]}${substr.slice(1).toLowerCase()}`; + }); + } +} + +/** + * These are the prefs that can be used in evaluation of a JEXL expression by + * Nimbus via the `getPrefValue` filter. + */ +export const PREFS = Object.freeze({ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons": PREF_BOOL, + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features": + PREF_BOOL, + "browser.newtabpage.activity-stream.feeds.section.highlights": PREF_BOOL, + "browser.newtabpage.activity-stream.feeds.section.topstories": PREF_BOOL, + "browser.newtabpage.activity-stream.feeds.topsites": PREF_BOOL, + "browser.newtabpage.activity-stream.showSearch": PREF_BOOL, + "browser.newtabpage.activity-stream.showSponsoredTopSites": PREF_BOOL, + "browser.newtabpage.enabled": PREF_BOOL, + "browser.toolbars.bookmarks.visibility": PREF_STRING, + "browser.urlbar.quicksuggest.dataCollection.enabled": PREF_BOOL, + "browser.urlbar.showSearchSuggestionsFirst": PREF_BOOL, + "browser.urlbar.suggest.quicksuggest.sponsored": PREF_BOOL, + "media.videocontrols.picture-in-picture.enabled": PREF_BOOL, + "media.videocontrols.picture-in-picture.video-toggle.enabled": PREF_BOOL, + "media.videocontrols.picture-in-picture.video-toggle.has-used": PREF_BOOL, + "messaging-system-action.testday": PREF_STRING, + "network.trr.mode": PREF_INT, + "nimbus.qa.pref-1": PREF_STRING, + "nimbus.qa.pref-2": PREF_STRING, + "security.sandbox.content.level": PREF_INT, + "trailhead.firstrun.didSeeAboutWelcome": PREF_BOOL, +}); + +/** + * Transform a pref name to its key in the targeting context metric. + * + * Using dashes and periods in the object metric type would make the resulting + * data harder to query, so we replace them with single and double underscores, + * respectively. + * + * @param {string} The pref name. + * @returns {string} The normalized pref name. + */ +export function normalizePrefName(pref) { + return pref.replaceAll(/-/g, "_").replaceAll(/\./g, "__"); +} + +/** + * Get the list of all prefs that Nimbus cares about and determine whether or + * not they have user branch values. + * + * This will walk the Feature Manifest, collecting every setPref entry. + * + * This does not return any errors because prefHasUserValue cannot throw. + * + * @returns {string[]} The array of prefs. + */ +function recordUserSetPrefs() { + const prefs = Object.values(lazy.NimbusFeatures) + .filter(feature => feature.manifest) + .flatMap(feature => feature.manifest.variables) + .flatMap(Object.values) + .filter(variable => variable.setPref) + .map(variable => variable.setPref.pref) + .filter(pref => Services.prefs.prefHasUserValue(pref)); + + Glean.nimbusTargetingEnvironment.userSetPrefs.set(prefs); +} + +/** + * Record pref values to the nimbus_targeting_environment.pref_values metric. + * + * The prefs queried are determined by `PREFS`. + * + * Any type errors will encountered will be recorded in the + * `nimbus_targeting_environment.pref_type_errors` metric. + */ +function recordPrefValues() { + const prefValues = {}; + + for (const [pref, expectedType] of Object.entries(PREFS)) { + const key = normalizePrefName(pref); + + const prefType = Services.prefs.getPrefType(pref); + if (prefType === PREF_INVALID) { + // The pref doesn't have a value on either branch. This is not an actual + // error. + continue; + } + + if (prefType !== expectedType) { + // We cannot record this value since the pref has the wrong type. + Glean.nimbusTargetingEnvironment.prefTypeErrors[pref].add(); + console.error( + `TargetingContextRecorder: Pref "${pref}" has the wrong type. Expected ${PREF_TYPES[expectedType]} but found ${PREF_TYPES[prefType]}` + ); + continue; + } + + try { + switch (expectedType) { + case PREF_STRING: + prefValues[key] = Services.prefs.getStringPref(pref); + break; + + case PREF_INT: + prefValues[key] = Services.prefs.getIntPref(pref); + break; + + case PREF_BOOL: + prefValues[key] = Services.prefs.getBoolPref(pref); + break; + } + } catch (ex) { + // `nsIPrefBranch::Get{String,Int,Bool}Pref` only fails for three reasons: + // - you request a pref that does not exist + // - you request a pref with the wrongly-typed method (e.g., you try to + // get the value of an int pref with `GetStringPref`) + // - the pref service is not available (likely because we are shutting down). + // + // The first two cases are covered before we attempt to read the pref + // value and the last case is not worth recording telemetry about. + console.error( + `TargetingContextRecorder: Could not get value of pref "${pref}; are we shutting down?"`, + ex + ); + } + } + + Glean.nimbusTargetingEnvironment.prefValues.set(prefValues); +} + +/** + * Evaluate the values of the `nimbus_targeting_context` category metrics and + * record them. + * + * Any errors encountered during evaluation will be recorded in the + * `nimbus_targeting_environment.attr_eval_errors` metric. + * + * The entire targeting context will be recorded inside the + * `nimbus_targeting_environment.targeting_context_value` metric as stringified + * JSON. The metric is disabled by default, but can be enabled via the + * `nimbusTelemetry` feature to debug evaluation failures. + */ +async function recordTargetingContextAttributes() { + const context = new lazy.TargetingContext( + lazy.TargetingContext.combineContexts( + lazy.ExperimentAPI.manager.createTargetingContext(), + lazy.ASRouterTargeting.Environment + ) + ).ctx; + + const recordAttrs = + lazy.NimbusFeatures.nimbusTelemetry.getVariable( + "nimbusTargetingEnvironment" + )?.recordAttrs ?? null; + const values = {}; + + for (const [attr, transform] of Object.entries(ATTRIBUTE_TRANSFORMS)) { + const metric = normalizeAttributeName(attr); + try { + const value = await transform(await context[attr]); + + if (recordAttrs === null || recordAttrs.includes(attr)) { + values[metric] = value; + } + + Glean.nimbusTargetingContext[metric].set(value); + } catch (ex) { + Glean.nimbusTargetingEnvironment.attrEvalErrors[metric].add(); + console.error(`TargetingContextRecorder: Could not get "${attr}"`, ex); + } + } + + let stringifiedCtx; + try { + stringifiedCtx = JSON.stringify(values); + } catch (ex) { + stringifiedCtx = "(JSON.stringify error)"; + } + + Glean.nimbusTargetingEnvironment.targetingContextValue.set(stringifiedCtx); +} + +/** + * Record the metrics for the nimbus-targeting-context ping and submit it. + */ +export async function recordTargetingContext() { + recordPrefValues(); + recordUserSetPrefs(); + await recordTargetingContextAttributes(); + + // This will ensure that the profile group ID metric has been set. + await lazy.ClientID.getProfileGroupID(); + + GleanPings.nimbusTargetingContext.submit(); +} diff --git a/experimenter/experimenter/features/manifests/firefox-desktop/v150.0.0/TargetingContextRecorder.sys.mjs b/experimenter/experimenter/features/manifests/firefox-desktop/v150.0.0/TargetingContextRecorder.sys.mjs new file mode 100644 index 0000000000..e4d9b2b120 --- /dev/null +++ b/experimenter/experimenter/features/manifests/firefox-desktop/v150.0.0/TargetingContextRecorder.sys.mjs @@ -0,0 +1,419 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ASRouterTargeting: + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + ClientID: "resource://gre/modules/ClientID.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", +}); + +const { PREF_INVALID, PREF_STRING, PREF_INT, PREF_BOOL } = Ci.nsIPrefBranch; +const PREF_TYPES = Object.freeze({ + [PREF_STRING]: "Ci.nsIPrefBranch.PREF_STRING", + [PREF_INT]: "Ci.nsIPrefBranch.PREF_INT", + [PREF_BOOL]: "Ci.nsIPrefBranch.PREF_BOOL", +}); + +/** + * Return a function that returns specific keys of an object. + * + * All values will be awaited, so objects containing promises will be flattened + * into objects. + * + * Any exceptions encountered will not prevent the key from being recorded in + * the metric. + * + * @param {string[]} keys - The keys to include. + * @returns The function. + */ +function pick(...keys) { + const identity = x => x; + return pickWith(Object.fromEntries(keys.map(key => [key, identity]))); +} + +/** + * Return a function that returns a specific keys of an object, with transforms. + * + * All values will be awaited, as will their transform functions, so objects + * containing promises will be flattened into objects. + * + * Any exceptions encountered will not prevent the key from being recorded in + * the metric. + * + * @param {Record any>} shape + * A mapping of keys to transformation functions. + * + * @returns The function. + */ +function pickWith(shape) { + return async function (object) { + const transformed = {}; + if (typeof object !== "undefined" && object !== null) { + for (const [key, transform] of Object.entries(shape)) { + try { + transformed[key] = await transform(await object[key]); + } catch (ex) {} + } + } + return transformed; + }; +} + +/** + * Assert that the attribute matches the given type (via typeof). + * + * @param {string} expectedType + * The expected type. + * If the attribute is not of this type, this function will throw. + * @param {any} attribute + * The value whose type is to be checked. + * + * @returns The attribute. + */ +function assertType(expectedType, attribute) { + const type = typeof attribute; + + if (type !== expectedType) { + throw new Error(`Expected ${expectedType} but got ${type} instead`); + } + + return attribute; +} + +/** + * Transforms that assert that the type of the attribute matches an expected + * type. + */ +const typeAssertions = { + integer: attribute => + assertType("number", attribute) && Number.isSafeInteger(attribute), + string: attribute => assertType("string", attribute), + boolean: attribute => assertType("boolean", attribute), + quantity: attribute => Math.floor(assertType("number", attribute)), + array: attribute => { + if (!Array.isArray(attribute)) { + throw new Error(`Expected Array but got ${typeof attribute} instead`); + } + + return attribute; + }, + // NB: Date methods will throw if called on a non-Date object. We can't simply + // use `attribute instanceof Date` because the Date constructor might be from + // a different context (and thus the expression would evaluate to false). + date: attribute => Date.prototype.toUTCString.call(attribute), +}; + +/** + * This contains the set of all top-level targeting attributes in the Nimbus + * Targeting context and optional transforms functions that will be applied + * before the value is recorded. + */ +export const ATTRIBUTE_TRANSFORMS = Object.freeze({ + activeExperiments: typeAssertions.array, + activeRollouts: typeAssertions.array, + addonsInfo: addonsInfo => ({ + addons: Object.keys(addonsInfo?.addons ?? {}).sort(), + hasInstalledAddons: !!addonsInfo?.hasInstalledAddons, + }), + addressesSaved: typeAssertions.quantity, + archBits: typeAssertions.quantity, + attributionData: pick("medium", "source", "ua"), + browserSettings: pickWith({ + update: pick("channel"), + }), + buildId: typeAssertions.integer, + currentDate: typeAssertions.date, + defaultPDFHandler: pick("knownBrowser", "registered"), + distributionId: typeAssertions.string, + doesAppNeedPin: typeAssertions.boolean, + enrollmentsMap: enrollmentsMap => + Object.entries(enrollmentsMap).map(([experimentSlug, branchSlug]) => ({ + experimentSlug, + branchSlug, + })), + firefoxVersion: typeAssertions.quantity, + hasActiveEnterprisePolicies: typeAssertions.boolean, + hasPinnedTabs: typeAssertions.boolean, + homePageSettings: pick("isCustomUrl", "isDefault", "isLocked", "isWebExt"), + isDefaultHandler: pick("html", "pdf"), + isDefaultBrowser: typeAssertions.boolean, + isFirstStartup: typeAssertions.boolean, + isFxAEnabled: typeAssertions.boolean, + isFxASignedIn: typeAssertions.boolean, + isMSIX: typeAssertions.boolean, + locale: typeAssertions.string, + memoryMB: typeAssertions.quantity, + os: pick( + "isLinux", + "isMac", + "isWindow", + "windowsBuildNumber", + "windowsVersion" + ), + primaryResolution: pick("height", "width"), + profileAgeCreated: typeAssertions.quantity, + profileGroupProfileCount: typeAssertions.quantity, + region: typeAssertions.string, + totalBookmarksCount: typeAssertions.quantity, + userMonthlyActivity: userMonthlyActivity => + userMonthlyActivity.map(([numberOfURLsVisited, date]) => ({ + numberOfURLsVisited, + date, + })), + // userPrefersReducedMotion can only be false in xpcshell tests because it + // uses a stubbed nsIXULAppInfo (/testing/modules/AppInfo.sys.mjs). + userPrefersReducedMotion: userPrefersReducedMotion => + userPrefersReducedMotion ?? false, + usesFirefoxSync: typeAssertions.boolean, + version: typeAssertions.string, +}); + +/** + * Transform a targeting context attribute name to the name that Glean expects + * for the corresponding metric. + * + * Glean metrics are defined in `snake_case` and are translated to `camelCase` + * for JavaScript. Most of our targeting attributes and their Glean metric + * equivalent have names that line up cleanly, but this falls apart when the + * targeting attribute has a name with an all-uppercase acronym. + * + * For example, the metric corresponding to the `defaultPDFHandler` attribute + * has the name `default_pdf_handler` in the metrics.yaml which would become + * `defaultPdfhandler` in JavaScript. + * + * @param {string} The attribute name. + * @returns {string} The metric name. + */ +export function normalizeAttributeName(attr) { + switch (attr) { + case "isFxAEnabled": // Would transform to `isFxAenabled`. + case "isFxASignedIn": // Would transform to `isFxAsignedIn`. + return attr; + + case "defaultPDFHandler": + // Would transform to `defaultPdfhandler`. + return "defaultPdfHandler"; + + default: + return attr.replaceAll(/[A-Z]+/g, substr => { + return `${substr[0]}${substr.slice(1).toLowerCase()}`; + }); + } +} + +/** + * These are the prefs that can be used in evaluation of a JEXL expression by + * Nimbus via the `getPrefValue` filter. + */ +export const PREFS = Object.freeze({ + "browser.ai.control.default": PREF_STRING, + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons": PREF_BOOL, + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features": + PREF_BOOL, + "browser.newtabpage.activity-stream.feeds.section.highlights": PREF_BOOL, + "browser.newtabpage.activity-stream.feeds.section.topstories": PREF_BOOL, + "browser.newtabpage.activity-stream.feeds.topsites": PREF_BOOL, + "browser.newtabpage.activity-stream.showSearch": PREF_BOOL, + "browser.newtabpage.activity-stream.showSponsoredTopSites": PREF_BOOL, + "browser.newtabpage.enabled": PREF_BOOL, + "browser.profiles.created": PREF_BOOL, + "browser.startup.page": PREF_INT, + "browser.toolbars.bookmarks.visibility": PREF_STRING, + "browser.urlbar.lastUrlbarSearchSeconds": PREF_INT, + "browser.urlbar.showSearchSuggestionsFirst": PREF_BOOL, + "browser.urlbar.suggest.quicksuggest.sponsored": PREF_BOOL, + "media.videocontrols.picture-in-picture.enabled": PREF_BOOL, + "media.videocontrols.picture-in-picture.video-toggle.enabled": PREF_BOOL, + "media.videocontrols.picture-in-picture.video-toggle.has-used": PREF_BOOL, + "messaging-system-action.testday": PREF_STRING, + "network.trr.mode": PREF_INT, + "nimbus.qa.pref-1": PREF_STRING, + "nimbus.qa.pref-2": PREF_STRING, + "security.sandbox.content.level": PREF_INT, + "termsofuse.acceptedDate": PREF_STRING, + "termsofuse.acceptedVersion": PREF_INT, + "termsofuse.bypassNotification": PREF_BOOL, + "termsofuse.firstAcceptedDate": PREF_STRING, + "trailhead.firstrun.didSeeAboutWelcome": PREF_BOOL, +}); + +/** + * Transform a pref name to its key in the targeting context metric. + * + * Using dashes and periods in the object metric type would make the resulting + * data harder to query, so we replace them with single and double underscores, + * respectively. + * + * @param {string} The pref name. + * @returns {string} The normalized pref name. + */ +export function normalizePrefName(pref) { + return pref.replaceAll(/-/g, "_").replaceAll(/\./g, "__"); +} + +/** + * Get the list of all prefs that Nimbus cares about and determine whether or + * not they have user branch values. + * + * This will walk the Feature Manifest, collecting every setPref entry. + * + * This does not return any errors because prefHasUserValue cannot throw. + * + * @returns {string[]} The array of prefs. + */ +function recordUserSetPrefs() { + const prefs = Object.values(lazy.NimbusFeatures) + .filter(feature => feature.manifest) + .flatMap(feature => feature.manifest.variables) + .flatMap(Object.values) + .filter(variable => variable.setPref) + .map(variable => variable.setPref.pref) + .filter(pref => Services.prefs.prefHasUserValue(pref)); + + Glean.nimbusTargetingEnvironment.userSetPrefs.set(prefs); +} + +/** + * Record pref values to the nimbus_targeting_environment.pref_values metric. + * + * The prefs queried are determined by `PREFS`. + * + * Any type errors will encountered will be recorded in the + * `nimbus_targeting_environment.pref_type_errors` metric. + */ +function recordPrefValues() { + const prefValues = {}; + + for (const [pref, expectedType] of Object.entries(PREFS)) { + const key = normalizePrefName(pref); + + const prefType = Services.prefs.getPrefType(pref); + if (prefType === PREF_INVALID) { + // The pref doesn't have a value on either branch. This is not an actual + // error. + continue; + } + + if (prefType !== expectedType) { + // We cannot record this value since the pref has the wrong type. + Glean.nimbusTargetingEnvironment.prefTypeErrors[pref].add(); + console.error( + `TargetingContextRecorder: Pref "${pref}" has the wrong type. Expected ${PREF_TYPES[expectedType]} but found ${PREF_TYPES[prefType]}` + ); + continue; + } + + try { + switch (expectedType) { + case PREF_STRING: + prefValues[key] = Services.prefs.getStringPref(pref); + break; + + case PREF_INT: + prefValues[key] = Services.prefs.getIntPref(pref); + break; + + case PREF_BOOL: + prefValues[key] = Services.prefs.getBoolPref(pref); + break; + } + } catch (ex) { + // `nsIPrefBranch::Get{String,Int,Bool}Pref` only fails for three reasons: + // - you request a pref that does not exist + // - you request a pref with the wrongly-typed method (e.g., you try to + // get the value of an int pref with `GetStringPref`) + // - the pref service is not available (likely because we are shutting down). + // + // The first two cases are covered before we attempt to read the pref + // value and the last case is not worth recording telemetry about. + console.error( + `TargetingContextRecorder: Could not get value of pref "${pref}; are we shutting down?"`, + ex + ); + } + } + + Glean.nimbusTargetingEnvironment.prefValues.set(prefValues); +} + +/** + * Evaluate the values of the `nimbus_targeting_context` category metrics and + * record them. + * + * Any errors encountered during evaluation will be recorded in the + * `nimbus_targeting_environment.attr_eval_errors` metric. + * + * The entire targeting context will be recorded inside the + * `nimbus_targeting_environment.targeting_context_value` metric as stringified + * JSON. The metric is disabled by default, but can be enabled via the + * `nimbusTelemetry` feature to debug evaluation failures. + */ +async function recordTargetingContextAttributes() { + const context = new lazy.TargetingContext( + lazy.TargetingContext.combineContexts( + lazy.ExperimentAPI.manager.createTargetingContext(), + lazy.ASRouterTargeting.Environment + ) + ).ctx; + + const recordAttrsEnabled = + lazy.NimbusFeatures.nimbusTelemetry.getVariable("gleanMetricConfiguration") + ?.metrics_enabled?.[ + "nimbus_targeting_environment.targeting_context_value" + ] ?? false; + + const recordAttrs = + lazy.NimbusFeatures.nimbusTelemetry.getVariable( + "nimbusTargetingEnvironment" + )?.recordAttrs ?? null; + const values = {}; + + for (const [attr, transform] of Object.entries(ATTRIBUTE_TRANSFORMS)) { + const metric = normalizeAttributeName(attr); + try { + const value = await transform(await context[attr]); + + if ( + recordAttrsEnabled && + (recordAttrs === null || recordAttrs.includes(attr)) + ) { + values[metric] = value; + } + + Glean.nimbusTargetingContext[metric].set(value); + } catch (ex) { + Glean.nimbusTargetingEnvironment.attrEvalErrors[metric].add(); + console.error(`TargetingContextRecorder: Could not get "${attr}"`, ex); + } + } + + if (recordAttrsEnabled) { + let stringifiedCtx; + try { + stringifiedCtx = JSON.stringify(values); + } catch (ex) { + stringifiedCtx = "(JSON.stringify error)"; + } + + Glean.nimbusTargetingEnvironment.targetingContextValue.set(stringifiedCtx); + } +} + +/** + * Record the metrics for the nimbus-targeting-context ping and submit it. + */ +export async function recordTargetingContext() { + recordPrefValues(); + recordUserSetPrefs(); + await recordTargetingContextAttributes(); + + // This will ensure that the profile group ID metric has been set. + await lazy.ClientID.getProfileGroupID(); +} diff --git a/experimenter/experimenter/features/manifests/ios/.ref-cache.yaml b/experimenter/experimenter/features/manifests/ios/.ref-cache.yaml index 745ee31e7e..2e2ab6bff4 100644 --- a/experimenter/experimenter/features/manifests/ios/.ref-cache.yaml +++ b/experimenter/experimenter/features/manifests/ios/.ref-cache.yaml @@ -83,7 +83,7 @@ firefox-v148.1: 981a01118303b8707b432e1d73040e8fd1c9cf8a firefox-v148.2: f1efef1c856662666006ed563686ed39c4e0cade firefox-v148.3: 1b5be10df86c50e67cea5cc55d6e9dbe66921da9 firefox-v149.0: 9db4bd51a49cfef1593cd30d07d314b21cc639a1 -main: 42c5deed463c0470e76654d2c707e1da496455bb +main: aa6243c7f0ecd72aab0517388eb441f60099c867 release/v117: 43b690c450066d4dde1b2ccb93ed3714d244d502 release/v118: 89a7ea3c3372e5a4ef5b3c5b85499d26198d0524 release/v119: 7dc381f991c6d2a983c3ba7f0cffd880e8c29e7b diff --git a/experimenter/experimenter/features/manifests/ios/RecordedNimbusContext.swift b/experimenter/experimenter/features/manifests/ios/RecordedNimbusContext.swift new file mode 100644 index 0000000000..e712e8a1c6 --- /dev/null +++ b/experimenter/experimenter/features/manifests/ios/RecordedNimbusContext.swift @@ -0,0 +1,222 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Glean +import Shared + +import class MozillaAppServices.NimbusGleanPings +import func MozillaAppServices.getCalculatedAttributes +import func MozillaAppServices.getLocaleTag +import struct MozillaAppServices.JsonObject +import protocol MozillaAppServices.RecordedContext +import MozillaRustComponents + +private extension Double? { + func toInt64() -> Int64? { + guard let self = self else { return nil } + return Int64(self) + } +} + +extension Int32? { + func toInt64() -> Int64? { + guard let self = self else { return nil } + return Int64(self) + } +} + +/// TODO(FXIOS-12942): Implement proper thread-safety +final class RecordedNimbusContext: RecordedContext, @unchecked Sendable { + /** + * The following constants are string constants of the keys that appear in the [EVENT_QUERIES] map. + */ + static let DAYS_OPENED_IN_LAST_28 = "days_opened_in_last_28" + + /** + * [EVENT_QUERIES] is a map of keys to Nimbus SDK EventStore queries. + */ + static let EVENT_QUERIES = [ + DAYS_OPENED_IN_LAST_28: "'events.app_opened'|eventCountNonZero('Days', 28, 0)", + ] + + var isFirstRun: Bool + var isPhone: Bool + var isDefaultBrowser: Bool + var isBottomToolbarUser: Bool + var hasEnabledTipsNotifications: Bool + var hasAcceptedTermsOfUse: Bool + var isAppleIntelligenceAvailable: Bool + var cannotUseAppleIntelligence: Bool + var appVersion: String? + var region: String? + var language: String? + var locale: String + var daysSinceInstall: Int32? + var daysSinceUpdate: Int32? + var touExperiencePoints: Int32? + + private var eventQueries: [String: String] + private var eventQueryValues: [String: Double] = [:] + + private var logger: Logger + + init(isFirstRun: Bool, + isDefaultBrowser: Bool, + isBottomToolbarUser: Bool, + hasEnabledTipsNotifications: Bool, + hasAcceptedTermsOfUse: Bool, + isAppleIntelligenceAvailable: Bool, + cannotUseAppleIntelligence: Bool, + eventQueries: [String: String] = RecordedNimbusContext.EVENT_QUERIES, + isPhone: Bool = UIDeviceDetails.userInterfaceIdiom == .phone, + bundle: Bundle = Bundle.main, + logger: Logger = DefaultLogger.shared) { + self.logger = logger + logger.log("init start", level: .debug, category: .experiments) + self.eventQueries = eventQueries + + self.isFirstRun = isFirstRun + self.isPhone = isPhone + self.isDefaultBrowser = isDefaultBrowser + self.isBottomToolbarUser = isBottomToolbarUser + self.hasEnabledTipsNotifications = hasEnabledTipsNotifications + self.hasAcceptedTermsOfUse = hasAcceptedTermsOfUse + self.isAppleIntelligenceAvailable = isAppleIntelligenceAvailable + self.cannotUseAppleIntelligence = cannotUseAppleIntelligence + + let info = bundle.infoDictionary ?? [:] + appVersion = info["CFBundleShortVersionString"] as? String + + locale = getLocaleTag() + var inferredDateInstalledOn: Date? { + guard + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last, + let attributes = try? FileManager.default.attributesOfItem(atPath: documentsURL.path) + else { return nil } + return attributes[.creationDate] as? Date + } + let installationDateSinceEpoch = inferredDateInstalledOn.map { + Int64(($0.timeIntervalSince1970 * 1000).rounded()) + } + guard let dbPath = Experiments.dbPath else { + self.logger.log("Unable to obtain dbPath, skipping calculating attributes", + level: .warning, + category: .experiments) + return + } + guard let calculatedAttributes = try? getCalculatedAttributes(installationDate: installationDateSinceEpoch, + dbPath: dbPath, + locale: locale) + else { return } + + daysSinceInstall = calculatedAttributes.daysSinceInstall + daysSinceUpdate = calculatedAttributes.daysSinceUpdate + language = calculatedAttributes.language + region = calculatedAttributes.region + touExperiencePoints = Experiments.touExperiencePoints(region: region) + self.logger.log("init end", level: .debug, category: .experiments) + } + + /** + * [getEventQueries] is called by the Nimbus SDK Rust code to retrieve the map of event + * queries. The are then executed against the Nimbus SDK's EventStore to retrieve their values. + * + * @return Map + */ + func getEventQueries() -> [String: String] { + logger.log("getEventQueries", level: .debug, category: .experiments) + return eventQueries + } + + /** + * [record] is called when experiment enrollments are evolved. It should apply the + * [RecordedNimbusContext]'s values to a [NimbusSystem.RecordedNimbusContextObject] instance, + * and use that instance to record the values to Glean. + */ + func record() { + logger.log("record start", level: .debug, category: .experiments) + + // Bring the ping into scope so that Glean knows it exists and includes NimbusSystem.recordedNimbusContext + _ = NimbusGleanPings.nimbusTargetingContext + + let eventQueryValuesObject = GleanMetrics.NimbusSystem.RecordedNimbusContextObjectItemEventQueryValuesObject( + daysOpenedInLast28: eventQueryValues[RecordedNimbusContext.DAYS_OPENED_IN_LAST_28].toInt64() + ) + + GleanMetrics.NimbusSystem.recordedNimbusContext.set( + GleanMetrics.NimbusSystem.RecordedNimbusContextObject( + isFirstRun: isFirstRun, + eventQueryValues: eventQueryValuesObject, + isPhone: isPhone, + appVersion: appVersion, + locale: locale, + daysSinceInstall: daysSinceInstall.toInt64(), + daysSinceUpdate: daysSinceUpdate.toInt64(), + language: language, + region: region, + isDefaultBrowser: isDefaultBrowser, + isBottomToolbarUser: isBottomToolbarUser, + hasEnabledTipsNotifications: hasEnabledTipsNotifications, + hasAcceptedTermsOfUse: hasAcceptedTermsOfUse, + isAppleIntelligenceAvailable: isAppleIntelligenceAvailable, + cannotUseAppleIntelligence: cannotUseAppleIntelligence, + touExperiencePoints: touExperiencePoints.toInt64() + ) + ) + GleanMetrics.Pings.shared.nimbus.submit() + logger.log("record end", level: .debug, category: .experiments) + } + + /** + * [setEventQueryValues] is called by the Nimbus SDK Rust code after the event queries have been + * executed. The [eventQueryValues] should be written back to the Kotlin object. + * + * @param [eventQueryValues] The values for each query after they have been executed in the + * Nimbus SDK Rust environment. + */ + func setEventQueryValues(eventQueryValues: [String: Double]) { + logger.log("setEventQueryValues", level: .debug, category: .experiments) + self.eventQueryValues = eventQueryValues + } + + /** + * [toJson] is called by the Nimbus SDK Rust code after the event queries have been executed, + * and before experiment enrollments have been evolved. The value returned from this method + * will be applied directly to the Nimbus targeting context, and its keys/values take + * precedence over those in the main Nimbus targeting context. + * + * @return JsonObject + */ + func toJson() -> JsonObject { + logger.log("toJson start", level: .debug, category: .experiments) + guard let data = try? JSONSerialization.data(withJSONObject: [ + "is_first_run": isFirstRun, + "isFirstRun": "\(isFirstRun)", + "is_phone": isPhone, + "events": eventQueryValues, + "app_version": appVersion as Any, + "region": region as Any, + "language": language as Any, + "locale": locale as Any, + "days_since_install": daysSinceInstall as Any, + "days_since_update": daysSinceUpdate as Any, + "is_default_browser": isDefaultBrowser, + "is_bottom_toolbar_user": isBottomToolbarUser, + "has_enabled_tips_notifications": hasEnabledTipsNotifications, + "has_accepted_terms_of_use": hasAcceptedTermsOfUse, + "is_apple_intelligence_available": isAppleIntelligenceAvailable, + "cannot_use_apple_intelligence": cannotUseAppleIntelligence, + "tou_experience_points": touExperiencePoints as Any + ]), + let jsonString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) as? String + else { + logger.log("toJson error thrown while creating JSON string", level: .warning, category: .experiments) + return "{}" + } + logger.log("toJson end", level: .debug, category: .experiments, extra: ["json": jsonString]) + return jsonString + } +} diff --git a/experimenter/experimenter/features/manifests/ios/beta.fml.yaml b/experimenter/experimenter/features/manifests/ios/beta.fml.yaml index a0c025fe11..c188e9e3d5 100644 --- a/experimenter/experimenter/features/manifests/ios/beta.fml.yaml +++ b/experimenter/experimenter/features/manifests/ios/beta.fml.yaml @@ -811,12 +811,12 @@ features: description: | Toggle between legacy (false) and modern (true) UI variants. type: Boolean - default: false + default: true should-use-brand-refresh-configuration: description: | Toggle between standard (false) and brand refresh (true) onboarding content. type: Boolean - default: false + default: true should-use-japan-configuration: description: | Toggle between standard (false) and Japan-specific (true) assets and content. diff --git a/experimenter/experimenter/features/manifests/ios/developer.fml.yaml b/experimenter/experimenter/features/manifests/ios/developer.fml.yaml index 4be2a726f2..d1a22e2d2e 100644 --- a/experimenter/experimenter/features/manifests/ios/developer.fml.yaml +++ b/experimenter/experimenter/features/manifests/ios/developer.fml.yaml @@ -821,12 +821,12 @@ features: description: | Toggle between legacy (false) and modern (true) UI variants. type: Boolean - default: false + default: true should-use-brand-refresh-configuration: description: | Toggle between standard (false) and brand refresh (true) onboarding content. type: Boolean - default: false + default: true should-use-japan-configuration: description: | Toggle between standard (false) and Japan-specific (true) assets and content. diff --git a/experimenter/experimenter/features/manifests/ios/release.fml.yaml b/experimenter/experimenter/features/manifests/ios/release.fml.yaml index 117e14c23e..ff7bf90997 100644 --- a/experimenter/experimenter/features/manifests/ios/release.fml.yaml +++ b/experimenter/experimenter/features/manifests/ios/release.fml.yaml @@ -793,12 +793,12 @@ features: description: | Toggle between legacy (false) and modern (true) UI variants. type: Boolean - default: false + default: true should-use-brand-refresh-configuration: description: | Toggle between standard (false) and brand refresh (true) onboarding content. type: Boolean - default: false + default: true should-use-japan-configuration: description: | Toggle between standard (false) and Japan-specific (true) assets and content. diff --git a/experimenter/experimenter/features/manifests/ios/v149.2.0/RecordedNimbusContext.swift b/experimenter/experimenter/features/manifests/ios/v149.2.0/RecordedNimbusContext.swift new file mode 100644 index 0000000000..e712e8a1c6 --- /dev/null +++ b/experimenter/experimenter/features/manifests/ios/v149.2.0/RecordedNimbusContext.swift @@ -0,0 +1,222 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Glean +import Shared + +import class MozillaAppServices.NimbusGleanPings +import func MozillaAppServices.getCalculatedAttributes +import func MozillaAppServices.getLocaleTag +import struct MozillaAppServices.JsonObject +import protocol MozillaAppServices.RecordedContext +import MozillaRustComponents + +private extension Double? { + func toInt64() -> Int64? { + guard let self = self else { return nil } + return Int64(self) + } +} + +extension Int32? { + func toInt64() -> Int64? { + guard let self = self else { return nil } + return Int64(self) + } +} + +/// TODO(FXIOS-12942): Implement proper thread-safety +final class RecordedNimbusContext: RecordedContext, @unchecked Sendable { + /** + * The following constants are string constants of the keys that appear in the [EVENT_QUERIES] map. + */ + static let DAYS_OPENED_IN_LAST_28 = "days_opened_in_last_28" + + /** + * [EVENT_QUERIES] is a map of keys to Nimbus SDK EventStore queries. + */ + static let EVENT_QUERIES = [ + DAYS_OPENED_IN_LAST_28: "'events.app_opened'|eventCountNonZero('Days', 28, 0)", + ] + + var isFirstRun: Bool + var isPhone: Bool + var isDefaultBrowser: Bool + var isBottomToolbarUser: Bool + var hasEnabledTipsNotifications: Bool + var hasAcceptedTermsOfUse: Bool + var isAppleIntelligenceAvailable: Bool + var cannotUseAppleIntelligence: Bool + var appVersion: String? + var region: String? + var language: String? + var locale: String + var daysSinceInstall: Int32? + var daysSinceUpdate: Int32? + var touExperiencePoints: Int32? + + private var eventQueries: [String: String] + private var eventQueryValues: [String: Double] = [:] + + private var logger: Logger + + init(isFirstRun: Bool, + isDefaultBrowser: Bool, + isBottomToolbarUser: Bool, + hasEnabledTipsNotifications: Bool, + hasAcceptedTermsOfUse: Bool, + isAppleIntelligenceAvailable: Bool, + cannotUseAppleIntelligence: Bool, + eventQueries: [String: String] = RecordedNimbusContext.EVENT_QUERIES, + isPhone: Bool = UIDeviceDetails.userInterfaceIdiom == .phone, + bundle: Bundle = Bundle.main, + logger: Logger = DefaultLogger.shared) { + self.logger = logger + logger.log("init start", level: .debug, category: .experiments) + self.eventQueries = eventQueries + + self.isFirstRun = isFirstRun + self.isPhone = isPhone + self.isDefaultBrowser = isDefaultBrowser + self.isBottomToolbarUser = isBottomToolbarUser + self.hasEnabledTipsNotifications = hasEnabledTipsNotifications + self.hasAcceptedTermsOfUse = hasAcceptedTermsOfUse + self.isAppleIntelligenceAvailable = isAppleIntelligenceAvailable + self.cannotUseAppleIntelligence = cannotUseAppleIntelligence + + let info = bundle.infoDictionary ?? [:] + appVersion = info["CFBundleShortVersionString"] as? String + + locale = getLocaleTag() + var inferredDateInstalledOn: Date? { + guard + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last, + let attributes = try? FileManager.default.attributesOfItem(atPath: documentsURL.path) + else { return nil } + return attributes[.creationDate] as? Date + } + let installationDateSinceEpoch = inferredDateInstalledOn.map { + Int64(($0.timeIntervalSince1970 * 1000).rounded()) + } + guard let dbPath = Experiments.dbPath else { + self.logger.log("Unable to obtain dbPath, skipping calculating attributes", + level: .warning, + category: .experiments) + return + } + guard let calculatedAttributes = try? getCalculatedAttributes(installationDate: installationDateSinceEpoch, + dbPath: dbPath, + locale: locale) + else { return } + + daysSinceInstall = calculatedAttributes.daysSinceInstall + daysSinceUpdate = calculatedAttributes.daysSinceUpdate + language = calculatedAttributes.language + region = calculatedAttributes.region + touExperiencePoints = Experiments.touExperiencePoints(region: region) + self.logger.log("init end", level: .debug, category: .experiments) + } + + /** + * [getEventQueries] is called by the Nimbus SDK Rust code to retrieve the map of event + * queries. The are then executed against the Nimbus SDK's EventStore to retrieve their values. + * + * @return Map + */ + func getEventQueries() -> [String: String] { + logger.log("getEventQueries", level: .debug, category: .experiments) + return eventQueries + } + + /** + * [record] is called when experiment enrollments are evolved. It should apply the + * [RecordedNimbusContext]'s values to a [NimbusSystem.RecordedNimbusContextObject] instance, + * and use that instance to record the values to Glean. + */ + func record() { + logger.log("record start", level: .debug, category: .experiments) + + // Bring the ping into scope so that Glean knows it exists and includes NimbusSystem.recordedNimbusContext + _ = NimbusGleanPings.nimbusTargetingContext + + let eventQueryValuesObject = GleanMetrics.NimbusSystem.RecordedNimbusContextObjectItemEventQueryValuesObject( + daysOpenedInLast28: eventQueryValues[RecordedNimbusContext.DAYS_OPENED_IN_LAST_28].toInt64() + ) + + GleanMetrics.NimbusSystem.recordedNimbusContext.set( + GleanMetrics.NimbusSystem.RecordedNimbusContextObject( + isFirstRun: isFirstRun, + eventQueryValues: eventQueryValuesObject, + isPhone: isPhone, + appVersion: appVersion, + locale: locale, + daysSinceInstall: daysSinceInstall.toInt64(), + daysSinceUpdate: daysSinceUpdate.toInt64(), + language: language, + region: region, + isDefaultBrowser: isDefaultBrowser, + isBottomToolbarUser: isBottomToolbarUser, + hasEnabledTipsNotifications: hasEnabledTipsNotifications, + hasAcceptedTermsOfUse: hasAcceptedTermsOfUse, + isAppleIntelligenceAvailable: isAppleIntelligenceAvailable, + cannotUseAppleIntelligence: cannotUseAppleIntelligence, + touExperiencePoints: touExperiencePoints.toInt64() + ) + ) + GleanMetrics.Pings.shared.nimbus.submit() + logger.log("record end", level: .debug, category: .experiments) + } + + /** + * [setEventQueryValues] is called by the Nimbus SDK Rust code after the event queries have been + * executed. The [eventQueryValues] should be written back to the Kotlin object. + * + * @param [eventQueryValues] The values for each query after they have been executed in the + * Nimbus SDK Rust environment. + */ + func setEventQueryValues(eventQueryValues: [String: Double]) { + logger.log("setEventQueryValues", level: .debug, category: .experiments) + self.eventQueryValues = eventQueryValues + } + + /** + * [toJson] is called by the Nimbus SDK Rust code after the event queries have been executed, + * and before experiment enrollments have been evolved. The value returned from this method + * will be applied directly to the Nimbus targeting context, and its keys/values take + * precedence over those in the main Nimbus targeting context. + * + * @return JsonObject + */ + func toJson() -> JsonObject { + logger.log("toJson start", level: .debug, category: .experiments) + guard let data = try? JSONSerialization.data(withJSONObject: [ + "is_first_run": isFirstRun, + "isFirstRun": "\(isFirstRun)", + "is_phone": isPhone, + "events": eventQueryValues, + "app_version": appVersion as Any, + "region": region as Any, + "language": language as Any, + "locale": locale as Any, + "days_since_install": daysSinceInstall as Any, + "days_since_update": daysSinceUpdate as Any, + "is_default_browser": isDefaultBrowser, + "is_bottom_toolbar_user": isBottomToolbarUser, + "has_enabled_tips_notifications": hasEnabledTipsNotifications, + "has_accepted_terms_of_use": hasAcceptedTermsOfUse, + "is_apple_intelligence_available": isAppleIntelligenceAvailable, + "cannot_use_apple_intelligence": cannotUseAppleIntelligence, + "tou_experience_points": touExperiencePoints as Any + ]), + let jsonString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) as? String + else { + logger.log("toJson error thrown while creating JSON string", level: .warning, category: .experiments) + return "{}" + } + logger.log("toJson end", level: .debug, category: .experiments, extra: ["json": jsonString]) + return jsonString + } +} diff --git a/experimenter/experimenter/features/manifests/ios/v149.2.0/beta.fml.yaml b/experimenter/experimenter/features/manifests/ios/v149.2.0/beta.fml.yaml index a0c025fe11..c188e9e3d5 100644 --- a/experimenter/experimenter/features/manifests/ios/v149.2.0/beta.fml.yaml +++ b/experimenter/experimenter/features/manifests/ios/v149.2.0/beta.fml.yaml @@ -811,12 +811,12 @@ features: description: | Toggle between legacy (false) and modern (true) UI variants. type: Boolean - default: false + default: true should-use-brand-refresh-configuration: description: | Toggle between standard (false) and brand refresh (true) onboarding content. type: Boolean - default: false + default: true should-use-japan-configuration: description: | Toggle between standard (false) and Japan-specific (true) assets and content. diff --git a/experimenter/experimenter/features/manifests/ios/v149.2.0/developer.fml.yaml b/experimenter/experimenter/features/manifests/ios/v149.2.0/developer.fml.yaml index 4be2a726f2..d1a22e2d2e 100644 --- a/experimenter/experimenter/features/manifests/ios/v149.2.0/developer.fml.yaml +++ b/experimenter/experimenter/features/manifests/ios/v149.2.0/developer.fml.yaml @@ -821,12 +821,12 @@ features: description: | Toggle between legacy (false) and modern (true) UI variants. type: Boolean - default: false + default: true should-use-brand-refresh-configuration: description: | Toggle between standard (false) and brand refresh (true) onboarding content. type: Boolean - default: false + default: true should-use-japan-configuration: description: | Toggle between standard (false) and Japan-specific (true) assets and content. diff --git a/experimenter/experimenter/features/manifests/ios/v149.2.0/release.fml.yaml b/experimenter/experimenter/features/manifests/ios/v149.2.0/release.fml.yaml index 117e14c23e..ff7bf90997 100644 --- a/experimenter/experimenter/features/manifests/ios/v149.2.0/release.fml.yaml +++ b/experimenter/experimenter/features/manifests/ios/v149.2.0/release.fml.yaml @@ -793,12 +793,12 @@ features: description: | Toggle between legacy (false) and modern (true) UI variants. type: Boolean - default: false + default: true should-use-brand-refresh-configuration: description: | Toggle between standard (false) and brand refresh (true) onboarding content. type: Boolean - default: false + default: true should-use-japan-configuration: description: | Toggle between standard (false) and Japan-specific (true) assets and content. diff --git a/experimenter/manifesttool/appconfig.py b/experimenter/manifesttool/appconfig.py index 1199247690..8493e6e969 100644 --- a/experimenter/manifesttool/appconfig.py +++ b/experimenter/manifesttool/appconfig.py @@ -150,6 +150,7 @@ class AppConfig(BaseModel): fml_path: str | list[str] | None = None experimenter_yaml_path: str | None = None release_discovery: ReleaseDiscovery | None = None + targeting_files: list[str] | None = None @model_validator(mode="before") @classmethod diff --git a/experimenter/manifesttool/fetch.py b/experimenter/manifesttool/fetch.py index 9ddb98fc58..c53a2b14e6 100644 --- a/experimenter/manifesttool/fetch.py +++ b/experimenter/manifesttool/fetch.py @@ -107,6 +107,19 @@ def fetch_fml_app( version, ) + targeting_files_path = app_config.targeting_files + if targeting_files_path and version: + print(f"fetch: {app_name}: downloading targeting files for version {version}") + github_api.fetch_file( + app_config.repo.name, + targeting_files_path[0], + ref.target, + manifest_dir + / app_config.slug + / f"v{version}" + / Path(targeting_files_path[0]).name, + ) + print(f"fetch: {app_name}: generate experimenter.yaml") # The single-file fml file for each channel will generate the same # experimenter.yaml, so we can pick any here. @@ -169,6 +182,19 @@ def fetch_legacy_app( manifest_path, ) + targeting_files_path = app_config.targeting_files + if targeting_files_path and version: + print(f"fetch: {app_name}: downloading targeting files for version {version}") + github_api.fetch_file( + app_config.repo.name, + targeting_files_path[0], + ref.target, + manifest_dir + / app_config.slug + / f"v{version}" + / Path(targeting_files_path[0]).name, + ) + with manifest_path.open() as f: raw_manifest = yaml.safe_load(f) manifest = DesktopFeatureManifest.parse_obj(raw_manifest) @@ -252,6 +278,17 @@ def fetch_releases( results.append(result) + targeting_files_path = app_config.targeting_files + if targeting_files_path: + print(f"fetch: {app_name} at {ref.name} downloading targeting files ") + + github_api.fetch_file( + app_config.repo.name, + targeting_files_path[0], + ref.target, + manifest_dir / app_config.slug / Path(targeting_files_path[0]).name, + ) + return results diff --git a/experimenter/manifesttool/github_api.py b/experimenter/manifesttool/github_api.py index 35e79c3bf9..82af136748 100644 --- a/experimenter/manifesttool/github_api.py +++ b/experimenter/manifesttool/github_api.py @@ -96,7 +96,9 @@ def _get_refs(repo: str, kind: str) -> list[Ref]: @overload -def fetch_file(repo: str, file_path: str, rev: str) -> str: ... # pragma: no cover +def fetch_file( + repo: str, file_path: str, rev: str +) -> Optional[str]: ... # pragma: no cover @overload diff --git a/experimenter/manifesttool/tests/test_fetch.py b/experimenter/manifesttool/tests/test_fetch.py index 202429b431..0f1f4cc7ef 100644 --- a/experimenter/manifesttool/tests/test_fetch.py +++ b/experimenter/manifesttool/tests/test_fetch.py @@ -4,7 +4,7 @@ from tempfile import TemporaryDirectory from typing import Any, Optional from unittest import TestCase -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch import responses import yaml @@ -101,6 +101,7 @@ def mock_download_single_file( version: Optional[Version], ): """A mock version of `nimbus fml -- single file`.""" + (manifest_dir / app_config.slug).mkdir(exist_ok=True) filename = _get_fml_path(manifest_dir, app_config, channel, version) with filename.open("w") as f: yaml.dump(generate_fml(app_config, channel), f) @@ -1082,6 +1083,78 @@ def test_fetch_releases_cached(self, fetch_fml_app): ), ) + @patch.object( + manifesttool.fetch, + "discover_branched_releases", + lambda *args: { + Version(1): Ref("branch", "foo"), + Version(1, 2, 3): Ref("tag", "bar"), + }, + ) + @patch.object(manifesttool.fetch.github_api, "download") + @patch.object(manifesttool.fetch.github_api, "api_request") + @patch.object( + manifesttool.fetch.nimbus_cli, + "download_single_file", + side_effect=mock_download_single_file, + ) + @patch.object( + manifesttool.fetch.nimbus_cli, + "get_channels", + side_effect=lambda *args: ["release", "beta"], + ) + def test_fetch_releases_targeting_contexts( + self, + get_channels, + download_single_file, + api_request, + mock_download, + ): + app_config = AppConfig( + slug="fml-app", + repo=Repository( + type=RepositoryType.GITHUB, + name="fml-repo", + ), + fml_path="nimbus.fml.yaml", + release_discovery=ReleaseDiscovery( + version_file=VersionFile.create_plain_text("version.txt"), + strategies=[DiscoveryStrategy.create_branched()], + ), + targeting_files=["targeting-contexts.yaml"], + ) + + # Mock api_request to return a response with download_url + mock_response = MagicMock() + mock_response.json.return_value = { + "download_url": "https://raw.githubusercontent.com/fml-repo/targeting-contexts.yaml" + } + api_request.return_value = mock_response + + cache = RefCache() + + # Mock download.to_path to create files locally + def create_file(url, path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# mock targeting contexts") + + mock_download.to_path.side_effect = create_file + + with TemporaryDirectory() as tmp: + manifest_dir = Path(tmp) + + fetch_releases(manifest_dir, "fml_app", app_config, cache) + + self.assertTrue( + (manifest_dir / "fml-app" / "v1.0.0" / "targeting-contexts.yaml").exists() + ) + self.assertTrue( + (manifest_dir / "fml-app" / "v1.2.3" / "targeting-contexts.yaml").exists() + ) + self.assertTrue( + (manifest_dir / "fml-app" / "targeting-contexts.yaml").exists() + ) + def test_summarize_results(self): buffer = StringIO()