Skip to content

Commit

Permalink
Add event logging upload (#127)
Browse files Browse the repository at this point in the history
  • Loading branch information
jkmassel authored Mar 30, 2020
1 parent 6add300 commit c7bfcc6
Show file tree
Hide file tree
Showing 18 changed files with 711 additions and 7 deletions.
2 changes: 1 addition & 1 deletion Automattic-Tracks-iOS.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Pod::Spec.new do |spec|
spec.authors = { 'Automattic' => '[email protected]' }
spec.summary = 'Simple way to track events in an iOS app with Automattic Tracks internal service'
spec.source = { :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :tag => spec.version.to_s }
spec.swift_version = '4.2'
spec.swift_version = '5.0'

spec.ios.source_files = 'Automattic-Tracks-iOS/**/*.{h,m,swift}'
spec.ios.exclude_files = 'Automattic-Tracks-OSX/Automattic_Tracks_OSX.h'
Expand Down
90 changes: 84 additions & 6 deletions Automattic-Tracks-iOS.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions Automattic-Tracks-iOS/Event Logging/EventLoggingDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

public protocol EventLoggingDataSource {
/// A base-64 encoded representation of the encryption key.
var loggingEncryptionKey: String { get }

/// The URL to the upload endpoint for encrypted logs.
var logUploadURL: URL { get }

/// The path to the log file for the most recent session.
var previousSessionLogPath: URL? { get }
}

public extension EventLoggingDataSource {
// The default implementation points to the WP.com private encrypted logging API
var logUploadURL: URL {
return URL(string: "https://public-api.wordpress.com/rest/v1.1/encrypted-logging")!
}
}
35 changes: 35 additions & 0 deletions Automattic-Tracks-iOS/Event Logging/EventLoggingDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

public protocol EventLoggingDelegate {
/// The event logging system will call this delegate property prior to attempting to upload, giving the application a chance to determine
/// whether or not the upload should proceed. If this is not overridden, the default is `false`.
var shouldUploadLogFiles: Bool { get }

/// The event logging system will call this delegate method each time a log file starts uploading.
func didStartUploadingLog(_ log: LogFile)

/// The event logging system will call this delegate method if a log file upload is cancelled by the delegate.
func uploadCancelledByDelegate(_ log: LogFile)

/// The event logging system will call this delegate method if a log file fails to upload.
/// It may be called prior to upload starting if the file is missing, and is called prior to the `upload` callback.
func uploadFailed(withError: Error, forLog: LogFile)

/// The event logging system will call this delegate method each time a log file finishes uploading.
/// It is called prior to the `upload` callback.
func didFinishUploadingLog(_ log: LogFile)
}

/// Default implementations for EventLoggingDelegate
public extension EventLoggingDelegate {

var shouldUploadLogFiles: Bool {
return false // Use a privacy-preserving default
}

// Empty default implementations allow the developer to only implement these if they need them
func didStartUploadingLog(_ log: LogFile) {}
func uploadCancelledByDelegate(_ log: LogFile) {}
func uploadFailed(withError error: Error, forLog log: LogFile) {}
func didFinishUploadingLog(_ log: LogFile) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import CocoaLumberjack
import Sodium

class EventLoggingUploadManager {

private enum Constants {
static let uuidHeaderKey = "log-uuid"
static let uploadHttpMethod = "POST"
}

private let dataSource: EventLoggingDataSource
private let delegate: EventLoggingDelegate
private let networkService: EventLoggingNetworkService
private let fileManager: FileManager

typealias LogUploadCallback = (Result<Void, Error>) -> Void

init(dataSource: EventLoggingDataSource,
delegate: EventLoggingDelegate,
networkService: EventLoggingNetworkService = EventLoggingNetworkService(),
fileManager: FileManager = FileManager.default
) {
self.dataSource = dataSource
self.delegate = delegate
self.networkService = networkService
self.fileManager = fileManager
}

func upload(_ log: LogFile, then callback: @escaping LogUploadCallback) {
guard delegate.shouldUploadLogFiles else {
delegate.uploadCancelledByDelegate(log)
return
}

guard let fileContents = fileManager.contents(atUrl: log.url) else {
delegate.uploadFailed(withError: EventLoggingFileUploadError.fileMissing, forLog: log)
return
}

var request = URLRequest(url: dataSource.logUploadURL)
request.addValue(log.uuid, forHTTPHeaderField: Constants.uuidHeaderKey)
request.httpMethod = Constants.uploadHttpMethod
request.httpBody = fileContents

delegate.didStartUploadingLog(log)

networkService.uploadFile(request: request, fileURL: log.url) { result in
switch result {
case .success:
self.delegate.didFinishUploadingLog(log)
callback(.success(()))
case .failure(let error):
self.delegate.uploadFailed(withError: error, forLog: log)
callback(.failure(error))
}
}
}
}
27 changes: 27 additions & 0 deletions Automattic-Tracks-iOS/Extensions/FileManager+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

extension FileManager {

var documentsDirectory: URL {
let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
return URL(fileURLWithPath: documentsDirectory, isDirectory: true)
}

func contentsOfDirectory(at url: URL) throws -> [URL] {
return try self.contentsOfDirectory(atPath: url.path).map { url.appendingPathComponent($0) }
}

func contents(atUrl url: URL) -> Data? {
return self.contents(atPath: url.path)
}

func fileExistsAtURL(_ url: URL) -> Bool {
return self.fileExists(atPath: url.path)
}

func directoryExistsAtURL(_ url: URL) -> Bool {
var isDir: ObjCBool = false
let exists = self.fileExists(atPath: url.path, isDirectory: &isDir)
return exists && isDir.boolValue
}
}
15 changes: 15 additions & 0 deletions Automattic-Tracks-iOS/Model/EventLoggingFileUploadError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

public enum EventLoggingFileUploadError: Error, LocalizedError {
case httpError(String)
case fileMissing

public var errorDescription: String? {
switch self {
case .httpError(let message): return message
case .fileMissing: return NSLocalizedString(
"File not found", comment: "A message indicating that a file queued for upload could not be found"
)
}
}
}
8 changes: 8 additions & 0 deletions Automattic-Tracks-iOS/Model/LogFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ public struct LogFile {
self.url = url
self.uuid = uuid
}

var fileName: String {
return uuid
}

static func fromExistingFile(at url: URL) -> LogFile {
return LogFile(url: url, uuid: url.lastPathComponent)
}
}
35 changes: 35 additions & 0 deletions Automattic-Tracks-iOS/Services/EventLoggingNetworkService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

class EventLoggingNetworkService {

typealias ResultCallback = (Result<Data?, Error>) -> Void

private let urlSession: URLSession

init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
}

func uploadFile(request: URLRequest, fileURL: URL, completion: @escaping ResultCallback) {
urlSession.uploadTask(with: request, fromFile: fileURL, completionHandler: { data, response, error in

if let error = error {
completion(.failure(error))
return
}

/// The `response` should *always* be an HTTPURLResponse.
/// Fail fast by force-unwrapping – this will cause a crash and will bring the issue to our attention if something has changed.
let statusCode = (response as! HTTPURLResponse).statusCode

/// Generate a reasonable error message based on the HTTP status
if !(200 ... 299).contains(statusCode) {
let errorMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode)
completion(.failure(EventLoggingFileUploadError.httpError(errorMessage)))
return
}

completion(.success(data))
}).resume()
}
}
40 changes: 40 additions & 0 deletions Automattic-Tracks-iOS/Services/EventLoggingUploadQueue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

class EventLoggingUploadQueue {

private let fileManager: FileManager
var storageDirectory: URL

init(storageDirectory: URL? = nil, fileManager: FileManager = FileManager.default) {
let defaultStorageDirectory = fileManager.documentsDirectory.appendingPathComponent("log-upload-queue")
self.storageDirectory = storageDirectory ?? defaultStorageDirectory
self.fileManager = fileManager
}

/// The log file on top of the queue
var first: LogFile? {
guard let url = try? fileManager.contentsOfDirectory(at: storageDirectory).first else {
return nil
}

return LogFile.fromExistingFile(at: url)
}

func add(_ log: LogFile) throws {
try createStorageDirectoryIfNeeded()
try fileManager.copyItem(at: log.url, to: storageDirectory.appendingPathComponent(log.fileName))
}

func remove(_ log: LogFile) throws {
let url = storageDirectory.appendingPathComponent(log.fileName)
if fileManager.fileExistsAtURL(url) {
try fileManager.removeItem(at: url)
}
}

func createStorageDirectoryIfNeeded() throws {
if !fileManager.directoryExistsAtURL(storageDirectory) {
try fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true, attributes: nil)
}
}
}
9 changes: 9 additions & 0 deletions Automattic-Tracks-iOSTests/Test Helpers/Extensions.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Sodium
import XCTest

extension String {
static func randomString(length: Int) -> String {
Expand All @@ -26,3 +27,11 @@ extension FileManager {
return fileURL
}
}

extension XCTestCase {
func waitForExpectation(timeout: TimeInterval, _ block: (XCTestExpectation) -> ()) {
let exp = XCTestExpectation()
block(exp)
wait(for: [exp], timeout: timeout)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ extension LogFile {
static func containingRandomString(length: Int = 128) -> LogFile {
return LogFile(containing: String.randomString(length: length))
}

static func withInvalidPath() -> LogFile {
return LogFile(url: URL(fileURLWithPath: "invalid"))
}
}
107 changes: 107 additions & 0 deletions Automattic-Tracks-iOSTests/Test Helpers/Mocks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation
import Sodium
@testable import AutomatticTracks

typealias LogFileCallback = (LogFile) -> ()
typealias ErrorWithLogFileCallback = (Error, LogFile) -> ()

enum MockError: Error {
case generic
}

class MockEventLoggingDataSource: EventLoggingDataSource {

var loggingEncryptionKey: String = "foo"
var previousSessionLogPath: URL? = nil

/// Overrides for logUploadURL
var _logUploadURL: URL = URL(string: "example.com")!
var logUploadURL: URL {
return _logUploadURL
}

func setLogUploadUrl(_ url: URL) {
self._logUploadURL = url
}

func withEncryptionKeys() -> Self {
let keyPair = Sodium().box.keyPair()!
loggingEncryptionKey = Data(keyPair.publicKey).base64EncodedString()
return self
}
}

class MockEventLoggingDelegate: EventLoggingDelegate {

var didStartUploadingTriggered = false
var didStartUploadingCallback: LogFileCallback?

func didStartUploadingLog(_ log: LogFile) {
didStartUploadingTriggered = true
didStartUploadingCallback?(log)
}

var didFinishUploadingTriggered = false
var didFinishUploadingCallback: LogFileCallback?

func didFinishUploadingLog(_ log: LogFile) {
didFinishUploadingTriggered = true
didFinishUploadingCallback?(log)
}

var uploadCancelledByDelegateTriggered = false
var uploadCancelledByDelegateCallback: LogFileCallback?

func uploadCancelledByDelegate(_ log: LogFile) {
uploadCancelledByDelegateTriggered = true
uploadCancelledByDelegateCallback?(log)
}

var uploadFailedTriggered = false
var uploadFailedCallback: ErrorWithLogFileCallback?

func uploadFailed(withError error: Error, forLog log: LogFile) {
uploadFailedTriggered = true
uploadFailedCallback?(error, log)
}

func setShouldUploadLogFiles(_ newValue: Bool) {
_shouldUploadLogFiles = newValue
}

private var _shouldUploadLogFiles: Bool = true
var shouldUploadLogFiles: Bool {
return _shouldUploadLogFiles
}
}

class MockEventLoggingNetworkService: EventLoggingNetworkService {
var shouldSucceed = true

override func uploadFile(request: URLRequest, fileURL: URL, completion: @escaping EventLoggingNetworkService.ResultCallback) {
shouldSucceed ? completion(.success(Data())) : completion(.failure(MockError.generic))
}
}

class MockEventLoggingUploadQueue: EventLoggingUploadQueue {

typealias LogFileCallback = (LogFile) -> ()

var queue = [LogFile]()

var addCallback: LogFileCallback?
override func add(_ log: LogFile) {
self.addCallback?(log)
self.queue.append(log)
}

var removeCallback: LogFileCallback?
override func remove(_ log: LogFile) {
self.removeCallback?(log)
self.queue.removeAll(where: { $0.uuid == log.uuid })
}

override var first: LogFile? {
return self.queue.first
}
}
Loading

0 comments on commit c7bfcc6

Please sign in to comment.