diff --git a/Cargo.lock b/Cargo.lock index 610d71551f..f85fafcb5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +[[package]] +name = "android_log-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e" + +[[package]] +name = "android_logger" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037f3e1da32ddba7770530e69258b742c15ad67bdf90e5f6b35f4b6db9a60eb7" +dependencies = [ + "android_log-sys", + "env_logger 0.10.0", + "log", + "once_cell", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -947,6 +965,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dashmap" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c" +dependencies = [ + "cfg-if 1.0.0", + "num_cpus", +] + [[package]] name = "difference" version = "2.0.0" @@ -1141,6 +1169,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "log", +] + [[package]] name = "errno" version = "0.2.8" @@ -1623,6 +1660,26 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" +[[package]] +name = "glean" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca520309b2eb189c3834b4d9752286615a1cc04798715805a6da8f0d7f16941" +dependencies = [ + "chrono", + "crossbeam-channel", + "glean-core", + "inherent", + "log", + "once_cell", + "serde", + "serde_json", + "thiserror", + "time 0.1.44", + "uuid 1.4.1", + "whatsys", +] + [[package]] name = "glean-build" version = "8.1.0" @@ -1630,6 +1687,30 @@ dependencies = [ "xshell-venv", ] +[[package]] +name = "glean-core" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07e342f3f81264ddd21a0cdd37888052d501fa76cdff73948e972731009708e" +dependencies = [ + "android_logger", + "bincode", + "chrono", + "crossbeam-channel", + "flate2", + "log", + "once_cell", + "oslog", + "rkv 0.18.4", + "serde", + "serde_json", + "thiserror", + "time 0.1.44", + "uniffi", + "uuid 1.4.1", + "zeitstempel", +] + [[package]] name = "glob" version = "0.3.1" @@ -1892,6 +1973,17 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inherent" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce243b1bfa62ffc028f1cc3b6034ec63d649f3031bc8a4fbbb004e1ac17d1f68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.26", +] + [[package]] name = "instant" version = "0.1.12" @@ -2023,7 +2115,7 @@ dependencies = [ "serde_json", "time 0.3.11", "url", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -2583,13 +2675,14 @@ dependencies = [ "clap 2.34.0", "env_logger 0.7.1", "error-support", + "glean", "glean-build", "hex", "jexl-eval", "log", "once_cell", "remote_settings", - "rkv", + "rkv 0.17.1", "serde", "serde_derive", "serde_json", @@ -2599,7 +2692,7 @@ dependencies = [ "unicode-segmentation", "uniffi", "url", - "uuid", + "uuid 0.8.2", "viaduct-reqwest", ] @@ -2904,6 +2997,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "oslog" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8343ce955f18e7e68c0207dd0ea776ec453035685395ababd2ea651c569728b3" +dependencies = [ + "cc", + "dashmap", + "log", +] + [[package]] name = "output_vt100" version = "0.1.3" @@ -3552,7 +3656,30 @@ dependencies = [ "serde_derive", "thiserror", "url", - "uuid", + "uuid 0.8.2", +] + +[[package]] +name = "rkv" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0ea3af1393b22f8fe25615b6fa5d13072b7b622e66acffc8b12b2baa0342b1" +dependencies = [ + "arrayref", + "bincode", + "bitflags 1.3.2", + "byteorder", + "id-arena", + "lazy_static", + "lmdb-rkv", + "log", + "ordered-float", + "paste", + "serde", + "serde_derive", + "thiserror", + "url", + "uuid 1.4.1", ] [[package]] @@ -4713,6 +4840,15 @@ dependencies = [ "serde", ] +[[package]] +name = "uuid" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +dependencies = [ + "getrandom", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4996,6 +5132,17 @@ dependencies = [ "nom", ] +[[package]] +name = "whatsys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb632c0076024630111a08ca9fcbd34736c80d10b9ae517077487b0c82f46a36" +dependencies = [ + "cc", + "cfg-if 1.0.0", + "libc", +] + [[package]] name = "which" version = "4.2.5" @@ -5322,3 +5469,14 @@ checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" dependencies = [ "linked-hash-map", ] + +[[package]] +name = "zeitstempel" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeea3eb6a30ed24e374f59368d3917c5180a845fdd4ed6f1b2278811a9e826f8" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "once_cell", +] diff --git a/components/nimbus/Cargo.toml b/components/nimbus/Cargo.toml index 8f34a9470d..3c567d91c0 100644 --- a/components/nimbus/Cargo.toml +++ b/components/nimbus/Cargo.toml @@ -39,6 +39,7 @@ unicode-segmentation = "1.8.0" error-support = { path = "../support/error" } remote_settings = { path = "../remote_settings", optional = true } cfg-if = "1.0.0" +glean = "54.0.0" [build-dependencies] uniffi = { version = "0.24.1", features = ["build"] } diff --git a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt index 429cc74cd9..62eafbad30 100644 --- a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt +++ b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt @@ -27,15 +27,9 @@ import mozilla.telemetry.glean.Glean import org.json.JSONObject import org.mozilla.experiments.nimbus.GleanMetrics.NimbusEvents import org.mozilla.experiments.nimbus.GleanMetrics.NimbusHealth -import org.mozilla.experiments.nimbus.internal.AppContext +import org.mozilla.experiments.nimbus.internal.* import org.mozilla.experiments.nimbus.internal.AvailableExperiment -import org.mozilla.experiments.nimbus.internal.AvailableRandomizationUnits import org.mozilla.experiments.nimbus.internal.EnrolledExperiment -import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent -import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEventType -import org.mozilla.experiments.nimbus.internal.NimbusClient -import org.mozilla.experiments.nimbus.internal.NimbusClientInterface -import org.mozilla.experiments.nimbus.internal.NimbusException import java.io.File import java.io.IOException @@ -77,6 +71,29 @@ open class Nimbus( private val logger = delegate.logger + private val metricsHandler = object : HostMetricsHandler { + override fun recordEnrollmentStatus(enrollmentStatusExtra: EnrollmentStatusExtraDef) { + NimbusEvents.enrollmentStatus.record( + NimbusEvents.EnrollmentStatusExtra( + branch = enrollmentStatusExtra.branch, + slug = enrollmentStatusExtra.slug, + status = enrollmentStatusExtra.status, + reason = enrollmentStatusExtra.reason, + errorString = enrollmentStatusExtra.errorString, + conflictSlug = enrollmentStatusExtra.conflictSlug, + ) + ) + } + + override fun getEnrollmentStatusRecords(): List { + TODO("Not yet implemented") + } + + override fun clear() { + TODO("Not yet implemented") + } + } + private val nimbusClient: NimbusClientInterface override var globalUserParticipation: Boolean @@ -118,6 +135,7 @@ open class Nimbus( // The "dummy" field here is required for obscure reasons when generating code on desktop, // so we just automatically set it to a dummy value. AvailableRandomizationUnits(clientId = null, userId = null, nimbusId = null, dummy = 0), + metricsHandler, ) } diff --git a/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt b/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt index 96902147a5..48e21601c5 100644 --- a/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt +++ b/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt @@ -33,8 +33,7 @@ import org.mockito.Mockito import org.mockito.Mockito.`when` import org.mozilla.experiments.nimbus.GleanMetrics.NimbusEvents import org.mozilla.experiments.nimbus.GleanMetrics.NimbusHealth -import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent -import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEventType +import org.mozilla.experiments.nimbus.internal.* import org.robolectric.RobolectricTestRunner import java.util.Calendar import java.util.concurrent.Executors @@ -644,6 +643,31 @@ class NimbusTests { assertTrue(observed) } + + @Test + fun `Nimbus records EnrollmentStatus metrics`() { + suspend fun getString(): String { + return testExperimentsJsonString(appInfo, packageName) + } + + val job = nimbus.applyLocalExperiments(::getString) + runBlocking { + job.join() + } + + assertEquals(1, nimbus.getAvailableExperiments().size) + assertNotNull("Event must have a value", NimbusEvents.enrollmentStatus.testGetValue()) + val enrollmentStatusEvents = NimbusEvents.enrollmentStatus.testGetValue()!! + assertEquals("Event count must match", enrollmentStatusEvents.count(), 1) + + val enrolledExtra = enrollmentStatusEvents[0].extra!! + assertEquals("branch must match", "test-branch", enrolledExtra["branch"]) + assertEquals("slug must match", "test-experiment", enrolledExtra["slug"]) + assertEquals("status must match", "Enrolled", enrolledExtra["status"]) + assertEquals("reason must match", "Qualified", enrolledExtra["reason"]) + assertEquals("errorString must match", null, enrolledExtra["error_string"]) + assertEquals("conflictSlug must match", null, enrolledExtra["conflict_slug"]) + } } // Mocking utilities, from mozilla.components.support.test diff --git a/components/nimbus/examples/experiment.rs b/components/nimbus/examples/experiment.rs index 55146c7308..5badf2fdbe 100644 --- a/components/nimbus/examples/experiment.rs +++ b/components/nimbus/examples/experiment.rs @@ -12,12 +12,29 @@ fn main() -> Result<()> { use clap::{App, Arg, SubCommand}; use env_logger::Env; use nimbus::{ + metrics::{EnrollmentStatusExtraDef, HostMetricsHandler}, AppContext, AvailableRandomizationUnits, EnrollmentStatus, NimbusClient, NimbusTargetingHelper, RemoteSettingsConfig, }; use std::collections::HashMap; use std::io::prelude::*; + pub struct NoopHostMetrics {} + + impl HostMetricsHandler for NoopHostMetrics { + fn record_enrollment_status(&self, _: EnrollmentStatusExtraDef) { + // do nothing + } + + fn get_enrollment_status_records(&self) -> Vec { + todo!() + } + + fn clear(&self) { + todo!() + } + } + // We set the logging level to be `warn` here, meaning that only // logs of `warn` or higher will be actually be shown, any other // error will be omitted @@ -208,6 +225,7 @@ fn main() -> Result<()> { db_path, Some(config), aru, + Box::new(NoopHostMetrics {}), )?; log::info!("Nimbus ID is {}", nimbus_client.nimbus_id()?); diff --git a/components/nimbus/ios/Nimbus/NimbusCreate.swift b/components/nimbus/ios/Nimbus/NimbusCreate.swift index dcf4d4f61b..548850592a 100644 --- a/components/nimbus/ios/Nimbus/NimbusCreate.swift +++ b/components/nimbus/ios/Nimbus/NimbusCreate.swift @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import Foundation +import Glean import UIKit private let logTag = "Nimbus.swift" @@ -18,6 +19,26 @@ public let defaultErrorReporter: NimbusErrorReporter = { err in } } +class MetricsHandler: HostMetricsHandler { + func recordEnrollmentStatus(enrollmentStatusExtra: EnrollmentStatusExtraDef) { + GleanMetrics.NimbusEvents.enrollmentStatus + .record(GleanMetrics.NimbusEvents.EnrollmentStatusExtra( + branch: enrollmentStatusExtra.branch, + conflictSlug: enrollmentStatusExtra.conflictSlug, + errorString: enrollmentStatusExtra.errorString, + reason: enrollmentStatusExtra.reason, + slug: enrollmentStatusExtra.slug, + status: enrollmentStatusExtra.status + )) + } + + func getEnrollmentStatusRecords() -> [EnrollmentStatusExtraDef] { + return [] + } + + func clear() {} +} + public extension Nimbus { /// Create an instance of `Nimbus`. /// @@ -63,7 +84,8 @@ public extension Nimbus { userId: nil, nimbusId: nil, dummy: 0 - ) + ), + hostMetricsHandler: MetricsHandler() ) return Nimbus(nimbusClient: nimbusClient, resourceBundles: resourceBundles, errorReporter: errorReporter) diff --git a/components/nimbus/metrics.yaml b/components/nimbus/metrics.yaml index 1b6c65b981..4b56102001 100644 --- a/components/nimbus/metrics.yaml +++ b/components/nimbus/metrics.yaml @@ -203,6 +203,39 @@ nimbus_events: - jhugman@mozilla.com - nimbus-team@mozilla.com expires: never + enrollment_status: + type: event + description: > + Recorded for each enrollment status each time the SDK completes application of pending experiments. + extra_keys: + slug: + type: string + description: The slug/unique identifier of the experiment + status: + type: string + description: The status of this enrollment + reason: + type: string + description: The reason the client is in the noted status + branch: + type: string + description: The branch slug/identifier that was randomly chosen (if the client is enrolled) + error_string: + type: string + description: If the enrollment resulted in an error, the associated error string + conflict_slug: + type: string + description: If the enrollment hit a feature conflict, the slug of the conflicting experiment/rollout + bugs: + - https://mozilla-hub.atlassian.net/browse/EXP-3827 + data_reviews: + - '' + data_sensitivity: + - technical + notification_emails: + - chumphreys@mozilla.com + - project-nimbus@mozilla.com + expires: never nimbus_health: cache_not_ready_for_feature: type: event diff --git a/components/nimbus/src/enrollment.rs b/components/nimbus/src/enrollment.rs index 3e12358459..20780a4f99 100644 --- a/components/nimbus/src/enrollment.rs +++ b/components/nimbus/src/enrollment.rs @@ -12,6 +12,7 @@ use ::uuid::Uuid; use serde_derive::*; use std::{ collections::{HashMap, HashSet}, + fmt, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -28,6 +29,12 @@ pub enum EnrolledReason { OptIn, } +impl fmt::Display for EnrolledReason { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + // These are types we use internally for managing non-enrollments. // ⚠️ Attention : Changes to this type should be accompanied by a new test ⚠️ @@ -46,6 +53,12 @@ pub enum NotEnrolledReason { FeatureConflict, } +impl fmt::Display for NotEnrolledReason { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + // These are types we use internally for managing disqualifications. // ⚠️ Attention : Changes to this type should be accompanied by a new test ⚠️ @@ -62,6 +75,12 @@ pub enum DisqualifiedReason { NotSelected, } +impl fmt::Display for DisqualifiedReason { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + // Every experiment has an ExperimentEnrollment, even when we aren't enrolled. // ⚠️ Attention : Changes to this type should be accompanied by a new test ⚠️ @@ -439,7 +458,9 @@ impl ExperimentEnrollment { }, EnrollmentChangeEventType::Disqualification, ), - EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => unreachable!(), + EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Error { .. } => { + unreachable!() + } } } @@ -496,6 +517,19 @@ pub enum EnrollmentStatus { }, } +impl EnrollmentStatus { + pub fn name(&self) -> String { + match self { + EnrollmentStatus::Enrolled { .. } => "Enrolled", + EnrollmentStatus::NotEnrolled { .. } => "NotEnrolled", + EnrollmentStatus::Disqualified { .. } => "Disqualified", + EnrollmentStatus::WasEnrolled { .. } => "WasEnrolled", + EnrollmentStatus::Error { .. } => "Error", + } + .into() + } +} + impl EnrollmentStatus { // Note that for now, we only support a single feature_id per experiment, // so this code is expected to shift once we start supporting multiple. diff --git a/components/nimbus/src/error.rs b/components/nimbus/src/error.rs index dc54caf9a1..5cc5e004aa 100644 --- a/components/nimbus/src/error.rs +++ b/components/nimbus/src/error.rs @@ -66,6 +66,9 @@ pub enum NimbusError { #[cfg(not(feature = "stateful"))] #[error("Error in Cirrus: {0}")] CirrusError(#[from] CirrusClientError), + #[cfg(feature = "stateful")] + #[error("UniFFI callback error: {0}")] + UniFFICallbackError(#[from] uniffi::UnexpectedUniFFICallbackError), } #[cfg(feature = "stateful")] diff --git a/components/nimbus/src/lib.rs b/components/nimbus/src/lib.rs index bcf7ea509d..de626a2dbc 100644 --- a/components/nimbus/src/lib.rs +++ b/components/nimbus/src/lib.rs @@ -23,6 +23,11 @@ pub use targeting::NimbusTargetingHelper; cfg_if::cfg_if! { if #[cfg(feature = "stateful")] { + mod glean_metrics { + include!(concat!(env!("OUT_DIR"), "/glean_metrics.rs")); + } + pub mod metrics; + pub mod stateful; pub use stateful::nimbus_client::*; diff --git a/components/nimbus/src/metrics.rs b/components/nimbus/src/metrics.rs new file mode 100644 index 0000000000..12c6c8394d --- /dev/null +++ b/components/nimbus/src/metrics.rs @@ -0,0 +1,59 @@ +use crate::{ + enrollment::ExperimentEnrollment, glean_metrics::nimbus_events::EnrollmentStatusExtra, + EnrollmentStatus, +}; +use serde_derive::{Deserialize, Serialize}; + +pub trait HostMetricsHandler: Send + Sync { + fn record_enrollment_status(&self, enrollment_status_extra: EnrollmentStatusExtraDef); + + /// Only used for testing + fn get_enrollment_status_records(&self) -> Vec; + + /// Only used for testing + fn clear(&self); +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(remote = "EnrollmentStatusExtra")] +pub struct EnrollmentStatusExtraDef { + pub branch: Option, + pub conflict_slug: Option, + pub error_string: Option, + pub reason: Option, + pub slug: Option, + pub status: Option, +} + +impl From for EnrollmentStatusExtraDef { + fn from(enrollment: ExperimentEnrollment) -> Self { + let mut branch_value: Option = None; + let mut reason_value: Option = None; + let mut error_value: Option = None; + match &enrollment.status { + EnrollmentStatus::Enrolled { reason, branch, .. } => { + branch_value = Some(branch.to_owned()); + reason_value = Some(reason.to_string()); + } + EnrollmentStatus::Disqualified { reason, branch, .. } => { + branch_value = Some(branch.to_owned()); + reason_value = Some(reason.to_string()); + } + EnrollmentStatus::NotEnrolled { reason } => { + reason_value = Some(reason.to_string()); + } + EnrollmentStatus::WasEnrolled { branch, .. } => branch_value = Some(branch.to_owned()), + EnrollmentStatus::Error { reason } => { + error_value = Some(reason.to_owned()); + } + } + EnrollmentStatusExtraDef { + branch: branch_value, + conflict_slug: None, + error_string: error_value, + reason: reason_value, + slug: Some(enrollment.slug), + status: Some(enrollment.status.name()), + } + } +} diff --git a/components/nimbus/src/nimbus.udl b/components/nimbus/src/nimbus.udl index 8906eb9444..b52ab560e9 100644 --- a/components/nimbus/src/nimbus.udl +++ b/components/nimbus/src/nimbus.udl @@ -77,6 +77,23 @@ enum EnrollmentChangeEventType { "UnenrollFailed", }; +callback interface HostMetricsHandler { + void record_enrollment_status(EnrollmentStatusExtraDef enrollment_status_extra); + + sequence get_enrollment_status_records(); + + void clear(); +}; + +dictionary EnrollmentStatusExtraDef { + string? branch; + string? conflict_slug; + string? error_string; + string? reason; + string? slug; + string? status; +}; + [Error] enum NimbusError { "InvalidPersistedData", "RkvError", "IOError", @@ -85,7 +102,7 @@ enum NimbusError { "UuidError", "InvalidExperimentFormat", "InvalidPath", "InternalError", "NoSuchExperiment", "NoSuchBranch", "DatabaseNotReady", "VersionParsingError", "BehaviorError", "TryFromIntError", - "ParseIntError", "TransformParameterError", "ClientError", + "ParseIntError", "TransformParameterError", "ClientError", "UniFFICallbackError", }; interface NimbusClient { @@ -95,7 +112,8 @@ interface NimbusClient { optional sequence coenrolling_feature_ids = [], string dbpath, RemoteSettingsConfig? remote_settings_config, - AvailableRandomizationUnits available_randomization_units + AvailableRandomizationUnits available_randomization_units, + HostMetricsHandler host_metrics_handler ); // Initializes the database and caches enough information so that the diff --git a/components/nimbus/src/stateful/dbcache.rs b/components/nimbus/src/stateful/dbcache.rs index 81f193e889..1acb0b8ce7 100644 --- a/components/nimbus/src/stateful/dbcache.rs +++ b/components/nimbus/src/stateful/dbcache.rs @@ -24,6 +24,7 @@ use std::sync::RwLock; // This struct is the cached data. This is never mutated, but instead // recreated every time the cache is updated. struct CachedData { + pub enrollments: Vec, pub experiments_by_slug: HashMap, pub features_by_feature_id: HashMap, } @@ -80,6 +81,7 @@ impl DatabaseCache { // This is where rollouts (promoted experiments on a given feature) will be merged in to the feature variables. let data = CachedData { + enrollments, experiments_by_slug, features_by_feature_id, }; @@ -151,4 +153,8 @@ impl DatabaseCache { .collect::>() }) } + + pub fn get_enrollments(&self) -> Result> { + self.get_data(|data| data.enrollments.to_owned()) + } } diff --git a/components/nimbus/src/stateful/nimbus_client.rs b/components/nimbus/src/stateful/nimbus_client.rs index dce3072e3b..8a04a27183 100644 --- a/components/nimbus/src/stateful/nimbus_client.rs +++ b/components/nimbus/src/stateful/nimbus_client.rs @@ -10,6 +10,7 @@ use crate::{ }, error::BehaviorError, evaluator::{is_experiment_available, TargetingAttributes}, + metrics::{EnrollmentStatusExtraDef, HostMetricsHandler}, schema::parse_experiments, stateful::{ behavior::EventStore, @@ -32,6 +33,7 @@ use once_cell::sync::OnceCell; use remote_settings::RemoteSettingsConfig; use serde_json::{Map, Value}; use std::collections::HashSet; +use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use uuid::Uuid; @@ -67,6 +69,7 @@ pub struct NimbusClient { db_path: PathBuf, coenrolling_feature_ids: Vec, event_store: Arc>, + pub(crate) host_metrics_handler: Arc>, } impl NimbusClient { @@ -78,6 +81,7 @@ impl NimbusClient { db_path: P, config: Option, available_randomization_units: AvailableRandomizationUnits, + host_metrics: Box, ) -> Result { let settings_client = Mutex::new(create_client(config)?); @@ -95,6 +99,7 @@ impl NimbusClient { coenrolling_feature_ids, db: OnceCell::default(), event_store: Arc::default(), + host_metrics_handler: Arc::new(host_metrics), }) } @@ -151,6 +156,7 @@ impl NimbusClient { .collect(); self.database_cache .commit_and_update(db, writer, &coenrolling_ids)?; + self.record_enrollment_status_telemetry()?; Ok(()) } @@ -684,6 +690,14 @@ impl NimbusClient { } Ok(()) } + + fn record_enrollment_status_telemetry(&self) -> Result<()> { + for enrollment in self.database_cache.get_enrollments()? { + self.host_metrics_handler + .record_enrollment_status(enrollment.into()); + } + Ok(()) + } } pub struct NimbusStringHelper { diff --git a/components/nimbus/src/tests/mod.rs b/components/nimbus/src/tests/mod.rs index 7d7262a247..6765bb6bf1 100644 --- a/components/nimbus/src/tests/mod.rs +++ b/components/nimbus/src/tests/mod.rs @@ -14,6 +14,7 @@ mod test_versioning; #[cfg(feature = "stateful")] mod stateful { + mod helpers; mod test_behavior; mod test_enrollment; mod test_evaluator; diff --git a/components/nimbus/src/tests/stateful/client/test_null_client.rs b/components/nimbus/src/tests/stateful/client/test_null_client.rs index 6ddb8eb106..04865d1752 100644 --- a/components/nimbus/src/tests/stateful/client/test_null_client.rs +++ b/components/nimbus/src/tests/stateful/client/test_null_client.rs @@ -6,6 +6,7 @@ #![allow(unused_imports)] use crate::error::Result; +use crate::tests::stateful::helpers::MockHostMetrics; #[cfg(feature = "rkv-safe-mode")] #[test] @@ -23,6 +24,7 @@ fn test_null_client() -> Result<()> { tmp_dir.path(), None, aru, + Box::new(MockHostMetrics::new()), )?; client.fetch_experiments()?; client.apply_pending_experiments()?; diff --git a/components/nimbus/src/tests/stateful/helpers.rs b/components/nimbus/src/tests/stateful/helpers.rs new file mode 100644 index 0000000000..55e925fd6f --- /dev/null +++ b/components/nimbus/src/tests/stateful/helpers.rs @@ -0,0 +1,38 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ +use crate::metrics::{EnrollmentStatusExtraDef, HostMetricsHandler}; +use std::ops::Deref; +use std::sync::Mutex; + +#[derive(Default)] +pub struct MockHostMetrics { + pub enrollment_status_records: Mutex>, +} + +impl MockHostMetrics { + pub fn new() -> Self { + Self { + enrollment_status_records: Default::default(), + } + } +} + +impl HostMetricsHandler for MockHostMetrics { + fn record_enrollment_status(&self, enrollment_status_extra: EnrollmentStatusExtraDef) { + let mut records = self.enrollment_status_records.lock().unwrap(); + records.push(enrollment_status_extra); + } + + fn get_enrollment_status_records(&self) -> Vec { + self.enrollment_status_records + .lock() + .unwrap() + .deref() + .to_vec() + } + + fn clear(&self) { + self.enrollment_status_records.lock().unwrap().clear(); + } +} diff --git a/components/nimbus/src/tests/stateful/test_nimbus.rs b/components/nimbus/src/tests/stateful/test_nimbus.rs index 1237b961b0..085cccbba8 100644 --- a/components/nimbus/src/tests/stateful/test_nimbus.rs +++ b/components/nimbus/src/tests/stateful/test_nimbus.rs @@ -2,12 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use crate::enrollment::DisqualifiedReason; -use crate::tests::helpers::{ - get_bucketed_rollout, get_targeted_experiment, to_local_experiments_string, -}; use crate::{ - enrollment::{EnrolledReason, EnrollmentStatus, ExperimentEnrollment}, + enrollment::{DisqualifiedReason, EnrolledReason, EnrollmentStatus, ExperimentEnrollment}, error::Result, stateful::{ behavior::{ @@ -16,7 +12,13 @@ use crate::{ }, persistence::{Database, StoreId}, }, - tests::helpers::get_ios_rollout_experiment, + tests::{ + helpers::{ + get_bucketed_rollout, get_ios_rollout_experiment, get_targeted_experiment, + to_local_experiments_string, + }, + stateful::helpers::MockHostMetrics, + }, AppContext, AvailableRandomizationUnits, Experiment, NimbusClient, TargetingAttributes, DB_KEY_APP_VERSION, DB_KEY_UPDATE_DATE, }; @@ -24,7 +26,9 @@ use chrono::{DateTime, Duration, Utc}; use serde_json::json; use std::io::Write; use std::path::Path; +use std::str::FromStr; use tempfile::TempDir; +use uuid::Uuid; #[test] fn test_telemetry_reset() -> Result<()> { @@ -42,6 +46,7 @@ fn test_telemetry_reset() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let get_client_id = || { @@ -120,6 +125,7 @@ fn test_installation_date() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; @@ -157,6 +163,7 @@ fn test_installation_date() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; delete_test_creation_date(tmp_dir.path()).ok(); // When we check the filesystem, we will fail. We haven't `set_test_creation_date` @@ -181,6 +188,7 @@ fn test_installation_date() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; // We now store a date for days ago in our file system @@ -213,6 +221,7 @@ fn test_installation_date() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; // now that the store is clear, we will fallback again to the @@ -242,6 +251,7 @@ fn test_days_since_calculation_happens_at_startup() -> Result<()> { tmp_dir.path(), None, Default::default(), + Box::new(MockHostMetrics::new()), )?; // 0. We haven't initialized anything yet, so dates won't be available. @@ -268,6 +278,7 @@ fn test_days_since_calculation_happens_at_startup() -> Result<()> { tmp_dir.path(), None, Default::default(), + Box::new(MockHostMetrics::new()), )?; client.apply_pending_experiments()?; let targeting_attributes = client.get_targeting_attributes(); @@ -290,6 +301,7 @@ fn test_days_since_update_changes_with_context() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; @@ -312,6 +324,7 @@ fn test_days_since_update_changes_with_context() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; client.apply_pending_experiments()?; @@ -341,6 +354,7 @@ fn test_days_since_update_changes_with_context() -> Result<()> { client_id: Some(mock_client_id.clone()), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; client.apply_pending_experiments()?; @@ -376,6 +390,7 @@ fn test_days_since_update_changes_with_context() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; client.initialize()?; client.apply_pending_experiments()?; @@ -419,6 +434,7 @@ fn test_days_since_install() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -498,6 +514,7 @@ fn test_days_since_install_failed_targeting() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -576,6 +593,7 @@ fn test_days_since_update() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -655,6 +673,7 @@ fn test_days_since_update_failed_targeting() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -746,6 +765,7 @@ fn event_store_exists_for_apply_pending_experiments() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -869,6 +889,7 @@ fn event_store_on_targeting_attributes_is_updated_after_an_event_is_recorded() - client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -961,7 +982,14 @@ fn test_ios_rollout() -> Result<()> { ..Default::default() }; let tmp_dir = TempDir::new()?; - let client = NimbusClient::new(ctx, Default::default(), tmp_dir.path(), None, aru)?; + let client = NimbusClient::new( + ctx, + Default::default(), + tmp_dir.path(), + None, + aru, + Box::new(MockHostMetrics::new()), + )?; let exp = get_ios_rollout_experiment(); let data = json!({ @@ -994,6 +1022,7 @@ fn test_fetch_enabled() -> Result<()> { tmp_dir.path(), None, Default::default(), + Box::new(MockHostMetrics::new()), )?; client.set_fetch_enabled(false)?; @@ -1006,6 +1035,7 @@ fn test_fetch_enabled() -> Result<()> { tmp_dir.path(), None, Default::default(), + Box::new(MockHostMetrics::new()), )?; assert!(!client.is_fetch_enabled()?); Ok(()) @@ -1032,6 +1062,7 @@ fn test_active_enrollment_in_targeting() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { app_context, @@ -1095,6 +1126,7 @@ fn test_previous_enrollments_in_targeting() -> Result<()> { client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { @@ -1240,6 +1272,7 @@ fn test_opt_out_multiple_experiments_same_feature_does_not_re_enroll() -> Result client_id: Some(mock_client_id), ..AvailableRandomizationUnits::default() }, + Box::new(MockHostMetrics::new()), )?; let targeting_attributes = TargetingAttributes { @@ -1272,3 +1305,106 @@ fn test_opt_out_multiple_experiments_same_feature_does_not_re_enroll() -> Result Ok(()) } + +#[test] +fn test_enrollment_status_metrics_recorded() -> Result<()> { + let mock_client_id = "client-1".to_string(); + + let temp_dir = tempfile::tempdir()?; + + let metrics = Box::new(MockHostMetrics::new()); + let app_context = AppContext { + app_name: "fenix".to_string(), + app_id: "org.mozilla.fenix".to_string(), + channel: "nightly".to_string(), + ..Default::default() + }; + let mut client = NimbusClient::new( + app_context.clone(), + Default::default(), + temp_dir.path(), + None, + AvailableRandomizationUnits { + client_id: Some(mock_client_id), + ..AvailableRandomizationUnits::default() + }, + metrics, + )?; + client.set_nimbus_id(&Uuid::from_str("53baafb3-b800-42ac-878c-c3451e250928")?)?; + + let targeting_attributes = TargetingAttributes { + app_context, + ..Default::default() + }; + client.with_targeting_attributes(targeting_attributes); + client.initialize()?; + + let slug_1 = "experiment-1"; + let slug_2 = "experiment-2"; + let slug_3 = "rollout-1"; + let exp_1 = get_targeted_experiment(slug_1, "true"); + let exp_2 = get_targeted_experiment(slug_2, "true"); + let ro_1 = get_bucketed_rollout(slug_3, 10_000); + client.set_experiments_locally(to_local_experiments_string(&[ + exp_1.clone(), + exp_2, + serde_json::to_value(ro_1)?, + ])?)?; + client.apply_pending_experiments()?; + + let metric_records = client.host_metrics_handler.get_enrollment_status_records(); + assert_eq!(metric_records.len(), 3); + client.host_metrics_handler.clear(); + + assert_eq!(metric_records[0].slug, Some(slug_1.into())); + assert_eq!(metric_records[0].status, Some("Enrolled".into())); + assert_eq!(metric_records[0].reason, Some("Qualified".into())); + assert_eq!(metric_records[0].branch, Some("treatment".into())); + + assert_eq!(metric_records[1].slug, Some(slug_2.into())); + assert_eq!(metric_records[1].status, Some("Enrolled".into())); + assert_eq!(metric_records[1].reason, Some("Qualified".into())); + assert_eq!(metric_records[1].branch, Some("control".into())); + + assert_eq!(metric_records[2].slug, Some(slug_3.into())); + assert_eq!(metric_records[2].status, Some("Enrolled".into())); + assert_eq!(metric_records[2].reason, Some("Qualified".into())); + assert_eq!(metric_records[2].branch, Some("control".into())); + + let slug_4 = "experiment-3"; + let exp_2 = get_targeted_experiment(slug_2, "false"); + let ro_1 = get_bucketed_rollout(slug_3, 0); + let exp_4 = get_targeted_experiment(slug_4, "blah"); + client.set_experiments_locally(to_local_experiments_string(&[ + exp_2, + serde_json::to_value(ro_1)?, + exp_4, + ])?)?; + client.apply_pending_experiments()?; + + let metric_records = client.host_metrics_handler.get_enrollment_status_records(); + assert_eq!(metric_records.len(), 4); + + assert_eq!(metric_records[0].slug, Some(slug_1.into())); + assert_eq!(metric_records[0].status, Some("WasEnrolled".into())); + assert_eq!(metric_records[0].branch, Some("treatment".into())); + + assert_eq!(metric_records[1].slug, Some(slug_2.into())); + assert_eq!(metric_records[1].status, Some("Disqualified".into())); + assert_eq!(metric_records[1].reason, Some("NotTargeted".into())); + assert_eq!(metric_records[1].branch, Some("control".into())); + + assert_eq!(metric_records[2].slug, Some(slug_4.into())); + assert_eq!(metric_records[2].status, Some("Error".into())); + assert_eq!( + metric_records[2].error_string, + Some("EvaluationError: Identifier 'blah' is undefined".into()) + ); + + assert_eq!(metric_records[3].slug, Some(slug_3.into())); + assert_eq!(metric_records[3].status, Some("Disqualified".into())); + assert_eq!(metric_records[3].reason, Some("NotSelected".into())); + assert_eq!(metric_records[3].branch, Some("control".into())); + + Ok(()) +} diff --git a/components/nimbus/tests/common/mod.rs b/components/nimbus/tests/common/mod.rs index a932f79778..3d31d8faaf 100644 --- a/components/nimbus/tests/common/mod.rs +++ b/components/nimbus/tests/common/mod.rs @@ -1,13 +1,32 @@ +#![cfg(feature = "rkv-safe-mode")] /* 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/. */ - -#[cfg(feature = "rkv-safe-mode")] use rkv::StoreOptions; // utilities shared between tests -use nimbus::{error::Result, AppContext, NimbusClient, RemoteSettingsConfig}; +use nimbus::{ + error::Result, + metrics::{EnrollmentStatusExtraDef, HostMetricsHandler}, + AppContext, NimbusClient, RemoteSettingsConfig, +}; + +pub struct NoopHostMetrics {} + +impl HostMetricsHandler for NoopHostMetrics { + fn record_enrollment_status(&self, _: EnrollmentStatusExtraDef) { + // do nothing + } + + fn get_enrollment_status_records(&self) -> Vec { + todo!() + } + + fn clear(&self) { + todo!() + } +} #[allow(dead_code)] // work around https://github.com/rust-lang/rust/issues/46379 pub fn new_test_client(_identifier: &str) -> Result { @@ -43,7 +62,14 @@ fn new_test_client_internal( locale: Some("en-GB".to_string()), ..Default::default() }; - NimbusClient::new(ctx, Default::default(), tmp_dir.path(), Some(config), aru) + NimbusClient::new( + ctx, + Default::default(), + tmp_dir.path(), + Some(config), + aru, + Box::new(NoopHostMetrics {}), + ) } use nimbus::stateful::persistence::{Database, SingleStore}; diff --git a/components/nimbus/tests/test_fs_client.rs b/components/nimbus/tests/test_fs_client.rs index eb0e5a4bd6..72cf7c4dbd 100644 --- a/components/nimbus/tests/test_fs_client.rs +++ b/components/nimbus/tests/test_fs_client.rs @@ -1,17 +1,19 @@ +#![cfg(feature = "rkv-safe-mode")] /* 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/. */ // Simple tests for our file-system client -#[cfg(feature = "rkv-safe-mode")] use nimbus::error::Result; +mod common; + // This test crashes lmdb for reasons that make no sense, so only run it // in the "safe mode" backend. -#[cfg(feature = "rkv-safe-mode")] #[test] fn test_simple() -> Result<()> { + use common::NoopHostMetrics; use nimbus::{NimbusClient, RemoteSettingsConfig}; use std::path::PathBuf; use url::Url; @@ -37,6 +39,7 @@ fn test_simple() -> Result<()> { tmp_dir.path(), Some(config), aru, + Box::new(NoopHostMetrics {}), )?; client.fetch_experiments()?; client.apply_pending_experiments()?; diff --git a/components/nimbus/tests/test_get_experiment_branch_by_id.rs b/components/nimbus/tests/test_get_experiment_branch_by_id.rs index 587944bc89..388768c953 100644 --- a/components/nimbus/tests/test_get_experiment_branch_by_id.rs +++ b/components/nimbus/tests/test_get_experiment_branch_by_id.rs @@ -1,10 +1,10 @@ +#![cfg(feature = "rkv-safe-mode")] /* 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/. */ // Testing get_experiment_branch semantics. -#[cfg(feature = "rkv-safe-mode")] mod common; #[allow(unused_imports)] @@ -12,7 +12,6 @@ mod common; #[macro_use] use nimbus::error::Result; -#[cfg(feature = "rkv-safe-mode")] #[test] fn test_before_open() -> Result<()> { use nimbus::error::NimbusError; @@ -29,7 +28,6 @@ fn test_before_open() -> Result<()> { Ok(()) } -#[cfg(feature = "rkv-safe-mode")] #[test] fn test_enrolled() -> Result<()> { let _ = env_logger::try_init(); diff --git a/components/nimbus/tests/test_message_helpers.rs b/components/nimbus/tests/test_message_helpers.rs index 150cbd06bc..a774a12a91 100644 --- a/components/nimbus/tests/test_message_helpers.rs +++ b/components/nimbus/tests/test_message_helpers.rs @@ -1,10 +1,10 @@ +#![cfg(feature = "rkv-safe-mode")] /* 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/. */ // Testing get_experiment_branch semantics. -#[cfg(feature = "rkv-safe-mode")] mod common; #[allow(unused_imports)] @@ -12,7 +12,6 @@ mod common; #[macro_use] use nimbus::error::Result; -#[cfg(feature = "rkv-safe-mode")] #[cfg(test)] mod message_tests { diff --git a/components/nimbus/tests/test_restart.rs b/components/nimbus/tests/test_restart.rs index 53b1da43c4..9e5b6e458d 100644 --- a/components/nimbus/tests/test_restart.rs +++ b/components/nimbus/tests/test_restart.rs @@ -1,11 +1,10 @@ +#![cfg(feature = "rkv-safe-mode")] /* 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/. */ -#[cfg(feature = "rkv-safe-mode")] mod common; -#[cfg(feature = "rkv-safe-mode")] #[cfg(test)] mod test { diff --git a/components/nimbus/tests/test_updates.rs b/components/nimbus/tests/test_updates.rs index 8c659975e6..bc15788cad 100644 --- a/components/nimbus/tests/test_updates.rs +++ b/components/nimbus/tests/test_updates.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "rkv-safe-mode")] /* 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/. */ @@ -6,10 +7,8 @@ // This test crashes lmdb for reasons that make no sense, so only run it // in the "safe mode" backend. -#[cfg(feature = "rkv-safe-mode")] mod common; -#[cfg(feature = "rkv-safe-mode")] #[cfg(test)] mod test { use super::common::{ diff --git a/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift b/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift index e4a7b929d2..a21ae8c731 100644 --- a/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift +++ b/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift @@ -467,6 +467,26 @@ class NimbusTests: XCTestCase { XCTAssertTrue(try helper.evalJexl(expression: "is_test")) XCTAssertFalse(try helper.evalJexl(expression: "is_first_run")) } + + func testNimbusRecordsEnrollmentStatusMetrics() throws { + let appSettings = NimbusAppSettings(appName: "test", channel: "nightly") + let nimbus = try Nimbus.create(nil, appSettings: appSettings, dbPath: createDatabasePath()) as! Nimbus + + try nimbus.setExperimentsLocallyOnThisThread(minimalExperimentJSON()) + try nimbus.applyPendingExperimentsOnThisThread() + + XCTAssertNotNil(GleanMetrics.NimbusEvents.enrollmentStatus.testGetValue(), "EnrollmentStatus event must exist") + let enrollmentStatusEvents = GleanMetrics.NimbusEvents.enrollmentStatus.testGetValue()! + XCTAssertEqual(enrollmentStatusEvents.count, 1, "event count must match") + + let enrolledExtra = enrollmentStatusEvents[0].extra! + XCTAssertEqual("control", enrolledExtra["branch"], "branch must match") + XCTAssertEqual("secure-gold", enrolledExtra["slug"], "slug must match") + XCTAssertEqual("Enrolled", enrolledExtra["status"], "status must match") + XCTAssertEqual("Qualified", enrolledExtra["reason"], "reason must match") + XCTAssertEqual(nil, enrolledExtra["error_string"], "errorString must match") + XCTAssertEqual(nil, enrolledExtra["conflict_slug"], "conflictSlug must match") + } } private extension Device {