claudetracing 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """Claude Code Tracing - MLflow tracing for Claude Code sessions."""
2
+
3
+ from .client import TracingClient
4
+ from .formatters import (
5
+ format_for_context,
6
+ format_tool_usage,
7
+ format_traces_json,
8
+ format_traces_summary,
9
+ to_json,
10
+ to_summary,
11
+ )
12
+ from .models import SpanInfo, TraceData, TraceInfo, TraceSummary
13
+ from .setup import load_settings
14
+
15
+ __all__ = [
16
+ "TracingClient",
17
+ "load_settings",
18
+ "TraceData",
19
+ "TraceInfo",
20
+ "TraceSummary",
21
+ "SpanInfo",
22
+ "to_summary",
23
+ "to_json",
24
+ "format_traces_summary",
25
+ "format_traces_json",
26
+ "format_for_context",
27
+ "format_tool_usage",
28
+ ]
29
+
30
+ __version__ = "0.1.0"
claudetracing/cli.py ADDED
@@ -0,0 +1,93 @@
1
+ """CLI entry point for trace retrieval."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from .client import TracingClient
9
+ from .formatters import (
10
+ format_for_context,
11
+ format_tool_usage,
12
+ format_traces_json,
13
+ format_traces_summary,
14
+ )
15
+ from .setup import load_settings
16
+
17
+ app = typer.Typer(help="Claude Code MLflow tracing CLI")
18
+
19
+
20
+ @app.command()
21
+ def init():
22
+ """Initialize Claude Code tracing in the current project."""
23
+ from .setup import run_setup
24
+
25
+ raise SystemExit(run_setup())
26
+
27
+
28
+ @app.command()
29
+ def search(
30
+ experiment: Optional[str] = typer.Option(
31
+ None, "-e", "--experiment", help="Experiment name"
32
+ ),
33
+ limit: int = typer.Option(10, "-l", "--limit", help="Max traces to return"),
34
+ hours: Optional[int] = typer.Option(None, help="Search last N hours"),
35
+ since: Optional[str] = typer.Option(
36
+ None, help="Search since datetime (ISO format)"
37
+ ),
38
+ format: str = typer.Option(
39
+ "summary", "-f", "--format", help="Output: summary|json|context|tools"
40
+ ),
41
+ trace_id: Optional[str] = typer.Option(
42
+ None, "--trace-id", help="Get specific trace by ID"
43
+ ),
44
+ ):
45
+ """Search and retrieve traces."""
46
+ load_settings() # Load .claude/settings.json env vars
47
+ client = TracingClient()
48
+
49
+ if trace_id:
50
+ trace = client.get_trace(trace_id)
51
+ if not trace:
52
+ typer.echo(f"Trace not found: {trace_id}", err=True)
53
+ raise typer.Exit(1)
54
+ traces = [trace]
55
+ elif hours or since:
56
+ since_dt = datetime.fromisoformat(since) if since else None
57
+ traces = client.search_traces_by_time(
58
+ experiment_name=experiment, hours=hours, since=since_dt, max_results=limit
59
+ )
60
+ else:
61
+ traces = client.search_traces(experiment_name=experiment, max_results=limit)
62
+
63
+ output = {
64
+ "json": format_traces_json,
65
+ "context": format_for_context,
66
+ "tools": format_tool_usage,
67
+ }.get(format, format_traces_summary)(traces)
68
+
69
+ typer.echo(output)
70
+
71
+
72
+ @app.command("list")
73
+ def list_experiments():
74
+ """List available experiments."""
75
+ load_settings() # Load .claude/settings.json env vars
76
+ client = TracingClient()
77
+ experiments = client.list_experiments()
78
+
79
+ if not experiments:
80
+ typer.echo("No experiments found.")
81
+ return
82
+
83
+ typer.echo("Available experiments:")
84
+ for exp in experiments:
85
+ typer.echo(f" [{exp['id']}] {exp['name']}")
86
+
87
+
88
+ def main():
89
+ app()
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,184 @@
1
+ """MLFlow tracing client wrapper."""
2
+
3
+ from datetime import datetime, timedelta
4
+
5
+ import mlflow
6
+ from mlflow import MlflowClient
7
+ from mlflow.entities import Trace
8
+
9
+ from .models import SpanInfo, TraceData, TraceInfo
10
+
11
+
12
+ class TracingClient:
13
+ """Client for retrieving MLFlow traces from Claude Code sessions."""
14
+
15
+ def __init__(self, tracking_uri: str | None = None):
16
+ """Initialize the tracing client.
17
+
18
+ Args:
19
+ tracking_uri: MLFlow tracking URI. If None, uses MLFLOW_TRACKING_URI env var.
20
+ """
21
+ if tracking_uri:
22
+ mlflow.set_tracking_uri(tracking_uri)
23
+ self._client = MlflowClient()
24
+
25
+ def list_experiments(self) -> list[dict]:
26
+ """List all available experiments.
27
+
28
+ Returns:
29
+ List of experiment info dicts with id, name, and artifact_location.
30
+ """
31
+ experiments = self._client.search_experiments()
32
+ return [
33
+ {
34
+ "id": exp.experiment_id,
35
+ "name": exp.name,
36
+ "artifact_location": exp.artifact_location,
37
+ }
38
+ for exp in experiments
39
+ ]
40
+
41
+ def get_experiment_id(self, experiment_name: str) -> str | None:
42
+ """Get experiment ID by name.
43
+
44
+ Args:
45
+ experiment_name: Name of the experiment.
46
+
47
+ Returns:
48
+ Experiment ID or None if not found.
49
+ """
50
+ exp = self._client.get_experiment_by_name(experiment_name)
51
+ return exp.experiment_id if exp else None
52
+
53
+ def search_traces(
54
+ self,
55
+ experiment_name: str | None = None,
56
+ experiment_id: str | None = None,
57
+ filter_string: str | None = None,
58
+ max_results: int = 100,
59
+ order_by: list[str] | None = None,
60
+ ) -> list[TraceData]:
61
+ """Search for traces in an experiment.
62
+
63
+ Args:
64
+ experiment_name: Name of the experiment to search.
65
+ experiment_id: ID of the experiment (alternative to name).
66
+ filter_string: MLFlow filter string for traces.
67
+ max_results: Maximum number of traces to return.
68
+ order_by: List of fields to order by (e.g., ["timestamp DESC"]).
69
+
70
+ Returns:
71
+ List of TraceData objects.
72
+ """
73
+ if experiment_name and not experiment_id:
74
+ experiment_id = self.get_experiment_id(experiment_name)
75
+ if not experiment_id:
76
+ return []
77
+
78
+ locations = [experiment_id] if experiment_id else None
79
+
80
+ traces = mlflow.search_traces(
81
+ locations=locations,
82
+ filter_string=filter_string,
83
+ max_results=max_results,
84
+ order_by=order_by or ["timestamp DESC"],
85
+ return_type="list",
86
+ )
87
+
88
+ return [self._convert_trace(t) for t in traces]
89
+
90
+ def search_traces_by_time(
91
+ self,
92
+ experiment_name: str | None = None,
93
+ hours: int | None = None,
94
+ since: datetime | None = None,
95
+ until: datetime | None = None,
96
+ max_results: int = 100,
97
+ ) -> list[TraceData]:
98
+ """Search for traces within a time range.
99
+
100
+ Args:
101
+ experiment_name: Name of the experiment.
102
+ hours: Number of hours back from now to search.
103
+ since: Start datetime for the search range.
104
+ until: End datetime for the search range.
105
+ max_results: Maximum number of traces to return.
106
+
107
+ Returns:
108
+ List of TraceData objects within the time range.
109
+ """
110
+ if hours:
111
+ since = datetime.now() - timedelta(hours=hours)
112
+
113
+ filter_parts = []
114
+ if since:
115
+ # MLFlow uses milliseconds for timestamp filters
116
+ since_ms = int(since.timestamp() * 1000)
117
+ filter_parts.append(f"timestamp >= {since_ms}")
118
+ if until:
119
+ until_ms = int(until.timestamp() * 1000)
120
+ filter_parts.append(f"timestamp <= {until_ms}")
121
+
122
+ filter_string = " AND ".join(filter_parts) if filter_parts else None
123
+
124
+ return self.search_traces(
125
+ experiment_name=experiment_name,
126
+ filter_string=filter_string,
127
+ max_results=max_results,
128
+ )
129
+
130
+ def get_trace(self, trace_id: str) -> TraceData | None:
131
+ """Get a single trace by ID.
132
+
133
+ Args:
134
+ trace_id: The trace ID to retrieve.
135
+
136
+ Returns:
137
+ TraceData object or None if not found.
138
+ """
139
+ trace = self._client.get_trace(trace_id)
140
+ return self._convert_trace(trace) if trace else None
141
+
142
+ def _convert_trace(self, trace: Trace) -> TraceData:
143
+ """Convert MLFlow Trace to TraceData model.
144
+
145
+ Args:
146
+ trace: MLFlow Trace entity.
147
+
148
+ Returns:
149
+ TraceData model instance.
150
+ """
151
+ info = TraceInfo(
152
+ trace_id=trace.info.request_id,
153
+ request_id=trace.info.request_id,
154
+ experiment_id=trace.info.experiment_id,
155
+ timestamp=datetime.fromtimestamp(trace.info.timestamp_ms / 1000)
156
+ if trace.info.timestamp_ms
157
+ else None,
158
+ execution_time_ms=trace.info.execution_time_ms,
159
+ status=str(trace.info.status) if trace.info.status else None,
160
+ tags=dict(trace.info.tags) if trace.info.tags else {},
161
+ )
162
+
163
+ spans = []
164
+ if trace.data and trace.data.spans:
165
+ for span in trace.data.spans:
166
+ spans.append(
167
+ SpanInfo(
168
+ span_id=span.span_id,
169
+ name=span.name,
170
+ parent_id=span.parent_id,
171
+ start_time=datetime.fromtimestamp(span.start_time_ns / 1e9)
172
+ if span.start_time_ns
173
+ else None,
174
+ end_time=datetime.fromtimestamp(span.end_time_ns / 1e9)
175
+ if span.end_time_ns
176
+ else None,
177
+ status=str(span.status) if span.status else None,
178
+ inputs=dict(span.inputs) if span.inputs else {},
179
+ outputs=dict(span.outputs) if span.outputs else {},
180
+ attributes=dict(span.attributes) if span.attributes else {},
181
+ )
182
+ )
183
+
184
+ return TraceData(info=info, spans=spans)
@@ -0,0 +1,167 @@
1
+ """Output formatters for trace data."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .models import TraceData, TraceSummary
7
+
8
+
9
+ def to_summary(trace: TraceData) -> str:
10
+ """Format a trace as a human-readable summary string.
11
+
12
+ Args:
13
+ trace: TraceData object to format.
14
+
15
+ Returns:
16
+ Formatted summary string.
17
+ """
18
+ summary = trace.to_summary()
19
+ lines = [
20
+ f"Trace: {summary.trace_id}",
21
+ f" Time: {summary.timestamp.isoformat() if summary.timestamp else 'N/A'}",
22
+ f" Status: {summary.status or 'N/A'}",
23
+ f" Duration: {summary.duration_ms:.0f}ms"
24
+ if summary.duration_ms
25
+ else " Duration: N/A",
26
+ f" Spans: {summary.total_spans}",
27
+ ]
28
+
29
+ if summary.root_span_name:
30
+ lines.append(f" Root: {summary.root_span_name}")
31
+
32
+ if summary.tool_calls:
33
+ lines.append(f" Tools: {', '.join(summary.tool_calls)}")
34
+
35
+ if summary.error_message:
36
+ lines.append(f" Error: {summary.error_message}")
37
+
38
+ return "\n".join(lines)
39
+
40
+
41
+ def to_json(trace: TraceData) -> dict[str, Any]:
42
+ """Convert trace to full JSON-serializable dict.
43
+
44
+ Args:
45
+ trace: TraceData object to convert.
46
+
47
+ Returns:
48
+ Dictionary representation of the trace.
49
+ """
50
+ return trace.model_dump(mode="json")
51
+
52
+
53
+ def format_traces_summary(traces: list[TraceData]) -> str:
54
+ """Format multiple traces as summaries.
55
+
56
+ Args:
57
+ traces: List of TraceData objects.
58
+
59
+ Returns:
60
+ Formatted string with all trace summaries.
61
+ """
62
+ if not traces:
63
+ return "No traces found."
64
+
65
+ parts = [f"Found {len(traces)} trace(s):\n"]
66
+ for i, trace in enumerate(traces, 1):
67
+ parts.append(f"\n--- Trace {i} ---")
68
+ parts.append(to_summary(trace))
69
+
70
+ return "\n".join(parts)
71
+
72
+
73
+ def format_traces_json(traces: list[TraceData]) -> str:
74
+ """Format multiple traces as JSON.
75
+
76
+ Args:
77
+ traces: List of TraceData objects.
78
+
79
+ Returns:
80
+ JSON string of all traces.
81
+ """
82
+ return json.dumps([to_json(t) for t in traces], indent=2, default=str)
83
+
84
+
85
+ def format_for_context(traces: list[TraceData], max_chars: int = 50000) -> str:
86
+ """Format traces optimized for Claude's context window.
87
+
88
+ Provides a condensed format that maximizes information density
89
+ while staying within token limits.
90
+
91
+ Args:
92
+ traces: List of TraceData objects.
93
+ max_chars: Maximum characters to output (rough token proxy).
94
+
95
+ Returns:
96
+ Formatted string optimized for LLM context.
97
+ """
98
+ if not traces:
99
+ return "No traces found."
100
+
101
+ output_parts = [f"# Previous Session Traces ({len(traces)} total)\n"]
102
+ current_length = len(output_parts[0])
103
+
104
+ for trace in traces:
105
+ summary = trace.to_summary()
106
+
107
+ # Build compact trace entry
108
+ entry_lines = [
109
+ f"\n## {summary.timestamp.strftime('%Y-%m-%d %H:%M') if summary.timestamp else 'Unknown time'}",
110
+ f"ID: {summary.trace_id[:12]}...",
111
+ f"Status: {summary.status or 'N/A'} | Duration: {summary.duration_ms:.0f}ms"
112
+ if summary.duration_ms
113
+ else f"Status: {summary.status or 'N/A'}",
114
+ ]
115
+
116
+ if summary.tool_calls:
117
+ entry_lines.append(f"Tools: {', '.join(summary.tool_calls[:10])}")
118
+ if len(summary.tool_calls) > 10:
119
+ entry_lines[-1] += f" (+{len(summary.tool_calls) - 10} more)"
120
+
121
+ # Add root span inputs/outputs if available
122
+ root = trace.get_root_span()
123
+ if root and root.inputs:
124
+ # Truncate inputs for context
125
+ inputs_str = str(root.inputs)[:500]
126
+ if len(str(root.inputs)) > 500:
127
+ inputs_str += "..."
128
+ entry_lines.append(f"Input: {inputs_str}")
129
+
130
+ entry = "\n".join(entry_lines)
131
+
132
+ # Check if we'd exceed max chars
133
+ if current_length + len(entry) > max_chars:
134
+ output_parts.append(
135
+ f"\n... ({len(traces) - len(output_parts) + 1} more traces truncated)"
136
+ )
137
+ break
138
+
139
+ output_parts.append(entry)
140
+ current_length += len(entry)
141
+
142
+ return "\n".join(output_parts)
143
+
144
+
145
+ def format_tool_usage(traces: list[TraceData]) -> str:
146
+ """Format a summary of tool usage across traces.
147
+
148
+ Args:
149
+ traces: List of TraceData objects.
150
+
151
+ Returns:
152
+ Formatted string showing tool usage statistics.
153
+ """
154
+ tool_counts: dict[str, int] = {}
155
+
156
+ for trace in traces:
157
+ for tool in trace.get_tool_calls():
158
+ tool_counts[tool] = tool_counts.get(tool, 0) + 1
159
+
160
+ if not tool_counts:
161
+ return "No tool usage found in traces."
162
+
163
+ lines = ["Tool Usage Summary:", "-" * 30]
164
+ for tool, count in sorted(tool_counts.items(), key=lambda x: -x[1]):
165
+ lines.append(f" {tool}: {count}")
166
+
167
+ return "\n".join(lines)
@@ -0,0 +1,87 @@
1
+ """Pydantic models for MLFlow trace data."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class SpanInfo(BaseModel):
10
+ """Information about a single span within a trace."""
11
+
12
+ span_id: str
13
+ name: str
14
+ parent_id: str | None = None
15
+ start_time: datetime | None = None
16
+ end_time: datetime | None = None
17
+ status: str | None = None
18
+ inputs: dict[str, Any] = Field(default_factory=dict)
19
+ outputs: dict[str, Any] = Field(default_factory=dict)
20
+ attributes: dict[str, Any] = Field(default_factory=dict)
21
+
22
+ @property
23
+ def duration_ms(self) -> float | None:
24
+ """Calculate span duration in milliseconds."""
25
+ if self.start_time and self.end_time:
26
+ return (self.end_time - self.start_time).total_seconds() * 1000
27
+ return None
28
+
29
+
30
+ class TraceInfo(BaseModel):
31
+ """Metadata about a trace."""
32
+
33
+ trace_id: str
34
+ request_id: str | None = None
35
+ experiment_id: str | None = None
36
+ timestamp: datetime | None = None
37
+ execution_time_ms: float | None = None
38
+ status: str | None = None
39
+ tags: dict[str, str] = Field(default_factory=dict)
40
+
41
+
42
+ class TraceSummary(BaseModel):
43
+ """Condensed summary of a trace for quick overview."""
44
+
45
+ trace_id: str
46
+ timestamp: datetime | None = None
47
+ status: str | None = None
48
+ total_spans: int = 0
49
+ duration_ms: float | None = None
50
+ root_span_name: str | None = None
51
+ tool_calls: list[str] = Field(default_factory=list)
52
+ error_message: str | None = None
53
+
54
+
55
+ class TraceData(BaseModel):
56
+ """Complete trace data including info and spans."""
57
+
58
+ info: TraceInfo
59
+ spans: list[SpanInfo] = Field(default_factory=list)
60
+
61
+ def get_root_span(self) -> SpanInfo | None:
62
+ """Get the root span (span with no parent)."""
63
+ for span in self.spans:
64
+ if span.parent_id is None:
65
+ return span
66
+ return None
67
+
68
+ def get_tool_calls(self) -> list[str]:
69
+ """Extract names of tool call spans."""
70
+ return [
71
+ span.name
72
+ for span in self.spans
73
+ if "tool" in span.name.lower() or span.attributes.get("span_type") == "tool"
74
+ ]
75
+
76
+ def to_summary(self) -> TraceSummary:
77
+ """Convert to a condensed summary."""
78
+ root = self.get_root_span()
79
+ return TraceSummary(
80
+ trace_id=self.info.trace_id,
81
+ timestamp=self.info.timestamp,
82
+ status=self.info.status,
83
+ total_spans=len(self.spans),
84
+ duration_ms=self.info.execution_time_ms,
85
+ root_span_name=root.name if root else None,
86
+ tool_calls=self.get_tool_calls(),
87
+ )
claudetracing/setup.py ADDED
@@ -0,0 +1,339 @@
1
+ """Interactive setup for Claude Code tracing."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def load_settings() -> dict | None:
10
+ """Load settings from .claude/settings.json and set environment variables.
11
+
12
+ Returns:
13
+ The settings dict, or None if not found.
14
+ """
15
+ settings_path = Path.cwd() / ".claude" / "settings.json"
16
+ if not settings_path.exists():
17
+ return None
18
+
19
+ settings = json.loads(settings_path.read_text())
20
+
21
+ # Set environment variables from settings
22
+ if "environment" in settings:
23
+ for key, value in settings["environment"].items():
24
+ os.environ[key] = value
25
+
26
+ return settings
27
+
28
+
29
+ def prompt(message: str, default: str | None = None) -> str:
30
+ """Prompt user for input with optional default."""
31
+ suffix = f" [{default}]: " if default else ": "
32
+ result = input(f"{message}{suffix}").strip()
33
+ return result if result else (default or prompt(message, default))
34
+
35
+
36
+ def prompt_choice(message: str, choices: list[str], default: int = 0) -> int:
37
+ """Prompt user to choose from a list. Returns index."""
38
+ print(message)
39
+ for i, choice in enumerate(choices):
40
+ marker = "*" if i == default else " "
41
+ print(f" {marker} [{i + 1}] {choice}")
42
+
43
+ result = input(f"Choice [1-{len(choices)}] (default: {default + 1}): ").strip()
44
+ if not result:
45
+ return default
46
+ try:
47
+ idx = int(result) - 1
48
+ return idx if 0 <= idx < len(choices) else default
49
+ except ValueError:
50
+ return default
51
+
52
+
53
+ def get_databricks_profiles() -> list[dict]:
54
+ """Get list of configured Databricks profiles from ~/.databrickscfg."""
55
+ config_path = Path.home() / ".databrickscfg"
56
+ if not config_path.exists():
57
+ return []
58
+
59
+ profiles = []
60
+ current_profile = None
61
+ current_host = None
62
+
63
+ for line in config_path.read_text().splitlines():
64
+ line = line.strip()
65
+ if line.startswith("[") and line.endswith("]"):
66
+ if current_profile:
67
+ profiles.append({"name": current_profile, "host": current_host})
68
+ current_profile = line[1:-1]
69
+ current_host = None
70
+ elif line.startswith("host"):
71
+ current_host = line.split("=", 1)[1].strip()
72
+
73
+ if current_profile:
74
+ profiles.append({"name": current_profile, "host": current_host})
75
+
76
+ return profiles
77
+
78
+
79
+ def get_databricks_user(profile: str) -> str | None:
80
+ """Get current user's email from Databricks."""
81
+ try:
82
+ result = subprocess.run(
83
+ ["databricks", "current-user", "me", "--profile", profile, "-o", "json"],
84
+ capture_output=True,
85
+ text=True,
86
+ check=True,
87
+ )
88
+ data = json.loads(result.stdout)
89
+ return data.get("userName") or data.get("user_name")
90
+ except Exception:
91
+ return None
92
+
93
+
94
+ def create_settings_file(
95
+ profile: str | None, experiment_path: str, project_root: Path
96
+ ) -> Path:
97
+ """Create or update .claude/settings.json file.
98
+
99
+ Merges tracing config into existing settings, preserving other hooks and env vars.
100
+
101
+ Args:
102
+ profile: Databricks profile name, or None for local storage
103
+ experiment_path: MLflow experiment path/name
104
+ project_root: Project root directory
105
+
106
+ Returns:
107
+ Path to the settings file
108
+ """
109
+ claude_dir = project_root / ".claude"
110
+ claude_dir.mkdir(exist_ok=True)
111
+ settings_path = claude_dir / "settings.json"
112
+
113
+ # Tracing-specific config
114
+ tracing_hook = {
115
+ "type": "command",
116
+ "command": 'uv run python -c "from mlflow.claude_code.hooks import stop_hook_handler; stop_hook_handler()"',
117
+ }
118
+
119
+ # Environment config depends on local vs Databricks
120
+ if profile:
121
+ tracing_env = {
122
+ "MLFLOW_CLAUDE_TRACING_ENABLED": "true",
123
+ "MLFLOW_TRACKING_URI": f"databricks://{profile}",
124
+ "MLFLOW_EXPERIMENT_NAME": experiment_path,
125
+ "DATABRICKS_CONFIG_PROFILE": profile,
126
+ }
127
+ else:
128
+ tracing_env = {
129
+ "MLFLOW_CLAUDE_TRACING_ENABLED": "true",
130
+ "MLFLOW_EXPERIMENT_NAME": experiment_path,
131
+ }
132
+
133
+ # Load existing settings or start fresh
134
+ if settings_path.exists():
135
+ existing = json.loads(settings_path.read_text())
136
+ print(f"Found existing settings.json, merging tracing config...")
137
+ else:
138
+ existing = {}
139
+
140
+ # Merge environment variables (tracing vars override existing)
141
+ if "environment" not in existing:
142
+ existing["environment"] = {}
143
+ existing["environment"].update(tracing_env)
144
+
145
+ # Merge Stop hooks (append tracing hook to existing hooks)
146
+ if "hooks" not in existing:
147
+ existing["hooks"] = {}
148
+ if "Stop" not in existing["hooks"]:
149
+ existing["hooks"]["Stop"] = []
150
+
151
+ # Check if tracing hook already exists anywhere in Stop hooks
152
+ hook_command = tracing_hook["command"]
153
+ hook_exists = any(
154
+ hook.get("command") == hook_command
155
+ or any(h.get("command") == hook_command for h in hook.get("hooks", []))
156
+ for hook in existing["hooks"]["Stop"]
157
+ )
158
+
159
+ if not hook_exists:
160
+ # Append to existing Stop block, or create new one if empty
161
+ if existing["hooks"]["Stop"] and "hooks" in existing["hooks"]["Stop"][0]:
162
+ existing["hooks"]["Stop"][0]["hooks"].append(tracing_hook)
163
+ else:
164
+ existing["hooks"]["Stop"] = [{"hooks": [tracing_hook]}]
165
+
166
+ settings_path.write_text(json.dumps(existing, indent=2))
167
+ return settings_path
168
+
169
+
170
+ def update_gitignore(project_root: Path) -> None:
171
+ """Add Claude Code entries to .gitignore.
172
+
173
+ Args:
174
+ project_root: Project root directory
175
+ """
176
+ gitignore = project_root / ".gitignore"
177
+ entries = [".claude/settings.local.json", ".claude/mlflow/", "mlruns/"]
178
+ existing = gitignore.read_text() if gitignore.exists() else ""
179
+
180
+ to_add = [e for e in entries if e not in existing]
181
+ if to_add:
182
+ with open(gitignore, "a") as f:
183
+ f.write("\n# Claude Code Tracing\n" + "\n".join(to_add) + "\n")
184
+
185
+
186
+ def verify_connection(profile: str, experiment_path: str) -> bool:
187
+ """Verify MLflow connection to Databricks.
188
+
189
+ Args:
190
+ profile: Databricks profile name
191
+ experiment_path: MLflow experiment path
192
+
193
+ Returns:
194
+ True if connection succeeded
195
+ """
196
+ try:
197
+ import mlflow
198
+
199
+ os.environ["DATABRICKS_CONFIG_PROFILE"] = profile
200
+ mlflow.set_tracking_uri(f"databricks://{profile}")
201
+ mlflow.get_experiment_by_name(experiment_path) # type: ignore[possibly-missing-attribute]
202
+ return True
203
+ except Exception:
204
+ return False
205
+
206
+
207
+ def run_setup() -> int:
208
+ """Run the interactive setup process."""
209
+ print("\n=== Claude Code Tracing Setup ===\n")
210
+
211
+ # Choose storage backend
212
+ storage_type = prompt_choice(
213
+ "Where should traces be stored?",
214
+ [
215
+ "Local (mlruns/ folder - no setup required)",
216
+ "Databricks (requires workspace access)",
217
+ ],
218
+ )
219
+
220
+ if storage_type == 0:
221
+ return setup_local()
222
+ return setup_databricks()
223
+
224
+
225
+ def setup_local() -> int:
226
+ """Setup local MLflow storage."""
227
+ project_root = Path.cwd()
228
+ project_name = project_root.name
229
+
230
+ exp_name = prompt("Experiment name", default=project_name)
231
+
232
+ settings_path = create_settings_file(
233
+ profile=None,
234
+ experiment_path=exp_name,
235
+ project_root=project_root,
236
+ )
237
+ print(f"Created {settings_path.relative_to(project_root)}")
238
+
239
+ update_gitignore(project_root)
240
+ print("Updated .gitignore")
241
+
242
+ print(f"\nSetup complete! Restart Claude Code to enable tracing.")
243
+ print(f"Traces will be stored locally in: mlruns/")
244
+ return 0
245
+
246
+
247
+ def setup_databricks() -> int:
248
+ """Setup Databricks MLflow storage."""
249
+ # Check databricks CLI
250
+ try:
251
+ subprocess.run(["databricks", "--version"], capture_output=True, check=True)
252
+ except Exception:
253
+ print("Error: Databricks CLI not found. Install with: brew install databricks")
254
+ return 1
255
+
256
+ # Get or create profile
257
+ profiles = get_databricks_profiles()
258
+
259
+ if profiles:
260
+ choices = [f"{p['name']} ({p['host']})" for p in profiles] + [
261
+ "Add new workspace"
262
+ ]
263
+ idx = prompt_choice("Select Databricks profile:", choices)
264
+
265
+ if idx < len(profiles):
266
+ profile = profiles[idx]["name"]
267
+ user = get_databricks_user(profile)
268
+ else:
269
+ workspace = prompt(
270
+ "Databricks workspace URL (e.g., https://dbc-xxx.cloud.databricks.com)"
271
+ )
272
+ if not workspace.startswith("https://"):
273
+ workspace = f"https://{workspace}"
274
+ subprocess.run(
275
+ ["databricks", "auth", "login", "--host", workspace], check=True
276
+ )
277
+ profile = workspace.replace("https://", "").split(".")[0]
278
+ user = get_databricks_user(profile)
279
+ else:
280
+ workspace = prompt(
281
+ "Databricks workspace URL (e.g., https://dbc-xxx.cloud.databricks.com)"
282
+ )
283
+ if not workspace.startswith("https://"):
284
+ workspace = f"https://{workspace}"
285
+ subprocess.run(["databricks", "auth", "login", "--host", workspace], check=True)
286
+ profile = workspace.replace("https://", "").split(".")[0]
287
+ user = get_databricks_user(profile)
288
+
289
+ if user:
290
+ print(f"Authenticated as: {user}")
291
+
292
+ # Configure experiment location
293
+ print("\nMLflow experiments are stored in Databricks Workspace folders.")
294
+ exp_type = prompt_choice(
295
+ "Experiment location:",
296
+ [
297
+ "Shared folder - visible to all workspace users (recommended)",
298
+ f"Personal folder - only visible to you ({user or 'your account'})",
299
+ ],
300
+ )
301
+ project_name = Path.cwd().name
302
+ exp_name = prompt("Experiment name", default=project_name)
303
+
304
+ if exp_type == 0:
305
+ experiment_path = f"/Workspace/Shared/{exp_name}"
306
+ else:
307
+ if not user:
308
+ user = prompt("Databricks email (for personal folder path)")
309
+ experiment_path = f"/Workspace/Users/{user}/{exp_name}"
310
+
311
+ # Verify connection BEFORE creating config
312
+ print(f"\nVerifying connection to Databricks...")
313
+ if not verify_connection(profile, experiment_path):
314
+ print("\n" + "=" * 50)
315
+ print("ERROR: Could not connect to Databricks!")
316
+ print("=" * 50)
317
+ print(f"\nProfile '{profile}' failed to authenticate.")
318
+ print("This usually means:")
319
+ print(" - Your token/OAuth session has expired")
320
+ print(" - The profile doesn't have valid credentials")
321
+ print("\nTo fix, re-authenticate with:")
322
+ print(f" databricks auth login --profile {profile}")
323
+ print("\nThen run 'traces init' again.")
324
+ return 1
325
+
326
+ print("Connection verified!")
327
+
328
+ # Create settings.json
329
+ project_root = Path.cwd()
330
+ settings_path = create_settings_file(profile, experiment_path, project_root)
331
+ print(f"Created {settings_path.relative_to(project_root)}")
332
+
333
+ # Update .gitignore
334
+ update_gitignore(project_root)
335
+ print("Updated .gitignore")
336
+
337
+ print(f"\nSetup complete! Restart Claude Code to enable tracing.")
338
+ print(f"Traces will be sent to: {experiment_path}")
339
+ return 0
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudetracing
3
+ Version: 0.1.0
4
+ Summary: MLflow tracing for Claude Code sessions with Databricks integration
5
+ Project-URL: Homepage, https://github.com/CauchyIO/claudetracing
6
+ Project-URL: Repository, https://github.com/CauchyIO/claudetracing
7
+ Project-URL: Issues, https://github.com/CauchyIO/claudetracing/issues
8
+ Author: Casper
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude,databricks,llm,mlflow,tracing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: mlflow[databricks]>=3.4
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: ty>=0.0.8
25
+ Requires-Dist: typer>=0.9
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Claude Code MLflow Tracing
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/claudetracing.svg)](https://pypi.org/project/claudetracing/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/claudetracing.svg)](https://pypi.org/project/claudetracing/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
33
+
34
+ MLflow tracing for Claude Code sessions with Databricks integration. Automatically captures conversations, tool usage, and session metadata.
35
+
36
+ ## Why Trace Claude Code Sessions?
37
+
38
+ When Claude Code becomes part of your development workflow, visibility into how it's being used becomes valuable:
39
+
40
+ - **Review past sessions** - What did Claude do while you were away? Search and replay any session to understand decisions made.
41
+ - **Team insights** - See how your team uses Claude Code across projects. Identify patterns, common tasks, and areas for improvement.
42
+ - **Debug failures** - When something goes wrong, trace data shows exactly which tools were called, in what order, and what inputs/outputs were involved.
43
+ - **Cost awareness** - Track token usage and session duration to understand resource consumption.
44
+ - **Compliance & audit** - Maintain records of AI-assisted code changes for regulated environments.
45
+
46
+ ## Prerequisites
47
+
48
+ - Python 3.10+
49
+ - [Databricks CLI](https://docs.databricks.com/dev-tools/cli/index.html) installed
50
+ - Access to a Databricks workspace
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ uv add claudetracing
56
+ ```
57
+
58
+ Or with pip:
59
+ ```bash
60
+ pip install claudetracing
61
+ ```
62
+
63
+ ## Quick Start
64
+
65
+ Run the interactive setup in your project directory:
66
+
67
+ ```bash
68
+ traces init
69
+ ```
70
+
71
+ This will:
72
+ 1. Authenticate with Databricks (or use existing credentials)
73
+ 2. Configure your experiment path (shared or personal)
74
+ 3. Create `.claude/settings.json` with the proper hooks
75
+ 4. Update `.gitignore`
76
+
77
+ Restart Claude Code after setup. Traces are automatically sent to Databricks when sessions end.
78
+
79
+ ## CLI Commands
80
+
81
+ ```bash
82
+ traces init # Interactive setup
83
+ traces list # List available experiments
84
+ traces search # Search recent traces
85
+ traces search -e <experiment> # Filter by experiment name
86
+ traces search --hours 24 # Last 24 hours
87
+ traces search --trace-id <id> # Get specific trace
88
+ traces search -f json # Output as JSON
89
+ traces search -f context # LLM-optimized format
90
+ ```
91
+
92
+ ## Python API
93
+
94
+ ```python
95
+ from claudetracing import TracingClient
96
+
97
+ client = TracingClient()
98
+
99
+ # List experiments
100
+ experiments = client.list_experiments()
101
+
102
+ # Search traces
103
+ traces = client.search_traces(experiment_name="my-experiment", max_results=10)
104
+
105
+ # Search by time
106
+ traces = client.search_traces_by_time(hours=24)
107
+
108
+ # Get specific trace
109
+ trace = client.get_trace("trace-id")
110
+
111
+ # Access trace data
112
+ for trace in traces:
113
+ print(f"Session: {trace.info.trace_id}")
114
+ print(f"Tools used: {trace.get_tool_calls()}")
115
+ for span in trace.spans:
116
+ print(f" {span.name}: {span.execution_time_ms}ms")
117
+ ```
118
+
119
+ ## What Gets Traced
120
+
121
+ - User prompts and Claude responses
122
+ - Tool usage (Read, Write, Edit, Bash, etc.)
123
+ - Execution timing per operation
124
+ - Session metadata (user, working directory, git branch)
125
+
126
+ ## How It Works
127
+
128
+ 1. The `traces init` command creates a `.claude/settings.json` file
129
+ 2. This configures a Stop hook that runs when Claude Code sessions end
130
+ 3. The hook calls MLflow's built-in Claude Code tracing to capture the session
131
+ 4. Traces are uploaded to your Databricks MLflow experiment
132
+
133
+ ## License
134
+
135
+ MIT
136
+
137
+ ---
138
+
139
+ Built with [Claude Opus 4.5](https://www.anthropic.com/claude)
@@ -0,0 +1,11 @@
1
+ claudetracing/__init__.py,sha256=CjBmo0uEWLq4I2JX7ZoYRFjErCO38d080EFmhmskRf0,646
2
+ claudetracing/cli.py,sha256=10ssYckGb9l_eY1rRKkMFjvM9-5j5jqh_Ftfbp5uKd4,2514
3
+ claudetracing/client.py,sha256=YAmJEs6uMSwXZ9ian_Ncy0rZ0ZzBqfM8kCX55TeLGFE,6267
4
+ claudetracing/formatters.py,sha256=T1xaa3Kpk6wGEMQPILwxmDDdPADPll6dgrNgjv9OjwQ,4795
5
+ claudetracing/models.py,sha256=ZmFAo9P1qlHqkkhxyvmfT4TrEa6BZU_bBmaocPgGj0g,2651
6
+ claudetracing/setup.py,sha256=v6V3iZaTLDCuIhkNKH2vhfF69ZFyYs0vQ1_oduTpYRU,11200
7
+ claudetracing-0.1.0.dist-info/METADATA,sha256=Wpqq3WRIS38MuSCbinZWiMqe8e-rvZellen16JxkZ3Q,4655
8
+ claudetracing-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ claudetracing-0.1.0.dist-info/entry_points.txt,sha256=doR_f5hpH5xHkaJgTL9VUGiWJqd3THgcEvTkbt1hI88,50
10
+ claudetracing-0.1.0.dist-info/licenses/LICENSE,sha256=lUNADonA-UdPir60egcN2kl_kSm_ZHHPHwciDgetHEc,1063
11
+ claudetracing-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ traces = claudetracing.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Casper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.