2 Commits
0.3.0 ... 0.5.0

Author SHA1 Message Date
bdot
84b3a4cb20 Publish Score iOS SDK 0.5.0 2026-10-07 12:59:18 -04:00
bdot
540823aac1 Publish Score iOS SDK 0.4.0 2026-10-06 22:25:11 -04:00
6 changed files with 92 additions and 17 deletions

View File

@@ -8,9 +8,9 @@ identically — see [`conformance/CONTRACT.md`](conformance/CONTRACT.md).
--- ---
## Install — 0.3.0 beta (public, no auth) ## Install — 0.5.0 beta (public, no auth)
Distributed publicly from **[`3HL-SCORE/public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score)** (#284) — **no Gitea token / login required**. The iOS XCFramework is a public **release asset** (`mobile-v0.3.0`) on that repo; the Android artifacts live in the org's **Gitea Maven package registry** (anonymous read). Distributed publicly from **[`3HL-SCORE/public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score)** (#284) — **no Gitea token / login required**. The iOS XCFramework is a public **release asset** (`mobile-v0.5.0`) on that repo; the Android artifacts live in the org's **Gitea Maven package registry** (anonymous read).
### Android — Gradle (public Maven registry) ### Android — Gradle (public Maven registry)
```kotlin ```kotlin
@@ -18,7 +18,7 @@ repositories {
maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } // public — no credentials maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } // public — no credentials
} }
dependencies { dependencies {
implementation("com.threehalveslabs.scoretracker:shared-android:0.3.0") implementation("com.threehalveslabs.scoretracker:shared-android:0.5.0")
} }
``` ```
@@ -34,17 +34,17 @@ add it by **git URL** — no registry config, no login:
```swift ```swift
// your app's Package.swift // your app's Package.swift
dependencies: [ dependencies: [
.package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.3.0") .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.5.0")
], ],
// target: .product(name: "ScoreTracker", package: "public-sdks-score") // target: .product(name: "ScoreTracker", package: "public-sdks-score")
``` ```
Or in Xcode: **File → Add Packages…** → paste the URL. The binary `ScoreTrackerKit` Or in Xcode: **File → Add Packages…** → paste the URL. The binary `ScoreTrackerKit`
target pulls the XCFramework from the **public** `mobile-v0.3.0` release asset (SPM target pulls the XCFramework from the **public** `mobile-v0.5.0` release asset (SPM
verifies the checksum) — no `~/.netrc` credentials needed. Verified with an verifies the checksum) — no `~/.netrc` credentials needed. Verified with an
anonymous `swift package resolve`. anonymous `swift package resolve`.
### iOS — direct XCFramework ### iOS — direct XCFramework
Download `ScoreTracker-pod.zip` from the **mobile-v0.3.0** release on Download `ScoreTracker-pod.zip` from the **mobile-v0.5.0** release on
[`public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases). [`public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases).
It holds `ScoreTrackerKit.xcframework` and the Swift facade sources It holds `ScoreTrackerKit.xcframework` and the Swift facade sources
(`Sources/ScoreTracker/`: `ScoreClient.swift`, `Exports.swift`, `AppLifecycle.swift`). (`Sources/ScoreTracker/`: `ScoreClient.swift`, `Exports.swift`, `AppLifecycle.swift`).
@@ -220,6 +220,36 @@ score.setTokenProvider { credentialsManager.awaitCredentials().accessToken }
score.identify("auth0|abc123") score.identify("auth0|abc123")
``` ```
### Signing in: traits and replay (#488)
`identify` takes the fan's traits, sent on an `identify` event (consent-gated
and PII-scrubbed like any props). Pass `replayAnonymousEvents` to re-send the
events the app already sent while the fan was signed out, now with the fan
attached, so a poll vote cast before sign-in still earns its karma:
```swift
score.identify(userId: "auth0|abc123", traits: ["team": "urc-leinster"], replayAnonymousEvents: true)
```
```kotlin
score.identify("auth0|abc123", traits = mapOf("team" to "urc-leinster"), replayAnonymousEvents = true)
```
Replay is opt-in. It re-sends at most the last 100 anonymously sent events
(`identifyReplayBufferSize` on the factory / `ScoreClient` init), only those
current consent still allows, marked `_backfill: true` with `_originalEventId`.
Events still waiting in the queue aren't replayed: they go out with the fan on
the next flush. The buffer lives in memory only, so it's empty after a cold
start.
After `identify`, the tracker also links this device's `anonymousId` to the fan
(`POST /api/identity/link-anonymous`, with the token from your provider or
`setToken`), so the server can credit events that arrive with only the
`anonymousId`. It happens once per fan per device, on the next flush, after the
events; it needs `analytics` consent and a token, and a failure never affects
event delivery. Android's `score.lastLinkSkipReason()` (`already_linked`,
`consent`, `no_token`, `error`) says why a link hasn't happened. A custom
`Transport` must implement `postWithHeaders` for this call to carry the token.
How the provider is used: How the provider is used:
- **Before each flush**, and **single-flight**: overlapping flushes share one - **Before each flush**, and **single-flight**: overlapping flushes share one
in-flight call instead of starting several. in-flight call instead of starting several.
@@ -284,6 +314,28 @@ How the provider is used:
`startAutoFlush(everySeconds: 30)` (iOS) / `startAutoFlush(scope, intervalMs)` `startAutoFlush(everySeconds: 30)` (iOS) / `startAutoFlush(scope, intervalMs)`
(Android). (Android).
## Bug reports: attach `inspectJson()`
When the tracker misbehaves in the field, the first thing to attach is its
snapshot (#490), the counterpart of the web tracker's `inspect()`:
```swift
print(score.inspectJson()) // iOS
```
```kotlin
Log.d("Score", score.inspectJson()) // Android
```
It covers:
- the SDK name and version
- consent: each category, and how many events the gate dropped
- the queue: depth, caps, the oldest event's age, critical events, critical events lost
- identity: `anonymousId`, `sessionId`, `userId`, whether a token is held, the `link-anonymous` state and the replay buffer
- the last flush: when, whether it succeeded, how many events it sent, and how many failures in a row
- site, endpoint and the number of PII rules
**It never contains the token**, only `hasToken`, so it's safe to log or paste. `inspect()` returns the same data as a typed `InspectSnapshot`.
## Canonical app earning events ## Canonical app earning events
`type: "track"` with these names (props open; matched by the Odoo rules engine): `type: "track"` with these names (props open; matched by the Odoo rules engine):

View File

@@ -15,8 +15,8 @@ let package = Package(
targets: [ targets: [
.binaryTarget( .binaryTarget(
name: "ScoreTrackerKit", name: "ScoreTrackerKit",
url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.3.0/ScoreTrackerKit.xcframework.zip", url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.5.0/ScoreTrackerKit.xcframework.zip",
checksum: "81edf75ed592ef2b4cf17af8aea406333cebba62b2e5b69c28e386e8bc415a7d" checksum: "6943d712e2dcb5066f114e2fd14749fffebe79bab27a6ece3d1757b508f04d25"
), ),
.target( .target(
name: "ScoreTracker", name: "ScoreTracker",

View File

@@ -9,13 +9,13 @@ Public, auth-free distribution of the **Score mobile tracker** SDKs for iOS and
> This repo hosts the **consumable artifacts** (release assets + package manifests). The SDK source lives in 3 Halves Labs' private platform repo. > This repo hosts the **consumable artifacts** (release assets + package manifests). The SDK source lives in 3 Halves Labs' private platform repo.
## Install — 0.3.0 (no token / login required) ## Install — 0.5.0 (no token / login required)
### iOS — Swift Package Manager ### iOS — Swift Package Manager
Add this repo by git URL. There's no registry to configure and no login. Add this repo by git URL. There's no registry to configure and no login.
```swift ```swift
// Package.swift // Package.swift
dependencies: [ .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.3.0") ] dependencies: [ .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.5.0") ]
// target: .product(name: "ScoreTracker", package: "public-sdks-score") // target: .product(name: "ScoreTracker", package: "public-sdks-score")
``` ```
Or in Xcode: **File → Add Packages…** and paste the URL. (The old Swift package Or in Xcode: **File → Add Packages…** and paste the URL. (The old Swift package
@@ -29,7 +29,7 @@ pod 'ScoreTracker',
``` ```
### iOS — direct XCFramework ### iOS — direct XCFramework
Download `ScoreTracker-pod.zip` from the [`mobile-v0.3.0` release](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases). It contains `ScoreTrackerKit.xcframework` plus the Swift facade sources (`Sources/ScoreTracker/`). Drag the framework into Xcode and add the Swift files to your app target. Download `ScoreTracker-pod.zip` from the [`mobile-v0.5.0` release](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases). It contains `ScoreTrackerKit.xcframework` plus the Swift facade sources (`Sources/ScoreTracker/`). Drag the framework into Xcode and add the Swift files to your app target.
### Android — Gradle ### Android — Gradle
```kotlin ```kotlin
@@ -37,7 +37,7 @@ repositories {
maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } // public — no credentials maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } // public — no credentials
} }
dependencies { dependencies {
implementation("com.threehalveslabs.scoretracker:shared-android:0.3.0") implementation("com.threehalveslabs.scoretracker:shared-android:0.5.0")
} }
``` ```

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'ScoreTracker' s.name = 'ScoreTracker'
s.version = '0.3.0' s.version = '0.5.0'
s.summary = 'Score mobile tracker (iOS) — KMP shared core + Swift facade.' s.summary = 'Score mobile tracker (iOS) — KMP shared core + Swift facade.'
s.description = <<-DESC s.description = <<-DESC
Lightweight app analytics / fan-engagement tracker. Emits the same event Lightweight app analytics / fan-engagement tracker. Emits the same event

View File

@@ -17,3 +17,6 @@ public typealias DeliveryMode = ScoreTrackerKit.DeliveryMode
public typealias PiiOptions = ScoreTrackerKit.PiiOptions public typealias PiiOptions = ScoreTrackerKit.PiiOptions
public typealias PiiRule = ScoreTrackerKit.PiiRule public typealias PiiRule = ScoreTrackerKit.PiiRule
public typealias PiiAction = ScoreTrackerKit.PiiAction public typealias PiiAction = ScoreTrackerKit.PiiAction
/// The support snapshot returned by `ScoreClient.inspect()` (#490); its nested
/// types are reached through it (`snapshot.identity.hasToken`).
public typealias InspectSnapshot = ScoreTrackerKit.InspectSnapshot

View File

@@ -30,7 +30,8 @@ public final class ScoreClient {
locale: String? = nil, locale: String? = nil,
initialConsent: ConsentState = ConsentState.companion.NONE, initialConsent: ConsentState = ConsentState.companion.NONE,
pii: PiiOptions? = nil, pii: PiiOptions? = nil,
tokenRefreshTimeoutSeconds: Double = 5 tokenRefreshTimeoutSeconds: Double = 5,
identifyReplayBufferSize: Int = 100
) { ) {
let config = TrackerConfig( let config = TrackerConfig(
publishableKey: publishableKey, publishableKey: publishableKey,
@@ -42,7 +43,8 @@ public final class ScoreClient {
config: config, config: config,
initialConsent: initialConsent, initialConsent: initialConsent,
pii: pii, pii: pii,
tokenRefreshTimeoutMs: Int64(tokenRefreshTimeoutSeconds * 1000) tokenRefreshTimeoutMs: Int64(tokenRefreshTimeoutSeconds * 1000),
identifyReplayBufferSize: Int32(identifyReplayBufferSize)
) )
} }
@@ -82,8 +84,19 @@ public final class ScoreClient {
// MARK: - Identity / Auth0 // MARK: - Identity / Auth0
public func identify(userId: String, token: String? = nil) { /// Bind events to the fan (#488). Sends an `identify` event carrying
tracker.identify(userId: userId, token: token) /// `traits` (consent-gated and PII-scrubbed like any props). With
/// `replayAnonymousEvents`, events already sent before sign-in are re-sent
/// with the fan attached, marked `_backfill`, so a vote cast while signed
/// out earns its karma. Events still queued need no replay: they go out
/// with the user on the next flush.
public func identify(
userId: String,
token: String? = nil,
traits: [String: Any] = [:],
replayAnonymousEvents: Bool = false
) {
tracker.identify(userId: userId, token: token, traits: traits, replayAnonymousEvents: replayAnonymousEvents)
} }
public func setToken(_ token: String?) { tracker.setToken(token: token) } public func setToken(_ token: String?) { tracker.setToken(token: token) }
@@ -107,6 +120,13 @@ public final class ScoreClient {
public var anonymousId: String { tracker.anonymousId } public var anonymousId: String { tracker.anonymousId }
public var pending: Int { Int(tracker.pending()) } public var pending: Int { Int(tracker.pending()) }
/// The tracker's state for support (#490): consent, queue depth and oldest
/// event, identity (the token only as `hasToken`), the last flush, and the
/// link and replay state. Attach `inspectJson()` to any bug report.
public func inspect() -> InspectSnapshot { tracker.inspect() }
/// `inspect()` as JSON, ready to paste into a bug report.
public func inspectJson() -> String { tracker.inspectJson() }
// MARK: - Tracking // MARK: - Tracking
/// Track a typed loyalty event — the preferred form (#485). `Events` is /// Track a typed loyalty event — the preferred form (#485). `Events` is