Skip to content

Commit f9b5782

Browse files
authored
Merge pull request #139 from Fango2007/codex/metrics-v2-semantic-stream-timing
feat: add canonical semantic stream timing
2 parents 9a4d73c + 1542477 commit f9b5782

7 files changed

Lines changed: 791 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
2828
- **Provider-native metrics foundation** — expanded `docs/METRICS.md` with canonical Ollama timing mappings, cross-provider token accounting, native-field provenance, exact/qualified/provider-only mapping rules, and a clean database-reset transition to `metrics-v2` without legacy aliases or historical metric migration.
2929
- **Canonical provider metric normalization** — benchmark response normalization now uses an internal typed `metrics-v2` observation contract for registered Ollama, OpenAI Chat, Anthropic Messages, and Gemini GenerateContent usage and timing fields while persisted benchmark results remain on the existing `metrics-v1` contract.
3030
- **Canonical client metric normalization** — benchmark execution now records transient `metrics-v2` observations for operation and successful-attempt latency, retry overhead, attempt count, request and normalization health, terminal timeouts, stream completion, and first transport chunk timing while retaining the persisted `metrics-v1` result shape.
31+
- **Canonical semantic stream timing** — streamed benchmark execution now distinguishes first transport bytes from meaningful text or tool-call output and records transient first-output, first-tool-call, tool-readiness, and last-output timestamps without changing persisted `metrics-v1` results.
3132

3233
### Fixed
3334

backend/src/services/benchmark-client-metrics.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export interface ClientAttemptTelemetry {
99
started_at_ms: number;
1010
ended_at_ms: number;
1111
first_chunk_at_ms: number | null;
12+
first_output_at_ms: number | null;
13+
first_tool_call_at_ms: number | null;
14+
tool_calls_ready_at_ms: number | null;
15+
last_output_at_ms: number | null;
16+
tool_call_started: boolean;
17+
tool_call_error: string | null;
1218
request_succeeded: boolean;
1319
timed_out: boolean;
1420
response_normalization_succeeded: boolean | null;
@@ -106,6 +112,58 @@ export function normalizeClientMetricObservations(
106112
}
107113
}
108114

115+
const notApplicable = (metricId: string, reason: string) => observation({
116+
metricId,
117+
value: null,
118+
unit: 'milliseconds',
119+
status: 'not_applicable',
120+
reason
121+
});
122+
const semanticAttempt = successfulAttempt ?? finalAttempt;
123+
if (!telemetry.streaming) {
124+
observations.push(
125+
notApplicable('time_to_first_output_ms', 'The operation did not request a streaming response.'),
126+
notApplicable('time_to_first_tool_call_ms', 'The operation did not request a streaming response.'),
127+
notApplicable('time_to_tool_calls_ready_ms', 'The operation did not request a streaming response.')
128+
);
129+
} else {
130+
observations.push(semanticAttempt?.first_output_at_ms !== null && semanticAttempt?.first_output_at_ms !== undefined
131+
? observation({
132+
metricId: 'time_to_first_output_ms',
133+
value: semanticAttempt.first_output_at_ms - semanticAttempt.started_at_ms,
134+
unit: 'milliseconds'
135+
})
136+
: notApplicable('time_to_first_output_ms', 'The stream contained no normalized model output.'));
137+
observations.push(semanticAttempt?.first_tool_call_at_ms !== null && semanticAttempt?.first_tool_call_at_ms !== undefined
138+
? observation({
139+
metricId: 'time_to_first_tool_call_ms',
140+
value: semanticAttempt.first_tool_call_at_ms - semanticAttempt.started_at_ms,
141+
unit: 'milliseconds'
142+
})
143+
: notApplicable('time_to_first_tool_call_ms', 'The stream contained no normalized tool-call output.'));
144+
if (semanticAttempt?.tool_calls_ready_at_ms !== null && semanticAttempt?.tool_calls_ready_at_ms !== undefined) {
145+
observations.push(observation({
146+
metricId: 'time_to_tool_calls_ready_ms',
147+
value: semanticAttempt.tool_calls_ready_at_ms - semanticAttempt.started_at_ms,
148+
unit: 'milliseconds'
149+
}));
150+
} else if (semanticAttempt?.tool_call_started) {
151+
observations.push(observation({
152+
metricId: 'time_to_tool_calls_ready_ms',
153+
value: null,
154+
unit: 'milliseconds',
155+
status: 'execution_error',
156+
reason: semanticAttempt.tool_call_error
157+
?? 'The stream ended before all tool calls were complete and parseable.'
158+
}));
159+
} else {
160+
observations.push(notApplicable(
161+
'time_to_tool_calls_ready_ms',
162+
'The stream contained no normalized tool-call output.'
163+
));
164+
}
165+
}
166+
109167
if (finalAttempt && finalAttempt.response_normalization_succeeded !== null) {
110168
observations.push(observation({
111169
metricId: 'response_normalization_success',

backend/src/services/benchmark-runner.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import {
2626
normalizeClientMetricObservations,
2727
type ClientAttemptTelemetry
2828
} from './benchmark-client-metrics.js';
29+
import {
30+
classifyStreamSemanticEvent,
31+
createStreamTimingTracker,
32+
type StreamTimingTelemetry,
33+
type StreamTimingTracker
34+
} from './benchmark-stream-timing.js';
2935

3036
const ENGINE_VERSION = 'benchmark-runner-v1';
3137
const DEFAULT_TIMEOUT_MS = 30_000;
@@ -856,17 +862,14 @@ export function parseOpenAiSseStream(raw: string): StreamNormalization {
856862
events
857863
);
858864
}
865+
if (jsonRecord) {
866+
content.push(...classifyStreamSemanticEvent('openai_chat', jsonRecord, event.event).text_fragments);
867+
}
859868
const choices = Array.isArray(jsonRecord?.choices) ? jsonRecord.choices : [];
860869
for (const choice of choices) {
861870
const choiceRecord = objectValue(choice);
862871
const delta = objectValue(choiceRecord?.delta);
863872
const message = objectValue(choiceRecord?.message);
864-
const text = typeof delta?.content === 'string'
865-
? delta.content
866-
: typeof message?.content === 'string' ? message.content : null;
867-
if (text !== null) {
868-
content.push(text);
869-
}
870873
const streamedCalls = Array.isArray(delta?.tool_calls)
871874
? delta.tool_calls
872875
: Array.isArray(message?.tool_calls) ? message.tool_calls : [];
@@ -933,13 +936,10 @@ export function parseOllamaJsonlStream(raw: string): StreamNormalization {
933936
events
934937
);
935938
}
936-
const message = objectValue(record?.message);
937-
const part = typeof message?.content === 'string'
938-
? message.content
939-
: typeof record?.response === 'string' ? record.response : null;
940-
if (part !== null) {
941-
content.push(part);
939+
if (record) {
940+
content.push(...classifyStreamSemanticEvent('ollama_chat', record).text_fragments);
942941
}
942+
const message = objectValue(record?.message);
943943
const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
944944
for (const call of calls) {
945945
const callRecord = objectValue(call);
@@ -1006,6 +1006,9 @@ export function parseAnthropicSseStream(raw: string): StreamNormalization {
10061006
events
10071007
);
10081008
}
1009+
if (record) {
1010+
content.push(...classifyStreamSemanticEvent('anthropic_messages', record, event.event).text_fragments);
1011+
}
10091012
if (eventType === 'message_start') {
10101013
const message = objectValue(record?.message);
10111014
if (message) finalMetadata = message;
@@ -1021,15 +1024,12 @@ export function parseAnthropicSseStream(raw: string): StreamNormalization {
10211024
initialInput: block.input,
10221025
partialJson: ''
10231026
});
1024-
if (type === 'text' && typeof block.text === 'string') content.push(block.text);
10251027
}
10261028
} else if (eventType === 'content_block_delta') {
10271029
const blockIndex = numberAt(record, 'index');
10281030
const delta = objectValue(record?.delta);
10291031
const block = blockIndex === null ? undefined : blocks.get(blockIndex);
1030-
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
1031-
content.push(delta.text);
1032-
} else if (block && delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
1032+
if (block && delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
10331033
block.partialJson += delta.partial_json;
10341034
}
10351035
} else if (eventType === 'content_block_stop') {
@@ -1103,14 +1103,16 @@ export function parseGeminiSseStream(raw: string): StreamNormalization {
11031103
events
11041104
);
11051105
}
1106+
if (record) {
1107+
content.push(...classifyStreamSemanticEvent('gemini_generate_content', record, event.event).text_fragments);
1108+
}
11061109
const candidates = Array.isArray(record?.candidates) ? record.candidates : [];
11071110
for (const candidate of candidates) {
11081111
const candidateRecord = objectValue(candidate);
11091112
const candidateContent = objectValue(candidateRecord?.content);
11101113
const parts = Array.isArray(candidateContent?.parts) ? candidateContent.parts : [];
11111114
for (const part of parts) {
11121115
const partRecord = objectValue(part);
1113-
if (typeof partRecord?.text === 'string') content.push(partRecord.text);
11141116
const functionCall = objectValue(partRecord?.functionCall);
11151117
const name = textFromValue(functionCall?.name);
11161118
if (functionCall && !name) {
@@ -1307,6 +1309,17 @@ function roundMilliseconds(value: number): number {
13071309
return Math.round(value * 1000) / 1000;
13081310
}
13091311

1312+
function emptyStreamTimingTelemetry(): StreamTimingTelemetry {
1313+
return {
1314+
first_output_at_ms: null,
1315+
first_tool_call_at_ms: null,
1316+
tool_calls_ready_at_ms: null,
1317+
last_output_at_ms: null,
1318+
tool_call_started: false,
1319+
tool_call_error: null
1320+
};
1321+
}
1322+
13101323
async function executeItem(
13111324
instantiation: Record<string, unknown>,
13121325
stage: BenchmarkStage,
@@ -1353,6 +1366,7 @@ async function executeItem(
13531366
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
13541367
const attemptStartedAtMs = performance.now();
13551368
let firstChunkAtMs: number | null = null;
1369+
let streamTimingTracker: StreamTimingTracker | null = null;
13561370
const controller = new AbortController();
13571371
const timer = setTimeout(() => controller.abort(), timeoutMs);
13581372
try {
@@ -1364,6 +1378,9 @@ async function executeItem(
13641378
});
13651379
let responseText = '';
13661380
const effectiveStreaming = streaming && response.ok;
1381+
if (effectiveStreaming && isProviderProtocol(operationSpec?.protocol)) {
1382+
streamTimingTracker = createStreamTimingTracker(operationSpec.protocol, attemptStartedAtMs);
1383+
}
13671384
firstTokenMs = null;
13681385
if (effectiveStreaming && response.body) {
13691386
const reader = response.body.getReader();
@@ -1379,9 +1396,15 @@ async function executeItem(
13791396
firstChunkAtMs = performance.now();
13801397
firstTokenMs = roundMilliseconds(firstChunkAtMs - operationStartedAtMs);
13811398
}
1382-
responseText += decoder.decode(value, { stream: true });
1399+
const receivedAtMs = performance.now();
1400+
const decodedChunk = decoder.decode(value, { stream: true });
1401+
responseText += decodedChunk;
1402+
streamTimingTracker?.push(decodedChunk, receivedAtMs);
13831403
}
1384-
responseText += decoder.decode();
1404+
const finalDecodedChunk = decoder.decode();
1405+
responseText += finalDecodedChunk;
1406+
streamTimingTracker?.push(finalDecodedChunk, performance.now());
1407+
streamTimingTracker?.finish(performance.now());
13851408
} else {
13861409
responseText = await response.text();
13871410
}
@@ -1422,10 +1445,12 @@ async function executeItem(
14221445
stream_format: error.format
14231446
};
14241447
attemptErrors.push(issue);
1448+
const streamTiming = streamTimingTracker?.snapshot() ?? emptyStreamTimingTelemetry();
14251449
clientAttempts.push({
14261450
started_at_ms: attemptStartedAtMs,
14271451
ended_at_ms: attemptEndedAtMs,
14281452
first_chunk_at_ms: firstChunkAtMs,
1453+
...streamTiming,
14291454
request_succeeded: response.ok,
14301455
timed_out: false,
14311456
response_normalization_succeeded: false,
@@ -1505,10 +1530,12 @@ async function executeItem(
15051530
};
15061531
}
15071532
const errorCode = response.ok ? null : `http_${response.status}`;
1533+
const streamTiming = streamTimingTracker?.snapshot() ?? emptyStreamTimingTelemetry();
15081534
clientAttempts.push({
15091535
started_at_ms: attemptStartedAtMs,
15101536
ended_at_ms: attemptEndedAtMs,
15111537
first_chunk_at_ms: firstChunkAtMs,
1538+
...streamTiming,
15121539
request_succeeded: response.ok,
15131540
timed_out: false,
15141541
response_normalization_succeeded: response.ok,
@@ -1626,6 +1653,7 @@ async function executeItem(
16261653
} catch (error) {
16271654
const err = error as Error;
16281655
const attemptEndedAtMs = performance.now();
1656+
const streamTiming = streamTimingTracker?.snapshot() ?? emptyStreamTimingTelemetry();
16291657
const issue = {
16301658
code: err.name === 'AbortError' ? 'timeout' : 'connection_error',
16311659
message: err.name === 'AbortError' ? `Benchmark request timed out after ${timeoutMs}ms` : err.message,
@@ -1641,6 +1669,7 @@ async function executeItem(
16411669
started_at_ms: attemptStartedAtMs,
16421670
ended_at_ms: attemptEndedAtMs,
16431671
first_chunk_at_ms: firstChunkAtMs,
1672+
...streamTiming,
16441673
request_succeeded: false,
16451674
timed_out: err.name === 'AbortError',
16461675
response_normalization_succeeded: null,

0 commit comments

Comments
 (0)