humanbehavior-js 0.0.4 → 0.0.7

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