# 登录链路埋点问题清单 —— 给 Android / iOS

> 2026-08-24 · 数据侧整理。窗口 **2026-07-24 → 2026-08-22**（30 天），表 `dino-english-497507.analytics_538991439`。
> 每条都在双端源码里逐行核对过，并用 BigQuery 实测过量级。**行号基于 2026-08-24 的本地代码。**
> 数据侧只读了代码，**一个字符都没改**。要改请在各自仓库里处理。

---

## 怎么读这份清单

分三组：**两端都要动** / **只给 Android** / **只给 iOS**。
每条给：现象 → 证据 → 影响 → 建议 → 怎么验收。

优先级只标了 🔴（数据现在就是错的 / 结论会指错方向）和 🟡（会越来越糟，但眼下不致命）。

---

# 一、两端都要动

## 🔴 1. `trace_id` 上报率近乎为零，两个事件串不起来

**现象**　窗口内 `auth_login_result` 共 13,975 条，带 `trace_id` 的只有 **12 条**（Android 12 / 10,737，iOS 0 / 3,238）。

**证据**
- 客户端确实传了：Android `LoginViewModel.kt:239`（`socialSignInTraceId = traceId`）、`:579`（`traceId = socialSignInTraceId`）、`PhoneLoginViewModel.kt:234/314/338`；iOS 同链路也传。
- 但线上几乎没有。**那 12 条恰好等于同窗口埋点 V2 的事件数（Android 12 条 `metric_value`）** —— 高度怀疑 `trace_id` 是跟着 V2 一起上线的，V1 那条路径没带上。**这条推断请你们确认。**

**影响**　`sdk_auth_result`（三方授权那一段单独计时）和 `auth_login_result`（全程计时）**无法按次配对**，
所以「后端换会话本身花了多久」算不出来 —— 只能拿「手机号登录被拒的耗时」当替代观测。

**建议**　确认 `trace_id` 是不是只在 V2 路径上传；如果是，V1 路径补上（或直接推进 V2 铺开）。

**怎么验**　查 `auth_login_result` 里 `trace_id` 非空的占比，按 app 版本分组。

---

## 🟡 2. 埋点 V2（1.5.4）不再上报「用哪种方式登录」

**现象**　V2 事件不带 `auth_method`。窗口内实测：

| 端 | 1.5.3 | 1.5.4 |
| --- | --- | --- |
| Android | 3,591 条全带 `auth_method` | 20 条里只有 8 条带 |
| iOS | 1,057 条全带 | 24 条里只有 1 条带 |

`diagnostic_step` 仍然在（V2 也是 20/20、24/24）。耗时字段从 `duration_ms` 换成了 `metric_value` + `metric_unit='ms'`。

**影响**　数仓侧**耗时已经兼容两套**（`COALESCE(duration_ms, metric_value)`），
但**方式字段没有替代来源**。V2 占比涨上来之后，所有按登录方式拆的表会长出一行「(未上报)」并越涨越大 ——
而且不报错、不变灰，看板上看不出来。

**建议**　告诉数据侧：方式在 V2 里挂到哪个字段。
如果答案是 `target`，请给出词表映射 —— `sdk_auth_result` 用的 `target` 是另一套词（`google_signin` ≠ `google`），
不能直接当 `auth_method` 用。

**怎么验**　按 app 版本查 `auth_login_result` 的 `auth_method` 非空率。

---

## 🟡 3. 耗时的计时起点跟着登录方式变 —— 不是 bug，但没写进文档

**现象**　两端行为一致，但**同一个 `duration_ms` 字段在不同登录方式下量的是不同的东西**：

| 方式 | 起点 | Android | iOS |
| --- | --- | --- | --- |
| 三方（Google/FB/Kakao/Apple） | 用户**点下登录按钮**，含在授权页上的操作时间 | `LoginViewModel.kt:238` | `LoginActionCoordinator.swift:113` |
| 手机号 | **调接口前一行**，纯一次往返 | `PhoneLoginViewModel.kt:232-241` | `LoginActionCoordinator.swift:242` |

**实测后果**　手机号中位 **约 290ms**，三方 **7~17 秒**。混在一个分位数里算出来的「登录耗时 8.6 秒」
**不对应任何一次真实登录** —— 既不是后端慢，也不是某个方式慢。

**影响**　任何不按方式拆开的「登录耗时」都是误导。数据侧已经改成一律按方式拆，不再给整体值。

**建议**　两条选一：
1. 在埋点文档里把这个差异写死，注明「不可跨方式比较」；**或**
2. 给三方链路补一个**不含用户操作**的分段耗时（拿到凭据 → 后端换会话完成），
   这样才能回答「我们自己的链路慢不慢」。

---

# 二、只给 Android

## 🔴 4. `auth_refresh_result` 六成失败，原因全是 `network_error` —— 语义存疑

**现象**　窗口内 `auth_refresh_result` 共 **41,705** 次：

| 结果 | 原因 | 次数 | 占比 |
| --- | --- | ---: | ---: |
| 技术故障 | `network_error` | **25,265** | **60.6%** |
| 成功 | — | 10,696 | 25.6% |
| 服务端拒绝 | `unauthorized` | 5,733 | 13.7% |
| 服务端拒绝 | `signature_failed` / `denied` | 11 | 0.0% |

（iOS 同一事件 3,544 次，`network_error` 占 36.8%。）

**影响**　按字面读，这意味着**六成的令牌续期失败、用户被踢回登录页**。
但这个量级不合常理 —— 同期登录总共才 10,737 次。**所以更可能是这个词的含义和字面不一样**，
比如「本来就不需要续期」也被记成了失败。

**这正是问题所在：数据侧没法从事件本身判断是哪种。**

**建议**　请说明：这个事件在**什么条件下上报**、**什么算失败**、`network_error` 具体涵盖哪些情况。
在答复之前，数据侧已经把这一节标成「语义待确认」，不上色、不判档、不进任何结论。

---

## 🟡 5. `GoogleSignInErrorClassifier` 的兜底方向建议反过来

**现象**　`GoogleSignInErrorClassifier.kt:72-84`，`isUserCancelled()` 处理 `GetCredentialCancellationException`：
消息既不匹配「用户取消」关键词、也不匹配「非用户取消」关键词时，**第 81 行 `return true`** —— 默认判成用户主动取消。

代码注释自己写了：「message 关键词启发式（最后兜底，**跨 GMS 版本不可靠**）」。

**影响**　把技术故障说成「用户放弃」，工程问题会**永久隐形** —— 它不进任何失败率，没人会去看。
反过来（默认算不确定）最多少算一点放弃率。**代价不对称。**

**建议**　兜底改成 `false`，或返回一个明确的「不确定」态，用单独的 `reason` 标出来。

**污染量已量化，眼下不大**：授权页「没真弹出来」的只有 17 次（8,493 次拉起里），
所以「约四分之一的人在授权页放弃」这个结论**不受影响**。这条是防将来，不是救火。

---

## 🟡 6. 三家三方登录都把「协程被取消」记成用户取消

**现象**　三处写法一样，`catch (e: CancellationException)` 后直接上报 `SocialAuthError.Cancelled`：
- `GoogleAuthManagerImpl.kt:104`
- `KakaoAuthManagerImpl.kt:109`
- `FacebookAuthManagerImpl.kt:135`

**影响**　协程被取消可能是页面销毁、上层超时、切后台 —— **用户未必取消了**。
这些混进「用户自己取消」，同样让技术问题隐形。

**建议**　终态可以仍记 `user_abort`（不改口径），但**换一个 `reason`**（比如 `coroutine_cancelled`），
和用户真的点了取消的 `cancelled` 分开。这样数据侧能分开看，改动也小。

---

# 三、只给 iOS

## 🔴 7. `403` 仍然报成「服务端拒绝」，和 Android 相反

**现象**　同一个后端码，两端给出相反的终态：

| 端 | `403` 报成 | 版本 |
| --- | --- | --- |
| Android | `tech_fail` / `signature_failed` | 全部版本 |
| **iOS** | **`rejected` / `server_rejected`** | 1.5.0 与 1.5.3 都是 |

`403` 是签名失败 —— **我们自己的锅**，应该算技术故障。**Android 的判法是对的。**

**证据**　iOS 走 `LoginActionCoordinator.swift:739-745`：`HTTPError.server(let code, _)` 一律映射成
`result: .rejected, reason: .serverRejected` —— 没有对 403 做特例。

**影响**　两端的「技术故障」不是同一个东西，数据侧必须逐码折算才能对比，
而每加一个码就要改一次数仓。

**建议**　在那个 `switch` 里给 `403` 加特例 → `.techFail` / `.signatureFailed`。

**顺带：`40001` / `40002` 你们已经在 1.5.3 改好了**（实测 1.5.3+ 报 `rejected`，和 Android 一致），
这条不用再动，感谢。目前只剩 `403` 一个码不对齐。

---

## 🟡 8. `sdk_auth_result` 几乎不上报

**现象**　窗口内这个事件的条数：

| 端 | 条数 | 覆盖的三方 |
| --- | ---: | --- |
| Android | 7,337 | `google_signin` / `facebook` / `kakao` 三家齐全 |
| **iOS** | **69** | **只有 `facebook_sdk`** |

**影响**　这个事件是**唯一**能单独量「三方授权那一段」耗时的来源。
少了它，iOS 就无法做「放弃的人等了多久 vs 成功的人花了多久」的同段比较 ——
这个比较目前只能在 Android 做出来（放弃者中位 7.6 秒，成功者 9.4 秒）。

**建议**　确认 Apple 登录 / Google 登录路径上这个事件是不是漏接了。

---

## 🟡 9. `network_type` 初始值被硬编码成 `offline`

**现象**　`AppLaunch.swift:1699`：
```swift
Track.setEventCommonParameter(NetworkType.offline.rawValue, for: .networkType)
monitor.pathUpdateHandler = { path in ... }
monitor.start(queue: queue)
```
监听器**首次回调之前**上报的任何事件，都会带 `network_type=offline`。

**对比 Android**：`NetworkTypeProvider.kt:50-65`，缓存为 `null` 时会 `resolveNow()` **当场真读一次**再缓存 ——
不会出现假值。

**影响**　冷启动早期事件可能**误报离线**。看板在算「真实技术故障率」时会剔掉离线样本，误报会导致误剔。

**建议**　初值改成一个明确的「未知」枚举，或首次读取时同步真读一次（对齐 Android 的做法）。

**顺带**　`AppLaunch.swift:1710`：`networkType(for:)` 对未知 interface 兜底 `return .wifi` ——
未知网络会被记成 wifi。同样建议改成「未知」。

---

## 🟡 10. 用墙钟计时，切后台照走

**现象**　`LoginActionCoordinator.swift:772-773`：
```swift
private static func durationMS(since start: Date) -> Int {
    max(0, Int((Date().timeIntervalSince(start) * 1000).rounded()))
}
```

**影响**　三方授权页本来就在**另一个页面/App**，用户切走再回来，这一次就被记成几十分钟。
实测窗口内 iOS「第 1 步 · 技术故障」这一行：**75% 分位 3.0 分钟、95% 分位 8.8 分钟、99% 分位 28.3 分钟** ——
**污染已经漫过 75% 分位，整行的尾部都不能用**。

Android 用 `SystemClock.elapsedRealtime()`，同样含休眠，但至少不受系统时钟校正（NTP）影响。

**建议**　两端统一用单调时钟；如果可能，进入后台时打个标记或暂停计时，让数据侧能把这批剔掉。

---

## 🟡 11. `auth_refresh_result` 里 `unauthorized` 的终态又和 Android 相反

**现象**　令牌过期被拒这一类：

| 端 | 记成 |
| --- | --- |
| Android | `rejected` / `unauthorized`（5,733 次） |
| **iOS** | **`fail`** / `unauthorized`（8 次） |

**影响**　和第 7 条是**同一个毛病换了个事件**。数据侧目前只对「登录终态」做了状态码对齐，
**这个事件没有对齐**，所以两端的续期分类不能横着比。

**建议**　和第 7 条一起处理，统一「业务拒绝 vs 技术故障」的判定原则，而不是逐个事件打补丁。

---

# 四、一句话总结

- **最要紧的三条**：`trace_id` 没上报（#1）、`auth_refresh_result` 六成失败的语义（#4）、iOS 的 `403`（#7）。
- **有个系统性问题值得单独说**：#7 和 #11 说明「同一个后端结果，两端归成不同终态」不是偶发，而是**缺一份双端共同遵守的判定原则**。
  逐码打补丁的话，每加一个业务码就要两端各改一次、数仓再改一次。**建议先定原则，再对代码。**
- **已经修好的**：`40001` / `40002` 在 iOS 1.5.3 已改成和 Android 一致，这条不用再动。
