Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- [本番リリース PR でのバージョン更新](./bump-version-on-release-pr.md)
- [AI コードレビュー (GPT-5.6 Sol)](./ai-code-review-workflow.md)
- [Firebase Test Lab の Robo テスト](./test-lab-robo.md)
- [テレメトリの本番オプトイン: リスク評価](./telemetry-production-optin-risk.md)
- [AIエージェント設計書](./spec/ai-agent/architecture.md)
- [リモートTTS(iOS)設計書](./spec/tts/remote-tts.md)
- [オンデバイスTTS(VOICEVOX / iOS)設計書](./spec/tts/on-device-tts-ios.md)
185 changes: 185 additions & 0 deletions docs/telemetry-production-optin-risk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# テレメトリの本番オプトイン: リスク評価

試験的機能として canary 限定で動いているテレメトリを、本番 (App Store / Google Play
配布ビルド) でもオプトイン可能にする場合に何が起きるかを整理したもの。実装前の
意思決定用メモであり、この文書の時点でコードには一切手を付けていない。

結論として、**プライバシーポリシーの改定は必須**で、かつそれだけでは足りない。
コード側の前提を 3 点変えないと本番投入は危険。

## 現状のゲート

```text
isTelemetryEnabledByBuild = isDevApp && ENABLE_EXPERIMENTAL_TELEMETRY === 'true'
```

- `src/utils/telemetryConfig.ts`: ビルド時定数。canary (dev アプリ) でのみ true
- `src/screens/AppSettings.tsx`: 試験的機能の導線自体が `isDevApp` でガードされている
- `src/screens/ExperimentalSettings.tsx`: ユーザー側トグル。既定は false (オプトイン)
- `src/components/Permitted.tsx`: MMKV (`STORAGE_KEYS.TELEMETRY_ENABLED`) から復元

つまり「本番でオプトイン」は、ビルド時ゲートを外すことと、試験的機能画面の
`isDevApp` ガードを外す (あるいはトグルを通常の設定画面へ出す) ことの両方を指す。
Comment thread
coderabbitai[bot] marked this conversation as resolved.

これとは別に、送信先 URL (`EXPERIMENTAL_TELEMETRY_ENDPOINT_URL`) が空なら、下に挙げる
送信経路はいずれも早期 return する。コンソールテレメトリの場合は `console.*` の差し替え自体が
行われない。ビルド時ゲートを外しても、本番ビルドへ URL を焼き込まない限り送信は
起きない。

## オプトインすると実際に何が送信されるか

| 経路 | 中身 | 頻度 |
| --- | --- | --- |
| `sendLocation` | 緯度・経度 (生の精度)、accuracy、速度、lineId / stationId、移動状態、バッテリー残量・充電状態、端末モデル名、アプリバージョン (ビルド番号込)、OS、channel、sessionId | 最短 1 秒に 1 回 (`TELEMETRY_THROTTLE_MS = 1000`) |
| `sendLogEvent` / `sendInteractionEvent` | アプリ内ログ、`app_launch` などの操作イベント | 随時 |
| `useConsoleTelemetry` | `console.log` / `debug` / `warn` / `error` の全出力 | 5 秒ごとにフラッシュ |

送信先はいずれも `EXPERIMENTAL_TELEMETRY_ENDPOINT_URL`。GraphQL ミューテーション
(`/graphql`) と、コンソールテレメトリだけが使う REST (`/api/log`) の 2 系統がある。

ただし後者には対応するルートが無い。THQ (`TrainLCD/THQ`) のルータに定義されて
いるのは `/`、`/ws`、`/healthz`、`/graphql` の 4 つで、`/api/log` は含まれない
(`src/server.rs`)。GraphQL 側には同じ役割の `sendLogEvent` があり、そちらは
`sessionId` / `appVersion` / `platform` / `channel` も受け取れる。クライアント側は
カスケードエラーを防ぐため送信結果を見ないので、この不一致はアプリ側からは
分からない。どちらへ寄せるかの方針決めは TrainLCD/THQ#38。

### 同意文言と取得範囲のズレ

トグルの説明は `telemetryDescription` (`assets/translations/ja.json:351`,
`assets/translations/en.json:350`) の「お使いの端末の地理的座標を解析用サーバに
送信します」だけ。バッテリー・端末モデル・ログ本文・操作イベントは説明されて
いない。同意を取る地点の文言が実際の取得範囲を下回っているので、ポリシーと
あわせてこの文言も直す必要がある。

## プライバシーポリシーとの矛盾

現行ポリシー (`https://trainlcd.app/privacy-policy` / 最終更新 令和 8 年 8 月 13 日)
には、本番テレメトリと正面から矛盾する記述が 2 つある。

1. **「取得した位置情報は、駅の判定のために一時的に TinyKitten の管理するサーバーへ
送信されますが、サーバー上に保管されることはありません。」**
本番テレメトリは位置ログを解析基盤に永続化して集計するのが目的なので、この
一文はそのままでは事実に反する。「解析目的で送信された位置情報は、利用者が
明示的に有効化した場合に限り N 日間保管される」という趣旨へ書き換える。
1. **「位置情報を、広告の配信や利用者様の行動の追跡 (トラッキング) のために
利用することは一切ありません」**
広告目的でないのは事実だが、1Hz の連続測位ログは移動軌跡そのもので、
sessionId によりプロセス単位で連結できる。「やらないこと」だけを書くのでは
なく、「セッション単位の移動軌跡を解析目的で保管する。ただし個人の特定・
広告配信・アプリ横断の追跡には利用しない」と、やることを書いた上で否定する
形にする。

### 新設が必要な項目

現行ポリシーに項目自体が無く、追記が要るもの。

- 取得項目の追加: バッテリー残量・充電状態、端末モデル名、アプリバージョン、
アプリ内ログ本文、操作イベント
- 保管期間の明示: 現状は「一定の期間」で数値が無い。位置ログは日数を切る
- オプトアウト導線: 「端末の設定から停止できる」だけでなく、アプリ内設定から
個別に無効化できることを明記する
- 保管先: テレメトリ基盤 (THQ) の記載が無い。THQ は TinyKitten が管理する国内の
サーバで動き、Cloudflare 経由で公開している。保管も国内で完結し、第三者への
提供も無いので、外国にある第三者への提供 (個人情報保護法 28 条) と、外国で
取り扱う場合の「外的環境の把握」(安全管理措置) はどちらも論点にならない。
経路に入る Cloudflare は現行ポリシーが既に外部サービスとして記載している
- 開示・削除請求への応答方針: 後述のとおり、特定可能性の評価と手順の決めが要る

## コード側で先に直すべき 3 点

### 1. コンソールテレメトリは本番に出さない

`useConsoleTelemetry` は `console.*` を横取りして送る。`babel.config.js` は
production 環境のビルドで `transform-remove-console` を有効にし、`warn` / `error`
以外の `console.*` を除去するので、本番ビルドでこの経路に乗るのは警告とエラーに
なる。それでもサードパーティ製ライブラリのエラーや、ディープリンク URL・ユーザー
入力を含む例外メッセージは `console.error` で出るため、何が入るかは制御できない。
`sanitizeTelemetryMessage` の防御は正規表現 4 本
(`src/utils/sanitizeTelemetryMessage.ts`) で、キー名が想定外の秘密情報や生の JWT は
素通りする。

加えて `/api/log` への送信ペイロードには `channel` も `sessionId` も入っていない
ため、本番ログと canary ログが基盤側で区別できない。

前述のとおり、この経路の送信先 `/api/log` は THQ のルータに無い。受け口を用意すれば
上記の内容がそのまま流れ込むので、本番へ出す前にコンソール経路を `sendLogEvent` へ
寄せるか、経路ごと落とすかを決めておく。

対応は、本番では `FxConsoleTelemetry` (`src/screens/Main.tsx`) をマウントしない。
出すなら `warn` / `error` 限定かつ送信元を許可リストで絞る作りに変えてから。

### 2. 静的 Bearer トークンをやめる

`EXPERIMENTAL_TELEMETRY_TOKEN` は Babel プラグイン (`babel.config.js` の
`module:react-native-dotenv`) がビルド時に値へ置き換えるので、トークンはバンドルの
中身そのものになる。canary は配布先が限られるため許容できていたが、本番は公開配布
なので、アプリを入手した第三者は正規クライアントと同じ資格情報を持つことになる。
書き込み権限を端末ごとに絞れないため、汚染された位置ログを後から見分ける手段も
無い。

`src/lib/session.ts` に installId を短期セッショントークンへ交換する仕組み
(`/auth/token`) が既にあるので、これに乗せ替えるのが筋。

### 3. リモートキルスイッチを付ける

現在のゲートはビルド時定数なので、事故ってもストア審査を通さないと止められない。
`REMOTE_CONFIG_KEYS.AI_AGENT_ENABLED` に「障害・コスト超過時にサーバー側から機能を
止められるようにするキルスイッチ」という先例があるので、同じ形で
`telemetry_enabled` を追加する。

## その他のリスク

### ストア申告

App Store のプライバシーラベルと Google Play のデータセーフティは、アプリが実際に
収集する内容と一致している必要がある。位置情報を解析目的で収集する状態になるので、
申告内容を見直す。ATT が必要かどうかは「アプリ横断の追跡に使うか」「データブローカー
へ提供するか」で決まるため、そこを広げない設計を前提にする。

### 開示・削除請求への対応方針

`sessionId` はプロセスごとの使い捨て UUID (`src/hooks/useTelemetrySender.ts` の
`getOrCreateSessionId`) で端末に残らない。ただし「端末に ID が残らない」ことだけを
根拠に特定不能と決めつけない。送信されるのは連続した移動軌跡と時刻・端末モデル・
アプリバージョンなので、保管側の他の情報と突き合わせれば特定できる場合がある。
まず照合可能性の評価が要る。

そのうえで方針を決める。GDPR 17 条の削除請求に対して 11 条 (特定を要しない取扱い)
を援用できるのは、特定できないことを管理者が示せる場合に限られ、請求者が特定に
足る追加情報を出してきたときは同条を理由に一律で断れない。請求を受けたときの手順
(申請内容との照合、対象データの特定、削除可否と理由の記録、特定できない場合の説明、
追加情報が出てきたときの再評価) を決めておく。削除に応じられる形にするなら
installId 紐づけへ変える選択肢もあるが、匿名性は下がる。基盤側の論点として
TrainLCD/THQ#37 に起票済み。

### コストと電池

スロットル (`TELEMETRY_THROTTLE_MS`) が効くのは `sendLocation` だけで、最大 1 秒 1
リクエストに制限されるものの、バッチングもリトライ制御も無い。毎秒の測位が続けば
30 分乗車で 1 ユーザーあたり 1,800 リクエストになる。他の経路はこの制限の外にあり、
`sendLogEvent` / `sendInteractionEvent` は呼ばれるたびに送信し、コンソール経路は
5 秒ごとのフラッシュでキューに溜まったログ 1 件につき 1 リクエストを出す。送信は画面を見ている間に限らない。`index.js` の `TaskManager`
タスクが `handleTrackingLocation` 経由で `locationAtom` を更新し、
`FxTelemetrySender` がそれを購読しているため、バックグラウンド測位が動いている
あいだは測位のたびに送信が続く。canary の母数では問題にならなかった数字が、本番の DAU では桁が
変わる。本番はサンプリング (対象ユーザーの N 分の 1) か送信間隔の緩和 (5〜10 秒)
が現実的。省電力測位モードの効果を毎秒の通信で部分的に打ち消す点も検討が要る。

### 同意のスコープクリープ

MMKV に `'true'` が永続化されるだけなので、将来取得項目を増やしたときに古い同意が
そのまま流用される。同意バージョンを持たせ、範囲を広げるときは再同意を取る設計に
しておく。

## 推奨する出し方

段階を踏むなら、次の構成で本番へ出す。

- 位置イベントのみ (コンソールテレメトリとインタラクションイベントは canary 据え置き)
- サンプリングまたは送信間隔の緩和
- Remote Config によるキルスイッチ
- セッショントークン認証

プライバシーポリシーはこの「位置イベントのみ」の範囲で書き換え、取得項目を
増やすときに改めて改定する。
Loading