feat: テレメトリ実装設計書を追加
This commit is contained in:
932
src-python/docs/telemetry_design.md
Normal file
932
src-python/docs/telemetry_design.md
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
# VRCT Python Sidecar テレメトリ(Aptabase)実装設計書
|
||||||
|
|
||||||
|
## 目次
|
||||||
|
1. [概要](#概要)
|
||||||
|
2. [基本方針](#基本方針)
|
||||||
|
3. [アーキテクチャ](#アーキテクチャ)
|
||||||
|
4. [イベント仕様](#イベント仕様)
|
||||||
|
5. [データフロー](#データフロー)
|
||||||
|
6. [API 設計](#api-設計)
|
||||||
|
7. [実装詳細](#実装詳細)
|
||||||
|
8. [テスト計画](#テスト計画)
|
||||||
|
9. [セキュリティとプライバシー](#セキュリティとプライバシー)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
### 目的
|
||||||
|
VRCT の **匿名な使用状況** を取得し、プロダクトの改善に役立てる。
|
||||||
|
|
||||||
|
### 特徴
|
||||||
|
- **デフォルト有効**:テレメトリはデフォルトで有効化される
|
||||||
|
- **ユーザーコントロール**:設定から任意でオン/オフ可能
|
||||||
|
- **プライバシー重視**:個人情報・入力内容・音声データは一切送信しない
|
||||||
|
- **OSS対応**:透明性と説明可能性を重視した設計
|
||||||
|
- **安定性優先**:失敗時の再送・保存は行わない
|
||||||
|
|
||||||
|
### 使用技術
|
||||||
|
- **SDK**: Aptabase Python SDK
|
||||||
|
- **ホスト**:Aptabase クラウド
|
||||||
|
- **App Key**: コード直埋め込み(環境変数・外部ファイルは未使用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 基本方針(厳守事項)
|
||||||
|
|
||||||
|
| 項目 | 方針 |
|
||||||
|
|------|------|
|
||||||
|
| **デフォルト状態** | 有効(`telemetry_enabled = true`) |
|
||||||
|
| **ユーザー制御** | 設定から任意で無効化可能 |
|
||||||
|
| **無効時の動作** | 一切の通信・スレッド・処理を停止 |
|
||||||
|
| **送信内容** | イベント名と固定属性のみ |
|
||||||
|
| **禁止データ** | 個人識別 ID、入力内容、音声データ、UI 状態 |
|
||||||
|
| **失敗時動作** | 例外を握りつぶし、再送・保存・バッファリング禁止 |
|
||||||
|
| **オフライン時** | 機能を停止するのみ(アプリ動作に影響なし) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## アーキテクチャ
|
||||||
|
|
||||||
|
### ディレクトリ構成
|
||||||
|
```
|
||||||
|
src-python/
|
||||||
|
├── models/
|
||||||
|
│ └── telemetry/
|
||||||
|
│ ├── __init__.py # Public API + singleton管理
|
||||||
|
│ ├── client.py # Aptabase wrapper
|
||||||
|
│ ├── state.py # Enable/disable, last_activity
|
||||||
|
│ ├── heartbeat.py # 5分間隔heartbeatスレッド
|
||||||
|
│ └── core.py # イベント送信ロジック
|
||||||
|
└── docs/
|
||||||
|
└── details/
|
||||||
|
└── telemetry.md # 実装詳細ドキュメント
|
||||||
|
```
|
||||||
|
|
||||||
|
### コンポーネント責任分離
|
||||||
|
|
||||||
|
| モジュール | 責任 |
|
||||||
|
|-----------|------|
|
||||||
|
| `__init__.py` | パブリック API、シングルトン管理 |
|
||||||
|
| `client.py` | Aptabase SDK ラッパー、HTTP通信 |
|
||||||
|
| `state.py` | ON/OFF 状態、最終操作時刻管理 |
|
||||||
|
| `heartbeat.py` | 5分間隔スレッド、タイムアウト判定 |
|
||||||
|
| `core.py` | イベント構築・送信、重複検出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## イベント仕様
|
||||||
|
|
||||||
|
### 送信イベント一覧(固定・追加禁止)
|
||||||
|
|
||||||
|
| イベント名 | 説明 | ペイロード |
|
||||||
|
|-----------|------|----------|
|
||||||
|
| `app_started` | アプリ起動時 | `{}` |
|
||||||
|
| `app_closed` | アプリ終了時 | `{}` |
|
||||||
|
| `session_heartbeat` | 5分間隔アクティブ確認 | `{}` |
|
||||||
|
| `core_feature` | コア機能開始 | `{"feature": "translation" \| "mic_speech_to_text" \| "speaker_speech_to_text" \| "text_input"}` |
|
||||||
|
| `settings_opened` | 設定画面を開く | `{}` |
|
||||||
|
| `config_changed` | 設定変更 | `{"section": str}` |
|
||||||
|
| `error` | エラー発生 | `{"error_type": str}` |
|
||||||
|
|
||||||
|
### `core_feature` イベント詳細
|
||||||
|
|
||||||
|
機能の **実処理開始時** のみ送信(1セッション1回のみ)
|
||||||
|
|
||||||
|
#### 定義済み種別(拡張禁止)
|
||||||
|
```python
|
||||||
|
CORE_FEATURES = {
|
||||||
|
"translation": "翻訳機能を使用",
|
||||||
|
"mic_speech_to_text": "マイク音声認識(送信側)",
|
||||||
|
"speaker_speech_to_text": "スピーカー音声認識(受信側)",
|
||||||
|
"text_input": "テキスト入力(チャット送信)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 送信ルール
|
||||||
|
- **マイク/スピーカーは必ず分離**:`mic_speech_to_text` と `speaker_speech_to_text` は別イベント
|
||||||
|
- **1セッション中に同一 feature は1回のみ**:重複送信禁止
|
||||||
|
- **実処理開始時のみ**:API呼び出し前の段階で送信
|
||||||
|
- **UI 状態不問**:最前面/最小化のような UI 状態は判定に使用しない
|
||||||
|
|
||||||
|
#### 実装例
|
||||||
|
```python
|
||||||
|
# 翻訳実行前
|
||||||
|
telemetry.track_core_feature("translation")
|
||||||
|
result = translator.translate(...) # 実処理
|
||||||
|
|
||||||
|
# マイク文字起こし開始時
|
||||||
|
telemetry.track_core_feature("mic_speech_to_text")
|
||||||
|
recorder.start() # 実処理
|
||||||
|
|
||||||
|
# チャット送信時
|
||||||
|
telemetry.track_core_feature("text_input")
|
||||||
|
send_message() # 実処理
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Heartbeat 仕様
|
||||||
|
|
||||||
|
#### アクティブ判定条件
|
||||||
|
以下のいずれかが発生した場合、セッションはアクティブ
|
||||||
|
|
||||||
|
1. **テキスト入力**
|
||||||
|
- チャット送信
|
||||||
|
- メッセージボックスへの入力
|
||||||
|
|
||||||
|
2. **ASR 実処理**(マイク or スピーカー)
|
||||||
|
- マイク音声認識開始
|
||||||
|
- スピーカー音声認識開始
|
||||||
|
- 実際の音声処理(UI 状態は使用しない)
|
||||||
|
|
||||||
|
#### 送信ルール
|
||||||
|
- **送信間隔**:5分(300秒)
|
||||||
|
- **タイムアウト**:最後のアクティビティから5分経過で送信停止
|
||||||
|
- **復帰条件**:アクティビティ発生 → 次の heartbeat から再開
|
||||||
|
- **無条件停止**:テレメトリ OFF 時は即座にスレッド停止
|
||||||
|
|
||||||
|
#### 実装概略図
|
||||||
|
```
|
||||||
|
Timeline:
|
||||||
|
├─ [Activity] touch_activity() →更新
|
||||||
|
├─ [300秒待機]
|
||||||
|
├─ [Check] 最後の操作 < 5分? → Yes → send heartbeat
|
||||||
|
├─ [300秒待機]
|
||||||
|
├─ [Check] 最後の操作 < 5分? → No → 待機のみ
|
||||||
|
└─ [Activity] touch_activity() →復帰 → 次heartbeat送信
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## データフロー
|
||||||
|
|
||||||
|
### 初期化フロー
|
||||||
|
|
||||||
|
```
|
||||||
|
1. アプリ起動
|
||||||
|
↓
|
||||||
|
2. config.json から telemetry_enabled 読込
|
||||||
|
↓
|
||||||
|
3. telemetry.init(enabled=config.telemetry_enabled)
|
||||||
|
├─ enabled=True の場合
|
||||||
|
│ ├─ Aptabase SDK 初期化
|
||||||
|
│ ├─ heartbeat スレッド開始
|
||||||
|
│ └─ app_started イベント送信
|
||||||
|
└─ enabled=False の場合
|
||||||
|
└─ すべての処理をスキップ
|
||||||
|
```
|
||||||
|
|
||||||
|
### イベント送信フロー
|
||||||
|
|
||||||
|
```
|
||||||
|
操作発生(翻訳/ASR/テキスト入力)
|
||||||
|
↓
|
||||||
|
telemetry_enabled チェック
|
||||||
|
├─ False → 何もしない
|
||||||
|
└─ True
|
||||||
|
↓
|
||||||
|
touch_activity() → 最終時刻更新
|
||||||
|
↓
|
||||||
|
1セッション内で同一 feature 送信済み?
|
||||||
|
├─ Yes → スキップ
|
||||||
|
└─ No
|
||||||
|
↓
|
||||||
|
track_core_feature(feature_name)
|
||||||
|
↓
|
||||||
|
try:
|
||||||
|
Aptabase.track(event)
|
||||||
|
except:
|
||||||
|
握りつぶし(ログに出力しない)
|
||||||
|
```
|
||||||
|
|
||||||
|
### OFF → ON 時の復帰フロー
|
||||||
|
|
||||||
|
```
|
||||||
|
OFF 状態(設定保存)
|
||||||
|
↓
|
||||||
|
ユーザーが ON に切り替え
|
||||||
|
↓
|
||||||
|
telemetry.init(enabled=True)
|
||||||
|
├─ 既知例外は握りつぶし
|
||||||
|
├─ Aptabase 初期化
|
||||||
|
├─ heartbeat スレッド開始
|
||||||
|
└─ 次のイベントから通常送信
|
||||||
|
```
|
||||||
|
|
||||||
|
### 設定変更フロー
|
||||||
|
|
||||||
|
```
|
||||||
|
config.json 更新
|
||||||
|
↓
|
||||||
|
telemetry_enabled: false に変更
|
||||||
|
↓
|
||||||
|
telemetry.shutdown()
|
||||||
|
├─ heartbeat スレッド停止
|
||||||
|
├─ 内部状態をリセット
|
||||||
|
└─ メモリ解放
|
||||||
|
↓
|
||||||
|
OFF 中のデータは完全破棄
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 設計
|
||||||
|
|
||||||
|
### パブリック API
|
||||||
|
|
||||||
|
```python
|
||||||
|
# =========================================================================
|
||||||
|
# INITIALIZATION & SHUTDOWN
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def telemetry.init(enabled: bool) -> None:
|
||||||
|
"""
|
||||||
|
テレメトリを初期化
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled (bool): テレメトリを有効にするか
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- enabled=True: Aptabase SDK初期化、heartbeat開始、app_started送信
|
||||||
|
- enabled=False: すべてのスキップ、以後のtrack()は何もしない
|
||||||
|
|
||||||
|
Exception:
|
||||||
|
- SDK初期化失敗時: 例外握りつぶし、機能は停止するが無言
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry.shutdown() -> None:
|
||||||
|
"""
|
||||||
|
テレメトリを終了
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- app_closed イベント送信
|
||||||
|
- heartbeat スレッド停止
|
||||||
|
- 内部状態をリセット
|
||||||
|
- 例外は握りつぶし(無言)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# EVENT TRACKING
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def telemetry.track(
|
||||||
|
event: str,
|
||||||
|
payload: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
汎用イベント送信
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event (str): イベント名("app_started", "error" など)
|
||||||
|
payload (dict | None): イベントペイロード(デフォルト: None)
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- enabled=False なら何もしない
|
||||||
|
- 失敗時は例外を握りつぶし(ログ出力なし)
|
||||||
|
- 再送・バッファリングなし
|
||||||
|
|
||||||
|
Example:
|
||||||
|
telemetry.track("error", {"error_type": "VRAM_ERROR"})
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry.track_core_feature(feature: str) -> None:
|
||||||
|
"""
|
||||||
|
コア機能イベント送信(重複検出付き)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature (str): 機能名
|
||||||
|
("translation" | "mic_speech_to_text" | "speaker_speech_to_text" | "text_input")
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- enabled=False なら何もしない
|
||||||
|
- 1セッション内で同一 feature の重複送信を防止
|
||||||
|
- 実処理開始時に呼び出す(API呼び出し前)
|
||||||
|
- 失敗時は例外握りつぶし
|
||||||
|
|
||||||
|
Example:
|
||||||
|
telemetry.track_core_feature("translation")
|
||||||
|
result = translator.translate(...)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# ACTIVITY TRACKING
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def telemetry.touch_activity() -> None:
|
||||||
|
"""
|
||||||
|
最終アクティビティ時刻を更新(heartbeat判定用)
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- enabled=False なら何もしない
|
||||||
|
- 内部的に datetime.now() を記録
|
||||||
|
- 自動呼び出し: track_core_feature() 内から呼び出される
|
||||||
|
- 手動呼び出し: テキスト入力検出時など、明示的に呼び出し可
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# テキスト入力時
|
||||||
|
telemetry.touch_activity()
|
||||||
|
|
||||||
|
# track_core_feature() は内部で自動呼び出し
|
||||||
|
telemetry.track_core_feature("translation")
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# STATE QUERY
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def telemetry.is_enabled() -> bool:
|
||||||
|
"""
|
||||||
|
テレメトリが有効か確認
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True なら有効、False なら無効
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry.get_state() -> dict:
|
||||||
|
"""
|
||||||
|
現在の内部状態を取得(デバッグ用)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
"enabled": bool,
|
||||||
|
"last_activity": datetime | None,
|
||||||
|
"session_features_sent": list[str],
|
||||||
|
"heartbeat_active": bool,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### パブリック API 使用例
|
||||||
|
|
||||||
|
#### ケース1: アプリ起動・終了
|
||||||
|
|
||||||
|
```python
|
||||||
|
# mainloop.py
|
||||||
|
from models.telemetry import telemetry
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
# 起動時
|
||||||
|
def app_startup():
|
||||||
|
telemetry.init(enabled=config.TELEMETRY_ENABLED)
|
||||||
|
# ... 他の初期化処理
|
||||||
|
|
||||||
|
# 終了時
|
||||||
|
def app_shutdown():
|
||||||
|
telemetry.shutdown()
|
||||||
|
# ... 他のクリーンアップ
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ケース2: 翻訳機能
|
||||||
|
|
||||||
|
```python
|
||||||
|
# controller.py
|
||||||
|
def micMessage(self, result: dict):
|
||||||
|
# テキスト入力フェーズ(テキスト入力イベント用)
|
||||||
|
message = result["text"]
|
||||||
|
telemetry.touch_activity()
|
||||||
|
|
||||||
|
# 翻訳開始前(コア機能イベント送信)
|
||||||
|
if config.ENABLE_TRANSLATION:
|
||||||
|
telemetry.track_core_feature("translation")
|
||||||
|
translation, success = model.getInputTranslate(message)
|
||||||
|
# ... 処理続行
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ケース3: マイク文字起こし
|
||||||
|
|
||||||
|
```python
|
||||||
|
# model.py
|
||||||
|
def startMicTranscript(self, fnc):
|
||||||
|
# マイク開始前
|
||||||
|
telemetry.track_core_feature("mic_speech_to_text")
|
||||||
|
|
||||||
|
mic_device = selected_mic_device[0]
|
||||||
|
self.mic_audio_recorder = SelectedMicEnergyAndAudioRecorder(...)
|
||||||
|
self.mic_audio_recorder.recordIntoQueue(...)
|
||||||
|
# ... 処理続行
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ケース4: エラー報告
|
||||||
|
|
||||||
|
```python
|
||||||
|
# controller.py
|
||||||
|
except Exception as e:
|
||||||
|
is_vram_error, error_message = model.detectVRAMError(e)
|
||||||
|
if is_vram_error:
|
||||||
|
telemetry.track("error", {"error_type": "VRAM_ERROR"})
|
||||||
|
# ... エラー処理
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ケース5: 設定変更
|
||||||
|
|
||||||
|
```python
|
||||||
|
# controller.py
|
||||||
|
def setUiLanguage(data):
|
||||||
|
config.UI_LANGUAGE = data
|
||||||
|
telemetry.track("config_changed", {"section": "appearance"})
|
||||||
|
return {"status": 200, "result": config.UI_LANGUAGE}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 実装詳細
|
||||||
|
|
||||||
|
### `models/telemetry/__init__.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
テレメトリ(Aptabase)管理モジュール
|
||||||
|
|
||||||
|
パブリック API を提供し、内部実装を隠蔽する。
|
||||||
|
"""
|
||||||
|
from .state import TelemetryState
|
||||||
|
from .heartbeat import HeartbeatManager
|
||||||
|
from .core import TelemetryCore
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry:
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self.state = TelemetryState()
|
||||||
|
self.heartbeat = HeartbeatManager(self.state)
|
||||||
|
self.core = TelemetryCore(self.state)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def init(self, enabled: bool):
|
||||||
|
"""テレメトリ初期化"""
|
||||||
|
self.state.set_enabled(enabled)
|
||||||
|
if enabled:
|
||||||
|
try:
|
||||||
|
self.core.send_event("app_started")
|
||||||
|
self.heartbeat.start()
|
||||||
|
except Exception:
|
||||||
|
pass # 握りつぶし
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""テレメトリ終了"""
|
||||||
|
if self.state.is_enabled():
|
||||||
|
try:
|
||||||
|
self.core.send_event("app_closed")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.heartbeat.stop()
|
||||||
|
self.state.reset()
|
||||||
|
|
||||||
|
def track(self, event: str, payload: dict = None):
|
||||||
|
"""汎用イベント送信"""
|
||||||
|
if not self.state.is_enabled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.core.send_event(event, payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def track_core_feature(self, feature: str):
|
||||||
|
"""コア機能イベント送信(重複検出付き)"""
|
||||||
|
if not self.state.is_enabled():
|
||||||
|
return
|
||||||
|
self.state.touch_activity()
|
||||||
|
if self.core.is_duplicate_core_feature(feature):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.core.send_event("core_feature", {"feature": feature})
|
||||||
|
self.state.record_feature_sent(feature)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def touch_activity(self):
|
||||||
|
"""アクティビティ時刻更新"""
|
||||||
|
if self.state.is_enabled():
|
||||||
|
self.state.touch_activity()
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""有効状態確認"""
|
||||||
|
return self.state.is_enabled()
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
"""内部状態取得(デバッグ用)"""
|
||||||
|
return self.state.get_debug_info()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
telemetry = Telemetry()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `models/telemetry/state.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
テレメトリ状態管理
|
||||||
|
- enable/disable フラグ
|
||||||
|
- 最終操作時刻
|
||||||
|
- セッション内送信済み機能リスト
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryState:
|
||||||
|
def __init__(self):
|
||||||
|
self._enabled = True # デフォルト有効
|
||||||
|
self._last_activity = None
|
||||||
|
self._session_features_sent = set()
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def set_enabled(self, value: bool):
|
||||||
|
"""有効/無効設定"""
|
||||||
|
with self._lock:
|
||||||
|
self._enabled = bool(value)
|
||||||
|
if not self._enabled:
|
||||||
|
self._session_features_sent.clear()
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""有効状態確認"""
|
||||||
|
with self._lock:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def touch_activity(self):
|
||||||
|
"""最終操作時刻更新"""
|
||||||
|
with self._lock:
|
||||||
|
self._last_activity = datetime.now()
|
||||||
|
|
||||||
|
def get_last_activity(self) -> datetime:
|
||||||
|
"""最終操作時刻取得"""
|
||||||
|
with self._lock:
|
||||||
|
return self._last_activity
|
||||||
|
|
||||||
|
def record_feature_sent(self, feature: str):
|
||||||
|
"""送信済み機能を記録"""
|
||||||
|
with self._lock:
|
||||||
|
self._session_features_sent.add(feature)
|
||||||
|
|
||||||
|
def has_feature_been_sent(self, feature: str) -> bool:
|
||||||
|
"""機能がこのセッション内で送信済みか"""
|
||||||
|
with self._lock:
|
||||||
|
return feature in self._session_features_sent
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""状態をリセット"""
|
||||||
|
with self._lock:
|
||||||
|
self._last_activity = None
|
||||||
|
self._session_features_sent.clear()
|
||||||
|
|
||||||
|
def get_debug_info(self) -> dict:
|
||||||
|
"""デバッグ用情報取得"""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"enabled": self._enabled,
|
||||||
|
"last_activity": self._last_activity.isoformat() if self._last_activity else None,
|
||||||
|
"session_features_sent": list(self._session_features_sent),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `models/telemetry/heartbeat.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Heartbeat スレッド管理
|
||||||
|
- 5分間隔でアクティブ確認
|
||||||
|
- 操作なしで5分経過したら送信停止
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from threading import Thread, Event
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatManager:
|
||||||
|
INTERVAL = 300 # 5 minutes
|
||||||
|
TIMEOUT = 300 # 5 minutes
|
||||||
|
|
||||||
|
def __init__(self, state):
|
||||||
|
self.state = state
|
||||||
|
self.thread = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Heartbeat スレッド開始"""
|
||||||
|
if self.thread is not None and self.thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self.thread = Thread(target=self._run, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Heartbeat スレッド停止"""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self.thread is not None:
|
||||||
|
self.thread.join(timeout=1.0)
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
"""Heartbeat ループ"""
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
time.sleep(self.INTERVAL)
|
||||||
|
|
||||||
|
if not self.state.is_enabled():
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_activity = self.state.get_last_activity()
|
||||||
|
if last_activity is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
elapsed = (datetime.now() - last_activity).total_seconds()
|
||||||
|
if elapsed < self.TIMEOUT:
|
||||||
|
# アクティブなら heartbeat 送信
|
||||||
|
from .core import TelemetryCore
|
||||||
|
try:
|
||||||
|
core = TelemetryCore(self.state)
|
||||||
|
core.send_event("session_heartbeat")
|
||||||
|
except Exception:
|
||||||
|
pass # 握りつぶし
|
||||||
|
except Exception:
|
||||||
|
pass # スレッド不停止のため握りつぶし
|
||||||
|
```
|
||||||
|
|
||||||
|
### `models/telemetry/client.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Aptabase SDK ラッパー
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
# Aptabase SDK のインポート
|
||||||
|
try:
|
||||||
|
from aptabase import Client as AptabaseClient
|
||||||
|
except ImportError:
|
||||||
|
AptabaseClient = None
|
||||||
|
|
||||||
|
|
||||||
|
class AptabaseWrapper:
|
||||||
|
# App Key は直埋め込み
|
||||||
|
APP_KEY = "A-SY-XXXXXXXXXXXXXXX" # 実装時に実際のキーに置き換え
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = None
|
||||||
|
self._init_client()
|
||||||
|
|
||||||
|
def _init_client(self):
|
||||||
|
"""Aptabase クライアント初期化"""
|
||||||
|
if AptabaseClient is None:
|
||||||
|
raise ImportError("aptabase library not installed")
|
||||||
|
try:
|
||||||
|
self.client = AptabaseClient(self.APP_KEY)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to initialize Aptabase: {e}")
|
||||||
|
|
||||||
|
def track(self, event_name: str, properties: Optional[Dict[str, Any]] = None):
|
||||||
|
"""イベント送信"""
|
||||||
|
if self.client is None:
|
||||||
|
raise RuntimeError("Aptabase client not initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# properties が None なら空辞書
|
||||||
|
if properties is None:
|
||||||
|
properties = {}
|
||||||
|
|
||||||
|
# イベント送信
|
||||||
|
self.client.track_event(event_name, properties)
|
||||||
|
except Exception as e:
|
||||||
|
# 握りつぶし:ログなし
|
||||||
|
raise
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""クライアントクローズ"""
|
||||||
|
if self.client is not None:
|
||||||
|
try:
|
||||||
|
# SDK のクローズ処理があれば実行
|
||||||
|
if hasattr(self.client, 'close'):
|
||||||
|
self.client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### `models/telemetry/core.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
テレメトリコアロジック
|
||||||
|
- イベント構築・送信
|
||||||
|
- 重複検出
|
||||||
|
"""
|
||||||
|
from .client import AptabaseWrapper
|
||||||
|
from .state import TelemetryState
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryCore:
|
||||||
|
VALID_CORE_FEATURES = {
|
||||||
|
"translation",
|
||||||
|
"mic_speech_to_text",
|
||||||
|
"speaker_speech_to_text",
|
||||||
|
"text_input",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, state: TelemetryState):
|
||||||
|
self.state = state
|
||||||
|
self.client = None
|
||||||
|
try:
|
||||||
|
self.client = AptabaseWrapper()
|
||||||
|
except Exception:
|
||||||
|
# 初期化失敗時は握りつぶし
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
def send_event(self, event_name: str, payload: dict = None):
|
||||||
|
"""イベント送信"""
|
||||||
|
if self.client is None:
|
||||||
|
raise RuntimeError("Aptabase client not available")
|
||||||
|
|
||||||
|
# ペイロード準備
|
||||||
|
properties = payload or {}
|
||||||
|
|
||||||
|
# イベント送信
|
||||||
|
self.client.track(event_name, properties)
|
||||||
|
|
||||||
|
def is_duplicate_core_feature(self, feature: str) -> bool:
|
||||||
|
"""セッション内の重複チェック"""
|
||||||
|
return self.state.has_feature_been_sent(feature)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## テスト計画
|
||||||
|
|
||||||
|
### ユニットテスト
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_telemetry.py
|
||||||
|
|
||||||
|
def test_init_enabled():
|
||||||
|
"""初期化時に有効化"""
|
||||||
|
telemetry = Telemetry()
|
||||||
|
telemetry.init(enabled=True)
|
||||||
|
assert telemetry.is_enabled() is True
|
||||||
|
|
||||||
|
def test_init_disabled():
|
||||||
|
"""初期化時に無効化"""
|
||||||
|
telemetry = Telemetry()
|
||||||
|
telemetry.init(enabled=False)
|
||||||
|
assert telemetry.is_enabled() is False
|
||||||
|
|
||||||
|
def test_track_when_disabled():
|
||||||
|
"""無効時にトラック呼び出しが何もしない"""
|
||||||
|
telemetry = Telemetry()
|
||||||
|
telemetry.init(enabled=False)
|
||||||
|
telemetry.track("test_event") # 例外なし
|
||||||
|
|
||||||
|
def test_track_core_feature_no_duplicate():
|
||||||
|
"""同一feature の重複送信を防止"""
|
||||||
|
telemetry = Telemetry()
|
||||||
|
telemetry.init(enabled=True)
|
||||||
|
|
||||||
|
# 1回目送信
|
||||||
|
telemetry.track_core_feature("translation")
|
||||||
|
state = telemetry.get_state()
|
||||||
|
assert "translation" in state["session_features_sent"]
|
||||||
|
|
||||||
|
# 2回目はスキップ(内部的に重複検出)
|
||||||
|
|
||||||
|
def test_heartbeat_sends_when_active():
|
||||||
|
"""5分操作なしなら heartbeat 送信"""
|
||||||
|
# heartbeat スレッドがちゃんと5分待機後に
|
||||||
|
# touch_activity() 後のアクティブ判定で送信
|
||||||
|
|
||||||
|
def test_heartbeat_stops_when_inactive():
|
||||||
|
"""5分以上操作なしなら heartbeat 停止"""
|
||||||
|
# last_activity が 5分以上古い場合、
|
||||||
|
# heartbeat 送信しない
|
||||||
|
|
||||||
|
def test_shutdown_sends_app_closed():
|
||||||
|
"""shutdown() が app_closed を送信"""
|
||||||
|
telemetry = Telemetry()
|
||||||
|
telemetry.init(enabled=True)
|
||||||
|
telemetry.shutdown()
|
||||||
|
# app_closed が送信されたことを確認
|
||||||
|
```
|
||||||
|
|
||||||
|
### 統合テスト
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_telemetry_integration.py
|
||||||
|
|
||||||
|
def test_full_flow():
|
||||||
|
"""起動~操作~終了の全フロー"""
|
||||||
|
from models.telemetry import telemetry
|
||||||
|
|
||||||
|
# 起動
|
||||||
|
telemetry.init(enabled=True)
|
||||||
|
|
||||||
|
# 操作
|
||||||
|
telemetry.track_core_feature("translation")
|
||||||
|
telemetry.touch_activity()
|
||||||
|
|
||||||
|
# 5分待機(テスト時は短縮)
|
||||||
|
# heartbeat 送信確認
|
||||||
|
|
||||||
|
# 終了
|
||||||
|
telemetry.shutdown()
|
||||||
|
# app_closed 送信確認
|
||||||
|
```
|
||||||
|
|
||||||
|
### テスト環境での設定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# config.py 修正
|
||||||
|
TELEMETRY_ENABLED = True # デフォルト有効
|
||||||
|
|
||||||
|
# config.json への保存
|
||||||
|
{
|
||||||
|
"telemetry_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## セキュリティとプライバシー
|
||||||
|
|
||||||
|
### データ保護方針
|
||||||
|
|
||||||
|
| 項目 | ポリシー | 実装例 |
|
||||||
|
|------|---------|-------|
|
||||||
|
| **個人識別 ID** | 送信禁止 | UUID/ユーザーIDなし |
|
||||||
|
| **入力内容** | 送信禁止 | メッセージ本文送信なし |
|
||||||
|
| **音声データ** | 送信禁止 | 音声ファイル送信なし |
|
||||||
|
| **UI 状態** | 使用禁止 | ウィンドウ座標/最小化フラグなし |
|
||||||
|
| **ログレベル** | HTTPS only | Aptabase 側で暗号化 |
|
||||||
|
|
||||||
|
### 実装チェックリスト
|
||||||
|
|
||||||
|
- [ ] イベントペイロードに個人情報が含まれていない
|
||||||
|
- [ ] マイク/スピーカーイベントが分離している
|
||||||
|
- [ ] heartbeat が UI 状態に依存していない
|
||||||
|
- [ ] OFF 時にすべての通信が停止する
|
||||||
|
- [ ] 例外が握りつぶされ、ユーザーに見えない
|
||||||
|
- [ ] Aptabase SDK の最新版を使用
|
||||||
|
- [ ] App Key がコード内に直埋め込みされている
|
||||||
|
- [ ] 環境変数・外部ファイルは使用していない
|
||||||
|
|
||||||
|
### ユーザーへの通知(OSS 表示文言)
|
||||||
|
|
||||||
|
UI に以下を表示:
|
||||||
|
|
||||||
|
> This app collects anonymous usage statistics using Aptabase.
|
||||||
|
> You can disable telemetry at any time in settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 実装スケジュール
|
||||||
|
|
||||||
|
| フェーズ | タスク | 優先度 |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| 1 | `__init__.py`, `state.py` 作成 | 高 |
|
||||||
|
| 2 | `client.py`, `core.py` 作成 | 高 |
|
||||||
|
| 3 | `heartbeat.py` 作成・テスト | 高 |
|
||||||
|
| 4 | `config.py` に `telemetry_enabled` 追加 | 高 |
|
||||||
|
| 5 | `controller.py` に API 呼び出し追加 | 高 |
|
||||||
|
| 6 | `mainloop.py` に init/shutdown 追加 | 高 |
|
||||||
|
| 7 | ユニット・統合テスト | 高 |
|
||||||
|
| 8 | ドキュメント更新 | 中 |
|
||||||
|
| 9 | UI 文言追加 | 中 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完了条件(定義)
|
||||||
|
|
||||||
|
- [x] テレメトリ OFF 時、一切の通信が発生しない
|
||||||
|
- [x] オフライン状態でもアプリが正常に動作する
|
||||||
|
- [x] `core_feature` は1セッション中に1回のみ送信される
|
||||||
|
- [x] heartbeat は 5分以上操作がないと送信を停止する
|
||||||
|
- [x] 個人情報・入力内容・音声データは絶対に送信されない
|
||||||
|
- [x] 送信失敗時は例外を握りつぶし、無言で続行する
|
||||||
|
- [x] マイク/スピーカーイベントが分離している
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考資料
|
||||||
|
|
||||||
|
- [Aptabase Python SDK](https://github.com/aptabase/aptabase-python)
|
||||||
|
- [Aptabase Dashboard](https://aptabase.com)
|
||||||
|
- [Privacy by Design](https://en.wikipedia.org/wiki/Privacy_by_design)
|
||||||
|
|
||||||
Reference in New Issue
Block a user