设置页里有个「调试日志」开关,从很早就有。
某天我真去查了一下它到底管住了多少东西:全项目只有 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 script | localStorage.__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 里翻不到,原因有三个:
debug走console.debug,在 DevTools 里归到 Verbose 级,而 Verbose 默认是折叠/过滤掉的;- 宿主页面的 SDK 会周期性
console.clear(); - 内容脚本注入每一个 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() // 开始一轮复现前先清,免得混入上一轮(主世界怎么调到隔离世界的东西,见内容脚本的三个世界。)
完整实现
单文件,零依赖(除了一个存储键常量)。
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] 主世界调试接口已就绪');例外一定要在代码里写清楚为什么。 否则下一个做日志规范化的人(很可能是三个月后的你)会顺手把它也改掉,然后过很久才发现调试入口的提示没了。
做完这件事最大的感受是:一个「有但不生效」的开关,比没有这个开关更糟。
它会让你在排查时产生一个错误的信念——「我已经把日志关掉了,所以现在看到的都是重要的」——而事实是你什么都没关掉。