refactor(stage-tamagotchi): persist local IO traces (#2774)

## What users can do

- Turn on **Record IO traces automatically** in Settings > System >
Developer in Tamagotchi.
- Let a coding agent read the saved files to see which step was slow or
failed.

## How it works

- The main window sends each ended IO span to the main process over
Eventa.
- The main process appends one JSON line per span to
`<userData>/io-traces/<start time>.jsonl`. A new file starts each time
recording starts.
- Files older than seven days are removed when recording starts.
- The setting is saved and restored at launch.
- The IO Tracer window is unchanged. `use-io-tracer.ts` now has a
subscriber set next to the existing panel callback, so both receive
spans.
- A new skill, `analyze-io-traces`, tells agents where the files are and
gives `jq` examples.

## Why this shape

The goal is to let an agent read local traces. An agent reads files
well, so this needs no server, database, token, or CLI.

This replaces #2717, which stored traces behind an OTLP server and
DuckDB. That design was larger than the goal needs.

## Verification

- `pnpm -F @proj-airi/stage-tamagotchi exec vitest run
src/main/services/airi/io-trace-recording/index.test.ts`: 2 tests
passed, using the real file system.
- `pnpm -F @proj-airi/stage-ui exec vitest run --project browser
src/composables/use-io-tracer.browser.test.ts`: 1 test passed.
- `typecheck` passed for `@proj-airi/stage-tamagotchi` and
`@proj-airi/stage-ui`.
- ESLint on the changed files: 0 errors.
- Not tested: a real Electron run of the toggle, IPC, and file writes.

## Limits

- Spans are saved when they end. A call that never ends is not in the
file, so live view is not included.
- Only the main window forwards spans.
- Spans hold timing, counts, and status. They do not hold prompts or
replies.
- Web (`stage-web`) is not supported, because a browser cannot write
local files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RainbowBird
2026-10-03 04:27:55 +08:00
committed by GitHub
co-authored by autofix-ci[bot] Claude Sonnet 5.5
parent 146c04294c
commit a4da7edd73
20 changed files with 444 additions and 7 deletions
+45
View File
@@ -0,0 +1,45 @@
---
name: analyze-io-traces
description: Read and analyze AIRI IO traces that Tamagotchi saved to local files. Use when the user asks why a chat, speech, or audio turn was slow, failed, or behaved wrongly, and recording is enabled in Settings > System > Developer.
---
# Analyze IO traces
Tamagotchi saves each ended IO span as one JSON line when **Record IO traces automatically** is on. The setting is under Settings > System > Developer.
## Find the files
Files are named `<start time>.jsonl`. A new file starts each time recording starts. Files older than seven days are deleted.
```sh
ls -t "$HOME/Library/Application Support/ai.moeru.airi/io-traces"/*.jsonl | head -3
ls -t "$HOME/Library/Application Support/@proj-airi/stage-tamagotchi/io-traces"/*.jsonl | head -3
```
The first path is the packaged app on macOS. The second is `pnpm dev` on macOS. If `APP_USER_DATA_PATH` is set, use `$APP_USER_DATA_PATH/io-traces`. If recording is off or nothing ran yet, the folder does not exist. Ask the user to turn it on and reproduce the problem.
## Record shape
Each line is one span with `traceId`, `spanId`, `parentSpanId`, `name`, `startTimeNano`, `endTimeNano`, `status.code` (2 means error), `attributes`, and `events`. Time values are decimal strings in nanoseconds. A trace is one interaction turn. Subsystems are in `attributes["ai.moeru.airi.io.subsystem"]`.
## Common queries
Set `F` to the newest file first.
```sh
# Spans by name, slowest first, in milliseconds
jq -r '[.name, ((.endTimeNano|tonumber)-(.startTimeNano|tonumber))/1e6] | @tsv' "$F" | sort -t$'\t' -k2 -nr | head -20
# Spans with an error status
jq -c 'select(.status.code == 2) | {name, traceId, message: .status.message}' "$F"
# All spans of one trace in start order
jq -c 'select(.traceId == "TRACE_ID")' "$F" | jq -s 'sort_by(.startTimeNano|tonumber)'
# Time to first token for each LLM span
jq -c 'select(.events | any(.name | endswith("llm.first_token"))) | {traceId, events}' "$F"
```
## Limits
Spans are saved only after they end. A call that never ends is not in the file. Spans record timing and counts, not prompts or replies. Say so when the cause needs content.
+1
View File
@@ -150,6 +150,7 @@ Skills are in `.agents/skills/<name>/SKILL.md`. Read the listed file before you
| Write or change a string that users see, or a glossary term | [`packages/i18n/AGENTS.md`](packages/i18n/AGENTS.md) |
| Work under `server/` | [`server/AGENTS.md`](server/AGENTS.md). For `server/apps/api`, also read [`server/apps/api/AGENTS.md`](server/apps/api/AGENTS.md). |
| Write documentation, code comments, commit messages, or PR and issue text | [`simple-english`](.agents/skills/simple-english/SKILL.md) |
| Analyze why a chat, speech, or audio turn was slow or failed, using saved IO traces | [`analyze-io-traces`](.agents/skills/analyze-io-traces/SKILL.md) |
| Create or prepare a pull request | [`create-pr`](.agents/skills/create-pr/SKILL.md) |
| Upload a local file through a file input or a file chooser in a web or Electron app | [`use-agent-browser-with-input-file`](.agents/skills/use-agent-browser-with-input-file/SKILL.md) and [`agent-browser`](.agents/skills/agent-browser/SKILL.md). For Electron, also read [`agent-browser-electron`](.agents/skills/agent-browser-electron/SKILL.md). |
| Test Live2D, VRM, or MMD import and rendering in stage-web, stage-tamagotchi, or stage-pocket | [`use-agent-browser-for-airi`](.agents/skills/use-agent-browser-for-airi/SKILL.md) |
@@ -0,0 +1,12 @@
import { boolean, object } from 'valibot'
import { createConfig } from '../libs/electron/persistence'
export function createIOTraceRecordingConfig() {
const config = createConfig('io-trace-recording', 'options.json', object({ enabled: boolean() }), {
default: { enabled: false },
autoHeal: true,
})
config.setup()
return config
}
+27 -3
View File
@@ -4,7 +4,7 @@ import type { FileLoggerHandle } from './app/file-logger'
import process, { env, platform } from 'node:process'
import { dirname, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import messages from '@proj-airi/i18n/locales'
@@ -26,6 +26,7 @@ import { resolveIsWayland } from './app/ozone'
import { installSingleInstanceGuard } from './app/single-instance'
import { createArtistryConfig } from './configs/artistry'
import { createGlobalAppConfig } from './configs/global'
import { createIOTraceRecordingConfig } from './configs/io-trace-recording'
import { emitAppBeforeQuit, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
import { getElectronMainDirname, setElectronMainDirname } from './libs/electron/location'
import { createI18n } from './libs/i18n'
@@ -35,6 +36,7 @@ import { setupServerChannel } from './services/airi/channel-server'
import { setupComputerUse } from './services/airi/computer-use'
import { setupGodotStageManager } from './services/airi/godot-stage'
import { setupBuiltInServer } from './services/airi/http-server'
import { IOTraceRecordingService } from './services/airi/io-trace-recording'
import { setupMcpStdioManager } from './services/airi/mcp-servers'
import { setupExtensionHost } from './services/airi/plugins'
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
@@ -178,6 +180,7 @@ app.whenReady().then(async () => {
const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig())
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
const ioTraceRecordingConfig = injeca.provide('configs:io-trace-recording', () => createIOTraceRecordingConfig())
const electronApp = injeca.provide('host:electron:app', () => app)
const autoUpdater = injeca.provide('services:auto-updater', {
dependsOn: { appConfig },
@@ -228,6 +231,27 @@ app.whenReady().then(async () => {
build: async () => setupMcpStdioManager(),
})
const ioTraceRecording = injeca.provide('services:io-trace-recording', {
dependsOn: { config: ioTraceRecordingConfig, lifecycle },
build: ({ dependsOn }) => {
const service = new IOTraceRecordingService({
directory: join(app.getPath('userData'), 'io-traces'),
getStoredEnabled: () => dependsOn.config.get()?.enabled ?? false,
setStoredEnabled: enabled => dependsOn.config.update({ enabled }),
})
dependsOn.lifecycle.appHooks.onStart(async () => {
try {
await service.restore()
}
catch (error) {
log.withError(error).error('Failed to restore IO trace recording')
}
})
dependsOn.lifecycle.appHooks.onStop(() => service.dispose())
return service
},
})
const widgetsManager = injeca.provide('windows:widgets', {
dependsOn: { serverChannel, i18n },
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
@@ -286,7 +310,7 @@ app.whenReady().then(async () => {
})
const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow },
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow, ioTraceRecording },
build: async ({ dependsOn }) =>
setupSettingsWindowReusableFunc({
...dependsOn,
@@ -304,7 +328,7 @@ app.whenReady().then(async () => {
})
const mainWindow = injeca.provide('windows:main', {
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription, appleVision },
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription, appleVision, ioTraceRecording },
build: async ({ dependsOn }) => setupMainWindow({
...dependsOn,
onWindowCreated: (window) => {
@@ -0,0 +1,84 @@
import type { SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
import { mkdtemp, readdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { IOTraceRecordingService } from '.'
const directories: string[] = []
function span(spanId: string): SerializedIOSpan {
return {
attributes: {},
ended: true,
endTimeNano: '3000000',
events: [],
kind: 0,
name: 'llm.stream',
parentSpanId: '',
spanId,
startTimeNano: '1000000',
status: { code: 1, message: '' },
traceId: 'trace-1',
}
}
async function createService(initiallyEnabled = false) {
const root = await mkdtemp(join(tmpdir(), 'airi-io-trace-'))
directories.push(root)
const directory = join(root, 'io-traces')
let stored = initiallyEnabled
const service = new IOTraceRecordingService({
directory,
getStoredEnabled: () => stored,
setStoredEnabled: (enabled) => {
stored = enabled
},
})
return { directory, service, stored: () => stored }
}
afterEach(async () => {
await Promise.all(directories.splice(0).map(path => rm(path, { force: true, recursive: true })))
})
describe('iOTraceRecordingService', () => {
it('appends ended spans as JSON Lines only while recording and keeps the setting on dispose', async () => {
const { directory, service, stored } = await createService()
await service.recordSpan(span('before'))
const started = await service.setEnabled(true)
await Promise.all([service.recordSpan(span('first')), service.recordSpan(span('second'))])
await service.setEnabled(false)
await service.recordSpan(span('after'))
expect(started.filePath).toBeDefined()
const lines = (await readFile(started.filePath!, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
expect(lines.map(line => line.spanId)).toEqual(['first', 'second'])
expect(await readdir(directory)).toHaveLength(1)
expect(stored()).toBe(false)
await service.setEnabled(true)
await service.dispose()
expect(stored()).toBe(true)
expect(service.getState().enabled).toBe(false)
})
it('restores the stored setting and removes files older than seven days', async () => {
const { directory, service } = await createService()
await service.setEnabled(true)
await service.dispose()
const stale = join(directory, 'stale.jsonl')
await writeFile(stale, '{}\n')
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
await utimes(stale, old, old)
await service.restore()
expect(service.getState().enabled).toBe(true)
expect(await readdir(directory)).not.toContain('stale.jsonl')
})
})
@@ -0,0 +1,100 @@
import type { IOTraceRecordingState, SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
import { appendFile, mkdir, readdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { errorMessageFrom } from '@moeru/std'
import { Mutex } from 'async-mutex'
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
interface IOTraceRecordingServiceOptions {
directory: string
getStoredEnabled: () => boolean
setStoredEnabled: (enabled: boolean) => void
}
export class IOTraceRecordingService {
private readonly listeners = new Set<(state: IOTraceRecordingState) => void>()
private readonly mutex = new Mutex()
private filePath: string | undefined
private error: string | undefined
constructor(private readonly options: IOTraceRecordingServiceOptions) {}
getState(): IOTraceRecordingState {
return {
directory: this.options.directory,
enabled: this.filePath !== undefined,
error: this.error,
filePath: this.filePath,
}
}
onStateChange(listener: (state: IOTraceRecordingState) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
async restore(): Promise<void> {
if (this.options.getStoredEnabled())
await this.setEnabled(true, false)
}
async setEnabled(enabled: boolean, persist = true): Promise<IOTraceRecordingState> {
return await this.mutex.runExclusive(async () => {
if (enabled)
await this.start()
else
this.filePath = undefined
if (persist)
this.options.setStoredEnabled(enabled)
const state = this.getState()
for (const listener of this.listeners)
listener(state)
return state
})
}
async recordSpan(span: SerializedIOSpan): Promise<void> {
await this.mutex.runExclusive(async () => {
if (!this.filePath)
return
await appendFile(this.filePath, `${JSON.stringify(span)}\n`, { mode: 0o600 })
})
}
async dispose(): Promise<void> {
await this.mutex.runExclusive(() => {
this.filePath = undefined
})
}
private async start(): Promise<void> {
if (this.filePath)
return
try {
await mkdir(this.options.directory, { mode: 0o700, recursive: true })
await this.pruneExpiredFiles()
this.filePath = join(this.options.directory, `${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`)
this.error = undefined
}
catch (error) {
this.error = errorMessageFrom(error) ?? 'Failed to start IO trace recording.'
throw error
}
}
private async pruneExpiredFiles(): Promise<void> {
const cutoff = Date.now() - RETENTION_MS
for (const name of await readdir(this.options.directory)) {
if (!name.endsWith('.jsonl'))
continue
const path = join(this.options.directory, name)
if ((await stat(path)).mtimeMs < cutoff)
await rm(path)
}
}
}
@@ -0,0 +1,28 @@
import type { InvocableEventContext } from '@moeru/eventa'
import type { IOTraceRecordingService } from '.'
import { defineInvokeHandler } from '@moeru/eventa'
import {
ioTraceRecordingChanged,
ioTraceRecordingGet,
ioTraceRecordingRecordSpan,
ioTraceRecordingSetEnabled,
} from '../../../../shared/eventa'
export function registerIOTraceRecording(
context: InvocableEventContext<any, any>,
service: IOTraceRecordingService,
): () => void {
const disposers = [
defineInvokeHandler(context, ioTraceRecordingGet, () => service.getState()),
defineInvokeHandler(context, ioTraceRecordingSetEnabled, ({ enabled }) => service.setEnabled(enabled)),
defineInvokeHandler(context, ioTraceRecordingRecordSpan, span => service.recordSpan(span)),
service.onStateChange((state) => {
void context.emit(ioTraceRecordingChanged, state)
}),
]
return () => disposers.forEach(dispose => dispose())
}
@@ -4,6 +4,7 @@ import type { InferOutput } from 'valibot'
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import type { GodotStageManager } from '../../services/airi/godot-stage'
import type { IOTraceRecordingService } from '../../services/airi/io-trace-recording'
import type { McpStdioManager } from '../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../services/electron/auto-updater'
import type { ChatWindowManager } from '../chat'
@@ -61,6 +62,7 @@ export async function setupMainWindow(params: {
mcpStdioManager: McpStdioManager
i18n: I18n
onboardingWindowManager: OnboardingWindowManager
ioTraceRecording: IOTraceRecordingService
}) {
const {
setup: setupConfig,
@@ -185,6 +187,7 @@ export async function setupMainWindow(params: {
mcpStdioManager: params.mcpStdioManager,
i18n: params.i18n,
onboardingWindowManager: params.onboardingWindowManager,
ioTraceRecording: params.ioTraceRecording,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', {
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n'
import type { ServerChannel } from '../../../services/airi/channel-server'
import type { GodotStageManager } from '../../../services/airi/godot-stage'
import type { IOTraceRecordingService } from '../../../services/airi/io-trace-recording'
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
import type { ChatWindowManager } from '../../chat'
@@ -28,6 +29,7 @@ import {
} from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createGodotStageService } from '../../../services/airi/godot-stage'
import { registerIOTraceRecording } from '../../../services/airi/io-trace-recording/register'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createOnboardingService } from '../../../services/airi/onboarding'
import { createWidgetsService } from '../../../services/airi/widgets'
@@ -48,6 +50,7 @@ export async function setupMainWindowElectronInvokes(params: {
mcpStdioManager: McpStdioManager
i18n: I18n
onboardingWindowManager: OnboardingWindowManager
ioTraceRecording: IOTraceRecordingService
}) {
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
@@ -63,6 +66,8 @@ export async function setupMainWindowElectronInvokes(params: {
createGodotStageService({ context, manager: params.godotStageManager, window: params.window })
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
createAuthService({ context, window: params.window })
const stopIOTraceRecording = registerIOTraceRecording(context, params.ioTraceRecording)
params.window.once('closed', stopIOTraceRecording)
defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window))
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
@@ -1,6 +1,7 @@
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import type { GodotStageManager } from '../../services/airi/godot-stage'
import type { IOTraceRecordingService } from '../../services/airi/io-trace-recording'
import type { McpStdioManager } from '../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../services/electron/auto-updater'
import type { GlobalShortcutService } from '../../services/electron/global-shortcut'
@@ -38,6 +39,7 @@ export function setupSettingsWindowReusableFunc(params: {
i18n: I18n
globalShortcut: GlobalShortcutService
spotlightWindow: SpotlightWindowManager
ioTraceRecording: IOTraceRecordingService
}): SettingsWindowManager {
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
const defaultRoute = '/settings'
@@ -76,6 +78,7 @@ export function setupSettingsWindowReusableFunc(params: {
i18n: params.i18n,
globalShortcut: params.globalShortcut,
spotlightWindow: params.spotlightWindow,
ioTraceRecording: params.ioTraceRecording,
})
await load(window, withHashRoute(rendererBase, currentRoute, {
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n'
import type { ServerChannel } from '../../../services/airi/channel-server'
import type { GodotStageManager } from '../../../services/airi/godot-stage'
import type { IOTraceRecordingService } from '../../../services/airi/io-trace-recording'
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
import type { GlobalShortcutService } from '../../../services/electron/global-shortcut'
@@ -23,6 +24,7 @@ import {
} from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createGodotStageService } from '../../../services/airi/godot-stage'
import { registerIOTraceRecording } from '../../../services/airi/io-trace-recording/register'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
@@ -41,6 +43,7 @@ export async function setupSettingsWindowInvokes(params: {
i18n: I18n
globalShortcut: GlobalShortcutService
spotlightWindow: SpotlightWindowManager
ioTraceRecording: IOTraceRecordingService
}) {
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
@@ -56,6 +59,8 @@ export async function setupSettingsWindowInvokes(params: {
createMcpServersService({ context, manager: params.mcpStdioManager })
createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow })
createAuthService({ context, window: params.settingsWindow })
const stopIOTraceRecording = registerIOTraceRecording(context, params.ioTraceRecording)
params.settingsWindow.once('closed', stopIOTraceRecording)
// Register the global shortcut service for the settings window.
params.globalShortcut.registerWindow({ context, window: params.settingsWindow })
@@ -64,6 +64,7 @@ import {
} from '../shared/eventa/plugin/host'
import { electronPluginToolsChanged } from '../shared/eventa/plugin/tools'
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
import { initializeIOTraceRecordingBridge } from './bridges/io-trace-recording'
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
import { useLanguage } from './composables/use-language'
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
@@ -94,6 +95,9 @@ const isSpotlightWindow = initialRoutePath === '/spotlight'
// The floating chat resizes from its own grip, which keeps the corner beside the character in place.
const isFloatingChatWindow = initialRoutePath === '/chat-floating'
const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/')
const stopIOTraceRecordingBridge = initialRoutePath === '/'
? initializeIOTraceRecordingBridge(context.value)
: undefined
async function refreshPluginRuntimeTools() {
try {
@@ -369,6 +373,7 @@ watch(themeColorsHueDynamic, () => {
}, { immediate: true })
onUnmounted(() => {
stopIOTraceRecordingBridge?.()
stopLeadershipListener?.()
chatStore.dispose()
fullStageRuntime?.dispose()
@@ -0,0 +1,37 @@
import type { EventContext } from '@moeru/eventa'
import { useLogg } from '@guiiai/logg'
import { defineInvoke } from '@moeru/eventa'
import { serializeSpan, subscribeIOSpan } from '@proj-airi/stage-ui/composables/use-io-tracer'
import {
ioTraceRecordingChanged,
ioTraceRecordingGet,
ioTraceRecordingRecordSpan,
} from '../../shared/eventa'
const log = useLogg('electron:io-trace-recording').useGlobalConfig()
export function initializeIOTraceRecordingBridge(context: EventContext<any, any>): () => void {
const recordSpan = defineInvoke(context, ioTraceRecordingRecordSpan)
let enabled = false
const stopStateListener = context.on(ioTraceRecordingChanged, (event) => {
enabled = event.body?.enabled ?? false
})
void defineInvoke(context, ioTraceRecordingGet)()
.then((state) => {
enabled = state?.enabled ?? false
})
.catch(error => log.withError(error).error('Failed to load IO trace recording state'))
const stopSpanListener = subscribeIOSpan((span) => {
if (enabled && span.ended)
void recordSpan(serializeSpan(span)).catch(error => log.withError(error).error('Failed to record IO trace span'))
})
return () => {
stopStateListener()
stopSpanListener()
}
}
@@ -1,16 +1,41 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import type { IOTraceRecordingState } from '@proj-airi/stage-shared/types/io-trace'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { ButtonBar, CheckBar, IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { electronOpenDevtoolsWindow, electronOpenEditor, electronOpenMainDevtools } from '../../../../shared/eventa'
import { electronOpenDevtoolsWindow, electronOpenEditor, electronOpenMainDevtools, ioTraceRecordingChanged, ioTraceRecordingGet, ioTraceRecordingSetEnabled } from '../../../../shared/eventa'
const { t } = useI18n()
const settings = useSettings()
const router = useRouter()
const eventaContext = useElectronEventaContext()
const getIOTraceRecording = useElectronEventaInvoke(ioTraceRecordingGet)
const setIOTraceRecordingEnabled = useElectronEventaInvoke(ioTraceRecordingSetEnabled)
const ioTraceRecording = ref<IOTraceRecordingState>()
const ioTraceRecordingBusy = ref(false)
const stopIOTraceRecordingListener = eventaContext.value.on(ioTraceRecordingChanged, (event) => {
ioTraceRecording.value = event.body
})
onMounted(async () => {
ioTraceRecording.value = await getIOTraceRecording()
})
onUnmounted(stopIOTraceRecordingListener)
async function updateIOTraceRecording(enabled: boolean | undefined) {
ioTraceRecordingBusy.value = true
try {
ioTraceRecording.value = await setIOTraceRecordingEnabled({ enabled: enabled === true })
}
finally {
ioTraceRecordingBusy.value = false
}
}
const menu = computed(() => [
{
@@ -157,6 +182,17 @@ const openEditor = useElectronEventaInvoke(electronOpenEditor)
>
{{ t('tamagotchi.settings.devtools.pages.io-tracer.title') }}
</ButtonBar>
<CheckBar
:model-value="ioTraceRecording?.enabled ?? false"
:disabled="ioTraceRecording === undefined || ioTraceRecordingBusy"
:class="['mb-2']"
icon-on="i-solar:record-circle-bold-duotone"
icon-off="i-solar:record-circle-line-duotone"
text="tamagotchi.settings.devtools.pages.io-tracer.recording.title"
description="tamagotchi.settings.devtools.pages.io-tracer.recording.description"
transition="all ease-in-out duration-250"
@update:model-value="updateIOTraceRecording"
/>
<ButtonBar
:class="['mb-2', 'transition-all duration-250 ease-in-out']"
icon="i-solar:chart-square-bold-duotone"
@@ -16,6 +16,7 @@ import type {
StageViewSnapshotPayload,
} from '@proj-airi/stage-shared/godot-stage'
import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
import type { IOTraceRecordingState, SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
import type {
ThreeHitTestReadTracePayload,
ThreeSceneRenderInfoTracePayload,
@@ -41,6 +42,11 @@ export const electronOpenSettings = defineInvokeEventa<void, { route?: string }>
export const electronSettingsNavigate = defineEventa<{ route: string }>('eventa:event:electron:windows:settings:navigate')
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')
export const ioTraceRecordingChanged = defineEventa<IOTraceRecordingState>('eventa:event:electron:io-trace-recording:changed')
export const ioTraceRecordingGet = defineInvokeEventa<IOTraceRecordingState>('eventa:invoke:electron:io-trace-recording:get')
export const ioTraceRecordingSetEnabled = defineInvokeEventa<IOTraceRecordingState, { enabled: boolean }>('eventa:invoke:electron:io-trace-recording:set-enabled')
export const ioTraceRecordingRecordSpan = defineInvokeEventa<void, SerializedIOSpan>('eventa:invoke:electron:io-trace-recording:record-span')
/**
* Which window the Controls Island chat button opens.
*
@@ -533,6 +533,9 @@ devtools:
description: Stress markdown parsing and rendering with heavy chat payloads
io-tracer:
title: IO Tracer
recording:
title: Record IO traces automatically
description: Save ended IO spans as JSON Lines in the app data folder so coding agents can analyze them. Files older than seven days are removed.
widgets-calling:
title: Widget Calling
@@ -148,6 +148,10 @@ devtools:
markdown-stress:
title: Markdown 压力测试
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
io-tracer:
recording:
title: 自动记录 IO Trace
description: 把已结束的 IO span 以 JSON Lines 保存在应用数据目录,供 Agent 分析。七天前的文件会被自动删除。
widgets-calling:
title: 小组件调用
screen-capture:
@@ -15,3 +15,11 @@ export interface SerializedIOSpan {
status: { code: number, message: string }
ended: boolean
}
/** Runtime state for Electron-owned IO trace recording. */
export interface IOTraceRecordingState {
directory: string
enabled: boolean
error?: string
filePath?: string
}
@@ -0,0 +1,20 @@
import { afterEach, expect, it } from 'vitest'
import { onIOSpan, serializeSpan, startSpan, subscribeIOSpan } from './use-io-tracer'
afterEach(() => onIOSpan(undefined))
it('delivers one ended span to the panel callback and every subscriber', () => {
const panelNames: string[] = []
const recordedSpans: ReturnType<typeof serializeSpan>[] = []
onIOSpan(span => panelNames.push(span.name))
const unsubscribe = subscribeIOSpan(span => recordedSpans.push(serializeSpan(span)))
startSpan('before-unsubscribe').end()
unsubscribe()
startSpan('after-unsubscribe').end()
expect(panelNames).toEqual(['before-unsubscribe', 'after-unsubscribe'])
expect(recordedSpans).toHaveLength(1)
expect(recordedSpans[0]).toMatchObject({ ended: true, name: 'before-unsubscribe' })
})
@@ -55,7 +55,7 @@ export function deserializeSpan(s: SerializedIOSpan): ReadableSpan {
}
}
function serializeSpan(span: ReadableSpan): SerializedIOSpan {
export function serializeSpan(span: ReadableSpan): SerializedIOSpan {
const ctx = span.spanContext()
const parentCtx = span.parentSpanContext
return {
@@ -79,6 +79,7 @@ function serializeSpan(span: ReadableSpan): SerializedIOSpan {
let provider: BasicTracerProvider | undefined
let spanCallback: SpanCallback | undefined
const spanSubscribers = new Set<SpanCallback>()
let broadcastChannel: BroadcastChannel | undefined
export function createCallbackSpanExporter(): SpanExporter {
@@ -86,6 +87,8 @@ export function createCallbackSpanExporter(): SpanExporter {
export: (spans, resultCallback) => {
for (const span of spans) {
spanCallback?.(span)
for (const subscriber of spanSubscribers)
subscriber(span)
broadcastChannel?.postMessage({
span: serializeSpan(span),
@@ -118,6 +121,11 @@ export function initIOTracer() {
trace.setGlobalTracerProvider(provider)
}
export function subscribeIOSpan(subscriber: SpanCallback): () => void {
spanSubscribers.add(subscriber)
return () => spanSubscribers.delete(subscriber)
}
export function onIOSpan(cb: SpanCallback | undefined) {
spanCallback = cb
}