diff --git a/app/routes/admin.py b/app/routes/admin.py index fb9de08f..22f0e988 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -26,6 +26,7 @@ import app as app_module from app import db, limiter +from app.config.analytics_defaults import get_analytics_config from app.models import ( DonationInteraction, Invoice, @@ -1047,6 +1048,7 @@ def delete_user(user_id): def telemetry_dashboard(): """Telemetry and analytics dashboard""" installation_config = get_installation_config() + analytics_config = get_analytics_config() # Get telemetry status telemetry_data = { @@ -1059,16 +1061,19 @@ def telemetry_dashboard(): } # Get OTEL OTLP status + grafana_endpoint = analytics_config.get("otel_exporter_otlp_endpoint") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + grafana_token = analytics_config.get("otel_exporter_otlp_token") or os.getenv("OTEL_EXPORTER_OTLP_TOKEN", "") grafana_data = { - "enabled": bool(os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) and bool(os.getenv("OTEL_EXPORTER_OTLP_TOKEN")), - "endpoint": os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", ""), - "token_set": bool(os.getenv("OTEL_EXPORTER_OTLP_TOKEN")), + "enabled": bool(grafana_endpoint) and bool(grafana_token), + "endpoint": grafana_endpoint, + "token_set": bool(grafana_token), } # Get Sentry status + sentry_dsn = analytics_config.get("sentry_dsn") or os.getenv("SENTRY_DSN", "") sentry_data = { - "enabled": bool(os.getenv("SENTRY_DSN")), - "dsn_set": bool(os.getenv("SENTRY_DSN")), + "enabled": bool(sentry_dsn), + "dsn_set": bool(sentry_dsn), "traces_rate": os.getenv("SENTRY_TRACES_RATE", "0.0"), } diff --git a/app/telemetry/service.py b/app/telemetry/service.py index 4b62dad9..29fe12ab 100644 --- a/app/telemetry/service.py +++ b/app/telemetry/service.py @@ -21,6 +21,7 @@ BASE_SCHEMA_KEYS = frozenset( { "install_id", + "telemetry_fingerprint", "app_version", "platform", "os_version", @@ -45,6 +46,7 @@ def is_detailed_analytics_enabled() -> bool: def _build_base_telemetry_payload(event_kind: str) -> Dict[str, Any]: from app.config.analytics_defaults import get_analytics_config from app.utils.installation import get_installation_config + from app.utils.telemetry import get_telemetry_fingerprint config = get_analytics_config() inst = get_installation_config() @@ -53,6 +55,7 @@ def _build_base_telemetry_payload(event_kind: str) -> Dict[str, Any]: first_seen = inst.get_base_first_seen_sent_at() or now payload = { "install_id": inst.get_install_id(), + "telemetry_fingerprint": get_telemetry_fingerprint(), "app_version": config.get("app_version", "unknown"), "platform": platform.system(), "os_version": platform.release(), @@ -271,10 +274,12 @@ def send_analytics_event(user_id: Any, event_name: str, properties: Optional[Dic return from app.config.analytics_defaults import get_analytics_config from app.utils.installation import get_installation_config + from app.utils.telemetry import get_telemetry_fingerprint config = get_analytics_config() enhanced = dict(properties or {}) enhanced["install_id"] = get_installation_config().get_install_id() + enhanced["telemetry_fingerprint"] = get_telemetry_fingerprint() enhanced["environment"] = os.getenv("FLASK_ENV", "production") enhanced["app_version"] = config.get("app_version") enhanced["deployment_method"] = "docker" if os.path.exists("/.dockerenv") else "native" diff --git a/setup.py b/setup.py index a1052ee5..4398b146 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='5.1.0', + version='5.1.1', packages=find_packages(), include_package_data=True, install_requires=[ diff --git a/tests/test_telemetry_consent_and_base.py b/tests/test_telemetry_consent_and_base.py index 0934af9d..61f4ebae 100644 --- a/tests/test_telemetry_consent_and_base.py +++ b/tests/test_telemetry_consent_and_base.py @@ -34,12 +34,14 @@ def test_send_analytics_event_capture_when_opt_in(self, mock_send): } with patch("app.utils.installation.get_installation_config") as mock_inst: mock_inst.return_value.get_install_id.return_value = "install-uuid-123" - send_analytics_event(1, "test.event", {"k": "v"}) + with patch("app.utils.telemetry.get_telemetry_fingerprint", return_value="fp-abc-123"): + send_analytics_event(1, "test.event", {"k": "v"}) mock_send.assert_called_once() call_kw = mock_send.call_args[1] assert call_kw["identity"] == "1" assert call_kw["event_name"] == "test.event" assert call_kw["properties"].get("install_id") == "install-uuid-123" + assert call_kw["properties"].get("telemetry_fingerprint") == "fp-abc-123" class TestBaseTelemetry: @@ -56,9 +58,10 @@ def test_send_base_first_seen_idempotent(self): with patch("app.utils.installation.get_installation_config", return_value=mock_inst): with patch("app.telemetry.service.send_base_telemetry") as mock_send: - mock_send.return_value = True - r1 = send_base_first_seen() - r2 = send_base_first_seen() + with patch("app.utils.telemetry.get_telemetry_fingerprint", return_value="fp-abc-123"): + mock_send.return_value = True + r1 = send_base_first_seen() + r2 = send_base_first_seen() assert mock_send.call_count == 1, "first_seen should be sent only once" assert r1 is True assert r2 is False @@ -98,6 +101,22 @@ def test_send_base_heartbeat_calls_telemetry_with_schema(self): assert "app_version" in call_payload assert "platform" in call_payload + def test_base_payload_includes_telemetry_fingerprint(self): + """Base telemetry payload includes both install_id and telemetry fingerprint.""" + from app.telemetry.service import _build_base_telemetry_payload + + mock_inst = MagicMock() + mock_inst.get_base_first_seen_sent_at.return_value = None + mock_inst.get_install_id.return_value = "install-uuid-123" + + with patch("app.utils.installation.get_installation_config", return_value=mock_inst): + with patch("app.config.analytics_defaults.get_analytics_config", return_value={"app_version": "1.0.0"}): + with patch("app.utils.telemetry.get_telemetry_fingerprint", return_value="fp-abc-123"): + payload = _build_base_telemetry_payload("heartbeat") + + assert payload["install_id"] == "install-uuid-123" + assert payload["telemetry_fingerprint"] == "fp-abc-123" + class TestInstallIdInPayloads: """install_id is stable and present where required."""