设置页里有个「调试日志」开关,从很早就有。

某天我真去查了一下它到底管住了多少东西:全项目只有 2 个文件在用那个受控的日志函数,其余 339 处是裸 console.*。

也就是说这个开关拨不拨都一样——三百多条日志照样刷屏,而真要排查问题时,该有的上下文(是哪个模块打的?哪个 frame?)一条都没有。

收口的过程里定下四条规矩,还顺手解决了一个一直很难受的排障问题。

规矩一:error 恒开,其余受开关管

function shouldEmit(level: LogLevel, scope: string): boolean {
if (level === 'error') return true; // ★ 不受任何开关影响
if (!debugEnabled && !backdoorOn()) return false;
return scopeAllowed(scope);
}

判级别的时候只问一句:

开关关着的时候,用户还需不需要看见它?

需要就是 error。这条判据很朴素,但它能挡住「什么都写成 warn」的惯性。

我们判成 error 的有三类,抄作业用:

场景为什么必须恒开
模块启动失败的兜底 catch启动挂了不出声,你会以为功能没做
「扩展已失效,请按 F5」那一刻日志层自己读存储也会失败 → 开关退回默认的关 → 走 warn 这条提示永远不会被看见,用户只觉得扩展突然没反应了
长任务里的「防错位中止点」无人值守的批量任务,错位可能串数据;UI 上的状态会被后续步骤刷掉,Console 里的 error 才留得下

反例(保持 warn):单个条目处理失败、某个页面打不开。那是「这一个没做成」,不是「数据可能错乱」,而且批量结果里会统计出来。

规矩二:scope 前缀由日志层统一拼

const log = createLogger('quick-ticket/fill-customer');
log.debug('客户已填入', id);
// → [ext][quick-ticket/fill-customer] 客户已填入 123

调用点不要再手写 '[ext] xxx:'。手写的前缀会漂——有人写全名、有人写缩写、有人写完忘了改。统一拼之后,scope 还能拿来做过滤(见下)。

child() 派生子 scope:

const log = createLogger('quick-ticket');
log.child('Alt+5').debug('…'); // → [ext][quick-ticket/Alt+5]

规矩三:不能依赖配置单例

这条是架构约束,不是风格偏好。

配置单例要 await init() 才有值,而日志在各个入口的极早期就会被调用——content script 的第一行、Service Worker 冷启的瞬间。等不起。

而且 SW 随时会被冻结重启,任何「初始化过一次就好了」的假设在那边都不成立。

所以日志层自己直读 chrome.storage,import 即用,零初始化顺序要求。

由此推出一条硬约束:

日志层只能 import 类型定义,绝不能 import 配置模块。 否则就成环了——配置模块要打日志,日志层要读配置。

规矩四:两套作者后门

拨开调试日志最顺手的方式不是去设置页,是在 Console 里敲一行。但content script 和 Service Worker 需要两套:

环境拨法
content scriptlocalStorage.__ext_debug = 1
Service Worker(没有 localStorage)await chrome.storage.session.set({ __ext_debug: 1 })

storage.session 这套有三个正好的性质:

  • SW 冻结重启后仍在(比 localStorage 更适合 SW);
  • 关浏览器自动清——调试后门本来就该是临时的,不该长期残留;
  • background 里设了 setAccessLevel('TRUSTED_AND_UNTRUSTED_CONTEXTS') 之后,content script 和 SW 共用同一份:任一边拨开两边同时生效。

再加一个 scope 过滤,因为三百多条全开时自己也读不下去:

localStorage.__ext_debug_scope = 'quick-ticket'
// 前缀匹配:能盖住 quick-ticket/fill-customer 等全部子 scope
// 支持逗号分隔多个

这两套后门都不需要「作者身份」判定,也不下发任何标识。 比做一套身份系统轻得多,而且没有任何东西需要保密——能打开 Console 的人本来就能读你的全部代码。

一个环形缓冲,解决「事后翻不到日志」

这是收口过程中顺手加的,但它解决的问题比开关本身更难受。

排查「提交成功之后下一次必定失败」这种跨会话时序 bug 时,你需要回头看一整段时间线。而 Console 里翻不到,原因有三个:

  1. debug 走 console.debug,在 DevTools 里归到 Verbose 级,而 Verbose 默认是折叠/过滤掉的;
  2. 宿主页面的 SDK 会周期性 console.clear();
  3. 内容脚本注入每一个 frame(某个页面实测 5 个),日志散在五个上下文里。

所以日志层自己留一份:

const LOG_RING_MAX = 2000;
const logRing: Array<{ t: number; level: LogLevel; scope: string; msg: string }> = [];

只在开关或后门已打开时才写(写在 shouldEmit 之后),所以默认零开销。上限 2000 条、超出丢最旧的,不会无限涨。

读法是在 Console 里一行:

await __extTools.logs() // 全部
await __extTools.logs('ai/backfill') // 按 scope 或正文过滤(正则,不分大小写)
await __extTools.clearLogs() // 开始一轮复现前先清,免得混入上一轮

(主世界怎么调到隔离世界的东西,见内容脚本的三个世界。)

完整实现

单文件,零依赖(除了一个存储键常量)。

src/shared/logger.ts
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
/** 主配置在 chrome.storage.local 里的键。日志层只认这一个键,不 import 配置模块 */
const GENERAL_KEY = 'cfg:general';
const SESSION_ON_KEY = '__ext_debug';
const SESSION_SCOPE_KEY = '__ext_debug_scope';
let debugEnabled = false; // 默认关
let sessionBackdoorOn = false; // session 后门的本地镜像
let sessionScopeFilter: string | null = null;
/* ── 后门 ─────────────────────────────────────────────────────── */
function backdoorOn(): boolean {
if (sessionBackdoorOn) return true;
try {
return localStorage.getItem(SESSION_ON_KEY) === '1';
} catch {
return false; // Service Worker 没有 localStorage,走上面那个镜像
}
}
/** 前缀匹配:填 'quick-ticket' 能盖住 'quick-ticket/fill-customer' */
function scopeAllowed(scope: string): boolean {
let raw: string | null = sessionScopeFilter;
if (raw === null) {
try { raw = localStorage.getItem(SESSION_SCOPE_KEY); } catch { raw = null; }
}
if (!raw) return true;
return raw.split(',').some((s) => {
const t = s.trim();
return t !== '' && (scope === t || scope.startsWith(t + '/'));
});
}
/* ── 状态同步:各读一次 + 订阅变更。emit 是同步的,所以状态必须落到模块变量 ── */
void (async () => {
try {
const r = await chrome.storage.session.get([SESSION_ON_KEY, SESSION_SCOPE_KEY]);
const v = r[SESSION_ON_KEY];
sessionBackdoorOn = v === 1 || v === '1' || v === true;
const sc = r[SESSION_SCOPE_KEY];
sessionScopeFilter = typeof sc === 'string' && sc !== '' ? sc : null;
} catch { /* 旧版浏览器没有 storage.session → 仅 localStorage 后门可用 */ }
})();
void (async () => {
try {
const r = await chrome.storage.local.get(GENERAL_KEY);
const g = r[GENERAL_KEY] as { debugLog?: boolean } | undefined;
if (typeof g?.debugLog === 'boolean') debugEnabled = g.debugLog;
} catch { /* 极早期 / 受限环境 → 保持默认关 */ }
})();
try {
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && GENERAL_KEY in changes) {
debugEnabled = (changes[GENERAL_KEY].newValue as { debugLog?: boolean } | undefined)?.debugLog ?? false;
}
// 后门拨动后**即时生效**,不用重启 SW / 刷页
if (area === 'session') {
if (SESSION_ON_KEY in changes) {
const v = changes[SESSION_ON_KEY].newValue;
sessionBackdoorOn = v === 1 || v === '1' || v === true;
}
if (SESSION_SCOPE_KEY in changes) {
const v = changes[SESSION_SCOPE_KEY].newValue;
sessionScopeFilter = typeof v === 'string' && v !== '' ? v : null;
}
}
});
} catch { /* 同上 */ }
function shouldEmit(level: LogLevel, scope: string): boolean {
if (level === 'error') return true; // ★ 恒开
if (!debugEnabled && !backdoorOn()) return false;
return scopeAllowed(scope);
}
/* ── 环形缓冲 ─────────────────────────────────────────────────── */
const LOG_RING_MAX = 2000;
const logRing: Array<{ t: number; level: LogLevel; scope: string; msg: string }> = [];
export function readLogRing(filter?: string) {
const re = filter ? new RegExp(filter, 'i') : null;
return logRing
.filter((e) => !re || re.test(e.scope) || re.test(e.msg))
.map((e) => ({
at: new Date(e.t).toLocaleTimeString('zh-CN', { hour12: false })
+ '.' + String(e.t % 1000).padStart(3, '0'),
level: e.level, scope: e.scope, msg: e.msg,
}));
}
export function clearLogRing(): void { logRing.length = 0; }
/* ── 输出 ─────────────────────────────────────────────────────── */
function emit(level: LogLevel, scope: string, args: unknown[]): void {
if (!shouldEmit(level, scope)) return;
const tag = `[ext][${scope}]`;
// 先进环形缓冲:不受 DevTools 的 Verbose 过滤和宿主的 console.clear 影响
try {
if (logRing.length >= LOG_RING_MAX) logRing.shift();
logRing.push({
t: Date.now(), level, scope,
msg: args.map((a) => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
}).join(' ').slice(0, 1000),
});
} catch { /* 缓冲失败绝不能影响真正的日志输出 */ }
// debug 走 console.debug:DevTools 默认折叠在 Verbose 里,不占 Info 区
const sink = level === 'debug' ? console.debug
: level === 'info' ? console.log
: console[level];
try {
sink(tag, ...args);
} catch { /* 有些页面会魔改 console,别让日志本身把功能打挂 */ }
}
export interface Logger {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
/** 恒开,不受开关约束 */
error: (...args: unknown[]) => void;
child: (sub: string) => Logger;
}
export function createLogger(scope: string): Logger {
return {
debug: (...a) => emit('debug', scope, a),
info: (...a) => emit('info', scope, a),
warn: (...a) => emit('warn', scope, a),
error: (...a) => emit('error', scope, a),
child: (sub) => createLogger(`${scope}/${sub}`),
};
}
/** 给设置页显示「当前实际生效状态」(含后门)用 */
export function isDebugLogOn(): boolean {
return debugEnabled || backdoorOn();
}

几个防御性的 try/catch 不是凑数的:

  • sink(...) 外面那层——有些页面会魔改 console,别让日志本身把功能打挂;
  • 缓冲写入外面那层——同理,辅助设施不能拖垮主路径;
  • localStorage 外面那层——SW 里根本没有它。

批量迁移时踩的一个正则坑

339 处不可能手改,写了脚本批量剥旧前缀。坑在这里:

[ext] featureFlags: 裁决结果 …
[ext] sidepanel:open 已打开

[ext] xxx: 里的 xxx 可能是有意义的标签,而不是文件名。一刀切地剥掉,featureFlags: 和 sidepanel:open 这些语义就没了。

所以脚本只自动处理两种确定形式——[ext] 和 [ext][标签] ——剩下的 [ext] xxx: 留人工逐条判断。

批量重构时,宁可让脚本少管一类、留给人工,也别让它「聪明地」猜。 猜错的那几条通常正好是信息量最大的几条。

日志 ≠ 用户提醒

最后一条,是收口时最容易搞混的:

给谁看在哪受开关影响吗
日志(log.*)开发者DevTools Console✅ 受调试开关管
提醒(notify())用户页面上的消息条❌ 不受任何开关影响

这是两套完全独立的东西。监控告警、操作失败提示走 notify,拨关调试日志不会把它们关掉。

迁移时只动 console.*,notify() 一处都不该碰。

全项目唯一保留的裸 console.log

在主世界那个调试桥里:

/* ⚠ 本条故意不走统一日志层(全项目唯一的例外):
* 这里是页面主世界,没有 chrome.storage —— 日志层读不到开关会退回默认的「关」,
* 而这条恰恰是告诉人「调试接口已就绪」的提示,默认不显示就失去意义。 */
console.log('[ext][bridge] 主世界调试接口已就绪');

例外一定要在代码里写清楚为什么。 否则下一个做日志规范化的人(很可能是三个月后的你)会顺手把它也改掉,然后过很久才发现调试入口的提示没了。


做完这件事最大的感受是:一个「有但不生效」的开关,比没有这个开关更糟。

它会让你在排查时产生一个错误的信念——「我已经把日志关掉了,所以现在看到的都是重要的」——而事实是你什么都没关掉。