Skip to content

Commit 5db5f9b

Browse files
committed
feat(telemetry): Phase 4a — TTFT capture + GenAI semconv dual-emit (QwenLM#3731)
Adds time-to-first-token measurement and OTel GenAI semantic convention dual-emit to the qwen-code.llm_request span. First slice of Phase 4 LLM request timing decomposition (sub-issue QwenLM#4413). Builds on Phase 1 (QwenLM#4126), Phase 1.5 (QwenLM#4302), Phase 2 (QwenLM#4321). Self-contained — ships TTFT visibility without depending on the Phase 4b retry-callback work. Changes ------- - New hasUserVisibleContent(chunk) helper centralizes "is this chunk first user-visible output" detection across all 4 providers. Operates on the normalized GenerateContentResponse shape each provider emits before LoggingContentGenerator sees the stream. Recognizes text / functionCall / inlineData / executableCode / thought parts; skips role-only and usageMetadata-only chunks. - TTFT capture in LoggingContentGenerator.generateContentStream's stream wrapper. Method-local closure variable, NEVER instance field — the generator is one-per-ContentGenerator and shared across concurrent calls (subagent fan-out, warmup, side-queries). - Extends LLMRequestMetadata with: ttftMs, requestSetupMs, attempt, retryTotalDelayMs, cachedInputTokens. The first is populated in 4a; the middle three are declared as optional so Phase 4b's retry callback can populate them without touching the schema again. - endLLMRequestSpan writes new span attrs: ttft_ms, sampling_ms (derived: duration - ttft - requestSetup, clamped to >= 0), output_tokens_per_second (derived: outputTokens / (sampling_ms / 1000), omitted when sampling_ms == 0 to avoid divide-by-zero, rounded to 2 decimals), cached_input_tokens, plus Phase 4b placeholders (attempt, request_setup_ms, retry_total_delay_ms) when caller provides them. - GenAI semconv dual-emit. Private qwen-code names stay authoritative (dashboards, SLOs, ARMS queries reference these); semconv is a compat layer for spec-aware backends. Same pattern as Phase 3 (QwenLM#4410): qwen-code.model + gen_ai.request.model (Stable) input_tokens + gen_ai.usage.input_tokens (Stable) output_tokens + gen_ai.usage.output_tokens (Stable) cached_input_tokens + gen_ai.usage.cached_tokens (Experimental) ttft_ms (ms, int) + gen_ai.server.time_to_first_token (s, double, Experimental) TTFT semantic intentionally diverges from claude-code's ttftMs (which fires on Anthropic's message_start metadata event). qwen-code measures "first user-visible content" — uniform across Anthropic / OpenAI / Gemini / Qwen, matches the literal "first token" intent. See design doc D1 for rationale. Design doc ---------- docs/design/telemetry-llm-request-timing-design.md (~530 lines) covers all 7 locked decisions with "Why not X" justifications, lifecycle wiring for streaming + non-streaming + 4 retry sites, files-to-change table with LOC estimates, test strategy, edge cases, rollback plan, and a comparison with claude-code (full decomposition but only for Perfetto) and opencode (no TTFT measurement at all). Tests ----- - streamContentDetection.test.ts (13 tests) covers part types matrix. - session-tracing.test.ts +16 tests (new describe block) cover dual-emit for every gen_ai.* field, sampling_ms clamping at 0 for clock skew, output_tokens_per_second rounding + divide-by-zero, Phase 4b placeholder fields written when present, absent when not. - loggingContentGenerator.test.ts +4 tests cover TTFT capture in streaming, undefined when stream yields only metadata chunks, and cachedInputTokens forwarding from usageMetadata on both stream and non-stream paths. All 448 telemetry tests pass, tsc --noEmit clean, eslint clean. Phase 4b (retry callback + ApiRetryEvent) and Phase 4c (recordApiRequestBreakdown activation) follow in separate PRs per the design doc split. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
1 parent 48b0a8b commit 5db5f9b

7 files changed

Lines changed: 1148 additions & 2 deletions

File tree

docs/design/telemetry-llm-request-timing-design.md

Lines changed: 538 additions & 0 deletions
Large diffs are not rendered by default.

packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ vi.mock('../../telemetry/index.js', () => {
165165
success: boolean;
166166
inputTokens?: number;
167167
outputTokens?: number;
168+
cachedInputTokens?: number;
169+
ttftMs?: number;
170+
requestSetupMs?: number;
171+
attempt?: number;
172+
retryTotalDelayMs?: number;
168173
durationMs?: number;
169174
error?: string;
170175
},
@@ -616,6 +621,150 @@ describe('LoggingContentGenerator', () => {
616621
});
617622
});
618623

624+
it('captures ttftMs on the first user-visible stream chunk (Phase 4a)', async () => {
625+
// Two chunks: first has text (user-visible), second has only usage.
626+
// ttftMs must be set on the first chunk and not overwritten by the second.
627+
const streamFn = vi.fn().mockResolvedValue(
628+
(async function* () {
629+
yield createResponse('r1', 'test-model', [{ text: 'hi' }]);
630+
yield createResponse('r2', 'test-model', [], {
631+
promptTokenCount: 10,
632+
candidatesTokenCount: 2,
633+
totalTokenCount: 12,
634+
});
635+
})(),
636+
);
637+
const wrapped = createWrappedGenerator(vi.fn(), streamFn);
638+
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
639+
model: 'test-model',
640+
authType: AuthType.USE_OPENAI,
641+
enableOpenAILogging: false,
642+
});
643+
const request = {
644+
model: 'test-model',
645+
contents: 'Hello',
646+
} as unknown as GenerateContentParameters;
647+
648+
const stream = await generator.generateContentStream(
649+
request,
650+
'prompt-ttft',
651+
);
652+
for await (const _ of stream) {
653+
// consume
654+
}
655+
656+
const spanRecord = getStreamSpanRecord();
657+
const meta = spanRecord.endMetadata as { ttftMs?: number } | undefined;
658+
expect(meta).toBeDefined();
659+
expect(typeof meta!.ttftMs).toBe('number');
660+
expect(meta!.ttftMs!).toBeGreaterThanOrEqual(0);
661+
});
662+
663+
it('forwards cachedInputTokens from usageMetadata to endLLMRequestSpan (Phase 4a)', async () => {
664+
const streamFn = vi.fn().mockResolvedValue(
665+
(async function* () {
666+
yield createResponse('r1', 'test-model', [{ text: 'ok' }], {
667+
promptTokenCount: 100,
668+
candidatesTokenCount: 20,
669+
cachedContentTokenCount: 40,
670+
totalTokenCount: 160,
671+
});
672+
})(),
673+
);
674+
const wrapped = createWrappedGenerator(vi.fn(), streamFn);
675+
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
676+
model: 'test-model',
677+
authType: AuthType.USE_OPENAI,
678+
enableOpenAILogging: false,
679+
});
680+
const request = {
681+
model: 'test-model',
682+
contents: 'Hello',
683+
} as unknown as GenerateContentParameters;
684+
685+
const stream = await generator.generateContentStream(
686+
request,
687+
'prompt-cache',
688+
);
689+
for await (const _ of stream) {
690+
// consume
691+
}
692+
693+
const spanRecord = getStreamSpanRecord();
694+
expect(spanRecord.endMetadata).toMatchObject({
695+
success: true,
696+
inputTokens: 100,
697+
cachedInputTokens: 40,
698+
});
699+
});
700+
701+
it('leaves ttftMs undefined when stream yields no user-visible chunks (Phase 4a)', async () => {
702+
// Stream emits only usage-metadata chunks (no text/functionCall/etc).
703+
// ttftMs must stay undefined — TTFT is only meaningful when content arrives.
704+
const streamFn = vi.fn().mockResolvedValue(
705+
(async function* () {
706+
yield createResponse('r1', 'test-model', [], {
707+
promptTokenCount: 5,
708+
candidatesTokenCount: 0,
709+
totalTokenCount: 5,
710+
});
711+
})(),
712+
);
713+
const wrapped = createWrappedGenerator(vi.fn(), streamFn);
714+
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
715+
model: 'test-model',
716+
authType: AuthType.USE_OPENAI,
717+
enableOpenAILogging: false,
718+
});
719+
const request = {
720+
model: 'test-model',
721+
contents: 'Hello',
722+
} as unknown as GenerateContentParameters;
723+
724+
const stream = await generator.generateContentStream(
725+
request,
726+
'prompt-no-content',
727+
);
728+
for await (const _ of stream) {
729+
// consume
730+
}
731+
732+
const spanRecord = getStreamSpanRecord();
733+
const meta = spanRecord.endMetadata as { ttftMs?: number } | undefined;
734+
expect(meta!.ttftMs).toBeUndefined();
735+
});
736+
737+
it('forwards cachedInputTokens to endLLMRequestSpan on non-stream success (Phase 4a)', async () => {
738+
const generateFn = vi.fn().mockResolvedValue(
739+
createResponse('resp-cache', 'test-model', [{ text: 'ok' }], {
740+
promptTokenCount: 100,
741+
candidatesTokenCount: 30,
742+
cachedContentTokenCount: 60,
743+
totalTokenCount: 190,
744+
}),
745+
);
746+
const wrapped = createWrappedGenerator(generateFn, vi.fn());
747+
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
748+
model: 'test-model',
749+
authType: AuthType.USE_OPENAI,
750+
enableOpenAILogging: false,
751+
});
752+
const request = {
753+
model: 'test-model',
754+
contents: 'Hi',
755+
} as unknown as GenerateContentParameters;
756+
757+
await generator.generateContent(request, 'prompt-cache-non-stream');
758+
759+
const spanRecord = getGenerateContentSpanRecord();
760+
expect(spanRecord.endMetadata).toMatchObject({
761+
success: true,
762+
inputTokens: 100,
763+
outputTokens: 30,
764+
cachedInputTokens: 60,
765+
});
766+
});
767+
619768
it('preserves non-stream success when response and OpenAI logging fail', async () => {
620769
vi.mocked(logApiResponse).mockImplementationOnce(() => {
621770
throw new Error('response-log-fail');

packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
API_CALL_ABORTED_SPAN_STATUS_MESSAGE,
6262
API_CALL_FAILED_SPAN_STATUS_MESSAGE,
6363
} from '../../telemetry/tracer.js';
64+
import { hasUserVisibleContent } from './streamContentDetection.js';
6465

6566
const debugLogger = createDebugLogger('LOGGING_CONTENT_GENERATOR');
6667

@@ -285,6 +286,7 @@ export class LoggingContentGenerator implements ContentGenerator {
285286
success: true,
286287
inputTokens: response.usageMetadata?.promptTokenCount,
287288
outputTokens: response.usageMetadata?.candidatesTokenCount,
289+
cachedInputTokens: response.usageMetadata?.cachedContentTokenCount,
288290
durationMs: Date.now() - startTime,
289291
});
290292
return response;
@@ -462,6 +464,14 @@ export class LoggingContentGenerator implements ContentGenerator {
462464
let firstModelVersion = '';
463465
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined;
464466
let errorOccurred = false;
467+
468+
// TTFT (time to first token): wall-clock from generateContentStream
469+
// dispatch to the first stream chunk containing user-visible content.
470+
// Method-local closure variable — NEVER an instance field — because
471+
// LoggingContentGenerator is shared across concurrent generateContentStream
472+
// calls (one per ContentGenerator, see contentGenerator.ts:createContentGenerator).
473+
// See docs/design/telemetry-llm-request-timing-design.md (D1, D2).
474+
let ttftMs: number | undefined;
465475
// Tracks whether the idle timeout fired and ended the span. If so,
466476
// a resumed-after-timeout consumer must not call endLLMRequestSpan
467477
// again (the helper would no-op, but more importantly we skip the
@@ -516,6 +526,13 @@ export class LoggingContentGenerator implements ContentGenerator {
516526
if (response.usageMetadata) {
517527
lastUsageMetadata = response.usageMetadata;
518528
}
529+
// Capture TTFT on the first stream chunk that contains user-visible
530+
// content. hasUserVisibleContent skips role-only / usageMetadata-only
531+
// chunks, so TTFT reflects "model produced something the operator can
532+
// attribute to user-perceived latency."
533+
if (ttftMs === undefined && hasUserVisibleContent(response)) {
534+
ttftMs = Date.now() - startTime;
535+
}
519536
resetSpanTimeout?.();
520537
yield response;
521538
}
@@ -601,6 +618,8 @@ export class LoggingContentGenerator implements ContentGenerator {
601618
success: !errorOccurred,
602619
inputTokens: lastUsageMetadata?.promptTokenCount,
603620
outputTokens: lastUsageMetadata?.candidatesTokenCount,
621+
cachedInputTokens: lastUsageMetadata?.cachedContentTokenCount,
622+
ttftMs,
604623
durationMs: Date.now() - startTime,
605624
error: errorOccurred
606625
? aborted
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, expect, it } from 'vitest';
8+
import { GenerateContentResponse } from '@google/genai';
9+
import { hasUserVisibleContent } from './streamContentDetection.js';
10+
11+
function chunkWithParts(parts: unknown[]): GenerateContentResponse {
12+
const r = new GenerateContentResponse();
13+
r.candidates = [
14+
{
15+
content: { role: 'model', parts: parts as never },
16+
},
17+
];
18+
return r;
19+
}
20+
21+
describe('hasUserVisibleContent', () => {
22+
it('returns true for non-empty text part', () => {
23+
expect(hasUserVisibleContent(chunkWithParts([{ text: 'hi' }]))).toBe(true);
24+
});
25+
26+
it('returns false for empty text part', () => {
27+
expect(hasUserVisibleContent(chunkWithParts([{ text: '' }]))).toBe(false);
28+
});
29+
30+
it('returns true for functionCall part', () => {
31+
expect(
32+
hasUserVisibleContent(
33+
chunkWithParts([{ functionCall: { name: 'read', args: {} } }]),
34+
),
35+
).toBe(true);
36+
});
37+
38+
it('returns true for inlineData part', () => {
39+
expect(
40+
hasUserVisibleContent(
41+
chunkWithParts([
42+
{ inlineData: { mimeType: 'image/png', data: 'abc' } },
43+
]),
44+
),
45+
).toBe(true);
46+
});
47+
48+
it('returns true for executableCode part', () => {
49+
expect(
50+
hasUserVisibleContent(
51+
chunkWithParts([
52+
{ executableCode: { language: 'PYTHON', code: 'print(1)' } },
53+
]),
54+
),
55+
).toBe(true);
56+
});
57+
58+
it('returns true for thought / reasoning part', () => {
59+
expect(hasUserVisibleContent(chunkWithParts([{ thought: true }]))).toBe(
60+
true,
61+
);
62+
});
63+
64+
it('returns true when any part is user-visible (mixed)', () => {
65+
expect(
66+
hasUserVisibleContent(chunkWithParts([{ text: '' }, { text: 'hi' }])),
67+
).toBe(true);
68+
});
69+
70+
it('returns false for empty parts array', () => {
71+
expect(hasUserVisibleContent(chunkWithParts([]))).toBe(false);
72+
});
73+
74+
it('returns false when candidates is missing', () => {
75+
const r = new GenerateContentResponse();
76+
expect(hasUserVisibleContent(r)).toBe(false);
77+
});
78+
79+
it('returns false when content is missing', () => {
80+
const r = new GenerateContentResponse();
81+
r.candidates = [{}];
82+
expect(hasUserVisibleContent(r)).toBe(false);
83+
});
84+
85+
it('returns false when parts is undefined', () => {
86+
const r = new GenerateContentResponse();
87+
r.candidates = [{ content: { role: 'model' } }];
88+
expect(hasUserVisibleContent(r)).toBe(false);
89+
});
90+
91+
it('returns false for usage-only / role-only chunks', () => {
92+
const r = new GenerateContentResponse();
93+
r.candidates = [{ content: { role: 'model', parts: [] } }];
94+
r.usageMetadata = { totalTokenCount: 42 };
95+
expect(hasUserVisibleContent(r)).toBe(false);
96+
});
97+
98+
it('handles parts that are non-objects defensively', () => {
99+
expect(
100+
hasUserVisibleContent(
101+
chunkWithParts([null, undefined, 'string', 42, { text: 'real' }]),
102+
),
103+
).toBe(true);
104+
expect(hasUserVisibleContent(chunkWithParts([null, undefined, 'x']))).toBe(
105+
false,
106+
);
107+
});
108+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type { GenerateContentResponse } from '@google/genai';
8+
9+
/**
10+
* Detects whether a streaming chunk contains user-visible model output.
11+
*
12+
* Used by the LoggingContentGenerator stream wrapper to identify the first
13+
* chunk that should trigger TTFT (time-to-first-token) measurement.
14+
*
15+
* A chunk is "user-visible" if any normalized Part in candidates[0].content.parts
16+
* is one of:
17+
* - text with a non-empty string
18+
* - functionCall (tool use — even tool-call-only responses count)
19+
* - inlineData (image, binary blob)
20+
* - executableCode (sandbox / code-execution responses)
21+
* - thought / reasoning content (provider-dependent; o1, qwen thinking, Anthropic <thinking>)
22+
*
23+
* Chunks containing only role metadata, only usageMetadata (final summary
24+
* chunk), or empty parts are NOT user-visible — TTFT should not fire on these.
25+
*
26+
* Centralised here (single predicate over the normalized GenerateContentResponse
27+
* shape) so the four provider generators (Anthropic / OpenAI / Gemini / Qwen)
28+
* don't each need their own first-token logic. Each provider already normalizes
29+
* its native chunk shape to GenerateContentResponse before LoggingContentGenerator
30+
* sees it (see loggingContentGenerator.ts generateContentStream).
31+
*/
32+
export function hasUserVisibleContent(chunk: GenerateContentResponse): boolean {
33+
const parts = chunk.candidates?.[0]?.content?.parts;
34+
if (!parts || parts.length === 0) return false;
35+
return parts.some(isUserVisiblePart);
36+
}
37+
38+
function isUserVisiblePart(part: unknown): boolean {
39+
if (part === null || typeof part !== 'object') return false;
40+
const p = part as {
41+
text?: unknown;
42+
functionCall?: unknown;
43+
inlineData?: unknown;
44+
executableCode?: unknown;
45+
thought?: unknown;
46+
};
47+
if (typeof p.text === 'string' && p.text.length > 0) return true;
48+
if (p.functionCall !== undefined) return true;
49+
if (p.inlineData !== undefined) return true;
50+
if (p.executableCode !== undefined) return true;
51+
// `thought` is provider-dependent and not always present on Part — guard with `in` to be safe.
52+
if (p.thought !== undefined) return true;
53+
return false;
54+
}

0 commit comments

Comments
 (0)