ruyi 0.44.0a20251118__tar.gz → 0.44.0b20251219__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/PKG-INFO +3 -2
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/README.md +2 -1
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/pyproject.toml +1 -1
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/main.py +20 -13
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/oobe.py +3 -7
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/self_cli.py +1 -2
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/__init__.py +4 -8
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/install.py +20 -22
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/aggregate.py +5 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/provider.py +229 -87
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/store.py +68 -15
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/telemetry_cli.py +23 -5
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/version.py +1 -1
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/LICENSE-Apache.txt +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/__main__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/builtin_commands.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/cmd.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/completer.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/completion.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/config_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/user_input.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/version_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/editor.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/errors.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/news.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/schema.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/device/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/device/provision.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/device/provision_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/log/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/runtime.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/emulator_cfg.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/maker.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/venv_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv_cfg.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/api.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/ctx.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/paths.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/plugin_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/unsandboxed.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/py.typed +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/resource_bundle/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/resource_bundle/__main__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/resource_bundle/data.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/admin_checksum.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/admin_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/atom.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/augmented_pkg.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/canonical_dump.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/checksum.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/cli_completion.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/distfile.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/entity.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/entity_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/entity_provider.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/fetcher.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/host.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/install_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/list.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/list_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/list_filter.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/msg.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/news.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/news_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/news_store.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/pkg_manifest.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/profile.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/profile_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/protocols.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/repo.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/state.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/unpack.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/unpack_method.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/update_cli.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/event.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/scope.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/__init__.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/ar.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/ci.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/frontmatter.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/git.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/global_mode.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/l10n.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/markdown.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/mounts.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/node_info.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/nuitka.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/porcelain.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/prereqs.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/ssl_patch.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/templating.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/toml.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/url.py +0 -0
- {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/xdg_basedir.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ruyi
|
|
3
|
-
Version: 0.44.
|
|
3
|
+
Version: 0.44.0b20251219
|
|
4
4
|
Summary: Package manager for RuyiSDK
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -408,7 +408,8 @@ described below.
|
|
|
408
408
|
There are 3 telemetry modes available:
|
|
409
409
|
|
|
410
410
|
* `local`: data will be collected but not uploaded without user action.
|
|
411
|
-
* `off`: data will
|
|
411
|
+
* `off`: data will neither be collected nor uploaded, except for a one-time
|
|
412
|
+
upload of `ruyi`'s version number on first run.
|
|
412
413
|
* `on`: data will be collected and periodically uploaded.
|
|
413
414
|
|
|
414
415
|
By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which
|
|
@@ -163,7 +163,8 @@ described below.
|
|
|
163
163
|
There are 3 telemetry modes available:
|
|
164
164
|
|
|
165
165
|
* `local`: data will be collected but not uploaded without user action.
|
|
166
|
-
* `off`: data will
|
|
166
|
+
* `off`: data will neither be collected nor uploaded, except for a one-time
|
|
167
|
+
upload of `ruyi`'s version number on first run.
|
|
167
168
|
* `on`: data will be collected and periodically uploaded.
|
|
168
169
|
|
|
169
170
|
By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which
|
|
@@ -39,11 +39,11 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
|
|
|
39
39
|
if not gm.is_cli_autocomplete:
|
|
40
40
|
oobe = OOBE(gc)
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
tm = gc.telemetry
|
|
43
|
+
tm.check_first_run_status()
|
|
44
|
+
tm.init_installation(False)
|
|
45
|
+
atexit.register(tm.flush)
|
|
46
|
+
oobe.handlers.append(tm.oobe_prompt)
|
|
47
47
|
|
|
48
48
|
oobe.maybe_prompt()
|
|
49
49
|
|
|
@@ -59,12 +59,11 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
|
|
|
59
59
|
from ..mux.runtime import mux_main
|
|
60
60
|
|
|
61
61
|
# record an invocation and the command name being proxied to
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
62
|
+
gc.telemetry.record(
|
|
63
|
+
TelemetryScope(None),
|
|
64
|
+
"cli:mux-invocation-v1",
|
|
65
|
+
target=os.path.basename(gm.argv0),
|
|
66
|
+
)
|
|
68
67
|
|
|
69
68
|
return mux_main(gm, gc, argv)
|
|
70
69
|
|
|
@@ -127,8 +126,16 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
|
|
|
127
126
|
except AttributeError:
|
|
128
127
|
pass
|
|
129
128
|
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
tm = gc.telemetry
|
|
130
|
+
tm.print_telemetry_notice()
|
|
131
|
+
|
|
132
|
+
# Do not record `ruyi telemetry --cron-upload` invocations.
|
|
133
|
+
skip_recording_invocation = telemetry_key == "telemetry" and getattr(
|
|
134
|
+
args,
|
|
135
|
+
"cron_upload",
|
|
136
|
+
False,
|
|
137
|
+
)
|
|
138
|
+
if not skip_recording_invocation:
|
|
132
139
|
tm.record(
|
|
133
140
|
TelemetryScope(None),
|
|
134
141
|
"cli:invocation-v1",
|
|
@@ -30,13 +30,9 @@ class OOBE:
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
def is_first_run(self) -> bool:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# we may revisit this later if it turns out users want OOBE tips even
|
|
37
|
-
# if they know how to disable telemetry (hence more likely to be power
|
|
38
|
-
# users)
|
|
39
|
-
return False
|
|
33
|
+
# We now always have our first-run indicator because of the minimal
|
|
34
|
+
# telemetry mode.
|
|
35
|
+
return self._gc.telemetry.is_first_run
|
|
40
36
|
|
|
41
37
|
def should_prompt(self) -> bool:
|
|
42
38
|
from ..utils.global_mode import is_env_var_truthy
|
|
@@ -241,8 +241,7 @@ def _do_reset(
|
|
|
241
241
|
|
|
242
242
|
# do not record any telemetry data if we're purging it
|
|
243
243
|
if all_state or telemetry:
|
|
244
|
-
|
|
245
|
-
tm.discard_events(True)
|
|
244
|
+
cfg.telemetry.discard_events(True)
|
|
246
245
|
|
|
247
246
|
if all_state:
|
|
248
247
|
status("removing state data")
|
|
@@ -297,17 +297,13 @@ class GlobalConfig:
|
|
|
297
297
|
def telemetry_root(self) -> os.PathLike[Any]:
|
|
298
298
|
return pathlib.Path(self.ensure_state_dir()) / "telemetry"
|
|
299
299
|
|
|
300
|
-
@property
|
|
301
|
-
def telemetry(self) -> "TelemetryProvider | None":
|
|
302
|
-
return None if self.telemetry_mode == "off" else self._telemetry_provider
|
|
303
|
-
|
|
304
300
|
@cached_property
|
|
305
|
-
def
|
|
306
|
-
"""Do not access directly; use the ``telemetry`` property instead."""
|
|
307
|
-
|
|
301
|
+
def telemetry(self) -> "TelemetryProvider":
|
|
308
302
|
from ..telemetry.provider import TelemetryProvider
|
|
309
303
|
|
|
310
|
-
|
|
304
|
+
# for allowing minimal uploads when telemetry is off
|
|
305
|
+
minimal_mode = self.telemetry_mode == "off"
|
|
306
|
+
return TelemetryProvider(self, minimal_mode)
|
|
311
307
|
|
|
312
308
|
@property
|
|
313
309
|
def telemetry_mode(self) -> str:
|
|
@@ -168,17 +168,16 @@ def do_install_atoms(
|
|
|
168
168
|
for s in sv.render_known_issues(pm.repo.messages, config.lang_code):
|
|
169
169
|
logger.I(s)
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
)
|
|
171
|
+
config.telemetry.record(
|
|
172
|
+
TelemetryScope(mr.repo_id),
|
|
173
|
+
"repo:package-install-v1",
|
|
174
|
+
atom=a_str,
|
|
175
|
+
host=canonicalized_host,
|
|
176
|
+
pkg_category=pm.category,
|
|
177
|
+
pkg_kinds=pm.kind,
|
|
178
|
+
pkg_name=pm.name,
|
|
179
|
+
pkg_version=pm.ver,
|
|
180
|
+
)
|
|
182
181
|
|
|
183
182
|
if pm.binary_metadata is not None:
|
|
184
183
|
ret = _do_install_binary_pkg(
|
|
@@ -478,17 +477,16 @@ def do_uninstall_atoms(
|
|
|
478
477
|
for a_str, pm in pms_to_uninstall:
|
|
479
478
|
pkg_name = pm.name_for_installation
|
|
480
479
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
)
|
|
480
|
+
config.telemetry.record(
|
|
481
|
+
TelemetryScope(mr.repo_id),
|
|
482
|
+
"repo:package-uninstall-v1",
|
|
483
|
+
atom=a_str,
|
|
484
|
+
host=canonicalized_host,
|
|
485
|
+
pkg_category=pm.category,
|
|
486
|
+
pkg_kinds=pm.kind,
|
|
487
|
+
pkg_name=pm.name,
|
|
488
|
+
pkg_version=pm.ver,
|
|
489
|
+
)
|
|
492
490
|
|
|
493
491
|
if pm.binary_metadata is not None:
|
|
494
492
|
ret = _do_uninstall_binary_pkg(
|
|
@@ -18,8 +18,13 @@ class UploadPayload(TypedDict):
|
|
|
18
18
|
fmt: int
|
|
19
19
|
nonce: str
|
|
20
20
|
ruyi_version: str
|
|
21
|
+
report_uuid: "NotRequired[str]"
|
|
22
|
+
"""Optional field in case the client wishes to report this, and nothing
|
|
23
|
+
else. If `installation` is present, this field is ignored."""
|
|
21
24
|
installation: "NotRequired[NodeInfo | None]"
|
|
25
|
+
"""More detailed installation info that the client has user consent to report."""
|
|
22
26
|
events: list[AggregatedTelemetryEvent]
|
|
27
|
+
"""Aggregated telemetry events that the client has user consent to upload."""
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
def stringify_param_val(v: object) -> str:
|
|
@@ -19,22 +19,27 @@ if TYPE_CHECKING:
|
|
|
19
19
|
FALLBACK_PM_TELEMETRY_ENDPOINT = "https://api.ruyisdk.cn/telemetry/pm/"
|
|
20
20
|
|
|
21
21
|
TELEMETRY_CONSENT_AND_UPLOAD_DESC = """
|
|
22
|
-
RuyiSDK collects
|
|
22
|
+
RuyiSDK collects minimal usage data in the form of just a version number of
|
|
23
|
+
the running [yellow]ruyi[/], to help us improve the product. With your consent,
|
|
24
|
+
RuyiSDK may also collect additional non-tracking usage data to be sent
|
|
25
|
+
periodically. The data will be recorded and processed by RuyiSDK team-managed
|
|
26
|
+
servers located in the Chinese mainland.
|
|
23
27
|
|
|
24
28
|
[green]By default, nothing leaves your machine[/], and you can also turn off usage data
|
|
25
|
-
collection completely. Only with your explicit permission can [yellow]ruyi[/]
|
|
26
|
-
|
|
27
|
-
the Chinese mainland. You can change this setting at any time by running
|
|
29
|
+
collection completely. Only with your explicit permission can [yellow]ruyi[/] collect and
|
|
30
|
+
upload more usage data. You can change this setting at any time by running
|
|
28
31
|
[yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/].
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
We'll also send a one-time report from this [yellow]ruyi[/] installation so the RuyiSDK
|
|
34
|
+
team can better understand adoption. If you choose to opt out, this will be the
|
|
35
|
+
only data to be ever uploaded, without any tracking ID being generated or kept.
|
|
36
|
+
Thank you for helping us build a better experience!
|
|
33
37
|
"""
|
|
34
38
|
TELEMETRY_CONSENT_AND_UPLOAD_PROMPT = (
|
|
35
|
-
"
|
|
39
|
+
"Do you agree to have usage data periodically uploaded?"
|
|
36
40
|
)
|
|
37
|
-
TELEMETRY_OPTOUT_PROMPT = "\nDo you want to
|
|
41
|
+
TELEMETRY_OPTOUT_PROMPT = "\nDo you want to opt out of telemetry entirely?"
|
|
42
|
+
MALFORMED_TELEMETRY_STATE_MSG = "malformed telemetry state: unable to determine upload weekday, nothing will be uploaded"
|
|
38
43
|
|
|
39
44
|
|
|
40
45
|
def next_utc_weekday(wday: int, now: float | None = None) -> int:
|
|
@@ -130,7 +135,7 @@ def set_telemetry_mode(
|
|
|
130
135
|
|
|
131
136
|
|
|
132
137
|
class TelemetryProvider:
|
|
133
|
-
def __init__(self, gc: "GlobalConfig") -> None:
|
|
138
|
+
def __init__(self, gc: "GlobalConfig", minimal: bool) -> None:
|
|
134
139
|
self.state_root = pathlib.Path(gc.telemetry_root)
|
|
135
140
|
|
|
136
141
|
self._discard_events = False
|
|
@@ -138,6 +143,7 @@ class TelemetryProvider:
|
|
|
138
143
|
self._is_first_run = False
|
|
139
144
|
self._stores: dict[TelemetryScope, TelemetryStore] = {}
|
|
140
145
|
self._upload_on_exit = False
|
|
146
|
+
self.minimal = minimal
|
|
141
147
|
|
|
142
148
|
# create the PM store
|
|
143
149
|
self.init_store(TelemetryScope(None))
|
|
@@ -154,6 +160,8 @@ class TelemetryProvider:
|
|
|
154
160
|
|
|
155
161
|
@property
|
|
156
162
|
def upload_consent_time(self) -> datetime.datetime | None:
|
|
163
|
+
if self.minimal or self.local_mode:
|
|
164
|
+
return None
|
|
157
165
|
return self._gc.telemetry_upload_consent_time
|
|
158
166
|
|
|
159
167
|
def store(self, scope: TelemetryScope) -> TelemetryStore | None:
|
|
@@ -203,11 +211,18 @@ class TelemetryProvider:
|
|
|
203
211
|
def installation_file(self) -> pathlib.Path:
|
|
204
212
|
return self.state_root / "installation.json"
|
|
205
213
|
|
|
214
|
+
@property
|
|
215
|
+
def minimal_installation_marker_file(self) -> pathlib.Path:
|
|
216
|
+
return self.state_root / "minimal-installation-marker"
|
|
217
|
+
|
|
206
218
|
def check_first_run_status(self) -> None:
|
|
207
219
|
"""Check if this is the first run of the application by checking if installation file exists.
|
|
208
220
|
This must be done before init_installation() is potentially called.
|
|
209
221
|
"""
|
|
210
|
-
self._is_first_run =
|
|
222
|
+
self._is_first_run = (
|
|
223
|
+
not self.installation_file.exists()
|
|
224
|
+
and not self.minimal_installation_marker_file.exists()
|
|
225
|
+
)
|
|
211
226
|
|
|
212
227
|
@property
|
|
213
228
|
def is_first_run(self) -> bool:
|
|
@@ -215,9 +230,15 @@ class TelemetryProvider:
|
|
|
215
230
|
return self._is_first_run
|
|
216
231
|
|
|
217
232
|
def init_installation(self, force_reinit: bool) -> NodeInfo | None:
|
|
233
|
+
if self.minimal:
|
|
234
|
+
# be extra safe by not reading or writing installation data at all
|
|
235
|
+
# in minimal mode
|
|
236
|
+
self._init_minimal_installation_marker(force_reinit)
|
|
237
|
+
return None
|
|
238
|
+
|
|
218
239
|
installation_file = self.installation_file
|
|
219
240
|
if installation_file.exists() and not force_reinit:
|
|
220
|
-
return self.
|
|
241
|
+
return self._read_installation_data()
|
|
221
242
|
|
|
222
243
|
# either this is a fresh installation or we're forcing a refresh
|
|
223
244
|
installation_id = uuid.uuid4()
|
|
@@ -232,13 +253,26 @@ class TelemetryProvider:
|
|
|
232
253
|
fp.write(json.dumps(installation_data).encode("utf-8"))
|
|
233
254
|
return installation_data
|
|
234
255
|
|
|
235
|
-
def
|
|
256
|
+
def _init_minimal_installation_marker(self, force_reinit: bool) -> None:
|
|
257
|
+
if self.minimal_installation_marker_file.exists() and not force_reinit:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
self.logger.D("initializing minimal installation marker file")
|
|
261
|
+
self.state_root.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
|
|
263
|
+
# just touch the file
|
|
264
|
+
self.minimal_installation_marker_file.touch()
|
|
265
|
+
|
|
266
|
+
def _read_installation_data(self) -> NodeInfo | None:
|
|
236
267
|
with open(self.installation_file, "rb") as fp:
|
|
237
268
|
return cast(NodeInfo, json.load(fp))
|
|
238
269
|
|
|
239
|
-
def
|
|
270
|
+
def _upload_weekday(self) -> int | None:
|
|
271
|
+
if self.minimal:
|
|
272
|
+
return None
|
|
273
|
+
|
|
240
274
|
try:
|
|
241
|
-
installation_data = self.
|
|
275
|
+
installation_data = self._read_installation_data()
|
|
242
276
|
except FileNotFoundError:
|
|
243
277
|
# init the node info if it's gone
|
|
244
278
|
installation_data = self.init_installation(False)
|
|
@@ -253,55 +287,29 @@ class TelemetryProvider:
|
|
|
253
287
|
|
|
254
288
|
return report_uuid_prefix % 7 # 0 is Monday
|
|
255
289
|
|
|
256
|
-
def
|
|
290
|
+
def _has_upload_consent(self, time_now: float | None = None) -> bool:
|
|
257
291
|
if self.upload_consent_time is None:
|
|
258
292
|
return False
|
|
259
293
|
if time_now is None:
|
|
260
294
|
time_now = time.time()
|
|
261
295
|
return self.upload_consent_time.timestamp() <= time_now
|
|
262
296
|
|
|
263
|
-
def
|
|
264
|
-
if self.local_mode:
|
|
265
|
-
if for_cli_verbose_output:
|
|
266
|
-
self.logger.I(
|
|
267
|
-
"telemetry mode is [green]local[/]: local data collection only, no uploads"
|
|
268
|
-
)
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
now = time.time()
|
|
272
|
-
if self.has_upload_consent(now) and not for_cli_verbose_output:
|
|
273
|
-
self.logger.D("user has consented to telemetry upload")
|
|
274
|
-
return
|
|
275
|
-
|
|
276
|
-
upload_wday = self.upload_weekday()
|
|
277
|
-
if upload_wday is None:
|
|
278
|
-
return
|
|
279
|
-
upload_wday_name = calendar.day_name[upload_wday]
|
|
280
|
-
|
|
297
|
+
def _print_upload_schedule_notice(self, upload_wday: int, now: float) -> None:
|
|
281
298
|
next_upload_day_ts = next_utc_weekday(upload_wday, now)
|
|
282
299
|
next_upload_day = time.localtime(next_upload_day_ts)
|
|
283
300
|
next_upload_day_end = time.localtime(next_upload_day_ts + 86400)
|
|
284
301
|
next_upload_day_str = time.strftime("%Y-%m-%d %H:%M:%S %z", next_upload_day)
|
|
285
302
|
next_upload_day_end_str = time.strftime(
|
|
286
|
-
"%Y-%m-%d %H:%M:%S %z",
|
|
303
|
+
"%Y-%m-%d %H:%M:%S %z",
|
|
304
|
+
next_upload_day_end,
|
|
287
305
|
)
|
|
288
306
|
|
|
289
|
-
|
|
290
|
-
if for_cli_verbose_output:
|
|
291
|
-
self.logger.I(
|
|
292
|
-
"telemetry mode is [green]on[/]: data is collected and periodically uploaded"
|
|
293
|
-
)
|
|
294
|
-
self.logger.I(
|
|
295
|
-
f"non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {upload_wday_name}[/]"
|
|
296
|
-
)
|
|
297
|
-
else:
|
|
298
|
-
self.logger.W(
|
|
299
|
-
f"this [yellow]ruyi[/] installation has telemetry mode set to [yellow]on[/], and [bold]will upload non-tracking usage information to RuyiSDK-managed servers[/] [bold green]every {upload_wday_name}[/]"
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
if today_is_upload_day:
|
|
307
|
+
if self._is_upload_day(now):
|
|
303
308
|
for scope, store in self._stores.items():
|
|
304
|
-
has_uploaded_today = self.
|
|
309
|
+
has_uploaded_today = self._has_uploaded_today(
|
|
310
|
+
store.last_upload_timestamp,
|
|
311
|
+
now,
|
|
312
|
+
)
|
|
305
313
|
if has_uploaded_today:
|
|
306
314
|
if last_upload_time := store.last_upload_timestamp:
|
|
307
315
|
last_upload_time_str = time.strftime(
|
|
@@ -320,53 +328,165 @@ class TelemetryProvider:
|
|
|
320
328
|
)
|
|
321
329
|
else:
|
|
322
330
|
self.logger.I(
|
|
323
|
-
|
|
331
|
+
"the next upload will happen anytime [yellow]ruyi[/] is executed:"
|
|
324
332
|
)
|
|
333
|
+
self.logger.I(
|
|
334
|
+
f" - between [bold green]{next_upload_day_str}[/] and [bold green]{next_upload_day_end_str}[/]"
|
|
335
|
+
)
|
|
336
|
+
self.logger.I(" - or if the last upload is more than a week ago")
|
|
337
|
+
|
|
338
|
+
def print_telemetry_notice(self, for_cli_verbose_output: bool = False) -> None:
|
|
339
|
+
if self.minimal:
|
|
340
|
+
if for_cli_verbose_output:
|
|
341
|
+
self.logger.I(
|
|
342
|
+
"telemetry mode is [green]off[/]: nothing is collected or uploaded after the first run"
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
now = time.time()
|
|
347
|
+
upload_wday = self._upload_weekday()
|
|
348
|
+
if upload_wday is None:
|
|
349
|
+
if for_cli_verbose_output:
|
|
350
|
+
self.logger.W(MALFORMED_TELEMETRY_STATE_MSG)
|
|
351
|
+
else:
|
|
352
|
+
self.logger.D(MALFORMED_TELEMETRY_STATE_MSG)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
upload_wday_name = calendar.day_name[upload_wday]
|
|
356
|
+
|
|
357
|
+
if self.local_mode:
|
|
358
|
+
if for_cli_verbose_output:
|
|
359
|
+
self.logger.I(
|
|
360
|
+
"telemetry mode is [green]local[/]: local usage collection only, no usage uploads except if requested"
|
|
361
|
+
)
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
if self._has_upload_consent(now) and not for_cli_verbose_output:
|
|
365
|
+
self.logger.D("user has consented to telemetry upload")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
if for_cli_verbose_output:
|
|
369
|
+
self.logger.I(
|
|
370
|
+
"telemetry mode is [green]on[/]: usage data is collected and periodically uploaded"
|
|
371
|
+
)
|
|
372
|
+
self.logger.I(
|
|
373
|
+
f"non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {upload_wday_name}[/]"
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
self.logger.W(
|
|
377
|
+
f"this [yellow]ruyi[/] installation has telemetry mode set to [yellow]on[/], and [bold]will upload non-tracking usage information to RuyiSDK-managed servers[/] [bold green]every {upload_wday_name}[/]"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
self._print_upload_schedule_notice(upload_wday, now)
|
|
325
381
|
|
|
326
382
|
if not for_cli_verbose_output:
|
|
327
383
|
self.logger.I("in order to hide this banner:")
|
|
328
384
|
self.logger.I("- opt out with [yellow]ruyi telemetry optout[/]")
|
|
329
385
|
self.logger.I("- or give consent with [yellow]ruyi telemetry consent[/]")
|
|
330
386
|
|
|
331
|
-
def
|
|
332
|
-
upload_wday = self.
|
|
387
|
+
def _next_upload_day(self, time_now: float | None = None) -> int | None:
|
|
388
|
+
upload_wday = self._upload_weekday()
|
|
333
389
|
if upload_wday is None:
|
|
334
390
|
return None
|
|
335
391
|
return next_utc_weekday(upload_wday, time_now)
|
|
336
392
|
|
|
337
|
-
def
|
|
393
|
+
def _is_upload_day(self, time_now: float | None = None) -> bool:
|
|
338
394
|
if time_now is None:
|
|
339
395
|
time_now = time.time()
|
|
340
|
-
if upload_day := self.
|
|
396
|
+
if upload_day := self._next_upload_day(time_now):
|
|
341
397
|
return upload_day <= time_now
|
|
342
398
|
return False
|
|
343
399
|
|
|
344
|
-
def
|
|
400
|
+
def _has_uploaded_today(
|
|
345
401
|
self,
|
|
346
|
-
|
|
402
|
+
last_upload_time: float | None,
|
|
347
403
|
time_now: float | None = None,
|
|
348
404
|
) -> bool:
|
|
349
405
|
if time_now is None:
|
|
350
406
|
time_now = time.time()
|
|
351
|
-
if upload_day := self.
|
|
407
|
+
if upload_day := self._next_upload_day(time_now):
|
|
352
408
|
upload_day_end = upload_day + 86400
|
|
353
|
-
|
|
354
|
-
if store is None:
|
|
355
|
-
return False
|
|
356
|
-
if last_upload_time := store.last_upload_timestamp:
|
|
409
|
+
if last_upload_time is not None:
|
|
357
410
|
return upload_day <= last_upload_time < upload_day_end
|
|
358
411
|
return False
|
|
359
412
|
|
|
360
413
|
def record(self, scope: TelemetryScope, kind: str, **params: object) -> None:
|
|
414
|
+
if self.minimal:
|
|
415
|
+
self.logger.D(
|
|
416
|
+
f"minimal telemetry mode enabled, discarding event '{kind}' for scope {scope}"
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
|
|
361
420
|
if store := self.store(scope):
|
|
362
421
|
return store.record(kind, **params)
|
|
363
|
-
self.logger.D(
|
|
422
|
+
self.logger.D(
|
|
423
|
+
f"no telemetry store for scope {scope}, discarding event '{kind}'"
|
|
424
|
+
)
|
|
364
425
|
|
|
365
426
|
def discard_events(self, v: bool = True) -> None:
|
|
366
427
|
self._discard_events = v
|
|
367
428
|
|
|
368
|
-
def
|
|
369
|
-
|
|
429
|
+
def _should_proceed_with_upload(
|
|
430
|
+
self,
|
|
431
|
+
scope: TelemetryScope,
|
|
432
|
+
explicit_request: bool,
|
|
433
|
+
cron_mode: bool,
|
|
434
|
+
now: float,
|
|
435
|
+
) -> tuple[bool, str]:
|
|
436
|
+
# proceed to uploading if forced (explicit requested or _upload_on_exit)
|
|
437
|
+
# regardless of schedule
|
|
438
|
+
if explicit_request:
|
|
439
|
+
return True, "explicit request"
|
|
440
|
+
if self._upload_on_exit:
|
|
441
|
+
return True, "first-run upload on exit"
|
|
442
|
+
|
|
443
|
+
# this is not an explicitly requested upload, so only proceed if today
|
|
444
|
+
# is the day, or if the last upload is more than a week ago
|
|
445
|
+
#
|
|
446
|
+
# the last-upload-more-than-a-week-ago check is to avoid situations
|
|
447
|
+
# where the user has not run ruyi for a long time, thus missing
|
|
448
|
+
# the scheduled upload day.
|
|
449
|
+
#
|
|
450
|
+
# cron jobs are a mitigation, but we cannot rely on them either, because:
|
|
451
|
+
#
|
|
452
|
+
# * ruyi is more likely installed user-locally than system-wide, so
|
|
453
|
+
# users may not set up cron jobs for themselves;
|
|
454
|
+
# * telemetry data is always recorded per user so system-wide cron jobs
|
|
455
|
+
# cannot easily access this data.
|
|
456
|
+
last_upload_time: float | None = None
|
|
457
|
+
if store := self.store(scope):
|
|
458
|
+
last_upload_time = store.last_upload_timestamp
|
|
459
|
+
|
|
460
|
+
if not self._is_upload_day(now):
|
|
461
|
+
if last_upload_time is not None and now - last_upload_time >= 7 * 86400:
|
|
462
|
+
return True, "last upload more than a week ago"
|
|
463
|
+
return False, "not upload day"
|
|
464
|
+
# now we're sure today is the day
|
|
465
|
+
|
|
466
|
+
# if we're in cron mode, proceed as if it's an explicit request;
|
|
467
|
+
# otherwise, only proceed if mode is "on" and we haven't uploaded yet today
|
|
468
|
+
# for this scope
|
|
469
|
+
if cron_mode:
|
|
470
|
+
return True, "cron mode upload on upload day"
|
|
471
|
+
|
|
472
|
+
if self._gc.telemetry_mode != "on":
|
|
473
|
+
return False, "telemetry mode not 'on'"
|
|
474
|
+
|
|
475
|
+
if not self._has_uploaded_today(last_upload_time, now):
|
|
476
|
+
return True, "upload day, not yet uploaded today"
|
|
477
|
+
return False, "upload day, already uploaded today"
|
|
478
|
+
|
|
479
|
+
def flush(self, *, upload_now: bool = False, cron_mode: bool = False) -> None:
|
|
480
|
+
"""
|
|
481
|
+
Flush collected telemetry data to persistent store, and upload if needed.
|
|
482
|
+
|
|
483
|
+
:param upload_now: Upload data right now regardless of schedule.
|
|
484
|
+
:type upload_now: bool
|
|
485
|
+
:param cron_mode: Whether this flush is called from a cron job. If true,
|
|
486
|
+
non-upload-day uploads will be skipped, otherwise acts just like
|
|
487
|
+
explicit uploads via `ruyi telemetry upload`.
|
|
488
|
+
:type cron_mode: bool
|
|
489
|
+
"""
|
|
370
490
|
|
|
371
491
|
# We may be self-uninstalling and purging all state data, and in this
|
|
372
492
|
# case we don't want to record anything (thus re-creating directories).
|
|
@@ -374,31 +494,50 @@ class TelemetryProvider:
|
|
|
374
494
|
self.logger.D("discarding collected telemetry data")
|
|
375
495
|
return
|
|
376
496
|
|
|
377
|
-
|
|
497
|
+
now = time.time()
|
|
498
|
+
|
|
499
|
+
def should_proceed(scope: TelemetryScope) -> tuple[bool, str]:
|
|
500
|
+
return self._should_proceed_with_upload(
|
|
501
|
+
scope,
|
|
502
|
+
explicit_request=upload_now,
|
|
503
|
+
cron_mode=cron_mode,
|
|
504
|
+
now=now,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if self.minimal:
|
|
508
|
+
if not self._upload_on_exit:
|
|
509
|
+
self.logger.D("skipping upload for non-first-run in minimal mode")
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
for scope, store in self._stores.items():
|
|
513
|
+
go_ahead, reason = should_proceed(scope)
|
|
514
|
+
self.logger.D(
|
|
515
|
+
f"minimal telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}"
|
|
516
|
+
)
|
|
517
|
+
if not go_ahead:
|
|
518
|
+
continue
|
|
519
|
+
store.upload_minimal()
|
|
520
|
+
return
|
|
378
521
|
|
|
379
522
|
for scope, store in self._stores.items():
|
|
523
|
+
self.logger.D(f"flushing telemetry to persistent store for scope {scope}")
|
|
380
524
|
store.persist(now)
|
|
381
525
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if not (upload_now or self._upload_on_exit) and (
|
|
388
|
-
self.local_mode
|
|
389
|
-
or not self.is_upload_day(now)
|
|
390
|
-
or self.has_uploaded_today(scope, now)
|
|
391
|
-
):
|
|
526
|
+
go_ahead, reason = should_proceed(scope)
|
|
527
|
+
self.logger.D(
|
|
528
|
+
f"regular telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}"
|
|
529
|
+
)
|
|
530
|
+
if not go_ahead:
|
|
392
531
|
continue
|
|
393
532
|
|
|
394
|
-
self.
|
|
395
|
-
|
|
533
|
+
self._prepare_data_for_upload(store)
|
|
534
|
+
store.upload_staged_payloads()
|
|
396
535
|
|
|
397
|
-
def
|
|
536
|
+
def _prepare_data_for_upload(self, store: TelemetryStore) -> None:
|
|
398
537
|
installation_data: NodeInfo | None = None
|
|
399
538
|
if store.scope.is_pm:
|
|
400
539
|
try:
|
|
401
|
-
installation_data = self.
|
|
540
|
+
installation_data = self._read_installation_data()
|
|
402
541
|
except FileNotFoundError:
|
|
403
542
|
# should not happen due to is_upload_day() initializing it for us
|
|
404
543
|
# beforehand, but proceed without node info nonetheless
|
|
@@ -406,16 +545,20 @@ class TelemetryProvider:
|
|
|
406
545
|
|
|
407
546
|
return store.prepare_data_for_upload(installation_data)
|
|
408
547
|
|
|
409
|
-
def upload_staged_payloads(self, store: TelemetryStore) -> None:
|
|
410
|
-
if self.local_mode:
|
|
411
|
-
return
|
|
412
|
-
|
|
413
|
-
return store.upload_staged_payloads()
|
|
414
|
-
|
|
415
548
|
def oobe_prompt(self) -> None:
|
|
416
549
|
"""Ask whether the user consents to a first-run telemetry upload, and
|
|
417
550
|
persist the user's exact telemetry choice."""
|
|
418
551
|
|
|
552
|
+
if self._gc.is_telemetry_optout:
|
|
553
|
+
# user has already explicitly opted out via the environment variable,
|
|
554
|
+
# don't bother asking
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
# We always report installation info on first run, regardless of
|
|
558
|
+
# user's telemetry choice. In case the user opts out, only do a one-time
|
|
559
|
+
# upload now, and never upload anything again.
|
|
560
|
+
self._upload_on_exit = True
|
|
561
|
+
|
|
419
562
|
from ..cli import user_input
|
|
420
563
|
|
|
421
564
|
self.logger.stdout(TELEMETRY_CONSENT_AND_UPLOAD_DESC)
|
|
@@ -441,4 +584,3 @@ class TelemetryProvider:
|
|
|
441
584
|
|
|
442
585
|
consent_time = datetime.datetime.now().astimezone()
|
|
443
586
|
set_telemetry_mode(self._gc, "on", consent_time)
|
|
444
|
-
self._upload_on_exit = True
|
|
@@ -85,6 +85,11 @@ class TelemetryStore:
|
|
|
85
85
|
def record_upload_timestamp(self, time_now: float | None = None) -> None:
|
|
86
86
|
if time_now is None:
|
|
87
87
|
time_now = time.time()
|
|
88
|
+
|
|
89
|
+
# We may not have store_root existing yet if we're in minimal telemetry
|
|
90
|
+
# mode
|
|
91
|
+
self.store_root.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
88
93
|
f = self.last_upload_marker_file
|
|
89
94
|
f.touch()
|
|
90
95
|
os.utime(f, (time_now, time_now))
|
|
@@ -122,10 +127,6 @@ class TelemetryStore:
|
|
|
122
127
|
f"scope {self.scope}: persisted {len(self._events)} telemetry event(s)"
|
|
123
128
|
)
|
|
124
129
|
|
|
125
|
-
def upload(self, installation_data: NodeInfo | None = None) -> None:
|
|
126
|
-
self.prepare_data_for_upload(installation_data)
|
|
127
|
-
self.upload_staged_payloads()
|
|
128
|
-
|
|
129
130
|
def read_back_raw_events(self) -> Iterable[TelemetryEvent]:
|
|
130
131
|
try:
|
|
131
132
|
for f in self.raw_events_dir.glob("run.*.ndjson"):
|
|
@@ -177,6 +178,47 @@ class TelemetryStore:
|
|
|
177
178
|
|
|
178
179
|
self.purge_raw_events()
|
|
179
180
|
|
|
181
|
+
def prepare_data_for_minimal_upload(self) -> bytes:
|
|
182
|
+
"""Prepare a minimal upload payload with no installation data and no events.
|
|
183
|
+
|
|
184
|
+
Used when user has not consented to telemetry collection but also not
|
|
185
|
+
explicitly opted out, for gaining minimal insight into adoption.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
# import ruyi.version here because this package is on the CLI startup
|
|
189
|
+
# critical path, and version probing is costly there
|
|
190
|
+
from ..version import RUYI_SEMVER
|
|
191
|
+
|
|
192
|
+
payload_nonce = uuid.uuid4().hex # for server-side dedup purposes
|
|
193
|
+
|
|
194
|
+
# We don't have installation data, and cannot have it initialized
|
|
195
|
+
# in this case because absence of installation data means no user
|
|
196
|
+
# consent. And making up a persistent installation ID is not a
|
|
197
|
+
# choice either, because "installation ID" resembles "advertising
|
|
198
|
+
# ID" a lot, which is considered personally identifiable information
|
|
199
|
+
# (PII) and not allowed to be collected without user consent.
|
|
200
|
+
#
|
|
201
|
+
# So, resort to re-using the completely random nonce as the report
|
|
202
|
+
# UUID, which does not allow for server-side correlation but at least
|
|
203
|
+
# allows for some insight into end-user adoption.
|
|
204
|
+
payload: UploadPayload = {
|
|
205
|
+
"fmt": 1,
|
|
206
|
+
"nonce": payload_nonce,
|
|
207
|
+
"ruyi_version": RUYI_SEMVER,
|
|
208
|
+
"report_uuid": payload_nonce,
|
|
209
|
+
"events": [],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return json.dumps(payload).encode("utf-8")
|
|
213
|
+
|
|
214
|
+
def upload_minimal(self) -> None:
|
|
215
|
+
if not self.api_url:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
p = self.prepare_data_for_minimal_upload()
|
|
219
|
+
self.upload_one_staged_payload(p, self.api_url)
|
|
220
|
+
self.record_upload_timestamp()
|
|
221
|
+
|
|
180
222
|
def upload_staged_payloads(self) -> None:
|
|
181
223
|
if not self.api_url:
|
|
182
224
|
return
|
|
@@ -198,7 +240,7 @@ class TelemetryStore:
|
|
|
198
240
|
|
|
199
241
|
def upload_one_staged_payload(
|
|
200
242
|
self,
|
|
201
|
-
f: pathlib.Path,
|
|
243
|
+
f: pathlib.Path | bytes,
|
|
202
244
|
endpoint: str,
|
|
203
245
|
) -> None:
|
|
204
246
|
# import ruyi.version here because this package is on the CLI startup
|
|
@@ -206,13 +248,23 @@ class TelemetryStore:
|
|
|
206
248
|
from ..version import RUYI_USER_AGENT
|
|
207
249
|
|
|
208
250
|
api_path = urljoin_for_sure(endpoint, "upload-v1")
|
|
209
|
-
|
|
251
|
+
|
|
252
|
+
if isinstance(f, pathlib.Path):
|
|
253
|
+
self._logger.D(
|
|
254
|
+
f"scope {self.scope}: about to upload payload {f} to {api_path}"
|
|
255
|
+
)
|
|
256
|
+
data = f.read_bytes()
|
|
257
|
+
else:
|
|
258
|
+
self._logger.D(
|
|
259
|
+
f"scope {self.scope}: about to upload in-memory payload to {api_path}"
|
|
260
|
+
)
|
|
261
|
+
data = f
|
|
210
262
|
|
|
211
263
|
import requests
|
|
212
264
|
|
|
213
265
|
resp = requests.post(
|
|
214
266
|
api_path,
|
|
215
|
-
data=
|
|
267
|
+
data=data,
|
|
216
268
|
headers={"User-Agent": RUYI_USER_AGENT},
|
|
217
269
|
allow_redirects=True,
|
|
218
270
|
timeout=5,
|
|
@@ -228,11 +280,12 @@ class TelemetryStore:
|
|
|
228
280
|
f"scope {self.scope}: telemetry upload ok: status code {resp.status_code}"
|
|
229
281
|
)
|
|
230
282
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
283
|
+
if isinstance(f, pathlib.Path):
|
|
284
|
+
# move to completed dir
|
|
285
|
+
# TODO: rotation
|
|
286
|
+
try:
|
|
287
|
+
f.rename(self.uploaded_dir / f.name)
|
|
288
|
+
except OSError as e:
|
|
289
|
+
self._logger.D(
|
|
290
|
+
f"scope {self.scope}: failed to move uploaded payload away: {e}"
|
|
291
|
+
)
|
|
@@ -13,12 +13,34 @@ if TYPE_CHECKING:
|
|
|
13
13
|
class TelemetryCommand(
|
|
14
14
|
RootCommand,
|
|
15
15
|
cmd="telemetry",
|
|
16
|
+
has_main=True,
|
|
16
17
|
has_subcommands=True,
|
|
17
18
|
help="Manage your telemetry preferences",
|
|
18
19
|
):
|
|
19
20
|
@classmethod
|
|
20
21
|
def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
|
|
21
|
-
|
|
22
|
+
# https://github.com/python/cpython/issues/67037 prevents the registration
|
|
23
|
+
# of undocumented subcommands, so a preferred usage of
|
|
24
|
+
# "ruyi telemetry cron-upload" is not possible right now.
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"--cron-upload",
|
|
27
|
+
action="store_true",
|
|
28
|
+
dest="cron_upload",
|
|
29
|
+
default=False,
|
|
30
|
+
help=argparse.SUPPRESS,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
|
|
35
|
+
cron_upload: bool = args.cron_upload
|
|
36
|
+
if not cron_upload:
|
|
37
|
+
args._parser.print_help() # pylint: disable=protected-access
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
# the rest are implementation of "--cron-upload"
|
|
41
|
+
|
|
42
|
+
cfg.telemetry.flush(cron_mode=True)
|
|
43
|
+
return 0
|
|
22
44
|
|
|
23
45
|
|
|
24
46
|
class TelemetryConsentCommand(
|
|
@@ -117,10 +139,6 @@ class TelemetryUploadCommand(
|
|
|
117
139
|
|
|
118
140
|
@classmethod
|
|
119
141
|
def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
|
|
120
|
-
if cfg.telemetry is None:
|
|
121
|
-
cfg.logger.W("telemetry is disabled, nothing to upload")
|
|
122
|
-
return 0
|
|
123
|
-
|
|
124
142
|
cfg.telemetry.flush(upload_now=True)
|
|
125
143
|
# disable the flush at program exit because we have just done that
|
|
126
144
|
cfg.telemetry.discard_events()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|