移动端Console调试新方案:零侵入日志采集与浏览器侧边栏实时查看

移动端Console调试新方案:零侵入日志采集与浏览器侧边栏实时查看 先说一个真实的场景。上个月我们公司在做一次线上H5页面的灰度验证用户反馈在安卓真机上点击某个按钮没反应但同机型的开发手机完全正常。组里的新同事条件反射地说“直接开vConsole看重放日志啊”我看了下那个项目是两年前的版本压根没接vConsole而且那个页面是嵌在原生App里的WebView临时改代码意味着要重新发一次整体联调包。另一个同事提议用chrome://inspect连真机看日志结果一连就是404设备列表能看到点进去白屏折腾了二十分钟没定位到问题。这两个我在过往项目里都踩过无数次vConsole是侵入式的得往页面上挂依赖再唤起悬浮球而inspect链路太长任何一环配置不对就是404。那次之后我花了几天时间做了个东西一个跑在浏览器侧边栏里的“原生移动端Console控制台”不侵入业务代码、不依赖USB链路、也不需要改页面重新发版把移动端真机上的console日志直接搬到浏览器侧边栏实时滚动。1. 复盘两个老方案的短板vConsole 侵入面与 inspect 404 根因1.1 vConsole 的核心问题不是功能而是侵入vConsole本身是个好工具功能齐全network、console、system都有很多团队直接用它的CDN模式一行代码就接入了。但“一行代码接入”这句话本身就是侵入的起点。先说代码侵入。接入vConsole的方式通常是script srchttps://cdn.jsdelivr.net/npm/vconsolelatest/dist/vconsole.min.js/script script var vConsole new VConsole(); /script或者通过npm包引入在入口文件里初始化。这意味着你必须在业务代码里增加一段逻辑。为了不让生产环境弹悬浮球你还要加环境判断if (import.meta.env.DEV || isTestEnv()) { const VConsole (await import(vconsole)).default; new VConsole(); }看似就几行但问题随之而来老项目没有这套构建环境怎么办纯静态页面怎么办嵌在第三方容器里的H5怎么办你都插不进这段代码因为动一动就要重新发布资源甚至重新走一遍App的发布流程。再说UI侵入。vConsole的悬浮球是覆盖在页面上的它会遮挡内容。对普通用户来说这个悬浮球非常致命——它不在设计稿里还可能挡住点击事件。我记得有一次测试同学在iPhone上做回归手指刚好点到了悬浮球的位置弹出了vConsole的调试面板她当场蒙了以为是线上bug。这种体验事故只要发生一次产品经理就会要求你彻底移除vConsole连测试环境都不给用。还有一个很多人忽略的问题vConsole会挂载全局对象。安全扫描工具对第三方脚本的检测越来越严格有些公司内部的安全策略明确要求生产环境的页面不允许加载这类调试SDK导致项目上线前要专门做一次脚本移除。更关键的是vConsole解决不了“连接”的问题。它的日志只存在当前这个浏览器页面的内存里你看不到另一个设备上的日志。如果bug是某个低端安卓机上必现的而你的开发机复现不出来你还是得想办法“到那台设备上去看日志”vConsole帮不了你。1.2 inspect 的 404 不是玄学链路断裂点需要逐个排查chrome://inspect的思路是通过Chrome DevTools ProtocolCDP把移动端WebView变成一个可调试的目标。原理上这套机制很成熟但它依赖一条很长的链路真机开启USB调试 - ADB识别设备 - adb reverse 端口转发 - WebView开启调试模式 - CDP握手成功 - DevTools前端拉取调试数据这一条链路上任何一个环节出问题表现都可能接近404。我整理了实际工作中最常见的几个断裂点现象常见根因排查方向设备列表能看到点inspect没反应WebView没有开启调试模式检查代码里是否调用了setWebContentsDebuggingEnabled(true)点击inspect后页面空白adb reverse映射失败或端口被占用重新执行adb reverse tcp:9222 tcp:9222设备不在列表里USB调试未开启或授权弹窗未确认重新插拔并确认授权弹窗混合App内打开H5inspect里看不到上下文WebView容器没有挂载到可调试目标上确认页面运行在哪个WebView实例上手机和电脑不在同一局域网远程调试场景端口转发无法穿透网络需要内网穿透或反向代理配置十分麻烦其中WebView开着调试模式这步最容易被忽略。很多App里的WebView出于安全考虑默认是关闭调试的找不到实际负责渲染的那个WebView类你就敲不开CDP的大门。即使链路全部通畅inspect还有另一个问题它依赖“设备-电脑”这条物理链路。在办公场景下你的真机可能在公司网络里而PC在一个隔离网段adb reverse能通但CDP的数据包被防火墙拦了表现依然是404或连接超时。inspect的另一个弱点是它只能调试当前正在交互的页面。一旦用户杀掉WebView进程重新进入调试会话就会断开。而很多线上问题恰恰发生在冷启动阶段等你连上inspect现场早就没了。1.3 倒逼出来的需求清单复盘了这两个方案之后我给自己列了一个需求清单零侵入不改业务代码不引入页面可见的UI生产环境可以长期挂着。跨设备连接真机在哪儿我就能从PC上远程看它的console不依赖USB物理链路。低门槛接入最好连SDK都不用引入或者引入的代价小到可以忽略。只看Console就够了初期不做performance、network这些重功能专注把console日志做扎实。这个清单最终指向了一个方向把移动端的console日志通过一条独立的通道推送出来在浏览器侧边栏用一个独立的用户界面来展示。这就有了题目里说的“原生移动端Console控制台”。2. 侧边栏控制台的整体链路与协议设计2.1 整体架构三个角色、一条通道为了把日志从移动端搬到侧边栏我设计了三个角色移动端采集SDK运行在目标页面上负责拦截console方法、收集日志并推送到网关。消息网关一个轻量的WebSocket中转服务负责维护会话、转发消息。浏览器侧边栏面板运行在PC浏览器侧边栏的调试界面接收网关转发过来的日志并渲染同时可以向移动端下发命令。链路是这样的移动端H5 WebView - WebSocket - 消息网关 - WebSocket - 浏览器侧边栏面板为什么中间要放一个网关而不是移动端直接连面板因为移动端和PC之间不一定在同一个网络。真机用4G/5G网络、PC在公司内网直连基本不可行。网关放在一个两边都能访问到的公网或内网服务器上天然解决了跨网络问题。而且网关还提供了房间隔离的能力不同的人调试不同的项目不会被彼此的日志串台。2.2 为什么自建日志通道而不是直接上CDP既然最终目标是像DevTools那样看console为什么不直接基于CDP做CDP确实提供了强大的调试能力但它依赖调试端口。在远程调试场景下你要么走USB/ADB要么在WebView里开启可调试模式并让调试端口能被外部访问。这个前提在真实业务里很难满足——大部分生产环境的WebView是不开调试的而且出于安全考虑也不该开。所以我选择了一条“绕过CDP”的路线不尝试连接WebView的调试端口而是在页面自己的JavaScript上下文里拦截console。SDK以脚本形式注入到页面这个注入可以是打包进项目也可以是运维平台统一注入在运行时把日志通过WebSocket主动推出来。它拿不到DOM断点、Network瀑布图这些高层次能力但换来的是极强的通用性只要有浏览器JavaScript环境就能用。2.3 消息协议一份简化到能记住的JSON格式协议设计上我只定义了两种消息类型LOG和CMD/RESULT。日志消息只需要保证“谁发的、什么级别、什么内容、什么时间”命令消息只需要保证“发了什么、返回什么”。一个典型的日志消息长这样{ type: LOG, roomId: abc-123, sessionId: device-uuid-01, level: error, method: error, args: [TypeError: Cannot read property x of undefined], stack: at clickBtn (main.js:120), ts: 1710000000000, seq: 42 }roomId标识一个调试会话sessionId标识设备seq是自增序号用于去重和排序。args统一用字符串数组表示解决了对象无法直接传输的问题。stack是可选的只有error级别才带上。命令消息我用了独立前缀{ type: CMD, cmdId: c-7, expression: location.href }返回结果会带着同样的cmdId回到面板这样面板就能把“执行结果”对应到“某一次命令输入”上。3. 移动端“原生”采集端的实现细节3.1 “原生”采集重写console方法而不依赖第三方库标题里说的“原生移动端Console控制台”中的“原生”我指的是采集端不依赖vConsole这类第三方调试库而是直接拦截JavaScript基础的console方法。实现原理很朴素先保存原始的console方法然后用包装函数覆盖const original { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console), debug: console.debug.bind(console) }; const LEVEL_MAP { log: log, info: info, warn: warn, error: error, debug: debug }; Object.keys(LEVEL_MAP).forEach((method) { console[method] (...args) { original[method](...args); capture(method, args); }; });注意必须先调用original的方法保证页面原有的日志输出行为不改变。这段脚本在整个页面初始化时执行越早越好否则你只能捕获到脚本注入之后产生的日志。在这之上还要补充全局异常捕获因为很多线上问题并不会以console打点的形式出现而是以uncaught error的形式冒出来window.addEventListener(error, (e) { capture(error, [e.message], e.error?.stack); }); window.addEventListener(unhandledrejection, (e) { const reason e.reason; const message reason instanceof Error ? reason.message : String(reason); capture(error, [Unhandled Promise Rejection: message], reason?.stack); });3.2 日志序列化让对象也能跨端传输console里最常见的参数是对象而JSON序列化对象会有一堆边界情况。比如一个对象里有循环引用直接JSON.stringify会抛错有BigInt会抛错有undefined或函数字段会被直接丢掉。如果日志里出现了这些异常值整个消息可能都发送不出去导致后续日志全部阻塞。所以我在capture函数里不能直接对参数做JSON.stringify而是用自定义的replacer来兜底function serializeArg(arg) { if (typeof arg string) return arg; if (typeof arg number !Number.isFinite(arg)) return String(arg); if (typeof arg bigint) return arg.toString() n; if (arg instanceof Error) return arg.message \n (arg.stack || ); const seen new Set(); try { return JSON.stringify(arg, (key, value) { if (typeof value bigint) return value.toString() n; if (value instanceof Error) return value.message; if (typeof value function) return [Function: ${value.name || anonymous}]; if (value undefined) return [undefined]; if (typeof value symbol) return value.toString(); if (typeof value object value ! null) { if (seen.has(value)) return [Circular]; seen.add(value); } return value; }, 2); } catch (err) { return [Unserializable] String(arg); } }序列化之后的长度也要做限制。真实业务里偶尔有某个库会打印一个几百KB的对象直接把网络带宽打满。我设置了单条日志最大10KB超长部分直接截断并追加...[Truncated]标记。这个策略在洗日志数据量大的场景里非常关键。3.3 发送策略队列缓冲、批量发送、断线重连移动端网络环境不稳定WebSocket随时可能断开。如果每产生一条日志就立刻send容易在断线瞬间丢失日志。我在SDK里加了一个环形队列作为缓冲只有在连接正常时才把队列里的日志批量发送。发送逻辑的伪代码const queue []; const MAX_QUEUE_SIZE 2000; let ws null; let retryCount 0; function enqueue(entry) { queue.push(entry); if (queue.length MAX_QUEUE_SIZE) { queue.shift(); } flushIfConnected(); } function flushIfConnected() { if (ws ws.readyState WebSocket.OPEN queue.length 0) { const batch queue.splice(0, queue.length); ws.send(JSON.stringify({ type: BATCH, items: batch })); } }批量发送而不是逐条发送的好处很明显减少小包数量降低网关的连接负担同时因为在队列里做了一次攒批日志吞吐大时性能更好。我实测在每秒500条日志的极端情况下批量发送不会出现明显延迟而逐条发送会直接把WebSocket拖垮。断线重连必须带指数退避。移动端网络抖动是常态如果断线后立刻高频重连不仅自己的CPU吃不消还可能被服务器误判为恶意连接function connect() { ws new WebSocket(WS_GATEWAY_URL ?roomId roomId sessionId sessionId); ws.onopen () { retryCount 0; // 连接成功后立即补发离线期间积累的日志 flushIfConnected(); }; ws.onclose () { const delay Math.min(1000 * Math.pow(2, retryCount), 30000) Math.random() * 500; retryCount; setTimeout(connect, delay); }; }连接成功后立即补发队列里攒下的日志这个设计保证了“用户切后台再回来”这个场景下日志不丢失。3.4 页面生命周期与性能兜底移动端H5经常被切到后台WebView在后台时JavaScript定时器会被系统节流甚至挂起。我做了两个处理一是在visibilitychange事件里感知页面状态切到后台时不再尝试flush二是在切回前台webSocket重连成功后再补发。此外为了不影响页面性能我把capture函数里的序列化和入队操作控制在极轻量级避免在真机上看到掉帧。4. 浏览器侧边栏面板的落地从“看日志”到“真控制台”4.1 为什么是“浏览器侧边栏”而不是新开一个DevTools一开始我做过一版独立的HTML页面就是新开一个浏览器标签里面显示日志列表。用了几次发现很不顺手调试的时候我的主视口要留着看业务页面、写代码再开一个标签页要来回切换Chrome的标签页多了以后定位麻烦。后来改成把面板塞进浏览器自带的侧边栏区域。Chrome支持side panel APIEdge浏览器自带侧边栏功能Firefox的扩展也支持sidebar。侧边栏的好处是它固定趴在浏览器视口的右侧或左侧主视口依然是业务页面左边写代码右边滚动日志视角不用来回跳。实现上我用了一个最简单的方式开发一个浏览器扩展扩展的side panel里加载一个远程的panel页面。这个panel页面和移动端SDK通过同一个网关会话关联起来由于扩展拥有跨域权限网页里不会有跨域限制的问题。4.2 日志渲染定时批量渲染而不是逐条append理论上日志到了面板端只需要append一条DOM就能显示但在高频日志场景下每帧append几十个DOM节点会让面板卡成幻灯片。我采用的方案是定时批量渲染把WebSocket收到的日志先推进一个pending数组每150毫秒把pending里的内容一次性追加到列表里。let pendingLogs []; setInterval(() { if (pendingLogs.length 0) return; const fragment document.createDocumentFragment(); pendingLogs.forEach((log) { const el createLogLine(log); fragment.appendChild(el); }); logList.appendChild(fragment); // 滚动到最新一条仅当用户已经滚到底部时 if (isScrolledToBottom()) { logList.scrollTop logList.scrollHeight; } pendingLogs []; }, 150);还要控制列表总长度。长时间挂着日志条目会无限增长DOM越来越多。我的上限是3000条超过后从头部移除1000条保证列表长度稳定滚动和搜索都还能保持流畅。UI上给每个级别做了颜色区分error是红底白字warn是黄底黑字info是普通白字debug是灰色。每条日志显示时间戳、设备sessionId前缀和日志内容。对象类型的日志用可展开的JSON树替代纯文本避免长文本把面板撑爆。4.3 命令回写从“单向监控”变“双向调试”只看日志是单向的监控很多问题在拿到日志之后还需要验证“如果是这个原因那执行某个表达式应该看到什么效果”。于是我在面板底部加了一个命令行输入框用法和在DevTools的Console面板里输表达式一样CMD_INPUT.addEventListener(keydown, async (e) { if (e.key Enter) { const expression CMD_INPUT.value; const cmdId cmd- (cmdCounter); ws.send(JSON.stringify({ type: CMD, cmdId, expression })); CMD_INPUT.value ; } });SDK收到CMD消息后在当前页面上下文里执行这段表达式然后把结果序列化通过RESULT消息送回来。这里有几个安全细节必须要处理只允许在面板连接的白名单域名下执行命令你不希望任何拿到链接的人都能在用户手机上执行任意JS。命令执行结果要经过序列化能返回的尽量返回函数、循环引用这些照前面serializeArg的逻辑处理。默认关闭命令回写功能需要在面板UI上手动点击“连接”后才开启而且开启后网关会在面板顶部显示一个明显的红色标识提醒当前处于可执行命令状态。实测下来这个能力非常强大。一次线上问题定位中我在面板里执行了一句document.querySelector(#main).getBoundingClientRect()立刻看到了用户手机上那个按钮的真实位置坐标——原来按钮被某个遮罩层挡住了这就是点击无效的直接原因。4.4 二维码与多设备管理为了免去手工输入roomId的麻烦面板打开时会生成一个包含会话参数的URL并渲染成二维码。移动端SDK注入页面上也可以自动带roomId参数。多个设备接入同一个roomId时面板左侧会显示设备列表每台设备一个sessionId可以单独只看某台设备的日志也可以合并看所有设备的日志流。这个能力在排查“某机型必现其他机型不现”的问题时特别有用。把同一页面分别装在两台不同机型上跑面板里并排对比两边的日志输出差异一目了然。5. 实测踩坑全记录四个让我熬夜的坑与排查链路5.1 打开面板看不到日志链路排查法第一个版本做完我自己先试用了一遍发现移动端连上后面板里一条日志都没有。我的第一反应是SDK采集出了问题但查了SDK代码和数据流发现日志已经通过WebSocket发出去了。这时候我的排查思路开始拉长——整个链路有四段移动端SDK有没有采集到日志网关有没有收到并转发面板端WebSocket有没有连上面板UI有没有渲染我在网关里临时加了一个实时流量统计接口每次收到消息就把计数累加并打点输出。然后复现一次操作发现网关收到的消息数量是0。这说明移动端SDK发送环节就有问题面板就算接收正常也是白搭。接着查SDK侧发现它的WebSocket连接从未成功建立。SDK连接的网关地址在移动端被测环境的网络安全策略里被拦了——那台测试机的网络环境是公司的内部灰度网络对公网WebSocket做了acl限制只放行少数端口。我改成在内网单独部署一个网关实例后问题立刻消失。这不是SDK代码的问题是部署拓扑的问题。但它在真实项目里很容易出现建议你在接入时提前确认好移动端网络环境和网关之间的连通性。5.2 日志突然断流且断流时间正好发生在手机锁屏真机在调试时我习惯打开面板后把手机放在旁边让它自己跑日志。有一次发现手机息屏一段时间后日志流就断了再亮屏时日志流恢复了但中间缺了一段。分析下来原因有两个。一是移动端浏览器或WebView在后台时JavaScript定时器会被系统冻结setInterval不触发批量发送逻辑完全停止执行。二是如果后台期间WebSocket连接被系统回收web页面根本感知不到断线直到切回前台才触发onclose回调。解决方案是监听visibilitychange事件。页面进入后台时立即停止批量发送定时器同时标记一个状态页面回到前台时马上检查WebSocket连接状态如果断了就重连并补发后台期间暂存的日志。对调试来说暂存日志的意义大于实时性用户就算锁屏只要再亮屏日志还是齐全的。5.3 某些页面日志量大时移动端出现肉眼可见的卡顿起初我以为日志量大导致卡顿是因为WebSocket发送太频繁做了批量发送优化后依旧卡。后来用面板上的performance信息才发现卡顿的根因在日志参数序列化环节。某些业务对象特别大比如一个包含上万条数据的数组serializeArg里的JSON.stringify在主线程上执行了上百毫秒。页面一出现这种日志整个页面的UI渲染就停住。排查链路是这样的先确认是否批量发送导致CPU打满排除再确认是否是DOM渲染导致排除最后在SDK里加了每一步耗时打点才发现耗时集中在序列化。解决方案是把序列化从同步改成异步。用setTimeout推迟到下一个macrotask执行让出主线程给UI渲染。如果日志对象实在太大直接降级为只序列化字段名和摘要不完整展开。5.4 重连后日志出现重复和乱序这个坑出现在断线重连之后。SDK在连接断开期间把日志攒到队列里恢复连接后把队列里所有日志一次性补发。但问题在于补发的日志和重连后新产生的日志都到了面板端顺序乱了而且有些日志被发送了两次。排查下来原因如下面板端收到的是无序消息因为WebSocket本身不保证重连后消息在服务端的顺序严格一致重复则是因为我没有在面板端做幂等。解决办法每一条日志消息都带一个seq序号面板端维护一个按sessionId分组的lastSeq收到消息后如果seq小于等于lastSeq就丢弃。同时在UI上按“sessionId seq”排序后再渲染而不是按WebSocket到达时间渲染。加了这两个改动后重复日志彻底消失乱序问题也解决了。5.5 命令回写失败表达式在错误的作用域里执行面板端执行location.href能反回正确结果但执行document.getElementById(btn)时却返回null。一开始我以为页面里没有这个元素但用户在手机上明明看到了按钮。研究之后发现SDK注入的脚本是在页面主world执行但某些WebView容器会创建多个JS context或者页面里存在跨域iframe面板下发的命令被SDK用eval执行时eval的作用域是SDK自身的闭包而SDK闭包里的document对象不一定指向用户想操作的那个页面document。解决办法是把表达式改为window.eval调用并且显式传入window和document的上下文const result window.eval(expression);同时在表达式执行前确认SDK挂载在目标页面的window全局对象上。如果页面里还嵌了iframe需要通过document.querySelector(iframe).contentWindow指定执行环境不能在面板端臆测。6. 这套方案在真实项目里的扩展玩法与使用建议6.1 线上问题定位从“小时级”降到“分钟级”接入这套方案之后我们最直观的收益是线上问题定位效率的提升。以前遇到用户反馈要么让用户配合装个测试包要么让技术人员远程连手机调试流程长且体验差。现在只要让出问题的用户扫一个二维码H5页面通过注入脚本把日志实时送到面板端开发在PC侧边栏里就能看到完整的console流很多问题在日志里直接就能定位。对一个团队来说这个方案还天然支持多人协作。一个roomId可以同时有多个移动端接入PC端也可以开多个面板窗口测试同学在手机上操作开发同学在面板上观察日志两边配合沟通成本低很多。6.2 建议补充这两个能力URL白名单与采样率控制安全意识不能等上线后再补。网关端建议默认开启白名单校验只有域名匹配才能在特定roomId里接入和推送日志。同时日志发送要有采样率控制不能日志量大时全量推建议支持三种模式debug模式全量推送任何级别日志用于联调阶段。warn模式只推送warn和error用于线上观察。silent模式SDK采集但不推送只打点计数用于性能压测。我建议给SDK增加一个动态配置获取逻辑SDK启动时先向网关拉取当前会话的配置再决定运行在哪种模式。这样线上事故发生时不需要重新发布脚本直接在面板端切换模式就能开启全量日志。6.3 最后聊点实际体会做这个工具的过程让我对“调试基础设施”有了新的理解。vConsole这种方案的优势是接入简单但它把调试UI强行塞进了产品页面这在开发和体验上是割裂的。而inspect依赖的CDP链路又太重它对网络环境、设备权限的要求在真实业务里往往是奢侈品。我们的侧边栏方案选取了一个中间值采集端保持轻量无侵入展示端完全独立于业务页面通信链路选最通用的WebSocket。这个取舍让它成为最适合内部团队日常使用的调试形态。如果你也想尝试类似方案我给你的建议是先把采集端做稳再去折腾UI。日志采集的稳定性决定了整个工具的可用性重连、序列化、队列缓冲这些细节任何一环掉链子都会让“看日志”变成“猜日志”。协议设计上不要太贪心先满足“能完整看到console日志”“能远程执行表达式”这两个核心诉求其他高级能力等有需要再去完善。移动端调试的答案从来不是某个开箱即用的银弹把链路玩明白了遇到新场景你才有底气自己搭一套趁手的工具。