From 68d63b44a91127e7ee55955449dfa636280fa0df Mon Sep 17 00:00:00 2001 From: cragwolfe Date: Tue, 11 Aug 2026 15:06:36 -0700 Subject: [PATCH 1/5] fix: restore telemetry by default --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 6 + README.md | 2 +- conftest.py | 6 + scripts/image/test-outbound-connectivity.sh | 5 +- test_unstructured/test_telemetry.py | 186 ++++++++------------ unstructured/__init__.py | 2 +- unstructured/__version__.py | 2 +- unstructured/telemetry.py | 2 +- unstructured/utils.py | 13 +- 10 files changed, 94 insertions(+), 133 deletions(-) create mode 100644 conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb736fb947..02c952e48c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,9 @@ permissions: id-token: write contents: read +env: + DO_NOT_TRACK: "1" + jobs: setup: strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 44718af363..285d780a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.26.0-dev0 + +### Fixes + +- **Restore default-on library-load telemetry**: The lightweight library-load ping once again runs by default when `unstructured` is imported. Set either `DO_NOT_TRACK` or `SCARF_NO_ANALYTICS` to any non-empty value (after trimming whitespace) before import to disable it; empty and whitespace-only values retain the default behavior. This removes the `UNSTRUCTURED_TELEMETRY_ENABLED` explicit opt-in gate introduced by #4281 without changing the existing endpoint, payload, timeout, GPU probing, or failure-suppression behavior. + ## 0.25.3-dev0 ### Fixes diff --git a/README.md b/README.md index 9bb939b7a3..0a97be753d 100644 --- a/README.md +++ b/README.md @@ -290,4 +290,4 @@ Encountered a bug? Please create a new [GitHub issue](https://github.com/Unstruc ## :chart_with_upwards_trend: Analytics -Telemetry is **off by default**. To opt in, set `UNSTRUCTURED_TELEMETRY_ENABLED=true` (or `=1`) before importing `unstructured`. To opt out, set `DO_NOT_TRACK` or `SCARF_NO_ANALYTICS` to any non-empty value (e.g. `true`, `1`, `yes`, `false`, `0`—any non-empty string opts out); opt-out takes precedence. Unset the variable or leave it empty if you do not want to opt out. See our [Privacy Policy](https://unstructured.io/privacy-policy). +Unstructured sends a lightweight library-load analytics ping by default when it is imported. To opt out before importing `unstructured`, set either `DO_NOT_TRACK` or `SCARF_NO_ANALYTICS` to any non-empty value after trimming whitespace (for example, `true`, `1`, `yes`, `false`, or `0`); either variable disables the ping. Unset the variables or leave them empty or whitespace-only to retain the default behavior. See our [Privacy Policy](https://unstructured.io/privacy-policy). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000..22bba4c01a --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +"""Global test configuration.""" + +import os + +# Package import telemetry is default-on; keep ordinary test collection hermetic. +os.environ.setdefault("DO_NOT_TRACK", "1") diff --git a/scripts/image/test-outbound-connectivity.sh b/scripts/image/test-outbound-connectivity.sh index d2e33a37bc..4bd9da7375 100755 --- a/scripts/image/test-outbound-connectivity.sh +++ b/scripts/image/test-outbound-connectivity.sh @@ -61,14 +61,12 @@ fi # ---------- scenario‑specific settings -------------------------------- DO_NOT_TRACK="" -UNSTRUCTURED_TELEMETRY_ENABLED="" HF_HUB_OFFLINE="" REMOVE_CACHE=0 case "$SCENARIO" in baseline) ;; missing-models) REMOVE_CACHE=1 ;; analytics-online-only) - UNSTRUCTURED_TELEMETRY_ENABLED=1 HF_HUB_OFFLINE=1 ;; offline) @@ -93,7 +91,6 @@ CID=$(docker run -d --rm --name "sut_${SCENARIO}" \ --network "$NET" \ --cap-add NET_RAW --cap-add NET_ADMIN \ -e DO_NOT_TRACK="$DO_NOT_TRACK" \ - -e UNSTRUCTURED_TELEMETRY_ENABLED="$UNSTRUCTURED_TELEMETRY_ENABLED" \ -e HF_HUB_OFFLINE="$HF_HUB_OFFLINE" \ --entrypoint /bin/sh "$IMAGE" -c "sleep infinity") echo "Container: $CID (scenario $SCENARIO)" @@ -132,7 +129,7 @@ fi docker exec -i -e PYTHONUNBUFFERED=1 "$CID" python - < None: - """Run the analytics ping if enabled by env. Best-effort and non-fatal.""" + """Run the library-load analytics ping unless opted out. Best-effort and non-fatal.""" scarf_analytics() diff --git a/unstructured/utils.py b/unstructured/utils.py index 7c0efcaf6a..c70cf3bce8 100644 --- a/unstructured/utils.py +++ b/unstructured/utils.py @@ -188,21 +188,12 @@ def _telemetry_opt_out() -> bool: ) -def _telemetry_opt_in() -> bool: - """True if telemetry is explicitly enabled via env. Only 'true' and '1' opt in.""" - return (os.getenv("UNSTRUCTURED_TELEMETRY_ENABLED") or "").strip().lower() in ( - "true", - "1", - ) - - def scarf_analytics(): - """Send a lightweight analytics ping. Off by default. + """Send a lightweight library-load analytics ping unless it is opted out. - Set UNSTRUCTURED_TELEMETRY_ENABLED=true to opt in. Opt-out env vars (DO_NOT_TRACK, SCARF_NO_ANALYTICS): any non-empty value opts out. """ - if _telemetry_opt_out() or not _telemetry_opt_in(): + if _telemetry_opt_out(): return try: From cbcf05d4dafc75167754b9781dc1b2ca44fef22d Mon Sep 17 00:00:00 2001 From: cragwolfe Date: Tue, 11 Aug 2026 16:15:58 -0700 Subject: [PATCH 2/5] chore: normalize 0.26.0 changelog --- CHANGELOG.md | 6 +----- unstructured/__version__.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 285d780a15..5957136746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,9 @@ -## 0.26.0-dev0 +## 0.26.0 ### Fixes - **Restore default-on library-load telemetry**: The lightweight library-load ping once again runs by default when `unstructured` is imported. Set either `DO_NOT_TRACK` or `SCARF_NO_ANALYTICS` to any non-empty value (after trimming whitespace) before import to disable it; empty and whitespace-only values retain the default behavior. This removes the `UNSTRUCTURED_TELEMETRY_ENABLED` explicit opt-in gate introduced by #4281 without changing the existing endpoint, payload, timeout, GPU probing, or failure-suppression behavior. -## 0.25.3-dev0 - -### Fixes - - **Stop the `GLOBAL_WORKING_DIR` tests from disturbing other pytest-xdist workers**: test-only change, no library behavior changes. The two tests exercising `GLOBAL_WORKING_DIR_ENABLED` now redirect the working dir to a private `tmp_path` and restore `tempfile.tempdir` unconditionally, rather than moving the shared pgid-keyed directory aside mid-run and leaving the worker's `tempfile.tempdir` pointed at it. That shared path made `test_dockerfile` fail intermittently, with an unrelated test dying inside `tempfile`. ## 0.25.2 diff --git a/unstructured/__version__.py b/unstructured/__version__.py index e5663c16af..dc57acc0c1 100644 --- a/unstructured/__version__.py +++ b/unstructured/__version__.py @@ -1 +1 @@ -__version__ = "0.26.0-dev0" # pragma: no cover +__version__ = "0.26.0" # pragma: no cover From d9a973a93e5d4b80d03732d68fcaff529d957b3e Mon Sep 17 00:00:00 2001 From: cragwolfe Date: Wed, 12 Aug 2026 09:53:41 -0700 Subject: [PATCH 3/5] fix: bound telemetry GPU probe --- CHANGELOG.md | 2 +- conftest.py | 4 +- test_unstructured/test_telemetry.py | 144 ++++++++++++++++++++++++---- unstructured/utils.py | 14 ++- 4 files changed, 138 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5957136746..3e971aee40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Fixes -- **Restore default-on library-load telemetry**: The lightweight library-load ping once again runs by default when `unstructured` is imported. Set either `DO_NOT_TRACK` or `SCARF_NO_ANALYTICS` to any non-empty value (after trimming whitespace) before import to disable it; empty and whitespace-only values retain the default behavior. This removes the `UNSTRUCTURED_TELEMETRY_ENABLED` explicit opt-in gate introduced by #4281 without changing the existing endpoint, payload, timeout, GPU probing, or failure-suppression behavior. +- **Restore default-on library-load telemetry**: The lightweight library-load ping once again runs by default when `unstructured` is imported. Set either `DO_NOT_TRACK` or `SCARF_NO_ANALYTICS` to any non-empty value (after trimming whitespace) before import to disable it; empty and whitespace-only values retain the default behavior. This removes the `UNSTRUCTURED_TELEMETRY_ENABLED` explicit opt-in gate introduced by #4281 while preserving the existing endpoint and payload. The best-effort `nvidia-smi` GPU probe now has a one-second timeout, and GPU-probe and request failures remain non-fatal. - **Stop the `GLOBAL_WORKING_DIR` tests from disturbing other pytest-xdist workers**: test-only change, no library behavior changes. The two tests exercising `GLOBAL_WORKING_DIR_ENABLED` now redirect the working dir to a private `tmp_path` and restore `tempfile.tempdir` unconditionally, rather than moving the shared pgid-keyed directory aside mid-run and leaving the worker's `tempfile.tempdir` pointed at it. That shared path made `test_dockerfile` fail intermittently, with an unrelated test dying inside `tempfile`. diff --git a/conftest.py b/conftest.py index 22bba4c01a..073b13e646 100644 --- a/conftest.py +++ b/conftest.py @@ -2,5 +2,5 @@ import os -# Package import telemetry is default-on; keep ordinary test collection hermetic. -os.environ.setdefault("DO_NOT_TRACK", "1") +# Package import telemetry is default-on; force ordinary test collection to opt out. +os.environ["DO_NOT_TRACK"] = "1" diff --git a/test_unstructured/test_telemetry.py b/test_unstructured/test_telemetry.py index a432fb3a3d..fce54e0714 100644 --- a/test_unstructured/test_telemetry.py +++ b/test_unstructured/test_telemetry.py @@ -14,6 +14,7 @@ os.environ.pop("SCARF_NO_ANALYTICS", None) import platform +import runpy import subprocess import sys from pathlib import Path @@ -31,10 +32,21 @@ def telemetry_mocks(monkeypatch): monkeypatch.delenv("SCARF_NO_ANALYTICS", raising=False) monkeypatch.delenv("DO_NOT_TRACK", raising=False) mock_get = Mock() - mock_subprocess = Mock() + mock_run = Mock() monkeypatch.setattr("unstructured.utils.requests.get", mock_get) - monkeypatch.setattr("unstructured.utils.subprocess.check_output", mock_subprocess) - return mock_get, mock_subprocess + monkeypatch.setattr("unstructured.utils.subprocess.run", mock_run) + return mock_get, mock_run + + +def _assert_gpu_probe_called_once(mock_run): + mock_run.assert_called_once_with( + ["nvidia-smi"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=1.0, + ) def _apply_telemetry_env(monkeypatch, env_overrides): @@ -50,12 +62,12 @@ class DescribeScarfAnalytics: """Tests for the default-on library-load analytics ping.""" def it_sends_telemetry_by_default(self, telemetry_mocks): - mock_get, mock_subprocess = telemetry_mocks + mock_get, mock_run = telemetry_mocks utils.scarf_analytics() mock_get.assert_called_once() - mock_subprocess.assert_called_once_with(["nvidia-smi"], stderr=subprocess.DEVNULL) + _assert_gpu_probe_called_once(mock_run) call_args = mock_get.call_args assert call_args[0][0] == "https://packages.unstructured.io/python-telemetry" params = call_args[1]["params"] @@ -75,14 +87,14 @@ def it_sends_telemetry_by_default(self, telemetry_mocks): def it_does_not_probe_or_send_when_either_opt_out_is_non_empty( self, monkeypatch, telemetry_mocks, env_name, value ): - mock_get, mock_subprocess = telemetry_mocks + mock_get, mock_run = telemetry_mocks _apply_telemetry_env(monkeypatch, {env_name: value}) assert utils._telemetry_opt_out() is True utils.scarf_analytics() mock_get.assert_not_called() - mock_subprocess.assert_not_called() + mock_run.assert_not_called() @pytest.mark.parametrize( ("env_name", "value"), @@ -97,14 +109,14 @@ def it_does_not_probe_or_send_when_either_opt_out_is_non_empty( def it_sends_when_opt_out_is_empty_or_whitespace_only( self, monkeypatch, telemetry_mocks, env_name, value ): - mock_get, mock_subprocess = telemetry_mocks + mock_get, mock_run = telemetry_mocks _apply_telemetry_env(monkeypatch, {env_name: value}) assert utils._telemetry_opt_out() is False utils.scarf_analytics() mock_get.assert_called_once() - mock_subprocess.assert_called_once_with(["nvidia-smi"], stderr=subprocess.DEVNULL) + _assert_gpu_probe_called_once(mock_run) @pytest.mark.parametrize( ("version_val", "expected_dev"), @@ -114,13 +126,13 @@ def it_sends_when_opt_out_is_empty_or_whitespace_only( def it_sends_telemetry_with_correct_dev_param( self, monkeypatch, telemetry_mocks, version_val, expected_dev ): - mock_get, mock_subprocess = telemetry_mocks + mock_get, mock_run = telemetry_mocks monkeypatch.setattr("unstructured.utils.__version__", version_val) utils.scarf_analytics() mock_get.assert_called_once() - mock_subprocess.assert_called_once() + mock_run.assert_called_once() params = mock_get.call_args[1]["params"] assert params["dev"] == expected_dev assert params["version"] == version_val @@ -129,13 +141,13 @@ def it_sends_telemetry_with_correct_dev_param( assert mock_get.call_args[1]["timeout"] == 10 def it_suppresses_requests_exceptions(self, telemetry_mocks): - mock_get, mock_subprocess = telemetry_mocks + mock_get, mock_run = telemetry_mocks mock_get.side_effect = requests.RequestException("network error") utils.scarf_analytics() # does not raise mock_get.assert_called_once() - mock_subprocess.assert_called_once() + mock_run.assert_called_once() assert mock_get.call_args[0][0] == "https://packages.unstructured.io/python-telemetry" assert "version" in mock_get.call_args[1]["params"] @@ -145,18 +157,19 @@ def it_suppresses_requests_exceptions(self, telemetry_mocks): OSError(), PermissionError("nvidia-smi denied"), subprocess.CalledProcessError(returncode=1, cmd=["nvidia-smi"]), + subprocess.TimeoutExpired(cmd=["nvidia-smi"], timeout=1.0), ], - ids=["OSError", "PermissionError", "CalledProcessError"], + ids=["OSError", "PermissionError", "CalledProcessError", "TimeoutExpired"], ) def it_sends_with_gpu_false_when_the_probe_fails(self, telemetry_mocks, exc): - mock_get, mock_subprocess = telemetry_mocks - mock_subprocess.side_effect = exc + mock_get, mock_run = telemetry_mocks + mock_run.side_effect = exc utils.scarf_analytics() # does not raise mock_get.assert_called_once() assert mock_get.call_args[1]["params"]["gpu"] == "False" - mock_subprocess.assert_called_once_with(["nvidia-smi"], stderr=subprocess.DEVNULL) + _assert_gpu_probe_called_once(mock_run) def it_import_unstructured_succeeds_when_opted_out(self): """Importing while opted out remains non-fatal.""" @@ -193,12 +206,23 @@ def it_import_unstructured_runs_telemetry_once_by_default(self): } env["PYTHONPATH"] = str(project_root) script = """ +import subprocess from unittest.mock import Mock, patch + m_get = Mock() -m_subprocess = Mock() -with patch('requests.get', m_get), patch('subprocess.check_output', m_subprocess): +m_run = Mock() +with patch("requests.get", m_get), patch("subprocess.run", m_run): import unstructured -exit(0 if (m_get.call_count == 1 and m_subprocess.call_count == 1) else 1) + +m_run.assert_called_once_with( + ["nvidia-smi"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=1.0, +) +assert m_get.call_count == 1 """ result = subprocess.run( [sys.executable, "-c", script], @@ -210,6 +234,84 @@ def it_import_unstructured_runs_telemetry_once_by_default(self): ) assert result.returncode == 0, ( "Import without an opt-out should run telemetry exactly once (requests.get and " - "subprocess.check_output each called once). " + "subprocess.run each called once). " f"stderr={result.stderr!r} stdout={result.stdout!r}" ) + + def it_import_unstructured_uses_a_bounded_gpu_probe(self): + """Import remains non-fatal when the bounded GPU probe times out.""" + project_root = Path(__file__).resolve().parent.parent + env = { + key: value + for key, value in os.environ.items() + if key not in ("DO_NOT_TRACK", "SCARF_NO_ANALYTICS") + } + env["PYTHONPATH"] = str(project_root) + script = """ +import subprocess +from unittest.mock import Mock, patch + +real_run = subprocess.run +real_check_output = subprocess.check_output +m_get = Mock() + +def reject_legacy_probe(args, **kwargs): + if args == ["nvidia-smi"]: + raise AssertionError("legacy unbounded subprocess.check_output was called") + return real_check_output(args, **kwargs) + +def timeout_gpu_probe(args, **kwargs): + if args != ["nvidia-smi"]: + return real_run(args, **kwargs) + + assert kwargs == { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "check": True, + "timeout": 1.0, + } + raise subprocess.TimeoutExpired(cmd=args, timeout=kwargs["timeout"]) + +with ( + patch("requests.get", m_get), + patch("subprocess.check_output", side_effect=reject_legacy_probe), + patch("subprocess.run", side_effect=timeout_gpu_probe) as m_run, +): + import unstructured + +assert m_run.call_count == 1 +assert m_get.call_count == 1 +assert m_get.call_args.args[0] == "https://packages.unstructured.io/python-telemetry" +assert m_get.call_args.kwargs["params"]["gpu"] == "False" +assert m_get.call_args.kwargs["timeout"] == 10 +""" + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + cwd=project_root, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +class DescribeTelemetryTestHermeticity: + @pytest.mark.parametrize( + "inherited_value", + ["", " \t "], + ids=["empty", "whitespace"], + ) + def it_root_conftest_forces_do_not_track_for_empty_or_whitespace_inheritance( + self, + monkeypatch, + inherited_value, + ): + project_root = Path(__file__).resolve().parent.parent + monkeypatch.setenv("DO_NOT_TRACK", inherited_value) + + runpy.run_path(str(project_root / "conftest.py")) + + assert os.environ["DO_NOT_TRACK"] == "1" diff --git a/unstructured/utils.py b/unstructured/utils.py index c70cf3bce8..ba87f63c01 100644 --- a/unstructured/utils.py +++ b/unstructured/utils.py @@ -177,6 +177,9 @@ def only(it: Iterable[Any]) -> Any: return out +_NVIDIA_SMI_TIMEOUT_SECONDS = 1.0 + + def _telemetry_opt_out() -> bool: """True if telemetry should be disabled via env. @@ -197,9 +200,16 @@ def scarf_analytics(): return try: - subprocess.check_output(["nvidia-smi"], stderr=subprocess.DEVNULL) + subprocess.run( + ["nvidia-smi"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=_NVIDIA_SMI_TIMEOUT_SECONDS, + ) gpu_present = True - except (OSError, subprocess.CalledProcessError): + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): gpu_present = False python_version = ".".join(platform.python_version().split(".")[:2]) From 62ac149803609957ebfe6ecca83328636b47c75d Mon Sep 17 00:00:00 2001 From: cragwolfe Date: Wed, 12 Aug 2026 10:18:10 -0700 Subject: [PATCH 4/5] test: tolerate packaged test layout --- test_unstructured/test_telemetry.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test_unstructured/test_telemetry.py b/test_unstructured/test_telemetry.py index fce54e0714..05edc8edd9 100644 --- a/test_unstructured/test_telemetry.py +++ b/test_unstructured/test_telemetry.py @@ -310,8 +310,12 @@ def it_root_conftest_forces_do_not_track_for_empty_or_whitespace_inheritance( inherited_value, ): project_root = Path(__file__).resolve().parent.parent + conftest_path = project_root / "conftest.py" + if not conftest_path.is_file(): + pytest.skip("root conftest is not included in this test distribution") + monkeypatch.setenv("DO_NOT_TRACK", inherited_value) - runpy.run_path(str(project_root / "conftest.py")) + runpy.run_path(str(conftest_path)) assert os.environ["DO_NOT_TRACK"] == "1" From 5acfa77b42767f934db992ac219f0eaf020eddc5 Mon Sep 17 00:00:00 2001 From: cragwolfe Date: Wed, 12 Aug 2026 10:40:12 -0700 Subject: [PATCH 5/5] test: disable telemetry in Docker suite --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 2c82270587..70a0601a1f 100644 --- a/Makefile +++ b/Makefile @@ -160,6 +160,7 @@ docker-test: -v ${CURRENT_DIR}/test_unstructured:/home/notebook-user/test_unstructured \ -v ${CURRENT_DIR}/test_unstructured_ingest:/home/notebook-user/test_unstructured_ingest \ $(if $(wildcard uns_test_env_file),--env-file uns_test_env_file,) \ + --env DO_NOT_TRACK=1 \ $(DOCKER_IMAGE) \ bash -c "uv sync --locked --all-extras --group test --no-install-project && \ CI=$(CI) \