@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,405 @@
|
|
|
1
|
+
import { safeDiagnostics, type DiagnosticHandler } from "../core/diagnostics";
|
|
2
|
+
import type { SinkResult } from "../core/sink";
|
|
3
|
+
import { systemClock, type Clock } from "../core/time";
|
|
4
|
+
import type { OnboardingRunSnapshot } from "../onboarding/contract";
|
|
5
|
+
import type { PaywallPresentationSnapshot } from "../paywall/contract";
|
|
6
|
+
import { paywallIngestOutcome } from "./paywallSink";
|
|
7
|
+
import { loadExpoModules, type RocalyticsModules } from "./native";
|
|
8
|
+
import { rocalyticsOutcome, toOnboardingResponsePayload } from "./onboardingSink";
|
|
9
|
+
import {
|
|
10
|
+
buildDemandScoreRequest,
|
|
11
|
+
buildIdentifyRequest,
|
|
12
|
+
buildOnboardingResponseRequest,
|
|
13
|
+
buildPaywallPresentationRequest,
|
|
14
|
+
buildTrackRequest,
|
|
15
|
+
ROCALYTICS_API_BASE,
|
|
16
|
+
sendRocalyticsRequest,
|
|
17
|
+
type FetchLike,
|
|
18
|
+
type RequestContext,
|
|
19
|
+
type RocalyticsRequest,
|
|
20
|
+
} from "./requests";
|
|
21
|
+
import type {
|
|
22
|
+
DemandScoreResult,
|
|
23
|
+
DemandScoreSignals,
|
|
24
|
+
DeviceContext,
|
|
25
|
+
IdentifyParams,
|
|
26
|
+
OnboardingMetadata,
|
|
27
|
+
OnboardingStepAnswers,
|
|
28
|
+
OnboardingStepResponse,
|
|
29
|
+
TrackEventName,
|
|
30
|
+
TrackPurchaseParams,
|
|
31
|
+
} from "./types";
|
|
32
|
+
|
|
33
|
+
/** SecureStore key of the device's roca id. */
|
|
34
|
+
export const ROCA_ID_KEY = "rocalytics-roca-id";
|
|
35
|
+
/**
|
|
36
|
+
* The misspelled key the copied clients stored the id under. Read when the
|
|
37
|
+
* corrected key is empty, copied to it, and never deleted.
|
|
38
|
+
*/
|
|
39
|
+
export const LEGACY_ROCA_ID_KEY = "rocalitics-roca-id";
|
|
40
|
+
/** SecureStore key that records the install event was sent. */
|
|
41
|
+
export const INSTALL_TRACKED_KEY = "rocadata-install-tracked";
|
|
42
|
+
/** A second install key one copied client used. Holding it also counts as "install already sent". */
|
|
43
|
+
export const LEGACY_INSTALL_TRACKED_KEYS = ["rocadata-install-tracked-4"];
|
|
44
|
+
|
|
45
|
+
export interface RocalyticsClientOptions {
|
|
46
|
+
/**
|
|
47
|
+
* The native modules. By default the Expo modules and react-native are
|
|
48
|
+
* loaded lazily when the client starts; when one is missing the client is
|
|
49
|
+
* inert. Pass `null` to run inert on purpose, or your own implementation
|
|
50
|
+
* (tests, a non-Expo host).
|
|
51
|
+
*/
|
|
52
|
+
modules?: RocalyticsModules | null;
|
|
53
|
+
/** Defaults to the global `fetch`, read on each request. */
|
|
54
|
+
fetch?: FetchLike;
|
|
55
|
+
/** Defaults to `https://rocalytics-api.rocapine.io`. */
|
|
56
|
+
baseUrl?: string;
|
|
57
|
+
clock?: Clock;
|
|
58
|
+
/** Receives why the client went inert, or why its start-up failed. Default: `console.warn`. */
|
|
59
|
+
onDiagnostic?: DiagnosticHandler;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const intlOptions = () => Intl.DateTimeFormat().resolvedOptions();
|
|
63
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The Rocalytics client. Create one per app, at startup: the constructor
|
|
67
|
+
* starts it (reads or mints the device's roca id, identifies the device,
|
|
68
|
+
* sends `install` once per device), and every method waits for that.
|
|
69
|
+
*
|
|
70
|
+
* Inert when it cannot run: a native module is missing from the binary, or
|
|
71
|
+
* the stored id cannot be read. `ready` still resolves, `rocaId` stays null,
|
|
72
|
+
* and every method resolves without sending anything, except
|
|
73
|
+
* `getDemandScore`, which rejects because it has no value to return.
|
|
74
|
+
*
|
|
75
|
+
* A method whose request gets a non-2xx answer rejects with
|
|
76
|
+
* `[ROCALYTICS] <endpoint> failed: <status>`. No request, response or
|
|
77
|
+
* purchase property is ever logged; only start-up problems are reported, to
|
|
78
|
+
* `onDiagnostic` (default `console.warn`).
|
|
79
|
+
*/
|
|
80
|
+
export class RocalyticsClient {
|
|
81
|
+
/** The device's id, once `ready` resolves. Null while starting, and when inert. */
|
|
82
|
+
rocaId: string | null = null;
|
|
83
|
+
/** Resolves once the client has started, or has gone inert. Never rejects. */
|
|
84
|
+
readonly ready: Promise<void>;
|
|
85
|
+
|
|
86
|
+
private native: RocalyticsModules | null = null;
|
|
87
|
+
private deviceContext: DeviceContext | null = null;
|
|
88
|
+
private onboardingMetadata: OnboardingMetadata | null = null;
|
|
89
|
+
private onboardingResponses: OnboardingStepResponse[] = [];
|
|
90
|
+
/** Runs whose `onboarding_completed` event was sent this session. */
|
|
91
|
+
private readonly completionSent = new Set<string>();
|
|
92
|
+
private readonly clock: Clock;
|
|
93
|
+
private readonly report: DiagnosticHandler;
|
|
94
|
+
|
|
95
|
+
constructor(private readonly options: RocalyticsClientOptions = {}) {
|
|
96
|
+
this.clock = options.clock ?? systemClock;
|
|
97
|
+
this.report = safeDiagnostics(options.onDiagnostic);
|
|
98
|
+
this.ready = this.init().catch(() => undefined);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Where `sendOnboardingRun` delivers: the `/onboarding-response` endpoint of
|
|
103
|
+
* `baseUrl`, marked ` (custom fetch)` when a `fetch` is passed, which may
|
|
104
|
+
* never reach it, so that it never counts as the real endpoint.
|
|
105
|
+
*/
|
|
106
|
+
get onboardingRunDestination(): string {
|
|
107
|
+
const url = `${this.options.baseUrl ?? ROCALYTICS_API_BASE}/functions/v1/onboarding-response`;
|
|
108
|
+
return this.options.fetch ? `${url} (custom fetch)` : url;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Where `sendPaywallPresentation` delivers, marked ` (custom fetch)` like `onboardingRunDestination`. */
|
|
112
|
+
get paywallPresentationDestination(): string {
|
|
113
|
+
const url = `${this.options.baseUrl ?? ROCALYTICS_API_BASE}/functions/v1/paywall-presentations`;
|
|
114
|
+
return this.options.fetch ? `${url} (custom fetch)` : url;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Sends one paywall presentation snapshot. The send of `createRocalyticsPaywallSink`; it never throws. */
|
|
118
|
+
async sendPaywallPresentation(snapshot: PaywallPresentationSnapshot): Promise<SinkResult> {
|
|
119
|
+
await this.ready;
|
|
120
|
+
if (!this.rocaId) return { outcome: "transient", reason: "the Rocalytics client is inert" };
|
|
121
|
+
const fetch = this.fetchFn();
|
|
122
|
+
if (!fetch) return { outcome: "transient", reason: "no fetch available" };
|
|
123
|
+
try {
|
|
124
|
+
const request = buildPaywallPresentationRequest(this.context(), snapshot);
|
|
125
|
+
const response = await fetch(request.url, request.init);
|
|
126
|
+
const body = await response.json().catch(() => undefined);
|
|
127
|
+
return paywallIngestOutcome(response.status, body);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { outcome: "transient", reason: String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Sends a named analytics event. */
|
|
134
|
+
async track(name: TrackEventName, properties?: Record<string, unknown>): Promise<void> {
|
|
135
|
+
await this.ready;
|
|
136
|
+
if (!this.rocaId) return;
|
|
137
|
+
await this.sendTrack(name, properties || {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The same as `track`. */
|
|
141
|
+
async trackEvent(name: TrackEventName, properties?: Record<string, unknown>): Promise<void> {
|
|
142
|
+
await this.track(name, properties);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sends a custom event with any name. It is not stored as an analytics
|
|
147
|
+
* event: the API passes it on to drive automations, with
|
|
148
|
+
* `properties` as template variables. Deduplicated on `${rocaId}-${name}`,
|
|
149
|
+
* so a once-ever event can be re-fired safely; pass `dedupSuffix` (a date,
|
|
150
|
+
* say) to keep each occurrence of a recurring event distinct.
|
|
151
|
+
*/
|
|
152
|
+
async trackCustomEvent(name: string, properties?: Record<string, unknown>, dedupSuffix?: string): Promise<void> {
|
|
153
|
+
await this.ready;
|
|
154
|
+
if (!this.rocaId) return;
|
|
155
|
+
await this.sendTrack(name, properties || {}, `${this.rocaId}-${name}${dedupSuffix ? `-${dedupSuffix}` : ""}`, true);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Sends `purchase`, deduplicated per original transaction. */
|
|
159
|
+
async trackPurchase(params: TrackPurchaseParams): Promise<void> {
|
|
160
|
+
await this.ready;
|
|
161
|
+
if (!this.rocaId) return;
|
|
162
|
+
const { isTrial, value, currency, originalTransactionIdentifier, product, transaction, redemptionResult } = params;
|
|
163
|
+
const productIdentifier = (product as { productIdentifier?: unknown } | undefined)?.productIdentifier;
|
|
164
|
+
const experimental: Record<string, unknown> = {};
|
|
165
|
+
if (product !== undefined) experimental.product = product;
|
|
166
|
+
if (transaction !== undefined) experimental.transaction = transaction;
|
|
167
|
+
if (redemptionResult !== undefined) experimental.redemption_result = redemptionResult;
|
|
168
|
+
const properties: Record<string, unknown> = {
|
|
169
|
+
is_trial: isTrial,
|
|
170
|
+
original_transaction_identifier: originalTransactionIdentifier,
|
|
171
|
+
product_id: params.productId ?? (typeof productIdentifier === "string" ? productIdentifier : undefined),
|
|
172
|
+
price: value,
|
|
173
|
+
currency_code: currency,
|
|
174
|
+
// Omitted when empty: the raw purchase objects, forwarded as given.
|
|
175
|
+
...(Object.keys(experimental).length > 0 ? { experimental } : {}),
|
|
176
|
+
};
|
|
177
|
+
await this.sendTrack("purchase", properties, `${this.rocaId}-purchase-${originalTransactionIdentifier}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Attaches identifiers to the device's identity. Null and undefined values are dropped. */
|
|
181
|
+
async identify(identifiers: IdentifyParams): Promise<void> {
|
|
182
|
+
await this.ready;
|
|
183
|
+
if (!this.rocaId) return;
|
|
184
|
+
await this.sendIdentify(identifiers);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Call on every onboarding navigation change: entering a step, answering on
|
|
189
|
+
* it, or moving on. Sends every step seen so far; the API keeps the latest
|
|
190
|
+
* snapshot per roca id, so calls need no batching or debouncing.
|
|
191
|
+
*
|
|
192
|
+
* The same `stepId` as the current step merges `answers` into it. A
|
|
193
|
+
* different one closes the current step and opens a new one, so returning
|
|
194
|
+
* to an earlier step appends it again. `metadata` is merged into what was
|
|
195
|
+
* sent before.
|
|
196
|
+
*/
|
|
197
|
+
async trackOnboarding(stepId: string, answers?: OnboardingStepAnswers, metadata?: OnboardingMetadata): Promise<void> {
|
|
198
|
+
await this.ready;
|
|
199
|
+
if (!this.rocaId) return;
|
|
200
|
+
if (metadata) this.onboardingMetadata = { ...this.onboardingMetadata, ...metadata };
|
|
201
|
+
const now = new Date(this.clock.now()).toISOString();
|
|
202
|
+
const current = this.onboardingResponses[this.onboardingResponses.length - 1];
|
|
203
|
+
if (current && current.step_id === stepId) {
|
|
204
|
+
if (answers) current.answers = { ...current.answers, ...answers };
|
|
205
|
+
} else {
|
|
206
|
+
if (current && current.exited_at === null) current.exited_at = now;
|
|
207
|
+
this.onboardingResponses.push({ step_id: stepId, entered_at: now, exited_at: null, answers: answers ?? {} });
|
|
208
|
+
}
|
|
209
|
+
await this.send(
|
|
210
|
+
buildOnboardingResponseRequest(this.context(), {
|
|
211
|
+
onboarding_metadata: this.onboardingMetadata ?? undefined,
|
|
212
|
+
sent_at: now,
|
|
213
|
+
responses: this.onboardingResponses,
|
|
214
|
+
}),
|
|
215
|
+
"onboarding-response",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The server-computed demand score (1 to 100) for this install. Waits for
|
|
221
|
+
* `ready` on purpose: the endpoint answers 404 until `/identify` has created
|
|
222
|
+
* the identity, and computes the score from it.
|
|
223
|
+
*/
|
|
224
|
+
async getDemandScore(signals?: DemandScoreSignals): Promise<DemandScoreResult> {
|
|
225
|
+
await this.ready;
|
|
226
|
+
if (!this.rocaId) throw new Error("[ROCALYTICS] demand-score unavailable: client failed to initialize");
|
|
227
|
+
const response = await this.send(buildDemandScoreRequest(this.context(), signals), "demand-score");
|
|
228
|
+
return (await response.json()) as DemandScoreResult;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Delivers one onboarding run snapshot as the pre-v1 onboarding payload,
|
|
233
|
+
* then, for an accepted completed snapshot, sends `onboarding_completed`
|
|
234
|
+
* once per run. Its deduplication id is run-scoped,
|
|
235
|
+
* `${rocaId}-onboarding_completed-${run_id}` (the API only requires the
|
|
236
|
+
* `${rocaId}-${name}` prefix), so each completed run on a device counts
|
|
237
|
+
* once, and a resend of the same run's completion is deduplicated. A failed
|
|
238
|
+
* completion event makes the send transient, so a retry sends it. This is
|
|
239
|
+
* the send of `createRocalyticsOnboardingSink`; it never throws.
|
|
240
|
+
*/
|
|
241
|
+
async sendOnboardingRun(snapshot: OnboardingRunSnapshot): Promise<SinkResult> {
|
|
242
|
+
await this.ready;
|
|
243
|
+
if (!this.rocaId) return { outcome: "transient", reason: "the Rocalytics client is inert" };
|
|
244
|
+
const fetch = this.fetchFn();
|
|
245
|
+
if (!fetch) return { outcome: "transient", reason: "no fetch available" };
|
|
246
|
+
try {
|
|
247
|
+
const request = buildOnboardingResponseRequest(this.context(), toOnboardingResponsePayload(snapshot));
|
|
248
|
+
const response = await fetch(request.url, request.init);
|
|
249
|
+
const body = response.ok ? undefined : await response.json().catch(() => undefined);
|
|
250
|
+
const result = rocalyticsOutcome(response.status, body);
|
|
251
|
+
if (result.outcome === "accepted" && snapshot.status === "completed" && !this.completionSent.has(snapshot.run_id)) {
|
|
252
|
+
await this.sendTrack("onboarding_completed", {}, `${this.rocaId}-onboarding_completed-${snapshot.run_id}`);
|
|
253
|
+
this.completionSent.add(snapshot.run_id);
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return { outcome: "transient", reason: String(error) };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- internals --------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
private async init(): Promise<void> {
|
|
264
|
+
const native = this.loadModules();
|
|
265
|
+
if (!native) return;
|
|
266
|
+
const store = native.secureStore;
|
|
267
|
+
try {
|
|
268
|
+
this.rocaId = await this.readOrMintRocaId(native);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.report({ code: "identity-unavailable", message: `the stored roca id could not be read, so none is minted: ${message(error)}` });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.native = native;
|
|
274
|
+
try {
|
|
275
|
+
const idfv = native.platform.OS === "ios" ? await native.application.getIosIdForVendorAsync() : null;
|
|
276
|
+
const androidId = native.platform.OS === "android" ? native.application.getAndroidId() : null;
|
|
277
|
+
await this.sendIdentify({ idfv, android_id: androidId, locale: intlOptions().locale });
|
|
278
|
+
|
|
279
|
+
this.deviceContext = await this.readDeviceContext(native);
|
|
280
|
+
|
|
281
|
+
let tracked = await store.getItemAsync(INSTALL_TRACKED_KEY);
|
|
282
|
+
for (const key of LEGACY_INSTALL_TRACKED_KEYS) tracked = tracked || (await store.getItemAsync(key));
|
|
283
|
+
if (!tracked) {
|
|
284
|
+
const installTime = await native.application.getInstallationTimeAsync();
|
|
285
|
+
await this.sendTrack("install", { install_time: installTime });
|
|
286
|
+
await store.setItemAsync(INSTALL_TRACKED_KEY, "true");
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
this.report({ code: "init-failed", message: message(error) });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private loadModules(): RocalyticsModules | null {
|
|
294
|
+
const injected = this.options.modules;
|
|
295
|
+
if (injected) return injected;
|
|
296
|
+
let reason = "no native modules were provided";
|
|
297
|
+
if (injected === undefined) {
|
|
298
|
+
const result = loadExpoModules();
|
|
299
|
+
if (result.ok) return result.modules;
|
|
300
|
+
reason = result.reason;
|
|
301
|
+
}
|
|
302
|
+
this.report({ code: "native-modules-unavailable", message: `Rocalytics is disabled for this session: ${reason}` });
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The corrected key's id; else the legacy key's id, copied to the corrected
|
|
308
|
+
* key; else a new id, written under both keys. A read that fails throws, so
|
|
309
|
+
* no id is ever minted while a key may still hold one.
|
|
310
|
+
*/
|
|
311
|
+
private async readOrMintRocaId(native: RocalyticsModules): Promise<string> {
|
|
312
|
+
const store = native.secureStore;
|
|
313
|
+
const current = await store.getItemAsync(ROCA_ID_KEY);
|
|
314
|
+
if (current) return current;
|
|
315
|
+
const legacy = await store.getItemAsync(LEGACY_ROCA_ID_KEY);
|
|
316
|
+
if (legacy) {
|
|
317
|
+
try {
|
|
318
|
+
await store.setItemAsync(ROCA_ID_KEY, legacy);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.report({ code: "identity-migration-failed", message: `the roca id was kept but not copied to ${ROCA_ID_KEY}: ${message(error)}` });
|
|
321
|
+
}
|
|
322
|
+
return legacy;
|
|
323
|
+
}
|
|
324
|
+
const id = native.crypto.randomUUID();
|
|
325
|
+
await store.setItemAsync(ROCA_ID_KEY, id);
|
|
326
|
+
// Also under the legacy key: if a later over-the-air rollback brings back
|
|
327
|
+
// a bundle with a copied client, which reads only that key, it finds this
|
|
328
|
+
// id instead of minting a second identity for the same device.
|
|
329
|
+
try {
|
|
330
|
+
await store.setItemAsync(LEGACY_ROCA_ID_KEY, id);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
this.report({ code: "identity-legacy-write-failed", message: `the roca id was not also written to ${LEGACY_ROCA_ID_KEY}: ${message(error)}` });
|
|
333
|
+
}
|
|
334
|
+
return id;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private async readDeviceContext(native: RocalyticsModules): Promise<DeviceContext> {
|
|
338
|
+
const { application: app, device, platform } = native;
|
|
339
|
+
const { width, height, scale } = native.dimensions.get("screen");
|
|
340
|
+
let ip: string | null = null;
|
|
341
|
+
try {
|
|
342
|
+
const result = await native.network.getIpAddressAsync();
|
|
343
|
+
if (result && result !== "0.0.0.0") ip = result;
|
|
344
|
+
} catch {
|
|
345
|
+
// The ip is best-effort.
|
|
346
|
+
}
|
|
347
|
+
const { locale, timeZone } = intlOptions();
|
|
348
|
+
const brand = device.brand ?? platform.OS;
|
|
349
|
+
const model = device.modelName ?? "Unknown";
|
|
350
|
+
const osName = device.osName ?? platform.OS;
|
|
351
|
+
const osVersion = device.osVersion ?? String(platform.Version);
|
|
352
|
+
return {
|
|
353
|
+
ip,
|
|
354
|
+
user_agent: `${app.applicationName ?? "App"}/${app.nativeApplicationVersion ?? "1.0"} (${brand} ${model}; ${osName} ${osVersion}; ${locale})`,
|
|
355
|
+
device_model: device.modelName,
|
|
356
|
+
device_brand: device.brand,
|
|
357
|
+
device_manufacturer: device.manufacturer,
|
|
358
|
+
os_name: device.osName,
|
|
359
|
+
os_version: device.osVersion,
|
|
360
|
+
screen_width: width,
|
|
361
|
+
screen_height: height,
|
|
362
|
+
screen_scale: scale,
|
|
363
|
+
timezone: timeZone,
|
|
364
|
+
locale,
|
|
365
|
+
app_version: app.nativeApplicationVersion,
|
|
366
|
+
app_build: app.nativeBuildVersion,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Only called once `rocaId` and the modules are set. */
|
|
371
|
+
private context(): RequestContext {
|
|
372
|
+
return {
|
|
373
|
+
rocaId: this.rocaId as string,
|
|
374
|
+
applicationId: this.native?.application.applicationId ?? null,
|
|
375
|
+
platform: this.native?.platform.OS ?? "unknown",
|
|
376
|
+
baseUrl: this.options.baseUrl,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private fetchFn(): FetchLike | undefined {
|
|
381
|
+
return this.options.fetch ?? (globalThis as { fetch?: FetchLike }).fetch;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private send(request: RocalyticsRequest, endpoint: string, detail?: string) {
|
|
385
|
+
const fetch = this.fetchFn();
|
|
386
|
+
if (!fetch) return Promise.reject(new Error(`[ROCALYTICS] ${endpoint} failed: no fetch available`));
|
|
387
|
+
return sendRocalyticsRequest(fetch, request, endpoint, detail);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async sendIdentify(identifiers: IdentifyParams): Promise<void> {
|
|
391
|
+
const payload = Object.fromEntries(Object.entries(identifiers).filter(([, v]) => v != null));
|
|
392
|
+
await this.send(buildIdentifyRequest(this.context(), payload), "identify");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async sendTrack(name: string, properties: Record<string, unknown>, deduplicationId?: string, customEvent?: boolean): Promise<void> {
|
|
396
|
+
const request = buildTrackRequest(this.context(), {
|
|
397
|
+
name,
|
|
398
|
+
properties,
|
|
399
|
+
deviceContext: this.deviceContext,
|
|
400
|
+
deduplicationId,
|
|
401
|
+
customEvent,
|
|
402
|
+
});
|
|
403
|
+
await this.send(request, "track", name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @rocapine/rocalytics-sdk/client: the Rocalytics client.
|
|
2
|
+
//
|
|
3
|
+
// Sends installs, purchases, identities, onboarding progress and custom events
|
|
4
|
+
// to the Rocalytics API, and reads the demand score. The Expo native modules
|
|
5
|
+
// it needs are optional peer dependencies, loaded lazily when the client
|
|
6
|
+
// starts (see native.ts): importing this subpath loads none of them.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
INSTALL_TRACKED_KEY,
|
|
10
|
+
LEGACY_INSTALL_TRACKED_KEYS,
|
|
11
|
+
LEGACY_ROCA_ID_KEY,
|
|
12
|
+
ROCA_ID_KEY,
|
|
13
|
+
RocalyticsClient,
|
|
14
|
+
} from "./client";
|
|
15
|
+
export type { RocalyticsClientOptions } from "./client";
|
|
16
|
+
export { createRocalyticsOnboardingSink, rocalyticsOutcome, toOnboardingResponsePayload } from "./onboardingSink";
|
|
17
|
+
export { createRocalyticsPaywallSink, paywallIngestOutcome } from "./paywallSink";
|
|
18
|
+
export { loadExpoModules } from "./native";
|
|
19
|
+
export type { ApplicationModule, ExpoModuleLoaders, LoadResult, RocalyticsModules } from "./native";
|
|
20
|
+
export {
|
|
21
|
+
ROCALYTICS_API_BASE,
|
|
22
|
+
buildDemandScoreRequest,
|
|
23
|
+
buildIdentifyRequest,
|
|
24
|
+
buildOnboardingResponseRequest,
|
|
25
|
+
buildPaywallPresentationRequest,
|
|
26
|
+
buildTrackRequest,
|
|
27
|
+
getEventId,
|
|
28
|
+
rocalyticsHeaders,
|
|
29
|
+
sendRocalyticsRequest,
|
|
30
|
+
} from "./requests";
|
|
31
|
+
export type { FetchLike, FetchResponseLike, RequestContext, RocalyticsRequest, TrackRequestInput } from "./requests";
|
|
32
|
+
export type {
|
|
33
|
+
AdjustAttribution,
|
|
34
|
+
DemandScoreResult,
|
|
35
|
+
DemandScoreSignalScore,
|
|
36
|
+
DemandScoreSignals,
|
|
37
|
+
DemandScoreV1,
|
|
38
|
+
DemandScoreV2,
|
|
39
|
+
DemandScoreV2Signal,
|
|
40
|
+
DemandScoreVersion,
|
|
41
|
+
DeviceContext,
|
|
42
|
+
IdentifyParams,
|
|
43
|
+
OnboardingMetadata,
|
|
44
|
+
OnboardingResponsePayload,
|
|
45
|
+
OnboardingStepAnswers,
|
|
46
|
+
OnboardingStepResponse,
|
|
47
|
+
PurchaseProduct,
|
|
48
|
+
PurchaseTransaction,
|
|
49
|
+
TrackEventName,
|
|
50
|
+
TrackPurchaseParams,
|
|
51
|
+
} from "./types";
|
|
52
|
+
export type { Diagnostic, DiagnosticHandler } from "../core/diagnostics";
|
|
53
|
+
export type { Sink, SinkResult } from "../core/sink";
|
|
54
|
+
export type { Clock } from "../core/time";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The one file in the package that loads another package.
|
|
2
|
+
//
|
|
3
|
+
// The Rocalytics client needs five Expo native modules and react-native. They
|
|
4
|
+
// are optional peer dependencies: an app that only uses /onboarding installs
|
|
5
|
+
// none of them, and nothing outside this file references them.
|
|
6
|
+
//
|
|
7
|
+
// They are loaded lazily, at client start, never at import time. An Expo
|
|
8
|
+
// module's JS wrapper calls `requireNativeModule("ExpoXxx")` when it is first
|
|
9
|
+
// evaluated, which throws when the app binary predates the module (a JS update
|
|
10
|
+
// shipped over the air onto an older build). So each native module is first
|
|
11
|
+
// probed through `requireOptionalNativeModule` (expo-modules-core), which
|
|
12
|
+
// returns null instead of throwing, and no wrapper is required unless every
|
|
13
|
+
// probe succeeds. The probe must not be `TurboModuleRegistry.get`: on the New
|
|
14
|
+
// Architecture, Expo modules are registered with the Expo modules registry,
|
|
15
|
+
// not the TurboModule registry, so that probe reports a linked module as
|
|
16
|
+
// missing.
|
|
17
|
+
//
|
|
18
|
+
// Every require is a string literal, so a bundler can see it. Metro resolves
|
|
19
|
+
// them at bundle time: an app that imports /client without installing a
|
|
20
|
+
// peer fails to bundle, rather than going inert at run time.
|
|
21
|
+
|
|
22
|
+
declare const require: (id: string) => unknown;
|
|
23
|
+
|
|
24
|
+
/** The members of `expo-application` the client reads. The module itself fits as is. */
|
|
25
|
+
export interface ApplicationModule {
|
|
26
|
+
readonly applicationId: string | null;
|
|
27
|
+
readonly applicationName: string | null;
|
|
28
|
+
readonly nativeApplicationVersion: string | null;
|
|
29
|
+
readonly nativeBuildVersion: string | null;
|
|
30
|
+
getIosIdForVendorAsync(): Promise<string | null>;
|
|
31
|
+
getAndroidId(): string;
|
|
32
|
+
getInstallationTimeAsync(): Promise<Date>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The native capabilities the client uses, shaped like the Expo modules and react-native, which fit as they are. */
|
|
36
|
+
export interface RocalyticsModules {
|
|
37
|
+
application: ApplicationModule;
|
|
38
|
+
crypto: { randomUUID(): string };
|
|
39
|
+
device: {
|
|
40
|
+
readonly brand: string | null;
|
|
41
|
+
readonly modelName: string | null;
|
|
42
|
+
readonly manufacturer: string | null;
|
|
43
|
+
readonly osName: string | null;
|
|
44
|
+
readonly osVersion: string | null;
|
|
45
|
+
};
|
|
46
|
+
network: { getIpAddressAsync(): Promise<string> };
|
|
47
|
+
secureStore: {
|
|
48
|
+
getItemAsync(key: string): Promise<string | null>;
|
|
49
|
+
setItemAsync(key: string, value: string): Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
/** react-native's `Platform`. */
|
|
52
|
+
platform: { readonly OS: string; readonly Version: string | number };
|
|
53
|
+
/** react-native's `Dimensions`. */
|
|
54
|
+
dimensions: { get(dim: "screen"): { width: number; height: number; scale: number } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How each package is loaded. Injected by tests; the defaults are the literal requires. */
|
|
58
|
+
export interface ExpoModuleLoaders {
|
|
59
|
+
core: () => unknown;
|
|
60
|
+
application: () => unknown;
|
|
61
|
+
crypto: () => unknown;
|
|
62
|
+
device: () => unknown;
|
|
63
|
+
network: () => unknown;
|
|
64
|
+
secureStore: () => unknown;
|
|
65
|
+
reactNative: () => unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type LoadResult = { ok: true; modules: RocalyticsModules } | { ok: false; reason: string };
|
|
69
|
+
|
|
70
|
+
/** The native module names the wrappers pass to `requireNativeModule`. */
|
|
71
|
+
const NATIVE_MODULES = ["ExpoApplication", "ExpoCrypto", "ExpoDevice", "ExpoNetwork", "ExpoSecureStore"];
|
|
72
|
+
|
|
73
|
+
const DEFAULT_LOADERS: ExpoModuleLoaders = {
|
|
74
|
+
core: () => require("expo-modules-core"),
|
|
75
|
+
application: () => require("expo-application"),
|
|
76
|
+
crypto: () => require("expo-crypto"),
|
|
77
|
+
device: () => require("expo-device"),
|
|
78
|
+
network: () => require("expo-network"),
|
|
79
|
+
secureStore: () => require("expo-secure-store"),
|
|
80
|
+
reactNative: () => require("react-native"),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Loads the Expo modules and react-native, or explains why it could not.
|
|
85
|
+
* Never throws: a missing package, a native module absent from the binary, or
|
|
86
|
+
* a wrapper that throws while loading all come back as `{ ok: false, reason }`.
|
|
87
|
+
*/
|
|
88
|
+
export function loadExpoModules(loaders: ExpoModuleLoaders = DEFAULT_LOADERS): LoadResult {
|
|
89
|
+
try {
|
|
90
|
+
const core = loaders.core() as { requireOptionalNativeModule(name: string): unknown };
|
|
91
|
+
const missing = NATIVE_MODULES.find((name) => core.requireOptionalNativeModule(name) == null);
|
|
92
|
+
if (missing) return { ok: false, reason: `native module ${missing} is not in this binary` };
|
|
93
|
+
const reactNative = loaders.reactNative() as { Platform: RocalyticsModules["platform"]; Dimensions: RocalyticsModules["dimensions"] };
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
modules: {
|
|
97
|
+
application: loaders.application() as RocalyticsModules["application"],
|
|
98
|
+
crypto: loaders.crypto() as RocalyticsModules["crypto"],
|
|
99
|
+
device: loaders.device() as RocalyticsModules["device"],
|
|
100
|
+
network: loaders.network() as RocalyticsModules["network"],
|
|
101
|
+
secureStore: loaders.secureStore() as RocalyticsModules["secureStore"],
|
|
102
|
+
platform: reactNative.Platform,
|
|
103
|
+
dimensions: reactNative.Dimensions,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
108
|
+
}
|
|
109
|
+
}
|