Skip to content

telemetry

tit.telemetry

Anonymous usage telemetry for TI-Toolbox.

Collects minimal, non-identifying usage data to help the development team understand feature adoption, platform distribution, and error rates. All network calls are fire-and-forget in daemon threads — they never block or crash the user's workflow.

Collected Data

  • TI-Toolbox version, Python version, OS / platform
  • Interface (cli or gui)
  • Operation type (sim_ti, sim_mti, flex_search, etc.)
  • Operation status (start, success, error)
  • Operation wall-clock duration (seconds, on completion events)
  • Error class name on failure (e.g. ValueErrorno tracebacks)
  • Anonymous client ID (random UUID, stored locally)
  • Approximate country via GA4 IP-based geolocation (IP is anonymised by Google)

Not collected: file paths, subject IDs, parameter values, hostnames, usernames, tracebacks, or any scientific data.

Opt-Out

  1. Environment variable: TIT_NO_TELEMETRY=1
  2. Config file: user config dir telemetry.json"enabled": false
  3. GUI: Settings → Privacy toggle

Public API

is_enabled Check whether telemetry is active. track_event Send a single GA4 event (non-blocking). track_operation Context manager that sends start + success / error events. consent_prompt_cli Show first-run consent prompt in a terminal. consent_prompt_gui Show first-run consent dialog in the PyQt5 GUI. set_enabled Programmatically enable or disable telemetry.

See Also

tit.constants : GA4_MEASUREMENT_ID, GA4_API_SECRET, and event names.

TelemetryConfig dataclass

TelemetryConfig(enabled: bool = False, client_id: str = (lambda: hex)(), consent_shown: bool = False)

Persistent telemetry preferences stored as JSON.

Attributes

enabled : bool Whether telemetry events are sent. Default False until the user gives explicit consent. client_id : str Random UUID generated once per install. Never linked to identity. consent_shown : bool True after the user has been asked (regardless of answer).

load_config

load_config() -> TelemetryConfig

Load telemetry config from disk, or return defaults.

Returns TelemetryConfig(enabled=False) when no project is active (i.e. :func:_config_path returns None).

Returns

TelemetryConfig Loaded or default configuration.

Source code in tit/telemetry.py
def load_config() -> TelemetryConfig:
    """Load telemetry config from disk, or return defaults.

    Returns ``TelemetryConfig(enabled=False)`` when no project is active
    (i.e. :func:`_config_path` returns ``None``).

    Returns
    -------
    TelemetryConfig
        Loaded or default configuration.
    """
    path = _config_path()
    if path is None:
        return TelemetryConfig()
    if path.exists():
        try:
            with open(path) as f:
                data = json.load(f)
            return TelemetryConfig(
                enabled=data.get("enabled", False),
                client_id=data.get("client_id", uuid.uuid4().hex),
                consent_shown=data.get("consent_shown", False),
            )
        except (json.JSONDecodeError, OSError, KeyError):
            logger.debug("Telemetry config corrupt or unreadable; using defaults.")
    return TelemetryConfig()

save_config

save_config(config: TelemetryConfig) -> None

Persist telemetry config to disk.

Does nothing when no project is active (no config path available). Sets file permissions to 0o600 (owner read/write only).

Parameters

config : TelemetryConfig Configuration to write.

Source code in tit/telemetry.py
def save_config(config: TelemetryConfig) -> None:
    """Persist telemetry config to disk.

    Does nothing when no project is active (no config path available).
    Sets file permissions to ``0o600`` (owner read/write only).

    Parameters
    ----------
    config : TelemetryConfig
        Configuration to write.
    """
    path = _config_path()
    if path is None:
        logger.debug("No project active; telemetry config not saved.")
        return
    try:
        with open(path, "w") as f:
            json.dump(asdict(config), f, indent=2)
        path.chmod(0o600)
    except OSError:
        logger.debug("Could not write telemetry config to %s", path)

is_enabled

is_enabled() -> bool

Check whether telemetry is active.

Telemetry is active only when all of the following are true:

  1. config.enabled is True (user gave consent).
  2. The environment variable TIT_NO_TELEMETRY is not set to a truthy value (1, true, yes).
  3. The GA4 credentials are not still set to placeholders.
Returns

bool True if telemetry events should be sent.

Source code in tit/telemetry.py
def is_enabled() -> bool:
    """Check whether telemetry is active.

    Telemetry is active only when **all** of the following are true:

    1. ``config.enabled`` is ``True`` (user gave consent).
    2. The environment variable ``TIT_NO_TELEMETRY`` is **not** set to a
       truthy value (``1``, ``true``, ``yes``).
    3. The GA4 credentials are not still set to placeholders.

    Returns
    -------
    bool
        ``True`` if telemetry events should be sent.
    """
    env_val = os.environ.get(const.ENV_NO_TELEMETRY, "").strip().lower()
    if env_val in ("1", "true", "yes"):
        return False
    cfg = _get_config()
    if not cfg.enabled:
        return False
    # Don't send events if GA4 credentials haven't been configured yet.
    if const.GA4_MEASUREMENT_ID == "G-XXXXXXXXXX":
        return False
    return True

set_enabled

set_enabled(enabled: bool) -> None

Programmatically enable or disable telemetry.

Writes the change to disk immediately and invalidates the in-process cache so subsequent is_enabled() calls reflect the new state.

When the user opts in for the first time (enabled=True and consent was not previously shown), a one-time first_open event is sent to distinguish new installations from returning users.

Parameters

enabled : bool True to enable, False to disable.

Source code in tit/telemetry.py
def set_enabled(enabled: bool) -> None:
    """Programmatically enable or disable telemetry.

    Writes the change to disk immediately and invalidates the in-process
    cache so subsequent ``is_enabled()`` calls reflect the new state.

    When the user opts in for the first time (``enabled=True`` and consent
    was not previously shown), a one-time ``first_open`` event is sent to
    distinguish new installations from returning users.

    Parameters
    ----------
    enabled : bool
        ``True`` to enable, ``False`` to disable.
    """
    cfg = _get_config()
    is_first_consent = enabled and not cfg.consent_shown
    cfg.enabled = enabled
    cfg.consent_shown = True
    save_config(cfg)
    _invalidate_cache()
    if is_first_consent:
        track_event(const.TELEMETRY_OP_FIRST_OPEN)

track_event

track_event(event_name: str, params: dict[str, str | int] | None = None, *, _blocking: bool = False) -> None

Send a single GA4 event in a background daemon thread.

Does nothing if telemetry is disabled.

Parameters

event_name : str GA4 event name (e.g. "sim_ti", "gui_launch"). Must be ≤40 chars, alphanumeric + underscores. params : dict[str, str | int], optional Extra event parameters. System params (OS, version, etc.) are merged in automatically. _blocking : bool, optional If True, wait up to TELEMETRY_TIMEOUT_S + 1 seconds for the HTTP request to complete. Used for completion events (success / error) so they are not lost when the process exits shortly after.

Examples

from tit.telemetry import track_event track_event("gui_launch") track_event("sim_ti", {"status": "success"})

Source code in tit/telemetry.py
def track_event(
    event_name: str,
    params: dict[str, str | int] | None = None,
    *,
    _blocking: bool = False,
) -> None:
    """Send a single GA4 event in a background daemon thread.

    Does nothing if telemetry is disabled.

    Parameters
    ----------
    event_name : str
        GA4 event name (e.g. ``"sim_ti"``, ``"gui_launch"``).
        Must be ≤40 chars, alphanumeric + underscores.
    params : dict[str, str | int], optional
        Extra event parameters.  System params (OS, version, etc.) are
        merged in automatically.
    _blocking : bool, optional
        If ``True``, wait up to ``TELEMETRY_TIMEOUT_S + 1`` seconds for
        the HTTP request to complete.  Used for completion events
        (``success`` / ``error``) so they are not lost when the process
        exits shortly after.

    Examples
    --------
    >>> from tit.telemetry import track_event
    >>> track_event("gui_launch")
    >>> track_event("sim_ti", {"status": "success"})
    """
    if not is_enabled():
        return

    cfg = _get_config()
    merged_params = _system_params()
    if params:
        merged_params.update(params)

    payload = {
        "client_id": cfg.client_id,
        "events": [
            {
                "name": event_name,
                "params": merged_params,
            }
        ],
    }

    thread = threading.Thread(target=_send_ga4, args=(payload,), daemon=True)
    thread.start()
    if _blocking:
        thread.join(timeout=const.TELEMETRY_TIMEOUT_S + 1)

track_operation

track_operation(op_name: str) -> Generator[None, None, None]

Context manager that sends start and success/error events.

Wraps a major operation (simulation, optimization, analysis) with two telemetry events:

  • On entry: {op_name} with {"status": "start"}
  • On clean exit: {op_name} with {"status": "success"}
  • On exception: {op_name} with {"status": "error", "error_type": "<class name>"}

The exception is always re-raised — this context manager is transparent to control flow.

Parameters

op_name : str Operation name (e.g. "sim_ti", "flex_search").

Yields

None

Examples

from tit.telemetry import track_operation with track_operation("sim_ti"): ... run_simulation(config)

Source code in tit/telemetry.py
@contextmanager
def track_operation(op_name: str) -> Generator[None, None, None]:
    """Context manager that sends ``start`` and ``success``/``error`` events.

    Wraps a major operation (simulation, optimization, analysis) with two
    telemetry events:

    - On entry: ``{op_name}`` with ``{"status": "start"}``
    - On clean exit: ``{op_name}`` with ``{"status": "success"}``
    - On exception: ``{op_name}`` with ``{"status": "error",
      "error_type": "<class name>"}``

    The exception is always re-raised — this context manager is
    transparent to control flow.

    Parameters
    ----------
    op_name : str
        Operation name (e.g. ``"sim_ti"``, ``"flex_search"``).

    Yields
    ------
    None

    Examples
    --------
    >>> from tit.telemetry import track_operation
    >>> with track_operation("sim_ti"):
    ...     run_simulation(config)
    """
    track_event(op_name, {"status": "start"})
    t0 = time.monotonic()
    try:
        yield
    except Exception as exc:
        duration_s = int(round(time.monotonic() - t0))
        detail = re.sub(r"/\S+", "<path>", str(exc))[:80]
        track_event(
            op_name,
            {
                "status": "error",
                "error_type": type(exc).__name__,
                "error_detail": detail,
                "duration_s": duration_s,
            },
            _blocking=True,
        )
        raise
    else:
        duration_s = int(round(time.monotonic() - t0))
        track_event(
            op_name, {"status": "success", "duration_s": duration_s}, _blocking=True
        )

consent_prompt_cli

consent_prompt_cli() -> None

Show a one-time consent prompt in the terminal.

Only runs when: - consent_shown is False in the stored config. - sys.stdin is a TTY (interactive terminal).

Non-interactive environments (Docker builds, CI, piped input) are silently skipped — telemetry stays disabled until an interactive session occurs.

The user's answer is persisted immediately so the prompt never appears again.

Source code in tit/telemetry.py
def consent_prompt_cli() -> None:
    """Show a one-time consent prompt in the terminal.

    Only runs when:
    - ``consent_shown`` is ``False`` in the stored config.
    - ``sys.stdin`` is a TTY (interactive terminal).

    Non-interactive environments (Docker builds, CI, piped input) are
    silently skipped — telemetry stays disabled until an interactive
    session occurs.

    The user's answer is persisted immediately so the prompt never
    appears again.
    """
    cfg = _get_config()
    if cfg.consent_shown:
        return
    if not sys.stdin.isatty():
        return

    print(_CONSENT_BANNER)
    try:
        answer = input("\n  Enable anonymous usage statistics? [Y/n]: ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        answer = "n"

    cfg.enabled = answer in ("", "y", "yes")
    cfg.consent_shown = True
    save_config(cfg)
    _invalidate_cache()

    if cfg.enabled:
        print("  ✓ Telemetry enabled. Thank you!\n")
    else:
        print("  ✗ Telemetry disabled. No data will be sent.\n")

consent_prompt_gui

consent_prompt_gui(parent: Any = None) -> None

Show a one-time consent dialog in the PyQt5 GUI.

Only runs when consent_shown is False. Uses a lazy import of PyQt5.QtWidgets so that non-GUI code never pulls in Qt.

Parameters

parent : QWidget, optional Parent widget for the dialog.

Source code in tit/telemetry.py
def consent_prompt_gui(parent: Any = None) -> None:
    """Show a one-time consent dialog in the PyQt5 GUI.

    Only runs when ``consent_shown`` is ``False``.  Uses a lazy import
    of ``PyQt5.QtWidgets`` so that non-GUI code never pulls in Qt.

    Parameters
    ----------
    parent : QWidget, optional
        Parent widget for the dialog.
    """
    cfg = _get_config()
    if cfg.consent_shown:
        return

    from PyQt5 import QtWidgets

    msg = QtWidgets.QMessageBox(parent)
    msg.setWindowTitle("TI-Toolbox — Usage Data")
    msg.setIcon(QtWidgets.QMessageBox.Question)
    msg.setText(
        "<b>Usage Data</b><br><br>"
        "TI-Toolbox can send anonymous usage data to help us "
        "identify issues and improve stability.<br><br>"
        "This includes which operations ran and whether they "
        "succeeded or failed. No personal data, file paths, or "
        "scientific results are collected.<br><br>"
        "You can disable this at any time in Settings → Privacy."
    )
    msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
    msg.setDefaultButton(QtWidgets.QMessageBox.Yes)
    msg.button(QtWidgets.QMessageBox.Yes).setText("Enable")
    msg.button(QtWidgets.QMessageBox.No).setText("No Thanks")

    result = msg.exec_()

    cfg.enabled = result == QtWidgets.QMessageBox.Yes
    cfg.consent_shown = True
    save_config(cfg)
    _invalidate_cache()