UNPKG

@grainql/analytics-web

Version:

Lightweight TypeScript SDK for sending analytics events and managing remote configurations via Grain's REST API

749 lines 27.7 kB
"use strict"; /** * Grain Analytics Web SDK * A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API */ Object.defineProperty(exports, "__esModule", { value: true }); exports.GrainAnalytics = void 0; exports.createGrainAnalytics = createGrainAnalytics; class GrainAnalytics { constructor(config) { this.eventQueue = []; this.flushTimer = null; this.isDestroyed = false; this.globalUserId = null; // Remote Config properties this.configCache = null; this.configRefreshTimer = null; this.configChangeListeners = []; this.configFetchPromise = null; this.config = { apiUrl: 'https://api.grainql.com', authStrategy: 'NONE', batchSize: 50, flushInterval: 5000, // 5 seconds retryAttempts: 3, retryDelay: 1000, // 1 second maxEventsPerRequest: 160, // Maximum events per API request debug: false, // Remote Config defaults defaultConfigurations: {}, configCacheKey: 'grain_config', configRefreshInterval: 300000, // 5 minutes enableConfigCache: true, ...config, tenantId: config.tenantId, }; // Set global userId if provided in config if (config.userId) { this.globalUserId = config.userId; } this.validateConfig(); this.setupBeforeUnload(); this.startFlushTimer(); this.initializeConfigCache(); } validateConfig() { if (!this.config.tenantId) { throw new Error('Grain Analytics: tenantId is required'); } if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) { throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy'); } if (this.config.authStrategy === 'JWT' && !this.config.authProvider) { throw new Error('Grain Analytics: authProvider is required for JWT auth strategy'); } } log(...args) { if (this.config.debug) { console.log('[Grain Analytics]', ...args); } } formatEvent(event) { return { eventName: event.eventName, userId: event.userId || this.globalUserId || 'anonymous', properties: event.properties || {}, }; } async getAuthHeaders() { const headers = { 'Content-Type': 'application/json', }; switch (this.config.authStrategy) { case 'NONE': break; case 'SERVER_SIDE': headers['Authorization'] = `Chase ${this.config.secretKey}`; break; case 'JWT': if (this.config.authProvider) { const token = await this.config.authProvider.getToken(); headers['Authorization'] = `Bearer ${token}`; } break; } return headers; } async delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } isRetriableError(error) { if (error instanceof Error) { // Check for specific network or fetch errors const message = error.message.toLowerCase(); if (message.includes('fetch failed')) return true; if (message === 'network error') return true; // Exact match to avoid "Non-network error" if (message.includes('timeout')) return true; if (message.includes('connection')) return true; } // Check for HTTP status codes that are retriable if (typeof error === 'object' && error !== null && 'status' in error) { const status = error.status; return status >= 500 || status === 429; // Server errors or rate limiting } return false; } async sendEvents(events) { if (events.length === 0) return; let lastError; for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) { try { const headers = await this.getAuthHeaders(); const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`; this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`); const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ events }), }); if (!response.ok) { let errorMessage = `HTTP ${response.status}`; try { const errorBody = await response.json(); if (errorBody?.message) { errorMessage = errorBody.message; } } catch { const errorText = await response.text(); if (errorText) { errorMessage = errorText; } } const error = new Error(`Failed to send events: ${errorMessage}`); error.status = response.status; throw error; } this.log(`Successfully sent ${events.length} events`); return; // Success, exit retry loop } catch (error) { lastError = error; if (attempt === this.config.retryAttempts) { // Last attempt, don't retry break; } if (!this.isRetriableError(error)) { // Non-retriable error, don't retry break; } const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff this.log(`Retrying in ${delayMs}ms after error:`, error); await this.delay(delayMs); } } console.error('[Grain Analytics] Failed to send events after all retries:', lastError); throw lastError; } async sendEventsWithBeacon(events) { if (events.length === 0) return; try { const headers = await this.getAuthHeaders(); const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`; const body = JSON.stringify({ events }); // Try beacon API first (more reliable for page unload) if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) { const blob = new Blob([body], { type: 'application/json' }); const success = navigator.sendBeacon(url, blob); if (success) { this.log(`Successfully sent ${events.length} events via beacon`); return; } } // Fallback to fetch with keepalive await fetch(url, { method: 'POST', headers, body, keepalive: true, }); this.log(`Successfully sent ${events.length} events via fetch (keepalive)`); } catch (error) { console.error('[Grain Analytics] Failed to send events via beacon:', error); } } startFlushTimer() { if (this.flushTimer) { clearInterval(this.flushTimer); } this.flushTimer = window.setInterval(() => { if (this.eventQueue.length > 0) { this.flush().catch((error) => { console.error('[Grain Analytics] Auto-flush failed:', error); }); } }, this.config.flushInterval); } setupBeforeUnload() { if (typeof window === 'undefined') return; const handleBeforeUnload = () => { if (this.eventQueue.length > 0) { // Use beacon API for reliable delivery during page unload const eventsToSend = [...this.eventQueue]; this.eventQueue = []; const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); // Send first chunk with beacon (most important for page unload) if (chunks.length > 0) { this.sendEventsWithBeacon(chunks[0]).catch(() => { // Silently fail - page is unloading }); } } }; // Handle page unload window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('pagehide', handleBeforeUnload); // Handle visibility change (page hidden) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) { const eventsToSend = [...this.eventQueue]; this.eventQueue = []; const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); // Send first chunk with beacon (most important for page hidden) if (chunks.length > 0) { this.sendEventsWithBeacon(chunks[0]).catch(() => { // Silently fail }); } } }); } async track(eventOrName, propertiesOrOptions, options) { if (this.isDestroyed) { throw new Error('Grain Analytics: Client has been destroyed'); } let event; let opts = {}; if (typeof eventOrName === 'string') { event = { eventName: eventOrName, properties: propertiesOrOptions, }; opts = options || {}; } else { event = eventOrName; opts = propertiesOrOptions || {}; } const formattedEvent = this.formatEvent(event); this.eventQueue.push(formattedEvent); this.log(`Queued event: ${event.eventName}`, event.properties); // Check if we should flush immediately if (opts.flush || this.eventQueue.length >= this.config.batchSize) { await this.flush(); } } /** * Identify a user (sets userId for subsequent events) */ identify(userId) { this.log(`Identified user: ${userId}`); this.globalUserId = userId; } /** * Set global user ID for all subsequent events */ setUserId(userId) { this.log(`Set global user ID: ${userId}`); this.globalUserId = userId; } /** * Get current global user ID */ getUserId() { return this.globalUserId; } /** * Set user properties */ async setProperty(properties, options) { if (this.isDestroyed) { throw new Error('Grain Analytics: Client has been destroyed'); } const userId = options?.userId || this.globalUserId || 'anonymous'; // Validate property count (max 4 properties) const propertyKeys = Object.keys(properties); if (propertyKeys.length > 4) { throw new Error('Grain Analytics: Maximum 4 properties allowed per request'); } if (propertyKeys.length === 0) { throw new Error('Grain Analytics: At least one property is required'); } // Serialize all values to strings const serializedProperties = {}; for (const [key, value] of Object.entries(properties)) { if (value === null || value === undefined) { serializedProperties[key] = ''; } else if (typeof value === 'string') { serializedProperties[key] = value; } else { serializedProperties[key] = JSON.stringify(value); } } const payload = { userId, ...serializedProperties, }; await this.sendProperties(payload); } /** * Send properties to the API */ async sendProperties(payload) { let lastError; for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) { try { const headers = await this.getAuthHeaders(); const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`; this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`); const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload), }); if (!response.ok) { let errorMessage = `HTTP ${response.status}`; try { const errorBody = await response.json(); if (errorBody?.message) { errorMessage = errorBody.message; } } catch { const errorText = await response.text(); if (errorText) { errorMessage = errorText; } } const error = new Error(`Failed to set properties: ${errorMessage}`); error.status = response.status; throw error; } this.log(`Successfully set properties for user ${payload.userId}`); return; // Success, exit retry loop } catch (error) { lastError = error; if (attempt === this.config.retryAttempts) { // Last attempt, don't retry break; } if (!this.isRetriableError(error)) { // Non-retriable error, don't retry break; } const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff this.log(`Retrying in ${delayMs}ms after error:`, error); await this.delay(delayMs); } } console.error('[Grain Analytics] Failed to set properties after all retries:', lastError); throw lastError; } // Template event methods /** * Track user login event */ async trackLogin(properties, options) { return this.track('login', properties, options); } /** * Track user signup event */ async trackSignup(properties, options) { return this.track('signup', properties, options); } /** * Track checkout event */ async trackCheckout(properties, options) { return this.track('checkout', properties, options); } /** * Track page view event */ async trackPageView(properties, options) { return this.track('page_view', properties, options); } /** * Track purchase event */ async trackPurchase(properties, options) { return this.track('purchase', properties, options); } /** * Track search event */ async trackSearch(properties, options) { return this.track('search', properties, options); } /** * Track add to cart event */ async trackAddToCart(properties, options) { return this.track('add_to_cart', properties, options); } /** * Track remove from cart event */ async trackRemoveFromCart(properties, options) { return this.track('remove_from_cart', properties, options); } /** * Manually flush all queued events */ async flush() { if (this.eventQueue.length === 0) return; const eventsToSend = [...this.eventQueue]; this.eventQueue = []; // Split events into chunks to respect maxEventsPerRequest limit const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); // Send all chunks sequentially to maintain order for (const chunk of chunks) { await this.sendEvents(chunk); } } // Remote Config Methods /** * Initialize configuration cache from localStorage */ initializeConfigCache() { if (!this.config.enableConfigCache || typeof window === 'undefined') return; try { const cached = localStorage.getItem(this.config.configCacheKey); if (cached) { this.configCache = JSON.parse(cached); this.log('Loaded configuration from cache:', this.configCache); } } catch (error) { this.log('Failed to load configuration cache:', error); } } /** * Save configuration cache to localStorage */ saveConfigCache(cache) { if (!this.config.enableConfigCache || typeof window === 'undefined') return; try { localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache)); this.log('Saved configuration to cache:', cache); } catch (error) { this.log('Failed to save configuration cache:', error); } } /** * Get configuration value with fallback to defaults */ getConfig(key) { // First check cache if (this.configCache?.configurations?.[key]) { return this.configCache.configurations[key]; } // Then check defaults if (this.config.defaultConfigurations?.[key]) { return this.config.defaultConfigurations[key]; } return undefined; } /** * Get all configurations with fallback to defaults */ getAllConfigs() { const configs = { ...this.config.defaultConfigurations }; if (this.configCache?.configurations) { Object.assign(configs, this.configCache.configurations); } return configs; } /** * Fetch configurations from API */ async fetchConfig(options = {}) { if (this.isDestroyed) { throw new Error('Grain Analytics: Client has been destroyed'); } const userId = options.userId || this.globalUserId || 'anonymous'; const immediateKeys = options.immediateKeys || []; const properties = options.properties || {}; const request = { userId, immediateKeys, properties, }; let lastError; for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) { try { const headers = await this.getAuthHeaders(); const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`; this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`); const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(request), }); if (!response.ok) { let errorMessage = `HTTP ${response.status}`; try { const errorBody = await response.json(); if (errorBody?.message) { errorMessage = errorBody.message; } } catch { const errorText = await response.text(); if (errorText) { errorMessage = errorText; } } const error = new Error(`Failed to fetch configurations: ${errorMessage}`); error.status = response.status; throw error; } const configResponse = await response.json(); // Update cache if successful if (configResponse.configurations) { this.updateConfigCache(configResponse, userId); } this.log(`Successfully fetched configurations for user ${userId}:`, configResponse); return configResponse; } catch (error) { lastError = error; if (attempt === this.config.retryAttempts) { break; } if (!this.isRetriableError(error)) { break; } const delayMs = this.config.retryDelay * Math.pow(2, attempt); this.log(`Retrying config fetch in ${delayMs}ms after error:`, error); await this.delay(delayMs); } } console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError); throw lastError; } /** * Get configuration asynchronously (cache-first with fallback to API) */ async getConfigAsync(key, options = {}) { // Return immediately if we have it in cache and not forcing refresh if (!options.forceRefresh && this.configCache?.configurations?.[key]) { return this.configCache.configurations[key]; } // Return default if available and not forcing refresh if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) { return this.config.defaultConfigurations[key]; } // Fetch from API try { const response = await this.fetchConfig(options); return response.configurations[key]; } catch (error) { this.log(`Failed to fetch config for key "${key}":`, error); // Return default as fallback return this.config.defaultConfigurations?.[key]; } } /** * Get all configurations asynchronously (cache-first with fallback to API) */ async getAllConfigsAsync(options = {}) { // Return cache if available and not forcing refresh if (!options.forceRefresh && this.configCache?.configurations) { return { ...this.config.defaultConfigurations, ...this.configCache.configurations }; } // Fetch from API try { const response = await this.fetchConfig(options); return { ...this.config.defaultConfigurations, ...response.configurations }; } catch (error) { this.log('Failed to fetch all configs:', error); // Return defaults as fallback return { ...this.config.defaultConfigurations }; } } /** * Update configuration cache and notify listeners */ updateConfigCache(response, userId) { const newCache = { configurations: response.configurations, snapshotId: response.snapshotId, timestamp: response.timestamp, userId, }; const oldConfigs = this.configCache?.configurations || {}; this.configCache = newCache; this.saveConfigCache(newCache); // Notify listeners if configurations changed if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) { this.notifyConfigChangeListeners(response.configurations); } } /** * Add configuration change listener */ addConfigChangeListener(listener) { this.configChangeListeners.push(listener); } /** * Remove configuration change listener */ removeConfigChangeListener(listener) { const index = this.configChangeListeners.indexOf(listener); if (index > -1) { this.configChangeListeners.splice(index, 1); } } /** * Notify all configuration change listeners */ notifyConfigChangeListeners(configurations) { this.configChangeListeners.forEach(listener => { try { listener(configurations); } catch (error) { console.error('[Grain Analytics] Config change listener error:', error); } }); } /** * Start automatic configuration refresh timer */ startConfigRefreshTimer() { if (this.configRefreshTimer) { clearInterval(this.configRefreshTimer); } this.configRefreshTimer = window.setInterval(() => { if (!this.isDestroyed && this.globalUserId) { this.fetchConfig().catch((error) => { console.error('[Grain Analytics] Auto-config refresh failed:', error); }); } }, this.config.configRefreshInterval); } /** * Stop automatic configuration refresh timer */ stopConfigRefreshTimer() { if (this.configRefreshTimer) { clearInterval(this.configRefreshTimer); this.configRefreshTimer = null; } } /** * Preload configurations for immediate access */ async preloadConfig(immediateKeys = [], properties) { if (!this.globalUserId) { this.log('Cannot preload config: no user ID set'); return; } try { await this.fetchConfig({ immediateKeys, properties }); this.startConfigRefreshTimer(); } catch (error) { this.log('Failed to preload config:', error); } } /** * Split events array into chunks of specified size */ chunkEvents(events, chunkSize) { const chunks = []; for (let i = 0; i < events.length; i += chunkSize) { chunks.push(events.slice(i, i + chunkSize)); } return chunks; } /** * Destroy the client and clean up resources */ destroy() { this.isDestroyed = true; if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } // Stop config refresh timer this.stopConfigRefreshTimer(); // Clear config change listeners this.configChangeListeners = []; // Send any remaining events (in chunks if necessary) if (this.eventQueue.length > 0) { const eventsToSend = [...this.eventQueue]; this.eventQueue = []; const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); // Send first chunk with beacon (most important for page unload) if (chunks.length > 0) { this.sendEventsWithBeacon(chunks[0]).catch(() => { // Silently fail during cleanup }); // If there are more chunks, try to send them with regular fetch for (let i = 1; i < chunks.length; i++) { this.sendEventsWithBeacon(chunks[i]).catch(() => { // Silently fail during cleanup }); } } } } } exports.GrainAnalytics = GrainAnalytics; /** * Create a new Grain Analytics client */ function createGrainAnalytics(config) { return new GrainAnalytics(config); } // Default export for convenience exports.default = GrainAnalytics; // Auto-setup for IIFE build if (typeof window !== 'undefined') { window.Grain = { GrainAnalytics, createGrainAnalytics, }; } //# sourceMappingURL=index.js.map