diff --git a/README.en.md b/README.en.md index e79ced10..94de25c8 100644 --- a/README.en.md +++ b/README.en.md @@ -24,6 +24,7 @@ Say it, and it is scheduled. When the time comes, it keeps the promise. - [Requirements](#requirements) - [Getting started](#getting-started) - [Pointing the app at a local API](#pointing-the-app-at-a-local-api) +- [Client reminder telemetry (Sentry)](#client-reminder-telemetry-sentry) - [Enabling real voice](#enabling-real-voice) - [Running the API on the host](#running-the-api-on-the-host) - [Quality gates](#quality-gates) @@ -191,6 +192,28 @@ A new username creates an account. An existing username verifies the password. On a physical device, allow port `8000` through the host firewall, keep phone and computer on the same network, and make sure the API listens on `0.0.0.0` (Compose does). `EXPO_PUBLIC_DEVICE_ID` can stay `device_001`; it identifies the WebSocket session. +## Client reminder telemetry (Sentry) + +Whether a reminder fires through the native or JS channel, whether it rang in the background, and which OEM permissions are missing, is recorded on the client in Sentry. Grafana dashboards still chart server Prometheus only. Leave the DSN unset to keep the SDK off; that is the default for local development. + +Copy the DSN from the Sentry project **Settings → Client Keys (DSN)** into `frontend/.env` (do not commit that file): + +```bash +# frontend/.env +EXPO_PUBLIC_SENTRY_DSN=https://@o.ingest.sentry.io/ +``` + +See [frontend/.env.example](frontend/.env.example). After changing `EXPO_PUBLIC_*`, rebuild the app (`npm run android`) so Expo bakes the value in. + +Never put a real DSN in the README or commit it. Events use closed-enum tags only (manufacturer, permission, channel, outcome). They do not include account, session, transcript, schedule titles, or coordinates. + +Optional and unrelated to sending events: + +| Variable | Where | Role | +| --- | --- | --- | +| `SENTRY_ORG` / `SENTRY_PROJECT` | Build env | Enables the `@sentry/react-native` plugin for source maps. Unset keeps the plugin off; the DSN can still send events | +| `SENTRY_AUTH_TOKEN` | Root `.env` | Grafana Sentry datasource reads Issues; **not** the client DSN | + ## Enabling real voice Compose injects database, JWT, and CORS into the API container. It does **not** pass Aliyun keys, so `docker compose up` uses the stand-in agent in development. @@ -292,7 +315,7 @@ There are three templates, depending on how you run things: | --- | --- | | [`.env.example`](.env.example) | `docker compose`: database, API, JWT, CORS, observability | | [`backend/.env.example`](backend/.env.example) | Host `uvicorn`: database URL, JWT, voice, maps, tracing | -| [`frontend/.env.example`](frontend/.env.example) | Android client: API / WebSocket URLs, device ID | +| [`frontend/.env.example`](frontend/.env.example) | Android client: API / WebSocket URLs, device ID, optional `EXPO_PUBLIC_SENTRY_DSN` | Compose interpolates the root `.env` for `${VAR}` substitution. The API container only receives the backend allowlist in `docker-compose.yml`; `GRAFANA_*` and `SENTRY_AUTH_TOKEN` go to Grafana only. @@ -311,6 +334,14 @@ Compose interpolates the root `.env` for `${VAR}` substitution. The API containe | `SENTRY_AUTH_TOKEN` | Grafana → sentry.io; not the app DSN | | `TIMEFLOW_OTEL_EXPORTER_OTLP_ENDPOINT` | Empty disables traces; the overlay defaults to `http://tempo:4318` | +`frontend/.env` values the app reads: + +| Variable | Notes | +| --- | --- | +| `EXPO_PUBLIC_API_URL` / `EXPO_PUBLIC_WS_URL` | See [Pointing the app at a local API](#pointing-the-app-at-a-local-api) | +| `EXPO_PUBLIC_DEVICE_ID` | WebSocket session id; `device_001` is fine in development | +| `EXPO_PUBLIC_SENTRY_DSN` | Optional; empty keeps the Sentry SDK off. Copy from Sentry Client Keys; do not commit | + ## Cloud deploy and observability CI on `main` publishes `ghcr.io/1024xengineer/timeflow-backend:latest`. Grafana, Prometheus, and Tempo stay official images; their config lives under [`observability/`](observability/README.md). diff --git a/README.md b/README.md index 8c381eca..58ba3790 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ TimeFlow 是一款语音优先的个人日程助手,帮你用说话的方式 - [环境要求](#环境要求) - [快速上手](#快速上手) - [客户端如何连上本机 API](#客户端如何连上本机-api) +- [客户端提醒埋点(Sentry)](#客户端提醒埋点sentry) - [开启真实语音](#开启真实语音) - [本机开发后端](#本机开发后端) - [质量检查](#质量检查) @@ -191,6 +192,28 @@ npm start 真机还需要:电脑防火墙放行 `8000`,手机和电脑在同一网络,且后端监听 `0.0.0.0`(Compose 默认如此)。`EXPO_PUBLIC_DEVICE_ID` 可保持 `device_001`,用于 WebSocket 会话标识。 +## 客户端提醒埋点(Sentry) + +提醒走原生还是 JS 通道、后台是否响铃、缺哪些厂商权限,客户端记到 Sentry。服务端 Grafana 看板仍只画 Prometheus。未配置 DSN 时 SDK 保持关闭,日常开发可以不填。 + +在 Sentry 项目 **Settings → Client Keys (DSN)** 复制 DSN,写入 `frontend/.env`(该文件不要提交): + +```bash +# frontend/.env +EXPO_PUBLIC_SENTRY_DSN=https://@o.ingest.sentry.io/ +``` + +模板见 [frontend/.env.example](frontend/.env.example)。改完 `EXPO_PUBLIC_*` 后需要重新编译客户端(`npm run android`),Expo 才会打进包。 + +不要把真实 DSN 写进 README 或提交到 Git。事件只带封闭枚举标签(厂商、权限、通道、结果),不上报账号、session、语音原文、日程标题或坐标。 + +可选、与上报无关: + +| 变量 | 位置 | 作用 | +| --- | --- | --- | +| `SENTRY_ORG` / `SENTRY_PROJECT` | 构建环境 | 打开 `@sentry/react-native` 插件以上传 source map;不设则插件关闭,DSN 仍可上报 | +| `SENTRY_AUTH_TOKEN` | 仓库根 `.env` | Grafana Sentry 数据源读 Issues;**不是**客户端 DSN | + ## 开启真实语音 Compose 默认只把数据库、JWT 和 CORS 注入 API 容器,**不会**带上阿里云密钥。因此 `docker compose up` 在 development 下走占位助手。 @@ -293,7 +316,7 @@ npm run test:coverage | --- | --- | | [`.env.example`](.env.example) | `docker compose`:数据库、API 端口、JWT、CORS、观测 | | [`backend/.env.example`](backend/.env.example) | 本机 `uvicorn`:数据库 URL、JWT、语音、地图、追踪 | -| [`frontend/.env.example`](frontend/.env.example) | Android 客户端:API / WebSocket 地址、设备 ID | +| [`frontend/.env.example`](frontend/.env.example) | Android 客户端:API / WebSocket 地址、设备 ID、可选 `EXPO_PUBLIC_SENTRY_DSN` | 仓库根 `.env` 供 Compose 做 `${VAR}` 插值。API 容器只注入 `docker-compose.yml` 列出的后端变量;`GRAFANA_*` / `SENTRY_AUTH_TOKEN` 只进 Grafana。 @@ -312,6 +335,14 @@ npm run test:coverage | `SENTRY_AUTH_TOKEN` | Grafana 读 sentry.io;不是 App 的 DSN | | `TIMEFLOW_OTEL_EXPORTER_OTLP_ENDPOINT` | 空则不上报 trace;观测 overlay 默认 `http://tempo:4318` | +`frontend/.env` 里客户端会读取的项: + +| 变量 | 说明 | +| --- | --- | +| `EXPO_PUBLIC_API_URL` / `EXPO_PUBLIC_WS_URL` | 见「客户端如何连上本机 API」 | +| `EXPO_PUBLIC_DEVICE_ID` | WebSocket 会话标识,开发可保持 `device_001` | +| `EXPO_PUBLIC_SENTRY_DSN` | 可选;空则 Sentry SDK 关闭。取值来自 Sentry 项目 Client Keys,不要提交 | + ## 云上部署与观测 `main` 上的 CI 会把后端打成 `ghcr.io/1024xengineer/timeflow-backend:latest`。观测三件套是 Grafana / Prometheus / Tempo 官方镜像,配置在 [`observability/`](observability/README.md)。 diff --git a/frontend/.env.example b/frontend/.env.example index d15fbb5b..19d68c71 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,8 @@ EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 EXPO_PUBLIC_WS_URL=ws://10.0.2.2:8000/ws EXPO_PUBLIC_DEVICE_ID=device_001 + +# Optional. Copy the DSN from Sentry → Settings → Client Keys. +# When unset, the Sentry SDK stays disabled (no client events). +# Do not commit frontend/.env. Do not put account, session, transcript, or schedule titles in events. +EXPO_PUBLIC_SENTRY_DSN= diff --git a/frontend/app.config.js b/frontend/app.config.js index 250efe7f..bf416f98 100644 --- a/frontend/app.config.js +++ b/frontend/app.config.js @@ -69,6 +69,22 @@ module.exports = { defaultChannel: 'timeflow-reminders', }, ], + ...sentryPlugin(), ], }, }; + +function sentryPlugin() { + if (!process.env.SENTRY_ORG || !process.env.SENTRY_PROJECT) { + return []; + } + return [ + [ + '@sentry/react-native', + { + organization: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + }, + ], + ]; +} diff --git a/frontend/index.ts b/frontend/index.ts index 8c543392..80757185 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -8,8 +8,11 @@ import './src/infrastructure/location/reminderGuardTask'; import { registerRootComponent } from 'expo'; +import { initSentry, wrapRoot } from './src/infrastructure/observability/initSentry'; import App from './App'; +initSentry(); + // 注册根组件会向应用注册表登记主组件。 // 无论通过开发容器还是原生构建加载应用,它都会完成必要的运行环境设置。 -registerRootComponent(App); +registerRootComponent(wrapRoot(App)); diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java index b35f9b98..5525bcd2 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java @@ -33,6 +33,8 @@ final class AlarmContract { static final String PREFS_NAME = "timeflow_alarms"; static final String ALARMS_KEY = "pending_alarms"; static final String DISPOSITIONS_KEY = "native_dispositions"; + /** 后台响铃失败(FGS 被拒等),JS 下次起来再上报 Sentry。不含日程 id。 */ + static final String FIRE_ATTEMPTS_KEY = "native_fire_attempts"; static final String ALARM_URI_SCHEME = "timeflow-alarm"; /** * 自启动/后台弹出界面没有标准 API 能查真实授权状态,这三个 key 只记录 diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt index 5a53fbd7..afaacb4d 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt @@ -230,6 +230,33 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : } } + @ReactMethod + fun peekNativeFireAttempts(promise: Promise) { + try { + val records = AlarmNativeBridge.peekFireAttempts(reactContext) + val array: WritableArray = Arguments.createArray() + for (record in records) { + val item = Arguments.createMap() + item.putString("result", record.result) + item.putDouble("atMillis", record.atMillis.toDouble()) + array.pushMap(item) + } + promise.resolve(array) + } catch (error: Exception) { + promise.reject("PEEK_FIRE_ATTEMPTS_FAILED", error.message, error) + } + } + + @ReactMethod + fun ackNativeFireAttempts(promise: Promise) { + try { + AlarmNativeBridge.ackFireAttempts(reactContext) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("ACK_FIRE_ATTEMPTS_FAILED", error.message, error) + } + } + @ReactMethod fun getPermissionStatus(promise: Promise) { try { diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java index 54110769..507d1402 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java @@ -136,6 +136,93 @@ private static List loadDispositionObjects(Context context) { return objects; } + public static final class FireAttemptRecord { + public final String result; + public final long atMillis; + + FireAttemptRecord(String result, long atMillis) { + this.result = result; + this.atMillis = atMillis; + } + } + + public static final String RESULT_SERVICE_DENIED = "service_denied"; + public static final String RESULT_PRESENT_FAILED = "present_failed"; + public static final String RESULT_FALLBACK_NOTIFICATION = "fallback_notification"; + private static final int MAX_FIRE_ATTEMPTS = 20; + + public static void recordFireAttempt(Context context, String result) { + if (!RESULT_SERVICE_DENIED.equals(result) + && !RESULT_PRESENT_FAILED.equals(result) + && !RESULT_FALLBACK_NOTIFICATION.equals(result)) { + return; + } + JSONArray remaining = new JSONArray(); + for (JSONObject object : loadFireAttemptObjects(context)) { + remaining.put(object); + } + try { + JSONObject next = new JSONObject(); + next.put("result", result); + next.put("at", System.currentTimeMillis()); + remaining.put(next); + } catch (JSONException ignored) { + return; + } + JSONArray trimmed = new JSONArray(); + int start = Math.max(0, remaining.length() - MAX_FIRE_ATTEMPTS); + for (int index = start; index < remaining.length(); index++) { + try { + trimmed.put(remaining.getJSONObject(index)); + } catch (JSONException ignored) { + // skip malformed rows + } + } + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(AlarmContract.FIRE_ATTEMPTS_KEY, trimmed.toString()) + .apply(); + } + + public static List peekFireAttempts(Context context) { + List records = new ArrayList<>(); + for (JSONObject object : loadFireAttemptObjects(context)) { + String result = object.optString("result", ""); + if (result.isEmpty()) continue; + records.add(new FireAttemptRecord( + result, + object.optLong("at", System.currentTimeMillis()) + )); + } + return records; + } + + public static void ackFireAttempts(Context context) { + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(AlarmContract.FIRE_ATTEMPTS_KEY, "[]") + .apply(); + } + + private static List loadFireAttemptObjects(Context context) { + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.FIRE_ATTEMPTS_KEY, "[]"); + List objects = new ArrayList<>(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (value instanceof JSONObject) { + objects.add((JSONObject) value); + } + } + } catch (JSONException ignored) { + return new ArrayList<>(); + } + return objects; + } + public static void stopRinging(Context context) { AlarmSoundService.stop(context); RingActivity.finishIfOpen(); diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java index 06e91156..77a97217 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java @@ -63,6 +63,7 @@ public void onReceive(Context context, Intent intent) { // 原来这里完全没兜底,抛出去要么被系统吞掉、要么让这条广播直接崩溃退出, // 日志里连个痕迹都留不下。 Log.w(TAG, "startForegroundService failed for alarmId=" + alarmId, exception); + AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_SERVICE_DENIED); postFallbackNotification(context, title); } } diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java index 0c5951ae..2457030a 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java @@ -206,6 +206,7 @@ private void presentAlarm(AlarmContract.ExtractedExtras extras) { // 走到这个 catch,整条链路就是"通知也没弹、声音也没放、震动也没震", // 跟用户看到的现象完全对得上,但之前没有任何日志能证实。 Log.w(TAG, "presentAlarm failed for alarmId=" + alarmId, exception); + AlarmNativeBridge.recordFireAttempt(this, AlarmNativeBridge.RESULT_PRESENT_FAILED); // 真的什么都没展示:之前这里完全没告诉 presentNow() 的调用方,JS 侧会把 // 服务启动请求本身当成"已展示",跳过通知兜底——用户彻底看不到任何东西。 AlarmModule.resolvePresentation(alarmId, false); diff --git a/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmNativeBridgeFireAttemptTest.java b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmNativeBridgeFireAttemptTest.java new file mode 100644 index 00000000..75ffa0ee --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/test/java/com/timeflow/alarm/AlarmNativeBridgeFireAttemptTest.java @@ -0,0 +1,50 @@ +package com.timeflow.alarm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.os.Build; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +public class AlarmNativeBridgeFireAttemptTest { + + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + AlarmNativeBridge.ackFireAttempts(context); + } + + @Test + public void recordsAndPeeksClosedEnumResultsWithoutScheduleIds() { + AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_SERVICE_DENIED); + AlarmNativeBridge.recordFireAttempt(context, "not-a-real-result"); + AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_PRESENT_FAILED); + + List rows = AlarmNativeBridge.peekFireAttempts(context); + assertEquals(2, rows.size()); + assertEquals(AlarmNativeBridge.RESULT_SERVICE_DENIED, rows.get(0).result); + assertEquals(AlarmNativeBridge.RESULT_PRESENT_FAILED, rows.get(1).result); + assertTrue(rows.get(0).atMillis > 0); + } + + @Test + public void ackClearsPersistedFireAttempts() { + AlarmNativeBridge.recordFireAttempt(context, AlarmNativeBridge.RESULT_SERVICE_DENIED); + AlarmNativeBridge.ackFireAttempts(context); + assertTrue(AlarmNativeBridge.peekFireAttempts(context).isEmpty()); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d406795..0a9b16a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", + "@sentry/react-native": "~7.11.0", "expo": "~57.0.7", "expo-audio": "~57.0.3", "expo-location": "~57.0.9", @@ -3099,6 +3100,335 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.37.0.tgz", + "integrity": "sha512-rqdESYaVio9Ktz55lhUhtBsBUCF3wvvJuWia5YqoHDd+egyIfwWxITTAa0TSEyZl7283A4WNHNl0hyeEMblmfA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.37.0.tgz", + "integrity": "sha512-P0PVlfrDvfvCYg2KPIS7YUG/4i6ZPf8z1MicXx09C9Cz9W9UhSBh/nii13eBdDtLav2BFMKhvaFMcghXHX03Hw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.37.0.tgz", + "integrity": "sha512-snuk12ZaDerxesSnetNIwKoth/51R0y/h3eXD/bGtXp+hnSkeXN5HanI/RJl297llRjn4zJYRShW9Nx86Ay0Dw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.37.0", + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.37.0.tgz", + "integrity": "sha512-PyIYSbjLs+L5essYV0MyIsh4n5xfv2eV7l0nhUoPJv9Bak3kattQY3tholOj0EP3SgKgb+8HSZnmazgF++Hbog==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.37.0", + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.8.0.tgz", + "integrity": "sha512-cy/9Eipkv23MsEJ4IuB4dNlVwS9UqOzI3Eu+QPake5BVFgPYCX0uP0Tr3Z43Ime6Rb+BiDnWC51AJK9i9afHYw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.37.0.tgz", + "integrity": "sha512-kheqJNqGZP5TSBCPv4Vienv1sfZwXKHQDYR+xrdHHYdZqwWuZMJJW/cLO9XjYAe+B9NnJ4UwJOoY4fPvU+HQ1Q==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.37.0", + "@sentry-internal/feedback": "10.37.0", + "@sentry-internal/replay": "10.37.0", + "@sentry-internal/replay-canvas": "10.37.0", + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.4.tgz", + "integrity": "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.4", + "@sentry/cli-linux-arm": "2.58.4", + "@sentry/cli-linux-arm64": "2.58.4", + "@sentry/cli-linux-i686": "2.58.4", + "@sentry/cli-linux-x64": "2.58.4", + "@sentry/cli-win32-arm64": "2.58.4", + "@sentry/cli-win32-i686": "2.58.4", + "@sentry/cli-win32-x64": "2.58.4" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz", + "integrity": "sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.4.tgz", + "integrity": "sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.4.tgz", + "integrity": "sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.4.tgz", + "integrity": "sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.4.tgz", + "integrity": "sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.4.tgz", + "integrity": "sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.4.tgz", + "integrity": "sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.4.tgz", + "integrity": "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/core": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.37.0.tgz", + "integrity": "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.37.0.tgz", + "integrity": "sha512-XLnXJOHgsCeVAVBbO+9AuGlZWnCxLQHLOmKxpIr8wjE3g7dHibtug6cv8JLx78O4dd7aoCqv2TTyyKY9FLJ2EQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.37.0", + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react-native": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-7.11.0.tgz", + "integrity": "sha512-OiDaLCAGpRN18YG/o7IIwLhU0Xpb0tYKQ5QxkGHiwb+L3VHn+MqGCGfITYNdhqr06HHMvu9Lysm+UJxaNmGaJg==", + "license": "MIT", + "dependencies": { + "@sentry/babel-plugin-component-annotate": "4.8.0", + "@sentry/browser": "10.37.0", + "@sentry/cli": "2.58.4", + "@sentry/core": "10.37.0", + "@sentry/react": "10.37.0", + "@sentry/types": "10.37.0" + }, + "bin": { + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@sentry/types": { + "version": "10.37.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-10.37.0.tgz", + "integrity": "sha512-umpnUKRC0AAbJrADg6SlFtqN2yzf7NHciCF9lkHau+ax2PIZ/NDmoG4RQujFVflVaVoD60Ly2t+CcPnYIWMPlw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.37.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", @@ -12477,6 +12807,12 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 401a61c8..b07818bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", + "@sentry/react-native": "~7.11.0", "expo": "~57.0.7", "expo-audio": "~57.0.3", "expo-location": "~57.0.9", diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index 6834d61f..82a1dbe3 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -27,6 +27,7 @@ import { openTimeflowDatabase } from '../infrastructure/database'; import { ExpoLocationProvider } from '../infrastructure/location/ExpoLocationProvider'; import { HomeScreen } from '../screens/HomeScreen'; import { LoginScreen } from '../screens/LoginScreen'; +import { boundManufacturer, boundOs, type ClientTelemetryPort } from '../shared/observability'; import { colors, spacing } from '../shared/ui/theme'; import { AppProviders } from './AppProviders'; import { createAppServices, type AppServices } from './composition/createAppServices'; @@ -59,6 +60,7 @@ export function AppRoot({ services: providedServices }: { services?: AppServices reminder={services.reminder} reminderState={services.reminderState} scheduleReader={services.schedules} + telemetry={services.reminderPorts.telemetry} webSocketClient={services.webSocketClient} /> @@ -73,6 +75,7 @@ function AuthRoute({ reminder, reminderState, scheduleReader, + telemetry, webSocketClient, }: { readonly alertDialog: AlertDialogPort; @@ -82,6 +85,7 @@ function AuthRoute({ readonly reminder: ReminderApplicationPort; readonly reminderState: SqliteReminderStateStore; readonly scheduleReader: SqliteLocalScheduleReader; + readonly telemetry?: ClientTelemetryPort; readonly webSocketClient: AuthenticatedWebSocketClient; }) { const { retryInitialization, viewState } = useAuth(); @@ -121,6 +125,7 @@ function AuthRoute({ reminder={reminder} reminderState={reminderState} scheduleReader={scheduleReader} + telemetry={telemetry} username={viewState.username} webSocketClient={webSocketClient} /> @@ -147,6 +152,7 @@ function AuthenticatedScheduleRoute({ reminder, reminderState, scheduleReader, + telemetry, username, webSocketClient, }: { @@ -158,6 +164,7 @@ function AuthenticatedScheduleRoute({ readonly reminder: ReminderApplicationPort; readonly reminderState: SqliteReminderStateStore; readonly scheduleReader: SqliteLocalScheduleReader; + readonly telemetry?: ClientTelemetryPort; readonly username: string; readonly webSocketClient: AuthenticatedWebSocketClient; }) { @@ -174,11 +181,15 @@ function AuthenticatedScheduleRoute({ let active = true; void device.getStatus().then((status) => { if (active) setPermissionGate(status.permissions.notifications ? 'clear' : 'gated'); + telemetry?.setDeviceContext({ + manufacturer: boundManufacturer(status.oemGuidance.manufacturer), + os: boundOs(status.platform), + }); }); return () => { active = false; }; - }, [device]); + }, [device, telemetry]); const handleRequestPermission = useCallback((permission?: DevicePermission) => { setHighlightPermission(permission ?? null); diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 04743f1e..53fd3b13 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -28,6 +28,10 @@ import { ReactNativeVibration, } from '../../infrastructure/notifications'; import { IntervalTimeListener } from '../../infrastructure/time'; +import { SentryClientTelemetry } from '../../infrastructure/observability'; +import { RNAppStateProvider } from '../../infrastructure/appState/RNAppStateProvider'; +import { setGuardTaskTelemetry } from '../../infrastructure/location/reminderGuardTask'; +import { boundAppState } from '../../shared/observability'; import { ScheduleViewStore } from '../../features/schedule/presentation'; export interface CreateAppServicesOptions { @@ -65,6 +69,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe ...restOverrides } = options.overrides ?? {}; + const appStateProvider = new RNAppStateProvider(); const reminderPorts: ReminderApplicationDependencies = { time: new IntervalTimeListener(), location: new ExpoLocationMonitor(), @@ -78,10 +83,19 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe recovery: new LocalReminderRecovery(), state: reminderState, dispositionSync: new ReminderDispositionHttpSync(auth.protectedClient), + telemetry: new SentryClientTelemetry(), + lifecycle: { + current: () => boundAppState(appStateProvider.current()), + subscribe: (listener) => + appStateProvider.subscribe((status) => listener(boundAppState(status))), + }, ...restOverrides, schedules, presenter, }; + if (reminderPorts.telemetry != null) { + setGuardTaskTelemetry(reminderPorts.telemetry); + } const reminder = new LocalReminderApplication(reminderPorts); const reminderGuard = new ReminderGuardCoordinator({ diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index be99cb3c..e287c411 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -1,3 +1,21 @@ +import { + NOOP_CLIENT_TELEMETRY, + boundManufacturer, + boundNativeBackgroundResult, + boundOs, + boundPermissions, + countBucket, + isLateLatency, + latencyBucket, + latencyBucketFromTimes, + type ClientTelemetryPort, + type ReminderDeliveryOutcome, + type ReminderTelemetryChannel, + type ReminderTriggerSource, + type TelemetryAppState, + type TelemetryManufacturer, + type TelemetryOs, +} from '../../../shared/observability'; import type { ReminderApplicationDependencies, ReminderApplicationPort, @@ -9,6 +27,7 @@ import type { LocationWatchHandle, AlarmScheduleReceipt, AlarmNativeEvent, + DeviceCapabilityStatus, DevicePermission, } from './interfaces'; import type { @@ -42,6 +61,36 @@ type RegistrationRecord = ReminderRegistration & { const EMPTY_CHANNELS: ReminderDeliveryReceipt['channels'] = []; /** 跟 reminderGuardTask.ts 的同名常量保持一致——两处合起来覆盖会话存活/已死两种场景。 */ const STUCK_PENDING_THRESHOLD_MS = 2 * 60_000; +/** 回到前台后这么久内的迟到 JS 送达,算「必须回 App 才响」。 */ +const RESUME_CATCHUP_WINDOW_MS = 90_000; + +type DeviceTelemetrySnapshot = { + manufacturer: TelemetryManufacturer; + os: TelemetryOs; + overlayFailed: boolean; + permissions: DeviceCapabilityStatus['permissions']; + platform: DeviceCapabilityStatus['platform']; + supported: boolean; +}; + +const DENIED_PERMISSIONS: DeviceCapabilityStatus['permissions'] = { + battery_optimization: false, + exact_alarm: false, + full_screen: false, + location_background: false, + location_foreground: false, + microphone: false, + notifications: false, + overlay: false, +}; + +const CHANNEL_PRIORITY: readonly DeliveryChannel[] = [ + 'popup', + 'system_notification', + 'tts', + 'local_sound', + 'vibration', +]; function emptyRegistration(scheduleId: string): ReminderRegistration { return { @@ -59,6 +108,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { private unsubscribePresenter: (() => void) | null = null; private unsubscribeSchedules: (() => void) | null = null; private unsubscribeAlarms: (() => void) | null = null; + private unsubscribeLifecycle: (() => void) | null = null; private readonly registrations = new Map(); private readonly activeDeliveries = new Set(); private readonly deliverLocks = new Set(); @@ -73,8 +123,15 @@ export class LocalReminderApplication implements ReminderApplicationPort { /** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */ private generation = 0; private acceptingWork = true; - - constructor(readonly dependencies: ReminderApplicationDependencies) {} + private readonly telemetry: ClientTelemetryPort; + private appState: TelemetryAppState = 'unknown'; + private resumedAtMs: number | null = null; + private lastBackgroundedAtMs: number | null = null; + + constructor(readonly dependencies: ReminderApplicationDependencies) { + this.telemetry = dependencies.telemetry ?? NOOP_CLIENT_TELEMETRY; + this.appState = dependencies.lifecycle?.current() ?? 'unknown'; + } async start(): Promise { return this.enqueueOp(() => this.startInternal()); @@ -194,6 +251,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.dependencies.alarms.subscribe?.((event) => { void this.handleNativeAlarmEvent(event).catch(() => undefined); }) ?? null; + this.unsubscribeLifecycle = + this.dependencies.lifecycle?.subscribe((state) => { + this.handleLifecycle(state); + }) ?? null; + this.appState = this.dependencies.lifecycle?.current() ?? this.appState; + this.resumedAtMs = Date.now(); // IntervalTimeListener 不在 start 时同步打点;先挂上 listener id 再 rebuild。 const timeHandle = await this.dependencies.time.start( @@ -214,6 +277,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { return; } + await this.hydrateNativeFireAttempts(); + if (!this.isLive(generation)) { + await this.stopInternal(); + return; + } + this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { void this.enqueueRebuild(); }); @@ -222,6 +291,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { await this.stopInternal(); return; } + await this.maybeReportOverdueUnarmed(); this.started = true; } catch (error) { if (this.isLive(generation)) this.invalidate(); @@ -259,6 +329,8 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribeSchedules = null; this.unsubscribeAlarms?.(); this.unsubscribeAlarms = null; + this.unsubscribeLifecycle?.(); + this.unsubscribeLifecycle = null; } private async registerInternal( @@ -808,6 +880,28 @@ export class LocalReminderApplication implements ReminderApplicationPort { return this.stoppedReceipt(trigger); } + if (presentedNatively) { + await this.recordDeliveryTelemetry({ + schedule, + outcome: 'native_ok', + channel: 'native_full_screen', + source: triggerSourceFromReason(trigger.reason), + observedAt: trigger.triggered_at, + usedFallbackAudio: false, + nativeArmed: this.registrations.get(schedule.id)?.alarm_id != null, + }); + } else if (!this.nativePresented.has(schedule.id) && channels.length > 0) { + await this.recordDeliveryTelemetry({ + schedule, + outcome: 'js_channel', + channel: primaryDeliveryChannel(channels), + source: triggerSourceFromReason(trigger.reason), + observedAt: trigger.triggered_at, + usedFallbackAudio, + nativeArmed: this.registrations.get(schedule.id)?.alarm_id != null, + }); + } + return { delivery_id: deliveryId, schedule_id: schedule.id, @@ -836,6 +930,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { try { await this.track(this.runDeliver(trigger, generation)); } catch { + this.telemetry.recordUnexpectedError('reminder_delivery'); // 单条送达失败不阻断其余日程。 } } @@ -864,6 +959,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { if (!event.schedule_id) return; if (event.type === 'fired') { await this.acknowledgeNativeFire(event.schedule_id, event.at); + await this.recordNativeAlarmFired(event.schedule_id, event.at); return; } if (event.type === 'snoozed') { @@ -1118,6 +1214,9 @@ export class LocalReminderApplication implements ReminderApplicationPort { 'battery_optimization', 'notifications', ]); + if (!receipt.scheduled) { + await this.recordNativeScheduleFailure(schedule, triggerAt); + } return receipt; } @@ -1131,14 +1230,184 @@ export class LocalReminderApplication implements ReminderApplicationPort { permissions: readonly DevicePermission[], ): Promise { const status = await this.dependencies.device.getStatus(); + this.syncDeviceContext(status); if (status.platform !== 'android') return; const missing = permissions.filter((permission) => !status.permissions[permission]); if (missing.length === 0) return; + this.telemetry.recordReminderPermissionBlocked({ + manufacturer: boundManufacturer(status.oemGuidance.manufacturer), + missing: boundPermissions(missing), + }); for (const listener of this.permissionBlockedListeners) { listener({ schedule_id: scheduleId, missing }); } } + private async recordNativeAlarmFired(scheduleId: string, firedAt: string): Promise { + const schedule = + (await this.dependencies.schedules.getReminderSchedule(scheduleId)) ?? + this.registrations.get(scheduleId)?.schedule ?? + null; + await this.recordDeliveryTelemetry({ + schedule, + outcome: 'native_ok', + channel: 'native_full_screen', + source: 'native_alarm', + observedAt: firedAt, + usedFallbackAudio: false, + nativeArmed: true, + }); + } + + private async recordNativeScheduleFailure( + schedule: LocalReminderSchedule, + triggerAt: string, + ): Promise { + const snapshot = await this.snapshotDevice(); + const outcome = classifyNativeScheduleFailure(snapshot); + if (outcome == null) return; + await this.recordDeliveryTelemetry({ + schedule, + outcome, + channel: 'native_full_screen', + source: 'js_time', + observedAt: triggerAt, + usedFallbackAudio: false, + nativeArmed: false, + forceDeferred: false, + }); + } + + private async recordDeliveryTelemetry(input: { + schedule: LocalReminderSchedule | null; + outcome: ReminderDeliveryOutcome; + channel: ReminderTelemetryChannel; + source: ReminderTriggerSource; + observedAt: string; + usedFallbackAudio: boolean; + nativeArmed: boolean; + forceDeferred?: boolean; + }): Promise { + const snapshot = await this.snapshotDevice(); + const appState = this.dependencies.lifecycle?.current() ?? this.appState; + this.appState = appState; + const latency = latencyBucketFromTimes( + input.schedule == null ? null : resolveEffectiveTriggerAt(input.schedule), + input.observedAt, + ); + const deferred = + input.forceDeferred === false + ? false + : this.isDeferredUntilForeground(input.source, latency, appState); + this.telemetry.recordReminderDelivery({ + app_state: appState, + channel: input.channel, + deferred_until_foreground: deferred, + latency_bucket: latency, + manufacturer: snapshot.manufacturer, + native_armed: input.nativeArmed, + outcome: input.outcome, + overlay_failed: snapshot.overlayFailed, + schedule_type: input.schedule?.schedule_type ?? 'time', + strength: input.schedule?.reminder?.reminder_strength ?? 'medium', + trigger_source: input.source, + used_fallback_audio: input.usedFallbackAudio, + }); + } + + private isDeferredUntilForeground( + source: ReminderTriggerSource, + latency: ReturnType, + appState: TelemetryAppState, + ): boolean { + if (source !== 'js_time' && source !== 'stuck_pending') return false; + if (appState === 'background') return false; + if (!isLateLatency(latency)) return false; + if (this.resumedAtMs == null) return false; + return Date.now() - this.resumedAtMs < RESUME_CATCHUP_WINDOW_MS; + } + + private handleLifecycle(state: TelemetryAppState): void { + const previous = this.appState; + this.appState = state; + if (state === 'background' || state === 'inactive') { + this.lastBackgroundedAtMs = Date.now(); + return; + } + if (state !== 'active' || previous === 'active') return; + this.resumedAtMs = Date.now(); + void this.maybeReportOverdueUnarmed(); + } + + private async maybeReportOverdueUnarmed(): Promise { + const observedAt = new Date().toISOString(); + let overdue = 0; + for (const registration of this.registrations.values()) { + if (registration.schedule.schedule_type !== 'time') continue; + if (registration.alarm_id != null) continue; + if (!isTimeWindowReached(registration.schedule, observedAt)) continue; + overdue += 1; + } + if (overdue === 0) return; + const snapshot = await this.snapshotDevice(); + this.telemetry.recordReminderLifecycle({ + background_duration_bucket: latencyBucket( + this.lastBackgroundedAtMs == null ? null : Date.now() - this.lastBackgroundedAtMs, + ), + kind: 'foreground_resume', + manufacturer: snapshot.manufacturer, + overdue_unarmed: countBucket(overdue), + }); + } + + private async hydrateNativeFireAttempts(): Promise { + const rows = await this.dependencies.alarms.peekNativeFireAttempts?.(); + if (rows == null || rows.length === 0) return; + const snapshot = await this.snapshotDevice(); + const seen = new Set(); + for (const row of rows) { + const result = boundNativeBackgroundResult(row.result); + if (result == null || seen.has(result)) continue; + seen.add(result); + this.telemetry.recordReminderNativeBackground({ + manufacturer: snapshot.manufacturer, + result, + }); + } + await this.dependencies.alarms.ackNativeFireAttempts?.(); + } + + private async snapshotDevice(): Promise { + try { + const status = await this.dependencies.device.getStatus(); + this.syncDeviceContext(status); + return { + manufacturer: boundManufacturer(status.oemGuidance.manufacturer), + os: boundOs(status.platform), + overlayFailed: status.oemGuidance.lastOverlayFailed, + permissions: status.permissions, + platform: status.platform, + supported: status.supported, + }; + } catch { + return { + manufacturer: 'other', + os: 'other', + overlayFailed: false, + permissions: DENIED_PERMISSIONS, + platform: 'unknown', + supported: false, + }; + } + } + + private syncDeviceContext(status: DeviceCapabilityStatus): void { + this.telemetry.setDeviceContext({ + manufacturer: boundManufacturer(status.oemGuidance.manufacturer), + os: boundOs(status.platform), + }); + } + private buildTrigger( schedule: LocalReminderSchedule, reason: ReminderTriggerReason, @@ -1187,6 +1456,24 @@ function toDeliveryRequest( }; } +function primaryDeliveryChannel(channels: readonly DeliveryChannel[]): ReminderTelemetryChannel { + for (const candidate of CHANNEL_PRIORITY) { + if (channels.includes(candidate)) return candidate; + } + return channels[0] ?? 'popup'; +} + +function classifyNativeScheduleFailure( + snapshot: DeviceTelemetrySnapshot, +): ReminderDeliveryOutcome | null { + if (snapshot.platform !== 'android') return null; + if (!snapshot.supported) return 'native_unavailable'; + if (!snapshot.permissions.exact_alarm || !snapshot.permissions.notifications) { + return 'native_declined'; + } + return 'native_unavailable'; +} + /** * 原生闹钟响铃时要不要震动/弹全屏止铃界面、声音走哪个档位。全屏恒为真(时间型 * 提醒没有别的可见形式,静音也得让用户看到),vibrate/soundTier 复用 JS 侧的 @@ -1208,6 +1495,12 @@ function alarmRingChannels(schedule: LocalReminderSchedule): { }; } +function triggerSourceFromReason(reason: ReminderTriggerReason): ReminderTriggerSource { + if (reason === 'arrive_location' || reason === 'return_to_recorded_location') return 'location'; + if (reason === 'stuck_pending') return 'stuck_pending'; + return 'js_time'; +} + function toTimeReason(schedule: LocalReminderSchedule): ReminderTriggerReason { return schedule.reminder?.reminder_type === 'before_start' ? 'before_start' : 'at_time'; } diff --git a/frontend/src/features/reminder/application/index.ts b/frontend/src/features/reminder/application/index.ts index 8af0089c..568b699d 100644 --- a/frontend/src/features/reminder/application/index.ts +++ b/frontend/src/features/reminder/application/index.ts @@ -4,6 +4,7 @@ export type { ReminderGuardDependencies } from './ReminderGuardCoordinator'; export type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmNativeFireAttempt, AlarmPresentationReceipt, AlarmPresentationRequest, AlarmScheduleReceipt, @@ -37,6 +38,7 @@ export type { ReminderDeliveryPort, ReminderDeliveryReceipt, ReminderDeliveryRequest, + ReminderLifecyclePort, ReminderDispositionSyncPort, ReminderDispositionSyncReceipt, ReminderPermissionBlockedEvent, diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index 97237aa3..787d3eb0 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -35,6 +35,12 @@ export type AlarmNativeDisposition = { updated_at: string; }; +/** 原生在 JS 被冻住时记下的后台响铃失败,冷启动后再交给埋点。 */ +export type AlarmNativeFireAttempt = { + result: 'service_denied' | 'present_failed' | 'fallback_notification'; + at: string; +}; + export type AlarmPresentationRequest = { alarm_id: string; schedule_id: string; @@ -65,6 +71,10 @@ export interface AlarmSchedulerPort { peekNativeDispositions?(): Promise; /** 确认对应 schedule_id 已经在 JS 侧落盘成功,原生缓冲区才真正删除这批记录。 */ ackNativeDispositions?(scheduleIds: readonly string[]): Promise; + /** 读取进程外记下的后台响铃失败(FGS 被拒、startForeground 抛错),不清空。 */ + peekNativeFireAttempts?(): Promise; + /** JS 上报成功后清空原生失败缓冲。 */ + ackNativeFireAttempts?(): Promise; /** 立即交给原生全局响铃页;不可用时返回 presented=false,由上层回退。 */ presentNow?(request: AlarmPresentationRequest): Promise; } diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts index 0f86f8a7..6e340471 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts @@ -1,3 +1,4 @@ +import type { ClientTelemetryPort } from '../../../../shared/observability'; import type { LocalReminderSchedule, LocationSample, @@ -22,6 +23,10 @@ export type ReminderApplicationDependencies = { recovery: import('./ReminderRecoveryPort').ReminderRecoveryPort; state: import('./ReminderStateStore').ReminderStateStore; dispositionSync: import('./ReminderDispositionSyncPort').ReminderDispositionSyncPort; + /** 客户端埋点;缺省为 NoOp,单测不用接 Sentry。 */ + telemetry?: ClientTelemetryPort; + /** 前后台状态;缺省则 app_state=unknown,不把送达标成回前台补响。 */ + lifecycle?: import('./ReminderLifecyclePort').ReminderLifecyclePort; }; export type ReminderSnoozeRequest = { diff --git a/frontend/src/features/reminder/application/interfaces/ReminderLifecyclePort.ts b/frontend/src/features/reminder/application/interfaces/ReminderLifecyclePort.ts new file mode 100644 index 00000000..934e8d0c --- /dev/null +++ b/frontend/src/features/reminder/application/interfaces/ReminderLifecyclePort.ts @@ -0,0 +1,7 @@ +import type { TelemetryAppState } from '../../../../shared/observability'; + +/** 提醒引擎订阅前后台,用来区分后台准时响铃和回前台才补上。 */ +export interface ReminderLifecyclePort { + current(): TelemetryAppState; + subscribe(listener: (state: TelemetryAppState) => void): () => void; +} diff --git a/frontend/src/features/reminder/application/interfaces/index.ts b/frontend/src/features/reminder/application/interfaces/index.ts index f76e2dc2..12c59d30 100644 --- a/frontend/src/features/reminder/application/interfaces/index.ts +++ b/frontend/src/features/reminder/application/interfaces/index.ts @@ -1,6 +1,7 @@ export type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmNativeFireAttempt, AlarmPresentationReceipt, AlarmPresentationRequest, AlarmScheduleReceipt, @@ -39,6 +40,7 @@ export type { SystemNotificationRequest, VibrationPort, } from './NotificationChannels'; +export type { ReminderLifecyclePort } from './ReminderLifecyclePort'; export type { ReminderApplicationDependencies, ReminderApplicationPort, diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index a38330c6..4a778fe8 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -39,6 +39,7 @@ export { export type { AlarmNativeDisposition, AlarmNativeEvent, + AlarmNativeFireAttempt, AlarmPresentationReceipt, AlarmPresentationRequest, AlarmScheduleReceipt, @@ -70,6 +71,7 @@ export type { ReminderApplicationResult, ReminderConfirmedDisposition, ReminderDeliveryPort, + ReminderLifecyclePort, ReminderDispositionSyncPort, ReminderDispositionSyncReceipt, ReminderPermissionBlockedEvent, diff --git a/frontend/src/infrastructure/appState/AppStateProvider.ts b/frontend/src/infrastructure/appState/AppStateProvider.ts index 2b16b8ae..8da6ea5c 100644 --- a/frontend/src/infrastructure/appState/AppStateProvider.ts +++ b/frontend/src/infrastructure/appState/AppStateProvider.ts @@ -2,5 +2,6 @@ export type AppLifecycleStatus = 'active' | 'background' | 'inactive'; /** 订阅前后台切换的平台适配器。 */ export interface AppStateProvider { + current(): AppLifecycleStatus; subscribe(listener: (status: AppLifecycleStatus) => void): () => void; } diff --git a/frontend/src/infrastructure/appState/RNAppStateProvider.ts b/frontend/src/infrastructure/appState/RNAppStateProvider.ts index 57625500..e4170ca2 100644 --- a/frontend/src/infrastructure/appState/RNAppStateProvider.ts +++ b/frontend/src/infrastructure/appState/RNAppStateProvider.ts @@ -2,10 +2,21 @@ import { AppState, type AppStateStatus } from 'react-native'; import type { AppLifecycleStatus, AppStateProvider } from './AppStateProvider'; +function boundStatus(state: AppStateStatus): AppLifecycleStatus { + if (state === 'active' || state === 'background' || state === 'inactive') { + return state; + } + return 'inactive'; +} + export class RNAppStateProvider implements AppStateProvider { + current(): AppLifecycleStatus { + return boundStatus(AppState.currentState); + } + subscribe(listener: (status: AppLifecycleStatus) => void): () => void { const subscription = AppState.addEventListener('change', (next: AppStateStatus) => - listener(next as AppLifecycleStatus), + listener(boundStatus(next)), ); return () => subscription.remove(); } diff --git a/frontend/src/infrastructure/location/reminderGuardTask.ts b/frontend/src/infrastructure/location/reminderGuardTask.ts index d87aa356..eecba6a2 100644 --- a/frontend/src/infrastructure/location/reminderGuardTask.ts +++ b/frontend/src/infrastructure/location/reminderGuardTask.ts @@ -19,6 +19,11 @@ import type { LocalReminderSchedule, ReminderStrength, } from '../../features/reminder/domain'; +import { + NOOP_CLIENT_TELEMETRY, + boundAppState, + type ClientTelemetryPort, +} from '../../shared/observability'; export const GUARD_TASK_NAME = 'timeflow-reminder-guard'; export const GUARD_NOTIFICATION_TITLE = 'Timeflow 提醒守护'; @@ -68,6 +73,12 @@ type GuardTaskListener = (sample: GuardTaskSample) => unknown; * 同一个事件被处理三遍,也量到过槽位被清空后长时间没人补上(连续 8 个 tick 为空)。 */ let taskListener: GuardTaskListener | null = null; +let guardTelemetry: ClientTelemetryPort = NOOP_CLIENT_TELEMETRY; + +/** 组合根注入埋点;headless 唤醒时也能上报后台是否真的弹出了提醒。 */ +export function setGuardTaskTelemetry(telemetry: ClientTelemetryPort): void { + guardTelemetry = telemetry; +} /** 订阅常驻前台服务的位置心跳;须在应用入口尽早 import 本模块以完成 defineTask。 */ export function subscribeGuardTaskEvents(listener: GuardTaskListener): () => void { @@ -311,6 +322,7 @@ async function runHeadlessLocationPass( row.reminder_type === 'return_to_recorded_location' ? `您已回到${row.location_name ?? '记录地点'}附近,请及时处理。` : `您已进入${row.location_name ?? '目标地点'}附近,请及时处理。`, + 'location', ); } } @@ -440,6 +452,7 @@ async function runTimeFallbackPass(database: SQLiteDatabase, accountId: string): row.title || '日程提醒', row.reminder_strength ?? 'medium', null, + 'time', ); } } @@ -536,6 +549,7 @@ async function runStuckPendingPass(database: SQLiteDatabase, accountId: string): row.title || '日程提醒', row.reminder_strength ?? 'medium', null, + 'time', ); } } @@ -547,11 +561,13 @@ async function presentOrNotify( title: string, strength: ReminderStrength, fallbackBody: string | null, + scheduleType: 'time' | 'location', ): Promise { const plan = resolveStrengthDeliveryPlan(strength); + let presented = false; try { const bridge = await import('../notifications/native/TimeflowAlarmBridge'); - const presented = await bridge.nativePresentAlarmNow( + presented = await bridge.nativePresentAlarmNow( `guard-${scheduleId}-${Date.now()}`, scheduleId, title, @@ -565,7 +581,10 @@ async function presentOrNotify( 'for', scheduleId, ); - if (presented) return; + if (presented) { + recordGuardDelivery(true, scheduleType, strength); + return; + } } catch (error) { console.warn('[guard] presentOrNotify: nativePresentAlarmNow threw, falling back', error); } @@ -590,11 +609,34 @@ async function presentOrNotify( }, trigger: { channelId: 'timeflow-reminders' }, }); + recordGuardDelivery(false, scheduleType, strength); } catch (error) { console.warn('[guard] presentOrNotify fallback notification failed', error); } } +/** 供单测直接覆盖 headless 送达埋点;生产路径由 presentOrNotify 调用。 */ +export function recordGuardDelivery( + presented: boolean, + scheduleType: 'time' | 'location', + strength: ReminderStrength, +): void { + guardTelemetry.recordReminderDelivery({ + app_state: boundAppState(AppState.currentState), + channel: presented ? 'native_full_screen' : 'system_notification', + deferred_until_foreground: false, + latency_bucket: 'unknown', + manufacturer: 'other', + native_armed: false, + outcome: presented ? 'native_ok' : 'js_channel', + overlay_failed: false, + schedule_type: scheduleType, + strength, + trigger_source: 'headless_guard', + used_fallback_audio: false, + }); +} + /** 只用来判断哪些行是地点型、算轮询密度——通知文案是固定文案,不需要标题/时间。 */ type GuardWatchRow = { id: string; diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index 174ac92e..2b67a7bc 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -1,5 +1,6 @@ import type { AlarmNativeDisposition, + AlarmNativeFireAttempt, AlarmPresentationReceipt, AlarmPresentationRequest, AlarmNativeEvent, @@ -10,11 +11,13 @@ import type { import { isTimeflowAlarmAvailable, nativeAckAlarmDispositions, + nativeAckFireAttempts, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, nativeCancelAllAlarms, nativePresentAlarmNow, nativePeekAlarmDispositions, + nativePeekFireAttempts, nativeScheduleAlarm, nativeStopAlarmRinging, subscribeNativeAlarmEvents, @@ -137,6 +140,29 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { async ackNativeDispositions(scheduleIds: readonly string[]): Promise { await nativeAckAlarmDispositions(scheduleIds); } + + async peekNativeFireAttempts(): Promise { + const rows = await nativePeekFireAttempts(); + return rows + .map((row) => { + const result = + row.result === 'service_denied' || + row.result === 'present_failed' || + row.result === 'fallback_notification' + ? row.result + : null; + if (result == null) return null; + return { + result, + at: new Date(row.atMillis || Date.now()).toISOString(), + } satisfies AlarmNativeFireAttempt; + }) + .filter((row): row is AlarmNativeFireAttempt => row != null); + } + + async ackNativeFireAttempts(): Promise { + await nativeAckFireAttempts(); + } } function unscheduled(scheduleId: string): AlarmScheduleReceipt { diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts index 8396bb43..935df0df 100644 --- a/frontend/src/infrastructure/notifications/index.ts +++ b/frontend/src/infrastructure/notifications/index.ts @@ -10,6 +10,7 @@ export { isTimeflowAlarmAvailable, nativeAreAlarmPermissionsGranted, nativeAckAlarmDispositions, + nativeAckFireAttempts, nativeCancelAlarm, nativeCancelAllAlarms, nativeGetAlarmPermissionStatus, @@ -17,6 +18,7 @@ export { nativeOpenAlarmPermissionSettings, nativePresentAlarmNow, nativePeekAlarmDispositions, + nativePeekFireAttempts, nativeRequestNotificationPermission, nativeScheduleAlarm, nativeStopAlarmRinging, diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index a65633c7..b5be7af6 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -31,6 +31,11 @@ export type NativeAlarmDispositionPayload = { updatedAtMillis: number; }; +export type NativeFireAttemptPayload = { + result: string; + atMillis: number; +}; + type TimeflowAlarmNative = { schedule: ( triggerAtMillis: number, @@ -56,6 +61,8 @@ type TimeflowAlarmNative = { hasArmedAlarm: (scheduleId: string) => Promise; peekNativeDispositions: () => Promise; ackNativeDispositions: (scheduleIds: string[]) => Promise; + peekNativeFireAttempts: () => Promise; + ackNativeFireAttempts: () => Promise; getPermissionStatus: () => Promise; openPermissionSettings: ( kind: @@ -193,6 +200,26 @@ export async function nativeAckAlarmDispositions(scheduleIds: readonly string[]) } } +export async function nativePeekFireAttempts(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return []; + try { + return await native.peekNativeFireAttempts(); + } catch { + return []; + } +} + +export async function nativeAckFireAttempts(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.ackNativeFireAttempts(); + } catch { + // 上报失败就留着,下次启动再读。 + } +} + export async function nativeGetAlarmPermissionStatus(): Promise { const native = getNativeAlarm(); if (!isTimeflowAlarmAvailable() || native == null) return null; diff --git a/frontend/src/infrastructure/observability/SentryClientTelemetry.ts b/frontend/src/infrastructure/observability/SentryClientTelemetry.ts new file mode 100644 index 00000000..c4637402 --- /dev/null +++ b/frontend/src/infrastructure/observability/SentryClientTelemetry.ts @@ -0,0 +1,112 @@ +import * as Sentry from '@sentry/react-native'; + +import type { + ClientTelemetryPort, + DeviceTelemetryContext, + ReminderDeliveryTelemetry, + ReminderLifecycleTelemetry, + ReminderNativeBackgroundTelemetry, + ReminderPermissionBlockedTelemetry, +} from '../../shared/observability'; +import { boundPermissions } from '../../shared/observability'; + +const EVENT_REMINDER_DELIVERY = 'timeflow.reminder.delivery'; +const EVENT_REMINDER_PERMISSION = 'timeflow.reminder.permission_blocked'; +const EVENT_REMINDER_ERROR = 'timeflow.reminder.delivery'; +const EVENT_REMINDER_LIFECYCLE = 'timeflow.reminder.foreground_resume'; +const EVENT_REMINDER_NATIVE_BACKGROUND = 'timeflow.reminder.native_background'; + +function flag(value: boolean): 'true' | 'false' { + return value ? 'true' : 'false'; +} + +function deliveryLevel(event: ReminderDeliveryTelemetry): 'info' | 'warning' { + if (event.deferred_until_foreground) return 'warning'; + if (event.overlay_failed) return 'warning'; + if (event.outcome === 'native_ok' || event.outcome === 'js_channel') return 'info'; + return 'warning'; +} + +function lifecycleLevel(event: ReminderLifecycleTelemetry): 'info' | 'warning' { + return event.overdue_unarmed === 'none' ? 'info' : 'warning'; +} + +function nativeBackgroundLevel(event: ReminderNativeBackgroundTelemetry): 'warning' | 'error' { + return event.result === 'fallback_notification' ? 'warning' : 'error'; +} + +function capture( + message: string, + level: 'info' | 'warning' | 'error', + tags: Record, +): void { + Sentry.withScope((scope) => { + scope.setLevel(level); + for (const [key, value] of Object.entries(tags)) { + scope.setTag(key, value); + } + Sentry.captureMessage(message); + }); +} + +/** Sentry 适配器:只打封闭枚举 tag,不设 user,不带标题/原文/坐标。 */ +export class SentryClientTelemetry implements ClientTelemetryPort { + setDeviceContext(context: DeviceTelemetryContext): void { + Sentry.setTag('manufacturer', context.manufacturer); + Sentry.setTag('os', context.os); + } + + recordReminderDelivery(event: ReminderDeliveryTelemetry): void { + capture(EVENT_REMINDER_DELIVERY, deliveryLevel(event), { + app_state: event.app_state, + channel: event.channel, + deferred_until_foreground: flag(event.deferred_until_foreground), + latency_bucket: event.latency_bucket, + manufacturer: event.manufacturer, + native_armed: flag(event.native_armed), + outcome: event.outcome, + overlay_failed: flag(event.overlay_failed), + schedule_type: event.schedule_type, + strength: event.strength, + trigger_source: event.trigger_source, + used_fallback_audio: flag(event.used_fallback_audio), + }); + } + + recordReminderPermissionBlocked(event: ReminderPermissionBlockedTelemetry): void { + const missing = boundPermissions(event.missing); + if (missing.length === 0) return; + capture(EVENT_REMINDER_PERMISSION, 'warning', { + manufacturer: event.manufacturer, + missing_permissions: missing.join(','), + }); + } + + recordReminderLifecycle(event: ReminderLifecycleTelemetry): void { + Sentry.addBreadcrumb({ + category: 'app.lifecycle', + level: 'info', + message: event.kind, + }); + capture(EVENT_REMINDER_LIFECYCLE, lifecycleLevel(event), { + background_duration_bucket: event.background_duration_bucket, + kind: event.kind, + manufacturer: event.manufacturer, + overdue_unarmed: event.overdue_unarmed, + }); + } + + recordReminderNativeBackground(event: ReminderNativeBackgroundTelemetry): void { + capture(EVENT_REMINDER_NATIVE_BACKGROUND, nativeBackgroundLevel(event), { + manufacturer: event.manufacturer, + result: event.result, + }); + } + + recordUnexpectedError(kind: 'reminder_delivery'): void { + capture(EVENT_REMINDER_ERROR, 'error', { + error_kind: 'exception', + source: kind, + }); + } +} diff --git a/frontend/src/infrastructure/observability/index.ts b/frontend/src/infrastructure/observability/index.ts new file mode 100644 index 00000000..fb3256ce --- /dev/null +++ b/frontend/src/infrastructure/observability/index.ts @@ -0,0 +1,2 @@ +export { initSentry, wrapRoot } from './initSentry'; +export { SentryClientTelemetry } from './SentryClientTelemetry'; diff --git a/frontend/src/infrastructure/observability/initSentry.ts b/frontend/src/infrastructure/observability/initSentry.ts new file mode 100644 index 00000000..e6b3d57f --- /dev/null +++ b/frontend/src/infrastructure/observability/initSentry.ts @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/react-native'; +import type { ErrorEvent } from '@sentry/core'; +import { Platform } from 'react-native'; +import type { ComponentType } from 'react'; + +import { boundOs } from '../../shared/observability'; + +const SENSITIVE_KEY = + /title|transcript|latitude|longitude|session|account|speech|user|coord|schedule_id/i; + +function readDsn(): string { + return process.env.EXPO_PUBLIC_SENTRY_DSN?.trim() ?? ''; +} + +function scrubRecord(record: Record | undefined): void { + if (record == null) return; + for (const key of Object.keys(record)) { + if (SENSITIVE_KEY.test(key)) { + delete record[key]; + } + } +} + +function scrubEvent(event: ErrorEvent): ErrorEvent { + delete event.user; + scrubRecord(event.extra as Record | undefined); + scrubRecord(event.tags as Record | undefined); + return event; +} + +/** 仅当设置了 EXPO_PUBLIC_SENTRY_DSN 时真正上报;未设置则 SDK 保持关闭。 */ +export function initSentry(): void { + const dsn = readDsn(); + Sentry.init({ + attachStacktrace: true, + beforeSend(event: ErrorEvent) { + return scrubEvent(event); + }, + dsn: dsn || undefined, + enableAutoPerformanceTracing: false, + enabled: dsn.length > 0, + sendDefaultPii: false, + tracesSampleRate: 0, + }); + if (dsn.length > 0) { + Sentry.setTag('os', boundOs(Platform.OS)); + } +} + +export function wrapRoot(component: ComponentType): ComponentType { + return Sentry.wrap(component as ComponentType>); +} diff --git a/frontend/src/shared/observability/ClientTelemetryPort.ts b/frontend/src/shared/observability/ClientTelemetryPort.ts new file mode 100644 index 00000000..dd0ff84c --- /dev/null +++ b/frontend/src/shared/observability/ClientTelemetryPort.ts @@ -0,0 +1,173 @@ +/** 客户端可观测性端口:features 只记封闭枚举,不依赖 Sentry SDK。 */ + +export type TelemetryManufacturer = 'xiaomi' | 'huawei' | 'oppo' | 'vivo' | 'other'; +export type TelemetryOs = 'android' | 'ios' | 'web' | 'other'; +export type TelemetryAppState = 'active' | 'background' | 'inactive' | 'unknown'; + +export type ReminderDeliveryOutcome = + 'native_ok' | 'native_declined' | 'native_unavailable' | 'js_channel'; + +export type ReminderTelemetryChannel = + 'native_full_screen' | 'system_notification' | 'popup' | 'vibration' | 'tts' | 'local_sound'; + +export type ReminderTriggerSource = + 'native_alarm' | 'js_time' | 'location' | 'stuck_pending' | 'headless_guard'; + +export type ReminderLatencyBucket = + 'on_time' | 'late_1m' | 'late_5m' | 'late_30m' | 'late_hour_plus' | 'unknown'; + +export type ReminderCountBucket = 'none' | 'one' | 'few' | 'many'; + +export type NativeBackgroundResult = 'service_denied' | 'present_failed' | 'fallback_notification'; + +export const TELEMETRY_PERMISSIONS = [ + 'notifications', + 'exact_alarm', + 'overlay', + 'full_screen', + 'battery_optimization', + 'location_foreground', + 'location_background', + 'microphone', +] as const; + +export type TelemetryPermission = (typeof TELEMETRY_PERMISSIONS)[number]; + +export type DeviceTelemetryContext = { + manufacturer: TelemetryManufacturer; + os: TelemetryOs; +}; + +export type ReminderDeliveryTelemetry = { + outcome: ReminderDeliveryOutcome; + channel: ReminderTelemetryChannel; + schedule_type: 'time' | 'location'; + strength: 'low' | 'medium' | 'high'; + manufacturer: TelemetryManufacturer; + used_fallback_audio: boolean; + overlay_failed: boolean; + app_state: TelemetryAppState; + trigger_source: ReminderTriggerSource; + latency_bucket: ReminderLatencyBucket; + deferred_until_foreground: boolean; + native_armed: boolean; +}; + +export type ReminderPermissionBlockedTelemetry = { + missing: readonly TelemetryPermission[]; + manufacturer: TelemetryManufacturer; +}; + +export type ReminderLifecycleTelemetry = { + kind: 'foreground_resume'; + manufacturer: TelemetryManufacturer; + background_duration_bucket: ReminderLatencyBucket; + overdue_unarmed: ReminderCountBucket; +}; + +export type ReminderNativeBackgroundTelemetry = { + result: NativeBackgroundResult; + manufacturer: TelemetryManufacturer; +}; + +export interface ClientTelemetryPort { + setDeviceContext(context: DeviceTelemetryContext): void; + recordReminderDelivery(event: ReminderDeliveryTelemetry): void; + recordReminderPermissionBlocked(event: ReminderPermissionBlockedTelemetry): void; + recordReminderLifecycle(event: ReminderLifecycleTelemetry): void; + recordReminderNativeBackground(event: ReminderNativeBackgroundTelemetry): void; + recordUnexpectedError(kind: 'reminder_delivery'): void; +} + +export const NOOP_CLIENT_TELEMETRY: ClientTelemetryPort = { + setDeviceContext() {}, + recordReminderDelivery() {}, + recordReminderPermissionBlocked() {}, + recordReminderLifecycle() {}, + recordReminderNativeBackground() {}, + recordUnexpectedError() {}, +}; + +const KNOWN_MANUFACTURERS: ReadonlySet = new Set(['xiaomi', 'huawei', 'oppo', 'vivo']); +const KNOWN_OS: ReadonlySet = new Set(['android', 'ios', 'web']); +const KNOWN_APP_STATES: ReadonlySet = new Set(['active', 'background', 'inactive']); +const KNOWN_NATIVE_BACKGROUND: ReadonlySet = new Set([ + 'service_denied', + 'present_failed', + 'fallback_notification', +]); + +const LATE_BUCKETS: ReadonlySet = new Set([ + 'late_1m', + 'late_5m', + 'late_30m', + 'late_hour_plus', +]); + +/** 未识别国产 ROM / 未知字符串一律 other,禁止把 Device.brand 原串当 tag。 */ +export function boundManufacturer(value: string | null | undefined): TelemetryManufacturer { + if (value != null && KNOWN_MANUFACTURERS.has(value)) { + return value as TelemetryManufacturer; + } + return 'other'; +} + +export function boundOs(value: string | null | undefined): TelemetryOs { + if (value != null && KNOWN_OS.has(value)) { + return value as TelemetryOs; + } + return 'other'; +} + +export function boundAppState(value: string | null | undefined): TelemetryAppState { + if (value != null && KNOWN_APP_STATES.has(value)) { + return value as TelemetryAppState; + } + return 'unknown'; +} + +export function boundPermissions(values: readonly string[]): readonly TelemetryPermission[] { + const wanted = new Set(values); + return TELEMETRY_PERMISSIONS.filter((permission) => wanted.has(permission)); +} + +export function boundNativeBackgroundResult( + value: string | null | undefined, +): NativeBackgroundResult | null { + if (value != null && KNOWN_NATIVE_BACKGROUND.has(value)) { + return value as NativeBackgroundResult; + } + return null; +} + +/** JS 30s tick 内算准时;再晚按 1/5/30 分钟分桶,不上报原始毫秒。 */ +export function latencyBucket(delayMs: number | null | undefined): ReminderLatencyBucket { + if (delayMs == null || !Number.isFinite(delayMs)) return 'unknown'; + if (delayMs < 30_000) return 'on_time'; + if (delayMs < 60_000) return 'late_1m'; + if (delayMs < 5 * 60_000) return 'late_5m'; + if (delayMs <= 30 * 60_000) return 'late_30m'; + return 'late_hour_plus'; +} + +export function latencyBucketFromTimes( + scheduledAt: string | null | undefined, + observedAt: string, +): ReminderLatencyBucket { + if (scheduledAt == null) return 'unknown'; + const scheduledMs = Date.parse(scheduledAt); + const observedMs = Date.parse(observedAt); + if (Number.isNaN(scheduledMs) || Number.isNaN(observedMs)) return 'unknown'; + return latencyBucket(observedMs - scheduledMs); +} + +export function countBucket(count: number): ReminderCountBucket { + if (count <= 0) return 'none'; + if (count === 1) return 'one'; + if (count <= 4) return 'few'; + return 'many'; +} + +export function isLateLatency(bucket: ReminderLatencyBucket): boolean { + return LATE_BUCKETS.has(bucket); +} diff --git a/frontend/src/shared/observability/index.ts b/frontend/src/shared/observability/index.ts new file mode 100644 index 00000000..a6184f1c --- /dev/null +++ b/frontend/src/shared/observability/index.ts @@ -0,0 +1,31 @@ +export type { + ClientTelemetryPort, + DeviceTelemetryContext, + NativeBackgroundResult, + ReminderCountBucket, + ReminderDeliveryOutcome, + ReminderDeliveryTelemetry, + ReminderLatencyBucket, + ReminderLifecycleTelemetry, + ReminderNativeBackgroundTelemetry, + ReminderPermissionBlockedTelemetry, + ReminderTelemetryChannel, + ReminderTriggerSource, + TelemetryAppState, + TelemetryManufacturer, + TelemetryOs, + TelemetryPermission, +} from './ClientTelemetryPort'; +export { + NOOP_CLIENT_TELEMETRY, + TELEMETRY_PERMISSIONS, + boundAppState, + boundManufacturer, + boundNativeBackgroundResult, + boundOs, + boundPermissions, + countBucket, + isLateLatency, + latencyBucket, + latencyBucketFromTimes, +} from './ClientTelemetryPort'; diff --git a/frontend/tests/architecture/sentryImportBoundary.test.ts b/frontend/tests/architecture/sentryImportBoundary.test.ts new file mode 100644 index 00000000..61eace06 --- /dev/null +++ b/frontend/tests/architecture/sentryImportBoundary.test.ts @@ -0,0 +1,60 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { extname, relative, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const projectRoot = process.cwd(); + +describe('sentry import boundary', () => { + it('keeps @sentry/react-native inside infrastructure observability', () => { + const sourceRoot = resolve(projectRoot, 'src'); + const imports = listSourceFiles(sourceRoot) + .filter((file) => readFileSync(file, 'utf8').includes('@sentry/react-native')) + .map((file) => normalizePath(relative(projectRoot, file))) + .sort(); + + expect(imports).toEqual([ + 'src/infrastructure/observability/SentryClientTelemetry.ts', + 'src/infrastructure/observability/initSentry.ts', + ]); + }); + + it('does not let features import the Sentry adapter', () => { + const featuresRoot = resolve(projectRoot, 'src/features'); + const imports = listSourceFiles(featuresRoot) + .filter((file) => { + const source = readFileSync(file, 'utf8'); + return ( + source.includes('infrastructure/observability') || source.includes('@sentry/react-native') + ); + }) + .map((file) => normalizePath(relative(projectRoot, file))); + + expect(imports).toEqual([]); + }); + + it('keeps the Sentry adapter in infrastructure', () => { + expect( + existsSync(resolve(projectRoot, 'src/infrastructure/observability/SentryClientTelemetry.ts')), + ).toBe(true); + }); +}); + +function listSourceFiles(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listSourceFiles(path)); + continue; + } + if (['.ts', '.tsx'].includes(extname(entry.name))) { + files.push(path); + } + } + return files; +} + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/'); +} diff --git a/frontend/tests/unit/__mocks__/@sentry/react-native.ts b/frontend/tests/unit/__mocks__/@sentry/react-native.ts new file mode 100644 index 00000000..0c44f471 --- /dev/null +++ b/frontend/tests/unit/__mocks__/@sentry/react-native.ts @@ -0,0 +1,16 @@ +import { jest } from '@jest/globals'; + +export const mockedScope = { + setLevel: jest.fn(), + setTag: jest.fn(), +}; + +export const init = jest.fn(); +export const wrap = jest.fn((component: unknown) => component); +export const setTag = jest.fn(); +export const captureMessage = jest.fn(); +export const captureException = jest.fn(); +export const addBreadcrumb = jest.fn(); +export const withScope = jest.fn((callback: (scope: typeof mockedScope) => void) => { + callback(mockedScope); +}); diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx index 8b93ff04..ef1f4941 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -18,7 +18,7 @@ import { openTimeflowDatabase } from '../../../src/infrastructure/database'; jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), })); - +jest.mock('@sentry/react-native'); jest.mock('../../../src/infrastructure/database', () => ({ openTimeflowDatabase: jest .fn<() => Promise>() diff --git a/frontend/tests/unit/app/createAppServices.test.ts b/frontend/tests/unit/app/createAppServices.test.ts index 8610a938..44c5d89a 100644 --- a/frontend/tests/unit/app/createAppServices.test.ts +++ b/frontend/tests/unit/app/createAppServices.test.ts @@ -9,6 +9,7 @@ import { import { subscribeGuardTaskEvents } from '../../../src/infrastructure/location/reminderGuardTask'; import { FakeAuthSessionStore } from '../../fakes/FakeAuthSessionStore'; +jest.mock('@sentry/react-native'); jest.mock('../../../src/infrastructure/location/reminderGuardTask', () => { const actual = jest.requireActual< typeof import('../../../src/infrastructure/location/reminderGuardTask') @@ -16,6 +17,7 @@ jest.mock('../../../src/infrastructure/location/reminderGuardTask', () => { return { GUARD_TASK_NAME: actual.GUARD_TASK_NAME, subscribeGuardTaskEvents: jest.fn(() => () => {}), + setGuardTaskTelemetry: jest.fn(), resolveNextPollIntervalMs: actual.resolveNextPollIntervalMs, }; }); diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index 7897960d..f7dec134 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -128,6 +128,7 @@ function createDeps( applyCategoryUpdate: jest.fn(overrides.applyCategoryUpdate ?? (async () => true)), }; const appState: AppStateProvider = { + current: () => 'active', subscribe: jest.fn((listener: (status: AppLifecycleStatus) => void) => { capturedAppStateListener = listener; return unsubscribeAppState; diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts index 75c9ea81..f2b1da26 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts @@ -111,6 +111,7 @@ function createDeps(overrides: { applyCategoryUpdate: jest.fn(overrides.applyCategoryUpdate ?? (async () => true)), }; const appState: AppStateProvider = { + current: () => 'active', subscribe: jest.fn(() => () => undefined), }; return { appState, capture, localScheduleWriter, location, playback, transport }; diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index 348b9c4b..1ad3dc7f 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -22,6 +22,15 @@ import type { ReminderRuntimeState, ReminderStrength, } from '../../../../../src/features/reminder/domain'; +import type { + ClientTelemetryPort, + DeviceTelemetryContext, + ReminderDeliveryTelemetry, + ReminderLifecycleTelemetry, + ReminderNativeBackgroundTelemetry, + ReminderPermissionBlockedTelemetry, + TelemetryAppState, +} from '../../../../../src/shared/observability'; /** 事件订阅回调是 fire-and-forget(void handleNativeAlarmEvent(event)),背后 * 排了好几层 await(enqueueOp → teardownDelivery 的 6 个串行任务 → state 读写 @@ -117,6 +126,8 @@ function createFakeAlarms(overrides: Partial = {}) { ), peekNativeDispositions: jest.fn(async () => []), ackNativeDispositions: jest.fn(async () => {}), + peekNativeFireAttempts: jest.fn(async () => []), + ackNativeFireAttempts: jest.fn(async () => {}), ...overrides, }; @@ -1757,4 +1768,618 @@ describe('LocalReminderApplication', () => { expect(deps.vibration.vibrate).not.toHaveBeenCalled(); }); }); + + describe('client telemetry', () => { + it('records native_ok when the native alarm fires', async () => { + const schedule = fixtureSchedule({ id: 's1' }); + const telemetry = new RecordingTelemetry(); + const listenerRef: { current: ((event: AlarmNativeEvent) => void) | null } = { + current: null, + }; + const { alarms } = createFakeAlarms({ + subscribe: jest.fn((listener: (event: AlarmNativeEvent) => void) => { + listenerRef.current = listener; + return () => { + listenerRef.current = null; + }; + }), + }); + const deps = createDeps({ + alarms, + schedules: new FakeScheduleReader([schedule]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + + listenerRef.current?.({ + type: 'fired', + schedule_id: 's1', + alarm_id: 'alarm-1', + title: schedule.title, + at: '2026-08-18T10:00:00.000Z', + }); + await flushAsync(); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + channel: 'native_full_screen', + manufacturer: 'other', + outcome: 'native_ok', + overlay_failed: false, + schedule_type: 'time', + strength: 'medium', + used_fallback_audio: false, + }), + ]); + }); + + it('does not record native_ok when hydrating a pending native disposition', async () => { + const telemetry = new RecordingTelemetry(); + const { alarms } = createFakeAlarms({ + peekNativeDispositions: jest.fn(async () => [ + { + schedule_id: 's1', + alarm_id: 'alarm-1', + state: 'pending' as const, + updated_at: '2026-08-18T09:30:00.000Z', + }, + ]), + }); + const deps = createDeps({ alarms, telemetry }); + const app = new LocalReminderApplication(deps); + await app.start(); + expect(telemetry.deliveries).toEqual([]); + }); + + it('records js_channel for a location reminder that used the JS popup path', async () => { + const telemetry = new RecordingTelemetry(); + const schedule = fixtureSchedule({ + id: 's1', + schedule_type: 'location', + latitude: 31.2304, + longitude: 121.4737, + reminder: { + reminder_type: 'arrive_location', + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: 'high', + }, + }); + const deps = createDeps({ + schedules: new FakeScheduleReader([schedule]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + + await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'arrive_location', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + channel: 'popup', + outcome: 'js_channel', + schedule_type: 'location', + strength: 'high', + used_fallback_audio: true, + }), + ]); + expect(JSON.stringify(telemetry.deliveries)).not.toContain('喝水'); + }); + + it('does not record js_channel when a native alarm already owns the ring UI', async () => { + const telemetry = new RecordingTelemetry(); + const schedule = fixtureSchedule({ id: 's1' }); + const listenerRef: { current: ((event: AlarmNativeEvent) => void) | null } = { + current: null, + }; + const { alarms } = createFakeAlarms({ + subscribe: jest.fn((listener: (event: AlarmNativeEvent) => void) => { + listenerRef.current = listener; + return () => { + listenerRef.current = null; + }; + }), + }); + const deps = createDeps({ + alarms, + schedules: new FakeScheduleReader([schedule]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + + listenerRef.current?.({ + type: 'fired', + schedule_id: 's1', + alarm_id: 'alarm-1', + title: schedule.title, + at: '2026-08-18T10:00:00.000Z', + }); + await flushAsync(); + + await app.deliver({ + reminder_id: 'r1', + schedule_id: 's1', + reason: 'at_time', + triggered_at: '2026-08-18T10:00:00.000Z', + }); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + channel: 'native_full_screen', + outcome: 'native_ok', + }), + ]); + }); + + it('records native_declined when a time schedule cannot be hung on the native alarm', async () => { + const telemetry = new RecordingTelemetry(); + const schedule = fixtureSchedule({ id: 's1' }); + const { alarms } = createFakeAlarms({ + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + }); + const deps = createDeps({ + alarms, + device: { + getStatus: jest.fn(async () => ({ + platform: 'android' as const, + supported: true, + permissions: { + notifications: true, + exact_alarm: false, + overlay: true, + full_screen: true, + battery_optimization: true, + location_foreground: true, + location_background: true, + microphone: true, + }, + background_execution: true, + oemGuidance: { + manufacturer: 'xiaomi' as const, + autostartGuided: false, + backgroundPopupGuided: false, + lastOverlayFailed: false, + }, + })), + onAppActive: jest.fn(() => () => {}), + openOemSettings: jest.fn(async () => true), + openSettings: jest.fn(async () => true), + requestPermission: jest.fn(async () => true), + }, + schedules: new FakeScheduleReader([schedule]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + telemetry.deliveries.length = 0; + telemetry.permissions.length = 0; + + await app.register(schedule); + await flushAsync(); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + channel: 'native_full_screen', + manufacturer: 'xiaomi', + outcome: 'native_declined', + schedule_type: 'time', + }), + ]); + expect(telemetry.permissions).toEqual([ + expect.objectContaining({ + manufacturer: 'xiaomi', + missing: ['exact_alarm'], + }), + ]); + }); + + it('does not re-record permission gaps during rebuild', async () => { + const telemetry = new RecordingTelemetry(); + const schedule = fixtureSchedule({ id: 's1' }); + const deps = createDeps({ + device: { + getStatus: jest.fn(async () => ({ + platform: 'android' as const, + supported: true, + permissions: { + notifications: true, + exact_alarm: false, + overlay: true, + full_screen: true, + battery_optimization: true, + location_foreground: true, + location_background: true, + microphone: true, + }, + background_execution: true, + oemGuidance: { + manufacturer: null, + autostartGuided: false, + backgroundPopupGuided: false, + lastOverlayFailed: false, + }, + })), + onAppActive: jest.fn(() => () => {}), + openOemSettings: jest.fn(async () => true), + openSettings: jest.fn(async () => true), + requestPermission: jest.fn(async () => true), + }, + schedules: new FakeScheduleReader([schedule]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + await app.register(schedule); + await flushAsync(); + const permissionCount = telemetry.permissions.length; + + await app.rebuild(); + await flushAsync(); + + expect(telemetry.permissions).toHaveLength(permissionCount); + }); + + it('marks a late JS time catch-up after opening the app as deferred_until_foreground', async () => { + const telemetry = new RecordingTelemetry(); + const lifecycle = createLifecycle('active'); + const { alarms } = createFakeAlarms({ + rebuild: jest.fn(async (requests: readonly AlarmScheduleRequest[]) => + requests.map((request) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + ), + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + }); + const deps = createDeps({ + alarms, + lifecycle, + schedules: new FakeScheduleReader([fixtureSchedule({ id: 's1' })]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + telemetry.deliveries.length = 0; + + await app.handleTime({ observed_at: '2026-08-18T10:30:00.000Z' }); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + app_state: 'active', + deferred_until_foreground: true, + latency_bucket: 'late_30m', + native_armed: false, + outcome: 'js_channel', + trigger_source: 'js_time', + }), + ]); + }); + + it('does not mark a live native alarm as deferred even if JS learns about it late', async () => { + const telemetry = new RecordingTelemetry(); + const lifecycle = createLifecycle('background'); + const listenerRef: { current: ((event: AlarmNativeEvent) => void) | null } = { + current: null, + }; + const { alarms } = createFakeAlarms({ + subscribe: jest.fn((listener: (event: AlarmNativeEvent) => void) => { + listenerRef.current = listener; + return () => { + listenerRef.current = null; + }; + }), + }); + const deps = createDeps({ + alarms, + lifecycle, + schedules: new FakeScheduleReader([fixtureSchedule({ id: 's1' })]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + telemetry.deliveries.length = 0; + + listenerRef.current?.({ + type: 'fired', + schedule_id: 's1', + alarm_id: 'alarm-1', + title: '喝水提醒', + at: '2026-08-18T10:30:00.000Z', + }); + await flushAsync(); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + app_state: 'background', + deferred_until_foreground: false, + latency_bucket: 'late_30m', + outcome: 'native_ok', + trigger_source: 'native_alarm', + }), + ]); + }); + + it('reports persisted native background fire failures once on start', async () => { + const telemetry = new RecordingTelemetry(); + const ackNativeFireAttempts = jest.fn(async () => {}); + const { alarms } = createFakeAlarms({ + peekNativeFireAttempts: jest.fn(async () => [ + { result: 'service_denied' as const, at: '2026-08-18T09:50:00.000Z' }, + { result: 'service_denied' as const, at: '2026-08-18T09:51:00.000Z' }, + { result: 'present_failed' as const, at: '2026-08-18T09:52:00.000Z' }, + ]), + ackNativeFireAttempts, + }); + const deps = createDeps({ alarms, telemetry }); + const app = new LocalReminderApplication(deps); + await app.start(); + + expect(telemetry.nativeBackground).toEqual([ + expect.objectContaining({ result: 'service_denied' }), + expect.objectContaining({ result: 'present_failed' }), + ]); + expect(ackNativeFireAttempts).toHaveBeenCalledTimes(1); + expect(JSON.stringify(telemetry.nativeBackground)).not.toContain('s1'); + }); + + it('ignores unknown native background results and still acks the buffer', async () => { + const telemetry = new RecordingTelemetry(); + const ackNativeFireAttempts = jest.fn(async () => {}); + const { alarms } = createFakeAlarms({ + peekNativeFireAttempts: jest.fn(async () => [ + { result: 'not-a-result' as 'service_denied', at: '2026-08-18T09:50:00.000Z' }, + ]), + ackNativeFireAttempts, + }); + const deps = createDeps({ alarms, telemetry }); + const app = new LocalReminderApplication(deps); + await app.start(); + + expect(telemetry.nativeBackground).toEqual([]); + expect(ackNativeFireAttempts).toHaveBeenCalledTimes(1); + }); + + it('reports overdue unarmed time schedules when returning to the foreground', async () => { + const telemetry = new RecordingTelemetry(); + const lifecycle = createLifecycle('active'); + const { alarms } = createFakeAlarms({ + rebuild: jest.fn(async (requests: readonly AlarmScheduleRequest[]) => + requests.map((request) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + ), + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + }); + const deps = createDeps({ + alarms, + lifecycle, + schedules: new FakeScheduleReader([fixtureSchedule({ id: 's1' })]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + await flushAsync(); + + expect(telemetry.lifecycle).toEqual([ + expect.objectContaining({ + kind: 'foreground_resume', + overdue_unarmed: 'one', + }), + ]); + + telemetry.lifecycle.length = 0; + lifecycle.emit('background'); + lifecycle.emit('active'); + await flushAsync(); + + expect(telemetry.lifecycle).toEqual([ + expect.objectContaining({ + kind: 'foreground_resume', + overdue_unarmed: 'one', + }), + ]); + }); + + it('records unexpected delivery errors from the time tick without a schedule id', async () => { + const telemetry = new RecordingTelemetry(); + const { alarms } = createFakeAlarms({ + rebuild: jest.fn(async (requests: readonly AlarmScheduleRequest[]) => + requests.map((request) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + ), + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + }); + const deps = createDeps({ + alarms, + schedules: new FakeScheduleReader([fixtureSchedule({ id: 's1' })]), + telemetry, + }); + deps.vibration.vibrate = jest.fn(async () => { + throw new Error('vibrate failed'); + }); + const app = new LocalReminderApplication(deps); + await app.start(); + telemetry.errors.length = 0; + + await app.handleTime({ observed_at: '2026-08-18T10:00:00.000Z' }); + await flushAsync(); + + expect(telemetry.errors).toEqual(['reminder_delivery']); + expect(JSON.stringify(telemetry.errors)).not.toContain('s1'); + }); + + it('records stuck_pending catch-up through the JS channel', async () => { + const telemetry = new RecordingTelemetry(); + const schedule = fixtureSchedule({ + id: 's1', + runtime: { + ...emptyRuntime(), + reminder_disposition_state: 'pending', + disposition_updated_at: '2026-08-18T10:00:00.000Z', + }, + }); + const { alarms } = createFakeAlarms({ + rebuild: jest.fn(async (requests: readonly AlarmScheduleRequest[]) => + requests.map((request) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + ), + schedule: jest.fn(async (request: AlarmScheduleRequest) => ({ + alarm_id: '', + schedule_id: request.schedule_id, + scheduled: false, + })), + }); + const deps = createDeps({ + alarms, + schedules: new FakeScheduleReader([schedule]), + telemetry, + }); + await deps.state.write('s1', schedule.runtime); + const app = new LocalReminderApplication(deps); + await app.start(); + telemetry.deliveries.length = 0; + + await app.handleTime({ observed_at: '2026-08-18T10:02:30.000Z' }); + await flushAsync(); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + outcome: 'js_channel', + trigger_source: 'stuck_pending', + }), + ]); + }); + + it('falls back to other manufacturer tags when device status cannot be read', async () => { + const telemetry = new RecordingTelemetry(); + const listenerRef: { current: ((event: AlarmNativeEvent) => void) | null } = { + current: null, + }; + const { alarms } = createFakeAlarms({ + subscribe: jest.fn((listener: (event: AlarmNativeEvent) => void) => { + listenerRef.current = listener; + return () => { + listenerRef.current = null; + }; + }), + }); + const deps = createDeps({ + alarms, + device: { + getStatus: jest.fn(async () => { + throw new Error('device down'); + }), + onAppActive: jest.fn(() => () => {}), + openOemSettings: jest.fn(async () => true), + openSettings: jest.fn(async () => true), + requestPermission: jest.fn(async () => true), + }, + schedules: new FakeScheduleReader([fixtureSchedule({ id: 's1' })]), + telemetry, + }); + const app = new LocalReminderApplication(deps); + await app.start(); + telemetry.deliveries.length = 0; + + listenerRef.current?.({ + type: 'fired', + schedule_id: 's1', + alarm_id: 'alarm-1', + title: '喝水提醒', + at: '2026-08-18T10:00:00.000Z', + }); + await flushAsync(); + + expect(telemetry.deliveries).toEqual([ + expect.objectContaining({ + manufacturer: 'other', + outcome: 'native_ok', + overlay_failed: false, + }), + ]); + }); + }); }); + +class RecordingTelemetry implements ClientTelemetryPort { + readonly deliveries: ReminderDeliveryTelemetry[] = []; + readonly permissions: ReminderPermissionBlockedTelemetry[] = []; + readonly lifecycle: ReminderLifecycleTelemetry[] = []; + readonly nativeBackground: ReminderNativeBackgroundTelemetry[] = []; + readonly errors: string[] = []; + readonly contexts: DeviceTelemetryContext[] = []; + + setDeviceContext(context: DeviceTelemetryContext): void { + this.contexts.push(context); + } + + recordReminderDelivery(event: ReminderDeliveryTelemetry): void { + this.deliveries.push(event); + } + + recordReminderPermissionBlocked(event: ReminderPermissionBlockedTelemetry): void { + this.permissions.push(event); + } + + recordReminderLifecycle(event: ReminderLifecycleTelemetry): void { + this.lifecycle.push(event); + } + + recordReminderNativeBackground(event: ReminderNativeBackgroundTelemetry): void { + this.nativeBackground.push(event); + } + + recordUnexpectedError(kind: 'reminder_delivery'): void { + this.errors.push(kind); + } +} + +function createLifecycle(initial: TelemetryAppState = 'unknown') { + let current = initial; + const listeners = new Set<(state: TelemetryAppState) => void>(); + return { + current: () => current, + subscribe: (listener: (state: TelemetryAppState) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + emit(next: TelemetryAppState) { + current = next; + for (const listener of listeners) listener(next); + }, + }; +} diff --git a/frontend/tests/unit/infrastructure/appState/RNAppStateProvider.test.ts b/frontend/tests/unit/infrastructure/appState/RNAppStateProvider.test.ts index dd360bbf..e237588c 100644 --- a/frontend/tests/unit/infrastructure/appState/RNAppStateProvider.test.ts +++ b/frontend/tests/unit/infrastructure/appState/RNAppStateProvider.test.ts @@ -41,4 +41,10 @@ describe('RNAppStateProvider', () => { unsubscribe(); expect(remove).toHaveBeenCalledTimes(1); }); + + it('exposes the current AppState through current()', () => { + Object.defineProperty(AppState, 'currentState', { configurable: true, value: 'background' }); + const provider = new RNAppStateProvider(); + expect(provider.current()).toBe('background'); + }); }); diff --git a/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts b/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts index ec65df32..a8209e79 100644 --- a/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts +++ b/frontend/tests/unit/infrastructure/location/reminderGuardTask.test.ts @@ -245,3 +245,48 @@ describe('guard task executor routing', () => { expect(listener).not.toHaveBeenCalled(); }); }); + +describe('guard task telemetry', () => { + afterEach(() => { + jest.resetModules(); + setAppState('active'); + }); + + it('records headless native and JS fallback deliveries without schedule ids', () => { + const { module } = loadModule(); + const deliveries: unknown[] = []; + module.setGuardTaskTelemetry({ + setDeviceContext() {}, + recordReminderDelivery(event) { + deliveries.push(event); + }, + recordReminderPermissionBlocked() {}, + recordReminderLifecycle() {}, + recordReminderNativeBackground() {}, + recordUnexpectedError() {}, + }); + setAppState('background'); + + module.recordGuardDelivery(true, 'location', 'high'); + module.recordGuardDelivery(false, 'time', 'low'); + + expect(deliveries).toEqual([ + expect.objectContaining({ + app_state: 'background', + channel: 'native_full_screen', + outcome: 'native_ok', + schedule_type: 'location', + strength: 'high', + trigger_source: 'headless_guard', + }), + expect.objectContaining({ + channel: 'system_notification', + outcome: 'js_channel', + schedule_type: 'time', + strength: 'low', + trigger_source: 'headless_guard', + }), + ]); + expect(JSON.stringify(deliveries)).not.toContain('schedule_id'); + }); +}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts index 40f653db..4e51d34d 100644 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts @@ -6,6 +6,7 @@ import { NativeAlarmScheduler } from '../../../../src/infrastructure/notificatio import { isTimeflowAlarmAvailable, nativeAckAlarmDispositions, + nativeAckFireAttempts, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, nativeCancelAllAlarms, @@ -13,6 +14,7 @@ import { nativeHasArmedAlarm, nativeOpenAlarmPermissionSettings, nativePeekAlarmDispositions, + nativePeekFireAttempts, nativePresentAlarmNow, nativeRequestNotificationPermission, nativeScheduleAlarm, @@ -33,6 +35,8 @@ jest.mock('react-native', () => { hasArmedAlarm: jest.fn(), peekNativeDispositions: jest.fn(), ackNativeDispositions: jest.fn(), + peekNativeFireAttempts: jest.fn(), + ackNativeFireAttempts: jest.fn(), getPermissionStatus: jest.fn(), openPermissionSettings: jest.fn(), requestNotificationPermission: jest.fn(), @@ -90,6 +94,10 @@ type NativeAlarmMock = { () => Promise<{ scheduleId: string; alarmId: string; state: string; updatedAtMillis: number }[]> >; ackNativeDispositions: jest.MockedFunction<(scheduleIds: string[]) => Promise>; + peekNativeFireAttempts: jest.MockedFunction< + () => Promise<{ result: string; atMillis: number }[]> + >; + ackNativeFireAttempts: jest.MockedFunction<() => Promise>; getPermissionStatus: jest.MockedFunction< () => Promise<{ exactAlarm: boolean; @@ -549,6 +557,29 @@ describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { expect(native.ackNativeDispositions).toHaveBeenCalledWith(['a', 'b']); }); + it('peeks and acks native background fire attempts without schedule ids', async () => { + native.peekNativeFireAttempts.mockResolvedValue([ + { result: 'service_denied', atMillis: 1000 }, + { result: 'present_failed', atMillis: 2000 }, + { result: 'unknown', atMillis: 3000 }, + ]); + const scheduler = new NativeAlarmScheduler(); + await expect(scheduler.peekNativeFireAttempts()).resolves.toEqual([ + { result: 'service_denied', at: new Date(1000).toISOString() }, + { result: 'present_failed', at: new Date(2000).toISOString() }, + ]); + await scheduler.ackNativeFireAttempts(); + expect(native.ackNativeFireAttempts).toHaveBeenCalled(); + }); + + it('nativePeekFireAttempts and nativeAckFireAttempts swallow native rejection', async () => { + native.peekNativeFireAttempts.mockRejectedValue(new Error('peek failed')); + await expect(nativePeekFireAttempts()).resolves.toEqual([]); + + native.ackNativeFireAttempts.mockRejectedValue(new Error('ack failed')); + await expect(nativeAckFireAttempts()).resolves.toBeUndefined(); + }); + it('nativeAckAlarmDispositions is a no-op with an empty list and swallows rejection', async () => { await nativeAckAlarmDispositions([]); expect(native.ackNativeDispositions).not.toHaveBeenCalled(); diff --git a/frontend/tests/unit/infrastructure/observability/SentryClientTelemetry.test.ts b/frontend/tests/unit/infrastructure/observability/SentryClientTelemetry.test.ts new file mode 100644 index 00000000..99a9e9c9 --- /dev/null +++ b/frontend/tests/unit/infrastructure/observability/SentryClientTelemetry.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as Sentry from '@sentry/react-native'; + +import { SentryClientTelemetry } from '../../../../src/infrastructure/observability/SentryClientTelemetry'; +import type { ReminderDeliveryTelemetry } from '../../../../src/shared/observability'; + +jest.mock('@sentry/react-native'); + +const mockedScope = ( + Sentry as typeof Sentry & { + mockedScope: { setLevel: jest.Mock; setTag: jest.Mock }; + } +).mockedScope; + +function delivery(overrides: Partial = {}): ReminderDeliveryTelemetry { + return { + app_state: 'active', + channel: 'popup', + deferred_until_foreground: false, + latency_bucket: 'on_time', + manufacturer: 'huawei', + native_armed: false, + outcome: 'js_channel', + overlay_failed: false, + schedule_type: 'location', + strength: 'high', + trigger_source: 'location', + used_fallback_audio: true, + ...overrides, + }; +} + +describe('SentryClientTelemetry', () => { + const telemetry = new SentryClientTelemetry(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets manufacturer and os as global tags', () => { + telemetry.setDeviceContext({ manufacturer: 'xiaomi', os: 'android' }); + expect(Sentry.setTag).toHaveBeenCalledWith('manufacturer', 'xiaomi'); + expect(Sentry.setTag).toHaveBeenCalledWith('os', 'android'); + }); + + it('records reminder delivery with closed-enum tags and an info level for js_channel', () => { + telemetry.recordReminderDelivery(delivery()); + + expect(Sentry.captureMessage).toHaveBeenCalledWith('timeflow.reminder.delivery'); + expect(mockedScope.setLevel).toHaveBeenCalledWith('info'); + expect(Object.fromEntries(mockedScope.setTag.mock.calls)).toEqual({ + app_state: 'active', + channel: 'popup', + deferred_until_foreground: 'false', + latency_bucket: 'on_time', + manufacturer: 'huawei', + native_armed: 'false', + outcome: 'js_channel', + overlay_failed: 'false', + schedule_type: 'location', + strength: 'high', + trigger_source: 'location', + used_fallback_audio: 'true', + }); + }); + + it('records deferred_until_foreground as a warning issue', () => { + telemetry.recordReminderDelivery( + delivery({ + deferred_until_foreground: true, + latency_bucket: 'late_30m', + outcome: 'js_channel', + schedule_type: 'time', + trigger_source: 'js_time', + }), + ); + + expect(mockedScope.setLevel).toHaveBeenCalledWith('warning'); + expect(mockedScope.setTag).toHaveBeenCalledWith('deferred_until_foreground', 'true'); + }); + + it('records native_declined as a warning issue', () => { + telemetry.recordReminderDelivery( + delivery({ + channel: 'native_full_screen', + manufacturer: 'xiaomi', + outcome: 'native_declined', + schedule_type: 'time', + strength: 'medium', + trigger_source: 'js_time', + used_fallback_audio: false, + }), + ); + + expect(mockedScope.setLevel).toHaveBeenCalledWith('warning'); + expect(mockedScope.setTag).toHaveBeenCalledWith('outcome', 'native_declined'); + }); + + it('records native background failures without a schedule id', () => { + telemetry.recordReminderNativeBackground({ + manufacturer: 'xiaomi', + result: 'service_denied', + }); + + expect(Sentry.captureMessage).toHaveBeenCalledWith('timeflow.reminder.native_background'); + expect(mockedScope.setLevel).toHaveBeenCalledWith('error'); + expect(Object.fromEntries(mockedScope.setTag.mock.calls)).toEqual({ + manufacturer: 'xiaomi', + result: 'service_denied', + }); + expect(JSON.stringify(mockedScope.setTag.mock.calls)).not.toContain('schedule'); + }); + + it('records overdue unarmed resumes as warnings', () => { + telemetry.recordReminderLifecycle({ + background_duration_bucket: 'late_5m', + kind: 'foreground_resume', + manufacturer: 'oppo', + overdue_unarmed: 'one', + }); + + expect(Sentry.captureMessage).toHaveBeenCalledWith('timeflow.reminder.foreground_resume'); + expect(mockedScope.setLevel).toHaveBeenCalledWith('warning'); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ category: 'app.lifecycle', message: 'foreground_resume' }), + ); + }); + + it('treats overlay_failed as a warning even when the outcome is native_ok', () => { + telemetry.recordReminderDelivery( + delivery({ + channel: 'native_full_screen', + overlay_failed: true, + outcome: 'native_ok', + trigger_source: 'native_alarm', + }), + ); + expect(mockedScope.setLevel).toHaveBeenCalledWith('warning'); + }); + + it('records native_ok without overlay failure as info', () => { + telemetry.recordReminderDelivery( + delivery({ + channel: 'native_full_screen', + outcome: 'native_ok', + trigger_source: 'native_alarm', + used_fallback_audio: false, + }), + ); + expect(mockedScope.setLevel).toHaveBeenCalledWith('info'); + }); + + it('ignores an empty permission gap list', () => { + telemetry.recordReminderPermissionBlocked({ manufacturer: 'vivo', missing: [] }); + expect(Sentry.captureMessage).not.toHaveBeenCalled(); + }); + + it('records a clean foreground resume as info', () => { + telemetry.recordReminderLifecycle({ + background_duration_bucket: 'on_time', + kind: 'foreground_resume', + manufacturer: 'vivo', + overdue_unarmed: 'none', + }); + expect(mockedScope.setLevel).toHaveBeenCalledWith('info'); + }); + + it('records fallback_notification as a warning', () => { + telemetry.recordReminderNativeBackground({ + manufacturer: 'huawei', + result: 'fallback_notification', + }); + expect(mockedScope.setLevel).toHaveBeenCalledWith('warning'); + }); + + it('records unexpected delivery errors without a schedule id', () => { + telemetry.recordUnexpectedError('reminder_delivery'); + expect(Sentry.captureMessage).toHaveBeenCalledWith('timeflow.reminder.delivery'); + expect(mockedScope.setLevel).toHaveBeenCalledWith('error'); + expect(Object.fromEntries(mockedScope.setTag.mock.calls)).toEqual({ + error_kind: 'exception', + source: 'reminder_delivery', + }); + }); + + it('does not attach a title or schedule id when recording a permission gap', () => { + telemetry.recordReminderPermissionBlocked({ + manufacturer: 'oppo', + missing: ['exact_alarm', 'overlay'], + }); + + expect(Sentry.captureMessage).toHaveBeenCalledWith('timeflow.reminder.permission_blocked'); + expect(Object.fromEntries(mockedScope.setTag.mock.calls)).toEqual({ + manufacturer: 'oppo', + missing_permissions: 'exact_alarm,overlay', + }); + expect(JSON.stringify(mockedScope.setTag.mock.calls)).not.toContain('schedule'); + }); +}); diff --git a/frontend/tests/unit/infrastructure/observability/initSentry.test.ts b/frontend/tests/unit/infrastructure/observability/initSentry.test.ts new file mode 100644 index 00000000..88103126 --- /dev/null +++ b/frontend/tests/unit/infrastructure/observability/initSentry.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as Sentry from '@sentry/react-native'; + +import { initSentry, wrapRoot } from '../../../../src/infrastructure/observability/initSentry'; + +jest.mock('@sentry/react-native'); + +describe('initSentry', () => { + const originalDsn = process.env.EXPO_PUBLIC_SENTRY_DSN; + + afterEach(() => { + if (originalDsn == null) { + delete process.env.EXPO_PUBLIC_SENTRY_DSN; + } else { + process.env.EXPO_PUBLIC_SENTRY_DSN = originalDsn; + } + jest.clearAllMocks(); + }); + + it('stays disabled when the public DSN is unset', () => { + delete process.env.EXPO_PUBLIC_SENTRY_DSN; + initSentry(); + expect(Sentry.init).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + sendDefaultPii: false, + }), + ); + }); + + it('enables the SDK when a DSN is present', () => { + process.env.EXPO_PUBLIC_SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1'; + initSentry(); + expect(Sentry.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@o0.ingest.sentry.io/1', + enabled: true, + sendDefaultPii: false, + }), + ); + expect(Sentry.setTag).toHaveBeenCalledWith('os', expect.any(String)); + }); + + it('strips titles, coordinates, and user ids before send', () => { + process.env.EXPO_PUBLIC_SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1'; + initSentry(); + const options = (Sentry.init as jest.Mock).mock.calls[0]?.[0] as { + beforeSend: (event: Record) => Record; + }; + const scrubbed = options.beforeSend({ + extra: { title: '喝水提醒', outcome: 'native_ok' }, + tags: { schedule_id: 's1', manufacturer: 'xiaomi' }, + user: { id: 'acc_001' }, + }); + + expect(scrubbed.user).toBeUndefined(); + expect(scrubbed.extra).toEqual({ outcome: 'native_ok' }); + expect(scrubbed.tags).toEqual({ manufacturer: 'xiaomi' }); + }); + + it('leaves events without extra or tags untouched besides user', () => { + process.env.EXPO_PUBLIC_SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1'; + initSentry(); + const options = (Sentry.init as jest.Mock).mock.calls[0]?.[0] as { + beforeSend: (event: Record) => Record; + }; + const scrubbed = options.beforeSend({ user: { id: 'acc_001' } }); + expect(scrubbed.user).toBeUndefined(); + expect(scrubbed.extra).toBeUndefined(); + expect(scrubbed.tags).toBeUndefined(); + }); + + it('wraps the root component through the SDK', () => { + const Root = () => null; + expect(wrapRoot(Root)).toBe(Root); + expect(Sentry.wrap).toHaveBeenCalledWith(Root); + }); +}); diff --git a/frontend/tests/unit/shared/observability/ClientTelemetryPort.test.ts b/frontend/tests/unit/shared/observability/ClientTelemetryPort.test.ts new file mode 100644 index 00000000..c6cb5492 --- /dev/null +++ b/frontend/tests/unit/shared/observability/ClientTelemetryPort.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + NOOP_CLIENT_TELEMETRY, + boundAppState, + boundManufacturer, + boundNativeBackgroundResult, + boundOs, + boundPermissions, + countBucket, + isLateLatency, + latencyBucket, + latencyBucketFromTimes, +} from '../../../../src/shared/observability'; + +describe('device telemetry tags', () => { + it('maps unknown or null manufacturers to other', () => { + expect(boundManufacturer('xiaomi')).toBe('xiaomi'); + expect(boundManufacturer('Huawei')).toBe('other'); + expect(boundManufacturer(null)).toBe('other'); + expect(boundManufacturer('samsung')).toBe('other'); + }); + + it('maps platform strings to a closed os enum', () => { + expect(boundOs('android')).toBe('android'); + expect(boundOs('ios')).toBe('ios'); + expect(boundOs('web')).toBe('web'); + expect(boundOs('macos')).toBe('other'); + expect(boundOs(undefined)).toBe('other'); + }); + + it('maps app state strings to a closed enum', () => { + expect(boundAppState('active')).toBe('active'); + expect(boundAppState('background')).toBe('background'); + expect(boundAppState('unknown')).toBe('unknown'); + expect(boundAppState('extension')).toBe('unknown'); + }); + + it('keeps only the closed permission names, in stable order', () => { + expect(boundPermissions(['overlay', 'unknown', 'exact_alarm'])).toEqual([ + 'exact_alarm', + 'overlay', + ]); + }); + + it('buckets delivery delay without exposing raw timestamps', () => { + expect(latencyBucket(12_000)).toBe('on_time'); + expect(latencyBucket(45_000)).toBe('late_1m'); + expect(latencyBucket(3 * 60_000)).toBe('late_5m'); + expect(latencyBucket(12 * 60_000)).toBe('late_30m'); + expect(latencyBucket(2 * 60 * 60_000)).toBe('late_hour_plus'); + expect(latencyBucketFromTimes('2026-08-18T10:00:00.000Z', '2026-08-18T10:30:00.000Z')).toBe( + 'late_30m', + ); + }); + + it('buckets counts and native background results', () => { + expect(countBucket(0)).toBe('none'); + expect(countBucket(1)).toBe('one'); + expect(countBucket(3)).toBe('few'); + expect(countBucket(9)).toBe('many'); + expect(boundNativeBackgroundResult('service_denied')).toBe('service_denied'); + expect(boundNativeBackgroundResult('fallback_notification')).toBe('fallback_notification'); + expect(boundNativeBackgroundResult('boom')).toBeNull(); + expect(boundNativeBackgroundResult(null)).toBeNull(); + }); + + it('classifies late buckets without exposing raw delay', () => { + expect(isLateLatency('on_time')).toBe(false); + expect(isLateLatency('unknown')).toBe(false); + expect(isLateLatency('late_1m')).toBe(true); + expect(latencyBucket(null)).toBe('unknown'); + expect(latencyBucket(Number.NaN)).toBe('unknown'); + expect(latencyBucketFromTimes(null, '2026-08-18T10:00:00.000Z')).toBe('unknown'); + expect(latencyBucketFromTimes('not-a-date', 'also-bad')).toBe('unknown'); + }); + + it('exposes a no-op adapter that swallows every port call', () => { + expect(() => { + NOOP_CLIENT_TELEMETRY.setDeviceContext({ manufacturer: 'other', os: 'android' }); + NOOP_CLIENT_TELEMETRY.recordReminderDelivery({ + app_state: 'active', + channel: 'popup', + deferred_until_foreground: false, + latency_bucket: 'on_time', + manufacturer: 'other', + native_armed: false, + outcome: 'js_channel', + overlay_failed: false, + schedule_type: 'time', + strength: 'low', + trigger_source: 'js_time', + used_fallback_audio: false, + }); + NOOP_CLIENT_TELEMETRY.recordReminderPermissionBlocked({ + manufacturer: 'other', + missing: ['overlay'], + }); + NOOP_CLIENT_TELEMETRY.recordReminderLifecycle({ + background_duration_bucket: 'on_time', + kind: 'foreground_resume', + manufacturer: 'other', + overdue_unarmed: 'none', + }); + NOOP_CLIENT_TELEMETRY.recordReminderNativeBackground({ + manufacturer: 'other', + result: 'present_failed', + }); + NOOP_CLIENT_TELEMETRY.recordUnexpectedError('reminder_delivery'); + }).not.toThrow(); + }); +});