diff --git a/Cargo.lock b/Cargo.lock index c2e2bb76a7..a32c42defe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -938,6 +938,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ctor" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +dependencies = [ + "quote", + "syn 2.0.32", +] + [[package]] name = "ctrlc" version = "3.2.2" @@ -2411,9 +2421,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memmap2" @@ -2666,6 +2676,7 @@ dependencies = [ "cfg-if 1.0.0", "chrono", "clap 2.34.0", + "ctor 0.2.8", "env_logger", "error-support", "glean-build", @@ -2673,6 +2684,7 @@ dependencies = [ "jexl-eval", "log", "once_cell", + "regex", "remote_settings", "rkv", "serde", @@ -3248,7 +3260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f81e1644e1b54f5a68959a29aa86cde704219254669da328ecfdf6a1f09d427" dependencies = [ "ansi_term 0.11.0", - "ctor", + "ctor 0.1.22", "difference", "output_vt100", ] @@ -3537,13 +3549,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.4" +version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29" +checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.3.7", + "regex-automata 0.4.7", "regex-syntax", ] @@ -3555,9 +3567,9 @@ checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" [[package]] name = "regex-automata" -version = "0.3.7" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", @@ -3566,9 +3578,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.5" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "relevancy" diff --git a/components/nimbus/Cargo.toml b/components/nimbus/Cargo.toml index a583e4c840..b813cb3461 100644 --- a/components/nimbus/Cargo.toml +++ b/components/nimbus/Cargo.toml @@ -17,7 +17,7 @@ name = "nimbus" default=["stateful"] rkv-safe-mode = ["dep:rkv"] stateful-uniffi-bindings = [] -stateful = ["rkv-safe-mode", "stateful-uniffi-bindings", "dep:remote_settings"] +stateful = ["rkv-safe-mode", "stateful-uniffi-bindings", "dep:remote_settings", "dep:regex"] [dependencies] anyhow = "1" @@ -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" +regex = { version = "1.10.5", optional = true } [build-dependencies] uniffi = { workspace = true, features = ["build"] } @@ -49,3 +50,4 @@ viaduct-reqwest = { path = "../support/viaduct-reqwest" } env_logger = "0.10" clap = "2.34" tempfile = "3" +ctor = "0.2.2" 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 cda2e7c912..de76d87cc0 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 @@ -19,6 +19,7 @@ import mozilla.telemetry.glean.config.Configuration import mozilla.telemetry.glean.net.HttpStatus import mozilla.telemetry.glean.net.PingUploader import mozilla.telemetry.glean.testing.GleanTestRule +import org.json.JSONException import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -71,6 +72,7 @@ class NimbusTests { private fun createNimbus( coenrollingFeatureIds: List = listOf(), recordedContext: RecordedContext? = null, + block: Nimbus.() -> Unit = {}, ) = Nimbus( context = context, appInfo = appInfo, @@ -80,7 +82,7 @@ class NimbusTests { observer = null, delegate = nimbusDelegate, recordedContext = recordedContext, - ) + ).also(block) @get:Rule val gleanRule = GleanTestRule(context) @@ -734,21 +736,49 @@ class NimbusTests { assertEquals("Event count must match", isReadyEvents.count(), 3) } - @Test - fun `Nimbus records context if it's passed in`() { - class TestRecordedContext : RecordedContext { - var recordCount = 0 + class TestRecordedContext( + private var eventQueries: MutableMap? = null, + ) : RecordedContext { + var recorded = mutableListOf() + + override fun getEventQueries(): JsonObject { + val queriesJson = JSONObject() + for ((key, value) in eventQueries ?: mapOf()) { + queriesJson.put(key, value) + } + return queriesJson + } - override fun record() { - recordCount++ + override fun setEventQueryValues(json: JsonObject) { + for (key in json.keys()) { + try { + eventQueries?.put(key, json.getDouble(key)) + } catch (exception: JSONException) { + continue + } } + } - override fun toJson(): JsonObject { - val contextToRecord = JSONObject() - contextToRecord.put("enabled", true) - return contextToRecord + override fun record() { + recorded.add(this.toJson()) + } + + override fun toJson(): JsonObject { + val contextToRecord = JSONObject() + contextToRecord.put("enabled", true) + val queries = this.getEventQueries() + for (key in queries.keys()) { + if (queries.get(key)::class == String::class) { + queries.remove(key) + } } + contextToRecord.put("events", queries) + return contextToRecord } + } + + @Test + fun `Nimbus records context if it's passed in`() { val context = TestRecordedContext() val nimbus = createNimbus(recordedContext = context) @@ -761,7 +791,31 @@ class NimbusTests { job.join() } - assertEquals(context.recordCount, 1) + assertEquals(context.recorded.size, 1) + } + + @Test + fun `Nimbus recorded context event queries are run and the value is written back into the object`() { + val context = TestRecordedContext( + mutableMapOf( + "TEST_QUERY" to "'event'|eventSum('Days', 1, 0)", + ), + ) + val nimbus = createNimbus(recordedContext = context) { + this.recordEvent("event") + } + + suspend fun getString(): String { + return testExperimentsJsonString(appInfo, packageName) + } + + val job = nimbus.applyLocalExperiments(::getString) + runBlocking { + job.join() + } + + assertEquals(context.recorded.size, 1) + assertEquals(context.recorded[0].getJSONObject("events").getDouble("TEST_QUERY"), 1.0, 0.0) } } diff --git a/components/nimbus/src/error.rs b/components/nimbus/src/error.rs index 10676a90f9..00f09a6475 100644 --- a/components/nimbus/src/error.rs +++ b/components/nimbus/src/error.rs @@ -68,6 +68,9 @@ pub enum NimbusError { CirrusError(#[from] CirrusClientError), #[error("UniFFI callback error: {0}")] UniFFICallbackError(#[from] uniffi::UnexpectedUniFFICallbackError), + #[cfg(feature = "stateful")] + #[error("Regex error: {0}")] + RegexError(#[from] regex::Error), } #[cfg(feature = "stateful")] @@ -81,6 +84,12 @@ pub enum BehaviorError { IntervalParseError(String), #[error("The event store is not available on the targeting attributes")] MissingEventStore, + #[error("EventQueryTypeParseError: {0} is not a valid EventQueryType")] + EventQueryTypeParseError(String), + #[error(r#"EventQueryParseError: "{0}" is not a valid EventQuery"#)] + EventQueryParseError(String), + #[error(r#"TypeError: "{0}" is not of type {1}"#)] + TypeError(String, String), } #[cfg(not(feature = "stateful"))] diff --git a/components/nimbus/src/nimbus.udl b/components/nimbus/src/nimbus.udl index b25e48bdb1..67de9867dc 100644 --- a/components/nimbus/src/nimbus.udl +++ b/components/nimbus/src/nimbus.udl @@ -104,6 +104,7 @@ enum NimbusError { "InvalidPath", "InternalError", "NoSuchExperiment", "NoSuchBranch", "DatabaseNotReady", "VersionParsingError", "BehaviorError", "TryFromIntError", "ParseIntError", "TransformParameterError", "ClientError", "UniFFICallbackError", + "RegexError", }; [Custom] @@ -113,6 +114,10 @@ typedef string JsonObject; interface RecordedContext { JsonObject to_json(); + JsonObject get_event_queries(); + + void set_event_query_values(JsonObject json); + void record(); }; diff --git a/components/nimbus/src/stateful/behavior.rs b/components/nimbus/src/stateful/behavior.rs index 8a309013be..62e51aa2bf 100644 --- a/components/nimbus/src/stateful/behavior.rs +++ b/components/nimbus/src/stateful/behavior.rs @@ -7,6 +7,7 @@ use crate::{ stateful::persistence::{Database, StoreId}, }; use chrono::{DateTime, Datelike, Duration, TimeZone, Utc}; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::vec_deque::Iter; @@ -62,6 +63,7 @@ impl PartialEq for Interval { self.to_string() == other.to_string() } } + impl Eq for Interval {} impl Hash for Interval { @@ -84,7 +86,7 @@ impl FromStr for Interval { _ => { return Err(NimbusError::BehaviorError( BehaviorError::IntervalParseError(input.to_string()), - )) + )); } }) } @@ -365,7 +367,7 @@ impl EventQueryType { return Err(NimbusError::TransformParameterError(format!( "event transform {} requires a positive number as the second parameter", self - ))) + ))); } } as usize; let zero = &Value::from(0); @@ -375,7 +377,7 @@ impl EventQueryType { return Err(NimbusError::TransformParameterError(format!( "event transform {} requires a positive number as the third parameter", self - ))) + ))); } } as usize; @@ -402,7 +404,7 @@ impl EventQueryType { return Err(NimbusError::TransformParameterError(format!( "event transform {} requires a positive number as the second parameter", self - ))) + ))); } } as usize; @@ -427,6 +429,13 @@ impl EventQueryType { }) } + pub fn validate_query(maybe_query: &str) -> Result { + let regex = Regex::new( + r#"^["'][^"']+["']\|event(?:Sum|LastSeen|CountNonZero|Average|AveragePerNonZeroInterval)\(["'](?:Years|Months|Weeks|Days|Hours|Minutes)["'], \d+(?:, \d+)?\)$"#, + )?; + Ok(regex.is_match(maybe_query)) + } + fn error_value(&self) -> f64 { match self { Self::LastSeen => f64::MAX, diff --git a/components/nimbus/src/stateful/evaluator.rs b/components/nimbus/src/stateful/evaluator.rs index f8cbe7e314..ef6b44f9ad 100644 --- a/components/nimbus/src/stateful/evaluator.rs +++ b/components/nimbus/src/stateful/evaluator.rs @@ -7,7 +7,6 @@ use crate::{ evaluator::split_locale, json::JsonObject, stateful::matcher::AppContext, - targeting::RecordedContext, }; use chrono::{DateTime, Utc}; use serde_derive::*; @@ -50,8 +49,8 @@ impl From for TargetingAttributes { } impl TargetingAttributes { - pub(crate) fn set_recorded_context(&mut self, recorded_context: &dyn RecordedContext) { - self.recorded_context = Some(recorded_context.to_json()); + pub(crate) fn set_recorded_context(&mut self, recorded_context: JsonObject) { + self.recorded_context = Some(recorded_context); } pub(crate) fn update_time_to_now( diff --git a/components/nimbus/src/stateful/nimbus_client.rs b/components/nimbus/src/stateful/nimbus_client.rs index 9458f8161b..da8fffa55e 100644 --- a/components/nimbus/src/stateful/nimbus_client.rs +++ b/components/nimbus/src/stateful/nimbus_client.rs @@ -26,10 +26,10 @@ use crate::{ }, matcher::AppContext, persistence::{Database, StoreId, Writer}, + targeting::RecordedContext, updating::{read_and_remove_pending_experiments, write_pending_experiments}, }, strings::fmt_with_map, - targeting::RecordedContext, AvailableExperiment, AvailableRandomizationUnits, EnrolledExperiment, Experiment, ExperimentBranch, NimbusError, NimbusTargetingHelper, Result, }; @@ -103,10 +103,7 @@ impl NimbusClient { ) -> Result { let settings_client = Mutex::new(create_client(config)?); - let mut targeting_attributes: TargetingAttributes = app_context.clone().into(); - if let Some(ref context) = recorded_context { - targeting_attributes.set_recorded_context(&**context); - } + let targeting_attributes: TargetingAttributes = app_context.clone().into(); let mutable_state = Mutex::new(InternalMutableState { available_randomization_units: Default::default(), targeting_attributes, @@ -161,7 +158,25 @@ impl NimbusClient { ) -> Result<()> { self.read_or_create_nimbus_id(db, writer, state)?; self.update_ta_install_dates(db, writer, state)?; - self.event_store.lock().unwrap().read_from_db(db)?; + self.event_store + .lock() + .expect("unable to lock event_store mutex") + .read_from_db(db)?; + + if let Some(recorded_context) = &self.recorded_context { + let targeting_helper = self.create_targeting_helper_with_context( + serde_json::to_value(state.targeting_attributes.clone())?, + )?; + let query_results = recorded_context.execute_queries(targeting_helper.as_ref())?; + let event_queries = serde_json::Map::from_iter(vec![( + "events".to_string(), + serde_json::to_value(query_results)?, + )]); + let json = recorded_context.to_json(); + let merged = event_queries.defaults(&json)?; + state.targeting_attributes.set_recorded_context(merged); + } + Ok(()) } @@ -638,6 +653,14 @@ impl NimbusClient { Ok(Arc::new(helper)) } + pub fn create_targeting_helper_with_context( + &self, + context: Value, + ) -> Result> { + let helper = NimbusTargetingHelper::new(context, self.event_store.clone()); + Ok(Arc::new(helper)) + } + pub fn create_string_helper( &self, additional_context: Option, diff --git a/components/nimbus/src/stateful/targeting.rs b/components/nimbus/src/stateful/targeting.rs index eaaad810df..2f401e06ba 100644 --- a/components/nimbus/src/stateful/targeting.rs +++ b/components/nimbus/src/stateful/targeting.rs @@ -1,7 +1,15 @@ +// 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::{ - enrollment::ExperimentEnrollment, stateful::behavior::EventStore, NimbusTargetingHelper, - TargetingAttributes, + enrollment::ExperimentEnrollment, + json::JsonObject, + stateful::behavior::{EventQueryType, EventStore}, + NimbusTargetingHelper, Result, TargetingAttributes, }; +use serde_json::Value; +use serde_json::{json, Map}; use std::sync::{Arc, Mutex}; impl NimbusTargetingHelper { @@ -27,3 +35,71 @@ impl NimbusTargetingHelper { } } } + +pub trait RecordedContext: Send + Sync { + /// Returns a JSON representation of the context object + /// + /// This method will be implemented in foreign code, i.e: Kotlin, Swift, Python, etc... + fn to_json(&self) -> JsonObject; + + /// Returns a HashMap representation of the event queries that will be used in the targeting + /// context + /// + /// This method will be implemented in foreign code, i.e: Kotlin, Swift, Python, etc... + fn get_event_queries(&self) -> JsonObject; + + /// Sets the object's internal value for the event query values + /// + /// This method will be implemented in foreign code, i.e: Kotlin, Swift, Python, etc... + fn set_event_query_values(&self, json: JsonObject); + + /// Records the context object to Glean + /// + /// This method will be implemented in foreign code, i.e: Kotlin, Swift, Python, etc... + fn record(&self); +} + +impl dyn RecordedContext { + pub fn execute_queries( + &self, + nimbus_targeting_helper: &NimbusTargetingHelper, + ) -> Result> { + let results = Map::from_iter(self.get_event_queries().iter().filter_map( + |(key, value): (&String, &Value)| match value.as_str() { + Some(v) => match EventQueryType::validate_query(v) { + Ok(is_valid) => match is_valid { + true => match nimbus_targeting_helper.evaluate_jexl_raw_value(v.into()) { + Ok(result) => Some((key.to_string(), result)), + Err(err) => { + log::info!( + "error during jexl evaluation for query {} — {}", + value, + err.to_string() + ); + None + } + }, + false => { + log::info!( + "key {} with value {} is not a valid event_store query", + key, + value + ); + Some((key.to_string(), json!(value))) + } + }, + Err(err) => { + log::error!("{}", err.to_string()); + None + } + }, + None => { + log::info!("value {} for key {} is not a string", key, value); + Some((key.to_string(), json!(value))) + } + }, + )); + self.set_event_query_values(results.clone()); + Ok(results) + } +} diff --git a/components/nimbus/src/targeting.rs b/components/nimbus/src/targeting.rs index 879e6f4d63..427156ff67 100644 --- a/components/nimbus/src/targeting.rs +++ b/components/nimbus/src/targeting.rs @@ -10,24 +10,11 @@ use serde_json::{json, Value}; cfg_if::cfg_if! { if #[cfg(feature = "stateful")] { use anyhow::anyhow; - use crate::{TargetingAttributes, stateful::behavior::{EventStore, EventQueryType, query_event_store}, json::JsonObject}; + use crate::{TargetingAttributes, stateful::behavior::{EventStore, EventQueryType, query_event_store}}; use std::sync::{Arc, Mutex}; } } -#[cfg(feature = "stateful")] -pub trait RecordedContext: Send + Sync { - /// Returns a JSON representation of the context object - /// - /// This method will be implemented in foreign code, i.e: Kotlin, Swift, Python, etc... - fn to_json(&self) -> JsonObject; - - /// Records the context object to Glean - /// - /// This method will be implemented in foreign code, i.e: Kotlin, Swift, Python, etc... - fn record(&self); -} - #[derive(Clone)] pub struct NimbusTargetingHelper { pub(crate) context: Value, @@ -61,6 +48,16 @@ impl NimbusTargetingHelper { } } + pub fn evaluate_jexl_raw_value(&self, expr: String) -> Result { + cfg_if::cfg_if! { + if #[cfg(feature = "stateful")] { + jexl_eval_raw(&expr, &self.context, self.event_store.clone()) + } else { + jexl_eval_raw(&expr, &self.context) + } + } + } + pub(crate) fn put(&self, key: &str, value: bool) -> Self { let context = if let Value::Object(map) = &self.context { let mut map = map.clone(); @@ -80,15 +77,11 @@ impl NimbusTargetingHelper { } } -// This is the common entry point to JEXL evaluation. -// The targeting attributes and additional context should have been merged and calculated before -// getting here. -// Any additional transforms should be added here. -pub fn jexl_eval( +pub fn jexl_eval_raw( expression_statement: &str, context: &Context, #[cfg(feature = "stateful")] event_store: Arc>, -) -> Result { +) -> Result { let evaluator = Evaluator::new().with_transform("versionCompare", |args| Ok(version_compare(args)?)); @@ -131,7 +124,27 @@ pub fn jexl_eval( }) .with_transform("bucketSample", bucket_sample); - let res = evaluator.eval_in_context(expression_statement, context)?; + match evaluator.eval_in_context(expression_statement, context) { + Ok(v) => Ok(v), + Err(err) => Err(NimbusError::EvaluationError(err.to_string())), + } +} + +// This is the common entry point to JEXL evaluation. +// The targeting attributes and additional context should have been merged and calculated before +// getting here. +// Any additional transforms should be added here. +pub fn jexl_eval( + expression_statement: &str, + context: &Context, + #[cfg(feature = "stateful")] event_store: Arc>, +) -> Result { + let res = jexl_eval_raw( + expression_statement, + context, + #[cfg(feature = "stateful")] + event_store, + )?; match res.as_bool() { Some(v) => Ok(v), None => Err(NimbusError::InvalidExpression), diff --git a/components/nimbus/src/tests/helpers.rs b/components/nimbus/src/tests/helpers.rs index 46d9622d92..b67047a310 100644 --- a/components/nimbus/src/tests/helpers.rs +++ b/components/nimbus/src/tests/helpers.rs @@ -14,21 +14,41 @@ cfg_if::cfg_if! { use crate::{ metrics::{FeatureExposureExtraDef, MalformedFeatureConfigExtraDef}, json::JsonObject, - targeting::RecordedContext + stateful::{behavior::EventStore, targeting::RecordedContext} }; use serde_json::Map; } } +use log::{Level, LevelFilter, Metadata, Record}; use serde::Serialize; use serde_json::{json, Value}; use std::collections::HashSet; use std::sync::{Arc, Mutex}; -cfg_if::cfg_if! { - if #[cfg(feature = "stateful")] { - use crate::stateful::behavior::EventStore; +struct SimpleLogger; + +impl log::Log for SimpleLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Info + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + println!("{} - {}", record.level(), record.args()); + } } + + fn flush(&self) {} +} + +static LOGGER: SimpleLogger = SimpleLogger; + +#[ctor::ctor] +fn init() { + log::set_logger(&LOGGER) + .map(|()| log::set_max_level(LevelFilter::Info)) + .unwrap(); } impl From for NimbusTargetingHelper { @@ -64,6 +84,7 @@ impl Default for NimbusTargetingHelper { struct RecordedContextState { context: Map, record_calls: u64, + event_queries: Map, } #[cfg(feature = "stateful")] @@ -94,6 +115,11 @@ impl TestRecordedContext { .expect("value for `context` is not an object") .clone(); } + + pub fn set_event_queries(&self, queries: Map) { + let mut state = self.state.lock().expect("could not lock state mutex"); + state.event_queries = queries; + } } #[cfg(feature = "stateful")] @@ -106,6 +132,23 @@ impl RecordedContext for TestRecordedContext { .clone() } + fn get_event_queries(&self) -> JsonObject { + self.state + .lock() + .expect("could not lock state mutex") + .event_queries + .clone() + } + + fn set_event_query_values(&self, json: JsonObject) { + log::info!( + "set_event_query_values {}", + serde_json::to_string(&json).unwrap() + ); + let mut state = self.state.lock().expect("could not lock state mutex"); + state.event_queries = Map::from_iter(json.iter().map(|(k, v)| (k.clone(), json!(v)))); + } + fn record(&self) { let mut state = self.state.lock().expect("could not lock state mutex"); state.record_calls += 1; diff --git a/components/nimbus/src/tests/mod.rs b/components/nimbus/src/tests/mod.rs index 265ea1134e..f849c061b9 100644 --- a/components/nimbus/src/tests/mod.rs +++ b/components/nimbus/src/tests/mod.rs @@ -19,6 +19,7 @@ mod stateful { mod test_evaluator; mod test_nimbus; mod test_persistence; + mod test_targeting; mod test_updating; mod client { diff --git a/components/nimbus/src/tests/stateful/test_behavior.rs b/components/nimbus/src/tests/stateful/test_behavior.rs index bea1b6f476..50c49bac05 100644 --- a/components/nimbus/src/tests/stateful/test_behavior.rs +++ b/components/nimbus/src/tests/stateful/test_behavior.rs @@ -1522,3 +1522,33 @@ mod event_store_tests { Ok(()) } } + +#[cfg(test)] +mod event_query_type_tests { + use super::*; + use crate::stateful::behavior::EventQueryType; + + #[test] + fn test_extract_query() -> Result<()> { + assert!(EventQueryType::validate_query( + "'event'|eventSum('Years', 28, 0)" + )?); + assert!(EventQueryType::validate_query( + "'event'|eventCountNonZero('Months', 28, 0)" + )?); + assert!(EventQueryType::validate_query( + "'event'|eventAverage('Weeks', 28, 0)" + )?); + assert!(EventQueryType::validate_query( + "'event'|eventAveragePerNonZeroInterval('Days', 28, 0)" + )?); + assert!(EventQueryType::validate_query( + "'event'|eventLastSeen('Hours', 10)" + )?); + assert!(EventQueryType::validate_query( + "'event'|eventSum('Minutes', 86400, 0)" + )?); + assert!(!EventQueryType::validate_query("yolo")?); + Ok(()) + } +} diff --git a/components/nimbus/src/tests/stateful/test_evaluator.rs b/components/nimbus/src/tests/stateful/test_evaluator.rs index be08b3b644..21af34e942 100644 --- a/components/nimbus/src/tests/stateful/test_evaluator.rs +++ b/components/nimbus/src/tests/stateful/test_evaluator.rs @@ -5,9 +5,12 @@ use crate::{ enrollment::NotEnrolledReason, evaluator::targeting, - stateful::behavior::{ - EventStore, Interval, IntervalConfig, IntervalData, MultiIntervalCounter, - SingleIntervalCounter, + stateful::{ + behavior::{ + EventStore, Interval, IntervalConfig, IntervalData, MultiIntervalCounter, + SingleIntervalCounter, + }, + targeting::RecordedContext, }, tests::helpers::TestRecordedContext, AppContext, EnrollmentStatus, TargetingAttributes, @@ -506,7 +509,7 @@ fn test_multiple_contexts_flatten() -> crate::Result<()> { })); let mut targeting_attributes = crate::tests::test_evaluator::ta_with_locale("en-US".to_string()); - targeting_attributes.set_recorded_context(&*recorded_context); + targeting_attributes.set_recorded_context(recorded_context.to_json()); let value = serde_json::to_value(targeting_attributes).unwrap(); diff --git a/components/nimbus/src/tests/stateful/test_nimbus.rs b/components/nimbus/src/tests/stateful/test_nimbus.rs index c5ce95471f..2e3fcce28a 100644 --- a/components/nimbus/src/tests/stateful/test_nimbus.rs +++ b/components/nimbus/src/tests/stateful/test_nimbus.rs @@ -2,6 +2,7 @@ * 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::stateful::targeting::RecordedContext; use crate::tests::helpers::TestRecordedContext; use crate::{ enrollment::{DisqualifiedReason, EnrolledReason, EnrollmentStatus, ExperimentEnrollment}, @@ -23,7 +24,7 @@ use crate::{ DB_KEY_UPDATE_DATE, }; use chrono::{DateTime, Duration, Utc}; -use serde_json::json; +use serde_json::{json, Map}; use std::path::Path; use std::sync::Arc; use std::{io::Write, str::FromStr}; @@ -1686,3 +1687,59 @@ fn test_recorded_context_recorded() -> Result<()> { Ok(()) } + +#[test] +fn test_recorded_context_event_queries() -> Result<()> { + let metrics = TestMetrics::new(); + + let temp_dir = tempfile::tempdir()?; + + let app_context = AppContext { + app_name: "fenix".to_string(), + app_id: "org.mozilla.fenix".to_string(), + channel: "nightly".to_string(), + app_version: Some("124.0.0".to_string()), + ..Default::default() + }; + let recorded_context = Arc::new(TestRecordedContext::new()); + recorded_context.set_context(json!({ + "app_version": "125.0.0", + "other": "stuff", + })); + recorded_context.set_event_queries(Map::from_iter(vec![( + "TEST_QUERY".to_string(), + serde_json::to_value("'event'|eventSum('Days', 1, 0)")?, + )])); + let client = NimbusClient::new( + app_context.clone(), + Some(recorded_context), + Default::default(), + temp_dir.path(), + None, + Box::new(metrics), + )?; + client.set_nimbus_id(&Uuid::from_str("00000000-0000-0000-0000-000000000004")?)?; + client.initialize()?; + + let slug_1 = "test-1"; + + // Apply an initial experiment + let exp_1 = get_targeted_experiment(slug_1, "events.TEST_QUERY == 0.0"); + client.set_experiments_locally(to_local_experiments_string(&[exp_1])?)?; + client.apply_pending_experiments()?; + + log::info!( + "{}", + serde_json::to_string(&client.get_recorded_context().get_event_queries())? + ); + + let active_experiments = client.get_active_experiments()?; + assert_eq!( + client.get_recorded_context().get_event_queries()["TEST_QUERY"], + json!(0.0) + ); + assert_eq!(active_experiments.len(), 1); + assert_eq!(client.get_recorded_context().get_record_calls(), 1u64); + + Ok(()) +} diff --git a/components/nimbus/src/tests/stateful/test_targeting.rs b/components/nimbus/src/tests/stateful/test_targeting.rs new file mode 100644 index 0000000000..66e8791669 --- /dev/null +++ b/components/nimbus/src/tests/stateful/test_targeting.rs @@ -0,0 +1,43 @@ +// 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::{ + stateful::{behavior::EventStore, targeting::RecordedContext}, + tests::helpers::TestRecordedContext, + NimbusTargetingHelper, Result, +}; +use serde_json::{json, Map}; +use std::sync::{Arc, Mutex}; + +#[test] +fn test_recorded_context_execute_queries() -> Result<()> { + let mut event_store = EventStore::new(); + event_store.record_event(1, "event", None)?; + let event_store = Arc::new(Mutex::new(event_store)); + let targeting_helper = NimbusTargetingHelper::new(Map::new(), event_store); + + let map = Map::from_iter(vec![ + ( + "TEST_QUERY_SUCCESS".to_string(), + json!("'event'|eventSum('Days', 1, 0)"), + ), + ( + "TEST_QUERY_FAIL_NOT_VALID_QUERY".to_string(), + json!("'event'|eventYolo('Days', 1, 0)"), + ), + ]); + + let recorded_context = TestRecordedContext::new(); + recorded_context.set_event_queries(map.clone()); + let recorded_context: Box = Box::new(recorded_context); + + let result = recorded_context.execute_queries(&targeting_helper)?; + assert_eq!(result["TEST_QUERY_SUCCESS"], json!(1.0)); + assert!(result + .get("TEST_QUERY_FAIL_NOT_VALID_QUERY") + .unwrap() + .is_string()); + + Ok(()) +} diff --git a/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift b/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift index a44a5a942d..d6214b28ff 100644 --- a/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift +++ b/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/NimbusTests.swift @@ -508,20 +508,53 @@ class NimbusTests: XCTestCase { XCTAssertEqual(nil, enrolledExtra["conflict_slug"], "conflictSlug must match") } - func testNimbusRecordsRecordedContextObject() throws { - class TestRecordedContext: RecordedContext { - var recordedCount = 0 + class TestRecordedContext: RecordedContext { + var recorded: [[String: Any]] = [] + var enabled: Bool + var eventQueries: [String: Any]? = nil + + init(enabled: Bool = true, eventQueries: [String: Any]? = nil) { + self.enabled = enabled + self.eventQueries = eventQueries + } - func toJson() -> MozillaTestServices.JsonObject { - let json = "{\"enabled\": true}" - return json + func getEventQueries() -> MozillaTestServices.JsonObject { + if let queries = eventQueries { + do { + return try String(data: JSONSerialization.data(withJSONObject: queries), encoding: .ascii) ?? "{}" + } catch { + print(error.localizedDescription) + } } + return "{}" + } - func record() { - recordedCount += 1 + func setEventQueryValues(json: MozillaTestServices.JsonObject) { + do { + eventQueries = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + } catch { + print(error.localizedDescription) } } + func toJson() -> MozillaTestServices.JsonObject { + do { + return try String(data: JSONSerialization.data(withJSONObject: [ + "enabled": enabled, + "events": eventQueries as Any, + ] as Any), encoding: .ascii) ?? "{}" as MozillaTestServices.JsonObject + } catch { + print(error.localizedDescription) + return "{}" + } + } + + func record() { + recorded.append(["enabled": enabled, "events": eventQueries as Any]) + } + } + + func testNimbusRecordsRecordedContextObject() throws { let recordedContext = TestRecordedContext() let appSettings = NimbusAppSettings(appName: "test", channel: "nightly") let nimbus = try Nimbus.create(nil, appSettings: appSettings, dbPath: createDatabasePath(), recordedContext: recordedContext) as! Nimbus @@ -529,7 +562,22 @@ class NimbusTests: XCTestCase { try nimbus.setExperimentsLocallyOnThisThread(minimalExperimentJSON()) try nimbus.applyPendingExperimentsOnThisThread() - XCTAssertEqual(1, recordedContext.recordedCount) + XCTAssertEqual(1, recordedContext.recorded.count) + print(recordedContext.recorded) + XCTAssertEqual(true, recordedContext.recorded.first!["enabled"] as! Bool) + } + + func testNimbusRecordedContextEventQueriesAreRunAndTheValueIsWrittenBackIntoTheObject() throws { + let recordedContext = TestRecordedContext(eventQueries: ["TEST_QUERY": "'event'|eventSum('Days', 1, 0)"]) + let appSettings = NimbusAppSettings(appName: "test", channel: "nightly") + let nimbus = try Nimbus.create(nil, appSettings: appSettings, dbPath: createDatabasePath(), recordedContext: recordedContext) as! Nimbus + + try nimbus.setExperimentsLocallyOnThisThread(minimalExperimentJSON()) + try nimbus.applyPendingExperimentsOnThisThread() + + XCTAssertEqual(1, recordedContext.recorded.count) + XCTAssertEqual(true, recordedContext.recorded.first!["enabled"] as! Bool) + XCTAssertEqual(0, (recordedContext.recorded.first!["events"] as! [String: Any])["TEST_QUERY"] as! Int) } }