Skip to content

Commit b56ecd3

Browse files
committed
update Nimbus SDK to add and use RecordedContext
1 parent e7277a3 commit b56ecd3

File tree

23 files changed

+384
-43
lines changed

23 files changed

+384
-43
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515

1616
# v126.0 (_2024-04-15_)
1717

18+
### Nimbus SDK ⛅️🔬🔭
19+
20+
- Added `RecordedContext` extendable trait and define its usage in the Kotlin/Swift files ([#6207](https://github.com/mozilla/application-services/pull/6207)).
21+
1822
[Full Changelog](https://github.com/mozilla/application-services/compare/v125.0...v126.0)
1923

2024
# v125.0 (_2024-03-18_)

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import org.mozilla.experiments.nimbus.internal.MetricsHandler
4141
import org.mozilla.experiments.nimbus.internal.NimbusClient
4242
import org.mozilla.experiments.nimbus.internal.NimbusClientInterface
4343
import org.mozilla.experiments.nimbus.internal.NimbusException
44+
import org.mozilla.experiments.nimbus.internal.RecordedContext
4445
import java.io.File
4546
import java.io.IOException
4647

@@ -70,6 +71,7 @@ open class Nimbus(
7071
deviceInfo: NimbusDeviceInfo,
7172
private val observer: NimbusInterface.Observer? = null,
7273
delegate: NimbusDelegate,
74+
private val recordedContext: RecordedContext? = null,
7375
) : NimbusInterface {
7476
// An I/O scope is used for reading or writing from the Nimbus's RKV database.
7577
private val dbScope: CoroutineScope = delegate.dbScope
@@ -166,6 +168,7 @@ open class Nimbus(
166168

167169
nimbusClient = NimbusClient(
168170
experimentContext,
171+
recordedContext,
169172
coenrollingFeatureIds,
170173
dataDir.path,
171174
remoteSettingsConfig,
@@ -492,6 +495,7 @@ open class Nimbus(
492495
),
493496
)
494497
}
498+
495499
EnrollmentChangeEventType.DISQUALIFICATION -> {
496500
NimbusEvents.disqualification.record(
497501
NimbusEvents.DisqualificationExtra(
@@ -500,6 +504,7 @@ open class Nimbus(
500504
),
501505
)
502506
}
507+
503508
EnrollmentChangeEventType.UNENROLLMENT -> {
504509
NimbusEvents.unenrollment.record(
505510
NimbusEvents.UnenrollmentExtra(
@@ -508,6 +513,7 @@ open class Nimbus(
508513
),
509514
)
510515
}
516+
511517
EnrollmentChangeEventType.ENROLL_FAILED -> {
512518
NimbusEvents.enrollFailed.record(
513519
NimbusEvents.EnrollFailedExtra(
@@ -517,6 +523,7 @@ open class Nimbus(
517523
),
518524
)
519525
}
526+
520527
EnrollmentChangeEventType.UNENROLL_FAILED -> {
521528
NimbusEvents.unenrollFailed.record(
522529
NimbusEvents.UnenrollFailedExtra(
@@ -534,20 +541,26 @@ open class Nimbus(
534541
// If the experiment slug is known, then use that to look up the enrollment.
535542
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
536543
@AnyThread
537-
internal fun recordExposureOnThisThread(featureId: String, experimentSlug: String? = null) = withCatchAll("recordFeatureExposure") {
538-
nimbusClient.recordFeatureExposure(featureId, experimentSlug)
539-
}
544+
internal fun recordExposureOnThisThread(featureId: String, experimentSlug: String? = null) =
545+
withCatchAll("recordFeatureExposure") {
546+
nimbusClient.recordFeatureExposure(featureId, experimentSlug)
547+
}
540548

541549
// The malformed feature event is recorded by app developers, if the configuration is
542550
// _semantically_ invalid or malformed.
543551
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
544552
@AnyThread
545-
internal fun recordMalformedConfigurationOnThisThread(featureId: String, partId: String) = withCatchAll("recordMalformedConfiguration") {
546-
nimbusClient.recordMalformedFeatureConfig(featureId, partId)
547-
}
553+
internal fun recordMalformedConfigurationOnThisThread(featureId: String, partId: String) =
554+
withCatchAll("recordMalformedConfiguration") {
555+
nimbusClient.recordMalformedFeatureConfig(featureId, partId)
556+
}
548557

549558
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
550-
internal fun buildExperimentContext(context: Context, appInfo: NimbusAppInfo, deviceInfo: NimbusDeviceInfo): AppContext {
559+
internal fun buildExperimentContext(
560+
context: Context,
561+
appInfo: NimbusAppInfo,
562+
deviceInfo: NimbusDeviceInfo,
563+
): AppContext {
551564
val packageInfo: PackageInfo? = try {
552565
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
553566
context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0))

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusBuilder.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import android.net.Uri
1010
import androidx.annotation.RawRes
1111
import kotlinx.coroutines.runBlocking
1212
import org.mozilla.experiments.nimbus.internal.FeatureManifestInterface
13+
import org.mozilla.experiments.nimbus.internal.RecordedContext
1314

1415
private const val TIME_OUT_LOADING_EXPERIMENT_FROM_DISK_MS = 200L
1516

@@ -89,6 +90,11 @@ abstract class AbstractNimbusBuilder<T : NimbusInterface>(val context: Context)
8990
*/
9091
var sharedPreferences: SharedPreferences? = null
9192

93+
/**
94+
* Additional targeting context that will be recorded to Glean.
95+
*/
96+
var recordedContext: RecordedContext? = null
97+
9298
/**
9399
* Build a [Nimbus] singleton for the given [NimbusAppInfo]. Instances built with this method
94100
* have been initialized, and are ready for use by the app.
@@ -222,6 +228,7 @@ class DefaultNimbusBuilder(context: Context) : AbstractNimbusBuilder<NimbusInter
222228
deviceInfo = createDeviceInfo(),
223229
delegate = createDelegate(),
224230
observer = createObserver(),
231+
recordedContext = recordedContext,
225232
)
226233

227234
override fun newNimbusDisabled() = NullNimbus(context)

components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import mozilla.telemetry.glean.config.Configuration
1919
import mozilla.telemetry.glean.net.HttpStatus
2020
import mozilla.telemetry.glean.net.PingUploader
2121
import mozilla.telemetry.glean.testing.GleanTestRule
22+
import org.json.JSONObject
2223
import org.junit.Assert.assertEquals
2324
import org.junit.Assert.assertFalse
2425
import org.junit.Assert.assertNotNull
@@ -35,6 +36,8 @@ import org.mozilla.experiments.nimbus.GleanMetrics.NimbusEvents
3536
import org.mozilla.experiments.nimbus.GleanMetrics.NimbusHealth
3637
import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent
3738
import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEventType
39+
import org.mozilla.experiments.nimbus.internal.JsonObject
40+
import org.mozilla.experiments.nimbus.internal.RecordedContext
3841
import org.robolectric.RobolectricTestRunner
3942
import java.util.Calendar
4043
import java.util.concurrent.Executors
@@ -63,23 +66,28 @@ class NimbusTests {
6366
errorReporter = { message, e -> Log.e("NimbusTest", message, e) },
6467
)
6568

66-
private val nimbus = createNimbus()
69+
private var nimbus = createNimbus()
6770

68-
private fun createNimbus(coenrollingFeatureIds: List<String> = listOf()) = Nimbus(
71+
private fun createNimbus(
72+
coenrollingFeatureIds: List<String> = listOf(),
73+
recordedContext: RecordedContext? = null,
74+
) = Nimbus(
6975
context = context,
7076
appInfo = appInfo,
7177
coenrollingFeatureIds = coenrollingFeatureIds,
7278
server = null,
7379
deviceInfo = deviceInfo,
7480
observer = null,
7581
delegate = nimbusDelegate,
82+
recordedContext = recordedContext,
7683
)
7784

7885
@get:Rule
7986
val gleanRule = GleanTestRule(context)
8087

8188
@Before
8289
fun setupGlean() {
90+
nimbus = createNimbus()
8391
val buildInfo = BuildInfo(versionCode = "0.0.1", versionName = "0.0.1", buildDate = Calendar.getInstance())
8492

8593
// Glean needs to be initialized for the experiments API to accept enrollment events, so we
@@ -191,9 +199,15 @@ class NimbusTests {
191199
)
192200
}
193201

194-
@Ignore // until the activation event is enabled by default.
195202
@Test
196203
fun `getFeatureVariables records activation telemetry`() {
204+
Glean.applyServerKnobsConfig(
205+
"""{
206+
"metrics_enabled": {
207+
"nimbus_events.activation": true
208+
}
209+
}""",
210+
)
197211
// Load the experiment in nimbus so and optIn so that it will be active. This is necessary
198212
// because recordExposure checks for active experiments before recording.
199213
nimbus.setUpTestExperiments(packageName, appInfo)
@@ -719,6 +733,36 @@ class NimbusTests {
719733
isReadyEvents = NimbusEvents.isReady.testGetValue()!!
720734
assertEquals("Event count must match", isReadyEvents.count(), 3)
721735
}
736+
737+
@Test
738+
fun `Nimbus records context if it's passed in`() {
739+
class TestRecordedContext : RecordedContext {
740+
var recordCount = 0
741+
742+
override fun record() {
743+
recordCount++
744+
}
745+
746+
override fun toJson(): JsonObject {
747+
val contextToRecord = JSONObject()
748+
contextToRecord.put("enabled", true)
749+
return contextToRecord
750+
}
751+
}
752+
val context = TestRecordedContext()
753+
val nimbus = createNimbus(recordedContext = context)
754+
755+
suspend fun getString(): String {
756+
return testExperimentsJsonString(appInfo, packageName)
757+
}
758+
759+
val job = nimbus.applyLocalExperiments(::getString)
760+
runBlocking {
761+
job.join()
762+
}
763+
764+
assertEquals(context.recordCount, 1)
765+
}
722766
}
723767

724768
// Mocking utilities, from mozilla.components.support.test

components/nimbus/examples/experiment.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ fn main() -> Result<()> {
230230
let nimbus_client = NimbusClient::new(
231231
context.clone(),
232232
Default::default(),
233+
Default::default(),
233234
db_path,
234235
Some(config),
235236
Box::new(NoopMetricsHandler),

components/nimbus/ios/Nimbus/NimbusBuilder.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ public class NimbusBuilder {
164164

165165
var commandLineArgs: [String]?
166166

167+
/**
168+
* An optional RecordedContext object.
169+
*
170+
* When provided, its JSON contents will be added to the Nimbus targeting context, and its value will be published
171+
* to Glean.
172+
*/
173+
@discardableResult
174+
public func with(recordedContext: RecordedContext?) -> Self {
175+
self.recordedContext = recordedContext
176+
return self
177+
}
178+
179+
var recordedContext: RecordedContext?
180+
167181
// swiftlint:disable function_body_length
168182
/**
169183
* Build a [Nimbus] singleton for the given [NimbusAppSettings]. Instances built with this method
@@ -252,7 +266,8 @@ public class NimbusBuilder {
252266
dbPath: dbFilePath,
253267
resourceBundles: resourceBundles,
254268
userDefaults: userDefaults,
255-
errorReporter: errorReporter)
269+
errorReporter: errorReporter,
270+
recordedContext: recordedContext)
256271
}
257272

258273
func newNimbusDisabled() -> NimbusInterface {

components/nimbus/ios/Nimbus/NimbusCreate.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ public extension Nimbus {
8484
resourceBundles: [Bundle] = [Bundle.main],
8585
enabled: Bool = true,
8686
userDefaults: UserDefaults? = nil,
87-
errorReporter: @escaping NimbusErrorReporter = defaultErrorReporter
87+
errorReporter: @escaping NimbusErrorReporter = defaultErrorReporter,
88+
recordedContext: RecordedContext? = nil
8889
) throws -> NimbusInterface {
8990
guard enabled else {
9091
return NimbusDisabled.shared
@@ -99,6 +100,7 @@ public extension Nimbus {
99100
}
100101
let nimbusClient = try NimbusClient(
101102
appCtx: context,
103+
recordedContext: recordedContext,
102104
coenrollingFeatureIds: coenrollingFeatureIds,
103105
dbpath: dbPath,
104106
remoteSettingsConfig: remoteSettings,

components/nimbus/src/json.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
use serde_json::{Map, Value};
66
use std::collections::HashMap;
77

8+
#[cfg(feature = "stateful")]
9+
pub type JsonObject = Map<String, Value>;
10+
811
/// Replace any instance of [from] with [to] in any string within the [serde_json::Value].
912
///
1013
/// This recursively descends into the object, looking at string values and keys.

components/nimbus/src/nimbus.udl

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,21 @@ enum NimbusError {
106106
"ParseIntError", "TransformParameterError", "ClientError", "UniFFICallbackError",
107107
};
108108

109+
[Custom]
110+
typedef string JsonObject;
111+
112+
[Trait, WithForeign]
113+
interface RecordedContext {
114+
JsonObject to_json();
115+
116+
void record();
117+
};
118+
109119
interface NimbusClient {
110120
[Throws=NimbusError]
111121
constructor(
112122
AppContext app_ctx,
123+
RecordedContext? recorded_context,
113124
sequence<string> coenrolling_feature_ids,
114125
string dbpath,
115126
RemoteSettingsConfig? remote_settings_config,
@@ -274,9 +285,6 @@ interface NimbusClient {
274285
void dump_state_to_log();
275286
};
276287

277-
[Custom]
278-
typedef string JsonObject;
279-
280288
interface NimbusTargetingHelper {
281289
// Execute the given jexl expression and evaluate against the existing targeting parameters and context passed to
282290
// the helper at construction.

components/nimbus/src/stateful/evaluator.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
use crate::{
66
enrollment::{EnrollmentStatus, ExperimentEnrollment},
77
evaluator::split_locale,
8+
json::JsonObject,
89
stateful::matcher::AppContext,
10+
targeting::RecordedContext,
911
};
1012
use chrono::{DateTime, Utc};
1113
use serde_derive::*;
@@ -17,6 +19,8 @@ pub struct TargetingAttributes {
1719
pub app_context: AppContext,
1820
pub language: Option<String>,
1921
pub region: Option<String>,
22+
#[serde(flatten)]
23+
pub recorded_context: Option<JsonObject>,
2024
pub is_already_enrolled: bool,
2125
pub days_since_install: Option<i32>,
2226
pub days_since_update: Option<i32>,
@@ -28,7 +32,6 @@ pub struct TargetingAttributes {
2832
pub nimbus_id: Option<String>,
2933
}
3034

31-
#[cfg(feature = "stateful")]
3235
impl From<AppContext> for TargetingAttributes {
3336
fn from(app_context: AppContext) -> Self {
3437
let (language, region) = app_context
@@ -47,6 +50,10 @@ impl From<AppContext> for TargetingAttributes {
4750
}
4851

4952
impl TargetingAttributes {
53+
pub(crate) fn set_recorded_context(&mut self, recorded_context: &dyn RecordedContext) {
54+
self.recorded_context = Some(recorded_context.to_json());
55+
}
56+
5057
pub(crate) fn update_time_to_now(
5158
&mut self,
5259
now: DateTime<Utc>,

components/nimbus/src/stateful/matcher.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ use serde_json::{Map, Value};
3535
/// - `installation_date`: The date the application installed the app
3636
/// - `home_directory`: The application's home directory
3737
/// - `custom_targeting_attributes`: Contains attributes specific to the application, derived by the application
38-
#[cfg(feature = "stateful")]
3938
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
4039
pub struct AppContext {
4140
pub app_name: String,

0 commit comments

Comments
 (0)