From 77b92f76364600b12983fa1680efbdfd22b97525 Mon Sep 17 00:00:00 2001 From: "David Okun (LTK)" Date: Fri, 3 May 2024 13:02:23 -0500 Subject: [PATCH] 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) + } + } +}