/** * Interaction Tracker - Captures user interactions for behavioral analysis * * This module tracks user interactions with the annotation interface including: * - Clicks on annotation elements * - Focus changes between elements * - Scroll depth * - Keyboard shortcuts * - Navigation events * - AI assistance usage * - Annotation changes * * Events are batched and sent periodically to minimize network overhead. * Uses sendBeacon API for reliable delivery on page unload. */ class InteractionTracker { constructor() { this.events = []; this.focusStartTime = {}; this.focusTime = {}; this.scrollDepthMax = 0; this.currentInstanceId = null; this.previousInstanceId = null; this.flushInterval = 5000; // Flush every 5 seconds this.lastFlush = Date.now(); this.isInitialized = false; this.debugMode = false; // Don't auto-init - wait for explicit init call or DOMContentLoaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.init()); } else { this.init(); } } init() { if (this.isInitialized) return; this.isInitialized = true; // Track clicks on annotation elements document.addEventListener('click', (e) => this.trackClick(e), true); // Track focus changes document.addEventListener('focusin', (e) => this.trackFocusIn(e), true); document.addEventListener('focusout', (e) => this.trackFocusOut(e), true); // Track scroll depth window.addEventListener('scroll', () => this.trackScroll(), { passive: true }); // Track keyboard shortcuts document.addEventListener('keydown', (e) => this.trackKeypress(e), true); // Flush on page unload window.addEventListener('beforeunload', () => this.flush(true)); window.addEventListener('pagehide', () => this.flush(true)); // Periodic flush this.flushTimer = setInterval(() => this.flush(false), this.flushInterval); if (this.debugMode) { console.log('[InteractionTracker] Initialized'); } } /** * Set the current instance ID and notify about navigation * @param {string} instanceId - The new instance ID */ setInstanceId(instanceId) { if (this.debugMode) { console.log(`[InteractionTracker] setInstanceId: ${instanceId}`); } // Flush events for previous instance if (this.currentInstanceId && this.currentInstanceId !== instanceId) { this.flush(true); } this.previousInstanceId = this.currentInstanceId; this.currentInstanceId = instanceId; // Reset scroll depth for new instance this.scrollDepthMax = 0; this.addEvent('navigation', 'instance_load', { instance_id: instanceId, from_instance: this.previousInstanceId }); } /** * Track click events * @param {Event} e - Click event */ trackClick(e) { const target = this.getTargetIdentifier(e.target); if (target) { this.addEvent('click', target, { x: e.clientX, y: e.clientY, }); } } /** * Track focus entering an element * @param {Event} e - Focus event */ trackFocusIn(e) { const target = this.getTargetIdentifier(e.target); if (target) { this.focusStartTime[target] = Date.now(); this.addEvent('focus_in', target); } } /** * Track focus leaving an element * @param {Event} e - Focus event */ trackFocusOut(e) { const target = this.getTargetIdentifier(e.target); if (target && this.focusStartTime[target]) { const duration = Date.now() - this.focusStartTime[target]; this.focusTime[target] = (this.focusTime[target] || 0) + duration; delete this.focusStartTime[target]; this.addEvent('focus_out', target, { duration_ms: duration }); } } /** * Track scroll depth */ trackScroll() { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollHeight = document.documentElement.scrollHeight - window.innerHeight; const scrollPercent = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0; this.scrollDepthMax = Math.max(this.scrollDepthMax, scrollPercent); } /** * Track keyboard shortcuts * @param {Event} e - Keydown event */ trackKeypress(e) { // Track annotation-related keypresses (number keys for keybindings) if (e.key >= '0' && e.key <= '9') { this.addEvent('keypress', `key:${e.key}`, { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, }); } // Track navigation shortcuts if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { this.addEvent('keypress', `nav:${e.key}`, { ctrl: e.ctrlKey, alt: e.altKey, }); } // Track save shortcut (Ctrl/Cmd + S) if ((e.ctrlKey || e.metaKey) && e.key === 's') { this.addEvent('keypress', 'save:shortcut'); } } /** * Track AI assistance request * @param {string} schemaName - The schema requesting AI help */ trackAIRequest(schemaName) { this.addEvent('ai_request', `schema:${schemaName}`); // Also track via dedicated AI endpoint this.sendAIUsage('request', schemaName); } /** * Track AI assistance response * @param {string} schemaName - The schema that received help * @param {Array} suggestions - AI suggestions provided */ trackAIResponse(schemaName, suggestions) { this.addEvent('ai_response', `schema:${schemaName}`, { suggestion_count: suggestions ? suggestions.length : 0, suggestions: suggestions }); this.sendAIUsage('response', schemaName, { suggestions }); } /** * Track user accepting AI suggestion * @param {string} schemaName - The schema * @param {string} acceptedValue - The value accepted */ trackAIAccept(schemaName, acceptedValue) { this.addEvent('ai_accept', `schema:${schemaName}`, { accepted: acceptedValue }); this.sendAIUsage('accept', schemaName, { accepted_value: acceptedValue }); } /** * Track user rejecting AI suggestion * @param {string} schemaName - The schema */ trackAIReject(schemaName) { this.addEvent('ai_reject', `schema:${schemaName}`); this.sendAIUsage('reject', schemaName); } /** * Track annotation change * @param {string} schemaName - Schema name * @param {string} labelName - Label name * @param {string} action - Action type (select, deselect, update, clear) * @param {*} oldValue - Previous value * @param {*} newValue - New value * @param {string} source - What triggered the change (user, ai_accept, keyboard, prefill) */ trackAnnotationChange(schemaName, labelName, action, oldValue, newValue, source = 'user') { this.addEvent('annotation_change', `schema:${schemaName}`, { label: labelName, action: action, old_value: oldValue, new_value: newValue, source: source, }); // Also send to dedicated annotation change endpoint for persistence this.sendAnnotationChange(schemaName, labelName, action, oldValue, newValue, source); } /** * Track navigation between instances * @param {string} action - Navigation action (next, prev, jump) * @param {string} fromInstance - Previous instance ID * @param {string} toInstance - New instance ID */ trackNavigation(action, fromInstance, toInstance) { this.addEvent('navigation', action, { from_instance: fromInstance, to_instance: toInstance, }); } /** * Track save action * @param {string} instanceId - Instance being saved */ trackSave(instanceId) { this.addEvent('save', `instance:${instanceId || this.currentInstanceId}`); } /** * Track when an annotation becomes stale due to display logic changes. * Stale annotations are annotations for schemas that were hidden because * conditions changed (e.g., user changed a parent answer). * * @param {string} schemaName - Schema that became stale * @param {*} value - The value that is now stale * @param {string} reason - Why the schema became hidden (condition not met) */ trackStaleAnnotation(schemaName, value, reason) { this.addEvent('annotation_stale', `schema:${schemaName}`, { stale_value: value, reason: reason, timestamp: new Date().toISOString() }); } /** * Track display logic visibility changes. * @param {string} schemaName - Schema whose visibility changed * @param {boolean} visible - New visibility state * @param {string} reason - Reason for visibility change */ trackDisplayLogicChange(schemaName, visible, reason) { this.addEvent('display_logic_change', `schema:${schemaName}`, { visible: visible, reason: reason, timestamp: new Date().toISOString() }); } /** * Get a unique identifier for an element * @param {Element} element - DOM element * @returns {string|null} - Element identifier or null */ getTargetIdentifier(element) { if (!element || !element.closest) return null; // Check for annotation labels (checkbox/radio inputs or their labels) const labelInput = element.closest('input[type="checkbox"], input[type="radio"]'); if (labelInput) { const name = labelInput.name || ''; const value = labelInput.value || ''; if (name) { return `label:${name}:${value}`; } } // Check for label wrapper with data attributes const labelWrapper = element.closest('[data-label-name]'); if (labelWrapper) { return `label:${labelWrapper.dataset.labelName}`; } // Check for schema elements const schema = element.closest('[data-schema-name]'); if (schema) { return `schema:${schema.dataset.schemaName}`; } // Check for annotation schema containers const schemaContainer = element.closest('.annotation-schema'); if (schemaContainer) { const schemaName = schemaContainer.id || schemaContainer.dataset.schema; if (schemaName) { return `schema:${schemaName}`; } } // Check for navigation buttons if (element.id === 'next_instance_button' || element.closest('#next_instance_button')) { return 'nav:next'; } if (element.id === 'prev_instance_button' || element.closest('#prev_instance_button')) { return 'nav:prev'; } if (element.id === 'save_button' || element.closest('#save_button')) { return 'nav:save'; } // Check for AI assistant elements if (element.closest('.ai-assistant-button')) return 'ai:request'; if (element.closest('.ai-suggestion')) return 'ai:suggestion'; if (element.closest('.ai-assistant-panel')) return 'ai:panel'; // Check for span annotation if (element.closest('.annotation-span')) return 'span:click'; if (element.closest('.span-label-option')) return 'span:label'; // Check for text inputs (textbox schemas) const textInput = element.closest('input[type="text"], textarea'); if (textInput && textInput.name) { return `textbox:${textInput.name}`; } // Check for slider elements const slider = element.closest('input[type="range"]'); if (slider && slider.name) { return `slider:${slider.name}`; } return null; } /** * Add an event to the queue * @param {string} eventType - Type of event * @param {string} target - Target identifier * @param {Object} metadata - Additional metadata */ addEvent(eventType, target, metadata = {}) { const event = { event_type: eventType, timestamp: Date.now() / 1000, // Unix timestamp in seconds client_timestamp: Date.now(), // Milliseconds for latency analysis target: target, instance_id: this.currentInstanceId, metadata: metadata, }; this.events.push(event); if (this.debugMode) { console.log('[InteractionTracker] Event:', event); } // Auto-flush if buffer is large if (this.events.length >= 50) { this.flush(false); } } /** * Flush events to the server * @param {boolean} isFinal - Whether this is a final flush (page unload) */ async flush(isFinal) { if (this.events.length === 0 && Object.keys(this.focusTime).length === 0) { return; } const payload = { instance_id: this.currentInstanceId, events: [...this.events], focus_time: { ...this.focusTime }, scroll_depth: this.scrollDepthMax, }; // Clear local buffers this.events = []; this.focusTime = {}; if (this.debugMode) { console.log('[InteractionTracker] Flushing:', payload); } if (isFinal) { // Use sendBeacon for reliable delivery on page unload const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); navigator.sendBeacon('/api/track_interactions', blob); } else { try { await fetch('/api/track_interactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); } catch (e) { if (this.debugMode) { console.warn('[InteractionTracker] Failed to send interaction data:', e); } } } this.lastFlush = Date.now(); } /** * Send AI usage event to dedicated endpoint * @param {string} eventType - Event type * @param {string} schemaName - Schema name * @param {Object} data - Additional data */ async sendAIUsage(eventType, schemaName, data = {}) { try { await fetch('/api/track_ai_usage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instance_id: this.currentInstanceId, schema_name: schemaName, event_type: eventType, ...data, }), }); } catch (e) { if (this.debugMode) { console.warn('[InteractionTracker] Failed to send AI usage data:', e); } } } /** * Send annotation change to dedicated endpoint */ async sendAnnotationChange(schemaName, labelName, action, oldValue, newValue, source) { try { await fetch('/api/track_annotation_change', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instance_id: this.currentInstanceId, schema_name: schemaName, label_name: labelName, action: action, old_value: oldValue, new_value: newValue, source: source, }), }); } catch (e) { if (this.debugMode) { console.warn('[InteractionTracker] Failed to send annotation change:', e); } } } /** * Enable or disable debug mode * @param {boolean} enabled - Whether debug mode is enabled */ setDebugMode(enabled) { this.debugMode = enabled; console.log(`[InteractionTracker] Debug mode: ${enabled ? 'enabled' : 'disabled'}`); } /** * Clean up tracker resources */ destroy() { if (this.flushTimer) { clearInterval(this.flushTimer); } this.flush(true); } } // Create global instance window.interactionTracker = new InteractionTracker(); // Expose for debugging window.InteractionTracker = InteractionTracker;