Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic telemetry #203

Merged
merged 11 commits into from
Sep 28, 2024
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,61 @@ remote = "https://github.com/ruyisdk/packages-index.git"
# Name of the branch to use.
# If unset or empty, this default value is used.
branch = "main"

[telemetry]
# Whether to collect telemetry information for improving RuyiSDK's developer
# experience, and whether to send the data periodically to RuyiSDK team.
# Valid values are `local`, `off` and `on` -- see the documentation for
# details.
#
# If unset or empty, this default value is used: data will be collected but
# nothing will get uploaded without explicit action by the user.
mode = "local"
```

### Environment variables

Currently the following environment variables are supported by `ruyi`:

* `RUYI_VENV` -- explicitly specifies the Ruyi virtual environment to use.
* `RUYI_TELEMETRY_OPTOUT` -- boolean, whether to opt-out of telemetry.
* `RUYI_VENV` -- string, explicitly specifies the Ruyi virtual environment to use.

For boolean variables, the values `1`, `true`, `x`, `y` or `yes` (all case-insensitive)
are all treated as "true".

### Telemetry

The Ruyi package manager collects usage data in order to help us improve your
experience. It is collected by the RuyiSDK team and shared with the community.
You can opt-out of telemetry by setting the `RUYI_TELEMETRY_OPTOUT`
environment variable to any of `1`, `true`, `x`, `y` or `yes` using your
favorite shell.

> **NOTE**: Currently only the `local` and `off` modes are implemented. The
> server-side components of RuyiSDK are still under development, and has not
> been deployed yet. Consequently, no data will be uploaded for now.
>
> You will be notified at your next `ruyi update` when we are ready to
> do so, and you will have enough time to revise your configuration so as to
> prevent unintentional data uploads.

There are 3 telemetry modes available:

* `local`: data will be collected but not uploaded without user action.
* `off`: data will not be collected nor uploaded.
* `on`: data will be collected and periodically uploaded.

By default the `local` mode is active, which means every `ruyi` invocation
will record some non-sensitive information locally alongside various other
states of `ruyi`, but nothing will be uploaded.

You can change the telemetry mode by editing `ruyi`'s config file, or simply
by setting the `RUYI_TELEMETRY_OPTOUT` environment variable to any of the
values accepted as truthy.

<!-- We collect the following information with `ruyi`: -->

<!-- TODO: table of metrics -->

## License

Expand Down
61 changes: 46 additions & 15 deletions ruyi/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import argparse
import atexit
import os
import sys
from typing import Callable, List

from ..config import GlobalConfig

# Should be all-lower for is_called_as_ruyi to work
RUYI_ENTRYPOINT_NAME = "ruyi"

Expand Down Expand Up @@ -30,8 +33,9 @@ def init_argparse() -> argparse.ArgumentParser:
from ..ruyipkg.pkg_cli import cli_extract, cli_install, cli_list
from ..ruyipkg.profile_cli import cli_list_profiles
from ..ruyipkg.update_cli import cli_update
from ..version import RUYI_SEMVER
from .self_cli import cli_self_uninstall
from .version import RUYI_SEMVER, cli_version
from .version_cli import cli_version

native_host_str = get_native_host()

Expand Down Expand Up @@ -74,7 +78,10 @@ def init_argparse() -> argparse.ArgumentParser:
"provision",
help="Interactively initialize a device for development",
)
device_provision.set_defaults(func=cli_device_provision)
device_provision.set_defaults(
func=cli_device_provision,
tele_key="device provision",
)

extract = sp.add_parser(
"extract",
Expand All @@ -92,10 +99,12 @@ def init_argparse() -> argparse.ArgumentParser:
default=native_host_str,
help="Override the host architecture (normally not needed)",
)
extract.set_defaults(func=cli_extract)
extract.set_defaults(func=cli_extract, tele_key="extract")

install = sp.add_parser(
"install", aliases=["i"], help="Install package from configured repository"
"install",
aliases=["i"],
help="Install package from configured repository",
)
install.add_argument(
"atom",
Expand All @@ -120,22 +129,22 @@ def init_argparse() -> argparse.ArgumentParser:
action="store_true",
help="Force re-installation of already installed packages",
)
install.set_defaults(func=cli_install)
install.set_defaults(func=cli_install, tele_key="install")

list = sp.add_parser(
"list", help="List available packages in configured repository"
)
list.set_defaults(func=cli_list)
list.add_argument(
"--verbose",
"-v",
action="store_true",
help="Also show details for every package",
)
list.set_defaults(func=cli_list, tele_key="list")

listsp = list.add_subparsers(required=False)
list_profiles = listsp.add_parser("profiles", help="List all available profiles")
list_profiles.set_defaults(func=cli_list_profiles)
list_profiles.set_defaults(func=cli_list_profiles, tele_key="list profiles")

news = sp.add_parser(
"news",
Expand All @@ -153,7 +162,7 @@ def init_argparse() -> argparse.ArgumentParser:
action="store_true",
help="List unread news items only",
)
news_list.set_defaults(func=cli_news_list)
news_list.set_defaults(func=cli_news_list, tele_key="news list")

news_read = newssp.add_parser(
"read",
Expand All @@ -172,10 +181,10 @@ def init_argparse() -> argparse.ArgumentParser:
nargs="*",
help="Ordinal or ID of the news item(s) to read",
)
news_read.set_defaults(func=cli_news_read)
news_read.set_defaults(func=cli_news_read, tele_key="news read")

up = sp.add_parser("update", help="Update RuyiSDK repo and packages")
up.set_defaults(func=cli_update)
up.set_defaults(func=cli_update, tele_key="update")

venv = sp.add_parser(
"venv",
Expand Down Expand Up @@ -220,7 +229,7 @@ def init_argparse() -> argparse.ArgumentParser:
type=str,
help="Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable",
)
venv.set_defaults(func=cli_venv)
venv.set_defaults(func=cli_venv, tele_key="venv")

# Repo admin commands
admin = sp.add_parser(
Expand All @@ -244,7 +253,10 @@ def init_argparse() -> argparse.ArgumentParser:
nargs="+",
help="Path to the distfile(s) to generate manifest for",
)
admin_format_manifest.set_defaults(func=cli_admin_format_manifest)
admin_format_manifest.set_defaults(
func=cli_admin_format_manifest,
tele_key="admin format-manifest",
)

admin_manifest = adminsp.add_parser(
"manifest",
Expand All @@ -270,7 +282,7 @@ def init_argparse() -> argparse.ArgumentParser:
nargs="+",
help="Path to the distfile(s) to generate manifest for",
)
admin_manifest.set_defaults(func=cli_admin_manifest)
admin_manifest.set_defaults(func=cli_admin_manifest, tele_key="admin manifest")

# Self-management commands
self = sp.add_parser(
Expand All @@ -297,23 +309,33 @@ def init_argparse() -> argparse.ArgumentParser:
dest="consent",
help="Give consent for uninstallation on CLI; do not ask for confirmation",
)
self_uninstall.set_defaults(func=cli_self_uninstall)
self_uninstall.set_defaults(func=cli_self_uninstall, tele_key="self uninstall")

# Version info
# Keep this at the bottom
version = sp.add_parser(
"version",
help="Print version information",
)
version.set_defaults(func=cli_version)
version.set_defaults(func=cli_version, tele_key="version")

return root


def main(argv: List[str]) -> int:
gc = GlobalConfig.load_from_config()
if gc.telemetry is not None:
gc.telemetry.init_installation(False)
atexit.register(gc.telemetry.flush)

if not is_called_as_ruyi(argv[0]):
from ..mux.runtime import mux_main

# record an invocation and the command name being proxied to
if gc.telemetry is not None:
target = os.path.basename(argv[0])
gc.telemetry.record("cli:mux-invocation-v1", target=target)

return mux_main(argv)

import ruyi
Expand All @@ -335,6 +357,15 @@ def main(argv: List[str]) -> int:

func: CLIEntrypoint = args.func

# record every invocation's subcommand for better insight into usage
# frequencies
try:
telemetry_key = args.tele_key
except AttributeError:
log.F("internal error: CLI entrypoint was added without a telemetry key")
if gc.telemetry is not None:
gc.telemetry.record("cli:invocation-v1", key=telemetry_key)

try:
return func(args)
except Exception:
Expand Down
13 changes: 13 additions & 0 deletions ruyi/cli/version_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import argparse

from .. import log
from ..version import COPYRIGHT_NOTICE, RUYI_SEMVER


def cli_version(_: argparse.Namespace) -> int:
from ..ruyipkg.host import get_native_host

print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.\n")
log.stdout(COPYRIGHT_NOTICE)

return 0
43 changes: 38 additions & 5 deletions ruyi/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
import tomllib
from typing import Any, Iterable, NotRequired, Self, TypedDict

from .. import log, argv0
from .. import argv0, is_env_var_truthy, log
from ..telemetry import TelemetryStore
from ..utils.xdg_basedir import XDGBaseDir
from .news import NewsReadStatusStore

DEFAULT_APP_NAME = "ruyi"
DEFAULT_REPO_URL = "https://github.com/ruyisdk/packages-index.git"
DEFAULT_REPO_BRANCH = "main"

ENV_TELEMETRY_OPTOUT_KEY = "RUYI_TELEMETRY_OPTOUT"
ENV_VENV_ROOT_KEY = "RUYI_VENV"


Expand All @@ -38,9 +40,14 @@ class GlobalConfigRepoType(TypedDict):
branch: NotRequired[str]


class GlobalConfigTelemetryType(TypedDict):
mode: NotRequired[str]


class GlobalConfigRootType(TypedDict):
packages: NotRequired[GlobalConfigPackagesType]
repo: NotRequired[GlobalConfigRepoType]
telemetry: NotRequired[GlobalConfigTelemetryType]


class GlobalConfig:
Expand All @@ -52,19 +59,22 @@ def __init__(self) -> None:
self.include_prereleases = False

self._news_read_status_store: NewsReadStatusStore | None = None
self._telemetry_store: TelemetryStore | None = None

self._lang_code = _get_lang_code()

self._dirs = XDGBaseDir(DEFAULT_APP_NAME)

self._telemetry_mode: str | None = None

def apply_config(self, config_data: GlobalConfigRootType) -> None:
if pkgs_cfg := config_data.get("packages"):
self.include_prereleases = pkgs_cfg.get("prereleases", False)

if section := config_data.get("repo"):
self.override_repo_dir = section.get("local", None)
self.override_repo_url = section.get("remote", None)
self.override_repo_branch = section.get("branch", None)
if repo_cfg := config_data.get("repo"):
self.override_repo_dir = repo_cfg.get("local", None)
self.override_repo_url = repo_cfg.get("remote", None)
self.override_repo_branch = repo_cfg.get("branch", None)

if self.override_repo_dir:
if not pathlib.Path(self.override_repo_dir).is_absolute():
Expand All @@ -73,6 +83,9 @@ def apply_config(self, config_data: GlobalConfigRootType) -> None:
)
self.override_repo_dir = None

if tele_cfg := config_data.get("telemetry"):
self._telemetry_mode = tele_cfg.get("mode", None)

@property
def lang_code(self) -> str:
return self._lang_code
Expand All @@ -98,6 +111,22 @@ def news_read_status(self) -> NewsReadStatusStore:
self._news_read_status_store = NewsReadStatusStore(filename)
return self._news_read_status_store

@property
def telemetry(self) -> TelemetryStore | None:
if self.telemetry_mode == "off":
return None
if self._telemetry_store is not None:
return self._telemetry_store

local_mode = self.telemetry_mode == "local"
dirname = os.path.join(self.ensure_state_dir(), "telemetry")
self._telemetry_store = TelemetryStore(dirname, local_mode)
return self._telemetry_store

@property
def telemetry_mode(self) -> str:
return self._telemetry_mode or "local"

def get_repo_dir(self) -> str:
return self.override_repo_dir or os.path.join(self.cache_root, "packages-index")

Expand Down Expand Up @@ -173,6 +202,10 @@ def load_from_config(cls) -> Self:
log.D(f"applying config: {data}")
obj.apply_config(data)

# let environment variable take precedence
if is_env_var_truthy(ENV_TELEMETRY_OPTOUT_KEY):
obj._telemetry_mode = "off"

return obj


Expand Down
10 changes: 7 additions & 3 deletions ruyi/mux/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,13 @@ def ensure_venv_in_path(vcfg: RuyiVenvConfig) -> None:

orig_path = os.environ.get("PATH", "")
for p in orig_path.split(os.pathsep):
if os.path.samefile(p, venv_bindir):
# TODO: what if our bindir actually comes after the system ones?
return
try:
if os.path.samefile(p, venv_bindir):
# TODO: what if our bindir actually comes after the system ones?
return
except FileNotFoundError:
# maybe the PATH entry is stale
continue

# we're not in PATH, so prepend the bindir to PATH
os.environ["PATH"] = f"{venv_bindir}:{orig_path}" if orig_path else str(venv_bindir)
2 changes: 1 addition & 1 deletion ruyi/pluginhost/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from ruyi import log
from ruyi.cli import user_input
from ruyi.cli.version import RUYI_SEMVER
from ruyi.version import RUYI_SEMVER
from .paths import resolve_ruyi_load_path


Expand Down
1 change: 1 addition & 0 deletions ruyi/telemetry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .store import TelemetryStore as TelemetryStore
Loading