update Rust for recorded context to handle event store queries
This commit is contained in:
Родитель
9841bd97c0
Коммит
d29c918ae2
|
@ -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_)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче