feat: add telemetry build provenance

Stamp official Docker builds with a low-cardinality provenance marker and report build_provenance/image_modified in anonymous telemetry. Local and fork builds default to custom, while official GitHub/GitLab release paths mark images as official.
This commit is contained in:
3252a8
2026-06-10 15:18:32 +03:00
parent fd931581f5
commit 6bb2709244
22 changed files with 261 additions and 44 deletions
@@ -170,6 +170,7 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
except OSError:
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
async def _persist_appearance_upload(
request: web.Request,
updates: Dict[str, Any],
@@ -707,10 +707,11 @@ SETTINGS_MANIFEST: List[SettingField] = [
"bool",
"system",
"Анонимная статистика установки",
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
"пользователей в виде диапазона. Без персональных данных, токенов и "
"доменов. Помогает понять число активных установок и какие версии "
"используются. Можно отключить здесь без перезапуска.",
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
"данных, токенов и доменов. Помогает понять число активных установок, какие "
"версии используются и долю изменённых сборок. Можно отключить здесь без "
"перезапуска.",
),
]
+10 -1
View File
@@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
from bot.utils.app_version import (
resolve_app_version,
resolve_app_version_tag,
resolve_build_provenance,
resolve_image_modified,
)
from config.settings import Settings
from db.dal import app_settings_dal, user_dal
@@ -169,11 +174,15 @@ class TelemetryWorker:
version = resolve_app_version()
version_tag = resolve_app_version_tag()
build_provenance = resolve_build_provenance()
image_modified = resolve_image_modified()
# Person properties (``$set``) snapshot the latest state per install, so
# "version breakdown" in PostHog is a person-property breakdown.
person_props = {
"app_version": version,
"app_version_tag": version_tag,
"build_provenance": build_provenance,
"image_modified": image_modified,
"os": platform.system().lower() or "unknown",
"arch": platform.machine().lower() or "unknown",
"python_version": platform.python_version(),
+61
View File
@@ -6,6 +6,9 @@ the runtime container agree on the value:
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
> ``dev+unknown``
Build provenance is intentionally separate from the version: official release
automation stamps official images, while local/fork builds default to custom.
The same value powers the admin sidebar (web process) and the anonymous
telemetry beacon (worker process), so "active installs" and version
breakdowns line up across both.
@@ -24,6 +27,16 @@ from typing import Optional
APP_ROOT = Path(__file__).resolve().parents[3]
_APP_VERSION_CACHE: Optional[str] = None
_APP_BUILD_PROVENANCE_CACHE: Optional[str] = None
BUILD_PROVENANCE_OFFICIAL = "official"
BUILD_PROVENANCE_CUSTOM = "custom"
BUILD_PROVENANCE_UNKNOWN = "unknown"
_BUILD_PROVENANCE_VALUES = {
BUILD_PROVENANCE_OFFICIAL,
BUILD_PROVENANCE_CUSTOM,
BUILD_PROVENANCE_UNKNOWN,
}
def _run_git_command(*args: str) -> str:
@@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str:
return ""
def _normalize_build_provenance(raw: str) -> str:
value = str(raw or "").strip().lower()
if not value:
return ""
aliases = {
"true": BUILD_PROVENANCE_OFFICIAL,
"1": BUILD_PROVENANCE_OFFICIAL,
"yes": BUILD_PROVENANCE_OFFICIAL,
"upstream": BUILD_PROVENANCE_OFFICIAL,
"release": BUILD_PROVENANCE_OFFICIAL,
"false": BUILD_PROVENANCE_CUSTOM,
"0": BUILD_PROVENANCE_CUSTOM,
"no": BUILD_PROVENANCE_CUSTOM,
"fork": BUILD_PROVENANCE_CUSTOM,
"modified": BUILD_PROVENANCE_CUSTOM,
"local": BUILD_PROVENANCE_CUSTOM,
}
value = aliases.get(value, value)
if value in _BUILD_PROVENANCE_VALUES:
return value
return BUILD_PROVENANCE_CUSTOM
def resolve_app_version() -> str:
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
global _APP_VERSION_CACHE
@@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str:
if tag:
return tag
return resolve_app_version()
def resolve_build_provenance() -> str:
"""Low-cardinality image provenance: ``official``, ``custom`` or ``unknown``."""
global _APP_BUILD_PROVENANCE_CACHE
if _APP_BUILD_PROVENANCE_CACHE:
return _APP_BUILD_PROVENANCE_CACHE
env_value = _normalize_build_provenance(os.getenv("REMNAWAVE_MINISHOP_BUILD_PROVENANCE", ""))
if env_value:
_APP_BUILD_PROVENANCE_CACHE = env_value
return env_value
build_value = _normalize_build_provenance(_read_build_file(".build-provenance"))
if build_value:
_APP_BUILD_PROVENANCE_CACHE = build_value
return build_value
_APP_BUILD_PROVENANCE_CACHE = BUILD_PROVENANCE_CUSTOM
return _APP_BUILD_PROVENANCE_CACHE
def resolve_image_modified() -> bool:
"""True for non-official builds, including forks and local rebuilds."""
return resolve_build_provenance() != BUILD_PROVENANCE_OFFICIAL
+4 -3
View File
@@ -1256,9 +1256,10 @@ class Settings(BaseSettings):
TELEMETRY_ENABLED: bool = Field(
default=True,
description=(
"Send an anonymous daily install heartbeat (version, OS, locale, "
"user-count range). No personal data. Opt out here, via the web "
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
"Send an anonymous daily install heartbeat (version, official/custom "
"image provenance, OS, locale, user-count range). No personal data. "
"Opt out here, via the web admin, or by clearing "
"TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
),
)
TELEMETRY_ENDPOINT: str = Field(
+1 -3
View File
@@ -139,9 +139,7 @@ async def find_recent_pending_provider_payment(
conditions = [
Payment.user_id == user_id,
Payment.provider == provider,
func.lower(Payment.status).in_(
tuple({str(pending_status).lower(), "pending"})
),
func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})),
or_(
Payment.provider_payment_id.isnot(None),
Payment.yookassa_payment_id.isnot(None),