Files
openchamber/packages/ui/src/components/chat/work-status/telemetry.ts
T
alvins82 d8215ef5b3 feat(work-status): add opt-in turn statistics (#3177)
Add optional completed-turn statistics without changing the existing panel layout. Separate final text delivery speed from whole-turn throughput, preserve scope and opt-in settings, and explain each metric with localized delayed tooltips.

Validated focused telemetry, lifecycle, sync and persistence tests, all-workspace type-check and lint, web builds, the 12-locale narrow layout, and full GitHub CI.
2026-09-06 22:56:27 +03:00

292 lines
10 KiB
TypeScript

import type { Message, Part } from '@opencode-ai/sdk/v2';
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
type SessionMessageRecord = {
info: Message;
parts: Part[];
};
type CompletedStepStats = {
toolDurationMs: number | null;
adjustedLlmDurationMs: number | null;
ttftMs: number | null;
inputTokens: number | null;
outputTokens: number | null;
reasoningTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
cost: number | null;
};
export type CompletedTurnStats = {
lastAssistantMessageId: string;
stepsCount: number;
totalLlmDurationMs: number | null;
totalToolDurationMs: number | null;
avgTtftMs: number | null;
tokensPerSecond: number | null;
responseTokensPerSecond: number | null;
inputTokens: number | null;
outputTokens: number | null;
reasoningTokens: number | null;
totalGeneratedTokens: number | null;
cacheHitPercent: number | null;
cost: number | null;
};
/**
* Merge an array of [start, end] time intervals into a disjoint union of intervals.
* Correctly accounts for parallel / overlapping tool executions without double-counting.
*/
export function mergeTimeIntervals(intervals: readonly (readonly [number, number])[]): Array<[number, number]> {
if (intervals.length === 0) return [];
const valid: Array<[number, number]> = [];
for (const [start, end] of intervals) {
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
valid.push([start, end]);
}
}
valid.sort((a, b) => a[0] - b[0]);
if (valid.length === 0) return [];
const merged: Array<[number, number]> = [valid[0]];
for (let i = 1; i < valid.length; i += 1) {
const current = valid[i];
const last = merged[merged.length - 1];
if (current[0] <= last[1]) {
last[1] = Math.max(last[1], current[1]);
} else {
merged.push(current);
}
}
return merged;
}
/**
* Sum the total duration spanned by an array of disjoint intervals.
*/
export function sumIntervalsDuration(intervals: readonly (readonly [number, number])[]): number {
return intervals.reduce((sum, [start, end]) => sum + (end - start), 0);
}
export const formatTelemetryDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) {
return '0.0s';
}
if (ms < 60_000) {
return `${(ms / 1000).toFixed(1)}s`;
}
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m${seconds}s`;
};
export const formatTelemetryTokens = (tokens: number): string => {
if (!Number.isFinite(tokens) || tokens <= 0) {
return '0';
}
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`;
}
if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1)}K`;
}
return String(Math.round(tokens));
};
export const formatThroughputRate = (tps: number): string => {
return `~${Math.round(tps)} tok/s`;
};
const nonnegative = (value: number | undefined): number | null =>
value !== undefined && Number.isFinite(value) && value >= 0 ? value : null;
const add = (left: number | null, right: number | null): number | null =>
left === null || right === null ? null : nonnegative(left + right);
/** Text delivery rate for the final reply, not throughput of the agent loop. */
function calculateResponseTokenRate(record: SessionMessageRecord): number | null {
const { info, parts } = record;
if (info.role !== 'assistant' || info.error || parts.some((part) => part.type === 'tool')) return null;
const output = nonnegative(info.tokens?.output);
const { created, completed } = info.time;
if (output === null || completed === undefined || nonnegative(created) === null || nonnegative(completed) === null) return null;
const intervals: Array<[number, number]> = [];
for (const part of parts) {
if (part.type !== 'text') continue;
// Synthetic/ignored text cannot be matched to the provider's output count.
if (part.synthetic || part.ignored) return null;
if (!part.text) continue;
const start = part.time?.start;
const end = part.time?.end;
if (start === undefined || end === undefined || !Number.isFinite(start) || !Number.isFinite(end)
|| start < created || end > completed || end <= start) return null;
intervals.push([start, end]);
}
const duration = sumIntervalsDuration(mergeTimeIntervals(intervals));
return duration > 0 ? nonnegative(output / (duration / 1000)) : null;
}
/**
* Calculate stats for a single completed assistant step.
*/
function calculateCompletedStepStats(record: SessionMessageRecord): CompletedStepStats | null {
const { info, parts } = record;
if (info.role !== 'assistant') return null;
const { created } = info.time;
const completed = info.time.completed;
if (completed === undefined) return null;
const validWindow = nonnegative(created) !== null && nonnegative(completed) !== null && completed >= created;
const totalDurationMs = validWindow ? nonnegative(completed - created) : null;
// An unfinished or invalid tool makes duration-dependent metrics unknown.
const rawToolIntervals: Array<[number, number]> = [];
let validTools = validWindow;
for (const part of parts) {
if (part.type !== 'tool') continue;
if (part.state.status !== 'completed' && part.state.status !== 'error') {
validTools = false;
continue;
}
const start = part.state.time?.start;
const end = part.state.time?.end;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < created || end > completed || end < start) {
validTools = false;
continue;
}
rawToolIntervals.push([start, end]);
}
const toolDurationMs = validTools ? nonnegative(sumIntervalsDuration(mergeTimeIntervals(rawToolIntervals))) : null;
const adjustedLlmDurationMs = totalDurationMs !== null && toolDurationMs !== null
? nonnegative(totalDurationMs - toolDurationMs)
: null;
// Measure TTFT from first text or reasoning part start timestamp
let ttftMs: number | null = null;
for (const part of parts) {
if (part.type === 'text' || part.type === 'reasoning') {
const partStart = part.time?.start;
if (validWindow && partStart !== undefined && Number.isFinite(partStart) && partStart >= created && partStart <= completed) {
const delta = partStart - created;
ttftMs = ttftMs === null ? delta : Math.min(ttftMs, delta);
}
}
}
const inputTokens = nonnegative(info.tokens?.input);
const outputTokens = nonnegative(info.tokens?.output);
const reasoningTokens = nonnegative(info.tokens?.reasoning);
const cacheReadTokens = nonnegative(info.tokens?.cache?.read);
const cacheWriteTokens = nonnegative(info.tokens?.cache?.write);
const cost = nonnegative(info.cost);
return {
toolDurationMs,
adjustedLlmDurationMs,
ttftMs,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
cost,
};
}
/**
* Calculates telemetry metrics for the latest completed turn in the session.
* A turn encompasses all assistant steps since the preceding user message up to the final completed assistant step.
*/
export function getLatestCompletedTurnStats(
records: readonly SessionMessageRecord[] | null | undefined,
): CompletedTurnStats | null {
if (!records || records.length === 0) return null;
// Only the newest user-bounded turn qualifies. A partial newer turn must not
// be published as complete or silently replaced with an older turn's stats.
const lastCompletedAssistantIdx = records.length - 1;
if (records[lastCompletedAssistantIdx].info.role !== 'assistant') return null;
let turnStartIdx = -1;
for (let i = records.length - 1; i >= 0; i -= 1) {
const record = records[i];
if (record.info.role === 'user') {
turnStartIdx = i + 1;
break;
}
}
if (turnStartIdx === -1) return null;
const stepStatsList: CompletedStepStats[] = [];
for (let i = turnStartIdx; i <= lastCompletedAssistantIdx; i += 1) {
const record = records[i];
if (record.info.role === 'assistant') {
const stepStats = calculateCompletedStepStats(record);
if (!stepStats) return null;
stepStatsList.push(stepStats);
}
}
if (stepStatsList.length === 0) return null;
let totalLlmDurationMs: number | null = 0;
let totalToolDurationMs: number | null = 0;
let totalInputTokens: number | null = 0;
let totalOutputTokens: number | null = 0;
let totalReasoningTokens: number | null = 0;
let totalCacheReadTokens: number | null = 0;
let totalCacheWriteTokens: number | null = 0;
let totalCost: number | null = 0;
let totalTtft: number | null = 0;
for (const step of stepStatsList) {
totalLlmDurationMs = add(totalLlmDurationMs, step.adjustedLlmDurationMs);
totalToolDurationMs = add(totalToolDurationMs, step.toolDurationMs);
totalInputTokens = add(totalInputTokens, step.inputTokens);
totalOutputTokens = add(totalOutputTokens, step.outputTokens);
totalReasoningTokens = add(totalReasoningTokens, step.reasoningTokens);
totalCacheReadTokens = add(totalCacheReadTokens, step.cacheReadTokens);
totalCacheWriteTokens = add(totalCacheWriteTokens, step.cacheWriteTokens);
totalCost = add(totalCost, step.cost);
totalTtft = add(totalTtft, step.ttftMs);
}
const avgTtftMs = totalTtft === null ? null : totalTtft / stepStatsList.length;
const totalGeneratedTokens = add(totalOutputTokens, totalReasoningTokens);
const tokensPerSecond = totalGeneratedTokens !== null && totalLlmDurationMs !== null && totalLlmDurationMs > 0
? nonnegative(totalGeneratedTokens / (totalLlmDurationMs / 1000))
: null;
const cacheHit = totalInputTokens !== null && totalCacheReadTokens !== null && totalCacheWriteTokens !== null ? computeCacheHitRate({
input: totalInputTokens,
cache: { read: totalCacheReadTokens, write: totalCacheWriteTokens },
}) : null;
return {
lastAssistantMessageId: records[lastCompletedAssistantIdx].info.id,
stepsCount: stepStatsList.length,
totalLlmDurationMs,
totalToolDurationMs,
avgTtftMs,
tokensPerSecond,
responseTokensPerSecond: calculateResponseTokenRate(records[lastCompletedAssistantIdx]),
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
totalGeneratedTokens,
cacheHitPercent: cacheHit?.hasInput ? Math.round(cacheHit.percent) : null,
cost: totalCost,
};
}