diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index 37e2b07bc..1bc2d07ae 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -61,6 +61,9 @@ "@formkit/auto-animate": "^0.9.0", "@huggingface/transformers": "^3.8.1", "@moeru/eventa": "catalog:", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.6.0", + "@opentelemetry/sdk-trace-base": "^2.6.0", "@proj-airi/audio": "workspace:^", "@proj-airi/ccc": "workspace:^", "@proj-airi/chromatic": "^1.0.2", diff --git a/packages/stage-ui/src/composables/use-io-tracer.ts b/packages/stage-ui/src/composables/use-io-tracer.ts new file mode 100644 index 000000000..972859000 --- /dev/null +++ b/packages/stage-ui/src/composables/use-io-tracer.ts @@ -0,0 +1,161 @@ +import type { Span, SpanContext, SpanStatusCode } from '@opentelemetry/api' +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { TimedEvent } from '@opentelemetry/sdk-trace-base/build/esm/TimedEvent' + +import { context, trace } from '@opentelemetry/api' +import { hrTimeToNanoseconds } from '@opentelemetry/core' +import { BasicTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { shallowRef } from 'vue' + +export type { ReadableSpan } from '@opentelemetry/sdk-trace-base' + +const TRACER_NAME = 'ai.moeru.airi.io-tracer' +const BROADCAST_CHANNEL = 'io-tracer-channel' // TODO: Use simple BroadcastChannel for now + +export interface SerializedSpan { + traceId: string + spanId: string + parentSpanId: string + name: string + kind: number + startTimeNano: string + endTimeNano: string + attributes: Record + events: { name: string, timeNano: string, attributes: Record }[] + status: { code: number, message: string } + ended: boolean +} + +function serializeSpan(span: ReadableSpan): SerializedSpan { + const ctx = span.spanContext() + const parentCtx = span.parentSpanContext + return { + traceId: ctx.traceId, + spanId: ctx.spanId, + parentSpanId: parentCtx?.spanId ?? '', + name: span.name, + kind: span.kind, + startTimeNano: String(hrTimeToNanoseconds(span.startTime)), + endTimeNano: span.ended ? String(hrTimeToNanoseconds(span.endTime)) : '0', + attributes: { ...span.attributes }, + events: span.events.map((e: TimedEvent) => ({ + name: e.name, + timeNano: String(hrTimeToNanoseconds(e.time)), + attributes: { ...e.attributes }, + })), + status: { code: span.status.code, message: span.status.message ?? '' }, + ended: span.ended, + } +} + +export function deserializeSpan(s: SerializedSpan): ReadableSpan { + const nanoToHr = (nano: string): [number, number] => { + const n = Number(nano) + return [Math.floor(n / 1e9), n % 1e9] + } + const spanCtx: SpanContext = { + traceId: s.traceId, + spanId: s.spanId, + traceFlags: 1, + isRemote: false, + } + const parentCtx: SpanContext | undefined = s.parentSpanId + ? { traceId: s.traceId, spanId: s.parentSpanId, traceFlags: 1, isRemote: false } + : undefined + + return { + name: s.name, + kind: s.kind, + spanContext: () => spanCtx, + parentSpanContext: parentCtx, + startTime: nanoToHr(s.startTimeNano), + endTime: nanoToHr(s.endTimeNano), + status: { code: s.status.code as SpanStatusCode, message: s.status.message }, + attributes: s.attributes as Record, + links: [], + events: s.events.map(e => ({ + name: e.name, + time: nanoToHr(e.timeNano), + attributes: e.attributes as Record, + droppedAttributesCount: 0, + })), + duration: nanoToHr(String(Number(s.endTimeNano) - Number(s.startTimeNano))), + ended: s.ended, + resource: { attributes: {}, merge: () => ({ attributes: {} }) } as any, + instrumentationScope: { name: TRACER_NAME }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + } +} + +type SpanCallback = (span: ReadableSpan) => void + +let provider: BasicTracerProvider | undefined +let spanCallback: SpanCallback | undefined +let broadcastChannel: BroadcastChannel | undefined + +export function createCallbackSpanExporter(): SpanExporter { + return { + export: (spans, resultCallback) => { + for (const span of spans) { + spanCallback?.(span) + + broadcastChannel?.postMessage({ + type: 'span', + span: serializeSpan(span), + }) + } + resultCallback({ code: 0 /* SUCCESS */ }) + }, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), + } +} + +export function initIOTracer() { + if (!broadcastChannel) + broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL) + + if (provider) + return + + provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(createCallbackSpanExporter())], + }) + trace.setGlobalTracerProvider(provider) +} + +export function getIOTracer() { + if (provider) + return provider.getTracer(TRACER_NAME) + return trace.getTracer(TRACER_NAME) +} + +export function onIOSpan(cb: SpanCallback | undefined) { + spanCallback = cb +} + +export function onRemoteIOSpan(cb: SpanCallback): () => void { + const channel = new BroadcastChannel(BROADCAST_CHANNEL) + const handler = (event: MessageEvent) => { + if (event.data?.type === 'span') { + cb(deserializeSpan(event.data.span)) + } + } + channel.addEventListener('message', handler) + return () => { + channel.removeEventListener('message', handler) + channel.close() + } +} + +export function startSpan(name: string, parent?: Span, attrs?: Record): Span { + initIOTracer() + + const tracer = getIOTracer() + const ctx = parent ? trace.setSpan(context.active(), parent) : undefined + return tracer.startSpan(name, { attributes: attrs }, ctx) +} + +export const activeTurnSpan = shallowRef() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4454ba1ac..10a8722fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2942,6 +2942,15 @@ importers: '@moeru/eventa': specifier: 'catalog:' version: 1.0.0-beta.3(electron@40.8.5)(h3@2.0.1-rc.20(crossws@0.4.4(patch_hash=4d79ec736d10d2a81a9e2a31b067d43f0b6665122267981e652ab9923d165958)(srvx@0.11.15))) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/core': + specifier: ^2.6.0 + version: 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.6.0 + version: 2.6.1(@opentelemetry/api@1.9.1) '@proj-airi/audio': specifier: workspace:^ version: link:../audio