リモート telemetry(opt-in 送信)の運用
spec: docs/superpowers/specs/2026-07-02-telemetry-error-reporting-design.md Phase 2
WingStats Manager / Recorder / Viewer はユーザーが同意した場合のみ、 サニタイズ済み telemetry イベントを Cloudflare Worker(workers/telemetry-ingest/)へ送信する。 同意の入口は 2 つ:
- インストーラのオプションページ「エラーレポートを開発者に送信する(匿名・推奨)」— 既定 ON。
build/installer.nshがHKCU\Software\WingStats\Setup\TelemetryConsentに保存し、初回起動時にseedSetupFromRegistryがtelemetryUpload(enabled / level=errors / consentedAt / consentVersion / installId)をシードする。アプリ内で OFF にした後は、アップグレードで再インストールしても再シードしない - アプリ内の設定タブ「エラーレポートの送信」— いつでも ON/OFF できる。ポータブル版や サイレントインストール以外でインストーラを通らなかった場合はこちらで ON にする
同意が無ければ 1 バイトも送信しない。ローカル記録(telemetry-viewing.md)とは独立した転送層で、 electron/services/telemetry/ は従来どおり network コード禁止(yarn telemetry:network-gate)。
アーキテクチャ
<userData>/logs/telemetry-*.jsonl (収集層 — 変更なし・送信コードなし)
│ 読み取り専用
▼
TelemetryUploader(electron/services/telemetryUpload/ — 同意チェック → filter → マスク → batch/gzip)
│ POST /v1/events (x-app-token)
▼
Cloudflare Worker: wingstats-telemetry-ingest ──▶ D1 events テーブル(90 日で自動 purge)- 送信対象は既定で
error.*+app.crash+app.start/app.quit+api.usageのみ(level: errors) api.fetchは level に関係なく送らない(戦績 API 呼び出しごとに 1 件出て、実測で送信量の 95% を占めたため。ローカル JSONL には残る)- 代わりに
api.usageで戦績 API の呼び出し回数を日ごとに送る({day, total, failed, retries, byEndpoint}。回数だけ)。 日付が変わって最初の呼び出し時と終了時に出るので、同じ日に複数行になる。集計はdayごとに合計する(下の「全利用者の API 呼び出し回数」) error.*/app.crashを記録した瞬間に次回送信を 5 秒後に前倒しする(通常は 15 分間隔)。送り残しがあるときも 5 秒間隔で詰めて送る。クラッシュ直後の再起動・再クラッシュで送る機会を失わないため- 同意 OFF / 未設定で送らないときは、その理由を update.log に 1 回だけ残す(
skipped-consent/skipped-config) - UID / プレイヤー名は hash6、URL は host/path のみ、stack は先頭 5 行(収集時点で処理済み)
- uploader が送信直前に
C:\Users\<名前>→<HOME>の追加マスクをかける - installId はランダム UUID(端末情報から導出しない)
デプロイ状況
2026-08-31 に初回デプロイ、2026-09-04 に再デプロイ済み(/v1/bundle 受け口・reports / settings テーブル・ allowlist に error.db-write / error.login / error.browser-automation / bundle.upload を追加)。 以降は「調査クエリ」の節がそのまま使える。
⚠ allowlist に無いイベントは Worker が行単位で drop して 200 を返す(クライアントは成功と見なす)。 electron 側で TelemetryEvent を増やしたら、同期テストを通したうえで 必ず Worker を再デプロイすること。 再デプロイを忘れると新イベントは黙って捨てられる。
| 項目 | 値 |
|---|---|
| Worker | wingstats-telemetry-ingest |
| エンドポイント | https://wingstats-telemetry-ingest.yukidarumamouri.workers.dev/v1/events |
| D1 | wingstats-telemetry(f50cf5b1-dccb-4db5-bdeb-7de5a2203e3c, APAC) |
| Cron | 日次 03:00 UTC に 90 日超を purge |
| クライアント定数 | electron/services/telemetryUpload/constants.ts に反映済み |
クライアントに埋め込まれた APP_TOKEN は秘密情報ではなく、いたずら投稿を弾くための固定値。 漏れた場合は wrangler secret put APP_TOKEN で更新し、constants.ts も合わせて次のリリースに載せる (旧トークンのビルドは 401 になり送信を諦めるだけで、アプリ本体は壊れない)。
Worker のコードや schema を変えたときの再デプロイ:
cd workers/telemetry-ingest
npx wrangler deploy⚠ schema.sql に表を足したときは deploy より先に
npx wrangler d1 execute wingstats-telemetry --remote --file=workers/telemetry-ingest/schema.sqlを流す(全文IF NOT EXISTSなので何度流しても安全)。daily_counters(日次総量上限)が無い間、/v1/eventsは上限なしで受け続け、/v1/bundleは 503 になる。
レート制限と日次上限(2026-10 の強化)
- レート制限のキーは送信元 IP(
CF-Connecting-IP)だけ。installId は送信側が自由に決められるので使わない。 本文を読む前に判定する。 - 本命は Workers の Rate Limiting バインディング(
wrangler.jsoncのratelimits。EVENTS_RATE_LIMITER60 回/分・BUNDLE_RATE_LIMITER2 回/分)。バインディングが無い環境(ローカル・unit test)や呼び出し失敗時は per-isolate のメモリ制限(events 60 回/時・bundle 3 回/24h、いずれも IP 単位)にフォールバックする。 bundle の 24h 窓のメモリ制限はバインディングがあっても二次防御として通す。 namespace_id(47101 / 47102)はアカウント内で一意な整数を自分で決める値で、ダッシュボードでの事前作成は不要。 同じアカウントの別 Worker と番号を共有しないこと。/v1/eventsは 1 日(UTC)10 万 record で 429(EVENTS_DAILY_RECORD_LIMIT)。実績が分かったら次で見直す:npx wrangler d1 execute wingstats-telemetry --remote --command "SELECT substr(received_at,1,10) d, COUNT(*) c FROM events GROUP BY d ORDER BY d DESC LIMIT 14"/v1/bundleの日次 50 件はdaily_countersの 1 行をUPDATE ... WHERE n < 50 RETURNINGで原子的に確保する (並列投稿でも超えない)。R2 保存・D1 登録に失敗したら枠を戻す。- 今日のカウンタ:
SELECT * FROM daily_counters ORDER BY day DESC LIMIT 10(cron が 7 日で消す)
初回デプロイ手順(新しいアカウントで作り直す場合)
Cloudflare アカウントは docs サイト(wingstats-manager-docs)と同じものを使う。
# 1. D1 データベースを作成
npx wrangler d1 create wingstats-telemetry
# → 出力された database_id を workers/telemetry-ingest/wrangler.jsonc の
# REPLACE_WITH_DATABASE_ID に書き込む
# 2. スキーマ適用
npx wrangler d1 execute wingstats-telemetry --remote --file=workers/telemetry-ingest/schema.sql
# 3. アプリトークンを設定(クライアント埋め込み値と同じもの。ゴミ避け目的の固定値)
cd workers/telemetry-ingest
npx wrangler secret put APP_TOKEN
# 4. デプロイ
npx wrangler deploy
# → https://wingstats-telemetry-ingest.<account>.workers.dev が発行されるデプロイ後、クライアント側(P2-c の electron/services/telemetryUpload/constants.ts)の TELEMETRY_INGEST_ENDPOINT と TELEMETRY_APP_TOKEN を実値に更新してリリースする。
型チェック: corepack yarn worker:typecheck(Worker は別ランタイムのため root tsc の対象外)。 検証ロジックの unit test は corepack yarn vitest run workers(test:unit にも含まれる)。
診断バンドル受け口(POST /v1/bundle)
アプリの「開発者にログを送る」が叩く経路。spec と判定順序は docs/superpowers/specs/2026-09-02-diagnostic-report-upload-design.md §3、 ヘッダ契約は workers/telemetry-ingest/src/bundle.ts の validateBundleHeaders が正。
現状: R2 がアカウントで未有効化のため未稼働。 未バインドの間は 503 を返し、クライアントは ローカル保存にフォールバックする。有効化手順は workers/telemetry-ingest/wrangler.jsonc の コメントに書いてある(schema 適用 → secret 2 つ → r2_buckets のコメントを外して deploy)。
届いたレポートの調べ方:
# 直近のレポート一覧(受付番号 R-xxxxxxxx は report_id の先頭 8 桁)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT report_id, received_at, app_version, mode, category, size_bytes, notify_failed \
FROM reports WHERE deleted_at IS NULL ORDER BY received_at DESC LIMIT 20"
# zip を取得(r2_key は reports.r2_key)
npx wrangler r2 object get wingstats-reports/reports/2026/09/<report_id>.zip --file report.zip
# 同じ install / session の前後の telemetry を突合
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT ts, event, props_json FROM events WHERE session_id = '<session_id>' ORDER BY ts DESC LIMIT 200"
# 全利用者の戦績 API 呼び出し回数(日別。公式サーバに負担をかけていないかの確認。spec: api-request-politeness)
npx wrangler d1 execute wingstats-telemetry --remote --command "SELECT json_extract(props_json,'$.day') AS day, COUNT(DISTINCT install_id) AS installs, SUM(json_extract(props_json,'$.total')) AS total, SUM(json_extract(props_json,'$.failed')) AS failed, SUM(json_extract(props_json,'$.retries')) AS retries FROM events WHERE event = 'api.usage' GROUP BY day ORDER BY day DESC LIMIT 30"
# 受付停止(キルスイッチ。deploy 不要)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"INSERT INTO settings(key, value) VALUES('intake_enabled','0') ON CONFLICT(key) DO UPDATE SET value='0'"削除要求は reports.report_id を特定して R2 の object を消し、行の deleted_at を埋める。 zip 本体は 30 日で cron と R2 ライフサイクルの二重で消える。
API 仕様(クライアント → Worker)
POST /v1/events
- ヘッダ:
x-app-token: <APP_TOKEN>(必須)、content-encoding: gzip(任意) - body(JSON, gzip 可, 512KB 上限):
{
"installId": "3f2b4a1c-9d8e-4f70-a1b2-c3d4e5f60718",
"appVersion": "5.4.0",
"mode": "manager",
"records": [
{ "ts": "2026-07-03T10:00:00.000Z", "sessionId": "…", "event": "error.main", "props": {}, "schemaVersion": 1 }
]
}- レスポンス:
{ "ok": true, "accepted": n, "dropped": m }- 構造不正(installId / mode / records 形式)→ 4xx でペイロードごと reject
- record 単位の不正(allowlist 外 event / ts 不正)→ その行だけ drop して 200
- record は任意で
appVersion/mode(manager|recorder|viewer)を持てる(v7.0.3+)。 D1 のapp_version/mode列は record 側を優先し、無い旧版 record だけ payload の値で補う。- 背景: Manager / Recorder / Viewer は同じ
<userData>/logs/telemetry-*.jsonlに書き、送信は別プロセスが 後から行う。payload の値は「送った側」なので、Viewer の BP 窓のエラーがrecorderとして届いたり、 更新後に旧版のログが新版として届いたりしていた(2026-09-18 に D1 実データで確認)。
- 背景: Manager / Recorder / Viewer は同じ
- 送信は
<userData>/logs/telemetry-upload-cursor.lockでプロセス間排他する(uploadLock.ts)。 Manager と Recorder の tick が重なると同じバッチを 2 回送っていた(カーソルは送信成功後にしか進まない)。 lock を取れなかった側はskipped-lockedでその回を譲る。- 2026-09-18 より前の D1 データはこの二重送信を含む(同じ ts の行が mode 違いで 2 本)。件数を見るときは
COUNT(DISTINCT install_id || session_id || ts || event)などで畳むこと。
- 2026-09-18 より前の D1 データはこの二重送信を含む(同じ ts の行が mode 違いで 2 本)。件数を見るときは
- レート制限: 送信元 IP 単位(Rate Limiting バインディングで 60 req/分、無い環境ではメモリで 60 req/h)。 1 日 10 万 record の総量上限もある(どちらも超過は 429、本文を読む前に判定)
- 送信直前に props の文字列へホームパス・URL クエリ・token / Authorization らしき値のマスクを掛ける (
electron/services/telemetryUpload/mask.tsのsanitizeOutboundDeep。規則はelectron/utils/redactSecrets.ts) - records は 1 リクエスト 500 件まで(413)
日次ダイジェスト(Discord)と 1 コマンドのレポート
気づく仕組み: Worker の日次 cron(03:00 UTC)が直近 24h の error.* / app.crash を event × role × where で集計し、DISCORD_REPORT_WEBHOOK に 1 通投げる(src/digest.ts)。 件数 0 でも「✅ エラーなし」を送るので、通知が来ないこと自体が「壊れた」の合図になる。 前日比(↑↓→)とアクティブ端末数に加え、どの版で起きているかが分かるようになっている:
- 上位エラーの各行末に、そのエラーが出た
app_versionを併記する(例… — 15 / 1 ・v7.0.0)。 4 版以上に跨るときは先頭 3 つ + 「ほか N」に畳む(Discord の 2000 文字制限のため) - 「バージョン別エラー」節で
error.* + app.crashを版ごとに集計する(件数 / 端末) - 「app.crash(バージョン別)」は従来どおり
「新しい版で増えたのか、古い版に取り残された端末なのか」はこの 2 つを見比べれば分かる。 分母(版ごとの稼働端末数)は yarn telemetry:report の「バージョン別の稼働端末」節にある。
secret が未設定なら黙って skip する。設定は:
cd workers/telemetry-ingest
npx wrangler secret put DISCORD_REPORT_WEBHOOK # discord-webhook 台帳の role `wingstats-telemetry` の URL調べる仕組み: yarn telemetry:report が「調査クエリ」節の SQL を順に実行して表で出す。
yarn telemetry:report # 直近 7 日
yarn telemetry:report --days 1 # ダイジェストと同じ範囲
yarn telemetry:report --install <id> # 特定端末のタイムライン(直近 100 件)
yarn telemetry:report --json # 他ツールへ渡すwrangler の OAuth ログインが前提(npx wrangler whoami)。セクションは サマリ / エラー種別(event × role)/ error.main の where 上位 / DB 書き込み失敗 / ログイン失敗 / ブラウザ連携の失敗 / app.crash / 直近のエラー 20 件 / 診断バンドル。
調査クエリ(wrangler d1 execute)
# 直近 24h のエラー一覧
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT ts, install_id, app_version, event, props_json FROM events \
WHERE event LIKE 'error.%' OR event = 'app.crash' \
ORDER BY ts DESC LIMIT 50"
# DB 書き込み失敗を where / SQLite code 別に集計(SQLITE_READONLY が出たら委譲 Manager の誤書き込み)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT json_extract(props_json,'$.where') AS where_, json_extract(props_json,'$.code') AS code, \
COUNT(*) AS c, COUNT(DISTINCT install_id) AS installs \
FROM events WHERE event = 'error.db-write' AND received_at > datetime('now','-7 days') \
GROUP BY where_, code ORDER BY c DESC"
# ログインできない端末(api: token 失効など。source=browser は 2026-10 以降送らない=未ログインを失敗に数えていた)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT json_extract(props_json,'$.source') AS source, json_extract(props_json,'$.method') AS method, \
json_extract(props_json,'$.endpoint') AS endpoint, COUNT(*) AS c, COUNT(DISTINCT install_id) AS installs \
FROM events WHERE event = 'error.login' AND received_at > datetime('now','-7 days') \
GROUP BY source, method, endpoint ORDER BY c DESC"
# ブラウザ連携の失敗(message 上位)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT json_extract(props_json,'$.where') AS where_, json_extract(props_json,'$.message') AS message, \
COUNT(*) AS c, COUNT(DISTINCT install_id) AS installs \
FROM events WHERE event = 'error.browser-automation' AND received_at > datetime('now','-7 days') \
GROUP BY where_, message ORDER BY c DESC LIMIT 20"
# version 別のクラッシュ数
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT app_version, COUNT(*) AS crashes FROM events \
WHERE event = 'app.crash' GROUP BY app_version ORDER BY crashes DESC"
# 特定 install の直近タイムライン(ユーザー報告時に installId を添えてもらう)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT ts, event, props_json FROM events \
WHERE install_id = '<installId>' ORDER BY ts DESC LIMIT 100"
# 画面と操作の使われ方(all レベルの同意者だけ。PC は screen=タブのキー / action=操作名・setting:<キー>)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT mode, event, COALESCE(json_extract(props_json,'$.screen'), json_extract(props_json,'$.action')) AS name, \
COUNT(*) AS c, COUNT(DISTINCT install_id) AS installs \
FROM events WHERE event IN ('ui.screen-view','ui.action') AND received_at > datetime('now','-7 days') \
GROUP BY mode, event, name ORDER BY c DESC"
# 試合の記録(Recorder のログ監視。all レベルの同意者だけ。skipped の reason は filtered / duplicate / merged)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT event, json_extract(props_json,'$.matchType') AS type, json_extract(props_json,'$.reason') AS reason, \
COUNT(*) AS c FROM events WHERE event LIKE 'match.%' AND received_at > datetime('now','-7 days') \
GROUP BY event, type, reason ORDER BY c DESC"
# アクティブ install 数(直近 7 日)
npx wrangler d1 execute wingstats-telemetry --remote --command \
"SELECT COUNT(DISTINCT install_id) FROM events \
WHERE received_at > datetime('now', '-7 days')"データ保持と削除
- Cron(日次 03:00 UTC)で
received_atが 90 日より古い行を自動削除 - ユーザーから削除要求があった場合:
npx wrangler d1 execute wingstats-telemetry --remote --command \
"DELETE FROM events WHERE install_id = '<installId>'"関連
- ローカル telemetry の見方: telemetry-viewing.md
- 設計:
docs/superpowers/specs/2026-07-02-telemetry-error-reporting-design.md(7 章) - Worker 実装:
workers/telemetry-ingest/