Skip to content

Commit

Permalink
fix: Reduce memory usage for analytics service (#756)
Browse files Browse the repository at this point in the history
* Extract analytics max batch size to constant + reduce to 100

* Rework analytics service to allow automated batch sending and flushing

* Update analytics test code

* Initial test fixes + add or reintroduce promises where sensible

* Further refactor of service + tests

* Further refactoring. Separate analytics storage utils into separate class

* Tidy up storage interface

* Turn analytics service into instance singleton to allow better testing

* Implicit self fixes in analytics service

* Added new tests + further refactoring and improved analytics log messages

* Implicit self usage fixes

* Add analytics storage test

* Fix for race to delete file after sending analytics events to endpoint

* That was a lot more difficult than I expected

* Remove background dispatch for final callback

* Chain callback for event send from promise

* expectation per event, not just for remaining events

* expectation per event, not just for remaining events

* Better name for analytics file url + small test cleanup
  • Loading branch information
jnewc authored Dec 5, 2023
1 parent ce2944c commit 5e8c039
Show file tree
Hide file tree
Showing 15 changed files with 677 additions and 1,005 deletions.
12 changes: 12 additions & 0 deletions Debug App/Primer.io Debug App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
/* Begin PBXBuildFile section */
041295AA2AB9E25D00A4F243 /* MerchantHeadlessCheckoutNolPayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041295A92AB9E25D00A4F243 /* MerchantHeadlessCheckoutNolPayViewController.swift */; };
041295AD2AB9E2C100A4F243 /* PrimerHeadlessNolPayManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041295AB2AB9E2A900A4F243 /* PrimerHeadlessNolPayManagerTests.swift */; };
04769C152B1A680C0051581C /* Promises+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04769C142B1A680C0051581C /* Promises+Helper.swift */; };
049298012B1DD466002E04B8 /* AnalyticsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049298002B1DD466002E04B8 /* AnalyticsServiceTests.swift */; };
049298072B1E1F4D002E04B8 /* AnalyticsStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049298062B1E1F4D002E04B8 /* AnalyticsStorageTests.swift */; };
04DFAADC2AAA01E60030FECE /* Debug App Tests-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 04DFAADB2AAA01E60030FECE /* Debug App Tests-Info.plist */; };
04F6EF722AE69FC500115D05 /* AnalyticsTests+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F6EF712AE69FC500115D05 /* AnalyticsTests+Helpers.swift */; };
04F6EF742AE6A06200115D05 /* AnalyticsEventsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F6EF732AE6A06200115D05 /* AnalyticsEventsTests.swift */; };
Expand Down Expand Up @@ -138,6 +141,9 @@
021A00DEB01A46C876592575 /* ThreeDSProtocolVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreeDSProtocolVersionTests.swift; sourceTree = "<group>"; };
041295A92AB9E25D00A4F243 /* MerchantHeadlessCheckoutNolPayViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MerchantHeadlessCheckoutNolPayViewController.swift; sourceTree = "<group>"; };
041295AB2AB9E2A900A4F243 /* PrimerHeadlessNolPayManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrimerHeadlessNolPayManagerTests.swift; sourceTree = "<group>"; };
04769C142B1A680C0051581C /* Promises+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promises+Helper.swift"; sourceTree = "<group>"; };
049298002B1DD466002E04B8 /* AnalyticsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsServiceTests.swift; sourceTree = "<group>"; };
049298062B1E1F4D002E04B8 /* AnalyticsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStorageTests.swift; sourceTree = "<group>"; };
04DFAADB2AAA01E60030FECE /* Debug App Tests-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Debug App Tests-Info.plist"; sourceTree = "<group>"; };
04F6EF712AE69FC500115D05 /* AnalyticsTests+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnalyticsTests+Helpers.swift"; sourceTree = "<group>"; };
04F6EF732AE6A06200115D05 /* AnalyticsEventsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -463,6 +469,7 @@
children = (
D3D9154BF1E011FED6799CD5 /* CardData.swift */,
D038BDB23C062D362AAA09BE /* Networking.swift */,
04769C142B1A680C0051581C /* Promises+Helper.swift */,
);
path = Helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -581,6 +588,8 @@
E90441E821B5FE76643B62A6 /* AnalyticsTests+Constants.swift */,
04F6EF712AE69FC500115D05 /* AnalyticsTests+Helpers.swift */,
04F6EF732AE6A06200115D05 /* AnalyticsEventsTests.swift */,
049298002B1DD466002E04B8 /* AnalyticsServiceTests.swift */,
049298062B1E1F4D002E04B8 /* AnalyticsStorageTests.swift */,
);
path = Analytics;
sourceTree = "<group>";
Expand Down Expand Up @@ -835,6 +844,8 @@
8064B65A8F83D5B004D081FD /* PaymentMethodConfigTests.swift in Sources */,
4BD7794627267B40E9E10686 /* PrimerCheckoutTheme.swift in Sources */,
F99DAF50E86E6F8CCD127E5B /* ThemeTests.swift in Sources */,
04769C152B1A680C0051581C /* Promises+Helper.swift in Sources */,
049298072B1E1F4D002E04B8 /* AnalyticsStorageTests.swift in Sources */,
24C060A48D4A2670FFC3426F /* ThreeDSErrorTests.swift in Sources */,
F1A71C2E0D900FEB9AF1351C /* ThreeDSProtocolVersionTests.swift in Sources */,
04FAF9EC2AE7B33E002E4BAE /* StringExtensionTests.swift in Sources */,
Expand All @@ -856,6 +867,7 @@
208CA849F3187C2DA63CC17B /* HUC_TokenizationViewModelTests.swift in Sources */,
213196DEDF2A3A84037ED884 /* PollingModuleTests.swift in Sources */,
04F6EF742AE6A06200115D05 /* AnalyticsEventsTests.swift in Sources */,
049298012B1DD466002E04B8 /* AnalyticsServiceTests.swift in Sources */,
1589385B62C86DE7C735F3EC /* PrimerAPIConfigurationModuleTests.swift in Sources */,
A39750255D4C33527A3766A0 /* UserInterfaceModuleTests.swift in Sources */,
3EE90DEB1DB7BE9270FB17BF /* URLSessionStackTests.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion Debug App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
customizeAppearance()
PrimerLogging.shared.logger = DefaultLogger(logLevel: .info)
PrimerLogging.shared.logger = DefaultLogger(logLevel: .debug)
return true
}

Expand Down
180 changes: 180 additions & 0 deletions Debug App/Tests/Unit Tests/Analytics/AnalyticsServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//
// AnalyticsServiceTests.swift
// Debug App Tests
//
// Created by Jack Newcombe on 04/12/2023.
// Copyright © 2023 Primer API Ltd. All rights reserved.
//

import XCTest
@testable import PrimerSDK

final class AnalyticsServiceTests: XCTestCase {

var apiClient: MockPrimerAPIAnalyticsClient!

var storage: Analytics.Storage!

var service: Analytics.Service!

override func setUpWithError() throws {
apiClient = MockPrimerAPIAnalyticsClient()
storage = MockAnalyticsStorage()
service = Analytics.Service(sdkLogsUrl: URL(string: "http://localhost/")!,
batchSize: 5,
storage: storage,
apiClient: apiClient)
}

override func tearDownWithError() throws {
service = nil
storage = nil
apiClient = nil
}

func testSimpleBatchSend() throws {

// Setup API Client

let expectation = self.expectation(description: "Batch of five events are sent")

apiClient.onSendAnalyticsEvent = { events in
XCTAssertNotNil(events)
XCTAssertEqual(events?.count, 5)

let messages = events!.enumerated().compactMap { (index, event) in
return (event.properties as? MessageEventProperties)?.message
}.sorted()
XCTAssertEqual(messages, ["Test #1", "Test #2", "Test #3", "Test #4", "Test #5"])
expectation.fulfill()
}

// Send Events

sendEvents(numberOfEvents: 5)

waitForExpectations(timeout: 1.0)
}

func testComplexMultiBatchFastSend() throws {

let expectation = self.expectation(description: "Called expected number of times")
expectation.expectedFulfillmentCount = 5

apiClient.onSendAnalyticsEvent = { _ in
expectation.fulfill()
}

(0..<5).forEach { _ in
sendEvents(numberOfEvents: 5, delay: 0.1)
}
sendEvents(numberOfEvents: 4, delay: 0.5)

waitForExpectations(timeout: 15.0)

XCTAssertEqual(apiClient.batches.count, 5)
XCTAssertEqual(apiClient.batches.joined().count, 25)
XCTAssertEqual(storage.loadEvents().count, 4)
}

func testComplexMultiBatchSlowSend() throws {

let expectation = self.expectation(description: "Called expected number of times")
expectation.expectedFulfillmentCount = 3

apiClient.onSendAnalyticsEvent = { _ in
expectation.fulfill()
}

(0..<3).forEach { _ in
sendEvents(numberOfEvents: 5, delay: 0.5)
}
sendEvents(numberOfEvents: 4, delay: 0.5)

waitForExpectations(timeout: 15.0)

XCTAssertEqual(apiClient.batches.count, 3)
XCTAssertEqual(apiClient.batches.joined().count, 15)
XCTAssertEqual(storage.loadEvents().count, 4)
}

// MARK: Helpers

static func createQueue() -> DispatchQueue {
DispatchQueue(label: "AnalyticsServiceTestsQueue-\(UUID().uuidString)", qos: .background, attributes: .concurrent)
}

func sendEvents(numberOfEvents: Int,
delay: TimeInterval? = nil,
onQueue queue: DispatchQueue = AnalyticsServiceTests.createQueue()) {
let events = (0..<numberOfEvents).map { num in messageEvent(withMessage: "Test #\(num + 1)") }
events.forEach { (event: Analytics.Event) in
let expectEventToRecord = self.expectation(description: "event is recorded - \(event.localId)")
let _callback = {
_ = self.service.record(event: event).ensure {
expectEventToRecord.fulfill()
}
}
if let delay = delay {
queue.asyncAfter(deadline: .now() + delay, execute: _callback)
} else {
queue.async(execute: _callback)
}
}
}

func messageEvent(withMessage message: String) -> Analytics.Event {
Analytics.Event(eventType: .message, properties: MessageEventProperties(message: message, messageType: .other, severity: .info))
}
}

class MockAnalyticsStorage: Analytics.Storage {

var events: [Analytics.Event] = []

func loadEvents() -> [Analytics.Event] {
return events
}

func save(_ events: [Analytics.Event]) throws {
self.events = events
}

func delete(_ eventsToDelete: [Analytics.Event]?) {
guard let eventsToDelete = eventsToDelete else { return }
let idsToDelete = eventsToDelete.map { $0.localId }
print(">>>> Delete events (before): \(self.events.count)")
self.events = self.events.filter { event in
!idsToDelete.contains(event.localId)
}

print(">>>> Delete events (after): \(self.events.count)")
}

func deleteAnalyticsFile() {
events = []
}
}

class MockPrimerAPIAnalyticsClient: PrimerAPIClientAnalyticsProtocol {

var shouldSucceed: Bool = true

var onSendAnalyticsEvent: (([PrimerSDK.Analytics.Event]?) -> Void)?

var batches: [[Analytics.Event]] = []

func sendAnalyticsEvents(clientToken: DecodedJWTToken?, url: URL, body: [Analytics.Event]?, completion: @escaping ResponseHandler) {
guard let body = body else {
XCTFail(); return
}
print(">>>>> Received batch of: \(body.count)")
batches.append(body)
if shouldSucceed {
completion(.success(.init(id: nil, result: nil)))
} else {
completion(.failure(PrimerError.generic(message: "", userInfo: nil, diagnosticsId: "")))
}
self.onSendAnalyticsEvent?(body)
}
}
63 changes: 63 additions & 0 deletions Debug App/Tests/Unit Tests/Analytics/AnalyticsStorageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// AnalyticsStorageTests.swift
// Debug App Tests
//
// Created by Jack Newcombe on 04/12/2023.
// Copyright © 2023 Primer API Ltd. All rights reserved.
//

import XCTest
@testable import PrimerSDK

final class AnalyticsStorageTests: XCTestCase {

var storage: Analytics.Storage!

let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("analytics")

let events = [
Analytics.Event(eventType: .message, properties: MessageEventProperties(message: "Test #1", messageType: .other, severity: .info)),
Analytics.Event(eventType: .message, properties: MessageEventProperties(message: "Test #2", messageType: .other, severity: .info)),
Analytics.Event(eventType: .message, properties: MessageEventProperties(message: "Test #3", messageType: .other, severity: .info)),
]

override func setUpWithError() throws {
storage = Analytics.DefaultStorage(fileURL: url)
}

override func tearDownWithError() throws {
storage = nil
}

func testSaveLoadDelete() throws {
try storage.save(events)

let loadedEvents = storage.loadEvents()
XCTAssertEqual(Set(events), Set(loadedEvents))

storage.delete(events)

let reloadedEvents = storage.loadEvents()

XCTAssert(reloadedEvents.isEmpty)
}

func testDeleteFile() throws {
try storage.save(events)

let loadedEvents = storage.loadEvents()
XCTAssertEqual(Set(events), Set(loadedEvents))

XCTAssert(FileManager.default.fileExists(atPath: url.path))

storage.deleteAnalyticsFile()

XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
}

extension Analytics.Event: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(localId)
}
}
Loading

0 comments on commit 5e8c039

Please sign in to comment.