mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
feat(code): surface tracing in doctor and config show (#4163)
`dcode doctor` now reports a tracing summary, and `dcode config show`/`list` group tracing options under a dedicated `Tracing` section. --- Makes LangSmith tracing configuration easy to inspect from the CLI without digging through env vars. `dcode doctor` gains a brief `Tracing` section reporting whether tracing is enabled, whether credentials are configured (presence only — the API key value is never read or printed), the resolved project, and any custom endpoint or replica project. It stays fully offline and flags a problem only when tracing is enabled without a key *and* without a custom endpoint, mirroring the existing orphaned-tracing guard (a keyless self-hosted endpoint is a valid, healthy setup). The detection lives in a new offline `get_tracing_status` helper so it is independently testable. The `tracing.*` options move into their own `Tracing` group so `config show` and `config list` render them as a dedicated section instead of burying them under `Models`. Grouping is first-seen order, so the new section appears right after `Models` with no other changes. ## Test Plan - [ ] `dcode doctor` shows a `Tracing` section; with `LANGSMITH_TRACING=true` but no key/endpoint, the `Credentials` item is flagged unhealthy. - [ ] `dcode config show` lists a `Tracing` group. Made by [Open SWE](https://openswe.vercel.app/agents/b0ab8323-a16c-9185-e910-a4de6bdc11ae) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -351,7 +351,9 @@ def _quiet_sdk_tracing_logging() -> None:
|
||||
sdk_logger.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def _load_langsmith_profile_config() -> _LangSmithProfileConfig | None:
|
||||
def _load_langsmith_profile_config(
|
||||
env: dict[str, str] | None = None,
|
||||
) -> _LangSmithProfileConfig | None:
|
||||
"""Return the active LangSmith profile client config, if available."""
|
||||
try:
|
||||
client_module = importlib.import_module("langsmith.client")
|
||||
@@ -362,12 +364,18 @@ def _load_langsmith_profile_config() -> _LangSmithProfileConfig | None:
|
||||
if profiles is None:
|
||||
return None
|
||||
|
||||
return profiles.load_profile_client_config()
|
||||
if env is None:
|
||||
return profiles.load_profile_client_config()
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return profiles.load_profile_client_config()
|
||||
|
||||
|
||||
def _has_langsmith_profile_credentials() -> bool:
|
||||
def _has_langsmith_profile_credentials(env: dict[str, str] | None = None) -> bool:
|
||||
"""Return whether the LangSmith profile config has usable auth material."""
|
||||
config = _load_langsmith_profile_config()
|
||||
config = _load_langsmith_profile_config(env)
|
||||
if config is None:
|
||||
return False
|
||||
|
||||
@@ -376,9 +384,9 @@ def _has_langsmith_profile_credentials() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _has_langsmith_profile_custom_endpoint() -> bool:
|
||||
def _has_langsmith_profile_custom_endpoint(env: dict[str, str] | None = None) -> bool:
|
||||
"""Return whether the LangSmith profile points at a custom endpoint."""
|
||||
config = _load_langsmith_profile_config()
|
||||
config = _load_langsmith_profile_config(env)
|
||||
if config is None:
|
||||
return False
|
||||
|
||||
@@ -2456,12 +2464,24 @@ def get_langsmith_replica_projects() -> list[str]:
|
||||
Parses `DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS` (comma-separated) into a
|
||||
de-duplicated, order-preserving list.
|
||||
|
||||
Returns:
|
||||
Project names, or `[]` when the env var is unset or empty.
|
||||
"""
|
||||
return _get_langsmith_replica_projects_from(dict(os.environ))
|
||||
|
||||
|
||||
def _get_langsmith_replica_projects_from(env: dict[str, str]) -> list[str]:
|
||||
"""Parse replica project names from an environment snapshot.
|
||||
|
||||
Args:
|
||||
env: Environment mapping to read.
|
||||
|
||||
Returns:
|
||||
Project names, or `[]` when the env var is unset or empty.
|
||||
"""
|
||||
from deepagents_code._env_vars import LANGSMITH_REPLICA_PROJECTS
|
||||
|
||||
raw = os.environ.get(LANGSMITH_REPLICA_PROJECTS)
|
||||
raw = env.get(LANGSMITH_REPLICA_PROJECTS)
|
||||
if not raw:
|
||||
return []
|
||||
return list(dict.fromkeys(p.strip() for p in raw.split(",") if p.strip()))
|
||||
@@ -2489,6 +2509,18 @@ def get_langsmith_replica_project() -> str | None:
|
||||
The first configured replica project name, or `None` when none are set.
|
||||
"""
|
||||
extras = get_langsmith_replica_projects()
|
||||
return _get_first_langsmith_replica_project(extras)
|
||||
|
||||
|
||||
def _get_first_langsmith_replica_project(extras: list[str]) -> str | None:
|
||||
"""Return the first configured LangSmith replica project, if any.
|
||||
|
||||
Args:
|
||||
extras: Parsed replica project names.
|
||||
|
||||
Returns:
|
||||
The first configured replica project name, or `None` when none are set.
|
||||
"""
|
||||
if not extras:
|
||||
return None
|
||||
if len(extras) > 1:
|
||||
@@ -2503,6 +2535,167 @@ def get_langsmith_replica_project() -> str | None:
|
||||
return extras[0]
|
||||
|
||||
|
||||
_TRACING_BRIDGED_ENABLE_ENV_VARS = ("LANGSMITH_TRACING", "LANGCHAIN_TRACING_V2")
|
||||
"""Tracing flags bootstrap propagates from a `DEEPAGENTS_CODE_` prefix.
|
||||
|
||||
`dcode doctor` runs before `_ensure_bootstrap` bridges these to their canonical
|
||||
names, so it must resolve them prefix-aware (via `resolve_env_var`) to predict
|
||||
the runtime's effective state. The remaining flags in `_TRACING_ENABLE_ENV_VARS`
|
||||
are not bridged, so only their canonical form takes effect.
|
||||
"""
|
||||
|
||||
|
||||
def _tracing_enabled_from(env: dict[str, str]) -> bool:
|
||||
"""Return whether tracing is (or will be) enabled, prefix-aware.
|
||||
|
||||
Mirrors the runtime: `DEEPAGENTS_CODE_`-prefixed forms of the bridged flags
|
||||
count (bootstrap propagates them), while the non-bridged flags are honored
|
||||
only in their canonical form.
|
||||
|
||||
Args:
|
||||
env: Environment mapping to read.
|
||||
"""
|
||||
from deepagents_code._env_vars import classify_env_bool
|
||||
|
||||
for var in _TRACING_BRIDGED_ENABLE_ENV_VARS:
|
||||
raw = _resolve_env_var_from(env, var)
|
||||
if raw is not None and classify_env_bool(raw):
|
||||
return True
|
||||
return any(
|
||||
classify_env_bool(env[var])
|
||||
for var in _TRACING_ENABLE_ENV_VARS
|
||||
if var not in _TRACING_BRIDGED_ENABLE_ENV_VARS and var in env
|
||||
)
|
||||
|
||||
|
||||
def _tracing_enabled() -> bool:
|
||||
"""Return whether tracing is (or will be) enabled, prefix-aware."""
|
||||
return _tracing_enabled_from(dict(os.environ))
|
||||
|
||||
|
||||
def _tracing_has_credentials_from(env: dict[str, str]) -> bool:
|
||||
"""Return whether a LangSmith API key (env or active profile) is available.
|
||||
|
||||
Both API-key vars are bridged from a `DEEPAGENTS_CODE_` prefix at bootstrap,
|
||||
so resolve them prefix-aware to match what the runtime will see.
|
||||
|
||||
Args:
|
||||
env: Environment mapping to read.
|
||||
"""
|
||||
has_key = any(_resolve_env_var_from(env, var) for var in _TRACING_API_KEY_ENV_VARS)
|
||||
return has_key or _has_langsmith_profile_credentials(env)
|
||||
|
||||
|
||||
def _tracing_endpoint_from(env: dict[str, str]) -> str | None:
|
||||
"""Return a custom tracing endpoint (env or active profile), if configured.
|
||||
|
||||
The endpoint vars are not bridged from a `DEEPAGENTS_CODE_` prefix and the
|
||||
LangSmith SDK reads them canonically, so only the canonical names (plus the
|
||||
active profile's `api_url`) are consulted here.
|
||||
|
||||
Args:
|
||||
env: Environment mapping to read.
|
||||
"""
|
||||
for var in _TRACING_ENDPOINT_ENV_VARS:
|
||||
value = (env.get(var) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
config = _load_langsmith_profile_config(env)
|
||||
if config is not None:
|
||||
api_url = (config.api_url or "").strip()
|
||||
if api_url:
|
||||
return api_url
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tracing_project_from(env: dict[str, str]) -> str:
|
||||
"""Resolve the project agent traces would route to, without bootstrap.
|
||||
|
||||
The reported project matches the `tracing.langsmith_project` manifest
|
||||
option's env precedence: the prefixed `DEEPAGENTS_CODE_LANGSMITH_PROJECT`
|
||||
(skipped when empty), then bare `LANGSMITH_PROJECT`, then the default.
|
||||
Unlike `resolve_env_var`, an empty prefixed value does not shadow a real
|
||||
`LANGSMITH_PROJECT`.
|
||||
|
||||
Args:
|
||||
env: Environment mapping to read.
|
||||
|
||||
Returns:
|
||||
The resolved project name, or the default when none is configured.
|
||||
"""
|
||||
from deepagents_code._env_vars import LANGSMITH_PROJECT
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
for name in (LANGSMITH_PROJECT, "LANGSMITH_PROJECT"):
|
||||
value = env.get(name)
|
||||
if value:
|
||||
return value
|
||||
return LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
|
||||
def _tracing_diagnostic_env() -> dict[str, str]:
|
||||
"""Return the dotenv-aware environment snapshot for tracing diagnostics.
|
||||
|
||||
Returns:
|
||||
Environment mapping with project/global dotenv values applied using the
|
||||
same precedence as bootstrap, without mutating `os.environ`.
|
||||
"""
|
||||
from deepagents_code.project_utils import get_server_project_context
|
||||
|
||||
ctx = get_server_project_context()
|
||||
return _preview_dotenv_environ(start_path=ctx.user_cwd if ctx else None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TracingStatus:
|
||||
"""Offline snapshot of LangSmith tracing configuration for diagnostics.
|
||||
|
||||
Carries only presence/identity facts — never API keys or other secret
|
||||
values — so it is safe to render in `dcode doctor` output.
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
"""Whether a tracing flag is truthy in the environment."""
|
||||
|
||||
has_credentials: bool
|
||||
"""Whether an API key or profile credential is resolvable."""
|
||||
|
||||
endpoint: str | None
|
||||
"""Custom (self-hosted/proxied) endpoint URL, if one is configured."""
|
||||
|
||||
project: str | None
|
||||
"""Resolved configured project name, independent of active trace ingestion."""
|
||||
|
||||
replica_project: str | None
|
||||
"""Extra project agent runs are mirrored to, if configured."""
|
||||
|
||||
|
||||
def get_tracing_status() -> TracingStatus:
|
||||
"""Summarize LangSmith tracing configuration for diagnostics.
|
||||
|
||||
Reads only the local environment and the active LangSmith profile; never
|
||||
contacts the network and never exposes secret values. All fields are
|
||||
resolved prefix-/profile-aware so the report matches what the runtime does
|
||||
after bootstrap, even though `dcode doctor` runs before it.
|
||||
|
||||
Returns:
|
||||
A `TracingStatus` snapshot describing the current tracing setup.
|
||||
"""
|
||||
env = _tracing_diagnostic_env()
|
||||
enabled = _tracing_enabled_from(env)
|
||||
has_credentials = _tracing_has_credentials_from(env)
|
||||
endpoint = _tracing_endpoint_from(env)
|
||||
return TracingStatus(
|
||||
enabled=enabled,
|
||||
has_credentials=has_credentials,
|
||||
endpoint=endpoint,
|
||||
project=_resolve_tracing_project_from(env),
|
||||
replica_project=_get_first_langsmith_replica_project(
|
||||
_get_langsmith_replica_projects_from(env)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class LangSmithLookupError(Exception):
|
||||
"""Base class for typed LangSmith project URL lookup failures.
|
||||
|
||||
|
||||
@@ -840,7 +840,7 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
default=False,
|
||||
env_var=_env_vars.NO_TERMINAL_ESCAPE,
|
||||
),
|
||||
# --- Models / Tracing ----------------------------------------------
|
||||
# --- Models --------------------------------------------------------
|
||||
ConfigOption(
|
||||
key="models.default",
|
||||
group="Models",
|
||||
@@ -856,9 +856,10 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
kind=OptionKind.STR,
|
||||
toml_keys=("models", "recent"),
|
||||
),
|
||||
# --- Tracing -------------------------------------------------------
|
||||
ConfigOption(
|
||||
key="tracing.langsmith_project",
|
||||
group="Models",
|
||||
group="Tracing",
|
||||
summary="LangSmith project name for deepagents agent traces.",
|
||||
kind=OptionKind.STR,
|
||||
default=LANGSMITH_PROJECT_DEFAULT,
|
||||
@@ -868,14 +869,14 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
),
|
||||
ConfigOption(
|
||||
key="tracing.user_id",
|
||||
group="Models",
|
||||
group="Tracing",
|
||||
summary="User identifier attached to LangSmith trace metadata.",
|
||||
kind=OptionKind.STR,
|
||||
env_var=_env_vars.USER_ID,
|
||||
),
|
||||
ConfigOption(
|
||||
key="tracing.langsmith_replica_projects",
|
||||
group="Models",
|
||||
group="Tracing",
|
||||
summary=(
|
||||
"Extra LangSmith project to also write agent traces to. "
|
||||
"Comma-separated for forward-compatibility, but only the first "
|
||||
|
||||
@@ -16,6 +16,7 @@ import platform
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from deepagents_code.output import write_json
|
||||
|
||||
@@ -186,6 +187,65 @@ def _collect_updates() -> DiagnosticSection:
|
||||
return DiagnosticSection(title="Updates", items=items)
|
||||
|
||||
|
||||
def _sanitize_endpoint(endpoint: str) -> str:
|
||||
"""Return a paste-safe custom endpoint identifier.
|
||||
|
||||
Args:
|
||||
endpoint: Configured endpoint URL.
|
||||
|
||||
Returns:
|
||||
The endpoint origin when parseable, otherwise a generic configured
|
||||
marker that does not include user-controlled URL contents.
|
||||
"""
|
||||
parsed = urlsplit(endpoint.strip())
|
||||
if not parsed.scheme or not parsed.hostname:
|
||||
return "(custom endpoint configured)"
|
||||
|
||||
host = parsed.hostname
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]"
|
||||
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
port = None
|
||||
|
||||
netloc = f"{host}:{port}" if port is not None else host
|
||||
return f"{parsed.scheme}://{netloc}"
|
||||
|
||||
|
||||
def _collect_tracing() -> DiagnosticSection:
|
||||
"""Collect LangSmith tracing status from env and profile (offline).
|
||||
|
||||
Credentials are reported as configured/not configured only — the API key
|
||||
value is never read or printed. The `Credentials` item is flagged as a
|
||||
problem only when tracing is enabled without a key and without a custom
|
||||
endpoint, mirroring the runtime's orphaned-tracing guard (a keyless
|
||||
self-hosted endpoint is a valid, healthy setup).
|
||||
|
||||
Returns:
|
||||
The `Tracing` section.
|
||||
"""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
|
||||
status = get_tracing_status()
|
||||
creds_required = status.enabled and status.endpoint is None
|
||||
items = [
|
||||
DiagnosticItem("Tracing", "enabled" if status.enabled else "disabled"),
|
||||
DiagnosticItem(
|
||||
"Credentials",
|
||||
"configured" if status.has_credentials else "not configured",
|
||||
ok=status.has_credentials or not creds_required,
|
||||
),
|
||||
DiagnosticItem("Project", status.project or "(unset)"),
|
||||
]
|
||||
if status.endpoint:
|
||||
items.append(DiagnosticItem("Endpoint", _sanitize_endpoint(status.endpoint)))
|
||||
if status.replica_project:
|
||||
items.append(DiagnosticItem("Replica project", status.replica_project))
|
||||
return DiagnosticSection(title="Tracing", items=items)
|
||||
|
||||
|
||||
def _path_status(label: str, path: object) -> DiagnosticItem:
|
||||
"""Build an item reporting a path and whether it exists on disk.
|
||||
|
||||
@@ -245,6 +305,7 @@ def collect_sections() -> list[DiagnosticSection]:
|
||||
return [
|
||||
_collect_diagnostics(),
|
||||
_collect_updates(),
|
||||
_collect_tracing(),
|
||||
_collect_configuration(),
|
||||
]
|
||||
|
||||
@@ -286,6 +347,19 @@ def _render_text(sections: list[DiagnosticSection]) -> None:
|
||||
)
|
||||
console.print()
|
||||
|
||||
console.print(
|
||||
" Tip: Run `dcode config show` or `dcode config get <key>` "
|
||||
"to drill into config details.",
|
||||
style=theme.MUTED,
|
||||
highlight=False,
|
||||
)
|
||||
console.print(
|
||||
" Run `dcode --version` (or `dcode -v`) for dependency versions.",
|
||||
style=theme.MUTED,
|
||||
highlight=False,
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def run_doctor_command(args: argparse.Namespace) -> int:
|
||||
"""Run `dcode doctor`, printing diagnostics as text or JSON.
|
||||
|
||||
@@ -477,6 +477,18 @@ def show_doctor_help() -> None:
|
||||
console.print(" dcode doctor")
|
||||
console.print(" dcode doctor --json")
|
||||
console.print()
|
||||
console.print(
|
||||
"Tip: Run `dcode config show` or `dcode config get <key>` "
|
||||
"to drill into config details.",
|
||||
style=theme.MUTED,
|
||||
highlight=False,
|
||||
)
|
||||
console.print(
|
||||
" Run `dcode --version` (or `dcode -v`) for dependency versions.",
|
||||
style=theme.MUTED,
|
||||
highlight=False,
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_mcp_discovery_paths() -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -2109,6 +2109,234 @@ class TestDisableOrphanedTracing:
|
||||
assert os.environ["LANGSMITH_TRACING"] == "false"
|
||||
|
||||
|
||||
class TestGetTracingStatus:
|
||||
"""Tests for get_tracing_status()."""
|
||||
|
||||
_CLEAN: ClassVar[dict[str, str]] = {
|
||||
"LANGSMITH_TRACING_V2": "",
|
||||
"LANGCHAIN_TRACING_V2": "",
|
||||
"LANGSMITH_TRACING": "",
|
||||
"LANGCHAIN_TRACING": "",
|
||||
"LANGSMITH_API_KEY": "",
|
||||
"LANGCHAIN_API_KEY": "",
|
||||
"LANGSMITH_ENDPOINT": "",
|
||||
"LANGCHAIN_ENDPOINT": "",
|
||||
"LANGSMITH_PROJECT": "",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_PROJECT": "",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_TRACING": "",
|
||||
"DEEPAGENTS_CODE_LANGCHAIN_TRACING_V2": "",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_API_KEY": "",
|
||||
"DEEPAGENTS_CODE_LANGCHAIN_API_KEY": "",
|
||||
"LANGSMITH_REPLICA_PROJECTS": "",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS": "",
|
||||
"LANGSMITH_PROFILE": "",
|
||||
"LANGSMITH_CONFIG_FILE": "/__deepagents_missing_langsmith_config__.json",
|
||||
}
|
||||
|
||||
def test_disabled_when_no_flags(self) -> None:
|
||||
"""A clean environment reports tracing off with configured project metadata."""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
with patch.dict("os.environ", self._CLEAN, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is False
|
||||
assert status.has_credentials is False
|
||||
assert status.endpoint is None
|
||||
assert status.project == LANGSMITH_PROJECT_DEFAULT
|
||||
assert status.replica_project is None
|
||||
|
||||
def test_prefixed_flag_and_key_are_detected(self) -> None:
|
||||
"""`DEEPAGENTS_CODE_`-prefixed tracing/key vars resolve like the runtime.
|
||||
|
||||
`dcode doctor` runs before bootstrap bridges these to canonical names,
|
||||
so a user with only the supported prefixed vars must still read as
|
||||
enabled/configured with the prefixed project resolved.
|
||||
"""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_TRACING"] = "true"
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_API_KEY"] = "lsv2_test"
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_PROJECT"] = "prefixed-proj"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is True
|
||||
assert status.has_credentials is True
|
||||
assert status.project == "prefixed-proj"
|
||||
|
||||
def test_dotenv_values_are_detected(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Doctor tracing status sees the same dotenv values as bootstrap."""
|
||||
import deepagents_code.config as config_mod
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / ".env").write_text(
|
||||
"DEEPAGENTS_CODE_LANGSMITH_TRACING=true\n"
|
||||
"DEEPAGENTS_CODE_LANGSMITH_API_KEY=lsv2_dotenv\n"
|
||||
"DEEPAGENTS_CODE_LANGSMITH_PROJECT=dotenv-proj\n"
|
||||
"DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS=replica\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"_GLOBAL_DOTENV_PATH",
|
||||
tmp_path / "missing-global.env",
|
||||
)
|
||||
config_mod._dotenv_loaded_values.clear()
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
status = config_mod.get_tracing_status()
|
||||
|
||||
assert status.enabled is True
|
||||
assert status.has_credentials is True
|
||||
assert status.project == "dotenv-proj"
|
||||
assert status.replica_project == "replica"
|
||||
|
||||
def test_dotenv_profile_credentials_are_detected(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Doctor tracing status uses dotenv profile selectors for credentials."""
|
||||
import deepagents_code.config as config_mod
|
||||
|
||||
langsmith = tmp_path / "langsmith.json"
|
||||
langsmith.write_text(
|
||||
"{"
|
||||
'"current_profile":"default",'
|
||||
'"profiles":{'
|
||||
'"default":{},'
|
||||
'"dotenv":{"api_key":"lsv2_profile","api_url":"http://localhost:1984"}'
|
||||
"}"
|
||||
"}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / ".env").write_text(
|
||||
"DEEPAGENTS_CODE_LANGSMITH_TRACING=true\n"
|
||||
f"LANGSMITH_CONFIG_FILE={langsmith}\n"
|
||||
"LANGSMITH_PROFILE=dotenv\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"_GLOBAL_DOTENV_PATH",
|
||||
tmp_path / "missing-global.env",
|
||||
)
|
||||
config_mod._dotenv_loaded_values.clear()
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
status = config_mod.get_tracing_status()
|
||||
|
||||
assert status.enabled is True
|
||||
assert status.has_credentials is True
|
||||
assert status.endpoint == "http://localhost:1984"
|
||||
|
||||
def test_empty_prefixed_flag_shadows_canonical(self) -> None:
|
||||
"""An empty `DEEPAGENTS_CODE_` flag suppresses the canonical one.
|
||||
|
||||
Mirrors `resolve_env_var`/bootstrap: a present-but-empty prefixed var
|
||||
disables tracing even when the canonical flag is truthy.
|
||||
"""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_TRACING"] = ""
|
||||
env["LANGSMITH_TRACING"] = "true"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is False
|
||||
|
||||
def test_canonical_non_bridged_flag_enables(self) -> None:
|
||||
"""A canonical, non-bridged flag (`LANGSMITH_TRACING_V2`) enables tracing."""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["LANGSMITH_TRACING_V2"] = "true"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is True
|
||||
|
||||
def test_keyless_custom_endpoint_resolves_project(self) -> None:
|
||||
"""A keyless custom endpoint counts as active and resolves the project."""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_TRACING"] = "true"
|
||||
env["LANGSMITH_ENDPOINT"] = "http://localhost:1984"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is True
|
||||
assert status.has_credentials is False
|
||||
assert status.endpoint == "http://localhost:1984"
|
||||
assert status.project == LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
def test_profile_credentials_are_detected(self, tmp_path: Path) -> None:
|
||||
"""A LangSmith profile API key counts as credentials (no env key needed)."""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
config = tmp_path / "config.json"
|
||||
config.write_text(
|
||||
'{"current_profile":"default","profiles":{"default":{"api_key":"lsv2_profile"}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_TRACING"] = "true"
|
||||
env["LANGSMITH_CONFIG_FILE"] = str(config)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is True
|
||||
assert status.has_credentials is True
|
||||
assert status.project == LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
def test_project_resolved_when_enabled_without_auth(self) -> None:
|
||||
"""Tracing auth state does not hide configured project metadata."""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_TRACING"] = "true"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.enabled is True
|
||||
assert status.has_credentials is False
|
||||
assert status.project == LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
def test_empty_prefixed_project_falls_through_to_canonical(self) -> None:
|
||||
"""An empty prefixed project must not shadow a real `LANGSMITH_PROJECT`.
|
||||
|
||||
Mirrors the manifest/runtime contract: `resolve_scalar` skips an empty
|
||||
`DEEPAGENTS_CODE_LANGSMITH_PROJECT` and uses bare `LANGSMITH_PROJECT`,
|
||||
unlike `resolve_env_var`, which would shadow it and report the default.
|
||||
"""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_TRACING"] = "true"
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_API_KEY"] = "lsv2_test"
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_PROJECT"] = ""
|
||||
env["LANGSMITH_PROJECT"] = "prod"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.project == "prod"
|
||||
|
||||
def test_reports_first_replica_project(self) -> None:
|
||||
"""Only the first replica project is reported (server mirrors one)."""
|
||||
from deepagents_code.config import get_tracing_status
|
||||
|
||||
env = dict(self._CLEAN)
|
||||
env["DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS"] = "replica-a, replica-b"
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
status = get_tracing_status()
|
||||
assert status.replica_project == "replica-a"
|
||||
|
||||
|
||||
class TestQuietSdkTracingLogging:
|
||||
"""Tests for _quiet_sdk_tracing_logging()."""
|
||||
|
||||
|
||||
@@ -61,11 +61,12 @@ class TestCollectSections:
|
||||
"""Tests for the diagnostic data collection."""
|
||||
|
||||
def test_section_titles(self) -> None:
|
||||
"""All three sections are collected in display order."""
|
||||
"""All sections are collected in display order."""
|
||||
sections = collect_sections()
|
||||
assert [s.title for s in sections] == [
|
||||
"Diagnostics",
|
||||
"Updates",
|
||||
"Tracing",
|
||||
"Configuration",
|
||||
]
|
||||
|
||||
@@ -82,6 +83,87 @@ class TestCollectSections:
|
||||
assert "Install method" in labels
|
||||
|
||||
|
||||
class TestCollectTracing:
|
||||
"""Tests for the Tracing diagnostic section."""
|
||||
|
||||
def _section(self, **kwargs: object) -> DiagnosticSection:
|
||||
from deepagents_code.config import TracingStatus
|
||||
from deepagents_code.doctor import _collect_tracing
|
||||
|
||||
defaults: dict[str, object] = {
|
||||
"enabled": False,
|
||||
"has_credentials": False,
|
||||
"endpoint": None,
|
||||
"project": None,
|
||||
"replica_project": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
status = TracingStatus(**defaults) # type: ignore[arg-type]
|
||||
with patch("deepagents_code.config.get_tracing_status", return_value=status):
|
||||
return _collect_tracing()
|
||||
|
||||
def test_disabled_is_healthy(self) -> None:
|
||||
"""A disabled, keyless setup is informational, not a failure."""
|
||||
section = self._section(enabled=False, project="deepagents-code")
|
||||
assert section.title == "Tracing"
|
||||
assert section.ok is True
|
||||
labels = {item.label: item.value for item in section.items}
|
||||
assert labels["Tracing"] == "disabled"
|
||||
assert labels["Credentials"] == "not configured"
|
||||
assert labels["Project"] == "deepagents-code"
|
||||
|
||||
def test_enabled_without_credentials_is_unhealthy(self) -> None:
|
||||
"""Tracing on with no key and no endpoint is a genuine problem."""
|
||||
section = self._section(enabled=True, has_credentials=False)
|
||||
assert section.ok is False
|
||||
creds = next(i for i in section.items if i.label == "Credentials")
|
||||
assert creds.ok is False
|
||||
|
||||
def test_enabled_with_credentials_is_healthy(self) -> None:
|
||||
"""A configured key keeps the section healthy and reports the project."""
|
||||
section = self._section(enabled=True, has_credentials=True, project="my-proj")
|
||||
assert section.ok is True
|
||||
labels = {item.label: item.value for item in section.items}
|
||||
assert labels["Tracing"] == "enabled"
|
||||
assert labels["Credentials"] == "configured"
|
||||
assert labels["Project"] == "my-proj"
|
||||
|
||||
def test_keyless_custom_endpoint_is_healthy(self) -> None:
|
||||
"""A custom endpoint is a valid keyless setup, so it stays healthy."""
|
||||
section = self._section(
|
||||
enabled=True,
|
||||
has_credentials=False,
|
||||
endpoint="http://localhost:1984",
|
||||
)
|
||||
assert section.ok is True
|
||||
labels = {item.label: item.value for item in section.items}
|
||||
assert labels["Endpoint"] == "http://localhost:1984"
|
||||
|
||||
def test_endpoint_is_sanitized(self) -> None:
|
||||
"""Endpoint diagnostics redact userinfo, path, query, and fragment."""
|
||||
section = self._section(
|
||||
enabled=True,
|
||||
has_credentials=False,
|
||||
endpoint=(
|
||||
"https://user:secret@example.com:8443/trace?api_key=secret-token#frag"
|
||||
),
|
||||
)
|
||||
labels = {item.label: item.value for item in section.items}
|
||||
assert labels["Endpoint"] == "https://example.com:8443"
|
||||
assert "secret" not in labels["Endpoint"]
|
||||
assert "api_key" not in labels["Endpoint"]
|
||||
|
||||
def test_replica_project_listed_when_set(self) -> None:
|
||||
"""A configured replica project is surfaced as its own item."""
|
||||
section = self._section(
|
||||
enabled=True,
|
||||
has_credentials=True,
|
||||
replica_project="replica",
|
||||
)
|
||||
labels = {item.label: item.value for item in section.items}
|
||||
assert labels["Replica project"] == "replica"
|
||||
|
||||
|
||||
class TestCommitHash:
|
||||
"""Tests for git commit hash detection."""
|
||||
|
||||
@@ -132,8 +214,13 @@ class TestRunDoctorCommand:
|
||||
assert code == 0
|
||||
assert "Diagnostics" in output
|
||||
assert "Updates" in output
|
||||
assert "Tracing" in output
|
||||
assert "Configuration" in output
|
||||
assert "deepagents-code" in output
|
||||
assert "dcode config show" in output
|
||||
assert "dcode config get <key>" in output
|
||||
assert "dcode --version" in output
|
||||
assert "dcode -v" in output
|
||||
|
||||
def test_json_output_envelope(self, capsys) -> None:
|
||||
"""JSON output is a stable envelope with section data."""
|
||||
@@ -148,7 +235,7 @@ class TestRunDoctorCommand:
|
||||
data = envelope["data"]
|
||||
assert data["healthy"] is True
|
||||
titles = [section["title"] for section in data["sections"]]
|
||||
assert titles == ["Diagnostics", "Updates", "Configuration"]
|
||||
assert titles == ["Diagnostics", "Updates", "Tracing", "Configuration"]
|
||||
|
||||
def test_unhealthy_returns_nonzero(self) -> None:
|
||||
"""An unhealthy section yields a non-zero exit code."""
|
||||
@@ -220,3 +307,7 @@ class TestDoctorHelp:
|
||||
output = buf.getvalue()
|
||||
assert "dcode doctor [options]" in output
|
||||
assert "Usage:" in output
|
||||
assert "dcode config show" in output
|
||||
assert "dcode config get <key>" in output
|
||||
assert "dcode --version" in output
|
||||
assert "dcode -v" in output
|
||||
|
||||
Reference in New Issue
Block a user