UNPKG

@grainql/analytics-web

Version:

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

715 lines (713 loc) 23.9 kB
/* Grain Analytics Web SDK v1.6.0 | MIT License | Development Build */ "use strict"; var Grain = (() => { var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { GrainAnalytics: () => GrainAnalytics, createGrainAnalytics: () => createGrainAnalytics, default: () => src_default }); var GrainAnalytics = class { 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: 5e3, // 5 seconds retryAttempts: 3, retryDelay: 1e3, // 1 second maxEventsPerRequest: 160, // Maximum events per API request debug: false, // Remote Config defaults defaultConfigurations: {}, configCacheKey: "grain_config", configRefreshInterval: 3e5, // 5 minutes enableConfigCache: true, ...config, tenantId: config.tenantId }; 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) { const message = error.message.toLowerCase(); if (message.includes("fetch failed")) return true; if (message === "network error") return true; if (message.includes("timeout")) return true; if (message.includes("connection")) return true; } if (typeof error === "object" && error !== null && "status" in error) { const status = error.status; return status >= 500 || status === 429; } 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; } 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 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 }); 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; } } 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) { const eventsToSend = [...this.eventQueue]; this.eventQueue = []; const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); if (chunks.length > 0) { this.sendEventsWithBeacon(chunks[0]).catch(() => { }); } } }; window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("pagehide", handleBeforeUnload); 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); if (chunks.length > 0) { this.sendEventsWithBeacon(chunks[0]).catch(() => { }); } } }); } 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); 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"; 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"); } const serializedProperties = {}; for (const [key, value] of Object.entries(properties)) { if (value === null || value === void 0) { 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; } 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 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 = []; const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); 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) { if (this.configCache?.configurations?.[key]) { return this.configCache.configurations[key]; } if (this.config.defaultConfigurations?.[key]) { return this.config.defaultConfigurations[key]; } return void 0; } /** * 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(); 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 = {}) { if (!options.forceRefresh && this.configCache?.configurations?.[key]) { return this.configCache.configurations[key]; } if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) { return this.config.defaultConfigurations[key]; } try { const response = await this.fetchConfig(options); return response.configurations[key]; } catch (error) { this.log(`Failed to fetch config for key "${key}":`, error); return this.config.defaultConfigurations?.[key]; } } /** * Get all configurations asynchronously (cache-first with fallback to API) */ async getAllConfigsAsync(options = {}) { if (!options.forceRefresh && this.configCache?.configurations) { return { ...this.config.defaultConfigurations, ...this.configCache.configurations }; } try { const response = await this.fetchConfig(options); return { ...this.config.defaultConfigurations, ...response.configurations }; } catch (error) { this.log("Failed to fetch all configs:", error); 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); 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; } this.stopConfigRefreshTimer(); this.configChangeListeners = []; if (this.eventQueue.length > 0) { const eventsToSend = [...this.eventQueue]; this.eventQueue = []; const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest); if (chunks.length > 0) { this.sendEventsWithBeacon(chunks[0]).catch(() => { }); for (let i = 1; i < chunks.length; i++) { this.sendEventsWithBeacon(chunks[i]).catch(() => { }); } } } } }; function createGrainAnalytics(config) { return new GrainAnalytics(config); } var src_default = GrainAnalytics; if (typeof window !== "undefined") { window.Grain = { GrainAnalytics, createGrainAnalytics }; } return __toCommonJS(src_exports); })(); //# sourceMappingURL=index.global.dev.js.map