-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: Reduce memory usage for analytics service (#756)
* 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
Showing
15 changed files
with
677 additions
and
1,005 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
180 changes: 180 additions & 0 deletions
180
Debug App/Tests/Unit Tests/Analytics/AnalyticsServiceTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
63
Debug App/Tests/Unit Tests/Analytics/AnalyticsStorageTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.