@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
JavaScript
"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