Files
TimeTracker/tests/test_analytics.py
Dries Peeters e4789cc26e feat: Add telemetry and analytics infrastructure with observability stack
Implement comprehensive analytics and monitoring system with PostHog integration,
complete observability stack (Prometheus, Grafana, Loki, Promtail), and CI/CD
workflows for automated builds.

Features:
- Add PostHog telemetry integration with privacy-focused event tracking
- Implement installation flow for opt-in telemetry configuration
- Add telemetry management UI in admin panel with detailed transparency
- Track key user events across all major features (projects, tasks, timer, etc.)

Infrastructure:
- Set up Prometheus for metrics collection
- Configure Grafana for visualization dashboards
- Integrate Loki and Promtail for log aggregation
- Add separate analytics docker-compose configuration

CI/CD:
- Add GitHub Actions workflows for building and publishing Docker images
- Implement separate dev and production build pipelines
- Configure automated image publishing to registry

Documentation:
- Restructure documentation into organized docs/ directory
- Add comprehensive guides for telemetry, analytics, and local development
- Create transparency documentation for tracked events
- Add CI/CD and build configuration guides

Code improvements:
- Integrate telemetry hooks across all route handlers
- Add feature flags and configuration management
- Refactor test suite for analytics functionality
- Clean up root directory by moving docs and removing test artifacts

Breaking changes:
- Requires new environment variables for PostHog configuration
- Docker compose setup now supports analytics stack

Changes: 73 files changed, 955 insertions(+), 14126 deletions(-)
2025-10-20 14:38:57 +02:00

276 lines
10 KiB
Python

"""
Tests for analytics functionality (logging, Prometheus, PostHog)
"""
import pytest
import os
import json
from unittest.mock import patch, MagicMock, call
from flask import g
from app import create_app, log_event, track_event
@pytest.fixture
def app():
"""Create test Flask application"""
app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'})
return app
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
class TestLogEvent:
"""Tests for structured JSON logging"""
def test_log_event_basic(self, app):
"""Test basic log event"""
with app.app_context():
with app.test_request_context():
g.request_id = 'test-request-123'
# This should not raise an exception
log_event("test.event", user_id=1, test_data="value")
def test_log_event_without_request_context(self, app):
"""Test that log_event handles missing request context gracefully"""
with app.app_context():
# Should not raise an exception even without request context
log_event("test.event", user_id=1)
def test_log_event_with_extra_data(self, app):
"""Test log event with various data types"""
with app.app_context():
with app.test_request_context():
g.request_id = 'test-request-456'
log_event("test.event",
user_id=1,
project_id=42,
duration=3600,
success=True,
tags=['tag1', 'tag2'])
class TestTrackEvent:
"""Tests for PostHog event tracking"""
@patch('app.posthog.capture')
def test_track_event_when_enabled(self, mock_capture, app):
"""Test that PostHog events are tracked when API key is set"""
with patch.dict(os.environ, {'POSTHOG_API_KEY': 'test-key'}):
track_event(123, "test.event", {"property": "value"})
mock_capture.assert_called_once_with(
distinct_id='123',
event='test.event',
properties={"property": "value"}
)
@patch('app.posthog.capture')
def test_track_event_when_disabled(self, mock_capture, app):
"""Test that PostHog events are not tracked when API key is not set"""
with patch.dict(os.environ, {'POSTHOG_API_KEY': ''}):
track_event(123, "test.event", {"property": "value"})
mock_capture.assert_not_called()
@patch('app.posthog.capture')
def test_track_event_handles_errors_gracefully(self, mock_capture, app):
"""Test that tracking errors don't crash the application"""
mock_capture.side_effect = Exception("PostHog error")
with patch.dict(os.environ, {'POSTHOG_API_KEY': 'test-key'}):
# Should not raise an exception
track_event(123, "test.event", {})
def test_track_event_with_none_properties(self, app):
"""Test that track_event handles None properties"""
with patch.dict(os.environ, {'POSTHOG_API_KEY': 'test-key'}):
with patch('app.posthog.capture') as mock_capture:
track_event(123, "test.event", None)
# Should use empty dict as default
call_args = mock_capture.call_args
assert call_args[1]['properties'] == {}
class TestPrometheusMetrics:
"""Tests for Prometheus metrics"""
def test_metrics_endpoint_exists(self, client):
"""Test that /metrics endpoint exists"""
response = client.get('/metrics')
assert response.status_code == 200
assert response.content_type == 'text/plain; version=0.0.4; charset=utf-8'
def test_metrics_endpoint_format(self, client):
"""Test that /metrics returns Prometheus format"""
response = client.get('/metrics')
data = response.data.decode('utf-8')
# Should contain our custom metrics
assert 'tt_requests_total' in data
assert 'tt_request_latency_seconds' in data
def test_metrics_are_incremented(self, client):
"""Test that metrics are incremented on requests"""
# Make a request to trigger metric recording
response = client.get('/metrics')
assert response.status_code == 200
# Get metrics
response = client.get('/metrics')
data = response.data.decode('utf-8')
# Should have recorded requests
assert 'tt_requests_total' in data
class TestAnalyticsIntegration:
"""Integration tests for analytics in routes"""
@patch('app.routes.auth.log_event')
@patch('app.routes.auth.track_event')
def test_login_analytics(self, mock_track, mock_log, app, client):
"""Test that login events are tracked"""
with app.app_context():
from app.models import User
from app import db
# Create a test user
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
# Attempt login
response = client.post('/login', data={'username': 'testuser'}, follow_redirects=False)
# Should have logged the event
# Note: This might not be called if there are validation errors or other issues
# The actual assertion depends on your authentication flow
@patch('app.routes.timer.log_event')
@patch('app.routes.timer.track_event')
def test_timer_analytics_integration(self, mock_track, mock_log, app, client):
"""Test that timer events are tracked (integration test placeholder)"""
# This is a placeholder - actual implementation would require:
# 1. Authenticated session
# 2. Valid project
# 3. Timer start/stop operations
pass
class TestSentryIntegration:
"""Tests for Sentry error monitoring"""
@patch('app.sentry_sdk.init')
def test_sentry_initializes_when_dsn_set(self, mock_init):
"""Test that Sentry initializes when DSN is provided"""
with patch.dict(os.environ, {
'SENTRY_DSN': 'https://test@sentry.io/123',
'SENTRY_TRACES_RATE': '0.1',
'FLASK_ENV': 'production'
}):
app = create_app({'TESTING': True})
# Sentry should have been initialized
# Note: The actual initialization happens in create_app
def test_sentry_not_initialized_without_dsn(self):
"""Test that Sentry is not initialized when DSN is not set"""
with patch.dict(os.environ, {'SENTRY_DSN': ''}, clear=True):
with patch('app.sentry_sdk.init') as mock_init:
app = create_app({'TESTING': True})
# Sentry init should not be called
mock_init.assert_not_called()
class TestRequestIDAttachment:
"""Tests for request ID attachment"""
def test_request_id_attached(self, app, client):
"""Test that request ID is attached to requests"""
with app.app_context():
with app.test_request_context():
# Trigger the before_request hook
with client:
response = client.get('/metrics')
# Request ID should be set in g
# Note: This test might need adjustment based on context handling
class TestAnalyticsEventSchema:
"""Tests to ensure analytics events follow the documented schema"""
def test_event_naming_convention(self):
"""Test that event names follow resource.action pattern"""
valid_events = [
"auth.login",
"auth.logout",
"timer.started",
"timer.stopped",
"project.created",
"project.updated",
"export.csv",
"report.viewed"
]
for event_name in valid_events:
parts = event_name.split('.')
assert len(parts) == 2, f"Event {event_name} should follow resource.action pattern"
assert parts[0].isalpha(), f"Resource part should be alphabetic: {event_name}"
assert parts[1].replace('_', '').isalpha(), f"Action part should be alphabetic: {event_name}"
class TestAnalyticsPrivacy:
"""Tests to ensure analytics respect privacy guidelines"""
def test_no_pii_in_standard_events(self, app):
"""Test that standard events don't include PII"""
# Events should use IDs, not emails or usernames
with app.app_context():
with app.test_request_context():
g.request_id = 'test-123'
# This is acceptable (uses ID)
log_event("test.event", user_id=123)
# In production, events should NOT include:
# - email addresses
# - usernames (use IDs instead)
# - IP addresses (unless explicitly needed)
# - passwords or tokens
@patch('app.posthog.capture')
def test_posthog_uses_internal_ids(self, mock_capture, app):
"""Test that PostHog events use internal IDs, not PII"""
with patch.dict(os.environ, {'POSTHOG_API_KEY': 'test-key'}):
# Should use numeric ID, not email
track_event(123, "test.event", {"project_id": 456})
call_args = mock_capture.call_args
# distinct_id should be the internal user ID (converted to string)
assert call_args[1]['distinct_id'] == '123'
class TestAnalyticsPerformance:
"""Tests to ensure analytics don't impact performance"""
def test_analytics_dont_block_requests(self, client):
"""Test that analytics operations don't significantly delay requests"""
import time
start = time.time()
response = client.get('/metrics')
duration = time.time() - start
# Request should complete quickly even with analytics
assert duration < 1.0 # Should complete in less than 1 second
assert response.status_code == 200
@patch('app.posthog.capture')
def test_analytics_errors_dont_break_app(self, mock_capture, app, client):
"""Test that analytics failures don't break the application"""
mock_capture.side_effect = Exception("Analytics service down")
# Application should still work
response = client.get('/metrics')
assert response.status_code == 200