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.
ValueError — no 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
- Environment variable:
TIT_NO_TELEMETRY=1
- Config file: user config dir
telemetry.json → "enabled": false
- 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 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
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
Check whether telemetry is active.
Telemetry is active only when all of the following are true:
config.enabled is True (user gave consent).
- The environment variable
TIT_NO_TELEMETRY is not set to a
truthy value (1, true, yes).
- 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()
|