@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.
Files changed (161) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +449 -2
  4. package/client/package.json +1 -0
  5. package/core/package.json +4 -0
  6. package/dist/client/client.d.ts +146 -0
  7. package/dist/client/client.d.ts.map +1 -0
  8. package/dist/client/client.js +376 -0
  9. package/dist/client/client.js.map +1 -0
  10. package/dist/client/index.d.ts +13 -0
  11. package/dist/client/index.d.ts.map +1 -0
  12. package/dist/client/index.js +35 -0
  13. package/dist/client/index.js.map +1 -0
  14. package/dist/client/native.d.ts +68 -0
  15. package/dist/client/native.d.ts.map +1 -0
  16. package/dist/client/native.js +64 -0
  17. package/dist/client/native.js.map +1 -0
  18. package/dist/client/onboardingSink.d.ts +63 -0
  19. package/dist/client/onboardingSink.d.ts.map +1 -0
  20. package/dist/client/onboardingSink.js +126 -0
  21. package/dist/client/onboardingSink.js.map +1 -0
  22. package/dist/client/paywallSink.d.ts +13 -0
  23. package/dist/client/paywallSink.d.ts.map +1 -0
  24. package/dist/client/paywallSink.js +26 -0
  25. package/dist/client/paywallSink.js.map +1 -0
  26. package/dist/client/requests.d.ts +66 -0
  27. package/dist/client/requests.d.ts.map +1 -0
  28. package/dist/client/requests.js +90 -0
  29. package/dist/client/requests.js.map +1 -0
  30. package/dist/client/types.d.ts +180 -0
  31. package/dist/client/types.d.ts.map +1 -0
  32. package/dist/client/types.js +4 -0
  33. package/dist/client/types.js.map +1 -0
  34. package/dist/core/context.d.ts +39 -0
  35. package/dist/core/context.d.ts.map +1 -0
  36. package/dist/core/context.js +45 -0
  37. package/dist/core/context.js.map +1 -0
  38. package/dist/core/delivery.d.ts +63 -0
  39. package/dist/core/delivery.d.ts.map +1 -0
  40. package/dist/core/delivery.js +133 -0
  41. package/dist/core/delivery.js.map +1 -0
  42. package/dist/core/diagnostics.d.ts +16 -0
  43. package/dist/core/diagnostics.d.ts.map +1 -0
  44. package/dist/core/diagnostics.js +24 -0
  45. package/dist/core/diagnostics.js.map +1 -0
  46. package/dist/core/httpSink.d.ts +35 -0
  47. package/dist/core/httpSink.d.ts.map +1 -0
  48. package/dist/core/httpSink.js +65 -0
  49. package/dist/core/httpSink.js.map +1 -0
  50. package/dist/core/ids.d.ts +16 -0
  51. package/dist/core/ids.d.ts.map +1 -0
  52. package/dist/core/ids.js +51 -0
  53. package/dist/core/ids.js.map +1 -0
  54. package/dist/core/index.d.ts +17 -0
  55. package/dist/core/index.d.ts.map +1 -0
  56. package/dist/core/index.js +32 -0
  57. package/dist/core/index.js.map +1 -0
  58. package/dist/core/sink.d.ts +39 -0
  59. package/dist/core/sink.d.ts.map +1 -0
  60. package/dist/core/sink.js +16 -0
  61. package/dist/core/sink.js.map +1 -0
  62. package/dist/core/storage.d.ts +52 -0
  63. package/dist/core/storage.d.ts.map +1 -0
  64. package/dist/core/storage.js +79 -0
  65. package/dist/core/storage.js.map +1 -0
  66. package/dist/core/text.d.ts +9 -0
  67. package/dist/core/text.d.ts.map +1 -0
  68. package/dist/core/text.js +47 -0
  69. package/dist/core/text.js.map +1 -0
  70. package/dist/core/time.d.ts +18 -0
  71. package/dist/core/time.d.ts.map +1 -0
  72. package/dist/core/time.js +18 -0
  73. package/dist/core/time.js.map +1 -0
  74. package/dist/onboarding/contract.d.ts +138 -0
  75. package/dist/onboarding/contract.d.ts.map +1 -0
  76. package/dist/onboarding/contract.js +11 -0
  77. package/dist/onboarding/contract.js.map +1 -0
  78. package/dist/onboarding/index.d.ts +15 -0
  79. package/dist/onboarding/index.d.ts.map +1 -0
  80. package/dist/onboarding/index.js +22 -0
  81. package/dist/onboarding/index.js.map +1 -0
  82. package/dist/onboarding/input.d.ts +102 -0
  83. package/dist/onboarding/input.d.ts.map +1 -0
  84. package/dist/onboarding/input.js +216 -0
  85. package/dist/onboarding/input.js.map +1 -0
  86. package/dist/onboarding/persisted.d.ts +20 -0
  87. package/dist/onboarding/persisted.d.ts.map +1 -0
  88. package/dist/onboarding/persisted.js +75 -0
  89. package/dist/onboarding/persisted.js.map +1 -0
  90. package/dist/onboarding/state.d.ts +65 -0
  91. package/dist/onboarding/state.d.ts.map +1 -0
  92. package/dist/onboarding/state.js +207 -0
  93. package/dist/onboarding/state.js.map +1 -0
  94. package/dist/onboarding/tracker.d.ts +135 -0
  95. package/dist/onboarding/tracker.d.ts.map +1 -0
  96. package/dist/onboarding/tracker.js +734 -0
  97. package/dist/onboarding/tracker.js.map +1 -0
  98. package/dist/paywall/contract.d.ts +67 -0
  99. package/dist/paywall/contract.d.ts.map +1 -0
  100. package/dist/paywall/contract.js +3 -0
  101. package/dist/paywall/contract.js.map +1 -0
  102. package/dist/paywall/index.d.ts +14 -0
  103. package/dist/paywall/index.d.ts.map +1 -0
  104. package/dist/paywall/index.js +19 -0
  105. package/dist/paywall/index.js.map +1 -0
  106. package/dist/paywall/observer.d.ts +38 -0
  107. package/dist/paywall/observer.d.ts.map +1 -0
  108. package/dist/paywall/observer.js +6 -0
  109. package/dist/paywall/observer.js.map +1 -0
  110. package/dist/paywall/state.d.ts +26 -0
  111. package/dist/paywall/state.d.ts.map +1 -0
  112. package/dist/paywall/state.js +132 -0
  113. package/dist/paywall/state.js.map +1 -0
  114. package/dist/paywall/tracker.d.ts +36 -0
  115. package/dist/paywall/tracker.d.ts.map +1 -0
  116. package/dist/paywall/tracker.js +180 -0
  117. package/dist/paywall/tracker.js.map +1 -0
  118. package/dist/version.d.ts +3 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +6 -0
  121. package/dist/version.js.map +1 -0
  122. package/docs/onboarding-run-contract.md +1013 -0
  123. package/docs/onboarding-run.examples.ts +306 -0
  124. package/docs/onboarding-run.schema.json +224 -0
  125. package/docs/onboarding-run.types.ts +144 -0
  126. package/docs/paywall-presentation-contract.md +139 -0
  127. package/docs/paywall-presentation.examples.ts +42 -0
  128. package/docs/paywall-presentation.schema.json +268 -0
  129. package/docs/paywall-presentation.types.ts +73 -0
  130. package/onboarding/package.json +4 -0
  131. package/package.json +120 -4
  132. package/paywall/package.json +1 -0
  133. package/src/client/client.ts +417 -0
  134. package/src/client/index.ts +54 -0
  135. package/src/client/native.ts +109 -0
  136. package/src/client/onboardingSink.ts +121 -0
  137. package/src/client/paywallSink.ts +25 -0
  138. package/src/client/requests.ts +126 -0
  139. package/src/client/types.ts +198 -0
  140. package/src/core/context.ts +69 -0
  141. package/src/core/delivery.ts +181 -0
  142. package/src/core/diagnostics.ts +30 -0
  143. package/src/core/httpSink.ts +70 -0
  144. package/src/core/ids.ts +46 -0
  145. package/src/core/index.ts +20 -0
  146. package/src/core/sink.ts +46 -0
  147. package/src/core/storage.ts +112 -0
  148. package/src/core/text.ts +37 -0
  149. package/src/core/time.ts +26 -0
  150. package/src/onboarding/contract.ts +144 -0
  151. package/src/onboarding/index.ts +39 -0
  152. package/src/onboarding/input.ts +246 -0
  153. package/src/onboarding/persisted.ts +93 -0
  154. package/src/onboarding/state.ts +233 -0
  155. package/src/onboarding/tracker.ts +855 -0
  156. package/src/paywall/contract.ts +73 -0
  157. package/src/paywall/index.ts +36 -0
  158. package/src/paywall/observer.ts +43 -0
  159. package/src/paywall/state.ts +125 -0
  160. package/src/paywall/tracker.ts +200 -0
  161. package/src/version.ts +2 -0
@@ -0,0 +1,121 @@
1
+ import type { SinkResult, Sink } from "../core/sink";
2
+ import type { Answer, OnboardingRunSnapshot } from "../onboarding/contract";
3
+ import type { RocalyticsClient } from "./client";
4
+ import type { OnboardingMetadata, OnboardingResponsePayload, OnboardingStepAnswers } from "./types";
5
+
6
+ /**
7
+ * A v1 onboarding run snapshot in the pre-v1 `/onboarding-response` shape,
8
+ * which is the only onboarding payload the Rocalytics ingest reads. The keys
9
+ * follow docs/onboarding-run-contract.md, section 9, which is how the ingest
10
+ * reads such a payload back into a run.
11
+ *
12
+ * - Each entry is a response: `step_key` becomes `step_id`, timestamps are kept.
13
+ * - Answers become `{ [question_key]: value }`. A numeric answer's `unit` has
14
+ * no place in that shape and is dropped.
15
+ * - `onboarding_metadata` carries the keys section 9 reads: `onboardingId`
16
+ * (the Studio onboarding) or, with none, `onboarding_id` (the onboarding
17
+ * key, as a hand-coded sender sets it), `audienceId`, `deployment_id`,
18
+ * `locale` (from the run's context) and `draft: true` for a draft. It also
19
+ * carries `onboarding_key`, `onboarding_version`, `variant_key`, `run_id`
20
+ * and `seq`, which section 9 does not read.
21
+ * - `sent_at` is NOT the device's send time. The ingest keeps one snapshot
22
+ * per sender and replaces it only when the incoming `sent_at` is strictly
23
+ * later, answering 2xx either way, so two sends in one millisecond, or a
24
+ * device clock stepping back, would drop a snapshot (the completion
25
+ * included) while it reads as delivered. So `sent_at` is the run's latest
26
+ * recorded timestamp plus `seq` milliseconds. A snapshot only ever adds
27
+ * timestamps to the one before it and `seq` rises with every send, so this
28
+ * strictly increases across a run's sends whatever the device clock does.
29
+ * Only within a run: the ingest keeps one row per sender, so after the
30
+ * clock steps back between runs, a later run's snapshots can still be
31
+ * dropped while answered 2xx, as pre-v1 reporting already could.
32
+ * It depends on the snapshot alone, so a retry, even after a relaunch,
33
+ * resends the identical body.
34
+ */
35
+ export function toOnboardingResponsePayload(snapshot: OnboardingRunSnapshot): OnboardingResponsePayload {
36
+ const metadata: OnboardingMetadata = {};
37
+ const set = (key: string, value: unknown) => {
38
+ if (value !== undefined && value !== null) metadata[key] = value;
39
+ };
40
+ const studioOnboardingId = snapshot.studio?.onboarding_id;
41
+ if (studioOnboardingId != null) set("onboardingId", studioOnboardingId);
42
+ else set("onboarding_id", snapshot.onboarding.key);
43
+ set("audienceId", snapshot.studio?.audience_id);
44
+ set("deployment_id", snapshot.studio?.deployment_id);
45
+ set("locale", snapshot.context.locale);
46
+ if (snapshot.onboarding.version === "draft") set("draft", true);
47
+ set("onboarding_key", snapshot.onboarding.key);
48
+ set("onboarding_version", snapshot.onboarding.version);
49
+ set("variant_key", snapshot.onboarding.variant_key);
50
+ set("run_id", snapshot.run_id);
51
+ set("seq", snapshot.seq);
52
+ return {
53
+ onboarding_metadata: metadata,
54
+ sent_at: new Date(latestRecorded(snapshot) + snapshot.seq).toISOString(),
55
+ responses: snapshot.steps.map((entry) => ({
56
+ step_id: entry.step_key,
57
+ entered_at: entry.entered_at,
58
+ exited_at: entry.exited_at,
59
+ answers: toAnswers(entry.answers),
60
+ })),
61
+ };
62
+ }
63
+
64
+ /** The latest timestamp the run has recorded: its start, entries, exits and completion. Not `sent_at`. */
65
+ function latestRecorded(snapshot: OnboardingRunSnapshot): number {
66
+ let latest = Date.parse(snapshot.started_at);
67
+ const consider = (ts: string | null) => {
68
+ if (ts !== null) latest = Math.max(latest, Date.parse(ts));
69
+ };
70
+ for (const entry of snapshot.steps) {
71
+ consider(entry.entered_at);
72
+ consider(entry.exited_at);
73
+ }
74
+ consider(snapshot.completed_at);
75
+ return latest;
76
+ }
77
+
78
+ function toAnswers(answers: Answer[]): OnboardingStepAnswers {
79
+ const out: OnboardingStepAnswers = {};
80
+ for (const answer of answers) out[answer.question_key] = Array.isArray(answer.value) ? [...answer.value] : answer.value;
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * Reads an `/onboarding-response` answer as a sink outcome. The endpoint
86
+ * answers success with a 2xx and no body, so a 2xx is accepted. Otherwise,
87
+ * as the contract's section 5 requires, only a body that says
88
+ * `{"outcome": "rejected"}` is permanent: every other status, whatever its
89
+ * body, is transient and the snapshot is retried.
90
+ */
91
+ export function rocalyticsOutcome(status: number, body?: unknown): SinkResult {
92
+ if (status >= 200 && status < 300) return { outcome: "accepted" };
93
+ if (body && typeof body === "object" && (body as { outcome?: unknown }).outcome === "rejected") {
94
+ const reason = (body as { reason?: unknown }).reason;
95
+ return typeof reason === "string" ? { outcome: "rejected", reason } : { outcome: "rejected" };
96
+ }
97
+ return { outcome: "transient", reason: `status ${status}` };
98
+ }
99
+
100
+ /**
101
+ * A sink for the onboarding run tracker (`@rocapine/rocalytics-sdk/onboarding`)
102
+ * that delivers each snapshot to Rocalytics through `client`, as the pre-v1
103
+ * onboarding payload (see `toOnboardingResponsePayload`).
104
+ *
105
+ * Once a completed snapshot is accepted, the sink also sends the
106
+ * `onboarding_completed` event, which is where consumers of the pre-v1 data
107
+ * read completion from. It is deduplicated per device, like any `track`
108
+ * event; if it fails, the send is transient, so the tracker's retry sends it.
109
+ *
110
+ * The ingest keeps one onboarding snapshot per roca id, so a new run replaces
111
+ * the previous one there, and an app should report a flow through this sink
112
+ * or through `client.trackOnboarding`, not both.
113
+ *
114
+ * While the client is inert every send is transient: the tracker keeps the
115
+ * snapshot, persisted when it has storage, for a launch where the client works.
116
+ *
117
+ * Its `destination` is the client's `onboardingRunDestination`.
118
+ */
119
+ export function createRocalyticsOnboardingSink(client: RocalyticsClient): Sink<OnboardingRunSnapshot> {
120
+ return { destination: client.onboardingRunDestination, send: (snapshot) => client.sendOnboardingRun(snapshot) };
121
+ }
@@ -0,0 +1,25 @@
1
+ import type { Sink, SinkResult } from "../core/sink";
2
+ import type { PaywallPresentationSnapshot } from "../paywall/contract";
3
+ import type { RocalyticsClient } from "./client";
4
+
5
+ const OUTCOMES = new Set(["accepted", "ignored", "rejected"]);
6
+
7
+ /**
8
+ * Reads a `/paywall-presentations` answer per the paywall contract's section 5
9
+ * (onboarding D27): the body's `outcome` decides. A rejected body is permanent
10
+ * whatever the status. Accepted or ignored counts only on a 2xx. Anything
11
+ * else, including a 2xx without an outcome, is transient and retried.
12
+ */
13
+ export function paywallIngestOutcome(status: number, body?: unknown): SinkResult {
14
+ const o = body && typeof body === "object" ? (body as { outcome?: unknown; reason?: unknown }) : undefined;
15
+ const outcome = typeof o?.outcome === "string" && OUTCOMES.has(o.outcome) ? (o.outcome as "accepted" | "ignored" | "rejected") : undefined;
16
+ const reason = typeof o?.reason === "string" ? o.reason : undefined;
17
+ if (outcome === "rejected") return reason ? { outcome, reason } : { outcome };
18
+ if (outcome && status >= 200 && status < 300) return { outcome };
19
+ return { outcome: "transient", reason: `status ${status}` };
20
+ }
21
+
22
+ /** Delivers the paywall tracker's snapshots to Rocalytics. Its `destination` is the client's `paywallPresentationDestination`. */
23
+ export function createRocalyticsPaywallSink(client: RocalyticsClient): Sink<PaywallPresentationSnapshot> {
24
+ return { destination: client.paywallPresentationDestination, send: (s) => client.sendPaywallPresentation(s) };
25
+ }
@@ -0,0 +1,126 @@
1
+ import type { DemandScoreSignals, IdentifyParams, OnboardingResponsePayload } from "./types";
2
+ import type { PaywallPresentationSnapshot } from "../paywall/contract";
3
+
4
+ export const ROCALYTICS_API_BASE = "https://rocalytics-api.rocapine.io";
5
+
6
+ /** What every request carries: the device's roca id, the app id and the platform. There is no auth token. */
7
+ export interface RequestContext {
8
+ rocaId: string;
9
+ /** The app's bundle id or package name; sent as `unknown` when null. */
10
+ applicationId: string | null;
11
+ /** `Platform.OS`. */
12
+ platform: string;
13
+ /** Defaults to `ROCALYTICS_API_BASE`. */
14
+ baseUrl?: string;
15
+ }
16
+
17
+ /** A request, ready to hand to `fetch(url, init)`. */
18
+ export interface RocalyticsRequest {
19
+ url: string;
20
+ init: { method: "POST"; headers: Record<string, string>; body?: string };
21
+ }
22
+
23
+ export interface FetchResponseLike {
24
+ ok: boolean;
25
+ status: number;
26
+ json(): Promise<unknown>;
27
+ }
28
+
29
+ /** The subset of `fetch` the client uses. */
30
+ export type FetchLike = (url: string, init: RocalyticsRequest["init"]) => Promise<FetchResponseLike>;
31
+
32
+ export function rocalyticsHeaders(context: RequestContext): Record<string, string> {
33
+ return {
34
+ "Content-Type": "application/json",
35
+ "X-Roca-ID": context.rocaId,
36
+ "X-Application-ID": context.applicationId ?? "unknown",
37
+ "X-Platform": context.platform,
38
+ };
39
+ }
40
+
41
+ const post = (context: RequestContext, endpoint: string, body?: unknown): RocalyticsRequest => ({
42
+ url: `${context.baseUrl ?? ROCALYTICS_API_BASE}/functions/v1/${endpoint}`,
43
+ init: {
44
+ method: "POST",
45
+ headers: rocalyticsHeaders(context),
46
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
47
+ },
48
+ });
49
+
50
+ /** `/identify`. The payload is sent as given: drop null identifiers first (the client does). */
51
+ export function buildIdentifyRequest(context: RequestContext, payload: Partial<IdentifyParams>): RocalyticsRequest {
52
+ return post(context, "identify", payload);
53
+ }
54
+
55
+ export interface TrackRequestInput {
56
+ name: string;
57
+ properties: Record<string, unknown>;
58
+ deviceContext: Record<string, unknown> | null;
59
+ /** Defaults to `${rocaId}-${name}`. */
60
+ deduplicationId?: string;
61
+ /** A custom event is not stored as an analytics event: the API passes it on to drive automations. */
62
+ customEvent?: boolean;
63
+ }
64
+
65
+ /** `/track`. */
66
+ export function buildTrackRequest(context: RequestContext, input: TrackRequestInput): RocalyticsRequest {
67
+ return post(context, "track", {
68
+ name: input.name,
69
+ deduplication_id: input.deduplicationId ?? `${context.rocaId}-${input.name}`,
70
+ properties: input.properties,
71
+ device_context: input.deviceContext,
72
+ ...(input.customEvent ? { custom_event: true } : {}),
73
+ });
74
+ }
75
+
76
+ /** `/onboarding-response`. */
77
+ export function buildOnboardingResponseRequest(context: RequestContext, payload: OnboardingResponsePayload): RocalyticsRequest {
78
+ return post(context, "onboarding-response", payload);
79
+ }
80
+
81
+ /** `/demand-score`. The body is optional server-side, so none is sent without signals. */
82
+ export function buildDemandScoreRequest(context: RequestContext, signals?: DemandScoreSignals): RocalyticsRequest {
83
+ return post(context, "demand-score", signals && Object.keys(signals).length > 0 ? signals : undefined);
84
+ }
85
+
86
+ /**
87
+ * Sends a request and resolves its response, or rejects with
88
+ * `[ROCALYTICS] <endpoint> failed: <status>` (plus ` (<detail>)` when given)
89
+ * when the status is not 2xx.
90
+ */
91
+ export async function sendRocalyticsRequest(
92
+ fetch: FetchLike,
93
+ request: RocalyticsRequest,
94
+ endpoint: string,
95
+ detail?: string,
96
+ ): Promise<FetchResponseLike> {
97
+ const response = await fetch(request.url, request.init);
98
+ if (!response.ok) {
99
+ throw new Error(`[ROCALYTICS] ${endpoint} failed: ${response.status}${detail ? ` (${detail})` : ""}`);
100
+ }
101
+ return response;
102
+ }
103
+
104
+ /**
105
+ * The cross-network event id for an event, or `undefined` when its properties
106
+ * carry no transaction identifier: `${name}-${originalTransactionIdentifier}`.
107
+ *
108
+ * Use it as the `event_id` (Meta CAPI or Pixel, TikTok Events API) or
109
+ * `callback_id` (Adjust S2S) when the app also fires the conversion to those
110
+ * networks itself. Rocalytics derives the same id when it forwards the event,
111
+ * so the network deduplicates the two.
112
+ */
113
+ export function getEventId(name: string, properties?: Record<string, unknown> | null): string | undefined {
114
+ if (!properties) return undefined;
115
+ const txId =
116
+ (properties.original_transaction_identifier as string | undefined) ??
117
+ (properties.originalTransactionIdentifier as string | undefined) ??
118
+ (properties.transaction as { originalTransactionIdentifier?: string } | undefined)?.originalTransactionIdentifier;
119
+ if (!txId) return undefined;
120
+ return `${name}-${txId}`;
121
+ }
122
+
123
+ /** `/paywall-presentations` (I3): the body is the bare snapshot; identity is in the headers `post` sets. */
124
+ export function buildPaywallPresentationRequest(context: RequestContext, snapshot: PaywallPresentationSnapshot): RocalyticsRequest {
125
+ return post(context, "paywall-presentations", snapshot);
126
+ }
@@ -0,0 +1,198 @@
1
+ // Wire types of the Rocalytics API, as the client sends and reads them.
2
+
3
+ /**
4
+ * Event names `track` accepts. The union of every name the copied clients
5
+ * allowed; the API rejects a name it does not know. Any other name goes
6
+ * through `trackCustomEvent`, which the API forwards instead of storing.
7
+ */
8
+ export type TrackEventName = "install" | "onboarding_completed" | "purchase" | "subscription_started" | "trial_started";
9
+
10
+ /** Adjust attribution, as the Adjust SDK reports it. */
11
+ export type AdjustAttribution = {
12
+ trackerToken?: string | null;
13
+ trackerName?: string | null;
14
+ network?: string | null;
15
+ campaign?: string | null;
16
+ adgroup?: string | null;
17
+ creative?: string | null;
18
+ clickLabel?: string | null;
19
+ adid?: string | null;
20
+ costType?: string | null;
21
+ costAmount?: number | null;
22
+ costCurrency?: string | null;
23
+ fbInstallReferrer?: string | null;
24
+ };
25
+
26
+ /** Identifiers for `identify`. Null and undefined values are dropped before sending. */
27
+ export type IdentifyParams = {
28
+ revenue_cat_id?: string | null;
29
+ qonversion_id?: string | null;
30
+ adjust_id?: string | null;
31
+ adjust_attribution?: AdjustAttribution | null;
32
+ user_id?: string | null;
33
+ email?: string | null;
34
+ amplitude_device_id?: string | null;
35
+ idfa?: string | null;
36
+ idfv?: string | null;
37
+ android_id?: string | null;
38
+ customerio_id?: string | null;
39
+ segment_id?: string | null;
40
+ gaid?: string | null;
41
+ /** BCP 47 tag of the language the app is shown in. */
42
+ locale?: string | null;
43
+ };
44
+
45
+ /**
46
+ * A store product, from any purchase SDK. Forwarded whole under
47
+ * `experimental.product`; its `productIdentifier` is the product id when
48
+ * `productId` is not given.
49
+ */
50
+ export type PurchaseProduct = object;
51
+
52
+ /** A store transaction, from any purchase SDK. Forwarded whole under `experimental.transaction`. */
53
+ export type PurchaseTransaction = object;
54
+
55
+ export type TrackPurchaseParams = {
56
+ isTrial: boolean;
57
+ /** The price charged: 0 for a free trial. */
58
+ value: number;
59
+ /** ISO 4217 code. */
60
+ currency: string;
61
+ /** Links renewals to the first purchase, and keys the event's deduplication id. */
62
+ originalTransactionIdentifier: string;
63
+ /** The store product id. Defaults to `product.productIdentifier`. */
64
+ productId?: string;
65
+ product?: PurchaseProduct;
66
+ transaction?: PurchaseTransaction;
67
+ /** A web-checkout redemption result, for a purchase made outside the store. Forwarded under `experimental.redemption_result`. */
68
+ redemptionResult?: unknown;
69
+ };
70
+
71
+ /** Answers given on one onboarding step, by question or element id. */
72
+ export type OnboardingStepAnswers = Record<string, unknown>;
73
+
74
+ /** Free-form context about which onboarding was shown, e.g. `onboarding_id`, `audience_id`, `deployment_id`. */
75
+ export type OnboardingMetadata = Record<string, unknown>;
76
+
77
+ export type OnboardingStepResponse = {
78
+ step_id: string;
79
+ entered_at: string;
80
+ /** Null while the user is still on the step. */
81
+ exited_at: string | null;
82
+ answers: OnboardingStepAnswers;
83
+ };
84
+
85
+ /** The body of `/onboarding-response`: every step seen so far, resent whole on each call. */
86
+ export type OnboardingResponsePayload = {
87
+ onboarding_metadata?: OnboardingMetadata;
88
+ /** When the snapshot was produced. The API ignores a snapshot older than the one it has. */
89
+ sent_at: string;
90
+ responses: OnboardingStepResponse[];
91
+ };
92
+
93
+ /** Device facts sent with every tracked event. */
94
+ export type DeviceContext = {
95
+ ip: string | null;
96
+ user_agent: string;
97
+ device_model: string | null;
98
+ device_brand: string | null;
99
+ device_manufacturer: string | null;
100
+ os_name: string | null;
101
+ os_version: string | null;
102
+ screen_width: number;
103
+ screen_height: number;
104
+ screen_scale: number;
105
+ timezone: string;
106
+ locale: string;
107
+ app_version: string | null;
108
+ app_build: string | null;
109
+ };
110
+
111
+ /**
112
+ * Optional client-side signals for the demand score. Anything omitted falls
113
+ * back server-side or drops out of the score, so send only what the app has;
114
+ * never invent a value.
115
+ */
116
+ export type DemandScoreSignals = {
117
+ /** Native model identifier, e.g. `iPhone15,3`. */
118
+ device_model?: string;
119
+ /** Hardware release year. */
120
+ device_release_year?: number;
121
+ /** OS version, e.g. `17.4`. Only the major number is read. */
122
+ os_version?: string;
123
+ connection_type?: "wifi" | "cellular" | "other";
124
+ /** Two-letter storefront country, e.g. `US`. */
125
+ store_country?: string;
126
+ app_open_count?: number;
127
+ /** Send 0 only for a real zero: the server reads its own 0 as "not tracked". */
128
+ paywall_view_count?: number;
129
+ /** BCP 47 locale, e.g. `en-US`. */
130
+ locale?: string;
131
+ };
132
+
133
+ export type DemandScoreSignalScore = {
134
+ signal: string;
135
+ /** Normalized 0 to 1 sub-score; null when the signal was unavailable. */
136
+ value: number | null;
137
+ weight: number;
138
+ /** Contribution to the final score after weight renormalization. */
139
+ weighted: number;
140
+ };
141
+
142
+ /** Scoring models the endpoint computes. */
143
+ export type DemandScoreVersion = "v1" | "v2";
144
+
145
+ /** v1: a weighted-signal model over behavioural history and device context. */
146
+ export type DemandScoreV1 = {
147
+ /** Integer 1 to 100. */
148
+ score: number;
149
+ /** True when the identity has no event history yet. */
150
+ coldStart: boolean;
151
+ signals: DemandScoreSignalScore[];
152
+ };
153
+
154
+ export type DemandScoreV2Signal = {
155
+ signal: string;
156
+ /** The level looked up, after normalization. */
157
+ level: string;
158
+ /** Smoothed conversion rate for that level. */
159
+ rate: number;
160
+ /** Contribution to the total, in log-odds. */
161
+ logOdds: number;
162
+ /** False when the level was absent from the rates table and the fallback was used. */
163
+ matched: boolean;
164
+ };
165
+
166
+ /** v2: a learned-rates scorecard, calibrated to a percentile. */
167
+ export type DemandScoreV2 = {
168
+ /** Integer 1 to 100. */
169
+ score: number;
170
+ /** Raw additive score before calibration. */
171
+ logOdds: number;
172
+ /** How many scorecard fields resolved to a known level. */
173
+ matchedSignals: number;
174
+ /** `app` when scored with this app's own rates, `portfolio` when it fell back to the pooled table. */
175
+ ratesSource: "app" | "portfolio";
176
+ signals: DemandScoreV2Signal[];
177
+ };
178
+
179
+ /**
180
+ * The demand score response. `selectedVersion` and `versions` are optional,
181
+ * and `signals` is kept, so a response from before the score was versioned
182
+ * still fits.
183
+ */
184
+ export type DemandScoreResult = {
185
+ /** Integer 1 to 100: the likelihood this install converts to a paying customer. Mirrors `versions[selectedVersion].score`. */
186
+ demandScore: number;
187
+ /** Which version `demandScore` mirrors: a server-side setting. */
188
+ selectedVersion?: DemandScoreVersion;
189
+ /** True when the identity has no event history yet. */
190
+ coldStart: boolean;
191
+ versions?: {
192
+ v1: DemandScoreV1;
193
+ /** Absent when no rates table exists for the app or the portfolio. */
194
+ v2?: DemandScoreV2;
195
+ };
196
+ /** The unversioned response's breakdown. */
197
+ signals?: DemandScoreSignalScore[];
198
+ };
@@ -0,0 +1,69 @@
1
+ import { codePointLength } from "./text";
2
+
3
+ /**
4
+ * Device and app facts for a run, supplied by the host (the core reads no
5
+ * platform API). Captured once when a run starts.
6
+ */
7
+ export interface RunContextInput {
8
+ /** Marketing version, e.g. `2.4.0`. */
9
+ appVersion: string;
10
+ /** Build number, or null on a platform with none. */
11
+ build: string | null;
12
+ platform: "ios" | "android" | "web";
13
+ /** OS version, or null when the platform cannot report one. */
14
+ osVersion: string | null;
15
+ /** BCP 47 tag of the language the flow is shown in, e.g. `fr-FR`. */
16
+ locale: string;
17
+ /** IANA time zone, e.g. `Europe/Paris`. */
18
+ timezone: string;
19
+ }
20
+
21
+ /** The context in its wire form. Country is deliberately absent: the server derives it. */
22
+ export interface WireRunContext {
23
+ app_version: string;
24
+ build: string | null;
25
+ platform: "ios" | "android" | "web";
26
+ os_version: string | null;
27
+ locale: string;
28
+ timezone: string;
29
+ library_version?: string;
30
+ }
31
+
32
+ const LOCALE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
33
+ const TIMEZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
34
+ const shortText = (v: unknown) => typeof v === "string" && codePointLength(v) >= 1 && codePointLength(v) <= 32;
35
+
36
+ /**
37
+ * Maps the host's context to its wire form, copying only the known fields.
38
+ * Returns the list of problems instead when a field would fail the schema.
39
+ */
40
+ export function captureRunContext(
41
+ input: RunContextInput,
42
+ libraryVersion: string,
43
+ ): { ok: true; context: WireRunContext } | { ok: false; errors: string[] } {
44
+ const errors: string[] = [];
45
+ if (!input || typeof input !== "object") return { ok: false, errors: ["context is not an object"] };
46
+ if (!shortText(input.appVersion)) errors.push("appVersion must be 1 to 32 characters");
47
+ if (input.build !== null && !shortText(input.build)) errors.push("build must be null or 1 to 32 characters");
48
+ if (input.platform !== "ios" && input.platform !== "android" && input.platform !== "web") {
49
+ errors.push("platform must be ios, android or web");
50
+ }
51
+ if (input.osVersion !== null && !shortText(input.osVersion)) errors.push("osVersion must be null or 1 to 32 characters");
52
+ if (typeof input.locale !== "string" || !LOCALE.test(input.locale)) errors.push("locale must be a BCP 47 tag");
53
+ if (typeof input.timezone !== "string" || input.timezone.length > 64 || !TIMEZONE.test(input.timezone)) {
54
+ errors.push("timezone must be an IANA name of at most 64 characters");
55
+ }
56
+ if (errors.length) return { ok: false, errors };
57
+ return {
58
+ ok: true,
59
+ context: {
60
+ app_version: input.appVersion,
61
+ build: input.build,
62
+ platform: input.platform,
63
+ os_version: input.osVersion,
64
+ locale: input.locale,
65
+ timezone: input.timezone,
66
+ library_version: libraryVersion,
67
+ },
68
+ };
69
+ }