diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f4a2f98a..0cc6e730e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,15 @@ # Braintree iOS SDK Release Notes ## unreleased -* Send `paypal_context_id` in `batch_params` to PayPal's analytics service (FPTI) when available * BraintreeVenmo * Add `isFinalAmount` to `BTVenmoRequest` * Add `BTVenmoRequest.fallbackToWeb` * If set to `true` customers will fallback to a web based Venmo flow if the Venmo app is not installed * This method uses Universal Links instead of URL Schemes +* BraintreeCore + * Send `paypal_context_id` in `batch_params` to PayPal's analytics service (FPTI) when available * Send `link_type` in `event_params` to PayPal's analytics service (FPTI) + * Fix bug where FPTI analytic events were being sent multiple times ## 6.12.0 (2024-01-18) * BraintreePayPal diff --git a/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift b/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift index 98e3457d19..6006c495ed 100644 --- a/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift +++ b/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift @@ -1,33 +1,11 @@ import Foundation -/// Encapsulates analytics events for a given session -struct BTAnalyticsSession { - - let sessionID: String - var events: [FPTIBatchData.Event] = [] - - init(with sessionID: String) { - self.sessionID = sessionID - } -} - class BTAnalyticsService: Equatable { // MARK: - Internal Properties - /// A serial dispatch queue that synchronizes access to `analyticsSessions` - let sessionsQueue: DispatchQueue = DispatchQueue(label: "com.braintreepayments.BTAnalyticsService") - /// The HTTP client for communication with the analytics service endpoint. Exposed for testing. var http: BTHTTP? - - /// Defaults to 1, can be overridden - var flushThreshold: Int - - /// Dictionary of analytics sessions, keyed by session ID. The analytics service requires that batched events - /// are sent from only one session. In practice, BTAPIClient.metadata.sessionID should never change, so this - /// is defensive. - var analyticsSessions: [String: BTAnalyticsSession] = [:] /// The FPTI URL to post all analytic events. static let url = URL(string: "https://api-m.paypal.com")! @@ -38,18 +16,19 @@ class BTAnalyticsService: Equatable { // MARK: - Initializer - init(apiClient: BTAPIClient, flushThreshold: Int = 1) { + init(apiClient: BTAPIClient) { self.apiClient = apiClient - self.flushThreshold = flushThreshold } // MARK: - Internal Methods - - /// Tracks an event. - /// - /// Events are queued and sent in batches to the analytics service, based on the status of the app and - /// the number of queued events. After exiting this method, there is no guarantee that the event has been sent. - /// - Parameter eventName: String representing the event name + + /// Sends analytics event to https://api.paypal.com/v1/tracking/batch/events/ via a background task. + /// - Parameters: + /// - eventName: Name of analytic event. + /// - errorDescription: Optional. Full error description returned to merchant. + /// - correlationID: Optional. CorrelationID associated with the checkout session. + /// - payPalContextID: Optional. PayPal Context ID associated with the checkout session. + /// - linkType: Optional. The type of link the SDK will be handling, currently deeplink or universal. func sendAnalyticsEvent( _ eventName: String, errorDescription: String? = nil, @@ -57,46 +36,38 @@ class BTAnalyticsService: Equatable { payPalContextID: String? = nil, linkType: String? = nil ) { - DispatchQueue.main.async { - self.payPalContextID = payPalContextID - self.enqueueEvent( + Task(priority: .background) { + await performEventRequest( eventName, errorDescription: errorDescription, correlationID: correlationID, - linkType: linkType + linkType: linkType, + payPalContextID: payPalContextID ) - self.flushIfAtThreshold() } } - - /// Sends request to FPTI immediately, without checking number of events in queue against flush threshold - func sendAnalyticsEvent( + + /// Exposed to be able to execute this function synchronously in unit tests + func performEventRequest( _ eventName: String, errorDescription: String? = nil, correlationID: String? = nil, - payPalContextID: String? = nil, linkType: String? = nil, - completion: @escaping (Error?) -> Void = { _ in } - ) { - DispatchQueue.main.async { - self.payPalContextID = payPalContextID - self.enqueueEvent( - eventName, - errorDescription: errorDescription, - correlationID: correlationID, - linkType: linkType - ) - self.flush(completion) - } - } - - /// Executes API request to FPTI - func flush(_ completion: @escaping (Error?) -> Void = { _ in }) { + payPalContextID: String? = nil + ) async { + self.payPalContextID = payPalContextID + + let timestampInMilliseconds = UInt64(Date().timeIntervalSince1970 * 1000) + let event = FPTIBatchData.Event( + correlationID: correlationID, + errorDescription: errorDescription, + eventName: eventName, + linkType: linkType, + timestamp: String(timestampInMilliseconds) + ) + apiClient.fetchOrReturnRemoteConfiguration { configuration, error in guard let configuration, error == nil else { - if let error { - completion(error) - } return } @@ -107,81 +78,24 @@ class BTAnalyticsService: Equatable { } else if let tokenizationKey = self.apiClient.tokenizationKey { self.http = BTHTTP(url: BTAnalyticsService.url, tokenizationKey: tokenizationKey) } else { - completion(BTAnalyticsServiceError.invalidAPIClient) return } } // A special value passed in by unit tests to prevent BTHTTP from actually posting if let http = self.http, http.baseURL.absoluteString == "test://do-not-send.url" { - completion(nil) return } - self.sessionsQueue.async { - if self.analyticsSessions.count == 0 { - completion(nil) - return - } - - self.analyticsSessions.keys.forEach { sessionID in - let postParameters = self.createAnalyticsEvent(config: configuration, sessionID: sessionID) - self.http?.post("v1/tracking/batch/events", parameters: postParameters) { body, response, error in - if let error { - completion(error) - } - } - } - completion(nil) - } + let postParameters = self.createAnalyticsEvent(config: configuration, sessionID: self.apiClient.metadata.sessionID, event: event) + self.http?.post("v1/tracking/batch/events", parameters: postParameters) { _, _, _ in } } } // MARK: - Helpers - /// Adds an event to the queue - func enqueueEvent( - _ eventName: String, - errorDescription: String?, - correlationID: String?, - linkType: String? - ) { - let timestampInMilliseconds = UInt64(Date().timeIntervalSince1970 * 1000) - let event = FPTIBatchData.Event( - correlationID: correlationID, - errorDescription: errorDescription, - eventName: eventName, - linkType: linkType, - timestamp: String(timestampInMilliseconds) - ) - let session = BTAnalyticsSession(with: apiClient.metadata.sessionID) - - sessionsQueue.async { - if self.analyticsSessions[session.sessionID] == nil { - self.analyticsSessions[session.sessionID] = session - } - - self.analyticsSessions[session.sessionID]?.events.append(event) - } - } - - /// Checks queued event count to determine if ready to fire API request - func flushIfAtThreshold() { - var eventCount = 0 - - sessionsQueue.sync { - analyticsSessions.values.forEach { analyticsSession in - eventCount += analyticsSession.events.count - } - } - - if eventCount >= flushThreshold { - flush() - } - } - - /// Constructs POST params to be sent to FPTI from the queued events in the session - func createAnalyticsEvent(config: BTConfiguration, sessionID: String) -> Codable { + /// Constructs POST params to be sent to FPTI + func createAnalyticsEvent(config: BTConfiguration, sessionID: String, event: FPTIBatchData.Event) -> Codable { let batchMetadata = FPTIBatchData.Metadata( authorizationFingerprint: apiClient.clientToken?.authorizationFingerprint, environment: config.fptiEnvironment, @@ -192,14 +106,12 @@ class BTAnalyticsService: Equatable { tokenizationKey: apiClient.tokenizationKey ) - let session = self.analyticsSessions[sessionID] - - return FPTIBatchData(metadata: batchMetadata, events: session?.events) + return FPTIBatchData(metadata: batchMetadata, events: [event]) } // MARK: Equitable Protocol Conformance static func == (lhs: BTAnalyticsService, rhs: BTAnalyticsService) -> Bool { - lhs.http == rhs.http && lhs.flushThreshold == rhs.flushThreshold && lhs.apiClient == rhs.apiClient + lhs.http == rhs.http && lhs.apiClient == rhs.apiClient } } diff --git a/Sources/BraintreeCore/BTAPIClient.swift b/Sources/BraintreeCore/BTAPIClient.swift index 4afbb0139d..3951cde025 100644 --- a/Sources/BraintreeCore/BTAPIClient.swift +++ b/Sources/BraintreeCore/BTAPIClient.swift @@ -65,7 +65,7 @@ import Foundation self.metadata = BTClientMetadata() super.init() - BTAPIClient._analyticsService = BTAnalyticsService(apiClient: self, flushThreshold: 5) + BTAPIClient._analyticsService = BTAnalyticsService(apiClient: self) guard let authorizationType: BTAPIClientAuthorization = Self.authorizationType(forAuthorization: authorization) else { return nil } let errorString = BTLogLevelDescription.string(for: .error) @@ -351,8 +351,7 @@ import Foundation errorDescription: errorDescription, correlationID: correlationID, payPalContextID: payPalContextID, - linkType: linkType, - completion: { _ in } + linkType: linkType ) } diff --git a/UnitTests/BraintreeCoreTests/Analytics/BTAnalyticsService_Tests.swift b/UnitTests/BraintreeCoreTests/Analytics/BTAnalyticsService_Tests.swift index 4f9c94697f..3d65c81e1f 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/BTAnalyticsService_Tests.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/BTAnalyticsService_Tests.swift @@ -13,223 +13,35 @@ final class BTAnalyticsService_Tests: XCTestCase { oneSecondLater = UInt64((Date().timeIntervalSince1970 * 1000) + 999) } - func testSendAnalyticsEvent_whenConfigFetchCompletes_setsUpAnalyticsHTTPToUseBaseURL() { + func testSendAnalyticsEvent_whenConfigFetchCompletes_setsUpAnalyticsHTTPToUseBaseURL() async { let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - let expectation = expectation(description: "Sends analytics event") - analyticsService.sendAnalyticsEvent("any.analytics.event") { error in - XCTAssertNil(error) - XCTAssertEqual(analyticsService.http?.baseURL.absoluteString, "https://api-m.paypal.com") - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - } - - func testSendAnalyticsEvent_whenNumberOfQueuedEventsMeetsThreshold_sendsAnalyticsEvent() { - let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") - let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() - let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - analyticsService.flushThreshold = 1 - analyticsService.http = mockAnalyticsHTTP - - let expectation = expectation(description: "Sends analytics event") - analyticsService.sendAnalyticsEvent("any.analytics.event") { error in - expectation.fulfill() - } + await analyticsService.performEventRequest("any.analytics.event") - waitForExpectations(timeout: 1) { error in - // Assertions - XCTAssertEqual(mockAnalyticsHTTP.lastRequestEndpoint, "v1/tracking/batch/events") - - let timestamp = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters)! - let eventName = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters) - XCTAssertEqual(eventName!, "any.analytics.event") - XCTAssertGreaterThanOrEqual(timestamp, self.currentTime) - XCTAssertLessThanOrEqual(timestamp, self.oneSecondLater) - self.validateMetadataParameters(mockAnalyticsHTTP.lastRequestParameters) - } + XCTAssertEqual(analyticsService.http?.baseURL.absoluteString, "https://api-m.paypal.com") } - func testSendAnalyticsEvent_whenFlushThresholdIsGreaterThanNumberOfBatchedEvents_doesNotSendAnalyticsEvent() { + func testSendAnalyticsEvent_sendsAnalyticsEvent() async { let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - analyticsService.flushThreshold = 2 analyticsService.http = mockAnalyticsHTTP - - let expectation = expectation(description: "Sends analytics event") - analyticsService.sendAnalyticsEvent("any.analytics.event") { error in - expectation.fulfill() - } + await analyticsService.performEventRequest("any.analytics.event") - waitForExpectations(timeout: 1) { error in - XCTAssertEqual(mockAnalyticsHTTP.POSTRequestCount, 1) - } - } - - func testSendAnalyticsEventCompletion_whenCalled_sendsAllEvents() { - let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") - let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() - let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - analyticsService.flushThreshold = 5 - analyticsService.http = mockAnalyticsHTTP - - let expectation = expectation(description: "Sends batched request") - - analyticsService.sendAnalyticsEvent("an.analytics.event") - analyticsService.sendAnalyticsEvent("another.analytics.event") { error in - XCTAssertNil(error) - XCTAssertEqual(mockAnalyticsHTTP.POSTRequestCount, 1) - XCTAssertEqual(mockAnalyticsHTTP.lastRequestEndpoint, "v1/tracking/batch/events") - - let timestampOne = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters, at: 0)! - let timestampTwo = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters, at: 1)! - - let eventOne = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters, at: 0) - XCTAssertEqual(eventOne, "an.analytics.event") - XCTAssertGreaterThanOrEqual(timestampOne, self.currentTime) - XCTAssertLessThanOrEqual(timestampOne, self.oneSecondLater) - - let eventTwo = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters, at: 1) - XCTAssertEqual(eventTwo, "another.analytics.event") - XCTAssertGreaterThanOrEqual(timestampTwo, self.currentTime) - XCTAssertLessThanOrEqual(timestampTwo, self.oneSecondLater) - self.validateMetadataParameters(mockAnalyticsHTTP.lastRequestParameters) - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - } - - func testFlush_whenCalled_sendsAllQueuedEvents() { - let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") - let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() - let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - analyticsService.flushThreshold = 5 - analyticsService.http = mockAnalyticsHTTP - - analyticsService.sendAnalyticsEvent("an.analytics.event") - analyticsService.sendAnalyticsEvent("another.analytics.event") - - // Pause briefly to allow analytics service to dispatch async blocks - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - - let expectation = expectation(description: "Sends batched request") - - analyticsService.flush() { error in - XCTAssertNil(error) - XCTAssertEqual(mockAnalyticsHTTP.POSTRequestCount, 1) - - let timestampOne = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters, at: 0)! - let timestampTwo = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters, at: 1)! - - let eventOne = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters, at: 0) - XCTAssertEqual(eventOne, "an.analytics.event") - XCTAssertGreaterThanOrEqual(timestampOne, self.currentTime) - XCTAssertLessThanOrEqual(timestampOne, self.oneSecondLater) - - let eventTwo = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters, at: 1) - XCTAssertEqual(eventTwo, "another.analytics.event") - XCTAssertGreaterThanOrEqual(timestampTwo, self.currentTime) - XCTAssertLessThanOrEqual(timestampTwo, self.oneSecondLater) - self.validateMetadataParameters(mockAnalyticsHTTP.lastRequestParameters) - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - } - - func testFlush_whenThereAreNoQueuedEvents_doesNotPOST() { - let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") - let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() - let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - analyticsService.flushThreshold = 5 - analyticsService.http = mockAnalyticsHTTP - - let expectation = expectation(description: "Sends batched request") - - analyticsService.flush() { error in - XCTAssertNil(error) - XCTAssertEqual(mockAnalyticsHTTP.POSTRequestCount, 0) - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - } - - func testAnalyticsService_whenAPIClientConfigurationFails_returnsError() { - let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") - let stubbedError = NSError(domain: "SomeError", code: 1) - let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() - let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - stubAPIClient.cannedConfigurationResponseError = stubbedError - analyticsService.http = mockAnalyticsHTTP - - let expectation = expectation(description: "Callback invoked with error") - - analyticsService.sendAnalyticsEvent("an.analytics.event") { error in - guard let error = error as? NSError else { return } - XCTAssertEqual(error, stubbedError) - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - } - - func testAnalyticsService_afterConfigurationError_maintainsQueuedEventsUntilConfigurationIsSuccessful() { - let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url") - let stubbedError = NSError(domain: "SomeError", code: 1) - let mockAnalyticsHTTP = FakeHTTP.fakeHTTP() - let analyticsService = BTAnalyticsService(apiClient: stubAPIClient) - - stubAPIClient.cannedConfigurationResponseError = stubbedError - analyticsService.http = mockAnalyticsHTTP - - let expectation1 = expectation(description: "Callback invoked with error") - - analyticsService.sendAnalyticsEvent("an.analytics.event.1") { error in - guard let error = error as? NSError else { return } - XCTAssertEqual(error, stubbedError) - expectation1.fulfill() - } - - waitForExpectations(timeout: 2) - - stubAPIClient.cannedConfigurationResponseError = nil - - let expectation2 = expectation(description: "Callback invoked with error") - - analyticsService.sendAnalyticsEvent("an.analytics.event.2") { error in - XCTAssertNil(error) - XCTAssertEqual(mockAnalyticsHTTP.POSTRequestCount, 1) - - let timestampOne = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters, at: 0)! - let timestampTwo = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters, at: 1)! - - let eventOne = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters, at: 0) - XCTAssertEqual(eventOne, "an.analytics.event.1") - XCTAssertGreaterThanOrEqual(timestampOne, self.currentTime) - XCTAssertLessThanOrEqual(timestampOne, self.oneSecondLater) - - let eventTwo = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters, at: 1) - XCTAssertEqual(eventTwo, "an.analytics.event.2") - XCTAssertGreaterThanOrEqual(timestampTwo, self.currentTime) - XCTAssertLessThanOrEqual(timestampTwo, self.oneSecondLater) - self.validateMetadataParameters(mockAnalyticsHTTP.lastRequestParameters) - expectation2.fulfill() - } + XCTAssertEqual(mockAnalyticsHTTP.lastRequestEndpoint, "v1/tracking/batch/events") + + let timestamp = self.parseTimestamp(mockAnalyticsHTTP.lastRequestParameters)! + let eventName = self.parseEventName(mockAnalyticsHTTP.lastRequestParameters) + + XCTAssertEqual(eventName!, "any.analytics.event") + XCTAssertGreaterThanOrEqual(timestamp, self.currentTime) + XCTAssertLessThanOrEqual(timestamp, self.oneSecondLater) + XCTAssertEqual(mockAnalyticsHTTP.POSTRequestCount, 1) - waitForExpectations(timeout: 2) + self.validateMetadataParameters(mockAnalyticsHTTP.lastRequestParameters) } // MARK: - Helper Functions diff --git a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift index ce138b96d4..12168ea7d4 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift @@ -3,7 +3,6 @@ import Foundation class FakeAnalyticsService: BTAnalyticsService { var lastEvent: String = "" - var didLastFlush: Bool = false override func sendAnalyticsEvent( _ eventName: String, @@ -13,18 +12,5 @@ class FakeAnalyticsService: BTAnalyticsService { linkType: String? = nil ) { self.lastEvent = eventName - self.didLastFlush = false - } - - override func sendAnalyticsEvent( - _ eventName: String, - errorDescription: String? = nil, - correlationID: String? = nil, - payPalContextID: String? = nil, - linkType: String? = nil, - completion: @escaping (Error?) -> Void = { _ in } - ) { - self.lastEvent = eventName - self.didLastFlush = true } } diff --git a/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift b/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift index 3d2c5bfa46..5bf4930625 100644 --- a/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift +++ b/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift @@ -389,7 +389,7 @@ class BTAPIClient_Tests: XCTestCase { XCTAssertTrue(apiClient?.analyticsService is BTAnalyticsService) } - func testSendAnalyticsEvent_whenCalled_callsAnalyticsService_doesFlush() { + func testSendAnalyticsEvent_whenCalled_callsAnalyticsService() { let apiClient = BTAPIClient(authorization: "development_tokenization_key")! let mockAnalyticsService = FakeAnalyticsService(apiClient: apiClient) @@ -397,7 +397,6 @@ class BTAPIClient_Tests: XCTestCase { apiClient.sendAnalyticsEvent("blahblah") XCTAssertEqual(mockAnalyticsService.lastEvent, "blahblah") - XCTAssertTrue(mockAnalyticsService.didLastFlush) } // MARK: - Client SDK Metadata