Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @AndrewKochulab
51 changes: 51 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blank_issues_enabled: true
20 changes: 20 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
commit-message:
prefix: "ci"
labels: [dependencies]
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## What and why

<!-- What changes, and what problem it solves. -->

## 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
27 changes: 26 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
@@ -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 '<script>window.location.href="/AnalyticsSystem/documentation/analyticssystem"</script>' > _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
6 changes: 6 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 1 addition & 1 deletion AnalyticsSystem.podspec
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
57 changes: 57 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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)
Expand Down
Loading
Loading