feat(stage-pages,stage-shared,stage-ui): better OTel identifiers
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user