diff --git a/src/bubo/analytics.py b/src/bubo/analytics.py index b101cb1..acb4f75 100644 --- a/src/bubo/analytics.py +++ b/src/bubo/analytics.py @@ -39,6 +39,7 @@ import threading import uuid from collections.abc import Iterable +from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.request import Request, urlopen @@ -203,6 +204,9 @@ def __init__(self, config, event, properties): _install_node: int | None = None _identity_lock = threading.Lock() _surface = "unknown" +# UTC days this process sent session_start on; the service polls in-process. +_session_days: set[str] = set() +_session_lock = threading.Lock() # Only these labels may leave the machine; paths are used locally and discarded. _LANGUAGES = { @@ -456,8 +460,24 @@ def track(cfg: AnalyticsConfig, event: str, **props: Any) -> None: _emit(cfg, event, props) +def _utc_day() -> str: + return datetime.now(UTC).date().isoformat() + + def record_session_start(cfg: AnalyticsConfig, *, scm_provider: str, projects_count: int) -> None: - """One event per poll cycle start — liveness + install context.""" + """Liveness + install context: once per process per UTC day. + + The service calls ``poll()`` every few minutes in one process; sending + this per poll made it the bulk of all events. Once a day still shows a + long-running idle service as alive. A day isn't spent while consent is off. + """ + if not analytics_enabled(cfg): + return + day = _utc_day() + with _session_lock: + if day in _session_days: + return + _session_days.add(day) _emit( cfg, "session_start", diff --git a/tests/conftest.py b/tests/conftest.py index d2e775b..940251a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,6 +64,7 @@ def offline(): posthog.configure_otel(TelemetryConfig(analytics=AnalyticsConfig(enabled=False))) with analytics._pending_lock: analytics._pending_events.clear() + analytics._session_days.clear() assert not attempted, "A test attempted external telemetry without a fake transport" diff --git a/tests/test_session_start.py b/tests/test_session_start.py new file mode 100644 index 0000000..88064d9 --- /dev/null +++ b/tests/test_session_start.py @@ -0,0 +1,72 @@ +"""``session_start`` is a liveness signal, not a per-poll event. + +The service calls ``poll()`` in one process every few minutes. Drives the +real ``poll()`` with an idle config and counts the queued PostHog events. +""" + +from __future__ import annotations + +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bubo import analytics, db, paths, poller +from bubo.analytics_config import AnalyticsConfig +from bubo.review_config import ReviewConfig + +CONSENT = AnalyticsConfig(enabled=True, endpoint="https://posthog.invalid/batch", api_key="k") + + +@contextmanager +def _state() -> Iterator[None]: + old = paths.DB + try: + with tempfile.TemporaryDirectory() as tmp: + paths.DB = Path(tmp) / "state" / "reviewer.sqlite" + paths.DB.parent.mkdir() + db.init_db() + yield + finally: + paths.DB = old + + +def _session_starts(cfg: ReviewConfig, polls: int, days: list[str] | None = None) -> int: + provider = MagicMock() + provider.name = "github" + provider.token.return_value = "t" + with ( + _state(), + patch.object(poller, "read_config", return_value=cfg), + patch.object(poller, "get_provider", return_value=provider), + patch.object(analytics, "flush"), + patch.object(analytics, "_utc_day", side_effect=days or ["2026-10-20"] * polls), + ): + for _ in range(polls): + poller.poll(worker_mode="inline") + with analytics._pending_lock: + return sum( + 1 for *_, queued in analytics._pending_events if queued["event"] == "session_start" + ) + + +def _idle() -> ReviewConfig: + return ReviewConfig(provider="github", projects=[], analytics_config=CONSENT) + + +def test_repeated_polls_in_one_process_send_one_session_start() -> None: + assert _session_starts(_idle(), polls=3) == 1 + + +def test_a_long_running_service_still_checks_in_once_a_day() -> None: + days = ["2026-10-20", "2026-10-20", "2026-10-21", "2026-10-21"] + assert _session_starts(_idle(), polls=4, days=days) == 2 + + +def test_session_start_is_not_spent_while_analytics_is_off() -> None: + off = ReviewConfig( + provider="github", projects=[], analytics_config=AnalyticsConfig(enabled=False) + ) + assert _session_starts(off, polls=1) == 0 + assert _session_starts(_idle(), polls=1) == 1