From 3c7c94761725f9c627d08416ed224eda286b0349 Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Thu, 10 Sep 2026 16:58:28 -0700 Subject: [PATCH] Let capture_env patterns be persisted in config.yaml Config.load() read capture_env_patterns only from THIRDEYE_CAPTURE_ENV in os.environ. That makes capture depend on the launching shell: an agent dispatched from a context that never sources the user's rc (workbench's tmux wrapper forwards its own env, cron, a GUI) sees no pattern and captures nothing -- no wb.* attributes, no tags -- while an agent run straight from an interactive terminal works. Fall back to a `capture_env` key in ~/.thirdeye/config.yaml (string or list) when the env var is unset; the env var still overrides for one-off runs. Add `Config.write_capture_env_patterns()` and a `thirdeye capture-env` command group (show / set / clear) to manage it. Co-Authored-By: Claude Sonnet 5 --- README.md | 12 +++++ src/thirdeye/cli.py | 2 + src/thirdeye/commands/capture_env.py | 48 +++++++++++++++++++ src/thirdeye/config.py | 38 ++++++++++++++- tests/test_capture_env_command.py | 55 ++++++++++++++++++++++ tests/test_config.py | 70 ++++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/thirdeye/commands/capture_env.py create mode 100644 tests/test_capture_env_command.py diff --git a/README.md b/README.md index 126ead4..f491149 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,18 @@ exported span for Claude Code, Codex, and Cursor. For example: THIRDEYE_CAPTURE_ENV='WB_*' WB_PLAN=my-plan WB_TASK=task-1 claude ``` +The patterns can also be persisted so they do not depend on the launching +shell exporting `THIRDEYE_CAPTURE_ENV` (an agent dispatched from a context +that never sources your shell rc would otherwise capture nothing): + +```sh +thirdeye capture-env set 'WB_*' # writes capture_env to ~/.thirdeye/config.yaml +thirdeye capture-env show # what is active, and where it came from +``` + +`THIRDEYE_CAPTURE_ENV` still overrides the persisted value when set, so a +one-off run can change it. + `WB_PLAN` becomes the span attribute `wb.plan` (and likewise for other `WB_` fields). Other matched names are lowercased: `BUILD_LABEL` becomes `build_label`. Values remain strings with their original case and contents; diff --git a/src/thirdeye/cli.py b/src/thirdeye/cli.py index c26792b..d50f640 100644 --- a/src/thirdeye/cli.py +++ b/src/thirdeye/cli.py @@ -6,6 +6,7 @@ from thirdeye._compat.streams import force_utf8_stdio from thirdeye.commands.add import add, remove from thirdeye.commands.agent import agent_cmd +from thirdeye.commands.capture_env import capture_env_group from thirdeye.commands.eval import eval_group from thirdeye.commands.ingest import ingest from thirdeye.commands.logfire_cmd import logfire_group @@ -44,6 +45,7 @@ def main() -> None: main.add_command(views_group) main.add_command(agent_cmd) main.add_command(logfire_group) +main.add_command(capture_env_group) main.add_command(setup) diff --git a/src/thirdeye/commands/capture_env.py b/src/thirdeye/commands/capture_env.py new file mode 100644 index 0000000..6c301f6 --- /dev/null +++ b/src/thirdeye/commands/capture_env.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import click + +from thirdeye.config import Config + + +@click.group( + name="capture-env", + help="Manage which environment variables thirdeye records as span attributes and tags.", +) +def capture_env_group() -> None: + pass + + +@capture_env_group.command( + "show", help="Show the active capture patterns and where they come from." +) +def show() -> None: + import os + + config = Config.load() + env_raw = os.environ.get("THIRDEYE_CAPTURE_ENV", "") + if env_raw.strip(): + source = "THIRDEYE_CAPTURE_ENV (overrides config.yaml)" + elif config.capture_env_patterns: + source = f"config.yaml ({config.config_file})" + else: + source = "(not configured)" + patterns = ", ".join(config.capture_env_patterns) or "(none)" + click.echo(f"patterns : {patterns}") + click.echo(f"source : {source}") + + +@capture_env_group.command("set", help="Persist capture patterns to config.yaml, e.g. 'WB_*'.") +@click.argument("patterns", nargs=-1, required=True) +def set_patterns(patterns: tuple[str, ...]) -> None: + # Accept both `set WB_* BUILD_LABEL` and `set 'WB_*,BUILD_LABEL'`. + flat = tuple(p.strip() for chunk in patterns for p in chunk.split(",") if p.strip()) + config = Config.load().write_capture_env_patterns(flat) + click.echo(f"capture_env set to: {', '.join(config.capture_env_patterns)}") + click.echo(f"written to {config.config_file}") + + +@capture_env_group.command("clear", help="Remove the persisted capture patterns from config.yaml.") +def clear() -> None: + Config.load().write_capture_env_patterns(()) + click.echo("capture_env cleared") diff --git a/src/thirdeye/config.py b/src/thirdeye/config.py index 240b3f9..1505024 100644 --- a/src/thirdeye/config.py +++ b/src/thirdeye/config.py @@ -21,6 +21,19 @@ def _parse_patterns(raw: str) -> tuple[str, ...]: return tuple(p.strip() for p in raw.split(",") if p.strip()) +def _coerce_patterns(value: Any) -> tuple[str, ...]: + """Normalize a config.yaml ``capture_env`` value to a pattern tuple. + + Accepts either a comma-separated string (``"WB_*, BUILD_LABEL"``) or a + YAML list (``["WB_*", "BUILD_LABEL"]``); anything else yields ``()``. + """ + if isinstance(value, str): + return _parse_patterns(value) + if isinstance(value, (list, tuple)): + return tuple(str(item).strip() for item in value if str(item).strip()) + return () + + @dataclass(frozen=True) class LogfireSettings: """Persisted Logfire export settings, read from config.yaml's ``logfire`` key. @@ -81,9 +94,16 @@ class Config: def load(cls) -> Config: root = default_root() raw = _read_config_yaml(root / "config.yaml") + # THIRDEYE_CAPTURE_ENV wins when set, so a one-off run can still + # override; otherwise fall back to config.yaml's ``capture_env`` so + # capture does not depend on the launching shell exporting anything + # (workbench dispatches agents from contexts that may not source rc). + patterns = _parse_patterns(os.environ.get("THIRDEYE_CAPTURE_ENV", "")) + if not patterns: + patterns = _coerce_patterns(raw.get("capture_env")) return cls( root=root, - capture_env_patterns=_parse_patterns(os.environ.get("THIRDEYE_CAPTURE_ENV", "")), + capture_env_patterns=patterns, logfire=LogfireSettings.from_dict(raw.get("logfire")), ) @@ -106,6 +126,22 @@ def write_logfire_settings(self, settings: LogfireSettings) -> Config: _write_config_yaml(self.config_file, data) return replace(self, logfire=settings) + def write_capture_env_patterns(self, patterns: tuple[str, ...] | list[str]) -> Config: + """Persist ``capture_env`` to config.yaml, preserving other top-level keys. + + An empty sequence removes the key. Returns a copy of this Config with + the new patterns applied. ``THIRDEYE_CAPTURE_ENV`` still overrides this + at load time when set. + """ + cleaned = tuple(str(p).strip() for p in patterns if str(p).strip()) + data = _read_config_yaml(self.config_file) + if cleaned: + data["capture_env"] = list(cleaned) + else: + data.pop("capture_env", None) + _write_config_yaml(self.config_file, data) + return replace(self, capture_env_patterns=cleaned) + def load() -> Config: return Config.load() diff --git a/tests/test_capture_env_command.py b/tests/test_capture_env_command.py new file mode 100644 index 0000000..65188c0 --- /dev/null +++ b/tests/test_capture_env_command.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from thirdeye.commands.capture_env import capture_env_group +from thirdeye.config import Config + + +@pytest.fixture(autouse=True) +def _home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + return tmp_path + + +def test_show_when_unconfigured(): + result = CliRunner().invoke(capture_env_group, ["show"]) + assert result.exit_code == 0 + assert "patterns : (none)" in result.output + assert "not configured" in result.output + + +def test_set_persists_and_show_reports_config_source(): + r = CliRunner().invoke(capture_env_group, ["set", "WB_*"]) + assert r.exit_code == 0 + assert Config.load().capture_env_patterns == ("WB_*",) + + shown = CliRunner().invoke(capture_env_group, ["show"]) + assert "patterns : WB_*" in shown.output + assert "config.yaml" in shown.output + + +def test_set_accepts_multiple_and_comma_forms(): + CliRunner().invoke(capture_env_group, ["set", "WB_*", "BUILD_LABEL"]) + assert Config.load().capture_env_patterns == ("WB_*", "BUILD_LABEL") + CliRunner().invoke(capture_env_group, ["set", "A_*,B_*"]) + assert Config.load().capture_env_patterns == ("A_*", "B_*") + + +def test_clear_removes_the_key(): + CliRunner().invoke(capture_env_group, ["set", "WB_*"]) + r = CliRunner().invoke(capture_env_group, ["clear"]) + assert r.exit_code == 0 + assert Config.load().capture_env_patterns == () + + +def test_env_var_is_reported_as_overriding(monkeypatch: pytest.MonkeyPatch): + CliRunner().invoke(capture_env_group, ["set", "WB_*"]) + monkeypatch.setenv("THIRDEYE_CAPTURE_ENV", "OTHER_*") + shown = CliRunner().invoke(capture_env_group, ["show"]) + assert "patterns : OTHER_*" in shown.output + assert "THIRDEYE_CAPTURE_ENV" in shown.output diff --git a/tests/test_config.py b/tests/test_config.py index c436edb..2da84ca 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +import yaml from thirdeye.config import Config, LogfireSettings @@ -104,3 +105,72 @@ def test_malformed_config_file_yields_defaults( cfg_path = tmp_path / "config.yaml" cfg_path.write_text("not: valid: yaml: [") assert Config.load().logfire == LogfireSettings() + + +class TestCaptureEnvPatternsFromConfigFile: + def test_load_falls_back_to_config_file_string( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + (tmp_path / "config.yaml").write_text("capture_env: 'WB_*, BUILD_LABEL'\n") + assert Config.load().capture_env_patterns == ("WB_*", "BUILD_LABEL") + + def test_load_falls_back_to_config_file_list( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + (tmp_path / "config.yaml").write_text("capture_env:\n - 'WB_*'\n - OTHER\n") + assert Config.load().capture_env_patterns == ("WB_*", "OTHER") + + def test_env_var_overrides_config_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.setenv("THIRDEYE_CAPTURE_ENV", "FROM_ENV_*") + (tmp_path / "config.yaml").write_text("capture_env: 'WB_*'\n") + assert Config.load().capture_env_patterns == ("FROM_ENV_*",) + + def test_no_env_no_config_key_yields_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + (tmp_path / "config.yaml").write_text("logfire:\n enabled: true\n") + assert Config.load().capture_env_patterns == () + + +class TestCaptureEnvPatternsPersistence: + def test_write_then_load_roundtrips( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + Config.load().write_capture_env_patterns(("WB_*", "BUILD_LABEL")) + assert Config.load().capture_env_patterns == ("WB_*", "BUILD_LABEL") + + def test_write_returns_updated_copy(self, tmp_path: Path) -> None: + updated = Config(root=tmp_path).write_capture_env_patterns(["WB_*"]) + assert updated.capture_env_patterns == ("WB_*",) + + def test_write_preserves_logfire_key( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + Config.load().write_logfire_settings(LogfireSettings(enabled=True, token="tok")) + Config.load().write_capture_env_patterns(("WB_*",)) + reloaded = Config.load() + assert reloaded.logfire == LogfireSettings(enabled=True, token="tok") + assert reloaded.capture_env_patterns == ("WB_*",) + + def test_write_empty_clears_the_key( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + monkeypatch.delenv("THIRDEYE_CAPTURE_ENV", raising=False) + Config.load().write_capture_env_patterns(("WB_*",)) + Config.load().write_capture_env_patterns(()) + assert Config.load().capture_env_patterns == () + assert "capture_env" not in yaml.safe_load((tmp_path / "config.yaml").read_text())