fix: complete traces on idle, improve dashboard span/event/analytics views

This commit is contained in:
Vectry
2026-02-10 13:25:19 +00:00
parent 7534c709f5
commit 638a5d2640
5 changed files with 105 additions and 33 deletions

View File

@@ -12,6 +12,7 @@ interface TraceData {
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
costUsd: number | null; costUsd: number | null;
totalCost: number | null; totalCost: number | null;
totalTokens: number | null;
decisionPoints: Array<{ decisionPoints: Array<{
id: string; id: string;
type: string; type: string;
@@ -96,6 +97,8 @@ export default async function TraceDetailPage({ params }: TraceDetailPageProps)
tags: trace.tags, tags: trace.tags,
metadata: trace.metadata, metadata: trace.metadata,
costUsd: trace.costUsd ?? trace.totalCost, costUsd: trace.costUsd ?? trace.totalCost,
totalTokens: trace.totalTokens ?? null,
totalCost: trace.totalCost ?? null,
}} }}
decisionPoints={trace.decisionPoints} decisionPoints={trace.decisionPoints}
spans={trace.spans} spans={trace.spans}

View File

@@ -49,6 +49,8 @@ interface Trace {
tags: string[]; tags: string[];
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
costUsd: number | null; costUsd: number | null;
totalTokens: number | null;
totalCost: number | null;
} }
interface TraceAnalyticsProps { interface TraceAnalyticsProps {
@@ -112,9 +114,15 @@ function ExecutionTimeline({ trace, spans }: { trace: Trace; spans: Span[] }) {
} }
const traceStartTime = new Date(trace.startedAt).getTime(); const traceStartTime = new Date(trace.startedAt).getTime();
const lastSpanEnd = spans.reduce((max, s) => {
const end = s.endedAt ? new Date(s.endedAt).getTime() : 0;
return end > max ? end : max;
}, 0);
const traceEndTime = trace.endedAt const traceEndTime = trace.endedAt
? new Date(trace.endedAt).getTime() ? new Date(trace.endedAt).getTime()
: Date.now(); : trace.status === "COMPLETED" || trace.status === "ERROR"
? lastSpanEnd || traceStartTime
: Date.now();
const totalDuration = traceEndTime - traceStartTime; const totalDuration = traceEndTime - traceStartTime;
// Build span hierarchy for nesting // Build span hierarchy for nesting
@@ -310,7 +318,7 @@ function CostBreakdown({
(sum, d) => sum + (d.cost || 0), (sum, d) => sum + (d.cost || 0),
0 0
); );
const totalCostValue = trace.costUsd ?? totalSpanCost + totalDecisionCost; const totalCostValue = trace.costUsd ?? trace.totalCost ?? totalSpanCost + totalDecisionCost;
const hasData = const hasData =
totalCostValue > 0 || spanCostsData.length > 0 || decisionCostsData.length > 0; totalCostValue > 0 || spanCostsData.length > 0 || decisionCostsData.length > 0;
@@ -462,6 +470,7 @@ function CostBreakdown({
function TokenUsageGauge({ trace }: { trace: Trace }) { function TokenUsageGauge({ trace }: { trace: Trace }) {
const tokenData = useMemo(() => { const tokenData = useMemo(() => {
const totalTokens = const totalTokens =
trace.totalTokens ??
(trace.metadata?.totalTokens as number | null | undefined) ?? (trace.metadata?.totalTokens as number | null | undefined) ??
(trace.metadata?.tokenCount as number | null | undefined) ?? (trace.metadata?.tokenCount as number | null | undefined) ??
null; null;

View File

@@ -77,6 +77,8 @@ interface Trace {
tags: string[]; tags: string[];
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
costUsd: number | null; costUsd: number | null;
totalTokens: number | null;
totalCost: number | null;
} }
interface TraceDetailProps { interface TraceDetailProps {
@@ -530,19 +532,77 @@ function SpanItem({ span, maxDuration }: { span: Span; maxDuration: number }) {
</div> </div>
<div> <div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Output</h5> <h5 className="text-xs font-medium text-neutral-500 mb-2">Output</h5>
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300"> {span.output === null || span.output === undefined || span.output === "" ? (
{JSON.stringify(span.output, null, 2)} <p className="text-sm text-neutral-500 italic">No output recorded</p>
</pre> ) : (
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.output, null, 2)}
</pre>
)}
</div> </div>
</div> </div>
{Object.keys(span.metadata).length > 0 && ( {Object.entries(span.metadata).some(([, v]) => v !== null && v !== undefined) && (
<div> <div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Metadata</h5> <h5 className="text-xs font-medium text-neutral-500 mb-2">Metadata</h5>
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300"> <pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.metadata, null, 2)} {JSON.stringify(
Object.fromEntries(Object.entries(span.metadata).filter(([, v]) => v !== null && v !== undefined)),
null,
2
)}
</pre> </pre>
</div> </div>
)} )}
<details className="group">
<summary className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-200 cursor-pointer transition-colors">
<FileJson className="w-4 h-4" />
<span>Raw JSON</span>
<ChevronRight className="w-4 h-4 group-open:rotate-90 transition-transform" />
</summary>
<pre className="mt-3 p-4 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300 max-h-96 overflow-y-auto">
{JSON.stringify(span, null, 2)}
</pre>
</details>
</div>
)}
</div>
);
}
function EventItem({ event }: { event: Event }) {
const [expanded, setExpanded] = useState(false);
const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT;
const hasMetadata = event.metadata && Object.entries(event.metadata).some(([, v]) => v !== null && v !== undefined);
return (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-4 p-4"
>
<div className={cn("p-2 rounded-lg bg-neutral-800", color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 text-left">
<h4 className="font-medium text-neutral-100">{event.name}</h4>
<p className="text-xs text-neutral-500">{event.type}</p>
</div>
<span className="text-sm text-neutral-400">
{formatRelativeTime(event.timestamp)}
</span>
{hasMetadata && (
expanded ? (
<ChevronDown className="w-5 h-5 text-neutral-500" />
) : (
<ChevronRight className="w-5 h-5 text-neutral-500" />
)
)}
</button>
{expanded && hasMetadata && (
<div className="px-4 pb-4 pt-0 border-t border-neutral-800">
<pre className="mt-3 p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300 max-h-64 overflow-y-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
</div> </div>
)} )}
</div> </div>
@@ -560,26 +620,9 @@ function EventsTab({ events }: { events: Event[] }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{events.map((event) => { {events.map((event) => (
const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT; <EventItem key={event.id} event={event} />
return ( ))}
<div
key={event.id}
className="flex items-center gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors"
>
<div className={cn("p-2 rounded-lg bg-neutral-800", color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1">
<h4 className="font-medium text-neutral-100">{event.name}</h4>
<p className="text-xs text-neutral-500">{event.type}</p>
</div>
<span className="text-sm text-neutral-400">
{formatRelativeTime(event.timestamp)}
</span>
</div>
);
})}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "opencode-agentlens", "name": "opencode-agentlens",
"version": "0.1.4", "version": "0.1.5",
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions", "description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",

View File

@@ -111,11 +111,16 @@ export class SessionState {
metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }), metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }),
}); });
const reasoningText =
title !== call.tool && title
? `Selected ${call.tool}: ${title}`
: `Selected tool: ${call.tool}`;
trace.addDecision({ trace.addDecision({
type: DecisionType.TOOL_SELECTION, type: DecisionType.TOOL_SELECTION,
chosen: call.tool as JsonValue, chosen: call.tool as JsonValue,
alternatives: [], alternatives: [],
reasoning: title, reasoning: reasoningText,
durationMs, durationMs,
parentSpanId: rootSpanId, parentSpanId: rootSpanId,
}); });
@@ -186,17 +191,29 @@ export class SessionState {
return Array.from(this.traces.keys()); return Array.from(this.traces.keys());
} }
/**
* Send the current trace state without ending the session.
* This creates a snapshot so data isn't lost if the process exits unexpectedly.
*/
flushSession(sessionId: string): void { flushSession(sessionId: string): void {
const trace = this.traces.get(sessionId); const trace = this.traces.get(sessionId);
if (!trace) return; if (!trace) return;
const rootSpanId = this.rootSpans.get(sessionId);
if (rootSpanId) {
trace.addSpan({
id: rootSpanId,
name: "session",
type: SpanType.AGENT,
status: SpanStatus.COMPLETED,
endedAt: nowISO(),
});
}
trace.end({ status: "COMPLETED" });
const transport = getClient(); const transport = getClient();
if (transport) { if (transport) {
transport.add(trace.toPayload()); transport.add(trace.toPayload());
} }
this.traces.delete(sessionId);
this.rootSpans.delete(sessionId);
} }
} }