Skip to content

Commit

Permalink
feat: Manage Events - track app lifecycle events (#118)
Browse files Browse the repository at this point in the history
* move all the logic inside confidence

* add evaluation extension in flag resolution

* cleanup

* have the evaluation in the confidence module for the flags

* update demo app to support only confidence

* fix some tests to move to confidence

* use int instead of int64 for 32bits system to work with default value for int, fix some more tests

* fixup! use int instead of int64 for 32bits system to work with default value for int, fix some more tests

* fixup! Merge branch 'main' into move-flag-evaluation-confidence

* fixup! fixup! Merge branch 'main' into move-flag-evaluation-confidence

* fixup! fixup! fixup! Merge branch 'main' into move-flag-evaluation-confidence

* add analytics for app and ui kit lifecycle, add the appear and disappear for demo app events

* fixup! add analytics for app and ui kit lifecycle, add the appear and disappear for demo app events

* handle app launch and app install and app updates

* add context producer and produce context for is_foreground and build and version

* fixup! merge main

* fixup! fixup! merge main

* refactor: Smaller refactor and fixes

* Remove some managed events

* wip: minor fixes and experiments

* introduce passthrough subject with buffer

* update the engine to remove the batches by default and add them if failed

* fixup! update the engine to remove the batches by default and add them if failed

* queue label prefix

* use semaphore to make upload serialz

* Remove dead code

* Make buffered passthrough serial

---------

Co-authored-by: Fabrizio Demaria <fabrizio.f.demaria@gmail.com>
  • Loading branch information
vahidlazio and fabriziodemaria committed May 10, 2024
1 parent bfdc949 commit e74af7c
Show file tree
Hide file tree
Showing 9 changed files with 334 additions and 5 deletions.
3 changes: 3 additions & 0 deletions ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class Status: ObservableObject {

@main
struct ConfidenceDemoApp: App {
@StateObject private var lifecycleObserver = ConfidenceAppLifecycleProducer()

var body: some Scene {
WindowGroup {
let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? ""
Expand All @@ -27,6 +29,7 @@ struct ConfidenceDemoApp: App {
ContentView(confidence: confidence, status: status)
.task {
do {
confidence.track(producer: lifecycleObserver)
try await self.setup(confidence: confidence)
status.state = .ready
} catch {
Expand Down
30 changes: 30 additions & 0 deletions Sources/Confidence/BufferedPassthroughSubject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import Combine

class BufferedPassthrough<T> {
private let subject = PassthroughSubject<T, Never>()
private var buffer: [T] = []
private var isListening = false
private let queue = DispatchQueue(label: "com.confidence.passthrough_serial")

func send(_ value: T) {
queue.sync {
if isListening {
subject.send(value)
} else {
buffer.append(value)
}
}
}

func publisher() -> AnyPublisher<T, Never> {
return queue.sync {
isListening = true
let bufferedPublisher = buffer.publisher
buffer.removeAll()
return bufferedPublisher
.append(subject)
.eraseToAnyPublisher()
}
}
}
24 changes: 24 additions & 0 deletions Sources/Confidence/Confidence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ public class Confidence: ConfidenceEventSender {
)
}

public func track(producer: ConfidenceProducer) {
if let eventProducer = producer as? ConfidenceEventProducer {
eventProducer.produceEvents()
.sink { [weak self] event in
guard let self = self else {
return
}
self.track(eventName: event.name, message: event.message)
}
.store(in: &cancellables)
}

if let contextProducer = producer as? ConfidenceContextProducer {
contextProducer.produceContexts()
.sink { [weak self] context in
guard let self = self else {
return
}
self.putContext(context: context)
}
.store(in: &cancellables)
}
}

private func withLock(callback: @escaping (Confidence) -> Void) {
confidenceQueue.sync { [weak self] in
guard let self = self else {
Expand Down
109 changes: 109 additions & 0 deletions Sources/Confidence/ConfidenceAppLifecycleProducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst)
import Foundation
import UIKit
import Combine

public class ConfidenceAppLifecycleProducer: ConfidenceEventProducer, ConfidenceContextProducer, ObservableObject {
public var currentProducedContext: CurrentValueSubject<ConfidenceStruct, Never> = CurrentValueSubject([:])
private var events: BufferedPassthrough<Event> = BufferedPassthrough()
private let queue = DispatchQueue(label: "com.confidence.lifecycle_producer")
private var appNotifications: [NSNotification.Name] = [
UIApplication.didEnterBackgroundNotification,
UIApplication.willEnterForegroundNotification,
UIApplication.didBecomeActiveNotification
]

private static var versionNameKey = "CONFIDENCE_VERSION_NAME_KEY"
private static var buildNameKey = "CONFIDENCE_VERSIONN_KEY"
private let appLaunchedEventName = "app-launched"

public init() {
for notification in appNotifications {
NotificationCenter.default.addObserver(
self,
selector: #selector(notificationResponse),
name: notification,
object: nil
)
}

let currentVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let currentBuild: String = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
let context: ConfidenceStruct = [
"version": .init(string: currentVersion),
"build": .init(string: currentBuild)
]

self.currentProducedContext.send(context)
}

deinit {
NotificationCenter.default.removeObserver(self)
}

public func produceEvents() -> AnyPublisher<Event, Never> {
events.publisher()
}

public func produceContexts() -> AnyPublisher<ConfidenceStruct, Never> {
currentProducedContext
.filter { context in !context.isEmpty }
.eraseToAnyPublisher()
}

private func track(eventName: String) {
let previousBuild: String? = UserDefaults.standard.string(forKey: Self.buildNameKey)
let previousVersion: String? = UserDefaults.standard.string(forKey: Self.versionNameKey)

let currentVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let currentBuild: String = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""

let message: ConfidenceStruct = [
"version": .init(string: currentVersion),
"build": .init(string: currentBuild)
]

if eventName == self.appLaunchedEventName {
if previousBuild == nil && previousVersion == nil {
events.send(Event(name: "app-installed", message: message))
} else if previousBuild != currentBuild || previousVersion != currentVersion {
events.send(Event(name: "app-updated", message: message))
}
}
events.send(Event(name: eventName, message: message))

UserDefaults.standard.setValue(currentVersion, forKey: Self.versionNameKey)
UserDefaults.standard.setValue(currentBuild, forKey: Self.buildNameKey)
}

private func updateContext(isForeground: Bool) {
withLock { [weak self] in
guard let self = self else {
return
}
var context = self.currentProducedContext.value
context.updateValue(.init(boolean: isForeground), forKey: "is_foreground")
self.currentProducedContext.send(context)
}
}

private func withLock(callback: @escaping () -> Void) {
queue.sync {
callback()
}
}

@objc func notificationResponse(notification: NSNotification) {
switch notification.name {
case UIApplication.didEnterBackgroundNotification:
updateContext(isForeground: false)
case UIApplication.willEnterForegroundNotification:
updateContext(isForeground: true)
case UIApplication.didBecomeActiveNotification:
track(eventName: appLaunchedEventName)
default:
break
}
}
}
#endif
4 changes: 4 additions & 0 deletions Sources/Confidence/ConfidenceEventSender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ public protocol ConfidenceEventSender: Contextual {
according to the configured flushing logic
*/
func track(eventName: String, message: ConfidenceStruct)
/**
The ConfidenceProducer can be used to push context changes or event tracking
*/
func track(producer: ConfidenceProducer)
}
32 changes: 32 additions & 0 deletions Sources/Confidence/ConfidenceProducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import Combine

/**
ConfidenceContextProducer or ConfidenceEventProducer
*/
public protocol ConfidenceProducer {
}

public struct Event {
let name: String
let message: ConfidenceStruct

public init(name: String, message: ConfidenceStruct = [:]) {
self.name = name
self.message = message
}
}

/**
ConfidenceContextProducer implementer pushses context changes in a Publisher fashion
*/
public protocol ConfidenceContextProducer: ConfidenceProducer {
func produceContexts() -> AnyPublisher<ConfidenceStruct, Never>
}

/**
ConfidenceContextProducer implementer emit events in a Publisher fashion
*/
public protocol ConfidenceEventProducer: ConfidenceProducer {
func produceEvents() -> AnyPublisher<Event, Never>
}
102 changes: 102 additions & 0 deletions Sources/Confidence/ConfidenceScreenTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst)
import Foundation
import UIKit
import Combine

public class ConfidenceScreenTracker: ConfidenceEventProducer {
private var events = BufferedPassthrough<Event>()
static let notificationName = Notification.Name(rawValue: "ConfidenceScreenTracker")
static let screenName = "screen_name"
static let messageKey = "message_json"
static let controllerKey = "controller"

public init() {
swizzle(
forClass: UIViewController.self,
original: #selector(UIViewController.viewDidAppear(_:)),
new: #selector(UIViewController.confidence__viewDidAppear)
)

swizzle(
forClass: UIViewController.self,
original: #selector(UIViewController.viewDidDisappear(_:)),
new: #selector(UIViewController.confidence__viewDidDisappear)
)

NotificationCenter.default.addObserver(
forName: Self.notificationName,
object: nil,
queue: OperationQueue.main) { [weak self] notification in
let name = notification.userInfo?[Self.screenName] as? String
let messageJson = (notification.userInfo?[Self.messageKey] as? String)?.data(using: .utf8)
var message: ConfidenceStruct = [:]
if let data = messageJson {
let decoder = JSONDecoder()
do {
message = try decoder.decode(ConfidenceStruct.self, from: data)
} catch {
}
}

guard let self = self else {
return
}
if let name = name {
self.events.send(Event(name: name, message: message))
}
}
}

public func produceEvents() -> AnyPublisher<Event, Never> {
events.publisher()
}

private func swizzle(forClass: AnyClass, original: Selector, new: Selector) {
guard let originalMethod = class_getInstanceMethod(forClass, original) else { return }
guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return }
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}

public protocol TrackableComponent {
func trackName() -> String
}

public protocol TrackableComponentWithMessage: TrackableComponent {
func trackMessage() -> ConfidenceStruct
}

extension UIViewController {
private func sendNotification(event: String) {
var className = String(describing: type(of: self))
.replacingOccurrences(of: "ViewController", with: "")
var message: [String: String] = [ConfidenceScreenTracker.screenName: className]

if let trackable = self as? TrackableComponent {
className = trackable.trackName()
if let trackableWithMessage = self as? TrackableComponentWithMessage {
let encoder = JSONEncoder()
do {
let data = try encoder.encode(trackableWithMessage.trackMessage())
let messageString = String(data: data, encoding: .utf8)
if let json = messageString {
message.updateValue(json, forKey: ConfidenceScreenTracker.messageKey)
}
} catch {
}
}
}

NotificationCenter.default.post(
name: ConfidenceScreenTracker.notificationName,
object: self,
userInfo: message
)
}
@objc internal func confidence__viewDidAppear(animated: Bool) {
}

@objc internal func confidence__viewDidDisappear(animated: Bool) {
}
}
#endif
29 changes: 26 additions & 3 deletions Sources/Confidence/EventSenderEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ final class EventSenderEngineImpl: EventSenderEngine {
private let uploader: ConfidenceClient
private let clientSecret: String
private let payloadMerger: PayloadMerger = PayloadMergerImpl()
private let semaphore = DispatchSemaphore(value: 1)

init(
clientSecret: String,
Expand Down Expand Up @@ -52,10 +53,21 @@ final class EventSenderEngineImpl: EventSenderEngine {
.store(in: &cancellables)

uploadReqChannel.sink { [weak self] _ in
guard let self = self else { return }
await self.upload()
}
.store(in: &cancellables)
}

func upload() async {
await withSemaphore { [weak self] in
guard let self = self else { return }
do {
guard let self = self else { return }
try self.storage.startNewBatch()
let ids = try storage.batchReadyIds()
if ids.isEmpty {
return
}
for id in ids {
let events: [NetworkEvent] = try self.storage.eventsFrom(id: id)
.compactMap { event in
Expand All @@ -64,15 +76,26 @@ final class EventSenderEngineImpl: EventSenderEngine {
payload: NetworkStruct(fields: TypeMapper.convert(structure: event.payload).fields),
eventTime: Date.backport.toISOString(date: event.eventTime))
}
let shouldCleanup = try await self.uploader.upload(events: events)
var shouldCleanup = false
if events.isEmpty {
shouldCleanup = true
} else {
shouldCleanup = try await self.uploader.upload(events: events)
}

if shouldCleanup {
try storage.remove(id: id)
}
}
} catch {
}
}
.store(in: &cancellables)
}

func withSemaphore(callback: @escaping () async -> Void) async {
semaphore.wait()
await callback()
semaphore.signal()
}

func emit(eventName: String, message: ConfidenceStruct, context: ConfidenceStruct) {
Expand Down
Loading

0 comments on commit e74af7c

Please sign in to comment.