feat(logging): log provider switches and routing / aggregation mode changes (#7882)

* feat(logging): log provider switches and routing / aggregation mode changes

Switching providers and entering or leaving the routing / aggregation
modes left nothing in cc-switch.log, so "I clicked switch and nothing
happened" reports could not be told apart from a rule refusal, a user
cancel or a pending roll-forward.

- [SWITCH]: every ProviderService::switch logs where it went (direct
  from -> to, or proxy route from -> to) and how it ended: done, done
  with warnings, refused by the official-provider rule (INFO), or failed
  (ERROR).
- [MODE]: enter / attach, exit / detach, route changes and the direct-mode
  route memory log the mode transition (direct / routing / aggregation),
  the route or direct provider, and whether the client config was
  rewritten; failures log at ERROR.
- Frontend: the mode dialogs log when they open and what the user picked
  (cancel, switch directly anyway, start routing, confirm with a pick),
  and the frontend-only switch gates log too.

Fixes #7881

* fix(logging): drop quoted TOML source lines from switch / mode failure logs

TOML parse diagnostics quote the offending config line, which can be the
one holding a credential. Strip the quoted source lines before the new
backend failure logs write them to cc-switch.log; line/column and the
reason are kept.
This commit is contained in:
Allen Xu authored and GitHub committed 2026-10-06 17:56:51 +08:00
1 parent c615c3eeba
commit beff557e3c
6 files changed
+302 -22

No files matched your search

+37 -1
View File
@@ -235,6 +235,22 @@ pub(crate) fn redact_url_origin_for_log(url_str: &str) -> String {
}
}
/// 给日志用的错误文本:去掉 TOML 解析诊断里引用的源码行(`1 | key = "..."` 和它上下的
/// `|`、`^` 标注行)。那一行是用户配置原文,出错的可能正是密钥那一行;行列号和原因留着。
pub(crate) fn error_for_log(error: &str) -> String {
error
.lines()
.filter(|line| {
!line
.trim_start()
.trim_start_matches(|c: char| c.is_ascii_digit())
.trim_start()
.starts_with('|')
})
.collect::<Vec<_>>()
.join("\n")
}
fn runtime_log_level_allows(level: log::Level, max_level: log::LevelFilter) -> bool {
max_level.to_level().is_some_and(|maximum| level <= maximum)
}
@@ -2263,10 +2279,30 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{
classify_exit_request, redact_url_for_log, redact_url_for_log_with_secrets,
classify_exit_request, error_for_log, redact_url_for_log, redact_url_for_log_with_secrets,
redact_url_origin_for_log, runtime_log_level_allows, ExitRequestAction,
};
#[test]
fn log_error_drops_toml_source_lines_but_keeps_position() {
let secret = "sk-review-only-secret";
let toml_edit_error = format!("experimental_bearer_token = \"{secret}\" !\n")
.parse::<toml_edit::DocumentMut>()
.unwrap_err()
.to_string();
let toml_error = toml::from_str::<toml::Table>(&format!("token = \"{secret}\" !\n"))
.unwrap_err()
.to_string();
for error in [toml_edit_error, toml_error] {
assert!(error.contains(secret), "前提:诊断里带源码行");
let logged = error_for_log(&format!("无法解析:{error} (cannot parse: {error})"));
assert!(!logged.contains(secret), "{logged}");
assert!(logged.contains("line 1"), "{logged}");
}
// 普通错误原样保留。
assert_eq!(error_for_log("供应商 a 不存在"), "供应商 a 不存在");
}
#[test]
fn log_url_redaction_strips_credentials_and_query_keeps_path() {
// userinfo 与整个 query 剥离,path 保留用于诊断 base_url 配错。
+151 -13
View File
@@ -247,6 +247,24 @@ fn settled_stack(app: &AppType) -> Result<StackState, String> {
super::state::stack(&DeviceStore::for_device(), app.as_str()).map_err(err)
}
/// 日志里的模式名。
fn mode_label(proxy: bool, stack: bool) -> &'static str {
match (proxy, stack) {
(false, _) => "直连",
(true, false) => "路由",
(true, true) => "聚合",
}
}
/// 日志里写没写客户端文件。
fn rewrite_label(rewritten: bool) -> &'static str {
if rewritten {
"已重写"
} else {
"没变,未重写"
}
}
/// 发布 Stack 模型的成员(见 [`stack::published_members`])。
fn published_members(
state: &AppState,
@@ -263,7 +281,7 @@ fn commit_state(state: &AppState, app: &AppType, target: &PendingTarget) -> Resu
.map_err(err)
}
/// 写代理契约。`target` 的 `contract` 由这里填好。契约没变时不碰客户端文件;接上
/// 写代理契约。`target` 的 `contract` 由这里填好。契约没变时不碰客户端文件(返回假);接上
/// (启动时)一律重写:顺带核对路由供应商还能用(比如托管账号还在),并修正 CC Switch
/// 没运行期间客户端文件里的漂移。
///
@@ -277,7 +295,7 @@ async fn write_proxy(
live_now: &LiveNow,
mut target: ModeState,
next_stack: Option<StackState>,
) -> Result<(), String> {
) -> Result<bool, String> {
let (proxy_url, codex_base_url) = state.proxy_service.build_proxy_urls().await?;
// 改用 CC Switch 的目录时契约可能没变(指针只在 live 里),也要重写。
let force = op_name == op::ATTACH || op_name == op::CATALOG;
@@ -291,7 +309,7 @@ async fn write_proxy(
stack: next_stack.clone(),
..PendingTarget::default()
};
match app {
let unchanged = match app {
AppType::Claude => {
let members = published_members(state, app, &stack, route)?;
let published = stack::claude_published(&members);
@@ -308,6 +326,7 @@ async fn write_proxy(
pending(target),
)
.map_err(err)?;
unchanged
}
AppType::Gemini => {
let projection = GeminiProjection::proxy_contract(
@@ -325,6 +344,7 @@ async fn write_proxy(
pending(target),
)
.map_err(err)?;
unchanged
}
AppType::Codex => {
let owner = live_now.codex_owner();
@@ -348,6 +368,7 @@ async fn write_proxy(
} else {
codex_direct::run(&state.db, op_name, planned, &prepared, pending).map_err(err)?;
}
unchanged
}
AppType::GrokBuild => {
let base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/'));
@@ -368,10 +389,11 @@ async fn write_proxy(
pending(target),
)
.map_err(err)?;
unchanged
}
_ => return Err(format!("{} 不支持本地路由", app.as_str())),
}
Ok(())
};
Ok(!unchanged)
}
/// 写回直连投影(直连指针的供应商)。
@@ -564,6 +586,25 @@ pub async fn enter_with_route(
app: &AppType,
stack_mode: bool,
route: Option<&str>,
) -> Result<(), String> {
let result = try_enter_with_route(state, app, stack_mode, route).await;
if let Err(error) = &result {
log::error!(
"[MODE] {} 进入{}模式失败(路由目标 {}): {}",
app.as_str(),
mode_label(true, stack_mode),
route.unwrap_or("沿用上次"),
crate::error_for_log(error)
);
}
result
}
async fn try_enter_with_route(
state: &AppState,
app: &AppType,
stack_mode: bool,
route: Option<&str>,
) -> Result<(), String> {
require_proxy_app(app)?;
if stack_mode && !stack::supports_stack(app) {
@@ -630,8 +671,11 @@ async fn enter_locked(
}
None => None,
};
// 只给日志用,读不到不拦着进入。
let was_stack = settled_stack(app).is_ok_and(|stack| stack.enabled);
let stack_on = stack_mode.unwrap_or(was_stack);
let live_now = LiveNow::of(state, app, &mode)?;
write_proxy(
let rewritten = write_proxy(
state,
app,
op_name,
@@ -646,6 +690,14 @@ async fn enter_locked(
next_stack,
)
.await?;
log::info!(
"[MODE] {} {op_name}:{} → {},路由 {},客户端配置{}",
app.as_str(),
mode_label(mode.is_proxy(), was_stack),
mode_label(true, stack_on),
route.id,
rewrite_label(rewritten)
);
state.proxy_service.set_active_target(app, &route).await;
warn_if_official_route(state, app, &route).await;
Ok(())
@@ -711,13 +763,48 @@ fn exit_locked(state: &AppState, app: &AppType, keep_mode: bool) -> Result<(), S
return Ok(());
}
if !keep_mode && !mode.is_proxy() && !mode.attached {
log::debug!("[MODE] {} 已经是直连,不用退出", app.as_str());
return Ok(());
}
let live_now = LiveNow::of(state, app, &mode)?;
let op_name = if keep_mode { op::DETACH } else { op::EXIT };
let was_stack = settled_stack(app).is_ok_and(|stack| stack.enabled);
let result = write_direct_for_exit(state, app, op_name, keep_mode, &mode);
match &result {
Ok(()) => log::info!(
"[MODE] {} {op_name}:{} → {},客户端配置写回直连供应商 {}",
app.as_str(),
mode_label(mode.is_proxy(), was_stack),
if keep_mode {
"客户端指回直连,模式保留"
} else {
"直连"
},
direct_provider(state, app)
.ok()
.flatten()
.map_or_else(|| "(无)".to_string(), |provider| provider.id)
),
Err(error) => log::error!(
"[MODE] {} {op_name} 失败: {}",
app.as_str(),
crate::error_for_log(error)
),
}
result
}
fn write_direct_for_exit(
state: &AppState,
app: &AppType,
op_name: &str,
keep_mode: bool,
mode: &ModeState,
) -> Result<(), String> {
let live_now = LiveNow::of(state, app, mode)?;
write_direct(
state,
app,
if keep_mode { op::DETACH } else { op::EXIT },
op_name,
&live_now,
ModeState {
mode: Some(if keep_mode && mode.is_proxy() {
@@ -726,7 +813,7 @@ fn exit_locked(state: &AppState, app: &AppType, keep_mode: bool) -> Result<(), S
Mode::Direct
}),
attached: false,
proxy_route: mode.proxy_route,
proxy_route: mode.proxy_route.clone(),
contract: None,
},
)
@@ -836,7 +923,7 @@ pub async fn switch_route_locked(
add_default(app, &mut next, target);
}
let next_stack = (next != current).then_some(next);
if !mode.attached {
let rewritten = if !mode.attached {
commit_state(
state,
app,
@@ -846,6 +933,7 @@ pub async fn switch_route_locked(
..PendingTarget::default()
},
)?;
false
} else {
let live_now = LiveNow::of(state, app, &mode)?;
write_proxy(
@@ -857,7 +945,19 @@ pub async fn switch_route_locked(
new_state,
next_stack,
)
.await?;
.await?
};
// 编辑路由那家之后也走这里重算契约:路由没换、文件也没动的不记。
if rewritten || !mode.routes_to(&target.id) {
log::info!(
"[MODE] {} {}:{}模式路由 {} → {},客户端配置{}",
app.as_str(),
op::ROUTE,
mode_label(true, current.enabled),
mode.proxy_route.as_deref().unwrap_or("(无)"),
target.id,
rewrite_label(rewritten)
);
}
state.proxy_service.set_active_target(app, target).await;
Ok(())
@@ -917,6 +1017,7 @@ pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Re
if mode.proxy_route.as_deref() == Some(provider_id) {
return Ok(());
}
let previous = mode.proxy_route.clone();
commit_state(
state,
app,
@@ -927,7 +1028,14 @@ pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Re
}),
..PendingTarget::default()
},
)
)?;
log::info!(
"[MODE] {} 直连模式下记下路由 {} → {},下次进入路由 / 聚合模式时用",
app.as_str(),
previous.as_deref().unwrap_or("(无)"),
provider_id
);
Ok(())
}
/// 代理模式下不能切到不支持代理的官方供应商(Codex 官方账号走客户端自己的登录,除外)。
@@ -1069,7 +1177,9 @@ async fn set_stack_member_locked(
match attached_route(state, app)? {
Some((mode, route)) => {
let live_now = LiveNow::of(state, app, &mode)?;
write_proxy(state, app, op::STACK, &route, &live_now, mode, Some(next)).await
write_proxy(state, app, op::STACK, &route, &live_now, mode, Some(next))
.await
.map(|_| ())
}
None => commit_state(
state,
@@ -6231,6 +6341,34 @@ model_provider = "c"
);
}
/// 行里的 TOML 坏在密钥那一行:解析诊断会带上这行原文,失败日志不能把它写进日志文件。
#[tokio::test]
#[serial]
async fn failure_logs_drop_the_config_line_a_broken_row_quotes() {
let _home = Home::new();
seed_codex("", None);
let secret = "sk-review-only-secret";
let mut rows = codex_stack_rows().to_vec();
let mut broken = codex_native("broken", "https://b.example/v1", "", None);
broken.settings_config["config"] =
json!(format!("experimental_bearer_token = \"{secret}\" !\n"));
rows.push(broken);
let state = state_with(AppType::Codex, &rows, "a").await;
let switch_error = ProviderService::switch(&state, AppType::Codex, "broken")
.unwrap_err()
.to_string();
let enter_error = enter_with_route(&state, &AppType::Codex, false, Some("broken"))
.await
.unwrap_err();
for error in [switch_error, enter_error] {
assert!(error.contains(secret), "前提:错误里带配置原文 {error}");
let logged = crate::error_for_log(&error);
assert!(!logged.contains(secret), "{logged}");
assert!(logged.contains("line 1"), "{logged}");
}
}
#[tokio::test]
#[serial]
async fn codex_official_accounts_cannot_be_stacked() {
+57 -1
View File
@@ -6093,6 +6093,37 @@ impl ProviderService {
/// - 直连模式:切换式应用只替换客户端文件里的关键字段(不回填),文件和指针在同一个
/// 操作里提交;累加式应用按各自的规则写入。
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
let app = app_type.as_str().to_string();
let result = Self::switch_inner(state, app_type, id);
match &result {
Ok(result) if result.warnings.is_empty() => {
log::info!("[SWITCH] {app} 切到 {id} 完成");
}
Ok(result) => {
log::warn!(
"[SWITCH] {app} 切到 {id} 完成,有警告: {:?}",
result.warnings
);
}
// 规则拒绝:什么都没改。
Err(error @ AppError::Localized { key, .. })
if *key == "switch.official_blocked_by_proxy" =>
{
log::info!("[SWITCH] {app} 拒绝切到 {id}: {error}");
}
Err(error) => log::error!(
"[SWITCH] {app} 切到 {id} 失败: {}",
crate::error_for_log(&error.to_string())
),
}
result
}
fn switch_inner(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
if app_type == AppType::Pi {
return pi::enable(state, id);
}
@@ -6136,7 +6167,15 @@ impl ProviderService {
"Cannot switch to an official provider in routing mode. Using a proxy with official APIs may cause account bans.",
));
}
log::info!("路由模式:{} 的代理路由切到 {}", app_type.as_str(), id);
log::info!(
"[SWITCH] {} 代理模式:路由 {} → {}",
app_type.as_str(),
crate::mode::current::mode_state(&app_type)
.proxy_route
.as_deref()
.unwrap_or("(无)"),
id
);
futures::executor::block_on(crate::mode::controller::switch_route_locked(
state, &app_type, _provider,
))
@@ -6160,6 +6199,23 @@ impl ProviderService {
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
if app_type.is_additive_mode() {
log::info!("[SWITCH] {} 写入 {id}(累加式应用)", app_type.as_str());
} else {
let previous = crate::mode::current::provider_for(
&state.db,
&app_type,
crate::mode::current::Purpose::Direct,
)
.ok()
.flatten();
log::info!(
"[SWITCH] {} 直连:{} → {id},写客户端配置",
app_type.as_str(),
previous.as_deref().unwrap_or("(无)")
);
}
if matches!(app_type, AppType::Claude) {
return Self::switch_claude_direct(state, provider, providers);
}
+38 -6
View File
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/select";
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
import { extractErrorMessage } from "@/utils/errorUtils";
import { logFrontendInfo } from "@/lib/frontendLogger";
import { isOfficialAccount } from "@/utils/providerCapabilities";
/** 路由模式改写的客户端文件(确认框里写明,用默认位置)。 */
@@ -34,6 +35,20 @@ export type ModeDialogState =
| { kind: "enter"; target: Exclude<AppMode, "direct"> }
| { kind: "needsRoute"; providerId: string; reason: string };
const MODE_LABEL: Record<Exclude<AppMode, "direct">, string> = {
route: "路由",
stack: "聚合",
};
/** 框里的选择写进日志:取消、仍然直连切换、进入模式在日志里分得清。 */
function logDialog(app: AppId, state: ModeDialogState, choice: string) {
const subject =
state.kind === "enter"
? `进入${MODE_LABEL[state.target]}模式确认框`
: `「${state.providerId} 需要路由」确认框`;
logFrontendInfo(`[MODE] ${app} ${subject}:${choice}`);
}
interface ModeDialogProps {
app: AppId;
state: ModeDialogState | null;
@@ -57,18 +72,28 @@ interface ModeDialogProps {
* 卡片上不再有「从这家开始」的按钮)。
*/
export function ModeDialog(props: ModeDialogProps) {
const { state, onClose } = props;
const { app, state, onClose } = props;
useEffect(() => {
if (state) logDialog(app, state, "弹出");
}, [app, state]);
const cancel = () => {
if (state) logDialog(app, state, "取消");
onClose();
};
return (
<Dialog open={state !== null} onOpenChange={(open) => !open && onClose()}>
<Dialog open={state !== null} onOpenChange={(open) => !open && cancel()}>
{state && (
<DialogContent
zIndex="alert"
className="max-w-[480px] gap-0 rounded-dialog border-border bg-surface p-6 shadow-v7-lg sm:rounded-dialog"
>
{state.kind === "enter" ? (
<EnterBody {...props} state={state} />
<EnterBody {...props} state={state} onCancel={cancel} />
) : (
<NeedsRouteBody {...props} state={state} />
<NeedsRouteBody {...props} state={state} onCancel={cancel} />
)}
</DialogContent>
)}
@@ -85,9 +110,11 @@ function EnterBody({
defaultPick,
stackMembers,
onClose,
onCancel,
onEnter,
}: ModeDialogProps & {
state: Extract<ModeDialogState, { kind: "enter" }>;
onCancel: () => void;
}) {
const { t } = useTranslation();
const appName = APP_DISPLAY_NAME[app];
@@ -145,6 +172,7 @@ function EnterBody({
const confirm = async () => {
if (!pick || busy) return;
logDialog(app, state, `确认,选 ${pick}`);
setBusy(true);
setError(null);
try {
@@ -240,7 +268,7 @@ function EnterBody({
size="regular"
autoFocus
disabled={busy}
onClick={onClose}
onClick={onCancel}
>
{t("common.cancel")}
</Button>
@@ -265,10 +293,12 @@ function NeedsRouteBody({
state,
providers,
onClose,
onCancel,
onEnter,
onSwitchDirect,
}: ModeDialogProps & {
state: Extract<ModeDialogState, { kind: "needsRoute" }>;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
@@ -277,6 +307,7 @@ function NeedsRouteBody({
const name = provider?.name ?? state.providerId;
const route = async () => {
logDialog(app, state, "开始路由并使用");
setBusy(true);
setError(null);
try {
@@ -317,7 +348,7 @@ function NeedsRouteBody({
size="regular"
autoFocus
disabled={busy}
onClick={onClose}
onClick={onCancel}
>
{t("common.cancel")}
</Button>
@@ -326,6 +357,7 @@ function NeedsRouteBody({
size="regular"
disabled={busy}
onClick={() => {
logDialog(app, state, "仍然直连切换");
onClose();
onSwitchDirect(state.providerId);
}}
+7
View File
@@ -29,6 +29,7 @@ import { extractErrorMessage } from "@/utils/errorUtils";
import { openclawKeys } from "@/hooks/useOpenClaw";
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
import { getRoutingReason } from "@/utils/routingReason";
import { logFrontendInfo } from "@/lib/frontendLogger";
/**
* Hook for managing provider actions (add, update, delete, switch)
@@ -178,6 +179,9 @@ export function useProviderActions(
: getRoutingReason(activeApp, provider, t);
if (proxyRequiredReason) {
logFrontendInfo(
`[SWITCH] ${activeApp} 直连切到 ${provider.id},提示需要路由:${proxyRequiredReason}`,
);
toast.warning(
t("notifications.proxyRequiredForSwitch", {
reason: proxyRequiredReason,
@@ -198,6 +202,9 @@ export function useProviderActions(
provider.category === "official" &&
!officialSupportsTakeover
) {
logFrontendInfo(
`[SWITCH] ${activeApp} 拒绝切到 ${provider.id}:路由模式下不能切到官方供应商`,
);
toast.error(
t("notifications.officialBlockedByProxy", {
defaultValue:
+12 -1
View File
@@ -1,4 +1,7 @@
import { error as writeErrorLog } from "@tauri-apps/plugin-log";
import {
error as writeErrorLog,
info as writeInfoLog,
} from "@tauri-apps/plugin-log";
const MAX_LOG_MESSAGE_LENGTH = 12_000;
const MAX_RAW_LOG_INPUT_LENGTH = 16_000;
@@ -322,6 +325,14 @@ export function reportFrontendError(
void writeErrorLog(message, { file: "frontend" }).catch(() => undefined);
}
/** 用户动作(确认框里的选择等)写一行 INFO,和后端的切换日志对得上。 */
export function logFrontendInfo(message: string): void {
const redacted = redactFrontendLogText(
truncateForProcessing(`[frontend] ${message}`, MAX_LOG_MESSAGE_LENGTH),
);
void writeInfoLog(redacted, { file: "frontend" }).catch(() => undefined);
}
export function installGlobalErrorHandlers(
target: Window = window,
): () => void {