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
Родитель 9841bd97c0
Коммит d29c918ae2
18 изменённых файлов: 576 добавлений и 86 удалений

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

@ -1,5 +1,10 @@
# v134.0 (In progress)
## ⚠️ 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)).
[Full Changelog](In progress)
# v133.0 (_2024-10-28_)

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)
}
@Test
fun `Nimbus records context if it's passed in`() {
class TestRecordedContext : RecordedContext {
var recordCount = 0
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() {
recordCount++
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`() {
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,10 +5,13 @@
use crate::{
enrollment::NotEnrolledReason,
evaluator::targeting,
stateful::behavior::{
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
var recorded: [[String: Any]] = []
var enabled: Bool
var eventQueries: [String: String]? = nil
var eventQueryValues: [String: Double]? = nil
init(enabled: Bool = true, eventQueries: [String: String]? = nil) {
self.enabled = enabled
self.eventQueries = eventQueries
}
func getEventQueries() -> [String: String] {
if let queries = eventQueries {
return queries
} else {
return [:]
}
}
func setEventQueryValues(eventQueryValues: [String: Double]) {
self.eventQueryValues = eventQueryValues
}
func toJson() -> MozillaTestServices.JsonObject {
let json = "{\"enabled\": true}"
return json
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() {
recordedCount += 1
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))
}
}