From d9c21d3c15419cdfb38dc427788fc98c36e03845 Mon Sep 17 00:00:00 2001 From: Hisenberg Date: Thu, 30 Jul 2026 14:24:35 +0200 Subject: [PATCH 1/2] feat: add environment in system_config Add "environment" and "installed" fields to system config. Environment was only used by sentry telemetry, so was moved from there. Environment is set to: - CODEPLAIN_ENV env variable (if explicitly set) - production (if package was installed from index) - development (otherwise, default) --- plain2code_telemetry.py | 4 +-- system_config.py | 45 ++++++++++++++++++++++++++++ tests/test_system_config.py | 58 +++++++++++++++++++++++++++++++++++++ tests/test_telemetry.py | 11 ++++--- 4 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 tests/test_system_config.py diff --git a/plain2code_telemetry.py b/plain2code_telemetry.py index 8e1776fd..afb59cbf 100644 --- a/plain2code_telemetry.py +++ b/plain2code_telemetry.py @@ -21,8 +21,6 @@ SENTRY_DSN = "https://64d0d86b50b34e2dede3e4eaf5142282@o4510793955934208.ingest.us.sentry.io/4511540621213696" NO_TELEMETRY_ENV_VAR = "CODEPLAIN_NO_TELEMETRY" -ENVIRONMENT_ENV_VAR = "CODEPLAIN_ENV" -DEFAULT_ENVIRONMENT = "production" FLUSH_TIMEOUT_SECONDS = 2 @@ -65,7 +63,7 @@ def initialize_telemetry(**init_overrides: Any) -> bool: init_kwargs: dict[str, Any] = dict( dsn=SENTRY_DSN, release=system_config.client_version, - environment=os.environ.get(ENVIRONMENT_ENV_VAR, DEFAULT_ENVIRONMENT), + environment=system_config.environment, send_default_pii=False, server_name="", # hostname is identifying; don't send it default_integrations=False, diff --git a/system_config.py b/system_config.py index 49f81942..a97d7f28 100644 --- a/system_config.py +++ b/system_config.py @@ -6,6 +6,10 @@ from plain2code_console import console +ENVIRONMENT_VAR = "CODEPLAIN_ENV" +PRODUCTION_ENV = "production" +DEVELOPMENT_ENV = "development" + def _resolve_version() -> str: """Resolve the client version. @@ -38,7 +42,46 @@ def _resolve_version() -> str: return "0.0.0.dev0" +def _resolve_installed() -> bool: + """Return True if codeplain was installed from a package index. + + An installed distribution that came from an index has no PEP 610 + ``direct_url.json``; installs from a local path, VCS or direct URL + (``pip install -e .``, ``pip install .``, a git URL) do. A source checkout + that was never installed has no distribution metadata at all. + """ + from importlib.metadata import PackageNotFoundError, distribution + + try: + dist = distribution("codeplain") + except PackageNotFoundError: + return False + except Exception: + return False + + try: + return dist.read_text("direct_url.json") is None + except Exception: + return False + + +def _resolve_environment(installed: bool) -> str: + """Resolve the environment this run reports itself as. + + An explicit CODEPLAIN_ENV always wins. Otherwise a package installed from an + index is a real user install ("production"), while anything else (a source + checkout, an editable install) is a dev run. + """ + explicit_env = os.environ.get(ENVIRONMENT_VAR, "").strip() + if explicit_env: + return explicit_env + + return PRODUCTION_ENV if installed else DEVELOPMENT_ENV + + __version__ = _resolve_version() +__installed__ = _resolve_installed() +__environment__ = _resolve_environment(__installed__) class SystemConfig: @@ -50,6 +93,8 @@ def __init__(self): raise KeyError("Missing 'error_messages' section in system_config.yaml") self.client_version = __version__ + self.installed = __installed__ + self.environment = __environment__ self.error_messages = self.config["error_messages"] def _load_config(self): diff --git a/tests/test_system_config.py b/tests/test_system_config.py new file mode 100644 index 00000000..320de982 --- /dev/null +++ b/tests/test_system_config.py @@ -0,0 +1,58 @@ +"""Tests for environment resolution in system_config.""" + +import pytest + +from system_config import ( + DEVELOPMENT_ENV, + ENVIRONMENT_VAR, + PRODUCTION_ENV, + _resolve_environment, + system_config, +) + +# An arbitrary environment name, deliberately neither production nor development. +EXPLICIT_ENV = "staging" + + +@pytest.fixture +def unset_environment_var(monkeypatch): + monkeypatch.delenv(ENVIRONMENT_VAR, raising=False) + + +@pytest.mark.parametrize("installed", [True, False]) +def test_explicit_environment_var_overrides_installed_state(monkeypatch, installed): + monkeypatch.setenv(ENVIRONMENT_VAR, EXPLICIT_ENV) + assert _resolve_environment(installed) == EXPLICIT_ENV + + +def test_installed_package_defaults_to_production(unset_environment_var): + assert _resolve_environment(True) == PRODUCTION_ENV + + +def test_uninstalled_source_checkout_defaults_to_development(unset_environment_var): + assert _resolve_environment(False) == DEVELOPMENT_ENV + + +def test_surrounding_whitespace_is_stripped_from_explicit_value(monkeypatch): + monkeypatch.setenv(ENVIRONMENT_VAR, f" {EXPLICIT_ENV} ") + assert _resolve_environment(True) == EXPLICIT_ENV + + +@pytest.mark.parametrize("blank", ["", " "], ids=["empty", "whitespace"]) +@pytest.mark.parametrize( + "installed,expected", + [(True, PRODUCTION_ENV), (False, DEVELOPMENT_ENV)], + ids=["installed", "uninstalled"], +) +def test_blank_environment_var_is_treated_as_unset(monkeypatch, blank, installed, expected): + monkeypatch.setenv(ENVIRONMENT_VAR, blank) + assert _resolve_environment(installed) == expected + + +def test_system_config_exposes_resolved_environment(): + assert isinstance(system_config.environment, str) + assert system_config.environment + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 4ceafb54..c50a30c5 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -48,7 +48,6 @@ def make_args(**overrides): def clean_telemetry_env(monkeypatch): """Ensure tests are not affected by the developer's environment and never send real events.""" monkeypatch.delenv(NO_TELEMETRY_ENV_VAR, raising=False) - monkeypatch.delenv(plain2code_telemetry.ENVIRONMENT_ENV_VAR, raising=False) yield client = sentry_sdk.get_client() if client.is_active(): @@ -192,15 +191,15 @@ def crash_with_nested_secret(): assert secret not in json.dumps(transport.events[0], default=str) -def test_environment_defaults_to_production(transport): +def test_environment_comes_from_system_config(transport): init_with_transport(transport) - assert sentry_sdk.get_client().options["environment"] == "production" + assert sentry_sdk.get_client().options["environment"] == plain2code_telemetry.system_config.environment -def test_environment_env_var_respected(monkeypatch, transport): - monkeypatch.setenv(plain2code_telemetry.ENVIRONMENT_ENV_VAR, "development") +def test_environment_follows_system_config(monkeypatch, transport): + monkeypatch.setattr(plain2code_telemetry.system_config, "environment", "staging") init_with_transport(transport) - assert sentry_sdk.get_client().options["environment"] == "development" + assert sentry_sdk.get_client().options["environment"] == "staging" def test_release_is_client_version(transport): From a94c16afffe74e1c4c5d1acddffc9bc6b4e0caad Mon Sep 17 00:00:00 2001 From: Hisenberg Date: Thu, 30 Jul 2026 14:41:58 +0200 Subject: [PATCH 2/2] feat: default telemetry off in non-prod envs Disable telemetry in non-production environments by default. --- plain2code_telemetry.py | 24 ++++++++++++++++-------- tests/test_telemetry.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/plain2code_telemetry.py b/plain2code_telemetry.py index afb59cbf..0f4cf28c 100644 --- a/plain2code_telemetry.py +++ b/plain2code_telemetry.py @@ -1,9 +1,10 @@ """Crash reporting via Sentry. Only unexpected exceptions are reported (the caller decides which exceptions are -expected; see EXPECTED_EXCEPTIONS in plain2code.py). Reporting is on by default -and can be disabled by setting the CODEPLAIN_NO_TELEMETRY environment variable -to any non-empty value. +expected; see EXPECTED_EXCEPTIONS in plain2code.py). In production, reporting is +on by default and can be disabled by setting CODEPLAIN_TELEMETRY to 0, false or +off. In any other environment it is off unless CODEPLAIN_TELEMETRY is explicitly +set to 1, true or on. """ import os @@ -20,7 +21,7 @@ SENTRY_DSN = "https://64d0d86b50b34e2dede3e4eaf5142282@o4510793955934208.ingest.us.sentry.io/4511540621213696" -NO_TELEMETRY_ENV_VAR = "CODEPLAIN_NO_TELEMETRY" +TELEMETRY_ENV_VAR = "CODEPLAIN_TELEMETRY" FLUSH_TIMEOUT_SECONDS = 2 @@ -48,10 +49,17 @@ def telemetry_enabled() -> bool: - """Return True if crash reporting should be active.""" - if os.environ.get(NO_TELEMETRY_ENV_VAR): - return False - return True + """Return True if crash reporting should be active. + + In production it is on unless CODEPLAIN_TELEMETRY disables it. + Anywhere else it is off unless CODEPLAIN_TELEMETRY enables it. + """ + setting = os.environ.get(TELEMETRY_ENV_VAR, "").strip().lower() + + if system_config.environment == "production": + return setting not in {"0", "false", "off"} + + return setting in {"1", "true", "on"} def initialize_telemetry(**init_overrides: Any) -> bool: diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index c50a30c5..22ea7da3 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -9,7 +9,7 @@ import plain2code_telemetry from plain2code_state import RunState -from plain2code_telemetry import NO_TELEMETRY_ENV_VAR, capture_crash, initialize_telemetry, telemetry_enabled +from plain2code_telemetry import TELEMETRY_ENV_VAR, capture_crash, initialize_telemetry, telemetry_enabled class CaptureTransport(Transport): @@ -47,7 +47,10 @@ def make_args(**overrides): @pytest.fixture(autouse=True) def clean_telemetry_env(monkeypatch): """Ensure tests are not affected by the developer's environment and never send real events.""" - monkeypatch.delenv(NO_TELEMETRY_ENV_VAR, raising=False) + monkeypatch.delenv(TELEMETRY_ENV_VAR, raising=False) + # Tests run from a source checkout (a dev environment, where telemetry is + # off by default); pretend to be production so the default path is covered. + monkeypatch.setattr(plain2code_telemetry.system_config, "environment", "production") yield client = sentry_sdk.get_client() if client.is_active(): @@ -63,8 +66,9 @@ def init_with_transport(transport): assert initialize_telemetry(transport=transport) -def test_no_telemetry_env_var_disables(monkeypatch, transport): - monkeypatch.setenv(NO_TELEMETRY_ENV_VAR, "1") +@pytest.mark.parametrize("value", ["0", "false", "off", "OFF", " False "]) +def test_telemetry_env_var_disables_in_production(monkeypatch, transport, value): + monkeypatch.setenv(TELEMETRY_ENV_VAR, value) assert not telemetry_enabled() assert not initialize_telemetry(transport=transport) @@ -72,6 +76,23 @@ def test_no_telemetry_env_var_disables(monkeypatch, transport): assert transport.events == [] +def test_telemetry_disabled_outside_production(monkeypatch, transport): + monkeypatch.setattr(plain2code_telemetry.system_config, "environment", "development") + + assert not telemetry_enabled() + assert not initialize_telemetry(transport=transport) + assert not capture_crash(make_exc_info(KeyError("boom")), None, make_args()) + assert transport.events == [] + + +@pytest.mark.parametrize("value", ["1", "true", "on", "ON"]) +def test_telemetry_env_var_enables_outside_production(monkeypatch, value): + monkeypatch.setattr(plain2code_telemetry.system_config, "environment", "development") + monkeypatch.setenv(TELEMETRY_ENV_VAR, value) + + assert telemetry_enabled() + + def test_capture_crash_sends_event_with_tags(transport): init_with_transport(transport) @@ -198,6 +219,7 @@ def test_environment_comes_from_system_config(transport): def test_environment_follows_system_config(monkeypatch, transport): monkeypatch.setattr(plain2code_telemetry.system_config, "environment", "staging") + monkeypatch.setenv(TELEMETRY_ENV_VAR, "1") init_with_transport(transport) assert sentry_sdk.get_client().options["environment"] == "staging"