Files
purchases-ios/Sources/Events/EventsManager.swift
T
dd88e3b761 Fix failing ad tracking tests (#5963)
* Re-recorded FallbackURLBackendIntegrationTests and LoadShedderIntegrationTests

* Fix failing ad tracking tests

* Remove commented code

* updated testCanGetOfferings

---------

Co-authored-by: Facundo Menzella <facumenzella@gmail.com>
Co-authored-by: Facundo Menzella <facumenzella@users.noreply.github.com>
2025-12-24 13:06:02 +01:00

357 lines
12 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// EventsManager.swift
//
// Created by Nacho Soto on 9/6/23.
import Foundation
#if os(iOS) || os(tvOS) || VISION_OS
import UIKit
#endif
protocol EventsManagerType {
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func track(featureEvent: FeatureEvent) async
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func track(adEvent: AdEvent) async
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func flushAllEventsWithBackgroundTask(batchSize: Int)
/// - Throws: if posting feature events fails
/// - Returns: the number of feature events posted
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func flushFeatureEvents(batchSize: Int) async throws -> Int
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func flushFeatureEventsWithBackgroundTask(batchSize: Int)
}
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
actor EventsManager: EventsManagerType {
static let defaultEventBatchSize = 50
static let maxBatchesPerFlush = 10
private let internalAPI: InternalAPI
private let userProvider: CurrentUserProvider
private let store: FeatureEventStoreType
private var appSessionID: UUID
private let systemInfo: SystemInfo
private let adEventStore: AdEventStoreType?
private var adFlushInProgress = false
private var flushInProgress = false
init(
internalAPI: InternalAPI,
userProvider: CurrentUserProvider,
store: FeatureEventStoreType,
systemInfo: SystemInfo,
appSessionID: UUID = SystemInfo.appSessionID,
adEventStore: AdEventStoreType? = nil
) {
self.internalAPI = internalAPI
self.userProvider = userProvider
self.store = store
self.systemInfo = systemInfo
self.appSessionID = appSessionID
self.adEventStore = adEventStore
}
func track(featureEvent: FeatureEvent) async {
guard let event: StoredFeatureEvent = .init(event: featureEvent,
userID: self.userProvider.currentAppUserID,
feature: featureEvent.feature,
appSessionID: self.appSessionID,
eventDiscriminator: featureEvent.eventDiscriminator) else {
Logger.error(Strings.paywalls.event_cannot_serialize)
return
}
await self.store.store(event)
}
func track(adEvent: AdEvent) async {
guard let store = self.adEventStore else {
Logger.warn(EventsManagerStrings.ad_event_tracking_disabled)
return
}
guard let event: StoredAdEvent = .init(event: adEvent,
userID: self.userProvider.currentAppUserID,
appSessionID: self.appSessionID) else {
Logger.error(EventsManagerStrings.ad_event_cannot_serialize)
return
}
await store.store(event)
}
func flushAllEvents(batchSize: Int) async throws -> Int {
let featureEventsFlushed = try await self.flushFeatureEventsInternal(batchSize: batchSize)
let adEventsFlushed = try await self.flushAdEvents(count: batchSize)
return featureEventsFlushed + adEventsFlushed
}
func flushFeatureEvents(batchSize: Int) async throws -> Int {
return try await self.flushFeatureEventsInternal(batchSize: batchSize)
}
private static let flushAllEventsBackgroundTaskName = "com.revenuecat.flushAllEvents"
private static let flushFeatureEventsBackgroundTaskName = "com.revenuecat.flushFeatureEvents"
nonisolated func flushAllEventsWithBackgroundTask(batchSize: Int) {
self.withBackgroundTask(name: Self.flushAllEventsBackgroundTaskName) {
do {
_ = try await self.flushAllEvents(batchSize: batchSize)
} catch {
Logger.error(Strings.paywalls.event_flush_failed(error))
}
}
}
nonisolated func flushFeatureEventsWithBackgroundTask(batchSize: Int) {
self.withBackgroundTask(name: Self.flushFeatureEventsBackgroundTaskName) {
do {
_ = try await self.flushFeatureEvents(batchSize: batchSize)
} catch {
Logger.error(Strings.paywalls.event_flush_failed(error))
}
}
}
}
// MARK: - Private Helpers
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
private extension EventsManager {
func flushFeatureEventsInternal(batchSize: Int) async throws -> Int {
guard !self.flushInProgress else {
Logger.debug(Strings.paywalls.event_flush_already_in_progress)
return 0
}
self.flushInProgress = true
defer { self.flushInProgress = false }
var totalFlushed = 0
var batchesSent = 0
while batchesSent < Self.maxBatchesPerFlush {
let events = await self.store.fetch(batchSize)
guard !events.isEmpty else {
if totalFlushed == 0 {
Logger.verbose(Strings.paywalls.event_flush_with_empty_store)
}
return totalFlushed
}
Logger.verbose(Strings.paywalls.event_flush_starting(count: events.count))
do {
try await self.internalAPI.postFeatureEvents(events: events)
Logger.debug(Strings.analytics.flush_events_success)
await self.store.clear(events.count)
totalFlushed += events.count
batchesSent += 1
} catch {
Logger.error(Strings.paywalls.event_sync_failed(error))
if let backendError = error as? BackendError,
backendError.successfullySynced {
await self.store.clear(events.count)
totalFlushed += events.count
batchesSent += 1
} else {
throw error
}
}
}
return totalFlushed
}
func flushAdEvents(count: Int) async throws -> Int {
guard let store = self.adEventStore else {
Logger.warn(EventsManagerStrings.ad_event_tracking_disabled)
return 0
}
guard !self.adFlushInProgress else {
Logger.debug(EventsManagerStrings.ad_event_flush_already_in_progress)
return 0
}
self.adFlushInProgress = true
defer { self.adFlushInProgress = false }
let events = await store.fetch(count)
guard !events.isEmpty else {
Logger.verbose(EventsManagerStrings.ad_event_flush_with_empty_store)
return 0
}
Logger.verbose(EventsManagerStrings.ad_event_flush_starting(events.count))
do {
try await self.internalAPI.postAdEvents(events: events)
Logger.debug(EventsManagerStrings.ad_events_flushed_successfully)
await store.clear(count)
return events.count
} catch {
Logger.error(EventsManagerStrings.ad_event_sync_failed(error))
if let backendError = error as? BackendError,
backendError.successfullySynced {
await store.clear(count)
}
throw error
}
}
nonisolated func withBackgroundTask(name: String, do work: @escaping () async -> Void) {
#if swift(>=5.9) && (os(iOS) || os(tvOS) || VISION_OS)
let endBackgroundTask: (() -> Void)?
if !self.systemInfo.isAppExtension {
endBackgroundTask = Self.beginBackgroundTask(named: name)
} else {
endBackgroundTask = nil
}
#endif
Task {
await work()
#if swift(>=5.9) && (os(iOS) || os(tvOS) || VISION_OS)
endBackgroundTask?()
#endif
}
}
}
// MARK: - Private Helpers
#if swift(>=5.9) && (os(iOS) || os(tvOS) || VISION_OS)
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
private extension EventsManager {
/// Begins a background task synchronously and returns a closure to end it.
/// This should be called BEFORE spawning async work to prevent the system from
/// suspending the app before the task starts executing.
///
/// - Parameter taskName: A name for the background task for debugging purposes.
/// - Returns: A closure to end the background task, or `nil` if the task couldn't be started.
static func beginBackgroundTask(named taskName: String) -> (@Sendable () -> Void)? {
guard let application = SystemInfo.sharedUIApplication else {
Logger.warn(EventsManagerStrings.background_task_unavailable)
return nil
}
let backgroundTaskID: Atomic<UIBackgroundTaskIdentifier?> = .init(nil)
backgroundTaskID.value = application.beginBackgroundTask(withName: taskName) {
Logger.warn(EventsManagerStrings.background_task_expired(taskName))
if let taskID = backgroundTaskID.value {
application.endBackgroundTask(taskID)
backgroundTaskID.value = .invalid
}
}
if backgroundTaskID.value == .invalid {
Logger.warn(EventsManagerStrings.background_task_failed(taskName))
return nil
}
Logger.debug(EventsManagerStrings.background_task_started(taskName))
return {
if let taskID = backgroundTaskID.value {
application.endBackgroundTask(taskID)
}
}
}
}
#endif
// MARK: - Messages
// swiftlint:disable identifier_name
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
enum EventsManagerStrings {
case background_task_unavailable
case background_task_expired(String)
case background_task_failed(String)
case background_task_started(String)
case ad_event_tracking_disabled
case ad_event_cannot_serialize
case ad_event_flush_already_in_progress
case ad_event_flush_with_empty_store
case ad_event_flush_starting(Int)
case ad_events_flushed_successfully
case ad_event_sync_failed(Error)
}
// swiftlint:enable identifier_name
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension EventsManagerStrings: LogMessage {
var description: String {
switch self {
case .background_task_unavailable:
return "Background task unavailable"
case .background_task_expired(let taskName):
return "Background task expired: \(taskName)"
case .background_task_failed(let taskName):
return "Background task failed to start: \(taskName)"
case .background_task_started(let taskName):
return "Background task started: \(taskName)"
case .ad_event_tracking_disabled:
return "Ad event tracking is disabled - no ad event store configured"
case .ad_event_cannot_serialize:
return "Cannot serialize ad event"
case .ad_event_flush_already_in_progress:
return "Ad event flush already in progress"
case .ad_event_flush_with_empty_store:
return "Ad event flush with empty store"
case let .ad_event_flush_starting(count):
return "Ad event flush starting with \(count) event(s)"
case .ad_events_flushed_successfully:
return "Ad events flushed successfully"
case let .ad_event_sync_failed(error):
return "Ad event sync failed: \(error)"
}
}
var category: String { return "events_manager" }
}