humanbehavior-js 0.0.5 → 0.0.8

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.
package/src/tracker.ts ADDED
@@ -0,0 +1,361 @@
1
+ import * as rrweb from 'rrweb';
2
+ import { v1 as uuidv1 } from 'uuid';
3
+ import { HumanBehaviorAPI } from './api';
4
+ import { RedactionManager, RedactionOptions } from './redact';
5
+
6
+ // Check if we're in a browser environment
7
+ const isBrowser = typeof window !== 'undefined';
8
+
9
+ // Add type declaration at the top level
10
+ declare global {
11
+ interface Window {
12
+ HumanBehaviorTracker: typeof HumanBehaviorTracker;
13
+ }
14
+ }
15
+
16
+ export class HumanBehaviorTracker {
17
+ private eventIngestionQueue: any[] = [];
18
+ private queueSizeBytes: number = 0;
19
+ private rejectedEvents: any[] = [];
20
+ private isProcessingRejectedEvents: boolean = false;
21
+
22
+ private sessionId: string;
23
+ private userProperties: Record<string, any> = {};
24
+ private isProcessing: boolean = false;
25
+ private flushInterval: number | null = null;
26
+ private readonly FLUSH_INTERVAL_MS = 5000; // Flush every 5 seconds
27
+ private api: HumanBehaviorAPI;
28
+ private endUserId: string | null = null;
29
+ private apiKey: string;
30
+ private initialized: boolean = false;
31
+ public initializationPromise: Promise<void> | null = null;
32
+ private redactionManager: RedactionManager;
33
+
34
+ constructor(apiKey: string | undefined, ingestionUrl?: string) {
35
+ if (!apiKey) {
36
+ throw new Error('Human Behavior API Key is required');
37
+ }
38
+
39
+ // ========================================
40
+ // DEVELOPER: Choose your ingestion server
41
+ // ========================================
42
+ // Uncomment ONE of the following lines to select your server:
43
+
44
+ // AWS Development Server
45
+ const defaultIngestionUrl = 'http://3.137.217.33:3000';
46
+
47
+ // Vercel Production Server
48
+ // const defaultIngestionUrl = 'https://ingestion-server.vercel.app';
49
+
50
+ // Local Development Server
51
+ // const defaultIngestionUrl = 'http://localhost:3000';
52
+
53
+ this.api = new HumanBehaviorAPI({
54
+ apiKey: apiKey,
55
+ ingestionUrl: ingestionUrl || defaultIngestionUrl
56
+ });
57
+ this.apiKey = apiKey;
58
+ this.redactionManager = new RedactionManager();
59
+
60
+ // Check for existing session ID and last activity time in localStorage
61
+ const existingSessionId = isBrowser ? localStorage.getItem('human_behavior_session_id') : null;
62
+ const lastActivity = isBrowser ? localStorage.getItem('human_behavior_last_activity') : null;
63
+
64
+ // If we have a last activity time, check if it's within 30 minutes
65
+ const thirtyMinutesAgo = Date.now() - (30 * 60 * 1000);
66
+ const shouldUseExistingSession = lastActivity && parseInt(lastActivity) > thirtyMinutesAgo;
67
+ this.sessionId = (existingSessionId && shouldUseExistingSession) ? existingSessionId : uuidv1();
68
+
69
+ // Store the session ID if it's new
70
+ if ((!existingSessionId || !shouldUseExistingSession) && isBrowser) {
71
+ localStorage.setItem('human_behavior_session_id', this.sessionId);
72
+ }
73
+
74
+ // Start initialization immediately
75
+ this.initializationPromise = this.init();
76
+ }
77
+
78
+ private async init(): Promise<void> {
79
+ try {
80
+ const userId = this.getCookie(`human_behavior_end_user_id_${this.apiKey}`);
81
+ const { sessionId, endUserId } = await this.api.init(this.sessionId, userId);
82
+ this.sessionId = sessionId;
83
+ this.endUserId = endUserId;
84
+ this.setCookie(`human_behavior_end_user_id_${this.apiKey}`, endUserId, 365);
85
+
86
+ // Only setup browser-specific handlers when in browser environment
87
+ if (isBrowser) {
88
+ this.setupPageUnloadHandler();
89
+ this.start();
90
+ this.processRejectedEvents();
91
+ } else {
92
+ console.warn('HumanBehaviorTracker initialized in a non-browser environment. Session tracking is disabled.');
93
+ }
94
+
95
+ this.initialized = true;
96
+ console.log('HumanBehaviorTracker initialized');
97
+ } catch (error) {
98
+ console.error('Failed to initialize HumanBehaviorTracker:', error);
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ private async ensureInitialized(): Promise<void> {
104
+ if (!this.initializationPromise) {
105
+ throw new Error('HumanBehaviorTracker initialization failed');
106
+ }
107
+ await this.initializationPromise;
108
+ }
109
+
110
+ public static logToStorage(message: string) {
111
+ try {
112
+ const logs = JSON.parse(localStorage.getItem('human_behavior_logs') || '[]');
113
+ logs.push(`${new Date().toISOString()}: ${message}`);
114
+ localStorage.setItem('human_behavior_logs', JSON.stringify(logs));
115
+ } catch (e) {
116
+ console.error('Failed to log to storage:', e);
117
+ }
118
+ }
119
+
120
+ private setupPageUnloadHandler() {
121
+ if (!isBrowser) return;
122
+
123
+ console.log('Setting up page unload handler');
124
+
125
+ // Handle visibility changes for sending events
126
+ window.addEventListener('visibilitychange', () => {
127
+ // Only send events when page becomes hidden
128
+ if (document.visibilityState === 'hidden') {
129
+ console.log('Page hidden - sending pending events');
130
+ this.api.sendBeaconEvents(this.eventIngestionQueue, this.sessionId);
131
+ }
132
+ });
133
+
134
+ // Handle actual page unload/close
135
+ window.addEventListener('beforeunload', () => {
136
+ // Update last activity time
137
+ localStorage.setItem('human_behavior_last_activity', Date.now().toString());
138
+
139
+ // Send final events
140
+ this.api.sendBeaconEvents(this.eventIngestionQueue, this.sessionId);
141
+ });
142
+
143
+ // Update activity timestamp periodically
144
+ setInterval(() => {
145
+ localStorage.setItem('human_behavior_last_activity', Date.now().toString());
146
+ }, 60000); // Update every minute
147
+ }
148
+
149
+ public viewLogs() {
150
+ try {
151
+ const logs = JSON.parse(localStorage.getItem('human_behavior_logs') || '[]');
152
+ console.log('HumanBehavior Logs:', logs);
153
+ localStorage.removeItem('human_behavior_logs'); // Clear logs after viewing
154
+ } catch (e) {
155
+ console.error('Failed to read logs:', e);
156
+ }
157
+ }
158
+
159
+ public async addUserInfo(userProperties: Record<string, any>) {
160
+ await this.ensureInitialized();
161
+ if (!this.endUserId) {
162
+ throw new Error('Cannot add user info before tracker initialization');
163
+ }
164
+ this.userProperties = userProperties;
165
+ await this.api.sendUserData(this.endUserId, userProperties, this.sessionId);
166
+ }
167
+
168
+ /**
169
+ * Authenticate user using existing userInfo data
170
+ * @param authFields Array of field names to check for existing users (e.g., ['email', 'phoneNumber'])
171
+ */
172
+ public async auth(authFields: string[]) {
173
+ await this.ensureInitialized();
174
+ if (!this.endUserId) {
175
+ throw new Error('Cannot authenticate before tracker initialization');
176
+ }
177
+ if (!this.userProperties || Object.keys(this.userProperties).length === 0) {
178
+ throw new Error('No user info available. Call addUserInfo() first.');
179
+ }
180
+ await this.api.sendUserAuth(this.endUserId, this.userProperties, this.sessionId, authFields);
181
+ }
182
+
183
+ public async customEvent(eventName: string, eventProperties: Record<string, any> = {}) {
184
+ await this.ensureInitialized();
185
+ this.api.sendBeaconCustomEvent(eventName, eventProperties, this.sessionId);
186
+ }
187
+
188
+ public async start() {
189
+ await this.ensureInitialized();
190
+ if (!isBrowser) return;
191
+
192
+ // Start periodic flushing
193
+ this.flushInterval = window.setInterval(() => {
194
+ this.flush();
195
+ }, this.FLUSH_INTERVAL_MS);
196
+
197
+ // Start recording with redaction enabled
198
+ rrweb.record({
199
+ emit: (event) => {
200
+ this.addEvent(event);
201
+ },
202
+ inlineStylesheet: true,
203
+ recordCanvas: true,
204
+ collectFonts: true,
205
+ blockClass: 'rr-block',
206
+ ignoreClass: 'rr-ignore',
207
+ maskTextClass: 'rr-ignore'
208
+ });
209
+ }
210
+
211
+ public async stop() {
212
+ await this.ensureInitialized();
213
+ if (!isBrowser) return;
214
+
215
+ if (this.flushInterval) {
216
+ clearInterval(this.flushInterval);
217
+ this.flushInterval = null;
218
+ }
219
+ }
220
+
221
+ public async addEvent(event: any) {
222
+ await this.ensureInitialized();
223
+
224
+ // Process event through redaction manager if active
225
+ const processedEvent = this.redactionManager.processEvent(event);
226
+
227
+ const eventSize = new TextEncoder().encode(JSON.stringify(processedEvent)).length;
228
+ this.eventIngestionQueue.push(processedEvent);
229
+ this.queueSizeBytes += eventSize;
230
+ }
231
+
232
+ private async processRejectedEvents() {
233
+ if (this.isProcessingRejectedEvents || this.rejectedEvents.length === 0) return;
234
+
235
+ this.isProcessingRejectedEvents = true;
236
+ try {
237
+ // Create a new session ID for rejected events
238
+ const newSessionId = uuidv1();
239
+ if (isBrowser) {
240
+ localStorage.setItem('human_behavior_session_id', newSessionId);
241
+ }
242
+
243
+ // Try to send rejected events with new session ID using beacon
244
+ // sendBeacon returns true if the request was queued successfully
245
+ this.api.sendBeaconEvents(this.rejectedEvents, newSessionId);
246
+
247
+ // Clear rejected events and update session ID
248
+ // Note: We can't verify if the beacon data was actually sent,
249
+ // but we clear the events to prevent duplicate sending attempts
250
+ this.rejectedEvents = [];
251
+ this.sessionId = newSessionId;
252
+ } catch (error) {
253
+ console.error('Failed to process rejected events:', error);
254
+ } finally {
255
+ this.isProcessingRejectedEvents = false;
256
+ }
257
+ }
258
+
259
+ private async flush() {
260
+ // Prevent concurrent flushes
261
+ if (this.isProcessing || !this.initialized) {
262
+ return;
263
+ }
264
+
265
+ this.isProcessing = true;
266
+ try {
267
+ // Swap the current queue with an empty one atomically
268
+ const eventsToProcess = this.eventIngestionQueue;
269
+ this.eventIngestionQueue = [];
270
+ this.queueSizeBytes = 0;
271
+
272
+ if (eventsToProcess.length > 0) {
273
+ console.log('Flushing events:', eventsToProcess);
274
+ try {
275
+ await this.api.sendEvents(eventsToProcess, this.sessionId, this.endUserId!);
276
+ } catch (error: any) {
277
+ // If we get a 400 error, store events for retry
278
+ if (error.message?.includes('ERROR: Session already completed')) {
279
+ console.log('Session expired, storing events for retry');
280
+ this.rejectedEvents.push(...eventsToProcess);
281
+ this.processRejectedEvents();
282
+ } else {
283
+ throw error;
284
+ }
285
+ }
286
+ }
287
+ } finally {
288
+ this.isProcessing = false;
289
+ }
290
+ }
291
+
292
+ // Add helper methods for cookie management
293
+ private setCookie(name: string, value: string, daysToExpire: number) {
294
+ if (!isBrowser) return;
295
+ const date = new Date();
296
+ date.setTime(date.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
297
+ const expires = `expires=${date.toUTCString()}`;
298
+ document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
299
+ }
300
+
301
+ private getCookie(name: string): string | null {
302
+ if (!isBrowser) return null;
303
+ const nameEQ = name + "=";
304
+ const ca = document.cookie.split(';');
305
+ for (let i = 0; i < ca.length; i++) {
306
+ let c = ca[i];
307
+ while (c.charAt(0) === ' ') c = c.substring(1, c.length);
308
+ if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
309
+ }
310
+ return null;
311
+ }
312
+
313
+ /**
314
+ * Start redaction functionality for sensitive input fields
315
+ * @param options Optional configuration for redaction behavior
316
+ */
317
+ public async redact(options?: RedactionOptions): Promise<void> {
318
+ await this.ensureInitialized();
319
+ if (!isBrowser) {
320
+ console.warn('Redaction is only available in browser environments');
321
+ return;
322
+ }
323
+
324
+ // Create a new redaction manager with the provided options
325
+ this.redactionManager = new RedactionManager(options);
326
+ }
327
+
328
+ /**
329
+ * Set specific fields to be redacted during session recording
330
+ * @param fields Array of CSS selectors for fields to redact (e.g., ['input[type="password"]', '#email-field'])
331
+ */
332
+ public setRedactedFields(fields: string[]): void {
333
+ if (!isBrowser) {
334
+ console.warn('Redaction is only available in browser environments');
335
+ return;
336
+ }
337
+
338
+ this.redactionManager.setFieldsToRedact(fields);
339
+ }
340
+
341
+ /**
342
+ * Check if redaction is currently active
343
+ */
344
+ public isRedactionActive(): boolean {
345
+ return this.redactionManager.isActive();
346
+ }
347
+
348
+ /**
349
+ * Get the currently selected fields for redaction
350
+ */
351
+ public getRedactedFields(): string[] {
352
+ return this.redactionManager.getSelectedFields();
353
+ }
354
+ }
355
+
356
+ // Only expose to window object in browser environments
357
+ if (isBrowser) {
358
+ window.HumanBehaviorTracker = HumanBehaviorTracker;
359
+ }
360
+
361
+ export default HumanBehaviorTracker;
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "./dist/",
4
+ "sourceMap": true,
5
+ "noImplicitAny": true,
6
+ "module": "ESNext",
7
+ "target": "es2015",
8
+ "downlevelIteration": true,
9
+ "jsx": "react",
10
+ "allowJs": true,
11
+ "moduleResolution": "node",
12
+ "allowSyntheticDefaultImports": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "esModuleInterop": true,
16
+ "strict": true,
17
+ "skipLibCheck": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "lib": ["dom", "dom.iterable", "esnext"]
20
+ },
21
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
22
+
23
+ "exclude": ["node_modules", "dist", "test-website"]
24
+ }
package/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- // This file is auto-generated. Do not edit directly.
2
- export * from "./dist/types/index";
package/index.js DELETED
@@ -1,3 +0,0 @@
1
- // This file is auto-generated. Do not edit directly.
2
- export * from './dist/esm/index.js';
3
- export { default } from './dist/esm/index.js';
package/react.d.ts DELETED
@@ -1,2 +0,0 @@
1
- // This file is auto-generated. Do not edit directly.
2
- export * from "./dist/types/react/index";
package/react.js DELETED
@@ -1,2 +0,0 @@
1
- // This file is auto-generated. Do not edit directly.
2
- export * from './dist/esm/react/index.js';