UNPKG

humanbehavior-js

Version:

SDK for HumanBehavior session and event recording

377 lines (320 loc) 13.7 kB
import { logError, logInfo, logDebug, logWarn } from './utils/logger'; export const MAX_CHUNK_SIZE_BYTES = 1024 * 1024; // 1MB chunk size - more conservative export function isChunkSizeExceeded(currentChunk: any[], newEvent: any, sessionId: string): boolean { const nextChunkSize = new TextEncoder().encode(JSON.stringify({ sessionId, events: [...currentChunk, newEvent] })).length; return nextChunkSize > MAX_CHUNK_SIZE_BYTES; } export function validateSingleEventSize(event: any, sessionId: string): void { const singleEventSize = new TextEncoder().encode(JSON.stringify({ sessionId, events: [event] })).length; if (singleEventSize > MAX_CHUNK_SIZE_BYTES) { // Instead of throwing, log a warning and suggest reducing event size logWarn(`Single event size (${singleEventSize} bytes) exceeds maximum chunk size (${MAX_CHUNK_SIZE_BYTES} bytes). Consider reducing event data size.`); } } export function splitLargeEvent(event: any, sessionId: string): any[] { // ✅ SIMPLE VALIDATION if (!event || typeof event !== 'object') { return []; } const eventSize = new TextEncoder().encode(JSON.stringify({ sessionId, events: [event] })).length; if (eventSize <= MAX_CHUNK_SIZE_BYTES) { return [event]; } // If event is too large, try to split it by removing large properties const simplifiedEvent = { ...event }; // Remove potentially large properties const largeProperties = ['screenshot', 'html', 'dom', 'fullText', 'innerHTML', 'outerHTML']; largeProperties.forEach(prop => { if (simplifiedEvent[prop]) { delete simplifiedEvent[prop]; } }); // Check if simplified event is now small enough const simplifiedSize = new TextEncoder().encode(JSON.stringify({ sessionId, events: [simplifiedEvent] })).length; if (simplifiedSize <= MAX_CHUNK_SIZE_BYTES) { return [simplifiedEvent]; } // If still too large, create a minimal event const minimalEvent = { type: event.type, timestamp: event.timestamp, url: event.url, pathname: event.pathname, // Keep only essential properties ...Object.fromEntries( Object.entries(event).filter(([key, value]) => !largeProperties.includes(key) && typeof value !== 'object' && typeof value !== 'string' || (typeof value === 'string' && value.length < 1000) ) ) }; return [minimalEvent]; } export class HumanBehaviorAPI { private apiKey: string; private baseUrl: string; constructor({ apiKey, ingestionUrl }: { apiKey: string, ingestionUrl: string }) { this.apiKey = apiKey; this.baseUrl = ingestionUrl; } public async init(sessionId: string, userId: string | null) { // Get current page URL and referrer if in browser environment let entryURL = null; let referrer = null; if (typeof window !== 'undefined') { entryURL = window.location.href; referrer = document.referrer; } logInfo('API init called with:', { sessionId, userId, entryURL, referrer, baseUrl: this.baseUrl }); try { const response = await fetch(`${this.baseUrl}/api/ingestion/init`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, 'Referer': referrer || '' }, body: JSON.stringify({ sessionId: sessionId, endUserId: userId, entryURL: entryURL, referrer: referrer }) }); logInfo('API init response status:', response.status); if (!response.ok) { const errorText = await response.text(); logError('API init failed:', response.status, errorText); throw new Error(`Failed to initialize ingestion: ${response.statusText} - ${errorText}`); } const responseJson = await response.json(); logInfo('API init success:', responseJson); return { sessionId: responseJson.sessionId, endUserId: responseJson.endUserId } } catch (error) { logError('API init error:', error); throw error; } } async sendEvents(events: any[], sessionId: string, userId: string) { // ✅ SIMPLE VALIDATION FOR ALL EVENTS const validEvents = events.filter(event => event && typeof event === 'object'); const response = await fetch(`${this.baseUrl}/api/ingestion/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ sessionId, events: validEvents, endUserId: userId }) }); if (!response.ok) { throw new Error(`Failed to send events: ${response.statusText}`); } } async sendEventsChunked(events: any[], sessionId: string, userId?: string) { try { const results = []; let currentChunk: any[] = []; for (const event of events) { // ✅ SIMPLE VALIDATION FOR ALL EVENTS if (!event || typeof event !== 'object') { continue; } if (isChunkSizeExceeded(currentChunk, event, sessionId)) { // If current chunk is not empty, send it first if (currentChunk.length > 0) { const response = await fetch(`${this.baseUrl}/api/ingestion/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ sessionId, events: currentChunk, endUserId: userId }) }); if (!response.ok) { throw new Error(`Failed to send events: ${response.statusText}`); } results.push(await response.json()); currentChunk = []; } // Handle large events by splitting them const splitEvents = splitLargeEvent(event, sessionId); // Start new chunk with the split events currentChunk = splitEvents; } else { // Add event to current chunk currentChunk.push(event); } } // Send any remaining events if (currentChunk.length > 0) { const response = await fetch(`${this.baseUrl}/api/ingestion/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ sessionId, events: currentChunk, endUserId: userId }) }); if (!response.ok) { throw new Error(`Failed to send events: ${response.statusText}`); } results.push(await response.json()); } return results.flat(); } catch (error) { logError('Error sending events:', error); throw error; } } async sendUserData(userId: string, userData: Record<string, any>, sessionId: string) { try { const payload = { userId: userId, userAttributes: userData, sessionId: sessionId, posthogName: userData.email || userData.name || null // Update user name with email }; logDebug('Sending user data to server:', payload); const response = await fetch(`${this.baseUrl}/api/ingestion/user`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`Failed to send user data: ${response.statusText} with API key: ${this.apiKey}`); } const result = await response.json(); logDebug('Server response:', result); return result; } catch (error) { logError('Error sending user data:', error); throw error; } } async sendUserAuth(userId: string, userData: Record<string, any>, sessionId: string, authFields: string[]) { try { const response = await fetch(`${this.baseUrl}/api/ingestion/user/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ userId: userId, userAttributes: userData, sessionId: sessionId, authFields: authFields }) }); if (!response.ok) { throw new Error(`Failed to authenticate user: ${response.statusText} with API key: ${this.apiKey}`); } // Returns: { success: true, message: '...', userId: '...' } return await response.json(); } catch (error) { logError('Error authenticating user:', error); throw error; } } public sendBeaconEvents(events: any[], sessionId: string) { // Create JSON payload that matches the server's expected format const payload = { sessionId: sessionId, events: events, endUserId: null, // Beacon doesn't have user context apiKey: this.apiKey // Include API key in body since beacon can't use headers }; // Convert to Blob for sendBeacon const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); const success = navigator.sendBeacon( `${this.baseUrl}/api/ingestion/events`, blob ); return success; } async sendCustomEvent(sessionId: string, eventName: string, eventProperties?: Record<string, any>) { logInfo('[SDK] Sending custom event', { sessionId, eventName, eventProperties }); try { const response = await fetch(`${this.baseUrl}/api/ingestion/customEvent`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ sessionId: sessionId, eventName: eventName, eventProperties: eventProperties || {} }) }); logInfo('[SDK] Custom event response', { status: response.status, statusText: response.statusText }); if (!response.ok) { const errorText = await response.text(); logError('[SDK] Failed to send custom event', { status: response.status, statusText: response.statusText, errorText }); throw new Error(`Failed to send custom event: ${response.status} ${response.statusText} - ${errorText}`); } const json = await response.json(); logDebug('[SDK] Custom event success', json); return json; } catch (error) { logError('[SDK] Error sending custom event', error, { sessionId, eventName, eventProperties }); throw error; } } async sendCustomEventBatch(sessionId: string, events: Array<{ eventName: string; eventProperties?: Record<string, any> }>) { try { const response = await fetch(`${this.baseUrl}/api/ingestion/customEvent/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ sessionId: sessionId, events: events }) }); if (!response.ok) { throw new Error(`Failed to send custom event batch: ${response.statusText}`); } return await response.json(); } catch (error) { logError('Error sending custom event batch:', error); throw error; } } }