Skip to content

Commit 3cfa6f5

Browse files
rohita5lclaude
andauthored
Route MLflow tracing to a shared, UC-backed Claude Code experiment (#134)
* Route all agents and users to a single shared MLflow experiment Collapse the per-agent, per-user tracing model into one shared experiment (/Shared/ucode-traces) so every agent and every user's coding sessions converge in one place. The first user to configure tracing creates the experiment; everyone after resolves the same one by name. Also includes the `configure --tracing` flag for enabling tracing on just-configured workspaces without re-prompting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Require a pre-provisioned, Unity Catalog-backed ucode-traces experiment Stop creating the MLflow experiment. ucode now asserts that an admin has already provisioned a `ucode-traces` experiment whose traces are stored in Unity Catalog, identified by the `databricksTraceDestinationPath` tag holding a catalog.schema.table value. Any UC destination qualifies. If none exists (or the match isn't UC-backed), fail with setup instructions instead of silently creating a non-UC experiment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Inject SQL warehouse id so UC-backed traces are actually written Writing traces to a Unity Catalog-backed experiment requires a SQL warehouse via MLFLOW_TRACING_SQL_WAREHOUSE_ID; without it the MLflow exporter silently drops every trace (verified: 0 rows landed in the UC table until the var was set). configure tracing now resolves a warehouse (preferring a RUNNING one), persists it, injects it into each agent's tracing env, and fails with setup instructions when no warehouse exists. Verified end-to-end: a headless Claude run now lands traces in the UC trace table. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Scope MLflow tracing to Claude Code only Codex and OpenCode used the @mlflow/codex / @mlflow/opencode JS clients, which only reach the classic (non-UC) trace store, so their tracing is removed. Tracing now routes exclusively through Claude Code's `mlflow autolog claude` Stop hook, which writes to the shared UC-backed ucode-traces experiment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Preserve Claude hooks when configuring tracing * Fix ty narrowing error in _is_tracing_stop_hook Cast the isinstance-narrowed object to dict so ty doesn't treat dict.get keys as Never, matching the cast pattern used in databricks.py. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d6ec352 commit 3cfa6f5

8 files changed

Lines changed: 754 additions & 475 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 142 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88
import subprocess
99
from pathlib import Path
10+
from typing import cast
1011

1112
from ucode.agent_updates import available_npm_package_update
1213
from ucode.config_io import (
@@ -63,19 +64,27 @@ def _resolve_web_search_model(state: dict) -> str | None:
6364
WEB_SEARCH_MCP_NAME = "web_search"
6465
_CLAUDE_MODEL_RE = re.compile(r"^databricks-claude-(opus|sonnet)-(\d+)-(\d+)(.*)$")
6566

66-
# Env keys consumed by the MLflow Claude tracing plugin. Written into the
67-
# settings `env` block; the plugin runtime (installed separately) reads them.
67+
# Env keys the MLflow Stop hook reads to route traces. Written into the
68+
# settings `env` block alongside the hook itself.
6869
CLAUDE_TRACING_ENV_KEYS = (
6970
"MLFLOW_CLAUDE_TRACING_ENABLED",
7071
"MLFLOW_TRACKING_URI",
7172
"MLFLOW_EXPERIMENT_ID",
73+
"MLFLOW_TRACING_SQL_WAREHOUSE_ID",
7274
)
73-
CLAUDE_TRACING_MARKETPLACE = "mlflow/mlflow"
74-
CLAUDE_TRACING_PLUGIN = "mlflow-tracing@mlflow-plugins"
75-
# The plugin runtime shells out to the `mlflow` CLI, so it must be on PATH at
76-
# this minimum version. ucode installs/upgrades it via `uv tool`.
77-
MLFLOW_CLI_SPEC = "mlflow[databricks]>=3.4"
78-
MINIMUM_MLFLOW_VERSION = (3, 4)
75+
CLAUDE_TRACING_STOP_HOOK_SUFFIX = " autolog claude stop-hook"
76+
# Tracing is driven by an `mlflow autolog claude stop-hook` Stop hook, run by
77+
# the `mlflow` CLI on each session end. Pin to 3.11.x: 3.12 dropped the Unity
78+
# Catalog trace-write path, so traces silently land in the classic store
79+
# instead of the experiment's UC table. ucode installs this via `uv tool` at
80+
# `configure tracing` time (where UV_INDEX_URL is set), then writes the hook
81+
# with the resolved absolute path — so the hook needs no uv or index at run
82+
# time, and can't be shadowed by a project venv's mlflow.
83+
MLFLOW_CLI_SPEC = "mlflow[databricks]>=3.11,<3.12"
84+
MINIMUM_MLFLOW_VERSION = (3, 11)
85+
# Upper bound (exclusive) — an installed mlflow at or above this is too new and
86+
# must be replaced, not just left alone.
87+
MAXIMUM_MLFLOW_VERSION = (3, 12)
7988

8089

8190
def _web_search_mcp_entry(workspace: str, search_model: str, profile: str | None = None) -> dict:
@@ -215,18 +224,31 @@ def write_tool_config(state: dict, model: str) -> dict:
215224
profile=state.get("profile"),
216225
)
217226
tracing_env_vars = tracing_env(state, "claude")
227+
stop_hook_command = claude_tracing_stop_hook_command() if tracing_env_vars else None
218228
if tracing_env_vars:
219229
overlay["env"]["MLFLOW_CLAUDE_TRACING_ENABLED"] = "true"
220230
overlay["env"].update(tracing_env_vars)
221231
managed_keys = managed_keys + [["env", key] for key in CLAUDE_TRACING_ENV_KEYS]
232+
if stop_hook_command:
233+
managed_keys = managed_keys + [["hooks", "Stop"]]
234+
else:
235+
print_warning(
236+
"MLflow tracing env was written, but the `mlflow` CLI could not be located "
237+
"to install the Claude Stop hook — traces won't be emitted. Re-run "
238+
"`ucode configure tracing`."
239+
)
222240

223241
existing = read_json_safe(CLAUDE_SETTINGS_PATH)
224242
merged = deep_merge_dict(existing, overlay)
243+
if tracing_env_vars and stop_hook_command:
244+
_upsert_tracing_stop_hook(merged, stop_hook_command)
225245
if not tracing_env_vars:
226246
env_block = merged.get("env")
227247
if isinstance(env_block, dict):
228248
for key in CLAUDE_TRACING_ENV_KEYS:
229249
env_block.pop(key, None)
250+
# Strip only ucode's tracing Stop hook so user hooks stay intact.
251+
_remove_tracing_stop_hook(merged)
230252
write_json_file(CLAUDE_SETTINGS_PATH, merged)
231253

232254
if web_search_model:
@@ -237,15 +259,67 @@ def write_tool_config(state: dict, model: str) -> dict:
237259
return state
238260

239261

262+
def _is_tracing_stop_hook(hook: object) -> bool:
263+
if not isinstance(hook, dict):
264+
return False
265+
hook = cast(dict, hook)
266+
if hook.get("type") != "command":
267+
return False
268+
command = hook.get("command")
269+
return isinstance(command, str) and command.endswith(CLAUDE_TRACING_STOP_HOOK_SUFFIX)
270+
271+
272+
def _remove_tracing_stop_hook(settings: dict) -> None:
273+
hooks = settings.get("hooks")
274+
if not isinstance(hooks, dict):
275+
return
276+
stop_entries = hooks.get("Stop")
277+
if not isinstance(stop_entries, list):
278+
return
279+
280+
cleaned_entries = []
281+
for entry in stop_entries:
282+
if not isinstance(entry, dict):
283+
cleaned_entries.append(entry)
284+
continue
285+
hook_list = entry.get("hooks")
286+
if not isinstance(hook_list, list):
287+
cleaned_entries.append(entry)
288+
continue
289+
cleaned_hooks = [hook for hook in hook_list if not _is_tracing_stop_hook(hook)]
290+
if cleaned_hooks:
291+
cleaned_entry = dict(entry)
292+
cleaned_entry["hooks"] = cleaned_hooks
293+
cleaned_entries.append(cleaned_entry)
294+
295+
if cleaned_entries:
296+
hooks["Stop"] = cleaned_entries
297+
else:
298+
hooks.pop("Stop", None)
299+
if not hooks:
300+
settings.pop("hooks", None)
301+
302+
303+
def _upsert_tracing_stop_hook(settings: dict, command: str) -> None:
304+
_remove_tracing_stop_hook(settings)
305+
hooks = settings.get("hooks")
306+
if not isinstance(hooks, dict):
307+
hooks = {}
308+
settings["hooks"] = hooks
309+
stop_entries = hooks.get("Stop")
310+
if not isinstance(stop_entries, list):
311+
stop_entries = []
312+
hooks["Stop"] = stop_entries
313+
stop_entries.append({"hooks": [{"type": "command", "command": command}]})
314+
315+
240316
def ensure_tracing_runtime() -> bool:
241-
"""Ensure Claude's MLflow tracing runtime is ready: an `mlflow` CLI >= 3.4 on
242-
PATH (the plugin shells out to it) and the MLflow Claude plugin installed.
317+
"""Ensure the MLflow tracing runtime is ready: a pinned `mlflow` CLI (3.11.x)
318+
installed via `uv tool`, whose absolute path the Stop hook will call.
243319
244-
Best-effort — warns and returns False if a piece can't be set up, so
320+
Best-effort — warns and returns False if it can't be set up, so
245321
`ucode configure tracing` can still finish for other agents."""
246-
if not _ensure_mlflow_cli():
247-
return False
248-
return _install_claude_tracing_plugin()
322+
return _ensure_mlflow_cli()
249323

250324

251325
def _parse_mlflow_version(text: str) -> tuple[int, int] | None:
@@ -255,37 +329,76 @@ def _parse_mlflow_version(text: str) -> tuple[int, int] | None:
255329
return int(match.group(1)), int(match.group(2))
256330

257331

332+
def _uv_tool_mlflow_path() -> str | None:
333+
"""Absolute path to the `mlflow` installed by `uv tool`, or None.
334+
335+
Resolved from `uv tool dir --bin` rather than ``shutil.which`` so a project
336+
venv's (possibly wrong-versioned) mlflow can't shadow the one ucode pins —
337+
the Stop hook must always run the uv-tool copy."""
338+
if not shutil.which("uv"):
339+
return None
340+
try:
341+
result = subprocess.run(
342+
["uv", "tool", "dir", "--bin"],
343+
check=False,
344+
capture_output=True,
345+
text=True,
346+
timeout=30,
347+
)
348+
except (OSError, subprocess.TimeoutExpired):
349+
return None
350+
bin_dir = (result.stdout or "").strip()
351+
if result.returncode != 0 or not bin_dir:
352+
return None
353+
candidate = Path(bin_dir) / "mlflow"
354+
return str(candidate) if candidate.exists() else None
355+
356+
258357
def _installed_mlflow_version() -> tuple[int, int] | None:
259-
"""The (major, minor) of the `mlflow` CLI on PATH, or None if absent."""
260-
if not shutil.which("mlflow"):
358+
"""The (major, minor) of the uv-tool `mlflow`, or None if absent."""
359+
path = _uv_tool_mlflow_path()
360+
if not path:
261361
return None
262362
try:
263363
result = subprocess.run(
264-
["mlflow", "--version"], check=False, capture_output=True, text=True, timeout=30
364+
[path, "--version"], check=False, capture_output=True, text=True, timeout=30
265365
)
266366
except (OSError, subprocess.TimeoutExpired):
267367
return None
268368
return _parse_mlflow_version(result.stdout or result.stderr or "")
269369

270370

371+
def claude_tracing_stop_hook_command() -> str | None:
372+
"""The Stop hook command string: the absolute uv-tool `mlflow` invoking its
373+
`autolog claude stop-hook` handler. None when mlflow isn't installed.
374+
375+
Using the absolute path means the hook needs neither `uv` nor a package
376+
index at run time (the minimal env Claude runs hooks in lacks UV_INDEX_URL),
377+
and can't be shadowed by another mlflow on PATH."""
378+
path = _uv_tool_mlflow_path()
379+
if not path:
380+
return None
381+
return f"{path} autolog claude stop-hook"
382+
383+
271384
def _ensure_mlflow_cli() -> bool:
272-
"""Ensure an `mlflow` CLI >= 3.4 is on PATH, installing or upgrading it via
273-
`uv tool` when needed."""
385+
"""Ensure the pinned `mlflow` CLI (3.11.x) is installed via `uv tool`,
386+
installing or replacing an out-of-range version when needed."""
274387
current = _installed_mlflow_version()
275-
if current and current >= MINIMUM_MLFLOW_VERSION:
388+
if current and MINIMUM_MLFLOW_VERSION <= current < MAXIMUM_MLFLOW_VERSION:
276389
return True
277390

278391
if not shutil.which("uv"):
279-
verb = "upgrade" if current else "install"
392+
verb = "replace" if current else "install"
280393
print_warning(
281-
f"Claude tracing needs the `mlflow` CLI >= 3.4 on PATH, but `uv` is not "
282-
f'available to {verb} it. Run `uv tool install "{MLFLOW_CLI_SPEC}"` '
283-
f'(or `pip install "{MLFLOW_CLI_SPEC}"`), then re-run `ucode configure tracing`.'
394+
f"Claude tracing needs the `mlflow` CLI ({MLFLOW_CLI_SPEC}), but `uv` is not "
395+
f'available to {verb} it. Run `uv tool install "{MLFLOW_CLI_SPEC}"`, then '
396+
"re-run `ucode configure tracing`."
284397
)
285398
return False
286399

287-
print_note(f"{'Upgrading' if current else 'Installing'} the mlflow CLI ({MLFLOW_CLI_SPEC})...")
288-
# --force replaces an existing (older) uv-managed mlflow tool in place.
400+
print_note(f"{'Replacing' if current else 'Installing'} the mlflow CLI ({MLFLOW_CLI_SPEC})...")
401+
# --force replaces an existing (out-of-range) uv-managed mlflow in place.
289402
cmd = ["uv", "tool", "install", MLFLOW_CLI_SPEC]
290403
if current:
291404
cmd.append("--force")
@@ -295,54 +408,16 @@ def _ensure_mlflow_cli() -> bool:
295408
print_warning(f"Could not install the mlflow CLI automatically: {exc}")
296409
return False
297410

298-
if not shutil.which("mlflow"):
411+
if not _uv_tool_mlflow_path():
299412
print_warning(
300-
"Installed mlflow, but `mlflow` is still not on PATH. Ensure your uv tool "
301-
"bin directory (e.g. ~/.local/bin) is on PATH, then re-run `ucode configure tracing`."
413+
"Installed mlflow via `uv tool`, but its binary could not be located. "
414+
"Re-run `ucode configure tracing`."
302415
)
303416
return False
304417
print_success("mlflow CLI ready")
305418
return True
306419

307420

308-
def _install_claude_tracing_plugin() -> bool:
309-
binary = SPEC["binary"]
310-
if not shutil.which(binary):
311-
print_warning("`claude` is not installed; skipping MLflow tracing plugin install.")
312-
return False
313-
commands = [
314-
[
315-
binary,
316-
"plugin",
317-
"marketplace",
318-
"add",
319-
CLAUDE_TRACING_MARKETPLACE,
320-
"--sparse",
321-
".claude-plugin",
322-
],
323-
[binary, "plugin", "install", CLAUDE_TRACING_PLUGIN],
324-
]
325-
for cmd in commands:
326-
try:
327-
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120)
328-
except (OSError, subprocess.TimeoutExpired) as exc:
329-
print_warning(f"Could not install the Claude MLflow plugin: {exc}")
330-
return False
331-
if result.returncode != 0:
332-
output = (result.stderr or result.stdout or "").strip()
333-
last = output.splitlines()[-1] if output else f"exit {result.returncode}"
334-
# `marketplace add` / `install` are idempotent; treat "already
335-
# added/installed" as success and keep going. Best-effort match
336-
# against stderr — an upstream wording change would degrade this
337-
# to a noisy warning on re-runs, but never corrupts state.
338-
if "already" in last.lower():
339-
continue
340-
print_warning(f"Claude MLflow plugin step failed: {last}")
341-
return False
342-
print_success("Claude MLflow tracing plugin installed")
343-
return True
344-
345-
346421
def default_model(state: dict) -> str | None:
347422
claude_models = state.get("claude_models") or {}
348423
return claude_models.get("opus") or claude_models.get("sonnet") or claude_models.get("haiku")

src/ucode/agents/codex.py

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import os
66
import re
7-
import subprocess
87
from pathlib import Path
98

109
from ucode.agent_updates import available_npm_package_update
@@ -23,8 +22,6 @@
2322
)
2423
from ucode.state import mark_tool_managed, save_state
2524
from ucode.telemetry import agent_version, ucode_version
26-
from ucode.tracing import agent_tracing, apply_tracing_env
27-
from ucode.ui import print_success, print_warning
2825

2926
CODEX_CONFIG_DIR = Path.home() / ".codex"
3027
CODEX_PROFILE_NAME = "ucode"
@@ -35,8 +32,6 @@
3532
CODEX_MODEL_PROVIDER_NAME = "ucode-databricks"
3633
MINIMUM_CODEX_VERSION = (0, 134, 0)
3734
MINIMUM_CODEX_VERSION_TEXT = "0.134.0"
38-
CODEX_TRACING_NOTIFY = ["mlflow-codex", "notify-hook"]
39-
CODEX_TRACING_PACKAGE = "@mlflow/codex"
4035

4136

4237
SPEC: ToolSpec = {
@@ -240,7 +235,6 @@ def write_tool_config(state: dict, model: str | None = None) -> dict:
240235
overlay = render_legacy_overlay(workspace, chosen_model, databricks_profile)
241236
doc = read_toml_safe(LEGACY_CODEX_CONFIG_PATH)
242237
deep_merge_dict(doc, overlay)
243-
_apply_tracing_notify(doc, state)
244238
write_toml_file(LEGACY_CODEX_CONFIG_PATH, doc)
245239
state = mark_tool_managed(state, "codex", LEGACY_MANAGED_KEYS)
246240
save_state(state)
@@ -251,55 +245,12 @@ def write_tool_config(state: dict, model: str | None = None) -> dict:
251245
overlay = render_overlay(workspace, chosen_model, databricks_profile)
252246
doc = read_toml_safe(CODEX_CONFIG_PATH)
253247
deep_merge_dict(doc, overlay)
254-
_apply_tracing_notify(doc, state)
255248
write_toml_file(CODEX_CONFIG_PATH, doc)
256249
state = mark_tool_managed(state, "codex", MANAGED_KEYS)
257250
save_state(state)
258251
return state
259252

260253

261-
def _apply_tracing_notify(doc: dict, state: dict) -> None:
262-
"""Set/clear the Codex ``notify`` hook that streams session traces to MLflow.
263-
264-
Only ucode's own notify value is removed on disable, so a user-defined
265-
``notify`` is left intact. When enabling on top of a pre-existing user
266-
``notify``, warn before replacing — the prior value is in the backup file
267-
but the user has to restore it manually."""
268-
if agent_tracing(state, "codex") is not None:
269-
existing = doc.get("notify")
270-
if existing is not None and list(existing) != CODEX_TRACING_NOTIFY:
271-
print_warning(
272-
f"Codex `notify` is already set to {existing!r}; replacing it with the "
273-
"MLflow tracing hook. The previous value is preserved in the Codex "
274-
"config backup — restore it manually if you need both."
275-
)
276-
doc["notify"] = list(CODEX_TRACING_NOTIFY)
277-
elif list(doc.get("notify") or []) == CODEX_TRACING_NOTIFY:
278-
doc.pop("notify", None)
279-
280-
281-
def ensure_tracing_dependency() -> bool:
282-
"""Install the `@mlflow/codex` npm package that provides the `mlflow-codex`
283-
notify-hook binary. Best-effort: warns and returns False on failure."""
284-
import shutil
285-
286-
if shutil.which("mlflow-codex"):
287-
return True
288-
if not shutil.which("npm"):
289-
print_warning(
290-
f"`npm` is not available to install {CODEX_TRACING_PACKAGE}; "
291-
"Codex tracing will be inactive until it is installed."
292-
)
293-
return False
294-
try:
295-
subprocess.run(["npm", "install", "-g", CODEX_TRACING_PACKAGE], check=True, timeout=300)
296-
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
297-
print_warning(f"Could not install {CODEX_TRACING_PACKAGE}; Codex tracing will be inactive.")
298-
return False
299-
print_success("Codex MLflow tracing hook installed")
300-
return True
301-
302-
303254
def default_model(state: dict) -> str | None:
304255
"""Pick the newest GPT model when multiple are available.
305256
@@ -328,10 +279,6 @@ def launch(state: dict, tool_args: list[str]) -> None:
328279
workspace = state.get("workspace")
329280
if workspace:
330281
os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile"))
331-
# The notify hook subprocess Codex spawns inherits this env, so MLflow
332-
# routing flows through to it without writing a separate tracing config.
333-
# When tracing is off this also clears any stale outer-shell value.
334-
apply_tracing_env(os.environ, state, "codex")
335282
os.execvp(binary, [binary, "--profile", CODEX_PROFILE_NAME, *tool_args])
336283

337284

0 commit comments

Comments
 (0)