@respan/cli 0.3.3 → 0.4.1

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,919 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Respan Hook for Claude Code
4
+
5
+ Sends Claude Code conversation traces to Respan after each response.
6
+ Uses Claude Code's Stop hook to capture transcripts and convert them to Respan spans.
7
+
8
+ Usage:
9
+ Copy this file to ~/.claude/hooks/respan_hook.py
10
+ Configure in ~/.claude/settings.json (see .claude/settings.json.example)
11
+ Enable per-project in .claude/settings.local.json (see .claude/settings.local.json.example)
12
+ """
13
+
14
+ import contextlib
15
+ import json
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import requests
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ try:
26
+ import fcntl
27
+ except ImportError:
28
+ fcntl = None # Not available on Windows
29
+
30
+ # Configuration
31
+ LOG_FILE = Path.home() / ".claude" / "state" / "respan_hook.log"
32
+ STATE_FILE = Path.home() / ".claude" / "state" / "respan_state.json"
33
+ LOCK_FILE = Path.home() / ".claude" / "state" / "respan_hook.lock"
34
+ DEBUG = os.environ.get("CC_RESPAN_DEBUG", "").lower() == "true"
35
+
36
+ try:
37
+ MAX_CHARS = int(os.environ.get("CC_RESPAN_MAX_CHARS", "4000"))
38
+ except (ValueError, TypeError):
39
+ MAX_CHARS = 4000
40
+
41
+
42
+ def log(level: str, message: str) -> None:
43
+ """Log a message to the log file."""
44
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
45
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
46
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
47
+ f.write(f"{timestamp} [{level}] {message}\n")
48
+
49
+
50
+ def debug(message: str) -> None:
51
+ """Log a debug message (only if DEBUG is enabled)."""
52
+ if DEBUG:
53
+ log("DEBUG", message)
54
+
55
+
56
+ def load_state() -> Dict[str, Any]:
57
+ """Load the state file containing session tracking info."""
58
+ if not STATE_FILE.exists():
59
+ return {}
60
+ try:
61
+ return json.loads(STATE_FILE.read_text(encoding="utf-8"))
62
+ except (json.JSONDecodeError, IOError):
63
+ return {}
64
+
65
+
66
+ def save_state(state: Dict[str, Any]) -> None:
67
+ """Save the state file atomically via write-to-temp + rename."""
68
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
69
+ try:
70
+ fd, tmp_path = tempfile.mkstemp(dir=STATE_FILE.parent, suffix=".tmp")
71
+ try:
72
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
73
+ json.dump(state, f, indent=2)
74
+ os.rename(tmp_path, STATE_FILE)
75
+ except BaseException:
76
+ with contextlib.suppress(OSError):
77
+ os.unlink(tmp_path)
78
+ raise
79
+ except OSError as e:
80
+ log("ERROR", f"Failed to save state atomically, falling back: {e}")
81
+ STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
82
+
83
+
84
+ def get_content(msg: Dict[str, Any]) -> Any:
85
+ """Extract content from a message."""
86
+ if isinstance(msg, dict):
87
+ if "message" in msg:
88
+ return msg["message"].get("content")
89
+ return msg.get("content")
90
+ return None
91
+
92
+
93
+ def is_tool_result(msg: Dict[str, Any]) -> bool:
94
+ """Check if a message contains tool results."""
95
+ content = get_content(msg)
96
+ if isinstance(content, list):
97
+ return any(
98
+ isinstance(item, dict) and item.get("type") == "tool_result"
99
+ for item in content
100
+ )
101
+ return False
102
+
103
+
104
+ def get_tool_calls(msg: Dict[str, Any]) -> List[Dict[str, Any]]:
105
+ """Extract tool use blocks from a message."""
106
+ content = get_content(msg)
107
+ if isinstance(content, list):
108
+ return [
109
+ item for item in content
110
+ if isinstance(item, dict) and item.get("type") == "tool_use"
111
+ ]
112
+ return []
113
+
114
+
115
+ def get_text_content(msg: Dict[str, Any]) -> str:
116
+ """Extract text content from a message."""
117
+ content = get_content(msg)
118
+ if isinstance(content, str):
119
+ return content
120
+ if isinstance(content, list):
121
+ text_parts = []
122
+ for item in content:
123
+ if isinstance(item, dict) and item.get("type") == "text":
124
+ text_parts.append(item.get("text", ""))
125
+ elif isinstance(item, str):
126
+ text_parts.append(item)
127
+ return "\n".join(text_parts)
128
+ return ""
129
+
130
+
131
+ def format_tool_input(tool_name: str, tool_input: Any, max_length: int = MAX_CHARS) -> str:
132
+ """Format tool input for better readability."""
133
+ if not tool_input:
134
+ return ""
135
+
136
+ # Handle Write/Edit tool - show file path and content preview
137
+ if tool_name in ("Write", "Edit", "MultiEdit"):
138
+ if isinstance(tool_input, dict):
139
+ file_path = tool_input.get("file_path", tool_input.get("path", ""))
140
+ content = tool_input.get("content", "")
141
+
142
+ result = f"File: {file_path}\n"
143
+ if content:
144
+ content_preview = content[:2000] + "..." if len(content) > 2000 else content
145
+ result += f"Content:\n{content_preview}"
146
+ return result[:max_length]
147
+
148
+ # Handle Read tool
149
+ if tool_name == "Read":
150
+ if isinstance(tool_input, dict):
151
+ file_path = tool_input.get("file_path", tool_input.get("path", ""))
152
+ return f"File: {file_path}"
153
+
154
+ # Handle Bash/Shell tool
155
+ if tool_name in ("Bash", "Shell"):
156
+ if isinstance(tool_input, dict):
157
+ command = tool_input.get("command", "")
158
+ return f"Command: {command}"
159
+
160
+ # Default: JSON dump with truncation
161
+ try:
162
+ result = json.dumps(tool_input, indent=2)
163
+ if len(result) > max_length:
164
+ result = result[:max_length] + "\n... (truncated)"
165
+ return result
166
+ except (TypeError, ValueError):
167
+ return str(tool_input)[:max_length]
168
+
169
+
170
+ def format_tool_output(tool_name: str, tool_output: Any, max_length: int = MAX_CHARS) -> str:
171
+ """Format tool output for better readability."""
172
+ if not tool_output:
173
+ return ""
174
+
175
+ # Handle string output directly
176
+ if isinstance(tool_output, str):
177
+ if len(tool_output) > max_length:
178
+ return tool_output[:max_length] + "\n... (truncated)"
179
+ return tool_output
180
+
181
+ # Handle list of content blocks (common in Claude Code tool results)
182
+ if isinstance(tool_output, list):
183
+ parts = []
184
+ total_length = 0
185
+
186
+ for item in tool_output:
187
+ if isinstance(item, dict):
188
+ # Text content block
189
+ if item.get("type") == "text":
190
+ text = item.get("text", "")
191
+ if total_length + len(text) > max_length:
192
+ remaining = max_length - total_length
193
+ if remaining > 100:
194
+ parts.append(text[:remaining] + "... (truncated)")
195
+ break
196
+ parts.append(text)
197
+ total_length += len(text)
198
+ # Image or other type
199
+ elif item.get("type") == "image":
200
+ parts.append("[Image output]")
201
+ else:
202
+ # Try to extract any text-like content
203
+ text = str(item)[:500]
204
+ parts.append(text)
205
+ total_length += len(text)
206
+ elif isinstance(item, str):
207
+ if total_length + len(item) > max_length:
208
+ remaining = max_length - total_length
209
+ if remaining > 100:
210
+ parts.append(item[:remaining] + "... (truncated)")
211
+ break
212
+ parts.append(item)
213
+ total_length += len(item)
214
+
215
+ return "\n".join(parts)
216
+
217
+ # Handle dict output
218
+ if isinstance(tool_output, dict):
219
+ # Special handling for Write tool success/error
220
+ if "success" in tool_output:
221
+ return f"Success: {tool_output.get('success')}\n{tool_output.get('message', '')}"
222
+
223
+ # Default JSON formatting
224
+ try:
225
+ result = json.dumps(tool_output, indent=2)
226
+ if len(result) > max_length:
227
+ result = result[:max_length] + "\n... (truncated)"
228
+ return result
229
+ except (TypeError, ValueError):
230
+ return str(tool_output)[:max_length]
231
+
232
+ return str(tool_output)[:max_length]
233
+
234
+
235
+ def merge_assistant_parts(parts: List[Dict[str, Any]]) -> Dict[str, Any]:
236
+ """Merge multiple assistant message parts into one."""
237
+ if not parts:
238
+ return {}
239
+
240
+ merged_content = []
241
+ for part in parts:
242
+ content = get_content(part)
243
+ if isinstance(content, list):
244
+ merged_content.extend(content)
245
+ elif content:
246
+ merged_content.append({"type": "text", "text": str(content)})
247
+
248
+ # Use the structure from the first part
249
+ result = parts[0].copy()
250
+ if "message" in result:
251
+ result["message"] = result["message"].copy()
252
+ result["message"]["content"] = merged_content
253
+ else:
254
+ result["content"] = merged_content
255
+
256
+ return result
257
+
258
+
259
+ def find_latest_transcript() -> Optional[Tuple[str, Path]]:
260
+ """Find the most recently modified transcript file.
261
+
262
+ Claude Code stores transcripts as *.jsonl files directly in the project directory.
263
+ Main conversation files have UUID names, agent files have agent-*.jsonl names.
264
+ The session ID is stored inside each JSON line.
265
+ """
266
+ projects_dir = Path.home() / ".claude" / "projects"
267
+
268
+ if not projects_dir.exists():
269
+ debug(f"Projects directory not found: {projects_dir}")
270
+ return None
271
+
272
+ latest_file = None
273
+ latest_mtime = 0
274
+
275
+ for project_dir in projects_dir.iterdir():
276
+ if not project_dir.is_dir():
277
+ continue
278
+
279
+ # Look for all .jsonl files directly in the project directory
280
+ for transcript_file in project_dir.glob("*.jsonl"):
281
+ mtime = transcript_file.stat().st_mtime
282
+ if mtime > latest_mtime:
283
+ latest_mtime = mtime
284
+ latest_file = transcript_file
285
+
286
+ if latest_file:
287
+ # Extract session ID from the first line of the file
288
+ try:
289
+ first_line = latest_file.read_text(encoding="utf-8").split("\n")[0]
290
+ if first_line:
291
+ first_msg = json.loads(first_line)
292
+ session_id = first_msg.get("sessionId", latest_file.stem)
293
+ debug(f"Found transcript: {latest_file}, session: {session_id}")
294
+ return (session_id, latest_file)
295
+ except (json.JSONDecodeError, IOError, IndexError, UnicodeDecodeError) as e:
296
+ debug(f"Error reading transcript {latest_file}: {e}")
297
+ return None
298
+
299
+ debug("No transcript files found")
300
+ return None
301
+
302
+
303
+ def parse_timestamp(ts_str: str) -> Optional[datetime]:
304
+ """Parse ISO timestamp string to datetime."""
305
+ try:
306
+ # Handle both with and without timezone
307
+ if ts_str.endswith("Z"):
308
+ ts_str = ts_str[:-1] + "+00:00"
309
+ return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
310
+ except (ValueError, AttributeError):
311
+ return None
312
+
313
+
314
+ def create_respan_spans(
315
+ session_id: str,
316
+ turn_num: int,
317
+ user_msg: Dict[str, Any],
318
+ assistant_msgs: List[Dict[str, Any]],
319
+ tool_results: List[Dict[str, Any]],
320
+ ) -> List[Dict[str, Any]]:
321
+ """Create Respan span logs for a single turn with all available metadata."""
322
+ spans = []
323
+
324
+ # Extract user text and timestamp
325
+ user_text = get_text_content(user_msg)
326
+ user_timestamp = user_msg.get("timestamp")
327
+ user_time = parse_timestamp(user_timestamp) if user_timestamp else None
328
+
329
+ # Extract assistant text from ALL messages in the turn (tool-using turns
330
+ # have multiple assistant messages: text before tool, then text after).
331
+ final_output = ""
332
+ first_assistant_msg = None
333
+ if assistant_msgs:
334
+ text_parts = [get_text_content(m) for m in assistant_msgs]
335
+ final_output = "\n".join(p for p in text_parts if p)
336
+ first_assistant_msg = assistant_msgs[0]
337
+
338
+ # Get model, usage, and timing info from assistant messages.
339
+ # For tool-using turns there are multiple assistant messages (multiple API
340
+ # calls), so we aggregate usage and take the *last* timestamp as end time.
341
+ model = "claude"
342
+ usage = None
343
+ request_id = None
344
+ stop_reason = None
345
+ first_assistant_timestamp = None
346
+ last_assistant_timestamp = None
347
+ last_assistant_time = None
348
+
349
+ for a_msg in assistant_msgs:
350
+ if not (isinstance(a_msg, dict) and "message" in a_msg):
351
+ continue
352
+ msg_obj = a_msg["message"]
353
+ model = msg_obj.get("model", model)
354
+ request_id = a_msg.get("requestId", request_id)
355
+ stop_reason = msg_obj.get("stop_reason") or stop_reason
356
+ ts = a_msg.get("timestamp")
357
+ if ts:
358
+ if first_assistant_timestamp is None:
359
+ first_assistant_timestamp = ts
360
+ last_assistant_timestamp = ts
361
+ last_assistant_time = parse_timestamp(ts)
362
+
363
+ # Aggregate usage across all API calls in the turn
364
+ msg_usage = msg_obj.get("usage")
365
+ if msg_usage:
366
+ if usage is None:
367
+ usage = dict(msg_usage)
368
+ else:
369
+ for key in ("input_tokens", "output_tokens",
370
+ "cache_creation_input_tokens",
371
+ "cache_read_input_tokens"):
372
+ if key in msg_usage:
373
+ usage[key] = usage.get(key, 0) + msg_usage[key]
374
+ # Keep last service_tier
375
+ if "service_tier" in msg_usage:
376
+ usage["service_tier"] = msg_usage["service_tier"]
377
+
378
+ # Calculate timing
379
+ start_time_str = user_timestamp or first_assistant_timestamp or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
380
+ timestamp_str = last_assistant_timestamp or first_assistant_timestamp or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
381
+
382
+ # Calculate latency from user message to final assistant response
383
+ latency = None
384
+ if user_time and last_assistant_time:
385
+ latency = (last_assistant_time - user_time).total_seconds()
386
+
387
+ # Extract messages for chat span
388
+ prompt_messages = []
389
+ if user_text:
390
+ prompt_messages.append({"role": "user", "content": user_text})
391
+
392
+ completion_message = None
393
+ if final_output:
394
+ completion_message = {"role": "assistant", "content": final_output}
395
+
396
+ # Create trace ID for this turn
397
+ trace_unique_id = f"{session_id}_turn_{turn_num}"
398
+
399
+ # Naming: human-readable workflow + span names
400
+ workflow_name = "claude-code"
401
+ # Use first ~60 chars of user message as span name for readability
402
+ user_preview = (user_text[:60] + "...") if user_text and len(user_text) > 60 else (user_text or f"turn_{turn_num}")
403
+ root_span_name = f"Turn {turn_num}: {user_preview}"
404
+ thread_id = f"claudecode_{session_id}"
405
+
406
+ # Build metadata with additional info
407
+ metadata = {
408
+ "claude_code_turn": turn_num,
409
+ }
410
+ if request_id:
411
+ metadata["request_id"] = request_id
412
+ if stop_reason:
413
+ metadata["stop_reason"] = stop_reason
414
+
415
+ # Merge user-provided metadata from env var
416
+ env_metadata = os.environ.get("RESPAN_METADATA")
417
+ if env_metadata:
418
+ try:
419
+ extra = json.loads(env_metadata)
420
+ if isinstance(extra, dict):
421
+ metadata.update(extra)
422
+ else:
423
+ debug("RESPAN_METADATA is not a JSON object, skipping")
424
+ except json.JSONDecodeError as e:
425
+ debug(f"Invalid JSON in RESPAN_METADATA, skipping: {e}")
426
+
427
+ # Build usage object with cache details
428
+ usage_obj = None
429
+ if usage:
430
+ usage_obj = {
431
+ "prompt_tokens": usage.get("input_tokens", 0),
432
+ "completion_tokens": usage.get("output_tokens", 0),
433
+ }
434
+ total_tokens = usage_obj["prompt_tokens"] + usage_obj["completion_tokens"]
435
+ if total_tokens > 0:
436
+ usage_obj["total_tokens"] = total_tokens
437
+
438
+ # Add cache details
439
+ prompt_tokens_details = {}
440
+ cache_creation = usage.get("cache_creation_input_tokens", 0)
441
+ cache_read = usage.get("cache_read_input_tokens", 0)
442
+ if cache_creation > 0:
443
+ prompt_tokens_details["cache_creation_tokens"] = cache_creation
444
+ usage_obj["cache_creation_prompt_tokens"] = cache_creation
445
+ if cache_read > 0:
446
+ prompt_tokens_details["cached_tokens"] = cache_read
447
+
448
+ if prompt_tokens_details:
449
+ usage_obj["prompt_tokens_details"] = prompt_tokens_details
450
+
451
+ # Add service tier to metadata
452
+ service_tier = usage.get("service_tier")
453
+ if service_tier:
454
+ metadata["service_tier"] = service_tier
455
+
456
+ # Create chat span (root)
457
+ chat_span_id = f"claudecode_{trace_unique_id}_chat"
458
+ customer_id = os.environ.get("RESPAN_CUSTOMER_ID", "claude-code")
459
+ chat_span = {
460
+ "trace_unique_id": trace_unique_id,
461
+ "thread_identifier": thread_id,
462
+ "customer_identifier": customer_id,
463
+ "span_unique_id": chat_span_id,
464
+ "span_parent_id": None,
465
+ "span_name": root_span_name,
466
+ "span_workflow_name": workflow_name,
467
+ "log_type": "agent",
468
+ "input": json.dumps(prompt_messages) if prompt_messages else "",
469
+ "output": json.dumps(completion_message) if completion_message else "",
470
+ "prompt_messages": prompt_messages,
471
+ "completion_message": completion_message,
472
+ "model": model,
473
+ "timestamp": timestamp_str,
474
+ "start_time": start_time_str,
475
+ "metadata": metadata,
476
+ }
477
+
478
+ # Add usage if available
479
+ if usage_obj:
480
+ chat_span["prompt_tokens"] = usage_obj["prompt_tokens"]
481
+ chat_span["completion_tokens"] = usage_obj["completion_tokens"]
482
+ if "total_tokens" in usage_obj:
483
+ chat_span["total_tokens"] = usage_obj["total_tokens"]
484
+ if "cache_creation_prompt_tokens" in usage_obj:
485
+ chat_span["cache_creation_prompt_tokens"] = usage_obj["cache_creation_prompt_tokens"]
486
+ if "prompt_tokens_details" in usage_obj:
487
+ chat_span["prompt_tokens_details"] = usage_obj["prompt_tokens_details"]
488
+
489
+ # Add latency if calculated
490
+ if latency is not None:
491
+ chat_span["latency"] = latency
492
+
493
+ spans.append(chat_span)
494
+
495
+ # Extract thinking blocks and create spans for them
496
+ thinking_spans = []
497
+ for idx, assistant_msg in enumerate(assistant_msgs):
498
+ if isinstance(assistant_msg, dict) and "message" in assistant_msg:
499
+ content = assistant_msg["message"].get("content", [])
500
+ if isinstance(content, list):
501
+ for item in content:
502
+ if isinstance(item, dict) and item.get("type") == "thinking":
503
+ thinking_text = item.get("thinking", "")
504
+ if thinking_text:
505
+ thinking_span_id = f"claudecode_{trace_unique_id}_thinking_{len(thinking_spans) + 1}"
506
+ thinking_timestamp = assistant_msg.get("timestamp", timestamp_str)
507
+ thinking_spans.append({
508
+ "trace_unique_id": trace_unique_id,
509
+ "span_unique_id": thinking_span_id,
510
+ "span_parent_id": chat_span_id,
511
+ "span_name": f"Thinking {len(thinking_spans) + 1}",
512
+ "span_workflow_name": workflow_name,
513
+ "log_type": "generation",
514
+ "input": "",
515
+ "output": thinking_text,
516
+ "timestamp": thinking_timestamp,
517
+ "start_time": thinking_timestamp,
518
+ })
519
+
520
+ spans.extend(thinking_spans)
521
+
522
+ # Collect all tool calls and results with metadata
523
+ tool_call_map = {}
524
+ for assistant_msg in assistant_msgs:
525
+ tool_calls = get_tool_calls(assistant_msg)
526
+ for tool_call in tool_calls:
527
+ tool_name = tool_call.get("name", "unknown")
528
+ tool_input = tool_call.get("input", {})
529
+ tool_id = tool_call.get("id", "")
530
+ tool_call_map[tool_id] = {
531
+ "name": tool_name,
532
+ "input": tool_input,
533
+ "id": tool_id,
534
+ "timestamp": assistant_msg.get("timestamp") if isinstance(assistant_msg, dict) else None,
535
+ }
536
+
537
+ # Find matching tool results with metadata
538
+ for tr in tool_results:
539
+ tr_content = get_content(tr)
540
+ tool_result_metadata = {}
541
+
542
+ # Extract tool result metadata
543
+ if isinstance(tr, dict):
544
+ tool_use_result = tr.get("toolUseResult", {})
545
+ if tool_use_result:
546
+ if "durationMs" in tool_use_result:
547
+ tool_result_metadata["duration_ms"] = tool_use_result["durationMs"]
548
+ if "numFiles" in tool_use_result:
549
+ tool_result_metadata["num_files"] = tool_use_result["numFiles"]
550
+ if "filenames" in tool_use_result:
551
+ tool_result_metadata["filenames"] = tool_use_result["filenames"]
552
+ if "truncated" in tool_use_result:
553
+ tool_result_metadata["truncated"] = tool_use_result["truncated"]
554
+
555
+ if isinstance(tr_content, list):
556
+ for item in tr_content:
557
+ if isinstance(item, dict) and item.get("type") == "tool_result":
558
+ tool_use_id = item.get("tool_use_id")
559
+ if tool_use_id in tool_call_map:
560
+ tool_call_map[tool_use_id]["output"] = item.get("content")
561
+ tool_call_map[tool_use_id]["result_metadata"] = tool_result_metadata
562
+ tool_call_map[tool_use_id]["result_timestamp"] = tr.get("timestamp")
563
+
564
+ # Create tool spans (children)
565
+ tool_num = 0
566
+ for tool_id, tool_data in tool_call_map.items():
567
+ tool_num += 1
568
+ tool_span_id = f"claudecode_{trace_unique_id}_tool_{tool_num}"
569
+
570
+ # Use tool result timestamp if available, otherwise use tool call timestamp
571
+ tool_timestamp = tool_data.get("result_timestamp") or tool_data.get("timestamp") or timestamp_str
572
+ tool_start_time = tool_data.get("timestamp") or start_time_str
573
+
574
+ # Format input and output for better readability
575
+ formatted_input = format_tool_input(tool_data['name'], tool_data["input"])
576
+ formatted_output = format_tool_output(tool_data['name'], tool_data.get("output"))
577
+
578
+ tool_span = {
579
+ "trace_unique_id": trace_unique_id,
580
+ "span_unique_id": tool_span_id,
581
+ "span_parent_id": chat_span_id,
582
+ "span_name": f"Tool: {tool_data['name']}",
583
+ "span_workflow_name": workflow_name,
584
+ "log_type": "tool",
585
+ "input": formatted_input,
586
+ "output": formatted_output,
587
+ "timestamp": tool_timestamp,
588
+ "start_time": tool_start_time,
589
+ }
590
+
591
+ # Add tool result metadata if available
592
+ if tool_data.get("result_metadata"):
593
+ tool_span["metadata"] = tool_data["result_metadata"]
594
+ # Calculate latency if duration_ms is available
595
+ duration_ms = tool_data["result_metadata"].get("duration_ms")
596
+ if duration_ms:
597
+ tool_span["latency"] = duration_ms / 1000.0 # Convert ms to seconds
598
+
599
+ spans.append(tool_span)
600
+
601
+ return spans
602
+
603
+
604
+ def send_spans(
605
+ spans: List[Dict[str, Any]],
606
+ api_key: str,
607
+ base_url: str,
608
+ turn_num: int,
609
+ ) -> None:
610
+ """Send spans to Respan with timeout and one retry on transient errors."""
611
+ url = f"{base_url}/v1/traces/ingest"
612
+ headers = {"Authorization": f"Bearer {api_key}"}
613
+
614
+ for attempt in range(2):
615
+ try:
616
+ response = requests.post(url, json=spans, headers=headers, timeout=30)
617
+ if response.status_code < 400:
618
+ debug(f"Sent {len(spans)} spans for turn {turn_num}")
619
+ return
620
+ if response.status_code < 500:
621
+ # 4xx — not retryable
622
+ log("ERROR", f"Failed to send spans for turn {turn_num}: HTTP {response.status_code}")
623
+ return
624
+ # 5xx — retryable
625
+ if attempt == 0:
626
+ debug(f"Server error {response.status_code} for turn {turn_num}, retrying...")
627
+ time.sleep(1)
628
+ continue
629
+ log("ERROR", f"Failed to send spans for turn {turn_num} after retry: HTTP {response.status_code}")
630
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
631
+ if attempt == 0:
632
+ debug(f"Transient error for turn {turn_num}: {e}, retrying...")
633
+ time.sleep(1)
634
+ continue
635
+ log("ERROR", f"Failed to send spans for turn {turn_num} after retry: {e}")
636
+ except Exception as e:
637
+ log("ERROR", f"Failed to send spans for turn {turn_num}: {e}")
638
+ return
639
+
640
+
641
+ def process_transcript(
642
+ session_id: str,
643
+ transcript_file: Path,
644
+ state: Dict[str, Any],
645
+ api_key: str,
646
+ base_url: str,
647
+ ) -> int:
648
+ """Process a transcript file and create traces for new turns."""
649
+ # Get previous state for this session
650
+ session_state = state.get(session_id, {})
651
+ last_line = session_state.get("last_line", 0)
652
+ turn_count = session_state.get("turn_count", 0)
653
+
654
+ # Read transcript - need ALL messages to build conversation history
655
+ lines = transcript_file.read_text(encoding="utf-8").strip().split("\n")
656
+ total_lines = len(lines)
657
+
658
+ if last_line >= total_lines:
659
+ debug(f"No new lines to process (last: {last_line}, total: {total_lines})")
660
+ return 0
661
+
662
+ # Parse new messages, tracking their line indices
663
+ new_messages = []
664
+ for i in range(last_line, total_lines):
665
+ try:
666
+ if lines[i].strip():
667
+ msg = json.loads(lines[i])
668
+ msg["_line_idx"] = i
669
+ new_messages.append(msg)
670
+ except json.JSONDecodeError:
671
+ continue
672
+
673
+ if not new_messages:
674
+ return 0
675
+
676
+ debug(f"Processing {len(new_messages)} new messages")
677
+
678
+ # Group messages into turns (user -> assistant(s) -> tool_results)
679
+ turns_processed = 0
680
+ # Track the line after the last fully-processed turn so we can
681
+ # re-read incomplete turns on the next invocation.
682
+ last_committed_line = last_line
683
+ current_user = None
684
+ current_user_line = last_line
685
+ current_assistants = []
686
+ current_assistant_parts = []
687
+ current_msg_id = None
688
+ current_tool_results = []
689
+
690
+ def _commit_turn():
691
+ """Send the current turn and update last_committed_line."""
692
+ nonlocal turns_processed, last_committed_line
693
+ turns_processed += 1
694
+ turn_num = turn_count + turns_processed
695
+ spans = create_respan_spans(
696
+ session_id, turn_num, current_user, current_assistants, current_tool_results
697
+ )
698
+ send_spans(spans, api_key, base_url, turn_num)
699
+ last_committed_line = total_lines # safe default, refined below
700
+
701
+ for msg in new_messages:
702
+ line_idx = msg.pop("_line_idx", last_line)
703
+ role = msg.get("type") or (msg.get("message", {}).get("role"))
704
+
705
+ if role == "user":
706
+ # Check if this is a tool result
707
+ if is_tool_result(msg):
708
+ current_tool_results.append(msg)
709
+ continue
710
+
711
+ # New user message - finalize previous turn
712
+ if current_msg_id and current_assistant_parts:
713
+ merged = merge_assistant_parts(current_assistant_parts)
714
+ current_assistants.append(merged)
715
+ current_assistant_parts = []
716
+ current_msg_id = None
717
+
718
+ if current_user and current_assistants:
719
+ _commit_turn()
720
+ # Advance committed line to just before this new user msg
721
+ last_committed_line = line_idx
722
+
723
+ # Start new turn
724
+ current_user = msg
725
+ current_user_line = line_idx
726
+ current_assistants = []
727
+ current_assistant_parts = []
728
+ current_msg_id = None
729
+ current_tool_results = []
730
+
731
+ elif role == "assistant":
732
+ msg_id = None
733
+ if isinstance(msg, dict) and "message" in msg:
734
+ msg_id = msg["message"].get("id")
735
+
736
+ if not msg_id:
737
+ # No message ID, treat as continuation
738
+ current_assistant_parts.append(msg)
739
+ elif msg_id == current_msg_id:
740
+ # Same message ID, add to current parts
741
+ current_assistant_parts.append(msg)
742
+ else:
743
+ # New message ID - finalize previous message
744
+ if current_msg_id and current_assistant_parts:
745
+ merged = merge_assistant_parts(current_assistant_parts)
746
+ current_assistants.append(merged)
747
+
748
+ # Start new assistant message
749
+ current_msg_id = msg_id
750
+ current_assistant_parts = [msg]
751
+
752
+ # Process final turn
753
+ if current_msg_id and current_assistant_parts:
754
+ merged = merge_assistant_parts(current_assistant_parts)
755
+ current_assistants.append(merged)
756
+
757
+ if current_user and current_assistants:
758
+ _commit_turn()
759
+ last_committed_line = total_lines
760
+ else:
761
+ # Incomplete turn — rewind so the next run re-reads from the
762
+ # unmatched user message (or from where we left off if no user).
763
+ if current_user:
764
+ last_committed_line = current_user_line
765
+ debug(f"Incomplete turn at line {current_user_line}, will retry next run")
766
+ # else: no pending user, advance past non-turn lines
767
+ elif last_committed_line == last_line:
768
+ last_committed_line = total_lines
769
+
770
+ # Update state
771
+ state[session_id] = {
772
+ "last_line": last_committed_line,
773
+ "turn_count": turn_count + turns_processed,
774
+ "updated": datetime.now(timezone.utc).isoformat(),
775
+ }
776
+ save_state(state)
777
+
778
+ return turns_processed
779
+
780
+
781
+ def read_stdin_payload() -> Optional[Tuple[str, Path]]:
782
+ """Read session_id and transcript_path from stdin JSON payload.
783
+
784
+ Claude Code hooks pipe a JSON object on stdin with at least
785
+ ``session_id`` and ``transcript_path``. Returns ``None`` when
786
+ stdin is a TTY, empty, or contains invalid data.
787
+ """
788
+ if sys.stdin.isatty():
789
+ debug("stdin is a TTY, skipping stdin payload")
790
+ return None
791
+
792
+ try:
793
+ raw = sys.stdin.read()
794
+ except Exception as e:
795
+ debug(f"Failed to read stdin: {e}")
796
+ return None
797
+
798
+ if not raw or not raw.strip():
799
+ debug("stdin is empty")
800
+ return None
801
+
802
+ try:
803
+ payload = json.loads(raw)
804
+ except json.JSONDecodeError as e:
805
+ debug(f"Invalid JSON on stdin: {e}")
806
+ return None
807
+
808
+ session_id = payload.get("session_id")
809
+ transcript_path_str = payload.get("transcript_path")
810
+ if not session_id or not transcript_path_str:
811
+ debug("stdin payload missing session_id or transcript_path")
812
+ return None
813
+
814
+ transcript_path = Path(transcript_path_str)
815
+ if not transcript_path.exists():
816
+ debug(f"transcript_path from stdin does not exist: {transcript_path}")
817
+ return None
818
+
819
+ debug(f"Got transcript from stdin: session={session_id}, path={transcript_path}")
820
+ return (session_id, transcript_path)
821
+
822
+
823
+ @contextlib.contextmanager
824
+ def state_lock(timeout: float = 5.0):
825
+ """Acquire an advisory file lock around state operations.
826
+
827
+ Falls back to no-lock when fcntl is unavailable (Windows) or on errors.
828
+ """
829
+ if fcntl is None:
830
+ yield
831
+ return
832
+
833
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
834
+ lock_fd = None
835
+ try:
836
+ lock_fd = open(LOCK_FILE, "w")
837
+ deadline = time.monotonic() + timeout
838
+ while True:
839
+ try:
840
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
841
+ break
842
+ except (IOError, OSError):
843
+ if time.monotonic() >= deadline:
844
+ debug("Could not acquire state lock within timeout, proceeding without lock")
845
+ lock_fd.close()
846
+ lock_fd = None
847
+ yield
848
+ return
849
+ time.sleep(0.1)
850
+ try:
851
+ yield
852
+ finally:
853
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
854
+ lock_fd.close()
855
+ except Exception as e:
856
+ debug(f"Lock error, proceeding without lock: {e}")
857
+ if lock_fd is not None:
858
+ with contextlib.suppress(Exception):
859
+ lock_fd.close()
860
+ yield
861
+
862
+
863
+ def main():
864
+ script_start = datetime.now()
865
+ debug("Hook started")
866
+
867
+ # Check if tracing is enabled
868
+ if os.environ.get("TRACE_TO_RESPAN", "").lower() != "true":
869
+ debug("Tracing disabled (TRACE_TO_RESPAN != true)")
870
+ sys.exit(0)
871
+
872
+ # Check for required environment variables
873
+ api_key = os.getenv("RESPAN_API_KEY")
874
+ # Default: api.respan.ai | Enterprise: endpoint.respan.ai (set RESPAN_BASE_URL)
875
+ base_url = os.getenv("RESPAN_BASE_URL", "https://api.respan.ai/api")
876
+
877
+ if not api_key:
878
+ log("ERROR", "Respan API key not set (RESPAN_API_KEY)")
879
+ sys.exit(0)
880
+
881
+ # Try stdin payload first, fall back to filesystem scan
882
+ result = read_stdin_payload()
883
+ if not result:
884
+ result = find_latest_transcript()
885
+ if not result:
886
+ debug("No transcript file found")
887
+ sys.exit(0)
888
+
889
+ session_id, transcript_file = result
890
+
891
+ if not transcript_file:
892
+ debug("No transcript file found")
893
+ sys.exit(0)
894
+
895
+ debug(f"Processing session: {session_id}")
896
+
897
+ # Process the transcript under file lock
898
+ try:
899
+ with state_lock():
900
+ state = load_state()
901
+ turns = process_transcript(session_id, transcript_file, state, api_key, base_url)
902
+
903
+ # Log execution time
904
+ duration = (datetime.now() - script_start).total_seconds()
905
+ log("INFO", f"Processed {turns} turns in {duration:.1f}s")
906
+
907
+ if duration > 180:
908
+ log("WARN", f"Hook took {duration:.1f}s (>3min), consider optimizing")
909
+
910
+ except Exception as e:
911
+ log("ERROR", f"Failed to process transcript: {e}")
912
+ import traceback
913
+ debug(traceback.format_exc())
914
+
915
+ sys.exit(0)
916
+
917
+
918
+ if __name__ == "__main__":
919
+ main()