From 8eea958f3c54807c9d77f4f46b6a9b9ee40221ee Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 16:42:02 +0200 Subject: [PATCH] Track version per daily activity to keep version history for 30 days --- ...e8c7d4b21_add_version_to_daily_activity.py | 43 +++++++++++++++++++ backend/app/models.py | 1 + backend/app/routers/stats.py | 20 ++++----- backend/app/routers/telemetry.py | 33 ++++++++------ backend/scripts/seed.py | 6 ++- backend/tests/test_stats.py | 43 ++++++++++++++++++- backend/tests/test_telemetry.py | 16 ++++++- 7 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 backend/alembic/versions/9a5e8c7d4b21_add_version_to_daily_activity.py diff --git a/backend/alembic/versions/9a5e8c7d4b21_add_version_to_daily_activity.py b/backend/alembic/versions/9a5e8c7d4b21_add_version_to_daily_activity.py new file mode 100644 index 0000000..1677c7b --- /dev/null +++ b/backend/alembic/versions/9a5e8c7d4b21_add_version_to_daily_activity.py @@ -0,0 +1,43 @@ +"""add version to daily_activity + +Revision ID: 9a5e8c7d4b21 +Revises: 0563d6d01623 +Create Date: 2026-09-10 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = "9a5e8c7d4b21" +down_revision: Union[str, Sequence[str], None] = "0563d6d01623" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "daily_activity", + sa.Column( + "version", + sqlmodel.sql.sqltypes.AutoString(length=32), + nullable=False, + server_default="", + ), + ) + op.create_index( + op.f("ix_daily_activity_version"), + "daily_activity", + ["version"], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_daily_activity_version"), table_name="daily_activity") + op.drop_column("daily_activity", "version") diff --git a/backend/app/models.py b/backend/app/models.py index 20b07e6..05198a9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -96,3 +96,4 @@ class DailyActivity(SQLModel, table=True): installation_id: str = Field(max_length=64, index=True) activity_date: str = Field(max_length=10, index=True) + version: str = Field(default="", max_length=32, index=True) diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index f4e0e4d..eef73c0 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -143,18 +143,18 @@ def _bucket(floor: datetime, *, upper: datetime | None = None) -> int: def _version_mix(session: Session, now) -> list[VersionMixEntry]: - """Version distribution of active installations per day, last 30 days.""" - cutoff = now - timedelta(days=30) + """Version distribution observed per day, last 30 days.""" + cutoff = (now - timedelta(days=30)).date().isoformat() rows = session.exec( select( - func.date(col(Installation.last_seen_at)), - col(Installation.version), - func.count(col(Installation.installation_id)), + col(DailyActivity.activity_date), + col(DailyActivity.version), + func.count(col(DailyActivity.installation_id)), ) - .where(col(Installation.last_seen_at) >= cutoff) - .where(col(Installation.version) != "") - .group_by(func.date(col(Installation.last_seen_at)), col(Installation.version)) - .order_by(func.date(col(Installation.last_seen_at))) + .where(col(DailyActivity.activity_date) >= cutoff) + .where(col(DailyActivity.version) != "") + .group_by(col(DailyActivity.activity_date), col(DailyActivity.version)) + .order_by(col(DailyActivity.activity_date)) ).all() by_date: dict[str, list[StatEntry]] = {} @@ -191,4 +191,4 @@ async def get_stats( longevity=_longevity(session, now), version_mix=_version_mix(session, now), server_version=__version__, - ) \ No newline at end of file + ) diff --git a/backend/app/routers/telemetry.py b/backend/app/routers/telemetry.py index 55fc5fa..ad53e19 100644 --- a/backend/app/routers/telemetry.py +++ b/backend/app/routers/telemetry.py @@ -76,20 +76,6 @@ async def ingest_telemetry( now = utcnow() existing = session.get(Installation, payload.installation_id) - # Record this installation as active today (one row per install per day). - today = now.date().isoformat() - activity = session.exec( - select(DailyActivity).where( - col(DailyActivity.installation_id) == payload.installation_id, - col(DailyActivity.activity_date) == today, - ) - ).first() - if activity is None: - session.add(DailyActivity( - installation_id=payload.installation_id, - activity_date=today, - )) - if existing is None: # If this installation was pruned for inactivity, resurrect it: move it # back to the live table so it is not double-counted in the all-time @@ -116,5 +102,24 @@ async def ingest_telemetry( _apply_version_specific(existing, payload) existing.last_seen_at = now + # Record the effective version for this installation on each ping day. + # Repeated pings on the same day update the snapshot if the installation + # upgrades during that day. + today = now.date().isoformat() + activity = session.exec( + select(DailyActivity).where( + col(DailyActivity.installation_id) == payload.installation_id, + col(DailyActivity.activity_date) == today, + ) + ).first() + if activity is None: + session.add(DailyActivity( + installation_id=payload.installation_id, + activity_date=today, + version=existing.version, + )) + else: + activity.version = existing.version + session.commit() return TelemetryOut(installation_id=payload.installation_id) diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py index 93a397a..403c42e 100644 --- a/backend/scripts/seed.py +++ b/backend/scripts/seed.py @@ -47,10 +47,11 @@ def main(count: int) -> None: now, ) install_id = f"seed-{i:04d}" + version = random.choice(_VERSIONS) session.add( Installation( installation_id=install_id, - version=random.choice(_VERSIONS), + version=version, os=random.choice(_OS), architecture=random.choice(_ARCHS), runtime=random.choice(_RUNTIMES), @@ -66,6 +67,7 @@ def main(count: int) -> None: DailyActivity( installation_id=install_id, activity_date=activity_date, + version=version, ) ) added += 1 @@ -75,4 +77,4 @@ def main(count: int) -> None: if __name__ == "__main__": count = int(sys.argv[1]) if len(sys.argv) > 1 else 25 - main(count) \ No newline at end of file + main(count) diff --git a/backend/tests/test_stats.py b/backend/tests/test_stats.py index 4065e26..49467cd 100644 --- a/backend/tests/test_stats.py +++ b/backend/tests/test_stats.py @@ -23,7 +23,11 @@ def _seed(session: Session, installation_id: str, **overrides) -> None: session.add(Installation(**data)) # Mirror the ingest behaviour: one DailyActivity row per day the install pings. activity_date = data["last_seen_at"].date().isoformat() - session.add(DailyActivity(installation_id=installation_id, activity_date=activity_date)) + session.add(DailyActivity( + installation_id=installation_id, + activity_date=activity_date, + version=data["version"], + )) def test_empty_stats(client: TestClient): @@ -204,4 +208,39 @@ def test_daily_activity_counts_each_ping_day(client: TestClient, session: Sessio by_date = {e["date"]: e["count"] for e in body["daily"]} assert by_date.get(yesterday) == 2 # pinger + yesterday-only - assert by_date.get(today) == 1 # only pinger \ No newline at end of file + assert by_date.get(today) == 1 # only pinger + + +def test_version_mix_keeps_history_for_daily_pings(client: TestClient, session: Session): + """A later ping must not replace an earlier day's version snapshot.""" + now = utcnow() + yesterday = (now - timedelta(days=1)).date().isoformat() + session.add(Installation( + installation_id="upgraded", + version="v2.0.0", + os="Linux", + architecture="x64", + runtime="docker", + first_seen_at=now - timedelta(days=1), + last_seen_at=now, + )) + session.add(DailyActivity( + installation_id="upgraded", + activity_date=yesterday, + version="v1.0.0", + )) + session.add(DailyActivity( + installation_id="upgraded", + activity_date=now.date().isoformat(), + version="v2.0.0", + )) + session.commit() + + resp = client.get("/api/stats") + assert resp.status_code == 200 + by_date = { + entry["date"]: {version["label"]: version["count"] for version in entry["versions"]} + for entry in resp.json()["version_mix"] + } + assert by_date[yesterday] == {"v1.0.0": 1} + assert by_date[now.date().isoformat()] == {"v2.0.0": 1} diff --git a/backend/tests/test_telemetry.py b/backend/tests/test_telemetry.py index 737d80f..b4fed49 100644 --- a/backend/tests/test_telemetry.py +++ b/backend/tests/test_telemetry.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from sqlmodel import Session, select -from app.models import Installation +from app.models import DailyActivity, Installation def _payload(**overrides): @@ -33,6 +33,12 @@ def test_ingest_new_installation(client: TestClient, session: Session): assert row.os == "Linux" assert row.message_version == 1 assert row.first_seen_at == row.last_seen_at + activity = session.get( + DailyActivity, + ("inst-001", row.last_seen_at.date().isoformat()), + ) + assert activity is not None + assert activity.version == "v1.2.3" def test_ingest_without_message_version_rejected(client: TestClient): @@ -62,6 +68,12 @@ def test_ingest_updates_existing(client: TestClient, session: Session): row = rows[0] assert row.version == "v1.1.0" assert (row.last_seen_at - row.first_seen_at).total_seconds() < 1 + activity = session.get( + DailyActivity, + ("inst-001", row.last_seen_at.date().isoformat()), + ) + assert activity is not None + assert activity.version == "v1.1.0" def test_ingest_unknown_fields_rejected(client: TestClient): @@ -236,4 +248,4 @@ def test_dropped_field_reset_on_update(): _apply_version_specific(fresh, incoming) assert fresh.message_version == 1 assert fresh.version == "v1.1.0" - assert fresh.runtime == "" \ No newline at end of file + assert fresh.runtime == ""