@unifold/analytics 0.1.72

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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Well-known event names.
3
+ *
4
+ * Exported as a const object (not an enum) so it tree-shakes cleanly —
5
+ * consumers get autocomplete without pulling in a runtime object they
6
+ * don't use. The `AnalyticsEventName` type still accepts arbitrary
7
+ * strings so new events don't require an SDK update.
8
+ */
9
+ declare const AnalyticsEvents: {
10
+ /** User viewed a screen / step in the flow. */
11
+ readonly SCREEN_VIEWED: "screen_viewed";
12
+ /** User selected a payment method. */
13
+ readonly PAYMENT_METHOD_SELECTED: "payment_method_selected";
14
+ /**
15
+ * User picked an asset — the token *and* the chain it lives on. One event
16
+ * for both, carrying the full identity, because neither half identifies an
17
+ * asset alone; either dropdown changing re-fires it.
18
+ */
19
+ readonly TOKEN_SELECTED: "token_selected";
20
+ /** User picked a browser wallet to connect. */
21
+ readonly WALLET_SELECTED: "wallet_selected";
22
+ /** User picked an onramp/exchange provider (card quote, bank, exchange). */
23
+ readonly PROVIDER_SELECTED: "provider_selected";
24
+ /**
25
+ * User picked what to pay with inside a provider's flow — a card, or a
26
+ * wallet like Apple Pay. Distinct from `payment_method_selected`, which is
27
+ * the funding route they chose from the deposit menu.
28
+ */
29
+ readonly PAYMENT_METHOD_TYPE_SELECTED: "payment_method_type_selected";
30
+ /**
31
+ * User reached a step that asks them to prove something about themselves —
32
+ * identity details, a document, an emailed or texted code, an exchange MFA
33
+ * prompt. `verification_type` says which.
34
+ */
35
+ readonly VERIFICATION_STARTED: "verification_started";
36
+ /** User handed over what the step asked for. A provider may still be reviewing. */
37
+ readonly VERIFICATION_SUBMITTED: "verification_submitted";
38
+ /** The provider accepted it. */
39
+ readonly VERIFICATION_COMPLETED: "verification_completed";
40
+ /** The provider rejected it, or the user backed out. See `failure_reason`. */
41
+ readonly VERIFICATION_FAILED: "verification_failed";
42
+ /**
43
+ * User began linking an account they hold somewhere else — an exchange, a
44
+ * Stripe Link profile. Distinct from connecting a browser wallet, which the
45
+ * user already controls locally.
46
+ */
47
+ readonly ACCOUNT_CONNECTION_STARTED: "account_connection_started";
48
+ /** The external account is linked and usable. */
49
+ readonly ACCOUNT_CONNECTED: "account_connected";
50
+ /** Linking the external account failed or was abandoned. */
51
+ readonly ACCOUNT_CONNECTION_FAILED: "account_connection_failed";
52
+ /** User began connecting a browser wallet. */
53
+ readonly WALLET_CONNECTION_STARTED: "wallet_connection_started";
54
+ /** The wallet is connected and usable. */
55
+ readonly WALLET_CONNECTED: "wallet_connected";
56
+ /** The connection failed, or the user declined it in their wallet. */
57
+ readonly WALLET_CONNECTION_FAILED: "wallet_connection_failed";
58
+ /** User navigated back a step. */
59
+ readonly BACK_CLICKED: "back_clicked";
60
+ /** User started a flow (deposit, checkout, withdraw). */
61
+ readonly FLOW_STARTED: "flow_started";
62
+ /** User completed a flow successfully. */
63
+ readonly FLOW_COMPLETED: "flow_completed";
64
+ /** A flow failed with an error. */
65
+ readonly FLOW_FAILED: "flow_failed";
66
+ /** Generic UI interaction (button press, link tap). */
67
+ readonly BUTTON_CLICKED: "button_clicked";
68
+ /** Widget/modal opened. */
69
+ readonly WIDGET_OPENED: "widget_opened";
70
+ /** Widget/modal closed. */
71
+ readonly WIDGET_CLOSED: "widget_closed";
72
+ };
73
+ /** Any known event name, or an arbitrary string for forward-compat. */
74
+ type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents] | (string & Record<never, never>);
75
+ /** Flat event-specific properties. Keep flat for easy PostHog filtering. */
76
+ interface AnalyticsProperties {
77
+ /** Payment method (transfer_crypto, card, apple_pay, exchange_connect, etc.) */
78
+ method?: string;
79
+ /** Flow type (deposit, checkout, withdraw). */
80
+ flow_type?: string;
81
+ /** Screen / step name (method_selector, amount_entry, review, processing, success). */
82
+ screen?: string;
83
+ /** Numeric step in the funnel (1-indexed). */
84
+ step?: number;
85
+ /** Chain id (e.g. "mainnet", "8453"). */
86
+ chain?: string;
87
+ /**
88
+ * Slugged network name, paired with `chain` (e.g. "base", "hypercore",
89
+ * "base_sepolia"). Grouping key for charts; `chain` stays the exact id.
90
+ */
91
+ network?: string;
92
+ /** Chain family (ethereum, solana, …). */
93
+ chain_type?: string;
94
+ /** Token contract address. */
95
+ token_address?: string;
96
+ /** Browser wallet identifier/name (metamask, phantom, …). */
97
+ wallet?: string;
98
+ /** Transaction amount in USD. */
99
+ amount_usd?: string;
100
+ /** Human-readable token amount (standard unit, e.g. "0.5"). */
101
+ amount?: string;
102
+ /** Token amount in base units (smallest denomination). */
103
+ amount_base_unit?: string;
104
+ /** UI element identifier for button_clicked events. */
105
+ element?: string;
106
+ /** Error code for flow_failed events. */
107
+ error_code?: string;
108
+ /** Onramp/exchange provider identifier. */
109
+ provider?: string;
110
+ /**
111
+ * What the user is paying with: `card`, `apple_pay`, `google_pay`. The brand
112
+ * and the last four digits stay out — which wallet, or none, is the part
113
+ * that moves conversion.
114
+ */
115
+ payment_method_type?: string;
116
+ /** Token symbol as displayed (e.g. "USDC", "USDC.e", "USDC (Perp)"). */
117
+ token?: string;
118
+ /**
119
+ * Slugged token symbol, paired with `token` (e.g. "usdc", "usdc_e",
120
+ * "usdc_perp"). Grouping key for charts; `token` stays the display symbol.
121
+ */
122
+ currency?: string;
123
+ /**
124
+ * What a `verification_*` event is about: `identity`, `document`, `email`,
125
+ * `phone`, `mfa`, `limit_upgrade`.
126
+ */
127
+ verification_type?: string;
128
+ /**
129
+ * Provider's own name for the level being verified, when it has tiers —
130
+ * Stripe's `l0` / `l1` / `l2`. Telemetry only.
131
+ */
132
+ verification_tier?: string;
133
+ /**
134
+ * Provider's raw status verbatim (`pending`, `rejected`, `resubmit`, …). The
135
+ * event name says what happened in our terms; this says what the provider
136
+ * called it, which is what support tickets quote.
137
+ *
138
+ * Telemetry only, like every other `verification_*` property: the host is
139
+ * told a check didn't pass and nothing more.
140
+ */
141
+ verification_status?: string;
142
+ /** Screen navigated away from (back_clicked). */
143
+ from_screen?: string;
144
+ /**
145
+ * Machine-readable failure reason.
146
+ *
147
+ * Telemetry only for verification and account connections, where the reason
148
+ * names what didn't check out about the user. Wallet connections do forward
149
+ * theirs — declining a prompt is the user's own visible action.
150
+ */
151
+ failure_reason?: string;
152
+ /** Catch-all for forward-compat. */
153
+ [key: string]: unknown;
154
+ }
155
+ /** Auto-populated SDK/device metadata. Not business data. */
156
+ interface AnalyticsContext {
157
+ library: {
158
+ name: string;
159
+ version: string;
160
+ };
161
+ platform?: string;
162
+ }
163
+ /** Full event payload sent to the telemetry backend. */
164
+ interface AnalyticsEvent {
165
+ event: AnalyticsEventName;
166
+ timestamp: string;
167
+ /**
168
+ * Analytics journey id (`asess_<ksuid>`): one per modal open → close.
169
+ * Present only while a journey is active (after {@link EventTracker.startSession},
170
+ * before {@link EventTracker.endSession}). Serialized as `session_id`.
171
+ */
172
+ sessionId?: string;
173
+ /**
174
+ * Internal Unifold user.id (`user_<ksuid>`), typically obtained from the
175
+ * deposit-addresses response. Serialized to the wire as `user_id`.
176
+ */
177
+ userId?: string;
178
+ properties: AnalyticsProperties;
179
+ context: AnalyticsContext;
180
+ }
181
+ interface AnalyticsConfig {
182
+ /** Full telemetry ingest URL. Must be provided by the host (e.g. connect-react reads it from @unifold/core). */
183
+ endpoint?: string;
184
+ /** Publishable key (pk_...) used for auth and project identification. */
185
+ publishableKey: string;
186
+ /** SDK version string (e.g. "0.1.70"). Auto-populated from package version if omitted. */
187
+ sdkVersion?: string;
188
+ /** Platform identifier (e.g. "web", "ios", "react-native"). */
189
+ platform?: string;
190
+ /**
191
+ * Internal Unifold user.id. Optional at construction — usually set later via
192
+ * {@link EventTracker.setUserId} once deposit-addresses returns.
193
+ */
194
+ userId?: string;
195
+ /** Disable all telemetry. Events are still emitted locally but never sent. */
196
+ disabled?: boolean;
197
+ /** Enable debug logging to console. */
198
+ debug?: boolean;
199
+ /**
200
+ * Dedup window in milliseconds. If the same event name + properties
201
+ * fires again within this window, the duplicate is dropped.
202
+ * Prevents noise from React re-renders. Defaults to 500ms.
203
+ * Set to 0 to disable dedup.
204
+ */
205
+ dedupMs?: number;
206
+ }
207
+
208
+ /**
209
+ * Lightweight analytics tracker for the Unifold SDK.
210
+ *
211
+ * Follows the Segment Track spec: event name + flat `properties` bag +
212
+ * auto-populated `context` for SDK metadata.
213
+ *
214
+ * Includes built-in dedup to prevent noise from React re-renders —
215
+ * identical events within the dedup window are silently dropped.
216
+ *
217
+ * ```ts
218
+ * const tracker = createEventTracker({ projectId: 'proj_123', platform: 'web' });
219
+ *
220
+ * // Funnel tracking
221
+ * tracker.track('screen_viewed', { screen: 'method_selector', flow_type: 'deposit', step: 1 });
222
+ * tracker.track('payment_method_selected', { method: 'transfer_crypto', flow_type: 'deposit' });
223
+ * tracker.track('screen_viewed', { screen: 'amount_entry', flow_type: 'deposit', step: 2 });
224
+ * tracker.track('flow_completed', { method: 'transfer_crypto', flow_type: 'deposit', amount_usd: '100' });
225
+ * ```
226
+ */
227
+ declare class EventTracker {
228
+ private emitter;
229
+ private config;
230
+ private context;
231
+ private recentEvents;
232
+ private unsubForwarder;
233
+ private userId;
234
+ /** Set only while a journey is active (modal open → close). */
235
+ private sessionId;
236
+ constructor(config: AnalyticsConfig);
237
+ private attachForwarder;
238
+ private detachForwarder;
239
+ /**
240
+ * Track an event. Properties are flat key-value pairs that become
241
+ * directly filterable dimensions in PostHog.
242
+ *
243
+ * Duplicate calls with the same event name + properties within the
244
+ * dedup window are silently dropped.
245
+ */
246
+ track(eventName: AnalyticsEventName, properties?: AnalyticsProperties): void;
247
+ /**
248
+ * Associate subsequent events with an internal Unifold user.id
249
+ * (`user_<ksuid>`). Pass `null` to clear. Usually called once the
250
+ * deposit-addresses response returns the id.
251
+ */
252
+ setUserId(userId: string | null): void;
253
+ /**
254
+ * Start a new analytics journey (modal open). Mints an `asess_<ksuid>` and
255
+ * returns it. Every {@link EventTracker.track} call until
256
+ * {@link EventTracker.endSession} carries it.
257
+ *
258
+ * Does not touch `userId`: the internal `user_<ksuid>` is attached separately
259
+ * via {@link EventTracker.setUserId} once the deposit-addresses response
260
+ * returns, and persists until the host replaces it.
261
+ */
262
+ startSession(): string;
263
+ /**
264
+ * End the current journey (modal close). Clears the session id so any stray
265
+ * events after close are session-less. Call this *after* the terminal
266
+ * `widget_closed` track call.
267
+ */
268
+ endSession(): void;
269
+ /** Current analytics journey id, or `undefined` when no journey is active. */
270
+ getSessionId(): string | undefined;
271
+ /**
272
+ * Subscribe to a specific event or `'*'` for all events.
273
+ * Returns an unsubscribe function.
274
+ */
275
+ on(eventName: AnalyticsEventName | '*', handler: (event: AnalyticsEvent) => void): () => void;
276
+ /** Remove all listeners and stop forwarding. */
277
+ destroy(): void;
278
+ /** Enable or disable network forwarding at runtime. Custom listeners are preserved. */
279
+ setDisabled(disabled: boolean): void;
280
+ }
281
+ /** Create a new EventTracker instance. */
282
+ declare function createEventTracker(config: AnalyticsConfig): EventTracker;
283
+
284
+ type EventHandler<T> = (event: T) => void;
285
+ /**
286
+ * Minimal typed event emitter with wildcard support.
287
+ *
288
+ * - `on()` returns an unsubscribe function.
289
+ * - `'*'` subscribes to every event type.
290
+ * - Handler exceptions are caught so a faulty listener never breaks the host app.
291
+ */
292
+ declare class Emitter<EventMap extends Record<string, unknown>> {
293
+ private handlers;
294
+ on<K extends keyof EventMap>(type: K, handler: EventHandler<EventMap[K]>): () => void;
295
+ on(type: '*', handler: EventHandler<EventMap[keyof EventMap]>): () => void;
296
+ emit<K extends keyof EventMap>(type: K, event: EventMap[K]): void;
297
+ removeAllListeners(): void;
298
+ }
299
+
300
+ /**
301
+ * Fire-and-forget POST of an analytics event.
302
+ *
303
+ * - Sends the publishable key via `x-publishable-key` header for auth.
304
+ * - Uses `keepalive: true` so the request completes even during page unload.
305
+ * - Never throws — failures are silently swallowed (optionally logged in debug mode).
306
+ */
307
+ declare function forwardEvent(event: AnalyticsEvent, publishableKey: string, endpoint?: string, debug?: boolean): void;
308
+
309
+ /**
310
+ * Dependency-free KSUID generator.
311
+ *
312
+ * Kept in sync with `@unifold/core`'s implementation (packages/core/src/lib/utils.ts)
313
+ * but duplicated intentionally: `@unifold/analytics` is a zero-dependency leaf
314
+ * package and must not depend on `@unifold/core`.
315
+ */
316
+ /**
317
+ * Generate a KSUID-like ID without external dependencies.
318
+ * Format: `base62(4-byte-timestamp + 16-random-bytes)` (27 chars).
319
+ */
320
+ declare function generateKSUID(): string;
321
+ /**
322
+ * Generate a KSUID-like prefixed ID without external dependencies.
323
+ * Format: `{prefix}_{base62(...)}`.
324
+ */
325
+ declare function generatePrefixedKSUID(prefix: string): string;
326
+
327
+ declare const PACKAGE_VERSION = "0.1.72";
328
+
329
+ export { type AnalyticsConfig, type AnalyticsContext, type AnalyticsEvent, type AnalyticsEventName, AnalyticsEvents, type AnalyticsProperties, Emitter, EventTracker, PACKAGE_VERSION, createEventTracker, forwardEvent, generateKSUID, generatePrefixedKSUID };
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Well-known event names.
3
+ *
4
+ * Exported as a const object (not an enum) so it tree-shakes cleanly —
5
+ * consumers get autocomplete without pulling in a runtime object they
6
+ * don't use. The `AnalyticsEventName` type still accepts arbitrary
7
+ * strings so new events don't require an SDK update.
8
+ */
9
+ declare const AnalyticsEvents: {
10
+ /** User viewed a screen / step in the flow. */
11
+ readonly SCREEN_VIEWED: "screen_viewed";
12
+ /** User selected a payment method. */
13
+ readonly PAYMENT_METHOD_SELECTED: "payment_method_selected";
14
+ /**
15
+ * User picked an asset — the token *and* the chain it lives on. One event
16
+ * for both, carrying the full identity, because neither half identifies an
17
+ * asset alone; either dropdown changing re-fires it.
18
+ */
19
+ readonly TOKEN_SELECTED: "token_selected";
20
+ /** User picked a browser wallet to connect. */
21
+ readonly WALLET_SELECTED: "wallet_selected";
22
+ /** User picked an onramp/exchange provider (card quote, bank, exchange). */
23
+ readonly PROVIDER_SELECTED: "provider_selected";
24
+ /**
25
+ * User picked what to pay with inside a provider's flow — a card, or a
26
+ * wallet like Apple Pay. Distinct from `payment_method_selected`, which is
27
+ * the funding route they chose from the deposit menu.
28
+ */
29
+ readonly PAYMENT_METHOD_TYPE_SELECTED: "payment_method_type_selected";
30
+ /**
31
+ * User reached a step that asks them to prove something about themselves —
32
+ * identity details, a document, an emailed or texted code, an exchange MFA
33
+ * prompt. `verification_type` says which.
34
+ */
35
+ readonly VERIFICATION_STARTED: "verification_started";
36
+ /** User handed over what the step asked for. A provider may still be reviewing. */
37
+ readonly VERIFICATION_SUBMITTED: "verification_submitted";
38
+ /** The provider accepted it. */
39
+ readonly VERIFICATION_COMPLETED: "verification_completed";
40
+ /** The provider rejected it, or the user backed out. See `failure_reason`. */
41
+ readonly VERIFICATION_FAILED: "verification_failed";
42
+ /**
43
+ * User began linking an account they hold somewhere else — an exchange, a
44
+ * Stripe Link profile. Distinct from connecting a browser wallet, which the
45
+ * user already controls locally.
46
+ */
47
+ readonly ACCOUNT_CONNECTION_STARTED: "account_connection_started";
48
+ /** The external account is linked and usable. */
49
+ readonly ACCOUNT_CONNECTED: "account_connected";
50
+ /** Linking the external account failed or was abandoned. */
51
+ readonly ACCOUNT_CONNECTION_FAILED: "account_connection_failed";
52
+ /** User began connecting a browser wallet. */
53
+ readonly WALLET_CONNECTION_STARTED: "wallet_connection_started";
54
+ /** The wallet is connected and usable. */
55
+ readonly WALLET_CONNECTED: "wallet_connected";
56
+ /** The connection failed, or the user declined it in their wallet. */
57
+ readonly WALLET_CONNECTION_FAILED: "wallet_connection_failed";
58
+ /** User navigated back a step. */
59
+ readonly BACK_CLICKED: "back_clicked";
60
+ /** User started a flow (deposit, checkout, withdraw). */
61
+ readonly FLOW_STARTED: "flow_started";
62
+ /** User completed a flow successfully. */
63
+ readonly FLOW_COMPLETED: "flow_completed";
64
+ /** A flow failed with an error. */
65
+ readonly FLOW_FAILED: "flow_failed";
66
+ /** Generic UI interaction (button press, link tap). */
67
+ readonly BUTTON_CLICKED: "button_clicked";
68
+ /** Widget/modal opened. */
69
+ readonly WIDGET_OPENED: "widget_opened";
70
+ /** Widget/modal closed. */
71
+ readonly WIDGET_CLOSED: "widget_closed";
72
+ };
73
+ /** Any known event name, or an arbitrary string for forward-compat. */
74
+ type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents] | (string & Record<never, never>);
75
+ /** Flat event-specific properties. Keep flat for easy PostHog filtering. */
76
+ interface AnalyticsProperties {
77
+ /** Payment method (transfer_crypto, card, apple_pay, exchange_connect, etc.) */
78
+ method?: string;
79
+ /** Flow type (deposit, checkout, withdraw). */
80
+ flow_type?: string;
81
+ /** Screen / step name (method_selector, amount_entry, review, processing, success). */
82
+ screen?: string;
83
+ /** Numeric step in the funnel (1-indexed). */
84
+ step?: number;
85
+ /** Chain id (e.g. "mainnet", "8453"). */
86
+ chain?: string;
87
+ /**
88
+ * Slugged network name, paired with `chain` (e.g. "base", "hypercore",
89
+ * "base_sepolia"). Grouping key for charts; `chain` stays the exact id.
90
+ */
91
+ network?: string;
92
+ /** Chain family (ethereum, solana, …). */
93
+ chain_type?: string;
94
+ /** Token contract address. */
95
+ token_address?: string;
96
+ /** Browser wallet identifier/name (metamask, phantom, …). */
97
+ wallet?: string;
98
+ /** Transaction amount in USD. */
99
+ amount_usd?: string;
100
+ /** Human-readable token amount (standard unit, e.g. "0.5"). */
101
+ amount?: string;
102
+ /** Token amount in base units (smallest denomination). */
103
+ amount_base_unit?: string;
104
+ /** UI element identifier for button_clicked events. */
105
+ element?: string;
106
+ /** Error code for flow_failed events. */
107
+ error_code?: string;
108
+ /** Onramp/exchange provider identifier. */
109
+ provider?: string;
110
+ /**
111
+ * What the user is paying with: `card`, `apple_pay`, `google_pay`. The brand
112
+ * and the last four digits stay out — which wallet, or none, is the part
113
+ * that moves conversion.
114
+ */
115
+ payment_method_type?: string;
116
+ /** Token symbol as displayed (e.g. "USDC", "USDC.e", "USDC (Perp)"). */
117
+ token?: string;
118
+ /**
119
+ * Slugged token symbol, paired with `token` (e.g. "usdc", "usdc_e",
120
+ * "usdc_perp"). Grouping key for charts; `token` stays the display symbol.
121
+ */
122
+ currency?: string;
123
+ /**
124
+ * What a `verification_*` event is about: `identity`, `document`, `email`,
125
+ * `phone`, `mfa`, `limit_upgrade`.
126
+ */
127
+ verification_type?: string;
128
+ /**
129
+ * Provider's own name for the level being verified, when it has tiers —
130
+ * Stripe's `l0` / `l1` / `l2`. Telemetry only.
131
+ */
132
+ verification_tier?: string;
133
+ /**
134
+ * Provider's raw status verbatim (`pending`, `rejected`, `resubmit`, …). The
135
+ * event name says what happened in our terms; this says what the provider
136
+ * called it, which is what support tickets quote.
137
+ *
138
+ * Telemetry only, like every other `verification_*` property: the host is
139
+ * told a check didn't pass and nothing more.
140
+ */
141
+ verification_status?: string;
142
+ /** Screen navigated away from (back_clicked). */
143
+ from_screen?: string;
144
+ /**
145
+ * Machine-readable failure reason.
146
+ *
147
+ * Telemetry only for verification and account connections, where the reason
148
+ * names what didn't check out about the user. Wallet connections do forward
149
+ * theirs — declining a prompt is the user's own visible action.
150
+ */
151
+ failure_reason?: string;
152
+ /** Catch-all for forward-compat. */
153
+ [key: string]: unknown;
154
+ }
155
+ /** Auto-populated SDK/device metadata. Not business data. */
156
+ interface AnalyticsContext {
157
+ library: {
158
+ name: string;
159
+ version: string;
160
+ };
161
+ platform?: string;
162
+ }
163
+ /** Full event payload sent to the telemetry backend. */
164
+ interface AnalyticsEvent {
165
+ event: AnalyticsEventName;
166
+ timestamp: string;
167
+ /**
168
+ * Analytics journey id (`asess_<ksuid>`): one per modal open → close.
169
+ * Present only while a journey is active (after {@link EventTracker.startSession},
170
+ * before {@link EventTracker.endSession}). Serialized as `session_id`.
171
+ */
172
+ sessionId?: string;
173
+ /**
174
+ * Internal Unifold user.id (`user_<ksuid>`), typically obtained from the
175
+ * deposit-addresses response. Serialized to the wire as `user_id`.
176
+ */
177
+ userId?: string;
178
+ properties: AnalyticsProperties;
179
+ context: AnalyticsContext;
180
+ }
181
+ interface AnalyticsConfig {
182
+ /** Full telemetry ingest URL. Must be provided by the host (e.g. connect-react reads it from @unifold/core). */
183
+ endpoint?: string;
184
+ /** Publishable key (pk_...) used for auth and project identification. */
185
+ publishableKey: string;
186
+ /** SDK version string (e.g. "0.1.70"). Auto-populated from package version if omitted. */
187
+ sdkVersion?: string;
188
+ /** Platform identifier (e.g. "web", "ios", "react-native"). */
189
+ platform?: string;
190
+ /**
191
+ * Internal Unifold user.id. Optional at construction — usually set later via
192
+ * {@link EventTracker.setUserId} once deposit-addresses returns.
193
+ */
194
+ userId?: string;
195
+ /** Disable all telemetry. Events are still emitted locally but never sent. */
196
+ disabled?: boolean;
197
+ /** Enable debug logging to console. */
198
+ debug?: boolean;
199
+ /**
200
+ * Dedup window in milliseconds. If the same event name + properties
201
+ * fires again within this window, the duplicate is dropped.
202
+ * Prevents noise from React re-renders. Defaults to 500ms.
203
+ * Set to 0 to disable dedup.
204
+ */
205
+ dedupMs?: number;
206
+ }
207
+
208
+ /**
209
+ * Lightweight analytics tracker for the Unifold SDK.
210
+ *
211
+ * Follows the Segment Track spec: event name + flat `properties` bag +
212
+ * auto-populated `context` for SDK metadata.
213
+ *
214
+ * Includes built-in dedup to prevent noise from React re-renders —
215
+ * identical events within the dedup window are silently dropped.
216
+ *
217
+ * ```ts
218
+ * const tracker = createEventTracker({ projectId: 'proj_123', platform: 'web' });
219
+ *
220
+ * // Funnel tracking
221
+ * tracker.track('screen_viewed', { screen: 'method_selector', flow_type: 'deposit', step: 1 });
222
+ * tracker.track('payment_method_selected', { method: 'transfer_crypto', flow_type: 'deposit' });
223
+ * tracker.track('screen_viewed', { screen: 'amount_entry', flow_type: 'deposit', step: 2 });
224
+ * tracker.track('flow_completed', { method: 'transfer_crypto', flow_type: 'deposit', amount_usd: '100' });
225
+ * ```
226
+ */
227
+ declare class EventTracker {
228
+ private emitter;
229
+ private config;
230
+ private context;
231
+ private recentEvents;
232
+ private unsubForwarder;
233
+ private userId;
234
+ /** Set only while a journey is active (modal open → close). */
235
+ private sessionId;
236
+ constructor(config: AnalyticsConfig);
237
+ private attachForwarder;
238
+ private detachForwarder;
239
+ /**
240
+ * Track an event. Properties are flat key-value pairs that become
241
+ * directly filterable dimensions in PostHog.
242
+ *
243
+ * Duplicate calls with the same event name + properties within the
244
+ * dedup window are silently dropped.
245
+ */
246
+ track(eventName: AnalyticsEventName, properties?: AnalyticsProperties): void;
247
+ /**
248
+ * Associate subsequent events with an internal Unifold user.id
249
+ * (`user_<ksuid>`). Pass `null` to clear. Usually called once the
250
+ * deposit-addresses response returns the id.
251
+ */
252
+ setUserId(userId: string | null): void;
253
+ /**
254
+ * Start a new analytics journey (modal open). Mints an `asess_<ksuid>` and
255
+ * returns it. Every {@link EventTracker.track} call until
256
+ * {@link EventTracker.endSession} carries it.
257
+ *
258
+ * Does not touch `userId`: the internal `user_<ksuid>` is attached separately
259
+ * via {@link EventTracker.setUserId} once the deposit-addresses response
260
+ * returns, and persists until the host replaces it.
261
+ */
262
+ startSession(): string;
263
+ /**
264
+ * End the current journey (modal close). Clears the session id so any stray
265
+ * events after close are session-less. Call this *after* the terminal
266
+ * `widget_closed` track call.
267
+ */
268
+ endSession(): void;
269
+ /** Current analytics journey id, or `undefined` when no journey is active. */
270
+ getSessionId(): string | undefined;
271
+ /**
272
+ * Subscribe to a specific event or `'*'` for all events.
273
+ * Returns an unsubscribe function.
274
+ */
275
+ on(eventName: AnalyticsEventName | '*', handler: (event: AnalyticsEvent) => void): () => void;
276
+ /** Remove all listeners and stop forwarding. */
277
+ destroy(): void;
278
+ /** Enable or disable network forwarding at runtime. Custom listeners are preserved. */
279
+ setDisabled(disabled: boolean): void;
280
+ }
281
+ /** Create a new EventTracker instance. */
282
+ declare function createEventTracker(config: AnalyticsConfig): EventTracker;
283
+
284
+ type EventHandler<T> = (event: T) => void;
285
+ /**
286
+ * Minimal typed event emitter with wildcard support.
287
+ *
288
+ * - `on()` returns an unsubscribe function.
289
+ * - `'*'` subscribes to every event type.
290
+ * - Handler exceptions are caught so a faulty listener never breaks the host app.
291
+ */
292
+ declare class Emitter<EventMap extends Record<string, unknown>> {
293
+ private handlers;
294
+ on<K extends keyof EventMap>(type: K, handler: EventHandler<EventMap[K]>): () => void;
295
+ on(type: '*', handler: EventHandler<EventMap[keyof EventMap]>): () => void;
296
+ emit<K extends keyof EventMap>(type: K, event: EventMap[K]): void;
297
+ removeAllListeners(): void;
298
+ }
299
+
300
+ /**
301
+ * Fire-and-forget POST of an analytics event.
302
+ *
303
+ * - Sends the publishable key via `x-publishable-key` header for auth.
304
+ * - Uses `keepalive: true` so the request completes even during page unload.
305
+ * - Never throws — failures are silently swallowed (optionally logged in debug mode).
306
+ */
307
+ declare function forwardEvent(event: AnalyticsEvent, publishableKey: string, endpoint?: string, debug?: boolean): void;
308
+
309
+ /**
310
+ * Dependency-free KSUID generator.
311
+ *
312
+ * Kept in sync with `@unifold/core`'s implementation (packages/core/src/lib/utils.ts)
313
+ * but duplicated intentionally: `@unifold/analytics` is a zero-dependency leaf
314
+ * package and must not depend on `@unifold/core`.
315
+ */
316
+ /**
317
+ * Generate a KSUID-like ID without external dependencies.
318
+ * Format: `base62(4-byte-timestamp + 16-random-bytes)` (27 chars).
319
+ */
320
+ declare function generateKSUID(): string;
321
+ /**
322
+ * Generate a KSUID-like prefixed ID without external dependencies.
323
+ * Format: `{prefix}_{base62(...)}`.
324
+ */
325
+ declare function generatePrefixedKSUID(prefix: string): string;
326
+
327
+ declare const PACKAGE_VERSION = "0.1.72";
328
+
329
+ export { type AnalyticsConfig, type AnalyticsContext, type AnalyticsEvent, type AnalyticsEventName, AnalyticsEvents, type AnalyticsProperties, Emitter, EventTracker, PACKAGE_VERSION, createEventTracker, forwardEvent, generateKSUID, generatePrefixedKSUID };
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ var __defProp = Object.defineProperty;
4
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
6
+
7
+ // src/emitter.ts
8
+ var Emitter = class {
9
+ constructor() {
10
+ __publicField(this, "handlers", /* @__PURE__ */ new Map());
11
+ }
12
+ on(type, handler) {
13
+ let set = this.handlers.get(type);
14
+ if (!set) {
15
+ set = /* @__PURE__ */ new Set();
16
+ this.handlers.set(type, set);
17
+ }
18
+ set.add(handler);
19
+ return () => {
20
+ set.delete(handler);
21
+ };
22
+ }
23
+ emit(type, event) {
24
+ const dispatch = (handler) => {
25
+ try {
26
+ handler(event);
27
+ } catch (err) {
28
+ console.error("[unifold/event-tracker] handler threw", err);
29
+ }
30
+ };
31
+ this.handlers.get(type)?.forEach(dispatch);
32
+ this.handlers.get("*")?.forEach(dispatch);
33
+ }
34
+ removeAllListeners() {
35
+ this.handlers.clear();
36
+ }
37
+ };
38
+
39
+ // src/forwarder.ts
40
+ var DEFAULT_ENDPOINT = "/v1/public/events";
41
+ function forwardEvent(event, publishableKey, endpoint = DEFAULT_ENDPOINT, debug = false) {
42
+ try {
43
+ if (typeof fetch === "undefined") {
44
+ if (debug) console.debug("[unifold/analytics] fetch unavailable, skipping forward");
45
+ return;
46
+ }
47
+ const { sessionId, userId, ...rest } = event;
48
+ const body = {
49
+ ...rest,
50
+ // Only attach session_id while a journey is active (modal open → close).
51
+ ...sessionId ? { session_id: sessionId } : {},
52
+ ...userId ? { user_id: userId } : {}
53
+ };
54
+ fetch(endpoint, {
55
+ method: "POST",
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ "x-publishable-key": publishableKey
59
+ },
60
+ body: JSON.stringify(body),
61
+ keepalive: true
62
+ }).catch((err) => {
63
+ if (debug) console.debug("[unifold/analytics] forward failed", err);
64
+ });
65
+ } catch (err) {
66
+ if (debug) console.debug("[unifold/analytics] forward error", err);
67
+ }
68
+ }
69
+
70
+ // src/ksuid.ts
71
+ function generateKSUID() {
72
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
73
+ const KSUID_EPOCH = 14e8;
74
+ const timestampSeconds = Math.floor(Date.now() / 1e3) - KSUID_EPOCH;
75
+ const payload = new Uint8Array(20);
76
+ payload[0] = timestampSeconds >>> 24 & 255;
77
+ payload[1] = timestampSeconds >>> 16 & 255;
78
+ payload[2] = timestampSeconds >>> 8 & 255;
79
+ payload[3] = timestampSeconds & 255;
80
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
81
+ crypto.getRandomValues(payload.subarray(4));
82
+ } else {
83
+ for (let i = 4; i < 20; i++) {
84
+ payload[i] = Math.floor(Math.random() * 256);
85
+ }
86
+ }
87
+ let value = 0n;
88
+ for (const byte of payload) {
89
+ value = value << 8n | BigInt(byte);
90
+ }
91
+ let encoded = "";
92
+ while (value > 0n) {
93
+ encoded = BASE62[Number(value % 62n)] + encoded;
94
+ value = value / 62n;
95
+ }
96
+ return encoded.padStart(27, "0");
97
+ }
98
+ function generatePrefixedKSUID(prefix) {
99
+ return `${prefix}_${generateKSUID()}`;
100
+ }
101
+
102
+ // src/version.ts
103
+ var PACKAGE_VERSION = "0.1.72";
104
+
105
+ // src/tracker.ts
106
+ var LIBRARY_NAME = "@unifold/analytics";
107
+ var DEFAULT_DEDUP_MS = 500;
108
+ var SESSION_PREFIX = "asess";
109
+ function dedupKey(sessionId, eventName, properties) {
110
+ const sorted = Object.keys(properties).sort().map((k) => `${k}=${String(properties[k])}`).join("&");
111
+ return `${sessionId ?? ""}|${eventName}|${sorted}`;
112
+ }
113
+ var EventTracker = class {
114
+ constructor(config) {
115
+ __publicField(this, "emitter", new Emitter());
116
+ __publicField(this, "config");
117
+ __publicField(this, "context");
118
+ __publicField(this, "recentEvents", /* @__PURE__ */ new Map());
119
+ __publicField(this, "unsubForwarder", null);
120
+ __publicField(this, "userId");
121
+ /** Set only while a journey is active (modal open → close). */
122
+ __publicField(this, "sessionId");
123
+ this.config = {
124
+ endpoint: config.endpoint ?? "/v1/public/events",
125
+ publishableKey: config.publishableKey,
126
+ sdkVersion: config.sdkVersion ?? PACKAGE_VERSION,
127
+ platform: config.platform ?? "web",
128
+ userId: config.userId ?? "",
129
+ disabled: config.disabled ?? false,
130
+ debug: config.debug ?? false,
131
+ dedupMs: config.dedupMs ?? DEFAULT_DEDUP_MS
132
+ };
133
+ this.userId = config.userId || void 0;
134
+ this.context = {
135
+ library: {
136
+ name: LIBRARY_NAME,
137
+ version: this.config.sdkVersion
138
+ },
139
+ platform: this.config.platform
140
+ };
141
+ if (!this.config.disabled) {
142
+ this.attachForwarder();
143
+ }
144
+ }
145
+ attachForwarder() {
146
+ this.unsubForwarder = this.emitter.on("*", (event) => {
147
+ forwardEvent(event, this.config.publishableKey, this.config.endpoint, this.config.debug);
148
+ });
149
+ }
150
+ detachForwarder() {
151
+ this.unsubForwarder?.();
152
+ this.unsubForwarder = null;
153
+ }
154
+ /**
155
+ * Track an event. Properties are flat key-value pairs that become
156
+ * directly filterable dimensions in PostHog.
157
+ *
158
+ * Duplicate calls with the same event name + properties within the
159
+ * dedup window are silently dropped.
160
+ */
161
+ track(eventName, properties) {
162
+ const props = properties ?? {};
163
+ if (this.config.dedupMs > 0) {
164
+ const key = dedupKey(this.sessionId, eventName, props);
165
+ const now = Date.now();
166
+ const lastSeen = this.recentEvents.get(key);
167
+ if (lastSeen !== void 0 && now - lastSeen < this.config.dedupMs) {
168
+ if (this.config.debug) {
169
+ console.debug("[unifold/analytics] dedup suppressed", eventName);
170
+ }
171
+ return;
172
+ }
173
+ this.recentEvents.set(key, now);
174
+ if (this.recentEvents.size > 200) {
175
+ const cutoff = now - this.config.dedupMs;
176
+ for (const [k, ts] of this.recentEvents) {
177
+ if (ts < cutoff) this.recentEvents.delete(k);
178
+ }
179
+ }
180
+ }
181
+ const event = {
182
+ event: eventName,
183
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
184
+ sessionId: this.sessionId,
185
+ userId: this.userId,
186
+ properties: props,
187
+ context: this.context
188
+ };
189
+ if (this.config.debug) {
190
+ console.debug("[unifold/analytics] track", eventName, event);
191
+ }
192
+ this.emitter.emit(eventName, event);
193
+ }
194
+ /**
195
+ * Associate subsequent events with an internal Unifold user.id
196
+ * (`user_<ksuid>`). Pass `null` to clear. Usually called once the
197
+ * deposit-addresses response returns the id.
198
+ */
199
+ setUserId(userId) {
200
+ this.userId = userId || void 0;
201
+ }
202
+ /**
203
+ * Start a new analytics journey (modal open). Mints an `asess_<ksuid>` and
204
+ * returns it. Every {@link EventTracker.track} call until
205
+ * {@link EventTracker.endSession} carries it.
206
+ *
207
+ * Does not touch `userId`: the internal `user_<ksuid>` is attached separately
208
+ * via {@link EventTracker.setUserId} once the deposit-addresses response
209
+ * returns, and persists until the host replaces it.
210
+ */
211
+ startSession() {
212
+ this.sessionId = generatePrefixedKSUID(SESSION_PREFIX);
213
+ return this.sessionId;
214
+ }
215
+ /**
216
+ * End the current journey (modal close). Clears the session id so any stray
217
+ * events after close are session-less. Call this *after* the terminal
218
+ * `widget_closed` track call.
219
+ */
220
+ endSession() {
221
+ this.sessionId = void 0;
222
+ }
223
+ /** Current analytics journey id, or `undefined` when no journey is active. */
224
+ getSessionId() {
225
+ return this.sessionId;
226
+ }
227
+ /**
228
+ * Subscribe to a specific event or `'*'` for all events.
229
+ * Returns an unsubscribe function.
230
+ */
231
+ on(eventName, handler) {
232
+ return this.emitter.on(eventName, handler);
233
+ }
234
+ /** Remove all listeners and stop forwarding. */
235
+ destroy() {
236
+ this.emitter.removeAllListeners();
237
+ this.recentEvents.clear();
238
+ }
239
+ /** Enable or disable network forwarding at runtime. Custom listeners are preserved. */
240
+ setDisabled(disabled) {
241
+ if (disabled && !this.config.disabled) {
242
+ this.detachForwarder();
243
+ } else if (!disabled && this.config.disabled) {
244
+ this.attachForwarder();
245
+ }
246
+ this.config.disabled = disabled;
247
+ }
248
+ };
249
+ function createEventTracker(config) {
250
+ return new EventTracker(config);
251
+ }
252
+
253
+ // src/types.ts
254
+ var AnalyticsEvents = {
255
+ /** User viewed a screen / step in the flow. */
256
+ SCREEN_VIEWED: "screen_viewed",
257
+ /** User selected a payment method. */
258
+ PAYMENT_METHOD_SELECTED: "payment_method_selected",
259
+ /**
260
+ * User picked an asset — the token *and* the chain it lives on. One event
261
+ * for both, carrying the full identity, because neither half identifies an
262
+ * asset alone; either dropdown changing re-fires it.
263
+ */
264
+ TOKEN_SELECTED: "token_selected",
265
+ /** User picked a browser wallet to connect. */
266
+ WALLET_SELECTED: "wallet_selected",
267
+ /** User picked an onramp/exchange provider (card quote, bank, exchange). */
268
+ PROVIDER_SELECTED: "provider_selected",
269
+ /**
270
+ * User picked what to pay with inside a provider's flow — a card, or a
271
+ * wallet like Apple Pay. Distinct from `payment_method_selected`, which is
272
+ * the funding route they chose from the deposit menu.
273
+ */
274
+ PAYMENT_METHOD_TYPE_SELECTED: "payment_method_type_selected",
275
+ /**
276
+ * User reached a step that asks them to prove something about themselves —
277
+ * identity details, a document, an emailed or texted code, an exchange MFA
278
+ * prompt. `verification_type` says which.
279
+ */
280
+ VERIFICATION_STARTED: "verification_started",
281
+ /** User handed over what the step asked for. A provider may still be reviewing. */
282
+ VERIFICATION_SUBMITTED: "verification_submitted",
283
+ /** The provider accepted it. */
284
+ VERIFICATION_COMPLETED: "verification_completed",
285
+ /** The provider rejected it, or the user backed out. See `failure_reason`. */
286
+ VERIFICATION_FAILED: "verification_failed",
287
+ /**
288
+ * User began linking an account they hold somewhere else — an exchange, a
289
+ * Stripe Link profile. Distinct from connecting a browser wallet, which the
290
+ * user already controls locally.
291
+ */
292
+ ACCOUNT_CONNECTION_STARTED: "account_connection_started",
293
+ /** The external account is linked and usable. */
294
+ ACCOUNT_CONNECTED: "account_connected",
295
+ /** Linking the external account failed or was abandoned. */
296
+ ACCOUNT_CONNECTION_FAILED: "account_connection_failed",
297
+ /** User began connecting a browser wallet. */
298
+ WALLET_CONNECTION_STARTED: "wallet_connection_started",
299
+ /** The wallet is connected and usable. */
300
+ WALLET_CONNECTED: "wallet_connected",
301
+ /** The connection failed, or the user declined it in their wallet. */
302
+ WALLET_CONNECTION_FAILED: "wallet_connection_failed",
303
+ /** User navigated back a step. */
304
+ BACK_CLICKED: "back_clicked",
305
+ /** User started a flow (deposit, checkout, withdraw). */
306
+ FLOW_STARTED: "flow_started",
307
+ /** User completed a flow successfully. */
308
+ FLOW_COMPLETED: "flow_completed",
309
+ /** A flow failed with an error. */
310
+ FLOW_FAILED: "flow_failed",
311
+ /** Generic UI interaction (button press, link tap). */
312
+ BUTTON_CLICKED: "button_clicked",
313
+ /** Widget/modal opened. */
314
+ WIDGET_OPENED: "widget_opened",
315
+ /** Widget/modal closed. */
316
+ WIDGET_CLOSED: "widget_closed"
317
+ };
318
+
319
+ exports.AnalyticsEvents = AnalyticsEvents;
320
+ exports.Emitter = Emitter;
321
+ exports.EventTracker = EventTracker;
322
+ exports.PACKAGE_VERSION = PACKAGE_VERSION;
323
+ exports.createEventTracker = createEventTracker;
324
+ exports.forwardEvent = forwardEvent;
325
+ exports.generateKSUID = generateKSUID;
326
+ exports.generatePrefixedKSUID = generatePrefixedKSUID;
package/dist/index.mjs ADDED
@@ -0,0 +1,317 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // src/emitter.ts
6
+ var Emitter = class {
7
+ constructor() {
8
+ __publicField(this, "handlers", /* @__PURE__ */ new Map());
9
+ }
10
+ on(type, handler) {
11
+ let set = this.handlers.get(type);
12
+ if (!set) {
13
+ set = /* @__PURE__ */ new Set();
14
+ this.handlers.set(type, set);
15
+ }
16
+ set.add(handler);
17
+ return () => {
18
+ set.delete(handler);
19
+ };
20
+ }
21
+ emit(type, event) {
22
+ const dispatch = (handler) => {
23
+ try {
24
+ handler(event);
25
+ } catch (err) {
26
+ console.error("[unifold/event-tracker] handler threw", err);
27
+ }
28
+ };
29
+ this.handlers.get(type)?.forEach(dispatch);
30
+ this.handlers.get("*")?.forEach(dispatch);
31
+ }
32
+ removeAllListeners() {
33
+ this.handlers.clear();
34
+ }
35
+ };
36
+
37
+ // src/forwarder.ts
38
+ var DEFAULT_ENDPOINT = "/v1/public/events";
39
+ function forwardEvent(event, publishableKey, endpoint = DEFAULT_ENDPOINT, debug = false) {
40
+ try {
41
+ if (typeof fetch === "undefined") {
42
+ if (debug) console.debug("[unifold/analytics] fetch unavailable, skipping forward");
43
+ return;
44
+ }
45
+ const { sessionId, userId, ...rest } = event;
46
+ const body = {
47
+ ...rest,
48
+ // Only attach session_id while a journey is active (modal open → close).
49
+ ...sessionId ? { session_id: sessionId } : {},
50
+ ...userId ? { user_id: userId } : {}
51
+ };
52
+ fetch(endpoint, {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ "x-publishable-key": publishableKey
57
+ },
58
+ body: JSON.stringify(body),
59
+ keepalive: true
60
+ }).catch((err) => {
61
+ if (debug) console.debug("[unifold/analytics] forward failed", err);
62
+ });
63
+ } catch (err) {
64
+ if (debug) console.debug("[unifold/analytics] forward error", err);
65
+ }
66
+ }
67
+
68
+ // src/ksuid.ts
69
+ function generateKSUID() {
70
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
71
+ const KSUID_EPOCH = 14e8;
72
+ const timestampSeconds = Math.floor(Date.now() / 1e3) - KSUID_EPOCH;
73
+ const payload = new Uint8Array(20);
74
+ payload[0] = timestampSeconds >>> 24 & 255;
75
+ payload[1] = timestampSeconds >>> 16 & 255;
76
+ payload[2] = timestampSeconds >>> 8 & 255;
77
+ payload[3] = timestampSeconds & 255;
78
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
79
+ crypto.getRandomValues(payload.subarray(4));
80
+ } else {
81
+ for (let i = 4; i < 20; i++) {
82
+ payload[i] = Math.floor(Math.random() * 256);
83
+ }
84
+ }
85
+ let value = 0n;
86
+ for (const byte of payload) {
87
+ value = value << 8n | BigInt(byte);
88
+ }
89
+ let encoded = "";
90
+ while (value > 0n) {
91
+ encoded = BASE62[Number(value % 62n)] + encoded;
92
+ value = value / 62n;
93
+ }
94
+ return encoded.padStart(27, "0");
95
+ }
96
+ function generatePrefixedKSUID(prefix) {
97
+ return `${prefix}_${generateKSUID()}`;
98
+ }
99
+
100
+ // src/version.ts
101
+ var PACKAGE_VERSION = "0.1.72";
102
+
103
+ // src/tracker.ts
104
+ var LIBRARY_NAME = "@unifold/analytics";
105
+ var DEFAULT_DEDUP_MS = 500;
106
+ var SESSION_PREFIX = "asess";
107
+ function dedupKey(sessionId, eventName, properties) {
108
+ const sorted = Object.keys(properties).sort().map((k) => `${k}=${String(properties[k])}`).join("&");
109
+ return `${sessionId ?? ""}|${eventName}|${sorted}`;
110
+ }
111
+ var EventTracker = class {
112
+ constructor(config) {
113
+ __publicField(this, "emitter", new Emitter());
114
+ __publicField(this, "config");
115
+ __publicField(this, "context");
116
+ __publicField(this, "recentEvents", /* @__PURE__ */ new Map());
117
+ __publicField(this, "unsubForwarder", null);
118
+ __publicField(this, "userId");
119
+ /** Set only while a journey is active (modal open → close). */
120
+ __publicField(this, "sessionId");
121
+ this.config = {
122
+ endpoint: config.endpoint ?? "/v1/public/events",
123
+ publishableKey: config.publishableKey,
124
+ sdkVersion: config.sdkVersion ?? PACKAGE_VERSION,
125
+ platform: config.platform ?? "web",
126
+ userId: config.userId ?? "",
127
+ disabled: config.disabled ?? false,
128
+ debug: config.debug ?? false,
129
+ dedupMs: config.dedupMs ?? DEFAULT_DEDUP_MS
130
+ };
131
+ this.userId = config.userId || void 0;
132
+ this.context = {
133
+ library: {
134
+ name: LIBRARY_NAME,
135
+ version: this.config.sdkVersion
136
+ },
137
+ platform: this.config.platform
138
+ };
139
+ if (!this.config.disabled) {
140
+ this.attachForwarder();
141
+ }
142
+ }
143
+ attachForwarder() {
144
+ this.unsubForwarder = this.emitter.on("*", (event) => {
145
+ forwardEvent(event, this.config.publishableKey, this.config.endpoint, this.config.debug);
146
+ });
147
+ }
148
+ detachForwarder() {
149
+ this.unsubForwarder?.();
150
+ this.unsubForwarder = null;
151
+ }
152
+ /**
153
+ * Track an event. Properties are flat key-value pairs that become
154
+ * directly filterable dimensions in PostHog.
155
+ *
156
+ * Duplicate calls with the same event name + properties within the
157
+ * dedup window are silently dropped.
158
+ */
159
+ track(eventName, properties) {
160
+ const props = properties ?? {};
161
+ if (this.config.dedupMs > 0) {
162
+ const key = dedupKey(this.sessionId, eventName, props);
163
+ const now = Date.now();
164
+ const lastSeen = this.recentEvents.get(key);
165
+ if (lastSeen !== void 0 && now - lastSeen < this.config.dedupMs) {
166
+ if (this.config.debug) {
167
+ console.debug("[unifold/analytics] dedup suppressed", eventName);
168
+ }
169
+ return;
170
+ }
171
+ this.recentEvents.set(key, now);
172
+ if (this.recentEvents.size > 200) {
173
+ const cutoff = now - this.config.dedupMs;
174
+ for (const [k, ts] of this.recentEvents) {
175
+ if (ts < cutoff) this.recentEvents.delete(k);
176
+ }
177
+ }
178
+ }
179
+ const event = {
180
+ event: eventName,
181
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
182
+ sessionId: this.sessionId,
183
+ userId: this.userId,
184
+ properties: props,
185
+ context: this.context
186
+ };
187
+ if (this.config.debug) {
188
+ console.debug("[unifold/analytics] track", eventName, event);
189
+ }
190
+ this.emitter.emit(eventName, event);
191
+ }
192
+ /**
193
+ * Associate subsequent events with an internal Unifold user.id
194
+ * (`user_<ksuid>`). Pass `null` to clear. Usually called once the
195
+ * deposit-addresses response returns the id.
196
+ */
197
+ setUserId(userId) {
198
+ this.userId = userId || void 0;
199
+ }
200
+ /**
201
+ * Start a new analytics journey (modal open). Mints an `asess_<ksuid>` and
202
+ * returns it. Every {@link EventTracker.track} call until
203
+ * {@link EventTracker.endSession} carries it.
204
+ *
205
+ * Does not touch `userId`: the internal `user_<ksuid>` is attached separately
206
+ * via {@link EventTracker.setUserId} once the deposit-addresses response
207
+ * returns, and persists until the host replaces it.
208
+ */
209
+ startSession() {
210
+ this.sessionId = generatePrefixedKSUID(SESSION_PREFIX);
211
+ return this.sessionId;
212
+ }
213
+ /**
214
+ * End the current journey (modal close). Clears the session id so any stray
215
+ * events after close are session-less. Call this *after* the terminal
216
+ * `widget_closed` track call.
217
+ */
218
+ endSession() {
219
+ this.sessionId = void 0;
220
+ }
221
+ /** Current analytics journey id, or `undefined` when no journey is active. */
222
+ getSessionId() {
223
+ return this.sessionId;
224
+ }
225
+ /**
226
+ * Subscribe to a specific event or `'*'` for all events.
227
+ * Returns an unsubscribe function.
228
+ */
229
+ on(eventName, handler) {
230
+ return this.emitter.on(eventName, handler);
231
+ }
232
+ /** Remove all listeners and stop forwarding. */
233
+ destroy() {
234
+ this.emitter.removeAllListeners();
235
+ this.recentEvents.clear();
236
+ }
237
+ /** Enable or disable network forwarding at runtime. Custom listeners are preserved. */
238
+ setDisabled(disabled) {
239
+ if (disabled && !this.config.disabled) {
240
+ this.detachForwarder();
241
+ } else if (!disabled && this.config.disabled) {
242
+ this.attachForwarder();
243
+ }
244
+ this.config.disabled = disabled;
245
+ }
246
+ };
247
+ function createEventTracker(config) {
248
+ return new EventTracker(config);
249
+ }
250
+
251
+ // src/types.ts
252
+ var AnalyticsEvents = {
253
+ /** User viewed a screen / step in the flow. */
254
+ SCREEN_VIEWED: "screen_viewed",
255
+ /** User selected a payment method. */
256
+ PAYMENT_METHOD_SELECTED: "payment_method_selected",
257
+ /**
258
+ * User picked an asset — the token *and* the chain it lives on. One event
259
+ * for both, carrying the full identity, because neither half identifies an
260
+ * asset alone; either dropdown changing re-fires it.
261
+ */
262
+ TOKEN_SELECTED: "token_selected",
263
+ /** User picked a browser wallet to connect. */
264
+ WALLET_SELECTED: "wallet_selected",
265
+ /** User picked an onramp/exchange provider (card quote, bank, exchange). */
266
+ PROVIDER_SELECTED: "provider_selected",
267
+ /**
268
+ * User picked what to pay with inside a provider's flow — a card, or a
269
+ * wallet like Apple Pay. Distinct from `payment_method_selected`, which is
270
+ * the funding route they chose from the deposit menu.
271
+ */
272
+ PAYMENT_METHOD_TYPE_SELECTED: "payment_method_type_selected",
273
+ /**
274
+ * User reached a step that asks them to prove something about themselves —
275
+ * identity details, a document, an emailed or texted code, an exchange MFA
276
+ * prompt. `verification_type` says which.
277
+ */
278
+ VERIFICATION_STARTED: "verification_started",
279
+ /** User handed over what the step asked for. A provider may still be reviewing. */
280
+ VERIFICATION_SUBMITTED: "verification_submitted",
281
+ /** The provider accepted it. */
282
+ VERIFICATION_COMPLETED: "verification_completed",
283
+ /** The provider rejected it, or the user backed out. See `failure_reason`. */
284
+ VERIFICATION_FAILED: "verification_failed",
285
+ /**
286
+ * User began linking an account they hold somewhere else — an exchange, a
287
+ * Stripe Link profile. Distinct from connecting a browser wallet, which the
288
+ * user already controls locally.
289
+ */
290
+ ACCOUNT_CONNECTION_STARTED: "account_connection_started",
291
+ /** The external account is linked and usable. */
292
+ ACCOUNT_CONNECTED: "account_connected",
293
+ /** Linking the external account failed or was abandoned. */
294
+ ACCOUNT_CONNECTION_FAILED: "account_connection_failed",
295
+ /** User began connecting a browser wallet. */
296
+ WALLET_CONNECTION_STARTED: "wallet_connection_started",
297
+ /** The wallet is connected and usable. */
298
+ WALLET_CONNECTED: "wallet_connected",
299
+ /** The connection failed, or the user declined it in their wallet. */
300
+ WALLET_CONNECTION_FAILED: "wallet_connection_failed",
301
+ /** User navigated back a step. */
302
+ BACK_CLICKED: "back_clicked",
303
+ /** User started a flow (deposit, checkout, withdraw). */
304
+ FLOW_STARTED: "flow_started",
305
+ /** User completed a flow successfully. */
306
+ FLOW_COMPLETED: "flow_completed",
307
+ /** A flow failed with an error. */
308
+ FLOW_FAILED: "flow_failed",
309
+ /** Generic UI interaction (button press, link tap). */
310
+ BUTTON_CLICKED: "button_clicked",
311
+ /** Widget/modal opened. */
312
+ WIDGET_OPENED: "widget_opened",
313
+ /** Widget/modal closed. */
314
+ WIDGET_CLOSED: "widget_closed"
315
+ };
316
+
317
+ export { AnalyticsEvents, Emitter, EventTracker, PACKAGE_VERSION, createEventTracker, forwardEvent, generateKSUID, generatePrefixedKSUID };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@unifold/analytics",
3
+ "version": "0.1.72",
4
+ "description": "Lightweight internal analytics for the Unifold SDK — zero external dependencies",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "devDependencies": {
19
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
20
+ "@typescript-eslint/parser": "^6.21.0",
21
+ "eslint": "^8.57.0",
22
+ "eslint-config-prettier": "^9.1.0",
23
+ "eslint-plugin-prettier": "^5.1.3",
24
+ "prettier": "^3.2.5",
25
+ "tsup": "^8.0.0",
26
+ "typescript": "^5.0.0",
27
+ "vitest": "^2.1.9"
28
+ },
29
+ "keywords": [
30
+ "unifold",
31
+ "analytics",
32
+ "telemetry",
33
+ "sdk"
34
+ ],
35
+ "license": "MIT",
36
+ "scripts": {
37
+ "build": "tsup",
38
+ "dev": "tsup --watch",
39
+ "clean": "rm -rf dist",
40
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
41
+ "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
42
+ "format": "prettier --write \"src/**/*.{ts,tsx}\"",
43
+ "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
44
+ "type-check": "tsc --noEmit",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ }
48
+ }