Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/utils/__tests__/jsonl-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,25 @@ describe('jsonl block metrics integration', () => {

expect(metrics).toBeNull();
});

it('finds the current block in a transcript with more usage rows than a call takes arguments', () => {
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
fs.mkdirSync(projectsDir, { recursive: true });
const transcriptPath = path.join(projectsDir, 'long-session.jsonl');

const now = new Date();
const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000));
const recentActivity = new Date(now.getTime() - (40 * 60 * 1000));
// More rows than Node (about 125k) or Bun (about 640k) accepts as
// arguments to a single function call. The older row ends the lookback
// at its session gap, so the file is read once.
const rowCount = 700_000;
const recentLine = `${makeUsageLine(recentActivity)}\n`;
fs.writeFileSync(transcriptPath, `${makeUsageLine(oldActivity)}\n${recentLine.repeat(rowCount)}`);

const metrics = getBlockMetrics();

expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(recentActivity).toISOString());
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
}, 30000);
});
111 changes: 101 additions & 10 deletions src/utils/__tests__/jsonl-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,36 @@ describe('jsonl transcript metrics', () => {
});
});

it('keeps token and speed metrics for a transcript with more usage rows than a call takes arguments', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'many-usage-rows.jsonl');

// More rows than Node (about 125k) or Bun (about 640k) accepts as
// arguments to a single function call
const rowCount = 700_000;
fs.writeFileSync(transcriptPath, `${JSON.stringify({
type: 'assistant',
message: {
usage: {
input_tokens: 1,
output_tokens: 2
}
}
})}\n`.repeat(rowCount));

const analysis = await getTranscriptAnalysis(transcriptPath, { includeSpeedMetrics: true });

expect(analysis.tokenMetrics.outputTokens).toBe(rowCount * 2);
expect(analysis.speedMetricsCollection?.sessionAverage).toEqual({
totalDurationMs: 0,
inputTokens: rowCount,
outputTokens: rowCount * 2,
totalTokens: rowCount * 3,
requestCount: rowCount
});
}, 30000);

it('collects configured transcript metrics in one combined analysis', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
Expand Down Expand Up @@ -1319,11 +1349,12 @@ describe('jsonl transcript metrics', () => {

const metrics = await getSpeedMetrics(transcriptPath, { windowSeconds: 5 });

// msg-2 ran 6s, 5s of it inside the window: 5/6 of its tokens, once
expect(metrics).toEqual({
totalDurationMs: 5000,
inputTokens: 150,
outputTokens: 90,
totalTokens: 240,
inputTokens: 125,
outputTokens: 75,
totalTokens: 200,
requestCount: 1
});
});
Expand Down Expand Up @@ -1412,11 +1443,15 @@ describe('jsonl transcript metrics', () => {

const metrics = await getSpeedMetrics(transcriptPath, { windowSeconds: 5 });

// msg-1's final usage ran 6s, 2s of it inside the window; msg-2 ran 9s,
// 5s of it inside. Each keeps that share of its tokens
const inputTokens = 50 * 2 / 6 + 70 * 5 / 9;
const outputTokens = 500 * 2 / 6 + 100 * 5 / 9;
expect(metrics).toEqual({
totalDurationMs: 5000,
inputTokens: 120,
outputTokens: 600,
totalTokens: 720,
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
requestCount: 2
});
});
Expand Down Expand Up @@ -1490,7 +1525,11 @@ describe('jsonl transcript metrics', () => {
const totals = { inputTokens: 300, outputTokens: expectedOutput, totalTokens: 300 + expectedOutput };
expect(analysis.tokenMetrics).toMatchObject({ ...totals, contextLength: 200 });
expect(analysis.speedMetricsCollection?.sessionAverage).toEqual({ ...totals, requestCount: 2, totalDurationMs: 5000 });
expect(analysis.speedMetricsCollection?.windowed['3']).toEqual({ ...totals, requestCount: 2, totalDurationMs: 2000 });
// a ran 0-3s and 6-7s, 1s of its 4s inside the window: 1/4 of its tokens.
// The middle response ran 4-5s, all of it inside
const windowedInput = 100 / 4 + 200;
const windowedOutput = (expectedOutput - 30) / 4 + 30;
expect(analysis.speedMetricsCollection?.windowed['3']).toEqual({ inputTokens: windowedInput, outputTokens: windowedOutput, totalTokens: windowedInput + windowedOutput, requestCount: 2, totalDurationMs: 2000 });
});

it('does not restore pre-compaction context from a duplicate response', async () => {
Expand Down Expand Up @@ -1612,6 +1651,57 @@ describe('jsonl transcript metrics', () => {
});
});

it('counts only the in-window share of tokens for a request that started before the window', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-window-straddle.jsonl');

// One 80s request at 50 output t/s, then its tool result 5s later, which
// ends the window. Only the last part of the request falls inside it.
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:01:20.000Z',
type: 'assistant',
input: 80,
output: 4000
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:01:25.000Z',
type: 'user'
})
].join('\n'));

const metricsCollection = await getSpeedMetricsCollection(transcriptPath, { windowSeconds: [10, 40] });

expect(metricsCollection.sessionAverage).toEqual({
totalDurationMs: 80000,
inputTokens: 80,
outputTokens: 4000,
totalTokens: 4080,
requestCount: 1
});
// 5s of the request's 80s fall inside the 10s window: 1/16 of its tokens
expect(metricsCollection.windowed['10']).toEqual({
totalDurationMs: 5000,
inputTokens: 5,
outputTokens: 250,
totalTokens: 255,
requestCount: 1
});
// 35s of its 80s fall inside the 40s window: 7/16 of its tokens
expect(metricsCollection.windowed['40']).toEqual({
totalDurationMs: 35000,
inputTokens: 35,
outputTokens: 1750,
totalTokens: 1785,
requestCount: 1
});
});

it('ignores sidechain and API error entries in speed metrics', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
Expand Down Expand Up @@ -1821,11 +1911,12 @@ describe('jsonl transcript metrics', () => {
windowSeconds: 4
});

// The subagent request ran 5s, 4s of it inside the window: 4/5 of its tokens
expect(metrics).toEqual({
totalDurationMs: 4000,
inputTokens: 30,
outputTokens: 60,
totalTokens: 90,
inputTokens: 24,
outputTokens: 48,
totalTokens: 72,
requestCount: 1
});
});
Expand Down
7 changes: 5 additions & 2 deletions src/utils/jsonl-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ function findMostRecentBlockStartTime(
if (mtime.getTime() < cutoffTime.getTime()) {
break;
}
const fileTimestamps = getAllTimestampsFromFile(file);
timestamps.push(...fileTimestamps);
// One push per timestamp: spreading a long transcript's timestamps
// into a single call exceeds the engine's argument limit and throws
for (const timestamp of getAllTimestampsFromFile(file)) {
timestamps.push(timestamp);
}
}

if (timestamps.length === 0) {
Expand Down
58 changes: 43 additions & 15 deletions src/utils/jsonl-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,42 @@ function getIntervalsDurationMs(intervals: SpeedInterval[]): number {
return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0);
}

/**
* Cuts a request down to the part of its time inside the window.
*
* A request's tokens are produced across the time its response ran, so one that
* started before the window produced only some of them inside it. It keeps the
* share of its tokens that matches the share of that time inside the window: all
* of its tokens over only the clipped time would overstate the speed. That time
* is the union of the intervals of the response's records, including those
* logged before the window; a request without any interval keeps all of its
* tokens.
*/
function clipRequestToWindow(request: SpeedRequest, window: SpeedInterval): SpeedRequest {
const responseTime = mergeIntervals(request.intervals);
const intervals: SpeedInterval[] = [];
for (const interval of responseTime) {
const startMs = Math.max(interval.startMs, window.startMs);
const endMs = Math.min(interval.endMs, window.endMs);
if (endMs > startMs) {
intervals.push({ startMs, endMs });
}
}

const fullMs = getIntervalsDurationMs(responseTime);
if (fullMs === 0) {
return { ...request, intervals };
}

const insideMs = getIntervalsDurationMs(intervals);
return {
...request,
inputTokens: request.inputTokens * insideMs / fullMs,
outputTokens: request.outputTokens * insideMs / fullMs,
intervals
};
}

function createEmptySpeedMetrics(): SpeedMetrics {
return {
totalDurationMs: 0,
Expand Down Expand Up @@ -458,7 +494,11 @@ function mergeCollectedSpeedMetrics(parts: CollectedSpeedMetrics[]): CollectedSp
let latestTimestampMs: number | null = null;

for (const part of parts) {
requests.push(...part.requests);
// One push per request: spreading a long transcript's requests into a
// single call exceeds the engine's argument limit and throws
for (const request of part.requests) {
requests.push(request);
}

if (part.latestTimestampMs !== null && (latestTimestampMs === null || part.latestTimestampMs > latestTimestampMs)) {
latestTimestampMs = part.latestTimestampMs;
Expand Down Expand Up @@ -490,7 +530,7 @@ function buildSpeedMetrics(
? collected.requests.filter(request => request.assistantTimestampMs !== null
&& request.assistantTimestampMs >= windowBounds.startMs
&& request.assistantTimestampMs <= windowBounds.endMs
)
).map(request => clipRequestToWindow(request, windowBounds))
: collected.requests;

let inputTokens = 0;
Expand All @@ -502,19 +542,7 @@ function buildSpeedMetrics(
outputTokens += request.outputTokens;

for (const interval of request.intervals) {
if (windowBounds === null) {
intervals.push(interval);
continue;
}

const clippedStartMs = Math.max(interval.startMs, windowBounds.startMs);
const clippedEndMs = Math.min(interval.endMs, windowBounds.endMs);
if (clippedEndMs > clippedStartMs) {
intervals.push({
startMs: clippedStartMs,
endMs: clippedEndMs
});
}
intervals.push(interval);
}
}

Expand Down
Loading