469 lines
17 KiB
Swift
469 lines
17 KiB
Swift
|
import Foundation
|
||
|
import CocoaLumberjackSwift
|
||
|
|
||
|
enum EventLoggingError {
|
||
|
case generic
|
||
|
case network
|
||
|
}
|
||
|
|
||
|
@objc(WMFEventLoggingService)
|
||
|
public class EventLoggingService : NSObject, URLSessionDelegate {
|
||
|
private struct Key {
|
||
|
static let isEnabled = "SendUsageReports"
|
||
|
static let appInstallID = "WMFAppInstallID"
|
||
|
static let lastLoggedSnapshot = "WMFLastLoggedSnapshot"
|
||
|
static let appInstallDate = "AppInstallDate"
|
||
|
static let loggedDaysInstalled = "DailyLoggingStatsDaysInstalled"
|
||
|
static let lastSuccessfulPost = "LastSuccessfulPost"
|
||
|
}
|
||
|
|
||
|
private var pruningAge: TimeInterval = 60*60*24*30 // 30 days
|
||
|
private var sendOnWWANThreshold: TimeInterval = 24 * 60 * 60
|
||
|
private var postBatchSize = 32
|
||
|
private var postTimeout: TimeInterval = 60*2 // 2 minutes
|
||
|
private var postInterval: TimeInterval = 60*10 // 10 minutes
|
||
|
|
||
|
private var debugDisableImmediateSend = false
|
||
|
|
||
|
private let session: Session
|
||
|
|
||
|
private let persistentStoreCoordinator: NSPersistentStoreCoordinator
|
||
|
private let managedObjectContext: NSManagedObjectContext
|
||
|
private let operationQueue: OperationQueue
|
||
|
|
||
|
@objc(sharedInstance) public static let shared: EventLoggingService? = {
|
||
|
let fileManager = FileManager.default
|
||
|
var permanentStorageDirectory = fileManager.wmf_containerURL().appendingPathComponent("Event Logging", isDirectory: true)
|
||
|
var didGetDirectoryExistsError = false
|
||
|
do {
|
||
|
try fileManager.createDirectory(at: permanentStorageDirectory, withIntermediateDirectories: true, attributes: nil)
|
||
|
} catch let error {
|
||
|
DDLogError("EventLoggingService: Error creating permanent cache: \(error)")
|
||
|
}
|
||
|
do {
|
||
|
var values = URLResourceValues()
|
||
|
values.isExcludedFromBackup = true
|
||
|
try permanentStorageDirectory.setResourceValues(values)
|
||
|
} catch let error {
|
||
|
DDLogError("EventLoggingService: Error excluding from backup: \(error)")
|
||
|
}
|
||
|
|
||
|
let permanentStorageURL = permanentStorageDirectory.appendingPathComponent("Events.sqlite")
|
||
|
DDLogDebug("EventLoggingService: Events persistent store: \(permanentStorageURL)")
|
||
|
|
||
|
// SINGLETONTODO
|
||
|
let eventLoggingService = EventLoggingService(session: MWKDataStore.shared().session, permanentStorageURL: permanentStorageURL)
|
||
|
if let eventLoggingService = eventLoggingService {
|
||
|
MWKDataStore.shared().setupAbTestsController(withPersistenceService: eventLoggingService)
|
||
|
}
|
||
|
|
||
|
return eventLoggingService
|
||
|
}()
|
||
|
|
||
|
@objc
|
||
|
public func log(event: [String: Any], schema: String, revision: Int, wiki: String) {
|
||
|
let event: NSDictionary = ["event": event, "schema": schema, "revision": revision, "wiki": wiki]
|
||
|
logEvent(event)
|
||
|
}
|
||
|
|
||
|
public init?(session: Session, permanentStorageURL: URL?) {
|
||
|
let bundle = Bundle.wmf
|
||
|
let modelURL = bundle.url(forResource: "EventLogging", withExtension: "momd")!
|
||
|
let model = NSManagedObjectModel(contentsOf: modelURL)!
|
||
|
let psc = NSPersistentStoreCoordinator(managedObjectModel: model)
|
||
|
let options = [NSMigratePersistentStoresAutomaticallyOption: NSNumber(booleanLiteral: true), NSInferMappingModelAutomaticallyOption: NSNumber(booleanLiteral: true)]
|
||
|
operationQueue = OperationQueue()
|
||
|
operationQueue.maxConcurrentOperationCount = 1
|
||
|
self.session = session
|
||
|
if let storeURL = permanentStorageURL {
|
||
|
do {
|
||
|
try psc.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options)
|
||
|
} catch {
|
||
|
do {
|
||
|
try FileManager.default.removeItem(at: storeURL)
|
||
|
} catch {
|
||
|
|
||
|
}
|
||
|
do {
|
||
|
try psc.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options)
|
||
|
} catch {
|
||
|
return nil
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
do {
|
||
|
try psc.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: options)
|
||
|
} catch {
|
||
|
return nil
|
||
|
}
|
||
|
}
|
||
|
|
||
|
self.persistentStoreCoordinator = psc
|
||
|
self.managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||
|
self.managedObjectContext.persistentStoreCoordinator = psc
|
||
|
super.init()
|
||
|
}
|
||
|
|
||
|
|
||
|
@objc
|
||
|
public func reset() {
|
||
|
self.resetSession()
|
||
|
self.resetInstall()
|
||
|
}
|
||
|
|
||
|
@objc
|
||
|
func migrateShareUsageAndInstallIDToUserDefaults() {
|
||
|
let enabledNumber = libraryValue(for: Key.isEnabled) as? NSNumber
|
||
|
if enabledNumber != nil {
|
||
|
UserDefaults.standard.wmf_sendUsageReports = enabledNumber!.boolValue
|
||
|
} else {
|
||
|
UserDefaults.standard.wmf_sendUsageReports = false
|
||
|
}
|
||
|
|
||
|
UserDefaults.standard.wmf_appInstallId = libraryValue(for: Key.appInstallID) as? String
|
||
|
}
|
||
|
|
||
|
@objc
|
||
|
private func logEvent(_ event: NSDictionary) {
|
||
|
let now = NSDate()
|
||
|
perform { moc in
|
||
|
let record = NSEntityDescription.insertNewObject(forEntityName: "WMFEventRecord", into: self.managedObjectContext) as! EventRecord
|
||
|
record.event = event
|
||
|
record.recorded = now
|
||
|
record.userAgent = WikipediaAppUtils.versionedUserAgent()
|
||
|
|
||
|
DDLogDebug("EventLoggingService: \(record.objectID) recorded!")
|
||
|
|
||
|
self.save(moc)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@objc
|
||
|
private func tryPostEvents(_ completion: (() -> Void)? = nil) {
|
||
|
let operation = AsyncBlockOperation { (operation) in
|
||
|
self.perform { moc in
|
||
|
let pruneFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "WMFEventRecord")
|
||
|
pruneFetch.returnsObjectsAsFaults = false
|
||
|
|
||
|
let pruneDate = Date().addingTimeInterval(-(self.pruningAge)) as NSDate
|
||
|
pruneFetch.predicate = NSPredicate(format: "(recorded < %@) OR (posted != nil) OR (failed == TRUE)", pruneDate)
|
||
|
let delete = NSBatchDeleteRequest(fetchRequest: pruneFetch)
|
||
|
delete.resultType = .resultTypeCount
|
||
|
|
||
|
do {
|
||
|
let result = try self.managedObjectContext.execute(delete)
|
||
|
guard let deleteResult = result as? NSBatchDeleteResult else {
|
||
|
DDLogError("EventLoggingService: Could not read NSBatchDeleteResult")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
guard let count = deleteResult.result as? Int else {
|
||
|
DDLogError("EventLoggingService: Could not read NSBatchDeleteResult count")
|
||
|
return
|
||
|
}
|
||
|
if count > 0 {
|
||
|
DDLogInfo("EventLoggingService: Pruned \(count) events")
|
||
|
}
|
||
|
|
||
|
} catch let error {
|
||
|
DDLogError("EventLoggingService: Error pruning events: \(error.localizedDescription)")
|
||
|
}
|
||
|
|
||
|
let fetch: NSFetchRequest<EventRecord> = EventRecord.fetchRequest()
|
||
|
fetch.sortDescriptors = [NSSortDescriptor(keyPath: \EventRecord.recorded, ascending: true)]
|
||
|
fetch.predicate = NSPredicate(format: "(posted == nil) AND (failed != TRUE)")
|
||
|
fetch.fetchLimit = self.postBatchSize
|
||
|
|
||
|
var eventRecords: [EventRecord] = []
|
||
|
|
||
|
do {
|
||
|
eventRecords = try moc.fetch(fetch)
|
||
|
} catch let error {
|
||
|
DDLogError(error.localizedDescription)
|
||
|
}
|
||
|
|
||
|
var wifiOnly = true
|
||
|
if let lastSuccessNumber = moc.wmf_keyValue(forKey: Key.lastSuccessfulPost)?.value as? NSNumber {
|
||
|
let now = CFAbsoluteTimeGetCurrent()
|
||
|
let interval = now - CFAbsoluteTime(lastSuccessNumber.doubleValue)
|
||
|
if interval > self.sendOnWWANThreshold {
|
||
|
wifiOnly = false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if !eventRecords.isEmpty {
|
||
|
self.postEvents(eventRecords, onlyWiFi: wifiOnly, completion: {
|
||
|
operation.finish()
|
||
|
})
|
||
|
} else {
|
||
|
operation.finish()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
operationQueue.addOperation(operation)
|
||
|
guard let completion = completion else {
|
||
|
return
|
||
|
}
|
||
|
let completionBlockOp = BlockOperation(block: completion)
|
||
|
completionBlockOp.addDependency(operation)
|
||
|
operationQueue.addOperation(completion)
|
||
|
}
|
||
|
|
||
|
private func perform(_ block: @escaping (_ moc: NSManagedObjectContext) -> Void) {
|
||
|
let moc = self.managedObjectContext
|
||
|
moc.perform {
|
||
|
block(moc)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func performAndWait(_ block: (_ moc: NSManagedObjectContext) -> Void) {
|
||
|
let moc = self.managedObjectContext
|
||
|
moc.performAndWait {
|
||
|
block(moc)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func asyncSave() {
|
||
|
perform { (moc) in
|
||
|
self.save(moc)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func postEvents(_ eventRecords: [EventRecord], onlyWiFi: Bool, completion: @escaping () -> Void) {
|
||
|
DDLogDebug("EventLoggingService: Posting \(eventRecords.count) events!")
|
||
|
|
||
|
let taskGroup = WMFTaskGroup()
|
||
|
|
||
|
var completedRecordIDs = Set<NSManagedObjectID>()
|
||
|
var failedRecordIDs = Set<NSManagedObjectID>()
|
||
|
|
||
|
for record in eventRecords {
|
||
|
let moid = record.objectID
|
||
|
guard let payload = record.event else {
|
||
|
failedRecordIDs.insert(moid)
|
||
|
continue
|
||
|
}
|
||
|
taskGroup.enter()
|
||
|
let userAgent = record.userAgent ?? WikipediaAppUtils.versionedUserAgent()
|
||
|
submit(payload: payload, userAgent: userAgent, onlyWiFi: onlyWiFi) { (error) in
|
||
|
if let error = error {
|
||
|
if error != .network {
|
||
|
failedRecordIDs.insert(moid)
|
||
|
}
|
||
|
} else {
|
||
|
completedRecordIDs.insert(moid)
|
||
|
}
|
||
|
taskGroup.leave()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
taskGroup.waitInBackground {
|
||
|
self.perform { moc in
|
||
|
let postDate = NSDate()
|
||
|
for moid in completedRecordIDs {
|
||
|
let mo = try? self.managedObjectContext.existingObject(with: moid)
|
||
|
guard let record = mo as? EventRecord else {
|
||
|
continue
|
||
|
}
|
||
|
record.posted = postDate
|
||
|
}
|
||
|
|
||
|
for moid in failedRecordIDs {
|
||
|
let mo = try? self.managedObjectContext.existingObject(with: moid)
|
||
|
guard let record = mo as? EventRecord else {
|
||
|
continue
|
||
|
}
|
||
|
record.failed = true
|
||
|
}
|
||
|
if completedRecordIDs.count == eventRecords.count {
|
||
|
self.managedObjectContext.wmf_setValue(NSNumber(value: CFAbsoluteTimeGetCurrent()), forKey: Key.lastSuccessfulPost)
|
||
|
DDLogDebug("EventLoggingService: All records succeeded")
|
||
|
} else {
|
||
|
DDLogDebug("EventLoggingService: Some records failed")
|
||
|
}
|
||
|
self.save(moc)
|
||
|
completion()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func submit(payload: NSObject, userAgent: String, onlyWiFi: Bool, completion: @escaping (EventLoggingError?) -> Void) {
|
||
|
guard let url = Configuration.current.eventLoggingAPIURL(with: payload) else {
|
||
|
DDLogError("EventLoggingService: Could not create URL")
|
||
|
completion(EventLoggingError.generic)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var request = URLRequest(url: url)
|
||
|
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||
|
let session = onlyWiFi ? self.session.wifiOnlyURLSession : self.session.defaultURLSession
|
||
|
let task = session.dataTask(with: request, completionHandler: { (_, response, error) in
|
||
|
guard error == nil,
|
||
|
let httpResponse = response as? HTTPURLResponse,
|
||
|
httpResponse.statusCode / 100 == 2 else {
|
||
|
if let error = error as NSError?, error.domain == NSURLErrorDomain {
|
||
|
completion(EventLoggingError.network)
|
||
|
} else {
|
||
|
completion(EventLoggingError.generic)
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
completion(nil)
|
||
|
// DDLogDebug("EventLoggingService: event \(eventRecord.objectID) posted!")
|
||
|
})
|
||
|
task.resume()
|
||
|
}
|
||
|
|
||
|
// mark stored values
|
||
|
|
||
|
private func save(_ moc: NSManagedObjectContext) {
|
||
|
guard moc.hasChanges else {
|
||
|
return
|
||
|
}
|
||
|
do {
|
||
|
try moc.save()
|
||
|
} catch let error {
|
||
|
DDLogError("Error saving EventLoggingService managedObjectContext: \(error)")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private var semaphore = DispatchSemaphore(value: 1)
|
||
|
|
||
|
private var libraryValueCache: [String: NSCoding] = [:]
|
||
|
public func libraryValue(for key: String) -> NSCoding? {
|
||
|
semaphore.wait()
|
||
|
defer {
|
||
|
semaphore.signal()
|
||
|
}
|
||
|
var value = libraryValueCache[key]
|
||
|
if value != nil {
|
||
|
return value
|
||
|
}
|
||
|
|
||
|
performAndWait { moc in
|
||
|
value = managedObjectContext.wmf_keyValue(forKey: key)?.value
|
||
|
if value != nil {
|
||
|
libraryValueCache[key] = value
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if let legacyValue = UserDefaults.standard.object(forKey: key) as? NSCoding {
|
||
|
value = legacyValue
|
||
|
libraryValueCache[key] = legacyValue
|
||
|
managedObjectContext.wmf_setValue(legacyValue, forKey: key)
|
||
|
UserDefaults.standard.removeObject(forKey: key)
|
||
|
save(moc)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return value
|
||
|
}
|
||
|
|
||
|
public func setLibraryValue(_ value: NSCoding?, for key: String) {
|
||
|
semaphore.wait()
|
||
|
defer {
|
||
|
semaphore.signal()
|
||
|
}
|
||
|
libraryValueCache[key] = value
|
||
|
perform { moc in
|
||
|
self.managedObjectContext.wmf_setValue(value, forKey: key)
|
||
|
self.save(moc)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@objc public var isEnabled: Bool {
|
||
|
return UserDefaults.standard.wmf_sendUsageReports
|
||
|
}
|
||
|
|
||
|
@objc public var lastLoggedSnapshot: NSCoding? {
|
||
|
get {
|
||
|
return libraryValue(for: Key.lastLoggedSnapshot)
|
||
|
}
|
||
|
set {
|
||
|
setLibraryValue(newValue, for: Key.lastLoggedSnapshot)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@objc public var appInstallDate: Date? {
|
||
|
get {
|
||
|
var value = libraryValue(for: Key.appInstallDate) as? Date
|
||
|
if value == nil {
|
||
|
value = Date()
|
||
|
setLibraryValue(value as NSDate?, for: Key.appInstallDate)
|
||
|
}
|
||
|
return value
|
||
|
}
|
||
|
set {
|
||
|
setLibraryValue(newValue as NSDate?, for: Key.appInstallDate)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@objc public var loggedDaysInstalled: NSNumber? {
|
||
|
get {
|
||
|
return libraryValue(for: Key.loggedDaysInstalled) as? NSNumber
|
||
|
}
|
||
|
set {
|
||
|
setLibraryValue(newValue, for: Key.loggedDaysInstalled)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private var _sessionID: String?
|
||
|
@objc public var sessionID: String? {
|
||
|
semaphore.wait()
|
||
|
defer {
|
||
|
semaphore.signal()
|
||
|
}
|
||
|
if _sessionID == nil {
|
||
|
_sessionID = UUID().uuidString
|
||
|
}
|
||
|
return _sessionID
|
||
|
}
|
||
|
|
||
|
private var _sessionStartDate: Date?
|
||
|
@objc public var sessionStartDate: Date? {
|
||
|
semaphore.wait()
|
||
|
defer {
|
||
|
semaphore.signal()
|
||
|
}
|
||
|
if _sessionStartDate == nil {
|
||
|
_sessionStartDate = Date()
|
||
|
}
|
||
|
return _sessionStartDate
|
||
|
}
|
||
|
|
||
|
@objc public func resetSession() {
|
||
|
semaphore.wait()
|
||
|
defer {
|
||
|
semaphore.signal()
|
||
|
}
|
||
|
_sessionID = nil
|
||
|
_sessionStartDate = Date()
|
||
|
}
|
||
|
|
||
|
private func resetInstall() {
|
||
|
UserDefaults.standard.wmf_appInstallId = nil
|
||
|
lastLoggedSnapshot = nil
|
||
|
loggedDaysInstalled = nil
|
||
|
appInstallDate = nil
|
||
|
}
|
||
|
}
|
||
|
|
||
|
extension EventLoggingService: PeriodicWorker {
|
||
|
public func doPeriodicWork(_ completion: @escaping () -> Void) {
|
||
|
tryPostEvents(completion)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
extension EventLoggingService: BackgroundFetcher {
|
||
|
public func performBackgroundFetch(_ completion: @escaping (UIBackgroundFetchResult) -> Void) {
|
||
|
doPeriodicWork {
|
||
|
completion(.noData)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
extension EventLoggingService: ABTestsPersisting {
|
||
|
|
||
|
}
|