@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.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +447 -2
- package/client/package.json +1 -0
- package/core/package.json +4 -0
- package/dist/client/client.d.ts +136 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +373 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +13 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +35 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/native.d.ts +68 -0
- package/dist/client/native.d.ts.map +1 -0
- package/dist/client/native.js +64 -0
- package/dist/client/native.js.map +1 -0
- package/dist/client/onboardingSink.d.ts +63 -0
- package/dist/client/onboardingSink.d.ts.map +1 -0
- package/dist/client/onboardingSink.js +126 -0
- package/dist/client/onboardingSink.js.map +1 -0
- package/dist/client/paywallSink.d.ts +13 -0
- package/dist/client/paywallSink.d.ts.map +1 -0
- package/dist/client/paywallSink.js +26 -0
- package/dist/client/paywallSink.js.map +1 -0
- package/dist/client/requests.d.ts +66 -0
- package/dist/client/requests.d.ts.map +1 -0
- package/dist/client/requests.js +90 -0
- package/dist/client/requests.js.map +1 -0
- package/dist/client/types.d.ts +180 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +4 -0
- package/dist/client/types.js.map +1 -0
- package/dist/core/context.d.ts +39 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js +45 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/delivery.d.ts +63 -0
- package/dist/core/delivery.d.ts.map +1 -0
- package/dist/core/delivery.js +133 -0
- package/dist/core/delivery.js.map +1 -0
- package/dist/core/diagnostics.d.ts +16 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +24 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/httpSink.d.ts +35 -0
- package/dist/core/httpSink.d.ts.map +1 -0
- package/dist/core/httpSink.js +65 -0
- package/dist/core/httpSink.js.map +1 -0
- package/dist/core/ids.d.ts +16 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +51 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +32 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/sink.d.ts +39 -0
- package/dist/core/sink.d.ts.map +1 -0
- package/dist/core/sink.js +16 -0
- package/dist/core/sink.js.map +1 -0
- package/dist/core/storage.d.ts +52 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +79 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/text.d.ts +9 -0
- package/dist/core/text.d.ts.map +1 -0
- package/dist/core/text.js +47 -0
- package/dist/core/text.js.map +1 -0
- package/dist/core/time.d.ts +18 -0
- package/dist/core/time.d.ts.map +1 -0
- package/dist/core/time.js +18 -0
- package/dist/core/time.js.map +1 -0
- package/dist/onboarding/contract.d.ts +138 -0
- package/dist/onboarding/contract.d.ts.map +1 -0
- package/dist/onboarding/contract.js +11 -0
- package/dist/onboarding/contract.js.map +1 -0
- package/dist/onboarding/index.d.ts +15 -0
- package/dist/onboarding/index.d.ts.map +1 -0
- package/dist/onboarding/index.js +22 -0
- package/dist/onboarding/index.js.map +1 -0
- package/dist/onboarding/input.d.ts +102 -0
- package/dist/onboarding/input.d.ts.map +1 -0
- package/dist/onboarding/input.js +216 -0
- package/dist/onboarding/input.js.map +1 -0
- package/dist/onboarding/persisted.d.ts +20 -0
- package/dist/onboarding/persisted.d.ts.map +1 -0
- package/dist/onboarding/persisted.js +75 -0
- package/dist/onboarding/persisted.js.map +1 -0
- package/dist/onboarding/state.d.ts +65 -0
- package/dist/onboarding/state.d.ts.map +1 -0
- package/dist/onboarding/state.js +207 -0
- package/dist/onboarding/state.js.map +1 -0
- package/dist/onboarding/tracker.d.ts +135 -0
- package/dist/onboarding/tracker.d.ts.map +1 -0
- package/dist/onboarding/tracker.js +734 -0
- package/dist/onboarding/tracker.js.map +1 -0
- package/dist/paywall/contract.d.ts +67 -0
- package/dist/paywall/contract.d.ts.map +1 -0
- package/dist/paywall/contract.js +3 -0
- package/dist/paywall/contract.js.map +1 -0
- package/dist/paywall/index.d.ts +14 -0
- package/dist/paywall/index.d.ts.map +1 -0
- package/dist/paywall/index.js +19 -0
- package/dist/paywall/index.js.map +1 -0
- package/dist/paywall/observer.d.ts +38 -0
- package/dist/paywall/observer.d.ts.map +1 -0
- package/dist/paywall/observer.js +6 -0
- package/dist/paywall/observer.js.map +1 -0
- package/dist/paywall/state.d.ts +26 -0
- package/dist/paywall/state.d.ts.map +1 -0
- package/dist/paywall/state.js +132 -0
- package/dist/paywall/state.js.map +1 -0
- package/dist/paywall/tracker.d.ts +36 -0
- package/dist/paywall/tracker.d.ts.map +1 -0
- package/dist/paywall/tracker.js +180 -0
- package/dist/paywall/tracker.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/docs/onboarding-run-contract.md +1013 -0
- package/docs/onboarding-run.examples.ts +306 -0
- package/docs/onboarding-run.schema.json +224 -0
- package/docs/onboarding-run.types.ts +144 -0
- package/docs/paywall-presentation-contract.md +139 -0
- package/docs/paywall-presentation.examples.ts +42 -0
- package/docs/paywall-presentation.schema.json +268 -0
- package/docs/paywall-presentation.types.ts +73 -0
- package/onboarding/package.json +4 -0
- package/package.json +120 -4
- package/paywall/package.json +1 -0
- package/src/client/client.ts +405 -0
- package/src/client/index.ts +54 -0
- package/src/client/native.ts +109 -0
- package/src/client/onboardingSink.ts +121 -0
- package/src/client/paywallSink.ts +25 -0
- package/src/client/requests.ts +126 -0
- package/src/client/types.ts +198 -0
- package/src/core/context.ts +69 -0
- package/src/core/delivery.ts +181 -0
- package/src/core/diagnostics.ts +30 -0
- package/src/core/httpSink.ts +70 -0
- package/src/core/ids.ts +46 -0
- package/src/core/index.ts +20 -0
- package/src/core/sink.ts +46 -0
- package/src/core/storage.ts +112 -0
- package/src/core/text.ts +37 -0
- package/src/core/time.ts +26 -0
- package/src/onboarding/contract.ts +144 -0
- package/src/onboarding/index.ts +39 -0
- package/src/onboarding/input.ts +246 -0
- package/src/onboarding/persisted.ts +93 -0
- package/src/onboarding/state.ts +233 -0
- package/src/onboarding/tracker.ts +855 -0
- package/src/paywall/contract.ts +73 -0
- package/src/paywall/index.ts +36 -0
- package/src/paywall/observer.ts +43 -0
- package/src/paywall/state.ts +125 -0
- package/src/paywall/tracker.ts +200 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
# Onboarding run contract
|
|
2
|
+
|
|
3
|
+
**Schema version 1.** This document defines the payload a client sends to describe one run of an onboarding flow. It also defines how analytics turns those payloads into a funnel that tells a skipped screen apart from a quit.
|
|
4
|
+
|
|
5
|
+
Every decision below is **Decided**. The status column remains so that a later revision can mark a new decision Proposed.
|
|
6
|
+
|
|
7
|
+
Companion files, which move together with this document:
|
|
8
|
+
|
|
9
|
+
| File | What it is |
|
|
10
|
+
|---|---|
|
|
11
|
+
| [`onboarding-run.schema.json`](onboarding-run.schema.json) | JSON Schema (draft 2020-12) for one snapshot. Closed: an unknown field is an error. |
|
|
12
|
+
| [`onboarding-run.types.ts`](onboarding-run.types.ts) | The same shape as TypeScript types, with no imports. |
|
|
13
|
+
| [`onboarding-run.examples.ts`](onboarding-run.examples.ts) | Every payload and every run used in this document. |
|
|
14
|
+
|
|
15
|
+
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119.
|
|
16
|
+
|
|
17
|
+
## Contents
|
|
18
|
+
|
|
19
|
+
1. [Decisions](#1-decisions)
|
|
20
|
+
2. [A complete payload](#2-a-complete-payload)
|
|
21
|
+
3. [Fields](#3-fields)
|
|
22
|
+
4. [Rules beyond the schema](#4-rules-beyond-the-schema)
|
|
23
|
+
5. [Transport](#5-transport-d4-d12-d13-d27)
|
|
24
|
+
6. [Funnel measures](#6-funnel-measures-d14-d15-d16)
|
|
25
|
+
7. [Worked examples](#7-worked-examples)
|
|
26
|
+
8. [Versioning](#8-versioning-d19-d20)
|
|
27
|
+
9. [Pre-v1 payloads](#9-pre-v1-payloads-d22-d23)
|
|
28
|
+
10. [Reference tracker](#10-reference-tracker)
|
|
29
|
+
|
|
30
|
+
## 1. Decisions
|
|
31
|
+
|
|
32
|
+
| # | Topic | Decision | Status | Rationale |
|
|
33
|
+
|---|---|---|---|---|
|
|
34
|
+
| D1 | Identity | The app declares `onboarding.key`, `onboarding.version`, an optional `onboarding.variant_key` (see D25), and a `step_key` per step. Studio ids are optional links under `studio`. | Decided | Analytics must work for a flow the Studio never served. Links add detail but are never required. |
|
|
35
|
+
| D2 | Field names | Identity is nested under `onboarding` (`onboarding.key`, not a top-level `onboarding_key`). | Decided | A top-level `version` would be read as `schema_version` or the app version. |
|
|
36
|
+
| D3 | Run | Each run has a client-minted UUID `run_id`, plus `schema_version`, `status` (`in_progress` or `completed`), `started_at` and `completed_at`. A quit is never sent. | Decided | The client knows when a run starts and when it completes, but never when it is abandoned. |
|
|
37
|
+
| D4 | Ordering | Each send carries `seq`, an integer that goes up with every send. The server keeps the snapshot with the highest `seq`. | Decided | `sent_at` is the device clock and is advisory, so it cannot order sends. |
|
|
38
|
+
| D5 | Run context | App version, build, platform, OS version, locale and timezone are captured once, at run start, and repeated unchanged in every snapshot (the schema requires them in each). The server pins them from the first snapshot it stores. | Decided | A run is analysed against the build and settings it started with. |
|
|
39
|
+
| D6 | Country | The client does not send it. The server derives it. | Decided | Device region settings are not location. A server-side source is consistent across apps. |
|
|
40
|
+
| D7 | Custom properties | A run-level `properties` map of scalar values (string, number, boolean, null). User-level traits stay in the backend's identify call, outside this contract. | Decided | The funnel slices runs, so the value that applies is the one the run was seen with. |
|
|
41
|
+
| D8 | Limits | 20 properties, 40-character property keys, 256-character property strings, 200 manifest steps, 500 entries, 50 answers per entry, 1,000-character text answers, 256 KiB per snapshot. The tracker enforces the size limit by stopping recording at a budget of 261,120 bytes, 256 KiB minus 1 KiB (D26). The ingest rejects a snapshot over 256 KiB with an explicit `rejected` outcome (section 5, rule 2). The exception is a run already completed: such a snapshot is ignored, because rule 1 runs first. | Decided | Ingest storage and query cost need a hard bound. The numbers are generous for a real flow with back navigation. |
|
|
42
|
+
| D9 | Steps | `steps` holds one entry per screen shown, in the order shown. Back navigation appends a repeated entry. There is no skip entry and no skip call: a step that was not shown simply has no entry. | Decided | This is the order the user actually went through. Repeats keep that order without rewriting history. A skip is a fact about the flow, so it is derived from the manifest (D17) rather than reported. |
|
|
43
|
+
| D10 | Last `exited_at` | `exited_at` is null on at most one entry: the last entry of an `in_progress` run. In a completed run every entry has an exit, and the last one equals `completed_at`, except in a truncated run (D26): there the last kept entry may already be closed while in progress, and it keeps its own exit on completion and on restore. | Decided | A null exit then means "still on this screen, or left here". A finished run has no open entry. |
|
|
44
|
+
| D11 | Answers | Four kinds: `single`, `multi`, `numeric` and `text`. Values are stable option keys, never displayed labels. Free text is never aggregated. | Decided | A translated label would split one option into one row per language. |
|
|
45
|
+
| D12 | Transport | Each send is a full snapshot that replaces the previous one. Retries are idempotent. `completed` is terminal: the first completed snapshot wins. See D27. | Decided | A lost or repeated send cannot corrupt the run. |
|
|
46
|
+
| D13 | Time | Analytics buckets a run by the server's receive time of its first snapshot. Client times are used only for durations within one run. | Decided | Device clocks drift and can be set in the future. A run's bucket must never move. |
|
|
47
|
+
| D14 | Quit | A run is quit when it is `in_progress` and no snapshot has been received for **24 hours**. This is recomputed at read time, so a run that resumes stops counting as quit. | Decided | The threshold trades early numbers for accuracy. 24 hours tolerates a user who comes back later the same day. |
|
|
48
|
+
| D15 | Funnel unit | Funnels count runs, not people. A replay is a second run. | Decided | A run is what the payload identifies. Counting people needs a stable person id, which this contract does not carry. |
|
|
49
|
+
| D16 | Funnel measures | *Still in the flow*, *Saw it*, *Skipped*, *Quit here* and the quit rate are defined per position, over runs. *Skipped* is derived at read time: a declared step with no entry, on a run that reached a later position or completed, counts as skipped, not as a quit. See [section 6](#6-funnel-measures-d14-d15-d16). | Decided | Makes the four measures computable and consistent with each other. *Still in the flow* means "reached this position". |
|
|
50
|
+
| D17 | Who declares the step list | Every run carries a `manifest`, declared once at run start and repeated unchanged in every snapshot (the schema requires it in each; a change within a run is rejected, see section 5): the flow's ordered step list, where an optional `slot` groups alternatives. For a Studio-served flow, the tracker fills it from the deployment. For a hand-coded flow, the app declares it. | Decided | *Skipped* and position cannot be computed from observed paths alone. See [3.5](#35-manifest-d17-d18). |
|
|
51
|
+
| D18 | Manifest identity | One `(onboarding.key, onboarding.version)` has exactly one manifest. A run that declares a different one (*differs* as defined in section 5, rule 4) is stored, excluded from funnels and reported as a version conflict. The version `draft` is exempt (D21). | Decided | A changed flow must never merge silently into the old flow's funnel. |
|
|
52
|
+
| D19 | Flow versioning | `onboarding.version` MUST change whenever the manifest or a question's keys change. The version `draft` is exempt (D21). | Decided | See [section 8](#8-versioning-d19-d20). |
|
|
53
|
+
| D20 | Schema versioning | The schema is closed, so every change increments `schema_version`. A change is *additive* when an older payload needs no conversion, and *breaking* otherwise. | Decided | One rule with no exception. See [section 8](#8-versioning-d19-d20). |
|
|
54
|
+
| D21 | Drafts | `onboarding.version` `"draft"` is reserved for a flow that has not been published. Readers exclude it by default. It is exempt from D18 and D19: there is no version-conflict handling for it, and its manifest may change between runs without a version change. | Decided | Keeps preview traffic out of funnels without adding a field. A draft is edited constantly, so holding it to one manifest would reject every edit. |
|
|
55
|
+
| D22 | Pre-v1 payloads | Pre-v1 payloads are converted on the server into v1-shaped rows marked `schema_version` 0, with `onboarding.version` `legacy:<deployment id>`. | Decided | They carry no run id, and their revision can only be recovered by time, so they must never mix with v1 runs. See [section 9](#9-pre-v1-payloads-d22-d23). |
|
|
56
|
+
| D23 | Pre-v1 deployment | The deployment behind a converted run is resolved by time: the one live for the run's audience when the run was first received. Its step list is the run's manifest, and converted runs appear in funnels under their own `legacy:` versions. | Decided | Keeps existing history visible. Resolving by time is best-effort: a run that spans a publish is attributed to the deployment live at its start. |
|
|
57
|
+
| D24 | Timestamp form | Every timestamp has exactly three fractional digits: `2026-01-10T09:00:00.000Z`. | Decided | One form sorts correctly as text and compares byte for byte. It is what `Date.prototype.toISOString` produces. |
|
|
58
|
+
| D25 | A/B arms | Each Studio-served A/B arm keeps its own `onboarding.key`: the arms are separate Studio onboardings, and they are compared as separate keys. `variant_key` is only for an app that runs one flow, under one key, with its own in-flow variants. A Studio-served run does not set it. | Decided | Studio arms are already separate records with separate step lists, so a shared key would merge different manifests. |
|
|
59
|
+
| D26 | Overflow | When recording more would take a run past 500 entries or past the recording budget of 261,120 bytes (256 KiB minus 1 KiB of headroom, measured on the completion form), the tracker stops recording and sets `truncated: true`. This covers a new entry, and also growth inside an entry: a new answer, or a new or changed property. It keeps what it already has and still sends the run, including the `completed` status when the run completes, so the run is not counted as quit. | Decided | Keeping the first entries keeps `started_at` valid and keeps the early funnel, which is where most runs are. See [3.9](#39-overflow-d26). |
|
|
60
|
+
| D27 | Snapshot acceptance | The server answers each snapshot with `accepted`, `ignored` or `rejected`, in the response body. Any response without an outcome in its body, whatever its status, and no response at all, is transient and retried. See [section 5](#5-transport-d4-d12-d13-d27). | Decided | A tracker needs to know when to drop a snapshot and when to send it again. |
|
|
61
|
+
|
|
62
|
+
## 2. A complete payload
|
|
63
|
+
|
|
64
|
+
A run is sent several times, and each send is a snapshot of everything so far. Below are two snapshots of the same run. The run is Studio-served, so its `onboarding.key` is the Studio onboarding id and its `onboarding.version` the deployment id ([3.1](#31-identity-d1-d2-d25)). Together the two snapshots use every optional part of the shape except `variant_key` (shown in 3.1) and `truncated` (shown in [3.9](#39-overflow-d26)).
|
|
65
|
+
|
|
66
|
+
The first send shown here is taken mid-flow. The user is on `experience`, so its `exited_at` is null:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"schema_version": 1,
|
|
71
|
+
"run_id": "00000000-0000-4000-8000-000000000001",
|
|
72
|
+
"seq": 3,
|
|
73
|
+
"status": "in_progress",
|
|
74
|
+
"started_at": "2026-01-10T08:00:00.000Z",
|
|
75
|
+
"completed_at": null,
|
|
76
|
+
"sent_at": "2026-01-10T08:00:20.000Z",
|
|
77
|
+
"onboarding": { "key": "6f1c2b0e-3d4a-4f8e-9b7c-2a1d0e9f8c7b", "version": "412" },
|
|
78
|
+
"studio": {
|
|
79
|
+
"onboarding_id": "6f1c2b0e-3d4a-4f8e-9b7c-2a1d0e9f8c7b",
|
|
80
|
+
"deployment_id": "412",
|
|
81
|
+
"audience_id": "7"
|
|
82
|
+
},
|
|
83
|
+
"context": {
|
|
84
|
+
"app_version": "2.4.0",
|
|
85
|
+
"build": "2040",
|
|
86
|
+
"platform": "android",
|
|
87
|
+
"os_version": "15",
|
|
88
|
+
"locale": "fr-FR",
|
|
89
|
+
"timezone": "Europe/Paris",
|
|
90
|
+
"library_version": "0.1.0"
|
|
91
|
+
},
|
|
92
|
+
"manifest": {
|
|
93
|
+
"steps": [
|
|
94
|
+
{ "step_key": "welcome" },
|
|
95
|
+
{ "step_key": "goal" },
|
|
96
|
+
{ "step_key": "experience" },
|
|
97
|
+
{ "step_key": "plan_quick", "slot": "plan" },
|
|
98
|
+
{ "step_key": "plan_detailed", "slot": "plan" },
|
|
99
|
+
{ "step_key": "permissions" },
|
|
100
|
+
{ "step_key": "summary" }
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
"properties": { "signup_source": "email", "returning_user": false, "cohort_week": 2 },
|
|
104
|
+
"steps": [
|
|
105
|
+
{
|
|
106
|
+
"step_key": "welcome",
|
|
107
|
+
"entered_at": "2026-01-10T08:00:00.000Z",
|
|
108
|
+
"exited_at": "2026-01-10T08:00:10.000Z",
|
|
109
|
+
"answers": []
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"step_key": "goal",
|
|
113
|
+
"entered_at": "2026-01-10T08:00:10.000Z",
|
|
114
|
+
"exited_at": "2026-01-10T08:00:20.000Z",
|
|
115
|
+
"answers": [
|
|
116
|
+
{ "question_key": "goal", "kind": "single", "value": "practice" },
|
|
117
|
+
{ "question_key": "topics", "kind": "multi", "value": ["vocabulary", "listening"] }
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"step_key": "experience",
|
|
122
|
+
"entered_at": "2026-01-10T08:00:20.000Z",
|
|
123
|
+
"exited_at": null,
|
|
124
|
+
"answers": [
|
|
125
|
+
{ "question_key": "daily_time", "kind": "numeric", "value": 15, "unit": "minute" }
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The completion send replaces that snapshot. Along the way, the user went back from `experience` to `goal` (the repeated entries), saw one of the two `plan` alternatives, and was never shown `permissions`. There is no entry for `permissions`. Because the run went on to `summary`, a reader derives the skip from the manifest. Here is the completed snapshot:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"schema_version": 1,
|
|
137
|
+
"run_id": "00000000-0000-4000-8000-000000000001",
|
|
138
|
+
"seq": 8,
|
|
139
|
+
"status": "completed",
|
|
140
|
+
"started_at": "2026-01-10T08:00:00.000Z",
|
|
141
|
+
"completed_at": "2026-01-10T08:01:10.000Z",
|
|
142
|
+
"sent_at": "2026-01-10T08:01:10.000Z",
|
|
143
|
+
"onboarding": { "key": "6f1c2b0e-3d4a-4f8e-9b7c-2a1d0e9f8c7b", "version": "412" },
|
|
144
|
+
"studio": {
|
|
145
|
+
"onboarding_id": "6f1c2b0e-3d4a-4f8e-9b7c-2a1d0e9f8c7b",
|
|
146
|
+
"deployment_id": "412",
|
|
147
|
+
"audience_id": "7"
|
|
148
|
+
},
|
|
149
|
+
"context": {
|
|
150
|
+
"app_version": "2.4.0",
|
|
151
|
+
"build": "2040",
|
|
152
|
+
"platform": "android",
|
|
153
|
+
"os_version": "15",
|
|
154
|
+
"locale": "fr-FR",
|
|
155
|
+
"timezone": "Europe/Paris",
|
|
156
|
+
"library_version": "0.1.0"
|
|
157
|
+
},
|
|
158
|
+
"manifest": {
|
|
159
|
+
"steps": [
|
|
160
|
+
{ "step_key": "welcome" },
|
|
161
|
+
{ "step_key": "goal" },
|
|
162
|
+
{ "step_key": "experience" },
|
|
163
|
+
{ "step_key": "plan_quick", "slot": "plan" },
|
|
164
|
+
{ "step_key": "plan_detailed", "slot": "plan" },
|
|
165
|
+
{ "step_key": "permissions" },
|
|
166
|
+
{ "step_key": "summary" }
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
"properties": { "signup_source": "email", "returning_user": false, "cohort_week": 2 },
|
|
170
|
+
"steps": [
|
|
171
|
+
{
|
|
172
|
+
"step_key": "welcome",
|
|
173
|
+
"entered_at": "2026-01-10T08:00:00.000Z",
|
|
174
|
+
"exited_at": "2026-01-10T08:00:10.000Z",
|
|
175
|
+
"answers": []
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"step_key": "goal",
|
|
179
|
+
"entered_at": "2026-01-10T08:00:10.000Z",
|
|
180
|
+
"exited_at": "2026-01-10T08:00:20.000Z",
|
|
181
|
+
"answers": [
|
|
182
|
+
{ "question_key": "goal", "kind": "single", "value": "practice" },
|
|
183
|
+
{ "question_key": "topics", "kind": "multi", "value": ["vocabulary", "listening"] }
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"step_key": "experience",
|
|
188
|
+
"entered_at": "2026-01-10T08:00:20.000Z",
|
|
189
|
+
"exited_at": "2026-01-10T08:00:30.000Z",
|
|
190
|
+
"answers": [
|
|
191
|
+
{ "question_key": "daily_time", "kind": "numeric", "value": 15, "unit": "minute" }
|
|
192
|
+
]
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"step_key": "goal",
|
|
196
|
+
"entered_at": "2026-01-10T08:00:30.000Z",
|
|
197
|
+
"exited_at": "2026-01-10T08:00:40.000Z",
|
|
198
|
+
"answers": [
|
|
199
|
+
{ "question_key": "goal", "kind": "single", "value": "practice" },
|
|
200
|
+
{ "question_key": "topics", "kind": "multi", "value": ["vocabulary", "listening"] }
|
|
201
|
+
]
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"step_key": "experience",
|
|
205
|
+
"entered_at": "2026-01-10T08:00:40.000Z",
|
|
206
|
+
"exited_at": "2026-01-10T08:00:50.000Z",
|
|
207
|
+
"answers": [
|
|
208
|
+
{ "question_key": "daily_time", "kind": "numeric", "value": 15, "unit": "minute" }
|
|
209
|
+
]
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"step_key": "plan_detailed",
|
|
213
|
+
"entered_at": "2026-01-10T08:00:50.000Z",
|
|
214
|
+
"exited_at": "2026-01-10T08:01:00.000Z",
|
|
215
|
+
"answers": []
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"step_key": "summary",
|
|
219
|
+
"entered_at": "2026-01-10T08:01:00.000Z",
|
|
220
|
+
"exited_at": "2026-01-10T08:01:10.000Z",
|
|
221
|
+
"answers": [{ "question_key": "feedback", "kind": "text", "value": "Looking forward to it" }]
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## 3. Fields
|
|
228
|
+
|
|
229
|
+
All timestamps are RFC 3339 in UTC with **exactly three fractional digits** and a `Z` suffix, for example `2026-01-10T08:00:00.000Z` (D24). `2026-01-10T08:00:00Z` and `2026-01-10T08:00:00.000000Z` are both rejected.
|
|
230
|
+
- One form sorts correctly as text.
|
|
231
|
+
- The user's local offset is recorded once, in `context.timezone`.
|
|
232
|
+
|
|
233
|
+
A **key** is 1 to 128 characters from `A–Z a–z 0–9 _ . : -`, starting with a letter or a digit, and is case-sensitive. Onboarding, step, question, option, slot and variant keys are all keys.
|
|
234
|
+
- Both readable slugs and UUIDs fit.
|
|
235
|
+
- Whitespace and punctuation that break URLs and CSV exports do not.
|
|
236
|
+
|
|
237
|
+
### 3.1 Identity (D1, D2, D25)
|
|
238
|
+
|
|
239
|
+
| Field | Required | Meaning |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| `onboarding.key` | yes | A stable key for one flow. It MUST NOT be reused for a different flow. |
|
|
242
|
+
| `onboarding.version` | yes | The flow's revision: 1 to 64 characters from `A–Z a–z 0–9 _ . : + -`, starting with a letter or a digit. See [section 8](#8-versioning-d19-d20). |
|
|
243
|
+
| `onboarding.variant_key` | no | An in-flow variant, for an app that runs one flow under one key with its own variants. Not set for a Studio-served run. Absent or null means the run is not in such a test. |
|
|
244
|
+
| `studio.onboarding_id`, `studio.deployment_id`, `studio.audience_id` | no | Links to Studio records. Opaque strings of 1 to 128 characters; a numeric id is sent in decimal. |
|
|
245
|
+
|
|
246
|
+
For a Studio-served flow, the tracker uses the Studio onboarding id as `onboarding.key` and the deployment id as `onboarding.version`, unless the app declares its own. A Studio-served draft or preview MUST send `onboarding.version` `"draft"` (D21), because it has no deployment id. A run's identity MUST NOT change between its snapshots ([section 5](#5-transport-d4-d12-d13-d27) rejects a change).
|
|
247
|
+
|
|
248
|
+
**A/B tests (D25).** There are two kinds, and they are identified differently:
|
|
249
|
+
- **Studio-served arms.** Each arm of a Studio A/B test is its own Studio onboarding, so each arm keeps its **own `onboarding.key`**, with its own versions and its own manifest. Arms are compared as separate keys: one funnel per key, side by side. A Studio-served run does not set `variant_key`.
|
|
250
|
+
- **In-flow variants.** An app that runs one flow, under one key and one manifest, and varies something inside it (copy, order of options, an intro screen chosen by a slot) sets `variant_key` on each run. Its funnel can then be split by `variant_key`.
|
|
251
|
+
|
|
252
|
+
A hand-coded flow running an in-flow variant:
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"schema_version": 1,
|
|
257
|
+
"run_id": "00000000-0000-4000-8000-000000000002",
|
|
258
|
+
"seq": 1,
|
|
259
|
+
"status": "in_progress",
|
|
260
|
+
"started_at": "2026-01-10T08:30:00.000Z",
|
|
261
|
+
"completed_at": null,
|
|
262
|
+
"sent_at": "2026-01-10T08:30:00.000Z",
|
|
263
|
+
"onboarding": { "key": "main", "version": "7", "variant_key": "short-intro" },
|
|
264
|
+
"context": {
|
|
265
|
+
"app_version": "2.4.0",
|
|
266
|
+
"build": "412",
|
|
267
|
+
"platform": "ios",
|
|
268
|
+
"os_version": "18.1",
|
|
269
|
+
"locale": "en-US",
|
|
270
|
+
"timezone": "America/New_York"
|
|
271
|
+
},
|
|
272
|
+
"manifest": {
|
|
273
|
+
"steps": [{ "step_key": "welcome" }, { "step_key": "goal" }, { "step_key": "summary" }]
|
|
274
|
+
},
|
|
275
|
+
"steps": [
|
|
276
|
+
{
|
|
277
|
+
"step_key": "welcome",
|
|
278
|
+
"entered_at": "2026-01-10T08:30:00.000Z",
|
|
279
|
+
"exited_at": null,
|
|
280
|
+
"answers": []
|
|
281
|
+
}
|
|
282
|
+
]
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### 3.2 Run (D3, D4)
|
|
287
|
+
|
|
288
|
+
| Field | Required | Meaning |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| `schema_version` | yes | `1` for this document. |
|
|
291
|
+
| `run_id` | yes | A lowercase UUID, minted by the client when the first step is entered. UUIDv7 is RECOMMENDED because it sorts by time. Any version is accepted. |
|
|
292
|
+
| `seq` | yes | `1` on the first send. It increases by at least 1 with every later send of the run, and a retry reuses the same value. |
|
|
293
|
+
| `status` | yes | `in_progress` or `completed`. A quit is never sent: see D14. |
|
|
294
|
+
| `started_at` | yes | Equal to the first entry's `entered_at`. |
|
|
295
|
+
| `completed_at` | yes | A timestamp when `status` is `completed`, null otherwise. |
|
|
296
|
+
| `sent_at` | yes | The client clock when the snapshot left the device. Advisory: stored so clock skew can be measured, never used to order or bucket. |
|
|
297
|
+
|
|
298
|
+
A run is one pass through the flow. If the flow restarts from its first step, or a finished flow is replayed, that is a new `run_id`. The abandoned run stays `in_progress` and counts as quit once D14's threshold passes.
|
|
299
|
+
|
|
300
|
+
**Resume after relaunch.** If the app restores the user's position after it was killed, the run continues with the same `run_id`. To make that possible, a conforming tracker MUST persist, on every change:
|
|
301
|
+
- the `run_id`;
|
|
302
|
+
- the last `seq` it assigned;
|
|
303
|
+
- the full `steps` list, together with the rest of the snapshot (manifest, context, properties);
|
|
304
|
+
- `last_active_at`: the last moment the app was known to be in the foreground on the current screen. It is updated on every entry, every send and every move to the background.
|
|
305
|
+
|
|
306
|
+
On restore, the tracker:
|
|
307
|
+
1. if the pre-kill entry is still open, sets its `exited_at` to the persisted `last_active_at`, because the time after that is not time spent on the screen. An exit already set, as on a truncated run's last kept entry, is never rewritten;
|
|
308
|
+
2. appends a **new entry** for the restored screen, with `entered_at` set to the restore time, subject to [3.9](#39-overflow-d26). The run then shows that step twice, as with back navigation, and it still counts once per position. If the entry would break a limit, the tracker follows 3.9 instead: it stops recording and sets `truncated`, rather than append it. An example is a run that already has 500 entries. A truncated run ([3.9](#39-overflow-d26)) appends **no** entry, because it has stopped recording;
|
|
309
|
+
3. sends the next snapshot with the persisted `seq` + 1.
|
|
310
|
+
|
|
311
|
+
A tracker that cannot restore the position starts a new run instead.
|
|
312
|
+
|
|
313
|
+
### 3.3 Context (D5, D6)
|
|
314
|
+
|
|
315
|
+
Captured once, when the run starts, and repeated unchanged in every snapshot: the schema requires `context` in each one. The server keeps the context of the first snapshot it stores, and ignores any different context sent later.
|
|
316
|
+
|
|
317
|
+
| Field | Required | Meaning |
|
|
318
|
+
|---|---|---|
|
|
319
|
+
| `app_version` | yes | Marketing version, for example `2.4.0`. 1 to 32 characters. |
|
|
320
|
+
| `build` | yes, nullable | Build number, 1 to 32 characters. Null on a platform with no build number. |
|
|
321
|
+
| `platform` | yes | `ios`, `android` or `web`. |
|
|
322
|
+
| `os_version` | yes, nullable | OS version, 1 to 32 characters. Null when the platform cannot report it. |
|
|
323
|
+
| `locale` | yes | BCP 47 tag of the language the flow is shown in. |
|
|
324
|
+
| `timezone` | yes | IANA name, for example `Europe/Paris`. At most 64 characters. |
|
|
325
|
+
| `library_version` | no | Version of the tracker that produced the payload. 1 to 32 characters. |
|
|
326
|
+
|
|
327
|
+
There is no `country` field, and the schema rejects one. The server derives country from its own records for the sender, or from the network address at receive time (D6).
|
|
328
|
+
|
|
329
|
+
### 3.4 Custom properties (D7, D8)
|
|
330
|
+
|
|
331
|
+
`properties` is an optional map from a key to a scalar value.
|
|
332
|
+
- **Keys:** at most 20, each matching `^[a-z][a-z0-9_]{0,39}$`.
|
|
333
|
+
- **Values:** a string of at most 256 characters, a finite number, a boolean, or null. Objects and arrays are not allowed, so every property can be a column.
|
|
334
|
+
|
|
335
|
+
The map MAY change during a run. Each snapshot carries the whole current map, and the last stored snapshot wins. Properties MUST NOT contain personal data: no names, email addresses, phone numbers or free-form user input.
|
|
336
|
+
|
|
337
|
+
A property is for slicing runs. A trait of the person, such as a subscription state, belongs to the backend's identify call. The analytics side MAY join those traits the same way it derives country.
|
|
338
|
+
|
|
339
|
+
### 3.5 Manifest (D17, D18)
|
|
340
|
+
|
|
341
|
+
The manifest is declared once, when the run starts, and repeated unchanged in every snapshot: the schema requires `manifest` in each one. `manifest.steps` lists every step the flow can show, in flow order, from 1 to 200 entries. A step key appears at most once. A screen shown at two different positions needs two keys.
|
|
342
|
+
|
|
343
|
+
A **position** is either:
|
|
344
|
+
- one step with no `slot`, or
|
|
345
|
+
- a run of consecutive steps that share a `slot`.
|
|
346
|
+
|
|
347
|
+
Steps in one slot are **alternatives**: different screens shown at the same point in the flow, of which a run is expected to see one. Steps that share a slot MUST be listed next to each other.
|
|
348
|
+
|
|
349
|
+
Who declares the manifest:
|
|
350
|
+
- **For a Studio-served flow**, it is the deployment's step list, and the tracker copies it into the payload.
|
|
351
|
+
- **For a hand-coded flow**, the app declares it when the run starts.
|
|
352
|
+
|
|
353
|
+
Either way the payload has the same shape, so ingest has one code path.
|
|
354
|
+
|
|
355
|
+
The rejected alternative was a step list inferred from the paths runs actually took. That list cannot place a step no run has seen yet. It cannot tell a skipped step from one that was not reached. And its order shifts every time a new path appears.
|
|
356
|
+
|
|
357
|
+
The first manifest the server receives for a `(onboarding.key, onboarding.version)` is canonical. A run declaring a different manifest for the same pair is kept, but excluded from funnels and reported as a version conflict. *Different* means the JSON values differ as defined in [section 5](#5-transport-d4-d12-d13-d27), rule 4: key order is ignored, an absent optional field equals `null`, and arrays are compared in order. Readers MAY merge funnels across versions of one key whose manifests are equal by the same definition.
|
|
358
|
+
|
|
359
|
+
The reserved version `draft` (D21) is exempt: there is no canonical manifest and no version-conflict handling for it, and its manifest may change from one run to the next. Readers exclude `draft` from funnels by default.
|
|
360
|
+
|
|
361
|
+
### 3.6 Steps (D9, D10)
|
|
362
|
+
|
|
363
|
+
`steps` holds 1 to 500 entries, one per screen shown, in the order shown:
|
|
364
|
+
|
|
365
|
+
| Field | Meaning |
|
|
366
|
+
|---|---|
|
|
367
|
+
| `step_key` | A key from the manifest. |
|
|
368
|
+
| `entered_at` | When the screen was shown. |
|
|
369
|
+
| `exited_at` | When the user left it. Null only on the last entry of an `in_progress` run; in a truncated run that entry may already be closed. |
|
|
370
|
+
| `answers` | What was answered on this visit. Empty when nothing was. |
|
|
371
|
+
|
|
372
|
+
**Back navigation** appends a new entry for the step returned to. Earlier entries are never rewritten.
|
|
373
|
+
|
|
374
|
+
**No skip mechanism.** A step the flow does not show has no entry, and there is no way to report a skip: no flag, no entry kind, no tracker call. Whether a run skipped a step is derived when the funnel is read. A declared step with no entry, on a run that went on to reach a later position or completed, counts as skipped rather than as a quit (see [section 6](#6-funnel-measures-d14-d15-d16)). This covers a step the app decides not to show, such as a permission prompt when the permission is already granted, and a step at the very end of the flow, because a completed run has reached every position.
|
|
375
|
+
|
|
376
|
+
**The last `exited_at`.** While the run is in progress, the last entry has `exited_at: null`, and every earlier entry has one. On completion, the tracker sets the last entry's `exited_at` to `completed_at` if it is still open, so every entry has an exit. In a truncated run ([3.9](#39-overflow-d26)) the last kept entry may already be closed; it keeps its own exit, so completion does not inflate its time on screen. A run that is quit keeps its null exit forever. That null is how "left on this screen" shows up in the data.
|
|
377
|
+
|
|
378
|
+
### 3.7 Answers (D11)
|
|
379
|
+
|
|
380
|
+
Each answer is `{ question_key, kind, value }`. Within one entry, `question_key` is unique. For a step with a single question, the step key is a good question key.
|
|
381
|
+
|
|
382
|
+
| `kind` | `value` | Aggregated as |
|
|
383
|
+
|---|---|---|
|
|
384
|
+
| `single` | One option key. | Runs per option. |
|
|
385
|
+
| `multi` | Option keys: unique, at most 50, possibly empty. | Runs that selected each option. The shares can add up to more than 100%. |
|
|
386
|
+
| `numeric` | A finite number, with an optional `unit` key. The unit MUST be the same for one question within one version. | A distribution. The reader chooses the buckets. |
|
|
387
|
+
| `text` | At most 1,000 characters. | **Never aggregated.** Free text is stored for each run, is never turned into a distribution, and is never shown in one. |
|
|
388
|
+
|
|
389
|
+
Option values MUST be the option's stable key, never its displayed label. Otherwise one option splits into one row per language.
|
|
390
|
+
|
|
391
|
+
If a run answers the same question on several visits, its effective answer is the one in the **last** entry that contains the question.
|
|
392
|
+
|
|
393
|
+
### 3.8 Limits (D8)
|
|
394
|
+
|
|
395
|
+
D8 sets the limits that bound ingest cost. The schema also enforces a few field-level limits, listed here so the two agree:
|
|
396
|
+
|
|
397
|
+
| Field | Limit |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `properties` | At most 20 keys, each matching `^[a-z][a-z0-9_]{0,39}$`. String values at most 256 characters. |
|
|
400
|
+
| `manifest.steps` | 1 to 200 steps. |
|
|
401
|
+
| `steps` | 1 to 500 entries (see [3.9](#39-overflow-d26)). |
|
|
402
|
+
| `steps[].answers` | At most 50 answers per entry; a `multi` value holds at most 50 option keys. |
|
|
403
|
+
| `text` answer | At most 1,000 characters. |
|
|
404
|
+
| Whole snapshot | At most 256 KiB (262,144 bytes) serialized. This is the one limit the schema cannot express. The tracker stops recording at a budget of 261,120 bytes ([3.9](#39-overflow-d26)). The ingest rejects a larger snapshot with `{ "outcome": "rejected" }` ([section 5](#5-transport-d4-d12-d13-d27), rule 2). The exception is a run already completed: such a snapshot is ignored, because rule 1 runs first. |
|
|
405
|
+
| Keys | 1 to 128 characters from `A–Z a–z 0–9 _ . : -`, starting with a letter or a digit. |
|
|
406
|
+
| `onboarding.version` | 1 to 64 characters from `A–Z a–z 0–9 _ . : + -`, starting with a letter or a digit. |
|
|
407
|
+
| `context.app_version`, `build`, `os_version`, `library_version` | 1 to 32 characters. |
|
|
408
|
+
| `context.timezone` | At most 64 characters. |
|
|
409
|
+
| `context.locale` | A BCP 47 tag: `^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$`. |
|
|
410
|
+
| `studio.*` links | 1 to 128 characters. |
|
|
411
|
+
| Timestamps | Exactly three fractional digits (D24). |
|
|
412
|
+
|
|
413
|
+
### 3.9 Overflow (D26)
|
|
414
|
+
|
|
415
|
+
A run that would break the entry limit or the size limit is **truncated**, not dropped. `truncated: true` means the tracker has **stopped recording**. It is set when recording one more thing would take the snapshot past 500 entries or past the **recording budget**. That thing can be a new entry, or growth inside an entry: a new answer, or a new or changed property.
|
|
416
|
+
|
|
417
|
+
The recording budget is **261,120 bytes**: 256 KiB (262,144 bytes) minus 1 KiB of headroom.
|
|
418
|
+
- **What is measured:** the tracker measures the serialized snapshot in the form its completion send would take: `status` `completed`, `completed_at` set, and every open exit set.
|
|
419
|
+
- **What the headroom covers:** the bytes that land after that measure. These are the `,"truncated":true` flag (17 bytes) and `seq` gaining digits on later sends (at most 15 bytes: a JSON-safe integer has at most 16 digits).
|
|
420
|
+
- **Result:** every later send, the completion included, stays within the ingest's 256 KiB hard limit.
|
|
421
|
+
|
|
422
|
+
When the limit would be crossed, the tracker:
|
|
423
|
+
|
|
424
|
+
1. does not record the change that would cross it, and records no further entries, answers or property changes for the rest of the run;
|
|
425
|
+
2. closes the current entry when the user leaves it, as usual. When the limit is hit by a new entry, the user has just left the current screen, so it is closed at once;
|
|
426
|
+
3. sets `truncated: true` on the snapshot, and keeps it there in every later snapshot;
|
|
427
|
+
4. keeps sending the run. In particular it MUST send the `completed` status when the run completes, so the run counts as Completed rather than quit.
|
|
428
|
+
|
|
429
|
+
The first entries are kept, never the last. So `steps[0]` is still the run's first screen and rule 4 in [section 4](#4-rules-beyond-the-schema), `started_at` = `steps[0].entered_at`, still holds.
|
|
430
|
+
|
|
431
|
+
A truncated run follows rules 6 and 7 in their truncated form:
|
|
432
|
+
- Its last kept entry may already be closed while the run is `in_progress`.
|
|
433
|
+
- On completion that entry keeps its own exit, which is at or before `completed_at` instead of equal to it.
|
|
434
|
+
- On restore after relaunch it appends no entry ([3.2](#32-run-d3-d4)).
|
|
435
|
+
|
|
436
|
+
In the funnel ([section 6](#6-funnel-measures-d14-d15-d16)), a truncated run is counted like any other run, with one exception: the screen it was on after truncation is unknown. If it completes, it is Completed and has reached every position. If it goes quiet, it counts as Quit in the totals but in no position's *Quit here*.
|
|
437
|
+
|
|
438
|
+
This run loops between `question` and `review` 300 times. The tracker keeps its first 500 entries, and the run still completes. Entries `steps[2]` to `steps[498]` are elided here; the full payload is in [`onboarding-run.examples.ts`](onboarding-run.examples.ts):
|
|
439
|
+
|
|
440
|
+
<!-- overflow -->
|
|
441
|
+
```jsonc
|
|
442
|
+
{
|
|
443
|
+
"schema_version": 1,
|
|
444
|
+
"run_id": "00000000-0000-4000-8000-000000000401",
|
|
445
|
+
"seq": 502,
|
|
446
|
+
"status": "completed",
|
|
447
|
+
"started_at": "2026-01-10T07:00:00.000Z",
|
|
448
|
+
"completed_at": "2026-01-10T08:40:20.000Z",
|
|
449
|
+
"sent_at": "2026-01-10T08:40:20.000Z",
|
|
450
|
+
"onboarding": { "key": "example-long", "version": "1" },
|
|
451
|
+
"context": {
|
|
452
|
+
"app_version": "2.4.0",
|
|
453
|
+
"build": "412",
|
|
454
|
+
"platform": "ios",
|
|
455
|
+
"os_version": "18.1",
|
|
456
|
+
"locale": "en-US",
|
|
457
|
+
"timezone": "America/New_York"
|
|
458
|
+
},
|
|
459
|
+
"manifest": {
|
|
460
|
+
"steps": [
|
|
461
|
+
{ "step_key": "welcome" },
|
|
462
|
+
{ "step_key": "question" },
|
|
463
|
+
{ "step_key": "review" },
|
|
464
|
+
{ "step_key": "summary" }
|
|
465
|
+
]
|
|
466
|
+
},
|
|
467
|
+
"truncated": true,
|
|
468
|
+
"steps": [
|
|
469
|
+
{
|
|
470
|
+
"step_key": "welcome",
|
|
471
|
+
"entered_at": "2026-01-10T07:00:00.000Z",
|
|
472
|
+
"exited_at": "2026-01-10T07:00:10.000Z",
|
|
473
|
+
"answers": []
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
"step_key": "question",
|
|
477
|
+
"entered_at": "2026-01-10T07:00:10.000Z",
|
|
478
|
+
"exited_at": "2026-01-10T07:00:20.000Z",
|
|
479
|
+
"answers": []
|
|
480
|
+
},
|
|
481
|
+
/* steps[2] to steps[498] elided */
|
|
482
|
+
{
|
|
483
|
+
"step_key": "question",
|
|
484
|
+
"entered_at": "2026-01-10T08:23:10.000Z",
|
|
485
|
+
"exited_at": "2026-01-10T08:23:20.000Z",
|
|
486
|
+
"answers": []
|
|
487
|
+
}
|
|
488
|
+
]
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
## 4. Rules beyond the schema
|
|
493
|
+
|
|
494
|
+
JSON Schema cannot express these rules, so the ingest MUST check them. A snapshot that breaks one of them is rejected whole, and the tracker MUST NOT resend it unchanged.
|
|
495
|
+
|
|
496
|
+
1. Every `steps[].step_key` appears in `manifest.steps`.
|
|
497
|
+
2. Manifest step keys are unique, and steps that share a `slot` are listed next to each other.
|
|
498
|
+
3. Entries are in chronological order: `entered_at` never decreases.
|
|
499
|
+
4. `started_at` equals `steps[0].entered_at`.
|
|
500
|
+
5. On every entry, `exited_at` is null or not earlier than `entered_at`.
|
|
501
|
+
6. Only the last entry may have a null `exited_at`, and it MUST be null when `status` is `in_progress`. **Truncated form:** when `truncated` is set, the last entry MAY also have an exit while `in_progress`.
|
|
502
|
+
7. When `status` is `completed`, the last entry's `exited_at` equals `completed_at`. **Truncated form:** when `truncated` is set, it is set and at or before `completed_at`.
|
|
503
|
+
8. `question_key` is unique within an entry.
|
|
504
|
+
|
|
505
|
+
These rules apply to one snapshot. The rules between snapshots of one run (identity, manifest, `seq`, completion) are acceptance rules, in [section 5](#5-transport-d4-d12-d13-d27).
|
|
506
|
+
|
|
507
|
+
The JSON Schema itself also requires the following:
|
|
508
|
+
- `completed_at` is set exactly when `status` is `completed`.
|
|
509
|
+
- The schema is closed: an unknown field is an error.
|
|
510
|
+
- Keys, timestamps and UUIDs match their patterns.
|
|
511
|
+
- The limits in [3.8](#38-limits-d8) hold, except the snapshot size.
|
|
512
|
+
|
|
513
|
+
## 5. Transport (D4, D12, D13, D27)
|
|
514
|
+
|
|
515
|
+
**Full snapshots, not deltas.** Every send carries the whole run so far.
|
|
516
|
+
|
|
517
|
+
**Who is sending.** The server identifies a run by the sender plus `run_id`. The sender is whatever the transport authenticates, such as an installation id and an application id. The sender is not part of the payload, which keeps device identifiers out of it.
|
|
518
|
+
|
|
519
|
+
### Acceptance rules
|
|
520
|
+
|
|
521
|
+
For each incoming snapshot, the server looks up the stored snapshot for the same sender and `run_id`, then applies these rules in order. The first rule that matches decides the outcome.
|
|
522
|
+
|
|
523
|
+
1. **Completed guard.** If the stored snapshot is `completed`, the incoming one is **ignored**, whatever its `seq`, its status or its validity. An `in_progress` snapshot never overwrites a completed run. If two completed snapshots arrive, the first one stored wins, and later ones are ignored.
|
|
524
|
+
2. **Validation.** A snapshot is **rejected** if it:
|
|
525
|
+
- is larger than 256 KiB serialized (D8), which is checked before schema validation;
|
|
526
|
+
- fails the schema of its `schema_version` (see [section 8](#8-versioning-d19-d20));
|
|
527
|
+
- or breaks a rule in [section 4](#4-rules-beyond-the-schema).
|
|
528
|
+
3. **New run.** With nothing stored, the snapshot is **accepted** and stored.
|
|
529
|
+
4. **Identity and manifest.** A snapshot whose `onboarding` or `manifest` differs from the stored one is **rejected**: a run's identity and its declared step list never change within the run. Here *differs* means JSON values are unequal, compared:
|
|
530
|
+
- without regard to object key order;
|
|
531
|
+
- with an absent optional field equal to `null`, so a `variant_key` of `null` equals no `variant_key`;
|
|
532
|
+
- with arrays compared in order.
|
|
533
|
+
|
|
534
|
+
(The context is not compared: the server pins it from the first stored snapshot and ignores later differences, see [3.3](#33-context-d5-d6).)
|
|
535
|
+
5. **Higher `seq`.** A snapshot with a higher `seq` than the stored one is **accepted** and replaces it.
|
|
536
|
+
6. **Equal `seq`.** A snapshot with the same `seq` is a retry and is **ignored**: a retry is always safe. If its body differs from the stored one, the stored one is kept and the difference is logged.
|
|
537
|
+
7. **Lower `seq`.** A snapshot with a lower `seq` is stale and is **ignored**.
|
|
538
|
+
|
|
539
|
+
Every case, with the outcome it gets:
|
|
540
|
+
|
|
541
|
+
<!-- acceptance -->
|
|
542
|
+
| Case | Stored snapshot | Incoming snapshot | Outcome |
|
|
543
|
+
|---|---|---|---|
|
|
544
|
+
| A1 | none | `in_progress`, valid | accepted |
|
|
545
|
+
| A2 | `in_progress`, `seq` 3 | `completed`, `seq` 8 | accepted |
|
|
546
|
+
| A3 | `in_progress`, `seq` 3 | `in_progress`, `seq` 4 | accepted |
|
|
547
|
+
| A4 | `in_progress`, `seq` 3 | the same snapshot again (a retry) | ignored |
|
|
548
|
+
| A5 | `in_progress`, `seq` 3 | `seq` 3 with a different body | ignored |
|
|
549
|
+
| A6 | `in_progress`, `seq` 3 | `in_progress`, `seq` 2 | ignored |
|
|
550
|
+
| A7 | `completed`, `seq` 8 | `in_progress`, `seq` 9 | ignored |
|
|
551
|
+
| A8 | `completed`, `seq` 8 | `completed`, `seq` 9 | ignored |
|
|
552
|
+
| A9 | `in_progress`, `seq` 3 | `status` `quit`, which fails the schema | rejected |
|
|
553
|
+
| A10 | `in_progress`, `seq` 3 | `seq` 4 with a different `onboarding.version` | rejected |
|
|
554
|
+
| A11 | `completed`, `seq` 8 | `status` `quit`, which fails the schema | ignored |
|
|
555
|
+
| A12 | `in_progress`, `seq` 3 | `seq` 4 with a different `manifest` | rejected |
|
|
556
|
+
| A13 | `in_progress`, `seq` 3 | `seq` 4, otherwise valid, larger than 256 KiB | rejected |
|
|
557
|
+
| A14 | `in_progress`, `seq` 3, no `variant_key` | `seq` 4 with `variant_key` `null` | accepted |
|
|
558
|
+
| A15 | `in_progress`, `seq` 3 | `seq` 4 with the keys of `onboarding` and of each manifest step in another order | accepted |
|
|
559
|
+
|
|
560
|
+
### Outcomes and retries
|
|
561
|
+
|
|
562
|
+
The server answers every snapshot with exactly one outcome, carried **in the response body** as `{ "outcome": "accepted" }`, `{ "outcome": "ignored" }` or `{ "outcome": "rejected", "reason": "…" }`. A rejection SHOULD use a 4xx status, for example 413 for a snapshot over 256 KiB. **The body is what counts, never the status alone.**
|
|
563
|
+
|
|
564
|
+
| Outcome | Meaning | What the tracker does |
|
|
565
|
+
|---|---|---|
|
|
566
|
+
| `accepted` | The snapshot is now the stored one. | Drops it from its send queue. |
|
|
567
|
+
| `ignored` | The server already holds this snapshot, a newer one, or a completed one. | Drops it. This is a success, not an error. |
|
|
568
|
+
| `rejected` | The snapshot is invalid and will never be accepted. This is a permanent failure. | Drops it, MUST NOT resend it unchanged, and reports it locally, for example in a debug log. |
|
|
569
|
+
| *transient* | Any response without one of these outcomes in its body, whatever its status, or no response at all. | Keeps the snapshot and retries later with the same `seq` and body, backing off between attempts. |
|
|
570
|
+
|
|
571
|
+
A response counts as `rejected` **only** when its body says `"outcome": "rejected"`. Some responses carry no outcome body, for example a 401, 404 or 413 from a proxy or gateway. Those are transient, because they may say nothing about the snapshot. So a completed snapshot is never dropped on an unclear response, and a retry is always safe, because the server ignores what it already holds.
|
|
572
|
+
|
|
573
|
+
<!-- responses -->
|
|
574
|
+
| Case | Response | Tracker |
|
|
575
|
+
|---|---|---|
|
|
576
|
+
| R1 | 200, body `{ "outcome": "accepted" }` | drops it |
|
|
577
|
+
| R2 | 200, body `{ "outcome": "ignored" }` | drops it |
|
|
578
|
+
| R3 | 400, body `{ "outcome": "rejected", "reason": "…" }` | drops it permanently |
|
|
579
|
+
| R4 | 401, no body | retries with backoff |
|
|
580
|
+
| R5 | 413, a body with no outcome | retries with backoff |
|
|
581
|
+
| R6 | 503, no body | retries with backoff |
|
|
582
|
+
| R7 | 200, no body | retries with backoff |
|
|
583
|
+
| R8 | no response, or a timeout | retries with backoff |
|
|
584
|
+
|
|
585
|
+
**Server time.** The server stamps its own receive time on every snapshot it accepts, and keeps `first_received_at` and `last_received_at` for the run.
|
|
586
|
+
- A run belongs to the time bucket of its `first_received_at`, so its bucket never moves.
|
|
587
|
+
- The quit threshold (D14) is measured from `last_received_at`.
|
|
588
|
+
- Client timestamps are used only for differences within one run, such as time spent on a step, because they all come from one clock.
|
|
589
|
+
|
|
590
|
+
**When to send.** A tracker SHOULD send:
|
|
591
|
+
- on every step entry,
|
|
592
|
+
- on completion,
|
|
593
|
+
- when the app moves to the background.
|
|
594
|
+
|
|
595
|
+
It MUST keep the latest unsent snapshot across app restarts (see *Resume after relaunch* in [3.2](#32-run-d3-d4)). A snapshot is at most 256 KiB serialized; past that, see [3.9](#39-overflow-d26).
|
|
596
|
+
|
|
597
|
+
## 6. Funnel measures (D14, D15, D16)
|
|
598
|
+
|
|
599
|
+
A funnel is drawn for one `(onboarding.key, onboarding.version)`, optionally for one `variant_key`. It covers the runs whose `first_received_at` falls in the chosen range, minus runs with a version conflict. It is computed at a moment *now*.
|
|
600
|
+
|
|
601
|
+
For each run *r*:
|
|
602
|
+
|
|
603
|
+
- **viewed(r)** is the set of step keys of *r*'s entries.
|
|
604
|
+
- **furthest(r)** is infinite if *r* is completed. Otherwise it is the highest position among *r*'s entries.
|
|
605
|
+
- **last_seen(r)** is the step key of *r*'s last entry. A truncated run ([3.9](#39-overflow-d26)) has no last_seen, because its last screen was not recorded.
|
|
606
|
+
- ***r* is quit** when it is `in_progress` and *now* − `last_received_at` ≥ 24 hours.
|
|
607
|
+
- ***r* is still in progress** when it is neither completed nor quit.
|
|
608
|
+
|
|
609
|
+
For each position *p*, with member steps *M* (one step, or a slot's alternatives):
|
|
610
|
+
|
|
611
|
+
| Measure | Definition |
|
|
612
|
+
|---|---|
|
|
613
|
+
| **Still in the flow** | Runs with furthest(r) ≥ *p*: the runs that got at least as far as this position. |
|
|
614
|
+
| **Saw it** | Runs where viewed(r) includes a step in *M*. |
|
|
615
|
+
| **Skipped** | Still in the flow − Saw it: runs that got past this position without seeing any of its screens. Derived here, never sent: the run has no entry for the position but reached a later one, or completed. |
|
|
616
|
+
| **Quit here** | Quit runs where last_seen(r) is in *M*. |
|
|
617
|
+
| **Quit rate** | Quit here ÷ Saw it. Shown as `—` when Saw it is 0. |
|
|
618
|
+
|
|
619
|
+
In the tables below the quit rate is rounded to the nearest whole percent.
|
|
620
|
+
|
|
621
|
+
For a slot, the row describes the position as a whole. Under it, each alternative gets a row with its own *Saw it*, *Quit here* and quit rate. *Still in the flow* and *Skipped* are reported for the slot only, because seeing one alternative is not skipping the other.
|
|
622
|
+
|
|
623
|
+
Totals: **Started** counts every run. Each run is then exactly one of **Completed**, **Quit** or **Still in progress**.
|
|
624
|
+
|
|
625
|
+
These consequences follow from the definitions and can be checked in every example below:
|
|
626
|
+
|
|
627
|
+
- A run counts **once per position**, however many times it visits (back navigation).
|
|
628
|
+
- Saw it ≤ Still in the flow at every position.
|
|
629
|
+
- Started = Still in the flow(*p*) + runs whose furthest(r) < *p*, at every position *p*.
|
|
630
|
+
- A run whose furthest position is before *p* is counted in no column at *p*. It shows up only in the totals, as Quit or Still in progress. A missing entry therefore reads as a skip only when the run went further; otherwise the run left, or has not arrived yet.
|
|
631
|
+
- *Quit here* belongs to the last screen the user saw. If they had gone further and navigated back before leaving, the quit belongs to the screen they went back to.
|
|
632
|
+
|
|
633
|
+
*Still in the flow* means "reached this position" (D16). A reading of "every run that has neither completed nor quit" would give the same number at every position, so it could not be drawn as a funnel.
|
|
634
|
+
|
|
635
|
+
## 7. Worked examples
|
|
636
|
+
|
|
637
|
+
In all three examples the funnel is computed at `2026-01-12T00:00:00.000Z`, with the 24-hour quit threshold (D14). The complete payload of every run is in [`onboarding-run.examples.ts`](onboarding-run.examples.ts). The path column lists each run's entries: the screens it was shown, in order.
|
|
638
|
+
|
|
639
|
+
### 7.1 A skipped step
|
|
640
|
+
|
|
641
|
+
Manifest: `welcome`, `goal`, `experience`, `permissions`, `summary`.
|
|
642
|
+
|
|
643
|
+
The flow passes over two steps, and the app reports neither:
|
|
644
|
+
- It does not show `experience` when the goal answer is `learn`.
|
|
645
|
+
- It does not show `permissions` when the permission is already granted.
|
|
646
|
+
|
|
647
|
+
In both cases the run simply has no entry for the step. The funnel derives the skip from the manifest.
|
|
648
|
+
|
|
649
|
+
<!-- funnel:derived_skip-runs -->
|
|
650
|
+
| Run | Status | Path | Last snapshot received | Counts as |
|
|
651
|
+
|---|---|---|---|---|
|
|
652
|
+
| r1 | completed | welcome → goal → permissions → summary | 2026-01-10T09:00:40.300Z | Completed |
|
|
653
|
+
| r2 | completed | welcome → goal → experience → goal → experience → summary | 2026-01-10T09:16:00.300Z | Completed |
|
|
654
|
+
| r3 | in_progress | welcome → goal → experience | 2026-01-10T09:30:20.300Z | Quit |
|
|
655
|
+
| r4 | in_progress | welcome → goal → permissions | 2026-01-10T09:45:20.300Z | Quit |
|
|
656
|
+
| r5 | in_progress | welcome | 2026-01-11T23:00:00.300Z | Still in progress |
|
|
657
|
+
| r6 | in_progress | welcome | 2026-01-10T10:00:00.300Z | Quit |
|
|
658
|
+
|
|
659
|
+
r2 went back from `experience` to `goal` once, and was never shown `permissions`, so its payload has no entry for it:
|
|
660
|
+
|
|
661
|
+
```json
|
|
662
|
+
{
|
|
663
|
+
"schema_version": 1,
|
|
664
|
+
"run_id": "00000000-0000-4000-8000-000000000102",
|
|
665
|
+
"seq": 7,
|
|
666
|
+
"status": "completed",
|
|
667
|
+
"started_at": "2026-01-10T09:15:00.000Z",
|
|
668
|
+
"completed_at": "2026-01-10T09:16:00.000Z",
|
|
669
|
+
"sent_at": "2026-01-10T09:16:00.000Z",
|
|
670
|
+
"onboarding": { "key": "example-skip", "version": "1" },
|
|
671
|
+
"context": {
|
|
672
|
+
"app_version": "2.4.0",
|
|
673
|
+
"build": "412",
|
|
674
|
+
"platform": "ios",
|
|
675
|
+
"os_version": "18.1",
|
|
676
|
+
"locale": "en-US",
|
|
677
|
+
"timezone": "America/New_York"
|
|
678
|
+
},
|
|
679
|
+
"manifest": {
|
|
680
|
+
"steps": [
|
|
681
|
+
{ "step_key": "welcome" },
|
|
682
|
+
{ "step_key": "goal" },
|
|
683
|
+
{ "step_key": "experience" },
|
|
684
|
+
{ "step_key": "permissions" },
|
|
685
|
+
{ "step_key": "summary" }
|
|
686
|
+
]
|
|
687
|
+
},
|
|
688
|
+
"steps": [
|
|
689
|
+
{
|
|
690
|
+
"step_key": "welcome",
|
|
691
|
+
"entered_at": "2026-01-10T09:15:00.000Z",
|
|
692
|
+
"exited_at": "2026-01-10T09:15:10.000Z",
|
|
693
|
+
"answers": []
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
"step_key": "goal",
|
|
697
|
+
"entered_at": "2026-01-10T09:15:10.000Z",
|
|
698
|
+
"exited_at": "2026-01-10T09:15:20.000Z",
|
|
699
|
+
"answers": [{ "question_key": "goal", "kind": "single", "value": "practice" }]
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
"step_key": "experience",
|
|
703
|
+
"entered_at": "2026-01-10T09:15:20.000Z",
|
|
704
|
+
"exited_at": "2026-01-10T09:15:30.000Z",
|
|
705
|
+
"answers": []
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
"step_key": "goal",
|
|
709
|
+
"entered_at": "2026-01-10T09:15:30.000Z",
|
|
710
|
+
"exited_at": "2026-01-10T09:15:40.000Z",
|
|
711
|
+
"answers": [{ "question_key": "goal", "kind": "single", "value": "practice" }]
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
"step_key": "experience",
|
|
715
|
+
"entered_at": "2026-01-10T09:15:40.000Z",
|
|
716
|
+
"exited_at": "2026-01-10T09:15:50.000Z",
|
|
717
|
+
"answers": []
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"step_key": "summary",
|
|
721
|
+
"entered_at": "2026-01-10T09:15:50.000Z",
|
|
722
|
+
"exited_at": "2026-01-10T09:16:00.000Z",
|
|
723
|
+
"answers": []
|
|
724
|
+
}
|
|
725
|
+
]
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Funnel:
|
|
730
|
+
|
|
731
|
+
<!-- funnel:derived_skip -->
|
|
732
|
+
| Step | Still in the flow | Saw it | Skipped | Quit here | Quit rate |
|
|
733
|
+
|---|---|---|---|---|---|
|
|
734
|
+
| `welcome` | 6 | 6 | 0 | 1 | 17% |
|
|
735
|
+
| `goal` | 4 | 4 | 0 | 0 | 0% |
|
|
736
|
+
| `experience` | 4 | 2 | 2 | 1 | 50% |
|
|
737
|
+
| `permissions` | 3 | 2 | 1 | 1 | 50% |
|
|
738
|
+
| `summary` | 2 | 2 | 0 | 0 | 0% |
|
|
739
|
+
|
|
740
|
+
<!-- totals:derived_skip -->
|
|
741
|
+
Started 6 · Completed 2 · Quit 3 · Still in progress 1
|
|
742
|
+
|
|
743
|
+
How to read it:
|
|
744
|
+
- **Skips at `experience`.** Two runs saw `experience`, but four got that far. r1 and r4 have no entry for it, yet each reached a later position, so they count as skipped, not quit.
|
|
745
|
+
- **The drop at `welcome`.** r5 was last heard from one hour before the funnel was computed, so it is still in progress rather than quit. It is counted at `welcome` but not at `goal`. That is why *Still in the flow* falls from 6 to 4 with only one quit.
|
|
746
|
+
- **The skip at `permissions`.** r2 has no entry for it and completed, so it reached every position: it counts as skipped there. r3 has no entry either, but it quit at `experience` before reaching `permissions`, so it is not counted at `permissions` at all.
|
|
747
|
+
- **Back navigation.** r2 visited `goal` and `experience` twice each, but is counted once at each.
|
|
748
|
+
|
|
749
|
+
A count of step views alone would read `experience` as a fall from 4 to 2 and call it a drop. The quit rate shows that only one of the two runs that saw it left there.
|
|
750
|
+
|
|
751
|
+
### 7.2 Two alternative screens at one position
|
|
752
|
+
|
|
753
|
+
Manifest: `welcome`, `goal`, then slot `plan` holding `plan_quick` and `plan_detailed`, then `summary`. The `pace` answer on `goal` decides which plan screen is shown.
|
|
754
|
+
|
|
755
|
+
<!-- funnel:alternative_screens-runs -->
|
|
756
|
+
| Run | Status | Path | Last snapshot received | Counts as |
|
|
757
|
+
|---|---|---|---|---|
|
|
758
|
+
| r1 | completed | welcome → goal → plan_quick → summary | 2026-01-10T09:00:40.300Z | Completed |
|
|
759
|
+
| r2 | completed | welcome → goal → plan_detailed → summary | 2026-01-10T09:15:40.300Z | Completed |
|
|
760
|
+
| r3 | in_progress | welcome → goal → plan_detailed | 2026-01-10T09:30:20.300Z | Quit |
|
|
761
|
+
| r4 | completed | welcome → goal → plan_quick → summary | 2026-01-10T09:45:40.300Z | Completed |
|
|
762
|
+
| r5 | in_progress | welcome → goal | 2026-01-10T10:00:10.300Z | Quit |
|
|
763
|
+
|
|
764
|
+
r3 was shown the detailed plan and left there:
|
|
765
|
+
|
|
766
|
+
```json
|
|
767
|
+
{
|
|
768
|
+
"schema_version": 1,
|
|
769
|
+
"run_id": "00000000-0000-4000-8000-000000000203",
|
|
770
|
+
"seq": 3,
|
|
771
|
+
"status": "in_progress",
|
|
772
|
+
"started_at": "2026-01-10T09:30:00.000Z",
|
|
773
|
+
"completed_at": null,
|
|
774
|
+
"sent_at": "2026-01-10T09:30:20.000Z",
|
|
775
|
+
"onboarding": { "key": "example-slot", "version": "1" },
|
|
776
|
+
"context": {
|
|
777
|
+
"app_version": "2.4.0",
|
|
778
|
+
"build": "412",
|
|
779
|
+
"platform": "ios",
|
|
780
|
+
"os_version": "18.1",
|
|
781
|
+
"locale": "en-US",
|
|
782
|
+
"timezone": "America/New_York"
|
|
783
|
+
},
|
|
784
|
+
"manifest": {
|
|
785
|
+
"steps": [
|
|
786
|
+
{ "step_key": "welcome" },
|
|
787
|
+
{ "step_key": "goal" },
|
|
788
|
+
{ "step_key": "plan_quick", "slot": "plan" },
|
|
789
|
+
{ "step_key": "plan_detailed", "slot": "plan" },
|
|
790
|
+
{ "step_key": "summary" }
|
|
791
|
+
]
|
|
792
|
+
},
|
|
793
|
+
"steps": [
|
|
794
|
+
{
|
|
795
|
+
"step_key": "welcome",
|
|
796
|
+
"entered_at": "2026-01-10T09:30:00.000Z",
|
|
797
|
+
"exited_at": "2026-01-10T09:30:10.000Z",
|
|
798
|
+
"answers": []
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"step_key": "goal",
|
|
802
|
+
"entered_at": "2026-01-10T09:30:10.000Z",
|
|
803
|
+
"exited_at": "2026-01-10T09:30:20.000Z",
|
|
804
|
+
"answers": [{ "question_key": "pace", "kind": "single", "value": "detailed" }]
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
"step_key": "plan_detailed",
|
|
808
|
+
"entered_at": "2026-01-10T09:30:20.000Z",
|
|
809
|
+
"exited_at": null,
|
|
810
|
+
"answers": []
|
|
811
|
+
}
|
|
812
|
+
]
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Funnel:
|
|
817
|
+
|
|
818
|
+
<!-- funnel:alternative_screens -->
|
|
819
|
+
| Step | Still in the flow | Saw it | Skipped | Quit here | Quit rate |
|
|
820
|
+
|---|---|---|---|---|---|
|
|
821
|
+
| `welcome` | 5 | 5 | 0 | 0 | 0% |
|
|
822
|
+
| `goal` | 5 | 5 | 0 | 1 | 20% |
|
|
823
|
+
| slot `plan` | 4 | 4 | 0 | 1 | 25% |
|
|
824
|
+
| ↳ `plan_quick` | — | 2 | — | 0 | 0% |
|
|
825
|
+
| ↳ `plan_detailed` | — | 2 | — | 1 | 50% |
|
|
826
|
+
| `summary` | 3 | 3 | 0 | 0 | 0% |
|
|
827
|
+
|
|
828
|
+
<!-- totals:alternative_screens -->
|
|
829
|
+
Started 5 · Completed 3 · Quit 2 · Still in progress 0
|
|
830
|
+
|
|
831
|
+
How to read it:
|
|
832
|
+
- **The slot row.** Every run that reached the plan position saw one of the two screens, so the slot shows no skips.
|
|
833
|
+
- **The alternative rows.** They compare the two screens directly: `plan_detailed` lost one of its two viewers, `plan_quick` none.
|
|
834
|
+
- **Without a slot.** Each plan screen would sit at its own position. Every run that saw the other screen would then count as a skip, and half of all traffic would look routed around both screens.
|
|
835
|
+
|
|
836
|
+
### 7.3 Branches that merge back
|
|
837
|
+
|
|
838
|
+
Manifest: `welcome`, `goal`, then slot `routine` holding `routine_a1` and `routine_b1`, then `routine_a2`, then `summary`. Path `a` is two screens long (`routine_a1` → `routine_a2`), path `b` is one screen (`routine_b1`), and both continue to `summary`.
|
|
839
|
+
|
|
840
|
+
<!-- funnel:merge_back-runs -->
|
|
841
|
+
| Run | Status | Path | Last snapshot received | Counts as |
|
|
842
|
+
|---|---|---|---|---|
|
|
843
|
+
| r1 | completed | welcome → goal → routine_a1 → routine_a2 → summary | 2026-01-10T09:00:50.300Z | Completed |
|
|
844
|
+
| r2 | in_progress | welcome → goal → routine_a1 | 2026-01-10T09:15:20.300Z | Quit |
|
|
845
|
+
| r3 | completed | welcome → goal → routine_b1 → summary | 2026-01-10T09:30:40.300Z | Completed |
|
|
846
|
+
| r4 | in_progress | welcome → goal → routine_b1 | 2026-01-10T09:45:20.300Z | Quit |
|
|
847
|
+
| r5 | completed | welcome → goal → routine_b1 → summary | 2026-01-10T10:00:40.300Z | Completed |
|
|
848
|
+
|
|
849
|
+
r3 took the short path and merged back at `summary`:
|
|
850
|
+
|
|
851
|
+
```json
|
|
852
|
+
{
|
|
853
|
+
"schema_version": 1,
|
|
854
|
+
"run_id": "00000000-0000-4000-8000-000000000303",
|
|
855
|
+
"seq": 5,
|
|
856
|
+
"status": "completed",
|
|
857
|
+
"started_at": "2026-01-10T09:30:00.000Z",
|
|
858
|
+
"completed_at": "2026-01-10T09:30:40.000Z",
|
|
859
|
+
"sent_at": "2026-01-10T09:30:40.000Z",
|
|
860
|
+
"onboarding": { "key": "example-merge", "version": "1" },
|
|
861
|
+
"context": {
|
|
862
|
+
"app_version": "2.4.0",
|
|
863
|
+
"build": "412",
|
|
864
|
+
"platform": "ios",
|
|
865
|
+
"os_version": "18.1",
|
|
866
|
+
"locale": "en-US",
|
|
867
|
+
"timezone": "America/New_York"
|
|
868
|
+
},
|
|
869
|
+
"manifest": {
|
|
870
|
+
"steps": [
|
|
871
|
+
{ "step_key": "welcome" },
|
|
872
|
+
{ "step_key": "goal" },
|
|
873
|
+
{ "step_key": "routine_a1", "slot": "routine" },
|
|
874
|
+
{ "step_key": "routine_b1", "slot": "routine" },
|
|
875
|
+
{ "step_key": "routine_a2" },
|
|
876
|
+
{ "step_key": "summary" }
|
|
877
|
+
]
|
|
878
|
+
},
|
|
879
|
+
"steps": [
|
|
880
|
+
{
|
|
881
|
+
"step_key": "welcome",
|
|
882
|
+
"entered_at": "2026-01-10T09:30:00.000Z",
|
|
883
|
+
"exited_at": "2026-01-10T09:30:10.000Z",
|
|
884
|
+
"answers": []
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
"step_key": "goal",
|
|
888
|
+
"entered_at": "2026-01-10T09:30:10.000Z",
|
|
889
|
+
"exited_at": "2026-01-10T09:30:20.000Z",
|
|
890
|
+
"answers": [{ "question_key": "path", "kind": "single", "value": "b" }]
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
"step_key": "routine_b1",
|
|
894
|
+
"entered_at": "2026-01-10T09:30:20.000Z",
|
|
895
|
+
"exited_at": "2026-01-10T09:30:30.000Z",
|
|
896
|
+
"answers": []
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
"step_key": "summary",
|
|
900
|
+
"entered_at": "2026-01-10T09:30:30.000Z",
|
|
901
|
+
"exited_at": "2026-01-10T09:30:40.000Z",
|
|
902
|
+
"answers": []
|
|
903
|
+
}
|
|
904
|
+
]
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
Funnel:
|
|
909
|
+
|
|
910
|
+
<!-- funnel:merge_back -->
|
|
911
|
+
| Step | Still in the flow | Saw it | Skipped | Quit here | Quit rate |
|
|
912
|
+
|---|---|---|---|---|---|
|
|
913
|
+
| `welcome` | 5 | 5 | 0 | 0 | 0% |
|
|
914
|
+
| `goal` | 5 | 5 | 0 | 0 | 0% |
|
|
915
|
+
| slot `routine` | 5 | 5 | 0 | 2 | 40% |
|
|
916
|
+
| ↳ `routine_a1` | — | 2 | — | 1 | 50% |
|
|
917
|
+
| ↳ `routine_b1` | — | 3 | — | 1 | 33% |
|
|
918
|
+
| `routine_a2` | 3 | 1 | 2 | 0 | 0% |
|
|
919
|
+
| `summary` | 3 | 3 | 0 | 0 | 0% |
|
|
920
|
+
|
|
921
|
+
<!-- totals:merge_back -->
|
|
922
|
+
Started 5 · Completed 3 · Quit 2 · Still in progress 0
|
|
923
|
+
|
|
924
|
+
How to read it:
|
|
925
|
+
- **Where the branches split.** The first screen of each path shares the `routine` slot. The longer path's second screen, `routine_a2`, is a position of its own.
|
|
926
|
+
- **The skips at `routine_a2`.** r3 and r5 took path `b` and reached `summary` without seeing `routine_a2`, so they are its two skips. Its one viewer is r1: the other path-`a` run, r2, quit before reaching it.
|
|
927
|
+
- **After the merge.** *Still in the flow* stays at 3 from `routine_a2` to `summary`, so nobody was lost after the branches joined.
|
|
928
|
+
|
|
929
|
+
## 8. Versioning (D19, D20)
|
|
930
|
+
|
|
931
|
+
### `onboarding.version`: the flow's revision
|
|
932
|
+
|
|
933
|
+
`onboarding.version` MUST change when:
|
|
934
|
+
- the manifest changes in any way: a step added, removed, reordered or renamed, or a slot changed;
|
|
935
|
+
- a question's `question_key`, its option keys or its `kind` change;
|
|
936
|
+
- a numeric question's unit changes.
|
|
937
|
+
|
|
938
|
+
It SHOULD change when a screen's content changes enough that before and after should not be compared. It MAY stay the same for a copy or visual change.
|
|
939
|
+
|
|
940
|
+
The reserved version `draft` (D21) is exempt from all of this: a draft's manifest and questions may change without a version change.
|
|
941
|
+
|
|
942
|
+
A Studio-served flow uses the deployment id, so every publish is a new version. Readers who want one funnel across publishes merge the versions whose manifests are equal, in the sense of section 5, rule 4 (see [3.5](#35-manifest-d17-d18)).
|
|
943
|
+
|
|
944
|
+
### `schema_version`: this document's revision
|
|
945
|
+
|
|
946
|
+
The schema is closed, so any change to it is a new `schema_version`. The tracker sends the version it was built against. The ingest MUST keep accepting every `schema_version` it has ever accepted, and validates each payload against the schema of its own version. Each version gets its own schema file.
|
|
947
|
+
|
|
948
|
+
- **Additive:** a payload of the previous version, relabelled with the new number, is valid under the new schema. The ingest stores both versions without conversion. Examples:
|
|
949
|
+
- a new optional field or object;
|
|
950
|
+
- a limit loosened;
|
|
951
|
+
- a new `context.platform` value.
|
|
952
|
+
- **Breaking:** every other change. The new version's contract MUST define how older payloads convert to it. Examples:
|
|
953
|
+
- a field removed, renamed, or changed in type or meaning;
|
|
954
|
+
- an optional field made required;
|
|
955
|
+
- a limit tightened;
|
|
956
|
+
- a new `status` or answer `kind` (readers branch on both);
|
|
957
|
+
- any change to the rules in [section 4](#4-rules-beyond-the-schema) or to the meaning of an entry or of `exited_at`.
|
|
958
|
+
|
|
959
|
+
## 9. Pre-v1 payloads (D22, D23)
|
|
960
|
+
|
|
961
|
+
Before this contract, a client sent one snapshot per sender, with no run id. The sender and platform came from the transport:
|
|
962
|
+
|
|
963
|
+
```jsonc
|
|
964
|
+
{
|
|
965
|
+
"onboarding_metadata": { /* free-form */ },
|
|
966
|
+
"responses": [
|
|
967
|
+
{ "step_id": "…", "entered_at": "…", "exited_at": null, "answers": { "<element id>": "<value>" } }
|
|
968
|
+
],
|
|
969
|
+
"sent_at": "…"
|
|
970
|
+
}
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
For Studio-served flows, `onboarding_metadata` is the onboarding SDK's `metadata` object: `id`, `onboardingId`, `name`, `onboardingName`, `audienceId`, `audienceName`, `audienceOrder`, `locale`, `startStepId` and `draft`. Hand-coded senders put whatever they chose there. Completion arrived separately, as its own completion event.
|
|
974
|
+
|
|
975
|
+
The server converts each stored pre-v1 snapshot into a v1-shaped row. Such rows are never sent in this form.
|
|
976
|
+
|
|
977
|
+
| v1 | From the pre-v1 payload |
|
|
978
|
+
|---|---|
|
|
979
|
+
| `schema_version` | `0`, meaning converted. |
|
|
980
|
+
| `run_id` | A UUIDv5 of the sender and the first `entered_at`, so converting again gives the same id. When the sender's only row is overwritten by a new run, the new run gets a new id, and the old one is lost, as it already was. |
|
|
981
|
+
| `seq` | Not applicable. Pre-v1 rows were ordered by `sent_at`. A row whose stored `sent_at` is in the future could never be updated, and the conversion cannot recover what it missed. |
|
|
982
|
+
| `status`, `completed_at` | `completed`, at the completion event's time, when the same sender has a completion event at or after the first `entered_at`. Otherwise `in_progress`. |
|
|
983
|
+
| `onboarding.key` | `onboardingId`, or `onboarding_id` from a hand-coded sender. With neither, the run is unattributed: it is stored but excluded from funnels. |
|
|
984
|
+
| `onboarding.version` | `legacy:<deployment id>`, using the deployment resolved below, or `legacy` when none can be resolved. |
|
|
985
|
+
| `onboarding.variant_key` | null. |
|
|
986
|
+
| `studio.onboarding_id` | `onboardingId`. |
|
|
987
|
+
| `studio.audience_id` | `audienceId`, unless it is `"none"`. That value means no audience matched, so the link is null. |
|
|
988
|
+
| `studio.deployment_id` | `deployment_id` if the sender set it. Otherwise it is resolved by time (D23): the deployment live for the run's audience when the row was first received. An audience can pin a deployment, so the audience's own deployment is used, not the onboarding's latest. Null when it cannot be resolved. |
|
|
989
|
+
| `context.platform` | From the transport. |
|
|
990
|
+
| `context.locale` | `locale` from the metadata. |
|
|
991
|
+
| Other `context` fields | Null, unless the ingest fills them from the sender's device context recorded closest to the first `entered_at`. |
|
|
992
|
+
| `manifest` | The step list of the resolved deployment. Without a resolved deployment there is no manifest, and the run is excluded from funnels. |
|
|
993
|
+
| `steps` | `responses` in order: `step_id` becomes `step_key`. On a completed run, a null last `exited_at` becomes `completed_at`. |
|
|
994
|
+
| `answers` | One answer per key of the `answers` map. The key becomes `question_key`; it is whatever the sender used, an element id or a generic key such as `answer`. An array of strings becomes `multi` and a number becomes `numeric`. A string becomes `single` when the deployment defines the step as a choice question, and `text` otherwise: the pre-v1 shape cannot tell them apart. Any other value is dropped. |
|
|
995
|
+
|
|
996
|
+
Pre-v1 answer values are whatever the sender recorded. That may be a displayed label rather than an option key, so the guarantee in D11 does not hold for converted runs, and their answer distributions can split by language.
|
|
997
|
+
|
|
998
|
+
Two kinds of pre-v1 row are excluded from funnels:
|
|
999
|
+
- `draft: true` rows, which are preview traffic.
|
|
1000
|
+
- Rows whose steps are not in the reconstructed manifest, which are flagged as version conflicts.
|
|
1001
|
+
|
|
1002
|
+
## 10. Reference tracker
|
|
1003
|
+
|
|
1004
|
+
The reference implementation is the headless package `@rocapine/rocalytics-sdk`. Apps import the tracker from its `/onboarding` subpath:
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
import { onboardingRun } from "@rocapine/rocalytics-sdk/onboarding";
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
- **Zero-dependency core.** It needs nothing beyond `react`. The clock, the id generator and the run context are injected, so the core has no platform dependency and tests can supply fixed values.
|
|
1011
|
+
- **Pluggable sinks.** The tracker produces snapshots in this contract's shape, and a sink delivers them. A backend adapter is a sink, which keeps the contract backend-neutral. A sink MAY also forward derived events to another analytics tool; those events are outside this contract.
|
|
1012
|
+
- **Tracking stands alone.** The tracking module never imports remote-control code, and the heavier dependencies of remote control are optional peers. An app that only tracks does not install them.
|
|
1013
|
+
- **Studio-served flows.** The existing onboarding SDK emits through the same tracker, so a Studio-served flow and a hand-coded one produce identical payloads.
|