@rocapine/rocalytics-sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +447 -2
  4. package/client/package.json +1 -0
  5. package/core/package.json +4 -0
  6. package/dist/client/client.d.ts +136 -0
  7. package/dist/client/client.d.ts.map +1 -0
  8. package/dist/client/client.js +373 -0
  9. package/dist/client/client.js.map +1 -0
  10. package/dist/client/index.d.ts +13 -0
  11. package/dist/client/index.d.ts.map +1 -0
  12. package/dist/client/index.js +35 -0
  13. package/dist/client/index.js.map +1 -0
  14. package/dist/client/native.d.ts +68 -0
  15. package/dist/client/native.d.ts.map +1 -0
  16. package/dist/client/native.js +64 -0
  17. package/dist/client/native.js.map +1 -0
  18. package/dist/client/onboardingSink.d.ts +63 -0
  19. package/dist/client/onboardingSink.d.ts.map +1 -0
  20. package/dist/client/onboardingSink.js +126 -0
  21. package/dist/client/onboardingSink.js.map +1 -0
  22. package/dist/client/paywallSink.d.ts +13 -0
  23. package/dist/client/paywallSink.d.ts.map +1 -0
  24. package/dist/client/paywallSink.js +26 -0
  25. package/dist/client/paywallSink.js.map +1 -0
  26. package/dist/client/requests.d.ts +66 -0
  27. package/dist/client/requests.d.ts.map +1 -0
  28. package/dist/client/requests.js +90 -0
  29. package/dist/client/requests.js.map +1 -0
  30. package/dist/client/types.d.ts +180 -0
  31. package/dist/client/types.d.ts.map +1 -0
  32. package/dist/client/types.js +4 -0
  33. package/dist/client/types.js.map +1 -0
  34. package/dist/core/context.d.ts +39 -0
  35. package/dist/core/context.d.ts.map +1 -0
  36. package/dist/core/context.js +45 -0
  37. package/dist/core/context.js.map +1 -0
  38. package/dist/core/delivery.d.ts +63 -0
  39. package/dist/core/delivery.d.ts.map +1 -0
  40. package/dist/core/delivery.js +133 -0
  41. package/dist/core/delivery.js.map +1 -0
  42. package/dist/core/diagnostics.d.ts +16 -0
  43. package/dist/core/diagnostics.d.ts.map +1 -0
  44. package/dist/core/diagnostics.js +24 -0
  45. package/dist/core/diagnostics.js.map +1 -0
  46. package/dist/core/httpSink.d.ts +35 -0
  47. package/dist/core/httpSink.d.ts.map +1 -0
  48. package/dist/core/httpSink.js +65 -0
  49. package/dist/core/httpSink.js.map +1 -0
  50. package/dist/core/ids.d.ts +16 -0
  51. package/dist/core/ids.d.ts.map +1 -0
  52. package/dist/core/ids.js +51 -0
  53. package/dist/core/ids.js.map +1 -0
  54. package/dist/core/index.d.ts +17 -0
  55. package/dist/core/index.d.ts.map +1 -0
  56. package/dist/core/index.js +32 -0
  57. package/dist/core/index.js.map +1 -0
  58. package/dist/core/sink.d.ts +39 -0
  59. package/dist/core/sink.d.ts.map +1 -0
  60. package/dist/core/sink.js +16 -0
  61. package/dist/core/sink.js.map +1 -0
  62. package/dist/core/storage.d.ts +52 -0
  63. package/dist/core/storage.d.ts.map +1 -0
  64. package/dist/core/storage.js +79 -0
  65. package/dist/core/storage.js.map +1 -0
  66. package/dist/core/text.d.ts +9 -0
  67. package/dist/core/text.d.ts.map +1 -0
  68. package/dist/core/text.js +47 -0
  69. package/dist/core/text.js.map +1 -0
  70. package/dist/core/time.d.ts +18 -0
  71. package/dist/core/time.d.ts.map +1 -0
  72. package/dist/core/time.js +18 -0
  73. package/dist/core/time.js.map +1 -0
  74. package/dist/onboarding/contract.d.ts +138 -0
  75. package/dist/onboarding/contract.d.ts.map +1 -0
  76. package/dist/onboarding/contract.js +11 -0
  77. package/dist/onboarding/contract.js.map +1 -0
  78. package/dist/onboarding/index.d.ts +15 -0
  79. package/dist/onboarding/index.d.ts.map +1 -0
  80. package/dist/onboarding/index.js +22 -0
  81. package/dist/onboarding/index.js.map +1 -0
  82. package/dist/onboarding/input.d.ts +102 -0
  83. package/dist/onboarding/input.d.ts.map +1 -0
  84. package/dist/onboarding/input.js +216 -0
  85. package/dist/onboarding/input.js.map +1 -0
  86. package/dist/onboarding/persisted.d.ts +20 -0
  87. package/dist/onboarding/persisted.d.ts.map +1 -0
  88. package/dist/onboarding/persisted.js +75 -0
  89. package/dist/onboarding/persisted.js.map +1 -0
  90. package/dist/onboarding/state.d.ts +65 -0
  91. package/dist/onboarding/state.d.ts.map +1 -0
  92. package/dist/onboarding/state.js +207 -0
  93. package/dist/onboarding/state.js.map +1 -0
  94. package/dist/onboarding/tracker.d.ts +135 -0
  95. package/dist/onboarding/tracker.d.ts.map +1 -0
  96. package/dist/onboarding/tracker.js +734 -0
  97. package/dist/onboarding/tracker.js.map +1 -0
  98. package/dist/paywall/contract.d.ts +67 -0
  99. package/dist/paywall/contract.d.ts.map +1 -0
  100. package/dist/paywall/contract.js +3 -0
  101. package/dist/paywall/contract.js.map +1 -0
  102. package/dist/paywall/index.d.ts +14 -0
  103. package/dist/paywall/index.d.ts.map +1 -0
  104. package/dist/paywall/index.js +19 -0
  105. package/dist/paywall/index.js.map +1 -0
  106. package/dist/paywall/observer.d.ts +38 -0
  107. package/dist/paywall/observer.d.ts.map +1 -0
  108. package/dist/paywall/observer.js +6 -0
  109. package/dist/paywall/observer.js.map +1 -0
  110. package/dist/paywall/state.d.ts +26 -0
  111. package/dist/paywall/state.d.ts.map +1 -0
  112. package/dist/paywall/state.js +132 -0
  113. package/dist/paywall/state.js.map +1 -0
  114. package/dist/paywall/tracker.d.ts +36 -0
  115. package/dist/paywall/tracker.d.ts.map +1 -0
  116. package/dist/paywall/tracker.js +180 -0
  117. package/dist/paywall/tracker.js.map +1 -0
  118. package/dist/version.d.ts +3 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +6 -0
  121. package/dist/version.js.map +1 -0
  122. package/docs/onboarding-run-contract.md +1013 -0
  123. package/docs/onboarding-run.examples.ts +306 -0
  124. package/docs/onboarding-run.schema.json +224 -0
  125. package/docs/onboarding-run.types.ts +144 -0
  126. package/docs/paywall-presentation-contract.md +139 -0
  127. package/docs/paywall-presentation.examples.ts +42 -0
  128. package/docs/paywall-presentation.schema.json +268 -0
  129. package/docs/paywall-presentation.types.ts +73 -0
  130. package/onboarding/package.json +4 -0
  131. package/package.json +120 -4
  132. package/paywall/package.json +1 -0
  133. package/src/client/client.ts +405 -0
  134. package/src/client/index.ts +54 -0
  135. package/src/client/native.ts +109 -0
  136. package/src/client/onboardingSink.ts +121 -0
  137. package/src/client/paywallSink.ts +25 -0
  138. package/src/client/requests.ts +126 -0
  139. package/src/client/types.ts +198 -0
  140. package/src/core/context.ts +69 -0
  141. package/src/core/delivery.ts +181 -0
  142. package/src/core/diagnostics.ts +30 -0
  143. package/src/core/httpSink.ts +70 -0
  144. package/src/core/ids.ts +46 -0
  145. package/src/core/index.ts +20 -0
  146. package/src/core/sink.ts +46 -0
  147. package/src/core/storage.ts +112 -0
  148. package/src/core/text.ts +37 -0
  149. package/src/core/time.ts +26 -0
  150. package/src/onboarding/contract.ts +144 -0
  151. package/src/onboarding/index.ts +39 -0
  152. package/src/onboarding/input.ts +246 -0
  153. package/src/onboarding/persisted.ts +93 -0
  154. package/src/onboarding/state.ts +233 -0
  155. package/src/onboarding/tracker.ts +855 -0
  156. package/src/paywall/contract.ts +73 -0
  157. package/src/paywall/index.ts +36 -0
  158. package/src/paywall/observer.ts +43 -0
  159. package/src/paywall/state.ts +125 -0
  160. package/src/paywall/tracker.ts +200 -0
  161. package/src/version.ts +2 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@rocapine/rocalytics-sdk` (named `@rocapine/studio-sdk` before 0.2.0, never published under that name) are recorded here.
4
+
5
+ The format follows Keep a Changelog, and the project follows Semantic Versioning. Dates are written YYYY-MM-DD. A version that has not been published yet is dated `unreleased`.
6
+
7
+ Versions before 1.0.0 may include breaking changes in a minor release. Once 1.0.0 is released, the entries before it may be truncated from this file.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.2.0] - unreleased
12
+
13
+ ### Changed
14
+ - Published to the public npm registry instead of GitHub Packages: `npm install @rocapine/rocalytics-sdk` needs no `.npmrc` and no token.
15
+ - Renamed to `@rocapine/rocalytics-sdk`; `/rocalytics` is now `/client`. Never published under the old name.
16
+
17
+ ### Added
18
+ - `@rocapine/rocalytics-sdk/paywall`: the paywall presentation tracker, `createPaywallTracker`. Pass it to the paywall host as `observer`.
19
+ - One presentation is up to three snapshots (start, shown, end) in the shape of the paywall presentation contract v1 (`docs/paywall-presentation-contract.md`, with its JSON Schema and types).
20
+ - Each presentation is delivered on its own, so a new presentation never supersedes an earlier one's unsent end. Unsent snapshots are stored, at most 20, and resent on the next launch.
21
+ - Never throws: invalid input, a failing context or a failing sink is reported through `onDiagnostic`.
22
+ - `@rocapine/rocalytics-sdk/client`: `createRocalyticsPaywallSink`, delivering presentations to `/paywall-presentations`, with `paywallIngestOutcome` and `buildPaywallPresentationRequest`.
23
+ - `@rocapine/rocalytics-sdk/client`: the Rocalytics client, `RocalyticsClient`, replacing the `rocalytics.client.ts` file apps copied.
24
+ - Its requests equal the reference client's. A test replays scenarios captured from the reference and compares every URL, header and body.
25
+ - API: `ready`, `rocaId`, `track`, `trackEvent`, `trackPurchase`, `identify`, `trackOnboarding`, `trackCustomEvent` (with `dedupSuffix`) and `getDemandScore`.
26
+ - Also exported: `getEventId`, and the request builders `buildTrackRequest`, `buildIdentifyRequest`, `buildOnboardingResponseRequest` and `buildDemandScoreRequest`.
27
+ - The roca id is stored under `rocalytics-roca-id`. A device that has only the misspelled `rocalitics-roca-id` keeps its id, which is copied to the new key. The old key is not deleted, and a newly minted id is written under both keys, so a bundle rolled back to a copied client reads the same id. A failed read never mints a new id.
28
+ - `install` fires once per device. Both `rocadata-install-tracked` and `rocadata-install-tracked-4` count as already sent.
29
+ - The Expo modules load lazily, after a presence check through `expo-modules-core`. When one is missing from the binary, the client is inert and reports why through `onDiagnostic`. It does not throw.
30
+ - No request, response or purchase property is logged. Start-up problems go to `onDiagnostic`, which defaults to `console.warn`.
31
+ - `createRocalyticsOnboardingSink(client)`: delivers the onboarding run tracker's snapshots to Rocalytics.
32
+ - Each snapshot is mapped onto the pre-v1 onboarding payload (`toOnboardingResponsePayload`), the only shape the ingest reads, with the `onboarding_metadata` keys the contract's section 9 reads.
33
+ - Its `sent_at` is the run's latest recorded timestamp plus `seq` milliseconds. Within one run that strictly increases across sends, whatever the device clock does, so an ingest that keeps only a strictly later snapshot never drops one of the run's snapshots. A retry resends the identical body. Across runs this does not hold. The ingest keeps one row per roca id, so after the device clock steps back between two runs, a later run's snapshots can be dropped silently, as they could before: about the first span of that run equal to the step, or all of it if the run is shorter. The sink cannot tell a stored 2xx from a dropped one.
34
+ - Once a completed snapshot is accepted, the sink sends `onboarding_completed` once per run, with the run-scoped deduplication id `${rocaId}-onboarding_completed-${run_id}`. Each completed run on a device counts once, and a resend of the same run's completion is deduplicated.
35
+ - `rocalyticsOutcome`: a 2xx is accepted. Only a body saying `{"outcome": "rejected"}` is permanent. Any other answer is transient.
36
+ - `Sink.destination` (optional): where a sink delivers. `createHttpSink` sets it to its `url`, and `createRocalyticsOnboardingSink` to the client's onboarding endpoint, exposed as the new `RocalyticsClient.onboardingRunDestination`. With an injected `fetch`, both mark it as `<url> (custom fetch)`. Headers are not part of it.
37
+ - `@rocapine/rocalytics-sdk/core`: `Delivery.idle()`, which resolves once no attempt is in flight. `/core` has no stability promise: a custom implementation of `Delivery` must add it.
38
+
39
+ ### Fixed
40
+
41
+ - `@rocapine/rocalytics-sdk/onboarding`: a reconfigure no longer loses or repeats a snapshot. `dispose()` hands what the sink has not taken after its last attempt, a completion included, to the next tracker created on the same `storageKey` whose sink has the same destination, in memory. That tracker sends it with its own sink and retries, with or without storage. It used to stay only in storage, so it was lost without working storage. The next tracker keeps them in its writes from the start, but waits until the old tracker's last attempts are answered before sending any of the old tracker's snapshots, stored or handed over, and drops the ones they delivered, where it used to send a completion twice. A tracker created and disposed without a run of its own, as two `configure()` calls in a row do, passes on everything it was handed. A write of the old tracker's that lands after the next tracker read storage no longer leaves the new run unresumable, and no longer leads the next tracker to erase the old run's completion: the next tracker writes its own state again as each such write lands. The old tracker's run is handed over as well, in its newest state, so the next tracker's `resume()` continues it from there, with storage or without, and returns null for a run the old tracker completed. It used to resume from whatever state it read, which a late write could leave older, even reviving a completed run and reusing its seq. A snapshot is still lost when the app is killed without working storage.
42
+ - `@rocapine/rocalytics-sdk/onboarding`: `resume()` waits for the stored state to be read, however long storage takes. A read slower than `storageReadTimeoutMs` used to make `resume()` resolve null for the rest of the session, so a run in progress was not resumed. Now such a read is reported through `onDiagnostic` and `resume()` resolves once it lands. A read that never answers means `resume()` never resolves, so an app that cannot wait should race it with its own timeout. `storageReadTimeoutMs` still bounds `idle()`, and so how long a tracker created after a `dispose()` on the same key waits for the disposed one's writes. `start()` never waits for the read.
43
+ - `@rocapine/rocalytics-sdk/onboarding`: a numeric `studio.onboardingId`, `deploymentId` or `audienceId` passed at run time is sent as its decimal string, as the contract says, when it is a safe non-negative integer. It used to make the start invalid. A fraction, a negative number, `NaN`, `Infinity` or an integer above `Number.MAX_SAFE_INTEGER` is still `invalid-start`, and so is `studio: null`. The TypeScript type stays `string`.
44
+
45
+ ### Changed
46
+
47
+ - `@rocapine/rocalytics-sdk/onboarding`: snapshots recorded for one destination are never sent to another.
48
+ - A reconfigure hands the old tracker's unsent snapshots and run over only when the new sink has the same `destination`, or, when either sink has none, is the same object.
49
+ - Otherwise the new tracker takes nothing, in memory or from storage, and discards the stored state. It reports `destination-changed` once, only when that discards something (a run in progress, a snapshot the old tracker's last attempts did not deliver, or stored state).
50
+ - Stored state is now stamped with the writing sink's destination. A launch discards stored state written for another destination instead of sending it; two sinks without a destination count as one there.
51
+ - On a reconfigure, a sink with another `destination`, or a custom sink without one that is not the same object, now gets nothing of what the old tracker left unsent. The stock sinks always have a destination, so rebuilding them with the same URL keeps the handoff. A custom sink built anew on each `configure()` should set a `destination`, or be reused.
52
+ - `@rocapine/rocalytics-sdk/onboarding`: a stored unsent snapshot that a disposed tracker first reads after `dispose()` no longer gets a last attempt from it. It stays in storage, and the next tracker on the same `storageKey` sends it, so the two never send it at once.
53
+ - The package declares optional peer dependencies: `expo-application`, `expo-crypto`, `expo-device`, `expo-modules-core`, `expo-network`, `expo-secure-store` and `react-native`. Every range is `*`, so an app that already has any version of them installs the package without a peer conflict, and the client's run-time presence probe is the compatibility check. Only `/rocalytics` loads them. `/onboarding` and `/core` still depend on nothing, and `npm run check:exports` verifies that over their built require graph.
54
+
55
+ ## [0.1.0] - unreleased
56
+
57
+ Never published: its changes ship in 0.2.0, the first published version.
58
+
59
+ First release.
60
+
61
+ ### Added
62
+
63
+ - `@rocapine/studio-sdk/onboarding`: a headless onboarding run tracker.
64
+ - It reports one run of an onboarding as snapshots of the onboarding run contract v1 (`schema_version: 1`).
65
+ - API: `onboardingRun.configure`, `start`, `resume`, and per run `enterStep`, `exitStep` with answers, `setProperties`, `complete` and `background`. `createOnboardingRunTracker` creates an independent instance.
66
+ - There is no skip call. Each send is a full snapshot with a rising `seq`, and a completed run is never overwritten.
67
+ - Recording stops at 500 entries or at a 261,120-byte budget, setting `truncated`, while the run still completes.
68
+ - Input that the ingest would reject is dropped and reported through `onDiagnostic`, never sent and never thrown.
69
+ - A pluggable sink interface, and `createHttpSink`, which POSTs each snapshot to a collector.
70
+ - Only an outcome in the response body (`accepted`, `ignored`, `rejected`) settles a send.
71
+ - Anything else is transient and retried with backoff, using the same `seq` and body.
72
+ - Storage-backed resume after the app is killed, over any AsyncStorage-shaped key-value storage.
73
+ - A resumed run keeps its `run_id` and `seq`.
74
+ - Each snapshot is written before it is sent, and an unsent snapshot is delivered after a restart.
75
+ - Malformed stored state is discarded. Storage that cannot be read, or holds an unknown format, is left untouched.
76
+ - `@rocapine/studio-sdk/core` (internal, no stability promise): the sink interface, latest-snapshot delivery with backoff, UUIDv7 run ids, run context capture, and a serial JSON store.
77
+ - The contract v1 document, JSON Schema, TypeScript types and example payloads in `docs/`. The public payload types are the contract's types, and a test fails if they drift from the schema.
78
+ - No runtime dependencies. `/onboarding` and `/core` have no peer dependencies either.
79
+ - MIT license.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rocapine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,448 @@
1
- # Temporary Holding Version
1
+ # @rocapine/rocalytics-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Headless client SDK for Onboarding Studio. The first surface is the **onboarding run tracker**: a small, typed API that lets any onboarding report its progress in one versioned shape, including an onboarding built entirely in app code with no SDK-rendered screen. The second is the [**Rocalytics client**](#client), for apps that report installs, purchases and onboarding progress to Rocalytics.
4
+
5
+ - **Headless.** No renderer, router or UI library. The package has no runtime dependency. The tracker (`/onboarding`, `/core`) has no peer dependency either; only `/client` uses optional peers, the Expo native modules it talks to.
6
+ - **One payload contract.** Every send is a snapshot in the shape of the [onboarding run contract v1](docs/onboarding-run-contract.md) (`schema_version: 1`), with a [JSON Schema](docs/onboarding-run.schema.json) and [TypeScript types](docs/onboarding-run.types.ts). The contract is authoritative; this README only explains how the tracker applies it.
7
+ - **Pluggable transport.** Snapshots go to a sink. The stock sink POSTs to an HTTP collector you configure.
8
+
9
+ > Status: `0.2.0`, not yet published. 0.2.0 is the first version published to npm; 0.1.0 never was.
10
+
11
+ ## Installing
12
+
13
+ The package is public on the npm registry:
14
+
15
+ ```sh
16
+ npm install @rocapine/rocalytics-sdk
17
+ ```
18
+
19
+ ## Subpaths
20
+
21
+ | Import | What it is |
22
+ |---|---|
23
+ | `@rocapine/rocalytics-sdk/onboarding` | The onboarding run tracker. Public API. |
24
+ | `@rocapine/rocalytics-sdk/core` | Shared building blocks: the sink interface, latest-snapshot delivery, id minting, run context, storage. Internal: exported for custom sinks and future surfaces, with no stability promise beyond what `/onboarding` re-exports. |
25
+ | `@rocapine/rocalytics-sdk/client` | The Rocalytics client, and a sink that delivers the tracker's runs to Rocalytics. Public API. Needs the Expo peers below. |
26
+ | `@rocapine/rocalytics-sdk/paywall` | The paywall presentation tracker. Public API. |
27
+
28
+ The package is for tracking only: it holds no remote-control code. `/onboarding` and `/paywall` never import `/client`, so an app that only tracks onboarding or paywalls installs and bundles no native module.
29
+
30
+ ## A hand-coded onboarding
31
+
32
+ This file is [`examples/hand-coded-onboarding.ts`](examples/hand-coded-onboarding.ts), type-checked and run by the test suite:
33
+
34
+ ```ts
35
+ import { createHttpSink, onboardingRun, type KeyValueStorage, type OnboardingRun } from "@rocapine/rocalytics-sdk/onboarding";
36
+
37
+ // 1. Once, at app startup.
38
+ export function setUpTracking(storage: KeyValueStorage, device: { appVersion: string; build: string; osVersion: string }) {
39
+ onboardingRun.configure({
40
+ sink: createHttpSink({
41
+ url: "https://collector.example.com/v1/onboarding-runs",
42
+ headers: { authorization: "Bearer <install token>" },
43
+ }),
44
+ context: () => ({
45
+ appVersion: device.appVersion,
46
+ build: device.build,
47
+ platform: "ios",
48
+ osVersion: device.osVersion,
49
+ locale: "en-US",
50
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
51
+ }),
52
+ storage, // e.g. AsyncStorage: lets a run survive the app being killed
53
+ });
54
+ }
55
+
56
+ // 2. When the onboarding opens: resume the killed run if the app restores
57
+ // the user's position, otherwise start a new one. resume() waits for
58
+ // storage however long it takes, so stop waiting after a few seconds.
59
+ export async function openOnboarding(restorePosition: boolean, resumeTimeoutMs = 3000): Promise<OnboardingRun> {
60
+ const resumed = restorePosition ? await resumeWithin(resumeTimeoutMs) : null;
61
+ // Resumed: show the screen your own navigation state restored. The tracker
62
+ // records it as a new entry for its last recorded step (none if truncated).
63
+ if (resumed) return resumed;
64
+ return onboardingRun.start({
65
+ onboarding: { key: "main", version: "3" },
66
+ // Every screen the flow can show, in order. Alternatives at one position share a slot.
67
+ manifest: {
68
+ steps: [
69
+ { stepKey: "welcome" },
70
+ { stepKey: "goal" },
71
+ { stepKey: "level_beginner", slot: "level" },
72
+ { stepKey: "level_advanced", slot: "level" },
73
+ { stepKey: "notifications" },
74
+ { stepKey: "done" },
75
+ ],
76
+ },
77
+ properties: { signup_source: "email" },
78
+ });
79
+ }
80
+
81
+ function resumeWithin(ms: number): Promise<OnboardingRun | null> {
82
+ let timer: ReturnType<typeof setTimeout> | undefined;
83
+ const timeout = new Promise<null>((resolve) => (timer = setTimeout(() => resolve(null), ms)));
84
+ return Promise.race([onboardingRun.resume(), timeout]).finally(() => clearTimeout(timer));
85
+ }
86
+
87
+ // 3. Each screen reports when it is shown and when the user leaves it.
88
+ // A screen the flow does not show is simply never entered: there is no
89
+ // skip call. A screen shown again after going back is entered again.
90
+ export function walkThrough(run: OnboardingRun, goal: "practice" | "learn", notificationsGranted: boolean) {
91
+ run.enterStep("welcome");
92
+ run.exitStep("welcome");
93
+
94
+ run.enterStep("goal");
95
+ run.exitStep("goal", { answers: [{ questionKey: "goal", kind: "single", value: goal }] });
96
+
97
+ const level = goal === "practice" ? "level_advanced" : "level_beginner";
98
+ run.enterStep(level);
99
+ run.exitStep(level, { answers: [{ questionKey: "daily_minutes", kind: "numeric", value: 15, unit: "minute" }] });
100
+
101
+ if (!notificationsGranted) {
102
+ run.enterStep("notifications");
103
+ run.exitStep("notifications", { answers: [{ questionKey: "notifications", kind: "single", value: "allowed" }] });
104
+ }
105
+
106
+ run.enterStep("done");
107
+ run.complete();
108
+ }
109
+
110
+ // 4. When the app moves to the background (React Native: AppState "background").
111
+ export function onAppBackground(run: OnboardingRun) {
112
+ run.background();
113
+ }
114
+ ```
115
+
116
+ That run produces one payload per send. The completed one lists `welcome`, `goal`, one of the two `level` alternatives and `done`. The manifest still declares `notifications`, so a reader can derive that the run skipped it.
117
+
118
+ ## API
119
+
120
+ ### `onboardingRun.configure(config)`
121
+
122
+ | Option | Default | |
123
+ |---|---|---|
124
+ | `sink` | required | Where snapshots go. `createHttpSink({ url, headers?, timeoutMs? })`, or your own `Sink`. Its `destination` decides what a reconfigure hands over and what stored state is sent (see [A custom sink](#a-custom-sink)). |
125
+ | `context` | required | `{ appVersion, build, platform, osVersion, locale, timezone }`, or a function returning it. Read once per run, at start. There is no country field: the server derives it. |
126
+ | `storage` | none | A key-value store shaped like AsyncStorage or `localStorage`. Without it, nothing survives a restart. If it cannot be read (after one retry), or holds a format this version does not know, it is left untouched and the session runs without persistence. |
127
+ | `storageKey` | `rocalytics-sdk:onboarding-run` | One restorable run per key. |
128
+ | `debounceMs` | `500` | Changes within this window go out as one send. `complete()` and `background()` skip the debounce. |
129
+ | `persistTimeoutMs` | `1000` | With `storage`, every snapshot (completion and background included) is written before it is sent, waiting at most this long for the write. |
130
+ | `storageReadTimeoutMs` | `5000` | How long a storage read may take before it is reported (a `storage` diagnostic). It does not cut `resume()` short. It bounds `idle()`, which waits up to this long for the read and as long again for queued writes, so about twice this, and with it how long a tracker created after a `dispose()` on the same `storageKey` waits for the disposed one's writes. `start()` never waits for the read. |
131
+ | `retry` | `1000 ms × 2, ≤ 60 s` | Backoff between retries of a transient failure. |
132
+ | `onDiagnostic` | `console.warn` | Receives what the tracker declined to do, as `{ code, message, runId? }`. |
133
+ | `clock`, `timers`, `uuid` | system | Injected for tests. `uuid` must return a lowercase UUID; the default is UUIDv7. |
134
+
135
+ `createOnboardingRunTracker(config)` returns an independent tracker with the same methods, for tests or for two flows at once. **Trackers that are live at the same time must each use their own `storageKey`**: two on one key overwrite each other's unsent snapshots, and the second reports a `storage` diagnostic. A tracker created after another one on its key was disposed (as `configure()` does) first waits, bounded, for the disposed one's last writes, whatever storage object each was given.
136
+
137
+ `dispose()` (which `configure()` calls on the tracker it replaces) stops recording. What was already recorded is not dropped:
138
+
139
+ - a change still waiting on the debounce is sent;
140
+ - writes already queued still land;
141
+ - each unsent snapshot gets one last attempt.
142
+
143
+ A snapshot the sink does not take is then **handed, in memory, to the next tracker created on the same `storageKey`**, as `configure()` does, when that tracker's sink has the same destination (see [A custom sink](#a-custom-sink)). That tracker sends it with its own sink and retries, with or without storage. It keeps them in its own writes from the start, but sends none of the old tracker's snapshots, handed over or read from storage, until the old tracker's last attempts are answered (each within `attemptTimeoutMs`), and it drops any snapshot one of them delivered, so a reconfigure sends each snapshot once and storage never loses one still waiting for its answer. A tracker created and disposed without a run of its own (two `configure()` calls in a row, for example) passes on everything it was handed. The old tracker's run is handed over too, in its newest state: the new tracker's `resume()` returns it, with storage or without, and returns null if the old tracker completed it or started another. So a write of the old tracker's that lands after the new tracker read storage (past the bounded wait) can neither be resumed from an older state nor hide the newer one: the new tracker writes its own state again as each such write lands, once it has a run or was handed one. After a reconfigure to another destination it does so too, which erases what the old tracker wrote.
144
+
145
+ With working storage, an unsent snapshot also stays in storage for the next launch. The next launch may send again a snapshot that the sink took during `dispose()`, with the same `seq` and body, which the ingest ignores. Without working storage, only a tracker created in the same process gets it (see [Known limits of the tracker](#known-limits-of-the-tracker)).
146
+
147
+ ### `onboardingRun.start(options): OnboardingRun`
148
+
149
+ ```ts
150
+ onboardingRun.start({
151
+ onboarding: { key: "main", version: "3", variantKey?: "short-intro" },
152
+ manifest: { steps: [{ stepKey: "goal" }, { stepKey: "level_a", slot: "level" }, { stepKey: "level_b", slot: "level" }] },
153
+ properties?: { signup_source: "email" }, // ≤ 20 scalar values, no personal data
154
+ studio?: { onboardingId?, deploymentId?, audienceId?, draft? },
155
+ });
156
+ ```
157
+
158
+ - **`onboarding.version` must change whenever the manifest or a question's keys change.**
159
+ - **Studio-served flows** may omit `onboarding`. The key then defaults to `studio.onboardingId`, and the version to `studio.deploymentId`. Studio ids are strings. A number passed at run time (from untyped JSON, for example) is sent as its decimal string when it is a safe non-negative integer; any other number makes the start invalid. Leave `studio` out rather than passing null. A draft or preview (`draft: true`, or no deployment id) always sends version `"draft"`. A Studio-served run does not carry `variantKey`, because each Studio A/B arm is its own onboarding key.
160
+ - **Each `start` is a new run** with a new `run_id`, including a replay. A run already in progress is left as it was, and counts as quit once the server's threshold passes.
161
+ - **Invalid input is never sent.** The tracker checks the manifest, identity and context against the contract. If any is invalid, `start` returns a run that records nothing and reports `invalid-start`. Invalid properties are dropped one by one.
162
+
163
+ ### `OnboardingRun`
164
+
165
+ | Method | |
166
+ |---|---|
167
+ | `enterStep(stepKey)` | A screen is shown. The step must be in the manifest. Going back to a screen enters it again, which appends a repeated entry. |
168
+ | `exitStep(stepKey, { answers? })` | The user leaves the screen. Answers are `{ questionKey, kind: "single" \| "multi" \| "numeric" \| "text", value, unit? }`, and values are stable option keys, never displayed labels. It is fine if this arrives just after the next screen's `enterStep`. |
169
+ | `setProperties(properties)` | Adds or changes run properties. |
170
+ | `complete()` | The onboarding is finished. This is final: the run records nothing afterwards, and a completed run is never overwritten. |
171
+ | `background()` | The app moved to the background. Records the moment and sends without waiting for the debounce. |
172
+ | `runId`, `currentStepKey` | The run's id, and the step of its last **recorded** entry. In a truncated run, recording stopped, so this can be an earlier screen than the one the user was on. Do not navigate to it blindly: restore the position from your own navigation state. |
173
+
174
+ **There is no skip call.** Report only the screens the user was shown. A declared step with no entry counts as skipped when the run reached a later position or completed, and that is worked out when the funnel is read.
175
+
176
+ No method throws. Anything the tracker declines to do is reported through `onDiagnostic` instead.
177
+
178
+ ### `onboardingRun.resume(): Promise<OnboardingRun | null>`
179
+
180
+ After the app was killed mid-onboarding, `resume()` returns the run that was in progress, if the app restores the user's position. The run keeps its `run_id` and continues its `seq`. The tracker:
181
+
182
+ 1. closes the screen the user was on, at the last moment the app was known to be in the foreground on it, or at its `exitStep` if that came later;
183
+ 2. appends a new entry for the restored screen, using the step of the last recorded entry.
184
+
185
+ The tracker assumes the app restores the screen of the last recorded entry. **A truncated run** (one that hit a recording limit) has stopped recording: `resume()` still returns it, so it can be completed, but it appends no entry, and its `currentStepKey` is the last screen recorded before the limit, not necessarily the one the user was on.
186
+
187
+ If the app does not restore the position, call `start()` instead.
188
+
189
+ After a reconfigure, `resume()` returns the run the disposed tracker on the same `storageKey` was recording, if it is still in progress, in the newest state that tracker had, whether or not storage is configured. It returns null when the new sink has another destination.
190
+
191
+ `resume()` waits for the stored state to be read, however long the storage takes; a read slower than `storageReadTimeoutMs` is reported, not abandoned. **A storage that never answers the read means `resume()` never resolves.** An app that cannot wait should race it with its own timeout, and call `start()` if the timeout wins: `start()` never waits for the read. The run it starts is sent as usual, and stored once the read lands.
192
+
193
+ ## Delivery
194
+
195
+ - **Every send is a full snapshot** with a `seq` that rises by one per send. The server keeps the highest, so a lost request is repaired by the next one.
196
+ - **Only an outcome in the response body counts.** A body of `{"outcome": "accepted"}` or `{"outcome": "ignored"}` settles the send. `{"outcome": "rejected"}` drops the snapshot for good, and it is reported, never resent unchanged.
197
+ - **Everything else is transient**: a 401, 404, 413 or 5xx without an outcome body, a timeout, or no response. The same snapshot, same `seq` and same body, is retried with backoff.
198
+ - **The latest unsent snapshot is persisted**, so it is still delivered after a restart, even when nobody calls `resume()`.
199
+
200
+ ### A custom sink
201
+
202
+ ```ts
203
+ import type { Sink, OnboardingRunSnapshot } from "@rocapine/rocalytics-sdk/onboarding";
204
+
205
+ const sink: Sink<OnboardingRunSnapshot> = {
206
+ destination: "https://collector.example.com/v1/onboarding-runs", // where it delivers; omit on a test double
207
+ async send(snapshot) {
208
+ // deliver it, then report what the ingest said:
209
+ return { outcome: "accepted" }; // or "ignored", "rejected", "transient"
210
+ },
211
+ };
212
+ ```
213
+
214
+ A sink that throws, or returns anything else, counts as transient.
215
+
216
+ **`destination`** says where the sink delivers. Snapshots recorded for one destination are never sent to another:
217
+
218
+ - A reconfigure hands the old tracker's unsent snapshots and run to the new tracker only if both sinks have the same `destination`, or, when either has none, are the same object.
219
+ - Otherwise the new tracker takes nothing and discards the stored state it reads. It reports `destination-changed` once, and only if that discards something: a run in progress, a snapshot the old tracker's last attempts did not deliver, or stored state. Switching when nothing is left reports nothing.
220
+ - Stored state is stamped with the destination of the tracker that wrote it, and a later launch discards, unsent, what was written for another destination. Two sinks without a destination count as the same one there, since nothing tells them apart.
221
+
222
+ `createHttpSink` sets `destination` to its `url`, and `createRocalyticsOnboardingSink` to the client's onboarding endpoint, so a sink built anew on each `configure()` with the same URL keeps the same destination. When a `fetch` is injected, both mark it, as `<url> (custom fetch)`: that `fetch` may never reach the URL, so a mock on the production URL never counts as the production sink, while two mocks on the same URL still match.
223
+
224
+ The destination is the URL only. Headers are not part of it: a collector that picks its environment from a header (a token, for example) needs distinct URLs per environment, or a custom sink with a `destination` of its own.
225
+
226
+ A sink whose `destination` differs from the old one's, or a custom sink without one that is not the same object, therefore gets nothing of what the old tracker left unsent on a reconfigure. A custom sink built anew on each `configure()` should set a `destination`, or be reused.
227
+
228
+ **Use one `storageKey` per destination.** A test or staging configuration should use a `storageKey` of its own, so that switching destinations discards nothing.
229
+
230
+ The tracker sends one snapshot of a run at a time, and treats a send with no answer after `attemptTimeoutMs` (30 s) as transient. The stock HTTP sink gives up after 15 s, so its sends never overlap. A custom sink that keeps a request alive past `attemptTimeoutMs` can see a retry start while the first attempt is still running. The two carry the same `seq` and the same body, so the ingest ignores the duplicate.
231
+
232
+ ## Limits
233
+
234
+ The tracker enforces the contract's limits:
235
+
236
+ - 200 manifest steps, 500 entries, 50 answers per entry, 20 properties;
237
+ - 1,000-character text answers and 256-character property strings, counted in code points;
238
+ - a **recording budget of 261,120 bytes**, measured on the snapshot in the form its completion send would take.
239
+
240
+ When recording one more entry, answer or property change would cross a limit, the tracker stops recording and sets `truncated: true`. It keeps the entries it already has, and it still sends `completed` when the run completes.
241
+
242
+ ## Known limits of the tracker
243
+
244
+ Unlike the limits above, which come from the contract, these are the tracker's own. Each is intended in this release, and a test pins it.
245
+
246
+ - **An app killed without working storage.** A snapshot the sink has not taken survives the end of the process only in storage. When the app is killed without storage, with persistence off for the session (storage that cannot be read, or a stored format this version does not know), or with a write that never finishes, such a snapshot is lost, a completion included. The same holds after a `dispose()` when no tracker is created on its `storageKey` before the app is killed. A reconfigure loses nothing: `dispose()` hands what is unsent to the next tracker in memory (see `dispose()` above).
247
+ - **The resumed screen.** `resume()` records the restored screen as a new entry for the last recorded step. There is no way to name a different screen. A truncated run records no entry at all, and its `currentStepKey` is the last step recorded before the limit.
248
+
249
+ ## Paywall presentations
250
+
251
+ `@rocapine/rocalytics-sdk/paywall` reports each paywall presentation as up to three snapshots (start, shown, end) in the shape of the [paywall presentation contract v1](docs/paywall-presentation-contract.md). A presentation is one `present()` call, or one display of a `Paywall` onboarding step, that resolved to a paywall. Calls refused before a paywall is chosen send nothing. An error after that sends a start and an `error` end.
252
+
253
+ The tracker satisfies the paywall host's `PaywallObserver` interface structurally, so it is passed to the host as `observer`. This file is [`examples/native-paywall.ts`](examples/native-paywall.ts), type-checked by the test suite:
254
+
255
+ ```ts
256
+ import { createPaywallTracker, type KeyValueStorage } from "@rocapine/rocalytics-sdk/paywall";
257
+ import { RocalyticsClient, createRocalyticsPaywallSink } from "@rocapine/rocalytics-sdk/client";
258
+
259
+ // Once, at app startup. Pass `paywallTracker` to the paywall host as `observer`:
260
+ // <PaywallProvider observer={paywallTracker} customScreens={SCREENS}>…</PaywallProvider>
261
+ export function setUpPaywallTracking(rocalytics: RocalyticsClient, storage: KeyValueStorage, device: { appVersion: string; build: string; platform: "ios" | "android"; osVersion: string; locale: string }) {
262
+ return createPaywallTracker({
263
+ sink: createRocalyticsPaywallSink(rocalytics),
264
+ context: () => ({
265
+ appVersion: device.appVersion,
266
+ build: device.build,
267
+ platform: device.platform, // e.g. Platform.OS
268
+ osVersion: device.osVersion,
269
+ locale: device.locale, // the device locale, e.g. from expo-localization
270
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
271
+ }),
272
+ storage, // e.g. AsyncStorage: an unsent snapshot survives the app being killed
273
+ });
274
+ }
275
+
276
+ // In a custom paywall screen, report the purchase with its join key:
277
+ // complete({ status: "purchased", transaction: {
278
+ // originalTransactionIdentifier, // the SAME value passed to Rocalytics' purchase call; iOS join key
279
+ // purchaseToken: result.transaction.purchaseToken ?? undefined, // Android join key: required to attribute proceeds
280
+ // productId: result.productIdentifier,
281
+ // } });
282
+ // A restore or a web redemption is not a new purchase:
283
+ // complete({ status: "purchased", transaction: { restored: true } });
284
+ ```
285
+
286
+ **Join keys.** The client never sends a price. Proceeds come from store transactions, joined on the key the custom screen reports:
287
+ - **iOS:** `originalTransactionIdentifier`, the same value the app already passes to Rocalytics' `purchase` call.
288
+ - **Android:** `purchaseToken`. The order id may be passed as `originalTransactionIdentifier` too, but it is not used to join.
289
+
290
+ A purchase without its platform's join key still counts as a conversion; it earns no proceeds.
291
+
292
+ **Restores.** `restored: true` marks a purchase that only restored existing access, such as a restore or a web redemption. It is not counted as a conversion.
293
+
294
+ **Never in the way.** The tracker never throws, never delays `present()` and never changes its result. Invalid input, a failing context or a failing sink is reported to `onDiagnostic`. Each presentation is delivered on its own, and unsent snapshots (at most 20) are stored and resent on the next launch.
295
+
296
+ ## Rocalytics client
297
+
298
+ `@rocapine/rocalytics-sdk/client` is the Rocalytics client that apps used to copy into their codebase as `rocalytics.client.ts`. It sends the same requests as the reference client: a test replays scenarios captured from the reference itself and compares every URL, header and body.
299
+
300
+ ### Peer dependencies
301
+
302
+ The client talks to these modules. They are optional peers, so an app that does not import `/client` needs none of them. An app that does must install all of them, which an Expo app usually already has, or gets with `npx expo install expo-application expo-crypto expo-device expo-network expo-secure-store`:
303
+
304
+ | Package | Range |
305
+ |---|---|
306
+ | `expo-application` | `*` |
307
+ | `expo-crypto` | `*` |
308
+ | `expo-device` | `*` |
309
+ | `expo-modules-core` | `*` |
310
+ | `expo-network` | `*` |
311
+ | `expo-secure-store` | `*` |
312
+ | `react-native` | `*` |
313
+
314
+ Every range is `*` on purpose. npm checks an optional peer that the app already has, so any range would stop some app that only imports `/onboarding` from installing the package. Compatibility is checked at run time instead, by the presence probe described below. Only the Expo SDK 54 to 57 versions of these modules have been checked against the client, by reading their source.
315
+
316
+ They are loaded when the client starts, never when the subpath is imported. The client first checks with `expo-modules-core` that each native module is in the app binary. If one is missing, for example because a JS update reached an older build, the client is **inert**: `ready` resolves, `rocaId` stays null, every method resolves without sending anything, and the cause goes to `onDiagnostic`. Nothing throws at launch.
317
+
318
+ An uninstalled peer is a different case. Metro resolves every `require` when it bundles, so a missing package fails the bundle rather than making the client inert.
319
+
320
+ ### Usage
321
+
322
+ This file is [`examples/client.ts`](examples/client.ts), type-checked and run by the test suite:
323
+
324
+ ```ts
325
+ import { createRocalyticsOnboardingSink, getEventId, RocalyticsClient } from "@rocapine/rocalytics-sdk/client";
326
+ import { onboardingRun, type KeyValueStorage, type RunContextInput } from "@rocapine/rocalytics-sdk/onboarding";
327
+
328
+ // 1. One client per app, created once at startup. It starts itself: it reads
329
+ // (or mints) the device's roca id, identifies the device, and sends
330
+ // `install` once per device. Every method waits for that.
331
+ export const rocalytics = new RocalyticsClient();
332
+
333
+ // 2. Identifiers, whenever the app learns them. Null values are dropped.
334
+ export async function onSignedIn(userId: string, revenueCatId: string | null) {
335
+ await rocalytics.identify({ user_id: userId, revenue_cat_id: revenueCatId });
336
+ }
337
+
338
+ // 3. A purchase. Pass the purchase SDK's product and transaction objects as
339
+ // they are: they are forwarded whole, and the product id is read from
340
+ // `product.productIdentifier` unless you pass `productId`.
341
+ export async function onPurchase(
342
+ product: { productIdentifier: string },
343
+ transaction: { originalTransactionIdentifier: string },
344
+ price: number,
345
+ currency: string,
346
+ isTrial: boolean,
347
+ ) {
348
+ const originalTransactionIdentifier = transaction.originalTransactionIdentifier;
349
+ await rocalytics.trackPurchase({ isTrial, value: price, currency, originalTransactionIdentifier, product, transaction });
350
+ // The id Rocalytics uses when it forwards the conversion, to deduplicate it
351
+ // against one the app sends to an ad network itself.
352
+ return getEventId("purchase", { original_transaction_identifier: originalTransactionIdentifier });
353
+ }
354
+
355
+ // 4. Onboarding progress, reported through the run tracker and delivered to Rocalytics.
356
+ export function setUpOnboardingTracking(storage: KeyValueStorage, context: () => RunContextInput) {
357
+ onboardingRun.configure({ sink: createRocalyticsOnboardingSink(rocalytics), context, storage });
358
+ }
359
+ ```
360
+
361
+ ### API
362
+
363
+ `new RocalyticsClient(options?)`. Every option is for tests or unusual hosts: `modules` (your own native modules, or `null` to run inert), `fetch`, `baseUrl`, `clock` and `onDiagnostic` (default `console.warn`).
364
+
365
+ | Member | |
366
+ |---|---|
367
+ | `ready` | Resolves once the client has started, or has gone inert. Never rejects. |
368
+ | `rocaId` | The device's id. Null before `ready`, and when inert. |
369
+ | `track(name, properties?)`, `trackEvent(...)` | An analytics event: `install`, `onboarding_completed`, `purchase`, `subscription_started` or `trial_started`. |
370
+ | `trackPurchase(params)` | `purchase`, deduplicated per original transaction. `{ isTrial, value, currency, originalTransactionIdentifier, productId?, product?, transaction?, redemptionResult? }`. |
371
+ | `identify(identifiers)` | Attaches identifiers (`user_id`, `revenue_cat_id`, `adjust_attribution`, ...) to the identity. |
372
+ | `trackCustomEvent(name, properties?, dedupSuffix?)` | An event with any name, passed on by the API to drive automations rather than stored as an analytics event. Deduplicated on `${rocaId}-${name}`, plus `-${dedupSuffix}` when given. |
373
+ | `trackOnboarding(stepId, answers?, metadata?)` | The pre-v1 onboarding calls, unchanged. Resends every step seen so far. |
374
+ | `getDemandScore(signals?)` | The server's 1 to 100 demand score for this install. Rejects when the client is inert. |
375
+ | `sendOnboardingRun(snapshot)` | Delivers one onboarding run snapshot as the pre-v1 onboarding payload. For an accepted completed snapshot it then sends `onboarding_completed` once per run, deduplicated on `${rocaId}-onboarding_completed-${run_id}`; if that event fails, the send is transient, so a retry sends it. Never throws. It is the send of `createRocalyticsOnboardingSink`. |
376
+ | `onboardingRunDestination` | Where `sendOnboardingRun` delivers: the `/onboarding-response` endpoint of `baseUrl`, marked ` (custom fetch)` when a `fetch` is passed, so that it never counts as the real endpoint. The `destination` of `createRocalyticsOnboardingSink`. |
377
+
378
+ A method whose request gets a non-2xx answer rejects with `[ROCALYTICS] <endpoint> failed: <status>`, as the copied client did. The client never logs a request, its response or purchase properties. The only thing it reports is why it went inert or why start-up failed, through `onDiagnostic`, which defaults to `console.warn`. The request builders (`buildTrackRequest`, `buildIdentifyRequest`, `buildOnboardingResponseRequest`, `buildDemandScoreRequest`) and `getEventId` are exported as pure functions.
379
+
380
+ **Onboarding runs.** `createRocalyticsOnboardingSink(client)` is a sink for the tracker. The Rocalytics ingest reads only the pre-v1 onboarding payload, so each snapshot is mapped onto it (`toOnboardingResponsePayload`):
381
+
382
+ - Entries become `responses`, with `step_key` as `step_id`.
383
+ - Answers become `{ [questionKey]: value }`. A numeric answer's unit is dropped, because that shape has no place for it.
384
+ - `onboarding_metadata` carries the keys the [contract's section 9](docs/onboarding-run-contract.md#9-pre-v1-payloads-d22-d23) reads back: `onboardingId` (or `onboarding_id`, set to the onboarding key, for a run with no Studio onboarding), `audienceId`, `deployment_id`, `locale` from the run's context, and `draft: true` for a draft. It also carries `onboarding_key`, `onboarding_version`, `variant_key`, `run_id` and `seq`.
385
+ - `sent_at` is the run's latest recorded timestamp plus `seq` milliseconds, not the device's send time. The ingest keeps one snapshot per roca id and replaces it only with a strictly later `sent_at`, so two sends in the same millisecond, or a device clock stepping back, would otherwise lose a snapshot. This value rises with every send **within a run**, and a retry resends the identical body. Across runs it does not help. If the device clock steps back between two runs, the next run's snapshots can be dropped silently: about the first span of that run equal to the step, or all of it if the run is shorter. The sink cannot tell a stored 2xx from a dropped one. Pre-v1 reporting had the same limit.
386
+
387
+ **Completion.** Consumers of the pre-v1 data read completion from the `onboarding_completed` event, not from the snapshot. So once a completed snapshot is accepted, the sink also sends `track("onboarding_completed")`, once per run. Its deduplication id is run-scoped, `${rocaId}-onboarding_completed-${run_id}`. Each completed run on a device produces its own event, a replay included, and a resend of the same run's completion, even after a relaunch, is deduplicated. If it fails, the send counts as transient, and the tracker's retry sends it. Do not also send `onboarding_completed` yourself for a flow reported through the sink.
388
+
389
+ **Answers.** An answer is stored under its step id and `question_key`. A consumer that reads one particular answer finds it only if the flow keeps the same step key and `questionKey` its previous reporting used. When you move a flow from `trackOnboarding` to the tracker, reuse those ids.
390
+
391
+ The ingest keeps one onboarding per roca id, so report a flow through the sink or through `trackOnboarding`, not both.
392
+
393
+ That endpoint answers success with a 2xx and no body, so a 2xx is accepted. Otherwise the contract's rule applies: only a body saying `{"outcome": "rejected"}` is permanent, and every other answer is transient and retried. That includes a 400 without such a body, and a 404. Every send waits for `ready`, so a 404 (no identity for this roca id) means the start-up identify failed in this session. The retries stop failing once an identify succeeds, at the next launch or through the app's own `identify()` call.
394
+
395
+ **Known limits.**
396
+
397
+ - **A late completion can be credited to the next run.** The app may be killed before the completed snapshot is sent, then start a new run on relaunch. When the stored snapshot is finally delivered, its `onboarding_completed` event arrives during the new run, so a consumer that matches the event to the row by time can credit the completion to the new run.
398
+ - **A permanently failing completion event is retried forever.** If `/track` permanently refuses `onboarding_completed`, the completed snapshot's send stays transient and is retried at the tracker's maximum backoff, about once a minute, for as long as the app runs.
399
+
400
+ ### Migrating from a copied `rocalytics.client.ts`
401
+
402
+ 1. Install the package and the peers above, then delete the copied file.
403
+ 2. Import from `@rocapine/rocalytics-sdk/client` instead. The class and type names are unchanged (`RocalyticsClient`, `TrackPurchaseParams`, `IdentifyParams`, `DemandScoreResult`, `OnboardingStepAnswers`, ...).
404
+ 3. Keep creating the client once, at startup.
405
+ 4. Check the rows below that apply to your copy.
406
+
407
+ | If your copy | Then |
408
+ |---|---|
409
+ | stored the id under `rocalitics-roca-id` | Nothing to do. The id is now read from `rocalytics-roca-id`. On the first launch, a device with only the old key keeps its id, which is copied to the new key. The old key is left in place, and a newly minted id is written under both keys, so a bundle rolled back to the copied client reads the same id. A device never gets a new id while either key holds one. |
410
+ | recorded the install under `rocadata-install-tracked-4` | Nothing to do. Either install key counts as "install sent", so no device sends `install` twice. |
411
+ | imported the Expo modules at the top of the file | Nothing to do. The modules now load lazily, and a missing native module makes the client inert instead of crashing the launch. |
412
+ | took `{ product, transaction }`, or `{ productId, redemptionResult }`, in `trackPurchase` | Both still work. `productId` defaults to `product.productIdentifier`. Product and transaction are typed as any object, so pass the purchase SDK's own types. |
413
+ | called `getEventId(name, transactionId)` | Call `getEventId(name, { original_transaction_identifier: transactionId })`. It returns `undefined` when there is no transaction id. |
414
+ | ran an app-specific step after start-up, such as handing the roca id to an attribution SDK | Do it in the app: `await rocalytics.ready`, then use `rocalytics.rocaId` when it is not null. |
415
+ | logged requests or purchase properties to the console | The package logs neither. Start-up problems go to `onDiagnostic` (default `console.warn`; pass your own handler to route or silence them). A failed request still rejects. |
416
+ | identified without `locale` at start-up | The start-up identify now sends the device locale, as the reference does. |
417
+ | read `selectedVersion` or `versions` from `DemandScoreResult` as always present | Both are optional, so a response from before the score was versioned still fits. Check for them. |
418
+ | mocked the copied module in Jest | Mock `@rocapine/rocalytics-sdk/client` instead, or create the client with `modules` and `fetch` stand-ins. |
419
+
420
+ ## Development
421
+
422
+ ```bash
423
+ npm ci
424
+ npm run lint
425
+ npm run type:check # the package source
426
+ npm run type:check:tests # tests, docs examples, README example, and the schema/type drift lock
427
+ npm test
428
+ npm run build && npm run check:exports
429
+ npm run gen:schema-types # after a schema change: regenerate the type the drift lock compares against
430
+ ```
431
+
432
+ The public payload types are [`docs/onboarding-run.types.ts`](docs/onboarding-run.types.ts), byte for byte. Two locks catch drift from the JSON Schema:
433
+
434
+ 1. a test regenerates a TypeScript type from the schema and compares it with the checked-in one;
435
+ 2. `tsc` fails unless that generated type equals `OnboardingRunSnapshot` field for field.
436
+
437
+ Every payload a test captures is validated against the schema and the contract's rules beyond the schema.
438
+
439
+ ## Releasing
440
+
441
+ Changes are recorded in [`CHANGELOG.md`](CHANGELOG.md), in Keep a Changelog format with no links, under `## [Unreleased]`. When you bump the version in `package.json`:
442
+
443
+ 1. Move the Unreleased entries under a new `## [x.y.z] - YYYY-MM-DD` heading. Use `unreleased` instead of the date until the version is published.
444
+ 2. Leave an empty `## [Unreleased]` section above it.
445
+
446
+ To publish, from a clean checkout of `main`: `npm ci`, then `npm publish`. `publishConfig` sends it to the public npm registry, and `prepublishOnly` runs lint, the test type-check, the tests and the exports check first, so a failing tree cannot be published. Publishing needs an npm account with publish rights on the `@rocapine` scope (`npm login`).
447
+
448
+ A test fails if the `package.json` version has no matching heading, or if the Unreleased section is missing.
@@ -0,0 +1 @@
1
+ {"main":"../dist/client/index.js","types":"../dist/client/index.d.ts"}
@@ -0,0 +1,4 @@
1
+ {
2
+ "main": "../dist/core/index.js",
3
+ "types": "../dist/core/index.d.ts"
4
+ }