Skip to content

Commit f33d485

Browse files
committed
feat(stage-ui): io tracer for CALL tokens
1 parent 77eeb64 commit f33d485

6 files changed

Lines changed: 155 additions & 14 deletions

File tree

packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-chart.vue

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type { IOSpan, IOSubsystem, IOTurn } from '@proj-airi/stage-shared'
33
4+
import { IOSubsystems } from '@proj-airi/stage-shared'
45
import { useElementBounding, useElementSize, useEventListener } from '@vueuse/core'
56
import { computed, ref, watch } from 'vue'
67
@@ -131,8 +132,8 @@ const layout = computed(() => {
131132
const gapAnnotations: GapAnnotation[] = []
132133
let y = 0
133134
134-
const subsystemOrder: IOSubsystem[] = ['tts', 'playback']
135-
const ttsSubsystems = new Set<IOSubsystem>(['tts', 'playback'])
135+
const subsystemOrder: IOSubsystem[] = [IOSubsystems.TTS, IOSubsystems.Playback]
136+
const ttsSubsystems = new Set<IOSubsystem>([IOSubsystems.TTS, IOSubsystems.Playback])
136137
let isFirstTurn = true
137138
138139
for (const turn of turns.value) {
@@ -146,7 +147,10 @@ const layout = computed(() => {
146147
}
147148
isFirstTurn = false
148149
149-
const llmSpans = turnSpans.filter(s => s.subsystem === 'llm').sort((a, b) => a.startTs - b.startTs)
150+
const llmSpans = turnSpans.filter(s => s.subsystem === IOSubsystems.LLM).sort((a, b) => a.startTs - b.startTs)
151+
const auxiliarySpans = turnSpans
152+
.filter(s => s.subsystem !== IOSubsystems.LLM && !ttsSubsystems.has(s.subsystem))
153+
.sort((a, b) => a.startTs - b.startTs)
150154
const ttsSpanList = turnSpans.filter(s => ttsSubsystems.has(s.subsystem))
151155
152156
const segmentGroups = new Map<string, IOSpan[]>()
@@ -166,7 +170,12 @@ const layout = computed(() => {
166170
.sort((a, b) => a[0].startTs - b[0].startTs)
167171
168172
for (const span of llmSpans) {
169-
rows.push({ type: 'span', span, turn, subsystem: 'llm', y })
173+
rows.push({ type: 'span', span, turn, subsystem: IOSubsystems.LLM, y })
174+
y += ROW_HEIGHT
175+
}
176+
177+
for (const span of auxiliarySpans) {
178+
rows.push({ type: 'span', span, turn, subsystem: span.subsystem, y })
170179
y += ROW_HEIGHT
171180
}
172181
@@ -512,6 +521,28 @@ function spanLabel(span: IOSpan): string {
512521
const subsystemLabel = SUBSYSTEM_CONFIG_MAP.get(span.subsystem)?.label ?? ''
513522
return `${subsystemLabel} / ${span.name}`
514523
}
524+
525+
function formatMetaValue(value: unknown): string {
526+
if (value == null)
527+
return ''
528+
if (typeof value === 'object')
529+
return JSON.stringify(value)
530+
return String(value)
531+
}
532+
533+
function tooltipMetaEntries(span: IOSpan) {
534+
const tooltipKeys = Array.isArray(span.meta.tooltipKeys)
535+
? span.meta.tooltipKeys.filter((key): key is string => typeof key === 'string')
536+
: []
537+
538+
return tooltipKeys
539+
.map(key => [key, span.meta[key]] as const)
540+
.filter(([, value]) => value !== undefined && value !== '')
541+
.map(([key, value]) => ({
542+
label: key,
543+
value: formatMetaValue(value),
544+
}))
545+
}
515546
</script>
516547

517548
<template>
@@ -831,6 +862,19 @@ function spanLabel(span: IOSpan): string {
831862
<div v-if="hoveredSpan.span.meta.chunk_reason" :class="['text-amber-300/80 mt-0.5']">
832863
chunk: {{ hoveredSpan.span.meta.chunk_reason }}
833864
</div>
865+
<div
866+
v-if="tooltipMetaEntries(hoveredSpan.span).length > 0"
867+
:class="['mt-1.5 pt-1.5 border-t border-neutral-700/80 flex flex-col gap-0.5']"
868+
>
869+
<div
870+
v-for="entry in tooltipMetaEntries(hoveredSpan.span)"
871+
:key="entry.label"
872+
:class="['grid grid-cols-[auto_1fr] gap-x-2 text-neutral-300']"
873+
>
874+
<span :class="['text-neutral-500']">{{ entry.label }}</span>
875+
<span :class="['font-mono text-neutral-100 truncate']">{{ entry.value }}</span>
876+
</div>
877+
</div>
834878
</div>
835879
</Teleport>
836880
</div>

packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-controls.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { IOSubsystem } from '@proj-airi/stage-shared'
33
44
import { Button } from '@proj-airi/ui'
55
6-
import { SUBSYSTEM_CONFIG_MAP } from '../io-tracer-types'
6+
import { SUBSYSTEM_CONFIG_MAP, SUBSYSTEM_CONFIGS } from '../io-tracer-types'
77
88
defineProps<{
99
isRecording: boolean
@@ -20,19 +20,19 @@ const emit = defineEmits<{
2020
exportOtlp: []
2121
}>()
2222
23-
const ttsSubsystems: { subsystem: IOSubsystem, label: string }[] = [
24-
{ subsystem: 'tts', label: 'TTS' },
25-
{ subsystem: 'playback', label: 'Play' },
26-
]
23+
const subsystemFilters = SUBSYSTEM_CONFIGS.map(config => ({
24+
subsystem: config.subsystem,
25+
label: config.label,
26+
}))
2727
</script>
2828

2929
<template>
3030
<div :class="['flex items-center gap-2', 'px-3 py-2', 'border-b border-neutral-200 dark:border-neutral-700']">
3131
<Button
3232
:class="[
3333
'flex items-center gap-1.5',
34-
isRecording ? 'text-red-500' : '',
3534
]"
35+
:variant="isRecording ? 'danger' : 'primary'"
3636
@click="emit('toggleRecording')"
3737
>
3838
<div
@@ -60,11 +60,11 @@ const ttsSubsystems: { subsystem: IOSubsystem, label: string }[] = [
6060
Fit
6161
</Button>
6262

63-
<!-- TTS Subsystem Toggles -->
63+
<!-- Subsystem Toggles -->
6464
<div :class="['w-px h-4 bg-neutral-200 dark:bg-neutral-700 mx-1']" />
65-
<span :class="['text-2.5 text-neutral-400']">TTS:</span>
65+
<span :class="['text-2.5 text-neutral-400']">Subsystems:</span>
6666
<button
67-
v-for="item in ttsSubsystems"
67+
v-for="item in subsystemFilters"
6868
:key="item.subsystem"
6969
:class="[
7070
'text-2.5 px-1.5 py-0.5 rounded',

packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-detail.vue

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ const metaEntries = computed(() => {
8383
}))
8484
})
8585
86+
const eventEntries = computed(() => {
87+
const span = props.span
88+
if (!span)
89+
return []
90+
91+
return (span.events ?? []).map(event => ({
92+
name: event.name,
93+
relativeTime: fmtMs(event.timeTs - span.startTs),
94+
meta: Object.entries(event.meta)
95+
.map(([key, value]) => ({
96+
key: key.includes('.') ? key.split('.').at(-1)! : key,
97+
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
98+
})),
99+
}))
100+
})
101+
86102
function copyValue(value: string) {
87103
navigator.clipboard.writeText(value)
88104
}
@@ -197,6 +213,42 @@ function copyValue(value: string) {
197213
</div>
198214
</div>
199215

216+
<!-- Events -->
217+
<div v-if="eventEntries.length > 0">
218+
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
219+
Events
220+
</div>
221+
<div :class="['flex flex-col gap-1.5']">
222+
<div
223+
v-for="entry in eventEntries"
224+
:key="`${entry.name}:${entry.relativeTime}`"
225+
:class="[
226+
'rounded border border-neutral-100 dark:border-neutral-800',
227+
'bg-neutral-50 dark:bg-neutral-800/60',
228+
'p-1.5',
229+
]"
230+
>
231+
<div :class="['flex items-center justify-between gap-2']">
232+
<span :class="['font-mono text-2.5 truncate']">{{ entry.name }}</span>
233+
<span :class="['font-mono text-2.5 text-neutral-400 flex-shrink-0']">+{{ entry.relativeTime }}</span>
234+
</div>
235+
<div
236+
v-if="entry.meta.length > 0"
237+
:class="['mt-1 flex flex-col gap-0.5']"
238+
>
239+
<div
240+
v-for="item in entry.meta"
241+
:key="item.key"
242+
:class="['grid grid-cols-[auto_1fr] gap-x-2']"
243+
>
244+
<span :class="['text-neutral-400 text-2.5']">{{ item.key }}</span>
245+
<span :class="['font-mono text-2.5 truncate']">{{ item.value }}</span>
246+
</div>
247+
</div>
248+
</div>
249+
</div>
250+
</div>
251+
200252
<!-- Metadata -->
201253
<div v-if="metaEntries.length > 0">
202254
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">

packages/stage-pages/src/pages/devtools/io-tracer/io-tracer-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface SubsystemConfig {
1313
export const SUBSYSTEM_CONFIGS: SubsystemConfig[] = [
1414
{ subsystem: IOSubsystems.ASR, label: 'ASR', color: '#3b82f6', bgColor: '#3b82f618', icon: 'i-lucide:mic' },
1515
{ subsystem: IOSubsystems.LLM, label: 'LLM', color: '#a855f7', bgColor: '#a855f718', icon: 'i-lucide:brain' },
16+
{ subsystem: IOSubsystems.StreamingControl, label: 'Streaming Control', color: '#06b6d4', bgColor: '#06b6d418', icon: 'i-lucide:radio-tower' },
1617
{ subsystem: IOSubsystems.TTS, label: 'TTS', color: '#22c55e', bgColor: '#22c55e18', icon: 'i-lucide:audio-lines' },
1718
{ subsystem: IOSubsystems.Playback, label: 'Playback', color: '#f87171', bgColor: '#f8717118', icon: 'i-lucide:play' },
1819
]

packages/stage-shared/src/perf/io-trace.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const IOSubsystems = {
22
ASR: 'asr',
33
LLM: 'llm',
4+
StreamingControl: 'streaming-control',
45
TTS: 'tts',
56
Playback: 'playback',
67
} as const
@@ -10,6 +11,7 @@ export const IOSpanNames = {
1011
InteractionTurn: 'Interaction turn',
1112
SpeechRecognition: 'Speech recognition',
1213
LLMInference: 'LLM inference',
14+
StreamingControlDispatch: 'Streaming control dispatch',
1315
TTSSynthesis: 'TTS synthesis',
1416
AudioPlayback: 'Audio playback',
1517
} as const
@@ -22,10 +24,22 @@ export const IOAttributes = {
2224

2325
// Non-standard
2426
Subsystem: `${customPrefix}.subsystem`,
27+
TooltipKeys: `${customPrefix}.tooltip.keys`,
2528
LLM_TTFT: `${customPrefix}.llm.time_to_first_token`,
2629
ASRText: `${customPrefix}.asr.text`,
2730
ASRAbort: `${customPrefix}.asr.abort`,
2831
LLMTextLength: `${customPrefix}.llm.text_length`,
32+
StreamingControlCallName: `${customPrefix}.streaming_control.call_name`,
33+
StreamingControlHandlerCount: `${customPrefix}.streaming_control.handler_count`,
34+
StreamingControlMatched: `${customPrefix}.streaming_control.matched`,
35+
StreamingControlParameter: `${customPrefix}.streaming_control.parameter`,
36+
StreamingControlParsed: `${customPrefix}.streaming_control.parsed`,
37+
StreamingControlParserName: `${customPrefix}.streaming_control.parser_name`,
38+
StreamingControlReason: `${customPrefix}.streaming_control.reason`,
39+
StreamingControlRawToken: `${customPrefix}.streaming_control.raw_token`,
40+
StreamingControlTokenLength: `${customPrefix}.streaming_control.token_length`,
41+
StreamingControlTokenType: `${customPrefix}.streaming_control.token_type`,
42+
StreamingControlTurnId: `${customPrefix}.streaming_control.turn_id`,
2943
TTSSegmentId: `${customPrefix}.tts.segment_id`,
3044
TTSText: `${customPrefix}.tts.text`,
3145
TTSChunkReason: `${customPrefix}.tts.chunk_reason`,
@@ -38,8 +52,26 @@ export const IOEvents = {
3852
// Non-standard
3953
LLMFirstToken: `${customPrefix}.llm.first_token`,
4054
ASRSentenceEnd: `${customPrefix}.asr.sentence_end`,
55+
StreamingControlHandlerEnd: `${customPrefix}.streaming_control.handler_end`,
56+
StreamingControlHandlerError: `${customPrefix}.streaming_control.handler_error`,
57+
StreamingControlHandlerStart: `${customPrefix}.streaming_control.handler_start`,
58+
StreamingControlParsed: `${customPrefix}.streaming_control.parsed`,
59+
StreamingControlRejected: `${customPrefix}.streaming_control.rejected`,
60+
StreamingControlSignalHandlerError: `${customPrefix}.streaming_control.signal_handler_error`,
4161
} as const
4262

63+
/**
64+
* Event captured inside an IO tracing span.
65+
*/
66+
export interface IOSpanEvent {
67+
/** OTel event name. */
68+
name: string
69+
/** Event timestamp in milliseconds. */
70+
timeTs: number
71+
/** Event attributes normalized for the devtools UI. */
72+
meta: Record<string, unknown>
73+
}
74+
4375
export interface IOSpan {
4476
id: string
4577
traceId: string
@@ -51,6 +83,8 @@ export interface IOSpan {
5183
subsystem: IOSubsystem
5284
name: string
5385
meta: Record<string, any>
86+
/** OTel events attached to the span. */
87+
events?: IOSpanEvent[]
5488
}
5589

5690
export interface IOTurn {

packages/stage-ui/src/stores/devtools/io-tracer.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ const MAX_TURNS = 50
1414
function attrsToMeta(attrs: Attributes): Record<string, any> {
1515
const meta: Record<string, any> = {}
1616
for (const [key, value] of Object.entries(attrs)) {
17-
const shortKey = key.includes('.') ? key.split('.').at(-1)! : key
17+
const shortKey = key === IOAttributes.TooltipKeys
18+
? 'tooltipKeys'
19+
: key.includes('.')
20+
? key.split('.').at(-1)!
21+
: key
1822
meta[shortKey] = value
1923
}
2024
return meta
@@ -124,6 +128,11 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => {
124128

125129
const turn = getOrCreateTurn()
126130
const meta = attrsToMeta(readable.attributes)
131+
const events = readable.events.map(event => ({
132+
name: event.name,
133+
timeTs: hrTimeToMilliseconds(event.time),
134+
meta: attrsToMeta(event.attributes ?? {}),
135+
}))
127136

128137
for (const event of readable.events) {
129138
const eventAttrs = event.attributes ?? {}
@@ -153,6 +162,7 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => {
153162
startTs: startMs,
154163
endTs: endMs,
155164
meta,
165+
events,
156166
}
157167

158168
turn.spans.push(ioSpan)

0 commit comments

Comments
 (0)