From d72c351bdb582864670cad3f6230e8d41176bbd5 Mon Sep 17 00:00:00 2001 From: Andrew Kochulab Date: Tue, 18 Aug 2026 23:43:47 +0300 Subject: [PATCH] feat: fix two silent event-loss bugs, add observability and docs (2.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive throughout — 2.0.0 code keeps compiling. The two fixes are behaviour changes, and both replace silent data loss with delivery. Fixed (each confirmed by probe before fixing, each with a regression test): * Events tracked before any tracker was registered were silently lost. That is exactly the app-launch case: anything reported before `register` returned went nowhere, with no diagnostic. * Events could reach a provider before its SDK was initialised. `track` before `start()` called `record()` on a tracker that had never been started — Firebase logging before `FirebaseApp.configure()`, Mixpanel discarding the event internally. Both are governed by AnalyticsStartupBuffer, which holds up to 100 events by default and replays them in order once `start()` completes. `.disabled` restores the previous behaviour. Added: * Global properties, merged into every record; event attributes win on conflict. * AnalyticsDiagnostic and a handler on Configuration, reporting every event the system buffers, drops, rejects or rewrites. All of these were previously invisible — an event that never sent looked identical to one never tracked. * AnalyticsRecordValidator per registration, with `.firebase` encoding Firebase's real limits. Firebase discards violations server-side and reports nothing, so these were undebuggable. * flushPendingEvents() on AnalyticsTracker (default no-op), surfaced as flushProviders(), wired to Mixpanel and Facebook, for backgrounding. * DocC catalog with two articles, published to GitHub Pages; .spi.yml; CONTRIBUTING, SECURITY, issue/PR templates, CODEOWNERS, Dependabot; code coverage and a DocC job in CI. Coverage rose from 71.9% to 87.8% of lines; 96 tests in 18 suites, up from 57 in 11. Note: DocC is built with `xcodebuild docbuild` rather than swift-docc-plugin, because the plugin appears in a default `swift package resolve` and would break the zero-dependency guarantee. CI asserts that guarantee on every run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 51 +++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 20 +++ .github/dependabot.yml | 9 ++ .github/pull_request_template.md | 13 ++ .github/workflows/ci.yml | 27 +++- .github/workflows/documentation.yml | 61 +++++++++ .spi.yml | 6 + AnalyticsSystem.podspec | 2 +- CHANGELOG.md | 51 +++++++ CONTRIBUTING.md | 57 ++++++++ .../ProviderBuild/ProviderIntegration.swift | 17 ++- README.md | 59 +++++++- SECURITY.md | 29 ++++ .../AnalyticsSystem.docc/AnalyticsSystem.md | 83 ++++++++++++ .../Articles/GettingStarted.md | 98 ++++++++++++++ .../Articles/WritingAProvider.md | 66 +++++++++ Sources/AnalyticsSystem/AnalyticsSystem.swift | 62 ++++++++- .../Core/AnalyticsCommand.swift | 2 + .../Core/AnalyticsDiagnostic.swift | 49 +++++++ .../Core/AnalyticsDispatcher.swift | 116 ++++++++++++++-- .../Core/AnalyticsRegistration.swift | 5 +- .../Core/AnalyticsStartupBuffer.swift | 18 +++ .../Events/AnalyticsRecordValidator.swift | 46 +++++++ .../Trackers/AnalyticsTracker.swift | 8 ++ .../FacebookProvider/FacebookTracker.swift | 4 + .../AnalyticsRecordValidator+Firebase.swift | 62 +++++++++ .../MixpanelProvider/MixpanelTracker.swift | 4 + .../AnalyticsValueTests.swift | 126 ++++++++++++++++++ .../DiagnosticsTests.swift | 93 +++++++++++++ .../GlobalPropertiesTests.swift | 70 ++++++++++ .../ReadmeExamplesTests.swift | 71 ++++++++++ .../StartupBufferTests.swift | 123 +++++++++++++++++ .../Support/SpyTracker.swift | 5 + .../Support/TestEvents.swift | 34 ++++- .../ValidationTests.swift | 99 ++++++++++++++ 37 files changed, 1625 insertions(+), 23 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/documentation.yml create mode 100644 .spi.yml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 Sources/AnalyticsSystem/AnalyticsSystem.docc/AnalyticsSystem.md create mode 100644 Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/GettingStarted.md create mode 100644 Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/WritingAProvider.md create mode 100644 Sources/AnalyticsSystem/Core/AnalyticsDiagnostic.swift create mode 100644 Sources/AnalyticsSystem/Core/AnalyticsStartupBuffer.swift create mode 100644 Sources/AnalyticsSystem/Events/AnalyticsRecordValidator.swift create mode 100644 Sources/FirebaseProvider/AnalyticsRecordValidator+Firebase.swift create mode 100644 Tests/AnalyticsSystemTests/AnalyticsValueTests.swift create mode 100644 Tests/AnalyticsSystemTests/DiagnosticsTests.swift create mode 100644 Tests/AnalyticsSystemTests/GlobalPropertiesTests.swift create mode 100644 Tests/AnalyticsSystemTests/StartupBufferTests.swift create mode 100644 Tests/AnalyticsSystemTests/ValidationTests.swift diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fa5a2a9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @AndrewKochulab diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..092a101 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,51 @@ +name: Bug report +description: Something behaves differently from what it documents. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Before filing: if events are not arriving, attach a diagnostics handler first — + it reports every event the system drops or rewrites, which is usually the answer. + + ```swift + AnalyticsSystem(configuration: .init(diagnostics: { print("analytics: \($0)") })) + ``` + - type: input + id: version + attributes: + label: AnalyticsSystem version + placeholder: "2.1.0" + validations: { required: true } + - type: dropdown + id: manager + attributes: + label: Installed via + options: [Swift Package Manager, CocoaPods] + validations: { required: true } + - type: input + id: traits + attributes: + label: Enabled traits / subspecs + placeholder: "Firebase, Mixpanel" + - type: input + id: platform + attributes: + label: Platform and Xcode version + placeholder: "iOS 18.2, Xcode 26.6" + validations: { required: true } + - type: textarea + id: expected + attributes: + label: What you expected, and what happened instead + validations: { required: true } + - type: textarea + id: repro + attributes: + label: Smallest code that reproduces it + render: swift + - type: textarea + id: diagnostics + attributes: + label: Diagnostics output, if any + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..96f377c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,20 @@ +name: Feature request +description: Suggest a capability or a new provider. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the situation, not just the API you have in mind. + validations: { required: true } + - type: textarea + id: proposal + attributes: + label: What would you like it to look like? + render: swift + - type: textarea + id: alternatives + attributes: + label: What have you tried instead? + description: Existing pieces — mappers, filters, validators, a custom tracker — may already cover it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d04fb3d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + prefix: "ci" + labels: [dependencies] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..16dac9b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## What and why + + + +## Checklist + +- [ ] `swift test` passes +- [ ] `swiftlint lint --strict` is clean +- [ ] Behaviour changes have a test; bug fixes have a regression test naming the bug +- [ ] Public API has doc comments +- [ ] README / DocC updated if the public API changed +- [ ] `CHANGELOG.md` updated +- [ ] If a provider adapter changed: `IntegrationTests/ProviderBuild` still builds for iOS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57edf81..f94a795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,18 @@ jobs: fi echo "Default install resolves zero third-party dependencies." - run: swift build - - run: swift test + - run: swift test --enable-code-coverage + + - name: Coverage summary + run: | + BIN=$(swift build --show-bin-path) + PROF="$BIN/codecov/default.profdata" + XCTEST=$(find "$BIN" -name '*.xctest' | head -1) + xcrun llvm-cov report \ + "$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)" \ + -instr-profile "$PROF" \ + -ignore-filename-regex='(Tests|\.build)/' \ + | tee -a "$GITHUB_STEP_SUMMARY" # Proves the cross-platform guards hold. Cross-compiling against each SDK avoids # depending on which simulator runtimes a runner image happens to ship. @@ -93,6 +104,20 @@ jobs: -skipMacroValidation \ -quiet + # Documentation must keep building, and it must keep building *without* the + # swift-docc-plugin — that plugin would show up in a default `swift package + # resolve` and break the zero-dependency promise the `core` job asserts. + docs: + name: DocC + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - run: | + xcodebuild docbuild \ + -scheme AnalyticsSystem \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$RUNNER_TEMP/docs" + lint: name: SwiftLint runs-on: macos-26 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..7692c7c --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,61 @@ +name: Documentation + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + +jobs: + build: + name: Build DocC + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + # Deliberately built with xcodebuild rather than swift-docc-plugin: the plugin + # would appear in a default `swift package resolve`, and this package promises + # that a core-only install pulls no third-party dependencies at all. + - name: Build documentation archive + run: | + xcodebuild docbuild \ + -scheme AnalyticsSystem \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$RUNNER_TEMP/docs" + + - name: Transform for static hosting + run: | + ARCHIVE=$(find "$RUNNER_TEMP/docs" -name '*.doccarchive' -maxdepth 4 | head -1) + echo "Using $ARCHIVE" + $(xcrun --find docc) process-archive transform-for-static-hosting \ + "$ARCHIVE" \ + --hosting-base-path AnalyticsSystem \ + --output-path _site + # Land visitors on the documentation root rather than a 404. + echo '' > _site/index.html + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + name: Deploy to Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..38ded4a --- /dev/null +++ b/.spi.yml @@ -0,0 +1,6 @@ +version: 1 +builder: + configs: + # Swift Package Index builds with default traits, which is the core-only, + # dependency-free configuration we most want validated. + - documentation_targets: [AnalyticsSystem] diff --git a/AnalyticsSystem.podspec b/AnalyticsSystem.podspec index 9537c9d..19e47dc 100644 --- a/AnalyticsSystem.podspec +++ b/AnalyticsSystem.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AnalyticsSystem' - s.version = '2.0.0' + s.version = '2.1.0' s.summary = 'Multi-provider analytics for Apple platforms, with a dependency-free core.' s.description = <<-DESC diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d0eb6..ff914dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2026-08-18 + +Additive throughout — 2.0.0 code keeps compiling. The two fixes below are behaviour +changes, and both replace silent data loss with delivery. + +### Fixed + +- **Events tracked before any tracker was registered were silently lost.** That is + exactly the app-launch case: anything reported before `register` returned went + nowhere, with no diagnostic. They are now held and replayed. +- **Events could reach a provider before its SDK was initialised.** `track` before + `start()` called `record()` on a tracker that had never been started — Firebase + logging before `FirebaseApp.configure()`, Mixpanel discarding the event internally. + Delivery now never precedes `start()`. + + Both are governed by ``AnalyticsStartupBuffer``, which holds up to 100 events by + default and replays them in order. Set `.disabled` for the previous behaviour. + +### Added + +- **Global properties.** `setGlobalProperties(_:)` merges attributes into every + record; event attributes win on key conflict. For app version, locale, build, + experiment bucket. +- **Diagnostics.** `AnalyticsDiagnostic` plus a handler on `Configuration` reports + every event the system buffers, drops, rejects or rewrites. Previously all of these + were invisible — an event that never sent looked identical to one never tracked. +- **Record validation.** `AnalyticsRecordValidator`, per registration, with + `.firebase` shipped in `FirebaseProvider` encoding Firebase's real limits (40-char + names, 25 parameters, 100-char values, reserved `firebase_`/`google_`/`ga_` + prefixes). Firebase discards violations server-side and reports nothing, so these + were previously undebuggable. +- **Provider flush.** `flushPendingEvents()` on `AnalyticsTracker` (default no-op), + surfaced as `AnalyticsSystem.flushProviders()`, wired to Mixpanel and Facebook. For + backgrounding and termination. +- DocC documentation catalog with two articles, published to GitHub Pages. +- `.spi.yml` for Swift Package Index. +- `CONTRIBUTING.md`, `SECURITY.md`, issue and PR templates, `CODEOWNERS`, Dependabot. +- Code coverage reporting in CI, and a job that keeps DocC building. + +### Changed + +- Test coverage raised from 71.9% to 87.8% of lines (92.3% of regions); 92 tests + across 17 suites, up from 57 across 11. + +### Note on documentation tooling + +DocC is built with `xcodebuild docbuild` rather than `swift-docc-plugin`, because the +plugin appears in a default `swift package resolve` and would break this package's +zero-dependency guarantee. CI asserts that guarantee on every run. + ## [2.0.0] - 2026-08-18 A full rewrite. 2.0.0 is a breaking release; see [MIGRATION.md](MIGRATION.md). @@ -99,5 +149,6 @@ A full rewrite. 2.0.0 is a breaking release; see [MIGRATION.md](MIGRATION.md). Initial release. +[2.1.0]: https://github.com/AndrewKochulab/AnalyticsSystem/compare/2.0.0...2.1.0 [2.0.0]: https://github.com/AndrewKochulab/AnalyticsSystem/compare/1.0.0...2.0.0 [1.0.0]: https://github.com/AndrewKochulab/AnalyticsSystem/releases/tag/1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..36e5024 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +Thanks for taking the time. Bug reports, provider adapters and documentation fixes are +all welcome. + +## Getting set up + +Requires **Xcode 16.3+ / Swift 6.1+** — the package uses SwiftPM package traits. + +```bash +git clone https://github.com/AndrewKochulab/AnalyticsSystem.git +cd AnalyticsSystem +swift build # core only: resolves no third-party dependencies +swift test +``` + +## Working on a provider + +Provider code is behind a trait *and*, in Facebook's case, behind `os(iOS)` — so a +plain `swift build` compiles none of it. Build the trait you are touching: + +```bash +swift build --traits Firebase +``` + +and, because that still does not exercise iOS-only paths, build the integration +package before opening a PR: + +```bash +cd IntegrationTests/ProviderBuild +xcodebuild build -scheme ProviderBuild -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +That package exists because the pre-2.0 CI was green for years while compiling zero +provider code. Please keep it exercising whatever you add. + +## Ground rules + +- **No `fatalError`, `as!`, `try!` or force unwraps in `Sources/`.** SwiftLint enforces + this. Each of them was a real crash in 1.x; model the failure in the type system. +- **Nothing may fail silently.** If a code path discards or rewrites an event, report an + ``AnalyticsDiagnostic``. A dropped event is invisible otherwise. +- **Value conversions must be total.** A partial `switch` over `AnalyticsValue` is how + data goes missing without anyone noticing. +- **The core stays dependency-free.** Anything that would appear in a default + `swift package resolve` does not belong in `Package.swift`; CI asserts this. +- **Swift 6 language mode, no `@unchecked Sendable`** outside the one documented lock. + +## Tests + +Use Swift Testing. Prefer `await system.flush()` as an exact barrier over sleeping. +Bug fixes get a regression test whose comment names the bug it prevents. + +```bash +swift test +swiftlint lint --strict +``` diff --git a/IntegrationTests/ProviderBuild/Sources/ProviderBuild/ProviderIntegration.swift b/IntegrationTests/ProviderBuild/Sources/ProviderBuild/ProviderIntegration.swift index ffbd269..cca37d3 100644 --- a/IntegrationTests/ProviderBuild/Sources/ProviderBuild/ProviderIntegration.swift +++ b/IntegrationTests/ProviderBuild/Sources/ProviderBuild/ProviderIntegration.swift @@ -11,7 +11,12 @@ import UIKit /// This is intentionally never executed — it is a type-checking fixture. public enum ProviderIntegration { public static func wireEverything() async throws -> AnalyticsSystem { - let analytics = AnalyticsSystem() + let analytics = AnalyticsSystem( + configuration: .init( + startupBuffer: .buffered(limit: 50), + diagnostics: { print("analytics: \($0)") } + ) + ) // A base mapping shared by all providers… let common = AnalyticsEventMapper() @@ -30,7 +35,13 @@ public enum ProviderIntegration { } .overriding(common) - try await analytics.register(FirebaseTracker(), mapper: common) + // Firebase's own limits, enforced locally so violations surface as + // diagnostics instead of vanishing server-side. + try await analytics.register( + FirebaseTracker(), + mapper: common, + validator: .firebase + ) try await analytics.register(MixpanelTracker(apiToken: "token"), mapper: common) try await analytics.register(BugsnagTracker(apiKey: "key"), mapper: common) try await analytics.register( @@ -40,8 +51,10 @@ public enum ProviderIntegration { ) try await analytics.register(ConsoleTracker()) + await analytics.setGlobalProperties(["app_version": "2.1.0"]) await analytics.start() analytics.track(SignUpEvent(method: .email)) + await analytics.flushProviders() await analytics.logIn(user: AnalyticsUser(id: "user-1", email: "a@example.com")) await analytics.logOut() await analytics.setEnabled(false) diff --git a/README.md b/README.md index 4849f35..7efefb1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Swift 6.1](https://img.shields.io/badge/Swift-6.1-orange.svg)](https://swift.org) [![Platforms](https://img.shields.io/badge/platforms-iOS%2015%20%7C%20macOS%2012%20%7C%20tvOS%2015%20%7C%20watchOS%208%20%7C%20visionOS%201-lightgrey.svg)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Documentation](https://img.shields.io/badge/docs-DocC-informational.svg)](https://andrewkochulab.github.io/AnalyticsSystem/documentation/analyticssystem) Fan analytics events out to any number of providers behind one small API. @@ -13,6 +14,7 @@ Fan analytics events out to any number of providers behind one small API. - **Built for Swift 6 strict concurrency.** No `@unchecked Sendable`, no locks in your code, no main-thread work. - **`track` is synchronous.** Call it from a view model, a background task, anywhere. No `await`. - **Ordering is guaranteed.** A `logIn` followed by a `track` reaches every provider in that order. +- **Nothing fails silently.** Every event the system buffers, drops, rejects or rewrites is reported to a diagnostics handler. ## Providers @@ -109,6 +111,55 @@ analytics.track(SignUpEvent(userID: "user-1", method: .email)) That's it — synchronous, non-throwing, callable from any isolation domain. +Events tracked before `start()` are held and replayed once it completes, so launch-time +events are neither lost nor handed to an SDK that has not been configured yet. See +[`AnalyticsStartupBuffer`](https://andrewkochulab.github.io/AnalyticsSystem/documentation/analyticssystem/analyticsstartupbuffer). + +### Attributes on every event + +```swift +await analytics.setGlobalProperties([ + "app_version": "2.1.0", + "locale": "en_US", +]) +``` + +Event attributes win on key conflict, so an event can always override a global. + +### Seeing what you lose + +Analytics failures are invisible by default — a dropped event looks exactly like one +that was never sent. A diagnostics handler makes them observable: + +```swift +let analytics = AnalyticsSystem( + configuration: .init( + diagnostics: { diagnostic in + logger.warning("analytics: \(diagnostic)") + } + ) +) +``` + +### Enforcing a provider's limits + +Firebase silently discards events that break its rules — names over 40 characters, +more than 25 parameters, reserved prefixes. Running those rules locally turns a +vanished event into a diagnostic: + +```swift +try await analytics.register(FirebaseTracker(), validator: .firebase) +``` + +### Flushing before the app goes away + +`flush()` drains this library's queue; `flushProviders()` asks each vendor SDK to send +what it has batched — that's the one you want when backgrounding. + +```swift +await analytics.flushProviders() +``` + ### Sending only some events to a provider Filters are values, and they compose: @@ -209,6 +260,10 @@ analytics.track(SignUpEvent(userID: "1", method: .email)) await analytics.flush() // returns only once every provider has been called ``` +## Documentation + +Full API reference: **[andrewkochulab.github.io/AnalyticsSystem](https://andrewkochulab.github.io/AnalyticsSystem/documentation/analyticssystem)** + ## Migrating from 1.0.0 2.0.0 is a deliberate breaking release — see [MIGRATION.md](MIGRATION.md) for a @@ -216,8 +271,8 @@ symbol-by-symbol map. 1.0.0 is untouched and remains installable. ## Contributing -Bug reports and pull requests are welcome. `swift test` should be green and -`swiftlint lint --strict` clean before you open one. +Bug reports and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). +`swift test` should be green and `swiftlint lint --strict` clean before you open one. ⭐️ If you find this useful, star the repo. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..444130c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---|---| +| 2.x | ✅ | +| 1.x | ❌ — unbuildable on current toolchains; please upgrade | + +## Reporting a vulnerability + +Please **do not open a public issue**. Report privately via +[GitHub Security Advisories](https://github.com/AndrewKochulab/AnalyticsSystem/security/advisories/new), +or email andrew.kochulab@gmail.com. + +Expect an acknowledgement within a few days. + +## Scope + +This library forwards data you give it to analytics providers you choose. Worth knowing: + +- **It does not collect anything on its own.** Every attribute is one you passed to it. +- **An anonymous identifier is generated and stored** in `UserDefaults` (or a store you + inject) and sent to providers to correlate a session. `logOut()` rotates it, so + post-logout activity is not linked to the previous user. +- **`AnalyticsUser` fields go to providers verbatim.** Do not put secrets, tokens or + data you are not entitled to share into events or user traits. +- **Vendor SDKs are the larger surface.** Report issues in Firebase, Facebook, Mixpanel + or Bugsnag to those projects; this package only adapts them. diff --git a/Sources/AnalyticsSystem/AnalyticsSystem.docc/AnalyticsSystem.md b/Sources/AnalyticsSystem/AnalyticsSystem.docc/AnalyticsSystem.md new file mode 100644 index 0000000..881bf69 --- /dev/null +++ b/Sources/AnalyticsSystem/AnalyticsSystem.docc/AnalyticsSystem.md @@ -0,0 +1,83 @@ +# ``AnalyticsSystem`` + +Fan analytics events out to any number of providers behind one small, `Sendable` API. + +## Overview + +The core carries no third-party dependencies. Providers are opt-in SwiftPM package +traits, so a default install resolves nothing at all — not Firebase, not its +transitive tree. What you do not enable is never even cloned. + +```swift +let analytics = AnalyticsSystem() + +try await analytics.register(ConsoleTracker()) +try await analytics.register(MixpanelTracker(apiToken: "token")) + +await analytics.start() +analytics.track(SignUpEvent(method: .email)) +``` + +``AnalyticsSystem/track(_:)`` is synchronous and callable from any isolation domain — +a fire-and-forget logging call should not put a suspension point in a UI path. Every +operation still passes through one serial queue, which is what guarantees that a +``AnalyticsSystem/logIn(user:)`` reaches providers before an event tracked right after it. + +### Call start() before you rely on delivery + +Events tracked before ``AnalyticsSystem/start(with:)`` are held and replayed once it +completes — see ``AnalyticsStartupBuffer``. Without that, launch-time events would +either be lost outright or handed to a provider whose SDK had not been initialised. + +## Topics + +### Essentials + +- ``AnalyticsSystem`` +- ``AnalyticsEvent`` +- ``AnalyticsTracker`` +- + +### Describing events + +- ``AnalyticsEventName`` +- ``AnalyticsEventCategory`` +- ``AnalyticsPayload`` +- ``AnalyticsValue`` +- ``AnalyticsValueConvertible`` +- ``AnalyticsRecord`` + +### Routing + +- ``AnalyticsEventMapper`` +- ``AnalyticsEventFilter`` +- ``AnalyticsRecordValidator`` +- ``AnalyticsTrackerID`` +- + +### Identity + +- ``AnalyticsUser`` +- ``AnalyticsID`` + +### Configuration and observability + +- ``AnalyticsStartupBuffer`` +- ``AnalyticsDiagnostic`` +- ``AnalyticsDiagnosticHandler`` +- ``AnalyticsStore`` +- ``UserDefaultsAnalyticsStore`` +- ``InMemoryAnalyticsStore`` + +### Built-in trackers + +- ``ConsoleTracker`` +- ``AnalyticsLogSink`` +- ``OSLogSink`` +- ``StandardOutputLogSink`` + +### Capabilities + +- ``CrashReportingTracker`` +- ``AnalyticsStartContext`` +- ``AnalyticsError`` diff --git a/Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/GettingStarted.md b/Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/GettingStarted.md new file mode 100644 index 0000000..85a00ac --- /dev/null +++ b/Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/GettingStarted.md @@ -0,0 +1,98 @@ +# Getting Started + +Wire up providers, describe your events, and track them. + +## Describe an event + +An event is a plain `Sendable` value that knows its own name and attributes. + +```swift +enum RegistrationMethod: String, AnalyticsValueConvertible { + case email = "Email" + case facebook = "Facebook" +} + +struct SignUpEvent: AnalyticsEvent { + static let category: AnalyticsEventCategory = .authentication + + let method: RegistrationMethod + + var name: AnalyticsEventName { "sign_up" } + var payload: AnalyticsPayload { ["method": method.analyticsValue] } +} +``` + +Attributes are ``AnalyticsValue``, not `Any`. That is what lets an event cross +concurrency domains, and it turns "the SDK did not recognise that value" from silent +data loss into an explicit, testable conversion. + +## Register providers and start + +```swift +let analytics = AnalyticsSystem() + +try await analytics.register(ConsoleTracker()) +try await analytics.register(BugsnagTracker(apiKey: "key")) + +await analytics.setGlobalProperties(["app_version": "2.1.0"]) +await analytics.start() +``` + +Global properties are merged into every record. Event attributes win on key conflict, +so an event can always override one. + +## Track + +```swift +analytics.track(SignUpEvent(method: .email)) +``` + +## Send only some events to a provider + +Filters are values, and they compose: + +```swift +try await analytics.register( + FacebookTracker(), + filter: .categories(.authentication) || .only(PurchaseEvent.self) +) +``` + +## Render an event differently for one provider + +Layer mappers rather than subclassing anything: + +```swift +let facebookMapper = AnalyticsEventMapper() + .mapping(for: SignUpEvent.self) { event in + AnalyticsRecord( + name: "fb_mobile_complete_registration", + attributes: ["fb_registration_method": event.method] + ) + } + .overriding(common) +``` + +Returning `nil`, or using ``AnalyticsEventMapper/ignoring(_:)``, drops that event for +that provider only. + +## See what you are losing + +Analytics failures are invisible by default: a dropped event looks exactly like one +that was never sent. Attach a diagnostics handler and they become observable. + +```swift +let analytics = AnalyticsSystem( + configuration: .init( + diagnostics: { diagnostic in + logger.warning("analytics: \(diagnostic)") + } + ) +) +``` + +## Flush before the app goes away + +``AnalyticsSystem/flush()`` drains this library's own queue. +``AnalyticsSystem/flushProviders()`` asks each vendor SDK to send what it has batched — +that is the one you want when backgrounding. diff --git a/Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/WritingAProvider.md b/Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/WritingAProvider.md new file mode 100644 index 0000000..326c699 --- /dev/null +++ b/Sources/AnalyticsSystem/AnalyticsSystem.docc/Articles/WritingAProvider.md @@ -0,0 +1,66 @@ +# Writing a Provider + +Adapt any analytics destination in a few lines. + +## Conform to AnalyticsTracker + +Every requirement has a default no-op, so implement only what your destination +actually supports. There is deliberately no initializer requirement: nothing in this +library ever constructs a tracker, so a provider that needs an API key simply makes +that its only initializer. + +```swift +struct MyTracker: AnalyticsTracker { + let id: AnalyticsTrackerID = "my-tracker" + private let apiKey: String + + init(apiKey: String) { self.apiKey = apiKey } + + func start(with context: AnalyticsStartContext) async { + MySDK.configure(apiKey: apiKey) + } + + func record(_ record: AnalyticsRecord) async { + MySDK.log(record.name, record.payload.myValues) + } +} +``` + +All requirements are `async`, so a conformer may be a `struct`, an `actor`, or a +`@MainActor` class — whichever matches the SDK's threading rules. The caller does not +care. + +## Convert values totally + +Write one exhaustive conversion from ``AnalyticsValue`` to your SDK's type. Making it +total is the point: a partial conversion is how values get dropped without anyone +noticing. + +```swift +extension AnalyticsValue { + var myValue: MySDKValue { + switch self { + case let .string(value): .text(value) + case let .int(value): .number(Double(value)) + // …every case handled + } + } +} +``` + +If your SDK only accepts scalars, call ``AnalyticsPayload/flattened(separator:)`` first. + +## Declare capabilities you have + +Conform to ``CrashReportingTracker`` if your SDK can answer whether the previous run +crashed. The system consults only trackers that conform, so nothing has to stub it. + +## Enforce your SDK's limits + +If your SDK silently discards non-conforming events, express its rules as an +``AnalyticsRecordValidator`` and let callers opt in. Rejections are reported through +the diagnostics handler instead of disappearing. + +```swift +try await analytics.register(FirebaseTracker(), validator: .firebase) +``` diff --git a/Sources/AnalyticsSystem/AnalyticsSystem.swift b/Sources/AnalyticsSystem/AnalyticsSystem.swift index f8aeff4..c929d91 100644 --- a/Sources/AnalyticsSystem/AnalyticsSystem.swift +++ b/Sources/AnalyticsSystem/AnalyticsSystem.swift @@ -36,18 +36,32 @@ public final class AnalyticsSystem: Sendable { public var idGenerator: @Sendable () -> AnalyticsID public var queuePolicy: QueuePolicy + /// How events tracked before ``AnalyticsSystem/start(with:)`` are handled. + public var startupBuffer: AnalyticsStartupBuffer + + /// Receives every event the system discards or rewrites. Analytics failures + /// are otherwise invisible: a dropped event looks exactly like one that was + /// never sent. + public var diagnostics: AnalyticsDiagnosticHandler? + /// - Parameters: /// - store: Backing store for the anonymous identity. /// - idGenerator: Anonymous ID factory. Override in tests for determinism. /// - queuePolicy: Defaults to ``QueuePolicy/unbounded``. + /// - startupBuffer: Defaults to holding up to 100 pre-`start()` events. + /// - diagnostics: Optional sink for discarded or rewritten events. public init( store: any AnalyticsStore = UserDefaultsAnalyticsStore(), idGenerator: @escaping @Sendable () -> AnalyticsID = AnalyticsID.random, - queuePolicy: QueuePolicy = .unbounded + queuePolicy: QueuePolicy = .unbounded, + startupBuffer: AnalyticsStartupBuffer = .default, + diagnostics: AnalyticsDiagnosticHandler? = nil ) { self.store = store self.idGenerator = idGenerator self.queuePolicy = queuePolicy + self.startupBuffer = startupBuffer + self.diagnostics = diagnostics } } @@ -58,6 +72,7 @@ public final class AnalyticsSystem: Sendable { /// Read on the synchronous `track` path, so it is a lock rather than actor state. private let enabled = Locked(true) + private let diagnostics: AnalyticsDiagnosticHandler? public init(configuration: Configuration = Configuration()) { let registry = AnalyticsRegistry() @@ -79,7 +94,14 @@ public final class AnalyticsSystem: Sendable { ) self.continuation = continuation - let dispatcher = AnalyticsDispatcher(registry: registry, identity: identity) + self.diagnostics = configuration.diagnostics + + let dispatcher = AnalyticsDispatcher( + registry: registry, + identity: identity, + startupBuffer: configuration.startupBuffer, + diagnostics: configuration.diagnostics + ) self.pump = Task.detached(priority: .utility) { await dispatcher.run(stream) } @@ -95,13 +117,24 @@ public final class AnalyticsSystem: Sendable { /// Registers a tracker along with how it should render and filter events. /// /// - Throws: ``AnalyticsError/duplicateTracker(_:)`` if the identifier is taken. + /// - Parameters: + /// - mapper: How this provider renders events. Defaults to the event's own. + /// - filter: Which events reach it. Defaults to all. + /// - validator: Provider-specific constraints, e.g. `.firebase`. Rejections are + /// reported through the diagnostics handler rather than failing silently. public func register( _ tracker: some AnalyticsTracker, mapper: AnalyticsEventMapper = AnalyticsEventMapper(), - filter: AnalyticsEventFilter = .all + filter: AnalyticsEventFilter = .all, + validator: AnalyticsRecordValidator = .default ) async throws { try await registry.register( - AnalyticsRegistration(tracker: tracker, mapper: mapper, filter: filter) + AnalyticsRegistration( + tracker: tracker, + mapper: mapper, + filter: filter, + validator: validator + ) ) } @@ -147,6 +180,22 @@ public final class AnalyticsSystem: Sendable { await enqueueAwaiting(.logOut) } + /// Attributes merged into every subsequent record. + /// + /// Event attributes win on key conflict, so an event can always override a global. + /// Typical use is app version, build, locale, or an experiment bucket. + public func setGlobalProperties(_ properties: AnalyticsPayload) async { + await enqueueAwaiting(.setGlobalProperties(properties)) + } + + /// Asks every provider's SDK to send whatever it has buffered. + /// + /// Distinct from ``flush()``, which drains only this library's queue. Call this + /// when the app is backgrounding or about to terminate. + public func flushProviders() async { + await enqueueAwaiting(.flushProviders) + } + /// Returns once every command enqueued before this call has been delivered. public func flush() async { await enqueueAwaiting(.barrier) @@ -158,7 +207,10 @@ public final class AnalyticsSystem: Sendable { /// /// Synchronous, non-throwing, and callable from any isolation domain. public func track(_ event: some AnalyticsEvent) { - guard enabled.withLock({ $0 }) else { return } + guard enabled.withLock({ $0 }) else { + diagnostics?(.droppedWhileDisabled(event.name)) + return + } continuation.yield( AnalyticsWorkItem(command: .event(AnalyticsEventEnvelope(event))) ) diff --git a/Sources/AnalyticsSystem/Core/AnalyticsCommand.swift b/Sources/AnalyticsSystem/Core/AnalyticsCommand.swift index d01edd4..a88c39b 100644 --- a/Sources/AnalyticsSystem/Core/AnalyticsCommand.swift +++ b/Sources/AnalyticsSystem/Core/AnalyticsCommand.swift @@ -4,9 +4,11 @@ import Foundation enum AnalyticsCommand: Sendable { case start(AnalyticsStartContext) case setEnabled(Bool) + case setGlobalProperties(AnalyticsPayload) case logIn(AnalyticsUser) case logOut case event(AnalyticsEventEnvelope) + case flushProviders /// Carries no work; used by `flush()` to observe that everything queued /// before it has been delivered. case barrier diff --git a/Sources/AnalyticsSystem/Core/AnalyticsDiagnostic.swift b/Sources/AnalyticsSystem/Core/AnalyticsDiagnostic.swift new file mode 100644 index 0000000..1fbfbc0 --- /dev/null +++ b/Sources/AnalyticsSystem/Core/AnalyticsDiagnostic.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Something the system did that you would otherwise never find out about. +/// +/// Analytics failures are silent by nature: a dropped event looks exactly like an +/// event that was never sent, and neither shows up in a dashboard. Every path that +/// discards data reports it here so it can be logged, asserted on in debug builds, or +/// counted in tests. +public enum AnalyticsDiagnostic: Hashable, Sendable { + /// An event arrived before ``AnalyticsSystem/start(with:)`` and was held. + case buffered(AnalyticsEventName) + + /// The startup buffer was full; the oldest held event was discarded. + case bufferOverflow(dropped: AnalyticsEventName, limit: Int) + + /// An event was discarded because collection is disabled. + case droppedWhileDisabled(AnalyticsEventName) + + /// A provider's mapper declined to render the event. + case unmapped(AnalyticsEventName, tracker: AnalyticsTrackerID) + + /// A record failed validation for a provider and was not sent. + case rejected(AnalyticsEventName, tracker: AnalyticsTrackerID, reason: String) + + /// A record was altered to satisfy a provider's constraints. + case sanitized(from: AnalyticsEventName, to: AnalyticsEventName, tracker: AnalyticsTrackerID) +} + +extension AnalyticsDiagnostic: CustomStringConvertible { + public var description: String { + switch self { + case let .buffered(name): + "buffered '\(name)' until start()" + case let .bufferOverflow(dropped, limit): + "startup buffer full (limit \(limit)); dropped '\(dropped)'" + case let .droppedWhileDisabled(name): + "dropped '\(name)': collection is disabled" + case let .unmapped(name, tracker): + "'\(name)' not mapped for '\(tracker)'" + case let .rejected(name, tracker, reason): + "'\(name)' rejected by '\(tracker)': \(reason)" + case let .sanitized(from, to, tracker): + "'\(from)' sanitized to '\(to)' for '\(tracker)'" + } + } +} + +/// Receives ``AnalyticsDiagnostic`` values. Called off the main actor. +public typealias AnalyticsDiagnosticHandler = @Sendable (AnalyticsDiagnostic) -> Void diff --git a/Sources/AnalyticsSystem/Core/AnalyticsDispatcher.swift b/Sources/AnalyticsSystem/Core/AnalyticsDispatcher.swift index 152ee93..10e3f3b 100644 --- a/Sources/AnalyticsSystem/Core/AnalyticsDispatcher.swift +++ b/Sources/AnalyticsSystem/Core/AnalyticsDispatcher.swift @@ -5,9 +5,31 @@ import Foundation /// A single consumer task processes the stream in order, which is what guarantees /// that `logIn` reaches providers before an event tracked immediately after it — /// something a detached `Task` per call could not promise. -struct AnalyticsDispatcher: Sendable { - let registry: AnalyticsRegistry - let identity: AnalyticsIdentityStore +/// +/// The dispatcher owns the only mutable state outside the registry (whether `start` +/// has run, the held events, and the global properties). Because exactly one task +/// runs `run(_:)`, that state needs no synchronisation of its own. +final class AnalyticsDispatcher { + private let registry: AnalyticsRegistry + private let identity: AnalyticsIdentityStore + private let startupBuffer: AnalyticsStartupBuffer + private let diagnostics: AnalyticsDiagnosticHandler? + + private var hasStarted = false + private var heldEvents: [AnalyticsEventEnvelope] = [] + private var globalProperties = AnalyticsPayload.empty + + init( + registry: AnalyticsRegistry, + identity: AnalyticsIdentityStore, + startupBuffer: AnalyticsStartupBuffer, + diagnostics: AnalyticsDiagnosticHandler? + ) { + self.registry = registry + self.identity = identity + self.startupBuffer = startupBuffer + self.diagnostics = diagnostics + } func run(_ stream: AsyncStream) async { for await item in stream { @@ -26,17 +48,23 @@ struct AnalyticsDispatcher: Sendable { await start(registrations, with: context) case let .setEnabled(isEnabled): await setEnabled(isEnabled, on: registrations) + case let .setGlobalProperties(properties): + globalProperties = properties case let .logIn(user): await logIn(user, on: registrations) case .logOut: await logOut(on: registrations) case let .event(envelope): - await deliver(envelope, to: registrations) + await handle(envelope, registrations: registrations) + case .flushProviders: + await flushProviders(registrations) case .barrier: break } } + // MARK: Lifecycle + private func start( _ registrations: [AnalyticsRegistration], with context: AnalyticsStartContext @@ -46,6 +74,15 @@ struct AnalyticsDispatcher: Sendable { await registration.tracker.start(with: context) await registration.tracker.identify(anonymousID: anonymousID) } + + hasStarted = true + + // Replay anything held during launch, in the order it was tracked. + let held = heldEvents + heldEvents = [] + for envelope in held { + await deliver(envelope, to: registrations) + } } private func setEnabled( @@ -77,17 +114,78 @@ struct AnalyticsDispatcher: Sendable { } } + private func flushProviders(_ registrations: [AnalyticsRegistration]) async { + for registration in registrations { + await registration.tracker.flushPendingEvents() + } + } + + // MARK: Events + + private func handle( + _ envelope: AnalyticsEventEnvelope, + registrations: [AnalyticsRegistration] + ) async { + guard shouldHold else { + await deliver(envelope, to: registrations) + return + } + hold(envelope) + } + + private var shouldHold: Bool { + guard case .buffered = startupBuffer else { return false } + return !hasStarted + } + + private func hold(_ envelope: AnalyticsEventEnvelope) { + guard case let .buffered(limit) = startupBuffer else { return } + + if limit <= 0 { + diagnostics?(.bufferOverflow(dropped: envelope.descriptor.name, limit: limit)) + return + } + + heldEvents.append(envelope) + diagnostics?(.buffered(envelope.descriptor.name)) + + while heldEvents.count > limit { + let dropped = heldEvents.removeFirst() + diagnostics?(.bufferOverflow(dropped: dropped.descriptor.name, limit: limit)) + } + } + private func deliver( _ envelope: AnalyticsEventEnvelope, to registrations: [AnalyticsRegistration] ) async { for registration in registrations where registration.filter.admits(envelope.descriptor) { - guard - let record = envelope.resolve(registration.mapper), - record.isValid - else { continue } - await registration.tracker.record(record) + guard let mapped = envelope.resolve(registration.mapper) else { + diagnostics?(.unmapped(envelope.descriptor.name, tracker: registration.id)) + continue + } + + let merged = AnalyticsRecord( + name: mapped.name, + // Event attributes win over global ones on conflict. + payload: globalProperties.merging(mapped.payload) + ) + + switch registration.validator(merged) { + case let .accept(record): + if record.name != merged.name { + diagnostics?( + .sanitized(from: merged.name, to: record.name, tracker: registration.id) + ) + } + await registration.tracker.record(record) + + case let .reject(reason): + diagnostics?( + .rejected(merged.name, tracker: registration.id, reason: reason) + ) + } } } } diff --git a/Sources/AnalyticsSystem/Core/AnalyticsRegistration.swift b/Sources/AnalyticsSystem/Core/AnalyticsRegistration.swift index fec5714..8eed97d 100644 --- a/Sources/AnalyticsSystem/Core/AnalyticsRegistration.swift +++ b/Sources/AnalyticsSystem/Core/AnalyticsRegistration.swift @@ -8,16 +8,19 @@ public struct AnalyticsRegistration: Sendable { public let tracker: any AnalyticsTracker public let mapper: AnalyticsEventMapper public let filter: AnalyticsEventFilter + public let validator: AnalyticsRecordValidator public var id: AnalyticsTrackerID { tracker.id } public init( tracker: any AnalyticsTracker, mapper: AnalyticsEventMapper = AnalyticsEventMapper(), - filter: AnalyticsEventFilter = .all + filter: AnalyticsEventFilter = .all, + validator: AnalyticsRecordValidator = .default ) { self.tracker = tracker self.mapper = mapper self.filter = filter + self.validator = validator } } diff --git a/Sources/AnalyticsSystem/Core/AnalyticsStartupBuffer.swift b/Sources/AnalyticsSystem/Core/AnalyticsStartupBuffer.swift new file mode 100644 index 0000000..d6ba821 --- /dev/null +++ b/Sources/AnalyticsSystem/Core/AnalyticsStartupBuffer.swift @@ -0,0 +1,18 @@ +import Foundation + +/// What to do with events tracked before ``AnalyticsSystem/start(with:)``. +/// +/// This exists because both alternatives are wrong. Delivering such events reaches a +/// provider whose SDK has not been initialised — Firebase logs before +/// `FirebaseApp.configure()`, Mixpanel discards them. Dropping them loses exactly the +/// launch events that matter most, and loses them silently. +public enum AnalyticsStartupBuffer: Hashable, Sendable { + /// Hold up to `limit` events and replay them, in order, once `start()` completes. + /// When full, the oldest is discarded and reported as a diagnostic. + case buffered(limit: Int) + + /// Deliver immediately, even if no provider has started yet. + case disabled + + public static let `default` = AnalyticsStartupBuffer.buffered(limit: 100) +} diff --git a/Sources/AnalyticsSystem/Events/AnalyticsRecordValidator.swift b/Sources/AnalyticsSystem/Events/AnalyticsRecordValidator.swift new file mode 100644 index 0000000..bedfbe2 --- /dev/null +++ b/Sources/AnalyticsSystem/Events/AnalyticsRecordValidator.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Decides whether a record may be sent to a provider, and optionally rewrites it. +/// +/// Vendor SDKs enforce limits that they do not report back: Firebase, for instance, +/// silently discards an event whose name exceeds 40 characters. Without a validator +/// the data simply never appears, with nothing to debug. Running the provider's own +/// rules locally turns that into a diagnostic at the call site. +public struct AnalyticsRecordValidator: Sendable { + public enum Outcome: Sendable { + /// Send this record. It may differ from the input if it was sanitized. + case accept(AnalyticsRecord) + /// Do not send; the reason is reported as a diagnostic. + case reject(reason: String) + } + + let validate: @Sendable (AnalyticsRecord) -> Outcome + + public init(_ validate: @escaping @Sendable (AnalyticsRecord) -> Outcome) { + self.validate = validate + } + + public func callAsFunction(_ record: AnalyticsRecord) -> Outcome { + validate(record) + } + + /// Accepts anything with a non-empty name — the baseline every provider shares. + public static let `default` = AnalyticsRecordValidator { record in + record.name.isEmpty + ? .reject(reason: "event name is empty") + : .accept(record) + } + + /// Accepts everything, including unnamed records. + public static let permissive = AnalyticsRecordValidator { .accept($0) } + + /// Runs `self`, then feeds an accepted record through `other`. + public func combined(with other: AnalyticsRecordValidator) -> Self { + AnalyticsRecordValidator { record in + switch validate(record) { + case let .accept(accepted): other.validate(accepted) + case let .reject(reason): .reject(reason: reason) + } + } + } +} diff --git a/Sources/AnalyticsSystem/Trackers/AnalyticsTracker.swift b/Sources/AnalyticsSystem/Trackers/AnalyticsTracker.swift index 0cf4034..7225a7b 100644 --- a/Sources/AnalyticsSystem/Trackers/AnalyticsTracker.swift +++ b/Sources/AnalyticsSystem/Trackers/AnalyticsTracker.swift @@ -26,6 +26,13 @@ public protocol AnalyticsTracker: Sendable { func logOut() async + /// Ask the underlying SDK to send anything it has buffered. + /// + /// Distinct from ``AnalyticsSystem/flush()``, which only drains this library's + /// own queue. Most SDKs batch on their own schedule, so an app being backgrounded + /// or about to terminate wants this. + func flushPendingEvents() async + /// Report an already-mapped record. Mapping is the system's responsibility, so /// trackers never see raw events. func record(_ record: AnalyticsRecord) async @@ -37,4 +44,5 @@ public extension AnalyticsTracker { func identify(anonymousID: AnalyticsID) async {} func logIn(user: AnalyticsUser) async {} func logOut() async {} + func flushPendingEvents() async {} } diff --git a/Sources/FacebookProvider/FacebookTracker.swift b/Sources/FacebookProvider/FacebookTracker.swift index 6377600..be51331 100644 --- a/Sources/FacebookProvider/FacebookTracker.swift +++ b/Sources/FacebookProvider/FacebookTracker.swift @@ -90,6 +90,10 @@ public final class FacebookTracker: AnalyticsTracker { AppEvents.shared.clearUserData() } + public func flushPendingEvents() async { + AppEvents.shared.flush() + } + public func record(_ record: AnalyticsRecord) async { AppEvents.shared.logEvent( AppEvents.Name(record.name), diff --git a/Sources/FirebaseProvider/AnalyticsRecordValidator+Firebase.swift b/Sources/FirebaseProvider/AnalyticsRecordValidator+Firebase.swift new file mode 100644 index 0000000..fd436b8 --- /dev/null +++ b/Sources/FirebaseProvider/AnalyticsRecordValidator+Firebase.swift @@ -0,0 +1,62 @@ +#if Firebase && (os(iOS) || os(macOS) || os(tvOS) || os(visionOS)) + +import AnalyticsSystem +import Foundation + +public extension AnalyticsRecordValidator { + /// Firebase Analytics' documented limits, enforced locally. + /// + /// Firebase discards a non-conforming event server-side and reports nothing, so + /// without this the data simply never appears and there is nothing to debug. + /// Names and parameter keys are sanitized where that is unambiguous; anything + /// that cannot be repaired safely is rejected with a reason. + /// + /// Limits: event and parameter names ≤ 40 characters, alphanumeric or underscore, + /// beginning with a letter; at most 25 parameters; string values ≤ 100 characters; + /// the `firebase_`, `google_` and `ga_` prefixes are reserved. + static let firebase = AnalyticsRecordValidator { record in + let name = sanitizedIdentifier(record.name) + + guard let first = name.first, first.isLetter else { + return .reject(reason: "Firebase event names must begin with a letter: '\(record.name)'") + } + for reserved in reservedPrefixes where name.lowercased().hasPrefix(reserved) { + return .reject(reason: "'\(reserved)' is a reserved Firebase prefix: '\(record.name)'") + } + guard record.payload.count <= maximumParameterCount else { + return .reject( + reason: "Firebase allows \(maximumParameterCount) parameters, got \(record.payload.count)" + ) + } + + var sanitized = AnalyticsPayload() + for (key, value) in record.payload { + let key = String(sanitizedIdentifier(key).prefix(maximumNameLength)) + guard let first = key.first, first.isLetter else { continue } + sanitized[key] = truncated(value) + } + + return .accept( + AnalyticsRecord(name: String(name.prefix(maximumNameLength)), payload: sanitized) + ) + } + + private static let maximumNameLength = 40 + private static let maximumValueLength = 100 + private static let maximumParameterCount = 25 + private static let reservedPrefixes = ["firebase_", "google_", "ga_"] + + /// Replaces every character Firebase disallows with an underscore. + private static func sanitizedIdentifier(_ value: String) -> String { + String(value.map { $0.isLetter || $0.isNumber || $0 == "_" ? $0 : "_" }) + } + + private static func truncated(_ value: AnalyticsValue) -> AnalyticsValue { + guard case let .string(string) = value, string.count > maximumValueLength else { + return value + } + return .string(String(string.prefix(maximumValueLength))) + } +} + +#endif diff --git a/Sources/MixpanelProvider/MixpanelTracker.swift b/Sources/MixpanelProvider/MixpanelTracker.swift index d4e3c68..7de1969 100644 --- a/Sources/MixpanelProvider/MixpanelTracker.swift +++ b/Sources/MixpanelProvider/MixpanelTracker.swift @@ -91,6 +91,10 @@ public struct MixpanelTracker: AnalyticsTracker { public func record(_ record: AnalyticsRecord) async { instance?.track(event: record.name, properties: record.payload.mixpanelProperties) } + + public func flushPendingEvents() async { + instance?.flush() + } } #else diff --git a/Tests/AnalyticsSystemTests/AnalyticsValueTests.swift b/Tests/AnalyticsSystemTests/AnalyticsValueTests.swift new file mode 100644 index 0000000..9687129 --- /dev/null +++ b/Tests/AnalyticsSystemTests/AnalyticsValueTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import AnalyticsSystem + +@Suite("AnalyticsValue") +struct AnalyticsValueTests { + @Test("Typed accessors return a value only for the matching case") + func typedAccessors() { + #expect(AnalyticsValue.string("x").stringValue == "x") + #expect(AnalyticsValue.int(3).stringValue == nil) + + #expect(AnalyticsValue.int(3).intValue == 3) + #expect(AnalyticsValue.string("x").intValue == nil) + + #expect(AnalyticsValue.bool(true).boolValue == true) + #expect(AnalyticsValue.int(1).boolValue == nil) + + #expect(AnalyticsValue.null.isNull) + #expect(!AnalyticsValue.int(0).isNull) + } + + @Test("doubleValue promotes an integer") + func doubleValuePromotesInt() { + #expect(AnalyticsValue.double(2.5).doubleValue == 2.5) + #expect(AnalyticsValue.int(2).doubleValue == 2.0) + #expect(AnalyticsValue.string("2").doubleValue == nil) + } + + @Test("Every case renders without a placeholder") + func descriptions() throws { + let url = try #require(URL(string: "https://example.com")) + #expect(AnalyticsValue.string("x").description == "x") + #expect(AnalyticsValue.int(3).description == "3") + #expect(AnalyticsValue.bool(false).description == "false") + #expect(AnalyticsValue.null.description == "null") + #expect(AnalyticsValue.url(url).description == "https://example.com") + #expect(AnalyticsValue.array([1, 2]).description == "[1, 2]") + // Object keys are sorted so the rendering is stable enough to assert on. + #expect(AnalyticsValue.object(["b": 2, "a": 1]).description == "{a: 1, b: 2}") + #expect(AnalyticsValue.date(Date(timeIntervalSince1970: 0)).description.hasPrefix("1970-01-01")) + } + + @Test("Every literal form produces the expected case") + func literals() { + #expect(AnalyticsValue("x") == .string("x")) + #expect(AnalyticsValue(3) == .int(3)) + #expect(AnalyticsValue(2.5) == .double(2.5)) + #expect(AnalyticsValue(true) == .bool(true)) + #expect(AnalyticsValue(nilLiteral: ()) == .null) + #expect([1, 2] as AnalyticsValue == .array([.int(1), .int(2)])) + #expect(["a": 1] as AnalyticsValue == .object(["a": .int(1)])) + } + + @Test("Scalar and collection conversions cover the convertible protocol") + func convertibleConformances() throws { + #expect("x".analyticsValue == .string("x")) + #expect(3.analyticsValue == .int(3)) + #expect(Double(2.5).analyticsValue == .double(2.5)) + #expect(Float(1.5).analyticsValue == .double(1.5)) + #expect(true.analyticsValue == .bool(true)) + #expect(AnalyticsValue.int(1).analyticsValue == .int(1)) + + let uuid = UUID() + #expect(uuid.analyticsValue == .string(uuid.uuidString)) + + let url = try #require(URL(string: "https://example.com")) + #expect(url.analyticsValue == .url(url)) + + #expect(["a", "b"].analyticsValue == .array([.string("a"), .string("b")])) + #expect(["k": 1].analyticsValue == .object(["k": .int(1)])) + } + + @Test("Payload mutation and access behave") + func payloadOperations() { + var payload = AnalyticsPayload() + #expect(payload.isEmpty) + + payload.set("a", 1) + payload["b"] = .string("x") + #expect(payload.count == 2) + #expect(Set(payload.keys) == ["a", "b"]) + #expect(payload.adding("c", true)["c"] == .bool(true)) + + payload.remove("a") + #expect(payload["a"] == nil) + #expect(!payload.isEmpty) + + #expect(AnalyticsPayload(attributes: ["n": 1, "s": "x"]) == ["n": 1, "s": "x"]) + #expect(AnalyticsPayload(["a": 1]).description == "a: 1") + #expect(payload.map(\.key).count == 1) + } + + @Test("Records render name and payload") + func recordDescription() { + #expect(AnalyticsRecord(name: "e").description == "e") + #expect(AnalyticsRecord(name: "e", attributes: ["a": 1]).description == "e { a: 1 }") + #expect(!AnalyticsRecord(name: "").isValid) + } + + @Test("Identifiers and users render and compose") + func identifiersAndUsers() { + #expect(AnalyticsID(rawValue: "x").description == "x") + #expect(AnalyticsTrackerID.firebase.description == "firebase") + + let user = AnalyticsUser(id: "1", firstName: "Ada", lastName: "Lovelace") + #expect(user.fullName == "Ada Lovelace") + #expect(AnalyticsUser(id: "1", firstName: "Ada").fullName == "Ada") + #expect(AnalyticsUser(id: "1").fullName == nil) + } + + @Test("Adopter categories occupy the reserved range") + func reservedCategories() { + #expect(AnalyticsEventCategory.reserved(0).rawValue == 1 << 16) + #expect(AnalyticsEventCategory.all.contains(.reserved(3))) + #expect(!AnalyticsEventCategory.lifecycle.contains(.reserved(0))) + } + + @Test("Errors describe themselves") + func errorDescriptions() throws { + let duplicate = AnalyticsError.duplicateTracker(.console) + #expect(try #require(duplicate.errorDescription).contains("already registered")) + + let unknown = AnalyticsError.unknownTracker("nope") + #expect(try #require(unknown.errorDescription).contains("nope")) + } +} diff --git a/Tests/AnalyticsSystemTests/DiagnosticsTests.swift b/Tests/AnalyticsSystemTests/DiagnosticsTests.swift new file mode 100644 index 0000000..ffb1d18 --- /dev/null +++ b/Tests/AnalyticsSystemTests/DiagnosticsTests.swift @@ -0,0 +1,93 @@ +import Testing +@testable import AnalyticsSystem + +@Suite("Diagnostics") +struct DiagnosticsTests { + @Test("Dropping an event while disabled is reported") + func reportsDropWhileDisabled() async throws { + let recorder = DiagnosticsRecorder() + let system = AnalyticsSystem.makeTestSystem(diagnostics: recorder.handler) + try await system.register(SpyTracker(id: "a")) + + await system.setEnabled(false) + system.track(DiagnosticEvent()) + await system.flush() + + #expect(recorder.recorded.contains(.droppedWhileDisabled("diagnostic"))) + } + + @Test("An unmapped event is reported per tracker") + func reportsUnmapped() async throws { + let recorder = DiagnosticsRecorder() + let system = AnalyticsSystem.makeTestSystem(diagnostics: recorder.handler) + try await system.register( + SpyTracker(id: "ignoring"), + mapper: AnalyticsEventMapper().ignoring(PurchaseEvent.self) + ) + + system.track(PurchaseEvent(sku: "sku", amount: 1)) + await system.flush() + + #expect(recorder.recorded.contains(.unmapped("purchase", tracker: "ignoring"))) + } + + @Test("No handler means no crash and no cost") + func handlerIsOptional() async throws { + let system = AnalyticsSystem.makeTestSystem() + try await system.register(SpyTracker(id: "a")) + await system.setEnabled(false) + system.track(DiagnosticEvent()) + await system.flush() + } + + @Test("Diagnostics render readably") + func descriptionsAreReadable() { + #expect( + AnalyticsDiagnostic.rejected("e", tracker: "t", reason: "why").description + == "'e' rejected by 't': why" + ) + #expect( + AnalyticsDiagnostic.bufferOverflow(dropped: "e", limit: 2).description + == "startup buffer full (limit 2); dropped 'e'" + ) + } +} + +@Suite("ProviderFlush") +struct ProviderFlushTests { + @Test("flushProviders reaches every tracker") + func flushReachesEveryTracker() async throws { + let system = AnalyticsSystem.makeTestSystem() + let first = SpyTracker(id: "a") + let second = SpyTracker(id: "b") + try await system.register(first) + try await system.register(second) + + await system.flushProviders() + + #expect(await first.recordedCalls.contains(.flushPendingEvents)) + #expect(await second.recordedCalls.contains(.flushPendingEvents)) + } + + @Test("flushProviders is ordered after events already tracked") + func flushIsOrderedAfterEvents() async throws { + let system = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "a") + try await system.register(spy) + + system.track(DiagnosticEvent()) + await system.flushProviders() + + let calls = await spy.recordedCalls + let recordIndex = try #require(calls.firstIndex { if case .record = $0 { true } else { false } }) + let flushIndex = try #require(calls.firstIndex(of: .flushPendingEvents)) + #expect(recordIndex < flushIndex) + } + + @Test("A tracker that does not implement flush is unaffected") + func defaultFlushIsNoOp() async throws { + let system = AnalyticsSystem.makeTestSystem() + try await system.register(PlainTracker(id: "plain")) + await system.flushProviders() + } +} diff --git a/Tests/AnalyticsSystemTests/GlobalPropertiesTests.swift b/Tests/AnalyticsSystemTests/GlobalPropertiesTests.swift new file mode 100644 index 0000000..a4ac1e4 --- /dev/null +++ b/Tests/AnalyticsSystemTests/GlobalPropertiesTests.swift @@ -0,0 +1,70 @@ +import Testing +@testable import AnalyticsSystem + +@Suite("GlobalProperties") +struct GlobalPropertiesTests { + @Test("Global properties are merged into every record") + func mergedIntoEveryRecord() async throws { + let system = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "a") + try await system.register(spy) + + await system.setGlobalProperties(["app_version": "2.1.0", "locale": "en_US"]) + system.track(DiagnosticEvent()) + system.track(PurchaseEvent(sku: "sku", amount: 3)) + await system.flush() + + for record in await spy.recordedEvents { + #expect(record.payload["app_version"] == .string("2.1.0")) + #expect(record.payload["locale"] == .string("en_US")) + } + } + + @Test("Event attributes win over globals on key conflict") + func eventAttributesWin() async throws { + let system = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "a") + try await system.register(spy) + + await system.setGlobalProperties(["user_id": "global", "app_version": "2.1.0"]) + system.track(SignUpEvent(userID: "event", method: .email)) + await system.flush() + + let record = try #require(await spy.recordedEvents.first) + #expect(record.payload["user_id"] == .string("event")) + #expect(record.payload["app_version"] == .string("2.1.0")) + } + + @Test("Globals only affect events tracked after they are set") + func appliesFromWhenSet() async throws { + let system = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "a") + try await system.register(spy) + + system.track(DiagnosticEvent()) + await system.setGlobalProperties(["app_version": "2.1.0"]) + system.track(DiagnosticEvent()) + await system.flush() + + let records = await spy.recordedEvents + #expect(records.count == 2) + #expect(records[0].payload["app_version"] == nil) + #expect(records[1].payload["app_version"] == .string("2.1.0")) + } + + @Test("Setting globals again replaces the previous set") + func replacesPreviousSet() async throws { + let system = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "a") + try await system.register(spy) + + await system.setGlobalProperties(["a": "1"]) + await system.setGlobalProperties(["b": "2"]) + system.track(DiagnosticEvent()) + await system.flush() + + let record = try #require(await spy.recordedEvents.first) + #expect(record.payload["a"] == nil) + #expect(record.payload["b"] == .string("2")) + } +} diff --git a/Tests/AnalyticsSystemTests/ReadmeExamplesTests.swift b/Tests/AnalyticsSystemTests/ReadmeExamplesTests.swift index a7a90a1..e4b0887 100644 --- a/Tests/AnalyticsSystemTests/ReadmeExamplesTests.swift +++ b/Tests/AnalyticsSystemTests/ReadmeExamplesTests.swift @@ -153,3 +153,74 @@ struct ReadmeExamplesTests { #expect(await spy.recordedEventNames == ["sign_up"]) } } + +// MARK: - 2.1.0 additions + +@Suite("ReadmeExamples2_1") +struct ReadmeExamples21Tests { + /// "Attributes on every event". + @Test("Global properties reach every record, with events winning") + func globalProperties() async throws { + let analytics = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "spy") + try await analytics.register(spy) + + await analytics.setGlobalProperties([ + "app_version": "2.1.0", + "locale": "en_US" + ]) + analytics.track(ReadmePurchaseEvent()) + await analytics.flush() + + let record = try #require(await spy.recordedEvents.first) + #expect(record.payload["app_version"] == .string("2.1.0")) + #expect(record.payload["locale"] == .string("en_US")) + } + + /// "Seeing what you lose". + @Test("The documented diagnostics handler receives drops") + func diagnosticsHandler() async throws { + let recorder = DiagnosticsRecorder() + let analytics = AnalyticsSystem( + configuration: .init( + store: InMemoryAnalyticsStore(), + startupBuffer: .disabled, + diagnostics: recorder.handler + ) + ) + try await analytics.register(SpyTracker(id: "spy")) + + await analytics.setEnabled(false) + analytics.track(ReadmePurchaseEvent()) + await analytics.flush() + + #expect(!recorder.recorded.isEmpty) + } + + /// "Flushing before the app goes away". + @Test("flushProviders reaches providers") + func flushProviders() async throws { + let analytics = AnalyticsSystem.makeTestSystem() + let spy = SpyTracker(id: "spy") + try await analytics.register(spy) + + await analytics.flushProviders() + + #expect(await spy.recordedCalls.contains(.flushPendingEvents)) + } + + /// "3. Track" — the documented pre-start buffering behaviour. + @Test("Launch-time events survive until start()") + func launchEventsSurvive() async throws { + let analytics = AnalyticsSystem.makeTestSystem(startupBuffer: .default) + + analytics.track(ReadmePurchaseEvent()) + + let spy = SpyTracker(id: "spy") + try await analytics.register(spy) + await analytics.start() + await analytics.flush() + + #expect(await spy.recordedEventNames == ["purchase"]) + } +} diff --git a/Tests/AnalyticsSystemTests/StartupBufferTests.swift b/Tests/AnalyticsSystemTests/StartupBufferTests.swift new file mode 100644 index 0000000..477c79e --- /dev/null +++ b/Tests/AnalyticsSystemTests/StartupBufferTests.swift @@ -0,0 +1,123 @@ +import Testing +@testable import AnalyticsSystem + +@Suite("StartupBuffer") +struct StartupBufferTests { + @Test("Buffering is on by default") + func bufferedByDefault() { + #expect(AnalyticsStartupBuffer.default == .buffered(limit: 100)) + #expect(AnalyticsSystem.Configuration().startupBuffer == .buffered(limit: 100)) + } + + /// GAP 1 — before this, an event tracked before any tracker was registered was + /// silently lost. That is precisely the app-launch case. + @Test("An event tracked before any tracker is registered survives") + func survivesTrackBeforeRegistration() async throws { + let system = AnalyticsSystem.makeTestSystem(startupBuffer: .default) + + system.track(SignUpEvent(userID: "1", method: .email)) + + let spy = SpyTracker(id: "late") + try await system.register(spy) + await system.start() + await system.flush() + + #expect(await spy.recordedEventNames == ["sign_up"]) + } + + /// GAP 2 — before this, a provider received `record()` with no preceding + /// `start()`, i.e. before its SDK had been initialised. + @Test("A provider never receives an event before it has been started") + func neverRecordsBeforeStart() async throws { + let system = AnalyticsSystem.makeTestSystem(startupBuffer: .default) + let spy = SpyTracker(id: "a") + try await system.register(spy) + + system.track(DiagnosticEvent()) + await system.flush() + #expect(await spy.recordedEvents.isEmpty) + + await system.start() + await system.flush() + + let calls = await spy.recordedCalls + let startIndex = try #require(calls.firstIndex { if case .start = $0 { true } else { false } }) + let recordIndex = try #require(calls.firstIndex { if case .record = $0 { true } else { false } }) + #expect(startIndex < recordIndex, "record() must never precede start()") + } + + @Test("Held events replay in the order they were tracked") + func replaysInOrder() async throws { + let system = AnalyticsSystem.makeTestSystem(startupBuffer: .default) + let spy = SpyTracker(id: "a") + try await system.register(spy) + + system.track(SignUpEvent(userID: "1", method: .email)) + system.track(PurchaseEvent(sku: "sku", amount: 1)) + system.track(DiagnosticEvent()) + await system.start() + await system.flush() + + #expect(await spy.recordedEventNames == ["sign_up", "purchase", "diagnostic"]) + } + + @Test("Events tracked after start are delivered immediately") + func passThroughAfterStart() async throws { + let system = AnalyticsSystem.makeTestSystem(startupBuffer: .default) + let spy = SpyTracker(id: "a") + try await system.register(spy) + await system.start() + + system.track(DiagnosticEvent()) + await system.flush() + + #expect(await spy.recordedEventNames == ["diagnostic"]) + } + + @Test("The buffer is bounded, dropping oldest first and reporting it") + func boundedBufferDropsOldest() async throws { + let recorder = DiagnosticsRecorder() + let system = AnalyticsSystem.makeTestSystem( + startupBuffer: .buffered(limit: 2), + diagnostics: recorder.handler + ) + let spy = SpyTracker(id: "a") + try await system.register(spy) + + system.track(SignUpEvent(userID: "1", method: .email)) // evicted + system.track(PurchaseEvent(sku: "sku", amount: 1)) + system.track(DiagnosticEvent()) + await system.start() + await system.flush() + + #expect(await spy.recordedEventNames == ["purchase", "diagnostic"]) + #expect(recorder.recorded.contains(.bufferOverflow(dropped: "sign_up", limit: 2))) + } + + @Test("Disabling the buffer restores immediate delivery") + func disabledBufferDeliversImmediately() async throws { + let system = AnalyticsSystem.makeTestSystem(startupBuffer: .disabled) + let spy = SpyTracker(id: "a") + try await system.register(spy) + + system.track(DiagnosticEvent()) + await system.flush() + + #expect(await spy.recordedEventNames == ["diagnostic"]) + } + + @Test("Buffering is reported as a diagnostic") + func reportsBuffering() async throws { + let recorder = DiagnosticsRecorder() + let system = AnalyticsSystem.makeTestSystem( + startupBuffer: .default, + diagnostics: recorder.handler + ) + try await system.register(SpyTracker(id: "a")) + + system.track(DiagnosticEvent()) + await system.flush() + + #expect(recorder.recorded.contains(.buffered("diagnostic"))) + } +} diff --git a/Tests/AnalyticsSystemTests/Support/SpyTracker.swift b/Tests/AnalyticsSystemTests/Support/SpyTracker.swift index 57c722c..69ea3ab 100644 --- a/Tests/AnalyticsSystemTests/Support/SpyTracker.swift +++ b/Tests/AnalyticsSystemTests/Support/SpyTracker.swift @@ -13,6 +13,7 @@ actor SpyTracker: AnalyticsTracker, CrashReportingTracker { case logIn(AnalyticsUser) case logOut case record(AnalyticsRecord) + case flushPendingEvents } nonisolated let id: AnalyticsTrackerID @@ -55,6 +56,10 @@ actor SpyTracker: AnalyticsTracker, CrashReportingTracker { calls.append(.logOut) } + func flushPendingEvents() async { + calls.append(.flushPendingEvents) + } + func record(_ record: AnalyticsRecord) async { if let recordDelay { try? await Task.sleep(nanoseconds: recordDelay) diff --git a/Tests/AnalyticsSystemTests/Support/TestEvents.swift b/Tests/AnalyticsSystemTests/Support/TestEvents.swift index cd5a2b5..f6be721 100644 --- a/Tests/AnalyticsSystemTests/Support/TestEvents.swift +++ b/Tests/AnalyticsSystemTests/Support/TestEvents.swift @@ -60,16 +60,46 @@ final class SpyLogSink: AnalyticsLogSink, @unchecked Sendable { extension AnalyticsSystem { /// A system wired to in-memory persistence and a deterministic ID generator. + /// + /// The startup buffer defaults to `.disabled` here so that suites about dispatch, + /// filtering or mapping stay focused on that behaviour rather than each having to + /// call `start()` first. The production default — and the buffer itself — is + /// covered by `StartupBufferTests`. static func makeTestSystem( store: any AnalyticsStore = InMemoryAnalyticsStore(), - idGenerator: @escaping @Sendable () -> AnalyticsID = AnalyticsID.random + idGenerator: @escaping @Sendable () -> AnalyticsID = AnalyticsID.random, + startupBuffer: AnalyticsStartupBuffer = .disabled, + diagnostics: AnalyticsDiagnosticHandler? = nil ) -> AnalyticsSystem { AnalyticsSystem( - configuration: Configuration(store: store, idGenerator: idGenerator) + configuration: Configuration( + store: store, + idGenerator: idGenerator, + startupBuffer: startupBuffer, + diagnostics: diagnostics + ) ) } } +/// Collects diagnostics for assertion. +final class DiagnosticsRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [AnalyticsDiagnostic] = [] + + var handler: AnalyticsDiagnosticHandler { + { [self] diagnostic in + lock.lock(); defer { lock.unlock() } + storage.append(diagnostic) + } + } + + var recorded: [AnalyticsDiagnostic] { + lock.lock(); defer { lock.unlock() } + return storage + } +} + /// A deterministic, concurrency-safe ID generator: `id-1`, `id-2`, … struct SequentialIDGenerator: Sendable { private let counter = Locked(0) diff --git a/Tests/AnalyticsSystemTests/ValidationTests.swift b/Tests/AnalyticsSystemTests/ValidationTests.swift new file mode 100644 index 0000000..dfb1b8b --- /dev/null +++ b/Tests/AnalyticsSystemTests/ValidationTests.swift @@ -0,0 +1,99 @@ +import Testing +@testable import AnalyticsSystem + +@Suite("Validation") +struct ValidationTests { + @Test("The default validator rejects an empty name") + func defaultRejectsEmptyName() { + if case .reject = AnalyticsRecordValidator.default(AnalyticsRecord(name: "")) {} else { + Issue.record("expected rejection") + } + if case .accept = AnalyticsRecordValidator.default(AnalyticsRecord(name: "ok")) {} else { + Issue.record("expected acceptance") + } + } + + @Test("The permissive validator accepts anything") + func permissiveAcceptsAnything() { + if case .accept = AnalyticsRecordValidator.permissive(AnalyticsRecord(name: "")) {} else { + Issue.record("expected acceptance") + } + } + + @Test("A rejection suppresses delivery and is reported") + func rejectionIsReported() async throws { + let recorder = DiagnosticsRecorder() + let system = AnalyticsSystem.makeTestSystem(diagnostics: recorder.handler) + let spy = SpyTracker(id: "strict") + + let noPurchases = AnalyticsRecordValidator { record in + record.name == "purchase" + ? .reject(reason: "purchases are not allowed here") + : .accept(record) + } + try await system.register(spy, validator: noPurchases) + + system.track(PurchaseEvent(sku: "sku", amount: 1)) + system.track(DiagnosticEvent()) + await system.flush() + + #expect(await spy.recordedEventNames == ["diagnostic"]) + #expect(recorder.recorded.contains( + .rejected("purchase", tracker: "strict", reason: "purchases are not allowed here") + )) + } + + @Test("Validation is per-tracker") + func validationIsPerTracker() async throws { + let system = AnalyticsSystem.makeTestSystem() + let strict = SpyTracker(id: "strict") + let lenient = SpyTracker(id: "lenient") + + try await system.register(strict, validator: AnalyticsRecordValidator { _ in + .reject(reason: "nope") + }) + try await system.register(lenient) + + system.track(DiagnosticEvent()) + await system.flush() + + #expect(await strict.recordedEvents.isEmpty) + #expect(await lenient.recordedEventNames == ["diagnostic"]) + } + + @Test("A sanitizing validator rewrites the record and reports it") + func sanitizationIsReported() async throws { + let recorder = DiagnosticsRecorder() + let system = AnalyticsSystem.makeTestSystem(diagnostics: recorder.handler) + let spy = SpyTracker(id: "trunc") + + let truncating = AnalyticsRecordValidator { record in + .accept(AnalyticsRecord(name: String(record.name.prefix(4)), payload: record.payload)) + } + try await system.register(spy, validator: truncating) + + system.track(DiagnosticEvent()) + await system.flush() + + #expect(await spy.recordedEventNames == ["diag"]) + #expect(recorder.recorded.contains( + .sanitized(from: "diagnostic", to: "diag", tracker: "trunc") + )) + } + + @Test("combined(with:) runs the second validator on the first's output") + func combinedChainsValidators() { + let truncate = AnalyticsRecordValidator { + .accept(AnalyticsRecord(name: String($0.name.prefix(4)), payload: $0.payload)) + } + let rejectShort = AnalyticsRecordValidator { + $0.name.count < 5 ? .reject(reason: "too short") : .accept($0) + } + + if case let .reject(reason) = truncate.combined(with: rejectShort)(AnalyticsRecord(name: "diagnostic")) { + #expect(reason == "too short") + } else { + Issue.record("expected the second validator to see the truncated name") + } + } +}