From bff879b01187bde774d4df8879beb8eb4ff5eda9 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:19:51 -0400 Subject: [PATCH 01/12] chore: stop tracking compiled Python files Twelve __pycache__/*.pyc files were committed, so they showed in the GitHub tree and changed on every local test run. Remove them from the index and ignore Python build and environment artifacts. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 11 +++++++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 2883 -> 0 bytes .../__pycache__/client.cpython-312.pyc | Bin 14749 -> 0 bytes .../__pycache__/config.cpython-312.pyc | Bin 6698 -> 0 bytes .../__pycache__/decorators.cpython-312.pyc | Bin 9880 -> 0 bytes .../__pycache__/events.cpython-312.pyc | Bin 7831 -> 0 bytes .../__pycache__/privacy.cpython-312.pyc | Bin 9380 -> 0 bytes tests/__pycache__/__init__.cpython-312.pyc | Bin 209 -> 0 bytes .../conftest.cpython-312-pytest-8.3.5.pyc | Bin 3341 -> 0 bytes .../test_client.cpython-312-pytest-8.3.5.pyc | Bin 29172 -> 0 bytes .../test_config.cpython-312-pytest-8.3.5.pyc | Bin 42255 -> 0 bytes ...test_decorators.cpython-312-pytest-8.3.5.pyc | Bin 14527 -> 0 bytes .../test_privacy.cpython-312-pytest-8.3.5.pyc | Bin 38608 -> 0 bytes 13 files changed, 11 insertions(+) delete mode 100644 src/openadapt_telemetry/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/openadapt_telemetry/__pycache__/client.cpython-312.pyc delete mode 100644 src/openadapt_telemetry/__pycache__/config.cpython-312.pyc delete mode 100644 src/openadapt_telemetry/__pycache__/decorators.cpython-312.pyc delete mode 100644 src/openadapt_telemetry/__pycache__/events.cpython-312.pyc delete mode 100644 src/openadapt_telemetry/__pycache__/privacy.cpython-312.pyc delete mode 100644 tests/__pycache__/__init__.cpython-312.pyc delete mode 100644 tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc delete mode 100644 tests/__pycache__/test_client.cpython-312-pytest-8.3.5.pyc delete mode 100644 tests/__pycache__/test_config.cpython-312-pytest-8.3.5.pyc delete mode 100644 tests/__pycache__/test_decorators.cpython-312-pytest-8.3.5.pyc delete mode 100644 tests/__pycache__/test_privacy.cpython-312-pytest-8.3.5.pyc diff --git a/.gitignore b/.gitignore index e92fd54..0df0569 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,14 @@ # template so it stays tracked. .env.* !.env.example + +# Python build and environment artifacts. +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.ruff_cache/ +.coverage +dist/ +build/ +*.egg-info/ diff --git a/src/openadapt_telemetry/__pycache__/__init__.cpython-312.pyc b/src/openadapt_telemetry/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6f216df7d0e10e755e45bda1e41ed7af9b841549..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2883 zcma)8%WfMt6dlQL$?_v{oY>BTmeWQ;Z7JCXbrIK+10#;@#xl@wL(nsnOn5jmASuPl zs{M-Yy6CQd(0}M3%(koU(i(+6bo7#-38l>@NXZO`6 zIy$Z&?$maUVWWOfe_e09c}rmD+k<+oY)tG|J=OsZ9z!0}%+)*$EbAusH8ZRb58xr7 zK58pCgT!x#1st*jI<_&{tMzaF{k{Y7?arwP{bd*-KC^_g$O27wAM&sh{T%{F*Wp zK!XL;lnfcx0vAyzHEOZcX3nZ?pnXU_r#^qrQfFS$-H5x8;VuN<&oUx}gD@eLs9Uz$ zBL++5ig}&bD%tlYC{G33#dEDA^}JfWyu5rtqo~rdrJ{LMaYIr49)88mfky98Nk9eL zE)Mu?iEi)B4YD036(oMU__Xq@^0X&V?sMFQbU;xdcImRn9FVfkphlC#nK&g^7hMA0 z;50y@lrT&*P`$5+s6uS0^l<{*#1R=dDzU_l^+35|xm;~<;{Wf|Y)O!QJtV7Ja%b1^sLQ&k2HabDkjv7 z-DKAal{(M^P|lyR+zYi-lrYdZ>rlT!J&$-ygejZ-t&-U%+}lK&s*8`!9wl9y(AYrr zpu=St2-8{}DtWrIEje~+TOr-!+t2Kq4Rj>aBA;pe?I!i%DE~A!O?e`o9}MSR-!yex z7^#bRkt^bI!iKTM3$WE63#6@>j8|btwu1nYOiUv~4KcT^4T17xoZCG*+}nRiYWr{E zeD8U0|DYaE*Xnyarw5IbbQE^1#d$xZ9&vjMMLVpwD38a5LC7Z&4dU5!y~Kr)ii#JL zWvU*^({Z5=6HM15*a_2H;!8kp%6Tp_3%krq(8Tcmy`_$e6R| zP&Aexb8sCJ@~d+NOXuI$)l=wNRi$T=yXUeR(yEefb>MGnaQlw#57S*uU8nK%*YE|= z_SrXb3#@10uTB9zjAb&Jzp~FVhK1n8;+W9DwWD8M6m)W#D>g YWcgv)nP2`P|M&QleD=pF`4w3H58iEhFaQ7m diff --git a/src/openadapt_telemetry/__pycache__/client.cpython-312.pyc b/src/openadapt_telemetry/__pycache__/client.cpython-312.pyc deleted file mode 100644 index 733129dba67203d5dcec383b0cc65b31f7fcddcd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14749 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 diff --git a/src/openadapt_telemetry/__pycache__/config.cpython-312.pyc b/src/openadapt_telemetry/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 3b2d7f237ebe3e28eefa5e60ab6f6d933fd40243..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6698 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 diff --git a/src/openadapt_telemetry/__pycache__/events.cpython-312.pyc b/src/openadapt_telemetry/__pycache__/events.cpython-312.pyc deleted file mode 100644 index 6bc608a516070a95c973f8b7de03cb7508454559..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7831 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 TxvPTYu4-SWRqfHLcrN7M7p8aq diff --git a/src/openadapt_telemetry/__pycache__/privacy.cpython-312.pyc b/src/openadapt_telemetry/__pycache__/privacy.cpython-312.pyc deleted file mode 100644 index 88a538bf283db67b10fd98d6614d1c5557e72fab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9380 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 R>;p3+BjYUwogx+>2LSndIsX6v diff --git a/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc b/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc deleted file mode 100644 index dd41f82e59fbd1ba534691bedcb85313bc2888df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3341 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
orN#2;2TKP+F+3j z7dJEA;OKJ?7N&k500gO`U5hW@zP1uQ_PP9Ech|ysJSTb_#Bdj+5&jnrj$nrRcqMvl zLq)Ushu+uDdjI9D@Ve+eg=)nx(avx>8|Zz?-g~~g;Dq3X%oF5f0sl;fg0sp(?RFOh zW4ryO{V4fj*E8A_#B$U$$EQ6OHC<`Akz4@{Ufc0+U?{a80Nbk3+Z~m$w^pMU{_Twikrv#mZ}Dr< z3)Sd_4Ha#nPxvnHkA(wnY+7RIub_t$n~i=hprL}Jm(0V7UrR{woSkTa!!Xuaaohn7 zL-xiqwpm=e2YD~sgB|Wyd^^!%+Wj05@xwaRmFFFQcPBY2CM|-F|1K_0Z?OpLE1- zcPw{|R9Z%0|Jn{$S`Tf&Kknt&T6DA;9o>3j0t}$oC*G804iUEg>&uh zt+f~h!w$}^MMkkVZ=Nsn(+0~xN9%HKB%E^E2^$^7pN43ut0v>%sbqQrIYxaO+z@nY zusH>%^XHU=y(h#9*}yM*=yvDnW`FElbG8MjLfOa!`b#{GYWS5prHpjK{E_aZRN zN_6B7a?2-g-+X&<_wB$+1QFj9UyF`Zqa%!Oez+^{T@sFMf?PEJpAES(oCr6!0TArR zKf7M^*YZfwZ^)@Y=@`Be2G5=u!@55Y`~YXHd^n7SYSA3H9@kL|mo#d}e#PQ644Eo1 z2l!XSHtH%%I9a&9c=O`o;O&t+=PUikS0bbJ2;cpJ_pUIC=aRNaC;SnNPyQ6apCR~j z1b>OZ9c(1OhiwS3hdKfP?eAkw`&$sYA2)f0*2f_q97&1*SdVxGX8zMh<9lc_Ll?~x z;`D@o-YKy&ZA8Eq!@ zB7m>GJkNi{4gMD*?ze*f!5#b=7yVCe{E0{4J?k8RCoM1Yryg+tp4{Mv`MyUSfG3xs SIF7|}C=T&sk2wI;nfV{>5;2Vc diff --git a/tests/__pycache__/test_privacy.cpython-312-pytest-8.3.5.pyc b/tests/__pycache__/test_privacy.cpython-312-pytest-8.3.5.pyc deleted file mode 100644 index 53953c5b9e78f2aef64457fac486c8b729d70f5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38608 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 R9RQE^pK=~}-~eEq_J85*LF)hj From 18a39f6d57bdb3e680f798d77170a2a07b318cb6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:20:37 -0400 Subject: [PATCH 02/12] fix: apply one opt-out rule to error reports and usage events Error reports (Sentry/GlitchTip) and usage events (PostHog) each had their own opt-out check, and they disagreed: - `[tool.openadapt] telemetry = false` in pyproject.toml stopped usage events but not error reports. - `"enabled": false` in ~/.config/openadapt/telemetry.json stopped error reports but not usage events. - DO_NOT_TRACK=yes or DO_NOT_TRACK=on stopped usage events but not error reports. Both paths now call config.is_telemetry_allowed(), which applies DO_NOT_TRACK, then OPENADAPT_TELEMETRY_ENABLED, then pyproject.toml, then the config file. Usage events still stay off in CI unless OPENADAPT_TELEMETRY_IN_CI or OPENADAPT_TELEMETRY_ENABLED turns them on. An empty OPENADAPT_TELEMETRY_ENABLED now counts as unset on both paths. Tests no longer read or write the real ~/.config/openadapt and ~/.openadapt files. Co-Authored-By: Claude Opus 5.5 --- src/openadapt_telemetry/__init__.py | 2 + src/openadapt_telemetry/config.py | 81 +++++++++++++++++- src/openadapt_telemetry/posthog.py | 60 +++---------- tests/conftest.py | 12 +++ tests/test_opt_out.py | 128 ++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 tests/test_opt_out.py diff --git a/src/openadapt_telemetry/__init__.py b/src/openadapt_telemetry/__init__.py index 5f24daa..455f55e 100644 --- a/src/openadapt_telemetry/__init__.py +++ b/src/openadapt_telemetry/__init__.py @@ -61,6 +61,7 @@ def add_demo(demo_id, task): CONFIG_FILE, DEFAULTS, TelemetryConfig, + is_telemetry_allowed, load_config, save_config, ) @@ -126,6 +127,7 @@ def add_demo(demo_id, task): "is_running_from_executable", # Config "TelemetryConfig", + "is_telemetry_allowed", "load_config", "save_config", "CONFIG_DIR", diff --git a/src/openadapt_telemetry/config.py b/src/openadapt_telemetry/config.py index 8129f79..700c2b1 100644 --- a/src/openadapt_telemetry/config.py +++ b/src/openadapt_telemetry/config.py @@ -5,6 +5,9 @@ 1. Environment variables (highest priority) 2. Configuration file (~/.config/openadapt/telemetry.json) 3. Package defaults (lowest priority) + +Whether telemetry runs at all is decided by :func:`is_telemetry_allowed`, which +also honors ``[tool.openadapt] telemetry = false`` in the nearest pyproject.toml. """ from __future__ import annotations @@ -68,7 +71,78 @@ def __post_init__(self) -> None: def _parse_bool(value: str) -> bool: """Parse a boolean from a string value.""" - return value.lower() in ("true", "1", "yes", "on") + return value.strip().lower() in ("true", "1", "yes", "on") + + +def _pyproject_opts_out() -> bool: + """Return True when the nearest pyproject.toml turns telemetry off. + + Walks up from the current directory to the first ``pyproject.toml`` and + reads:: + + [tool.openadapt] + telemetry = false + + This lets a team commit one file to turn telemetry off for everyone who + works in the repository. + """ + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + return False + + try: + cwd = Path.cwd() + except OSError: + return False + + for parent in [cwd, *cwd.parents]: + pyproject = parent / "pyproject.toml" + if pyproject.is_file(): + try: + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except Exception: + return False + value = data.get("tool", {}).get("openadapt", {}).get("telemetry") + if value is None: + return False + return not _parse_bool(str(value)) + return False + + +def _file_opts_out(file_config: dict[str, Any]) -> bool: + """Return True when the config file sets ``"enabled"`` to a false value.""" + value = file_config.get("enabled") + if isinstance(value, bool): + return not value + if isinstance(value, str): + return not _parse_bool(value) + return False + + +def is_telemetry_allowed() -> bool: + """Apply the opt-out rule shared by error reports and usage events. + + In order of precedence: + + 1. ``DO_NOT_TRACK`` set to ``1``, ``true``, ``yes`` or ``on`` turns telemetry off. + 2. ``OPENADAPT_TELEMETRY_ENABLED``, when set, decides. + 3. ``[tool.openadapt] telemetry = false`` in the nearest pyproject.toml turns it off. + 4. ``"enabled": false`` in ~/.config/openadapt/telemetry.json turns it off. + + Otherwise telemetry is allowed. + """ + if _parse_bool(os.getenv("DO_NOT_TRACK", "")): + return False + explicit = os.getenv("OPENADAPT_TELEMETRY_ENABLED", "").strip() + if explicit: + return _parse_bool(explicit) + if _pyproject_opts_out(): + return False + return not _file_opts_out(_load_config_file()) def _load_config_file() -> dict[str, Any]: @@ -94,7 +168,7 @@ def _get_env_config() -> dict[str, Any]: config["enabled"] = _parse_bool(enabled_env) # Universal opt-out (DO_NOT_TRACK standard) always wins. - if os.getenv("DO_NOT_TRACK", "").lower() in ("1", "true"): + if _parse_bool(os.getenv("DO_NOT_TRACK", "")): config["enabled"] = False # Internal/developer flags @@ -216,6 +290,9 @@ def load_config() -> TelemetryConfig: env_config = _get_env_config() merged.update(env_config) + # One opt-out rule for every telemetry path (see is_telemetry_allowed). + merged["enabled"] = is_telemetry_allowed() + # Remove None values for fields that should use defaults config_dict = {k: v for k, v in merged.items() if v is not None or k in {"dsn", "anon_salt"}} diff --git a/src/openadapt_telemetry/posthog.py b/src/openadapt_telemetry/posthog.py index b7af03c..4835a4b 100644 --- a/src/openadapt_telemetry/posthog.py +++ b/src/openadapt_telemetry/posthog.py @@ -20,6 +20,7 @@ from typing import Any from .client import is_ci_environment +from .config import is_telemetry_allowed from .privacy import scrub_dict DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" @@ -37,57 +38,22 @@ def _is_truthy(raw: str | None) -> bool: return str(raw or "").strip().lower() in {"1", "true", "yes", "on"} -def _pyproject_telemetry_disabled() -> bool: - """Check if telemetry is disabled via pyproject.toml [tool.openadapt]. - - Walks up from cwd looking for pyproject.toml with:: - - [tool.openadapt] - telemetry = false - - This lets enterprises commit one file to their repo to disable - telemetry for all developers — no per-user .env needed. - """ - try: - import tomllib - except ImportError: - try: - import tomli as tomllib # type: ignore[no-redef] - except ImportError: - return False - - from pathlib import Path - - for parent in [Path.cwd(), *Path.cwd().parents]: - pyproject = parent / "pyproject.toml" - if pyproject.is_file(): - try: - data = tomllib.loads(pyproject.read_text()) - val = data.get("tool", {}).get("openadapt", {}).get("telemetry") - if val is not None: - return not _is_truthy(str(val)) - except Exception: - pass - break # only check the nearest pyproject.toml - return False - - def _usage_enabled() -> bool: - # 1. Standard DO_NOT_TRACK env var (consoledonottrack.com) - if _is_truthy(os.getenv("DO_NOT_TRACK")): - return False + """Return True when usage events may be sent. - # 2. Explicit env var - explicit = os.getenv("OPENADAPT_TELEMETRY_ENABLED") - if explicit is not None: - return _is_truthy(explicit) - - # 3. pyproject.toml [tool.openadapt] telemetry = false - if _pyproject_telemetry_disabled(): + Uses the same opt-out rule as error reports (see + :func:`openadapt_telemetry.config.is_telemetry_allowed`). In addition, usage + events stay off in CI unless ``OPENADAPT_TELEMETRY_IN_CI`` or + ``OPENADAPT_TELEMETRY_ENABLED`` turns them on. + """ + if not is_telemetry_allowed(): return False - # 4. CI environments default to off - if is_ci_environment() and not _is_truthy(os.getenv("OPENADAPT_TELEMETRY_IN_CI")): + if ( + is_ci_environment() + and not _is_truthy(os.getenv("OPENADAPT_TELEMETRY_IN_CI")) + and not _is_truthy(os.getenv("OPENADAPT_TELEMETRY_ENABLED")) + ): return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 05c3861..57449c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,18 @@ from openadapt_telemetry.client import TelemetryClient +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + """Keep tests away from the real ~/.config/openadapt and ~/.openadapt files.""" + config_dir = tmp_path / "config" / "openadapt" + monkeypatch.setattr("openadapt_telemetry.config.CONFIG_DIR", config_dir) + monkeypatch.setattr("openadapt_telemetry.config.CONFIG_FILE", config_dir / "telemetry.json") + monkeypatch.setattr( + "openadapt_telemetry.posthog.DISTINCT_ID_FILE", + tmp_path / "openadapt" / "telemetry_distinct_id", + ) + + @pytest.fixture(autouse=True) def reset_telemetry(): """Reset telemetry singleton before and after each test.""" diff --git a/tests/test_opt_out.py b/tests/test_opt_out.py new file mode 100644 index 0000000..330d254 --- /dev/null +++ b/tests/test_opt_out.py @@ -0,0 +1,128 @@ +"""One opt-out rule covers error reports and usage events.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +import openadapt_telemetry.config as config +import openadapt_telemetry.posthog as posthog +from openadapt_telemetry import TelemetryClient, is_telemetry_allowed + +TELEMETRY_ENV = ( + "DO_NOT_TRACK", + "OPENADAPT_TELEMETRY_ENABLED", + "OPENADAPT_TELEMETRY_IN_CI", + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_URL", + "TRAVIS", + "CIRCLECI", + "BUILDKITE", + "AZURE_PIPELINES", + "TF_BUILD", + "CODEBUILD_BUILD_ID", + "TEAMCITY_VERSION", + "BITBUCKET_BUILD_NUMBER", +) + + +@pytest.fixture +def project(tmp_path, monkeypatch): + """Run from an empty project directory with no telemetry variables set.""" + for name in TELEMETRY_ENV: + monkeypatch.delenv(name, raising=False) + root = tmp_path / "project" + root.mkdir() + monkeypatch.chdir(root) + return root + + +def _write_pyproject(root, telemetry): + value = "true" if telemetry else "false" + (root / "pyproject.toml").write_text(f"[tool.openadapt]\ntelemetry = {value}\n") + + +def _write_config_file(enabled): + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + config.CONFIG_FILE.write_text(json.dumps({"enabled": enabled})) + + +def _paths_enabled() -> tuple[bool, bool]: + """Return (error reports enabled, usage events enabled).""" + TelemetryClient.reset_instance() + return TelemetryClient.get_instance().enabled, posthog._usage_enabled() + + +def test_enabled_by_default(project): + assert is_telemetry_allowed() is True + assert _paths_enabled() == (True, True) + + +@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "TRUE"]) +def test_do_not_track_turns_off_both_paths(project, monkeypatch, value): + monkeypatch.setenv("DO_NOT_TRACK", value) + assert _paths_enabled() == (False, False) + + +def test_do_not_track_beats_explicit_enable(project, monkeypatch): + monkeypatch.setenv("DO_NOT_TRACK", "1") + monkeypatch.setenv("OPENADAPT_TELEMETRY_ENABLED", "true") + assert _paths_enabled() == (False, False) + + +def test_pyproject_opt_out_turns_off_both_paths(project): + _write_pyproject(project, telemetry=False) + assert _paths_enabled() == (False, False) + + +def test_pyproject_opt_out_found_from_subdirectory(project, monkeypatch): + _write_pyproject(project, telemetry=False) + sub = project / "src" / "pkg" + sub.mkdir(parents=True) + monkeypatch.chdir(sub) + assert _paths_enabled() == (False, False) + + +def test_pyproject_telemetry_true_keeps_defaults(project): + _write_pyproject(project, telemetry=True) + assert _paths_enabled() == (True, True) + + +def test_explicit_env_enable_overrides_pyproject(project, monkeypatch): + _write_pyproject(project, telemetry=False) + monkeypatch.setenv("OPENADAPT_TELEMETRY_ENABLED", "true") + assert _paths_enabled() == (True, True) + + +def test_config_file_opt_out_turns_off_both_paths(project): + _write_config_file(False) + assert _paths_enabled() == (False, False) + + +def test_config_file_string_false_is_an_opt_out(project): + _write_config_file("false") + assert _paths_enabled() == (False, False) + + +def test_usage_events_stay_off_in_ci_by_default(project, monkeypatch): + monkeypatch.setenv("CI", "true") + assert _paths_enabled() == (True, False) + monkeypatch.setenv("OPENADAPT_TELEMETRY_IN_CI", "true") + assert posthog._usage_enabled() is True + + +def test_empty_explicit_variable_means_unset(project, monkeypatch): + monkeypatch.setenv("OPENADAPT_TELEMETRY_ENABLED", "") + assert _paths_enabled() == (True, True) + + +def test_opted_out_usage_event_is_not_queued(project): + _write_pyproject(project, telemetry=False) + with patch("openadapt_telemetry.posthog._ensure_worker") as worker: + assert posthog.capture_usage_event("agent_run") is False + worker.assert_not_called() From 928bbc5508a9114151f4c4dd3ac4710b32d8c721 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:23:29 -0400 Subject: [PATCH 03/12] fix: keep host names, arguments and local values out of error reports The README says the package never sends names, file contents, text content or paths with user names. A real Sentry client pointed at a local sink showed four gaps: - server_name carried the host name, which often contains the user's name (for example "alices-macbook"). - extra["sys.argv"] carried the full command line. - Stack frames carried local variable values and the source lines around each frame. Values were only pattern-scrubbed, so free text such as a patient note passed through. - Transactions from track_performance and TelemetrySpan skipped the privacy filter entirely, because only before_send was set. The client now turns off include_local_variables and include_source_context, and the filter drops server_name, sys.argv, frame values and source lines on every event. The same filter runs as before_send_transaction and scrubs span descriptions, data and tags. A caller's before_send_transaction runs first, like before_send. data_collection, which would supersede send_default_pii in newer SDKs, is refused like send_default_pii. scrub_string now also removes user names from home-directory paths, so a path inside an exception message is cleaned the same way as a frame path. The filter also replaced trace_id with [REDACTED], because a 32-character hex ID matches the key-like pattern. That broke the link between errors and traces and put an invalid trace header on the envelope. Trace IDs and the sampling context are now kept. tests/test_sentry_payload.py checks the payloads a real client builds, using an in-memory transport. Co-Authored-By: Claude Opus 5.5 --- src/openadapt_telemetry/client.py | 48 ++++++++++----- src/openadapt_telemetry/privacy.py | 98 ++++++++++++++++++++++++------ tests/test_client.py | 51 ++++++++++++++++ tests/test_privacy.py | 30 +++++++-- tests/test_sentry_payload.py | 98 ++++++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 tests/test_sentry_payload.py diff --git a/src/openadapt_telemetry/client.py b/src/openadapt_telemetry/client.py index d25da41..118324b 100644 --- a/src/openadapt_telemetry/client.py +++ b/src/openadapt_telemetry/client.py @@ -89,6 +89,13 @@ def is_internal_user() -> bool: return False +# Sentry options a caller may not override, with the reason given in the warning. +_ENFORCED_SENTRY_OPTIONS = { + "send_default_pii": "OpenAdapt telemetry enforces send_default_pii=False.", + "data_collection": "OpenAdapt telemetry does not collect personal data.", +} + + def _compose_before_send(base: BeforeSendFn, extra: BeforeSendFn) -> BeforeSendFn: """Compose custom before_send before final privacy filtering.""" @@ -213,26 +220,30 @@ def initialize( if not self._config.dsn: return False - # Always enforce privacy scrubber first; optional custom filter can run afterward. + # The privacy filter always runs last, on errors and on transactions. + # A caller's own filter may run first. base_before_send = create_before_send_filter() - custom_before_send = kwargs.pop("before_send", None) - if custom_before_send is not None: - if not callable(custom_before_send): - raise TypeError("before_send must be callable") + hooks = {} + for hook in ("before_send", "before_send_transaction"): + custom = kwargs.pop(hook, None) + if custom is None: + hooks[hook] = base_before_send + continue + if not callable(custom): + raise TypeError(f"{hook} must be callable") warnings.warn( - "Custom before_send runs before OpenAdapt privacy filtering; final payload is always scrubbed.", + f"Custom {hook} runs before OpenAdapt privacy filtering; final payload is always scrubbed.", stacklevel=2, ) - before_send = _compose_before_send(base_before_send, custom_before_send) - else: - before_send = base_before_send + hooks[hook] = _compose_before_send(base_before_send, custom) - if "send_default_pii" in kwargs: - kwargs.pop("send_default_pii") - warnings.warn( - "Ignoring sentry init override for send_default_pii; OpenAdapt telemetry enforces send_default_pii=False.", - stacklevel=2, - ) + for option, reason in _ENFORCED_SENTRY_OPTIONS.items(): + if option in kwargs: + kwargs.pop(option) + warnings.warn( + f"Ignoring sentry init override for {option}; {reason}", + stacklevel=2, + ) # Initialize Sentry SDK sentry_kwargs = { @@ -242,7 +253,12 @@ def initialize( "traces_sample_rate": self._config.traces_sample_rate, # Enforced for privacy safety across all callers/configs. "send_default_pii": False, - "before_send": before_send, + # Local variable values and source lines can hold application data. + # The privacy filter drops them too; these options stop the SDK + # from collecting them in the first place. + "include_local_variables": False, + "include_source_context": False, + **hooks, } # Merge in any additional kwargs diff --git a/src/openadapt_telemetry/privacy.py b/src/openadapt_telemetry/privacy.py index 027d10b..0eff532 100644 --- a/src/openadapt_telemetry/privacy.py +++ b/src/openadapt_telemetry/privacy.py @@ -242,12 +242,17 @@ def is_sensitive_key(key: str) -> bool: def scrub_string(value: str) -> str: """Scrub sensitive patterns from a string value. + Removes the user name from home-directory paths, then replaces emails, + phone numbers, card numbers, SSNs, bearer tokens and long key-like strings + with ``[REDACTED]``. + Args: value: The string to scrub. Returns: The scrubbed string with sensitive patterns replaced. """ + value = sanitize_path(value) for pattern in SENSITIVE_PATTERNS: value = pattern.sub("[REDACTED]", value) return value @@ -334,12 +339,33 @@ def scrub_list(data: List[Any], scrub_values: bool = False) -> List[Any]: return result +# Stack-frame fields that can carry application data rather than code location: +# local variable values and the source lines around each frame. +FRAME_CONTENT_KEYS = ("vars", "pre_context", "context_line", "post_context") + + +def _scrub_stacktrace(stacktrace: Any) -> None: + """Keep only code locations in stack frames, with user names removed.""" + if not isinstance(stacktrace, dict) or not isinstance(stacktrace.get("frames"), list): + return + for frame in stacktrace["frames"]: + if not isinstance(frame, dict): + continue + for key in FRAME_CONTENT_KEYS: + frame.pop(key, None) + if isinstance(frame.get("filename"), str): + frame["filename"] = sanitize_path(frame["filename"]) + if isinstance(frame.get("abs_path"), str): + frame["abs_path"] = sanitize_path(frame["abs_path"]) + + def scrub_exception_data(exception_data: Dict[str, Any]) -> None: """Scrub PII from Sentry exception data in place. - This modifies the exception data dictionary to sanitize: - - File paths in stack traces - - Variable values that may contain PII + This modifies the exception data dictionary to: + - Remove user names from file paths in stack traces + - Drop local variable values and source lines from stack frames + - Scrub sensitive patterns from exception messages Args: exception_data: The Sentry exception data dictionary. @@ -348,26 +374,47 @@ def scrub_exception_data(exception_data: Dict[str, Any]) -> None: return for value in exception_data["values"]: - # Sanitize stack trace file paths - if "stacktrace" in value and "frames" in value["stacktrace"]: - for frame in value["stacktrace"]["frames"]: - if "filename" in frame: - frame["filename"] = sanitize_path(frame["filename"]) - if "abs_path" in frame: - frame["abs_path"] = sanitize_path(frame["abs_path"]) - - # Scrub local variables if present - if "vars" in frame and isinstance(frame["vars"], dict): - frame["vars"] = scrub_dict(frame["vars"], deep=True, scrub_values=True) + _scrub_stacktrace(value.get("stacktrace")) # Scrub exception message for sensitive patterns if "value" in value and isinstance(value["value"], str): value["value"] = scrub_string(value["value"]) +# Trace identifiers are random hex. The key-like pattern in SENSITIVE_PATTERNS +# would replace them, which breaks the link between errors and traces. +TRACE_CONTEXT_KEYS = ("trace_id", "span_id", "parent_span_id", "dynamic_sampling_context") + + +def _scrub_contexts(contexts: Dict[str, Any]) -> Dict[str, Any]: + """Scrub event contexts but keep trace identifiers intact.""" + scrubbed = scrub_dict(contexts, deep=True, scrub_values=True) + trace = contexts.get("trace") + if isinstance(trace, dict) and isinstance(scrubbed.get("trace"), dict): + for key in TRACE_CONTEXT_KEYS: + if key in trace: + scrubbed["trace"][key] = trace[key] + return scrubbed + + +def _scrub_spans(spans: List[Any]) -> None: + """Scrub span descriptions, data and tags in a transaction in place.""" + for span in spans: + if not isinstance(span, dict): + continue + if isinstance(span.get("description"), str): + span["description"] = scrub_string(span["description"]) + if isinstance(span.get("data"), dict): + span["data"] = scrub_dict(span["data"], deep=True, scrub_values=True) + if isinstance(span.get("tags"), dict): + span["tags"] = _scrub_tags(span["tags"]) + + def create_before_send_filter(): """Create a before_send filter function for Sentry. + The same function also serves as ``before_send_transaction``. + Returns: A function suitable for use as Sentry's before_send callback. """ @@ -376,12 +423,23 @@ def create_before_send_filter(): def before_send(event: Event, hint: Hint) -> Optional[Event]: """Filter and sanitize events before sending to Sentry/GlitchTip.""" try: + # The host name often contains the user's name (for example + # "alices-macbook"), so it is never sent. + event.pop("server_name", None) + _scrub_top_level_messages(event) # Scrub exception data if "exception" in event: scrub_exception_data(event["exception"]) + # Thread stack traces (attach_stacktrace) get the same treatment. + threads = event.get("threads") + if isinstance(threads, dict) and isinstance(threads.get("values"), list): + for thread in threads["values"]: + if isinstance(thread, dict): + _scrub_stacktrace(thread.get("stacktrace")) + # Scrub breadcrumbs if "breadcrumbs" in event and "values" in event["breadcrumbs"]: for breadcrumb in event["breadcrumbs"]["values"]: @@ -392,13 +450,19 @@ def before_send(event: Event, hint: Hint) -> Optional[Event]: breadcrumb["data"], deep=True, scrub_values=True ) - # Scrub extra data + # Scrub extra data. Command-line arguments can name files or carry + # user text, so the SDK's sys.argv entry is dropped. if "extra" in event and isinstance(event["extra"], dict): - event["extra"] = scrub_dict(event["extra"], deep=True, scrub_values=True) + extra = {k: v for k, v in event["extra"].items() if k != "sys.argv"} + event["extra"] = scrub_dict(extra, deep=True, scrub_values=True) # Scrub contexts if "contexts" in event and isinstance(event["contexts"], dict): - event["contexts"] = scrub_dict(event["contexts"], deep=True, scrub_values=True) + event["contexts"] = _scrub_contexts(event["contexts"]) + + # Scrub transaction spans + if isinstance(event.get("spans"), list): + _scrub_spans(event["spans"]) # Scrub tags (allowlist + scrub values) if "tags" in event and isinstance(event["tags"], dict): diff --git a/tests/test_client.py b/tests/test_client.py index cc2606f..995da4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -327,6 +327,57 @@ def test_send_default_pii_override_is_ignored(self, mock_sentry): assert mock_sentry.init.call_args.kwargs["send_default_pii"] is False + @patch("openadapt_telemetry.client.sentry_sdk") + def test_data_collection_override_is_ignored(self, mock_sentry): + """data_collection would supersede send_default_pii, so it is refused.""" + with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False): + TelemetryClient.reset_instance() + client = TelemetryClient.get_instance() + with pytest.warns(UserWarning, match="Ignoring sentry init override for data_collection"): + client.initialize( + dsn="https://test@example.com/1", + data_collection={"user_info": True}, + ) + + assert "data_collection" not in mock_sentry.init.call_args.kwargs + + @patch("openadapt_telemetry.client.sentry_sdk") + def test_privacy_filter_covers_transactions(self, mock_sentry): + """Transactions get the same filter as errors, and frames carry no values.""" + with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False): + TelemetryClient.reset_instance() + client = TelemetryClient.get_instance() + client.initialize(dsn="https://test@example.com/1") + + kwargs = mock_sentry.init.call_args.kwargs + assert kwargs["include_local_variables"] is False + assert kwargs["include_source_context"] is False + transaction = {"server_name": "alices-laptop", "tags": {"password": "x"}} + output = kwargs["before_send_transaction"](transaction, {}) + assert output is not None + assert "server_name" not in output + assert "password" not in output["tags"] + + @patch("openadapt_telemetry.client.sentry_sdk") + def test_custom_before_send_transaction_runs_before_privacy_filter(self, mock_sentry): + """A caller's before_send_transaction cannot bypass the privacy filter.""" + + def custom(event, hint): + event["server_name"] = "alices-laptop" + return event + + with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False): + TelemetryClient.reset_instance() + client = TelemetryClient.get_instance() + with pytest.warns(UserWarning, match="Custom before_send_transaction runs before"): + client.initialize( + dsn="https://test@example.com/1", + before_send_transaction=custom, + ) + + hook = mock_sentry.init.call_args.kwargs["before_send_transaction"] + assert "server_name" not in hook({}, {}) + def test_initialize_rejects_non_callable_before_send(self): """Non-callable before_send should raise TypeError.""" with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False): diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 67f4f31..4395360 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -254,8 +254,8 @@ def test_exception_message_scrubbing(self): assert "user@example.com" not in exception_data["values"][0]["value"] - def test_local_variables_scrubbing(self): - """Local variables in stack frames should be scrubbed.""" + def test_local_variables_and_source_lines_dropped(self): + """Stack frames keep the code location only, not values or source lines.""" exception_data = { "values": [ { @@ -263,10 +263,15 @@ def test_local_variables_scrubbing(self): "frames": [ { "filename": "app.py", + "function": "save", + "lineno": 12, "vars": { "password": "secret123", - "username": "john", + "note": "patient text", }, + "pre_context": ["note = read()"], + "context_line": "save(note)", + "post_context": ["return"], }, ], }, @@ -276,8 +281,23 @@ def test_local_variables_scrubbing(self): scrub_exception_data(exception_data) frame = exception_data["values"][0]["stacktrace"]["frames"][0] - assert frame["vars"]["password"] == "[REDACTED]" - assert frame["vars"]["username"] == "john" + assert frame == {"filename": "app.py", "function": "save", "lineno": 12} + + def test_home_path_in_exception_message_sanitized(self): + """A path in an exception message loses the user name.""" + exception_data = { + "values": [ + { + "type": "FileNotFoundError", + "value": "No such file: '/Users/john/notes/visit.txt'", + }, + ], + } + scrub_exception_data(exception_data) + + assert exception_data["values"][0]["value"] == ( + "No such file: '/Users//notes/visit.txt'" + ) class TestAnonymizeIdentifier: diff --git a/tests/test_sentry_payload.py b/tests/test_sentry_payload.py new file mode 100644 index 0000000..3d59679 --- /dev/null +++ b/tests/test_sentry_payload.py @@ -0,0 +1,98 @@ +"""End-to-end checks on what the Sentry SDK would send, using a capturing transport.""" + +from __future__ import annotations + +import re + +import pytest +import sentry_sdk +from sentry_sdk.transport import Transport + +from openadapt_telemetry import TelemetryClient, TelemetrySpan, get_telemetry, track_performance + +HEX32 = re.compile(r"^[0-9a-f]{32}$") + + +class CapturingTransport(Transport): + """Keep every envelope in memory instead of sending it.""" + + envelopes: list = [] + + def __init__(self, options=None): + super().__init__(options) + CapturingTransport.envelopes = [] + + def capture_envelope(self, envelope): + CapturingTransport.envelopes.append(envelope) + + +def _events(kind: str) -> list[dict]: + found = [] + for envelope in CapturingTransport.envelopes: + for item in envelope.items: + if item.headers.get("type") == kind: + found.append(item.payload.json) + return found + + +@pytest.fixture +def live_client(clean_env): + """A real Sentry client whose transport keeps events in memory.""" + TelemetryClient.reset_instance() + client = get_telemetry() + assert client.initialize( + dsn="https://public@example.invalid/1", + package_name="openadapt-test", + package_version="1.0.0", + traces_sample_rate=1.0, + transport=CapturingTransport, + ) + yield client + sentry_sdk.flush() + sentry_sdk.init() # replace the client with one that sends nothing + TelemetryClient.reset_instance() + + +def _fail(note: str) -> None: + path = "/Users/alice/records/" + note + raise FileNotFoundError(f"No such file: {path!r}") + + +def test_error_event_carries_no_personal_or_application_data(live_client): + try: + _fail("visit-notes.txt") + except FileNotFoundError as exc: + assert live_client.capture_exception(exc) + sentry_sdk.flush() + + (event,) = _events("event") + assert "server_name" not in event + assert "sys.argv" not in event.get("extra", {}) + (exception,) = event["exception"]["values"] + assert exception["value"] == "No such file: '/Users//records/visit-notes.txt'" + for frame in exception["stacktrace"]["frames"]: + assert not {"vars", "pre_context", "context_line", "post_context"} & set(frame) + assert "alice" not in frame.get("abs_path", "") + assert HEX32.match(event["contexts"]["trace"]["trace_id"]) + assert event["tags"]["package"] == "openadapt-test" + + +def test_transactions_go_through_the_privacy_filter(live_client): + @track_performance("indexing.build") + def build(): + with TelemetrySpan("indexing", "load") as span: + span.set_data("source", "/home/alice/data.csv") + span.set_data("api_key", "abc") + return 1 + + build() + sentry_sdk.flush() + + (transaction,) = _events("transaction") + assert transaction["transaction"] == "indexing.build" + assert "server_name" not in transaction + assert "sys.argv" not in transaction.get("extra", {}) + assert HEX32.match(transaction["contexts"]["trace"]["trace_id"]) + (span,) = transaction["spans"] + assert span["data"]["source"] == "/home//data.csv" + assert span["data"]["api_key"] == "[REDACTED]" From b6dd652ee0488f514f8f483fe012834107d7c597 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:24:32 -0400 Subject: [PATCH 04/12] fix: require sentry-sdk 2.15 and drop deprecated calls pyproject.toml allowed sentry-sdk 2.0.0, but the code needs 2.15.0: - track_errors calls sentry_sdk.get_current_scope(), added in 2.12.0. On older SDKs the decorator raised AttributeError in place of the caller's exception. - TelemetrySpan passes name= to start_span, accepted from 2.15.0. On older SDKs every span raised TypeError. Raise the floor to 2.15.0 and run the tests against it in CI. Also replace calls that newer SDKs or Python deprecate: - push_scope() becomes new_scope(). - set_measurement() becomes set_data() for duration_ms. - send_default_pii is no longer passed to sentry_sdk.init. The SDK default is off, a caller still cannot override it, and a test checks should_send_default_pii() is False on a real client. - TelemetryEvent timestamps use datetime.now(timezone.utc) in place of datetime.utcnow(). The test suite now passes with DeprecationWarning treated as an error. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 5 +++++ pyproject.toml | 2 +- src/openadapt_telemetry/client.py | 9 +++++---- src/openadapt_telemetry/decorators.py | 4 ++-- src/openadapt_telemetry/events.py | 4 ++-- tests/test_client.py | 4 ++-- tests/test_sentry_payload.py | 4 ++++ 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c129b8..0201d65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,3 +29,8 @@ jobs: - name: Pytest run: PYTHONPATH=src uv run pytest -q + + - name: Pytest with the oldest supported sentry-sdk + run: | + uv pip install "sentry-sdk==2.15.0" + PYTHONPATH=src uv run --no-sync pytest -q diff --git a/pyproject.toml b/pyproject.toml index 99bb782..4136124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ classifiers = [ ] dependencies = [ - "sentry-sdk>=2.0.0", + "sentry-sdk>=2.15.0", ] [project.optional-dependencies] diff --git a/src/openadapt_telemetry/client.py b/src/openadapt_telemetry/client.py index 118324b..bad7e46 100644 --- a/src/openadapt_telemetry/client.py +++ b/src/openadapt_telemetry/client.py @@ -91,7 +91,7 @@ def is_internal_user() -> bool: # Sentry options a caller may not override, with the reason given in the warning. _ENFORCED_SENTRY_OPTIONS = { - "send_default_pii": "OpenAdapt telemetry enforces send_default_pii=False.", + "send_default_pii": "OpenAdapt telemetry never sends default PII.", "data_collection": "OpenAdapt telemetry does not collect personal data.", } @@ -251,8 +251,9 @@ def initialize( "environment": self._config.environment, "sample_rate": self._config.sample_rate, "traces_sample_rate": self._config.traces_sample_rate, - # Enforced for privacy safety across all callers/configs. - "send_default_pii": False, + # send_default_pii is left at the SDK default (off) rather than + # passed, because newer SDKs deprecate the option. Callers cannot + # override it (see _ENFORCED_SENTRY_OPTIONS). # Local variable values and source lines can hold application data. # The privacy filter drops them too; these options stop the SDK # from collecting them in the first place. @@ -336,7 +337,7 @@ def capture_event( properties = properties or {} - with sentry_sdk.push_scope() as scope: + with sentry_sdk.new_scope() as scope: for key, value in properties.items(): scope.set_extra(key, value) return sentry_sdk.capture_message( diff --git a/src/openadapt_telemetry/decorators.py b/src/openadapt_telemetry/decorators.py index 1e6d714..5004732 100644 --- a/src/openadapt_telemetry/decorators.py +++ b/src/openadapt_telemetry/decorators.py @@ -62,7 +62,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: raise finally: duration = time.perf_counter() - start - sentry_sdk.set_measurement("duration_ms", duration * 1000, "millisecond") + transaction.set_data("duration_ms", duration * 1000) return wrapper # type: ignore @@ -234,7 +234,7 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: else: self._span.set_status("ok") - self._span.set_measurement("duration_ms", duration * 1000, "millisecond") + self._span.set_data("duration_ms", duration * 1000) self._span.__exit__(exc_type, exc_val, exc_tb) def set_tag(self, key: str, value: str) -> None: diff --git a/src/openadapt_telemetry/events.py b/src/openadapt_telemetry/events.py index 9f46d4b..c93ef75 100644 --- a/src/openadapt_telemetry/events.py +++ b/src/openadapt_telemetry/events.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any, Dict, Optional @@ -60,7 +60,7 @@ class TelemetryEvent: severity: EventSeverity = EventSeverity.INFO properties: Dict[str, Any] = field(default_factory=dict) tags: Dict[str, str] = field(default_factory=dict) - timestamp: datetime = field(default_factory=datetime.utcnow) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def send(self) -> Optional[str]: """Send this event to the telemetry backend. diff --git a/tests/test_client.py b/tests/test_client.py index 995da4d..3f22dde 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -217,7 +217,7 @@ def test_initialize_with_dsn(self, mock_sentry): assert client.initialized is True mock_sentry.init.assert_called_once() init_kwargs = mock_sentry.init.call_args.kwargs - assert init_kwargs["send_default_pii"] is False + assert "send_default_pii" not in init_kwargs # SDK default is off @patch("openadapt_telemetry.client.sentry_sdk") def test_capture_exception_when_enabled(self, mock_sentry): @@ -325,7 +325,7 @@ def test_send_default_pii_override_is_ignored(self, mock_sentry): send_default_pii=True, ) - assert mock_sentry.init.call_args.kwargs["send_default_pii"] is False + assert "send_default_pii" not in mock_sentry.init.call_args.kwargs @patch("openadapt_telemetry.client.sentry_sdk") def test_data_collection_override_is_ignored(self, mock_sentry): diff --git a/tests/test_sentry_payload.py b/tests/test_sentry_payload.py index 3d59679..5a25b1f 100644 --- a/tests/test_sentry_payload.py +++ b/tests/test_sentry_payload.py @@ -58,6 +58,10 @@ def _fail(note: str) -> None: raise FileNotFoundError(f"No such file: {path!r}") +def test_sdk_default_pii_is_off(live_client): + assert sentry_sdk.get_client().should_send_default_pii() is False + + def test_error_event_carries_no_personal_or_application_data(live_client): try: _fail("visit-notes.txt") From 02ce2fd7a3db64cbbbb4ac3110268e2dcf902a88 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:26:06 -0400 Subject: [PATCH 05/12] fix: scrub usage-event property values, not only keys capture_usage_event dropped properties whose key looked sensitive (password, token, email) but sent every value as given, so {"owner": "alice@example.com"} or a path under /Users/alice reached PostHog unchanged. Property values now go through the same string scrubbing as error reports. A value that is fully redacted is dropped, as before; a value that is partly redacted, such as a path, is sent with the sensitive part replaced. Co-Authored-By: Claude Opus 5.5 --- src/openadapt_telemetry/posthog.py | 3 ++- tests/test_posthog.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/openadapt_telemetry/posthog.py b/src/openadapt_telemetry/posthog.py index 4835a4b..1269941 100644 --- a/src/openadapt_telemetry/posthog.py +++ b/src/openadapt_telemetry/posthog.py @@ -92,10 +92,11 @@ def _normalize_value(value: Any) -> Any: def _sanitize_properties(properties: dict[str, Any] | None) -> dict[str, Any]: + """Drop sensitive keys and scrub string values (emails, tokens, home paths).""" if not properties: return {} normalized = {str(k): _normalize_value(v) for k, v in properties.items() if str(k).strip()} - redacted = scrub_dict(normalized, deep=True, scrub_values=False) + redacted = scrub_dict(normalized, deep=True, scrub_values=True) return {k: v for k, v in redacted.items() if v != "[REDACTED]"} diff --git a/tests/test_posthog.py b/tests/test_posthog.py index b854ca5..ab42c2b 100644 --- a/tests/test_posthog.py +++ b/tests/test_posthog.py @@ -73,3 +73,28 @@ def test_capture_event_scrubs_sensitive_properties() -> None: assert props["entrypoint"] == "oa evals run" assert "api_key" not in props assert "password" not in props + + +def test_capture_event_scrubs_property_values() -> None: + queue = _CaptureQueue() + with patch.dict( + os.environ, + { + "OPENADAPT_TELEMETRY_ENABLED": "true", + "OPENADAPT_TELEMETRY_DISTINCT_ID": "test-id", + }, + clear=False, + ): + with patch("openadapt_telemetry.posthog._ensure_worker", return_value=queue): + assert posthog.capture_event( + "demo_recorded", + { + "steps": 12, + "owner": "alice@example.com", + "source": "/Users/alice/demos/intake.json", + }, + ) + props = queue.payload["properties"] + assert props["steps"] == 12 + assert "owner" not in props + assert props["source"] == "/Users//demos/intake.json" From c41504d1337753e8d57a62f75a8c6e1015904e32 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:27:54 -0400 Subject: [PATCH 06/12] fix: scrub log arguments and drop URL query strings in error reports Once initialize() runs, the SDK's logging integration turns ERROR log records into events and keeps recent log lines and HTTP requests as breadcrumbs. Two parts of those reached the server unscrubbed: - logentry.params, the arguments of a call such as logging.error("save failed for %s", path). The formatted message was scrubbed, but the raw arguments, including emails and home paths, were sent next to it. - The http.query and http.fragment fields of HTTP breadcrumbs, which often carry tokens or record IDs (for example ?token=...). Log arguments are now scrubbed like other strings, whether the SDK gives them as a tuple or a list. Query strings and fragments are dropped from breadcrumbs, and query_string and cookies from request data. The URL path is kept. Co-Authored-By: Claude Opus 5.5 --- src/openadapt_telemetry/privacy.py | 24 ++++++++++++++++++++---- tests/test_sentry_payload.py | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/openadapt_telemetry/privacy.py b/src/openadapt_telemetry/privacy.py index 0eff532..95bda36 100644 --- a/src/openadapt_telemetry/privacy.py +++ b/src/openadapt_telemetry/privacy.py @@ -144,6 +144,12 @@ def _scrub_top_level_messages(event: Dict[str, Any]) -> None: logentry["message"] = scrub_string(logentry["message"]) if isinstance(logentry.get("formatted"), str): logentry["formatted"] = scrub_string(logentry["formatted"]) + # Arguments of a log call such as logging.error("failed for %s", path). + params = logentry.get("params") + if isinstance(params, (list, tuple)): + logentry["params"] = scrub_list(list(params), scrub_values=True) + elif isinstance(params, dict): + logentry["params"] = scrub_dict(params, deep=True, scrub_values=True) def _is_safe_tag_key(key: Any) -> bool: @@ -381,6 +387,10 @@ def scrub_exception_data(exception_data: Dict[str, Any]) -> None: value["value"] = scrub_string(value["value"]) +# HTTP breadcrumb fields that hold the query string or fragment of a URL. +URL_DETAIL_KEYS = ("http.query", "http.fragment") + + # Trace identifiers are random hex. The key-like pattern in SENSITIVE_PATTERNS # would replace them, which breaks the link between errors and traces. TRACE_CONTEXT_KEYS = ("trace_id", "span_id", "parent_span_id", "dynamic_sampling_context") @@ -440,15 +450,19 @@ def before_send(event: Event, hint: Hint) -> Optional[Event]: if isinstance(thread, dict): _scrub_stacktrace(thread.get("stacktrace")) - # Scrub breadcrumbs + # Scrub breadcrumbs. Query strings and fragments of HTTP requests + # often carry tokens or record IDs, so they are dropped. if "breadcrumbs" in event and "values" in event["breadcrumbs"]: for breadcrumb in event["breadcrumbs"]["values"]: if "message" in breadcrumb and isinstance(breadcrumb["message"], str): breadcrumb["message"] = scrub_string(breadcrumb["message"]) if "data" in breadcrumb and isinstance(breadcrumb["data"], dict): - breadcrumb["data"] = scrub_dict( - breadcrumb["data"], deep=True, scrub_values=True - ) + data = { + k: v + for k, v in breadcrumb["data"].items() + if k not in URL_DETAIL_KEYS + } + breadcrumb["data"] = scrub_dict(data, deep=True, scrub_values=True) # Scrub extra data. Command-line arguments can name files or carry # user text, so the SDK's sys.argv entry is dropped. @@ -471,6 +485,8 @@ def before_send(event: Event, hint: Hint) -> Optional[Event]: # Scrub request data (if present) if "request" in event and isinstance(event["request"], dict): request = event["request"] + request.pop("query_string", None) + request.pop("cookies", None) if "headers" in request: if isinstance(request["headers"], dict): request["headers"] = scrub_dict( diff --git a/tests/test_sentry_payload.py b/tests/test_sentry_payload.py index 5a25b1f..fbfad56 100644 --- a/tests/test_sentry_payload.py +++ b/tests/test_sentry_payload.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import re import pytest @@ -100,3 +101,22 @@ def build(): (span,) = transaction["spans"] assert span["data"]["source"] == "/home//data.csv" assert span["data"]["api_key"] == "[REDACTED]" + + +def test_log_arguments_and_url_queries_are_scrubbed(live_client): + logging.getLogger("openadapt-test").error( + "save failed for %s (%s)", "/Users/alice/out.json", "alice@example.com" + ) + sentry_sdk.add_breadcrumb( + type="http", + category="httplib", + data={"url": "https://emr.example/api/visits", "http.query": "token=abc123"}, + ) + live_client.capture_message("after request") + sentry_sdk.flush() + + log_event, message_event = _events("event") + assert log_event["logentry"]["params"] == ["/Users//out.json", "[REDACTED]"] + assert "alice" not in log_event["logentry"].get("formatted", "") + (http,) = [b for b in message_event["breadcrumbs"]["values"] if b.get("type") == "http"] + assert http["data"] == {"url": "https://emr.example/api/visits"} From 2a49e0b454c7df1133221086c37b2f0bafd0ebdb Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:28:24 -0400 Subject: [PATCH 07/12] fix: honor "internal": true in the telemetry config file The README's config file example listed "internal", and load_config read it, but the client tagged reports as internal only from environment variables and CI detection. Setting "internal": true in ~/.config/openadapt/telemetry.json did nothing. The client now tags reports as internal when the config file says so, in addition to the existing signals. Co-Authored-By: Claude Opus 5.5 --- src/openadapt_telemetry/client.py | 7 ++++++- tests/test_opt_out.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/openadapt_telemetry/client.py b/src/openadapt_telemetry/client.py index bad7e46..5d12814 100644 --- a/src/openadapt_telemetry/client.py +++ b/src/openadapt_telemetry/client.py @@ -126,7 +126,7 @@ def __init__(self) -> None: self._initialized = False self._config: Optional[TelemetryConfig] = None self._enabled = self._check_enabled() - self._internal = is_internal_user() + self._internal = self._check_internal() @classmethod def get_instance(cls) -> "TelemetryClient": @@ -155,6 +155,10 @@ def _check_enabled(self) -> bool: self._config = load_config() return bool(self._config.enabled) + def _check_internal(self) -> bool: + """Return True for internal usage: env flags, CI, or ``"internal": true`` in the config file.""" + return is_internal_user() or bool(self._config is not None and self._config.internal) + @property def enabled(self) -> bool: """Whether telemetry is enabled.""" @@ -212,6 +216,7 @@ def initialize( if environment: self._config.environment = environment self._enabled = bool(self._config.enabled) + self._internal = self._check_internal() if not self._enabled: return False diff --git a/tests/test_opt_out.py b/tests/test_opt_out.py index 330d254..9eec796 100644 --- a/tests/test_opt_out.py +++ b/tests/test_opt_out.py @@ -126,3 +126,10 @@ def test_opted_out_usage_event_is_not_queued(project): with patch("openadapt_telemetry.posthog._ensure_worker") as worker: assert posthog.capture_usage_event("agent_run") is False worker.assert_not_called() + + +def test_config_file_internal_flag_tags_reports(project): + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + config.CONFIG_FILE.write_text(json.dumps({"internal": True})) + TelemetryClient.reset_instance() + assert TelemetryClient.get_instance().internal is True From edc99c0c3ece591a3c95f512d156dd0d1e5651bb Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:33:47 -0400 Subject: [PATCH 08/12] docs: lead the README with what the package does and a quick start The README opened with a feature list, and its examples called placeholder functions (risky_operation, transform, save_demo) that don't exist. It now says on the first screen what the package sends, who it is for, and how to turn it off, then gives a quick start (install, set a DSN, report an exception) and a doctest that shows the filter without a DSN. - "Turn it off" lists the four opt-outs and their precedence. The pyproject.toml opt-out, added in 0.3.0, was missing from the README. - "What is sent" is one table for error reports and usage events: destination, trigger, contents and what is removed. It now mentions what the SDK collects on its own (log lines, HTTP request URLs, installed packages), which the old "What We Collect" table left out. - Limits are in one short section: pattern scrubbing misses names and free text, URL paths are kept, and servers see connection IPs. - The failure-signal example and its boundary statement, internal-usage tagging, and the full filter rules moved to docs/COMPLIANCE.md, linked once. - The GlitchTip query example is gone. docs/COMPLIANCE.md describes the internal and ci tag values instead. tests/test_readme.py now runs the README's examples: the quick start against an in-memory Sentry transport (the received message must match the one the README quotes), the no-DSN case, the doctest, the usage event, the decorators, and the failure-signal example in docs/COMPLIANCE.md. It also fails if process terms such as "admitted" or "signed ledger" appear in the README. The in-memory transport moved to conftest.py so the payload tests share it. CLAUDE.md's internal-usage list and package layout matched an older version of the code; both are updated. The PyPI summary is plainer. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 27 +-- README.md | 285 ++++++++++------------------ docs/COMPLIANCE.md | 163 ++++++++++++++++ pyproject.toml | 2 +- src/openadapt_telemetry/__init__.py | 9 +- tests/conftest.py | 42 ++++ tests/test_readme.py | 135 ++++++++++++- tests/test_sentry_payload.py | 46 +---- 8 files changed, 465 insertions(+), 244 deletions(-) create mode 100644 docs/COMPLIANCE.md diff --git a/CLAUDE.md b/CLAUDE.md index 6c573d8..3e5b60e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ pytest tests/ -v pytest tests/ --cov=openadapt_telemetry # Run linting -ruff check src/ +ruff check . ``` ## Package Structure @@ -31,13 +31,16 @@ openadapt-telemetry/ │ ├── client.py # TelemetryClient (Sentry wrapper) │ ├── privacy.py # PII filtering and scrubbing │ ├── decorators.py # Convenience decorators -│ └── events.py # Event types and helpers +│ ├── events.py # Event types and helpers +│ ├── posthog.py # PostHog usage events +│ └── failure_signals.py # Closed-schema automation failure signals ├── tests/ -│ ├── conftest.py # Pytest fixtures -│ ├── test_client.py # Client tests -│ ├── test_config.py # Configuration tests -│ ├── test_decorators.py # Decorator tests -│ └── test_privacy.py # Privacy filtering tests +│ ├── conftest.py # Fixtures, including an in-memory Sentry transport +│ ├── test_sentry_payload.py # What a real Sentry client sends +│ ├── test_opt_out.py # The shared opt-out rule +│ ├── test_readme.py # Runs the README and docs examples +│ └── ... +├── docs/COMPLIANCE.md # Data-handling reference for reviewers ├── pyproject.toml # Package metadata ├── README.md # Documentation └── LICENSE # MIT License @@ -121,12 +124,12 @@ Config file location: `~/.config/openadapt/telemetry.json` ## Internal User Detection -The package automatically detects internal/developer usage via: +The package detects internal/developer usage via: 1. `OPENADAPT_INTERNAL=true` environment variable 2. `OPENADAPT_DEV=true` environment variable -3. Running from source (not frozen executable) -4. Git repository present in working directory -5. CI environment detected (GitHub Actions, GitLab CI, etc.) +3. CI environment detected (GitHub Actions, GitLab CI, etc.) +4. `OPENADAPT_INTERNAL_FROM_GIT=true` and a `.git` directory in the working directory or its parent +5. `"internal": true` in the config file Internal users are tagged with `internal: true` for filtering in GlitchTip. @@ -164,7 +167,7 @@ except ImportError: ## Dependencies Core: -- `sentry-sdk>=2.0.0` - Sentry/GlitchTip SDK +- `sentry-sdk>=2.15.0` - Sentry/GlitchTip SDK (CI also tests against 2.15.0) Dev: - `pytest>=8.0.0` - Testing diff --git a/README.md b/README.md index 6610ef3..a08fca3 100644 --- a/README.md +++ b/README.md @@ -5,249 +5,164 @@ [![PyPI version](https://img.shields.io/pypi/v/openadapt-telemetry.svg)](https://pypi.org/project/openadapt-telemetry/) [![Downloads](https://img.shields.io/pypi/dm/openadapt-telemetry.svg)](https://pypi.org/project/openadapt-telemetry/) -Unified telemetry and error tracking for OpenAdapt packages. +Error reports and usage counts for OpenAdapt packages. Data is filtered on the +user's machine before it is sent, and `DO_NOT_TRACK=1` turns everything off. -## Features +- **If you run OpenAdapt**, see [what is sent](#what-is-sent) and + [how to turn it off](#turn-it-off). +- **If you build an OpenAdapt package**, the quick start below sends error + reports to your GlitchTip or Sentry project. Other Python projects can use it + the same way with their own DSN. -- **Unified Error Tracking**: Consistent error reporting across all OpenAdapt packages -- **Usage Counters (PostHog)**: Lightweight product usage events for adoption metrics -- **Privacy-First Design**: Automatic PII scrubbing and path sanitization -- **Configurable Opt-Out**: Respects `DO_NOT_TRACK` and custom environment variables -- **Internal Usage Tagging**: Explicit flags + CI detection with optional git heuristic -- **GlitchTip/Sentry Compatible**: Uses the Sentry SDK for maximum compatibility -- **Failure recurrence without customer evidence**: Closed categorical failure - signatures omit application, tenant, workflow, step, text, input, screenshot, - origin, and exception details - -## Installation +## Quick start ```bash pip install openadapt-telemetry +export OPENADAPT_TELEMETRY_DSN="https://@app.glitchtip.com/" ``` -Or with development dependencies: +```python +from openadapt_telemetry import get_telemetry + +telemetry = get_telemetry() +telemetry.initialize(package_name="mypackage", package_version="0.1.0") -```bash -pip install openadapt-telemetry[dev] +try: + open("/Users/alice/notes.txt") +except OSError as exc: + telemetry.capture_exception(exc) ``` -## Quick Start +GlitchTip receives +`FileNotFoundError: [Errno 2] No such file or directory: '/Users//notes.txt'` +with the file, function and line of each stack frame. Without a DSN, or after +an opt-out, `initialize()` returns `False` and nothing is sent. -### Initialize Telemetry +To see the filter without a DSN: ```python -from openadapt_telemetry import get_telemetry - -# Initialize once at package startup -get_telemetry().initialize( - dsn="https://xxx@app.glitchtip.com/XXXX", - package_name="openadapt-mypackage", - package_version="0.1.0", -) +>>> from openadapt_telemetry import scrub_string +>>> scrub_string("Saved /Users/alice/notes.txt for alice@example.com") +'Saved /Users//notes.txt for [REDACTED]' ``` -### Capture Exceptions +## Turn it off -```python -from openadapt_telemetry import get_telemetry +Any one of these turns off both error reports and usage events: -try: - risky_operation() -except Exception as e: - get_telemetry().capture_exception(e) - raise -``` +| Where | Setting | +|-------|---------| +| Environment | `DO_NOT_TRACK=1` (or `true`, `yes`, `on`) | +| Environment | `OPENADAPT_TELEMETRY_ENABLED=false` | +| `pyproject.toml` of a repository, for everyone who works in it | `telemetry = false` under `[tool.openadapt]` | +| `~/.config/openadapt/telemetry.json` | `{"enabled": false}` | -### Capture Usage Events (PostHog) +`DO_NOT_TRACK` overrides everything else. `OPENADAPT_TELEMETRY_ENABLED=true` +overrides the two files. Usage events are also off in CI unless +`OPENADAPT_TELEMETRY_IN_CI=true` or `OPENADAPT_TELEMETRY_ENABLED=true`. Code can +check the result with `openadapt_telemetry.is_telemetry_allowed()`. -```python -from openadapt_telemetry import capture_usage_event +## What is sent -capture_usage_event( - "agent_run", - properties={"entrypoint": "oa evals run", "mode": "live"}, - package_name="openadapt-evals", -) -``` +| | Error reports | Usage events | +|-|---------------|--------------| +| Destination | The GlitchTip or Sentry project named by the DSN. With no DSN, nothing is sent. | OpenAdapt's PostHog project, unless `OPENADAPT_POSTHOG_PROJECT_API_KEY` names another one | +| When | After `initialize()`: on `capture_exception` or `capture_message`, an uncaught exception, an `ERROR` log record, or a `track_*` decorator or `TelemetrySpan` | When a package calls `capture_usage_event` | +| Contents | Exception type and message; file, function and line of each stack frame; recent log lines and HTTP request URLs; timings; package, Python and OS versions; names and versions of installed Python packages; whether the run looks internal or CI | Event name; package, Python and OS versions; a timestamp; a random install ID kept in `~/.openadapt/telemetry_distinct_id`; the properties the package passes | +| Removed before sending | User names in home-directory paths; emails, phone, card and social security numbers, bearer tokens and long key-like strings; values under keys such as `password`, `token` or `email`; the host name; the command line; local variable values; source code lines; URL query strings | Properties under sensitive keys; the same patterns in property values. Strings are cut to 256 characters. | -### Capture an automation failure safely +Data-handling details, including the closed-schema automation failure signal +(`capture_automation_failure`), are in +[docs/COMPLIANCE.md](https://github.com/OpenAdaptAI/openadapt-telemetry/blob/main/docs/COMPLIANCE.md). -Use the closed-schema failure API rather than sending an exception message or -run report as analytics. Its grouping key is derived only from categorical -runtime facts; it is not an installation or tenant identifier. +## Usage events ```python -from openadapt_telemetry import ( - ActionKind, AutomationFailureSignal, DeliveryState, ExecutionOutcome, - FailureKind, RiskClass, Substrate, capture_automation_failure, -) - -capture_automation_failure(AutomationFailureSignal( - failure_kind=FailureKind.DELIVERY_UNCERTAIN, - substrate=Substrate.CITRIX, - action_kind=ActionKind.CLICK, - risk_class=RiskClass.CONSEQUENTIAL, - delivery_state=DeliveryState.UNCERTAIN, - outcome=ExecutionOutcome.HALTED, -)) +from openadapt_telemetry import capture_usage_event + +capture_usage_event("demo_recorded", properties={"steps": 12}, package_name="mypackage") ``` -This signal supports aggregate discovery only. Full evidence remains local, -and no signal can authorize or promote a repair. +A background thread sends the event with a 1-second timeout, so a slow or +unreachable server doesn't block the caller. The function returns `False` when +telemetry is off or the queue is full. -### Using Decorators +## Decorators and spans ```python -from openadapt_telemetry import track_errors, track_performance, track_feature - -@track_errors() -def process_data(data): - """Exceptions are automatically captured.""" - return transform(data) - -@track_performance("indexing.build_faiss") -def build_index(vectors): - """Execution time is automatically tracked.""" - return create_index(vectors) - -@track_feature("retrieval.add_demo") -def add_demo(demo_id, task): - """Feature usage is tracked for analytics.""" - save_demo(demo_id, task) -``` +from openadapt_telemetry import TelemetrySpan, track_errors, track_feature, track_performance -### Span Context Manager +@track_errors() # report exceptions, then re-raise +def process(data): ... -```python -from openadapt_telemetry import TelemetrySpan +@track_performance("indexing.build") # record a timed transaction +def build_index(vectors): ... + +@track_feature("retrieval.add_demo") # count each call +def add_demo(demo_id): ... -with TelemetrySpan("indexing", "build_faiss_index") as span: - span.set_tag("num_vectors", 1000) - # ... indexing operations ... +with TelemetrySpan("indexing", "load_vectors") as span: + span.set_data("count", 1000) ``` +These do nothing until `initialize()` succeeds. + ## Configuration -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `DO_NOT_TRACK` | - | Universal opt-out (1 = disabled) | -| `OPENADAPT_TELEMETRY_ENABLED` | `true` | Enable/disable telemetry | -| `OPENADAPT_INTERNAL` | `false` | Tag as internal usage | -| `OPENADAPT_DEV` | `false` | Development mode | -| `OPENADAPT_INTERNAL_FROM_GIT` | `false` | Optional: tag as internal when running from a git checkout | -| `OPENADAPT_TELEMETRY_DSN` | - | GlitchTip/Sentry DSN | -| `OPENADAPT_POSTHOG_PROJECT_API_KEY` | embedded default | PostHog ingestion project token (`phc_...`) | +Environment variables take precedence over `~/.config/openadapt/telemetry.json`, +which takes precedence over the defaults. + +| Variable | Default | Effect | +|----------|---------|--------| +| `DO_NOT_TRACK` | unset | `1`, `true`, `yes` or `on` turns off all telemetry | +| `OPENADAPT_TELEMETRY_ENABLED` | unset (on) | `false` turns telemetry off; `true` overrides the opt-out files | +| `OPENADAPT_TELEMETRY_DSN` | none | GlitchTip or Sentry DSN for error reports | +| `OPENADAPT_TELEMETRY_ENVIRONMENT` | `production` | Environment name on error reports | +| `OPENADAPT_TELEMETRY_SAMPLE_RATE` | `1.0` | Share of errors sent (0.0 to 1.0) | +| `OPENADAPT_TELEMETRY_TRACES_SAMPLE_RATE` | `0.01` | Share of performance transactions sent | +| `OPENADAPT_TELEMETRY_IN_CI` | `false` | Send usage events from CI | +| `OPENADAPT_POSTHOG_PROJECT_API_KEY` | OpenAdapt's project | PostHog project token (`phc_...`) for usage events | | `OPENADAPT_POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host | -| `OPENADAPT_TELEMETRY_DISTINCT_ID` | generated UUID | Stable anonymous identifier override | -| `OPENADAPT_TELEMETRY_TIMEOUT_SECONDS` | `1.0` | PostHog network timeout | -| `OPENADAPT_TELEMETRY_IN_CI` | `false` | Enable usage events in CI pipelines | -| `OPENADAPT_TELEMETRY_ENVIRONMENT` | `production` | Environment name | -| `OPENADAPT_TELEMETRY_SAMPLE_RATE` | `1.0` | Error sampling rate (0.0-1.0) | -| `OPENADAPT_TELEMETRY_TRACES_SAMPLE_RATE` | `0.01` | Performance sampling rate | -| `OPENADAPT_TELEMETRY_ANON_SALT` | generated | Optional anonymization salt override (advanced use only) | +| `OPENADAPT_TELEMETRY_DISTINCT_ID` | random UUID | Install ID for usage events | +| `OPENADAPT_TELEMETRY_TIMEOUT_SECONDS` | `1.0` | Network timeout for usage events | +| `OPENADAPT_INTERNAL`, `OPENADAPT_DEV` | `false` | Tag error reports as internal | +| `OPENADAPT_INTERNAL_FROM_GIT` | `false` | Tag error reports as internal when run from a git checkout | +| `OPENADAPT_TELEMETRY_ANON_SALT` | random, saved in the config file | Key for hashing user IDs passed to `set_user` (32 characters or more) | -### Configuration File - -Create `~/.config/openadapt/telemetry.json`: +The config file takes these keys: ```json { "enabled": true, "internal": false, - "dsn": "https://xxx@app.glitchtip.com/XXXX", + "dsn": "https://@app.glitchtip.com/", "environment": "production", "sample_rate": 1.0, "traces_sample_rate": 0.01 } ``` -### Priority Order - -1. Environment variables (highest priority) -2. Configuration file -3. Package defaults (lowest priority) +## Limits -## Opt-Out - -To disable telemetry, set either: - -```bash -# Universal standard -export DO_NOT_TRACK=1 - -# Or package-specific -export OPENADAPT_TELEMETRY_ENABLED=false -``` - -## Privacy - -### What We Collect - -| Category | Data | Purpose | -|----------|------|---------| -| Errors | Exception type, stack trace | Bug fixing | -| Performance | Function timing | Optimization | -| Feature Usage | Feature names, counts | Prioritization | -| Environment | OS, Python version | Compatibility | - -### What We Never Collect - -- Screenshots or images -- Text content or file contents -- Personal information (names, emails, IPs) -- API keys or passwords -- Full file paths with usernames - -### Automatic Scrubbing - -- File paths have usernames replaced with `` -- Sensitive fields (password, token, api_key, etc.) are redacted -- Email addresses and phone numbers are scrubbed from messages -- Top-level event messages/logentry strings are scrubbed -- Tag keys are validated, sensitive/invalid keys are dropped, and values are scrubbed before upload -- User IDs are HMAC-anonymized before upload (`anon:v2:`) -- `send_default_pii` is enforced to `false` by the client -- PostHog events set `$geoip_disable: true` to suppress IP geolocation enrichment - -## Internal Usage Tagging - -Internal/developer usage is automatically detected via: - -1. `OPENADAPT_INTERNAL=true` environment variable -2. `OPENADAPT_DEV=true` environment variable -3. CI environment detected (GitHub Actions, GitLab CI, etc.) -4. Optional git repository heuristic when `OPENADAPT_INTERNAL_FROM_GIT=true` - -Filter in GlitchTip: -``` -tag:internal IS false # External users only -tag:internal IS true # Internal users only -``` +- The string filter matches patterns. It doesn't recognize names, addresses or + other free text, so an exception message such as `no record for Jane Doe` is + sent as written. +- URL paths in HTTP breadcrumbs are kept, so an identifier in a path (for + example `/patients/123`) is sent. +- GlitchTip, Sentry and PostHog see the IP address of each connection, as any + web server does. The library doesn't put IP addresses in events and asks + PostHog not to look up location. ## Development ```bash -# Clone and install git clone https://github.com/OpenAdaptAI/openadapt-telemetry cd openadapt-telemetry pip install -e ".[dev]" - -# Run tests -pytest tests/ -v - -# Run with coverage -pytest tests/ --cov=openadapt_telemetry +pytest +ruff check . ``` ## License -MIT License - see [LICENSE](LICENSE) for details. - -## Links - -- [Documentation](https://github.com/OpenAdaptAI/openadapt-telemetry#readme) -- [Issues](https://github.com/OpenAdaptAI/openadapt-telemetry/issues) -- [GlitchTip](https://glitchtip.com) -- [Sentry SDK](https://docs.sentry.io/platforms/python/) +MIT. See [LICENSE](https://github.com/OpenAdaptAI/openadapt-telemetry/blob/main/LICENSE). diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md new file mode 100644 index 0000000..45a192a --- /dev/null +++ b/docs/COMPLIANCE.md @@ -0,0 +1,163 @@ +# Data handling reference + +This page is for operators, security reviewers and maintainers. The +[README](../README.md) lists what is sent and how to turn it off. This page +gives the rules the code applies, so a reviewer can check them against +`src/openadapt_telemetry/`. + +## Opt-out rule + +`config.is_telemetry_allowed()` decides whether anything is sent. Error +reports, usage events and automation failure signals all call it. It applies +these checks in order and stops at the first that decides: + +1. `DO_NOT_TRACK` set to `1`, `true`, `yes` or `on` (any case): off. +2. `OPENADAPT_TELEMETRY_ENABLED` set to a non-empty value: on for `1`, `true`, + `yes` or `on`, off for anything else. +3. `telemetry = false` under `[tool.openadapt]` in the nearest `pyproject.toml`, + searching from the current directory upward: off. Only the nearest file is + read. +4. `"enabled": false` (or the string `"false"`) in + `~/.config/openadapt/telemetry.json`: off. +5. Otherwise: on. + +Usage events and failure signals have one more rule: in CI (detected from +`CI`, `GITHUB_ACTIONS`, `GITLAB_CI` and other common variables) they stay off +unless `OPENADAPT_TELEMETRY_IN_CI` or `OPENADAPT_TELEMETRY_ENABLED` is true. +Error reports in CI are sent, tagged `ci` and `internal`. + +## Error reports + +Error reports use the Sentry SDK (2.15 or later) and go to the DSN in +`OPENADAPT_TELEMETRY_DSN`, the config file, or the `dsn` argument of +`initialize()`. There is no default DSN. + +`TelemetryClient.initialize()` sets these SDK options, and callers can't +weaken them: + +| Option | Value | +|--------|-------| +| `send_default_pii` | SDK default (off). Passing it is refused with a warning. | +| `data_collection` | Not set. Passing it is refused with a warning, because newer SDKs let it override `send_default_pii`. | +| `include_local_variables` | `False` | +| `include_source_context` | `False` | +| `before_send`, `before_send_transaction` | The privacy filter. A caller's own hook runs first and the filter runs last. | + +If the filter raises, the event is dropped. + +### What the filter does + +The filter (`privacy.create_before_send_filter()`) runs on every error, +message and transaction event: + +| Event field | Treatment | +|-------------|-----------| +| `server_name` | Removed | +| `message`, `logentry` message, formatted text and arguments | String scrub | +| Exception messages | String scrub | +| Stack frames (exceptions and threads) | Local variables (`vars`) and source lines (`pre_context`, `context_line`, `post_context`) removed. `filename` and `abs_path` lose the user name. File, function and line stay. | +| Breadcrumbs | Message: string scrub. Data: `http.query` and `http.fragment` removed, then key and string scrub. | +| `extra` | `sys.argv` removed, then key and string scrub | +| `contexts` | Key and string scrub, except `trace_id`, `span_id`, `parent_span_id` and the sampling context in `contexts.trace` | +| Transaction spans | Description: string scrub. Data: key and string scrub. Tags: tag rules. | +| `tags` | Tag rules | +| `request` | `query_string` and `cookies` removed. Headers and data: key and string scrub. | +| `user` | Only `id` is kept, replaced by `anon:v2:` and the first 16 hex characters of an HMAC-SHA256 | + +**String scrub** (`privacy.scrub_string`) replaces the user name in +`/Users//`, `/home//` and `C:\Users\\` paths with ``, +then replaces these with `[REDACTED]`: email addresses, phone numbers, card +numbers, social security numbers, `Bearer` tokens, alphanumeric strings of 32 +or more characters, and base64-like strings of 40 or more characters. + +**Key scrub** (`privacy.scrub_dict`) replaces the value with `[REDACTED]` +when the key contains any entry of `privacy.PII_DENYLIST`, for example +`password`, `token`, `secret`, `auth`, `cookie`, `session`, `email`, `phone`, +`address`, `ssn` or `credit_card`. + +**Tag rules** keep the default tags (`package`, `package_version`, +`python_version`, `os`, `os_version`, `ci`, `internal`), drop keys that are +sensitive or don't match `^[a-zA-Z0-9._:-]{1,64}$`, keep at most 64 tags, and +string-scrub each value and cut it to 256 characters. + +## Usage events + +`capture_usage_event()` posts this JSON to `/capture/`: + +| Field | Value | +|-------|-------| +| `api_key` | PostHog project token. OpenAdapt's by default, or `OPENADAPT_POSTHOG_PROJECT_API_KEY`. | +| `event` | Event name given by the caller | +| `distinct_id` | Random UUID saved in `~/.openadapt/telemetry_distinct_id`, or `OPENADAPT_TELEMETRY_DISTINCT_ID` | +| `properties` | `package`, `version`, `python_version`, `platform`, `timestamp` (Unix seconds), the caller's properties, and `$geoip_disable: true` | + +Caller properties keep `None`, numbers and booleans as they are. Other values +become strings cut to 256 characters and get the string scrub. Properties +under sensitive keys, and values that are fully redacted, are dropped. + +Events go into an in-memory queue of up to 2,048 events and a daemon thread +sends them with a 1-second timeout (`OPENADAPT_TELEMETRY_TIMEOUT_SECONDS`). +Network errors are ignored. Events still queued when the process exits are +not sent. + +## Automation failure signals + +`capture_automation_failure()` sends a closed-schema description of an +automation failure, for counting how often the same kind of failure recurs +across installs. + +```python +from openadapt_telemetry import ( + ActionKind, AutomationFailureSignal, DeliveryState, ExecutionOutcome, + FailureKind, RiskClass, Substrate, capture_automation_failure, +) + +capture_automation_failure(AutomationFailureSignal( + failure_kind=FailureKind.DELIVERY_UNCERTAIN, + substrate=Substrate.CITRIX, + action_kind=ActionKind.CLICK, + risk_class=RiskClass.CONSEQUENTIAL, + delivery_state=DeliveryState.UNCERTAIN, + outcome=ExecutionOutcome.HALTED, +)) +``` + +The event is `automation_failure_observed` with schema +`openadapt.automation-failure-signal/v1`. It carries the base properties of a +usage event (package, versions, platform and send time) and the signal's +fields. Every signal field is an enum, except: + +- `runtime_version`: a release identifier such as `1.34.0`, or `unknown` +- `model_calls`: an integer from 0 to 1,000,000 +- `external_network_calls`: `none`, `observed` or `unknown` +- `occurred_at`: rounded down to the hour, in UTC + +The constructor rejects any other type or value, so application names, window +titles, workflow or step IDs, typed text, screenshots, exception messages and +URLs can't be attached. The `distinct_id` is `failure:` followed by a SHA-256 +of the categorical fields, not the install ID, so the event shows that a +failure shape recurred but not which install reported it. + +The signal carries none of the run report, screenshots or other evidence; +those stay wherever the caller keeps them. This package only sends the signal +and has no way to act on one, so a signal can't approve or apply a repair. + +## Internal usage + +Error reports carry an `internal` tag (`True` or `False`) so dashboards can +separate OpenAdapt's own use from outside use. A run counts as internal when +any of these holds: + +- `OPENADAPT_INTERNAL` or `OPENADAPT_DEV` is true +- a CI variable is set +- `OPENADAPT_INTERNAL_FROM_GIT` is true and `.git` exists in the current or + parent directory +- `"internal": true` is in the config file + +## Identifiers + +| Identifier | Where it comes from | Sent with | +|------------|---------------------|-----------| +| Install ID | Random UUID in `~/.openadapt/telemetry_distinct_id` | Usage events | +| Anonymous user ID | HMAC-SHA256 of the value passed to `set_user()`, keyed with `OPENADAPT_TELEMETRY_ANON_SALT` or a random salt saved as `anon_salt` in the config file | Error reports, only when a package calls `set_user()` | +| Failure signature | SHA-256 of a failure signal's categorical fields | Failure signals | diff --git a/pyproject.toml b/pyproject.toml index 4136124..19826f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-telemetry" version = "0.3.1" -description = "Unified telemetry and error tracking for OpenAdapt packages" +description = "Error reports and usage counts for OpenAdapt packages, filtered before sending, with one opt-out" readme = "README.md" requires-python = ">=3.10" license = "MIT" diff --git a/src/openadapt_telemetry/__init__.py b/src/openadapt_telemetry/__init__.py index 455f55e..8b7801c 100644 --- a/src/openadapt_telemetry/__init__.py +++ b/src/openadapt_telemetry/__init__.py @@ -4,11 +4,12 @@ using GlitchTip/Sentry SDK for error tracking and performance monitoring. Features: -- Unified error tracking across all packages -- Privacy-first design with automatic PII scrubbing -- Configurable opt-out (DO_NOT_TRACK, OPENADAPT_TELEMETRY_ENABLED) +- Error reports to GlitchTip or Sentry, filtered before sending +- Usage events to PostHog +- One opt-out rule for both (DO_NOT_TRACK, OPENADAPT_TELEMETRY_ENABLED, + [tool.openadapt] in pyproject.toml, or the config file) - CI/dev mode detection for filtering internal usage -- Convenient decorators for tracking errors, performance, and features +- Decorators for tracking errors, performance, and features Quick Start: from openadapt_telemetry import get_telemetry diff --git a/tests/conftest.py b/tests/conftest.py index 57449c6..348ddda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,52 @@ from unittest.mock import patch import pytest +import sentry_sdk +from sentry_sdk.transport import Transport from openadapt_telemetry.client import TelemetryClient +class CapturingTransport(Transport): + """Keep every Sentry envelope in memory instead of sending it.""" + + envelopes: list = [] + + def capture_envelope(self, envelope): + CapturingTransport.envelopes.append(envelope) + + +@pytest.fixture +def sentry_events(): + """Route real Sentry clients to memory; return a reader for captured events. + + ``sentry_events("event")`` returns error and message events and + ``sentry_events("transaction")`` returns transactions, as the server + would receive them. + """ + real_init = sentry_sdk.init + + def init(*args, **kwargs): + kwargs["transport"] = CapturingTransport + return real_init(*args, **kwargs) + + def read(kind: str) -> list[dict]: + sentry_sdk.flush() + return [ + item.payload.json + for envelope in CapturingTransport.envelopes + for item in envelope.items + if item.headers.get("type") == kind + ] + + CapturingTransport.envelopes = [] + with patch.object(sentry_sdk, "init", init): + yield read + sentry_sdk.flush() + real_init() # replace the client with one that sends nothing + TelemetryClient.reset_instance() + + @pytest.fixture(autouse=True) def isolated_home(tmp_path, monkeypatch): """Keep tests away from the real ~/.config/openadapt and ~/.openadapt files.""" diff --git a/tests/test_readme.py b/tests/test_readme.py index 15bf09b..29f7f77 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -1,20 +1,39 @@ -"""Checks that the README matches the published state of the package.""" +"""Checks that the README matches the published package and the code's behavior.""" +import doctest import re from pathlib import Path +from unittest.mock import patch -README = Path(__file__).resolve().parent.parent / "README.md" +import pytest + +from openadapt_telemetry import TelemetryClient + +ROOT = Path(__file__).resolve().parent.parent +README = ROOT / "README.md" HTML_COMMENT = re.compile(r"", re.DOTALL) +PYTHON_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL) + + +def _readme() -> str: + return README.read_text(encoding="utf-8") def _visible_readme() -> str: """Return the README text with HTML comments removed.""" - return HTML_COMMENT.sub("", README.read_text(encoding="utf-8")) + return HTML_COMMENT.sub("", _readme()) + + +def _python_block(containing: str) -> str: + """Return the one README python block that contains the given text.""" + blocks = [b for b in PYTHON_BLOCK.findall(_readme()) if containing in b] + assert len(blocks) == 1, f"expected one python block containing {containing!r}" + return blocks[0] def test_readme_does_not_say_package_is_unpublished(): """The package is on PyPI, so no note may say it isn't yet.""" - assert "uncomment once package is published" not in README.read_text(encoding="utf-8") + assert "uncomment once package is published" not in _readme() def test_readme_shows_pypi_badges(): @@ -22,3 +41,111 @@ def test_readme_shows_pypi_badges(): visible = _visible_readme() assert "img.shields.io/pypi/v/openadapt-telemetry" in visible assert "img.shields.io/pypi/dm/openadapt-telemetry" in visible + + +def test_readme_doctest_examples_pass(): + """Every >>> example in the README produces the output it shows.""" + parser = doctest.DocTestParser() + runner = doctest.DocTestRunner(optionflags=doctest.ELLIPSIS) + examples = [b for b in PYTHON_BLOCK.findall(_readme()) if ">>>" in b] + assert examples + for i, block in enumerate(examples): + test = parser.get_doctest(block, {}, f"README example {i}", str(README), 0) + runner.run(test) + assert runner.summarize(verbose=False).failed == 0 + + +def test_quick_start_sends_the_report_the_readme_describes(clean_env, monkeypatch, sentry_events): + """Run the quick start against an in-memory transport and compare with the README.""" + monkeypatch.setenv("OPENADAPT_TELEMETRY_DSN", "https://public@example.invalid/1") + TelemetryClient.reset_instance() + exec(_python_block("telemetry.initialize("), {}) + + (event,) = sentry_events("event") + (exception,) = event["exception"]["values"] + received = f"{exception['type']}: {exception['value']}" + claimed = re.search(r"GlitchTip receives\s+`([^`]+)`", _readme()).group(1) + assert received == claimed + assert "server_name" not in event + for frame in exception["stacktrace"]["frames"]: + assert {"filename", "function", "lineno"} <= set(frame) + assert not {"vars", "pre_context", "context_line", "post_context"} & set(frame) + + +def test_quick_start_sends_nothing_without_a_dsn(clean_env, sentry_events): + """Without a DSN, initialize() returns False and nothing is sent.""" + TelemetryClient.reset_instance() + namespace: dict = {} + exec(_python_block("telemetry.initialize("), namespace) + assert namespace["telemetry"].initialized is False + assert sentry_events("event") == [] + + +def test_usage_event_example_queues_an_event(monkeypatch): + """The usage-event example queues one event with the properties shown.""" + for name in ("DO_NOT_TRACK", "CI", "GITHUB_ACTIONS"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OPENADAPT_TELEMETRY_ENABLED", "true") + queued = [] + + class Queue: + def put_nowait(self, payload): + queued.append(payload) + + with patch("openadapt_telemetry.posthog._ensure_worker", return_value=Queue()): + exec(_python_block("capture_usage_event("), {}) + + (payload,) = queued + assert payload["event"] == "demo_recorded" + assert payload["properties"]["steps"] == 12 + + +def test_decorator_example_runs_before_initialize(): + """The decorator example runs as written and does nothing until initialize().""" + exec(_python_block("TelemetrySpan("), {}) + + +@pytest.mark.parametrize( + "term", + [ + "admission", + "admitted", + "signed ledger", + "evidence authority", + "product target", + "lifecycle state", + "governed", + ], +) +def test_readme_has_no_governance_language(term): + """Process and compliance terms belong in docs/COMPLIANCE.md, not the README.""" + assert term not in _readme().lower() + + +def test_readme_links_compliance_doc_once(): + """The data-handling reference is linked once, and the link target exists.""" + links = re.findall(r"\]\(([^)]*docs/COMPLIANCE\.md)\)", _readme()) + assert len(links) == 1 + assert (ROOT / "docs" / "COMPLIANCE.md").is_file() + + +def test_compliance_failure_signal_example_queues_one_event(monkeypatch): + """The failure-signal example in docs/COMPLIANCE.md runs and sends only the closed schema.""" + for name in ("DO_NOT_TRACK", "CI", "GITHUB_ACTIONS"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OPENADAPT_TELEMETRY_ENABLED", "true") + doc = (ROOT / "docs" / "COMPLIANCE.md").read_text(encoding="utf-8") + (block,) = PYTHON_BLOCK.findall(doc) + queued = [] + + class Queue: + def put_nowait(self, payload): + queued.append(payload) + + with patch("openadapt_telemetry.posthog._ensure_worker", return_value=Queue()): + exec(block, {}) + + (payload,) = queued + assert payload["event"] == "automation_failure_observed" + assert payload["distinct_id"].startswith("failure:") + assert payload["properties"]["occurred_at"].endswith(":00:00Z") diff --git a/tests/test_sentry_payload.py b/tests/test_sentry_payload.py index fbfad56..5ce4366 100644 --- a/tests/test_sentry_payload.py +++ b/tests/test_sentry_payload.py @@ -7,37 +7,14 @@ import pytest import sentry_sdk -from sentry_sdk.transport import Transport from openadapt_telemetry import TelemetryClient, TelemetrySpan, get_telemetry, track_performance HEX32 = re.compile(r"^[0-9a-f]{32}$") -class CapturingTransport(Transport): - """Keep every envelope in memory instead of sending it.""" - - envelopes: list = [] - - def __init__(self, options=None): - super().__init__(options) - CapturingTransport.envelopes = [] - - def capture_envelope(self, envelope): - CapturingTransport.envelopes.append(envelope) - - -def _events(kind: str) -> list[dict]: - found = [] - for envelope in CapturingTransport.envelopes: - for item in envelope.items: - if item.headers.get("type") == kind: - found.append(item.payload.json) - return found - - @pytest.fixture -def live_client(clean_env): +def live_client(clean_env, sentry_events): """A real Sentry client whose transport keeps events in memory.""" TelemetryClient.reset_instance() client = get_telemetry() @@ -46,12 +23,8 @@ def live_client(clean_env): package_name="openadapt-test", package_version="1.0.0", traces_sample_rate=1.0, - transport=CapturingTransport, ) - yield client - sentry_sdk.flush() - sentry_sdk.init() # replace the client with one that sends nothing - TelemetryClient.reset_instance() + return client def _fail(note: str) -> None: @@ -63,14 +36,13 @@ def test_sdk_default_pii_is_off(live_client): assert sentry_sdk.get_client().should_send_default_pii() is False -def test_error_event_carries_no_personal_or_application_data(live_client): +def test_error_event_carries_no_personal_or_application_data(live_client, sentry_events): try: _fail("visit-notes.txt") except FileNotFoundError as exc: assert live_client.capture_exception(exc) - sentry_sdk.flush() - (event,) = _events("event") + (event,) = sentry_events("event") assert "server_name" not in event assert "sys.argv" not in event.get("extra", {}) (exception,) = event["exception"]["values"] @@ -82,7 +54,7 @@ def test_error_event_carries_no_personal_or_application_data(live_client): assert event["tags"]["package"] == "openadapt-test" -def test_transactions_go_through_the_privacy_filter(live_client): +def test_transactions_go_through_the_privacy_filter(live_client, sentry_events): @track_performance("indexing.build") def build(): with TelemetrySpan("indexing", "load") as span: @@ -91,9 +63,8 @@ def build(): return 1 build() - sentry_sdk.flush() - (transaction,) = _events("transaction") + (transaction,) = sentry_events("transaction") assert transaction["transaction"] == "indexing.build" assert "server_name" not in transaction assert "sys.argv" not in transaction.get("extra", {}) @@ -103,7 +74,7 @@ def build(): assert span["data"]["api_key"] == "[REDACTED]" -def test_log_arguments_and_url_queries_are_scrubbed(live_client): +def test_log_arguments_and_url_queries_are_scrubbed(live_client, sentry_events): logging.getLogger("openadapt-test").error( "save failed for %s (%s)", "/Users/alice/out.json", "alice@example.com" ) @@ -113,9 +84,8 @@ def test_log_arguments_and_url_queries_are_scrubbed(live_client): data={"url": "https://emr.example/api/visits", "http.query": "token=abc123"}, ) live_client.capture_message("after request") - sentry_sdk.flush() - log_event, message_event = _events("event") + log_event, message_event = sentry_events("event") assert log_event["logentry"]["params"] == ["/Users//out.json", "[REDACTED]"] assert "alice" not in log_event["logentry"].get("formatted", "") (http,) = [b for b in message_event["breadcrumbs"]["values"] if b.get("type") == "http"] From 64cabce6d2d0c2cc2450345e053b09293ac34263 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:34:52 -0400 Subject: [PATCH 09/12] fix: read the pyproject.toml opt-out on Python 3.10 The `[tool.openadapt] telemetry = false` opt-out parses pyproject.toml with tomllib, which Python 3.10 lacks, and falls back to tomli. tomli was not a dependency, so on Python 3.10 without it the opt-out was silently ignored: a fresh 3.10 install in a project that opted out still reported is_telemetry_allowed() as True. Depend on tomli for Python versions before 3.11. The test suite didn't catch this because pytest itself pulls in tomli on 3.10, so the new test checks the declared dependency. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 ++ tests/test_opt_out.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 19826f4..bcf5776 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,8 @@ classifiers = [ dependencies = [ "sentry-sdk>=2.15.0", + # Reads [tool.openadapt] in pyproject.toml; Python 3.11+ has tomllib. + "tomli>=1.1.0; python_version < '3.11'", ] [project.optional-dependencies] diff --git a/tests/test_opt_out.py b/tests/test_opt_out.py index 9eec796..661bbca 100644 --- a/tests/test_opt_out.py +++ b/tests/test_opt_out.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path from unittest.mock import patch import pytest @@ -133,3 +134,21 @@ def test_config_file_internal_flag_tags_reports(project): config.CONFIG_FILE.write_text(json.dumps({"internal": True})) TelemetryClient.reset_instance() assert TelemetryClient.get_instance().internal is True + + +def test_python_310_installs_a_toml_parser(): + """Python 3.10 has no tomllib, so the package must depend on tomli there. + + Without it the pyproject.toml opt-out is silently ignored. pytest pulls in + tomli on 3.10, so this checks the declared dependency instead of importing. + """ + try: + import tomllib + except ImportError: # Python 3.10, where pytest provides tomli + import tomli as tomllib + + text = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text() + dependencies = tomllib.loads(text)["project"]["dependencies"] + assert any( + d.startswith("tomli") and "python_version < '3.11'" in d for d in dependencies + ) From 915736a548a44ffc4508dc16ac4f62d5c9f3a91e Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:35:02 -0400 Subject: [PATCH 10/12] docs: say which Sentry options a caller can override include_local_variables and include_source_context are off by default, but a caller's initialize() kwargs can turn them on. The filter still removes local variables and source lines, so the doc now says that instead of implying the options themselves are locked. Co-Authored-By: Claude Opus 5.5 --- docs/COMPLIANCE.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index 45a192a..58bb635 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -32,15 +32,14 @@ Error reports use the Sentry SDK (2.15 or later) and go to the DSN in `OPENADAPT_TELEMETRY_DSN`, the config file, or the `dsn` argument of `initialize()`. There is no default DSN. -`TelemetryClient.initialize()` sets these SDK options, and callers can't -weaken them: +`TelemetryClient.initialize()` sets these SDK options: | Option | Value | |--------|-------| | `send_default_pii` | SDK default (off). Passing it is refused with a warning. | | `data_collection` | Not set. Passing it is refused with a warning, because newer SDKs let it override `send_default_pii`. | -| `include_local_variables` | `False` | -| `include_source_context` | `False` | +| `include_local_variables` | `False`. A caller can pass `True`, but the filter still removes local variables. | +| `include_source_context` | `False`. A caller can pass `True`, but the filter still removes source lines. | | `before_send`, `before_send_transaction` | The privacy filter. A caller's own hook runs first and the filter runs last. | If the filter raises, the event is dropped. From 7b8141e17bc7ec75a0db51ae92e5981fae6a8cfb Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:51:53 -0400 Subject: [PATCH 11/12] fix: send the package version as the release, not a git commit hash initialize() didn't set a release, so the Sentry SDK filled it in with the output of `git rev-parse HEAD` in the current directory. A report from a process started inside any git checkout carried that repository's commit hash, which the README's list of what is sent doesn't include and which says nothing about the code that raised. The release is now `@`. A caller can still pass release= to initialize(). Co-Authored-By: Claude Opus 5.5 --- docs/COMPLIANCE.md | 1 + src/openadapt_telemetry/client.py | 4 ++++ tests/test_sentry_payload.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index 58bb635..4a2e327 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -37,6 +37,7 @@ Error reports use the Sentry SDK (2.15 or later) and go to the DSN in | Option | Value | |--------|-------| | `send_default_pii` | SDK default (off). Passing it is refused with a warning. | +| `release` | `@`. A caller can pass another value. Left unset, the SDK would send the commit hash of the git checkout the process runs in. | | `data_collection` | Not set. Passing it is refused with a warning, because newer SDKs let it override `send_default_pii`. | | `include_local_variables` | `False`. A caller can pass `True`, but the filter still removes local variables. | | `include_source_context` | `False`. A caller can pass `True`, but the filter still removes source lines. | diff --git a/src/openadapt_telemetry/client.py b/src/openadapt_telemetry/client.py index 5d12814..e1c7ce8 100644 --- a/src/openadapt_telemetry/client.py +++ b/src/openadapt_telemetry/client.py @@ -256,6 +256,10 @@ def initialize( "environment": self._config.environment, "sample_rate": self._config.sample_rate, "traces_sample_rate": self._config.traces_sample_rate, + # Without a release, the SDK sends the commit hash of whatever git + # checkout the process runs in. The package version names the code + # that raised instead; a caller can still pass release=. + "release": f"{package_name}@{package_version}", # send_default_pii is left at the SDK default (off) rather than # passed, because newer SDKs deprecate the option. Callers cannot # override it (see _ENFORCED_SENTRY_OPTIONS). diff --git a/tests/test_sentry_payload.py b/tests/test_sentry_payload.py index 5ce4366..b6b9cef 100644 --- a/tests/test_sentry_payload.py +++ b/tests/test_sentry_payload.py @@ -54,6 +54,23 @@ def test_error_event_carries_no_personal_or_application_data(live_client, sentry assert event["tags"]["package"] == "openadapt-test" +def test_release_is_the_package_version_not_a_git_commit(live_client, sentry_events): + """The tests run inside a git checkout, where the SDK would send its commit hash.""" + live_client.capture_message("hello") + (event,) = sentry_events("event") + assert event["release"] == "openadapt-test@1.0.0" + + +def test_caller_can_set_the_release(clean_env, sentry_events): + TelemetryClient.reset_instance() + get_telemetry().initialize( + dsn="https://public@example.invalid/1", package_name="pkg", release="pkg@abc" + ) + get_telemetry().capture_message("hello") + (event,) = sentry_events("event") + assert event["release"] == "pkg@abc" + + def test_transactions_go_through_the_privacy_filter(live_client, sentry_events): @track_performance("indexing.build") def build(): From 95a66798efe64534ef1e56e8dbed524218a76a01 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 14 Oct 2026 03:51:53 -0400 Subject: [PATCH 12/12] docs: match the README to what a fresh install sends Running the README examples against a local HTTP server showed these gaps between the text and the code: - The first screen told people who run OpenAdapt to read this page, but the launcher and engine don't depend on this package. It now addresses people whose OpenAdapt packages depend on it, and says how to check with pip show. - "Other Python projects can use it with their own DSN" left out that usage events go to OpenAdapt's PostHog project by default. - A TelemetrySpan outside a track_performance transaction sends nothing, and track_performance sends 1% of calls by default. track_feature sends a message event to GlitchTip on every call. - The usage-event example, run as a script, sends nothing because the process exits before the background thread posts the event. - Usage events carry the OS name, not its version. - Stack frames keep each file's full path apart from the user name, which the Limits section now says alongside URL paths. It also defines DSN on first use, says "usage events" instead of "usage counts" in the README and package description, and drops the term "closed-schema". A new test runs the decorator example after initialize() and checks what it sends. Co-Authored-By: Claude Opus 5.5 --- README.md | 59 +++++++++++++++++++++++++++----------------- pyproject.toml | 2 +- tests/test_readme.py | 18 +++++++++++++- 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a08fca3..4d339b7 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,23 @@ [![PyPI version](https://img.shields.io/pypi/v/openadapt-telemetry.svg)](https://pypi.org/project/openadapt-telemetry/) [![Downloads](https://img.shields.io/pypi/dm/openadapt-telemetry.svg)](https://pypi.org/project/openadapt-telemetry/) -Error reports and usage counts for OpenAdapt packages. Data is filtered on the -user's machine before it is sent, and `DO_NOT_TRACK=1` turns everything off. +Error reports and usage events for [OpenAdapt](https://github.com/OpenAdaptAI/OpenAdapt) +packages. Data is filtered on the user's machine before it is sent, and +`DO_NOT_TRACK=1` turns everything off. -- **If you run OpenAdapt**, see [what is sent](#what-is-sent) and - [how to turn it off](#turn-it-off). +- **If an OpenAdapt package you use depends on this one**, see + [what is sent](#what-is-sent) and [how to turn it off](#turn-it-off). + `pip show openadapt-telemetry` lists those packages under `Required-by`. - **If you build an OpenAdapt package**, the quick start below sends error reports to your GlitchTip or Sentry project. Other Python projects can use it - the same way with their own DSN. + for error reports the same way. Usage events go to OpenAdapt's PostHog project + unless `OPENADAPT_POSTHOG_PROJECT_API_KEY` names another. ## Quick start +Install the package and set the DSN, the URL with a key that GlitchTip or Sentry +shows in your project's settings: + ```bash pip install openadapt-telemetry export OPENADAPT_TELEMETRY_DSN="https://@app.glitchtip.com/" @@ -36,7 +42,7 @@ except OSError as exc: GlitchTip receives `FileNotFoundError: [Errno 2] No such file or directory: '/Users//notes.txt'` with the file, function and line of each stack frame. Without a DSN, or after -an opt-out, `initialize()` returns `False` and nothing is sent. +an opt-out, `initialize()` returns `False` and no error reports are sent. To see the filter without a DSN: @@ -54,26 +60,27 @@ Any one of these turns off both error reports and usage events: |-------|---------| | Environment | `DO_NOT_TRACK=1` (or `true`, `yes`, `on`) | | Environment | `OPENADAPT_TELEMETRY_ENABLED=false` | -| `pyproject.toml` of a repository, for everyone who works in it | `telemetry = false` under `[tool.openadapt]` | +| The nearest `pyproject.toml` at or above the current directory, for everyone who works in that repository | `telemetry = false` under `[tool.openadapt]` | | `~/.config/openadapt/telemetry.json` | `{"enabled": false}` | `DO_NOT_TRACK` overrides everything else. `OPENADAPT_TELEMETRY_ENABLED=true` -overrides the two files. Usage events are also off in CI unless -`OPENADAPT_TELEMETRY_IN_CI=true` or `OPENADAPT_TELEMETRY_ENABLED=true`. Code can -check the result with `openadapt_telemetry.is_telemetry_allowed()`. +overrides the two files. Code can check the result with +`openadapt_telemetry.is_telemetry_allowed()`. Usage events are also off in CI +unless `OPENADAPT_TELEMETRY_IN_CI=true` or `OPENADAPT_TELEMETRY_ENABLED=true`. ## What is sent | | Error reports | Usage events | |-|---------------|--------------| | Destination | The GlitchTip or Sentry project named by the DSN. With no DSN, nothing is sent. | OpenAdapt's PostHog project, unless `OPENADAPT_POSTHOG_PROJECT_API_KEY` names another one | -| When | After `initialize()`: on `capture_exception` or `capture_message`, an uncaught exception, an `ERROR` log record, or a `track_*` decorator or `TelemetrySpan` | When a package calls `capture_usage_event` | -| Contents | Exception type and message; file, function and line of each stack frame; recent log lines and HTTP request URLs; timings; package, Python and OS versions; names and versions of installed Python packages; whether the run looks internal or CI | Event name; package, Python and OS versions; a timestamp; a random install ID kept in `~/.openadapt/telemetry_distinct_id`; the properties the package passes | +| When | After `initialize()`: on `capture_exception` or `capture_message`, an uncaught exception, an `ERROR` log record, or a call to a function wrapped by a `track_*` decorator | When a package calls `capture_usage_event` | +| Contents | Exception type and message; file, function and line of each stack frame; recent log lines and HTTP request URLs; timings; package, Python and OS versions; names and versions of installed Python packages; whether the run looks internal or CI | Event name; package and Python versions; OS name; a timestamp; a random install ID kept in `~/.openadapt/telemetry_distinct_id`; the properties the package passes | | Removed before sending | User names in home-directory paths; emails, phone, card and social security numbers, bearer tokens and long key-like strings; values under keys such as `password`, `token` or `email`; the host name; the command line; local variable values; source code lines; URL query strings | Properties under sensitive keys; the same patterns in property values. Strings are cut to 256 characters. | -Data-handling details, including the closed-schema automation failure signal -(`capture_automation_failure`), are in -[docs/COMPLIANCE.md](https://github.com/OpenAdaptAI/openadapt-telemetry/blob/main/docs/COMPLIANCE.md). +The exact filter rules are in +[docs/COMPLIANCE.md](https://github.com/OpenAdaptAI/openadapt-telemetry/blob/main/docs/COMPLIANCE.md), +along with `capture_automation_failure`, which reports an automation failure as +fixed categories, a version, a count and an hour, with no free text. ## Usage events @@ -84,8 +91,10 @@ capture_usage_event("demo_recorded", properties={"steps": 12}, package_name="myp ``` A background thread sends the event with a 1-second timeout, so a slow or -unreachable server doesn't block the caller. The function returns `False` when -telemetry is off or the queue is full. +unreachable server doesn't block the caller. Events still queued when the +process exits are dropped, so a script that exits right after the call may send +nothing. The function returns `False` when telemetry is off or the queue is +full. ## Decorators and spans @@ -95,17 +104,19 @@ from openadapt_telemetry import TelemetrySpan, track_errors, track_feature, trac @track_errors() # report exceptions, then re-raise def process(data): ... -@track_performance("indexing.build") # record a timed transaction +@track_performance("indexing.build") # send a timed transaction (1% by default) def build_index(vectors): ... -@track_feature("retrieval.add_demo") # count each call +@track_feature("retrieval.add_demo") # send a message event on each call def add_demo(demo_id): ... -with TelemetrySpan("indexing", "load_vectors") as span: +with TelemetrySpan("indexing", "load_vectors") as span: # a timed step span.set_data("count", 1000) ``` -These do nothing until `initialize()` succeeds. +These send to the same GlitchTip or Sentry project as error reports, and do +nothing until `initialize()` succeeds. A `TelemetrySpan` is sent as a step of +the `track_performance` transaction it runs in. Outside one, it sends nothing. ## Configuration @@ -147,8 +158,10 @@ The config file takes these keys: - The string filter matches patterns. It doesn't recognize names, addresses or other free text, so an exception message such as `no record for Jane Doe` is sent as written. -- URL paths in HTTP breadcrumbs are kept, so an identifier in a path (for - example `/patients/123`) is sent. +- Paths are kept apart from the user name. Stack frames carry each file's full + path and HTTP breadcrumbs carry each URL's path, so a name or identifier in a + path (for example `/Users//clients/acme/app.py` or `/patients/123`) is + sent. - GlitchTip, Sentry and PostHog see the IP address of each connection, as any web server does. The library doesn't put IP addresses in events and asks PostHog not to look up location. diff --git a/pyproject.toml b/pyproject.toml index bcf5776..22b6dbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-telemetry" version = "0.3.1" -description = "Error reports and usage counts for OpenAdapt packages, filtered before sending, with one opt-out" +description = "Error reports and usage events for OpenAdapt packages, filtered before sending, with one opt-out" readme = "README.md" requires-python = ">=3.10" license = "MIT" diff --git a/tests/test_readme.py b/tests/test_readme.py index 29f7f77..fcbfdd9 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -7,7 +7,7 @@ import pytest -from openadapt_telemetry import TelemetryClient +from openadapt_telemetry import TelemetryClient, get_telemetry ROOT = Path(__file__).resolve().parent.parent README = ROOT / "README.md" @@ -105,6 +105,22 @@ def test_decorator_example_runs_before_initialize(): exec(_python_block("TelemetrySpan("), {}) +def test_decorator_example_after_initialize(clean_env, sentry_events): + """track_feature sends a message per call; a span outside a transaction sends nothing.""" + TelemetryClient.reset_instance() + get_telemetry().initialize(dsn="https://public@example.invalid/1", traces_sample_rate=1.0) + namespace: dict = {} + exec(_python_block("TelemetrySpan("), namespace) + assert sentry_events("transaction") == [] + + namespace["add_demo"]("demo-1") + namespace["add_demo"]("demo-2") + assert len(sentry_events("event")) == 2 + namespace["build_index"]([1, 2, 3]) + (transaction,) = sentry_events("transaction") + assert transaction["transaction"] == "indexing.build" + + @pytest.mark.parametrize( "term", [