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.
- claudetracing/__init__.py +30 -0
- claudetracing/cli.py +93 -0
- claudetracing/client.py +184 -0
- claudetracing/formatters.py +167 -0
- claudetracing/models.py +87 -0
- claudetracing/setup.py +339 -0
- claudetracing-0.1.0.dist-info/METADATA +139 -0
- claudetracing-0.1.0.dist-info/RECORD +11 -0
- claudetracing-0.1.0.dist-info/WHEEL +4 -0
- claudetracing-0.1.0.dist-info/entry_points.txt +2 -0
- claudetracing-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|
claudetracing/client.py
ADDED
|
@@ -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)
|
claudetracing/models.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/claudetracing/)
|
|
31
|
+
[](https://pypi.org/project/claudetracing/)
|
|
32
|
+
[](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,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.
|