From 14ebca04d0b8699d50d67fcd44956b83758cfa3c Mon Sep 17 00:00:00 2001 From: Anton Burnashev Date: Tue, 30 Jun 2026 10:43:04 +0200 Subject: [PATCH] feat(telemetry): add anonymous usage tracking Implements #22 --- .gitignore | 2 + CHANGELOG.md | 1 + CONTRIBUTING.md | 27 ++ README.md | 15 + hatch_build.py | 30 ++ pyproject.toml | 8 + src/create_dlthub_workspace/cli.py | 15 +- src/create_dlthub_workspace/config.py | 2 + src/create_dlthub_workspace/strings.py | 7 + src/create_dlthub_workspace/telemetry.py | 253 +++++++++++++++++ tests/__init__.py | 4 + tests/test_telemetry.py | 337 +++++++++++++++++++++++ tests_integration/__init__.py | 4 + uv.lock | 35 +++ 14 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 hatch_build.py create mode 100644 src/create_dlthub_workspace/telemetry.py create mode 100644 tests/test_telemetry.py diff --git a/.gitignore b/.gitignore index 1914c58..3a26b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ build/ # Example workspaces created for testing examples/ + +src/create_dlthub_workspace/_telemetry_key.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d67cca..9943379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The minimal workspace scaffold now ships an interactive onboarding notebook (`notebooks/onboarding_success/`) — a marimo app deployed alongside the pipeline (via `__deployment__.py`) that reads the loaded `sample_shop` data back from `playground` to browse the schema and run a first query. +- Anonymous usage telemetry: `dlthub-start` now emits a telemetry event on a command execution. Opt out with `--no-telemetry`, `DLTHUB_START_TELEMETRY=0`, `DO_NOT_TRACK=1`, or an existing dlt opt-out. See the README "Telemetry" section. ### Changed - Refreshed the bundled minimal workspace `uv.lock` (`dlthub-client` 0.28.0, `marimo` 0.23.11) and added the notebook's dependencies (`anywidget`, `pandas`, `numpy`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 037720b..44e9c27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,33 @@ When running `dlthub` commands **by hand** in a local workspace, set stack also needs the runtime's mock host resolvable — add `127.0.0.1 dev-mock-services` to `/etc/hosts`. +## Telemetry + +The CLI sends anonymous usage events to PostHog. Users opt out with `--no-telemetry`, +`DLTHUB_START_TELEMETRY=0`, or `DO_NOT_TRACK=1`, and an existing dlt opt-out +(`runtime.dlthub_telemetry = false` in dlt's global `config.toml`, or +`RUNTIME__DLTHUB_TELEMETRY=0`) is honored. + +For development and testing, three environment variables override the defaults: + +| Variable | Effect | +|---|---| +| `DLTHUB_START_TELEMETRY` | Force telemetry on (`1`/`true`/`yes`/`on`) or off (any other value). | +| `DLTHUB_START_POSTHOG_KEY` | Override the bundled PostHog project key. | +| `DLTHUB_START_POSTHOG_HOST` | Override the PostHog host (default `https://eu.i.posthog.com`). | + +Released builds bake the project key into a gitignored `_telemetry_key.py`; a dev +checkout has no key, so telemetry stays disabled until you set +`DLTHUB_START_POSTHOG_KEY`. To exercise the full path against a throwaway PostHog +project: + +```bash +DLTHUB_START_TELEMETRY=1 \ +DLTHUB_START_POSTHOG_KEY=phc_your_test_key \ +DLTHUB_START_POSTHOG_HOST=https://eu.i.posthog.com \ + uv run dlthub-start my-workspace --setup-only +``` + ## Tests Run the fast unit test suite: diff --git a/README.md b/README.md index d86fc96..a867bcf 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,21 @@ uvx dlthub-start@latest my-workspace --verbose If the scaffold was created successfully, you can also enter the workspace and run `uv sync` directly after fixing the underlying dependency or network issue. +## Telemetry + +`dlthub-start` sends anonymous usage events so we can improve our onboarding and +refine the user experience. + +No personal data is collected, no workspace is ever sent. + +Telemetry is controlled by the following, in order of precedence: + +1. the `--no-telemetry` flag, +2. `DLTHUB_START_TELEMETRY=0` (or `false`/`off`), +3. `DO_NOT_TRACK=1`, +4. an existing dlt opt-out (`~/.dlt/config.toml` `[runtime] dlthub_telemetry = false`, + or `RUNTIME__DLTHUB_TELEMETRY=false`). + ## Development For local setup, tests, build commands, `make workspace`, and AI workbench diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..088fd4d --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,30 @@ +"""Bake the telemetry ingest key into the distribution at build time.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +_KEY_MODULE = "src/create_dlthub_workspace/_telemetry_key.py" + + +class CustomBuildHook(BuildHookInterface): # type: ignore[type-arg] + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + key = os.environ.get("DLTHUB_START_POSTHOG_KEY", "").strip() + if not key: + self.app.display_warning("DLTHUB_START_POSTHOG_KEY is not set; building with telemetry disabled.") + return + if not key.startswith("phc_"): + raise ValueError("DLTHUB_START_POSTHOG_KEY must be a PostHog project key starting with 'phc_'.") + self._module.write_text(f"POSTHOG_PROJECT_KEY = {key!r}\n", encoding="utf-8") + build_data["artifacts"].append(_KEY_MODULE) + + def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None: + self._module.unlink(missing_ok=True) + + @property + def _module(self) -> Path: + return Path(self.root) / _KEY_MODULE diff --git a/pyproject.toml b/pyproject.toml index 719ee24..6781619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ dependencies = [ "rich>=13.7", "beaupy>=3.9", + "posthog>=3.7", "tomli>=2.0; python_version < '3.11'", ] @@ -72,6 +73,10 @@ warn_return_any = false module = "beaupy.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "posthog.*" +ignore_missing_imports = true + # `tomli` is only installed on Python <3.11 (we fall back to stdlib `tomllib` # on >=3.11), so it's absent from the dev env when running on newer pythons. # Suppress missing-stub errors there; the runtime path is short and stable. @@ -82,5 +87,8 @@ ignore_missing_imports = true [project.scripts] dlthub-start = "create_dlthub_workspace.cli:main" +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + [tool.hatch.build.targets.wheel] packages = ["src/create_dlthub_workspace"] diff --git a/src/create_dlthub_workspace/cli.py b/src/create_dlthub_workspace/cli.py index e316f74..f509c5a 100644 --- a/src/create_dlthub_workspace/cli.py +++ b/src/create_dlthub_workspace/cli.py @@ -9,7 +9,7 @@ import sys from pathlib import Path -from . import strings +from . import strings, telemetry from .config import ( AGENT_LAUNCH_COMMANDS, AGENT_SKILLS_DIR, @@ -111,6 +111,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Stream output from underlying subprocesses (uv, dlthub).", ) + parser.add_argument( + "--no-telemetry", + action="store_true", + help="Disable usage telemetry for this run.", + ) # Dev/CI only; hidden from --help. Both skip login + playground connection. # --setup-only installs deps; --scaffold-only skips deps too. parser.add_argument( @@ -130,25 +135,31 @@ def main(argv: list[str] | None = None) -> int: _ensure_utf8_io_on_windows() parser = build_parser() args = parser.parse_args(argv) + telemetry.init(no_telemetry=args.no_telemetry, interactive=stdin_is_interactive()) try: run(args) except KeyboardInterrupt: + telemetry.track_run("cancelled") console.print(strings.MSG_CANCELLED) return 130 except WorkspaceDirectoryNotEmptyError as exc: + telemetry.track_run("failed", error_code=type(exc).__name__) print_dir_not_empty(exc.project_dir) return 2 except WorkspaceError as exc: + telemetry.track_run("failed", error_code=type(exc).__name__) console.print(strings.MSG_ERROR_PREFIX.format(message=exc)) return 1 except Exception as exc: + telemetry.track_run("failed", error_code=type(exc).__name__) console.print(strings.MSG_UNEXPECTED_ERROR.format(message=exc)) if args.verbose: console.print_exception() else: console.print(strings.MSG_UNEXPECTED_ERROR_HINT) return 1 + telemetry.track_run("success") return 0 @@ -159,6 +170,8 @@ def run(args: argparse.Namespace) -> None: print_banner() console.print() + telemetry.show_first_run_notice() + if args.setup_only or args.scaffold_only: err_console.print(strings.MSG_TESTING_SHORTCUT_NOTE) diff --git a/src/create_dlthub_workspace/config.py b/src/create_dlthub_workspace/config.py index 2923f11..de870b7 100644 --- a/src/create_dlthub_workspace/config.py +++ b/src/create_dlthub_workspace/config.py @@ -12,6 +12,8 @@ except PackageNotFoundError: VERSION = "0.0.0+unknown" +POSTHOG_HOST = "https://eu.i.posthog.com" + GITHUB_ORG = "dlt-hub" AGENTS = ("claude", "codex", "cursor") diff --git a/src/create_dlthub_workspace/strings.py b/src/create_dlthub_workspace/strings.py index 0c24e5f..c9d0b59 100644 --- a/src/create_dlthub_workspace/strings.py +++ b/src/create_dlthub_workspace/strings.py @@ -174,3 +174,10 @@ f"`{config.ONE_SHOT_ENTRY_SKILL}` skill to deploy and run the sample pipeline. " "The skill is located at {skill_path}." ) + + +# Telemetry ------------------------------------------------------------- +MSG_TELEMETRY_NOTICE = ( + "[dim]dlthub-start sends anonymous usage events to help us improve user experience. " + "Opt out with --no-telemetry, DLTHUB_START_TELEMETRY=0, or DO_NOT_TRACK=1." +) diff --git a/src/create_dlthub_workspace/telemetry.py b/src/create_dlthub_workspace/telemetry.py new file mode 100644 index 0000000..4c5178e --- /dev/null +++ b/src/create_dlthub_workspace/telemetry.py @@ -0,0 +1,253 @@ +"""Usage telemetry for the dlthub-start CLI.""" + +from __future__ import annotations + +import atexit +import logging +import os +import secrets +import sys +import tempfile +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + +from . import strings +from .config import POSTHOG_HOST, VERSION +from .display import console + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +try: + from ._telemetry_key import POSTHOG_PROJECT_KEY # type: ignore[import-untyped] +except (ImportError, SyntaxError): + POSTHOG_PROJECT_KEY = "" + + +EVENT_NAME = "visitor:run:uvx_command" +_ACTOR_TYPE = "visitor" +_OBJECT_TYPE = "uvx_command" +_OBJECT_ID = "dlthub-start" +_CONTEXT = "uvx-start" +_SOURCE = "uvx" + +RunStatus = Literal["success", "failed", "cancelled"] + +_REQUEST_TIMEOUT_SECONDS = 3 +_ANONYMOUS_ID_FILE = ".anonymous_id" +_NOTICE_MARKER_FILE = ".dlthub_start_telemetry_notice" +_TRUTHY = {"1", "true", "yes", "on"} + + +class _Telemetry: + def __init__(self) -> None: + self._client: Any = None + self._distinct_id = "" + self._base: dict[str, object] = {} + self._started_at = 0.0 + self._emitted = False + + def init(self, *, no_telemetry: bool, interactive: bool) -> None: + """Start the telemetry client once per process, unless the user opted out.""" + if self._client is not None: + return + try: + key = _project_key() + if not key or not _is_enabled(no_telemetry): + return + # lazy import to speed up the disabled paths + import posthog # noqa: PLC0415 + + self._distinct_id = _device_id() + self._base = _base_properties(self._distinct_id, interactive) + self._started_at = time.monotonic() + _silence_posthog_logging() + self._client = posthog.Posthog( + key, + host=_host(), + max_retries=0, + timeout=_REQUEST_TIMEOUT_SECONDS, + disable_geoip=True, + ) + atexit.register(self.shutdown) + except Exception: + self._client = None + + def show_first_run_notice(self) -> None: + if self._client is None: + return + try: + _show_first_run_notice() + except Exception: + pass + + def track_run(self, status: RunStatus, error_code: str | None = None) -> None: + if self._emitted: + return + self._emitted = True + self._emit(status, error_code) + + def _emit(self, status: RunStatus, error_code: str | None = None) -> None: + if self._client is None: + return + properties: dict[str, object] = { + **self._base, + "status": status, + "duration_ms": self.elapsed_ms(), + } + if error_code is not None and status == "failed": + properties["error_code"] = error_code + try: + self._client.capture( + event=EVENT_NAME, + distinct_id=self._distinct_id, + properties=properties, + timestamp=datetime.now(tz=timezone.utc), + uuid=str(uuid.uuid4()), + ) + except Exception: + pass + + def elapsed_ms(self) -> int: + """Milliseconds since the run started.""" + return int((time.monotonic() - self._started_at) * 1000) + + def shutdown(self) -> None: + """Flush queued events on exit.""" + client, self._client = self._client, None + if client is None: + return + try: + client.flush() + except Exception: + pass + + +_telemetry = _Telemetry() + + +def init(*, no_telemetry: bool, interactive: bool) -> None: + _telemetry.init(no_telemetry=no_telemetry, interactive=interactive) + + +def show_first_run_notice() -> None: + _telemetry.show_first_run_notice() + + +def track_run(status: RunStatus, error_code: str | None = None) -> None: + _telemetry.track_run(status, error_code) + + +def _is_enabled(no_telemetry: bool) -> bool: + if no_telemetry: + return False + explicit = os.environ.get("DLTHUB_START_TELEMETRY") + if explicit is not None: + return _is_truthy(explicit) + if _is_truthy(os.environ.get("DO_NOT_TRACK")): + return False + return _dlt_telemetry_enabled() + + +def _dlt_telemetry_enabled() -> bool: + """Honor an existing dlt opt-out.""" + override = os.environ.get("RUNTIME__DLTHUB_TELEMETRY") + if override is not None: + return _is_truthy(override) + try: + config = tomllib.loads((_dlt_global_dir() / "config.toml").read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + return True + runtime = config.get("runtime") + return not (isinstance(runtime, dict) and runtime.get("dlthub_telemetry") is False) + + +def _is_truthy(value: str | None) -> bool: + return value is not None and value.strip().lower() in _TRUTHY + + +def _device_id() -> str: + """Read, or create, the dlt device id.""" + path = _dlt_global_dir() / _ANONYMOUS_ID_FILE + try: + existing = path.read_text(encoding="utf-8").strip() + except OSError: + existing = "" + if existing: + return existing + device_id = secrets.token_hex(16) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(device_id, encoding="utf-8") + except OSError: + pass + return device_id + + +def _dlt_global_dir() -> Path: + """Resolve dlt's global directory.""" + if hasattr(os, "geteuid") and os.geteuid() == 0: + return Path("/var") / "dlt" + home = os.path.expanduser("~") + if home == "~" or not _is_writable(Path(home)): + return Path(tempfile.gettempdir()) / "dlt" + dot_dlt = os.environ.get("DLT_CONFIG_FOLDER", ".dlt") + xdg = os.environ.get("XDG_DATA_HOME") + if xdg and not (Path(home) / dot_dlt).is_dir(): + return Path(xdg) / "dlt" + return Path(home) / dot_dlt + + +def _is_writable(path: Path) -> bool: + return path.is_dir() and os.access(path, os.W_OK) + + +def _base_properties(device_id: str, interactive: bool) -> dict[str, object]: + return { + "actor_type": _ACTOR_TYPE, + "actor_id": device_id, + "object_type": _OBJECT_TYPE, + "object_id": _OBJECT_ID, + "context": _CONTEXT, + "source": _SOURCE, + "device_id": device_id, + "session_id": secrets.token_hex(16), + "cli_version": VERSION, + "ci": _is_ci(), + "interactive": interactive, + } + + +def _is_ci() -> bool: + return bool(os.environ.get("CI")) + + +def _project_key() -> str: + return os.environ.get("DLTHUB_START_POSTHOG_KEY") or POSTHOG_PROJECT_KEY + + +def _host() -> str: + return os.environ.get("DLTHUB_START_POSTHOG_HOST") or POSTHOG_HOST + + +def _silence_posthog_logging() -> None: + posthog_logger = logging.getLogger("posthog") + posthog_logger.addHandler(logging.NullHandler()) + posthog_logger.propagate = False + + +def _show_first_run_notice() -> None: + marker = _dlt_global_dir() / _NOTICE_MARKER_FILE + if marker.exists(): + return + console.print(strings.MSG_TELEMETRY_NOTICE) + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + except OSError: + pass diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..d92556d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +import os + +# Prevent the suite from emitting real telemetry. Individual tests may opt back in +os.environ.setdefault("DLTHUB_START_TELEMETRY", "0") diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..53e65aa --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,337 @@ +"""Tests for the telemetry module.""" + +from __future__ import annotations + +import contextlib +import io +import os +import tempfile +import time +import unittest +from pathlib import Path +from typing import Any, Iterator +from unittest.mock import MagicMock, patch + +from create_dlthub_workspace import telemetry +from create_dlthub_workspace.cli import main +from create_dlthub_workspace.errors import WorkspaceDirectoryNotEmptyError, WorkspaceError + +_OPT_OUT_ENV = ("DLTHUB_START_TELEMETRY", "DO_NOT_TRACK", "RUNTIME__DLTHUB_TELEMETRY") + + +@contextlib.contextmanager +def _silenced() -> Iterator[None]: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + yield + + +@contextlib.contextmanager +def _clean_opt_out_env() -> Iterator[None]: + """Drop every opt-out signal so a test starts from the default-enabled state.""" + with patch.dict(os.environ, {}, clear=False): + for name in _OPT_OUT_ENV: + os.environ.pop(name, None) + yield + + +class IsEnabledTests(unittest.TestCase): + def test_no_telemetry_flag_disables(self) -> None: + self.assertFalse(telemetry._is_enabled(True)) + + def test_env_zero_disables(self) -> None: + with patch.dict(os.environ, {"DLTHUB_START_TELEMETRY": "0"}): + self.assertFalse(telemetry._is_enabled(False)) + + def test_env_one_enables_overriding_dlt(self) -> None: + with patch.dict(os.environ, {"DLTHUB_START_TELEMETRY": "1"}): + self.assertTrue(telemetry._is_enabled(False)) + + def test_do_not_track_disables(self) -> None: + with _clean_opt_out_env(): + os.environ["DO_NOT_TRACK"] = "1" + self.assertFalse(telemetry._is_enabled(False)) + + +class DltOptOutTests(unittest.TestCase): + def _is_enabled_with_config(self, contents: str | None) -> bool: + with tempfile.TemporaryDirectory() as global_dir: + if contents is not None: + (Path(global_dir) / "config.toml").write_text(contents, encoding="utf-8") + with _clean_opt_out_env(), patch.object(telemetry, "_dlt_global_dir", return_value=Path(global_dir)): + return telemetry._is_enabled(False) + + def test_dlt_opt_out_disables(self) -> None: + self.assertFalse(self._is_enabled_with_config("[runtime]\ndlthub_telemetry = false\n")) + + def test_dlt_opt_in_enabled(self) -> None: + self.assertTrue(self._is_enabled_with_config("[runtime]\ndlthub_telemetry = true\n")) + + def test_no_config_enabled_by_default(self) -> None: + self.assertTrue(self._is_enabled_with_config(None)) + + def test_non_utf8_config_do_not_crash_cli(self) -> None: + with tempfile.TemporaryDirectory() as global_dir: + (Path(global_dir) / "config.toml").write_bytes("[runtime]\n".encode("utf-16")) + with _clean_opt_out_env(), patch.object(telemetry, "_dlt_global_dir", return_value=Path(global_dir)): + self.assertTrue(telemetry._is_enabled(False)) + + def test_runtime_env_override_disables(self) -> None: + with _clean_opt_out_env(): + os.environ["RUNTIME__DLTHUB_TELEMETRY"] = "false" + self.assertFalse(telemetry._is_enabled(False)) + + +class ProjectKeyTests(unittest.TestCase): + def test_env_override_wins(self) -> None: + with patch.dict(os.environ, {"DLTHUB_START_POSTHOG_KEY": "phc_env"}): + self.assertEqual(telemetry._project_key(), "phc_env") + + def test_empty_without_baked_key_or_env(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("DLTHUB_START_POSTHOG_KEY", None) + self.assertEqual(telemetry._project_key(), "") + + def test_init_is_noop_without_a_key(self) -> None: + instance = telemetry._Telemetry() + with patch.dict(os.environ, {"DLTHUB_START_TELEMETRY": "1"}): + os.environ.pop("DLTHUB_START_POSTHOG_KEY", None) + instance.init(no_telemetry=False, interactive=True) + self.assertIsNone(instance._client) + + +class InitTests(unittest.TestCase): + def test_init_never_propagates_errors(self) -> None: + instance = telemetry._Telemetry() + with patch.dict(os.environ, {"DLTHUB_START_POSTHOG_KEY": "phc_x"}): + with patch.object(telemetry, "_is_enabled", side_effect=ValueError("boom")): + instance.init(no_telemetry=False, interactive=True) # must not raise + self.assertIsNone(instance._client) + + +class DeviceIdTests(unittest.TestCase): + def test_creates_hex_id_when_absent(self) -> None: + with tempfile.TemporaryDirectory() as global_dir: + with patch.object(telemetry, "_dlt_global_dir", return_value=Path(global_dir)): + device_id = telemetry._device_id() + self.assertEqual(len(device_id), 32) + int(device_id, 16) # raises if not hex + + def test_reads_existing_id(self) -> None: + with tempfile.TemporaryDirectory() as global_dir: + (Path(global_dir) / ".anonymous_id").write_text("existing-id", encoding="utf-8") + with patch.object(telemetry, "_dlt_global_dir", return_value=Path(global_dir)): + self.assertEqual(telemetry._device_id(), "existing-id") + + def test_persists_stable_id_without_newline(self) -> None: + with tempfile.TemporaryDirectory() as global_dir: + with patch.object(telemetry, "_dlt_global_dir", return_value=Path(global_dir)): + first = telemetry._device_id() + second = telemetry._device_id() + stored = (Path(global_dir) / ".anonymous_id").read_text(encoding="utf-8") + self.assertEqual(first, second) + self.assertEqual(stored, first) + + +@contextlib.contextmanager +def _global_dir_env(home: str, **overrides: str) -> Iterator[None]: + with patch.object(telemetry.os.path, "expanduser", return_value=home): + with patch.dict(os.environ, {}, clear=False): + for name in ("XDG_DATA_HOME", "DLT_CONFIG_FOLDER", "DLT_DATA_DIR"): + os.environ.pop(name, None) + os.environ.update(overrides) + yield + + +_XDG_HOME = "/xdg-data-home" + +_GLOBAL_DIR_CASES = [ + ("home default when no XDG", {}, None, lambda home: home / ".dlt"), + ("XDG used when ~/.dlt absent", {"XDG_DATA_HOME": _XDG_HOME}, None, lambda home: Path(_XDG_HOME) / "dlt"), + ("XDG ignored when ~/.dlt present", {"XDG_DATA_HOME": _XDG_HOME}, ".dlt", lambda home: home / ".dlt"), + ("DLT_CONFIG_FOLDER renames the home folder", {"DLT_CONFIG_FOLDER": ".cfg"}, None, lambda home: home / ".cfg"), + ( + "DLT_CONFIG_FOLDER drives the XDG existence check", + {"XDG_DATA_HOME": _XDG_HOME, "DLT_CONFIG_FOLDER": ".cfg"}, + None, + lambda home: Path(_XDG_HOME) / "dlt", + ), + ( + "DLT_CONFIG_FOLDER folder present beats XDG", + {"XDG_DATA_HOME": _XDG_HOME, "DLT_CONFIG_FOLDER": ".cfg"}, + ".cfg", + lambda home: home / ".cfg", + ), + ("DLT_DATA_DIR is ignored", {"DLT_DATA_DIR": "/data-dir"}, None, lambda home: home / ".dlt"), +] + + +class GlobalDirTests(unittest.TestCase): + def setUp(self) -> None: + if hasattr(os, "geteuid") and os.geteuid() == 0: + self.skipTest("resolution differs for root") + + def test_resolution_matrix(self) -> None: + for label, env, precreate, expected in _GLOBAL_DIR_CASES: + with self.subTest(label): + with tempfile.TemporaryDirectory() as home: + if precreate: + (Path(home) / precreate).mkdir() + with _global_dir_env(home, **env): + self.assertEqual(telemetry._dlt_global_dir(), expected(Path(home))) + + def test_temp_fallback_when_home_unresolved(self) -> None: + with _global_dir_env("~"): + self.assertEqual(telemetry._dlt_global_dir(), Path(tempfile.gettempdir()) / "dlt") + + def test_temp_fallback_when_home_unwritable(self) -> None: + with tempfile.TemporaryDirectory() as home: + with _global_dir_env(home), patch.object(telemetry, "_is_writable", return_value=False): + self.assertEqual(telemetry._dlt_global_dir(), Path(tempfile.gettempdir()) / "dlt") + + +class CiDetectionTests(unittest.TestCase): + def test_detects_ci(self) -> None: + with patch.dict(os.environ, {"CI": "true"}): + self.assertTrue(telemetry._is_ci()) + + def test_no_ci(self) -> None: + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(telemetry._is_ci()) + + +class EventShapeTests(unittest.TestCase): + def setUp(self) -> None: + self.client = MagicMock() + self.t = telemetry._Telemetry() + self.t._client = self.client + self.t._distinct_id = "device-123" + self.t._base = telemetry._base_properties("device-123", interactive=True) + self.t._started_at = time.monotonic() + + def _kwargs(self) -> dict[str, Any]: + return self.client.capture.call_args.kwargs + + def test_track_run_emits_taxonomy_event(self) -> None: + self.t.track_run("success") + kwargs = self._kwargs() + self.assertEqual(kwargs["event"], "visitor:run:uvx_command") + self.assertEqual(kwargs["distinct_id"], "device-123") + self.assertIn("uuid", kwargs) # dedup id, like the runtime tracker + self.assertIn("timestamp", kwargs) # server-side event time + props = kwargs["properties"] + self.assertEqual(props["actor_type"], "visitor") + self.assertEqual(props["actor_id"], "device-123") + self.assertEqual(props["object_type"], "uvx_command") + self.assertEqual(props["object_id"], "dlthub-start") + self.assertEqual(props["context"], "uvx-start") + self.assertEqual(props["source"], "uvx") + self.assertEqual(props["device_id"], "device-123") + self.assertEqual(props["cli_version"], telemetry.VERSION) + self.assertIn("session_id", props) + + def test_track_run_success(self) -> None: + self.t.track_run("success") + props = self._kwargs()["properties"] + self.assertEqual(props["status"], "success") + self.assertIn("duration_ms", props) + self.assertNotIn("error_code", props) + + def test_track_run_failed_carries_error_code(self) -> None: + self.t.track_run("failed", error_code="WorkspaceError") + props = self._kwargs()["properties"] + self.assertEqual(props["status"], "failed") + self.assertEqual(props["error_code"], "WorkspaceError") + + def test_track_run_cancelled_drops_error_code(self) -> None: + self.t.track_run("cancelled", error_code="KeyboardInterrupt") + props = self._kwargs()["properties"] + self.assertEqual(props["status"], "cancelled") + self.assertNotIn("error_code", props) + + def test_track_run_emits_once(self) -> None: + self.t.track_run("success") + self.t.track_run("failed", error_code="X") + self.client.capture.assert_called_once() + + def test_track_run_is_noop_when_disabled(self) -> None: + self.t._client = None + self.t.track_run("success") + + def test_track_run_swallows_client_errors(self) -> None: + self.client.capture.side_effect = RuntimeError("network down") + self.t.track_run("success") + + +class MainWiringTests(unittest.TestCase): + def setUp(self) -> None: + self.track_run = patch("create_dlthub_workspace.telemetry.track_run").start() + patch("create_dlthub_workspace.telemetry.init").start() + patch("create_dlthub_workspace.cli.stdin_is_interactive", return_value=False).start() + self.cli_run = patch("create_dlthub_workspace.cli.run").start() + self.addCleanup(patch.stopall) + + def _main(self) -> int: + with _silenced(): + return main([]) + + def test_success_path(self) -> None: + rc = self._main() + self.assertEqual(rc, 0) + self.track_run.assert_called_once_with("success") + + def test_cancelled_path(self) -> None: + self.cli_run.side_effect = KeyboardInterrupt() + rc = self._main() + self.assertEqual(rc, 130) + self.track_run.assert_called_once_with("cancelled") + + def test_workspace_error_path(self) -> None: + self.cli_run.side_effect = WorkspaceError("boom") + rc = self._main() + self.assertEqual(rc, 1) + self.track_run.assert_called_once_with("failed", error_code="WorkspaceError") + + def test_unexpected_error_path(self) -> None: + self.cli_run.side_effect = RuntimeError("kaboom") + rc = self._main() + self.assertEqual(rc, 1) + self.track_run.assert_called_once_with("failed", error_code="RuntimeError") + + def test_dir_not_empty_path(self) -> None: + self.cli_run.side_effect = WorkspaceDirectoryNotEmptyError(Path("/x")) + rc = self._main() + self.assertEqual(rc, 2) + self.track_run.assert_called_once_with("failed", error_code="WorkspaceDirectoryNotEmptyError") + + +class FirstRunNoticeTests(unittest.TestCase): + def test_shows_notice_only_once(self) -> None: + with tempfile.TemporaryDirectory() as global_dir: + with patch.object(telemetry, "_dlt_global_dir", return_value=Path(global_dir)): + with patch.object(telemetry.console, "print") as printer: + telemetry._show_first_run_notice() + telemetry._show_first_run_notice() + printer.assert_called_once() + + def test_method_is_noop_when_telemetry_disabled(self) -> None: + t = telemetry._Telemetry() + with patch.object(telemetry, "_show_first_run_notice") as notice: + t.show_first_run_notice() + notice.assert_not_called() + + def test_method_shows_notice_when_active(self) -> None: + t = telemetry._Telemetry() + t._client = MagicMock() + with patch.object(telemetry, "_show_first_run_notice") as notice: + t.show_first_run_notice() + notice.assert_called_once() + + def test_method_never_propagates_errors(self) -> None: + t = telemetry._Telemetry() + t._client = MagicMock() + with patch.object(telemetry, "_show_first_run_notice", side_effect=ValueError("boom")): + t.show_first_run_notice() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_integration/__init__.py b/tests_integration/__init__.py index e69de29..2c2d78a 100644 --- a/tests_integration/__init__.py +++ b/tests_integration/__init__.py @@ -0,0 +1,4 @@ +import os + +# Prevent the suite from emitting real telemetry +os.environ.setdefault("DLTHUB_START_TELEMETRY", "0") diff --git a/uv.lock b/uv.lock index 14f8511..1d61827 100644 --- a/uv.lock +++ b/uv.lock @@ -50,6 +50,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "beaupy" version = "3.11.0" @@ -200,6 +209,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "dlt" version = "1.28.0" @@ -243,6 +261,7 @@ version = "0.9.1" source = { editable = "." } dependencies = [ { name = "beaupy" }, + { name = "posthog" }, { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] @@ -260,6 +279,7 @@ requires-dist = [ { name = "beaupy", specifier = ">=3.9" }, { name = "dlt", marker = "extra == 'dev'", specifier = ">=1.28.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "posthog", specifier = ">=3.7" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "rich", specifier = ">=13.7" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, @@ -738,6 +758,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, ] +[[package]] +name = "posthog" +version = "7.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/65/a2b08d16e7b3b6518762f67d0c51c17aa6d02b08e0fe4260257eada02ee7/posthog-7.21.1.tar.gz", hash = "sha256:c8f7cd3eb3704fcd944c31c887801bf263dff39505d3531ecd0113e059d33bf8", size = 309024, upload-time = "2026-06-29T09:48:35.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/15/69e03271c251a039f767e4f1ea5d77e16c986de4c7134aedcbdd836b2493/posthog-7.21.1-py3-none-any.whl", hash = "sha256:30764b751b111fcd3b7056ee84468832c2d6bf6b495b06f56aa5c09bb7cf23fc", size = 371198, upload-time = "2026-06-29T09:48:34.057Z" }, +] + [[package]] name = "pygments" version = "2.20.0"