diff --git a/packages/stage-shared/src/perf/io-trace.ts b/packages/stage-shared/src/perf/io-trace.ts index f62edcbc3..22e2ebc24 100644 --- a/packages/stage-shared/src/perf/io-trace.ts +++ b/packages/stage-shared/src/perf/io-trace.ts @@ -15,25 +15,30 @@ export const IOSpanNames = { AudioPlayback: 'Audio playback', } as const -export const IOAttrs = { - Subsystem: 'io.subsystem', - ASRProvider: 'asr.provider', - ASRText: 'asr.text', - ASRAbort: 'asr.abort', - LLMModel: 'llm.model', - LLM_TTFT: 'llm.ttft_ms', - LLMTextLength: 'llm.text_length', - TTSSegmentId: 'tts.segment_id', - TTSText: 'tts.text', - TTSChunkReason: 'tts.chunk_reason', - TTSInterrupted: 'tts.interrupted', - TTSInterruptReason: 'tts.interrupt_reason', - TTSCanceled: 'tts.canceled', +const customPrefix = 'ai.moeru.airi.io' + +export const IOAttributes = { + GenAIRequestModel: 'gen_ai.request.model', + GenAIProviderName: 'gen_ai.provider.name', + + // Non-standard + Subsystem: `${customPrefix}.subsystem`, + LLM_TTFT: `${customPrefix}.llm.time_to_first_token`, + ASRText: `${customPrefix}.asr.text`, + ASRAbort: `${customPrefix}.asr.abort`, + LLMTextLength: `${customPrefix}.llm.text_length`, + TTSSegmentId: `${customPrefix}.tts.segment_id`, + TTSText: `${customPrefix}.tts.text`, + TTSChunkReason: `${customPrefix}.tts.chunk_reason`, + TTSInterrupted: `${customPrefix}.tts.interrupted`, + TTSInterruptReason: `${customPrefix}.tts.interrupt_reason`, + TTSCanceled: `${customPrefix}.tts.canceled`, } as const export const IOEvents = { - FirstToken: 'ai.moeru.airi.io.first_token', // Non-standard - SentenceEnd: 'ai.moeru.airi.io.sentence_end', // Non-standard + // Non-standard + LLMFirstToken: `${customPrefix}.llm.first_token`, + ASRSentenceEnd: `${customPrefix}.asr.sentence_end`, } as const export interface IOSpan { diff --git a/packages/stage-ui/src/composables/use-io-trace-bridge.ts b/packages/stage-ui/src/composables/use-io-trace-bridge.ts index c19e28355..dd7ce896a 100644 --- a/packages/stage-ui/src/composables/use-io-trace-bridge.ts +++ b/packages/stage-ui/src/composables/use-io-trace-bridge.ts @@ -1,7 +1,7 @@ import type { Span } from '@opentelemetry/api' import type { createSpeechPipeline } from '@proj-airi/pipelines-audio' -import { IOAttrs, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' +import { IOAttributes, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' import { onScopeDispose, watch } from 'vue' import { activeTurnSpan, startSpan } from './use-io-tracer' @@ -48,19 +48,19 @@ export function useIOTraceBridge(pipeline: ReturnType { const segSpan = segmentSpans.get(event.item.segmentId) const playbackSpan = startSpan(IOSpanNames.AudioPlayback, segSpan, { - [IOAttrs.Subsystem]: IOSubsystems.Playback, - [IOAttrs.TTSSegmentId]: event.item.segmentId, - [IOAttrs.TTSText]: event.item.text, + [IOAttributes.Subsystem]: IOSubsystems.Playback, + [IOAttributes.TTSSegmentId]: event.item.segmentId, + [IOAttributes.TTSText]: event.item.text, }) playbackSpans.set(event.item.segmentId, playbackSpan) })) @@ -96,8 +96,8 @@ export function useIOTraceBridge(pipeline: ReturnType { const playbackSpan = playbackSpans.get(event.item.segmentId) if (playbackSpan) { - playbackSpan.setAttribute(IOAttrs.TTSInterrupted, true) - playbackSpan.setAttribute(IOAttrs.TTSInterruptReason, event.reason) + playbackSpan.setAttribute(IOAttributes.TTSInterrupted, true) + playbackSpan.setAttribute(IOAttributes.TTSInterruptReason, event.reason) playbackSpan.end() playbackSpans.delete(event.item.segmentId) } @@ -112,7 +112,7 @@ export function useIOTraceBridge(pipeline: ReturnType { for (const [segmentId, span] of segmentSpans) { - span.setAttribute(IOAttrs.TTSCanceled, true) + span.setAttribute(IOAttributes.TTSCanceled, true) span.end() segmentSpans.delete(segmentId) } diff --git a/packages/stage-ui/src/stores/chat.ts b/packages/stage-ui/src/stores/chat.ts index 87f812622..5c0f8def2 100644 --- a/packages/stage-ui/src/stores/chat.ts +++ b/packages/stage-ui/src/stores/chat.ts @@ -5,7 +5,7 @@ import type { CommonContentPart, Message, ToolMessage } from '@xsai/shared-chat' import type { ChatAssistantMessage, ChatSlices, ChatStreamEventContext, StreamingAssistantMessage } from '../types/chat' import type { StreamEvent, StreamOptions } from './llm' -import { IOAttrs, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' +import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' import { createQueue } from '@proj-airi/stream-kit' import { nanoid } from 'nanoid' import { defineStore, storeToRefs } from 'pinia' @@ -368,8 +368,8 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { activeTurnSpan.value = startSpan(IOSpanNames.InteractionTurn) const llmSpan = startSpan(IOSpanNames.LLMInference, activeTurnSpan.value, { - [IOAttrs.Subsystem]: IOSubsystems.LLM, - [IOAttrs.LLMModel]: options.model, + [IOAttributes.Subsystem]: IOSubsystems.LLM, + [IOAttributes.GenAIRequestModel]: options.model, }) const llmRequestTs = performance.now() let llmFirstTokenEmitted = false @@ -410,8 +410,8 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { case 'text-delta': if (!llmFirstTokenEmitted) { llmFirstTokenEmitted = true - llmSpan.addEvent(IOEvents.FirstToken, { - [IOAttrs.LLM_TTFT]: performance.now() - llmRequestTs, + llmSpan.addEvent(IOEvents.LLMFirstToken, { + [IOAttributes.LLM_TTFT]: performance.now() - llmRequestTs, }) } fullText += event.text @@ -425,7 +425,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => { }, }) - llmSpan.setAttribute(IOAttrs.LLMTextLength, fullText.length) + llmSpan.setAttribute(IOAttributes.LLMTextLength, fullText.length) } finally { // TODO: Record errors on llmSpan diff --git a/packages/stage-ui/src/stores/devtools/io-tracer.ts b/packages/stage-ui/src/stores/devtools/io-tracer.ts index ebc7b2625..e8334bdd8 100644 --- a/packages/stage-ui/src/stores/devtools/io-tracer.ts +++ b/packages/stage-ui/src/stores/devtools/io-tracer.ts @@ -2,8 +2,8 @@ import type { Attributes } from '@opentelemetry/api' import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' import type { IOSpan, IOSubsystem, IOTurn } from '@proj-airi/stage-shared' -import { getTimeOrigin, hrTimeToMilliseconds, hrTimeToNanoseconds } from '@opentelemetry/core' -import { IOAttrs, IOEvents, IOSpanNames } from '@proj-airi/stage-shared' +import { hrTimeToMilliseconds, hrTimeToNanoseconds } from '@opentelemetry/core' +import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' import { defineStore } from 'pinia' import { computed, ref, triggerRef } from 'vue' @@ -25,16 +25,15 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { const isRecording = ref(false) const selectedSpanId = ref(null) const recordingStartTs = ref(0) - const revision = ref(0) + const rawSpanCount = ref(0) const turnsByTraceId = new Map() const rawSpans: ReadableSpan[] = [] - let unsubRemote: (() => void) | undefined + let unsubscribeRemote: (() => void) | undefined function notifyUpdate() { triggerRef(turns) - revision.value++ } const activeTurn = computed(() => { @@ -69,6 +68,7 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { function handleSpan(readable: ReadableSpan) { rawSpans.push(readable) + rawSpanCount.value++ const spanCtx = readable.spanContext() const traceId = spanCtx.traceId @@ -100,7 +100,7 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { const turn = getOrCreateTurn() if (endMs) turn.endTs = endMs - const text = readable.attributes[IOAttrs.ASRText] + const text = readable.attributes[IOAttributes.ASRText] if (typeof text === 'string') turn.inputText = text @@ -108,12 +108,12 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { return } - const subsystem = readable.attributes[IOAttrs.Subsystem] as IOSubsystem | undefined + const subsystem = readable.attributes[IOAttributes.Subsystem] as IOSubsystem | undefined if (!subsystem) { if (readable.name === IOSpanNames.TTSSegment) { const turn = getOrCreateTurn() - const text = readable.attributes[IOAttrs.TTSText] + const text = readable.attributes[IOAttributes.TTSText] if (typeof text === 'string' && !turn.outputText) turn.outputText = text } @@ -130,17 +130,17 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { const shortKey = key.includes('.') ? key.split('.').at(-1)! : key meta[shortKey] = value } - if (event.name === IOEvents.FirstToken) { + if (event.name === IOEvents.LLMFirstToken) { meta.firstTokenTs = hrTimeToMilliseconds(event.time) } } - if (subsystem === 'asr' && typeof readable.attributes[IOAttrs.ASRText] === 'string') - turn.inputText = (turn.inputText ?? '') + (readable.attributes[IOAttrs.ASRText] as string) - if (subsystem === 'llm' && typeof meta.text_length === 'number') + if (subsystem === IOSubsystems.ASR && typeof readable.attributes[IOAttributes.ASRText] === 'string') + turn.inputText = (turn.inputText ?? '') + (readable.attributes[IOAttributes.ASRText] as string) + if (subsystem === IOSubsystems.LLM && typeof meta.text_length === 'number') turn.outputText = `(${meta.text_length} chars)` - const segmentId = readable.attributes[IOAttrs.TTSSegmentId] + const segmentId = readable.attributes[IOAttributes.TTSSegmentId] const ioSpan: IOSpan = { id: spanId, @@ -148,7 +148,7 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { parentSpanId: readable.parentSpanContext?.spanId, ttsCorrelationId: typeof segmentId === 'string' ? segmentId : undefined, subsystem, - name: readable.name.split(':').at(-1) ?? readable.name, + name: readable.name, startTs: startMs, endTs: endMs, meta, @@ -164,8 +164,8 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { initIOTracer() onIOSpan(handleSpan) - unsubRemote = onRemoteIOSpan(handleSpan) - recordingStartTs.value = getTimeOrigin() + performance.now() + unsubscribeRemote = onRemoteIOSpan(handleSpan) + recordingStartTs.value = performance.timeOrigin + performance.now() isRecording.value = true console.info('[IOTracer] Recording started (OTel mode, local + remote)') @@ -179,30 +179,29 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { activeTurnSpan.value = undefined onIOSpan(undefined) - unsubRemote?.() - unsubRemote = undefined + unsubscribeRemote?.() + unsubscribeRemote = undefined isRecording.value = false - - console.info('[IOTracer] Recording stopped') } function clear() { turns.value = [] turnsByTraceId.clear() rawSpans.length = 0 + rawSpanCount.value = 0 selectedSpanId.value = null - recordingStartTs.value = getTimeOrigin() + performance.now() + recordingStartTs.value = performance.timeOrigin + performance.now() } function selectSpan(spanId: string | null) { selectedSpanId.value = spanId } - function exportOtlpJson() { + function exportOTLP() { if (rawSpans.length === 0) return - const spanJsons = rawSpans.map((span) => { + const spans = rawSpans.map((span) => { const ctx = span.spanContext() const parentCtx = span.parentSpanContext @@ -242,7 +241,7 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { }, scopeSpans: [{ scope: { name: 'io' }, - spans: spanJsons, + spans, }], }], } @@ -261,15 +260,14 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => { turns, activeTurn, isRecording, + rawSpanCount, recordingStartTs, - revision, selectedSpanId, selectedSpan, startRecording, stopRecording, clear, selectSpan, - exportOtlpJson, - rawSpanCount: computed(() => { revision.value; return rawSpans.length }), + exportOTLP, } }) diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 24a0acd2e..24d29b5f3 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -4,7 +4,7 @@ import type { WithUnknown } from '@xsai/shared' import type { StreamTranscriptionResult, StreamTranscriptionOptions as XSAIStreamTranscriptionOptions } from '@xsai/stream-transcription' import { errorMessageFrom, tryCatch } from '@moeru/std' -import { IOAttrs, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' +import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { generateTranscription } from '@xsai/generate-transcription' @@ -394,7 +394,7 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech return if (asrSpan) { - asrSpan.setAttribute(IOAttrs.ASRAbort, !!abort) + asrSpan.setAttribute(IOAttributes.ASRAbort, !!abort) asrSpan.end() asrSpan = undefined } @@ -507,8 +507,8 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech const turnSpan = startSpan(IOSpanNames.InteractionTurn) activeTurnSpan.value = turnSpan asrSpan = startSpan(IOSpanNames.SpeechRecognition, turnSpan, { - [IOAttrs.Subsystem]: IOSubsystems.ASR, - [IOAttrs.ASRProvider]: activeTranscriptionProvider.value ?? '', + [IOAttributes.Subsystem]: IOSubsystems.ASR, + [IOAttributes.GenAIRequestModel]: activeTranscriptionProvider.value ?? '', }) console.info('[Hearing Pipeline] transcribeForMediaStream called', { @@ -625,13 +625,13 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech onSentenceEnd: (delta) => { bumpIdle() // Bump idle timer on activity (only if enabled) if (asrSpan) - asrSpan.addEvent(IOEvents.SentenceEnd, { [IOAttrs.ASRText]: delta }) + asrSpan.addEvent(IOEvents.ASRSentenceEnd, { [IOAttributes.ASRText]: delta }) // Call the options callback options?.onSentenceEnd?.(delta) }, onSpeechEnd: (text) => { if (asrSpan) { - asrSpan.setAttribute(IOAttrs.ASRText, text) + asrSpan.setAttribute(IOAttributes.ASRText, text) asrSpan.end() asrSpan = undefined } @@ -793,7 +793,7 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech if (value) { fullText += value if (asrSpan) - asrSpan.addEvent(IOEvents.SentenceEnd, { [IOAttrs.ASRText]: value }) + asrSpan.addEvent(IOEvents.ASRSentenceEnd, { [IOAttributes.ASRText]: value }) // Use captured callbacks to avoid cross-session leakage sessionCallbacks.onSentenceEnd?.(value) } @@ -805,7 +805,7 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech } finally { if (asrSpan) { - asrSpan.setAttribute(IOAttrs.ASRText, fullText) + asrSpan.setAttribute(IOAttributes.ASRText, fullText) asrSpan.end() asrSpan = undefined }