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

View File

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

View File

@@ -77,6 +77,8 @@ interface Trace {
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
totalTokens: number | null;
totalCost: number | null;
}
interface TraceDetailProps {
@@ -530,19 +532,77 @@ function SpanItem({ span, maxDuration }: { span: Span; maxDuration: number }) {
</div>
<div>
<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">
{JSON.stringify(span.output, null, 2)}
</pre>
{span.output === null || span.output === undefined || span.output === "" ? (
<p className="text-sm text-neutral-500 italic">No output recorded</p>
) : (
<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>
{Object.keys(span.metadata).length > 0 && (
{Object.entries(span.metadata).some(([, v]) => v !== null && v !== undefined) && (
<div>
<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">
{JSON.stringify(span.metadata, null, 2)}
{JSON.stringify(
Object.fromEntries(Object.entries(span.metadata).filter(([, v]) => v !== null && v !== undefined)),
null,
2
)}
</pre>
</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>
@@ -560,26 +620,9 @@ function EventsTab({ events }: { events: Event[] }) {
return (
<div className="space-y-2">
{events.map((event) => {
const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT;
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>
);
})}
{events.map((event) => (
<EventItem key={event.id} event={event} />
))}
</div>
);
}

View File

@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "./dist/index.cjs",

View File

@@ -111,11 +111,16 @@ export class SessionState {
metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }),
});
const reasoningText =
title !== call.tool && title
? `Selected ${call.tool}: ${title}`
: `Selected tool: ${call.tool}`;
trace.addDecision({
type: DecisionType.TOOL_SELECTION,
chosen: call.tool as JsonValue,
alternatives: [],
reasoning: title,
reasoning: reasoningText,
durationMs,
parentSpanId: rootSpanId,
});
@@ -186,17 +191,29 @@ export class SessionState {
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 {
const trace = this.traces.get(sessionId);
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();
if (transport) {
transport.add(trace.toPayload());
}
this.traces.delete(sessionId);
this.rootSpans.delete(sessionId);
}
}