feat(stage-pages,stage-shared,stage-ui): better OTel identifiers

This commit is contained in:
Makito
2026-04-15 02:17:09 +09:00
parent 7d544d03a1
commit dbe85583bf
5 changed files with 74 additions and 71 deletions
+21 -16
View File
@@ -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 {
@@ -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<typeof createSpeechPipelin
if (!ttsSegmentSpan) {
hadSegments = true
ttsSegmentSpan = startSpan(IOSpanNames.TTSSegment, currentParent, {
[IOAttrs.Subsystem]: IOSubsystems.TTS,
[IOAttrs.TTSSegmentId]: request.segmentId,
[IOAttrs.TTSText]: request.text,
[IOAttrs.TTSChunkReason]: segmentReasons.get(request.segmentId) ?? '',
[IOAttributes.Subsystem]: IOSubsystems.TTS,
[IOAttributes.TTSSegmentId]: request.segmentId,
[IOAttributes.TTSText]: request.text,
[IOAttributes.TTSChunkReason]: segmentReasons.get(request.segmentId) ?? '',
})
segmentReasons.delete(request.segmentId)
segmentSpans.set(request.segmentId, ttsSegmentSpan)
}
const ttsSynthesisSpan = startSpan(IOSpanNames.TTSSynthesis, ttsSegmentSpan, {
[IOAttrs.Subsystem]: IOSubsystems.TTS,
[IOAttrs.TTSSegmentId]: request.segmentId,
[IOAttrs.TTSText]: request.text,
[IOAttributes.Subsystem]: IOSubsystems.TTS,
[IOAttributes.TTSSegmentId]: request.segmentId,
[IOAttributes.TTSText]: request.text,
})
synthesisSpans.set(request.segmentId, ttsSynthesisSpan)
}))
@@ -76,9 +76,9 @@ export function useIOTraceBridge(pipeline: ReturnType<typeof createSpeechPipelin
cleanupFns.push(pipeline.on('onPlaybackStart', (event) => {
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<typeof createSpeechPipelin
cleanupFns.push(pipeline.on('onPlaybackInterrupt', (event) => {
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<typeof createSpeechPipelin
cleanupFns.push(pipeline.on('onIntentCancel', () => {
for (const [segmentId, span] of segmentSpans) {
span.setAttribute(IOAttrs.TTSCanceled, true)
span.setAttribute(IOAttributes.TTSCanceled, true)
span.end()
segmentSpans.delete(segmentId)
}
+6 -6
View File
@@ -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
@@ -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<string | null>(null)
const recordingStartTs = ref(0)
const revision = ref(0)
const rawSpanCount = ref(0)
const turnsByTraceId = new Map<string, IOTurn>()
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,
}
})
@@ -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
}