humanbehavior-js 0.0.5 → 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.
@@ -0,0 +1,189 @@
1
+ import React, { useEffect, useState, createContext, useContext, ReactNode } from "react";
2
+ import { HumanBehaviorTracker } from "..";
3
+
4
+ // Check if we're in a browser environment
5
+ const isBrowser = () => typeof window !== 'undefined';
6
+
7
+ // Define the public interface that components will interact with
8
+ interface HumanBehaviorInterface {
9
+ addEvent: (event: any) => void;
10
+ addUserInfo: (userProperties: Record<string, any>) => Promise<void>;
11
+ start: () => void;
12
+ stop: () => void;
13
+ viewLogs: () => void;
14
+ }
15
+
16
+ interface HumanBehaviorContextType {
17
+ humanBehavior: HumanBehaviorTracker | null;
18
+ queueEvent: (event: any) => void;
19
+ }
20
+
21
+ interface HumanBehaviorProviderProps {
22
+ // Either provide an apiKey to create a new client, or provide an existing client
23
+ apiKey?: string;
24
+ client?: HumanBehaviorTracker;
25
+ children: ReactNode;
26
+ }
27
+
28
+ const defaultContext: HumanBehaviorContextType = {
29
+ humanBehavior: null,
30
+ queueEvent: (event: any) => {
31
+ // In server-side, just no-op
32
+ if (typeof window === 'undefined') {
33
+ return;
34
+ }
35
+ console.warn('HumanBehavior not initialized yet, event queued:', event);
36
+ }
37
+ };
38
+
39
+ const HumanBehaviorContext = createContext<HumanBehaviorContextType>(defaultContext);
40
+
41
+ export const HumanBehaviorProvider = ({ apiKey, client, children }: HumanBehaviorProviderProps) => {
42
+ const [humanBehavior, setHumanBehavior] = useState<HumanBehaviorTracker | null>(client || null);
43
+ const [eventQueue, setEventQueue] = useState<any[]>([]);
44
+ const [isMounted, setIsMounted] = useState(false);
45
+ const [isInitialized, setIsInitialized] = useState(false);
46
+
47
+ // Function to queue events before initialization
48
+ const queueEvent = (event: any) => {
49
+ setEventQueue(prev => [...prev, event]);
50
+ };
51
+
52
+ // Handle mounting state
53
+ useEffect(() => {
54
+ setIsMounted(true);
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ // Only run in browser
59
+ if (!(isBrowser())) {
60
+ return;
61
+ }
62
+
63
+ // Skip if not mounted yet (handles Next.js hydration)
64
+ if (!isMounted) {
65
+ return;
66
+ }
67
+
68
+ // If client is provided, use that
69
+ if (client) {
70
+ setHumanBehavior(client);
71
+ setIsInitialized(true);
72
+ return;
73
+ }
74
+
75
+ // If no client is provided, apiKey is required
76
+ if (!apiKey || apiKey.trim() === '') {
77
+ console.error("An apiKey is required when no client is provided");
78
+ return;
79
+ }
80
+
81
+ if (humanBehavior !== null) {
82
+ return;
83
+ }
84
+
85
+ // Create new tracker instance with the validated apiKey
86
+ const tracker = new HumanBehaviorTracker(apiKey.trim(), process.env.NEXT_PUBLIC_INGESTION_URL || 'https://ingestion.humanbehavior.ai');
87
+ setHumanBehavior(tracker);
88
+
89
+ // Wait for initialization to complete
90
+ tracker.initializationPromise?.then(() => {
91
+ setIsInitialized(true);
92
+
93
+ // Process any queued events
94
+ if (eventQueue.length > 0) {
95
+ eventQueue.forEach(event => {
96
+ if (event.type === 'identify') {
97
+ console.log('Processing queued identify event', event.userProperties);
98
+ tracker.addUserInfo(event.userProperties);
99
+ } else {
100
+ tracker.addEvent(event);
101
+ }
102
+ });
103
+ setEventQueue([]); // Clear the queue
104
+ }
105
+ }).catch(error => {
106
+ console.error('Failed to initialize HumanBehaviorTracker:', error);
107
+ });
108
+ }, [apiKey, client, eventQueue, isMounted]);
109
+
110
+ // If not in browser, render children without context
111
+ if (!(isBrowser())) {
112
+ return <>{children}</>;
113
+ }
114
+
115
+ // If not mounted yet, render children with queuing context
116
+ if (!isMounted) {
117
+ return (
118
+ <HumanBehaviorContext.Provider value={{ humanBehavior: null, queueEvent }}>
119
+ {children}
120
+ </HumanBehaviorContext.Provider>
121
+ );
122
+ }
123
+
124
+ // If not initialized yet, render children with queuing context
125
+ if (!isInitialized) {
126
+ return (
127
+ <HumanBehaviorContext.Provider value={{ humanBehavior: null, queueEvent }}>
128
+ {children}
129
+ </HumanBehaviorContext.Provider>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <HumanBehaviorContext.Provider value={{ humanBehavior, queueEvent }}>
135
+ {children}
136
+ </HumanBehaviorContext.Provider>
137
+ );
138
+ };
139
+
140
+ export const useHumanBehavior = (): HumanBehaviorInterface => {
141
+ const context = useContext(HumanBehaviorContext);
142
+
143
+ // Only throw if the hook is used outside of a provider entirely
144
+ if (context === null) {
145
+ throw new Error("useHumanBehavior must be used within a HumanBehaviorProvider");
146
+ }
147
+
148
+ // If we're in the server-side, return a no-op implementation
149
+ if (typeof window === 'undefined') {
150
+ console.warn('HumanBehavior is being used before being initialized');
151
+ return {
152
+ addEvent: () => {},
153
+ addUserInfo: async () => {},
154
+ start: () => {},
155
+ stop: () => {},
156
+ viewLogs: () => {},
157
+ };
158
+ }
159
+
160
+ // If we have an initialized tracker, return it as the interface
161
+ if (context.humanBehavior) {
162
+ return context.humanBehavior;
163
+ }
164
+
165
+ // If we reach here, we're in the initialization period where:
166
+ // - context.humanBehavior is null
167
+ // - context.queueEvent is available
168
+ // - we need to queue all operations until initialization completes
169
+ return {
170
+ addEvent: (event: any) => {
171
+ context.queueEvent(event);
172
+ },
173
+ addUserInfo: async (userProperties: Record<string, any>) => {
174
+ context.queueEvent({
175
+ type: 'identify',
176
+ userProperties,
177
+ });
178
+ },
179
+ start: () => {
180
+ // Start will be called automatically when initialized
181
+ },
182
+ stop: () => {
183
+ // Stop is a no-op when not initialized
184
+ },
185
+ viewLogs: () => {
186
+ console.warn('Logs are not available until HumanBehaviorTracker is initialized');
187
+ }
188
+ };
189
+ };
package/src/redact.ts ADDED
@@ -0,0 +1,474 @@
1
+ // Redaction functionality for sensitive input fields
2
+ // This module provides methods to redact sensitive input fields in event recordings
3
+
4
+ // Check if we're in a browser environment
5
+ const isBrowser = typeof window !== 'undefined';
6
+
7
+ export interface RedactionOptions {
8
+ redactedText?: string;
9
+ excludeSelectors?: string[];
10
+ userFields?: string[]; // Fields that the user wants to redact
11
+ }
12
+
13
+ export class RedactionManager {
14
+ private redactedText: string = '[REDACTED]';
15
+ private userSelectedFields: Set<string> = new Set(); // User-selected fields to redact
16
+ private excludeSelectors: string[] = [
17
+ '[data-no-redact="true"]',
18
+ '.human-behavior-no-redact'
19
+ ];
20
+
21
+ constructor(options?: RedactionOptions) {
22
+ if (options?.redactedText) {
23
+ this.redactedText = options.redactedText;
24
+ }
25
+ if (options?.excludeSelectors) {
26
+ this.excludeSelectors = [...this.excludeSelectors, ...options.excludeSelectors];
27
+ }
28
+ if (options?.userFields) {
29
+ this.setFieldsToRedact(options.userFields);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Set specific fields to be redacted
35
+ * @param fields Array of CSS selectors for fields to redact
36
+ */
37
+ public setFieldsToRedact(fields: string[]): void {
38
+ this.userSelectedFields.clear();
39
+ fields.forEach(field => this.userSelectedFields.add(field));
40
+
41
+ if (fields.length > 0) {
42
+ console.log(`Redaction: Active for ${fields.length} field(s):`, fields);
43
+
44
+ // Debug: Check if elements exist
45
+ fields.forEach(selector => {
46
+ const elements = document.querySelectorAll(selector);
47
+ console.log(`Redaction: Found ${elements.length} element(s) for selector '${selector}'`);
48
+ elements.forEach((el, index) => {
49
+ console.log(`Redaction: Element ${index} for '${selector}':`, el);
50
+ });
51
+ });
52
+ } else {
53
+ console.log('Redaction: Disabled - no fields selected');
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Check if redaction is currently active (has fields selected)
59
+ */
60
+ public isActive(): boolean {
61
+ return this.userSelectedFields.size > 0;
62
+ }
63
+
64
+ /**
65
+ * Get the currently selected fields for redaction
66
+ */
67
+ public getSelectedFields(): string[] {
68
+ return Array.from(this.userSelectedFields);
69
+ }
70
+
71
+ /**
72
+ * Process an event and redact sensitive data if needed
73
+ */
74
+ public processEvent(event: any): any {
75
+ // Only process if we have fields selected for redaction
76
+ if (this.userSelectedFields.size === 0) {
77
+ return event;
78
+ }
79
+
80
+ // Clone the event to avoid modifying the original
81
+ const processedEvent = JSON.parse(JSON.stringify(event));
82
+
83
+ // Handle different event types
84
+ if (processedEvent.type === 3) { // IncrementalSnapshot
85
+ if (processedEvent.data.source === 5) { // Input event
86
+ const shouldRedact = this.isFieldSelected(processedEvent.data);
87
+ if (shouldRedact) {
88
+ console.log('Redaction: Processing input event for redaction');
89
+ this.redactInputEvent(processedEvent.data);
90
+ }
91
+ }
92
+ // Also check for other sources that might contain text changes
93
+ else if (processedEvent.data.source === 0) { // DOM mutations
94
+ this.redactDOMEvent(processedEvent.data);
95
+ }
96
+ // Handle other sources that might contain text
97
+ else if (processedEvent.data.source === 2) { // Mouse/Touch interaction
98
+ this.redactMouseEvent(processedEvent.data);
99
+ }
100
+ }
101
+ else if (processedEvent.type === 2) { // FullSnapshot
102
+ this.redactFullSnapshot(processedEvent.data);
103
+ }
104
+
105
+ return processedEvent;
106
+ }
107
+
108
+ /**
109
+ * Redact sensitive data in input events
110
+ */
111
+ private redactInputEvent(inputData: any): void {
112
+ // Check if this input event is from a field we want to redact
113
+ if (!this.isFieldSelected(inputData)) {
114
+ return;
115
+ }
116
+
117
+ console.log('Redaction: Redacting input event with text:', inputData.text);
118
+
119
+ // Redact all text-related properties that could contain input data
120
+ const textProperties = ['text', 'value', 'content', 'data', 'input', 'textContent'];
121
+ textProperties.forEach(prop => {
122
+ if (inputData[prop] !== undefined && typeof inputData[prop] === 'string') {
123
+ inputData[prop] = this.redactedText;
124
+ console.log(`Redaction: Redacted property '${prop}'`);
125
+ }
126
+ });
127
+
128
+ // Also check for any other string properties that might contain input data
129
+ Object.keys(inputData).forEach(key => {
130
+ if (typeof inputData[key] === 'string' && inputData[key].length > 0) {
131
+ inputData[key] = this.redactedText;
132
+ console.log(`Redaction: Redacted additional property '${key}'`);
133
+ }
134
+ });
135
+
136
+ // Handle nested objects that might contain text data
137
+ if (inputData.attributes && typeof inputData.attributes === 'object') {
138
+ if (inputData.attributes.value && typeof inputData.attributes.value === 'string') {
139
+ inputData.attributes.value = this.redactedText;
140
+ console.log('Redaction: Redacted nested value attribute');
141
+ }
142
+ }
143
+
144
+ console.log('Redaction: Input event redaction complete');
145
+ }
146
+
147
+ /**
148
+ * Redact sensitive data in DOM mutation events
149
+ */
150
+ private redactDOMEvent(domData: any): void {
151
+ // Check for text changes in DOM mutations
152
+ if (domData.texts && Array.isArray(domData.texts)) {
153
+ domData.texts.forEach((textChange: any) => {
154
+ if (textChange.text && typeof textChange.text === 'string' &&
155
+ this.shouldRedactDOMChange(textChange)) {
156
+ textChange.text = this.redactedText;
157
+ }
158
+ });
159
+ }
160
+
161
+ // Also check for attribute changes that might contain input data
162
+ if (domData.attributes && Array.isArray(domData.attributes)) {
163
+ domData.attributes.forEach((attrChange: any) => {
164
+ if (attrChange.attributes && attrChange.attributes.value &&
165
+ typeof attrChange.attributes.value === 'string' &&
166
+ this.shouldRedactDOMChange(attrChange)) {
167
+ attrChange.attributes.value = this.redactedText;
168
+ }
169
+ });
170
+ }
171
+
172
+ // Check for any other properties that might contain text data
173
+ if (domData.adds && Array.isArray(domData.adds)) {
174
+ domData.adds.forEach((add: any) => {
175
+ if (add.node && add.node.textContent && typeof add.node.textContent === 'string' &&
176
+ this.shouldRedactDOMChange(add)) {
177
+ add.node.textContent = this.redactedText;
178
+ }
179
+ });
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Check if a DOM change should be redacted based on its ID
185
+ */
186
+ private shouldRedactDOMChange(changeData: any): boolean {
187
+ if (!isBrowser) return false;
188
+
189
+ try {
190
+ // Check if this change has an ID that we can use to find the element
191
+ const elementId = changeData.id;
192
+ if (elementId !== undefined) {
193
+ // Try to find the element by data-rrweb-id attribute
194
+ let element = document.querySelector(`[data-rrweb-id="${elementId}"]`) as HTMLElement;
195
+
196
+ if (element) {
197
+ return this.shouldRedactElement(element);
198
+ }
199
+ }
200
+
201
+ // Also check for nodeId which is another way rrweb identifies elements
202
+ const nodeId = changeData.nodeId;
203
+ if (nodeId !== undefined) {
204
+ const element = document.querySelector(`[data-rrweb-id="${nodeId}"]`) as HTMLElement;
205
+ if (element) {
206
+ return this.shouldRedactElement(element);
207
+ }
208
+ }
209
+
210
+ return false;
211
+ } catch (e) {
212
+ console.warn('Error checking if DOM change should be redacted:', e);
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Redact sensitive data in mouse/touch interaction events
219
+ */
220
+ private redactMouseEvent(mouseData: any): void {
221
+ // Mouse events typically don't contain text data, but check for any text properties
222
+ if (mouseData.text && typeof mouseData.text === 'string' &&
223
+ this.isFieldSelected(mouseData)) {
224
+ mouseData.text = this.redactedText;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Redact sensitive data in full snapshot events
230
+ */
231
+ private redactFullSnapshot(snapshotData: any): void {
232
+ if (snapshotData.node && snapshotData.node.type === 2) { // Element node
233
+ this.redactNode(snapshotData.node);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Recursively redact sensitive data in DOM nodes
239
+ */
240
+ private redactNode(node: any): void {
241
+ if (!node) return;
242
+
243
+ // Check if this node should be redacted
244
+ if (node.type === 2 && node.tagName &&
245
+ (node.tagName.toLowerCase() === 'input' || node.tagName.toLowerCase() === 'textarea')) {
246
+
247
+ // Check if this input/textarea should be redacted
248
+ if (this.shouldRedactNode(node)) {
249
+ // Redact value attribute
250
+ if (node.attributes && node.attributes.value) {
251
+ node.attributes.value = this.redactedText;
252
+ }
253
+ // Redact text content
254
+ if (node.textContent) {
255
+ node.textContent = this.redactedText;
256
+ }
257
+ }
258
+ }
259
+
260
+ // Recursively process child nodes
261
+ if (node.childNodes && Array.isArray(node.childNodes)) {
262
+ node.childNodes.forEach((childNode: any) => {
263
+ this.redactNode(childNode);
264
+ });
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Check if a node should be redacted based on its attributes
270
+ */
271
+ private shouldRedactNode(node: any): boolean {
272
+ if (!node.attributes) return false;
273
+
274
+ // Check if any of our selectors would match this node
275
+ for (const selector of this.userSelectedFields) {
276
+ if (this.selectorMatchesNode(selector, node)) {
277
+ return true;
278
+ }
279
+ }
280
+
281
+ return false;
282
+ }
283
+
284
+ /**
285
+ * Check if a CSS selector would match a node based on its attributes
286
+ */
287
+ private selectorMatchesNode(selector: string, node: any): boolean {
288
+ if (!node.attributes) return false;
289
+
290
+ // Create a temporary element to test the selector
291
+ try {
292
+ const tempElement = document.createElement(node.tagName || 'div');
293
+
294
+ // Copy attributes from the node to the temp element
295
+ if (node.attributes) {
296
+ Object.keys(node.attributes).forEach(key => {
297
+ tempElement.setAttribute(key, node.attributes[key]);
298
+ });
299
+ }
300
+
301
+ // Test if the selector matches this element
302
+ return tempElement.matches(selector);
303
+ } catch (e) {
304
+ // If matches() is not supported or fails, fall back to basic attribute checking
305
+ return this.basicSelectorMatch(selector, node);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Basic selector matching for environments where matches() is not available
311
+ */
312
+ private basicSelectorMatch(selector: string, node: any): boolean {
313
+ if (!node.attributes) return false;
314
+
315
+ // Handle simple selectors like 'input[type="password"]'
316
+ if (selector.includes('input[type=')) {
317
+ const typeMatch = selector.match(/input\[type="([^"]+)"\]/);
318
+ if (typeMatch && node.tagName === 'input' && node.attributes.type === typeMatch[1]) {
319
+ return true;
320
+ }
321
+ }
322
+
323
+ // Handle ID selectors like '#email'
324
+ if (selector.startsWith('#')) {
325
+ const id = selector.substring(1);
326
+ return node.attributes.id === id;
327
+ }
328
+
329
+ // Handle class selectors like '.sensitive-field'
330
+ if (selector.startsWith('.')) {
331
+ const className = selector.substring(1);
332
+ return node.attributes.class && node.attributes.class.includes(className);
333
+ }
334
+
335
+ // Handle tag selectors like 'input'
336
+ if (!selector.includes('[') && !selector.includes('.')) {
337
+ return node.tagName && node.tagName.toLowerCase() === selector.toLowerCase();
338
+ }
339
+
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Check if an event is from a field that should be redacted
345
+ */
346
+ private isFieldSelected(eventData: any): boolean {
347
+ if (!isBrowser) return false;
348
+
349
+ try {
350
+ // For input events (source 5), we need to determine if this is a sensitive field
351
+ if (eventData.source === 5) { // Input event
352
+ const elementId = eventData.id;
353
+ if (elementId !== undefined) {
354
+ // Try to find the element by data-rrweb-id attribute
355
+ let element = document.querySelector(`[data-rrweb-id="${elementId}"]`) as HTMLElement;
356
+
357
+ if (element) {
358
+ return this.shouldRedactElement(element);
359
+ }
360
+
361
+ // Fallback: Try to find by nodeId if available
362
+ if (eventData.nodeId !== undefined) {
363
+ element = document.querySelector(`[data-rrweb-id="${eventData.nodeId}"]`) as HTMLElement;
364
+ if (element) {
365
+ return this.shouldRedactElement(element);
366
+ }
367
+ }
368
+
369
+ // More aggressive approach: Check all elements that match our selectors
370
+ // and see if any of them are currently focused or have the same ID
371
+ for (const selector of this.userSelectedFields) {
372
+ const matchingElements = document.querySelectorAll(selector);
373
+ if (matchingElements.length > 0) {
374
+ // Check if any of these elements are currently focused
375
+ for (const el of matchingElements) {
376
+ if (el === document.activeElement) {
377
+ console.log('Redaction: Found focused element matching selector:', selector);
378
+ return true;
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ // If we still can't find it, try a more direct approach
385
+ // Look for any input element that might be the active one
386
+ const activeElement = document.activeElement;
387
+ if (activeElement && this.shouldRedactElement(activeElement as HTMLElement)) {
388
+ console.log('Redaction: Active element should be redacted');
389
+ return true;
390
+ }
391
+
392
+ return false;
393
+ }
394
+ }
395
+
396
+ // For other event types, try to find the element
397
+ const elementId = eventData.id;
398
+ if (elementId !== undefined) {
399
+ // First try to find by data-rrweb-id attribute
400
+ let element = document.querySelector(`[data-rrweb-id="${elementId}"]`) as HTMLElement;
401
+
402
+ if (element) {
403
+ return this.shouldRedactElement(element);
404
+ }
405
+ }
406
+
407
+ // Also check for nodeId which is another way rrweb identifies elements
408
+ const nodeId = eventData.nodeId;
409
+ if (nodeId !== undefined) {
410
+ const element = document.querySelector(`[data-rrweb-id="${nodeId}"]`) as HTMLElement;
411
+ if (element) {
412
+ return this.shouldRedactElement(element);
413
+ }
414
+ }
415
+
416
+ // For DOM mutations, check if the target element should be redacted
417
+ if (eventData.target && eventData.target.id) {
418
+ const element = document.querySelector(`[data-rrweb-id="${eventData.target.id}"]`) as HTMLElement;
419
+ if (element) {
420
+ return this.shouldRedactElement(element);
421
+ }
422
+ }
423
+
424
+ return false;
425
+ } catch (e) {
426
+ console.warn('Error checking if field should be redacted:', e);
427
+ return false;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Check if an element should be redacted based on user-selected fields
433
+ */
434
+ private shouldRedactElement(element: HTMLElement): boolean {
435
+ // Check if element is excluded from redaction
436
+ for (const excludeSelector of this.excludeSelectors) {
437
+ if (element.matches(excludeSelector) || element.closest(excludeSelector)) {
438
+ return false;
439
+ }
440
+ }
441
+
442
+ // Check if element matches any of the user-selected fields
443
+ for (const selector of this.userSelectedFields) {
444
+ if (element.matches(selector)) {
445
+ return true;
446
+ }
447
+ }
448
+
449
+ return false;
450
+ }
451
+
452
+ /**
453
+ * Get the original value of a redacted element (for debugging)
454
+ */
455
+ public getOriginalValue(element: HTMLElement): string | undefined {
456
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
457
+ return element.value;
458
+ }
459
+ return undefined;
460
+ }
461
+
462
+ /**
463
+ * Check if an element is currently being redacted
464
+ */
465
+ public isElementRedacted(element: HTMLElement): boolean {
466
+ return this.shouldRedactElement(element);
467
+ }
468
+ }
469
+
470
+ // Export a default instance
471
+ export const redactionManager = new RedactionManager();
472
+
473
+ // Export the class for custom instances
474
+ export default RedactionManager;