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:
@@ -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, ОС, локаль и число пользователей в виде диапазона. Без персональных "
|
||||
"данных, токенов и доменов. Помогает понять число активных установок, какие "
|
||||
"версии используются и долю изменённых сборок. Можно отключить здесь без "
|
||||
"перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user