@grainql/analytics-web 1.3.0 → 1.6.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["/**\n * Grain Analytics Web SDK\n * A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API\n */\n\nexport interface GrainEvent {\n eventName: string;\n userId?: string;\n properties?: Record<string, unknown>;\n timestamp?: Date;\n}\n\nexport interface EventPayload {\n eventName: string;\n userId: string;\n properties: Record<string, unknown>;\n}\n\nexport type AuthStrategy = 'NONE' | 'SERVER_SIDE' | 'JWT';\n\nexport interface AuthProvider {\n getToken(): Promise<string> | string;\n}\n\nexport interface GrainConfig {\n tenantId: string;\n apiUrl?: string;\n authStrategy?: AuthStrategy;\n secretKey?: string; // For SERVER_SIDE auth\n authProvider?: AuthProvider; // For JWT auth\n userId?: string; // Global user ID for all events\n batchSize?: number;\n flushInterval?: number; // milliseconds\n retryAttempts?: number;\n retryDelay?: number; // milliseconds\n maxEventsPerRequest?: number; // Maximum events to send in a single API request\n debug?: boolean;\n}\n\nexport interface SendEventOptions {\n flush?: boolean; // Force immediate send\n}\n\n// Template event interfaces\nexport interface LoginEventProperties extends Record<string, unknown> {\n method?: string; // 'email', 'google', 'facebook', etc.\n success?: boolean;\n errorMessage?: string;\n loginAttempt?: number;\n rememberMe?: boolean;\n twoFactorEnabled?: boolean;\n}\n\nexport interface SignupEventProperties extends Record<string, unknown> {\n method?: string; // 'email', 'google', 'facebook', etc.\n source?: string; // 'landing_page', 'referral', 'ad', etc.\n plan?: string; // 'free', 'pro', 'enterprise', etc.\n success?: boolean;\n errorMessage?: string;\n}\n\nexport interface CheckoutEventProperties extends Record<string, unknown> {\n orderId?: string;\n total?: number;\n currency?: string;\n items?: Array<{\n id: string;\n name: string;\n price: number;\n quantity: number;\n }>;\n paymentMethod?: string; // 'credit_card', 'paypal', 'stripe', etc.\n success?: boolean;\n errorMessage?: string;\n couponCode?: string;\n discount?: number;\n}\n\nexport interface PageViewEventProperties extends Record<string, unknown> {\n page?: string;\n title?: string;\n referrer?: string;\n url?: string;\n userAgent?: string;\n screenResolution?: string;\n viewportSize?: string;\n}\n\nexport interface PurchaseEventProperties extends Record<string, unknown> {\n orderId?: string;\n total?: number;\n currency?: string;\n items?: Array<{\n id: string;\n name: string;\n price: number;\n quantity: number;\n category?: string;\n }>;\n paymentMethod?: string;\n shippingMethod?: string;\n tax?: number;\n shipping?: number;\n discount?: number;\n couponCode?: string;\n}\n\nexport interface SearchEventProperties extends Record<string, unknown> {\n query?: string;\n results?: number;\n filters?: Record<string, unknown>;\n sortBy?: string;\n category?: string;\n success?: boolean;\n}\n\nexport interface AddToCartEventProperties extends Record<string, unknown> {\n itemId?: string;\n itemName?: string;\n price?: number;\n quantity?: number;\n currency?: string;\n category?: string;\n variant?: string;\n}\n\nexport interface RemoveFromCartEventProperties extends Record<string, unknown> {\n itemId?: string;\n itemName?: string;\n price?: number;\n quantity?: number;\n currency?: string;\n category?: string;\n variant?: string;\n}\n\n/**\n * Main Grain Analytics client\n */\ntype RequiredConfig = Required<Omit<GrainConfig, 'secretKey' | 'authProvider' | 'userId'>> & {\n secretKey?: string;\n authProvider?: AuthProvider;\n userId?: string;\n};\n\nexport class GrainAnalytics {\n private config: RequiredConfig;\n private eventQueue: EventPayload[] = [];\n private flushTimer: number | null = null;\n private isDestroyed = false;\n private globalUserId: string | null = null;\n\n constructor(config: GrainConfig) {\n this.config = {\n apiUrl: 'https://api.grainql.com',\n authStrategy: 'NONE',\n batchSize: 50,\n flushInterval: 5000, // 5 seconds\n retryAttempts: 3,\n retryDelay: 1000, // 1 second\n maxEventsPerRequest: 160, // Maximum events per API request\n debug: false,\n ...config,\n tenantId: config.tenantId,\n };\n\n // Set global userId if provided in config\n if (config.userId) {\n this.globalUserId = config.userId;\n }\n\n this.validateConfig();\n this.setupBeforeUnload();\n this.startFlushTimer();\n }\n\n private validateConfig(): void {\n if (!this.config.tenantId) {\n throw new Error('Grain Analytics: tenantId is required');\n }\n\n if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) {\n throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy');\n }\n\n if (this.config.authStrategy === 'JWT' && !this.config.authProvider) {\n throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');\n }\n }\n\n private log(...args: unknown[]): void {\n if (this.config.debug) {\n console.log('[Grain Analytics]', ...args);\n }\n }\n\n private formatEvent(event: GrainEvent): EventPayload {\n return {\n eventName: event.eventName,\n userId: event.userId || this.globalUserId || 'anonymous',\n properties: event.properties || {},\n };\n }\n\n private async getAuthHeaders(): Promise<Record<string, string>> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n switch (this.config.authStrategy) {\n case 'NONE':\n break;\n case 'SERVER_SIDE':\n headers['Authorization'] = `Chase ${this.config.secretKey}`;\n break;\n case 'JWT':\n if (this.config.authProvider) {\n const token = await this.config.authProvider.getToken();\n headers['Authorization'] = `Bearer ${token}`;\n }\n break;\n }\n\n return headers;\n }\n\n private async delay(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n }\n\n private isRetriableError(error: unknown): boolean {\n if (error instanceof Error) {\n // Check for specific network or fetch errors\n const message = error.message.toLowerCase();\n if (message.includes('fetch failed')) return true;\n if (message === 'network error') return true; // Exact match to avoid \"Non-network error\"\n if (message.includes('timeout')) return true;\n if (message.includes('connection')) return true;\n }\n \n // Check for HTTP status codes that are retriable\n if (typeof error === 'object' && error !== null && 'status' in error) {\n const status = (error as { status: number }).status;\n return status >= 500 || status === 429; // Server errors or rate limiting\n }\n \n return false;\n }\n\n private async sendEvents(events: EventPayload[]): Promise<void> {\n if (events.length === 0) return;\n\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {\n try {\n const headers = await this.getAuthHeaders();\n const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;\n\n this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({ events }),\n });\n\n if (!response.ok) {\n let errorMessage = `HTTP ${response.status}`;\n try {\n const errorBody = await response.json();\n if (errorBody?.message) {\n errorMessage = errorBody.message;\n }\n } catch {\n const errorText = await response.text();\n if (errorText) {\n errorMessage = errorText;\n }\n }\n \n const error = new Error(`Failed to send events: ${errorMessage}`) as Error & { status?: number };\n error.status = response.status;\n throw error;\n }\n\n this.log(`Successfully sent ${events.length} events`);\n return; // Success, exit retry loop\n \n } catch (error) {\n lastError = error;\n \n if (attempt === this.config.retryAttempts) {\n // Last attempt, don't retry\n break;\n }\n \n if (!this.isRetriableError(error)) {\n // Non-retriable error, don't retry\n break;\n }\n \n const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff\n this.log(`Retrying in ${delayMs}ms after error:`, error);\n await this.delay(delayMs);\n }\n }\n\n console.error('[Grain Analytics] Failed to send events after all retries:', lastError);\n throw lastError;\n }\n\n private async sendEventsWithBeacon(events: EventPayload[]): Promise<void> {\n if (events.length === 0) return;\n\n try {\n const headers = await this.getAuthHeaders();\n const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;\n\n const body = JSON.stringify({ events });\n\n // Try beacon API first (more reliable for page unload)\n if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {\n const blob = new Blob([body], { type: 'application/json' });\n const success = navigator.sendBeacon(url, blob);\n \n if (success) {\n this.log(`Successfully sent ${events.length} events via beacon`);\n return;\n }\n }\n\n // Fallback to fetch with keepalive\n await fetch(url, {\n method: 'POST',\n headers,\n body,\n keepalive: true,\n });\n\n this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);\n } catch (error) {\n console.error('[Grain Analytics] Failed to send events via beacon:', error);\n }\n }\n\n private startFlushTimer(): void {\n if (this.flushTimer) {\n clearInterval(this.flushTimer);\n }\n\n this.flushTimer = window.setInterval(() => {\n if (this.eventQueue.length > 0) {\n this.flush().catch((error) => {\n console.error('[Grain Analytics] Auto-flush failed:', error);\n });\n }\n }, this.config.flushInterval);\n }\n\n private setupBeforeUnload(): void {\n if (typeof window === 'undefined') return;\n\n const handleBeforeUnload = () => {\n if (this.eventQueue.length > 0) {\n // Use beacon API for reliable delivery during page unload\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n \n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send first chunk with beacon (most important for page unload)\n if (chunks.length > 0) {\n this.sendEventsWithBeacon(chunks[0]).catch(() => {\n // Silently fail - page is unloading\n });\n }\n }\n };\n\n // Handle page unload\n window.addEventListener('beforeunload', handleBeforeUnload);\n window.addEventListener('pagehide', handleBeforeUnload);\n \n // Handle visibility change (page hidden)\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) {\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n \n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send first chunk with beacon (most important for page hidden)\n if (chunks.length > 0) {\n this.sendEventsWithBeacon(chunks[0]).catch(() => {\n // Silently fail\n });\n }\n }\n });\n }\n\n /**\n * Track an analytics event\n */\n async track(eventName: string, properties?: Record<string, unknown>, options?: SendEventOptions): Promise<void>;\n async track(event: GrainEvent, options?: SendEventOptions): Promise<void>;\n async track(\n eventOrName: string | GrainEvent,\n propertiesOrOptions?: Record<string, unknown> | SendEventOptions,\n options?: SendEventOptions\n ): Promise<void> {\n if (this.isDestroyed) {\n throw new Error('Grain Analytics: Client has been destroyed');\n }\n\n let event: GrainEvent;\n let opts: SendEventOptions = {};\n\n if (typeof eventOrName === 'string') {\n event = {\n eventName: eventOrName,\n properties: propertiesOrOptions as Record<string, unknown>,\n };\n opts = options || {};\n } else {\n event = eventOrName;\n opts = propertiesOrOptions as SendEventOptions || {};\n }\n\n const formattedEvent = this.formatEvent(event);\n this.eventQueue.push(formattedEvent);\n\n this.log(`Queued event: ${event.eventName}`, event.properties);\n\n // Check if we should flush immediately\n if (opts.flush || this.eventQueue.length >= this.config.batchSize) {\n await this.flush();\n }\n }\n\n /**\n * Identify a user (sets userId for subsequent events)\n */\n identify(userId: string): void {\n this.log(`Identified user: ${userId}`);\n this.globalUserId = userId;\n }\n\n /**\n * Set global user ID for all subsequent events\n */\n setUserId(userId: string | null): void {\n this.log(`Set global user ID: ${userId}`);\n this.globalUserId = userId;\n }\n\n /**\n * Get current global user ID\n */\n getUserId(): string | null {\n return this.globalUserId;\n }\n\n // Template event methods\n\n /**\n * Track user login event\n */\n async trackLogin(properties?: LoginEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('login', properties, options);\n }\n\n /**\n * Track user signup event\n */\n async trackSignup(properties?: SignupEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('signup', properties, options);\n }\n\n /**\n * Track checkout event\n */\n async trackCheckout(properties?: CheckoutEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('checkout', properties, options);\n }\n\n /**\n * Track page view event\n */\n async trackPageView(properties?: PageViewEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('page_view', properties, options);\n }\n\n /**\n * Track purchase event\n */\n async trackPurchase(properties?: PurchaseEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('purchase', properties, options);\n }\n\n /**\n * Track search event\n */\n async trackSearch(properties?: SearchEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('search', properties, options);\n }\n\n /**\n * Track add to cart event\n */\n async trackAddToCart(properties?: AddToCartEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('add_to_cart', properties, options);\n }\n\n /**\n * Track remove from cart event\n */\n async trackRemoveFromCart(properties?: RemoveFromCartEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('remove_from_cart', properties, options);\n }\n\n /**\n * Manually flush all queued events\n */\n async flush(): Promise<void> {\n if (this.eventQueue.length === 0) return;\n\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n\n // Split events into chunks to respect maxEventsPerRequest limit\n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send all chunks sequentially to maintain order\n for (const chunk of chunks) {\n await this.sendEvents(chunk);\n }\n }\n\n /**\n * Split events array into chunks of specified size\n */\n private chunkEvents(events: EventPayload[], chunkSize: number): EventPayload[][] {\n const chunks: EventPayload[][] = [];\n for (let i = 0; i < events.length; i += chunkSize) {\n chunks.push(events.slice(i, i + chunkSize));\n }\n return chunks;\n }\n\n /**\n * Destroy the client and clean up resources\n */\n destroy(): void {\n this.isDestroyed = true;\n \n if (this.flushTimer) {\n clearInterval(this.flushTimer);\n this.flushTimer = null;\n }\n\n // Send any remaining events (in chunks if necessary)\n if (this.eventQueue.length > 0) {\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n \n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send first chunk with beacon (most important for page unload)\n if (chunks.length > 0) {\n this.sendEventsWithBeacon(chunks[0]).catch(() => {\n // Silently fail during cleanup\n });\n \n // If there are more chunks, try to send them with regular fetch\n for (let i = 1; i < chunks.length; i++) {\n this.sendEventsWithBeacon(chunks[i]).catch(() => {\n // Silently fail during cleanup\n });\n }\n }\n }\n }\n}\n\n/**\n * Create a new Grain Analytics client\n */\nexport function createGrainAnalytics(config: GrainConfig): GrainAnalytics {\n return new GrainAnalytics(config);\n}\n\n// Default export for convenience\nexport default GrainAnalytics;\n\n// Global interface for IIFE build\ndeclare global {\n interface Window {\n Grain?: {\n GrainAnalytics: typeof GrainAnalytics;\n createGrainAnalytics: typeof createGrainAnalytics;\n };\n }\n}\n\n// Auto-setup for IIFE build\nif (typeof window !== 'undefined') {\n window.Grain = {\n GrainAnalytics,\n createGrainAnalytics,\n };\n}"],
5
- "mappings": "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",
6
- "names": ["src_exports", "__export", "GrainAnalytics", "createGrainAnalytics", "src_default", "config", "args", "event", "headers", "token", "ms", "resolve", "error", "message", "status", "events", "lastError", "attempt", "url", "response", "errorMessage", "errorBody", "errorText", "delayMs", "body", "blob", "handleBeforeUnload", "eventsToSend", "chunks", "eventOrName", "propertiesOrOptions", "options", "opts", "formattedEvent", "userId", "properties", "chunk", "chunkSize", "i"]
4
+ "sourcesContent": ["/**\n * Grain Analytics Web SDK\n * A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API\n */\n\nexport interface GrainEvent {\n eventName: string;\n userId?: string;\n properties?: Record<string, unknown>;\n timestamp?: Date;\n}\n\nexport interface EventPayload {\n eventName: string;\n userId: string;\n properties: Record<string, unknown>;\n}\n\nexport type AuthStrategy = 'NONE' | 'SERVER_SIDE' | 'JWT';\n\nexport interface AuthProvider {\n getToken(): Promise<string> | string;\n}\n\nexport interface GrainConfig {\n tenantId: string;\n apiUrl?: string;\n authStrategy?: AuthStrategy;\n secretKey?: string; // For SERVER_SIDE auth\n authProvider?: AuthProvider; // For JWT auth\n userId?: string; // Global user ID for all events\n batchSize?: number;\n flushInterval?: number; // milliseconds\n retryAttempts?: number;\n retryDelay?: number; // milliseconds\n maxEventsPerRequest?: number; // Maximum events to send in a single API request\n debug?: boolean;\n // Remote Config options\n defaultConfigurations?: Record<string, string>; // Default values for configurations\n configCacheKey?: string; // Custom cache key for configurations\n configRefreshInterval?: number; // Auto-refresh interval in milliseconds (default: 5 minutes)\n enableConfigCache?: boolean; // Enable/disable configuration caching (default: true)\n}\n\nexport interface SendEventOptions {\n flush?: boolean; // Force immediate send\n}\n\nexport interface SetPropertyOptions {\n userId?: string; // Override global userId\n}\n\nexport interface PropertyPayload {\n userId: string;\n [key: string]: string; // All property values must be strings\n}\n\n// Remote Config interfaces\nexport interface RemoteConfigRequest {\n userId: string;\n immediateKeys: string[];\n properties?: Record<string, string>;\n}\n\nexport interface RemoteConfigResponse {\n userId: string;\n snapshotId: string;\n configurations: Record<string, string>;\n isFinal: boolean;\n qualifiedSegments: string[];\n qualifiedRuleSets: string[];\n timestamp: string;\n isFromCache: boolean;\n}\n\nexport interface RemoteConfigOptions {\n immediateKeys?: string[];\n properties?: Record<string, string>;\n userId?: string; // Override global userId\n forceRefresh?: boolean; // Force fetch from API, bypass cache\n}\n\nexport interface RemoteConfigCache {\n configurations: Record<string, string>;\n snapshotId: string;\n timestamp: string;\n userId: string;\n}\n\nexport type ConfigChangeListener = (configurations: Record<string, string>) => void;\n\n// Template event interfaces\nexport interface LoginEventProperties extends Record<string, unknown> {\n method?: string; // 'email', 'google', 'facebook', etc.\n success?: boolean;\n errorMessage?: string;\n loginAttempt?: number;\n rememberMe?: boolean;\n twoFactorEnabled?: boolean;\n}\n\nexport interface SignupEventProperties extends Record<string, unknown> {\n method?: string; // 'email', 'google', 'facebook', etc.\n source?: string; // 'landing_page', 'referral', 'ad', etc.\n plan?: string; // 'free', 'pro', 'enterprise', etc.\n success?: boolean;\n errorMessage?: string;\n}\n\nexport interface CheckoutEventProperties extends Record<string, unknown> {\n orderId?: string;\n total?: number;\n currency?: string;\n items?: Array<{\n id: string;\n name: string;\n price: number;\n quantity: number;\n }>;\n paymentMethod?: string; // 'credit_card', 'paypal', 'stripe', etc.\n success?: boolean;\n errorMessage?: string;\n couponCode?: string;\n discount?: number;\n}\n\nexport interface PageViewEventProperties extends Record<string, unknown> {\n page?: string;\n title?: string;\n referrer?: string;\n url?: string;\n userAgent?: string;\n screenResolution?: string;\n viewportSize?: string;\n}\n\nexport interface PurchaseEventProperties extends Record<string, unknown> {\n orderId?: string;\n total?: number;\n currency?: string;\n items?: Array<{\n id: string;\n name: string;\n price: number;\n quantity: number;\n category?: string;\n }>;\n paymentMethod?: string;\n shippingMethod?: string;\n tax?: number;\n shipping?: number;\n discount?: number;\n couponCode?: string;\n}\n\nexport interface SearchEventProperties extends Record<string, unknown> {\n query?: string;\n results?: number;\n filters?: Record<string, unknown>;\n sortBy?: string;\n category?: string;\n success?: boolean;\n}\n\nexport interface AddToCartEventProperties extends Record<string, unknown> {\n itemId?: string;\n itemName?: string;\n price?: number;\n quantity?: number;\n currency?: string;\n category?: string;\n variant?: string;\n}\n\nexport interface RemoveFromCartEventProperties extends Record<string, unknown> {\n itemId?: string;\n itemName?: string;\n price?: number;\n quantity?: number;\n currency?: string;\n category?: string;\n variant?: string;\n}\n\n/**\n * Main Grain Analytics client\n */\ntype RequiredConfig = Required<Omit<GrainConfig, 'secretKey' | 'authProvider' | 'userId'>> & {\n secretKey?: string;\n authProvider?: AuthProvider;\n userId?: string;\n};\n\nexport class GrainAnalytics {\n private config: RequiredConfig;\n private eventQueue: EventPayload[] = [];\n private flushTimer: number | null = null;\n private isDestroyed = false;\n private globalUserId: string | null = null;\n // Remote Config properties\n private configCache: RemoteConfigCache | null = null;\n private configRefreshTimer: number | null = null;\n private configChangeListeners: ConfigChangeListener[] = [];\n private configFetchPromise: Promise<RemoteConfigResponse> | null = null;\n\n constructor(config: GrainConfig) {\n this.config = {\n apiUrl: 'https://api.grainql.com',\n authStrategy: 'NONE',\n batchSize: 50,\n flushInterval: 5000, // 5 seconds\n retryAttempts: 3,\n retryDelay: 1000, // 1 second\n maxEventsPerRequest: 160, // Maximum events per API request\n debug: false,\n // Remote Config defaults\n defaultConfigurations: {},\n configCacheKey: 'grain_config',\n configRefreshInterval: 300000, // 5 minutes\n enableConfigCache: true,\n ...config,\n tenantId: config.tenantId,\n };\n\n // Set global userId if provided in config\n if (config.userId) {\n this.globalUserId = config.userId;\n }\n\n this.validateConfig();\n this.setupBeforeUnload();\n this.startFlushTimer();\n this.initializeConfigCache();\n }\n\n private validateConfig(): void {\n if (!this.config.tenantId) {\n throw new Error('Grain Analytics: tenantId is required');\n }\n\n if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) {\n throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy');\n }\n\n if (this.config.authStrategy === 'JWT' && !this.config.authProvider) {\n throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');\n }\n }\n\n private log(...args: unknown[]): void {\n if (this.config.debug) {\n console.log('[Grain Analytics]', ...args);\n }\n }\n\n private formatEvent(event: GrainEvent): EventPayload {\n return {\n eventName: event.eventName,\n userId: event.userId || this.globalUserId || 'anonymous',\n properties: event.properties || {},\n };\n }\n\n private async getAuthHeaders(): Promise<Record<string, string>> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n switch (this.config.authStrategy) {\n case 'NONE':\n break;\n case 'SERVER_SIDE':\n headers['Authorization'] = `Chase ${this.config.secretKey}`;\n break;\n case 'JWT':\n if (this.config.authProvider) {\n const token = await this.config.authProvider.getToken();\n headers['Authorization'] = `Bearer ${token}`;\n }\n break;\n }\n\n return headers;\n }\n\n private async delay(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n }\n\n private isRetriableError(error: unknown): boolean {\n if (error instanceof Error) {\n // Check for specific network or fetch errors\n const message = error.message.toLowerCase();\n if (message.includes('fetch failed')) return true;\n if (message === 'network error') return true; // Exact match to avoid \"Non-network error\"\n if (message.includes('timeout')) return true;\n if (message.includes('connection')) return true;\n }\n \n // Check for HTTP status codes that are retriable\n if (typeof error === 'object' && error !== null && 'status' in error) {\n const status = (error as { status: number }).status;\n return status >= 500 || status === 429; // Server errors or rate limiting\n }\n \n return false;\n }\n\n private async sendEvents(events: EventPayload[]): Promise<void> {\n if (events.length === 0) return;\n\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {\n try {\n const headers = await this.getAuthHeaders();\n const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;\n\n this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({ events }),\n });\n\n if (!response.ok) {\n let errorMessage = `HTTP ${response.status}`;\n try {\n const errorBody = await response.json();\n if (errorBody?.message) {\n errorMessage = errorBody.message;\n }\n } catch {\n const errorText = await response.text();\n if (errorText) {\n errorMessage = errorText;\n }\n }\n \n const error = new Error(`Failed to send events: ${errorMessage}`) as Error & { status?: number };\n error.status = response.status;\n throw error;\n }\n\n this.log(`Successfully sent ${events.length} events`);\n return; // Success, exit retry loop\n \n } catch (error) {\n lastError = error;\n \n if (attempt === this.config.retryAttempts) {\n // Last attempt, don't retry\n break;\n }\n \n if (!this.isRetriableError(error)) {\n // Non-retriable error, don't retry\n break;\n }\n \n const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff\n this.log(`Retrying in ${delayMs}ms after error:`, error);\n await this.delay(delayMs);\n }\n }\n\n console.error('[Grain Analytics] Failed to send events after all retries:', lastError);\n throw lastError;\n }\n\n private async sendEventsWithBeacon(events: EventPayload[]): Promise<void> {\n if (events.length === 0) return;\n\n try {\n const headers = await this.getAuthHeaders();\n const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;\n\n const body = JSON.stringify({ events });\n\n // Try beacon API first (more reliable for page unload)\n if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {\n const blob = new Blob([body], { type: 'application/json' });\n const success = navigator.sendBeacon(url, blob);\n \n if (success) {\n this.log(`Successfully sent ${events.length} events via beacon`);\n return;\n }\n }\n\n // Fallback to fetch with keepalive\n await fetch(url, {\n method: 'POST',\n headers,\n body,\n keepalive: true,\n });\n\n this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);\n } catch (error) {\n console.error('[Grain Analytics] Failed to send events via beacon:', error);\n }\n }\n\n private startFlushTimer(): void {\n if (this.flushTimer) {\n clearInterval(this.flushTimer);\n }\n\n this.flushTimer = window.setInterval(() => {\n if (this.eventQueue.length > 0) {\n this.flush().catch((error) => {\n console.error('[Grain Analytics] Auto-flush failed:', error);\n });\n }\n }, this.config.flushInterval);\n }\n\n private setupBeforeUnload(): void {\n if (typeof window === 'undefined') return;\n\n const handleBeforeUnload = () => {\n if (this.eventQueue.length > 0) {\n // Use beacon API for reliable delivery during page unload\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n \n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send first chunk with beacon (most important for page unload)\n if (chunks.length > 0) {\n this.sendEventsWithBeacon(chunks[0]).catch(() => {\n // Silently fail - page is unloading\n });\n }\n }\n };\n\n // Handle page unload\n window.addEventListener('beforeunload', handleBeforeUnload);\n window.addEventListener('pagehide', handleBeforeUnload);\n \n // Handle visibility change (page hidden)\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) {\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n \n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send first chunk with beacon (most important for page hidden)\n if (chunks.length > 0) {\n this.sendEventsWithBeacon(chunks[0]).catch(() => {\n // Silently fail\n });\n }\n }\n });\n }\n\n /**\n * Track an analytics event\n */\n async track(eventName: string, properties?: Record<string, unknown>, options?: SendEventOptions): Promise<void>;\n async track(event: GrainEvent, options?: SendEventOptions): Promise<void>;\n async track(\n eventOrName: string | GrainEvent,\n propertiesOrOptions?: Record<string, unknown> | SendEventOptions,\n options?: SendEventOptions\n ): Promise<void> {\n if (this.isDestroyed) {\n throw new Error('Grain Analytics: Client has been destroyed');\n }\n\n let event: GrainEvent;\n let opts: SendEventOptions = {};\n\n if (typeof eventOrName === 'string') {\n event = {\n eventName: eventOrName,\n properties: propertiesOrOptions as Record<string, unknown>,\n };\n opts = options || {};\n } else {\n event = eventOrName;\n opts = propertiesOrOptions as SendEventOptions || {};\n }\n\n const formattedEvent = this.formatEvent(event);\n this.eventQueue.push(formattedEvent);\n\n this.log(`Queued event: ${event.eventName}`, event.properties);\n\n // Check if we should flush immediately\n if (opts.flush || this.eventQueue.length >= this.config.batchSize) {\n await this.flush();\n }\n }\n\n /**\n * Identify a user (sets userId for subsequent events)\n */\n identify(userId: string): void {\n this.log(`Identified user: ${userId}`);\n this.globalUserId = userId;\n }\n\n /**\n * Set global user ID for all subsequent events\n */\n setUserId(userId: string | null): void {\n this.log(`Set global user ID: ${userId}`);\n this.globalUserId = userId;\n }\n\n /**\n * Get current global user ID\n */\n getUserId(): string | null {\n return this.globalUserId;\n }\n\n /**\n * Set user properties\n */\n async setProperty(properties: Record<string, unknown>, options?: SetPropertyOptions): Promise<void> {\n if (this.isDestroyed) {\n throw new Error('Grain Analytics: Client has been destroyed');\n }\n\n const userId = options?.userId || this.globalUserId || 'anonymous';\n \n // Validate property count (max 4 properties)\n const propertyKeys = Object.keys(properties);\n if (propertyKeys.length > 4) {\n throw new Error('Grain Analytics: Maximum 4 properties allowed per request');\n }\n\n if (propertyKeys.length === 0) {\n throw new Error('Grain Analytics: At least one property is required');\n }\n\n // Serialize all values to strings\n const serializedProperties: Record<string, string> = {};\n for (const [key, value] of Object.entries(properties)) {\n if (value === null || value === undefined) {\n serializedProperties[key] = '';\n } else if (typeof value === 'string') {\n serializedProperties[key] = value;\n } else {\n serializedProperties[key] = JSON.stringify(value);\n }\n }\n\n const payload: PropertyPayload = {\n userId,\n ...serializedProperties,\n };\n\n await this.sendProperties(payload);\n }\n\n /**\n * Send properties to the API\n */\n private async sendProperties(payload: PropertyPayload): Promise<void> {\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {\n try {\n const headers = await this.getAuthHeaders();\n const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`;\n\n this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`);\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify(payload),\n });\n\n if (!response.ok) {\n let errorMessage = `HTTP ${response.status}`;\n try {\n const errorBody = await response.json();\n if (errorBody?.message) {\n errorMessage = errorBody.message;\n }\n } catch {\n const errorText = await response.text();\n if (errorText) {\n errorMessage = errorText;\n }\n }\n \n const error = new Error(`Failed to set properties: ${errorMessage}`) as Error & { status?: number };\n error.status = response.status;\n throw error;\n }\n\n this.log(`Successfully set properties for user ${payload.userId}`);\n return; // Success, exit retry loop\n \n } catch (error) {\n lastError = error;\n \n if (attempt === this.config.retryAttempts) {\n // Last attempt, don't retry\n break;\n }\n \n if (!this.isRetriableError(error)) {\n // Non-retriable error, don't retry\n break;\n }\n \n const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff\n this.log(`Retrying in ${delayMs}ms after error:`, error);\n await this.delay(delayMs);\n }\n }\n\n console.error('[Grain Analytics] Failed to set properties after all retries:', lastError);\n throw lastError;\n }\n\n // Template event methods\n\n /**\n * Track user login event\n */\n async trackLogin(properties?: LoginEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('login', properties, options);\n }\n\n /**\n * Track user signup event\n */\n async trackSignup(properties?: SignupEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('signup', properties, options);\n }\n\n /**\n * Track checkout event\n */\n async trackCheckout(properties?: CheckoutEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('checkout', properties, options);\n }\n\n /**\n * Track page view event\n */\n async trackPageView(properties?: PageViewEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('page_view', properties, options);\n }\n\n /**\n * Track purchase event\n */\n async trackPurchase(properties?: PurchaseEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('purchase', properties, options);\n }\n\n /**\n * Track search event\n */\n async trackSearch(properties?: SearchEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('search', properties, options);\n }\n\n /**\n * Track add to cart event\n */\n async trackAddToCart(properties?: AddToCartEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('add_to_cart', properties, options);\n }\n\n /**\n * Track remove from cart event\n */\n async trackRemoveFromCart(properties?: RemoveFromCartEventProperties, options?: SendEventOptions): Promise<void> {\n return this.track('remove_from_cart', properties, options);\n }\n\n /**\n * Manually flush all queued events\n */\n async flush(): Promise<void> {\n if (this.eventQueue.length === 0) return;\n\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n\n // Split events into chunks to respect maxEventsPerRequest limit\n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send all chunks sequentially to maintain order\n for (const chunk of chunks) {\n await this.sendEvents(chunk);\n }\n }\n\n // Remote Config Methods\n\n /**\n * Initialize configuration cache from localStorage\n */\n private initializeConfigCache(): void {\n if (!this.config.enableConfigCache || typeof window === 'undefined') return;\n\n try {\n const cached = localStorage.getItem(this.config.configCacheKey);\n if (cached) {\n this.configCache = JSON.parse(cached);\n this.log('Loaded configuration from cache:', this.configCache);\n }\n } catch (error) {\n this.log('Failed to load configuration cache:', error);\n }\n }\n\n /**\n * Save configuration cache to localStorage\n */\n private saveConfigCache(cache: RemoteConfigCache): void {\n if (!this.config.enableConfigCache || typeof window === 'undefined') return;\n\n try {\n localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));\n this.log('Saved configuration to cache:', cache);\n } catch (error) {\n this.log('Failed to save configuration cache:', error);\n }\n }\n\n /**\n * Get configuration value with fallback to defaults\n */\n getConfig(key: string): string | undefined {\n // First check cache\n if (this.configCache?.configurations?.[key]) {\n return this.configCache.configurations[key];\n }\n\n // Then check defaults\n if (this.config.defaultConfigurations?.[key]) {\n return this.config.defaultConfigurations[key];\n }\n\n return undefined;\n }\n\n /**\n * Get all configurations with fallback to defaults\n */\n getAllConfigs(): Record<string, string> {\n const configs = { ...this.config.defaultConfigurations };\n \n if (this.configCache?.configurations) {\n Object.assign(configs, this.configCache.configurations);\n }\n\n return configs;\n }\n\n /**\n * Fetch configurations from API\n */\n async fetchConfig(options: RemoteConfigOptions = {}): Promise<RemoteConfigResponse> {\n if (this.isDestroyed) {\n throw new Error('Grain Analytics: Client has been destroyed');\n }\n\n const userId = options.userId || this.globalUserId || 'anonymous';\n const immediateKeys = options.immediateKeys || [];\n const properties = options.properties || {};\n\n const request: RemoteConfigRequest = {\n userId,\n immediateKeys,\n properties,\n };\n\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {\n try {\n const headers = await this.getAuthHeaders();\n const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;\n\n this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify(request),\n });\n\n if (!response.ok) {\n let errorMessage = `HTTP ${response.status}`;\n try {\n const errorBody = await response.json();\n if (errorBody?.message) {\n errorMessage = errorBody.message;\n }\n } catch {\n const errorText = await response.text();\n if (errorText) {\n errorMessage = errorText;\n }\n }\n \n const error = new Error(`Failed to fetch configurations: ${errorMessage}`) as Error & { status?: number };\n error.status = response.status;\n throw error;\n }\n\n const configResponse: RemoteConfigResponse = await response.json();\n \n // Update cache if successful\n if (configResponse.configurations) {\n this.updateConfigCache(configResponse, userId);\n }\n\n this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);\n return configResponse;\n \n } catch (error) {\n lastError = error;\n \n if (attempt === this.config.retryAttempts) {\n break;\n }\n \n if (!this.isRetriableError(error)) {\n break;\n }\n \n const delayMs = this.config.retryDelay * Math.pow(2, attempt);\n this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);\n await this.delay(delayMs);\n }\n }\n\n console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);\n throw lastError;\n }\n\n /**\n * Get configuration asynchronously (cache-first with fallback to API)\n */\n async getConfigAsync(key: string, options: RemoteConfigOptions = {}): Promise<string | undefined> {\n // Return immediately if we have it in cache and not forcing refresh\n if (!options.forceRefresh && this.configCache?.configurations?.[key]) {\n return this.configCache.configurations[key];\n }\n\n // Return default if available and not forcing refresh\n if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {\n return this.config.defaultConfigurations[key];\n }\n\n // Fetch from API\n try {\n const response = await this.fetchConfig(options);\n return response.configurations[key];\n } catch (error) {\n this.log(`Failed to fetch config for key \"${key}\":`, error);\n // Return default as fallback\n return this.config.defaultConfigurations?.[key];\n }\n }\n\n /**\n * Get all configurations asynchronously (cache-first with fallback to API)\n */\n async getAllConfigsAsync(options: RemoteConfigOptions = {}): Promise<Record<string, string>> {\n // Return cache if available and not forcing refresh\n if (!options.forceRefresh && this.configCache?.configurations) {\n return { ...this.config.defaultConfigurations, ...this.configCache.configurations };\n }\n\n // Fetch from API\n try {\n const response = await this.fetchConfig(options);\n return { ...this.config.defaultConfigurations, ...response.configurations };\n } catch (error) {\n this.log('Failed to fetch all configs:', error);\n // Return defaults as fallback\n return { ...this.config.defaultConfigurations };\n }\n }\n\n /**\n * Update configuration cache and notify listeners\n */\n private updateConfigCache(response: RemoteConfigResponse, userId: string): void {\n const newCache: RemoteConfigCache = {\n configurations: response.configurations,\n snapshotId: response.snapshotId,\n timestamp: response.timestamp,\n userId,\n };\n\n const oldConfigs = this.configCache?.configurations || {};\n this.configCache = newCache;\n this.saveConfigCache(newCache);\n\n // Notify listeners if configurations changed\n if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {\n this.notifyConfigChangeListeners(response.configurations);\n }\n }\n\n /**\n * Add configuration change listener\n */\n addConfigChangeListener(listener: ConfigChangeListener): void {\n this.configChangeListeners.push(listener);\n }\n\n /**\n * Remove configuration change listener\n */\n removeConfigChangeListener(listener: ConfigChangeListener): void {\n const index = this.configChangeListeners.indexOf(listener);\n if (index > -1) {\n this.configChangeListeners.splice(index, 1);\n }\n }\n\n /**\n * Notify all configuration change listeners\n */\n private notifyConfigChangeListeners(configurations: Record<string, string>): void {\n this.configChangeListeners.forEach(listener => {\n try {\n listener(configurations);\n } catch (error) {\n console.error('[Grain Analytics] Config change listener error:', error);\n }\n });\n }\n\n /**\n * Start automatic configuration refresh timer\n */\n private startConfigRefreshTimer(): void {\n if (this.configRefreshTimer) {\n clearInterval(this.configRefreshTimer);\n }\n\n this.configRefreshTimer = window.setInterval(() => {\n if (!this.isDestroyed && this.globalUserId) {\n this.fetchConfig().catch((error) => {\n console.error('[Grain Analytics] Auto-config refresh failed:', error);\n });\n }\n }, this.config.configRefreshInterval);\n }\n\n /**\n * Stop automatic configuration refresh timer\n */\n private stopConfigRefreshTimer(): void {\n if (this.configRefreshTimer) {\n clearInterval(this.configRefreshTimer);\n this.configRefreshTimer = null;\n }\n }\n\n /**\n * Preload configurations for immediate access\n */\n async preloadConfig(immediateKeys: string[] = [], properties?: Record<string, string>): Promise<void> {\n if (!this.globalUserId) {\n this.log('Cannot preload config: no user ID set');\n return;\n }\n\n try {\n await this.fetchConfig({ immediateKeys, properties });\n this.startConfigRefreshTimer();\n } catch (error) {\n this.log('Failed to preload config:', error);\n }\n }\n\n /**\n * Split events array into chunks of specified size\n */\n private chunkEvents(events: EventPayload[], chunkSize: number): EventPayload[][] {\n const chunks: EventPayload[][] = [];\n for (let i = 0; i < events.length; i += chunkSize) {\n chunks.push(events.slice(i, i + chunkSize));\n }\n return chunks;\n }\n\n /**\n * Destroy the client and clean up resources\n */\n destroy(): void {\n this.isDestroyed = true;\n \n if (this.flushTimer) {\n clearInterval(this.flushTimer);\n this.flushTimer = null;\n }\n\n // Stop config refresh timer\n this.stopConfigRefreshTimer();\n\n // Clear config change listeners\n this.configChangeListeners = [];\n\n // Send any remaining events (in chunks if necessary)\n if (this.eventQueue.length > 0) {\n const eventsToSend = [...this.eventQueue];\n this.eventQueue = [];\n \n const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);\n \n // Send first chunk with beacon (most important for page unload)\n if (chunks.length > 0) {\n this.sendEventsWithBeacon(chunks[0]).catch(() => {\n // Silently fail during cleanup\n });\n \n // If there are more chunks, try to send them with regular fetch\n for (let i = 1; i < chunks.length; i++) {\n this.sendEventsWithBeacon(chunks[i]).catch(() => {\n // Silently fail during cleanup\n });\n }\n }\n }\n }\n}\n\n/**\n * Create a new Grain Analytics client\n */\nexport function createGrainAnalytics(config: GrainConfig): GrainAnalytics {\n return new GrainAnalytics(config);\n}\n\n// Default export for convenience\nexport default GrainAnalytics;\n\n// Global interface for IIFE build\ndeclare global {\n interface Window {\n Grain?: {\n GrainAnalytics: typeof GrainAnalytics;\n createGrainAnalytics: typeof createGrainAnalytics;\n };\n }\n}\n\n// Auto-setup for IIFE build\nif (typeof window !== 'undefined') {\n window.Grain = {\n GrainAnalytics,\n createGrainAnalytics,\n };\n}"],
5
+ "mappings": "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",
6
+ "names": ["src_exports", "__export", "GrainAnalytics", "createGrainAnalytics", "src_default", "config", "args", "event", "headers", "token", "ms", "resolve", "error", "message", "status", "events", "lastError", "attempt", "url", "response", "errorMessage", "errorBody", "errorText", "delayMs", "body", "blob", "handleBeforeUnload", "eventsToSend", "chunks", "eventOrName", "propertiesOrOptions", "options", "opts", "formattedEvent", "userId", "properties", "propertyKeys", "serializedProperties", "key", "value", "payload", "chunk", "cached", "cache", "configs", "immediateKeys", "request", "configResponse", "newCache", "oldConfigs", "listener", "index", "configurations", "chunkSize", "i"]
7
7
  }
package/dist/index.js CHANGED
@@ -12,6 +12,11 @@ class GrainAnalytics {
12
12
  this.flushTimer = null;
13
13
  this.isDestroyed = false;
14
14
  this.globalUserId = null;
15
+ // Remote Config properties
16
+ this.configCache = null;
17
+ this.configRefreshTimer = null;
18
+ this.configChangeListeners = [];
19
+ this.configFetchPromise = null;
15
20
  this.config = {
16
21
  apiUrl: 'https://api.grainql.com',
17
22
  authStrategy: 'NONE',
@@ -21,6 +26,11 @@ class GrainAnalytics {
21
26
  retryDelay: 1000, // 1 second
22
27
  maxEventsPerRequest: 160, // Maximum events per API request
23
28
  debug: false,
29
+ // Remote Config defaults
30
+ defaultConfigurations: {},
31
+ configCacheKey: 'grain_config',
32
+ configRefreshInterval: 300000, // 5 minutes
33
+ enableConfigCache: true,
24
34
  ...config,
25
35
  tenantId: config.tenantId,
26
36
  };
@@ -31,6 +41,7 @@ class GrainAnalytics {
31
41
  this.validateConfig();
32
42
  this.setupBeforeUnload();
33
43
  this.startFlushTimer();
44
+ this.initializeConfigCache();
34
45
  }
35
46
  validateConfig() {
36
47
  if (!this.config.tenantId) {
@@ -104,7 +115,7 @@ class GrainAnalytics {
104
115
  for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
105
116
  try {
106
117
  const headers = await this.getAuthHeaders();
107
- const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
118
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
108
119
  this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
109
120
  const response = await fetch(url, {
110
121
  method: 'POST',
@@ -155,7 +166,7 @@ class GrainAnalytics {
155
166
  return;
156
167
  try {
157
168
  const headers = await this.getAuthHeaders();
158
- const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
169
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
159
170
  const body = JSON.stringify({ events });
160
171
  // Try beacon API first (more reliable for page unload)
161
172
  if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
@@ -271,6 +282,95 @@ class GrainAnalytics {
271
282
  getUserId() {
272
283
  return this.globalUserId;
273
284
  }
285
+ /**
286
+ * Set user properties
287
+ */
288
+ async setProperty(properties, options) {
289
+ if (this.isDestroyed) {
290
+ throw new Error('Grain Analytics: Client has been destroyed');
291
+ }
292
+ const userId = options?.userId || this.globalUserId || 'anonymous';
293
+ // Validate property count (max 4 properties)
294
+ const propertyKeys = Object.keys(properties);
295
+ if (propertyKeys.length > 4) {
296
+ throw new Error('Grain Analytics: Maximum 4 properties allowed per request');
297
+ }
298
+ if (propertyKeys.length === 0) {
299
+ throw new Error('Grain Analytics: At least one property is required');
300
+ }
301
+ // Serialize all values to strings
302
+ const serializedProperties = {};
303
+ for (const [key, value] of Object.entries(properties)) {
304
+ if (value === null || value === undefined) {
305
+ serializedProperties[key] = '';
306
+ }
307
+ else if (typeof value === 'string') {
308
+ serializedProperties[key] = value;
309
+ }
310
+ else {
311
+ serializedProperties[key] = JSON.stringify(value);
312
+ }
313
+ }
314
+ const payload = {
315
+ userId,
316
+ ...serializedProperties,
317
+ };
318
+ await this.sendProperties(payload);
319
+ }
320
+ /**
321
+ * Send properties to the API
322
+ */
323
+ async sendProperties(payload) {
324
+ let lastError;
325
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
326
+ try {
327
+ const headers = await this.getAuthHeaders();
328
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`;
329
+ this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`);
330
+ const response = await fetch(url, {
331
+ method: 'POST',
332
+ headers,
333
+ body: JSON.stringify(payload),
334
+ });
335
+ if (!response.ok) {
336
+ let errorMessage = `HTTP ${response.status}`;
337
+ try {
338
+ const errorBody = await response.json();
339
+ if (errorBody?.message) {
340
+ errorMessage = errorBody.message;
341
+ }
342
+ }
343
+ catch {
344
+ const errorText = await response.text();
345
+ if (errorText) {
346
+ errorMessage = errorText;
347
+ }
348
+ }
349
+ const error = new Error(`Failed to set properties: ${errorMessage}`);
350
+ error.status = response.status;
351
+ throw error;
352
+ }
353
+ this.log(`Successfully set properties for user ${payload.userId}`);
354
+ return; // Success, exit retry loop
355
+ }
356
+ catch (error) {
357
+ lastError = error;
358
+ if (attempt === this.config.retryAttempts) {
359
+ // Last attempt, don't retry
360
+ break;
361
+ }
362
+ if (!this.isRetriableError(error)) {
363
+ // Non-retriable error, don't retry
364
+ break;
365
+ }
366
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
367
+ this.log(`Retrying in ${delayMs}ms after error:`, error);
368
+ await this.delay(delayMs);
369
+ }
370
+ }
371
+ console.error('[Grain Analytics] Failed to set properties after all retries:', lastError);
372
+ throw lastError;
373
+ }
274
374
  // Template event methods
275
375
  /**
276
376
  * Track user login event
@@ -335,6 +435,258 @@ class GrainAnalytics {
335
435
  await this.sendEvents(chunk);
336
436
  }
337
437
  }
438
+ // Remote Config Methods
439
+ /**
440
+ * Initialize configuration cache from localStorage
441
+ */
442
+ initializeConfigCache() {
443
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
444
+ return;
445
+ try {
446
+ const cached = localStorage.getItem(this.config.configCacheKey);
447
+ if (cached) {
448
+ this.configCache = JSON.parse(cached);
449
+ this.log('Loaded configuration from cache:', this.configCache);
450
+ }
451
+ }
452
+ catch (error) {
453
+ this.log('Failed to load configuration cache:', error);
454
+ }
455
+ }
456
+ /**
457
+ * Save configuration cache to localStorage
458
+ */
459
+ saveConfigCache(cache) {
460
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
461
+ return;
462
+ try {
463
+ localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));
464
+ this.log('Saved configuration to cache:', cache);
465
+ }
466
+ catch (error) {
467
+ this.log('Failed to save configuration cache:', error);
468
+ }
469
+ }
470
+ /**
471
+ * Get configuration value with fallback to defaults
472
+ */
473
+ getConfig(key) {
474
+ // First check cache
475
+ if (this.configCache?.configurations?.[key]) {
476
+ return this.configCache.configurations[key];
477
+ }
478
+ // Then check defaults
479
+ if (this.config.defaultConfigurations?.[key]) {
480
+ return this.config.defaultConfigurations[key];
481
+ }
482
+ return undefined;
483
+ }
484
+ /**
485
+ * Get all configurations with fallback to defaults
486
+ */
487
+ getAllConfigs() {
488
+ const configs = { ...this.config.defaultConfigurations };
489
+ if (this.configCache?.configurations) {
490
+ Object.assign(configs, this.configCache.configurations);
491
+ }
492
+ return configs;
493
+ }
494
+ /**
495
+ * Fetch configurations from API
496
+ */
497
+ async fetchConfig(options = {}) {
498
+ if (this.isDestroyed) {
499
+ throw new Error('Grain Analytics: Client has been destroyed');
500
+ }
501
+ const userId = options.userId || this.globalUserId || 'anonymous';
502
+ const immediateKeys = options.immediateKeys || [];
503
+ const properties = options.properties || {};
504
+ const request = {
505
+ userId,
506
+ immediateKeys,
507
+ properties,
508
+ };
509
+ let lastError;
510
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
511
+ try {
512
+ const headers = await this.getAuthHeaders();
513
+ const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
514
+ this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
515
+ const response = await fetch(url, {
516
+ method: 'POST',
517
+ headers,
518
+ body: JSON.stringify(request),
519
+ });
520
+ if (!response.ok) {
521
+ let errorMessage = `HTTP ${response.status}`;
522
+ try {
523
+ const errorBody = await response.json();
524
+ if (errorBody?.message) {
525
+ errorMessage = errorBody.message;
526
+ }
527
+ }
528
+ catch {
529
+ const errorText = await response.text();
530
+ if (errorText) {
531
+ errorMessage = errorText;
532
+ }
533
+ }
534
+ const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
535
+ error.status = response.status;
536
+ throw error;
537
+ }
538
+ const configResponse = await response.json();
539
+ // Update cache if successful
540
+ if (configResponse.configurations) {
541
+ this.updateConfigCache(configResponse, userId);
542
+ }
543
+ this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
544
+ return configResponse;
545
+ }
546
+ catch (error) {
547
+ lastError = error;
548
+ if (attempt === this.config.retryAttempts) {
549
+ break;
550
+ }
551
+ if (!this.isRetriableError(error)) {
552
+ break;
553
+ }
554
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt);
555
+ this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
556
+ await this.delay(delayMs);
557
+ }
558
+ }
559
+ console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);
560
+ throw lastError;
561
+ }
562
+ /**
563
+ * Get configuration asynchronously (cache-first with fallback to API)
564
+ */
565
+ async getConfigAsync(key, options = {}) {
566
+ // Return immediately if we have it in cache and not forcing refresh
567
+ if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
568
+ return this.configCache.configurations[key];
569
+ }
570
+ // Return default if available and not forcing refresh
571
+ if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
572
+ return this.config.defaultConfigurations[key];
573
+ }
574
+ // Fetch from API
575
+ try {
576
+ const response = await this.fetchConfig(options);
577
+ return response.configurations[key];
578
+ }
579
+ catch (error) {
580
+ this.log(`Failed to fetch config for key "${key}":`, error);
581
+ // Return default as fallback
582
+ return this.config.defaultConfigurations?.[key];
583
+ }
584
+ }
585
+ /**
586
+ * Get all configurations asynchronously (cache-first with fallback to API)
587
+ */
588
+ async getAllConfigsAsync(options = {}) {
589
+ // Return cache if available and not forcing refresh
590
+ if (!options.forceRefresh && this.configCache?.configurations) {
591
+ return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
592
+ }
593
+ // Fetch from API
594
+ try {
595
+ const response = await this.fetchConfig(options);
596
+ return { ...this.config.defaultConfigurations, ...response.configurations };
597
+ }
598
+ catch (error) {
599
+ this.log('Failed to fetch all configs:', error);
600
+ // Return defaults as fallback
601
+ return { ...this.config.defaultConfigurations };
602
+ }
603
+ }
604
+ /**
605
+ * Update configuration cache and notify listeners
606
+ */
607
+ updateConfigCache(response, userId) {
608
+ const newCache = {
609
+ configurations: response.configurations,
610
+ snapshotId: response.snapshotId,
611
+ timestamp: response.timestamp,
612
+ userId,
613
+ };
614
+ const oldConfigs = this.configCache?.configurations || {};
615
+ this.configCache = newCache;
616
+ this.saveConfigCache(newCache);
617
+ // Notify listeners if configurations changed
618
+ if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {
619
+ this.notifyConfigChangeListeners(response.configurations);
620
+ }
621
+ }
622
+ /**
623
+ * Add configuration change listener
624
+ */
625
+ addConfigChangeListener(listener) {
626
+ this.configChangeListeners.push(listener);
627
+ }
628
+ /**
629
+ * Remove configuration change listener
630
+ */
631
+ removeConfigChangeListener(listener) {
632
+ const index = this.configChangeListeners.indexOf(listener);
633
+ if (index > -1) {
634
+ this.configChangeListeners.splice(index, 1);
635
+ }
636
+ }
637
+ /**
638
+ * Notify all configuration change listeners
639
+ */
640
+ notifyConfigChangeListeners(configurations) {
641
+ this.configChangeListeners.forEach(listener => {
642
+ try {
643
+ listener(configurations);
644
+ }
645
+ catch (error) {
646
+ console.error('[Grain Analytics] Config change listener error:', error);
647
+ }
648
+ });
649
+ }
650
+ /**
651
+ * Start automatic configuration refresh timer
652
+ */
653
+ startConfigRefreshTimer() {
654
+ if (this.configRefreshTimer) {
655
+ clearInterval(this.configRefreshTimer);
656
+ }
657
+ this.configRefreshTimer = window.setInterval(() => {
658
+ if (!this.isDestroyed && this.globalUserId) {
659
+ this.fetchConfig().catch((error) => {
660
+ console.error('[Grain Analytics] Auto-config refresh failed:', error);
661
+ });
662
+ }
663
+ }, this.config.configRefreshInterval);
664
+ }
665
+ /**
666
+ * Stop automatic configuration refresh timer
667
+ */
668
+ stopConfigRefreshTimer() {
669
+ if (this.configRefreshTimer) {
670
+ clearInterval(this.configRefreshTimer);
671
+ this.configRefreshTimer = null;
672
+ }
673
+ }
674
+ /**
675
+ * Preload configurations for immediate access
676
+ */
677
+ async preloadConfig(immediateKeys = [], properties) {
678
+ if (!this.globalUserId) {
679
+ this.log('Cannot preload config: no user ID set');
680
+ return;
681
+ }
682
+ try {
683
+ await this.fetchConfig({ immediateKeys, properties });
684
+ this.startConfigRefreshTimer();
685
+ }
686
+ catch (error) {
687
+ this.log('Failed to preload config:', error);
688
+ }
689
+ }
338
690
  /**
339
691
  * Split events array into chunks of specified size
340
692
  */
@@ -354,6 +706,10 @@ class GrainAnalytics {
354
706
  clearInterval(this.flushTimer);
355
707
  this.flushTimer = null;
356
708
  }
709
+ // Stop config refresh timer
710
+ this.stopConfigRefreshTimer();
711
+ // Clear config change listeners
712
+ this.configChangeListeners = [];
357
713
  // Send any remaining events (in chunks if necessary)
358
714
  if (this.eventQueue.length > 0) {
359
715
  const eventsToSend = [...this.eventQueue];