humanbehavior-js
Version:
SDK for HumanBehavior session and event recording
377 lines (320 loc) • 13.7 kB
text/typescript
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;
}
}
}