Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 76 additions & 0 deletions template/Modules/Analytics/Sources/Analytics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
public final class Analytics: AnalyticsProtocol {

public static let shared: AnalyticsProtocol = Analytics()

private var trackers: [AnalyticsTracker] = []

private init() {}

// MARK: - Setup

public func configure(trackers: [AnalyticsTracker], additionalParameters: [String: Any]? = nil) {
self.trackers = trackers
trackers.forEach { $0.setUp(additionalParameters: additionalParameters) }
}

public func addTracker(_ tracker: AnalyticsTracker, additionalParameters: [String: Any]? = nil) {
trackers.append(tracker)
tracker.setUp(additionalParameters: additionalParameters)
}
Comment on lines +5 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the complete Analytics.swift file
cat -n template/Modules/Analytics/Sources/Analytics.swift

Repository: nimblehq/ios-templates

Length of output: 3548


🏁 Script executed:

# Search for calls to Analytics.shared or Analytics() in the codebase to understand usage patterns
rg "Analytics\.shared|Analytics\(\)" --max-count=50

Repository: nimblehq/ios-templates

Length of output: 2279


🏁 Script executed:

# Check if there are any synchronization primitives (DispatchQueue, Lock, etc.) in the Analytics module
rg "DispatchQueue|os_unfair_lock|NSLock|OperationQueue" template/Modules/Analytics/

Repository: nimblehq/ios-templates

Length of output: 315


🏁 Script executed:

# Look at the AnalyticsProtocol to understand the interface contract
find template/Modules/Analytics -name "*.swift" -type f | head -20

Repository: nimblehq/ios-templates

Length of output: 761


🏁 Script executed:

# Let's check the test file to understand how this is being tested
cat -n template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift | head -100

Repository: nimblehq/ios-templates

Length of output: 4348


🏁 Script executed:

# Check if App.swift initialization happens on main thread and how analytics is used
cat -n template/Tuist/Interfaces/SwiftUI/Sources/Application/App.swift | head -60

Repository: nimblehq/ios-templates

Length of output: 684


🏁 Script executed:

# Look for any dispatcher/queue patterns in the Analytics module or test setup
rg -A 5 -B 5 "configure|addTracker" template/Modules/Analytics/Tests/

Repository: nimblehq/ios-templates

Length of output: 14836


Thread safety: trackers is mutated and read without synchronization, and this becomes critical if configure()/addTracker() are called from multiple threads.

Analytics.shared is a process-wide singleton invoked from many threads (main thread for UI, background threads from network/Combine callbacks). The trackers array is mutated by configure()/addTracker() (lines 12, 17) and read by trackEvent / trackScreen / setUserProperty / setUserId / tracker(for:) (lines 28, 47, 58, 67, 74). Swift Array is not thread-safe: concurrent append during .filter/.first read causes a data race and can crash.

In this template's current usage, configure() is called once in App.init() on the main thread, after which only reads occur—so the practical risk is low. However, if addTracker() or configure() are called dynamically from a background thread (e.g., switching analytics providers), the race becomes critical.

Option A (recommended): Guard with a serial dispatch queue:

public final class Analytics: AnalyticsProtocol {
    public static let shared: AnalyticsProtocol = Analytics()
    
    private var trackers: [AnalyticsTracker] = []
+   private let queue = DispatchQueue(label: "co.nimblehq.analytics", qos: .utility)
    
    private init() {}
    
    public func configure(trackers: [AnalyticsTracker], additionalParameters: [String: Any]? = nil) {
-       self.trackers = trackers
+       queue.sync { self.trackers = trackers }
        trackers.forEach { $0.setUp(additionalParameters: additionalParameters) }
    }
    
    public func addTracker(_ tracker: AnalyticsTracker, additionalParameters: [String: Any]? = nil) {
-       trackers.append(tracker)
+       queue.sync { trackers.append(tracker) }
        tracker.setUp(additionalParameters: additionalParameters)
    }

Then wrap each read: let targetTrackers = queue.sync { trackers.filter { ... } } (lines 28, 47, 58, 67, 74).

Option B: Document main-thread-only usage: Add a // MARK: - Thread Safety comment clarifying that configure() and addTracker() must be called only from the main thread during app initialization to prevent future regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@template/Modules/Analytics/Sources/Analytics.swift` around lines 5 - 19, The
trackers array is accessed without synchronization; protect all mutations and
reads by adding a private serial DispatchQueue (e.g., let trackersQueue =
DispatchQueue(label: "Analytics.trackers")) and use it to serialize access in
configure(trackers:), addTracker(_:,), and all reader methods (trackEvent,
trackScreen, setUserProperty, setUserId, tracker(for:)) — perform writes with
trackersQueue.async or .sync as appropriate and perform reads by capturing the
needed snapshot via trackersQueue.sync { return trackers.filter { ... } } so
callers operate on a stable copy; alternatively, if you choose not to add
synchronization, add a clear "Thread Safety" comment documenting that
configure() and addTracker() must only be called from the main thread during app
initialization.


// MARK: - Event Tracking

public func trackEvent(name: String, parameters: [String: Any]?) {
trackEvent(name: name, parameters: parameters, on: AnalyticsTrackerType.allCases)
}

public func trackEvent(name: String, parameters: [String: Any]?, on trackerTypes: [AnalyticsTrackerType]) {
let targetTrackers = trackers.filter { trackerTypes.contains($0.type) }
targetTrackers.forEach { $0.trackEvent(name: name, parameters: parameters) }
}

public func trackEvent(_ event: AnalyticsEvent) {
trackEvent(event, on: AnalyticsTrackerType.allCases)
}

public func trackEvent(_ event: AnalyticsEvent, on trackerTypes: [AnalyticsTrackerType]) {
trackEvent(name: event.name, parameters: event.parameters, on: trackerTypes)
}

// MARK: - Screen Tracking

public func trackScreen(name: String, screenClass: String?) {
trackScreen(name: name, screenClass: screenClass, on: AnalyticsTrackerType.allCases)
}

public func trackScreen(name: String, screenClass: String?, on trackerTypes: [AnalyticsTrackerType]) {
let targetTrackers = trackers.filter { trackerTypes.contains($0.type) }
targetTrackers.forEach { $0.trackScreen(name: name, screenClass: screenClass) }
}

// MARK: - User Properties

public func setUserProperty(key: String, value: String) {
setUserProperty(key: key, value: value, on: AnalyticsTrackerType.allCases)
}

public func setUserProperty(key: String, value: String, on trackerTypes: [AnalyticsTrackerType]) {
let targetTrackers = trackers.filter { trackerTypes.contains($0.type) }
targetTrackers.forEach { $0.setUserProperty(key: key, value: value) }
}

public func setUserId(_ userId: String?) {
setUserId(userId, on: AnalyticsTrackerType.allCases)
}

public func setUserId(_ userId: String?, on trackerTypes: [AnalyticsTrackerType]) {
let targetTrackers = trackers.filter { trackerTypes.contains($0.type) }
targetTrackers.forEach { $0.setUserId(userId) }
}

// MARK: - Utility

public func tracker(for type: AnalyticsTrackerType) -> AnalyticsTracker? {
trackers.first { $0.type == type }
}
}
5 changes: 5 additions & 0 deletions template/Modules/Analytics/Sources/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public protocol AnalyticsEvent {

var name: String { get }
var parameters: [String: Any]? { get }
}
Comment on lines +1 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm no AnalyticsParameter type exists in the module.
rg -nP --type=swift -C2 '\bAnalyticsParameter\b'

Repository: nimblehq/ios-templates

Length of output: 281


🏁 Script executed:

#!/bin/bash
# Search for AnalyticsParameter in Swift files only, ignoring gitignore parsing
cd template && rg -nP --type=swift '\bAnalyticsParameter\b' 2>/dev/null || echo "No matches found"

Repository: nimblehq/ios-templates

Length of output: 83


🏁 Script executed:

#!/bin/bash
# Check the full AnalyticsEvent.swift file
cd template && find . -name "AnalyticsEvent.swift" -type f -exec cat -n {} \;

Repository: nimblehq/ios-templates

Length of output: 213


🏁 Script executed:

#!/bin/bash
# Look for any parameter-related enums or protocols in the Analytics module
cd template && find ./Modules/Analytics -name "*.swift" -type f | head -20

Repository: nimblehq/ios-templates

Length of output: 684


🏁 Script executed:

#!/bin/bash
# Check if there are any other analytics-related files that might define types
cd template && ls -la Modules/Analytics/Sources/ 2>/dev/null || echo "Directory not found"

Repository: nimblehq/ios-templates

Length of output: 745


🏁 Script executed:

#!/bin/bash
# Check AnalyticsProtocol.swift to understand the broader API
cd template && cat -n Modules/Analytics/Sources/AnalyticsProtocol.swift

Repository: nimblehq/ios-templates

Length of output: 2114


🏁 Script executed:

#!/bin/bash
# Check ConsoleAnalyticsTracker to see how parameters are actually handled
cd template && cat -n Modules/Analytics/Sources/ConsoleAnalyticsTracker.swift

Repository: nimblehq/ios-templates

Length of output: 2063


🏁 Script executed:

#!/bin/bash
# Check Analytics.swift for usage context
cd template && cat -n Modules/Analytics/Sources/Analytics.swift

Repository: nimblehq/ios-templates

Length of output: 3548


🏁 Script executed:

#!/bin/bash
# Look for any references to issue `#701` or PR objectives in the codebase
cd template && grep -r "701\|AnalyticsParameter" . --include="*.md" --include="*.swift" 2>/dev/null | head -20

Repository: nimblehq/ios-templates

Length of output: 48


AnalyticsEvent uses untyped [String: Any]? parameters without type-safe wrapper.

The protocol currently exposes parameters as [String: Any]?, which:

  • Loses type-safety at call sites and in tracker implementations (e.g., ConsoleAnalyticsTracker handles parameters via .description with no compile-time guarantees).
  • Prevents AnalyticsEvent and related types from conforming to Sendable, blocking compatibility with Swift's strict concurrency model when events are dispatched from non-@MainActor contexts.

Consider introducing an AnalyticsParameter enum (e.g., cases for string, int, double, bool, date) and updating AnalyticsEvent.parameters to use [String: AnalyticsParameter]?. This would allow trackers to safely convert to their SDK-native representation in one place while maintaining Sendable conformance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@template/Modules/Analytics/Sources/AnalyticsEvent.swift` around lines 1 - 5,
Change the untyped parameters on AnalyticsEvent to a type-safe wrapper: add an
enum AnalyticsParameter (cases like string, int, double, bool, date) that
conforms to Sendable, update the protocol signature from var parameters:
[String: Any]? to var parameters: [String: AnalyticsParameter]?, and update all
implementations (e.g., ConsoleAnalyticsTracker) to convert AnalyticsParameter to
the tracker/SDK-native representation in one place; ensure AnalyticsParameter
and AnalyticsEvent conform to Sendable where appropriate and update any
callers/tests to construct AnalyticsParameter values instead of raw Any.

51 changes: 51 additions & 0 deletions template/Modules/Analytics/Sources/AnalyticsProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
public protocol AnalyticsProtocol {

// MARK: - Setup

func configure(trackers: [AnalyticsTracker], additionalParameters: [String: Any]?)
func addTracker(_ tracker: AnalyticsTracker, additionalParameters: [String: Any]?)

// MARK: - Event Tracking

func trackEvent(name: String, parameters: [String: Any]?)
func trackEvent(name: String, parameters: [String: Any]?, on trackerTypes: [AnalyticsTrackerType])
func trackEvent(_ event: AnalyticsEvent)
func trackEvent(_ event: AnalyticsEvent, on trackerTypes: [AnalyticsTrackerType])

// MARK: - Screen Tracking

func trackScreen(name: String, screenClass: String?)
func trackScreen(name: String, screenClass: String?, on trackerTypes: [AnalyticsTrackerType])

// MARK: - User Properties

func setUserProperty(key: String, value: String)
func setUserProperty(key: String, value: String, on trackerTypes: [AnalyticsTrackerType])
func setUserId(_ userId: String?)
func setUserId(_ userId: String?, on trackerTypes: [AnalyticsTrackerType])

// MARK: - Utility

func tracker(for type: AnalyticsTrackerType) -> AnalyticsTracker?
}

// MARK: - Default Parameters

public extension AnalyticsProtocol {

func configure(trackers: [AnalyticsTracker]) {
configure(trackers: trackers, additionalParameters: nil)
}

func addTracker(_ tracker: AnalyticsTracker) {
addTracker(tracker, additionalParameters: nil)
}

func trackEvent(name: String) {
trackEvent(name: name, parameters: nil)
}

func trackScreen(name: String) {
trackScreen(name: name, screenClass: nil)
}
}
31 changes: 31 additions & 0 deletions template/Modules/Analytics/Sources/AnalyticsTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
public protocol AnalyticsTracker {

var type: AnalyticsTrackerType { get }

func setUp(additionalParameters: [String: Any]?)
func trackEvent(name: String, parameters: [String: Any]?)
func trackScreen(name: String, screenClass: String?)
func setUserProperty(key: String, value: String)
func setUserId(_ userId: String?)
}

// MARK: - Extensions

public extension AnalyticsTracker {

func setUp() {
setUp(additionalParameters: nil)
}

func trackEvent(name: String) {
trackEvent(name: name, parameters: nil)
}

func trackEvent(_ event: AnalyticsEvent) {
trackEvent(name: event.name, parameters: event.parameters)
}

func trackScreen(name: String) {
trackScreen(name: name, screenClass: nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
public enum AnalyticsTrackerType: String, CaseIterable {

case console
}
Comment on lines +1 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Enum only defines .console but tests reference .firebase — module won’t compile.

template/Modules/Analytics/Tests/Sources/Specs/MockAnalyticsTrackerSpec.swift uses MockAnalyticsTracker(type: .firebase) in every test. With only .console defined here, the Analytics test target will fail to build.

Decide on one of two fixes and apply it consistently:

  • If the intent of this foundation PR is console-only, update the tests to use .console.
  • Otherwise, extend the enum to cover the tracker types referenced in tests (and implied by the PR description: Firebase/AppsFlyer/Facebook).
🛠 Suggested extension if you want to keep the `.firebase` test references
 public enum AnalyticsTrackerType: String, CaseIterable {
-    
-    case console
+
+    case console
+    case firebase
+    case appsFlyer
+    case facebook
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public enum AnalyticsTrackerType: String, CaseIterable {
case console
}
public enum AnalyticsTrackerType: String, CaseIterable {
case console
case firebase
case appsFlyer
case facebook
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@template/Modules/Analytics/Sources/AnalyticsTrackerType.swift` around lines 1
- 4, The enum AnalyticsTrackerType currently only defines case .console but
tests create MockAnalyticsTracker(type: .firebase), causing build failures;
either update all test usages (e.g., MockAnalyticsTracker(type: .firebase)) to
use .console, or add the missing cases to the enum by extending
AnalyticsTrackerType to include .firebase, .appsFlyer, and .facebook (keep the
rawValue strings as "firebase", "appsFlyer", "facebook" to match test/PR
expectations) so MockAnalyticsTracker(type: .firebase) compiles; pick one
approach and apply it consistently across the tests and enum declarations (refer
to AnalyticsTrackerType and MockAnalyticsTracker(type: .firebase) when making
the change).

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// Console tracker that logs analytics events to the console for debugging
public final class ConsoleAnalyticsTracker: AnalyticsTracker {

public let type: AnalyticsTrackerType
private let prefix: String

public init(type: AnalyticsTrackerType, logPrefix: String? = nil) {
self.type = type
self.prefix = logPrefix ?? "[\(type.rawValue.uppercased())]"
}

// MARK: - AnalyticsTracker Implementation

public func setUp(additionalParameters: [String: Any]?) {
let paramsString = additionalParameters?.description ?? "none"
print("\(prefix) SETUP - Additional parameters: \(paramsString)")
}

public func trackEvent(name: String, parameters: [String: Any]?) {
var message = "\(prefix) EVENT - \(name)"

if let parameters = parameters, !parameters.isEmpty {
let paramsString = parameters.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
message += " | Parameters: {\(paramsString)}"
}

print(message)
}

public func trackScreen(name: String, screenClass: String?) {
var message = "\(prefix) SCREEN - \(name)"

if let screenClass = screenClass {
message += " | Class: \(screenClass)"
}

print(message)
}

public func setUserProperty(key: String, value: String) {
print("\(prefix) USER_PROPERTY - \(key): \(value)")
}

public func setUserId(_ userId: String?) {
let userIdString = userId ?? "null"
print("\(prefix) USER_ID - \(userIdString)")
}
}
Comment thread
thinh2k1310 marked this conversation as resolved.
20 changes: 20 additions & 0 deletions template/Modules/Analytics/Sources/Events/UserLoginEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// Example analytics event for user login
public struct UserLoginEvent: AnalyticsEvent {

public let name = "user_login"

public let loginMethod: String
public let isSuccessful: Bool

public init(loginMethod: String, isSuccessful: Bool = true) {
self.loginMethod = loginMethod
self.isSuccessful = isSuccessful
}

public var parameters: [String: Any]? {
return [
"login_method": loginMethod,
"is_successful": isSuccessful
]
}
}
64 changes: 64 additions & 0 deletions template/Modules/Analytics/Sources/MockAnalyticsTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/// Mock tracker for testing purposes
public final class MockAnalyticsTracker: AnalyticsTracker {

public let type: AnalyticsTrackerType

// MARK: - Tracked Data

public private(set) var isSetUp = false
public private(set) var setupParameters: [String: Any]?
public private(set) var trackedEvents: [(name: String, parameters: [String: Any]?)] = []
public private(set) var trackedScreens: [(name: String, screenClass: String?)] = []
public private(set) var userProperties: [String: String] = [:]
public private(set) var userId: String?

public init(type: AnalyticsTrackerType) {
self.type = type
}

// MARK: - AnalyticsTracker Implementation

public func setUp(additionalParameters: [String: Any]?) {
isSetUp = true
setupParameters = additionalParameters
}

public func trackEvent(name: String, parameters: [String: Any]?) {
trackedEvents.append((name: name, parameters: parameters))
}

public func trackScreen(name: String, screenClass: String?) {
trackedScreens.append((name: name, screenClass: screenClass))
}

public func setUserProperty(key: String, value: String) {
userProperties[key] = value
}

public func setUserId(_ userId: String?) {
self.userId = userId
}

// MARK: - Test Helpers

public func reset() {
isSetUp = false
setupParameters = nil
trackedEvents.removeAll()
trackedScreens.removeAll()
userProperties.removeAll()
userId = nil
}

public func hasTrackedEvent(name: String) -> Bool {
trackedEvents.contains { $0.name == name }
}

public func hasTrackedScreen(name: String) -> Bool {
trackedScreens.contains { $0.name == name }
}

public func eventCount(for name: String) -> Int {
trackedEvents.filter { $0.name == name }.count
}
}
Comment on lines +1 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

MockAnalyticsTracker lives in production Sources and ships with the module.

This file is under template/Modules/Analytics/Sources/, which means it becomes part of the Analytics module that App / Domain link against in release builds. A mock intended for tests shouldn't be a publicly exported API of the production framework — besides the binary size cost, it invites accidental use from real code.

Recommended options:

  • Move this type into a test-only target (e.g. the Analytics tests target, or a dedicated AnalyticsTestSupport target).
  • At minimum, wrap the entire file in #if DEBUG so release builds don't ship it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@template/Modules/Analytics/Sources/MockAnalyticsTracker.swift` around lines 1
- 64, The MockAnalyticsTracker class is shipping in the production Analytics
module; either move the MockAnalyticsTracker type (public final class
MockAnalyticsTracker) out of template/Modules/Analytics/Sources into a test-only
target (e.g., AnalyticsTests or a new AnalyticsTestSupport target) so it is not
compiled into release builds, or wrap the entire file with a compile-time guard
(`#if` DEBUG ... `#endif`) to exclude it from release; after moving/wrapping ensure
the symbol MockAnalyticsTracker is no longer exported from the Analytics module
and update any test imports to reference the new test target or the conditional
compilation path.

Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Testing
@testable import Analytics

@Suite("UserLoginEvent Tests")
struct UserLoginEventTests {

@Test("Create event with correct parameters")
func createEventWithCorrectParameters() {
let event = UserLoginEvent(loginMethod: "email", isSuccessful: true)

#expect(event.name == "user_login")
#expect(event.parameters?["login_method"] as? String == "email")
#expect(event.parameters?["is_successful"] as? Bool == true)
}

@Test("Handle failed login")
func handleFailedLogin() {
let event = UserLoginEvent(loginMethod: "social", isSuccessful: false)

#expect(event.parameters?["is_successful"] as? Bool == false)
#expect(event.parameters?["login_method"] as? String == "social")
}
}
Loading