Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/bubo/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
72 changes: 72 additions & 0 deletions tests/test_session_start.py
Original file line number Diff line number Diff line change
@@ -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
Loading