File size: 16,993 Bytes
17847d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39bbebf
 
17847d4
 
 
 
39bbebf
17847d4
 
 
 
39bbebf
17847d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39bbebf
 
17847d4
 
 
 
 
39bbebf
17847d4
 
 
 
 
39bbebf
17847d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
"""
Analytics service for form tracking and insights
"""
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, and_
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from fastapi import Request
import hashlib
import uuid
import logging

from ..models import FormAnalyticsEvent, Form, FormQuestion, FormResponse, AnalyticsEventType

logger = logging.getLogger(__name__)


class AnalyticsService:
    """Service for tracking and analyzing form usage"""
    
    def __init__(self):
        self.ip_salt = "autoform_salt_2026"  # Use environment variable in production
    
    async def track_event(
        self,
        db: Session,
        event_type: str,
        form_id: int,
        session_id: str,
        request: Request,
        submission_id: Optional[int] = None,
        question_id: Optional[int] = None,
        time_spent_seconds: Optional[int] = None,
        metadata: Optional[Dict] = None
    ):
        """
        Track analytics event with automatic enrichment.
        
        Args:
            db: Database session
            event_type: Type of event (form_viewed, question_answered, etc.)
            form_id: ID of the form
            session_id: Session ID
            request: FastAPI Request object
            submission_id: Optional submission ID
            question_id: Optional question ID
            time_spent_seconds: Optional time spent
            metadata: Optional additional metadata
        """
        try:
            # Extract IP address
            client_ip = None
            if request.client:
                client_ip = request.client.host
            
            # Hash IP for privacy
            ip_hash = self._hash_ip(client_ip) if client_ip else "unknown"
            
            # Extract user agent
            user_agent = request.headers.get("user-agent")
            
            # Extract referrer
            referrer = request.headers.get("referer") or request.headers.get("referrer")
            
            # Extract UTM parameters from query params or referrer
            utm_source = None
            utm_medium = None
            utm_campaign = None
            
            if hasattr(request, 'query_params'):
                utm_source = request.query_params.get('utm_source')
                utm_medium = request.query_params.get('utm_medium')
                utm_campaign = request.query_params.get('utm_campaign')
            
            # Perform IP geolocation (basic - can be enhanced with MaxMind)
            country, city = await self._get_geo_location(client_ip)
            
            # Create event
            event = FormAnalyticsEvent(
                event_id=str(uuid.uuid4()),
                form_id=form_id,
                submission_id=submission_id,
                question_id=question_id,
                event_type=event_type,
                session_id=session_id,
                ip_address_hash=ip_hash,
                ip_address_raw=None,  # Only store if user opts in
                country=country,
                city=city,
                user_agent=user_agent,
                referrer=referrer,
                utm_source=utm_source,
                utm_medium=utm_medium,
                utm_campaign=utm_campaign,
                time_spent_seconds=time_spent_seconds,
                event_metadata=metadata or {}
            )
            
            db.add(event)
            db.commit()
            
            logger.info(f"Tracked event: {event_type} for form {form_id}, session {session_id}")
            
        except Exception as e:
            logger.error(f"Failed to track analytics event: {e}")
            # Don't raise - analytics failures shouldn't break form submission
            db.rollback()
    
    async def get_time_series_data(
        self,
        db: Session,
        form_id: int,
        start_date: Optional[datetime] = None,
        end_date: Optional[datetime] = None,
        question_ids: Optional[List[int]] = None,
        countries: Optional[List[str]] = None,
        utm_source: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Get time-series data for views and submissions.
        
        Returns daily counts of views and submissions within the date range.
        """
        # Default date range: last 30 days
        if not end_date:
            end_date = datetime.utcnow()
        if not start_date:
            start_date = end_date - timedelta(days=30)
        
        # Build base filter
        filters = [
            FormAnalyticsEvent.form_id == form_id,
            FormAnalyticsEvent.created_at >= start_date,
            FormAnalyticsEvent.created_at <= end_date
        ]
        
        if question_ids:
            filters.append(FormAnalyticsEvent.question_id.in_(question_ids))
        if countries:
            filters.append(FormAnalyticsEvent.country.in_(countries))
        if utm_source:
            filters.append(FormAnalyticsEvent.utm_source == utm_source)
        
        # Get daily views
        views_query = db.query(
            func.date(FormAnalyticsEvent.created_at).label('date'),
            func.count(FormAnalyticsEvent.id).label('count')
        ).filter(
            and_(*filters),
            FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_VIEWED.value
        ).group_by(func.date(FormAnalyticsEvent.created_at)).all()
        
        # Get daily submissions
        submissions_query = db.query(
            func.date(FormAnalyticsEvent.created_at).label('date'),
            func.count(FormAnalyticsEvent.id).label('count')
        ).filter(
            and_(*filters),
            FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
        ).group_by(func.date(FormAnalyticsEvent.created_at)).all()
        
        # Build complete date range
        current_date = start_date.date()
        end = end_date.date()
        time_series = []
        
        views_dict = {str(row.date): row.count for row in views_query}
        submissions_dict = {str(row.date): row.count for row in submissions_query}
        
        while current_date <= end:
            date_str = str(current_date)
            time_series.append({
                "date": date_str,
                "views": views_dict.get(date_str, 0),
                "submissions": submissions_dict.get(date_str, 0)
            })
            current_date += timedelta(days=1)
        
        return {
            "time_series": time_series,
            "total_views": sum(v["views"] for v in time_series),
            "total_submissions": sum(v["submissions"] for v in time_series)
        }
    
    async def get_funnel_analytics(
        self,
        db: Session,
        form_id: int,
        start_date: Optional[datetime] = None,
        end_date: Optional[datetime] = None,
        question_ids: Optional[List[int]] = None,
        countries: Optional[List[str]] = None,
        utm_source: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Generate funnel analytics for a form.
        
        Args:
            db: Database session
            form_id: ID of the form
            start_date: Optional start date for filtering
            end_date: Optional end date for filtering
        
        Returns:
            Dictionary with funnel analytics data
        """
        # Default date range: last 30 days
        if not end_date:
            end_date = datetime.utcnow()
        if not start_date:
            start_date = end_date - timedelta(days=30)
        
        # Base query filter
        filters = [
            FormAnalyticsEvent.form_id == form_id,
            FormAnalyticsEvent.created_at >= start_date,
            FormAnalyticsEvent.created_at <= end_date
        ]
        
        if question_ids:
            filters.append(FormAnalyticsEvent.question_id.in_(question_ids))
        if countries:
            filters.append(FormAnalyticsEvent.country.in_(countries))
        if utm_source:
            filters.append(FormAnalyticsEvent.utm_source == utm_source)
        
        base_filter = and_(*filters)
        
        # Count UNIQUE SESSIONS by event type (not total events)
        total_views = db.query(func.count(func.distinct(FormAnalyticsEvent.session_id))).filter(
            base_filter,
            FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_VIEWED.value
        ).scalar() or 0
        
        total_starts = db.query(func.count(func.distinct(FormAnalyticsEvent.session_id))).filter(
            base_filter,
            FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_STARTED.value
        ).scalar() or 0
        
        total_completes = db.query(func.count(func.distinct(FormAnalyticsEvent.session_id))).filter(
            base_filter,
            FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
        ).scalar() or 0
        
        # Calculate completion rate
        completion_rate = (total_completes / total_views * 100) if total_views > 0 else 0
        
        # Get form questions
        form = db.query(Form).filter(Form.id == form_id).first()
        if not form:
            return {}
        
        # Build question funnel
        question_funnel = []
        for question in sorted(form.questions, key=lambda q: q.question_order):
            # Count UNIQUE SESSIONS that viewed/answered/skipped each question
            viewed = db.query(func.count(func.distinct(FormAnalyticsEvent.session_id))).filter(
                base_filter,
                FormAnalyticsEvent.question_id == question.id,
                FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_VIEWED.value
            ).scalar() or 0
            
            answered = db.query(func.count(func.distinct(FormAnalyticsEvent.session_id))).filter(
                base_filter,
                FormAnalyticsEvent.question_id == question.id,
                FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_ANSWERED.value
            ).scalar() or 0
            
            skipped = db.query(func.count(func.distinct(FormAnalyticsEvent.session_id))).filter(
                base_filter,
                FormAnalyticsEvent.question_id == question.id,
                FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_SKIPPED.value
            ).scalar() or 0
            
            # Calculate average time spent
            avg_time_result = db.query(
                func.avg(FormAnalyticsEvent.time_spent_seconds)
            ).filter(
                base_filter,
                FormAnalyticsEvent.question_id == question.id,
                FormAnalyticsEvent.time_spent_seconds.isnot(None)
            ).scalar()
            
            avg_time_spent = float(avg_time_result) if avg_time_result else 0.0
            
            # Calculate drop-off rate
            drop_off_rate = (skipped / viewed * 100) if viewed > 0 else 0
            
            question_funnel.append({
                "question_id": question.id,
                "question_text": question.question_text,
                "question_order": question.question_order,
                "viewed": viewed,
                "answered": answered,
                "skipped": skipped,
                "drop_off_rate": round(drop_off_rate, 2),
                "avg_time_spent": round(avg_time_spent, 2)
            })
        
        # Traffic sources analysis
        traffic_sources = {}
        utm_source_data = db.query(
            FormAnalyticsEvent.utm_source,
            func.count(FormAnalyticsEvent.id).label('count')
        ).filter(
            base_filter,
            FormAnalyticsEvent.utm_source.isnot(None)
        ).group_by(FormAnalyticsEvent.utm_source).all()
        
        for source, count in utm_source_data:
            # Calculate completion rate for this source
            source_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
                base_filter,
                FormAnalyticsEvent.utm_source == source,
                FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
            ).scalar() or 0
            
            completion_rate_source = (source_completes / count * 100) if count > 0 else 0
            
            traffic_sources[source] = {
                "count": count,
                "completion_rate": round(completion_rate_source, 2)
            }
        
        # Geographic distribution
        geographic_distribution = {}
        country_data = db.query(
            FormAnalyticsEvent.country,
            func.count(FormAnalyticsEvent.id).label('views')
        ).filter(
            base_filter,
            FormAnalyticsEvent.country.isnot(None)
        ).group_by(FormAnalyticsEvent.country).all()
        
        for country, views in country_data:
            country_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
                base_filter,
                FormAnalyticsEvent.country == country,
                FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
            ).scalar() or 0
            
            geographic_distribution[country] = {
                "views": views,
                "completes": country_completes
            }
        
        return {
            "total_views": total_views,
            "total_starts": total_starts,
            "total_completes": total_completes,
            "completion_rate": round(completion_rate, 2),
            "question_funnel": question_funnel,
            "traffic_sources": traffic_sources,
            "geographic_distribution": geographic_distribution,
            "date_range": {
                "start": start_date.isoformat(),
                "end": end_date.isoformat()
            }
        }
    
    async def get_summary_analytics(
        self,
        db: Session,
        form_id: int
    ) -> Dict[str, Any]:
        """
        Get high-level analytics summary for a form.
        
        Args:
            db: Database session
            form_id: ID of the form
        
        Returns:
            Dictionary with summary analytics
        """
        # Total responses
        total_responses = db.query(func.count(FormResponse.id)).filter(
            FormResponse.form_id == form_id
        ).scalar() or 0
        
        # Complete vs partial
        complete_responses = db.query(func.count(FormResponse.id)).filter(
            FormResponse.form_id == form_id,
            FormResponse.status == "complete"
        ).scalar() or 0
        
        # Recent activity (last 7 days)
        seven_days_ago = datetime.utcnow() - timedelta(days=7)
        recent_responses = db.query(func.count(FormResponse.id)).filter(
            FormResponse.form_id == form_id,
            FormResponse.submitted_at >= seven_days_ago
        ).scalar() or 0
        
        # Average completion time (if tracked)
        avg_completion_seconds = db.query(
            func.avg(
                func.extract('epoch', FormResponse.submitted_at - FormResponse.started_at)
            )
        ).filter(
            FormResponse.form_id == form_id,
            FormResponse.started_at.isnot(None),
            FormResponse.status == "complete"
        ).scalar()
        
        avg_completion_minutes = (avg_completion_seconds / 60) if avg_completion_seconds else None
        
        return {
            "total_responses": total_responses,
            "complete_responses": complete_responses,
            "partial_responses": total_responses - complete_responses,
            "recent_responses_7d": recent_responses,
            "avg_completion_minutes": round(avg_completion_minutes, 2) if avg_completion_minutes else None
        }
    
    def _hash_ip(self, ip_address: str) -> str:
        """Hash IP address for privacy"""
        if not ip_address:
            return "unknown"
        
        # SHA256 hash with salt
        salted = f"{ip_address}{self.ip_salt}"
        return hashlib.sha256(salted.encode()).hexdigest()
    
    async def _get_geo_location(self, ip_address: Optional[str]) -> tuple:
        """
        Get geographic location from IP address.
        
        For now, returns None. In production:
        - Use MaxMind GeoLite2 database (free, local lookup)
        - Or use ipapi.co API (rate-limited)
        
        Args:
            ip_address: IP address to lookup
        
        Returns:
            Tuple of (country, city)
        """
        if not ip_address:
            return (None, None)
        
        # TODO: Implement with MaxMind GeoLite2 or ipapi.co
        # For localhost/development, return None
        if ip_address in ['127.0.0.1', 'localhost', '::1']:
            return ('Local', 'Development')
        
        # Placeholder - implement actual geolocation
        return (None, None)


# Singleton instance
analytics_service = AnalyticsService()