@rocapine/rocalytics-sdk 0.0.0-stage → 0.3.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 +86 -0
- package/LICENSE +21 -0
- package/README.md +449 -2
- package/client/package.json +1 -0
- package/core/package.json +4 -0
- package/dist/client/client.d.ts +146 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +376 -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 +417 -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,233 @@
|
|
|
1
|
+
// The run as the tracker records it, and the pure operations on it. Every
|
|
2
|
+
// function returns a new state (or the same one when nothing changed); none
|
|
3
|
+
// reads the clock or does I/O, so each rule of the contract is testable here.
|
|
4
|
+
import { utf8ByteLength } from "../core/text";
|
|
5
|
+
import { toTimestamp } from "../core/time";
|
|
6
|
+
import type { WireRunContext } from "../core/context";
|
|
7
|
+
import type {
|
|
8
|
+
Answer,
|
|
9
|
+
Manifest,
|
|
10
|
+
OnboardingIdentity,
|
|
11
|
+
OnboardingRunSnapshot,
|
|
12
|
+
Properties,
|
|
13
|
+
StepEntry,
|
|
14
|
+
StudioLinks,
|
|
15
|
+
} from "./contract";
|
|
16
|
+
import { MAX_ANSWERS } from "./input";
|
|
17
|
+
|
|
18
|
+
/** 256 KiB minus 1 KiB of headroom for what lands after the measure (D26). */
|
|
19
|
+
export const RECORDING_BUDGET = 261_120;
|
|
20
|
+
/** The most entries a snapshot holds (D8). */
|
|
21
|
+
export const MAX_ENTRIES = 500;
|
|
22
|
+
|
|
23
|
+
export interface RunState {
|
|
24
|
+
runId: string;
|
|
25
|
+
/** The last `seq` assigned to a send. 0 before the first. */
|
|
26
|
+
lastSeq: number;
|
|
27
|
+
onboarding: OnboardingIdentity;
|
|
28
|
+
studio?: StudioLinks;
|
|
29
|
+
context: WireRunContext;
|
|
30
|
+
manifest: Manifest;
|
|
31
|
+
properties?: Properties;
|
|
32
|
+
/** Entries as recorded. The last one's exit may be provisional, see below. */
|
|
33
|
+
steps: StepEntry[];
|
|
34
|
+
/**
|
|
35
|
+
* The last entry's `exited_at` was set by exitStep while the run goes on.
|
|
36
|
+
* An in-progress snapshot of an untruncated run still sends it as null
|
|
37
|
+
* (rule 6: only the current screen is open), until the next entry closes it
|
|
38
|
+
* for good or completion replaces it with `completed_at` (rule 7).
|
|
39
|
+
*/
|
|
40
|
+
provisionalExit: boolean;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
status: "in_progress" | "completed";
|
|
43
|
+
completedAt: string | null;
|
|
44
|
+
/** Last moment the app was known to be in the foreground on the current screen (3.2). Epoch ms. */
|
|
45
|
+
lastActiveAt: number;
|
|
46
|
+
/** A recorded change has not gone into a send yet (it is waiting on the debounce). */
|
|
47
|
+
dirty?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What an operation did, for the tracker to report. */
|
|
51
|
+
export type Note = "truncated" | "too-many-answers";
|
|
52
|
+
export interface Result {
|
|
53
|
+
state: RunState;
|
|
54
|
+
changed: boolean;
|
|
55
|
+
notes: Note[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const unchanged = (state: RunState, notes: Note[] = []): Result => ({ state, changed: false, notes });
|
|
59
|
+
|
|
60
|
+
/** The snapshot `state` sends with `seq` at `nowMs`, in the key order of the contract's examples. */
|
|
61
|
+
export function toSnapshot(state: RunState, seq: number, nowMs: number): OnboardingRunSnapshot {
|
|
62
|
+
const steps = state.steps.map((e) => ({ ...e, answers: e.answers.slice() }));
|
|
63
|
+
if (state.status === "in_progress" && !state.truncated && state.provisionalExit && steps.length) {
|
|
64
|
+
steps[steps.length - 1].exited_at = null;
|
|
65
|
+
}
|
|
66
|
+
const head = {
|
|
67
|
+
schema_version: 1 as const,
|
|
68
|
+
run_id: state.runId,
|
|
69
|
+
seq,
|
|
70
|
+
};
|
|
71
|
+
const tail = {
|
|
72
|
+
sent_at: toTimestamp(nowMs),
|
|
73
|
+
onboarding: state.onboarding,
|
|
74
|
+
...(state.studio ? { studio: state.studio } : {}),
|
|
75
|
+
context: state.context,
|
|
76
|
+
manifest: state.manifest,
|
|
77
|
+
...(state.properties ? { properties: state.properties } : {}),
|
|
78
|
+
...(state.truncated ? { truncated: true as const } : {}),
|
|
79
|
+
steps,
|
|
80
|
+
};
|
|
81
|
+
const started_at = steps[0]?.entered_at ?? toTimestamp(nowMs);
|
|
82
|
+
return state.status === "completed"
|
|
83
|
+
? { ...head, status: "completed", started_at, completed_at: state.completedAt as string, ...tail }
|
|
84
|
+
: { ...head, status: "in_progress", started_at, completed_at: null, ...tail };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Completion as the contract defines it: every open exit closed at `completed_at`, a truncated run's closed exit kept. */
|
|
88
|
+
export function completeState(state: RunState, nowMs: number): RunState {
|
|
89
|
+
const completedAt = toTimestamp(nowMs);
|
|
90
|
+
const steps = state.steps.slice();
|
|
91
|
+
const lastIndex = steps.length - 1;
|
|
92
|
+
if (lastIndex >= 0) {
|
|
93
|
+
const last = steps[lastIndex];
|
|
94
|
+
const keepExit = state.truncated && last.exited_at !== null;
|
|
95
|
+
if (!keepExit) steps[lastIndex] = { ...last, exited_at: completedAt };
|
|
96
|
+
}
|
|
97
|
+
return { ...state, steps, provisionalExit: false, status: "completed", completedAt };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether `state` stays within the recording budget, measured on its completion form with the next seq (D26). */
|
|
101
|
+
export function fits(state: RunState, nowMs: number): boolean {
|
|
102
|
+
const form = toSnapshot(completeState(state, nowMs), state.lastSeq + 1, nowMs);
|
|
103
|
+
return utf8ByteLength(JSON.stringify(form)) <= RECORDING_BUDGET;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const lastOf = (s: RunState) => s.steps[s.steps.length - 1] as StepEntry | undefined;
|
|
107
|
+
|
|
108
|
+
/** Closes the current entry for good: at its provisional exit if exitStep gave one, else at `at`. */
|
|
109
|
+
function closeCurrent(state: RunState, at: string): RunState {
|
|
110
|
+
const last = lastOf(state);
|
|
111
|
+
if (!last) return state;
|
|
112
|
+
if (last.exited_at !== null && !state.provisionalExit) return state;
|
|
113
|
+
const steps = state.steps.slice();
|
|
114
|
+
steps[steps.length - 1] = { ...last, exited_at: last.exited_at ?? at };
|
|
115
|
+
return { ...state, steps, provisionalExit: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stopRecording(state: RunState): RunState {
|
|
119
|
+
return { ...state, truncated: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A new screen is shown. */
|
|
123
|
+
export function enter(state: RunState, stepKey: string, nowMs: number): Result {
|
|
124
|
+
const at = toTimestamp(nowMs);
|
|
125
|
+
const closed = { ...closeCurrent(state, at), lastActiveAt: nowMs };
|
|
126
|
+
if (state.truncated) return { state: closed, changed: closed.steps !== state.steps, notes: ["truncated"] };
|
|
127
|
+
if (state.steps.length >= MAX_ENTRIES) return { state: stopRecording(closed), changed: true, notes: ["truncated"] };
|
|
128
|
+
const next: RunState = {
|
|
129
|
+
...closed,
|
|
130
|
+
steps: [...closed.steps, { step_key: stepKey, entered_at: at, exited_at: null, answers: [] }],
|
|
131
|
+
};
|
|
132
|
+
if (!fits(next, nowMs)) return { state: stopRecording(closed), changed: true, notes: ["truncated"] };
|
|
133
|
+
return { state: next, changed: true, notes: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The user leaves the screen at `index` (the current entry, or the one just
|
|
138
|
+
* before it when the next screen was entered first), with what they answered.
|
|
139
|
+
*/
|
|
140
|
+
export function exit(state: RunState, index: number, answers: Answer[], nowMs: number): Result {
|
|
141
|
+
const notes: Note[] = [];
|
|
142
|
+
let current = state;
|
|
143
|
+
let changed = false;
|
|
144
|
+
const isLast = index === state.steps.length - 1;
|
|
145
|
+
|
|
146
|
+
if (current.truncated) {
|
|
147
|
+
if (answers.length) notes.push("truncated");
|
|
148
|
+
} else {
|
|
149
|
+
for (const answer of answers) {
|
|
150
|
+
const entry = current.steps[index];
|
|
151
|
+
const at = entry.answers.findIndex((a) => a.question_key === answer.question_key);
|
|
152
|
+
if (at < 0 && entry.answers.length >= MAX_ANSWERS) {
|
|
153
|
+
notes.push("too-many-answers");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const list = entry.answers.slice();
|
|
157
|
+
if (at < 0) list.push(answer);
|
|
158
|
+
else list[at] = answer;
|
|
159
|
+
const steps = current.steps.slice();
|
|
160
|
+
steps[index] = { ...entry, answers: list };
|
|
161
|
+
const next = { ...current, steps };
|
|
162
|
+
if (!fits(next, nowMs)) {
|
|
163
|
+
current = stopRecording(current);
|
|
164
|
+
notes.push("truncated");
|
|
165
|
+
changed = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
current = next;
|
|
169
|
+
changed = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Record when the user left. In a truncated run that exit is final at once.
|
|
174
|
+
const entry = current.steps[index];
|
|
175
|
+
if (isLast && entry.exited_at === null) {
|
|
176
|
+
const steps = current.steps.slice();
|
|
177
|
+
steps[index] = { ...entry, exited_at: toTimestamp(nowMs) };
|
|
178
|
+
current = { ...current, steps, provisionalExit: !current.truncated };
|
|
179
|
+
changed = true;
|
|
180
|
+
} else if (isLast && current.truncated && current.provisionalExit) {
|
|
181
|
+
current = { ...current, provisionalExit: false };
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
return changed ? { state: current, changed, notes } : unchanged(state, notes);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A new or changed property set: `merged` is the whole map after the change. */
|
|
188
|
+
export function setProperties(state: RunState, merged: Properties, nowMs: number): Result {
|
|
189
|
+
if (JSON.stringify(merged) === JSON.stringify(state.properties ?? {})) return unchanged(state);
|
|
190
|
+
if (state.truncated) return unchanged(state, ["truncated"]);
|
|
191
|
+
const next = { ...state, properties: Object.keys(merged).length ? merged : undefined };
|
|
192
|
+
if (!fits(next, nowMs)) return { state: stopRecording(state), changed: true, notes: ["truncated"] };
|
|
193
|
+
return { state: next, changed: true, notes: [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Restore after relaunch (3.2): close a still-open pre-kill entry at
|
|
198
|
+
* last_active_at, or at the exit exitStep recorded if that is later (the
|
|
199
|
+
* user is known to have left then); never rewrite an exit already final. Then append a new
|
|
200
|
+
* entry for the restored screen at `nowMs`, unless the run is truncated or the
|
|
201
|
+
* entry would break a limit, in which case recording stops instead.
|
|
202
|
+
*/
|
|
203
|
+
export function restore(state: RunState, nowMs: number): Result {
|
|
204
|
+
const last = lastOf(state);
|
|
205
|
+
if (!last) return unchanged(state);
|
|
206
|
+
let current = state;
|
|
207
|
+
if (last.exited_at === null || (state.provisionalExit && !state.truncated)) {
|
|
208
|
+
const provisional = last.exited_at !== null ? Date.parse(last.exited_at) : -Infinity;
|
|
209
|
+
const steps = state.steps.slice();
|
|
210
|
+
steps[steps.length - 1] = { ...last, exited_at: toTimestamp(Math.max(state.lastActiveAt, provisional)) };
|
|
211
|
+
current = { ...state, steps, provisionalExit: false };
|
|
212
|
+
}
|
|
213
|
+
current = { ...current, lastActiveAt: nowMs };
|
|
214
|
+
if (current.truncated) return { state: current, changed: true, notes: [] };
|
|
215
|
+
if (current.steps.length >= MAX_ENTRIES) return { state: stopRecording(current), changed: true, notes: ["truncated"] };
|
|
216
|
+
const next: RunState = {
|
|
217
|
+
...current,
|
|
218
|
+
steps: [...current.steps, { step_key: last.step_key, entered_at: toTimestamp(nowMs), exited_at: null, answers: [] }],
|
|
219
|
+
};
|
|
220
|
+
if (!fits(next, nowMs)) return { state: stopRecording(current), changed: true, notes: ["truncated"] };
|
|
221
|
+
return { state: next, changed: true, notes: [] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The latest moment recorded in the run, so a clock set backwards cannot make timestamps go back. */
|
|
225
|
+
export function floorMs(state: RunState): number {
|
|
226
|
+
const last = lastOf(state);
|
|
227
|
+
let floor = 0;
|
|
228
|
+
if (last) {
|
|
229
|
+
floor = Date.parse(last.entered_at);
|
|
230
|
+
if (last.exited_at) floor = Math.max(floor, Date.parse(last.exited_at));
|
|
231
|
+
}
|
|
232
|
+
return floor;
|
|
233
|
+
}
|