diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 7a4606ab..cded69c6 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -51,6 +51,11 @@ jobs: needs: cancel_previous runs-on: macos-14 steps: + - name: Install yeetd + run: | + wget https://github.com/biscuitehh/yeetd/releases/download/1.0/yeetd-normal.pkg + sudo installer -pkg yeetd-normal.pkg -target / + yeetd & - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "15.2" diff --git a/Examples/other_plugins/IDFACollection.swift b/Examples/other_plugins/IDFACollection.swift index 4ee1550c..a2fe52e6 100644 --- a/Examples/other_plugins/IDFACollection.swift +++ b/Examples/other_plugins/IDFACollection.swift @@ -77,10 +77,16 @@ class IDFACollection: Plugin { extension IDFACollection: iOSLifecycle { func applicationDidBecomeActive(application: UIApplication?) { let status = ATTrackingManager.trackingAuthorizationStatus - if status == .notDetermined && !alreadyAsked { - // we don't know, so should ask the user. - alreadyAsked = true - askForPermission() + + _alreadyAsked.withValue { alreadyAsked in + if status == .notDetermined && !alreadyAsked { + // we don't know, so should ask the user. + alreadyAsked = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + askForPermission() + } + } } } } diff --git a/Package.resolved b/Package.resolved index 030ba0ba..413ef649 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { "pins" : [ { - "identity" : "jsonsafeencoder-swift", + "identity" : "jsonsafeencoding-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/segmentio/jsonsafeencoder-swift.git", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", "state" : { - "revision" : "75ad40f07d4e0b938e3afb80811244d6b7acd4ba", - "version" : "1.0.0" + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" } }, { diff --git a/Package.swift b/Package.swift index 09c7981c..067b9d04 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( platforms: [ .macOS("10.15"), .iOS("13.0"), - .tvOS("11.0"), + .tvOS("13.0"), .watchOS("7.1"), .visionOS("1.0") ], @@ -22,7 +22,7 @@ let package = Package( // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), .package(url: "https://github.com/segmentio/sovran-swift.git", .exact("1.1.1")), - .package(url: "https://github.com/segmentio/jsonsafeencoder-swift.git", .exact("1.0.1")) + .package(url: "https://github.com/segmentio/jsonsafeencoding-swift.git", .exact("2.0.0")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -31,7 +31,7 @@ let package = Package( name: "Segment", dependencies: [ .product(name: "Sovran", package: "sovran-swift"), - .product(name: "JSONSafeEncoder", package: "jsonsafeencoder-swift") + .product(name: "JSONSafeEncoding", package: "jsonsafeencoding-swift") ], resources: [.process("Resources")]), .testTarget( diff --git a/Sources/Segment/Analytics.swift b/Sources/Segment/Analytics.swift index 5334f9ca..85dd400c 100644 --- a/Sources/Segment/Analytics.swift +++ b/Sources/Segment/Analytics.swift @@ -78,7 +78,7 @@ public class Analytics { // provide our default state store.provide(state: System.defaultState(configuration: configuration, from: storage)) - store.provide(state: UserInfo.defaultState(from: storage)) + store.provide(state: UserInfo.defaultState(from: storage, anonIdGenerator: configuration.values.anonymousIdGenerator)) storage.analytics = self @@ -228,56 +228,23 @@ extension Analytics { /// called when flush has completed. public func flush(completion: (() -> Void)? = nil) { // only flush if we're enabled. - guard enabled == true else { return } - - let flushGroup = DispatchGroup() - // gotta call enter at least once before we ask to be notified. - flushGroup.enter() + guard enabled == true else { completion?(); return } + let completionGroup = CompletionGroup(queue: configuration.values.flushQueue) apply { plugin in - // we want to enter as soon as possible. waiting to do it from - // another queue just takes too long. - operatingMode.run(queue: configuration.values.flushQueue) { + completionGroup.add { group in if let p = plugin as? FlushCompletion { - // flush handles the groups enter/leave calls - p.flush(group: flushGroup) { plugin in - // we don't really care about the plugin value .. yet. - } + p.flush(group: group) } else if let p = plugin as? EventPlugin { - flushGroup.enter() - // we have no idea if this will be async or not, assume it's sync. + group.enter() p.flush() - flushGroup.leave() + group.leave() } } } - flushGroup.leave() // matches our initial enter(). - - // if we ARE in sync mode, we need to wait on the group. - // This effectively ends up being a `sync` operation. - if operatingMode == .synchronous { - flushGroup.wait() - // we need to call completion on our own since - // we skipped setting up notify. we don't need to do it on - // .main since we are in synchronous mode. - if let completion { completion() } - } else if operatingMode == .asynchronous { - // if we're not, flip over to our serial queue, tell it to wait on the flush - // group to complete if we have a completion to hit. Otherwise, no need to - // wait on completion. - if let completion { - // NOTE: DispatchGroup's `notify` method on linux ended up getting called - // before the tasks have actually completed, so we went with this instead. - OperatingMode.defaultQueue.async { [weak self] in - let timedOut = flushGroup.wait(timeout: .now() + 15 /*seconds*/) - if timedOut == .timedOut { - self?.log(message: "flush(completion:) timed out waiting for completion.") - } - completion() - //DispatchQueue.main.async { completion() } - } - } + completionGroup.run(mode: operatingMode) { + completion?() } } diff --git a/Sources/Segment/Configuration.swift b/Sources/Segment/Configuration.swift index edc3d0f1..0ae44ec4 100644 --- a/Sources/Segment/Configuration.swift +++ b/Sources/Segment/Configuration.swift @@ -6,11 +6,21 @@ // import Foundation -import JSONSafeEncoder +import JSONSafeEncoding #if os(Linux) import FoundationNetworking #endif +// MARK: - Custom AnonymousId generator +/// Conform to this protocol to generate your own AnonymousID +public protocol AnonymousIdGenerator: AnyObject, Codable { + /// Returns a new anonymousId. Segment still manages storage and retrieval of the + /// current anonymousId and will call this method when new id's are needed. + /// + /// - Returns: A new anonymousId. + func newAnonymousId() -> String +} + // MARK: - Operating Mode /// Specifies the operating mode/context public enum OperatingMode { @@ -56,6 +66,7 @@ public class Configuration { var userAgent: String? = nil var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero var storageMode: StorageMode = .disk + var anonymousIdGenerator: AnonymousIdGenerator = SegmentAnonymousId() } internal var values: Values @@ -248,11 +259,19 @@ public extension Configuration { return self } + /// Specify the storage mode to use. The default is `.disk`. @discardableResult func storageMode(_ mode: StorageMode) -> Configuration { values.storageMode = mode return self } + + /// Specify a custom anonymousId generator. The default is and instance of `SegmentAnonymousId`. + @discardableResult + func anonymousIdGenerator(_ generator: AnonymousIdGenerator) -> Configuration { + values.anonymousIdGenerator = generator + return self + } } extension Analytics { diff --git a/Sources/Segment/Events.swift b/Sources/Segment/Events.swift index 426be8a4..e4fc260f 100644 --- a/Sources/Segment/Events.swift +++ b/Sources/Segment/Events.swift @@ -14,6 +14,11 @@ extension Analytics { // and they need to write a middleware/enrichment now. // the objc version should accomodate them if it's really needed. + /// Tracks an event performed by a user, including some additional event properties. + /// - Parameters: + /// - name: Name of the action, e.g., 'Purchased a T-Shirt' + /// - properties: Properties specific to the named event. For example, an event with + /// the name 'Purchased a Shirt' might have properties like revenue or size. public func track(name: String, properties: P?) { do { if let properties = properties { @@ -29,6 +34,9 @@ extension Analytics { } } + /// Tracks an event performed by a user. + /// - Parameters: + /// - name: Name of the action, e.g., 'Purchased a T-Shirt' public func track(name: String) { track(name: name, properties: nil as TrackEvent?) } @@ -133,15 +141,12 @@ extension Analytics { // MARK: - Untyped Event Signatures extension Analytics { - /// Associate a user with their unique ID and record traits about them. + /// Tracks an event performed by a user, including some additional event properties. /// - Parameters: - /// - userId: A database ID for this user. If you don't have a userId - /// but want to record traits, just pass traits into the event and they will be associated - /// with the anonymousId of that user. In the case when user logs out, make sure to - /// call ``reset()`` to clear the user's identity info. For more information on how we - /// generate the UUID and Apple's policies on IDs, see - /// https://segment.io/libraries/ios#ids - /// - properties: A dictionary of traits you know about the user. Things like: email, name, plan, etc. + /// - name: Name of the action, e.g., 'Purchased a T-Shirt' + /// - properties: A dictionary or properties specific to the named event. + /// For example, an event with the name 'Purchased a Shirt' might have properties + /// like revenue or size. public func track(name: String, properties: [String: Any]? = nil) { var props: JSON? = nil if let properties = properties { diff --git a/Sources/Segment/ObjC/ObjCAnalytics.swift b/Sources/Segment/ObjC/ObjCAnalytics.swift index 93f32d4c..d5883ec0 100644 --- a/Sources/Segment/ObjC/ObjCAnalytics.swift +++ b/Sources/Segment/ObjC/ObjCAnalytics.swift @@ -8,7 +8,7 @@ #if !os(Linux) import Foundation -import JSONSafeEncoder +import JSONSafeEncoding // MARK: - ObjC Compatibility diff --git a/Sources/Segment/ObjC/ObjCConfiguration.swift b/Sources/Segment/ObjC/ObjCConfiguration.swift index 261e9da6..87117991 100644 --- a/Sources/Segment/ObjC/ObjCConfiguration.swift +++ b/Sources/Segment/ObjC/ObjCConfiguration.swift @@ -8,7 +8,7 @@ #if !os(Linux) import Foundation -import JSONSafeEncoder +import JSONSafeEncoding @objc(SEGConfiguration) public class ObjCConfiguration: NSObject { diff --git a/Sources/Segment/Plugins.swift b/Sources/Segment/Plugins.swift index f305896e..19705fb5 100644 --- a/Sources/Segment/Plugins.swift +++ b/Sources/Segment/Plugins.swift @@ -63,7 +63,7 @@ public protocol VersionedPlugin { } public protocol FlushCompletion { - func flush(group: DispatchGroup, completion: @escaping (DestinationPlugin) -> Void) + func flush(group: DispatchGroup) } // For internal platform-specific bits diff --git a/Sources/Segment/Plugins/Platforms/Vendors/AppleUtils.swift b/Sources/Segment/Plugins/Platforms/Vendors/AppleUtils.swift index 60245d73..2f7021bf 100644 --- a/Sources/Segment/Plugins/Platforms/Vendors/AppleUtils.swift +++ b/Sources/Segment/Plugins/Platforms/Vendors/AppleUtils.swift @@ -76,6 +76,9 @@ internal class iOSVendorSystem: VendorSystem { // BKS: It was discovered that on some platforms there can be a delay in retrieval. // It has to be fetched on the main thread, so we've spun it off // async and cache it when it comes back. + // Note that due to how the `@Atomic` wrapper works, this boolean check may pass twice or more + // times before the value is updated, fetching the user agent multiple times as the result. + // This is not a big deal as the `userAgent` value is not expected to change often. if Self.asyncUserAgent == nil { DispatchQueue.main.async { Self.asyncUserAgent = WKWebView().value(forKey: "userAgent") as? String @@ -248,6 +251,9 @@ internal class MacOSVendorSystem: VendorSystem { // BKS: It was discovered that on some platforms there can be a delay in retrieval. // It has to be fetched on the main thread, so we've spun it off // async and cache it when it comes back. + // Note that due to how the `@Atomic` wrapper works, this boolean check may pass twice or more + // times before the value is updated, fetching the user agent multiple times as the result. + // This is not a big deal as the `userAgent` value is not expected to change often. if Self.asyncUserAgent == nil { DispatchQueue.main.async { Self.asyncUserAgent = WKWebView().value(forKey: "userAgent") as? String diff --git a/Sources/Segment/Plugins/SegmentDestination.swift b/Sources/Segment/Plugins/SegmentDestination.swift index 4584d33c..50c00f69 100644 --- a/Sources/Segment/Plugins/SegmentDestination.swift +++ b/Sources/Segment/Plugins/SegmentDestination.swift @@ -16,9 +16,15 @@ import Sovran import FoundationNetworking #endif +public class SegmentAnonymousId: AnonymousIdGenerator { + public func newAnonymousId() -> String { + return UUID().uuidString + } +} + open class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion { public init() { } - + internal enum Constants: String { case integrationName = "Customer.io Data Pipelines" case apiHost = "apiHost" @@ -112,22 +118,24 @@ open class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion { guard let storage = self.storage else { return } // Send Event to File System storage.write(.events, value: event) - eventCount += 1 + self._eventCount.withValue { count in + count += 1 + } } public func flush() { // unused .. see flush(group:completion:) } - public func flush(group: DispatchGroup, completion: @escaping (DestinationPlugin) -> Void) { + public func flush(group: DispatchGroup) { + group.enter() + defer { group.leave() } + guard let storage = self.storage else { return } guard let analytics = self.analytics else { return } // don't flush if analytics is disabled. guard analytics.enabled == true else { return } - - // enter for the high level flush, allow us time to run through any existing files.. - group.enter() eventCount = 0 cleanupUploads() @@ -139,72 +147,76 @@ open class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion { if pendingUploads == 0 { if type == .file, hasData { - flushFiles(group: group, completion: completion) + flushFiles(group: group) } else if type == .data, hasData { // we know it's a data-based transaction as opposed to file I/O - flushData(group: group, completion: completion) - } else { - // there was nothing to do ... - completion(self) + flushData(group: group) } } else { analytics.log(message: "Skipping processing; Uploads in progress.") } - - // leave for the high level flush - group.leave() } } extension SegmentDestination { - private func flushFiles(group: DispatchGroup, completion: @escaping (DestinationPlugin) -> Void) { + private func flushFiles(group: DispatchGroup) { guard let storage = self.storage else { return } guard let analytics = self.analytics else { return } guard let httpClient = self.httpClient else { return } - guard let files = storage.dataStore.fetch()?.dataFiles else { return } - - for url in files { - // enter for this url we're going to kick off - group.enter() - analytics.log(message: "Processing Batch:\n\(url.lastPathComponent)") + // Cooperative release of allocated memory by URL instances (dataFiles). + autoreleasepool { + guard let files = storage.dataStore.fetch()?.dataFiles else { return } - // set up the task - let uploadTask = httpClient.startBatchUpload(writeKey: analytics.configuration.values.writeKey, batch: url) { [weak self] result in - guard let self else { return } - switch result { - case .success(_): - storage.remove(data: [url]) - cleanupUploads() + for url in files { + // Use the autorelease pool to ensure that unnecessary memory allocations + // are released after each iteration. If there is a large backlog of files + // to iterate, the host applications may crash due to OOM issues. + autoreleasepool { + // enter for this url we're going to kick off + group.enter() + analytics.log(message: "Processing Batch:\n\(url.lastPathComponent)") - // we don't want to retry events in a given batch when a 400 - // response for malformed JSON is returned - case .failure(Segment.HTTPClientErrors.statusCode(code: 400)): - storage.remove(data: [url]) - cleanupUploads() - default: - break + // set up the task + let uploadTask = httpClient.startBatchUpload(writeKey: analytics.configuration.values.writeKey, batch: url) { [weak self] result in + defer { + group.leave() + } + guard let self else { return } + switch result { + case .success(_): + storage.remove(data: [url]) + cleanupUploads() + + // we don't want to retry events in a given batch when a 400 + // response for malformed JSON is returned + case .failure(Segment.HTTPClientErrors.statusCode(code: 400)): + storage.remove(data: [url]) + cleanupUploads() + default: + break + } + + analytics.log(message: "Processed: \(url.lastPathComponent)") + // the upload we have here has just finished. + // make sure it gets removed and it's cleanup() called rather + // than waiting on the next flush to come around. + cleanupUploads() + } + + // we have a legit upload in progress now, so add it to our list. + if let upload = uploadTask { + add(uploadTask: UploadTaskInfo(url: url, data: nil, task: upload)) + } else { + // we couldn't get a task, so we need to leave the group or things will hang. + group.leave() + } } - - analytics.log(message: "Processed: \(url.lastPathComponent)") - // the upload we have here has just finished. - // make sure it gets removed and it's cleanup() called rather - // than waiting on the next flush to come around. - cleanupUploads() - // call the completion - completion(self) - // leave for the url we kicked off. - group.leave() - } - - // we have a legit upload in progress now, so add it to our list. - if let upload = uploadTask { - add(uploadTask: UploadTaskInfo(url: url, data: nil, task: upload)) } } } - private func flushData(group: DispatchGroup, completion: @escaping (DestinationPlugin) -> Void) { + private func flushData(group: DispatchGroup) { // DO NOT CALL THIS FROM THE MAIN THREAD, IT BLOCKS! // Don't make me add a check here; i'll be sad you didn't follow directions. guard let storage = self.storage else { return } @@ -235,6 +247,12 @@ extension SegmentDestination { // set up the task let uploadTask = httpClient.startBatchUpload(writeKey: analytics.configuration.values.writeKey, data: data) { [weak self] result in + defer { + // leave for the url we kicked off. + group.leave() + semaphore.signal() + } + guard let self else { return } switch result { case .success(_): @@ -255,16 +273,15 @@ extension SegmentDestination { // make sure it gets removed and it's cleanup() called rather // than waiting on the next flush to come around. cleanupUploads() - // call the completion - completion(self) - // leave for the url we kicked off. - group.leave() - semaphore.signal() } // we have a legit upload in progress now, so add it to our list. if let upload = uploadTask { add(uploadTask: UploadTaskInfo(url: nil, data: data, task: upload)) + } else { + // we couldn't get a task, so we need to leave the group or things will hang. + group.leave() + semaphore.signal() } _ = semaphore.wait(timeout: .distantFuture) diff --git a/Sources/Segment/State.swift b/Sources/Segment/State.swift index ede47290..0c8e798a 100644 --- a/Sources/Segment/State.swift +++ b/Sources/Segment/State.swift @@ -111,9 +111,17 @@ struct UserInfo: Codable, State { let traits: JSON? let referrer: URL? + @Noncodable var anonIdGenerator: AnonymousIdGenerator? + struct ResetAction: Action { func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: UUID().uuidString, userId: nil, traits: nil, referrer: nil) + var anonId: String + if let id = state.anonIdGenerator?.newAnonymousId() { + anonId = id + } else { + anonId = UUID().uuidString + } + return UserInfo(anonymousId: anonId, userId: nil, traits: nil, referrer: nil, anonIdGenerator: state.anonIdGenerator) } } @@ -121,7 +129,7 @@ struct UserInfo: Codable, State { let userId: String func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer) + return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator) } } @@ -129,7 +137,7 @@ struct UserInfo: Codable, State { let traits: JSON? func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer) + return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator) } } @@ -138,15 +146,7 @@ struct UserInfo: Codable, State { let traits: JSON? func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer) - } - } - - struct SetAnonymousIdAction: Action { - let anonymousId: String - - func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: anonymousId, userId: state.userId, traits: state.traits, referrer: state.referrer) + return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator) } } @@ -154,7 +154,7 @@ struct UserInfo: Codable, State { let url: URL func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url) + return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url, anonIdGenerator: state.anonIdGenerator) } } } @@ -176,13 +176,15 @@ extension System { } extension UserInfo { - static func defaultState(from storage: Storage) -> UserInfo { + static func defaultState(from storage: Storage, anonIdGenerator: AnonymousIdGenerator) -> UserInfo { let userId: String? = storage.read(.userId) let traits: JSON? = storage.read(.traits) - var anonymousId: String = UUID().uuidString + var anonymousId: String if let existingId: String = storage.read(.anonymousId) { anonymousId = existingId + } else { + anonymousId = anonIdGenerator.newAnonymousId() } - return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil) + return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil, anonIdGenerator: anonIdGenerator) } } diff --git a/Sources/Segment/Utilities/Atomic.swift b/Sources/Segment/Utilities/Atomic.swift index 50984aba..1dc6c07e 100644 --- a/Sources/Segment/Utilities/Atomic.swift +++ b/Sources/Segment/Utilities/Atomic.swift @@ -29,4 +29,12 @@ public class Atomic { get { return queue.sync { return value } } set { queue.sync { value = newValue } } } + + @discardableResult + public func withValue(_ operation: (inout T) -> Void) -> T { + queue.sync { + operation(&self.value) + return self.value + } + } } diff --git a/Sources/Segment/Utilities/CompletionGroup.swift b/Sources/Segment/Utilities/CompletionGroup.swift new file mode 100644 index 00000000..e2407875 --- /dev/null +++ b/Sources/Segment/Utilities/CompletionGroup.swift @@ -0,0 +1,54 @@ +// +// CompletionGroup.swift +// +// +// Created by Brandon Sneed on 4/17/24. +// + +import Foundation + +class CompletionGroup { + let queue: DispatchQueue + var items = [(DispatchGroup) -> Void]() + + init(queue: DispatchQueue) { + self.queue = queue + } + + func add(workItem: @escaping (DispatchGroup) -> Void) { + items.append(workItem) + } + + func run(mode: OperatingMode, completion: @escaping () -> Void) { + // capture self strongly on purpose + let task: () -> Void = { [self] in + let group = DispatchGroup() + group.enter() + group.notify(queue: queue) { [weak self] in + completion() + self?.items.removeAll() + } + + for item in items { + item(group) + } + + group.leave() + + if mode == .synchronous { + group.wait() + } + } + + switch mode { + case .synchronous: + queue.sync { + task() + } + case .asynchronous: + queue.async { + task() + } + } + } +} diff --git a/Sources/Segment/Utilities/JSON.swift b/Sources/Segment/Utilities/JSON.swift index fd8a79a0..510cc917 100644 --- a/Sources/Segment/Utilities/JSON.swift +++ b/Sources/Segment/Utilities/JSON.swift @@ -6,7 +6,7 @@ // import Foundation -import JSONSafeEncoder +import JSONSafeEncoding extension JSONDecoder { enum JSONDecodingError: Error { diff --git a/Sources/Segment/Utilities/Noncodable.swift b/Sources/Segment/Utilities/Noncodable.swift new file mode 100644 index 00000000..e986c63a --- /dev/null +++ b/Sources/Segment/Utilities/Noncodable.swift @@ -0,0 +1,34 @@ +// +// Noncodable.swift +// +// +// Created by Brandon Sneed on 4/17/24. +// + +import Foundation + +@propertyWrapper +internal struct Noncodable: Codable { + public var wrappedValue: T? + public init(wrappedValue: T?) { + self.wrappedValue = wrappedValue + } + public init(from decoder: Decoder) throws { + self.wrappedValue = nil + } + public func encode(to encoder: Encoder) throws { + // Do nothing + } +} + +extension KeyedDecodingContainer { + internal func decode(_ type: Noncodable.Type, forKey key: Self.Key) throws -> Noncodable { + return Noncodable(wrappedValue: nil) + } +} + +extension KeyedEncodingContainer { + internal mutating func encode(_ value: Noncodable, forKey key: KeyedEncodingContainer.Key) throws { + // Do nothing + } +} diff --git a/Sources/Segment/Utilities/Policies/CountBasedFlushPolicy.swift b/Sources/Segment/Utilities/Policies/CountBasedFlushPolicy.swift index 0756067f..0a07edf1 100644 --- a/Sources/Segment/Utilities/Policies/CountBasedFlushPolicy.swift +++ b/Sources/Segment/Utilities/Policies/CountBasedFlushPolicy.swift @@ -37,7 +37,9 @@ public class CountBasedFlushPolicy: FlushPolicy { } public func updateState(event: RawEvent) { - count += 1 + _count.withValue { value in + value += 1 + } } public func reset() { diff --git a/Sources/Segment/Utilities/Utils.swift b/Sources/Segment/Utilities/Utils.swift index b06d2695..423f938c 100644 --- a/Sources/Segment/Utilities/Utils.swift +++ b/Sources/Segment/Utilities/Utils.swift @@ -16,6 +16,11 @@ extension DispatchQueue { workItem.wait() } } + +// Linux doesn't have autoreleasepool. +func autoreleasepool(closure: () -> Void) { + closure() +} #endif /// Inquire as to whether we are within a Unit Testing environment. diff --git a/Sources/Segment/Utilities/iso8601.swift b/Sources/Segment/Utilities/iso8601.swift index 9a3fbb83..2b5feed8 100644 --- a/Sources/Segment/Utilities/iso8601.swift +++ b/Sources/Segment/Utilities/iso8601.swift @@ -6,7 +6,6 @@ // import Foundation -import JSONSafeEncoder enum SegmentISO8601DateFormatter { static let shared: ISO8601DateFormatter = { diff --git a/Sources/Segment/Version.swift b/Sources/Segment/Version.swift index 81cff3b4..f34bf3ea 100644 --- a/Sources/Segment/Version.swift +++ b/Sources/Segment/Version.swift @@ -15,4 +15,4 @@ // Use release.sh's automation. // BREAKING.FEATURE.FIX -internal let __segment_version = "1.5.9" +internal let __segment_version = "1.5.11" diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index b56db817..00000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import Segment_Tests - -var tests = [XCTestCaseEntry]() -tests += Segment_SwiftTests.allTests() -XCTMain(tests) diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 83c35687..2e0af6b7 100644 --- a/Tests/Segment-Tests/Analytics_Tests.swift +++ b/Tests/Segment-Tests/Analytics_Tests.swift @@ -142,10 +142,11 @@ final class Analytics_Tests: XCTestCase { let expectation = XCTestExpectation(description: "MyDestination Expectation") let myDestination = MyDestination(disabled: true) { expectation.fulfill() + print("called") return true } - let configuration = Configuration(writeKey: "test") + let configuration = Configuration(writeKey: "testDestNotEnabled") let analytics = Analytics(configuration: configuration) analytics.add(plugin: myDestination) @@ -744,7 +745,6 @@ final class Analytics_Tests: XCTestCase { let shared2 = Analytics.shared() XCTAssertFalse(alive2 === shared2) XCTAssertTrue(shared2 === shared) - } func testAsyncOperatingMode() throws { @@ -758,21 +758,25 @@ final class Analytics_Tests: XCTestCase { analytics.storage.hardReset(doYouKnowHowToUseThis: true) - @Atomic var completionCalled = false + let expectation = XCTestExpectation() // put an event in the pipe ... analytics.track(name: "completion test1") + + RunLoop.main.run(until: .distantPast) + // flush it, that'll get us an upload going analytics.flush { // verify completion is called. - completionCalled = true + expectation.fulfill() } - while !completionCalled { - RunLoop.main.run(until: Date.distantPast) - } + #if os(iOS) + wait(for: [expectation]) + #else + wait(for: [expectation], timeout: 5) + #endif - XCTAssertTrue(completionCalled) XCTAssertNil(analytics.pendingUploads) } @@ -787,18 +791,17 @@ final class Analytics_Tests: XCTestCase { analytics.storage.hardReset(doYouKnowHowToUseThis: true) - @Atomic var completionCalled = false - + let expectation = XCTestExpectation() // put an event in the pipe ... analytics.track(name: "completion test1") // flush it, that'll get us an upload going analytics.flush { // verify completion is called. - completionCalled = true + expectation.fulfill() } - // completion shouldn't be called before flush returned. - XCTAssertTrue(completionCalled) + wait(for: [expectation], timeout: 10) + XCTAssertNil(analytics.pendingUploads) // put another event in the pipe. @@ -921,4 +924,74 @@ final class Analytics_Tests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } #endif + + func testAnonIDGenerator() throws { + class MyAnonIdGenerator: AnonymousIdGenerator { + var currentId: String = "blah-" + func newAnonymousId() -> String { + currentId = currentId + "1" + return currentId + } + } + + // need to clear settings for this one. + UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.anonIdGenerator") + + let anonIdGenerator = MyAnonIdGenerator() + var analytics: Analytics? = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator)) + let outputReader = OutputReaderPlugin() + analytics?.add(plugin: outputReader) + + waitUntilStarted(analytics: analytics) + XCTAssertEqual(analytics?.anonymousId, "blah-1") + + analytics?.track(name: "Test1") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1") + XCTAssertEqual(anonIdGenerator.currentId, "blah-1") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.track(name: "Test2") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1") + XCTAssertEqual(anonIdGenerator.currentId, "blah-1") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.reset() + + analytics?.track(name: "Test3") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11") + XCTAssertEqual(anonIdGenerator.currentId, "blah-11") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.identify(userId: "Roger") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11") + XCTAssertEqual(anonIdGenerator.currentId, "blah-11") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.reset() + + analytics?.screen(title: "Screen") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111") + XCTAssertEqual(anonIdGenerator.currentId, "blah-111") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + // get rid of this instance, leave it time to go away ... + // ... also let any state updates happen as handlers get called async + RunLoop.main.run(until: .distantPast) + analytics = nil + // ... give it some time to release all it's stuff. + RunLoop.main.run(until: .distantPast) + + // make sure it makes it to the next instance + analytics = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator)) + analytics?.add(plugin: outputReader) + + waitUntilStarted(analytics: analytics) + + // same anonId as last time, yes? + analytics?.screen(title: "Screen") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111") + XCTAssertEqual(anonIdGenerator.currentId, "blah-111") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + } } diff --git a/Tests/Segment-Tests/Atomic_Tests.swift b/Tests/Segment-Tests/Atomic_Tests.swift new file mode 100644 index 00000000..6e1d1216 --- /dev/null +++ b/Tests/Segment-Tests/Atomic_Tests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Segment + +final class Atomic_Tests: XCTestCase { + + func testAtomicIncrement() { + + @Atomic var counter = 0 + + DispatchQueue.concurrentPerform(iterations: 1000) { _ in + // counter += 1 would fail, because it is expanded to: + // `let oldValue = queue.sync { counter }` + // `queue.sync { counter = oldValue + 1 }` + // And the threads are free to suspend in between the two calls to `queue.sync`. + + _counter.withValue { value in + value += 1 + } + } + + XCTAssertEqual(counter, 1000) + } +} diff --git a/Tests/Segment-Tests/CompletionGroup_Tests.swift b/Tests/Segment-Tests/CompletionGroup_Tests.swift new file mode 100644 index 00000000..a57fd82c --- /dev/null +++ b/Tests/Segment-Tests/CompletionGroup_Tests.swift @@ -0,0 +1,67 @@ +// +// CompletionGroup_Tests.swift +// +// +// Created by Brandon Sneed on 4/17/24. +// + +import XCTest +@testable import Segment + +final class CompletionGroup_Tests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + /*func testCompletionGroup() throws { + defer { + RunLoop.main.run() + } + + //let flushQueue = DispatchQueue(label: "com.segment.flush") + let flushQueue = DispatchQueue(label: "com.segment.flush", attributes: .concurrent) + + let group = CompletionGroup(queue: flushQueue) + + group.add { group in + group.enter() + print("item1 - sleeping 10") + sleep(10) + print("item1 - done sleeping") + group.leave() + } + + group.add { group in + group.enter() + print("item2 - launching an async task") + DispatchQueue.global(qos: .background).async { + print("item2 - background, sleeping 5") + sleep(5) + print("item2 - background, done sleeping") + group.leave() + } + } + + group.add { group in + group.enter() + print("item3 - returning real quick") + group.leave() + } + + group.add { group in + print("item4 - not entering group") + } + + group.run(mode: .asynchronous) { + print("all items completed.") + } + + print("test exited.") + }*/ + +} diff --git a/Tests/Segment-Tests/JSON_Tests.swift b/Tests/Segment-Tests/JSON_Tests.swift index fd744771..43f13cf9 100644 --- a/Tests/Segment-Tests/JSON_Tests.swift +++ b/Tests/Segment-Tests/JSON_Tests.swift @@ -6,7 +6,7 @@ // import XCTest -import JSONSafeEncoder +import JSONSafeEncoding @testable import Segment struct Personal: Codable { diff --git a/build.sh b/build.sh index d7184c19..e7d3602a 100755 --- a/build.sh +++ b/build.sh @@ -18,11 +18,11 @@ rm Segment.zip rm Sovran.zip rm Segment.sha256 rm Sovran.sha256 -rm JSONSafeEncoder.zip -rm JSONSafeEncoder.sha256 +rm JSONSafeEncoding.zip +rm JSONSafeEncoding.sha256 echo "Building XCFrameworks ..." -swift create-xcframework --clean --platform ios --platform macos --platform maccatalyst --platform tvos --platform watchos --stack-evolution --zip Segment Sovran JSONSafeEncoder +swift create-xcframework --clean --platform ios --platform macos --platform maccatalyst --platform tvos --platform watchos --stack-evolution --zip Segment Sovran JSONSafeEncoding echo "Done." \ No newline at end of file diff --git a/release.sh b/release.sh index 19877bd5..3bc204c2 100755 --- a/release.sh +++ b/release.sh @@ -147,8 +147,8 @@ rm $tempFile gh release upload $newVersion ${PRODUCT_NAME}.zip gh release upload $newVersion ${PRODUCT_NAME}.sha256 -# SPECIAL CASE: We need to upload Sovran and JSONSafeEncoder to save them time. +# SPECIAL CASE: We need to upload Sovran and JSONSafeEncoding to save them time. gh release upload $newVersion Sovran.zip gh release upload $newVersion Sovran.sha256 -gh release upload $newVersion JSONSafeEncoder.zip -gh release upload $newVersion JSONSafeEncoder.sha256 +gh release upload $newVersion JSONSafeEncoding.zip +gh release upload $newVersion JSONSafeEncoding.sha256