Repository: meltano/meltano

Issue:
Add is_codespaces to telemetry environment context
Just like [we set `is_ci_environment` when the `CI` env var is set](https://github.com/meltano/meltano/blob/main/src/meltano/core/tracking/contexts/environment.py#L57), we should set `is_codespaces` (or something to that effect) when `CODESPACES` is set (see [docs](https://docs.github.com/en/codespaces/developing-in-codespaces/default-environment-variables-for-your-codespace)).

@tayloramurphy It'd be interesting to compare how far people get into the funnel with codespaces vs having to install locally. On the one hand, the barrier is lower so some people that click the button may be less motivated to make it to the end, but on the other hand, it should be easier to just quickly follow the steps and get to "wow". We may run into the issue that we currently consider any usage of less than 5min a bot, and that these codespaces projects may be treated as one-offs instead of being reused to form the company's official Meltano projects, so they'll never turn active. It'll be good to have the option of treating new codespaces projects differently from new local projects in our reporting.


Discussion:
This sounds like a good bit of data to collect, and while we're at it I'd also like to generalize the collection a bit here so that in the future we don't have to update a telemetry schema to collect more info about which env vars are truthy.

We can store a list of env vars including `GITHUB_ACTIONS`, `CI`, `CODESPACES`, etc. in `meltano.core`, and then report a dict of those which were present with Snowplow mapped to what their actual values were.

Resolving patch:
diff --git a/src/meltano/core/tracking/contexts/environment.py b/src/meltano/core/tracking/contexts/environment.py
--- a/src/meltano/core/tracking/contexts/environment.py
+++ b/src/meltano/core/tracking/contexts/environment.py
@@ -9,7 +9,7 @@
 from contextlib import suppress
 from datetime import datetime
 from pathlib import Path
-from typing import Any
+from typing import Any, Iterable
 from warnings import warn
 
 import psutil
@@ -19,7 +19,7 @@
 
 import meltano
 from meltano.core.tracking.schemas import EnvironmentContextSchema
-from meltano.core.utils import hash_sha256, safe_hasattr
+from meltano.core.utils import get_boolean_env_var, hash_sha256, safe_hasattr, strtobool
 
 logger = get_logger(__name__)
 
@@ -44,9 +44,21 @@ def _get_parent_context_uuid_str() -> str | None:
 class EnvironmentContext(SelfDescribingJson):
     """Environment context for the Snowplow tracker."""
 
+    ci_markers = {"GITHUB_ACTIONS", "CI"}
+    notable_flag_env_vars = {"CODESPACES", *ci_markers}
+
+    @classmethod
+    def _notable_flag_env_vars(cls) -> Iterable[str]:
+        for env_var_name in cls.notable_flag_env_vars:
+            with suppress(KeyError):  # Skip unset env vars
+                env_var_value = os.environ[env_var_name]
+                try:
+                    yield env_var_name, strtobool(env_var_value)
+                except ValueError:
+                    yield env_var_name, None
+
     def __init__(self):
         """Initialize the environment context."""
-        ci_markers = ("GITHUB_ACTIONS", "CI")
         super().__init__(
             EnvironmentContextSchema.url,
             {
@@ -55,10 +67,9 @@ def __init__(self):
                 "meltano_version": meltano.__version__,
                 "is_dev_build": not release_marker_path.exists(),
                 "is_ci_environment": any(
-                    # True if 'true', 'TRUE', 'True', or '1'
-                    os.environ.get(marker, "").lower()[:1] in {"1", "t"}
-                    for marker in ci_markers
+                    get_boolean_env_var(marker) for marker in self.ci_markers
                 ),
+                "notable_flag_env_vars": dict(self._notable_flag_env_vars()),
                 "python_version": platform.python_version(),
                 "python_implementation": platform.python_implementation(),
                 **self.system_info,
@@ -108,7 +119,8 @@ def process_info(self) -> dict[str, Any]:
         """Obtain the process information for the current process.
 
         Returns:
-            A dictionary containing the process information. Such as the hashed process name, pid, core counts, etc
+            A dictionary containing the process information. Such as the hashed
+            process name, pid, core counts, etc
         """
         process = psutil.Process()
         with process.oneshot():
@@ -128,10 +140,11 @@ def process_info(self) -> dict[str, Any]:
     def num_available_cores(self) -> int:
         """Obtain the number of available CPU cores.
 
-        Uses sched_getaffinity where available, otherwise falls back to cpu_count().
+        Uses `sched_getaffinity` where available, otherwise falls back to
+        `cpu_count`.
 
         Returns:
-            int: The number of available CPU cores.
+            The number of available CPU cores.
         """
         if safe_hasattr(os, "sched_getaffinity"):
             return len(os.sched_getaffinity(0))
diff --git a/src/meltano/core/tracking/schemas.py b/src/meltano/core/tracking/schemas.py
--- a/src/meltano/core/tracking/schemas.py
+++ b/src/meltano/core/tracking/schemas.py
@@ -28,7 +28,7 @@ def url(self) -> str:
 CliContextSchema = IgluSchema("cli_context", "1-1-0")
 CliEventSchema = IgluSchema("cli_event", "1-0-1")
 BlockEventSchema = IgluSchema("block_event", "1-0-0")
-EnvironmentContextSchema = IgluSchema("environment_context", "1-1-0")
+EnvironmentContextSchema = IgluSchema("environment_context", "1-2-0")
 ExceptionContextSchema = IgluSchema("exception_context", "1-0-0")
 ExitEventSchema = IgluSchema("exit_event", "1-0-1")
 PluginsContextSchema = IgluSchema("plugins_context", "1-0-0")


Test patch:
diff --git a/tests/meltano/core/tracking/test_environment_context.py b/tests/meltano/core/tracking/test_environment_context.py
new file mode 100644
--- /dev/null
+++ b/tests/meltano/core/tracking/test_environment_context.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import random
+
+import pytest
+
+from meltano.core.tracking.contexts.environment import EnvironmentContext
+
+
+def test_environment_context(monkeypatch: pytest.MonkeyPatch):
+    for notable_flag_env_var in EnvironmentContext.notable_flag_env_vars:
+        monkeypatch.delenv(notable_flag_env_var, raising=False)
+
+    assert EnvironmentContext().data["notable_flag_env_vars"] == {}  # noqa: WPS520
+
+    def check(env_var_values: tuple[str, ...], expected: bool | None) -> None:
+        for notable_flag_env_var in EnvironmentContext.notable_flag_env_vars:
+            monkeypatch.setenv(
+                notable_flag_env_var,
+                random.choice(env_var_values),  # noqa: S311
+            )
+
+        assert EnvironmentContext().data["notable_flag_env_vars"] == dict.fromkeys(
+            EnvironmentContext.notable_flag_env_vars, expected
+        )
+
+    check(("Yes", "TRUE", "1", "true", "y", "on", "t"), True)
+    check(("No", "FALSE", "0", "false", "n", "off", "f"), False)
+    check(("trew", "Fallse", "nah", "si", "okay", "01"), None)