Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix - send analytics even when BTAPIClient deallocated #1379

Merged
merged 26 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0416807
Add print statements for no analytics on VC refresh
scannillo Jul 18, 2024
3468a67
Merge branch 'main' into replicate-main-analytics-issue
scannillo Jul 29, 2024
2da5b21
Move BTAnalyticsService to proper singleton pattern
scannillo Jul 29, 2024
9df6e1d
Fixup unit tests
scannillo Jul 29, 2024
cb2cf66
Cleanup
scannillo Jul 29, 2024
7b36004
Reset Demo
scannillo Jul 29, 2024
7eb5c6e
Cleanup docstrings
scannillo Jul 29, 2024
593da6f
Delete trigger_duplicate_config_calls.patch
scannillo Jul 29, 2024
1ba1867
Cleanup - docstring formatting
scannillo Jul 29, 2024
15f5faa
Fixup - Lint error on CI
scannillo Jul 29, 2024
cb18f49
Fixup - failing unit test
scannillo Jul 29, 2024
0515294
Add CHANGELOG entry
scannillo Jul 29, 2024
d296937
Merge branch 'main' into replicate-main-analytics-issue
scannillo Jul 29, 2024
292bdb8
Update Sources/BraintreeCore/Analytics/BTAnalyticsService.swift
scannillo Jul 30, 2024
3a7c253
Update Sources/BraintreeCore/Analytics/BTAnalyticsService.swift
scannillo Jul 30, 2024
a101749
PR Feedback - styling
scannillo Jul 30, 2024
0d5bc74
PR Feedback - method naming
scannillo Jul 30, 2024
b2715cd
Cleanup - make methods private where possible & make BTAnalyticsServi…
scannillo Jul 30, 2024
fa0c3ac
Update CHANGELOG.md
scannillo Jul 30, 2024
2fb2cf2
Update CHANGELOG.md
scannillo Jul 30, 2024
cdc823f
Make BTAnalyticsService final
scannillo Jul 30, 2024
71d6c0a
Merge branch 'main' into replicate-main-analytics-issue
scannillo Jul 30, 2024
087d3a2
Merge branch 'replicate-main-analytics-issue' of https://github.com/b…
scannillo Jul 30, 2024
93c68e8
Merge branch 'main' into replicate-main-analytics-issue
scannillo Jul 30, 2024
691cca3
PR Feedback - move weak reference into BTAnalyticsService
scannillo Jul 30, 2024
217bdcd
Merge branch 'main' into replicate-main-analytics-issue
scannillo Jul 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Braintree.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
8075CBEE2B1B735200CA6265 /* BTAPIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8075CBED2B1B735200CA6265 /* BTAPIRequest.swift */; };
80842DA72B8E49EF00A5CD92 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 80842DA62B8E49EF00A5CD92 /* PrivacyInfo.xcprivacy */; };
8087C10F2BFBACCA0020FC2E /* TokenizationKey_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8087C10E2BFBACCA0020FC2E /* TokenizationKey_Tests.swift */; };
808E4A162C581CD40006A737 /* AnalyticsSendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E4A152C581CD40006A737 /* AnalyticsSendable.swift */; };
80A6C6192B21205900416D50 /* UIApplication+URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A6C6182B21205900416D50 /* UIApplication+URLOpener.swift */; };
80AB424F2B27CAC200249218 /* BraintreeTestShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A903E1A624F9D34000C314E1 /* BraintreeTestShared.framework */; platformFilter = ios; };
80AB42542B27DF5B00249218 /* BraintreeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 570B93AC285397520041BAFE /* BraintreeCore.framework */; platformFilter = ios; };
Expand Down Expand Up @@ -860,6 +861,7 @@
8075CBED2B1B735200CA6265 /* BTAPIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTAPIRequest.swift; sourceTree = "<group>"; };
80842DA62B8E49EF00A5CD92 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
8087C10E2BFBACCA0020FC2E /* TokenizationKey_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenizationKey_Tests.swift; sourceTree = "<group>"; };
808E4A152C581CD40006A737 /* AnalyticsSendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSendable.swift; sourceTree = "<group>"; };
80A1EE3D2236AAC600F6218B /* BTThreeDSecureAdditionalInformation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureAdditionalInformation_Tests.swift; sourceTree = "<group>"; };
80A6C6182B21205900416D50 /* UIApplication+URLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+URLOpener.swift"; sourceTree = "<group>"; };
80AD35F12BFBB1DD00BF890E /* TokenizationKeyError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenizationKeyError.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1591,6 +1593,7 @@
80F4F4D529F8A628003EACB1 /* Analytics */ = {
isa = PBXGroup;
children = (
808E4A152C581CD40006A737 /* AnalyticsSendable.swift */,
BE549F132BF6576300B6F441 /* BTAnalyticsEventsStorage.swift */,
BEE2E4E329007FF100C03FDD /* BTAnalyticsService.swift */,
BEE2E4E5290080BD00C03FDD /* BTAnalyticsServiceError.swift */,
Expand Down Expand Up @@ -3318,6 +3321,7 @@
574891E9286F7D300020DA36 /* BTClientMetadataSource.swift in Sources */,
BED7493628579BAC0074C818 /* BTURLUtils.swift in Sources */,
BEBD05222A1FE8BE0003C15C /* BTWebAuthenticationSession.swift in Sources */,
808E4A162C581CD40006A737 /* AnalyticsSendable.swift in Sources */,
BE698EA028AA8DCB001D9B10 /* BTHTTP.swift in Sources */,
BC17F9B428D23C5C004B18CC /* BTGraphQLMultiErrorNode.swift in Sources */,
5708E0A828809BC6007946B9 /* BTJSONError.swift in Sources */,
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Braintree iOS SDK Release Notes

## unreleased
* BraintreeCore
* Fix bug where some analytics wouldn't send if `BTAPIClient` instantiated on button click
* Update `endpoint` syntax sent to FPTI for 3D Secure and Venmo flows
scannillo marked this conversation as resolved.
Show resolved Hide resolved
* BraintreePayPal
* Fix bug where `BTPayPalCheckoutRequest` was not passing the correct data causing issues with some transaction attempts

Expand All @@ -10,7 +13,6 @@
* BraintreeCore
* Prevent duplicate outbound `v1/configuration` requests
* Add network timeout of 30 seconds
* Update `endpoint` syntax sent to FPTI for 3D Secure and Venmo flows

## 6.23.0 (2024-07-15)
* BraintreeShopperInsights (BETA)
Expand Down
7 changes: 7 additions & 0 deletions Sources/BraintreeCore/Analytics/AnalyticsSendable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Describes a class that batches and sends analytics events.
/// - Note: Specifically created to mock the `BTAnalyticsService` singleton.
protocol AnalyticsSendable: AnyObject {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🫶🏻

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have a better name suggestion for this, please let me know


func sendAnalyticsEvent(_ event: FPTIBatchData.Event)
func setAPIClient(_ apiClient: BTAPIClient)
}
121 changes: 30 additions & 91 deletions Sources/BraintreeCore/Analytics/BTAnalyticsService.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Foundation

class BTAnalyticsService: Equatable {
class BTAnalyticsService: AnalyticsSendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take it or leave it, but I know for other classes we have had the protocol conformance in an extension so we can add a MARK: to make it clear where the protocol starts/ends.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could! Since the protocol is just for testing purposes, I thought that would make the functionality of BTAnalyticsService harder to read - but open to whatever folks prefer!


// MARK: - Internal Properties

static let shared = BTAnalyticsService()

// swiftlint:disable force_unwrapping
/// The FPTI URL to post all analytic events.
Expand All @@ -17,152 +19,89 @@ class BTAnalyticsService: Equatable {

// MARK: - Private Properties

private static let events = BTAnalyticsEventsStorage()
private let events = BTAnalyticsEventsStorage()

/// Amount of time, in seconds, between batch API requests sent to FPTI
private static let timeInterval = 15

private static let timer = RepeatingTimer(timeInterval: timeInterval)
private let timer = RepeatingTimer(timeInterval: timeInterval)

private let apiClient: BTAPIClient

private var apiClient: BTAPIClient?
// MARK: - Initializer

init(apiClient: BTAPIClient) {

private init() { }

/// Used to inject `BTAPIClient` dependency into `BTAnalyticsService` singleton
func setAPIClient(_ apiClient: BTAPIClient) {
self.apiClient = apiClient
self.http = BTHTTP(authorization: apiClient.authorization, customBaseURL: Self.url)

Self.timer.eventHandler = { [weak self] in
timer.eventHandler = { [weak self] in
guard let self else { return }
Task {
await self.sendQueuedAnalyticsEvents()
}
}
Self.timer.resume()

timer.resume()
}

// MARK: - Deinit

deinit {
Self.timer.suspend()
timer.suspend()
}

// MARK: - Internal Methods

/// Sends analytics event to https://api.paypal.com/v1/tracking/batch/events/ via a background task.
/// - Parameters:
/// - eventName: Name of analytic event.
/// - correlationID: Optional. CorrelationID associated with the checkout session.
/// - endpoint: Optional. The endpoint of the API request send during networking requests.
/// - endTime: Optional. The end time of the roundtrip networking request.
/// - errorDescription: Optional. Full error description returned to merchant.
/// - isVaultRequest: Optional. If the Venmo or PayPal request is being vaulted.
/// - linkType: Optional. The type of link the SDK will be handling, currently deeplink or universal.
/// - payPalContextID: Optional. PayPal Context ID associated with the checkout session.
/// - startTime: Optional. The start time of the networking request.
func sendAnalyticsEvent(
_ eventName: String,
connectionStartTime: Int? = nil,
correlationID: String? = nil,
endpoint: String? = nil,
endTime: Int? = nil,
errorDescription: String? = nil,
isVaultRequest: Bool? = nil,
linkType: String? = nil,
payPalContextID: String? = nil,
requestStartTime: Int? = nil,
startTime: Int? = nil
) {
/// - Parameter event: A single `FPTIBatchData.Event`
func sendAnalyticsEvent(_ event: FPTIBatchData.Event) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendAnalyticsEvent and performEventRequest could be merged, or if we decide to keep both, performEventRequest could be made private, thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have those as 2 separate functions for the purpose of unit testing right now. Since sendAnalyticsEvent launches an async background task, there is no way for our tests to know when it completes to run it's assertions. performEventRequest is acting as an "internal" version that delivers the async result, which we're calling in our unit tests.

We used this pattern in PPCP too to unit test background-type tasks. Of course open to future suggestions here for improving our tests!

Task(priority: .background) {
await performEventRequest(
eventName,
connectionStartTime: connectionStartTime,
correlationID: correlationID,
endpoint: endpoint,
endTime: endTime,
errorDescription: errorDescription,
isVaultRequest: isVaultRequest,
linkType: linkType,
payPalContextID: payPalContextID,
requestStartTime: requestStartTime,
startTime: startTime
)
await performEventRequest(with: event)
}
}

/// Exposed to be able to execute this function synchronously in unit tests
func performEventRequest(
_ eventName: String,
connectionStartTime: Int? = nil,
correlationID: String? = nil,
endpoint: String? = nil,
endTime: Int? = nil,
errorDescription: String? = nil,
isVaultRequest: Bool? = nil,
linkType: String? = nil,
payPalContextID: String? = nil,
requestStartTime: Int? = nil,
startTime: Int? = nil
) async {
let timestampInMilliseconds = Date().utcTimestampMilliseconds
let event = FPTIBatchData.Event(
connectionStartTime: connectionStartTime,
correlationID: correlationID,
endpoint: endpoint,
endTime: endTime,
errorDescription: errorDescription,
eventName: eventName,
isVaultRequest: isVaultRequest,
linkType: linkType,
payPalContextID: payPalContextID,
requestStartTime: requestStartTime,
startTime: startTime,
timestamp: String(timestampInMilliseconds)
)

await BTAnalyticsService.events.append(event)
func performEventRequest(with event: FPTIBatchData.Event) async {
await events.append(event)

if shouldBypassTimerQueue {
await self.sendQueuedAnalyticsEvents()
}
}

// MARK: - Helpers
// MARK: - Private Methods

func sendQueuedAnalyticsEvents() async {
if await !BTAnalyticsService.events.isEmpty {
private func sendQueuedAnalyticsEvents() async {
if await !events.isEmpty, let apiClient {
do {
let configuration = try await apiClient.fetchConfiguration()
let postParameters = await createAnalyticsEvent(
config: configuration,
sessionID: apiClient.metadata.sessionID,
events: Self.events.allValues
events: events.allValues
)
http?.post("v1/tracking/batch/events", parameters: postParameters) { _, _, _ in }
await Self.events.removeAll()
await events.removeAll()
} catch {
return
}
}
}

/// Constructs POST params to be sent to FPTI
func createAnalyticsEvent(config: BTConfiguration, sessionID: String, events: [FPTIBatchData.Event]) -> Codable {
private func createAnalyticsEvent(config: BTConfiguration, sessionID: String, events: [FPTIBatchData.Event]) -> Codable {
let batchMetadata = FPTIBatchData.Metadata(
authorizationFingerprint: apiClient.authorization.type == .clientToken ? apiClient.authorization.bearer : nil,
authorizationFingerprint: apiClient?.authorization.type == .clientToken ? apiClient?.authorization.bearer : nil,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think metadata and authorization are constants in BTAPIClient (they are var for testing purposes). In the future, we might consider removing the BTAPIClient dependency from here altogether and instead inject metadata, authorization, and Configuration

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeh this is a good callout. Though it did cross my mind going back to this PR (#1337) that we don't need a config to be present for analytics at the time of BTAPIClient.sendAnalytics() but rather right before we flush/batch upload the analytics after the 15 seconds timer.

If we inject the Config from the BTAPIClient, we might get back to calling the fetchOrReturnRemoteConfig more times than necessary

environment: config.fptiEnvironment,
integrationType: apiClient.metadata.integration.stringValue,
integrationType: apiClient?.metadata.integration.stringValue ?? BTClientMetadataIntegration.custom.stringValue,
merchantID: config.merchantID,
sessionID: sessionID,
tokenizationKey: apiClient.authorization.type == .tokenizationKey ? apiClient.authorization.originalValue : nil
tokenizationKey: apiClient?.authorization.type == .tokenizationKey ? apiClient?.authorization.originalValue : nil
)

return FPTIBatchData(metadata: batchMetadata, events: events)
}

// MARK: Equitable Protocol Conformance

static func == (lhs: BTAnalyticsService, rhs: BTAnalyticsService) -> Bool {
lhs.http == rhs.http && lhs.apiClient == rhs.apiClient
}
}
28 changes: 27 additions & 1 deletion Sources/BraintreeCore/Analytics/FPTIBatchData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,35 @@ struct FPTIBatchData: Codable {
let requestStartTime: Int?
/// UTC millisecond timestamp when a networking task initiated.
let startTime: Int?
let timestamp: String
let timestamp = String(Date().utcTimestampMilliseconds)
let tenantName: String = "Braintree"
let venmoInstalled: Bool = application.isVenmoAppInstalled()

init(
connectionStartTime: Int? = nil,
correlationID: String? = nil,
endpoint: String? = nil,
endTime: Int? = nil,
errorDescription: String? = nil,
eventName: String,
isVaultRequest: Bool? = nil,
linkType: String? = nil,
payPalContextID: String? = nil,
requestStartTime: Int? = nil,
startTime: Int? = nil
) {
self.connectionStartTime = connectionStartTime
self.correlationID = correlationID
self.endpoint = endpoint
self.endTime = endTime
self.errorDescription = errorDescription
self.eventName = eventName
self.isVaultRequest = isVaultRequest
self.linkType = linkType
self.payPalContextID = payPalContextID
self.requestStartTime = requestStartTime
self.startTime = startTime
}

enum CodingKeys: String, CodingKey {
case connectionStartTime = "connect_start_time"
Expand Down
41 changes: 19 additions & 22 deletions Sources/BraintreeCore/BTAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,7 @@ import Foundation
var configurationLoader: ConfigurationLoader

/// Exposed for testing analytics
/// By default, the `BTAnalyticsService` instance is static/shared so that only one queue of events exists.
/// The "singleton" is managed here because the analytics service depends on `BTAPIClient`.
weak var analyticsService: BTAnalyticsService? {
get { BTAPIClient._analyticsService }
set { BTAPIClient._analyticsService = newValue }
}
Comment on lines -30 to -33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:tears-of-joy:


private static var _analyticsService: BTAnalyticsService?
weak var analyticsService: AnalyticsSendable? = BTAnalyticsService.shared
scannillo marked this conversation as resolved.
Show resolved Hide resolved

// MARK: - Initializers

Expand Down Expand Up @@ -65,7 +58,7 @@ import Foundation
configurationLoader = ConfigurationLoader(http: btHttp)

super.init()
BTAPIClient._analyticsService = BTAnalyticsService(apiClient: self)
analyticsService?.setAPIClient(self)
http?.networkTimingDelegate = self

// Kickoff the background request to fetch the config
Expand Down Expand Up @@ -320,12 +313,14 @@ import Foundation
payPalContextID: String? = nil
) {
analyticsService?.sendAnalyticsEvent(
eventName,
correlationID: correlationID,
errorDescription: errorDescription,
isVaultRequest: isVaultRequest,
linkType: linkType,
payPalContextID: payPalContextID
FPTIBatchData.Event(
correlationID: correlationID,
errorDescription: errorDescription,
eventName: eventName,
isVaultRequest: isVaultRequest,
linkType: linkType,
payPalContextID: payPalContextID
)
)
}

Expand Down Expand Up @@ -407,15 +402,17 @@ import Foundation
with: "payment_methods/three_d_secure",
options: .regularExpression
)

if cleanedPath != "/v1/tracking/batch/events" {
analyticsService?.sendAnalyticsEvent(
BTCoreAnalytics.apiRequestLatency,
connectionStartTime: connectionStartTime,
endpoint: cleanedPath,
endTime: endTime,
requestStartTime: requestStartTime,
startTime: startTime
FPTIBatchData.Event(
connectionStartTime: connectionStartTime,
endpoint: cleanedPath,
endTime: endTime,
eventName: BTCoreAnalytics.apiRequestLatency,
requestStartTime: requestStartTime,
startTime: startTime
)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,25 @@ final class BTAnalyticsService_Tests: XCTestCase {

func testSendAnalyticsEvent_whenConfigFetchCompletes_setsUpAnalyticsHTTPToUseBaseURL() async {
let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url")
let analyticsService = BTAnalyticsService(apiClient: stubAPIClient)
let sut = BTAnalyticsService.shared
sut.setAPIClient(stubAPIClient)

await analyticsService.performEventRequest("any.analytics.event")
await sut.performEventRequest(with: FPTIBatchData.Event(eventName: "any.analytics.event"))

XCTAssertEqual(analyticsService.http?.customBaseURL?.absoluteString, "https://api.paypal.com")
XCTAssertEqual(sut.http?.customBaseURL?.absoluteString, "https://api.paypal.com")
}

func testSendAnalyticsEvent_sendsAnalyticsEvent() async {
let stubAPIClient: MockAPIClient = stubbedAPIClientWithAnalyticsURL("test://do-not-send.url")
let mockAnalyticsHTTP = FakeHTTP.fakeHTTP()
let analyticsService = BTAnalyticsService(apiClient: stubAPIClient)
analyticsService.shouldBypassTimerQueue = true
let sut = BTAnalyticsService.shared
sut.setAPIClient(stubAPIClient)
sut.shouldBypassTimerQueue = true

analyticsService.http = mockAnalyticsHTTP

await analyticsService.performEventRequest("any.analytics.event")
sut.http = mockAnalyticsHTTP

await sut.performEventRequest(with: FPTIBatchData.Event(eventName: "any.analytics.event"))

XCTAssertEqual(mockAnalyticsHTTP.lastRequestEndpoint, "v1/tracking/batch/events")

let timestamp = parseTimestamp(mockAnalyticsHTTP.lastRequestParameters)!
Expand Down
Loading
Loading