@grainql/analytics-web 1.0.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/README.md +228 -0
- package/dist/cjs/index.d.ts +86 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.d.ts +86 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.global.dev.js +292 -0
- package/dist/index.global.dev.js.map +7 -0
- package/dist/index.global.js +3 -0
- package/dist/index.global.js.map +7 -0
- package/dist/index.js +294 -0
- package/dist/index.mjs +289 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Grain Analytics Web SDK
|
|
4
|
+
* A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.GrainAnalytics = void 0;
|
|
8
|
+
exports.createGrainAnalytics = createGrainAnalytics;
|
|
9
|
+
class GrainAnalytics {
|
|
10
|
+
constructor(config) {
|
|
11
|
+
this.eventQueue = [];
|
|
12
|
+
this.flushTimer = null;
|
|
13
|
+
this.isDestroyed = false;
|
|
14
|
+
this.config = {
|
|
15
|
+
apiUrl: 'https://api.grainql.com',
|
|
16
|
+
authStrategy: 'NONE',
|
|
17
|
+
batchSize: 50,
|
|
18
|
+
flushInterval: 5000, // 5 seconds
|
|
19
|
+
retryAttempts: 3,
|
|
20
|
+
retryDelay: 1000, // 1 second
|
|
21
|
+
debug: false,
|
|
22
|
+
...config,
|
|
23
|
+
tenantId: config.tenantId,
|
|
24
|
+
};
|
|
25
|
+
this.validateConfig();
|
|
26
|
+
this.setupBeforeUnload();
|
|
27
|
+
this.startFlushTimer();
|
|
28
|
+
}
|
|
29
|
+
validateConfig() {
|
|
30
|
+
if (!this.config.tenantId) {
|
|
31
|
+
throw new Error('Grain Analytics: tenantId is required');
|
|
32
|
+
}
|
|
33
|
+
if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) {
|
|
34
|
+
throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy');
|
|
35
|
+
}
|
|
36
|
+
if (this.config.authStrategy === 'JWT' && !this.config.authProvider) {
|
|
37
|
+
throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
log(...args) {
|
|
41
|
+
if (this.config.debug) {
|
|
42
|
+
console.log('[Grain Analytics]', ...args);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
generateInsertId() {
|
|
46
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
47
|
+
}
|
|
48
|
+
formatEvent(event) {
|
|
49
|
+
const timestamp = event.timestamp || new Date();
|
|
50
|
+
const eventTs = timestamp.toISOString();
|
|
51
|
+
const eventDate = timestamp.toISOString().split('T')[0];
|
|
52
|
+
return {
|
|
53
|
+
eventName: event.eventName,
|
|
54
|
+
eventTs,
|
|
55
|
+
userId: event.userId || 'anonymous',
|
|
56
|
+
properties: event.properties || {},
|
|
57
|
+
eventDate,
|
|
58
|
+
insertId: this.generateInsertId(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async getAuthHeaders() {
|
|
62
|
+
const headers = {
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
};
|
|
65
|
+
switch (this.config.authStrategy) {
|
|
66
|
+
case 'NONE':
|
|
67
|
+
break;
|
|
68
|
+
case 'SERVER_SIDE':
|
|
69
|
+
headers['Authorization'] = `Chase ${this.config.secretKey}`;
|
|
70
|
+
break;
|
|
71
|
+
case 'JWT':
|
|
72
|
+
if (this.config.authProvider) {
|
|
73
|
+
const token = await this.config.authProvider.getToken();
|
|
74
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
79
|
+
}
|
|
80
|
+
async delay(ms) {
|
|
81
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
82
|
+
}
|
|
83
|
+
isRetriableError(error) {
|
|
84
|
+
if (error instanceof Error) {
|
|
85
|
+
// Check for network errors or server errors
|
|
86
|
+
if (error.message.includes('fetch'))
|
|
87
|
+
return true;
|
|
88
|
+
if (error.message.includes('network'))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
// Check for HTTP status codes that are retriable
|
|
92
|
+
if (typeof error === 'object' && error !== null && 'status' in error) {
|
|
93
|
+
const status = error.status;
|
|
94
|
+
return status >= 500 || status === 429; // Server errors or rate limiting
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
async sendEvents(events) {
|
|
99
|
+
if (events.length === 0)
|
|
100
|
+
return;
|
|
101
|
+
let lastError;
|
|
102
|
+
for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
|
|
103
|
+
try {
|
|
104
|
+
const headers = await this.getAuthHeaders();
|
|
105
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
|
|
106
|
+
this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
|
|
107
|
+
const response = await fetch(url, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers,
|
|
110
|
+
body: JSON.stringify({ events }),
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
114
|
+
try {
|
|
115
|
+
const errorBody = await response.json();
|
|
116
|
+
if (errorBody?.message) {
|
|
117
|
+
errorMessage = errorBody.message;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
const errorText = await response.text();
|
|
122
|
+
if (errorText) {
|
|
123
|
+
errorMessage = errorText;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const error = new Error(`Failed to send events: ${errorMessage}`);
|
|
127
|
+
error.status = response.status;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
this.log(`Successfully sent ${events.length} events`);
|
|
131
|
+
return; // Success, exit retry loop
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
lastError = error;
|
|
135
|
+
if (attempt === this.config.retryAttempts) {
|
|
136
|
+
// Last attempt, don't retry
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (!this.isRetriableError(error)) {
|
|
140
|
+
// Non-retriable error, don't retry
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
|
|
144
|
+
this.log(`Retrying in ${delayMs}ms after error:`, error);
|
|
145
|
+
await this.delay(delayMs);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.error('[Grain Analytics] Failed to send events after all retries:', lastError);
|
|
149
|
+
throw lastError;
|
|
150
|
+
}
|
|
151
|
+
async sendEventsWithBeacon(events) {
|
|
152
|
+
if (events.length === 0)
|
|
153
|
+
return;
|
|
154
|
+
try {
|
|
155
|
+
const headers = await this.getAuthHeaders();
|
|
156
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
|
|
157
|
+
const body = JSON.stringify({ events });
|
|
158
|
+
// Try beacon API first (more reliable for page unload)
|
|
159
|
+
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
|
|
160
|
+
const blob = new Blob([body], { type: 'application/json' });
|
|
161
|
+
const success = navigator.sendBeacon(url, blob);
|
|
162
|
+
if (success) {
|
|
163
|
+
this.log(`Successfully sent ${events.length} events via beacon`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Fallback to fetch with keepalive
|
|
168
|
+
await fetch(url, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers,
|
|
171
|
+
body,
|
|
172
|
+
keepalive: true,
|
|
173
|
+
});
|
|
174
|
+
this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
console.error('[Grain Analytics] Failed to send events via beacon:', error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
startFlushTimer() {
|
|
181
|
+
if (this.flushTimer) {
|
|
182
|
+
clearInterval(this.flushTimer);
|
|
183
|
+
}
|
|
184
|
+
this.flushTimer = window.setInterval(() => {
|
|
185
|
+
if (this.eventQueue.length > 0) {
|
|
186
|
+
this.flush().catch((error) => {
|
|
187
|
+
console.error('[Grain Analytics] Auto-flush failed:', error);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}, this.config.flushInterval);
|
|
191
|
+
}
|
|
192
|
+
setupBeforeUnload() {
|
|
193
|
+
if (typeof window === 'undefined')
|
|
194
|
+
return;
|
|
195
|
+
const handleBeforeUnload = () => {
|
|
196
|
+
if (this.eventQueue.length > 0) {
|
|
197
|
+
// Use beacon API for reliable delivery during page unload
|
|
198
|
+
this.sendEventsWithBeacon([...this.eventQueue]).catch(() => {
|
|
199
|
+
// Silently fail - page is unloading
|
|
200
|
+
});
|
|
201
|
+
this.eventQueue = [];
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
// Handle page unload
|
|
205
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
206
|
+
window.addEventListener('pagehide', handleBeforeUnload);
|
|
207
|
+
// Handle visibility change (page hidden)
|
|
208
|
+
document.addEventListener('visibilitychange', () => {
|
|
209
|
+
if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) {
|
|
210
|
+
this.sendEventsWithBeacon([...this.eventQueue]).catch(() => {
|
|
211
|
+
// Silently fail
|
|
212
|
+
});
|
|
213
|
+
this.eventQueue = [];
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async track(eventOrName, propertiesOrOptions, options) {
|
|
218
|
+
if (this.isDestroyed) {
|
|
219
|
+
throw new Error('Grain Analytics: Client has been destroyed');
|
|
220
|
+
}
|
|
221
|
+
let event;
|
|
222
|
+
let opts = {};
|
|
223
|
+
if (typeof eventOrName === 'string') {
|
|
224
|
+
event = {
|
|
225
|
+
eventName: eventOrName,
|
|
226
|
+
properties: propertiesOrOptions,
|
|
227
|
+
};
|
|
228
|
+
opts = options || {};
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
event = eventOrName;
|
|
232
|
+
opts = propertiesOrOptions || {};
|
|
233
|
+
}
|
|
234
|
+
const formattedEvent = this.formatEvent(event);
|
|
235
|
+
this.eventQueue.push(formattedEvent);
|
|
236
|
+
this.log(`Queued event: ${event.eventName}`, event.properties);
|
|
237
|
+
// Check if we should flush immediately
|
|
238
|
+
if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
|
|
239
|
+
await this.flush();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Identify a user (sets userId for subsequent events)
|
|
244
|
+
*/
|
|
245
|
+
identify(userId) {
|
|
246
|
+
// Store userId for future events - this would typically be handled
|
|
247
|
+
// by the application layer, but we can provide a helper
|
|
248
|
+
this.log(`Identified user: ${userId}`);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Manually flush all queued events
|
|
252
|
+
*/
|
|
253
|
+
async flush() {
|
|
254
|
+
if (this.eventQueue.length === 0)
|
|
255
|
+
return;
|
|
256
|
+
const eventsToSend = [...this.eventQueue];
|
|
257
|
+
this.eventQueue = [];
|
|
258
|
+
await this.sendEvents(eventsToSend);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Destroy the client and clean up resources
|
|
262
|
+
*/
|
|
263
|
+
destroy() {
|
|
264
|
+
this.isDestroyed = true;
|
|
265
|
+
if (this.flushTimer) {
|
|
266
|
+
clearInterval(this.flushTimer);
|
|
267
|
+
this.flushTimer = null;
|
|
268
|
+
}
|
|
269
|
+
// Send any remaining events
|
|
270
|
+
if (this.eventQueue.length > 0) {
|
|
271
|
+
this.sendEventsWithBeacon([...this.eventQueue]).catch(() => {
|
|
272
|
+
// Silently fail during cleanup
|
|
273
|
+
});
|
|
274
|
+
this.eventQueue = [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
exports.GrainAnalytics = GrainAnalytics;
|
|
279
|
+
/**
|
|
280
|
+
* Create a new Grain Analytics client
|
|
281
|
+
*/
|
|
282
|
+
function createGrainAnalytics(config) {
|
|
283
|
+
return new GrainAnalytics(config);
|
|
284
|
+
}
|
|
285
|
+
// Default export for convenience
|
|
286
|
+
exports.default = GrainAnalytics;
|
|
287
|
+
// Auto-setup for IIFE build
|
|
288
|
+
if (typeof window !== 'undefined') {
|
|
289
|
+
window.Grain = {
|
|
290
|
+
GrainAnalytics,
|
|
291
|
+
createGrainAnalytics,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
//# sourceMappingURL=index.js.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grain Analytics Web SDK
|
|
3
|
+
* A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API
|
|
4
|
+
*/
|
|
5
|
+
export class GrainAnalytics {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.eventQueue = [];
|
|
8
|
+
this.flushTimer = null;
|
|
9
|
+
this.isDestroyed = false;
|
|
10
|
+
this.config = {
|
|
11
|
+
apiUrl: 'https://api.grainql.com',
|
|
12
|
+
authStrategy: 'NONE',
|
|
13
|
+
batchSize: 50,
|
|
14
|
+
flushInterval: 5000, // 5 seconds
|
|
15
|
+
retryAttempts: 3,
|
|
16
|
+
retryDelay: 1000, // 1 second
|
|
17
|
+
debug: false,
|
|
18
|
+
...config,
|
|
19
|
+
tenantId: config.tenantId,
|
|
20
|
+
};
|
|
21
|
+
this.validateConfig();
|
|
22
|
+
this.setupBeforeUnload();
|
|
23
|
+
this.startFlushTimer();
|
|
24
|
+
}
|
|
25
|
+
validateConfig() {
|
|
26
|
+
if (!this.config.tenantId) {
|
|
27
|
+
throw new Error('Grain Analytics: tenantId is required');
|
|
28
|
+
}
|
|
29
|
+
if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) {
|
|
30
|
+
throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy');
|
|
31
|
+
}
|
|
32
|
+
if (this.config.authStrategy === 'JWT' && !this.config.authProvider) {
|
|
33
|
+
throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
log(...args) {
|
|
37
|
+
if (this.config.debug) {
|
|
38
|
+
console.log('[Grain Analytics]', ...args);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
generateInsertId() {
|
|
42
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
43
|
+
}
|
|
44
|
+
formatEvent(event) {
|
|
45
|
+
const timestamp = event.timestamp || new Date();
|
|
46
|
+
const eventTs = timestamp.toISOString();
|
|
47
|
+
const eventDate = timestamp.toISOString().split('T')[0];
|
|
48
|
+
return {
|
|
49
|
+
eventName: event.eventName,
|
|
50
|
+
eventTs,
|
|
51
|
+
userId: event.userId || 'anonymous',
|
|
52
|
+
properties: event.properties || {},
|
|
53
|
+
eventDate,
|
|
54
|
+
insertId: this.generateInsertId(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async getAuthHeaders() {
|
|
58
|
+
const headers = {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
};
|
|
61
|
+
switch (this.config.authStrategy) {
|
|
62
|
+
case 'NONE':
|
|
63
|
+
break;
|
|
64
|
+
case 'SERVER_SIDE':
|
|
65
|
+
headers['Authorization'] = `Chase ${this.config.secretKey}`;
|
|
66
|
+
break;
|
|
67
|
+
case 'JWT':
|
|
68
|
+
if (this.config.authProvider) {
|
|
69
|
+
const token = await this.config.authProvider.getToken();
|
|
70
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
async delay(ms) {
|
|
77
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
isRetriableError(error) {
|
|
80
|
+
if (error instanceof Error) {
|
|
81
|
+
// Check for network errors or server errors
|
|
82
|
+
if (error.message.includes('fetch'))
|
|
83
|
+
return true;
|
|
84
|
+
if (error.message.includes('network'))
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
// Check for HTTP status codes that are retriable
|
|
88
|
+
if (typeof error === 'object' && error !== null && 'status' in error) {
|
|
89
|
+
const status = error.status;
|
|
90
|
+
return status >= 500 || status === 429; // Server errors or rate limiting
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
async sendEvents(events) {
|
|
95
|
+
if (events.length === 0)
|
|
96
|
+
return;
|
|
97
|
+
let lastError;
|
|
98
|
+
for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
const headers = await this.getAuthHeaders();
|
|
101
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
|
|
102
|
+
this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
|
|
103
|
+
const response = await fetch(url, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers,
|
|
106
|
+
body: JSON.stringify({ events }),
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
110
|
+
try {
|
|
111
|
+
const errorBody = await response.json();
|
|
112
|
+
if (errorBody?.message) {
|
|
113
|
+
errorMessage = errorBody.message;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
const errorText = await response.text();
|
|
118
|
+
if (errorText) {
|
|
119
|
+
errorMessage = errorText;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const error = new Error(`Failed to send events: ${errorMessage}`);
|
|
123
|
+
error.status = response.status;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
this.log(`Successfully sent ${events.length} events`);
|
|
127
|
+
return; // Success, exit retry loop
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
lastError = error;
|
|
131
|
+
if (attempt === this.config.retryAttempts) {
|
|
132
|
+
// Last attempt, don't retry
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (!this.isRetriableError(error)) {
|
|
136
|
+
// Non-retriable error, don't retry
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
|
|
140
|
+
this.log(`Retrying in ${delayMs}ms after error:`, error);
|
|
141
|
+
await this.delay(delayMs);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
console.error('[Grain Analytics] Failed to send events after all retries:', lastError);
|
|
145
|
+
throw lastError;
|
|
146
|
+
}
|
|
147
|
+
async sendEventsWithBeacon(events) {
|
|
148
|
+
if (events.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
const headers = await this.getAuthHeaders();
|
|
152
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
|
|
153
|
+
const body = JSON.stringify({ events });
|
|
154
|
+
// Try beacon API first (more reliable for page unload)
|
|
155
|
+
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
|
|
156
|
+
const blob = new Blob([body], { type: 'application/json' });
|
|
157
|
+
const success = navigator.sendBeacon(url, blob);
|
|
158
|
+
if (success) {
|
|
159
|
+
this.log(`Successfully sent ${events.length} events via beacon`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Fallback to fetch with keepalive
|
|
164
|
+
await fetch(url, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers,
|
|
167
|
+
body,
|
|
168
|
+
keepalive: true,
|
|
169
|
+
});
|
|
170
|
+
this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
console.error('[Grain Analytics] Failed to send events via beacon:', error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
startFlushTimer() {
|
|
177
|
+
if (this.flushTimer) {
|
|
178
|
+
clearInterval(this.flushTimer);
|
|
179
|
+
}
|
|
180
|
+
this.flushTimer = window.setInterval(() => {
|
|
181
|
+
if (this.eventQueue.length > 0) {
|
|
182
|
+
this.flush().catch((error) => {
|
|
183
|
+
console.error('[Grain Analytics] Auto-flush failed:', error);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}, this.config.flushInterval);
|
|
187
|
+
}
|
|
188
|
+
setupBeforeUnload() {
|
|
189
|
+
if (typeof window === 'undefined')
|
|
190
|
+
return;
|
|
191
|
+
const handleBeforeUnload = () => {
|
|
192
|
+
if (this.eventQueue.length > 0) {
|
|
193
|
+
// Use beacon API for reliable delivery during page unload
|
|
194
|
+
this.sendEventsWithBeacon([...this.eventQueue]).catch(() => {
|
|
195
|
+
// Silently fail - page is unloading
|
|
196
|
+
});
|
|
197
|
+
this.eventQueue = [];
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
// Handle page unload
|
|
201
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
202
|
+
window.addEventListener('pagehide', handleBeforeUnload);
|
|
203
|
+
// Handle visibility change (page hidden)
|
|
204
|
+
document.addEventListener('visibilitychange', () => {
|
|
205
|
+
if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) {
|
|
206
|
+
this.sendEventsWithBeacon([...this.eventQueue]).catch(() => {
|
|
207
|
+
// Silently fail
|
|
208
|
+
});
|
|
209
|
+
this.eventQueue = [];
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async track(eventOrName, propertiesOrOptions, options) {
|
|
214
|
+
if (this.isDestroyed) {
|
|
215
|
+
throw new Error('Grain Analytics: Client has been destroyed');
|
|
216
|
+
}
|
|
217
|
+
let event;
|
|
218
|
+
let opts = {};
|
|
219
|
+
if (typeof eventOrName === 'string') {
|
|
220
|
+
event = {
|
|
221
|
+
eventName: eventOrName,
|
|
222
|
+
properties: propertiesOrOptions,
|
|
223
|
+
};
|
|
224
|
+
opts = options || {};
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
event = eventOrName;
|
|
228
|
+
opts = propertiesOrOptions || {};
|
|
229
|
+
}
|
|
230
|
+
const formattedEvent = this.formatEvent(event);
|
|
231
|
+
this.eventQueue.push(formattedEvent);
|
|
232
|
+
this.log(`Queued event: ${event.eventName}`, event.properties);
|
|
233
|
+
// Check if we should flush immediately
|
|
234
|
+
if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
|
|
235
|
+
await this.flush();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Identify a user (sets userId for subsequent events)
|
|
240
|
+
*/
|
|
241
|
+
identify(userId) {
|
|
242
|
+
// Store userId for future events - this would typically be handled
|
|
243
|
+
// by the application layer, but we can provide a helper
|
|
244
|
+
this.log(`Identified user: ${userId}`);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Manually flush all queued events
|
|
248
|
+
*/
|
|
249
|
+
async flush() {
|
|
250
|
+
if (this.eventQueue.length === 0)
|
|
251
|
+
return;
|
|
252
|
+
const eventsToSend = [...this.eventQueue];
|
|
253
|
+
this.eventQueue = [];
|
|
254
|
+
await this.sendEvents(eventsToSend);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Destroy the client and clean up resources
|
|
258
|
+
*/
|
|
259
|
+
destroy() {
|
|
260
|
+
this.isDestroyed = true;
|
|
261
|
+
if (this.flushTimer) {
|
|
262
|
+
clearInterval(this.flushTimer);
|
|
263
|
+
this.flushTimer = null;
|
|
264
|
+
}
|
|
265
|
+
// Send any remaining events
|
|
266
|
+
if (this.eventQueue.length > 0) {
|
|
267
|
+
this.sendEventsWithBeacon([...this.eventQueue]).catch(() => {
|
|
268
|
+
// Silently fail during cleanup
|
|
269
|
+
});
|
|
270
|
+
this.eventQueue = [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Create a new Grain Analytics client
|
|
276
|
+
*/
|
|
277
|
+
export function createGrainAnalytics(config) {
|
|
278
|
+
return new GrainAnalytics(config);
|
|
279
|
+
}
|
|
280
|
+
// Default export for convenience
|
|
281
|
+
export default GrainAnalytics;
|
|
282
|
+
// Auto-setup for IIFE build
|
|
283
|
+
if (typeof window !== 'undefined') {
|
|
284
|
+
window.Grain = {
|
|
285
|
+
GrainAnalytics,
|
|
286
|
+
createGrainAnalytics,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grainql/analytics-web",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight TypeScript SDK for sending analytics events to Grain's REST API",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.mjs",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npm run build:types && npm run build:esm && npm run build:cjs && npm run build:iife",
|
|
20
|
+
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist",
|
|
21
|
+
"build:esm": "tsc --module esnext --target es2020 --outDir dist/esm && mv dist/esm/index.js dist/index.mjs",
|
|
22
|
+
"build:cjs": "tsc --module commonjs --target es2020 --outDir dist/cjs && mv dist/cjs/index.js dist/index.js",
|
|
23
|
+
"build:iife": "node scripts/build-iife.js",
|
|
24
|
+
"clean": "rm -rf dist",
|
|
25
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"analytics",
|
|
29
|
+
"tracking",
|
|
30
|
+
"events",
|
|
31
|
+
"grain",
|
|
32
|
+
"typescript",
|
|
33
|
+
"sdk"
|
|
34
|
+
],
|
|
35
|
+
"author": "Grain Analytics",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"typescript": "^5.5.3",
|
|
40
|
+
"esbuild": "^0.19.0"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/grain-analytics/web-sdk.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://grainql.com",
|
|
47
|
+
"private": false
|
|
48
|
+
}
|