From 28387967866fe74b4fecbbca954612ad84988ba7 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 26 Oct 2026 11:48:52 +0000 Subject: [PATCH 1/2] feat(ENG-15367): add the usage event, install identity and first-run notice Add cloudsmith_cli/core/usage with the pieces that decide what a usage event contains, before anything sends it: - event.py: the only properties that can leave the machine, each with its type and the question it answers. Setting sources are sent by name only. - identity.py and state.py: a random install ID kept in telemetry.json on laptops; CI, containers and the Docker image get a new ID per run. - environment.py: CI provider and container detection. - notice.py: a one-time, versioned notice on stderr. - settings.py: developer switches (test project key and host, log mode). Nothing calls this code yet. Every read-modify-write of telemetry.json holds an inter-process lock, so two CLI runs cannot undo each other's changes, such as putting back an install ID that was just reset. The list properties (flags, flags_typed, envvars, aliases_used) must be lists of strings; anything else is left out of the event. Co-Authored-By: Claude Opus 5.5 --- cloudsmith_cli/conftest.py | 8 + .../core/tests/test_usage_environment.py | 50 ++++ cloudsmith_cli/core/tests/test_usage_event.py | 115 +++++++++ .../core/tests/test_usage_identity.py | 75 ++++++ .../core/tests/test_usage_notice.py | 24 ++ .../core/tests/test_usage_settings.py | 38 +++ cloudsmith_cli/core/tests/test_usage_state.py | 111 +++++++++ cloudsmith_cli/core/usage/__init__.py | 2 + cloudsmith_cli/core/usage/environment.py | 65 +++++ cloudsmith_cli/core/usage/event.py | 231 ++++++++++++++++++ cloudsmith_cli/core/usage/identity.py | 70 ++++++ cloudsmith_cli/core/usage/notice.py | 34 +++ cloudsmith_cli/core/usage/settings.py | 62 +++++ cloudsmith_cli/core/usage/state.py | 162 ++++++++++++ 14 files changed, 1047 insertions(+) create mode 100644 cloudsmith_cli/core/tests/test_usage_environment.py create mode 100644 cloudsmith_cli/core/tests/test_usage_event.py create mode 100644 cloudsmith_cli/core/tests/test_usage_identity.py create mode 100644 cloudsmith_cli/core/tests/test_usage_notice.py create mode 100644 cloudsmith_cli/core/tests/test_usage_settings.py create mode 100644 cloudsmith_cli/core/tests/test_usage_state.py create mode 100644 cloudsmith_cli/core/usage/__init__.py create mode 100644 cloudsmith_cli/core/usage/environment.py create mode 100644 cloudsmith_cli/core/usage/event.py create mode 100644 cloudsmith_cli/core/usage/identity.py create mode 100644 cloudsmith_cli/core/usage/notice.py create mode 100644 cloudsmith_cli/core/usage/settings.py create mode 100644 cloudsmith_cli/core/usage/state.py diff --git a/cloudsmith_cli/conftest.py b/cloudsmith_cli/conftest.py index 610db845..b3341fc5 100644 --- a/cloudsmith_cli/conftest.py +++ b/cloudsmith_cli/conftest.py @@ -97,3 +97,11 @@ def reset_wait_budget(): from cloudsmith_cli.core import telemetry_common telemetry_common.WaitBudget.shared().reset(1.0) + + +@pytest.fixture(autouse=True) +def usage_state_dir(monkeypatch, tmp_path_factory): + """Keep usage telemetry state out of the developer's real config dir.""" + path = tmp_path_factory.mktemp("usage-state") + monkeypatch.setattr("cloudsmith_cli.core.usage.state.config_dir", lambda: str(path)) + return path diff --git a/cloudsmith_cli/core/tests/test_usage_environment.py b/cloudsmith_cli/core/tests/test_usage_environment.py new file mode 100644 index 00000000..b99ed89b --- /dev/null +++ b/cloudsmith_cli/core/tests/test_usage_environment.py @@ -0,0 +1,50 @@ +import pytest + +from cloudsmith_cli.core.usage import environment + + +@pytest.mark.parametrize( + "env,expected", + [ + ({"GITHUB_ACTIONS": "true"}, "github_actions"), + ({"GITLAB_CI": "true"}, "gitlab"), + ({"JENKINS_URL": "http://j"}, "jenkins"), + ({"CIRCLECI": "true"}, "circleci"), + ({"BUILDKITE": "true"}, "buildkite"), + ({"TF_BUILD": "True"}, "azure_pipelines"), + ({"BITBUCKET_BUILD_NUMBER": "1"}, "bitbucket"), + ({"TEAMCITY_VERSION": "2024"}, "teamcity"), + ({"CODEBUILD_BUILD_ID": "x"}, "codebuild"), + ({"CI": "1"}, "other"), + ({}, None), + ], +) +def test_ci_provider_by_presence(env, expected): + assert environment.ci_provider(env) == expected + + +def test_ephemeral_rule(monkeypatch): + monkeypatch.setattr(environment, "is_container", lambda: False) + assert environment.is_ephemeral(env={"CI": "x"}, channel="pip", state_writable=True) + assert environment.is_ephemeral(env={}, channel="docker", state_writable=True) + assert environment.is_ephemeral(env={}, channel="pip", state_writable=False) + assert not environment.is_ephemeral(env={}, channel="pip", state_writable=True) + + +def test_non_tty_alone_is_not_ci(): + assert environment.persona(ci=False) == "human" + assert environment.persona(ci=True) == "ci" + + +def test_runtime_properties_truncate_versions(monkeypatch): + monkeypatch.setattr(environment.platform, "system", lambda: "Linux") + monkeypatch.setattr(environment.platform, "release", lambda: "6.8.0-1070-azure") + monkeypatch.setattr(environment.platform, "python_version", lambda: "3.12.7") + monkeypatch.setattr(environment.platform, "machine", lambda: "X86_64") + props = environment.runtime_properties() + assert props == { + "os": "Linux", + "os_version": "6.8", + "python_version": "3.12", + "arch": "x86_64", + } diff --git a/cloudsmith_cli/core/tests/test_usage_event.py b/cloudsmith_cli/core/tests/test_usage_event.py new file mode 100644 index 00000000..5bb2f6fc --- /dev/null +++ b/cloudsmith_cli/core/tests/test_usage_event.py @@ -0,0 +1,115 @@ +from typing import Any + +import pytest + +from cloudsmith_cli.core.usage import event +from cloudsmith_cli.core.usage.identity import Identity + + +def _facts(**overrides): + base: dict[str, Any] = dict( + command="list repos", + flags=["output_format", "page_size"], + flags_typed=["-F", "--page-size"], + envvars=["NO_COLOR"], + aliases_used=["ls"], + output_format="json", + outcome="success", + exit_code=0, + api_status=None, + duration_ms=708, + setting_sources={"workspace": "config_file", "color": "env"}, + auth_source="keyring", + api_host=None, + ssl_verify=True, + stdout_tty=False, + stdin_tty=False, + nested=False, + ) + base.update(overrides) + return event.RunFacts(**base) + + +def _build(facts, anonymous=True): + ident = Identity( + "id-1", anonymous=anonymous, first_run=False, persisted=not anonymous + ) + return event.build_event( + facts=facts, + identity=ident, + runtime={ + "os": "Darwin", + "os_version": "26.3", + "python_version": "3.12", + "arch": "arm64", + }, + channel="pip", + environment="production", + cli_version="1.31.0", + ci_provider=None, + sample_rate=100, + internal=False, + timestamp="2026-10-23T12:00:00+00:00", + ) + + +def test_only_declared_properties_are_sent(): + declared = {p.name for p in event.PROPERTIES} + props = _build(_facts())["properties"] + for key in props: + assert key in declared or ( + key.startswith("source_") + and key[len("source_") :] in event.ALLOWED_SETTINGS + ), key + + +def test_snapshot_of_a_typical_event(): + payload = _build(_facts()) + assert payload["event"] == "cli command executed" + assert payload["distinct_id"] == "id-1" + props = payload["properties"] + assert props["$process_person_profile"] is False + assert props["$geoip_disable"] is True + assert props["command"] == "list repos" + assert props["flags_typed"] == ["--page-size", "-F"] + assert props["source_workspace"] == "config_file" + assert props["persona"] == "human" + assert "api_status" not in props # None values are dropped + + +def test_install_id_event_creates_no_person_profile(): + # A stable install ID still never creates a person profile. + props = _build(_facts(), anonymous=False)["properties"] + assert props["$process_person_profile"] is False + + +def test_unknown_setting_or_source_is_dropped(): + props = _build( + _facts(setting_sources={"secret_thing": "env", "workspace": "weird"}) + )["properties"] + assert not any(k.startswith("source_") for k in props) + + +def test_wrong_type_is_dropped(): + props = _build(_facts(duration_ms="708"))["properties"] + assert "duration_ms" not in props + + +@pytest.mark.parametrize("name", ["flags", "flags_typed", "envvars", "aliases_used"]) +@pytest.mark.parametrize("bad", ["--page-size", None, ["ok", 3], {"a": 1}, 7]) +def test_malformed_name_lists_are_dropped_not_split(name, bad): + # A bare string must not be sent one character at a time, and None or + # non-string items must not raise. + props = _build(_facts(**{name: bad}))["properties"] + assert name not in props + + +def test_name_lists_are_deduplicated_and_sorted_but_aliases_keep_order(): + props = _build( + _facts( + flags=("page_size", "output_format", "page_size"), + aliases_used=["ls", "repos"], + ) + )["properties"] + assert props["flags"] == ["output_format", "page_size"] + assert props["aliases_used"] == ["ls", "repos"] diff --git a/cloudsmith_cli/core/tests/test_usage_identity.py b/cloudsmith_cli/core/tests/test_usage_identity.py new file mode 100644 index 00000000..10d3973c --- /dev/null +++ b/cloudsmith_cli/core/tests/test_usage_identity.py @@ -0,0 +1,75 @@ +import uuid + +import pytest + +from cloudsmith_cli.core.usage import identity, state + + +def _is_uuid(value): + return str(uuid.UUID(value)) == value + + +def test_default_is_anonymous_per_run_and_writes_nothing(usage_state_dir): + first = identity.resolve(ephemeral=False, install_id_enabled=False) + second = identity.resolve(ephemeral=False, install_id_enabled=False) + assert first.anonymous and second.anonymous + assert first.distinct_id != second.distinct_id and _is_uuid(first.distinct_id) + assert state.read() == {} + + +def test_ephemeral_never_persists_even_when_enabled(usage_state_dir): + ident = identity.resolve(ephemeral=True, install_id_enabled=True) + assert ident.anonymous and not ident.persisted + assert "install_id" not in state.read() + + +def test_install_id_persists_and_first_run_flag(usage_state_dir): + first = identity.resolve(ephemeral=False, install_id_enabled=True) + second = identity.resolve(ephemeral=False, install_id_enabled=True) + assert first.first_run and not second.first_run + assert first.distinct_id == second.distinct_id + assert not first.anonymous and first.persisted + assert state.read()["install_id"] == first.distinct_id + + +@pytest.mark.parametrize( + "stored", ["hello@example.com", "6F9619FF-8B86-D011-B42D-00CF4FC964FF", 7] +) +def test_a_stored_id_that_is_not_a_uuid_is_replaced(usage_state_dir, stored): + # A hand-edited telemetry.json must not put arbitrary text in distinct_id. + state.write({"install_id": stored, "notice_version": 1}) + ident = identity.resolve(ephemeral=False, install_id_enabled=True) + assert _is_uuid(ident.distinct_id) and ident.first_run and ident.persisted + assert state.read()["install_id"] == ident.distinct_id + assert state.read()["notice_version"] == 1 + + +def test_reset_deletes_the_id_and_keeps_other_state(usage_state_dir, monkeypatch): + monkeypatch.setenv("CLOUDSMITH_TELEMETRY_INSTALL_ID", "1") + old = identity.resolve(ephemeral=False, install_id_enabled=True).distinct_id + state.write({"notice_version": 1}) + identity.reset() + assert "install_id" not in state.read() + assert state.read()["notice_version"] == 1 + again = identity.resolve(ephemeral=False, install_id_enabled=True) + assert again.first_run and again.distinct_id != old + + +def test_reset_never_mints_an_id(usage_state_dir, monkeypatch): + monkeypatch.setenv("CLOUDSMITH_TELEMETRY_INSTALL_ID", "1") + identity.reset() + assert state.read() == {} + + +def test_reset_raises_when_delete_fails(usage_state_dir, monkeypatch): + identity.resolve(ephemeral=False, install_id_enabled=True) + monkeypatch.setattr(state, "delete", lambda: False) + with pytest.raises(identity.IdentityResetError): + identity.reset() + + +def test_reset_raises_when_restoring_other_state_fails(usage_state_dir, monkeypatch): + state.write({"install_id": "x", "notice_version": 1}) + monkeypatch.setattr(state, "write", lambda updates: False) + with pytest.raises(identity.IdentityResetError): + identity.reset() diff --git a/cloudsmith_cli/core/tests/test_usage_notice.py b/cloudsmith_cli/core/tests/test_usage_notice.py new file mode 100644 index 00000000..dd034cd0 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_usage_notice.py @@ -0,0 +1,24 @@ +from cloudsmith_cli.core.usage import notice, state + + +def test_needs_notice_until_current_version_recorded(usage_state_dir): + assert notice.needs_notice() is True + state.write({"notice_version": notice.NOTICE_VERSION}) + assert notice.needs_notice() is False + state.write({"notice_version": notice.NOTICE_VERSION - 1}) + assert notice.needs_notice() is True + + +def test_show_prints_to_stderr_only(usage_state_dir, capsys): + assert notice.show() is True + out, err = capsys.readouterr() + assert out == "" + assert "usage data" in err and "CLOUDSMITH_NO_TELEMETRY" in err + # The notice names the install ID and how to see and reset it. + assert "install ID" in err + assert "cloudsmith telemetry id" in err and "cloudsmith telemetry reset" in err + assert state.read()["notice_version"] == notice.NOTICE_VERSION + + +def test_notice_version_bumped_for_the_install_id(): + assert notice.NOTICE_VERSION >= 2 diff --git a/cloudsmith_cli/core/tests/test_usage_settings.py b/cloudsmith_cli/core/tests/test_usage_settings.py new file mode 100644 index 00000000..c8df14c7 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_usage_settings.py @@ -0,0 +1,38 @@ +import pytest + +from cloudsmith_cli.core.usage import settings + + +def test_no_production_key_and_override_wins(): + assert settings.POSTHOG_PROJECT_KEY == "" + assert settings.project_key({}) == "" + assert settings.override_set({}) is False + env = {settings.KEY_ENV: "phc_test", settings.HOST_ENV: "https://eu.i.posthog.com"} + assert settings.project_key(env) == "phc_test" + assert settings.override_set(env) is True + + +def test_flags_parse_truthy_values_only(): + assert settings.log_mode({settings.MODE_ENV: "log"}) is True + assert settings.log_mode({settings.MODE_ENV: "1"}) is False + assert settings.internal({settings.INTERNAL_ENV: "true"}) is True + assert settings.internal({settings.INTERNAL_ENV: "0"}) is False + assert settings.install_id_enabled({settings.INSTALL_ID_ENV: "yes"}) is True + + +@pytest.mark.parametrize("value", ["", "1", "true", "yes", "anything"]) +def test_install_id_is_on_by_default(value): + env = {settings.INSTALL_ID_ENV: value} if value else {} + assert settings.install_id_enabled(env) is True + + +@pytest.mark.parametrize("value", ["0", "false", "no", "off", " FALSE "]) +def test_install_id_off_switch(value): + assert settings.install_id_enabled({settings.INSTALL_ID_ENV: value}) is False + + +def test_ci_sample_rate_defaults_to_100_and_clamps(): + assert settings.ci_sample_rate({}) == 100 + assert settings.ci_sample_rate({settings.SAMPLE_RATE_ENV: "10"}) == 10 + assert settings.ci_sample_rate({settings.SAMPLE_RATE_ENV: "500"}) == 100 + assert settings.ci_sample_rate({settings.SAMPLE_RATE_ENV: "x"}) == 100 diff --git a/cloudsmith_cli/core/tests/test_usage_state.py b/cloudsmith_cli/core/tests/test_usage_state.py new file mode 100644 index 00000000..b3737a28 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_usage_state.py @@ -0,0 +1,111 @@ +import json +import os + +from cloudsmith_cli.core.usage import state + + +def test_read_missing_or_corrupt_is_empty(usage_state_dir): + assert state.read() == {} + with open(state.state_path(), "w") as f: + f.write("{not json") + assert state.read() == {} + + +def test_write_merges_and_uses_0600(usage_state_dir): + assert state.write({"notice_version": 1}) is True + assert state.write({"last_failure_at": 5.0}) is True + assert state.read() == {"notice_version": 1, "last_failure_at": 5.0} + assert oct(os.stat(state.state_path()).st_mode & 0o777) == "0o600" + with open(state.state_path()) as f: + assert json.load(f)["notice_version"] == 1 + + +def test_write_failure_returns_false(usage_state_dir, monkeypatch): + monkeypatch.setattr(state, "state_path", lambda: "/nonexistent/dir/telemetry.json") + monkeypatch.setattr( + os, "makedirs", lambda *a, **k: (_ for _ in ()).throw(OSError()) + ) + assert state.write({"x": 1}) is False + + +def test_writable_true_for_a_writable_dir(usage_state_dir): + assert state.writable() is True + assert not os.path.exists(state.state_path()) + + +def test_writable_walks_up_to_the_nearest_existing_dir(usage_state_dir, monkeypatch): + missing = os.path.join(str(usage_state_dir), "not", "yet") + monkeypatch.setattr(state, "config_dir", lambda: missing) + assert state.writable() is True + assert not os.path.exists(missing) + + +def test_writable_false_when_not_accessible(usage_state_dir, monkeypatch): + monkeypatch.setattr(os, "access", lambda *a, **k: False) + assert state.writable() is False + + +def _other_process(usage_state_dir, code): + """Run ``code`` in a second process using the same state dir. + + It prints ``ready`` just before the state call under test, then the + call's result. + """ + import subprocess + import sys + + script = ( + "from cloudsmith_cli.core.usage import identity, state\n" + f"state.config_dir = lambda: {str(usage_state_dir)!r}\n" + "print('ready', flush=True)\n" + f"print(repr({code}), flush=True)\n" + ) + return subprocess.Popen( + [sys.executable, "-c", script], stdout=subprocess.PIPE, text=True + ) + + +def test_a_concurrent_write_never_restores_a_reset_install_id(usage_state_dir): + import time + + from cloudsmith_cli.core.usage import identity + + state.write({"install_id": "a" * 8, "notice_version": 2}) + with state.locked() as ok: + assert ok + other = _other_process(usage_state_dir, "state.write({'last_failure_at': 1})") + assert other.stdout.readline().strip() == "ready" + time.sleep(0.05) # the other process is now waiting for the lock + identity.reset() + assert other.stdout.readline().strip() == "True" + other.wait(10) + # The other process read the file after the reset, not before it. + assert state.read() == {"notice_version": 2, "last_failure_at": 1} + + +def test_writes_give_up_rather_than_wait_on_a_held_lock(usage_state_dir, monkeypatch): + import pytest + + from cloudsmith_cli.core.usage import identity + + monkeypatch.setattr(state, "LOCK_TIMEOUT_SECONDS", 0.1) + handle = state._acquire() # held as if by another process + try: + assert state.write({"x": 1}) is False + assert ( + identity.resolve(ephemeral=False, install_id_enabled=True).persisted + is False + ) + with pytest.raises(identity.IdentityResetError): + identity.reset() + finally: + state._unlock(handle) + assert state.read() == {} + + +def test_locked_is_reentrant(usage_state_dir): + with state.locked() as outer: + with state.locked() as inner: + assert outer and inner + assert state.write({"x": 1}) is True + assert state.read() == {"x": 1} diff --git a/cloudsmith_cli/core/usage/__init__.py b/cloudsmith_cli/core/usage/__init__.py new file mode 100644 index 00000000..2513a25c --- /dev/null +++ b/cloudsmith_cli/core/usage/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Usage telemetry. Import submodules directly; this package imports nothing.""" diff --git a/cloudsmith_cli/core/usage/environment.py b/cloudsmith_cli/core/usage/environment.py new file mode 100644 index 00000000..ca7230df --- /dev/null +++ b/cloudsmith_cli/core/usage/environment.py @@ -0,0 +1,65 @@ +# Copyright 2026 Cloudsmith Ltd +"""Where the CLI is running: CI, containers, persona and runtime facts. + +CI providers are detected by the presence of their well-known variables; +their values are never read or sent. +""" + +import os +import platform +import re + +#: (provider, variables any of which marks it), checked in order. +_CI_PROVIDERS = ( + ("github_actions", ("GITHUB_ACTIONS",)), + ("gitlab", ("GITLAB_CI",)), + ("jenkins", ("JENKINS_URL", "JENKINS_HOME")), + ("circleci", ("CIRCLECI",)), + ("buildkite", ("BUILDKITE",)), + ("azure_pipelines", ("TF_BUILD",)), + ("bitbucket", ("BITBUCKET_BUILD_NUMBER",)), + ("teamcity", ("TEAMCITY_VERSION",)), + ("codebuild", ("CODEBUILD_BUILD_ID",)), + ("other", ("CI",)), +) + + +def ci_provider(env=None): + env = os.environ if env is None else env + for provider, names in _CI_PROVIDERS: + if any(name in env for name in names): + return provider + return None + + +def is_container(): + if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"): + return True + return "KUBERNETES_SERVICE_HOST" in os.environ + + +def is_ephemeral(*, env=None, channel, state_writable): + """Runs whose disk does not survive: no saved ID, no person profile.""" + if ci_provider(env) is not None: + return True + if channel == "docker" or not state_writable: + return True + return is_container() + + +def persona(*, ci): + return "ci" if ci else "human" + + +def _major_minor(version): + match = re.match(r"(\d+)\.(\d+)", version or "") + return f"{match.group(1)}.{match.group(2)}" if match else "unknown" + + +def runtime_properties(): + return { + "os": platform.system() or "unknown", + "os_version": _major_minor(platform.release()), + "python_version": _major_minor(platform.python_version()), + "arch": (platform.machine() or "unknown").lower(), + } diff --git a/cloudsmith_cli/core/usage/event.py b/cloudsmith_cli/core/usage/event.py new file mode 100644 index 00000000..caab878c --- /dev/null +++ b/cloudsmith_cli/core/usage/event.py @@ -0,0 +1,231 @@ +# Copyright 2026 Cloudsmith Ltd +"""The usage event: one declaration per property. + +To add a property: add a ``Property`` here with the question it answers, +fill it in ``_values``, and extend the tests. +Only names declared here can leave the machine, and only with their type. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field + +EVENT_NAME = "cli command executed" +SCHEMA_VERSION = 1 + +#: Settings whose *source* (never value) may be reported as ``source_``. +ALLOWED_SETTINGS = frozenset( + { + "api_headers", + "api_host", + "proxy", + "ssl_verify", + "ssl_verify_strict", + "api_user_agent", + "mcp_allowed_tools", + "mcp_allowed_tool_groups", + "oidc_audience", + "workspace", + "oidc_service_slug", + "oidc_detector_order", + "oidc_disabled_detectors", + "metadata_failure_mode", + "check_for_update", + "telemetry", + "api_key", + "profile", + "config_file_path", + "credentials_file_path", + "output_format", + "color", + } +) +#: Names that map onto a setting in ``ALLOWED_SETTINGS``: config.ini keys +#: (``config_sources``) and Click parameter names (``usage_capture``). +SETTING_ALIASES = { + "api_proxy": "proxy", + "api_ssl_verify": "ssl_verify", + "org": "workspace", + "organization": "workspace", + "oidc_org": "workspace", + "config_file": "config_file_path", + "credentials_file": "credentials_file_path", + "without_api_ssl_verify": "ssl_verify", +} +SOURCE_VALUES = frozenset( + { + "flag", + "env", + "config_file", + "config_profile", + "credentials_file", + "credentials_profile", + } +) + + +@dataclass +class RunFacts: + command: str + flags: list[str] = field(default_factory=list) + flags_typed: list[str] = field(default_factory=list) + envvars: list[str] = field(default_factory=list) + aliases_used: list[str] = field(default_factory=list) + output_format: str | None = None + outcome: str = "success" + exit_code: int = 0 + api_status: int | None = None + duration_ms: int = 0 + setting_sources: dict[str, str] = field(default_factory=dict) + auth_source: str | None = None + api_host: str | None = None + ssl_verify: bool = True + stdout_tty: bool = False + stdin_tty: bool = False + nested: bool = False + #: A typed ``--config-file``/``--profile`` the command never loaded; the + #: run sends nothing. Not an event property. + config_unresolved: bool = False + #: Loads config-file setting sources after the telemetry gates, so a run + #: that sends nothing does no file I/O. Not an event property. + deferred_sources: Callable[[], dict[str, str]] | None = None + + +@dataclass(frozen=True) +class Property: + name: str + kind: type + #: The question the property answers. + purpose: str + + +PROPERTIES = ( + Property("command", str, "command and flag use; persona; reliability and speed"), + Property("flags", list, "command and flag use; configuration habits"), + Property("flags_typed", list, "command and flag use"), + Property("envvars", list, "configuration habits; login methods"), + Property("aliases_used", list, "command and flag use"), + Property("output_format", str, "command and flag use; persona"), + Property("outcome", str, "reliability and speed"), + Property("exit_code", int, "reliability and speed"), + Property("api_status", int, "reliability and speed"), + Property("duration_ms", int, "reliability and speed"), + Property("auth_source", str, "configuration habits; login methods"), + Property("cli_version", str, "version adoption"), + Property("channel", str, "dimension"), + Property("environment", str, "data quality"), + Property("os", str, "dimension"), + Property("os_version", str, "dimension"), + Property("python_version", str, "support policy"), + Property("arch", str, "dimension"), + Property("persona", str, "persona"), + Property("ci", bool, "persona"), + Property("ci_provider", str, "persona"), + Property("stdout_tty", bool, "persona"), + Property("first_run", bool, "onboarding and retention"), + Property("install_id_persisted", bool, "data quality"), + Property("sample_rate", int, "cost"), + Property("internal", bool, "data quality"), + Property("schema_version", int, "maintenance"), + Property("$geoip_disable", bool, "privacy"), + Property("$process_person_profile", bool, "privacy; cost"), +) + + +def _names(value, *, ordered=False): + """Normalise a collection of names, or return None so it is left out. + + Anything but a list, tuple or set of strings is malformed: a bare string + would otherwise be sent one character at a time. + """ + if not isinstance(value, (list, tuple, set, frozenset)): + return None + if not all(isinstance(item, str) for item in value): + return None + return list(value) if ordered else sorted(set(value)) + + +def _values( + facts, + identity, + runtime, + channel, + environment, + cli_version, + ci_provider, + sample_rate, + internal, +): + return { + "command": facts.command, + "flags": _names(facts.flags), + "flags_typed": _names(facts.flags_typed), + "envvars": _names(facts.envvars), + "aliases_used": _names(facts.aliases_used, ordered=True), + "output_format": facts.output_format, + "outcome": facts.outcome, + "exit_code": facts.exit_code, + "api_status": facts.api_status, + "duration_ms": facts.duration_ms, + "auth_source": facts.auth_source, + "cli_version": cli_version, + "channel": channel, + "environment": environment, + "persona": "ci" if ci_provider else "human", + "ci": ci_provider is not None, + "ci_provider": ci_provider, + "stdout_tty": facts.stdout_tty, + "first_run": identity.first_run, + "install_id_persisted": identity.persisted, + "sample_rate": sample_rate, + "internal": internal or None, + "schema_version": SCHEMA_VERSION, + "$geoip_disable": True, + # Never a person profile, install ID or not. + "$process_person_profile": False, + **runtime, + } + + +def build_event( + *, + facts, + identity, + runtime, + channel, + environment, + cli_version, + ci_provider, + sample_rate, + internal, + timestamp, +): + values = _values( + facts, + identity, + runtime, + channel, + environment, + cli_version, + ci_provider, + sample_rate, + internal, + ) + properties = {} + for prop in PROPERTIES: + value = values.get(prop.name) + if value is None: + continue + if not isinstance(value, prop.kind) or ( + prop.kind is int and isinstance(value, bool) + ): + continue + properties[prop.name] = value + for setting, source in sorted(facts.setting_sources.items()): + if setting in ALLOWED_SETTINGS and source in SOURCE_VALUES: + properties[f"source_{setting}"] = source + return { + "event": EVENT_NAME, + "distinct_id": identity.distinct_id, + "timestamp": timestamp, + "properties": properties, + } diff --git a/cloudsmith_cli/core/usage/identity.py b/cloudsmith_cli/core/usage/identity.py new file mode 100644 index 00000000..e78af0a2 --- /dev/null +++ b/cloudsmith_cli/core/usage/identity.py @@ -0,0 +1,70 @@ +# Copyright 2026 Cloudsmith Ltd +"""Which ``distinct_id`` an event carries.""" + +import time +import uuid +from dataclasses import dataclass + +from . import state + + +class IdentityResetError(Exception): + """The stored install ID could not be removed or other state restored.""" + + +@dataclass(frozen=True) +class Identity: + distinct_id: str + anonymous: bool + first_run: bool + persisted: bool + + +def _one_off(): + return Identity(str(uuid.uuid4()), anonymous=True, first_run=False, persisted=False) + + +def _is_install_id(value): + # Only the canonical form uuid4() writes; a hand-edited file must not put + # arbitrary text into distinct_id. + try: + return isinstance(value, str) and str(uuid.UUID(value)) == value + except ValueError: + return False + + +def resolve(*, ephemeral, install_id_enabled): + if ephemeral or not install_id_enabled: + return _one_off() + # Read and create under one lock, so two first runs agree on one ID. + with state.locked() as ok: + if not ok: + return _one_off() + current = state.read().get("install_id") + if _is_install_id(current): + return Identity(current, anonymous=False, first_run=False, persisted=True) + new_id = str(uuid.uuid4()) + if state.write({"install_id": new_id, "created_at": int(time.time())}): + return Identity(new_id, anonymous=False, first_run=True, persisted=True) + return _one_off() + + +def reset(): + """Delete the stored install ID. + + resolve() creates a new random one on the next run, so a deleted ID never + re-links. Holds the state lock throughout, so a concurrent run cannot + write its old copy of the file (and the ID) back. + """ + with state.locked() as ok: + if not ok: + raise IdentityResetError("telemetry state is locked by another process") + data = state.read() + data.pop("install_id", None) + data.pop("created_at", None) + if not state.delete(): + raise IdentityResetError("could not remove the stored install ID") + if data and not state.write(data): + raise IdentityResetError( + "removed the install ID but could not restore other telemetry state" + ) diff --git a/cloudsmith_cli/core/usage/notice.py b/cloudsmith_cli/core/usage/notice.py new file mode 100644 index 00000000..aaaac507 --- /dev/null +++ b/cloudsmith_cli/core/usage/notice.py @@ -0,0 +1,34 @@ +# Copyright 2026 Cloudsmith Ltd +"""The one-time usage telemetry notice. + +Bump ``NOTICE_VERSION`` whenever what is collected changes, so the notice is +shown again. The wording is a draft. +""" + +import click + +from . import state + +NOTICE_VERSION = 2 +NOTICE_TEXT = ( + "The Cloudsmith CLI collects pseudonymous usage data: which commands run, " + "how they end and how long they take, plus CLI, Python and OS versions. It " + "never collects argument values, file names, credentials or account " + "names. A random install ID is kept on this machine so repeat use can " + "be counted; see it with 'cloudsmith telemetry id' and replace it with " + "'cloudsmith telemetry reset'.\n" + "To opt out, set CLOUDSMITH_NO_TELEMETRY=1 or DO_NOT_TRACK=1, or add " + "'telemetry = false' to config.ini. To see exactly what is sent, set " + "CLOUDSMITH_TELEMETRY=log.\n" + "Learn more: https://docs.cloudsmith.com/developer-tools/cli\n" + "Nothing was sent for this command. This notice will not be shown again." +) + + +def needs_notice(): + return state.read().get("notice_version") != NOTICE_VERSION + + +def show(): + click.echo(NOTICE_TEXT, err=True) + return state.write({"notice_version": NOTICE_VERSION}) diff --git a/cloudsmith_cli/core/usage/settings.py b/cloudsmith_cli/core/usage/settings.py new file mode 100644 index 00000000..76486a9d --- /dev/null +++ b/cloudsmith_cli/core/usage/settings.py @@ -0,0 +1,62 @@ +# Copyright 2026 Cloudsmith Ltd +"""Switches and endpoints for usage telemetry. + +There is no production project key yet: only the developer override +(``CLOUDSMITH_TELEMETRY_POSTHOG_KEY``) can make the CLI send. +""" + +import os + +POSTHOG_PROJECT_KEY = "" +POSTHOG_HOST = "https://eu.i.posthog.com" + +KEY_ENV = "CLOUDSMITH_TELEMETRY_POSTHOG_KEY" +HOST_ENV = "CLOUDSMITH_TELEMETRY_POSTHOG_HOST" +MODE_ENV = "CLOUDSMITH_TELEMETRY" +INTERNAL_ENV = "CLOUDSMITH_TELEMETRY_INTERNAL" +INSTALL_ID_ENV = "CLOUDSMITH_TELEMETRY_INSTALL_ID" +SAMPLE_RATE_ENV = "CLOUDSMITH_TELEMETRY_CI_SAMPLE_RATE" + +_TRUTHY = ("1", "true", "yes") +_FALSY = ("0", "false", "no", "off") + + +def _env(env): + return os.environ if env is None else env + + +def _truthy(env, name): + return _env(env).get(name, "").strip().lower() in _TRUTHY + + +def override_set(env=None): + return bool(_env(env).get(KEY_ENV, "").strip()) + + +def project_key(env=None): + return _env(env).get(KEY_ENV, "").strip() or POSTHOG_PROJECT_KEY + + +def host(env=None): + return (_env(env).get(HOST_ENV, "").strip() or POSTHOG_HOST).rstrip("/") + + +def log_mode(env=None): + return _env(env).get(MODE_ENV, "").strip().lower() == "log" + + +def internal(env=None): + return _truthy(env, INTERNAL_ENV) + + +def install_id_enabled(env=None): + # On by default for laptops; only an explicit off value disables it. + return _env(env).get(INSTALL_ID_ENV, "").strip().lower() not in _FALSY + + +def ci_sample_rate(env=None): + try: + rate = int(_env(env).get(SAMPLE_RATE_ENV, "100")) + except ValueError: + return 100 + return rate if 0 <= rate <= 100 else 100 diff --git a/cloudsmith_cli/core/usage/state.py b/cloudsmith_cli/core/usage/state.py new file mode 100644 index 00000000..5213f25d --- /dev/null +++ b/cloudsmith_cli/core/usage/state.py @@ -0,0 +1,162 @@ +# Copyright 2026 Cloudsmith Ltd +"""The usage telemetry state file, ``telemetry.json`` in the config dir. + +Holds the install ID (only when enabled), the shown notice version and the +time of the last failed send. Read and written on the main thread only. + +Every read-modify-write holds an inter-process lock (``locked``), so two +CLI processes cannot overwrite each other's changes, e.g. put back an +install ID that ``telemetry reset`` just deleted. +""" + +import contextlib +import json +import os +import sys +import time + +STATE_FILE_NAME = "telemetry.json" +LOCK_FILE_NAME = "telemetry.json.lock" +#: Writers hold the lock for one small read and write. If it is still held +#: after this, give up rather than delay the command. +LOCK_TIMEOUT_SECONDS = 0.5 +_LOCK = {"depth": 0} + + +def config_dir(): + from ...cli.config import get_default_config_path + + return get_default_config_path() + + +def state_path(): + return os.path.join(config_dir(), STATE_FILE_NAME) + + +def writable(): + """Tell whether the state file could be written, without creating anything. + + Walks up from the config dir to the nearest directory that exists, since + ``write`` creates the missing levels. + """ + try: + path = os.path.abspath(config_dir()) + while not os.path.isdir(path): + parent = os.path.dirname(path) + if parent == path: + return False + path = parent + return os.access(path, os.W_OK) + except OSError: + return False + + +def read(): + try: + with open(state_path(), encoding="utf-8") as state_file: + data = json.load(state_file) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _try_lock(handle): + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _unlock(handle): + try: + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + handle.close() + + +def _acquire(): + """Open and lock the lock file; None if that fails or times out.""" + try: + os.makedirs(config_dir(), mode=0o700, exist_ok=True) + handle = open( # noqa: SIM115 - closed by _unlock + os.path.join(config_dir(), LOCK_FILE_NAME), "a+", encoding="utf-8" + ) + except OSError: + return None + deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS + while True: + try: + _try_lock(handle) + return handle + except OSError: + if time.monotonic() >= deadline: + handle.close() + return None + time.sleep(0.01) + + +@contextlib.contextmanager +def locked(): + """Hold the state lock; yields False if it could not be taken. + + Re-entrant within the process, so a caller can hold it across a whole + read-check-write that itself calls ``write`` or ``delete``. + """ + if _LOCK["depth"]: + _LOCK["depth"] += 1 + try: + yield True + finally: + _LOCK["depth"] -= 1 + return + handle = _acquire() + if handle is None: + yield False + return + _LOCK["depth"] = 1 + try: + yield True + finally: + _LOCK["depth"] = 0 + _unlock(handle) + + +def write(updates): + from ..cache_utils import atomic_write_json + + with locked() as ok: + if not ok: + return False + data = read() + data.update(updates) + try: + atomic_write_json(state_path(), data) + except OSError: + return False + return True + + +def delete(): + with locked() as ok: + if not ok: + return False + try: + os.remove(state_path()) + except FileNotFoundError: + return True + except OSError: + return False + return True From 145946d0fdbe27226e8a7824f1a52301643a0510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:25:16 +0000 Subject: [PATCH 2/2] fix(ENG-15367): point the first-run notice at the CLI telemetry docs page --- cloudsmith_cli/core/usage/notice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudsmith_cli/core/usage/notice.py b/cloudsmith_cli/core/usage/notice.py index aaaac507..9ca1eb72 100644 --- a/cloudsmith_cli/core/usage/notice.py +++ b/cloudsmith_cli/core/usage/notice.py @@ -20,7 +20,7 @@ "To opt out, set CLOUDSMITH_NO_TELEMETRY=1 or DO_NOT_TRACK=1, or add " "'telemetry = false' to config.ini. To see exactly what is sent, set " "CLOUDSMITH_TELEMETRY=log.\n" - "Learn more: https://docs.cloudsmith.com/developer-tools/cli\n" + "Learn more: https://docs.cloudsmith.com/developer-tools/cli-telemetry\n" "Nothing was sent for this command. This notice will not be shown again." )