Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- [交接总览](linux-egui-handoff/README.md):阅读顺序、责任与复用起点。
- [01 Core 合同](linux-egui-handoff/01-core-contract.md)、[02 缺口登记](linux-egui-handoff/02-gap-register.md)(L01–L12)、[03 热键与窗口](linux-egui-handoff/03-hotkeys-and-windows.md)、[04 页面与领域](linux-egui-handoff/04-ui-domains.md)、[05 原生宿主与数据](linux-egui-handoff/05-native-host-and-data.md)、[06 事件与会话](linux-egui-handoff/06-events-and-sessions.md)、[07 验收](linux-egui-handoff/07-acceptance.md)。

- [Linux egui 日志约定](linux-egui-logging.md):级别语义、`OPENLESS_LOG` 开关、轮转策略与不记录的内容。
- [Linux 已知缺口](linux-egui-known-gaps.md):当前仍未闭环的行为与复现路径(如 G01 纠错卡)。

## 接口契约
Expand Down
137 changes: 137 additions & 0 deletions docs/linux-egui-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Linux egui 日志约定

状态:canonical;更新:2026-10-22。适用于 `openless-all/app/linux-egui`,以及被两个前端共用的 `crates/openless-core`。

日志是 Linux 用户唯一能交回来的证据:界面出错时他们既没有控制台,也不会跑 gdb。所以这份文件规定的不是文风,而是**「出问题时能不能从日志里读出结论」**。

## 1. 落在哪里

| 项 | 值 |
|---|---|
| 文件 | `$XDG_DATA_HOME/openless/logs/openless.log`(默认 `~/.local/share/OpenLess/logs/`) |
| 单文件上限 | 2 MiB |
| 历史份数 | `openless.log.1` … `openless.log.3`(合计 ≤ 8 MiB) |
| 轮转时机 | 进程启动时;**只有宿主轮转**,UI 窗口进程不轮转(否则宿主手里的句柄会指向被改名的 inode) |
| 终端 | 同时输出到 stderr,级别与文件一致 |
| 导出 | 设置 → 高级 → 调试 → 「导出错误日志」(`export_error_logs`,原子写) |
| 导出内容 | 当前 `openless.log` **加全部轮转历史**(`.3`→`.2`→`.1`→当前,时间正序),文件头写明版本、系统架构、生效级别与导出时间 |

两个进程(宿主 `openless`、界面 `openless --ui-client`)写同一个文件,排查「窗口进程怎么没了」时两端记录在同一处。

## 2. 级别

| 级别 | 用来记什么 | 判据 |
|---|---|---|
| `error` | **失败**。用户可见的能力没做到,或数据可能受损 | 「这条出现时,功能是坏的」 |
| `warn` | 降级但仍然工作:回退到默认设备、重试成功、可选能力缺失 | 「这条出现时,行为与预期不同」 |
| `info` | **状态变化**与低频存活证明(热键注册结果、模型加载、云同步相位、会话开始/结束) | 用户报「它不工作了」时,需要看到「上一次好的是哪一步」 |
| `debug` | 周期仪表:心跳、IPC 往返、逐帧/逐事件细节 | 打开后能让 `info` 的结论落到具体数值 |
| `trace` | 逐条协议/逐样本,只为复现一次具体 bug | 默认关闭,通常只用一次 |

两条硬规则:

1. **周期性的东西不许写 `info`。** 每 10 秒一条 = 8640 行/天,轮转后整份日志九成都是它,真正的错误被挤出去。这类日志写 `debug`,`info` 只留状态变化与低频存活证明。
- 参考实现:`log_pump_heartbeat`(`linux-egui/src/main.rs`)——状态变化立刻记 `info`,否则每 5 分钟一条,其余 `debug`。
2. **`error` 只写失败,不写进度。** `log::error!("[x] stage=device_id_ok")` 这类 tracer 会让每次启动看起来都像崩了,必须降到 `debug`。

## 3. 设置里也能改

设置 → 高级 → 调试 → **日志级别**(`info` / `debug` / `trace`):

- 选完写进 `<data_dir>/logs/level`,**重启 OpenLess 生效**(界面会提示)。
- 这是宿主本地设置,不进 Core 偏好,因此不上云、不影响 Tauri 与移动端。
- 环境变量优先于这里的设置:临时排查不该被上一次的界面选择挡住。

## 4. 不用重新编译也能改级别

```bash
# 全开
OPENLESS_LOG=debug openless

# 只把 Core 的依赖图噪声打开
OPENLESS_LOG=info,openless_core=debug openless

# 环境变量在 systemd 启动的情况下要写进服务定义
systemctl --user edit openless
# [Service]
# Environment=OPENLESS_LOG=debug
```

`OPENLESS_LOG` 优先,`RUST_LOG` 作为兼容名也认。语法:

```
<level>[,<module-prefix>=<level>]...
```

- 模块前缀按 `::` 边界匹配,**最长匹配优先**(`openless_core::asr=warn` 会盖住 `openless_core=debug`)。
- 任何无法识别的片段会让整条规则退回默认 `info`:宁可多记,不可静默少记。
- 默认 `info`。发布版不需要用户改任何东西就能看到状态变化。

启动时的第一行会写明生效的过滤与是否轮转过:

```
2026-10-22T23:40:12.423+08:00 [INFO] openless_linux_egui::logging: Linux egui logger ready: level=debug rotated=false path=/home/x/.local/share/OpenLess/logs/openless.log
```

## 5. 行格式

```
<本地时区 RFC3339> [<LEVEL>] [<target>] [<thread>] <文件:行> <消息>
```

- `target` 一直在(模块路径),定位「哪一层打的」不用猜。
- 线程名与源码位置只在 `debug` 及以上出现,避免 `info` 行变长。
- 时间戳是**用户本地时区**。历史版本写 UTC(结尾 `Z`),用户对着自己的操作时间找不到对应行。

消息里的固定前缀是约定,用方括号小写,便于 `grep '\[fcitx\]'`:

| 前缀 | 范围 |
|---|---|
| `[pump]` | 宿主循环存活 |
| `[hotkey]` | 热键注册与分发 |
| `[fcitx]` | fcitx5 addon 通信 |
| `[ui-host]` / `[ui-client]` | 两个进程各自的 IPC 侧 |
| `[dictation]` | 一次听写的生命周期(Core) |
| `[insert-diagnostics]` | 文本注入 |
| `[audio]` / `[audio-cue]` / `[audio-mute]` | 采集与提示音 |
| `[style]` | 风格包 |
| `[remote-input]` | 手机/远程输入 |
| `[capture]` | 截图与选区 |

## 6. 一次听写在日志里长什么样

```
[dictation] start session_id=… target=Dictation asr=dashscope insert=true translate=false silence_after_ms=None
[audio] capture started backend=pipewire device=… format=F32 rate=48000Hz channels=1
[transcript] partial emitted session=… ← 仅 debug
[asr] session_id=… provider=dashscope label=… elapsed_ms=1840 chars=42
[asr-diagnostics] session_id=… asr_chars=42 asr_hash=… corrected_chars=42 …
[polish-diagnostics] session_id=… stage=decision uses_polisher=true …
[insert-diagnostics] session_id=… attempt=1 operation=… chars=42 hash=…
[dictation] session … failed: code=… message=… ← 失败时(warn)
```

## 7. 定位一次听写

同一次听写里的多行用 `session_id` 串起来(`[dictation]`、`[insert-diagnostics]` 都带)。排查顺序:

```bash
grep 'session_id=<id>' ~/.local/share/OpenLess/logs/openless.log*
```

失败原因现在会同时给出 `code` 与 `message`(见 `mark_dictation_failed`)。历史版本只有 `code`,因此「录音停止 / 转写 / 润色」这一步光看日志分不出来。

## 8. 不要记录的东西

- 任何凭据:API key、token、password、私钥、keystore 内容。日志会被用户原样贴到 issue 里。
- 用户听写的正文。需要时可记长度或哈希(`[insert-diagnostics]` 就是这么做的:`chars=` / `hash=`)。
- 完整 `data_dir` 之外的路径通常没必要;`data_dir` 本身要记,因为「日志写在哪」是第一个问题。

`scripts/egui-logging-contract.test.mjs` 会拒绝形如 `api_key=` / `token=` / `password=` 的日志格式化参数,以及 `stage=` 级别的 `error`。

## 9. 改动这份约定时

- 新增周期日志 → 必须是 `debug`,或带节流。
- 新增 `info` → 必须能回答「状态变了什么」。
- 新增 `error` → 必须对应一次真实的失败分支。
- 跑 `node scripts/egui-logging-contract.test.mjs`。
11 changes: 11 additions & 0 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7809,6 +7809,17 @@ impl OpenLessBackend {
context
};

// 一次听写的起点。日志从「按下热键」到第一条识别结果之间原本是空白,
// 于是「按了没反应」与「录上了但没识别」在日志里长得一样。
log::info!(
"[dictation] start session_id={} target={:?} asr={} insert={} translate={} silence_after_ms={:?}",
session_id,
context.output_target,
context.asr.provider_type,
context.insertion.enabled,
context.polish.translation_active,
context.recording.silence_after_ms,
);
if let Err(error) = self
.deps
.host_actions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub(crate) fn build(
}
let origin = origin.origin().ascii_serialization();
let root = data_dir.join("encrypted-sync");
log::error!(
log::debug!(
"[e2ee-adapter] build start data_dir={} root={}",
data_dir.display(),
root.display()
Expand All @@ -55,15 +55,15 @@ pub(crate) fn build(
);
super::error("local_storage_unavailable")
})?;
log::error!("[e2ee-adapter] stage=gate_open");
log::debug!("[e2ee-adapter] stage=gate_open");
let gate =
crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(|error| {
log::error!("[e2ee-adapter] reopen sync write gate failed: {error:#}");
document_error(error)
})?;
log::error!("[e2ee-adapter] stage=device_id");
log::debug!("[e2ee-adapter] stage=device_id");
let device_id = load_device_id(&root)?;
log::error!(
log::debug!(
"[e2ee-adapter] stage=device_id_ok id_len={}",
device_id.len()
);
Expand All @@ -79,7 +79,7 @@ pub(crate) fn build(
device_id,
credentials.clone(),
);
log::error!("[e2ee-adapter] stage=store_new");
log::debug!("[e2ee-adapter] stage=store_new");
let credential_store_for_service = credentials.clone();
let store = Arc::new(
CoreSyncStore::new(
Expand All @@ -96,7 +96,7 @@ pub(crate) fn build(
document_error(error)
})?,
);
log::error!("[e2ee-adapter] stage=store_ok");
log::debug!("[e2ee-adapter] stage=store_ok");
let service = super::EncryptedSyncService::new(
super::SyncServiceConfig {
origin,
Expand Down
16 changes: 14 additions & 2 deletions openless-all/app/crates/openless-core/src/dictation_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,17 @@ impl DictationEngine for PipelineDictationEngine {
}
}
};
let asr_ms = Some(asr_started.elapsed().as_millis() as u64);
let asr_elapsed_ms = asr_started.elapsed().as_millis() as u64;
let asr_ms = Some(asr_elapsed_ms);
// 「听写很慢」时要能一句话看出慢在 ASR 还是慢在润色。
log::info!(
"[asr] session_id={} provider={} label={:?} elapsed_ms={} chars={}",
session_id,
context.asr.provider_type,
asr_call_label,
asr_elapsed_ms,
transcript.text.chars().count(),
);
if session.cancelled.load(Ordering::Acquire) {
if !context.recording.archive_successful_recording {
discard_ephemeral_archive(archive.as_ref(), &mut has_audio_recording).await;
Expand Down Expand Up @@ -2195,7 +2205,9 @@ struct TranscriptProgressForwarder {

impl TextStreamSink for TranscriptProgressForwarder {
fn publish(&self, chunk: TextStreamChunk) -> Result<(), BackendError> {
log::info!(
// 流式转写每来一块就一条;它属于逐事件仪表,走 debug,
// 否则一次长听写就能把 info 层刷满。
log::debug!(
"[transcript] partial emitted session={} offset={} chars={}",
self.session_id,
chunk.offset,
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/linux-egui/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,11 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) {
stream
}
Err(error) => {
log::error!(
"[audio] no input backend could open a stream (tried {:?}): {}",
audio_backend_order(),
error.message
);
drop(frame_tx);
let _ = processor.finish();
let _ = startup.send(Err(error));
Expand Down Expand Up @@ -540,6 +545,15 @@ fn try_start_audio_stream(
.play()
.map_err(|error| classify_audio_error("start input stream", error.to_string()))?;
accepting.store(true, std::sync::atomic::Ordering::Release);
// 「录到的全是静音」这类问题,第一个要回答的就是:落到哪个后端、哪只
// 设备、什么采样格式。以前只有失败时才有日志,成功时什么都看不到。
log::info!(
"[audio] capture started backend={backend} device={} format={:?} rate={}Hz channels={}",
device,
supported.sample_format(),
supported.sample_rate(),
usize::from(supported.channels()),
);
Ok(stream)
};
let initial_error = match device.default_input_config() {
Expand Down
15 changes: 15 additions & 0 deletions openless-all/app/linux-egui/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,7 @@ pub const CATALOG: &[Msg] = &[
Msg { key: "status.channel_saved", text: row("渠道配置已保存", "管道設定已儲存", "Channel configuration saved", "チャネル設定を保存しました", "채널 설정 저장됨") },
Msg { key: "status.secret_cleared", text: row("渠道 Secret 已清除", "管道 Secret 已清除", "Channel Secret cleared", "チャネルの Secret を消去しました", "채널 Secret 지워짐") },
Msg { key: "status.provider_validated", text: row("Provider 验证通过({} ms)", "Provider 驗證通過({} ms)", "Provider validated ({} ms)", "Provider 検証成功({} ms)", "Provider 검증 통과({}ms)") },
Msg { key: "status.log_level_saved", text: row("日志级别已保存,重启 OpenLess 后生效", "日誌等級已儲存,重新啟動 OpenLess 後生效", "Log level saved; restart OpenLess to apply", "ログレベルを保存しました。OpenLess の再起動で反映されます", "로그 레벨을 저장했습니다. OpenLess를 다시 시작하면 적용됩니다") },
Msg { key: "status.export_log_done", text: row("错误日志已导出", "錯誤日誌已匯出", "Error log exported", "エラーログをエクスポートしました", "오류 로그 내보냄") },
Msg { key: "status.hotkey_handled", text: row("已处理快捷键", "已處理快速鍵", "Hotkey handled", "ホットキーを処理しました", "단축키 처리됨") },
Msg { key: "status.launch_handled", text: row("已处理启动请求", "已處理啟動請求", "Launch request handled", "起動要求を処理しました", "시작 요청 처리됨") },
Expand Down Expand Up @@ -3794,6 +3795,20 @@ pub const CATALOG: &[Msg] = &[
"Shift+Insert (xterm / urxvt)",
),
},
Msg {
key: "settings.debug.log_level_label",
text: row("日志级别", "日誌等級", "Log level", "ログレベル", "로그 레벨"),
},
Msg {
key: "settings.debug.log_level_desc",
text: row(
"debug 记录心跳与 IPC 细节,排查问题时更有用;选择后需重启 OpenLess 生效。",
"debug 記錄心跳與 IPC 細節,排查問題時更有用;選擇後需重新啟動 OpenLess 生效。",
"debug records heartbeats and IPC details, which is what support needs; restart OpenLess to apply.",
"debug はハートビートと IPC の詳細を記録します(調査向け)。OpenLess の再起動で反映されます。",
"debug는 하트비트와 IPC 세부 정보를 기록합니다(문제 해결용). OpenLess를 다시 시작해야 적용됩니다.",
),
},
Msg {
key: "settings.recording.record_audio_for_debug_label",
text: row(
Expand Down
5 changes: 4 additions & 1 deletion openless-all/app/linux-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ pub use local_hotkeys::{
next_local_press_id, plugin_event_hotkey, style_pack_raw, translation_hotkey_event,
HotkeyDeduplicator, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, HOTKEY_DEDUPE_WINDOW,
};
pub use logging::{export_error_log, init_file_logger, log_path};
pub use logging::{
export_error_log, export_error_logs, init_file_logger, level_name, log_path, read_log_level,
write_log_level,
};
pub use popup::{
force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl,
ApplyOutcome as PopupApplyOutcome, CapsulePopupState, CapsuleSuggestion, HostToPopup,
Expand Down
Loading
Loading