UNPKG

humanbehavior-js

Version:

SDK for HumanBehavior session and event recording

1 lines 315 kB
{"version":3,"file":"index.mjs","sources":["../src/utils/logger.ts","../src/retry-queue.ts","../src/persistence.ts","../src/api.ts","../src/redact.ts","../src/utils/property-detector.ts","../src/utils/property-manager.ts","../src/tracker.ts","../src/utils/global-tracker.ts"],"sourcesContent":["export enum LogLevel {\n NONE = 0,\n ERROR = 1,\n WARN = 2,\n INFO = 3,\n DEBUG = 4\n}\n\nexport interface LoggerConfig {\n level: LogLevel;\n enableConsole: boolean;\n enableStorage: boolean;\n}\n\nclass Logger {\n private config: LoggerConfig = {\n level: LogLevel.ERROR, // Default to only errors in production\n enableConsole: true,\n enableStorage: false\n };\n\n private isBrowser = typeof window !== 'undefined';\n\n constructor(config?: Partial<LoggerConfig>) {\n if (config) {\n this.config = { ...this.config, ...config };\n }\n }\n\n setConfig(config: Partial<LoggerConfig>): void {\n this.config = { ...this.config, ...config };\n }\n\n private shouldLog(level: LogLevel): boolean {\n return level <= this.config.level;\n }\n\n private formatMessage(level: string, message: string, ...args: any[]): string {\n const timestamp = new Date().toISOString();\n return `[HumanBehavior ${level}] ${timestamp}: ${message}`;\n }\n\n error(message: string, ...args: any[]): void {\n if (!this.shouldLog(LogLevel.ERROR)) return;\n \n const formattedMessage = this.formatMessage('ERROR', message);\n \n if (this.config.enableConsole) {\n console.error(formattedMessage, ...args);\n }\n \n if (this.config.enableStorage && this.isBrowser) {\n this.logToStorage(formattedMessage, args);\n }\n }\n\n warn(message: string, ...args: any[]): void {\n if (!this.shouldLog(LogLevel.WARN)) return;\n \n const formattedMessage = this.formatMessage('WARN', message);\n \n if (this.config.enableConsole) {\n console.warn(formattedMessage, ...args);\n }\n \n if (this.config.enableStorage && this.isBrowser) {\n this.logToStorage(formattedMessage, args);\n }\n }\n\n info(message: string, ...args: any[]): void {\n if (!this.shouldLog(LogLevel.INFO)) return;\n \n const formattedMessage = this.formatMessage('INFO', message);\n \n if (this.config.enableConsole) {\n console.log(formattedMessage, ...args);\n }\n \n if (this.config.enableStorage && this.isBrowser) {\n this.logToStorage(formattedMessage, args);\n }\n }\n\n debug(message: string, ...args: any[]): void {\n if (!this.shouldLog(LogLevel.DEBUG)) return;\n \n const formattedMessage = this.formatMessage('DEBUG', message);\n \n if (this.config.enableConsole) {\n console.log(formattedMessage, ...args);\n }\n \n if (this.config.enableStorage && this.isBrowser) {\n this.logToStorage(formattedMessage, args);\n }\n }\n\n private logToStorage(message: string, args: any[]): void {\n try {\n const logs = JSON.parse(localStorage.getItem('human_behavior_logs') || '[]');\n const logEntry = {\n message,\n args: args.length > 0 ? args : undefined,\n timestamp: Date.now()\n };\n logs.push(logEntry);\n \n // Keep only last 1000 logs to prevent storage bloat\n if (logs.length > 1000) {\n logs.splice(0, logs.length - 1000);\n }\n \n localStorage.setItem('human_behavior_logs', JSON.stringify(logs));\n } catch (e) {\n // Silently fail if storage is not available\n }\n }\n\n getLogs(): any[] {\n if (!this.isBrowser) return [];\n \n try {\n return JSON.parse(localStorage.getItem('human_behavior_logs') || '[]');\n } catch (e) {\n return [];\n }\n }\n\n clearLogs(): void {\n if (this.isBrowser) {\n localStorage.removeItem('human_behavior_logs');\n }\n }\n}\n\n// Create singleton instance\nexport const logger = new Logger();\n\n// Global flag to track if SDK is currently logging (prevents self-tracking)\nlet sdkLoggingInProgress = false;\n\n// Export getter for tracker to check\nexport const isSDKLogging = (): boolean => sdkLoggingInProgress;\n\n// Export convenience methods with SDK logging flag\nexport const logError = (message: string, ...args: any[]) => {\n sdkLoggingInProgress = true;\n try {\n logger.error(message, ...args);\n } finally {\n sdkLoggingInProgress = false;\n }\n};\n\nexport const logWarn = (message: string, ...args: any[]) => {\n sdkLoggingInProgress = true;\n try {\n logger.warn(message, ...args);\n } finally {\n sdkLoggingInProgress = false;\n }\n};\n\nexport const logInfo = (message: string, ...args: any[]) => {\n sdkLoggingInProgress = true;\n try {\n logger.info(message, ...args);\n } finally {\n sdkLoggingInProgress = false;\n }\n};\n\nexport const logDebug = (message: string, ...args: any[]) => {\n sdkLoggingInProgress = true;\n try {\n logger.debug(message, ...args);\n } finally {\n sdkLoggingInProgress = false;\n }\n}; ","import { logWarn, logError, logDebug } from './utils/logger';\n\nconst THIRTY_MINUTES = 30 * 60 * 1000;\nconst KEEP_ALIVE_THRESHOLD = 64 * 1024 * 0.8; // 64KB * 0.8 for safety margin\n\n/**\n * Generates a jittered exponential backoff delay in milliseconds\n * \n * The base value is 3 seconds, which is doubled with each retry\n * up to the maximum of 30 minutes\n * \n * Each value then has +/- 50% jitter\n * \n * Giving a range of 3 seconds up to 45 minutes\n */\nexport function pickNextRetryDelay(retriesPerformedSoFar: number): number {\n const rawBackoffTime = 3000 * 2 ** retriesPerformedSoFar;\n const minBackoff = rawBackoffTime / 2;\n const cappedBackoffTime = Math.min(THIRTY_MINUTES, rawBackoffTime);\n const jitterFraction = Math.random() - 0.5; // A random number between -0.5 and 0.5\n const jitter = jitterFraction * (cappedBackoffTime - minBackoff);\n return Math.ceil(cappedBackoffTime + jitter);\n}\n\nexport interface RetriableRequestOptions {\n url: string;\n method?: string;\n headers?: Record<string, string>;\n body?: string | Blob;\n retriesPerformedSoFar?: number;\n estimatedSize?: number;\n callback?: (response: { statusCode: number; text: string; json?: any }) => void;\n}\n\ninterface RetryQueueElement {\n retryAt: number;\n requestOptions: RetriableRequestOptions;\n}\n\nexport class RetryQueue {\n private _isPolling: boolean = false;\n private _poller: ReturnType<typeof setTimeout> | undefined;\n private _pollIntervalMs: number = 3000;\n private _queue: RetryQueueElement[] = [];\n private _areWeOnline: boolean;\n private _sendRequest: (options: RetriableRequestOptions) => Promise<void>;\n\n constructor(sendRequest: (options: RetriableRequestOptions) => Promise<void>) {\n this._queue = [];\n this._areWeOnline = true;\n this._sendRequest = sendRequest;\n\n if (typeof window !== 'undefined' && 'onLine' in window.navigator) {\n this._areWeOnline = window.navigator.onLine;\n\n window.addEventListener('online', () => {\n this._areWeOnline = true;\n this._flush();\n });\n\n window.addEventListener('offline', () => {\n this._areWeOnline = false;\n });\n }\n }\n\n get length(): number {\n return this._queue.length;\n }\n\n async retriableRequest(options: RetriableRequestOptions): Promise<void> {\n const retriesPerformedSoFar = options.retriesPerformedSoFar || 0;\n \n // Add retry count to URL if retrying\n if (retriesPerformedSoFar > 0) {\n const url = new URL(options.url);\n url.searchParams.set('retry_count', retriesPerformedSoFar.toString());\n options.url = url.toString();\n }\n\n try {\n await this._sendRequest(options);\n } catch (error: any) {\n // Check if we should retry\n const shouldRetry = this._shouldRetry(error, retriesPerformedSoFar);\n \n if (shouldRetry && retriesPerformedSoFar < 10) {\n this._enqueue(options);\n return;\n }\n\n // Call callback with error if provided\n if (options.callback) {\n options.callback({\n statusCode: error.status || 0,\n text: error.message || 'Request failed'\n });\n }\n }\n }\n\n private _shouldRetry(error: any, retriesPerformedSoFar: number): boolean {\n // Don't retry on client errors (4xx) except for 408, 429\n if (error.status >= 400 && error.status < 500) {\n return error.status === 408 || error.status === 429;\n }\n \n // Retry on server errors (5xx) and network errors\n return error.status >= 500 || !error.status;\n }\n\n private _enqueue(requestOptions: RetriableRequestOptions): void {\n const retriesPerformedSoFar = requestOptions.retriesPerformedSoFar || 0;\n requestOptions.retriesPerformedSoFar = retriesPerformedSoFar + 1;\n\n const msToNextRetry = pickNextRetryDelay(retriesPerformedSoFar);\n const retryAt = Date.now() + msToNextRetry;\n\n this._queue.push({ retryAt, requestOptions });\n\n let logMessage = `Enqueued failed request for retry in ${Math.round(msToNextRetry / 1000)}s`;\n if (typeof navigator !== 'undefined' && !navigator.onLine) {\n logMessage += ' (Browser is offline)';\n }\n logWarn(logMessage);\n\n if (!this._isPolling) {\n this._isPolling = true;\n this._poll();\n }\n }\n\n private _poll(): void {\n if (this._poller) {\n clearTimeout(this._poller);\n }\n this._poller = setTimeout(() => {\n if (this._areWeOnline && this._queue.length > 0) {\n this._flush();\n }\n this._poll();\n }, this._pollIntervalMs);\n }\n\n private _flush(): void {\n const now = Date.now();\n const notToFlush: RetryQueueElement[] = [];\n const toFlush = this._queue.filter((item) => {\n if (item.retryAt < now) {\n return true;\n }\n notToFlush.push(item);\n return false;\n });\n\n this._queue = notToFlush;\n\n if (toFlush.length > 0) {\n for (const { requestOptions } of toFlush) {\n this.retriableRequest(requestOptions).catch((error) => {\n logError('Failed to retry request:', error);\n });\n }\n }\n }\n\n unload(): void {\n if (this._poller) {\n clearTimeout(this._poller);\n this._poller = undefined;\n }\n\n for (const { requestOptions } of this._queue) {\n try {\n // Use sendBeacon for unload to ensure requests are sent\n this._sendBeaconRequest(requestOptions);\n } catch (e) {\n logError('Failed to send request via sendBeacon on unload:', e);\n }\n }\n this._queue = [];\n }\n\n private _sendBeaconRequest(options: RetriableRequestOptions): void {\n if (typeof navigator === 'undefined' || !navigator.sendBeacon) {\n return;\n }\n\n try {\n const url = new URL(options.url);\n url.searchParams.set('beacon', '1');\n\n let body: Blob | null = null;\n if (options.body) {\n if (typeof options.body === 'string') {\n body = new Blob([options.body], { type: 'application/json' });\n } else if (options.body instanceof Blob) {\n body = options.body;\n }\n }\n\n const success = navigator.sendBeacon(url.toString(), body);\n if (!success) {\n logWarn('sendBeacon returned false for unload request');\n }\n } catch (error) {\n logError('Error sending beacon request:', error);\n }\n }\n}\n\n","import { logDebug, logWarn } from './utils/logger';\n\nconst STORAGE_KEY_PREFIX = 'human_behavior_';\n\nexport interface QueuedEvent {\n sessionId: string;\n events: any[];\n endUserId?: string | null;\n windowId?: string;\n automaticProperties?: any;\n timestamp: number;\n}\n\nexport class EventPersistence {\n private storageKey: string;\n private maxQueueSize: number;\n\n constructor(apiKey: string, maxQueueSize: number = 1000) {\n this.storageKey = `${STORAGE_KEY_PREFIX}queue`;\n this.maxQueueSize = maxQueueSize;\n }\n\n /**\n * Get persisted events from storage\n */\n getQueue(): QueuedEvent[] {\n if (typeof window === 'undefined' || !window.localStorage) {\n return [];\n }\n\n try {\n const stored = window.localStorage.getItem(this.storageKey);\n if (!stored) {\n return [];\n }\n\n const queue = JSON.parse(stored);\n if (!Array.isArray(queue)) {\n return [];\n }\n\n return queue;\n } catch (error) {\n logWarn('Failed to read persisted queue:', error);\n return [];\n }\n }\n\n /**\n * Save events to storage\n */\n setQueue(queue: QueuedEvent[]): void {\n if (typeof window === 'undefined' || !window.localStorage) {\n return;\n }\n\n try {\n // Limit queue size\n const limitedQueue = queue.slice(-this.maxQueueSize);\n window.localStorage.setItem(this.storageKey, JSON.stringify(limitedQueue));\n logDebug(`Persisted ${limitedQueue.length} events to storage`);\n } catch (error: any) {\n // Handle quota exceeded errors gracefully\n if (error.name === 'QuotaExceededError' || error.code === 22) {\n logWarn('Storage quota exceeded, clearing old events');\n try {\n // Try to save a smaller queue\n const smallerQueue = queue.slice(-Math.floor(this.maxQueueSize / 2));\n window.localStorage.setItem(this.storageKey, JSON.stringify(smallerQueue));\n } catch (e) {\n logWarn('Failed to save smaller queue, clearing storage');\n this.clearQueue();\n }\n } else {\n logWarn('Failed to persist queue:', error);\n }\n }\n }\n\n /**\n * Add event to persisted queue\n */\n addToQueue(event: QueuedEvent): void {\n const queue = this.getQueue();\n queue.push(event);\n\n // Remove oldest events if queue is too large\n if (queue.length > this.maxQueueSize) {\n queue.shift();\n logDebug('Queue is full, the oldest event is dropped.');\n }\n\n this.setQueue(queue);\n }\n\n /**\n * Remove events from queue (after successful send)\n */\n removeFromQueue(count: number): void {\n const queue = this.getQueue();\n queue.splice(0, count);\n this.setQueue(queue);\n }\n\n /**\n * Clear persisted queue\n */\n clearQueue(): void {\n if (typeof window === 'undefined' || !window.localStorage) {\n return;\n }\n\n try {\n window.localStorage.removeItem(this.storageKey);\n } catch (error) {\n logWarn('Failed to clear persisted queue:', error);\n }\n }\n\n /**\n * Get queue length\n */\n getQueueLength(): number {\n return this.getQueue().length;\n }\n}\n\n","import { logError, logInfo, logDebug, logWarn } from './utils/logger';\nimport { v1 as uuidv1 } from 'uuid';\nimport { RetryQueue, RetriableRequestOptions } from './retry-queue';\nimport { EventPersistence, QueuedEvent } from './persistence';\n\n// SDK version will be replaced at build time by Rollup replace plugin\nconst SDK_VERSION = '__SDK_VERSION__';\n\nexport const MAX_CHUNK_SIZE_BYTES = 1024 * 1024; // 1MB chunk size - more conservative\nconst KEEP_ALIVE_THRESHOLD = 64 * 1024 * 0.8; // 64KB * 0.8 for safety margin\nconst REQUEST_TIMEOUT_MS = 10000; // 10 seconds default timeout\n\nexport function isChunkSizeExceeded(currentChunk: any[], newEvent: any, sessionId: string): boolean {\n const nextChunkSize = new TextEncoder().encode(safeJsonStringify({\n sessionId,\n events: [...currentChunk, newEvent]\n })).length;\n \n return nextChunkSize > MAX_CHUNK_SIZE_BYTES;\n}\n\nexport function validateSingleEventSize(event: any, sessionId: string): void {\n const singleEventSize = new TextEncoder().encode(safeJsonStringify({\n sessionId,\n events: [event]\n })).length;\n\n if (singleEventSize > MAX_CHUNK_SIZE_BYTES) {\n // Instead of throwing, log a warning and suggest reducing event size\n logWarn(`Single event size (${singleEventSize} bytes) exceeds maximum chunk size (${MAX_CHUNK_SIZE_BYTES} bytes). Consider reducing event data size.`);\n }\n}\n\n\n\n\n\n/**\n * Safe JSON stringify that handles BigInt values\n */\nfunction safeJsonStringify(data: any): string {\n return JSON.stringify(data, (_, value) => {\n if (typeof value === 'bigint') {\n return value.toString();\n }\n return value;\n });\n}\n\nexport function splitLargeEvent(event: any, sessionId: string): any[] {\n // ✅ SIMPLE VALIDATION\n if (!event || typeof event !== 'object') {\n return [];\n }\n \n const eventSize = new TextEncoder().encode(safeJsonStringify({\n sessionId,\n events: [event]\n })).length;\n\n if (eventSize <= MAX_CHUNK_SIZE_BYTES) {\n return [event];\n }\n\n // If event is too large, try to split it by removing large properties\n const simplifiedEvent = { ...event };\n \n // Remove potentially large properties\n const largeProperties = ['screenshot', 'html', 'dom', 'fullText', 'innerHTML', 'outerHTML'];\n largeProperties.forEach(prop => {\n if (simplifiedEvent[prop]) {\n delete simplifiedEvent[prop];\n }\n });\n\n // Check if simplified event is now small enough\n const simplifiedSize = new TextEncoder().encode(safeJsonStringify({\n sessionId,\n events: [simplifiedEvent]\n })).length;\n\n if (simplifiedSize <= MAX_CHUNK_SIZE_BYTES) {\n return [simplifiedEvent];\n }\n\n // If still too large, create a minimal event\n const minimalEvent = {\n type: event.type,\n timestamp: event.timestamp,\n url: event.url,\n pathname: event.pathname,\n // Keep only essential properties\n ...Object.fromEntries(\n Object.entries(event).filter(([key, value]) => \n !largeProperties.includes(key) && \n typeof value !== 'object' && \n typeof value !== 'string' || \n (typeof value === 'string' && value.length < 1000)\n )\n )\n };\n\n return [minimalEvent];\n}\n\nexport class HumanBehaviorAPI {\n private apiKey: string;\n private baseUrl: string;\n private monthlyLimitReached: boolean = false;\n private sessionId: string = '';\n private endUserId: string | null = null;\n private cspBlocked: boolean = false; // Track if CSP is blocking requests\n private retryQueue: RetryQueue;\n private persistence: EventPersistence;\n private requestTimeout: number = REQUEST_TIMEOUT_MS;\n private currentBatchSize: number = 100; // Dynamic batch size for 413 handling\n\n constructor({ apiKey, ingestionUrl }: { apiKey: string, ingestionUrl: string }) {\n this.apiKey = apiKey;\n this.baseUrl = ingestionUrl;\n this.persistence = new EventPersistence(apiKey);\n this.retryQueue = new RetryQueue((options) => this._sendRequestInternal(options));\n \n // Load persisted events on initialization\n this._loadPersistedEvents();\n }\n\n /**\n * Set session and user IDs for tracking context\n */\n public setTrackingContext(sessionId: string, endUserId: string | null): void {\n this.sessionId = sessionId;\n this.endUserId = endUserId;\n }\n\n /**\n * Load persisted events from storage and send them\n */\n private async _loadPersistedEvents(): Promise<void> {\n const persistedQueue = this.persistence.getQueue();\n if (persistedQueue.length === 0) {\n return;\n }\n\n logDebug(`Loading ${persistedQueue.length} persisted events from storage`);\n \n for (const queuedEvent of persistedQueue) {\n try {\n await this.sendEventsChunked(\n queuedEvent.events,\n queuedEvent.sessionId,\n queuedEvent.endUserId || undefined,\n queuedEvent.windowId,\n queuedEvent.automaticProperties\n );\n // Remove from persistence after successful send\n this.persistence.removeFromQueue(1);\n } catch (error) {\n logWarn('Failed to send persisted event, will retry later:', error);\n // Keep in persistence for retry\n }\n }\n }\n\n /**\n * Internal method to send request (used by retry queue)\n */\n private async _sendRequestInternal(options: RetriableRequestOptions): Promise<void> {\n const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n\n if (controller) {\n timeoutId = setTimeout(() => {\n controller!.abort();\n }, this.requestTimeout);\n }\n\n try {\n const estimatedSize = options.estimatedSize || 0;\n const useKeepalive = options.method === 'POST' && estimatedSize < KEEP_ALIVE_THRESHOLD;\n\n const response = await fetch(options.url, {\n method: options.method || 'GET',\n headers: options.headers || {},\n body: options.body,\n signal: controller?.signal,\n keepalive: useKeepalive\n });\n\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n\n const responseText = await response.text();\n let responseJson: any = null;\n \n try {\n responseJson = JSON.parse(responseText);\n } catch {\n // Not JSON, ignore\n }\n\n if (options.callback) {\n options.callback({\n statusCode: response.status,\n text: responseText,\n json: responseJson\n });\n }\n\n if (!response.ok) {\n throw { status: response.status, message: responseText };\n }\n } catch (error: any) {\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n\n if (error.name === 'AbortError') {\n throw { status: 0, message: 'Request timeout' };\n }\n throw error;\n }\n }\n\n /**\n * Handle unload - send pending retries via sendBeacon\n */\n public unload(): void {\n this.retryQueue.unload();\n }\n\n private checkMonthlyLimit(): boolean {\n if (this.monthlyLimitReached) {\n return false;\n }\n return true;\n }\n\n public async init(sessionId: string, userId: string | null) {\n // Check if monthly limit is already reached - silently skip if so\n if (!this.checkMonthlyLimit()) {\n // Silently return success to avoid any errors\n return {\n sessionId: sessionId,\n endUserId: userId\n };\n }\n\n // Get current page URL and referrer if in browser environment\n let entryURL = null;\n let referrer = null;\n \n if (typeof window !== 'undefined') {\n entryURL = window.location.href;\n referrer = document.referrer;\n }\n\n logInfo('API init called with:', { sessionId, userId, entryURL, referrer, baseUrl: this.baseUrl });\n\n try {\n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/init`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`,\n 'Referer': referrer || ''\n },\n body: safeJsonStringify({\n sessionId: sessionId,\n endUserId: userId,\n entryURL: entryURL,\n referrer: referrer,\n sdkVersion: SDK_VERSION // Include SDK version for tracking\n })\n });\n\n logInfo('API init response status:', response.status);\n\n if (!response.ok) {\n if (response.status === 429) {\n this.monthlyLimitReached = true;\n // Silently return success to avoid any errors\n return {\n sessionId: sessionId,\n endUserId: userId\n };\n }\n const errorText = await response.text();\n logError('API init failed:', response.status, errorText);\n throw new Error(`Failed to initialize ingestion: ${response.statusText} - ${errorText}`);\n } \n\n const responseJson = await response.json();\n \n // Check for monthly limit flag in successful response\n if (responseJson.monthlyLimitReached === true) {\n this.monthlyLimitReached = true;\n logInfo('Monthly limit reached detected from server response');\n }\n \n logInfo('API init success:', responseJson);\n return {\n sessionId: responseJson.sessionId,\n endUserId: responseJson.endUserId\n }\n } catch (error) {\n logError('API init error:', error);\n throw error;\n }\n }\n\n /**\n * Server detects IP from HTTP requests automatically\n */\n\n async sendEvents(events: any[], sessionId: string, userId: string) {\n // ✅ SIMPLE VALIDATION FOR ALL EVENTS\n const validEvents = events.filter(event => event && typeof event === 'object');\n \n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/events`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify({\n sessionId,\n events: validEvents,\n endUserId: userId,\n sdkVersion: SDK_VERSION // Include SDK version for tracking\n })\n });\n \n if (!response.ok) {\n if (response.status === 429) {\n this.monthlyLimitReached = true;\n throw new Error(`429: Monthly video processing limit reached`);\n }\n throw new Error(`Failed to send events: ${response.statusText}`);\n }\n \n // Check for monthly limit flag in successful response\n const responseJson = await response.json();\n if (responseJson.monthlyLimitReached === true) {\n this.monthlyLimitReached = true;\n logInfo('Monthly limit reached detected from events response');\n }\n }\n \n async sendEventsChunked(events: any[], sessionId: string, userId?: string, windowId?: string, automaticProperties?: any) {\n // Check if monthly limit is already reached - silently skip if so\n if (!this.checkMonthlyLimit()) {\n // Silently return success to avoid any errors\n return [];\n }\n try {\n const results = [];\n let currentChunk: any[] = [];\n \n for (const event of events) {\n // ✅ SIMPLE VALIDATION FOR ALL EVENTS\n if (!event || typeof event !== 'object') {\n continue;\n }\n \n if (isChunkSizeExceeded(currentChunk, event, sessionId)) {\n // If current chunk is not empty, send it first\n if (currentChunk.length > 0) {\n logDebug(`[SDK] Sending chunk with ${currentChunk.length} events`);\n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/events`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify({\n sessionId,\n events: currentChunk,\n endUserId: userId,\n windowId: windowId,\n automaticProperties: automaticProperties, // Include automatic properties for user creation\n sdkVersion: SDK_VERSION // Include SDK version for tracking\n })\n });\n \n if (!response.ok) {\n if (response.status === 429) {\n this.monthlyLimitReached = true;\n // Silently skip this chunk\n return results.flat();\n }\n throw new Error(`Failed to send events: ${response.statusText}`);\n }\n \n const responseJson = await response.json();\n \n // Check for monthly limit flag in successful response\n if (responseJson.monthlyLimitReached === true) {\n this.monthlyLimitReached = true;\n logInfo('Monthly limit reached detected from chunked events response');\n }\n \n results.push(responseJson);\n currentChunk = [];\n }\n\n // Handle large events by splitting them\n const splitEvents = splitLargeEvent(event, sessionId);\n \n // Start new chunk with the split events\n currentChunk = splitEvents;\n } else {\n // Add event to current chunk\n currentChunk.push(event);\n }\n }\n \n // Send any remaining events\n if (currentChunk.length > 0) {\n const result = await this._sendChunkWithRetry(\n currentChunk,\n sessionId,\n userId,\n windowId,\n automaticProperties || currentChunk[0]?.automaticProperties\n );\n if (result) {\n results.push(result);\n }\n }\n \n return results.flat();\n } catch (error) {\n logError('Error sending events:', error);\n // Persist failed events for retry\n this._persistEvents(events, sessionId, userId, windowId, automaticProperties);\n throw error;\n }\n }\n\n /**\n * Send a chunk of events with retry logic and 413 handling\n */\n private async _sendChunkWithRetry(\n chunk: any[],\n sessionId: string,\n userId?: string,\n windowId?: string,\n automaticProperties?: any\n ): Promise<any | null> {\n let batchSize = Math.min(this.currentBatchSize, chunk.length);\n let startIndex = 0;\n\n while (startIndex < chunk.length) {\n const batch = chunk.slice(startIndex, startIndex + batchSize);\n const payload = {\n sessionId,\n events: batch,\n endUserId: userId,\n windowId: windowId,\n automaticProperties: automaticProperties,\n sdkVersion: SDK_VERSION // Include SDK version for tracking\n };\n\n const bodyString = safeJsonStringify(payload);\n const estimatedSize = new TextEncoder().encode(bodyString).length;\n\n try {\n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/events`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: bodyString\n }, estimatedSize);\n \n if (!response.ok) {\n if (response.status === 429) {\n this.monthlyLimitReached = true;\n // Persist remaining events\n this._persistEvents(chunk.slice(startIndex), sessionId, userId, windowId, automaticProperties);\n return null;\n }\n \n if (response.status === 413) {\n // Content too large - reduce batch size and retry\n logWarn(`413 error: reducing batch size from ${batchSize} to ${Math.max(1, Math.floor(batchSize / 2))}`);\n this.currentBatchSize = Math.max(1, Math.floor(batchSize / 2));\n batchSize = this.currentBatchSize;\n // Retry with smaller batch\n continue;\n }\n\n // For other errors, use retry queue\n await this.retryQueue.retriableRequest({\n url: `${this.baseUrl}/api/ingestion/events`,\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: bodyString,\n estimatedSize: estimatedSize,\n callback: (response) => {\n if (response.statusCode === 200 && response.json) {\n if (response.json.monthlyLimitReached === true) {\n this.monthlyLimitReached = true;\n }\n }\n }\n });\n \n // Persist for retry\n this._persistEvents(chunk.slice(startIndex), sessionId, userId, windowId, automaticProperties);\n return null;\n }\n \n const responseJson = await response.json();\n \n // Check for monthly limit flag in successful response\n if (responseJson.monthlyLimitReached === true) {\n this.monthlyLimitReached = true;\n logInfo('Monthly limit reached detected from chunked events response');\n }\n \n startIndex += batchSize;\n \n // If we successfully sent a batch, return the result\n if (startIndex >= chunk.length) {\n return responseJson;\n }\n } catch (error: any) {\n // Network error - use retry queue\n logWarn('Network error sending chunk, adding to retry queue:', error);\n await this.retryQueue.retriableRequest({\n url: `${this.baseUrl}/api/ingestion/events`,\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: bodyString,\n estimatedSize: estimatedSize,\n callback: (response) => {\n if (response.statusCode === 200 && response.json) {\n if (response.json.monthlyLimitReached === true) {\n this.monthlyLimitReached = true;\n }\n }\n }\n });\n \n // Persist for retry\n this._persistEvents(chunk.slice(startIndex), sessionId, userId, windowId, automaticProperties);\n return null;\n }\n }\n\n return null;\n }\n\n /**\n * Persist events to storage for retry\n */\n private _persistEvents(\n events: any[],\n sessionId: string,\n userId?: string,\n windowId?: string,\n automaticProperties?: any\n ): void {\n if (events.length === 0) {\n return;\n }\n\n this.persistence.addToQueue({\n sessionId,\n events,\n endUserId: userId,\n windowId,\n automaticProperties,\n timestamp: Date.now()\n });\n }\n\n async sendUserData(userId: string, userData: Record<string, any>, sessionId: string) {\n try {\n const payload = {\n userId: userId,\n userAttributes: userData,\n sessionId: sessionId,\n posthogName: userData.email || userData.name || null // Update user name with email\n };\n \n logDebug('Sending user data to server:', payload);\n \n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/user`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify(payload)\n });\n \n if (!response.ok) {\n throw new Error(`Failed to send user data: ${response.statusText} with API key: ${this.apiKey}`);\n }\n \n const result = await response.json();\n logDebug('Server response:', result);\n return result;\n } catch (error) {\n logError('Error sending user data:', error);\n throw error;\n }\n }\n\n async sendUserAuth(userId: string, userData: Record<string, any>, sessionId: string, authFields: string[]) {\n try {\n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/user/auth`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify({\n userId: userId,\n userAttributes: userData,\n sessionId: sessionId,\n authFields: authFields\n })\n });\n \n if (!response.ok) {\n throw new Error(`Failed to authenticate user: ${response.statusText} with API key: ${this.apiKey}`);\n }\n // Returns: { success: true, message: '...', userId: '...' }\n return await response.json();\n } catch (error) {\n logError('Error authenticating user:', error);\n throw error;\n }\n }\n\n public sendBeaconEvents(events: any[], sessionId: string, userId?: string, windowId?: string, automaticProperties?: any) {\n // Create JSON payload that matches the server's expected format\n // ✅ FIX: Include all fields that sendEventsChunked includes\n // This ensures sendBeacon requests are processed identically to regular HTTP requests\n const payload = {\n sessionId: sessionId,\n events: events,\n endUserId: userId || null, // ✅ FIX: Use actual userId instead of hardcoded null\n windowId: windowId, // ✅ FIX: Include windowId if available\n automaticProperties: automaticProperties, // ✅ FIX: Include automatic properties for user creation\n sdkVersion: SDK_VERSION, // ✅ FIX: Include SDK version for tracking\n apiKey: this.apiKey // Include API key in body since beacon can't use headers\n };\n\n // Convert to Blob for sendBeacon\n const blob = new Blob([safeJsonStringify(payload)], {\n type: 'application/json'\n });\n\n const success = navigator.sendBeacon(\n `${this.baseUrl}/api/ingestion/events`, \n blob\n );\n\n return success;\n }\n\n async sendCustomEvent(sessionId: string, eventName: string, eventProperties?: Record<string, any>, endUserId?: string | null) {\n logInfo('[SDK] Sending custom event', { sessionId, eventName, eventProperties, endUserId });\n try {\n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/customEvent`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify({\n sessionId: sessionId,\n eventName: eventName,\n eventProperties: eventProperties || {},\n endUserId: endUserId || null\n })\n });\n \n logInfo('[SDK] Custom event response', { status: response.status, statusText: response.statusText });\n \n if (!response.ok) {\n const errorText = await response.text();\n logError('[SDK] Failed to send custom event', { status: response.status, statusText: response.statusText, errorText });\n throw new Error(`Failed to send custom event: ${response.status} ${response.statusText} - ${errorText}`);\n }\n \n const json = await response.json();\n logDebug('[SDK] Custom event success', json);\n return json;\n } catch (error) {\n logError('[SDK] Error sending custom event', error, { sessionId, eventName, eventProperties });\n throw error;\n }\n }\n\n async sendCustomEventBatch(sessionId: string, events: Array<{ eventName: string; eventProperties?: Record<string, any> }>, endUserId?: string | null) {\n try {\n const response = await this.trackedFetch(`${this.baseUrl}/api/ingestion/customEvent/batch`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify({\n sessionId: sessionId,\n events: events,\n endUserId: endUserId || null\n })\n });\n \n if (!response.ok) {\n throw new Error(`Failed to send custom event batch: ${response.statusText}`);\n }\n \n return await response.json();\n } catch (error) {\n logError('Error sending custom event batch:', error);\n throw error;\n }\n }\n\n /**\n * Send console log (warn/error) to ingestion server\n */\n async sendLog(logData: {\n level: 'warn' | 'error';\n message: string;\n stack?: string;\n url: string;\n timestampMs: number;\n sessionId: string;\n endUserId: string | null;\n }): Promise<void> {\n try {\n logDebug('[SDK] Sending log to server:', { level: logData.level, message: logData.message.substring(0, 50), sessionId: logData.sessionId });\n \n if (!this.baseUrl) {\n return;\n }\n \n if (!logData.sessionId) {\n return;\n }\n \n // Use regular fetch (not trackedFetch) since this is SDK's own request\n const response = await fetch(`${this.baseUrl}/api/ingestion/logs`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify(logData)\n });\n \n if (!response.ok) {\n logWarn('[SDK] Failed to send log to server:', response.status, response.statusText);\n } else {\n logDebug('[SDK] Log sent successfully');\n }\n } catch (error) {\n // Silent fail - don't break app if logging fails\n logWarn('[SDK] Failed to send log to server:', error);\n }\n }\n\n /**\n * Send network error to ingestion server\n */\n async sendNetworkError(errorData: {\n requestId: string;\n url: string;\n method: string;\n status: number | null;\n statusText: string | null;\n duration: number;\n timestampMs: number;\n sessionId: string;\n endUserId: string | null;\n errorType: string;\n errorMessage: string | null;\n errorName?: string | null;\n // New span fields\n startTimeMs?: number;\n spanName?: string;\n spanStatus?: 'error' | 'success' | 'slow';\n attributes?: Record<string, any>;\n }): Promise<void> {\n try {\n logDebug('[SDK] Sending network error to server:', { errorType: errorData.errorType, url: errorData.url.substring(0, 50), sessionId: errorData.sessionId });\n \n if (!this.baseUrl) {\n return;\n }\n \n if (!errorData.sessionId) {\n return;\n }\n \n // Use regular fetch (not trackedFetch) since this is SDK's own request\n const response = await fetch(`${this.baseUrl}/api/ingestion/network`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.apiKey}`\n },\n body: safeJsonStringify(errorData)\n });\n \n if (!response.ok) {\n logWarn('[SDK] Failed to send network error to server:', response.status, response.statusText);\n } else {\n logDebug('[SDK] Network error sent successfully');\n }\n } catch (error) {\n // Silent fail - don't break app if tracking fails\n logWarn('[SDK] Failed to send network error to server:', error);\n }\n }\n\n /**\n * Wrapper for fetch that tracks network errors and falls back to sendBeacon on CSP violations\n * Skips tracking for SDK's own requests to ingestion server\n */\n private async trackedFetch(url: string, options: RequestInit, estimatedSize?: number): Promise<Response> {\n const requestStartTime = Date.now();\n const requestId = uuidv1();\n \n // ✅ SKIP TRACKING: Don't track SDK's own requests to ingestion server\n const shouldSkipTracking = this.shouldSkipNetworkTracking(url);\n \n // If CSP is already known to be blocking, use sendBeacon directly for POST requests\n if (this.cspBlocked && options.method === 'POST' && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {\n return this.trackedFetchWithBeaconFallback(url, options, shouldSkipTracking);\n }\n \n try {\n const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n\n if (controller) {\n timeoutId = setTimeout(() => {\n controller!.abort();\n }, this.requestTimeout);\n }\n\n const useKeepalive = options.method === 'POST' && estimatedSize !== undefined && estimatedSize < KEEP_ALIVE_THRESHOLD;\n\n const response = await fetch(url, {\n ...options,\n signal: controller?.signal,\n keepalive: useKeepalive\n });\n\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n const requestDuration = Date.now() - requestStartTime;\n \n // Track failed requests (4xx, 5xx) AND skip SDK requests\n if (!response.ok && !shouldSkipTracking) {\n await this.sendNetworkError({\n requestId,\n