Revision control
Copy as Markdown
/* 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
import Foundation
import Glean
public class Nimbus: NimbusInterface {
private let _userDefaults: UserDefaults?
private let nimbusClient: NimbusClientProtocol
private let resourceBundles: [Bundle]
private let errorReporter: NimbusErrorReporter
lazy var fetchQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "Nimbus fetch queue"
queue.maxConcurrentOperationCount = 1
return queue
}()
lazy var dbQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "Nimbus database queue"
queue.maxConcurrentOperationCount = 1
return queue
}()
init(nimbusClient: NimbusClientProtocol,
resourceBundles: [Bundle],
userDefaults: UserDefaults?,
errorReporter: @escaping NimbusErrorReporter)
{
self.errorReporter = errorReporter
self.nimbusClient = nimbusClient
self.resourceBundles = resourceBundles
_userDefaults = userDefaults
NilVariables.instance.set(bundles: resourceBundles)
}
}
private extension Nimbus {
func catchAll<T>(_ thunk: () throws -> T?) -> T? {
do {
return try thunk()
} catch NimbusError.DatabaseNotReady {
return nil
} catch {
errorReporter(error)
return nil
}
}
func catchAll(_ queue: OperationQueue, thunk: @escaping (Operation) throws -> Void) -> Operation {
let op = BlockOperation()
op.addExecutionBlock {
self.catchAll {
try thunk(op)
}
}
queue.addOperation(op)
return op
}
}
extension Nimbus: NimbusQueues {
public func waitForFetchQueue() {
fetchQueue.waitUntilAllOperationsAreFinished()
}
public func waitForDbQueue() {
dbQueue.waitUntilAllOperationsAreFinished()
}
}
extension Nimbus: NimbusEventStore {
public func recordEvent(_ eventId: String) {
recordEvent(1, eventId)
}
public func recordEvent(_ count: Int, _ eventId: String) {
_ = catchAll(dbQueue) { _ in
try self.nimbusClient.recordEvent(eventId: eventId, count: Int64(count))
}
}
public func recordPastEvent(_ count: Int, _ eventId: String, _ timeAgo: TimeInterval) throws {
try nimbusClient.recordPastEvent(eventId: eventId, secondsAgo: Int64(timeAgo), count: Int64(count))
}
public func advanceEventTime(by duration: TimeInterval) throws {
try nimbusClient.advanceEventTime(bySeconds: Int64(duration))
}
public func clearEvents() {
_ = catchAll(dbQueue) { _ in
try self.nimbusClient.clearEvents()
}
}
}
extension Nimbus: FeaturesInterface {
public var userDefaults: UserDefaults? {
_userDefaults
}
public func recordExposureEvent(featureId: String, experimentSlug: String? = nil) {
catchAll {
nimbusClient.recordFeatureExposure(featureId: featureId, slug: experimentSlug)
}
}
public func recordMalformedConfiguration(featureId: String, with partId: String) {
catchAll {
nimbusClient.recordMalformedFeatureConfig(featureId: featureId, partId: partId)
}
}
func postEnrollmentCalculation(_ events: [EnrollmentChangeEvent]) {
// We need to update the experiment enrollment annotations in Glean
// regardless of whether we received any events. Calling the
// `setExperimentActive` function multiple times with the same
// experiment id is safe so nothing bad should happen in case we do.
let experiments = getActiveExperiments()
recordExperimentTelemetry(experiments)
// Record enrollment change events, if any
recordExperimentEvents(events)
// Inform any listeners that we're done here.
notifyOnExperimentsApplied(experiments)
}
func recordExperimentTelemetry(_ experiments: [EnrolledExperiment]) {
for experiment in experiments {
Glean.shared.setExperimentActive(
experiment.slug,
branch: experiment.branchSlug,
extra: nil
)
}
}
func recordExperimentEvents(_ events: [EnrollmentChangeEvent]) {
for event in events {
switch event.change {
case .enrollment:
GleanMetrics.NimbusEvents.enrollment.record(GleanMetrics.NimbusEvents.EnrollmentExtra(
branch: event.branchSlug,
experiment: event.experimentSlug
))
case .disqualification:
GleanMetrics.NimbusEvents.disqualification.record(GleanMetrics.NimbusEvents.DisqualificationExtra(
branch: event.branchSlug,
experiment: event.experimentSlug
))
case .unenrollment:
GleanMetrics.NimbusEvents.unenrollment.record(GleanMetrics.NimbusEvents.UnenrollmentExtra(
branch: event.branchSlug,
experiment: event.experimentSlug
))
case .enrollFailed:
GleanMetrics.NimbusEvents.enrollFailed.record(GleanMetrics.NimbusEvents.EnrollFailedExtra(
branch: event.branchSlug,
experiment: event.experimentSlug,
reason: event.reason
))
case .unenrollFailed:
GleanMetrics.NimbusEvents.unenrollFailed.record(GleanMetrics.NimbusEvents.UnenrollFailedExtra(
experiment: event.experimentSlug,
reason: event.reason
))
}
}
}
func getFeatureConfigVariablesJson(featureId: String) -> [String: Any]? {
do {
guard let string = try nimbusClient.getFeatureConfigVariables(featureId: featureId) else {
return nil
}
return try Dictionary.parse(jsonString: string)
} catch NimbusError.DatabaseNotReady {
GleanMetrics.NimbusHealth.cacheNotReadyForFeature.record(
GleanMetrics.NimbusHealth.CacheNotReadyForFeatureExtra(
featureId: featureId
)
)
return nil
} catch {
errorReporter(error)
return nil
}
}
public func getVariables(featureId: String, sendExposureEvent: Bool) -> Variables {
guard let json = getFeatureConfigVariablesJson(featureId: featureId) else {
return NilVariables.instance
}
if sendExposureEvent {
recordExposureEvent(featureId: featureId)
}
return JSONVariables(with: json, in: resourceBundles)
}
}
private extension Nimbus {
func notifyOnExperimentsFetched() {
NotificationCenter.default.post(name: .nimbusExperimentsFetched, object: nil)
}
func notifyOnExperimentsApplied(_ experiments: [EnrolledExperiment]) {
NotificationCenter.default.post(name: .nimbusExperimentsApplied, object: experiments)
}
}
/*
* Methods split out onto a separate internal extension for testing purposes.
*/
extension Nimbus {
func setGlobalUserParticipationOnThisThread(_ value: Bool) throws {
let changes = try nimbusClient.setGlobalUserParticipation(optIn: value)
postEnrollmentCalculation(changes)
}
func initializeOnThisThread() throws {
try nimbusClient.initialize()
}
func fetchExperimentsOnThisThread() throws {
try GleanMetrics.NimbusHealth.fetchExperimentsTime.measure {
try nimbusClient.fetchExperiments()
}
notifyOnExperimentsFetched()
}
func applyPendingExperimentsOnThisThread() throws {
let changes = try GleanMetrics.NimbusHealth.applyPendingExperimentsTime.measure {
try nimbusClient.applyPendingExperiments()
}
postEnrollmentCalculation(changes)
}
func setExperimentsLocallyOnThisThread(_ experimentsJson: String) throws {
try nimbusClient.setExperimentsLocally(experimentsJson: experimentsJson)
}
func optOutOnThisThread(_ experimentId: String) throws {
let changes = try nimbusClient.optOut(experimentSlug: experimentId)
postEnrollmentCalculation(changes)
}
func optInOnThisThread(_ experimentId: String, branch: String) throws {
let changes = try nimbusClient.optInWithBranch(experimentSlug: experimentId, branch: branch)
postEnrollmentCalculation(changes)
}
func resetTelemetryIdentifiersOnThisThread() throws {
let changes = try nimbusClient.resetTelemetryIdentifiers()
postEnrollmentCalculation(changes)
}
}
extension Nimbus: NimbusUserConfiguration {
public var globalUserParticipation: Bool {
get {
catchAll { try nimbusClient.getGlobalUserParticipation() } ?? false
}
set {
_ = catchAll(dbQueue) { _ in
try self.setGlobalUserParticipationOnThisThread(newValue)
}
}
}
public func getActiveExperiments() -> [EnrolledExperiment] {
return catchAll {
try nimbusClient.getActiveExperiments()
} ?? []
}
public func getAvailableExperiments() -> [AvailableExperiment] {
return catchAll {
try nimbusClient.getAvailableExperiments()
} ?? []
}
public func getExperimentBranches(_ experimentId: String) -> [Branch]? {
return catchAll {
try nimbusClient.getExperimentBranches(experimentSlug: experimentId)
}
}
public func optOut(_ experimentId: String) {
_ = catchAll(dbQueue) { _ in
try self.optOutOnThisThread(experimentId)
}
}
public func optIn(_ experimentId: String, branch: String) {
_ = catchAll(dbQueue) { _ in
try self.optInOnThisThread(experimentId, branch: branch)
}
}
public func resetTelemetryIdentifiers() {
_ = catchAll(dbQueue) { _ in
try self.resetTelemetryIdentifiersOnThisThread()
}
}
}
extension Nimbus: NimbusStartup {
public func initialize() {
_ = catchAll(dbQueue) { _ in
try self.initializeOnThisThread()
}
}
public func fetchExperiments() {
_ = catchAll(fetchQueue) { _ in
try self.fetchExperimentsOnThisThread()
}
}
public func setFetchEnabled(_ enabled: Bool) {
_ = catchAll(fetchQueue) { _ in
try self.nimbusClient.setFetchEnabled(flag: enabled)
}
}
public func isFetchEnabled() -> Bool {
return catchAll {
try self.nimbusClient.isFetchEnabled()
} ?? true
}
public func applyPendingExperiments() -> Operation {
catchAll(dbQueue) { _ in
try self.applyPendingExperimentsOnThisThread()
}
}
public func applyLocalExperiments(fileURL: URL) -> Operation {
applyLocalExperiments(getString: { try String(contentsOf: fileURL) })
}
func applyLocalExperiments(getString: @escaping () throws -> String) -> Operation {
catchAll(dbQueue) { op in
let json = try getString()
if op.isCancelled {
try self.initializeOnThisThread()
} else {
try self.setExperimentsLocallyOnThisThread(json)
try self.applyPendingExperimentsOnThisThread()
}
}
}
public func setExperimentsLocally(_ fileURL: URL) {
_ = catchAll(dbQueue) { _ in
let json = try String(contentsOf: fileURL)
try self.setExperimentsLocallyOnThisThread(json)
}
}
public func setExperimentsLocally(_ experimentsJson: String) {
_ = catchAll(dbQueue) { _ in
try self.setExperimentsLocallyOnThisThread(experimentsJson)
}
}
public func resetEnrollmentsDatabase() -> Operation {
catchAll(dbQueue) { _ in
try self.nimbusClient.resetEnrollments()
}
}
public func dumpStateToLog() {
catchAll {
try self.nimbusClient.dumpStateToLog()
}
}
}
extension Nimbus: NimbusBranchInterface {
public func getExperimentBranch(experimentId: String) -> String? {
return catchAll {
try nimbusClient.getExperimentBranch(id: experimentId)
}
}
}
extension Nimbus: NimbusMessagingProtocol {
public func createMessageHelper() throws -> NimbusMessagingHelperProtocol {
return try createMessageHelper(string: nil)
}
public func createMessageHelper(additionalContext: [String: Any]) throws -> NimbusMessagingHelperProtocol {
let string = try additionalContext.stringify()
return try createMessageHelper(string: string)
}
public func createMessageHelper<T: Encodable>(additionalContext: T) throws -> NimbusMessagingHelperProtocol {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let data = try encoder.encode(additionalContext)
let string = String(data: data, encoding: .utf8)!
return try createMessageHelper(string: string)
}
private func createMessageHelper(string: String?) throws -> NimbusMessagingHelperProtocol {
let targetingHelper = try nimbusClient.createTargetingHelper(additionalContext: string)
let stringHelper = try nimbusClient.createStringHelper(additionalContext: string)
return NimbusMessagingHelper(targetingHelper: targetingHelper, stringHelper: stringHelper)
}
public var events: NimbusEventStore {
self
}
}
public class NimbusDisabled: NimbusApi {
public static let shared = NimbusDisabled()
public var globalUserParticipation: Bool = false
}
public extension NimbusDisabled {
func getActiveExperiments() -> [EnrolledExperiment] {
return []
}
func getAvailableExperiments() -> [AvailableExperiment] {
return []
}
func getExperimentBranch(experimentId _: String) -> String? {
return nil
}
func getVariables(featureId _: String, sendExposureEvent _: Bool) -> Variables {
return NilVariables.instance
}
func initialize() {}
func fetchExperiments() {}
func setFetchEnabled(_: Bool) {}
func isFetchEnabled() -> Bool {
false
}
func applyPendingExperiments() -> Operation {
BlockOperation()
}
func applyLocalExperiments(fileURL _: URL) -> Operation {
BlockOperation()
}
func setExperimentsLocally(_: URL) {}
func setExperimentsLocally(_: String) {}
func resetEnrollmentsDatabase() -> Operation {
BlockOperation()
}
func optOut(_: String) {}
func optIn(_: String, branch _: String) {}
func resetTelemetryIdentifiers() {}
func recordExposureEvent(featureId _: String, experimentSlug _: String? = nil) {}
func recordMalformedConfiguration(featureId _: String, with _: String) {}
func recordEvent(_: Int, _: String) {}
func recordEvent(_: String) {}
func recordPastEvent(_: Int, _: String, _: TimeInterval) {}
func advanceEventTime(by _: TimeInterval) throws {}
func clearEvents() {}
func dumpStateToLog() {}
func getExperimentBranches(_: String) -> [Branch]? {
return nil
}
func waitForFetchQueue() {}
func waitForDbQueue() {}
}
extension NimbusDisabled: NimbusMessagingProtocol {
public func createMessageHelper() throws -> NimbusMessagingHelperProtocol {
NimbusMessagingHelper(
targetingHelper: AlwaysConstantTargetingHelper(),
stringHelper: EchoStringHelper()
)
}
public func createMessageHelper(additionalContext _: [String: Any]) throws -> NimbusMessagingHelperProtocol {
try createMessageHelper()
}
public func createMessageHelper<T: Encodable>(additionalContext _: T) throws -> NimbusMessagingHelperProtocol {
try createMessageHelper()
}
public var events: NimbusEventStore { self }
}