Skip to content

リモート 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 を再デプロイすること。 再デプロイを忘れると新イベントは黙って捨てられる。

項目値
Workerwingstats-telemetry-ingest
エンドポイントhttps://wingstats-telemetry-ingest.yukidarumamouri.workers.dev/v1/events
D1wingstats-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 を変えたときの再デプロイ:

bash
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_LIMITER 60 回/分・ BUNDLE_RATE_LIMITER 2 回/分)。バインディングが無い環境(ローカル・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)と同じものを使う。

bash
# 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)。

届いたレポートの調べ方:

bash
# 直近のレポート一覧(受付番号 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 上限):
json
{
  "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 実データで確認)。
  • 送信は <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) などで畳むこと。
  • レート制限: 送信元 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 する。設定は:

bash
cd workers/telemetry-ingest
npx wrangler secret put DISCORD_REPORT_WEBHOOK   # discord-webhook 台帳の role `wingstats-telemetry` の URL

調べる仕組み: yarn telemetry:report が「調査クエリ」節の SQL を順に実行して表で出す。

bash
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) ​

bash
# 直近 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 日より古い行を自動削除
  • ユーザーから削除要求があった場合:
bash
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/