From 77b92f76364600b12983fa1680efbdfd22b97525 Mon Sep 17 00:00:00 2001 From: "David Okun (LTK)" Date: Fri, 3 May 2024 13:02:23 -0500 Subject: [PATCH 1/3] Initial commit --- .github/workflows/build-and-test.yml | 24 ++++++++ Package.swift | 40 +++++++++++++ Sources/LTKAnalytics/Private/EventBus.swift | 59 +++++++++++++++++++ .../LTKAnalytics/Public/AnalyticsAPI.swift | 33 +++++++++++ .../LTKAnalytics/Public/AnalyticsError.swift | 17 ++++++ Sources/LTKAnalytics/Public/EventType.swift | 33 +++++++++++ Sources/LTKAnalytics/Public/Events.swift | 35 +++++++++++ Tests/LTKAnalyticsTests/PublicAPITests.swift | 39 ++++++++++++ 8 files changed, 280 insertions(+) create mode 100644 .github/workflows/build-and-test.yml create mode 100644 Package.swift create mode 100644 Sources/LTKAnalytics/Private/EventBus.swift create mode 100644 Sources/LTKAnalytics/Public/AnalyticsAPI.swift create mode 100644 Sources/LTKAnalytics/Public/AnalyticsError.swift create mode 100644 Sources/LTKAnalytics/Public/EventType.swift create mode 100644 Sources/LTKAnalytics/Public/Events.swift create mode 100644 Tests/LTKAnalyticsTests/PublicAPITests.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..3fa5975 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,24 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Build and Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-14 + + steps: + - uses: actions/checkout@v3 + - name: Switch Xcode Version + run: sudo xcode-select -s /Applications/Xcode_15.3.app/Contents/Developer + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..d969716 --- /dev/null +++ b/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LTKAnalytics-iOS", + platforms: [ + .macOS( + .v14 + ), + .iOS( + .v16 + ), + .tvOS( + .v16 + ), + .watchOS( + .v9 + ), + .visionOS( + .v1 + ) + ], + products: [ + .library( + name: "LTKAnalytics", + targets: ["LTKAnalytics"] + ), + ], + targets: [ + .target( + name: "LTKAnalytics" + ), + .testTarget( + name: "LTKAnalyticsTests", + dependencies: ["LTKAnalytics"] + ), + ] +) diff --git a/Sources/LTKAnalytics/Private/EventBus.swift b/Sources/LTKAnalytics/Private/EventBus.swift new file mode 100644 index 0000000..0e5b4c4 --- /dev/null +++ b/Sources/LTKAnalytics/Private/EventBus.swift @@ -0,0 +1,59 @@ +// +// EventBus.swift +// +// +// Created by David Okun on 5/3/24. +// + +import Foundation +import os + +internal actor EventBus { + private let logger = os.Logger( + subsystem: "LTKAnalytics", + category: "EventBus" + ) + private var queue = [AnalyticsEvent]() + + init() { + logger.trace( + #function + ) + } + + internal func addEventToQueue( + _ event: AnalyticsEvent + ) async throws { + // TODO: do this in a better way + let existingEvents = queue.filter { $0.id == event.id} + if existingEvents.count > 0 { + throw AnalyticsError.duplicateEvent(id: event.id) + } + if await checkForPotentialRapidFire(event) { + await dispatchEvent(event) + logger.critical("potential duplicate event fired: \(event.loggingDescription)") + // throw error of potential duplicate, but this is more of a warning + throw AnalyticsError.potentialRapidFire(type: event.type) + } + await dispatchEvent(event) + } + + private func dispatchEvent(_ event: AnalyticsEvent) async { + queue.append(event) + // TODO: actually send events to a stream where they are processed and sent off + } + + private func checkForPotentialRapidFire(_ event: AnalyticsEvent) async -> Bool { + let existingEvents = queue.filter { existingEvent in + existingEvent.type == event.type && + abs(existingEvent.timestamp.timeIntervalSince(event.timestamp)) <= 0.25 + } + return existingEvents.count > 0 + } + + deinit { + logger.trace( + #function + ) + } +} diff --git a/Sources/LTKAnalytics/Public/AnalyticsAPI.swift b/Sources/LTKAnalytics/Public/AnalyticsAPI.swift new file mode 100644 index 0000000..dab70a0 --- /dev/null +++ b/Sources/LTKAnalytics/Public/AnalyticsAPI.swift @@ -0,0 +1,33 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import Foundation +import os + +public actor LTKAnalytics { + private let logger = os.Logger(subsystem: "LTKAnalytics", category: "PublicAPI") + private let eventBus = EventBus() + + public init() { + logger.trace(#function) + } + + @discardableResult + public func recordEvent(_ type: EventType, attributes: [String: String]? = nil) async throws -> UUID { + let recordedEvent = AnalyticsEvent(type: type, attributes: attributes) + do { + try await eventBus.addEventToQueue(recordedEvent) + return recordedEvent.id + } catch AnalyticsError.duplicateEvent(let id) { + logger.warning("received duplicate event: \(id, privacy: .public)") + throw AnalyticsError.duplicateEvent(id: id) + } catch AnalyticsError.potentialRapidFire(let type) { + logger.critical("potential duplicate event fired: \(type.description)") + return recordedEvent.id + } + } + + deinit { + logger.trace(#function) + } +} diff --git a/Sources/LTKAnalytics/Public/AnalyticsError.swift b/Sources/LTKAnalytics/Public/AnalyticsError.swift new file mode 100644 index 0000000..9101c8f --- /dev/null +++ b/Sources/LTKAnalytics/Public/AnalyticsError.swift @@ -0,0 +1,17 @@ +// +// AnalyticsError.swift +// +// +// Created by David Okun on 5/3/24. +// + +import Foundation + +public enum AnalyticsError: Error { + case duplicateEvent( + id: UUID + ) + case potentialRapidFire( + type: EventType + ) +} diff --git a/Sources/LTKAnalytics/Public/EventType.swift b/Sources/LTKAnalytics/Public/EventType.swift new file mode 100644 index 0000000..8969a7e --- /dev/null +++ b/Sources/LTKAnalytics/Public/EventType.swift @@ -0,0 +1,33 @@ +// +// File.swift +// +// +// Created by David Okun on 5/3/24. +// + +import Foundation + +public enum EventType: Equatable { + case applicationBecameActive + case applicationFinishedLoading + case loadedHomeFeed + case postImpression(id: String) + case profileImpression(id: String) +} + +extension EventType: CustomStringConvertible { + public var description: String { + switch self { + case .applicationBecameActive: + return "Application Became Active" + case .applicationFinishedLoading: + return "Application Finished Loading" + case .loadedHomeFeed: + return "Loaded Home Feed" + case .postImpression(let id): + return "Post Impression (ID: \(id))" + case .profileImpression(let id): + return "Profile Impression (ID: \(id))" + } + } +} diff --git a/Sources/LTKAnalytics/Public/Events.swift b/Sources/LTKAnalytics/Public/Events.swift new file mode 100644 index 0000000..569d218 --- /dev/null +++ b/Sources/LTKAnalytics/Public/Events.swift @@ -0,0 +1,35 @@ +// +// AnalyticsEvent.swift +// +// +// Created by David Okun on 5/3/24. +// + +import Foundation + +public struct AnalyticsEvent: Identifiable { + internal let type: EventType + internal let attributes: [String: String]? + internal let timestamp: Date + public let id: UUID + + init( + type: EventType, + attributes: [String : String]? = nil + ) { + self.id = UUID() + self.timestamp = Date() + self.type = type + self.attributes = attributes + } + + public var loggingDescription: String { + """ + + ID: \(id.uuidString) + type: \(type.description) + timestamp: \(timestamp.ISO8601Format()) + attributes: \(String(describing: attributes)) + """ + } +} diff --git a/Tests/LTKAnalyticsTests/PublicAPITests.swift b/Tests/LTKAnalyticsTests/PublicAPITests.swift new file mode 100644 index 0000000..7696ad2 --- /dev/null +++ b/Tests/LTKAnalyticsTests/PublicAPITests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import LTKAnalytics + +final class PublicAPITests: XCTestCase { + private var publicAPI: LTKAnalytics? + + override func setUp() async throws { + publicAPI = LTKAnalytics() + } + + func testExample() async throws { + do { + let eventID = try await publicAPI?.recordEvent(.loadedHomeFeed) + XCTAssertNotNil(eventID) + } catch { + XCTFail(error.localizedDescription) + } + } + + func testThatTwoSeparateEventsHaveSeparateIDs() async throws { + do { + let first = try await publicAPI?.recordEvent(.loadedHomeFeed) + let second = try await publicAPI?.recordEvent(.postImpression(id: "8675309")) + XCTAssertNotEqual(first, second, "two events of different types fired should have different IDs") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testThatTwoIdenticalEventsHaveSeparateIDs() async throws { + do { + let first = try await publicAPI?.recordEvent(.loadedHomeFeed) + let second = try await publicAPI?.recordEvent(.loadedHomeFeed) + XCTAssertNotEqual(first, second, "two events of the same type fired should have different IDs") + } catch { + XCTFail(error.localizedDescription) + } + } +} From f03c50a3cd2de5042a8bf091b463ad8392065f00 Mon Sep 17 00:00:00 2001 From: "David Okun (LTK)" Date: Fri, 3 May 2024 13:05:22 -0500 Subject: [PATCH 2/3] Formatting --- Sources/LTKAnalytics/Private/EventBus.swift | 50 ++++++++++++----- .../LTKAnalytics/Public/AnalyticsAPI.swift | 54 ++++++++++++++----- Sources/LTKAnalytics/Public/EventType.swift | 32 ++++++----- Sources/LTKAnalytics/Public/Events.swift | 4 +- Tests/LTKAnalyticsTests/PublicAPITests.swift | 54 ++++++++++++++----- 5 files changed, 141 insertions(+), 53 deletions(-) diff --git a/Sources/LTKAnalytics/Private/EventBus.swift b/Sources/LTKAnalytics/Private/EventBus.swift index 0e5b4c4..fc62b71 100644 --- a/Sources/LTKAnalytics/Private/EventBus.swift +++ b/Sources/LTKAnalytics/Private/EventBus.swift @@ -25,28 +25,52 @@ internal actor EventBus { _ event: AnalyticsEvent ) async throws { // TODO: do this in a better way - let existingEvents = queue.filter { $0.id == event.id} + let existingEvents = queue.filter { + $0.id == event.id + } if existingEvents.count > 0 { - throw AnalyticsError.duplicateEvent(id: event.id) + throw AnalyticsError.duplicateEvent( + id: event.id + ) } - if await checkForPotentialRapidFire(event) { - await dispatchEvent(event) - logger.critical("potential duplicate event fired: \(event.loggingDescription)") + if await checkForPotentialRapidFire( + event + ) { + await dispatchEvent( + event + ) + logger.critical( + "potential duplicate event fired: \(event.loggingDescription)" + ) // throw error of potential duplicate, but this is more of a warning - throw AnalyticsError.potentialRapidFire(type: event.type) + throw AnalyticsError.potentialRapidFire( + type: event.type + ) } - await dispatchEvent(event) + await dispatchEvent( + event + ) } - - private func dispatchEvent(_ event: AnalyticsEvent) async { - queue.append(event) + + private func dispatchEvent( + _ event: AnalyticsEvent + ) async { + queue.append( + event + ) // TODO: actually send events to a stream where they are processed and sent off } - - private func checkForPotentialRapidFire(_ event: AnalyticsEvent) async -> Bool { + + private func checkForPotentialRapidFire( + _ event: AnalyticsEvent + ) async -> Bool { let existingEvents = queue.filter { existingEvent in existingEvent.type == event.type && - abs(existingEvent.timestamp.timeIntervalSince(event.timestamp)) <= 0.25 + abs( + existingEvent.timestamp.timeIntervalSince( + event.timestamp + ) + ) <= 0.25 } return existingEvents.count > 0 } diff --git a/Sources/LTKAnalytics/Public/AnalyticsAPI.swift b/Sources/LTKAnalytics/Public/AnalyticsAPI.swift index dab70a0..26d7bc3 100644 --- a/Sources/LTKAnalytics/Public/AnalyticsAPI.swift +++ b/Sources/LTKAnalytics/Public/AnalyticsAPI.swift @@ -5,29 +5,55 @@ import Foundation import os public actor LTKAnalytics { - private let logger = os.Logger(subsystem: "LTKAnalytics", category: "PublicAPI") + private let logger = os.Logger( + subsystem: "LTKAnalytics", + category: "PublicAPI" + ) private let eventBus = EventBus() - + public init() { - logger.trace(#function) + logger.trace( + #function + ) } - + @discardableResult - public func recordEvent(_ type: EventType, attributes: [String: String]? = nil) async throws -> UUID { - let recordedEvent = AnalyticsEvent(type: type, attributes: attributes) + public func recordEvent( + _ type: EventType, + attributes: [String: String]? = nil + ) async throws -> UUID { + let recordedEvent = AnalyticsEvent( + type: type, + attributes: attributes + ) do { - try await eventBus.addEventToQueue(recordedEvent) + try await eventBus.addEventToQueue( + recordedEvent + ) return recordedEvent.id - } catch AnalyticsError.duplicateEvent(let id) { - logger.warning("received duplicate event: \(id, privacy: .public)") - throw AnalyticsError.duplicateEvent(id: id) - } catch AnalyticsError.potentialRapidFire(let type) { - logger.critical("potential duplicate event fired: \(type.description)") + } catch AnalyticsError.duplicateEvent( + let id + ) { + logger.warning( + "received duplicate event: \(id, + privacy: .public)" + ) + throw AnalyticsError.duplicateEvent( + id: id + ) + } catch AnalyticsError.potentialRapidFire( + let type + ) { + logger.critical( + "potential duplicate event fired: \(type.description)" + ) return recordedEvent.id } } - + deinit { - logger.trace(#function) + logger.trace( + #function + ) } } diff --git a/Sources/LTKAnalytics/Public/EventType.swift b/Sources/LTKAnalytics/Public/EventType.swift index 8969a7e..885fefc 100644 --- a/Sources/LTKAnalytics/Public/EventType.swift +++ b/Sources/LTKAnalytics/Public/EventType.swift @@ -1,5 +1,5 @@ // -// File.swift +// EventType.swift // // // Created by David Okun on 5/3/24. @@ -11,22 +11,30 @@ public enum EventType: Equatable { case applicationBecameActive case applicationFinishedLoading case loadedHomeFeed - case postImpression(id: String) - case profileImpression(id: String) + case postImpression( + id: String + ) + case profileImpression( + id: String + ) } extension EventType: CustomStringConvertible { public var description: String { switch self { - case .applicationBecameActive: - return "Application Became Active" - case .applicationFinishedLoading: - return "Application Finished Loading" - case .loadedHomeFeed: - return "Loaded Home Feed" - case .postImpression(let id): - return "Post Impression (ID: \(id))" - case .profileImpression(let id): + case .applicationBecameActive: + return "Application Became Active" + case .applicationFinishedLoading: + return "Application Finished Loading" + case .loadedHomeFeed: + return "Loaded Home Feed" + case .postImpression( + let id + ): + return "Post Impression (ID: \(id))" + case .profileImpression( + let id + ): return "Profile Impression (ID: \(id))" } } diff --git a/Sources/LTKAnalytics/Public/Events.swift b/Sources/LTKAnalytics/Public/Events.swift index 569d218..69f36e3 100644 --- a/Sources/LTKAnalytics/Public/Events.swift +++ b/Sources/LTKAnalytics/Public/Events.swift @@ -29,7 +29,9 @@ public struct AnalyticsEvent: Identifiable { ID: \(id.uuidString) type: \(type.description) timestamp: \(timestamp.ISO8601Format()) - attributes: \(String(describing: attributes)) + attributes: \(String( + describing: attributes + )) """ } } diff --git a/Tests/LTKAnalyticsTests/PublicAPITests.swift b/Tests/LTKAnalyticsTests/PublicAPITests.swift index 7696ad2..0f18a29 100644 --- a/Tests/LTKAnalyticsTests/PublicAPITests.swift +++ b/Tests/LTKAnalyticsTests/PublicAPITests.swift @@ -10,30 +10,58 @@ final class PublicAPITests: XCTestCase { func testExample() async throws { do { - let eventID = try await publicAPI?.recordEvent(.loadedHomeFeed) - XCTAssertNotNil(eventID) + let eventID = try await publicAPI?.recordEvent( + .loadedHomeFeed + ) + XCTAssertNotNil( + eventID + ) } catch { - XCTFail(error.localizedDescription) + XCTFail( + error.localizedDescription + ) } } - + func testThatTwoSeparateEventsHaveSeparateIDs() async throws { do { - let first = try await publicAPI?.recordEvent(.loadedHomeFeed) - let second = try await publicAPI?.recordEvent(.postImpression(id: "8675309")) - XCTAssertNotEqual(first, second, "two events of different types fired should have different IDs") + let first = try await publicAPI?.recordEvent( + .loadedHomeFeed + ) + let second = try await publicAPI?.recordEvent( + .postImpression( + id: "8675309" + ) + ) + XCTAssertNotEqual( + first, + second, + "two events of different types fired should have different IDs" + ) } catch { - XCTFail(error.localizedDescription) + XCTFail( + error.localizedDescription + ) } } - + func testThatTwoIdenticalEventsHaveSeparateIDs() async throws { do { - let first = try await publicAPI?.recordEvent(.loadedHomeFeed) - let second = try await publicAPI?.recordEvent(.loadedHomeFeed) - XCTAssertNotEqual(first, second, "two events of the same type fired should have different IDs") + let first = try await publicAPI?.recordEvent( + .loadedHomeFeed + ) + let second = try await publicAPI?.recordEvent( + .loadedHomeFeed + ) + XCTAssertNotEqual( + first, + second, + "two events of the same type fired should have different IDs" + ) } catch { - XCTFail(error.localizedDescription) + XCTFail( + error.localizedDescription + ) } } } From b8de5e16289402b9a385fe657c678888a9fd245e Mon Sep 17 00:00:00 2001 From: "David Okun (LTK)" Date: Fri, 3 May 2024 13:09:09 -0500 Subject: [PATCH 3/3] Aha, a bug in Xcode formatting --- Sources/LTKAnalytics/Public/AnalyticsAPI.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/LTKAnalytics/Public/AnalyticsAPI.swift b/Sources/LTKAnalytics/Public/AnalyticsAPI.swift index 26d7bc3..5076de8 100644 --- a/Sources/LTKAnalytics/Public/AnalyticsAPI.swift +++ b/Sources/LTKAnalytics/Public/AnalyticsAPI.swift @@ -35,8 +35,7 @@ public actor LTKAnalytics { let id ) { logger.warning( - "received duplicate event: \(id, - privacy: .public)" + "received duplicate event: \(id, privacy: .public)" ) throw AnalyticsError.duplicateEvent( id: id