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 @@
[](https://swift.org)
[](https://swift.org)
[](LICENSE)
+[](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")
+ }
+ }
+}