update Rust for recorded context to handle event store queries

This commit is contained in:
Charlie Humphreys 2024-09-26 15:03:12 -05:00
Родитель d160a87f30
Коммит b8f83af2bd
18 изменённых файлов: 574 добавлений и 86 удалений

Просмотреть файл

@ -2,6 +2,9 @@
## ⚠️ Breaking Changes ⚠️
### Nimbus SDK ⛅️🔬🔭
- Added methods to `RecordedContext` for retrieving event queries and setting their values back to the foreign object ([#6322](https://github.com/mozilla/application-services/pull/6322)).
### Remote Settings
- Updated Error hierarchy. We don't need to update consumer code because the only consumer was
Android and it only caught exceptions using the base RemoteSettingsException class.

32
Cargo.lock сгенерированный
Просмотреть файл

@ -937,6 +937,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.72",
]
[[package]]
name = "ctrlc"
version = "3.2.2"
@ -2429,9 +2439,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"
@ -2684,6 +2694,7 @@ dependencies = [
"cfg-if 1.0.0",
"chrono",
"clap 2.34.0",
"ctor 0.2.8",
"env_logger",
"error-support",
"glean-build",
@ -2691,6 +2702,7 @@ dependencies = [
"jexl-eval",
"log",
"once_cell",
"regex",
"remote_settings",
"rkv",
"serde",
@ -3272,7 +3284,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",
]
@ -3561,13 +3573,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",
]
@ -3579,9 +3591,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",
@ -3590,9 +3602,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"

Просмотреть файл

@ -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"

Просмотреть файл

@ -24,6 +24,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
@ -37,7 +38,9 @@ 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.JsonObject
import org.mozilla.experiments.nimbus.internal.NimbusException
import org.mozilla.experiments.nimbus.internal.RecordedContext
import org.mozilla.experiments.nimbus.internal.validateEventQueries
import org.robolectric.RobolectricTestRunner
import java.util.Calendar
import java.util.concurrent.Executors
@ -71,6 +74,7 @@ class NimbusTests {
private fun createNimbus(
coenrollingFeatureIds: List<String> = listOf(),
recordedContext: RecordedContext? = null,
block: Nimbus.() -> Unit = {},
) = Nimbus(
context = context,
appInfo = appInfo,
@ -80,7 +84,7 @@ class NimbusTests {
observer = null,
delegate = nimbusDelegate,
recordedContext = recordedContext,
)
).also(block)
@get:Rule
val gleanRule = GleanTestRule(context)
@ -734,21 +738,34 @@ class NimbusTests {
assertEquals("Event count must match", isReadyEvents.count(), 3)
}
class TestRecordedContext(
private val eventQueries: Map<String, String>? = null,
private var eventQueryValues: Map<String, Double>? = null,
) : RecordedContext {
var recorded = mutableListOf<JSONObject>()
override fun getEventQueries(): Map<String, String> {
return eventQueries?.toMap() ?: mapOf()
}
override fun setEventQueryValues(eventQueryValues: Map<String, Double>) {
this.eventQueryValues = eventQueryValues
}
override fun record() {
recorded.add(this.toJson())
}
override fun toJson(): JsonObject {
val contextToRecord = JSONObject()
contextToRecord.put("enabled", true)
contextToRecord.put("events", JSONObject(eventQueryValues ?: mapOf<String, Double>()))
return contextToRecord
}
}
@Test
fun `Nimbus records context if it's passed in`() {
class TestRecordedContext : RecordedContext {
var recordCount = 0
override fun record() {
recordCount++
}
override fun toJson(): JsonObject {
val contextToRecord = JSONObject()
contextToRecord.put("enabled", true)
return contextToRecord
}
}
val context = TestRecordedContext()
val nimbus = createNimbus(recordedContext = context)
@ -761,7 +778,42 @@ 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(
mapOf(
"TEST_QUERY" to "'event'|eventSum('Days', 1, 0)",
),
)
val nimbus = createNimbus(recordedContext = context) {
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)
}
@Test
fun `Nimbus recorded context event queries are validated`() {
val context = TestRecordedContext(
mapOf(
"FAILING_QUERY" to "'event'|eventSumThisWillFail('Days', 1, 0)",
),
)
assertThrows("Expected an error to be thrown", NimbusException::class.java, { validateEventQueries(context) })
}
}

Просмотреть файл

@ -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,14 @@ pub enum BehaviorError {
IntervalParseError(String),
#[error("The event store is not available on the targeting attributes")]
MissingEventStore,
#[error("The recorded context is not available on the nimbus client")]
MissingRecordedContext,
#[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"))]

Просмотреть файл

@ -4,7 +4,15 @@ typedef extern RemoteSettingsConfig;
[ExternalExport="remote_settings"]
typedef extern RemoteSettingsServer;
namespace nimbus {};
namespace nimbus {
/// A test utility used to validate event queries against the jexl evaluator.
///
/// This method should only be used in tests.
[Throws=NimbusError]
void validate_event_queries(RecordedContext recorded_context);
};
dictionary AppContext {
string app_name;
string app_id;
@ -104,6 +112,7 @@ enum NimbusError {
"InvalidPath", "InternalError", "NoSuchExperiment", "NoSuchBranch",
"DatabaseNotReady", "VersionParsingError", "BehaviorError", "TryFromIntError",
"ParseIntError", "TransformParameterError", "ClientError", "UniFFICallbackError",
"RegexError",
};
[Custom]
@ -113,6 +122,10 @@ typedef string JsonObject;
interface RecordedContext {
JsonObject to_json();
record<string, string> get_event_queries();
void set_event_query_values(record<string, f64> event_query_values);
void record();
};
@ -283,6 +296,7 @@ interface NimbusClient {
[Throws=NimbusError]
void dump_state_to_log();
};
interface NimbusTargetingHelper {

Просмотреть файл

@ -62,6 +62,7 @@ impl PartialEq for Interval {
self.to_string() == other.to_string()
}
}
impl Eq for Interval {}
impl Hash for Interval {
@ -84,7 +85,7 @@ impl FromStr for Interval {
_ => {
return Err(NimbusError::BehaviorError(
BehaviorError::IntervalParseError(input.to_string()),
))
));
}
})
}
@ -365,7 +366,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 +376,7 @@ impl EventQueryType {
return Err(NimbusError::TransformParameterError(format!(
"event transform {} requires a positive number as the third parameter",
self
)))
)));
}
} as usize;
@ -402,7 +403,7 @@ impl EventQueryType {
return Err(NimbusError::TransformParameterError(format!(
"event transform {} requires a positive number as the second parameter",
self
)))
)));
}
} as usize;
@ -427,6 +428,13 @@ impl EventQueryType {
})
}
pub fn validate_query(maybe_query: &str) -> Result<bool> {
let regex = regex::Regex::new(
r#"^(?:"[^"']+"|'[^"']+')\|event(?:Sum|LastSeen|CountNonZero|Average|AveragePerNonZeroInterval)\(["'](?:Years|Months|Weeks|Days|Hours|Minutes)["'],\s*\d+\s*(?:,\s*\d+\s*)?\)$"#,
)?;
Ok(regex.is_match(maybe_query))
}
fn error_value(&self) -> f64 {
match self {
Self::LastSeen => f64::MAX,

Просмотреть файл

@ -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<AppContext> 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(

Просмотреть файл

@ -2,6 +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/. */
#[cfg(test)]
use crate::tests::helpers::{TestMetrics, TestRecordedContext};
use crate::{
defaults::Defaults,
enrollment::{
@ -26,10 +28,10 @@ use crate::{
},
matcher::AppContext,
persistence::{Database, StoreId, Writer},
targeting::{validate_event_queries, RecordedContext},
updating::{read_and_remove_pending_experiments, write_pending_experiments},
},
strings::fmt_with_map,
targeting::RecordedContext,
AvailableExperiment, AvailableRandomizationUnits, EnrolledExperiment, Experiment,
ExperimentBranch, NimbusError, NimbusTargetingHelper, Result,
};
@ -43,9 +45,6 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use uuid::Uuid;
#[cfg(test)]
use crate::tests::helpers::{TestMetrics, TestRecordedContext};
const DB_KEY_NIMBUS_ID: &str = "nimbus-id";
pub const DB_KEY_INSTALLATION_DATE: &str = "installation-date";
pub const DB_KEY_UPDATE_DATE: &str = "update-date";
@ -103,10 +102,7 @@ impl NimbusClient {
) -> Result<Self> {
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 +157,21 @@ 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,
)?);
recorded_context.execute_queries(targeting_helper.as_ref())?;
state
.targeting_attributes
.set_recorded_context(recorded_context.to_json());
}
Ok(())
}
@ -638,6 +648,16 @@ impl NimbusClient {
Ok(Arc::new(helper))
}
pub fn create_targeting_helper_with_context(
&self,
context: Value,
) -> Arc<NimbusTargetingHelper> {
Arc::new(NimbusTargetingHelper::new(
context,
self.event_store.clone(),
))
}
pub fn create_string_helper(
&self,
additional_context: Option<JsonObject>,

Просмотреть файл

@ -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,
error::BehaviorError,
json::JsonObject,
stateful::behavior::{EventQueryType, EventStore},
NimbusError, NimbusTargetingHelper, Result, TargetingAttributes,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
impl NimbusTargetingHelper {
@ -27,3 +35,79 @@ 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) -> HashMap<String, String>;
/// 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, event_query_values: HashMap<String, f64>);
/// 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<HashMap<String, f64>> {
let results: HashMap<String, f64> =
HashMap::from_iter(self.get_event_queries().iter().filter_map(|(key, query)| {
match nimbus_targeting_helper.evaluate_jexl_raw_value(query) {
Ok(result) => match result.as_f64() {
Some(v) => Some((key.clone(), v)),
None => {
log::warn!(
"Value '{}' for query '{}' was not a string",
result.to_string(),
query
);
None
}
},
Err(err) => {
let error_string = format!(
"error during jexl evaluation for query '{}' — {}",
query, err
);
log::warn!("{}", error_string);
None
}
}
}));
self.set_event_query_values(results.clone());
Ok(results)
}
pub fn validate_queries(&self) -> Result<()> {
for query in self.get_event_queries().values() {
match EventQueryType::validate_query(query) {
Ok(true) => continue,
Ok(false) => {
return Err(NimbusError::BehaviorError(
BehaviorError::EventQueryParseError(query.clone()),
));
}
Err(err) => return Err(err),
}
}
Ok(())
}
}
pub fn validate_event_queries(recorded_context: Arc<dyn RecordedContext>) -> Result<()> {
recorded_context.validate_queries()
}

Просмотреть файл

@ -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: &str) -> Result<Value> {
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<Context: serde::Serialize>(
pub fn jexl_eval_raw<Context: serde::Serialize>(
expression_statement: &str,
context: &Context,
#[cfg(feature = "stateful")] event_store: Arc<Mutex<EventStore>>,
) -> Result<bool> {
) -> Result<Value> {
let evaluator =
Evaluator::new().with_transform("versionCompare", |args| Ok(version_compare(args)?));
@ -131,7 +124,26 @@ pub fn jexl_eval<Context: serde::Serialize>(
})
.with_transform("bucketSample", bucket_sample);
let res = evaluator.eval_in_context(expression_statement, context)?;
evaluator
.eval_in_context(expression_statement, context)
.map_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<Context: serde::Serialize>(
expression_statement: &str,
context: &Context,
#[cfg(feature = "stateful")] event_store: Arc<Mutex<EventStore>>,
) -> Result<bool> {
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),

Просмотреть файл

@ -14,21 +14,42 @@ cfg_if::cfg_if! {
use crate::{
metrics::{FeatureExposureExtraDef, MalformedFeatureConfigExtraDef},
json::JsonObject,
targeting::RecordedContext
stateful::{behavior::EventStore, targeting::RecordedContext}
};
use std::collections::HashMap;
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 TestLogger;
impl log::Log for TestLogger {
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: TestLogger = TestLogger;
#[ctor::ctor]
fn init() {
log::set_logger(&LOGGER)
.map(|()| log::set_max_level(LevelFilter::Info))
.unwrap();
}
impl From<TargetingAttributes> for NimbusTargetingHelper {
@ -64,6 +85,8 @@ impl Default for NimbusTargetingHelper {
struct RecordedContextState {
context: Map<String, Value>,
record_calls: u64,
event_queries: HashMap<String, String>,
event_query_values: HashMap<String, f64>,
}
#[cfg(feature = "stateful")]
@ -94,6 +117,19 @@ impl TestRecordedContext {
.expect("value for `context` is not an object")
.clone();
}
pub fn set_event_queries(&self, queries: HashMap<String, String>) {
let mut state = self.state.lock().expect("could not lock state mutex");
state.event_queries = queries;
}
pub fn get_event_query_values(&self) -> HashMap<String, f64> {
self.state
.lock()
.expect("could not lock state mutex")
.event_query_values
.clone()
}
}
#[cfg(feature = "stateful")]
@ -106,6 +142,22 @@ impl RecordedContext for TestRecordedContext {
.clone()
}
fn get_event_queries(&self) -> HashMap<String, String> {
self.state
.lock()
.expect("could not lock state mutex")
.event_queries
.clone()
}
fn set_event_query_values(&self, event_query_values: HashMap<String, f64>) {
let mut state = self.state.lock().expect("could not lock state mutex");
state.event_query_values.clone_from(&event_query_values);
state
.context
.insert("events".into(), json!(event_query_values));
}
fn record(&self) {
let mut state = self.state.lock().expect("could not lock state mutex");
state.record_calls += 1;

Просмотреть файл

@ -19,6 +19,7 @@ mod stateful {
mod test_evaluator;
mod test_nimbus;
mod test_persistence;
mod test_targeting;
mod test_updating;
mod client {

Просмотреть файл

@ -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(())
}
}

Просмотреть файл

@ -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();

Просмотреть файл

@ -2,7 +2,6 @@
* 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::tests::helpers::TestRecordedContext;
use crate::{
enrollment::{DisqualifiedReason, EnrolledReason, EnrollmentStatus, ExperimentEnrollment},
error::Result,
@ -13,17 +12,19 @@ use crate::{
SingleIntervalCounter,
},
persistence::{Database, StoreId},
targeting::RecordedContext,
},
tests::helpers::{
get_bucketed_rollout, get_ios_rollout_experiment, get_single_feature_experiment,
get_single_feature_rollout, get_targeted_experiment, to_local_experiments_string,
TestMetrics,
TestMetrics, TestRecordedContext,
},
AppContext, Experiment, NimbusClient, TargetingAttributes, DB_KEY_APP_VERSION,
DB_KEY_UPDATE_DATE,
};
use chrono::{DateTime, Duration, Utc};
use serde_json::json;
use std::collections::HashMap;
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(HashMap::from_iter(vec![(
"TEST_QUERY".to_string(),
"'event'|eventSum('Days', 1, 0)".into(),
)]));
let client = NimbusClient::new(
app_context,
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_query_values()["TEST_QUERY"],
0.0
);
assert_eq!(active_experiments.len(), 1);
assert_eq!(client.get_recorded_context().get_record_calls(), 1u64);
Ok(())
}

Просмотреть файл

@ -0,0 +1,80 @@
// 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::Map;
use std::collections::HashMap;
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 = HashMap::from_iter(vec![
(
"TEST_QUERY_SUCCESS".to_string(),
"'event'|eventSum('Days', 1, 0)".into(),
),
(
"TEST_QUERY_FAIL_NOT_VALID_QUERY".to_string(),
"'event'|eventYolo('Days', 1, 0)".into(),
),
]);
let recorded_context = TestRecordedContext::new();
recorded_context.set_event_queries(map.clone());
let recorded_context: Box<dyn RecordedContext> = Box::new(recorded_context);
recorded_context.execute_queries(&targeting_helper)?;
// SAFETY: The cast to TestRecordedContext is safe because the Rust instance is
// guaranteed to be a TestRecordedContext instance. TestRecordedContext is the only
// Rust-implemented version of RecordedContext, and, like this method, is only
// used in tests.
let test_recorded_context = unsafe {
std::mem::transmute::<&&dyn RecordedContext, &&TestRecordedContext>(&&*recorded_context)
};
assert_eq!(
test_recorded_context.get_event_query_values()["TEST_QUERY_SUCCESS"],
1.0
);
assert!(!test_recorded_context
.get_event_query_values()
.contains_key("TEST_QUERY_FAIL_NOT_VALID_QUERY"));
Ok(())
}
#[test]
fn test_recorded_context_validate_queries() -> Result<()> {
let map = HashMap::from_iter(vec![
(
"TEST_QUERY_SUCCESS".to_string(),
"'event'|eventSum('Days', 1, 0)".into(),
),
(
"TEST_QUERY_FAIL_NOT_VALID_QUERY".to_string(),
"'event'|eventYolo('Days', 1, 0)".into(),
),
]);
let recorded_context = TestRecordedContext::new();
recorded_context.set_event_queries(map.clone());
let recorded_context: Box<dyn RecordedContext> = Box::new(recorded_context);
let result = recorded_context.validate_queries();
assert!(result.is_err_and(|e| {
assert_eq!(e.to_string(), "Behavior error: EventQueryParseError: \"'event'|eventYolo('Days', 1, 0)\" is not a valid EventQuery".to_string());
true
}));
Ok(())
}

Просмотреть файл

@ -508,20 +508,47 @@ 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: String]? = nil
var eventQueryValues: [String: Double]? = nil
func toJson() -> MozillaTestServices.JsonObject {
let json = "{\"enabled\": true}"
return json
}
init(enabled: Bool = true, eventQueries: [String: String]? = nil) {
self.enabled = enabled
self.eventQueries = eventQueries
}
func record() {
recordedCount += 1
func getEventQueries() -> [String: String] {
if let queries = eventQueries {
return queries
} else {
return [:]
}
}
func setEventQueryValues(eventQueryValues: [String: Double]) {
self.eventQueryValues = eventQueryValues
}
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": eventQueryValues 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 +556,28 @@ 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! Double)
}
func testNimbusRecordedContextEventQueriesAreValidated() throws {
let recordedContext = TestRecordedContext(eventQueries: ["TEST_QUERY": "'event'|eventSumThisWillFail('Days', 1, 0)"])
XCTAssertThrowsError(try validateEventQueries(recordedContext: recordedContext))
}
}