882 lines
32 KiB
Python
882 lines
32 KiB
Python
"""MLflow tracing integration for Claude Code interactions."""
|
|
|
|
import dataclasses
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import dateutil.parser
|
|
|
|
import mlflow
|
|
from mlflow.claude_code.config import (
|
|
MLFLOW_TRACING_ENABLED,
|
|
get_env_var,
|
|
)
|
|
from mlflow.entities import SpanType
|
|
from mlflow.environment_variables import (
|
|
MLFLOW_EXPERIMENT_ID,
|
|
MLFLOW_EXPERIMENT_NAME,
|
|
MLFLOW_TRACKING_URI,
|
|
)
|
|
from mlflow.telemetry.events import AutologgingEvent
|
|
from mlflow.telemetry.track import _record_event
|
|
from mlflow.tracing.constant import SpanAttributeKey, TokenUsageKey, TraceMetadataKey
|
|
from mlflow.tracing.provider import _get_trace_exporter
|
|
from mlflow.tracing.trace_manager import InMemoryTraceManager
|
|
|
|
# ============================================================================
|
|
# CONSTANTS
|
|
# ============================================================================
|
|
|
|
# Used multiple times across the module
|
|
NANOSECONDS_PER_MS = 1e6
|
|
NANOSECONDS_PER_S = 1e9
|
|
MAX_PREVIEW_LENGTH = 1000
|
|
|
|
MESSAGE_TYPE_USER = "user"
|
|
MESSAGE_TYPE_ASSISTANT = "assistant"
|
|
CONTENT_TYPE_TEXT = "text"
|
|
CONTENT_TYPE_TOOL_USE = "tool_use"
|
|
CONTENT_TYPE_TOOL_RESULT = "tool_result"
|
|
MESSAGE_FIELD_CONTENT = "content"
|
|
MESSAGE_FIELD_TYPE = "type"
|
|
MESSAGE_FIELD_MESSAGE = "message"
|
|
MESSAGE_FIELD_TIMESTAMP = "timestamp"
|
|
MESSAGE_FIELD_TOOL_USE_RESULT = "toolUseResult"
|
|
MESSAGE_FIELD_COMMAND_NAME = "commandName"
|
|
MESSAGE_TYPE_QUEUE_OPERATION = "queue-operation"
|
|
QUEUE_OPERATION_ENQUEUE = "enqueue"
|
|
METADATA_KEY_CLAUDE_CODE_VERSION = "mlflow.claude_code_version"
|
|
|
|
# Custom logging level for Claude tracing
|
|
CLAUDE_TRACING_LEVEL = logging.WARNING - 5
|
|
|
|
|
|
# ============================================================================
|
|
# LOGGING AND SETUP
|
|
# ============================================================================
|
|
|
|
|
|
def setup_logging() -> logging.Logger:
|
|
"""Set up logging directory and return configured logger.
|
|
|
|
Creates .claude/mlflow directory structure and configures file-based logging
|
|
with INFO level. Prevents log propagation to avoid duplicate messages.
|
|
"""
|
|
# Create logging directory structure
|
|
log_dir = Path(os.getcwd()) / ".claude" / "mlflow"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.handlers.clear() # Remove any existing handlers
|
|
|
|
# Configure file handler with timestamp formatting
|
|
log_file = log_dir / "claude_tracing.log"
|
|
file_handler = logging.FileHandler(log_file)
|
|
file_handler.setFormatter(
|
|
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
)
|
|
logger.addHandler(file_handler)
|
|
logging.addLevelName(CLAUDE_TRACING_LEVEL, "CLAUDE_TRACING")
|
|
logger.setLevel(CLAUDE_TRACING_LEVEL)
|
|
logger.propagate = False # Prevent duplicate log messages
|
|
|
|
return logger
|
|
|
|
|
|
_MODULE_LOGGER: logging.Logger | None = None
|
|
|
|
|
|
def get_logger() -> logging.Logger:
|
|
"""Get the configured module logger."""
|
|
global _MODULE_LOGGER
|
|
|
|
if _MODULE_LOGGER is None:
|
|
_MODULE_LOGGER = setup_logging()
|
|
return _MODULE_LOGGER
|
|
|
|
|
|
def setup_mlflow() -> None:
|
|
"""Configure MLflow tracking URI and experiment."""
|
|
if not is_tracing_enabled():
|
|
return
|
|
|
|
# Get tracking URI from environment/settings
|
|
mlflow.set_tracking_uri(get_env_var(MLFLOW_TRACKING_URI.name))
|
|
|
|
# Set experiment if specified via environment variables
|
|
experiment_id = get_env_var(MLFLOW_EXPERIMENT_ID.name)
|
|
experiment_name = get_env_var(MLFLOW_EXPERIMENT_NAME.name)
|
|
|
|
try:
|
|
if experiment_id:
|
|
mlflow.set_experiment(experiment_id=experiment_id)
|
|
elif experiment_name:
|
|
mlflow.set_experiment(experiment_name)
|
|
except Exception as e:
|
|
get_logger().warning("Failed to set experiment: %s", e)
|
|
|
|
_record_event(AutologgingEvent, {"flavor": "claude_code"})
|
|
|
|
|
|
def is_tracing_enabled() -> bool:
|
|
"""Check if MLflow Claude tracing is enabled via environment variable."""
|
|
return get_env_var(MLFLOW_TRACING_ENABLED).lower() in ("true", "1", "yes")
|
|
|
|
|
|
# ============================================================================
|
|
# INPUT/OUTPUT UTILITIES
|
|
# ============================================================================
|
|
|
|
|
|
def read_hook_input() -> dict[str, Any]:
|
|
"""Read JSON input from stdin for Claude Code hook processing."""
|
|
try:
|
|
input_data = sys.stdin.read()
|
|
return json.loads(input_data)
|
|
except json.JSONDecodeError as e:
|
|
raise json.JSONDecodeError(f"Failed to parse hook input: {e}", input_data, 0) from e
|
|
|
|
|
|
def read_transcript(transcript_path: str) -> list[dict[str, Any]]:
|
|
"""Read and parse a Claude Code conversation transcript from JSONL file."""
|
|
with open(transcript_path, encoding="utf-8") as f:
|
|
lines = f.readlines()
|
|
return [json.loads(line) for line in lines if line.strip()]
|
|
|
|
|
|
def get_hook_response(error: str | None = None, **kwargs) -> dict[str, Any]:
|
|
"""Build hook response dictionary for Claude Code hook protocol.
|
|
|
|
Args:
|
|
error: Error message if hook failed, None if successful
|
|
kwargs: Additional fields to include in response
|
|
|
|
Returns:
|
|
Hook response dictionary
|
|
"""
|
|
if error is not None:
|
|
return {"continue": False, "stopReason": error, **kwargs}
|
|
return {"continue": True, **kwargs}
|
|
|
|
|
|
# ============================================================================
|
|
# TIMESTAMP AND CONTENT PARSING UTILITIES
|
|
# ============================================================================
|
|
|
|
|
|
def parse_timestamp_to_ns(timestamp: str | int | float | None) -> int | None:
|
|
"""Convert various timestamp formats to nanoseconds since Unix epoch.
|
|
|
|
Args:
|
|
timestamp: Can be ISO string, Unix timestamp (seconds/ms), or nanoseconds
|
|
|
|
Returns:
|
|
Nanoseconds since Unix epoch, or None if parsing fails
|
|
"""
|
|
if not timestamp:
|
|
return None
|
|
|
|
if isinstance(timestamp, str):
|
|
try:
|
|
dt = dateutil.parser.parse(timestamp)
|
|
return int(dt.timestamp() * NANOSECONDS_PER_S)
|
|
except Exception:
|
|
get_logger().warning("Could not parse timestamp: %s", timestamp)
|
|
return None
|
|
if isinstance(timestamp, (int, float)):
|
|
if timestamp < 1e10:
|
|
return int(timestamp * NANOSECONDS_PER_S)
|
|
if timestamp < 1e13:
|
|
return int(timestamp * NANOSECONDS_PER_MS)
|
|
return int(timestamp)
|
|
|
|
return None
|
|
|
|
|
|
def extract_text_content(content: str | list[dict[str, Any]] | Any) -> str:
|
|
"""Extract text content from Claude message content (handles both string and list formats).
|
|
|
|
Args:
|
|
content: Either a string or list of content parts from Claude API
|
|
|
|
Returns:
|
|
Extracted text content, empty string if none found
|
|
"""
|
|
if isinstance(content, list):
|
|
text_parts = [
|
|
part.get(CONTENT_TYPE_TEXT, "")
|
|
for part in content
|
|
if isinstance(part, dict) and part.get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TEXT
|
|
]
|
|
return "\n".join(text_parts)
|
|
if isinstance(content, str):
|
|
return content
|
|
return str(content)
|
|
|
|
|
|
def find_last_user_message_index(transcript: list[dict[str, Any]]) -> int | None:
|
|
"""Find the index of the last actual user message (ignoring tool results and empty messages).
|
|
|
|
Args:
|
|
transcript: List of conversation entries from Claude Code transcript
|
|
|
|
Returns:
|
|
Index of last user message, or None if not found
|
|
"""
|
|
for i in range(len(transcript) - 1, -1, -1):
|
|
entry = transcript[i]
|
|
if entry.get(MESSAGE_FIELD_TYPE) == MESSAGE_TYPE_USER and not entry.get(
|
|
MESSAGE_FIELD_TOOL_USE_RESULT
|
|
):
|
|
# Skip skill content injections: a user message immediately following
|
|
# a Skill tool result (which has toolUseResult with commandName)
|
|
if (
|
|
i > 0
|
|
and isinstance(
|
|
prev_tool_result := transcript[i - 1].get(MESSAGE_FIELD_TOOL_USE_RESULT), dict
|
|
)
|
|
and prev_tool_result.get(MESSAGE_FIELD_COMMAND_NAME)
|
|
):
|
|
continue
|
|
|
|
msg = entry.get(MESSAGE_FIELD_MESSAGE, {})
|
|
content = msg.get(MESSAGE_FIELD_CONTENT, "")
|
|
|
|
if isinstance(content, list) and len(content) > 0:
|
|
if (
|
|
isinstance(content[0], dict)
|
|
and content[0].get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TOOL_RESULT
|
|
):
|
|
continue
|
|
|
|
if isinstance(content, str) and "<local-command-stdout>" in content:
|
|
continue
|
|
|
|
if not content or (isinstance(content, str) and content.strip() == ""):
|
|
continue
|
|
|
|
return i
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# TRANSCRIPT PROCESSING HELPERS
|
|
# ============================================================================
|
|
|
|
|
|
def _get_next_timestamp_ns(transcript: list[dict[str, Any]], current_idx: int) -> int | None:
|
|
"""Get the timestamp of the next entry for duration calculation."""
|
|
for i in range(current_idx + 1, len(transcript)):
|
|
if timestamp := transcript[i].get(MESSAGE_FIELD_TIMESTAMP):
|
|
return parse_timestamp_to_ns(timestamp)
|
|
return None
|
|
|
|
|
|
def _extract_content_and_tools(content: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
|
"""Extract text content and tool uses from assistant response content."""
|
|
text_content = ""
|
|
tool_uses = []
|
|
|
|
if isinstance(content, list):
|
|
for part in content:
|
|
if isinstance(part, dict):
|
|
if part.get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TEXT:
|
|
text_content += part.get(CONTENT_TYPE_TEXT, "")
|
|
elif part.get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TOOL_USE:
|
|
tool_uses.append(part)
|
|
|
|
return text_content, tool_uses
|
|
|
|
|
|
def _find_tool_results(transcript: list[dict[str, Any]], start_idx: int) -> dict[str, Any]:
|
|
"""Find tool results following the current assistant response.
|
|
|
|
Returns a mapping from tool_use_id to tool result content.
|
|
"""
|
|
tool_results = {}
|
|
|
|
# Look for tool results in subsequent entries
|
|
for i in range(start_idx + 1, len(transcript)):
|
|
entry = transcript[i]
|
|
if entry.get(MESSAGE_FIELD_TYPE) != MESSAGE_TYPE_USER:
|
|
continue
|
|
|
|
msg = entry.get(MESSAGE_FIELD_MESSAGE, {})
|
|
content = msg.get(MESSAGE_FIELD_CONTENT, [])
|
|
|
|
if isinstance(content, list):
|
|
for part in content:
|
|
if (
|
|
isinstance(part, dict)
|
|
and part.get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TOOL_RESULT
|
|
):
|
|
tool_use_id = part.get("tool_use_id")
|
|
result_content = part.get("content", "")
|
|
if tool_use_id:
|
|
tool_results[tool_use_id] = result_content
|
|
|
|
# Stop looking once we hit the next assistant response
|
|
if entry.get(MESSAGE_FIELD_TYPE) == MESSAGE_TYPE_ASSISTANT:
|
|
break
|
|
|
|
return tool_results
|
|
|
|
|
|
def _get_input_messages(transcript: list[dict[str, Any]], current_idx: int) -> list[dict[str, Any]]:
|
|
"""Get all messages between the previous text-bearing assistant response and the current one.
|
|
|
|
Claude Code emits separate transcript entries for text and tool_use content.
|
|
A typical sequence looks like:
|
|
assistant [text] ← previous LLM boundary (stop here)
|
|
assistant [tool_use] ← include
|
|
user [tool_result] ← include
|
|
assistant [tool_use] ← include
|
|
user [tool_result] ← include
|
|
assistant [text] ← current (the span we're building inputs for)
|
|
|
|
We walk backward and collect everything, only stopping when we hit an
|
|
assistant entry that contains text content (which marks the previous LLM span).
|
|
|
|
Args:
|
|
transcript: List of conversation entries from Claude Code transcript
|
|
current_idx: Index of the current assistant response
|
|
|
|
Returns:
|
|
List of messages in Anthropic format
|
|
"""
|
|
messages = []
|
|
for i in range(current_idx - 1, -1, -1):
|
|
entry = transcript[i]
|
|
msg = entry.get(MESSAGE_FIELD_MESSAGE, {})
|
|
|
|
# Stop at a previous assistant entry that has text content (previous LLM span)
|
|
if entry.get(MESSAGE_FIELD_TYPE) == MESSAGE_TYPE_ASSISTANT:
|
|
content = msg.get(MESSAGE_FIELD_CONTENT, [])
|
|
has_text = False
|
|
if isinstance(content, str):
|
|
has_text = bool(content.strip())
|
|
elif isinstance(content, list):
|
|
has_text = any(
|
|
isinstance(p, dict) and p.get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TEXT
|
|
for p in content
|
|
)
|
|
if has_text:
|
|
break
|
|
|
|
# Include steer messages (queue-operation enqueue) as user messages
|
|
if (
|
|
entry.get(MESSAGE_FIELD_TYPE) == MESSAGE_TYPE_QUEUE_OPERATION
|
|
and entry.get("operation") == QUEUE_OPERATION_ENQUEUE
|
|
and (steer_content := entry.get(MESSAGE_FIELD_CONTENT))
|
|
):
|
|
messages.append({"role": "user", "content": steer_content})
|
|
continue
|
|
|
|
if msg.get("role") and msg.get(MESSAGE_FIELD_CONTENT):
|
|
messages.append(msg)
|
|
messages.reverse()
|
|
return messages
|
|
|
|
|
|
def _build_usage_dict(usage: dict[str, Any]) -> dict[str, int]:
|
|
"""Normalize a Claude Code usage payload into the CHAT_USAGE schema.
|
|
|
|
Stores fields as the Anthropic API reports them, matching
|
|
``mlflow.anthropic.autolog``: ``input_tokens`` is the non-cached input,
|
|
cache tokens are exposed as separate optional keys so consumers can
|
|
compute cache hit rate, and ``total_tokens`` follows the
|
|
``mlflow.anthropic`` convention of ``input_tokens + output_tokens``
|
|
(cache tokens excluded).
|
|
"""
|
|
input_tokens = usage.get("input_tokens", 0)
|
|
output_tokens = usage.get("output_tokens", 0)
|
|
|
|
usage_dict: dict[str, int] = {
|
|
TokenUsageKey.INPUT_TOKENS: input_tokens,
|
|
TokenUsageKey.OUTPUT_TOKENS: output_tokens,
|
|
TokenUsageKey.TOTAL_TOKENS: input_tokens + output_tokens,
|
|
}
|
|
if (cached := usage.get("cache_read_input_tokens")) is not None:
|
|
usage_dict[TokenUsageKey.CACHE_READ_INPUT_TOKENS] = cached
|
|
if (created := usage.get("cache_creation_input_tokens")) is not None:
|
|
usage_dict[TokenUsageKey.CACHE_CREATION_INPUT_TOKENS] = created
|
|
return usage_dict
|
|
|
|
|
|
def _set_token_usage_attribute(span, usage: dict[str, Any]) -> None:
|
|
"""Set token usage on a span using the standardized CHAT_USAGE attribute.
|
|
|
|
Args:
|
|
span: The MLflow span to set token usage on
|
|
usage: Dictionary containing token usage info from Claude Code transcript
|
|
"""
|
|
if not usage:
|
|
return
|
|
|
|
span.set_attribute(SpanAttributeKey.CHAT_USAGE, _build_usage_dict(usage))
|
|
|
|
|
|
def _create_llm_and_tool_spans(
|
|
parent_span, transcript: list[dict[str, Any]], start_idx: int
|
|
) -> None:
|
|
"""Create LLM and tool spans for assistant responses with proper timing."""
|
|
for i in range(start_idx, len(transcript)):
|
|
entry = transcript[i]
|
|
if entry.get(MESSAGE_FIELD_TYPE) != MESSAGE_TYPE_ASSISTANT:
|
|
continue
|
|
|
|
timestamp_ns = parse_timestamp_to_ns(entry.get(MESSAGE_FIELD_TIMESTAMP))
|
|
|
|
# Calculate duration based on next timestamp or use default
|
|
if next_timestamp_ns := _get_next_timestamp_ns(transcript, i):
|
|
duration_ns = next_timestamp_ns - timestamp_ns
|
|
else:
|
|
duration_ns = int(1000 * NANOSECONDS_PER_MS) # 1 second default
|
|
|
|
msg = entry.get(MESSAGE_FIELD_MESSAGE, {})
|
|
content = msg.get(MESSAGE_FIELD_CONTENT, [])
|
|
usage = msg.get("usage", {})
|
|
|
|
# First check if we have meaningful content to create a span for
|
|
text_content, tool_uses = _extract_content_and_tools(content)
|
|
|
|
# Only create LLM span if there's text content (no tools)
|
|
llm_span = None
|
|
if text_content and text_content.strip() and not tool_uses:
|
|
messages = _get_input_messages(transcript, i)
|
|
|
|
llm_span = mlflow.start_span_no_context(
|
|
name="llm",
|
|
parent_span=parent_span,
|
|
span_type=SpanType.LLM,
|
|
start_time_ns=timestamp_ns,
|
|
inputs={
|
|
"model": msg.get("model", "unknown"),
|
|
"messages": messages,
|
|
},
|
|
attributes={
|
|
"model": msg.get("model", "unknown"),
|
|
SpanAttributeKey.MESSAGE_FORMAT: "anthropic",
|
|
},
|
|
)
|
|
|
|
# Set token usage using the standardized CHAT_USAGE attribute
|
|
_set_token_usage_attribute(llm_span, usage)
|
|
|
|
# Output in Anthropic response format for Chat UI rendering
|
|
llm_span.set_outputs({
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": content,
|
|
})
|
|
llm_span.end(end_time_ns=timestamp_ns + duration_ns)
|
|
|
|
# Create tool spans with proportional timing and actual results
|
|
if tool_uses:
|
|
tool_results = _find_tool_results(transcript, i)
|
|
tool_duration_ns = duration_ns // len(tool_uses)
|
|
|
|
for idx, tool_use in enumerate(tool_uses):
|
|
tool_start_ns = timestamp_ns + (idx * tool_duration_ns)
|
|
tool_use_id = tool_use.get("id", "")
|
|
tool_result = tool_results.get(tool_use_id, "No result found")
|
|
|
|
tool_span = mlflow.start_span_no_context(
|
|
name=f"tool_{tool_use.get('name', 'unknown')}",
|
|
parent_span=parent_span,
|
|
span_type=SpanType.TOOL,
|
|
start_time_ns=tool_start_ns,
|
|
inputs=tool_use.get("input", {}),
|
|
attributes={
|
|
"tool_name": tool_use.get("name", "unknown"),
|
|
"tool_id": tool_use_id,
|
|
},
|
|
)
|
|
|
|
tool_span.set_outputs({"result": tool_result})
|
|
tool_span.end(end_time_ns=tool_start_ns + tool_duration_ns)
|
|
|
|
|
|
def _finalize_trace(
|
|
parent_span,
|
|
user_prompt: str,
|
|
final_response: str | None,
|
|
session_id: str | None,
|
|
end_time_ns: int | None = None,
|
|
usage: dict[str, Any] | None = None,
|
|
claude_code_version: str | None = None,
|
|
) -> mlflow.entities.Trace:
|
|
try:
|
|
# Set trace previews and metadata for UI display
|
|
with InMemoryTraceManager.get_instance().get_trace(parent_span.trace_id) as in_memory_trace:
|
|
if user_prompt:
|
|
in_memory_trace.info.request_preview = user_prompt[:MAX_PREVIEW_LENGTH]
|
|
if final_response:
|
|
in_memory_trace.info.response_preview = final_response[:MAX_PREVIEW_LENGTH]
|
|
|
|
metadata = {
|
|
TraceMetadataKey.TRACE_USER: os.environ.get("USER", ""),
|
|
"mlflow.trace.working_directory": os.getcwd(),
|
|
}
|
|
if session_id:
|
|
metadata[TraceMetadataKey.TRACE_SESSION] = session_id
|
|
if claude_code_version:
|
|
metadata[METADATA_KEY_CLAUDE_CODE_VERSION] = claude_code_version
|
|
|
|
# Set token usage directly on trace metadata so it survives
|
|
# even if span-level aggregation doesn't pick it up
|
|
if usage:
|
|
metadata[TraceMetadataKey.TOKEN_USAGE] = json.dumps(_build_usage_dict(usage))
|
|
|
|
in_memory_trace.info.trace_metadata = {
|
|
**in_memory_trace.info.trace_metadata,
|
|
**metadata,
|
|
}
|
|
except Exception as e:
|
|
get_logger().warning("Failed to update trace metadata and previews: %s", e)
|
|
|
|
outputs = {"status": "completed"}
|
|
if final_response:
|
|
outputs["response"] = final_response
|
|
parent_span.set_outputs(outputs)
|
|
parent_span.end(end_time_ns=end_time_ns)
|
|
_flush_trace_async_logging()
|
|
get_logger().log(CLAUDE_TRACING_LEVEL, "Created MLflow trace: %s", parent_span.trace_id)
|
|
return mlflow.get_trace(parent_span.trace_id)
|
|
|
|
|
|
def _flush_trace_async_logging() -> None:
|
|
try:
|
|
if hasattr(_get_trace_exporter(), "_async_queue"):
|
|
mlflow.flush_trace_async_logging()
|
|
except Exception as e:
|
|
get_logger().debug("Failed to flush trace async logging: %s", e)
|
|
|
|
|
|
def find_final_assistant_response(transcript: list[dict[str, Any]], start_idx: int) -> str | None:
|
|
"""Find the final text response from the assistant for trace preview.
|
|
|
|
Args:
|
|
transcript: List of conversation entries from Claude Code transcript
|
|
start_idx: Index to start searching from (typically after last user message)
|
|
|
|
Returns:
|
|
Final assistant response text or None
|
|
"""
|
|
final_response = None
|
|
|
|
for i in range(start_idx, len(transcript)):
|
|
entry = transcript[i]
|
|
if entry.get(MESSAGE_FIELD_TYPE) != MESSAGE_TYPE_ASSISTANT:
|
|
continue
|
|
|
|
msg = entry.get(MESSAGE_FIELD_MESSAGE, {})
|
|
content = msg.get(MESSAGE_FIELD_CONTENT, [])
|
|
|
|
if isinstance(content, list):
|
|
for part in content:
|
|
if isinstance(part, dict) and part.get(MESSAGE_FIELD_TYPE) == CONTENT_TYPE_TEXT:
|
|
text = part.get(CONTENT_TYPE_TEXT, "")
|
|
if text.strip():
|
|
final_response = text
|
|
|
|
return final_response
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN TRANSCRIPT PROCESSING
|
|
# ============================================================================
|
|
|
|
|
|
def process_transcript(
|
|
transcript_path: str, session_id: str | None = None
|
|
) -> mlflow.entities.Trace | None:
|
|
"""Process a Claude conversation transcript and create an MLflow trace with spans.
|
|
|
|
Args:
|
|
transcript_path: Path to the Claude Code transcript.jsonl file
|
|
session_id: Optional session identifier, defaults to timestamp-based ID
|
|
|
|
Returns:
|
|
MLflow trace object if successful, None if processing fails
|
|
"""
|
|
try:
|
|
transcript = read_transcript(transcript_path)
|
|
if not transcript:
|
|
get_logger().warning("Empty transcript, skipping")
|
|
return None
|
|
|
|
last_user_idx = find_last_user_message_index(transcript)
|
|
if last_user_idx is None:
|
|
get_logger().warning("No user message found in transcript")
|
|
return None
|
|
|
|
last_user_entry = transcript[last_user_idx]
|
|
last_user_prompt = last_user_entry.get(MESSAGE_FIELD_MESSAGE, {}).get(
|
|
MESSAGE_FIELD_CONTENT, ""
|
|
)
|
|
|
|
if not session_id:
|
|
session_id = f"claude-{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
|
|
get_logger().log(CLAUDE_TRACING_LEVEL, "Creating MLflow trace for session: %s", session_id)
|
|
|
|
conv_start_ns = parse_timestamp_to_ns(last_user_entry.get(MESSAGE_FIELD_TIMESTAMP))
|
|
|
|
parent_span = mlflow.start_span_no_context(
|
|
name="claude_code_conversation",
|
|
inputs={"prompt": extract_text_content(last_user_prompt)},
|
|
start_time_ns=conv_start_ns,
|
|
span_type=SpanType.AGENT,
|
|
)
|
|
|
|
# Create spans for all assistant responses and tool uses
|
|
_create_llm_and_tool_spans(parent_span, transcript, last_user_idx + 1)
|
|
|
|
# Update trace with preview content and end timing
|
|
final_response = find_final_assistant_response(transcript, last_user_idx + 1)
|
|
user_prompt_text = extract_text_content(last_user_prompt)
|
|
|
|
# Calculate end time based on last entry or use default duration
|
|
last_entry = transcript[-1] if transcript else last_user_entry
|
|
conv_end_ns = parse_timestamp_to_ns(last_entry.get(MESSAGE_FIELD_TIMESTAMP))
|
|
if not conv_end_ns or conv_end_ns <= conv_start_ns:
|
|
conv_end_ns = conv_start_ns + int(10 * NANOSECONDS_PER_S)
|
|
|
|
# Extract Claude Code version from transcript entries (CLI-only)
|
|
claude_code_version = next(
|
|
(ver for entry in transcript if (ver := entry.get("version"))), None
|
|
)
|
|
|
|
return _finalize_trace(
|
|
parent_span,
|
|
user_prompt_text,
|
|
final_response,
|
|
session_id,
|
|
conv_end_ns,
|
|
claude_code_version=claude_code_version,
|
|
)
|
|
|
|
except Exception as e:
|
|
get_logger().error("Error processing transcript: %s", e, exc_info=True)
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# SDK MESSAGE PROCESSING
|
|
# ============================================================================
|
|
|
|
|
|
def _find_sdk_user_prompt(messages: list[Any]) -> str | None:
|
|
from claude_agent_sdk.types import TextBlock, UserMessage
|
|
|
|
for msg in messages:
|
|
if not isinstance(msg, UserMessage) or msg.tool_use_result is not None:
|
|
continue
|
|
content = msg.content
|
|
if isinstance(content, str):
|
|
text = content
|
|
elif isinstance(content, list):
|
|
text = "\n".join(block.text for block in content if isinstance(block, TextBlock))
|
|
else:
|
|
continue
|
|
if text and text.strip():
|
|
return text
|
|
return None
|
|
|
|
|
|
def _build_tool_result_map(messages: list[Any]) -> dict[str, str]:
|
|
"""Map tool_use_id to its result content so tool spans can show outputs."""
|
|
from claude_agent_sdk.types import ToolResultBlock, UserMessage
|
|
|
|
tool_result_map: dict[str, str] = {}
|
|
for msg in messages:
|
|
if isinstance(msg, UserMessage) and isinstance(msg.content, list):
|
|
for block in msg.content:
|
|
if isinstance(block, ToolResultBlock):
|
|
result = block.content
|
|
if isinstance(result, list):
|
|
result = str(result)
|
|
tool_result_map[block.tool_use_id] = result or ""
|
|
return tool_result_map
|
|
|
|
|
|
# Maps SDK dataclass names to Anthropic API "type" discriminators.
|
|
# dataclasses.asdict() gives us the fields but not the type tag that
|
|
# the Anthropic message format requires on every content block.
|
|
_CONTENT_BLOCK_TYPES = {
|
|
"TextBlock": "text",
|
|
"ToolUseBlock": "tool_use",
|
|
"ToolResultBlock": "tool_result",
|
|
}
|
|
|
|
|
|
def _serialize_content_block(block) -> dict[str, Any] | None:
|
|
block_type = _CONTENT_BLOCK_TYPES.get(type(block).__name__)
|
|
if not block_type:
|
|
return None
|
|
fields = {key: value for key, value in dataclasses.asdict(block).items() if value is not None}
|
|
fields["type"] = block_type
|
|
return fields
|
|
|
|
|
|
def _serialize_sdk_message(msg) -> dict[str, Any] | None:
|
|
from claude_agent_sdk.types import AssistantMessage, UserMessage
|
|
|
|
if isinstance(msg, UserMessage):
|
|
content = msg.content
|
|
if isinstance(content, str):
|
|
return {"role": "user", "content": content} if content.strip() else None
|
|
elif isinstance(content, list):
|
|
if parts := [
|
|
serialized for block in content if (serialized := _serialize_content_block(block))
|
|
]:
|
|
return {"role": "user", "content": parts}
|
|
elif isinstance(msg, AssistantMessage) and msg.content:
|
|
if parts := [
|
|
serialized for block in msg.content if (serialized := _serialize_content_block(block))
|
|
]:
|
|
return {"role": "assistant", "content": parts}
|
|
return None
|
|
|
|
|
|
def _create_sdk_child_spans(
|
|
messages: list[Any],
|
|
parent_span,
|
|
tool_result_map: dict[str, str],
|
|
) -> str | None:
|
|
"""Create LLM and tool child spans under ``parent_span`` from SDK messages."""
|
|
from claude_agent_sdk.types import AssistantMessage, TextBlock, ToolUseBlock
|
|
|
|
final_response = None
|
|
pending_messages: list[dict[str, Any]] = []
|
|
|
|
for msg in messages:
|
|
if isinstance(msg, AssistantMessage) and msg.content:
|
|
text_blocks = [block for block in msg.content if isinstance(block, TextBlock)]
|
|
tool_blocks = [block for block in msg.content if isinstance(block, ToolUseBlock)]
|
|
|
|
if text_blocks and not tool_blocks:
|
|
text = "\n".join(block.text for block in text_blocks)
|
|
if text.strip():
|
|
final_response = text
|
|
|
|
llm_span = mlflow.start_span_no_context(
|
|
name="llm",
|
|
parent_span=parent_span,
|
|
span_type=SpanType.LLM,
|
|
inputs={
|
|
"model": getattr(msg, "model", "unknown"),
|
|
"messages": pending_messages,
|
|
},
|
|
attributes={
|
|
"model": getattr(msg, "model", "unknown"),
|
|
SpanAttributeKey.MESSAGE_FORMAT: "anthropic",
|
|
},
|
|
)
|
|
llm_span.set_outputs({
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": block.text} for block in text_blocks],
|
|
})
|
|
llm_span.end()
|
|
pending_messages = []
|
|
continue
|
|
|
|
for tool_block in tool_blocks:
|
|
tool_span = mlflow.start_span_no_context(
|
|
name=f"tool_{tool_block.name}",
|
|
parent_span=parent_span,
|
|
span_type=SpanType.TOOL,
|
|
inputs=tool_block.input,
|
|
attributes={"tool_name": tool_block.name, "tool_id": tool_block.id},
|
|
)
|
|
tool_span.set_outputs({"result": tool_result_map.get(tool_block.id, "")})
|
|
tool_span.end()
|
|
|
|
if anthropic_msg := _serialize_sdk_message(msg):
|
|
pending_messages.append(anthropic_msg)
|
|
|
|
return final_response
|
|
|
|
|
|
def process_sdk_messages(
|
|
messages: list[Any], session_id: str | None = None
|
|
) -> mlflow.entities.Trace | None:
|
|
"""
|
|
Build an MLflow trace from Claude Agent SDK message objects.
|
|
|
|
Args:
|
|
messages: List of SDK message objects (UserMessage, AssistantMessage,
|
|
ResultMessage, etc.) captured during a conversation.
|
|
session_id: Optional session identifier for grouping traces.
|
|
|
|
Returns:
|
|
MLflow Trace if successful, None if no user prompt is found or processing fails.
|
|
"""
|
|
from claude_agent_sdk.types import ResultMessage
|
|
|
|
try:
|
|
if not messages:
|
|
get_logger().warning("Empty messages list, skipping")
|
|
return None
|
|
|
|
user_prompt = _find_sdk_user_prompt(messages)
|
|
if user_prompt is None:
|
|
get_logger().warning("No user prompt found in SDK messages")
|
|
return None
|
|
|
|
result_msg = next((msg for msg in messages if isinstance(msg, ResultMessage)), None)
|
|
|
|
# Prefer the SDK's own session_id, fall back to caller arg
|
|
session_id = (result_msg.session_id if result_msg else None) or session_id
|
|
|
|
get_logger().log(
|
|
CLAUDE_TRACING_LEVEL,
|
|
"Creating MLflow trace for session: %s",
|
|
session_id,
|
|
)
|
|
|
|
tool_result_map = _build_tool_result_map(messages)
|
|
|
|
if duration_ms := (getattr(result_msg, "duration_ms", None) if result_msg else None):
|
|
duration_ns = int(duration_ms * NANOSECONDS_PER_MS)
|
|
now_ns = int(datetime.now().timestamp() * NANOSECONDS_PER_S)
|
|
start_time_ns = now_ns - duration_ns
|
|
end_time_ns = now_ns
|
|
else:
|
|
start_time_ns = None
|
|
end_time_ns = None
|
|
|
|
parent_span = mlflow.start_span_no_context(
|
|
name="claude_code_conversation",
|
|
inputs={"prompt": user_prompt},
|
|
span_type=SpanType.AGENT,
|
|
start_time_ns=start_time_ns,
|
|
)
|
|
|
|
final_response = _create_sdk_child_spans(messages, parent_span, tool_result_map)
|
|
|
|
# Set token usage on the root span so it aggregates into trace-level usage
|
|
usage = getattr(result_msg, "usage", None) if result_msg else None
|
|
if usage:
|
|
_set_token_usage_attribute(parent_span, usage)
|
|
|
|
return _finalize_trace(
|
|
parent_span,
|
|
user_prompt,
|
|
final_response,
|
|
session_id,
|
|
end_time_ns=end_time_ns,
|
|
usage=usage,
|
|
)
|
|
|
|
except Exception as e:
|
|
get_logger().error("Error processing SDK messages: %s", e, exc_info=True)
|
|
return None
|