@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/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
+ }