@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.
- package/.env.example +1 -0
- package/CONTRIBUTING.md +93 -0
- package/README.md +4 -163
- package/dist/cjs/src/FormoAnalytics.d.ts +45 -69
- package/dist/cjs/src/FormoAnalytics.d.ts.map +1 -1
- package/dist/cjs/src/FormoAnalytics.js +378 -404
- package/dist/cjs/src/FormoAnalytics.js.map +1 -1
- package/dist/cjs/src/FormoAnalyticsProvider.d.ts +2 -2
- package/dist/cjs/src/FormoAnalyticsProvider.d.ts.map +1 -1
- package/dist/cjs/src/FormoAnalyticsProvider.js +120 -29
- package/dist/cjs/src/FormoAnalyticsProvider.js.map +1 -1
- package/dist/cjs/src/constants/config.d.ts +2 -2
- package/dist/cjs/src/constants/config.d.ts.map +1 -1
- package/dist/cjs/src/constants/config.js +3 -3
- package/dist/cjs/src/constants/config.js.map +1 -1
- package/dist/cjs/src/constants/events.d.ts +3 -1
- package/dist/cjs/src/constants/events.d.ts.map +1 -1
- package/dist/cjs/src/constants/events.js +2 -0
- package/dist/cjs/src/constants/events.js.map +1 -1
- package/dist/cjs/src/types/base.d.ts +10 -2
- package/dist/cjs/src/types/base.d.ts.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/src/FormoAnalytics.d.ts +45 -69
- package/dist/esm/src/FormoAnalytics.d.ts.map +1 -1
- package/dist/esm/src/FormoAnalytics.js +381 -407
- package/dist/esm/src/FormoAnalytics.js.map +1 -1
- package/dist/esm/src/FormoAnalyticsProvider.d.ts +2 -2
- package/dist/esm/src/FormoAnalyticsProvider.d.ts.map +1 -1
- package/dist/esm/src/FormoAnalyticsProvider.js +120 -29
- package/dist/esm/src/FormoAnalyticsProvider.js.map +1 -1
- package/dist/esm/src/constants/config.d.ts +2 -2
- package/dist/esm/src/constants/config.d.ts.map +1 -1
- package/dist/esm/src/constants/config.js +2 -2
- package/dist/esm/src/constants/config.js.map +1 -1
- package/dist/esm/src/constants/events.d.ts +3 -1
- package/dist/esm/src/constants/events.d.ts.map +1 -1
- package/dist/esm/src/constants/events.js +2 -0
- package/dist/esm/src/constants/events.js.map +1 -1
- package/dist/esm/src/types/base.d.ts +10 -2
- package/dist/esm/src/types/base.d.ts.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/index.umd.min.js +1 -1
- package/dist/index.umd.min.js.map +1 -1
- package/package.json +2 -2
- package/src/FormoAnalytics.ts +293 -448
- package/src/FormoAnalyticsProvider.tsx +60 -30
- package/src/constants/config.ts +2 -2
- package/src/constants/events.ts +2 -0
- package/src/types/base.ts +16 -2
- package/dist/cjs/src/utils/index.d.ts +0 -2
- package/dist/cjs/src/utils/index.d.ts.map +0 -1
- package/dist/cjs/src/utils/index.js +0 -18
- package/dist/cjs/src/utils/index.js.map +0 -1
- package/dist/cjs/src/utils/isNotEmptyObject.d.ts +0 -2
- package/dist/cjs/src/utils/isNotEmptyObject.d.ts.map +0 -1
- package/dist/cjs/src/utils/isNotEmptyObject.js +0 -9
- package/dist/cjs/src/utils/isNotEmptyObject.js.map +0 -1
- package/dist/esm/src/utils/index.d.ts +0 -2
- package/dist/esm/src/utils/index.d.ts.map +0 -1
- package/dist/esm/src/utils/index.js +0 -2
- package/dist/esm/src/utils/index.js.map +0 -1
- package/dist/esm/src/utils/isNotEmptyObject.d.ts +0 -2
- package/dist/esm/src/utils/isNotEmptyObject.d.ts.map +0 -1
- package/dist/esm/src/utils/isNotEmptyObject.js +0 -6
- package/dist/esm/src/utils/isNotEmptyObject.js.map +0 -1
- package/src/utils/index.ts +0 -1
- package/src/utils/isNotEmptyObject.ts +0 -5
package/src/FormoAnalytics.ts
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
|
-
import axios from
|
|
1
|
+
import axios from "axios";
|
|
2
2
|
import {
|
|
3
3
|
COUNTRY_LIST,
|
|
4
|
-
|
|
5
|
-
SESSION_STORAGE_ID_KEY,
|
|
4
|
+
EVENTS_API_URL,
|
|
6
5
|
Event,
|
|
7
|
-
} from
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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: {
|
|
19
|
+
connect(params: { chainId: ChainID; address: string }): Promise<void>;
|
|
32
20
|
|
|
33
21
|
/**
|
|
34
|
-
* Disconnects the current wallet
|
|
22
|
+
* Disconnects the current wallet.
|
|
35
23
|
*/
|
|
36
|
-
disconnect(
|
|
24
|
+
disconnect(params?: { chainId?: ChainID; address?: string }): Promise<void>;
|
|
37
25
|
|
|
38
26
|
/**
|
|
39
|
-
*
|
|
27
|
+
* Switches the blockchain chain context and optionally logs additional params.
|
|
40
28
|
*/
|
|
41
|
-
|
|
29
|
+
chain(params: { chainId: ChainID; address?: string }): Promise<void>;
|
|
42
30
|
|
|
43
31
|
/**
|
|
44
|
-
*
|
|
32
|
+
* Tracks a specific event with a name and associated data.
|
|
45
33
|
*/
|
|
46
|
-
|
|
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
|
|
39
|
+
private _providerListeners: Record<
|
|
51
40
|
string,
|
|
52
41
|
(...args: unknown[]) => void
|
|
53
42
|
> = {};
|
|
54
43
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
50
|
+
public options: Options = {}
|
|
66
51
|
) {
|
|
67
52
|
this.config = {
|
|
68
|
-
|
|
53
|
+
apiKey: apiKey,
|
|
69
54
|
};
|
|
70
55
|
|
|
71
|
-
const provider =
|
|
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
|
-
|
|
65
|
+
options?: Options
|
|
80
66
|
): Promise<FormoAnalytics> {
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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.
|
|
140
|
-
|
|
141
|
-
const address = await this.getCurrentWallet();
|
|
84
|
+
this.currentChainId = chainId;
|
|
85
|
+
this.currentConnectedAddress = address;
|
|
142
86
|
|
|
143
|
-
|
|
144
|
-
|
|
87
|
+
await this.trackEvent(Event.CONNECT, {
|
|
88
|
+
chain_id: chainId,
|
|
145
89
|
address: address,
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
256
|
-
}
|
|
114
|
+
this.currentChainId = chainId;
|
|
257
115
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
116
|
+
await this.trackEvent(Event.CHAIN_CHANGED, {
|
|
117
|
+
chain_id: chainId,
|
|
118
|
+
address: address || this.currentConnectedAddress,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
262
121
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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.
|
|
142
|
+
this.currentConnectedAddress = undefined;
|
|
301
143
|
|
|
302
144
|
if (this._provider) {
|
|
303
|
-
const eventNames = Object.keys(this.
|
|
145
|
+
const eventNames = Object.keys(this._providerListeners);
|
|
304
146
|
for (const eventName of eventNames) {
|
|
305
147
|
this._provider.removeListener(
|
|
306
148
|
eventName,
|
|
307
|
-
this.
|
|
149
|
+
this._providerListeners[eventName]
|
|
308
150
|
);
|
|
309
|
-
delete this.
|
|
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.
|
|
316
|
-
this.
|
|
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(
|
|
324
|
-
this.
|
|
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
|
|
328
|
-
if (
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
338
|
-
this.
|
|
210
|
+
this.currentConnectedAddress = undefined;
|
|
211
|
+
await this.trackEvent(Event.DISCONNECT, payload);
|
|
212
|
+
}
|
|
339
213
|
|
|
340
|
-
|
|
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)
|
|
345
|
-
if (!this.
|
|
218
|
+
private async onChainChanged(chainIdHex: string): Promise<void> {
|
|
219
|
+
this.currentChainId = parseInt(chainIdHex);
|
|
220
|
+
if (!this.currentConnectedAddress) {
|
|
346
221
|
if (!this.provider) {
|
|
347
|
-
console.
|
|
348
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
account: this.currentConnectedAccount,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
237
|
+
this.currentConnectedAddress = address[0];
|
|
238
|
+
}
|
|
386
239
|
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
this.
|
|
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
|
-
|
|
247
|
+
console.log(
|
|
248
|
+
"FormoAnalytics::onChainChanged: currentConnectedAddress is null despite fetch attempt"
|
|
249
|
+
);
|
|
395
250
|
}
|
|
396
251
|
}
|
|
397
252
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
260
|
+
setTimeout(async () => {
|
|
261
|
+
this.trackEvent(Event.PAGE, {
|
|
262
|
+
pathname,
|
|
263
|
+
href,
|
|
264
|
+
});
|
|
265
|
+
}, 300);
|
|
408
266
|
}
|
|
409
267
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
307
|
+
/*
|
|
308
|
+
Utility functions
|
|
309
|
+
*/
|
|
437
310
|
|
|
438
|
-
|
|
439
|
-
this.
|
|
440
|
-
}
|
|
311
|
+
get provider(): EIP1193Provider | undefined {
|
|
312
|
+
return this._provider;
|
|
313
|
+
}
|
|
441
314
|
|
|
442
|
-
private async
|
|
315
|
+
private async getAddress(): Promise<Address | null> {
|
|
443
316
|
if (!this.provider) {
|
|
444
|
-
console.
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
356
|
+
return null;
|
|
506
357
|
}
|
|
507
|
-
return 'Error: No token provided';
|
|
508
358
|
}
|
|
509
359
|
|
|
510
|
-
|
|
511
|
-
if (!
|
|
512
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
-
|
|
583
|
-
this.
|
|
584
|
-
|
|
416
|
+
const location = this.getLocation();
|
|
417
|
+
const language = this.getLanguage();
|
|
418
|
+
const address = await this.getAddress();
|
|
585
419
|
|
|
586
|
-
|
|
587
|
-
|
|
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
|
}
|