@formo/analytics 1.12.0-alpha.2 → 1.12.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 (67) hide show
  1. package/.env.example +1 -0
  2. package/CONTRIBUTING.md +93 -0
  3. package/README.md +4 -163
  4. package/dist/cjs/src/FormoAnalytics.d.ts +45 -69
  5. package/dist/cjs/src/FormoAnalytics.d.ts.map +1 -1
  6. package/dist/cjs/src/FormoAnalytics.js +378 -404
  7. package/dist/cjs/src/FormoAnalytics.js.map +1 -1
  8. package/dist/cjs/src/FormoAnalyticsProvider.d.ts +2 -2
  9. package/dist/cjs/src/FormoAnalyticsProvider.d.ts.map +1 -1
  10. package/dist/cjs/src/FormoAnalyticsProvider.js +120 -29
  11. package/dist/cjs/src/FormoAnalyticsProvider.js.map +1 -1
  12. package/dist/cjs/src/constants/config.d.ts +2 -2
  13. package/dist/cjs/src/constants/config.d.ts.map +1 -1
  14. package/dist/cjs/src/constants/config.js +3 -3
  15. package/dist/cjs/src/constants/config.js.map +1 -1
  16. package/dist/cjs/src/constants/events.d.ts +3 -1
  17. package/dist/cjs/src/constants/events.d.ts.map +1 -1
  18. package/dist/cjs/src/constants/events.js +2 -0
  19. package/dist/cjs/src/constants/events.js.map +1 -1
  20. package/dist/cjs/src/types/base.d.ts +10 -2
  21. package/dist/cjs/src/types/base.d.ts.map +1 -1
  22. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  23. package/dist/esm/src/FormoAnalytics.d.ts +45 -69
  24. package/dist/esm/src/FormoAnalytics.d.ts.map +1 -1
  25. package/dist/esm/src/FormoAnalytics.js +381 -407
  26. package/dist/esm/src/FormoAnalytics.js.map +1 -1
  27. package/dist/esm/src/FormoAnalyticsProvider.d.ts +2 -2
  28. package/dist/esm/src/FormoAnalyticsProvider.d.ts.map +1 -1
  29. package/dist/esm/src/FormoAnalyticsProvider.js +120 -29
  30. package/dist/esm/src/FormoAnalyticsProvider.js.map +1 -1
  31. package/dist/esm/src/constants/config.d.ts +2 -2
  32. package/dist/esm/src/constants/config.d.ts.map +1 -1
  33. package/dist/esm/src/constants/config.js +2 -2
  34. package/dist/esm/src/constants/config.js.map +1 -1
  35. package/dist/esm/src/constants/events.d.ts +3 -1
  36. package/dist/esm/src/constants/events.d.ts.map +1 -1
  37. package/dist/esm/src/constants/events.js +2 -0
  38. package/dist/esm/src/constants/events.js.map +1 -1
  39. package/dist/esm/src/types/base.d.ts +10 -2
  40. package/dist/esm/src/types/base.d.ts.map +1 -1
  41. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  42. package/dist/index.umd.min.js +1 -1
  43. package/dist/index.umd.min.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/FormoAnalytics.ts +293 -448
  46. package/src/FormoAnalyticsProvider.tsx +60 -30
  47. package/src/constants/config.ts +2 -2
  48. package/src/constants/events.ts +2 -0
  49. package/src/types/base.ts +16 -2
  50. package/dist/cjs/src/utils/index.d.ts +0 -2
  51. package/dist/cjs/src/utils/index.d.ts.map +0 -1
  52. package/dist/cjs/src/utils/index.js +0 -18
  53. package/dist/cjs/src/utils/index.js.map +0 -1
  54. package/dist/cjs/src/utils/isNotEmptyObject.d.ts +0 -2
  55. package/dist/cjs/src/utils/isNotEmptyObject.d.ts.map +0 -1
  56. package/dist/cjs/src/utils/isNotEmptyObject.js +0 -9
  57. package/dist/cjs/src/utils/isNotEmptyObject.js.map +0 -1
  58. package/dist/esm/src/utils/index.d.ts +0 -2
  59. package/dist/esm/src/utils/index.d.ts.map +0 -1
  60. package/dist/esm/src/utils/index.js +0 -2
  61. package/dist/esm/src/utils/index.js.map +0 -1
  62. package/dist/esm/src/utils/isNotEmptyObject.d.ts +0 -2
  63. package/dist/esm/src/utils/isNotEmptyObject.d.ts.map +0 -1
  64. package/dist/esm/src/utils/isNotEmptyObject.js +0 -6
  65. package/dist/esm/src/utils/isNotEmptyObject.js.map +0 -1
  66. package/src/utils/index.ts +0 -1
  67. package/src/utils/isNotEmptyObject.ts +0 -5
@@ -1,25 +1,13 @@
1
- import axios from 'axios';
1
+ import axios from "axios";
2
2
  import {
3
3
  COUNTRY_LIST,
4
- EVENTS_API,
5
- SESSION_STORAGE_ID_KEY,
4
+ EVENTS_API_URL,
6
5
  Event,
7
- } from './constants';
8
- import { isNotEmpty } from './utils';
9
- import { H } from 'highlight.run';
10
- import { ChainID, EIP1193Provider, RequestArguments } from './types';
6
+ } from "./constants";
7
+ import { H } from "highlight.run";
8
+ import { ChainID, Address, EIP1193Provider, Options, Config } from "./types";
11
9
 
12
10
  interface IFormoAnalytics {
13
- /**
14
- * Initializes the FormoAnalytics instance with the provided API key and project ID.
15
- */
16
- init(apiKey: string, projectId: string): Promise<FormoAnalytics>;
17
-
18
- /**
19
- * Identifies the user with the provided user data.
20
- */
21
- identify(userData: Record<string, any>): void;
22
-
23
11
  /**
24
12
  * Tracks page visit events.
25
13
  */
@@ -28,47 +16,45 @@ interface IFormoAnalytics {
28
16
  /**
29
17
  * Connects to a wallet with the specified chain ID and address.
30
18
  */
31
- connect(params: { account: string; chainId: ChainID }): Promise<void>;
19
+ connect(params: { chainId: ChainID; address: string }): Promise<void>;
32
20
 
33
21
  /**
34
- * Disconnects the current wallet and clears the session information.
22
+ * Disconnects the current wallet.
35
23
  */
36
- disconnect(attributes?: { account?: string; chainId?: ChainID }): void;
24
+ disconnect(params?: { chainId?: ChainID; address?: string }): Promise<void>;
37
25
 
38
26
  /**
39
- * Tracks a specific event with a name and associated data.
27
+ * Switches the blockchain chain context and optionally logs additional params.
40
28
  */
41
- track(eventName: string, eventData: Record<string, any>): void;
29
+ chain(params: { chainId: ChainID; address?: string }): Promise<void>;
42
30
 
43
31
  /**
44
- * Switches the blockchain chain context and optionally logs additional attributes.
32
+ * Tracks a specific event with a name and associated data.
45
33
  */
46
- chain(attributes: { chainId: ChainID; account?: string }): void;
34
+ track(eventName: string, eventData: Record<string, any>): Promise<void>;
47
35
  }
36
+
48
37
  export class FormoAnalytics implements IFormoAnalytics {
49
38
  private _provider?: EIP1193Provider;
50
- private _registeredProviderListeners: Record<
39
+ private _providerListeners: Record<
51
40
  string,
52
41
  (...args: unknown[]) => void
53
42
  > = {};
54
43
 
55
- private sessionKey = 'walletAddress';
56
- private config: any;
57
- private sessionIdKey: string = SESSION_STORAGE_ID_KEY;
58
- private timezoneToCountry: Record<string, string> = COUNTRY_LIST;
59
-
60
- currentChainId?: string | null;
61
- currentConnectedAccount?: string;
44
+ config: Config;
45
+ currentChainId?: ChainID;
46
+ currentConnectedAddress?: Address;
62
47
 
63
48
  private constructor(
64
49
  public readonly apiKey: string,
65
- public projectId: string
50
+ public options: Options = {}
66
51
  ) {
67
52
  this.config = {
68
- token: this.apiKey,
53
+ apiKey: apiKey,
69
54
  };
70
55
 
71
- const provider = window?.ethereum || window.web3?.currentProvider;
56
+ const provider =
57
+ window?.ethereum || window.web3?.currentProvider || options?.provider;
72
58
  if (provider) {
73
59
  this.trackProvider(provider);
74
60
  }
@@ -76,514 +62,373 @@ export class FormoAnalytics implements IFormoAnalytics {
76
62
 
77
63
  static async init(
78
64
  apiKey: string,
79
- projectId: string
65
+ options?: Options
80
66
  ): Promise<FormoAnalytics> {
81
- const config = {
82
- token: apiKey,
83
- };
84
- const instance = new FormoAnalytics(apiKey, projectId);
85
- instance.config = config;
86
-
87
- return instance;
67
+ // May be needed for delayed loading
68
+ // https://github.com/segmentio/analytics-next/tree/master/packages/browser#lazy--delayed-loading
69
+ return new FormoAnalytics(apiKey, options);
88
70
  }
89
71
 
90
- get provider(): EIP1193Provider | undefined {
91
- return this._provider;
92
- }
93
-
94
- private identifyUser(userData: any) {
95
- this.trackEvent(Event.IDENTIFY, userData);
96
- }
97
-
98
- private getSessionId() {
99
- const existingSessionId = this.getCookieValue(this.sessionIdKey);
72
+ /*
73
+ Public SDK functions
74
+ */
100
75
 
101
- if (existingSessionId) {
102
- return existingSessionId;
76
+ async connect({ chainId, address }: { chainId: ChainID; address: Address }): Promise<void> {
77
+ if (!chainId) {
78
+ throw new Error("FormoAnalytics::connect: chain ID cannot be empty");
103
79
  }
104
-
105
- const newSessionId = this.generateSessionId();
106
- return newSessionId;
107
- }
108
-
109
- // Function to set the session cookie
110
- private setSessionCookie(domain?: string) {
111
- const sessionId = this.getSessionId();
112
- let cookieValue = `${this.sessionIdKey}=${sessionId}; Max-Age=1800; path=/; secure`;
113
- if (domain) {
114
- cookieValue += `; domain=${domain}`;
80
+ if (!address) {
81
+ throw new Error("FormoAnalytics::connect: address cannot be empty");
115
82
  }
116
- document.cookie = cookieValue;
117
- }
118
-
119
- // Function to generate a new session ID
120
- private generateSessionId(): string {
121
- return crypto.randomUUID();
122
- }
123
-
124
- // Function to get a cookie value by name
125
- private getCookieValue(name: string): string | undefined {
126
- const cookies = document.cookie.split(';').reduce((acc, cookie) => {
127
- const [key, value] = cookie.split('=');
128
- acc[key.trim()] = value;
129
- return acc;
130
- }, {} as Record<string, string>);
131
- return cookies[name];
132
- }
133
-
134
- // Function to send tracking data
135
- private async trackEvent(action: string, payload: any) {
136
- const maxRetries = 3;
137
- let attempt = 0;
138
83
 
139
- this.setSessionCookie(this.config.domain);
140
- const apiUrl = this.buildApiUrl();
141
- const address = await this.getCurrentWallet();
84
+ this.currentChainId = chainId;
85
+ this.currentConnectedAddress = address;
142
86
 
143
- const requestData = {
144
- project_id: this.projectId,
87
+ await this.trackEvent(Event.CONNECT, {
88
+ chain_id: chainId,
145
89
  address: address,
146
- session_id: this.getSessionId(),
147
- timestamp: new Date().toISOString(),
148
- action: action,
149
- version: '1',
150
- payload: isNotEmpty(payload) ? this.maskSensitiveData(payload) : payload,
151
- };
152
-
153
- const sendRequest = async (): Promise<void> => {
154
- try {
155
- const response = await axios.post(apiUrl, JSON.stringify(requestData), {
156
- headers: {
157
- 'Content-Type': 'application/json',
158
- },
159
- });
160
-
161
- if (response.status >= 200 && response.status < 300) {
162
- console.log('Event sent successfully:', action);
163
- } else {
164
- throw new Error(`Failed with status: ${response.status}`);
165
- }
166
- } catch (error) {
167
- attempt++;
168
-
169
- if (attempt <= maxRetries) {
170
- const retryDelay = Math.pow(2, attempt) * 1000;
171
- console.error(
172
- `Attempt ${attempt}: Retrying event "${action}" in ${
173
- retryDelay / 1000
174
- } seconds...`
175
- );
176
- setTimeout(sendRequest, retryDelay);
177
- } else {
178
- H.consumeError(
179
- error as Error,
180
- `Request data: ${JSON.stringify(requestData)}`
181
- );
182
- console.error(
183
- `Event "${action}" failed after ${maxRetries} attempts. Error: ${error}`
184
- );
185
- }
186
- }
187
- };
90
+ });
91
+ }
188
92
 
189
- await sendRequest();
93
+ async disconnect(params?: { chainId?: ChainID; address?: Address }): Promise<void> {
94
+ const address = params?.address || this.currentConnectedAddress;
95
+ const chainId = params?.chainId || this.currentChainId;
96
+ await this.handleDisconnect(chainId, address);
190
97
  }
191
98
 
192
- // Function to mask sensitive data in the payload
193
- private maskSensitiveData(
194
- data: string | undefined | null
195
- ): Record<string, any> | null {
196
- // Check if data is null or undefined
197
- if (data === null || data === undefined) {
198
- console.warn('Data is null or undefined, returning null');
199
- return null;
99
+ async chain({ chainId, address }: { chainId: ChainID; address?: Address }): Promise<void> {
100
+ if (!chainId || Number(chainId) === 0) {
101
+ throw new Error("FormoAnalytics::chain: chainId cannot be empty or 0");
200
102
  }
201
-
202
- // Check if data is a string; if so, parse it to an object
203
- if (typeof data === 'string') {
204
- let parsedData: Record<string, any>;
205
- try {
206
- parsedData = JSON.parse(data);
207
- } catch (error) {
208
- console.error('Failed to parse JSON:', error);
209
- return null; // Return null if parsing fails
210
- }
211
-
212
- const sensitiveFields = [
213
- 'username',
214
- 'user',
215
- 'user_id',
216
- 'password',
217
- 'email',
218
- 'phone',
219
- ];
220
-
221
- // Create a new object to store masked data
222
- const maskedData = { ...parsedData };
223
-
224
- // Mask sensitive fields
225
- sensitiveFields.forEach((field) => {
226
- if (field in maskedData) {
227
- maskedData[field] = '********'; // Replace value with masked string
228
- }
229
- });
230
-
231
- return maskedData; // Return the new object with masked fields
232
- } else if (typeof data === 'object') {
233
- // If data is already an object, handle masking directly
234
- const sensitiveFields = [
235
- 'username',
236
- 'user',
237
- 'user_id',
238
- 'password',
239
- 'email',
240
- 'phone',
241
- ];
242
-
243
- const maskedData = { ...(data as Record<string, any>) };
244
-
245
- // Mask sensitive fields
246
- sensitiveFields.forEach((field) => {
247
- if (field in maskedData) {
248
- maskedData[field] = '********'; // Replace value with masked string
249
- }
250
- });
251
-
252
- return maskedData; // Return the new object with masked fields
103
+ if (!address && !this.currentConnectedAddress) {
104
+ throw new Error(
105
+ "FormoAnalytics::chain: address was empty and no previous address has been recorded. You can either pass an address or call connect() first"
106
+ );
107
+ }
108
+ if (isNaN(Number(chainId))) {
109
+ throw new Error(
110
+ "FormoAnalytics::chain: chainId must be a valid decimal number"
111
+ );
253
112
  }
254
113
 
255
- return data;
256
- }
114
+ this.currentChainId = chainId;
257
115
 
258
- // Function to track page hits
259
- private trackPageHit() {
260
- if (window.__nightmare || window.navigator.webdriver || window.Cypress)
261
- return;
116
+ await this.trackEvent(Event.CHAIN_CHANGED, {
117
+ chain_id: chainId,
118
+ address: address || this.currentConnectedAddress,
119
+ });
120
+ }
262
121
 
263
- let location: string | undefined;
264
- let language: string;
265
- try {
266
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
267
- location = this.timezoneToCountry[timezone];
268
- language =
269
- navigator.languages && navigator.languages.length
270
- ? navigator.languages[0]
271
- : navigator.language || 'en';
272
- } catch (error) {
273
- console.error('Error resolving timezone or language:', error);
274
- }
122
+ // TODO: allow custom url as input
123
+ async page(): Promise<void> {
124
+ await this.trackPageHit();
125
+ }
275
126
 
276
- setTimeout(() => {
277
- const url = new URL(window.location.href);
278
- const params = new URLSearchParams(url.search);
279
- this.trackEvent(Event.PAGE, {
280
- 'user-agent': window.navigator.userAgent,
281
- locale: language,
282
- location: location,
283
- referrer: document.referrer,
284
- pathname: window.location.pathname,
285
- href: window.location.href,
286
- utm_source: params.get('utm_source'),
287
- utm_medium: params.get('utm_medium'),
288
- utm_campaign: params.get('utm_campaign'),
289
- ref: params.get('ref'),
290
- });
291
- }, 300);
127
+ async track(eventName: string, eventData: Record<string, any>): Promise<void> {
128
+ await this.trackEvent(eventName, eventData);
292
129
  }
293
130
 
294
- private trackProvider(provider: EIP1193Provider) {
131
+ /*
132
+ SDK tracking and event listener functions
133
+ */
134
+
135
+ private trackProvider(provider: EIP1193Provider): void {
295
136
  if (provider === this._provider) {
137
+ console.log("Provider already tracked.");
296
138
  return;
297
139
  }
298
140
 
299
141
  this.currentChainId = undefined;
300
- this.currentConnectedAccount = undefined;
142
+ this.currentConnectedAddress = undefined;
301
143
 
302
144
  if (this._provider) {
303
- const eventNames = Object.keys(this._registeredProviderListeners);
145
+ const eventNames = Object.keys(this._providerListeners);
304
146
  for (const eventName of eventNames) {
305
147
  this._provider.removeListener(
306
148
  eventName,
307
- this._registeredProviderListeners[eventName]
149
+ this._providerListeners[eventName]
308
150
  );
309
- delete this._registeredProviderListeners[eventName];
151
+ delete this._providerListeners[eventName];
310
152
  }
311
153
  }
312
154
 
155
+ console.log("Tracking new provider:", provider);
313
156
  this._provider = provider;
314
157
 
315
- this.getCurrentWallet();
316
- this.registerAccountsChangedListener();
158
+ this.getAddress();
159
+ this.registerAddressChangedListener();
317
160
  this.registerChainChangedListener();
161
+ // TODO: track signing and transactions
162
+ // https://linear.app/getformo/issue/P-607/sdk-support-signature-and-transaction-events
163
+ }
164
+
165
+ private registerAddressChangedListener(): void {
166
+ const listener = (...args: unknown[]) =>
167
+ this.onAddressChanged(args[0] as string[]);
168
+
169
+ this._provider?.on("accountsChanged", listener);
170
+ this._providerListeners["accountsChanged"] = listener;
171
+
172
+ const onAddressDisconnected = this.onAddressDisconnected.bind(this);
173
+ this._provider?.on("disconnect", onAddressDisconnected);
174
+ this._providerListeners["disconnect"] = onAddressDisconnected;
318
175
  }
319
176
 
320
- private registerChainChangedListener() {
177
+ private registerChainChangedListener(): void {
321
178
  const listener = (...args: unknown[]) =>
322
179
  this.onChainChanged(args[0] as string);
323
- this.provider?.on('chainChanged', listener);
324
- this._registeredProviderListeners['chainChanged'] = listener;
180
+ this.provider?.on("chainChanged", listener);
181
+ this._providerListeners["chainChanged"] = listener;
182
+ }
183
+
184
+ private async onAddressChanged(addresses: Address[]): Promise<void> {
185
+ if (addresses.length > 0) {
186
+ this.onAddressConnected(addresses[0]);
187
+ } else {
188
+ this.onAddressDisconnected();
189
+ }
325
190
  }
326
191
 
327
- private handleAccountDisconnected() {
328
- if (!this.currentConnectedAccount) {
192
+ private async onAddressConnected(address: Address): Promise<void> {
193
+ if (address === this.currentConnectedAddress) {
194
+ // We have already reported this address
329
195
  return;
196
+ } else {
197
+ this.currentConnectedAddress = address;
330
198
  }
331
199
 
332
- const disconnectAttributes = {
333
- account: this.currentConnectedAccount,
334
- chainId: this.currentChainId,
200
+ this.currentChainId = await this.getCurrentChainId();
201
+ this.connect({ chainId: this.currentChainId, address });
202
+ }
203
+
204
+ private async handleDisconnect(chainId?: ChainID, address?: Address): Promise<void> {
205
+ const payload = {
206
+ chain_id: chainId || this.currentChainId,
207
+ address: address || this.currentConnectedAddress,
335
208
  };
336
209
  this.currentChainId = undefined;
337
- this.currentConnectedAccount = undefined;
338
- this.clearWalletAddress();
210
+ this.currentConnectedAddress = undefined;
211
+ await this.trackEvent(Event.DISCONNECT, payload);
212
+ }
339
213
 
340
- return this.trackEvent(Event.DISCONNECT, disconnectAttributes);
214
+ private async onAddressDisconnected(): Promise<void> {
215
+ await this.handleDisconnect(this.currentChainId, this.currentConnectedAddress);
341
216
  }
342
217
 
343
- private async onChainChanged(chainIdHex: string) {
344
- this.currentChainId = parseInt(chainIdHex).toString();
345
- if (!this.currentConnectedAccount) {
218
+ private async onChainChanged(chainIdHex: string): Promise<void> {
219
+ this.currentChainId = parseInt(chainIdHex);
220
+ if (!this.currentConnectedAddress) {
346
221
  if (!this.provider) {
347
- console.error(
348
- 'error',
349
- 'FormoAnalytics::onChainChanged: provider not found. CHAIN_CHANGED not reported'
222
+ console.log(
223
+ "FormoAnalytics::onChainChanged: provider not found. CHAIN_CHANGED not reported"
350
224
  );
351
- return;
225
+ return Promise.resolve();
352
226
  }
353
227
 
354
- try {
355
- const res: string[] | null | undefined = await this.provider.request({
356
- method: 'eth_accounts',
357
- });
358
- if (!res || res.length === 0) {
359
- console.error(
360
- 'error',
361
- 'FormoAnalytics::onChainChanged: unable to get account. eth_accounts returned empty'
362
- );
363
- return;
364
- }
365
-
366
- this.currentConnectedAccount = res[0];
367
- } catch (err) {
368
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
369
- if ((err as any).code !== 4001) {
370
- // 4001: The request is rejected by the user , see https://docs.metamask.io/wallet/reference/provider-api/#errors
371
- console.error(
372
- 'error',
373
- `FormoAnalytics::onChainChanged: unable to get account. eth_accounts threw an error`,
374
- err
375
- );
376
- return;
377
- }
228
+ // Attempt to fetch and store the connected address
229
+ const address = await this.getAddress();
230
+ if (!address) {
231
+ console.log(
232
+ "FormoAnalytics::onChainChanged: Unable to fetch or store connected address"
233
+ );
234
+ return Promise.resolve();
378
235
  }
379
- }
380
236
 
381
- return this.chain({
382
- chainId: this.currentChainId,
383
- account: this.currentConnectedAccount,
384
- });
385
- }
237
+ this.currentConnectedAddress = address[0];
238
+ }
386
239
 
387
- private async onAccountsChanged(accounts: string[]) {
388
- if (accounts.length > 0) {
389
- const newAccount = accounts[0];
390
- if (newAccount !== this.currentConnectedAccount) {
391
- this.handleAccountConnected(newAccount);
392
- }
240
+ // Proceed only if the address exists
241
+ if (this.currentConnectedAddress) {
242
+ return this.chain({
243
+ chainId: this.currentChainId,
244
+ address: this.currentConnectedAddress,
245
+ });
393
246
  } else {
394
- this.handleAccountDisconnected();
247
+ console.log(
248
+ "FormoAnalytics::onChainChanged: currentConnectedAddress is null despite fetch attempt"
249
+ );
395
250
  }
396
251
  }
397
252
 
398
- private registerAccountsChangedListener() {
399
- const listener = (...args: unknown[]) =>
400
- this.onAccountsChanged(args[0] as string[]);
401
-
402
- this._provider?.on('accountsChanged', listener);
403
- this._registeredProviderListeners['accountsChanged'] = listener;
253
+ // TOFIX: support multiple page hit events
254
+ // TODO: Add event listener and support for SPA and hash-based navigation
255
+ // https://linear.app/getformo/issue/P-800/sdk-support-spa-and-hash-based-routing
256
+ private trackPageHit(): void {
257
+ const pathname = window.location.pathname;
258
+ const href = window.location.href;
404
259
 
405
- const handleAccountDisconnected = this.handleAccountDisconnected.bind(this);
406
- this._provider?.on('disconnect', handleAccountDisconnected);
407
- this._registeredProviderListeners['disconnect'] = handleAccountDisconnected;
260
+ setTimeout(async () => {
261
+ this.trackEvent(Event.PAGE, {
262
+ pathname,
263
+ href,
264
+ });
265
+ }, 300);
408
266
  }
409
267
 
410
- private async getCurrentChainId(): Promise<string> {
411
- if (!this.provider) {
412
- console.error('FormoAnalytics::getCurrentChainId: provider not set');
413
- }
268
+ // TODO: refactor this with event queue and flushing
269
+ // https://linear.app/getformo/issue/P-835/sdk-refactor-retries-with-event-queue-and-batching
270
+ private async trackEvent(action: string, payload: any): Promise<void> {
271
+ const address = await this.getAddress();
414
272
 
415
- const chainIdHex = await this.provider?.request<string>({
416
- method: 'eth_chainId',
417
- });
418
- // Because we're connected, the chainId cannot be null
419
- if (!chainIdHex) {
420
- console.error(
421
- `FormoAnalytics::getCurrentChainId: chainIdHex is: ${chainIdHex}`
422
- );
423
- }
273
+ const requestData = {
274
+ address: address,
275
+ timestamp: new Date().toISOString(),
276
+ action,
277
+ version: "1",
278
+ payload: await this.buildEventPayload(payload),
279
+ };
424
280
 
425
- return parseInt(chainIdHex as string, 16).toString();
426
- }
281
+ try {
282
+ const response = await axios.post(
283
+ EVENTS_API_URL,
284
+ JSON.stringify(requestData),
285
+ {
286
+ headers: {
287
+ "Content-Type": "application/json",
288
+ Authorization: `Bearer ${this.config.apiKey}`,
289
+ },
290
+ }
291
+ );
427
292
 
428
- private async handleAccountConnected(account: string) {
429
- if (account === this.currentConnectedAccount) {
430
- // We have already reported this account
431
- return;
432
- } else {
433
- this.currentConnectedAccount = account;
293
+ if (response.status >= 200 && response.status < 300) {
294
+ console.log("Event sent successfully:", action);
295
+ } else {
296
+ throw new Error(`Failed with status: ${response.status}`);
297
+ }
298
+ } catch (error) {
299
+ H.consumeError(
300
+ error as Error,
301
+ `Request data: ${JSON.stringify(requestData)}`
302
+ );
303
+ console.error(`Event "${action}" failed. Error: ${error}`);
434
304
  }
305
+ }
435
306
 
436
- this.currentChainId = await this.getCurrentChainId();
307
+ /*
308
+ Utility functions
309
+ */
437
310
 
438
- this.connect({ account, chainId: this.currentChainId });
439
- this.storeWalletAddress(account);
440
- }
311
+ get provider(): EIP1193Provider | undefined {
312
+ return this._provider;
313
+ }
441
314
 
442
- private async getCurrentWallet() {
315
+ private async getAddress(): Promise<Address | null> {
443
316
  if (!this.provider) {
444
- console.warn('FormoAnalytics::getCurrentWallet: the provider is not set');
445
- return;
446
- }
447
- const sessionData = sessionStorage.getItem(this.sessionKey);
448
-
449
- if (!sessionData) {
317
+ console.log("FormoAnalytics::getAddress: the provider is not set");
450
318
  return null;
451
319
  }
452
320
 
453
- const parsedData = JSON.parse(sessionData);
454
- const sessionExpiry = 30 * 60 * 1000; // 30 minutes in milliseconds
455
- const currentTime = Date.now();
456
-
457
- if (currentTime - parsedData.timestamp > sessionExpiry) {
458
- console.warn('Session expired. Ignoring wallet address.');
459
- sessionStorage.removeItem(this.sessionKey); // Clear expired session data
460
- return '';
461
- }
462
-
463
- this.handleAccountConnected(parsedData.address);
464
- return parsedData.address || '';
465
- }
466
-
467
- /**
468
- * Stores the wallet address in session storage when connected.
469
- * @param address - The wallet address to store.
470
- */
471
- private storeWalletAddress(address: string): void {
472
- if (!address) {
473
- console.error('No wallet address provided to store.');
474
- return;
321
+ try {
322
+ const accounts = await this.getAccounts();
323
+ if (accounts && accounts.length > 0) {
324
+ const address = accounts[0];
325
+ // TODO: how to handle multiple addresses? Should we emit a connect event here? Since the user has not manually connected
326
+ // https://linear.app/getformo/issue/P-691/sdk-detect-multiple-wallets-using-eip6963
327
+ this.onAddressConnected(address);
328
+ return address;
329
+ }
330
+ } catch (err) {
331
+ console.log("Failed to fetch accounts from provider:", err);
332
+ return null;
475
333
  }
476
-
477
- const sessionData = {
478
- address,
479
- timestamp: Date.now(),
480
- };
481
-
482
- sessionStorage.setItem(this.sessionKey, JSON.stringify(sessionData));
334
+ return null;
483
335
  }
484
336
 
485
- /**
486
- * Clears the wallet address from session storage when disconnected.
487
- */
488
- private clearWalletAddress(): void {
489
- sessionStorage.removeItem(this.sessionKey);
490
- }
491
-
492
- // Function to build the API URL
493
- private buildApiUrl(): string {
494
- const { host, proxy, token, dataSource = 'analytics_events' } = this.config;
495
- if (token) {
496
- if (proxy) {
497
- return `${proxy}/api/tracking`;
337
+ private async getAccounts(): Promise<Address[] | null> {
338
+ try {
339
+ const res: string[] | null | undefined = await this.provider?.request({
340
+ method: "eth_accounts",
341
+ });
342
+ if (!res || res.length === 0) {
343
+ console.log(
344
+ "FormoAnalytics::getAccounts: unable to get account. eth_accounts returned empty"
345
+ );
346
+ return null;
498
347
  }
499
- if (host) {
500
- return `${host.replace(
501
- /\/+$/,
502
- ''
503
- )}/v0/events?name=${dataSource}&token=${token}`;
348
+ return res;
349
+ } catch (err) {
350
+ if ((err as any).code !== 4001) {
351
+ console.log(
352
+ "FormoAnalytics::getAccounts: eth_accounts threw an error",
353
+ err
354
+ );
504
355
  }
505
- return `${EVENTS_API}?name=${dataSource}&token=${token}`;
356
+ return null;
506
357
  }
507
- return 'Error: No token provided';
508
358
  }
509
359
 
510
- connect({ account, chainId }: { account: string; chainId: ChainID }) {
511
- if (!chainId) {
512
- throw new Error('FormoAnalytics::connect: chainId cannot be empty');
513
- }
514
- if (!account) {
515
- throw new Error('FormoAnalytics::connect: account cannot be empty');
360
+ private async getCurrentChainId(): Promise<number> {
361
+ if (!this.provider) {
362
+ console.error("FormoAnalytics::getCurrentChainId: provider not set");
516
363
  }
517
364
 
518
- this.currentChainId = chainId.toString();
519
- this.currentConnectedAccount = account;
520
-
521
- return this.trackEvent(Event.CONNECT, {
522
- chainId,
523
- address: account,
524
- });
525
- }
526
-
527
- disconnect(attributes?: { account?: string; chainId?: ChainID }) {
528
- const account = attributes?.account || this.currentConnectedAccount;
529
- if (!account) {
530
- // We have most likely already reported this disconnection with the automatic
531
- // `disconnect` detection
532
- return;
365
+ let chainIdHex;
366
+ try {
367
+ chainIdHex = await this.provider?.request<string>({
368
+ method: "eth_chainId",
369
+ });
370
+ if (!chainIdHex) {
371
+ console.log(
372
+ "FormoAnalytics::fetchChainId: chain id not found"
373
+ );
374
+ return 0;
375
+ }
376
+ return parseInt(chainIdHex as string, 16);
377
+ } catch (err) {
378
+ console.log(
379
+ "FormoAnalytics::fetchChainId: eth_chainId threw an error",
380
+ err
381
+ );
382
+ return 0;
533
383
  }
534
-
535
- const chainId = attributes?.chainId || this.currentChainId;
536
- const eventAttributes = {
537
- account,
538
- ...(chainId && { chainId }),
539
- };
540
-
541
- this.currentChainId = undefined;
542
- this.currentConnectedAccount = undefined;
543
-
544
- return this.trackEvent(Event.DISCONNECT, eventAttributes);
545
384
  }
546
385
 
547
- chain({ chainId, account }: { chainId: ChainID; account?: string }) {
548
- if (!chainId || Number(chainId) === 0) {
549
- throw new Error('FormoAnalytics::chain: chainId cannot be empty or 0');
550
- }
551
-
552
- if (!account && !this.currentConnectedAccount) {
553
- throw new Error(
554
- 'FormoAnalytics::chain: account was empty and no previous account has been recorded. You can either pass an account or call connect() first'
555
- );
386
+ private getLocation(): string | undefined {
387
+ try {
388
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
389
+ return COUNTRY_LIST[timezone as keyof typeof COUNTRY_LIST];
390
+ } catch (error) {
391
+ console.error("Error resolving timezone:", error);
392
+ return undefined;
556
393
  }
394
+ }
557
395
 
558
- if (isNaN(Number(chainId))) {
559
- throw new Error(
560
- 'FormoAnalytics::chain: chainId must be a valid hex or decimal number'
396
+ private getLanguage(): string {
397
+ try {
398
+ return (
399
+ (navigator.languages && navigator.languages.length
400
+ ? navigator.languages[0]
401
+ : navigator.language) || "en"
561
402
  );
403
+ } catch (error) {
404
+ console.error("Error resolving language:", error);
405
+ return "en";
562
406
  }
563
-
564
- this.currentChainId = chainId.toString();
565
-
566
- return this.trackEvent(Event.CHAIN_CHANGED, {
567
- chainId,
568
- account: account || this.currentConnectedAccount,
569
- });
570
- }
571
-
572
- init(apiKey: string, projectId: string): Promise<FormoAnalytics> {
573
- const instance = new FormoAnalytics(apiKey, projectId);
574
-
575
- return Promise.resolve(instance);
576
407
  }
577
408
 
578
- identify(userData: any) {
579
- this.identifyUser(userData);
580
- }
409
+ // Adds browser properties to the user-supplied payload
410
+ private async buildEventPayload(
411
+ eventSpecificPayload: Record<string, unknown> = {}
412
+ ): Promise<Record<string, unknown>> {
413
+ const url = new URL(window.location.href);
414
+ const params = new URLSearchParams(url.search);
581
415
 
582
- page() {
583
- this.trackPageHit();
584
- }
416
+ const location = this.getLocation();
417
+ const language = this.getLanguage();
418
+ const address = await this.getAddress();
585
419
 
586
- track(eventName: string, eventData: any) {
587
- this.trackEvent(eventName, eventData);
588
- }
420
+ // common browser properties
421
+ return {
422
+ "user-agent": window.navigator.userAgent,
423
+ address,
424
+ locale: language,
425
+ location,
426
+ referrer: document.referrer,
427
+ utm_source: params.get("utm_source"),
428
+ utm_medium: params.get("utm_medium"),
429
+ utm_campaign: params.get("utm_campaign"),
430
+ ref: params.get("ref"),
431
+ ...eventSpecificPayload,
432
+ };
433
+ }
589
434
  }