fix: complete traces on idle, improve dashboard span/event/analytics views
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user