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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/thirdeye/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
48 changes: 48 additions & 0 deletions src/thirdeye/commands/capture_env.py
Original file line number Diff line number Diff line change
@@ -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")
38 changes: 37 additions & 1 deletion src/thirdeye/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")),
)

Expand All @@ -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()
55 changes: 55 additions & 0 deletions tests/test_capture_env_command.py
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

import pytest
import yaml

from thirdeye.config import Config, LogfireSettings

Expand Down Expand Up @@ -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())
Loading