Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions plain2code_telemetry.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,9 +21,7 @@

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"
TELEMETRY_ENV_VAR = "CODEPLAIN_TELEMETRY"

FLUSH_TIMEOUT_SECONDS = 2

Expand Down Expand Up @@ -50,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:
Expand All @@ -65,7 +71,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,
Expand Down
45 changes: 45 additions & 0 deletions system_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
58 changes: 58 additions & 0 deletions tests/test_system_config.py
Original file line number Diff line number Diff line change
@@ -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__])
41 changes: 31 additions & 10 deletions tests/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -47,8 +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(plain2code_telemetry.ENVIRONMENT_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():
Expand All @@ -64,15 +66,33 @@ 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)
assert not capture_crash(make_exc_info(KeyError("boom")), None, make_args())
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)

Expand Down Expand Up @@ -192,15 +212,16 @@ 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")
monkeypatch.setenv(TELEMETRY_ENV_VAR, "1")
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):
Expand Down
Loading