mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-16 09:11:19 -06:00
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(-)
276 lines
10 KiB
Python
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
|