sixty

面向 React Native 应用

你没法读别人手机上的日志。

移动应用是架在 API 之上的渲染层,所以几乎所有会坏的东西都会先在那里显现 —— 而返回内容的形状,在这里比在任何地方都更要紧:一个多返回十倍行数的接口,对服务器来说是一点内存,对手机来说是电量、是流量,最后是这个进程本身。Sixty 从应用内部测量这些,把崩溃按启动次数来计而不是孤立地数,并把每个版本和上一个作比较。

六种不抛任何异常就坏掉的方式

这些都不会报错、不会让请求失败,也不会把耗时拉到足以触发告警。它们是你的用户真正看到的东西。挑一个看看。

它测量什么

应用发出的每一次请求
花了多久、返回了什么、有多大、带了多少行 —— 埋点在 XHR 这一层,因为 `fetch` 和它上面的每一个 HTTP 客户端最后都会走到那里。
崩溃,和错误分开算
服务器上一个未捕获异常,丢掉的是一次请求。手机上它关掉的是某个人正拿在手里的应用。这不是同一种测量的程度差别,所以不会被平均到一起 —— 而且会把 `app_starts` 记为分母,因为「十四次崩溃」不是任何人能据此行动的数字。
每个界面的请求数,以及每次渲染的调用数
Babel 插件把组件变成父 span,正是这一点把「十七次请求」变成了「这个界面发十七次请求,而上个版本发三次」。
任何事情发生在哪个界面上
一个导航追踪器会给出当前界面的名字,于是一条发现能说出「在哪儿」,而不只是「是什么」。React Navigation 用两个 props 就能接上。
比会话活得更久的窗口
切到后台是大多数移动会话结束的方式,而 iOS 和 Android 都不会替一个已经不在运行的应用把请求发完。窗口在发送前会先写进存储,只有在确认收到 2xx 之后才清除,所以某人把手机揣回兜里之前的最后一个窗口,会在下次启动时送达 —— 挂在产生它的那个版本下,而不是现在装着的那个。

它能找到什么

这里的每一项都会和上一个版本作比较,所以发现指向的是那次改动,而不是某一天。

一个开始发十七次请求的界面

上个版本它发三次。每一次都很快,所以单看任何一次请求都没问题,也没有哪个耗时会变化到让人察觉 —— 错的只有次数,而在移动网络下,次数正是用户能感觉到的东西。

从外面看到的渲染循环

一个被调用得比以前频繁得多、而每次调用又没有变化的函数。当你看不到源码时,一个依赖不稳定的副作用看起来就是这样:不崩溃、没有失败的请求,只有一块在一个下午之内被耗空的电池。

一个开始返回全部数据的接口

同一个调用,十倍的行数。在服务器上那是一点内存和几毫秒。在设备上那是用户的流量,最后是操作系统把一个不断长大的应用杀掉,而不是由着它。

成功了、却返回空的调用

一个里面什么都没有的 200。界面渲染成空白,什么都不抛,而所有服务端指标都把这报告为健康。

被拒绝的会话

发往某个端点的调用里有一部分被以 401 或 403 回应。一个悄悄不再刷新的令牌,看起来就完全是这样,而且会持续好几个小时,然后第一条支持消息才会来。

有人正在用的时候,应用关掉了

致命的 JavaScript 错误按启动次数来计,所以这个数字是一个比率而不是一笔流水账 —— 而且按它们在代码中的来处归类,而不是按报错文案。

永远到不了崩溃上报工具的错误

那些被边界接住的:不崩溃,本该是界面的地方是一块空白,而用户点了返回、再试一次。

如何安装

三行代码,再加一行 Babel 插件。Metro 就是 Babel,所以 Node agent 用的那套转换在这里原封不动就能跑 —— 每界面请求数和渲染循环信号就是它换来的,静态检查规则也一并附带。

npm install @sixty-sh/react-native

// index.js — before anything else
import { init, composeRelease } from '@sixty-sh/react-native'

init({
  key: 'sixty_pk_…',
  service: 'my-app',
  // A native version alone reports every OTA bundle you push as the
  // same release, which is the one thing a comparison cannot survive.
  release: composeRelease({ version: '1.4.2', otaUpdateId }),
})

// babel.config.js
module.exports = { plugins: ['@sixty-sh/react-native/babel'] }

再装上 @react-native-async-storage/async-storage,持久化的发送队列就会自己工作 —— 没有它 agent 照样跑,只是会丢掉应用切到后台时还欠着的那些窗口。至于界面名称,把导航追踪器的两个处理函数传给你的 NavigationContainer。

它不做什么

  • 没有启动耗时,没有掉帧,没有卡顿。那些是移动端对应的 web vitals,而且没法从 JavaScript 里测 —— 它们需要一个原生模块,在 iOS 上读 CADisplayLink 或 MetricKit,在 Android 上读 JankStats。从 JS 线程去测帧率,测到的会是 JS 线程对帧率的看法,而那恰恰是应用卡顿时最不准的那个数字。
  • 没有原生崩溃。一个原生模块里的野指针永远到不了 JavaScript,所以这里数的是致命的 *JavaScript* 错误。
  • 发布构建归类归得好,定位定得差。Hermes 会把 bundle 编译成字节码,所以一个调用栈帧到达时是 index.android.bundle 里的一个偏移量,而不是你仓库里的一个文件。它依然是一个 bug 的稳定 identity —— 那是最要紧的性质 —— 但在有 sourcemap 上传之前,它不会链到你代码的某一行。开发构建里有真实路径。
  • 版本之间的比较没有做同期群划分,而这是这里最大的一个未决问题。服务器部署会替换掉正在跑的东西;移动端发布不会。它会在好几天里逐步推送,旧版本会无限期地继续跑,而先更新的用户往往设备更新、网络更好 —— 所以 A 和 B 的比较,有一部分是在跟一个正在移动的人群作比较。
  • 没有代理,所以也没有结构性的 IP 隐私。浏览器 agent 更好的那个模式不持有任何凭据,并把数据发往你自己的源站,正因如此,访客的请求从来不会到达收集端。一个应用没有自己的源站,所以那套安排在这里根本用不上,这个 agent 携带的是一个公开密钥。如果你本来就在运行一个 API,把 `endpoint` 指向它并从那里转发,这个性质就原封不动地回来了。

如果你是因为下面某一条来的

看看你的上一次改动做了什么。

试试看!

在做别的东西?可以看看 Node 服务、Lovable 应用 —— 那些页面不一样,我们能看到的东西也不一样。

面向 React Native 应用的性能与错误监控 — Sixty