Files
jpmschweitzerandClaude Opus 4.5 60d84535c0 feat: add lightweight request tracing for debugging
Adds JSON-based tracing system for local development that captures
the full request flow through Tatlock's multi-agent architecture.

- Trace/Span dataclasses with automatic timing and nesting
- Context-var based propagation for async-safe tracing
- trace_span async context manager for clean instrumentation
- Traces written to logs/traces/{trace_id}.json
- REST API for listing and retrieving traces (/traces)
- Standalone HTML viewer with timeline visualization

Enabled via DEBUG=true environment variable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:25:56 +01:00

1214 lines
39 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tatlock Trace Viewer</title>
<style>
/* Twilight Color Palette */
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #414868;
--bg-hover: #343b58;
--text-primary: #c0caf5;
--text-secondary: #9aa5ce;
--text-muted: #565f89;
--accent-blue: #7aa2f7;
--accent-green: #9ece6a;
--accent-orange: #ff9e64;
--accent-purple: #bb9af7;
--accent-gray: #565f89;
--accent-red: #f7768e;
--accent-cyan: #7dcfff;
--border: #3b4261;
--shadow: rgba(0, 0, 0, 0.3);
--glass: rgba(36, 40, 59, 0.8);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.5;
}
/* Header */
header {
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 2rem;
box-shadow: 0 4px 20px var(--shadow);
}
h1 {
font-size: 1.5rem;
font-weight: 600;
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
color: var(--text-primary);
}
.btn:hover {
background: var(--bg-hover);
border-color: var(--accent-blue);
box-shadow: 0 0 15px rgba(122, 162, 247, 0.2);
}
.btn.primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
}
.btn.primary:hover {
background: #6b93e0;
}
.file-input-wrapper input[type="file"] {
display: none;
}
#trace-info {
margin-left: auto;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Main Layout */
main {
display: grid;
grid-template-columns: 280px 1fr 400px;
gap: 1rem;
padding: 1rem;
height: calc(100vh - 80px);
}
/* Trace List Panel */
#trace-list-panel {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-header h2 {
font-size: 1rem;
font-weight: 500;
}
.trace-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.trace-item {
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 0.25rem;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.trace-item:hover {
background: var(--bg-hover);
}
.trace-item.selected {
background: var(--bg-tertiary);
border-color: var(--accent-blue);
}
.trace-item-time {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.trace-item-preview {
font-size: 0.8125rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trace-item-meta {
display: flex;
gap: 0.5rem;
margin-top: 0.375rem;
font-size: 0.6875rem;
}
.trace-item-tag {
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: var(--bg-primary);
}
.trace-item-tag.completed { color: var(--accent-green); }
.trace-item-tag.error { color: var(--accent-red); }
.trace-item-tag.streaming { color: var(--accent-cyan); }
/* Timeline Section */
#timeline {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
display: flex;
flex-direction: column;
}
.timeline-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 1rem;
}
.timeline-header h2 {
font-size: 1rem;
font-weight: 500;
}
.timeline-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Span Row - Gantt Layout */
.span-row {
display: flex;
flex-direction: column;
padding: 0.375rem 0.75rem;
margin-bottom: 0.125rem;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
}
.span-row:hover {
background: var(--bg-hover);
}
.span-row.selected {
background: var(--bg-tertiary);
box-shadow: 0 0 0 1px var(--accent-blue);
}
.span-bar-row {
display: flex;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.span-indent {
width: 16px;
flex-shrink: 0;
}
/* Duration Bar (Gantt-style - full width) */
.span-bar-wrapper {
flex: 1;
height: 12px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.span-bar {
height: 100%;
border-radius: 4px;
transition: left 0.3s ease, width 0.3s ease;
position: absolute;
top: 0;
min-width: 4px;
}
.span-bar.router { background: var(--accent-gray); }
.span-bar.steward { background: var(--accent-blue); }
.span-bar.tatlock { background: var(--accent-green); }
.span-bar.expert { background: var(--accent-orange); }
.span-bar.tool { background: var(--accent-purple); }
.span-duration {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.6875rem;
color: var(--text-secondary);
padding: 0.125rem 0.375rem;
background: var(--bg-primary);
border-radius: 4px;
white-space: nowrap;
width: 54px;
text-align: right;
flex-shrink: 0;
}
.span-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.span-indent {
width: 16px;
flex-shrink: 0;
}
.span-status.ok { background: var(--accent-green); }
.span-status.error { background: var(--accent-red); }
/* Span label row (below bar) */
.span-label-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.span-icon {
width: 16px;
height: 16px;
border-radius: 3px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
}
.span-icon.router { background: var(--accent-gray); }
.span-icon.steward { background: var(--accent-blue); }
.span-icon.tatlock { background: var(--accent-green); }
.span-icon.expert { background: var(--accent-orange); }
.span-icon.tool { background: var(--accent-purple); }
.span-name {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Details Panel */
#details {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
display: flex;
flex-direction: column;
}
.details-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.details-header h2 {
font-size: 1rem;
font-weight: 500;
}
.details-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-key {
color: var(--text-secondary);
}
.detail-value {
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
}
/* Expandable Details */
.expandable {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
margin-top: 0.5rem;
overflow: hidden;
}
.expandable-header {
padding: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
transition: background 0.2s ease;
}
.expandable-header:hover {
background: var(--bg-hover);
}
.expandable-arrow {
transition: transform 0.2s ease;
}
.expandable.open .expandable-arrow {
transform: rotate(90deg);
}
.expandable-content {
display: none;
padding: 0.75rem;
border-top: 1px solid var(--border);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
color: var(--text-secondary);
line-height: 1.6;
}
.expandable.open .expandable-content {
display: block;
}
/* Stats Footer */
footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 0.75rem 2rem;
display: flex;
gap: 2rem;
font-size: 0.8125rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stat-label {
color: var(--text-muted);
}
.stat-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--text-primary);
}
.stat-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Legend */
.legend {
display: flex;
gap: 1rem;
margin-left: auto;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 3px;
}
/* Drop Zone */
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 2rem;
text-align: center;
transition: all 0.2s ease;
}
.drop-zone.drag-over {
border-color: var(--accent-blue);
background: rgba(122, 162, 247, 0.1);
}
/* Loading */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-muted);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Refresh button */
.refresh-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: all 0.2s ease;
}
.refresh-btn:hover {
color: var(--accent-blue);
background: var(--bg-hover);
}
/* Filter Controls */
.filter-bar {
padding: 0.75rem;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-row {
display: flex;
gap: 0.5rem;
}
.filter-select,
.filter-input {
flex: 1;
padding: 0.375rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.75rem;
transition: all 0.2s ease;
}
.filter-select:focus,
.filter-input:focus {
outline: none;
border-color: var(--accent-blue);
box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2);
}
.filter-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.filter-input::placeholder {
color: var(--text-muted);
}
.filter-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.125rem;
}
.filter-group {
display: flex;
flex-direction: column;
flex: 1;
}
</style>
</head>
<body>
<header>
<h1>Tatlock Trace Viewer</h1>
<div class="header-actions">
<div class="file-input-wrapper">
<input type="file" id="file-picker" accept=".json">
<label for="file-picker" class="btn">
<span>Open File</span>
</label>
</div>
</div>
<span id="trace-info"></span>
</header>
<main>
<section id="trace-list-panel">
<div class="panel-header">
<h2>Recent Traces</h2>
<button class="refresh-btn" onclick="loadTraceList()" title="Refresh">&#x21bb;</button>
</div>
<div class="filter-bar">
<div class="filter-row">
<div class="filter-group">
<label class="filter-label">Time</label>
<select id="filter-time" class="filter-select" onchange="loadTraceList()">
<option value="">All time</option>
<option value="5">Last 5 min</option>
<option value="30">Last 30 min</option>
<option value="60">Last hour</option>
<option value="1440">Last 24 hours</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label">Status</label>
<select id="filter-status" class="filter-select" onchange="loadTraceList()">
<option value="">All</option>
<option value="completed">Completed</option>
<option value="error">Error</option>
<option value="streaming">Streaming</option>
</select>
</div>
</div>
<div class="filter-row">
<input type="text" id="filter-search" class="filter-input" placeholder="Search requests..." onkeyup="debounceSearch()">
</div>
</div>
<div class="trace-list" id="trace-list">
<div class="loading">
<div class="spinner"></div>
Loading traces...
</div>
</div>
</section>
<section id="timeline">
<div class="timeline-header">
<h2>Timeline</h2>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-gray)"></div>Router</div>
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-blue)"></div>Steward</div>
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-green)"></div>Tatlock</div>
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-orange)"></div>Expert</div>
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-purple)"></div>Tool</div>
</div>
</div>
<div class="timeline-content" id="timeline-content">
<div class="empty-state drop-zone" id="drop-zone">
<div class="empty-state-icon">📊</div>
<p>Select a trace from the list<br>or drop a JSON file here</p>
</div>
</div>
</section>
<section id="details">
<div class="details-header">
<h2>Details</h2>
</div>
<div class="details-content" id="details-content">
<div class="empty-state">
<p>Select a span to view details</p>
</div>
</div>
</section>
</main>
<footer id="stats">
<div class="stat">
<span class="stat-label">Total Duration:</span>
<span class="stat-value" id="stat-duration">-</span>
</div>
<div class="stat">
<span class="stat-label">Spans:</span>
<span class="stat-value" id="stat-spans">-</span>
</div>
<div class="stat">
<span class="stat-dot" style="background: var(--accent-blue)"></span>
<span class="stat-label">Steward:</span>
<span class="stat-value" id="stat-steward">-</span>
</div>
<div class="stat">
<span class="stat-dot" style="background: var(--accent-green)"></span>
<span class="stat-label">Tatlock:</span>
<span class="stat-value" id="stat-tatlock">-</span>
</div>
<div class="stat">
<span class="stat-dot" style="background: var(--accent-orange)"></span>
<span class="stat-label">Expert:</span>
<span class="stat-value" id="stat-expert">-</span>
</div>
<div class="stat">
<span class="stat-dot" style="background: var(--accent-purple)"></span>
<span class="stat-label">Tool:</span>
<span class="stat-value" id="stat-tool">-</span>
</div>
</footer>
<script>
// State
let currentTrace = null;
let selectedSpanId = null;
let selectedTraceId = null;
// API Base URL (same origin)
const API_BASE = '/traces';
// DOM Elements
const filePicker = document.getElementById('file-picker');
const traceInfo = document.getElementById('trace-info');
const traceList = document.getElementById('trace-list');
const timelineContent = document.getElementById('timeline-content');
const detailsContent = document.getElementById('details-content');
const dropZone = document.getElementById('drop-zone');
const filterTime = document.getElementById('filter-time');
const filterStatus = document.getElementById('filter-status');
const filterSearch = document.getElementById('filter-search');
// Debounce timer for search
let searchDebounceTimer = null;
function debounceSearch() {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(loadTraceList, 300);
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadTraceList();
});
// File handling (local file support)
filePicker.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadTraceFromFile(file);
});
// Drag & Drop
dropZone?.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone?.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone?.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.json')) {
loadTraceFromFile(file);
}
});
// Load trace list from API
async function loadTraceList() {
traceList.innerHTML = '<div class="loading"><div class="spinner"></div>Loading traces...</div>';
try {
// Build query params from filters
const params = new URLSearchParams();
params.append('limit', '50');
const timeVal = filterTime?.value;
if (timeVal) {
params.append('since_minutes', timeVal);
}
const statusVal = filterStatus?.value;
if (statusVal) {
params.append('status', statusVal);
}
const searchVal = filterSearch?.value?.trim();
if (searchVal) {
params.append('search', searchVal);
}
const response = await fetch(`${API_BASE}/list?${params.toString()}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
renderTraceList(data.traces);
} catch (error) {
console.error('Failed to load trace list:', error);
traceList.innerHTML = `
<div class="empty-state">
<p>Could not load traces<br><small>${error.message}</small></p>
</div>
`;
}
}
function renderTraceList(traces) {
if (!traces || traces.length === 0) {
traceList.innerHTML = `
<div class="empty-state">
<p>No traces yet</p>
</div>
`;
return;
}
traceList.innerHTML = traces.map(trace => `
<div class="trace-item ${trace.trace_id === selectedTraceId ? 'selected' : ''}"
onclick="loadTraceFromAPI('${trace.trace_id}')"
data-trace-id="${trace.trace_id}">
<div class="trace-item-time">${formatTimestamp(trace.timestamp)}</div>
<div class="trace-item-preview">${escapeHtml(trace.request_preview || 'No preview')}</div>
<div class="trace-item-meta">
<span class="trace-item-tag ${trace.status}">${trace.status}</span>
<span class="trace-item-tag">${formatDuration(trace.total_duration_ms)}</span>
<span class="trace-item-tag">${trace.span_count} spans</span>
</div>
</div>
`).join('');
}
// Load trace from API
async function loadTraceFromAPI(traceId) {
selectedTraceId = traceId;
// Update selection in list
document.querySelectorAll('.trace-item').forEach(item => {
item.classList.toggle('selected', item.dataset.traceId === traceId);
});
try {
const response = await fetch(`${API_BASE}/${traceId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const trace = await response.json();
currentTrace = trace;
renderTrace(trace);
traceInfo.textContent = `${trace.trace_id} | ${formatTimestamp(trace.timestamp)}`;
} catch (error) {
console.error('Failed to load trace:', error);
traceInfo.textContent = `Error: ${error.message}`;
}
}
// Load trace from local file
async function loadTraceFromFile(file) {
try {
const text = await file.text();
const trace = JSON.parse(text);
currentTrace = trace;
selectedTraceId = null;
renderTrace(trace);
traceInfo.textContent = `${file.name} | ${formatTimestamp(trace.timestamp)}`;
// Clear list selection
document.querySelectorAll('.trace-item').forEach(item => {
item.classList.remove('selected');
});
} catch (error) {
console.error('Failed to load trace:', error);
traceInfo.textContent = 'Error loading trace file';
}
}
function renderTrace(trace) {
// Build span tree
const spanMap = new Map();
trace.spans.forEach(span => spanMap.set(span.span_id, span));
// Find root spans (no parent)
const rootSpans = trace.spans.filter(s => !s.parent_id);
// Calculate timeline bounds for Gantt chart positioning
let traceStart = Infinity;
let traceEnd = 0;
trace.spans.forEach(span => {
const startMs = new Date(span.start_time).getTime();
const endMs = span.end_time ? new Date(span.end_time).getTime() : startMs + (span.duration_ms || 0);
if (startMs < traceStart) traceStart = startMs;
if (endMs > traceEnd) traceEnd = endMs;
});
const totalDuration = traceEnd - traceStart || 1; // Avoid division by zero
// Render timeline
timelineContent.innerHTML = '';
rootSpans.forEach(span => {
renderSpanTree(span, spanMap, 0, traceStart, totalDuration);
});
// Update stats
updateStats(trace);
// Clear details
selectedSpanId = null;
detailsContent.innerHTML = '<div class="empty-state"><p>Select a span to view details</p></div>';
}
function renderSpanTree(span, spanMap, depth, traceStart, totalDuration) {
const row = document.createElement('div');
row.className = 'span-row';
row.dataset.spanId = span.span_id;
// === Bar Row (bar + duration + status) - NO indentation for alignment ===
const barRow = document.createElement('div');
barRow.className = 'span-bar-row';
// Gantt-style duration bar (full width, positioned by start time)
const barWrapper = document.createElement('div');
barWrapper.className = 'span-bar-wrapper';
const bar = document.createElement('div');
bar.className = `span-bar ${span.type}`;
// Calculate position and width relative to trace timeline
const spanStartMs = new Date(span.start_time).getTime();
const spanDurationMs = span.duration_ms || 0;
const leftPercent = ((spanStartMs - traceStart) / totalDuration) * 100;
const widthPercent = (spanDurationMs / totalDuration) * 100;
bar.style.left = `${leftPercent}%`;
bar.style.width = `${Math.max(widthPercent, 2)}%`; // Minimum 2% width for visibility
barWrapper.appendChild(bar);
barRow.appendChild(barWrapper);
// Duration label (right of bar, fixed width)
const duration = document.createElement('div');
duration.className = 'span-duration';
duration.textContent = formatDuration(span.duration_ms);
barRow.appendChild(duration);
// Status indicator
const status = document.createElement('div');
status.className = `span-status ${span.status}`;
barRow.appendChild(status);
row.appendChild(barRow);
// === Label Row (indents + icon + name, below bar) ===
const labelRow = document.createElement('div');
labelRow.className = 'span-label-row';
// Indentation only on label row to show hierarchy
for (let i = 0; i < depth; i++) {
const indent = document.createElement('div');
indent.className = 'span-indent';
labelRow.appendChild(indent);
}
// Icon
const icon = document.createElement('div');
icon.className = `span-icon ${span.type}`;
icon.textContent = getTypeIcon(span.type);
labelRow.appendChild(icon);
// Name
const name = document.createElement('div');
name.className = 'span-name';
name.textContent = span.name;
labelRow.appendChild(name);
row.appendChild(labelRow);
// Click handler
row.addEventListener('click', () => selectSpan(span));
timelineContent.appendChild(row);
// Render children
if (span.children) {
span.children.forEach(childId => {
const childSpan = spanMap.get(childId);
if (childSpan) {
renderSpanTree(childSpan, spanMap, depth + 1, traceStart, totalDuration);
}
});
}
}
function selectSpan(span) {
selectedSpanId = span.span_id;
// Update selection UI
document.querySelectorAll('.span-row').forEach(row => {
row.classList.toggle('selected', row.dataset.spanId === span.span_id);
});
// Render details
renderDetails(span);
}
function renderDetails(span) {
detailsContent.innerHTML = '';
// Basic info section
const basicSection = createDetailSection('Basic Info');
addDetailItem(basicSection, 'Name', span.name);
addDetailItem(basicSection, 'Type', span.type);
addDetailItem(basicSection, 'Status', span.status);
addDetailItem(basicSection, 'Duration', formatDuration(span.duration_ms));
addDetailItem(basicSection, 'Start', formatTimestamp(span.start_time));
if (span.end_time) {
addDetailItem(basicSection, 'End', formatTimestamp(span.end_time));
}
detailsContent.appendChild(basicSection);
// Metadata section
if (span.metadata && Object.keys(span.metadata).length > 0) {
const metaSection = createDetailSection('Metadata');
Object.entries(span.metadata).forEach(([key, value]) => {
addDetailItem(metaSection, key, formatValue(value));
});
detailsContent.appendChild(metaSection);
}
// Details section (expandable)
if (span.details && Object.keys(span.details).length > 0) {
const detailSection = createDetailSection('Expandable Details');
Object.entries(span.details).forEach(([key, value]) => {
const expandable = createExpandable(key, formatValue(value, true));
detailSection.appendChild(expandable);
});
detailsContent.appendChild(detailSection);
}
// Error section
if (span.error) {
const errorSection = createDetailSection('Error');
const errorBox = document.createElement('div');
errorBox.style.cssText = 'background: rgba(247, 118, 142, 0.1); border: 1px solid var(--accent-red); border-radius: 8px; padding: 0.75rem; color: var(--accent-red); font-size: 0.875rem;';
errorBox.textContent = span.error;
errorSection.appendChild(errorBox);
detailsContent.appendChild(errorSection);
}
}
function createDetailSection(title) {
const section = document.createElement('div');
section.className = 'detail-section';
const h3 = document.createElement('h3');
h3.textContent = title;
section.appendChild(h3);
return section;
}
function addDetailItem(section, key, value) {
const item = document.createElement('div');
item.className = 'detail-item';
item.innerHTML = `
<span class="detail-key">${escapeHtml(key)}</span>
<span class="detail-value">${escapeHtml(value)}</span>
`;
section.appendChild(item);
}
function createExpandable(title, content) {
const expandable = document.createElement('div');
expandable.className = 'expandable';
const header = document.createElement('div');
header.className = 'expandable-header';
header.innerHTML = `
<span class="expandable-arrow">▶</span>
<span>${escapeHtml(title)}</span>
`;
const contentDiv = document.createElement('div');
contentDiv.className = 'expandable-content';
contentDiv.textContent = content;
header.addEventListener('click', () => {
expandable.classList.toggle('open');
});
expandable.appendChild(header);
expandable.appendChild(contentDiv);
return expandable;
}
function updateStats(trace) {
document.getElementById('stat-duration').textContent = formatDuration(trace.total_duration_ms);
document.getElementById('stat-spans').textContent = trace.spans.length;
const typeCounts = { steward: 0, tatlock: 0, expert: 0, tool: 0 };
trace.spans.forEach(span => {
if (typeCounts.hasOwnProperty(span.type)) {
typeCounts[span.type]++;
}
});
document.getElementById('stat-steward').textContent = typeCounts.steward;
document.getElementById('stat-tatlock').textContent = typeCounts.tatlock;
document.getElementById('stat-expert').textContent = typeCounts.expert;
document.getElementById('stat-tool').textContent = typeCounts.tool;
}
// Utility functions
function formatDuration(ms) {
if (ms == null) return '-';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function formatTimestamp(ts) {
if (!ts) return '-';
try {
const date = new Date(ts);
return date.toLocaleString();
} catch {
return ts;
}
}
function formatValue(value, full = false) {
if (value === null || value === undefined) return '-';
if (typeof value === 'object') {
return full ? JSON.stringify(value, null, 2) : JSON.stringify(value);
}
if (typeof value === 'string' && !full && value.length > 50) {
return value.substring(0, 50) + '...';
}
return String(value);
}
function getTypeIcon(type) {
const icons = {
router: '🚪',
steward: '🎩',
tatlock: '🤵',
expert: '👨‍💼',
tool: '🔧'
};
return icons[type] || '•';
}
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
</script>
</body>
</html>