From 1656620ce4d4b05ecf986d2e7dfb6bd2ea9638af Mon Sep 17 00:00:00 2001 From: misyaguziya <53165965+misyaguziya@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:12:16 +0900 Subject: [PATCH] =?UTF-8?q?[Update]=20=E3=83=86=E3=83=AC=E3=83=A1=E3=83=88?= =?UTF-8?q?=E3=83=AA=E6=A9=9F=E8=83=BD=E3=81=AE=E6=94=B9=E5=96=84=E3=81=A8?= =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E3=82=B3=E3=83=BC=E3=83=89=E3=81=AE?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-python/controller.py | 5 +- src-python/docs/telemetry_design.md | 343 ++++++++++------------- src-python/docs/詳細設計書.md | 12 +- src-python/model.py | 5 - src-python/models/telemetry/__init__.py | 45 ++- src-python/models/telemetry/client.py | 5 +- src-python/models/telemetry/heartbeat.py | 53 ---- src-python/models/telemetry/state.py | 13 - 8 files changed, 175 insertions(+), 306 deletions(-) delete mode 100644 src-python/models/telemetry/heartbeat.py diff --git a/src-python/controller.py b/src-python/controller.py index 327752dd..fc2d1cf0 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -284,7 +284,6 @@ class Controller: ) elif isinstance(message, str) and len(message) > 0: - model.telemetryTouchActivity() model.telemetryTrackCoreFeature("mic_speech_to_text") translation = [] transliteration_message = [] @@ -457,7 +456,6 @@ class Controller: }, ) elif isinstance(message, str) and len(message) > 0: - model.telemetryTouchActivity() model.telemetryTrackCoreFeature("speaker_speech_to_text") translation = [] transliteration_message = [] @@ -640,7 +638,6 @@ class Controller: id = data["id"] message = data["message"] if len(message) > 0: - model.telemetryTouchActivity() model.telemetryTrackCoreFeature("text_input") translation = [] transliteration_message: List[Any] = [] @@ -2607,7 +2604,7 @@ class Controller: def setEnableTelemetry(*args, **kwargs) -> dict: if config.ENABLE_TELEMETRY is False: config.ENABLE_TELEMETRY = True - model.telemetryInit(enabled=config.ENABLE_TELEMETRY, app_version=config.VERSION) + model.telemetryInit(enabled=True, app_version=config.VERSION) return {"status":200, "result":config.ENABLE_TELEMETRY} @staticmethod diff --git a/src-python/docs/telemetry_design.md b/src-python/docs/telemetry_design.md index 9b12ebd0..f00d88dc 100644 --- a/src-python/docs/telemetry_design.md +++ b/src-python/docs/telemetry_design.md @@ -55,9 +55,8 @@ src-python/ │ └── telemetry/ │ ├── __init__.py # Public API + singleton管理 │ ├── client.py # Aptabase wrapper -│ ├── state.py # Enable/disable, last_activity -│ ├── heartbeat.py # 5分間隔heartbeatスレッド -│ └── core.py # イベント送信ロジック +│ ├── state.py # Enable/disable、セッション内送信済み機能管理 +│ └── core.py # イベント送信ロジック、重複検出 └── docs/ └── details/ └── telemetry.md # 実装詳細ドキュメント @@ -67,10 +66,9 @@ src-python/ | モジュール | 責任 | |-----------|------| -| `__init__.py` | パブリック API、シングルトン管理 | -| `client.py` | Aptabase SDK ラッパー、HTTP通信 | -| `state.py` | ON/OFF 状態、最終操作時刻管理 | -| `heartbeat.py` | 5分間隔スレッド、タイムアウト判定 | +| `__init__.py` | パブリック API、シングルトン管理、イベントループ管理 | +| `client.py` | Aptabase SDK ラッパー、HTTP通信、ログレベル設定 | +| `state.py` | ON/OFF 状態、セッション内送信済み機能の追跡 | | `core.py` | イベント構築・送信、重複検出 | --- @@ -83,7 +81,6 @@ src-python/ |-----------|------|----------| | `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}` | @@ -124,37 +121,6 @@ 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送信 -``` - --- ## データフロー @@ -169,7 +135,6 @@ Timeline: 3. telemetry.init(enabled=config.ENABLE_TELEMETRY) ├─ enabled=True の場合 │ ├─ Aptabase SDK 初期化 - │ ├─ heartbeat スレッド開始 │ └─ app_started イベント送信 └─ enabled=False の場合 └─ すべての処理をスキップ @@ -183,8 +148,6 @@ Timeline: ENABLE_TELEMETRY チェック ├─ False → 何もしない └─ True - ↓ - touch_activity() → 最終時刻更新 ↓ 1セッション内で同一 feature 送信済み? ├─ Yes → スキップ @@ -208,7 +171,7 @@ OFF 状態(設定保存) telemetry.init(enabled=True) ├─ 既知例外は握りつぶし ├─ Aptabase 初期化 - ├─ heartbeat スレッド開始 + ├─ asyncio イベントループ開始 └─ 次のイベントから通常送信 ``` @@ -220,7 +183,7 @@ config.json 更新 ENABLE_TELEMETRY: false に変更 ↓ telemetry.shutdown() - ├─ heartbeat スレッド停止 + ├─ asyncio イベントループ停止 ├─ 内部状態をリセット └─ メモリ解放 ↓ @@ -246,8 +209,9 @@ def telemetry.init(enabled: bool) -> None: enabled (bool): テレメトリを有効にするか Behavior: - - enabled=True: Aptabase SDK初期化、heartbeat開始、app_started送信 + - enabled=True: Aptabase SDK初期化、asyncioイベントループ開始、app_started送信 - enabled=False: すべてのスキップ、以後のtrack()は何もしない + - 複数回呼び出し時: _init_called フラグで二重初期化を防止 Exception: - SDK初期化失敗時: 例外握りつぶし、機能は停止するが無言 @@ -261,7 +225,7 @@ def telemetry.shutdown() -> None: Behavior: - app_closed イベント送信 - - heartbeat スレッド停止 + - asyncio イベントループ停止 - 内部状態をリセット - 例外は握りつぶし(無言) """ @@ -316,25 +280,18 @@ def telemetry.track_core_feature(feature: str) -> None: # ========================================================================= -# ACTIVITY TRACKING +# SHUTDOWN # ========================================================================= -def telemetry.touch_activity() -> None: +def telemetry.shutdown() -> None: """ - 最終アクティビティ時刻を更新(heartbeat判定用) + テレメトリシャットダウン Behavior: - - enabled=False なら何もしない - - 内部的に datetime.now() を記録 - - 自動呼び出し: track_core_feature() 内から呼び出される - - 手動呼び出し: テキスト入力検出時など、明示的に呼び出し可 - - Example: - # テキスト入力時 - telemetry.touch_activity() - - # track_core_feature() は内部で自動呼び出し - telemetry.track_core_feature("translation") + - app_closed イベント送信 + - イベントループ停止(フラッシュ待機) + - Aptabase クライアント停止 + - Tauri sidecar 環境では呼び出し後にプロセス終了 """ pass @@ -360,9 +317,7 @@ def telemetry.get_state() -> dict: Returns: dict: { "enabled": bool, - "last_activity": datetime | None, "session_features_sent": list[str], - "heartbeat_active": bool, } """ pass @@ -471,8 +426,11 @@ def setUiLanguage(data): パブリック API を提供し、内部実装を隠蔽する。 """ +import asyncio +import threading +from concurrent.futures import Future + from .state import TelemetryState -from .heartbeat import HeartbeatManager from .core import TelemetryCore @@ -489,56 +447,128 @@ class Telemetry: if self._initialized: return self.state = TelemetryState() - self.heartbeat = HeartbeatManager(self.state) self.core = TelemetryCore(self.state) + self._loop = None + self._loop_thread = None + self._init_called = False self._initialized = True - def init(self, enabled: bool): - """テレメトリ初期化""" + def _start_event_loop(self): + """バックグラウンドでイベントループを開始""" + if self._loop_thread is not None and self._loop_thread.is_alive(): + return + + def run_loop(): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + self._loop_thread = threading.Thread(target=run_loop, daemon=True, name="telemetry_loop") + self._loop_thread.start() + + # ループが開始されるまで待機 + while self._loop is None: + pass + + def _stop_event_loop(self, timeout: float = 5.0): + """イベントループを停止(フラッシュ完了を待つ)""" + if self._loop is None: + return + + # ループにstop()を予約 + self._loop.call_soon_threadsafe(self._loop.stop) + + # ループスレッド終了を待機 + if self._loop_thread is not None: + self._loop_thread.join(timeout=timeout) + + self._loop = None + self._loop_thread = None + + def _run_async(self, coro): + """同期コンテキストから非同期関数を実行""" + if self._loop is None: + return + + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + return future.result(timeout=5.0) + except Exception: + pass + + def _schedule_async(self, coro): + """非同期タスクをバックグラウンドでスケジュール(待機しない)""" + if self._loop is None: + return + + try: + asyncio.run_coroutine_threadsafe(coro, self._loop) + except Exception: + pass + + def init(self, enabled: bool, app_version: str = "1.0.0"): + """ + テレメトリ初期化(同期インターフェース) + + 重要:このメソッドは冪等です。複数回呼ばれても安全です。 + 既に初期化済みの場合は、有効/無効の状態のみを更新します。 + """ + # 既に初期化済みの場合は、状態の更新のみ + if self._init_called: + self.state.set_enabled(enabled) + return + + # 初回初期化 + self._init_called = True self.state.set_enabled(enabled) if enabled: - try: - self.core.send_event("app_started") - self.heartbeat.start() - except Exception: - pass # 握りつぶし + self._start_event_loop() + self._run_async(self._init_async(app_version)) + + async def _init_async(self, app_version: str): + """非同期初期化処理""" + await self.core.start(app_version=app_version) + await self.core.send_event("app_started") def shutdown(self): """テレメトリ終了""" - if self.state.is_enabled(): - try: - self.core.send_event("app_closed") - except Exception: - pass - self.heartbeat.stop() - self.state.reset() + try: + if self.state.is_enabled(): + try: + self._run_async(self._shutdown_async()) + except Exception: + pass + + self._stop_event_loop(timeout=5.0) + except Exception: + pass + finally: + self.state.reset() + self._init_called = False + + async def _shutdown_async(self): + """非同期終了処理""" + await self.core.send_event("app_closed") + await self.core.stop() 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 + self._schedule_async(self.core.send_event(event, payload)) 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 + self._schedule_async(self._track_core_feature_async(feature)) - def touch_activity(self): - """アクティビティ時刻更新""" - if self.state.is_enabled(): - self.state.touch_activity() + async def _track_core_feature_async(self, feature: str): + """非同期コア機能送信処理""" + await self.core.send_event("core_feature", {"feature": feature}) + self.state.record_feature_sent(feature) def is_enabled(self) -> bool: """有効状態確認""" @@ -559,19 +589,17 @@ telemetry = Telemetry() """ テレメトリ状態管理 - 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() + self._lock = Lock() def set_enabled(self, value: bool): """有効/無効設定""" @@ -585,15 +613,20 @@ class TelemetryState: with self._lock: return self._enabled - def touch_activity(self): - """最終操作時刻更新""" + def record_feature_sent(self, feature: str): + """送信済み機能を記録""" with self._lock: - self._last_activity = datetime.now() + self._session_features_sent.add(feature) - def get_last_activity(self) -> datetime: - """最終操作時刻取得""" + def has_feature_been_sent(self, feature: str) -> bool: + """機能がこのセッション内で送信済みか""" with self._lock: - return self._last_activity + return feature in self._session_features_sent + + def reset(self): + """状態をリセット""" + with self._lock: + self._session_features_sent.clear() def record_feature_sent(self, feature: str): """送信済み機能を記録""" @@ -608,7 +641,6 @@ class TelemetryState: def reset(self): """状態をリセット""" with self._lock: - self._last_activity = None self._session_features_sent.clear() def get_debug_info(self) -> dict: @@ -616,74 +648,10 @@ class TelemetryState: 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 @@ -828,7 +796,7 @@ class Main: th.join(timeout=remaining) ``` -**重要**: `controller.shutdown()` は **スレッド停止前** に呼び出す必要があります。そうしないと heartbeat スレッドが動作していない状態で shutdown に失敗する可能性があります。 +**重要**: `controller.shutdown()` は最初に呼び出され、その中で `model.shutdown()` が実行され、最後に `telemetry.shutdown()` が呼び出されます。 #### パターン2: Controller.setWatchdogCallback @@ -882,7 +850,7 @@ def start(self) -> None: main_instance.stop() 呼び出し ↓ 1. try: - telemetry.shutdown() # app_closed 送信 + heartbeat停止 + self.controller.shutdown() # app_closed 送信 + すべてのクリーンアップ except: pass # 握りつぶし(通信失敗でも続行) ↓ @@ -890,9 +858,7 @@ main_instance.stop() 呼び出し ↓ 3. スレッド停止待機(最大 2.0秒) ↓ -4. モデルクリーンアップ(if model が初期化されていれば) - ↓ -5. メモリ解放 +4. メモリ解放 ↓ プロセス終了 ``` @@ -967,11 +933,10 @@ def stop(self, wait: float = 2.0) -> None: def shutdown(self): if self.state.is_enabled(): try: - self.core.send_event("app_closed") # ← 実行されない + self.core.send_event("app_closed") # ← 実行される except Exception: pass - self.heartbeat.stop() # OFF時も停止する(スレッド安全) self.state.reset() # 状態リセット ``` @@ -1027,16 +992,6 @@ def test_track_core_feature_no_duplicate(): # 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() @@ -1059,10 +1014,6 @@ def test_full_flow(): # 操作 telemetry.track_core_feature("translation") - telemetry.touch_activity() - - # 5分待機(テスト時は短縮) - # heartbeat 送信確認 # 終了 telemetry.shutdown() @@ -1099,7 +1050,6 @@ ENABLE_TELEMETRY = True # デフォルト有効 - [ ] イベントペイロードに個人情報が含まれていない - [ ] マイク/スピーカーイベントが分離している -- [ ] heartbeat が UI 状態に依存していない - [ ] OFF 時にすべての通信が停止する - [ ] 例外が握りつぶされ、ユーザーに見えない - [ ] Aptabase SDK の最新版を使用 @@ -1121,13 +1071,12 @@ UI に以下を表示: |---------|--------|--------| | 1 | `__init__.py`, `state.py` 作成 | 高 | | 2 | `client.py`, `core.py` 作成 | 高 | -| 3 | `heartbeat.py` 作成・テスト | 高 | -| 4 | `config.py` に `ENABLE_TELEMETRY` 追加 | 高 | -| 5 | `controller.py` に API 呼び出し追加 | 高 | -| 6 | `mainloop.py` に init/shutdown 追加 | 高 | -| 7 | ユニット・統合テスト | 高 | -| 8 | ドキュメント更新 | 中 | -| 9 | UI 文言追加 | 中 | +| 3 | `config.py` に `ENABLE_TELEMETRY` 追加 | 高 | +| 4 | `controller.py` に API 呼び出し追加 | 高 | +| 5 | `mainloop.py` に init/shutdown 追加 | 高 | +| 6 | ユニット・統合テスト | 高 | +| 7 | ドキュメント更新 | 中 | +| 8 | UI 文言追加 | 中 | --- @@ -1136,7 +1085,6 @@ UI に以下を表示: - [x] テレメトリ OFF 時、一切の通信が発生しない - [x] オフライン状態でもアプリが正常に動作する - [x] `core_feature` は1セッション中に1回のみ送信される -- [x] heartbeat は 5分以上操作がないと送信を停止する - [x] 個人情報・入力内容・音声データは絶対に送信されない - [x] 送信失敗時は例外を握りつぶし、無言で続行する - [x] マイク/スピーカーイベントが分離している @@ -1149,3 +1097,4 @@ UI に以下を表示: - [Aptabase Dashboard](https://aptabase.com) - [Privacy by Design](https://en.wikipedia.org/wiki/Privacy_by_design) + diff --git a/src-python/docs/詳細設計書.md b/src-python/docs/詳細設計書.md index 08343e89..614a74d1 100644 --- a/src-python/docs/詳細設計書.md +++ b/src-python/docs/詳細設計書.md @@ -112,18 +112,13 @@ Aptabase を用いた匿名な使用状況データ収集。デフォルト有 ### イベント種別(送信固定) - `app_started`: アプリ起動 - `app_closed`: アプリ終了(最後のイベント) -- `session_heartbeat`: 5 分間隔アクティブ確認 - `core_feature`: 機能開始(translation / mic_speech_to_text / speaker_speech_to_text / text_input) -- `settings_opened`: 設定画面開閉 -- `config_changed`: 設定変更 -- `error`: エラー発生 ### 動作フロー 1. アプリ起動時に telemetryInit() 呼び出し -2. ユーザーアクティビティ検出時に telemetryTouchActivity() 呼び出し -3. 機能開始時に track_core_feature() で 1 セッション 1 回のみ送信 -4. アプリ終了時に telemetryShutdown() で app_closed 送信 -5. config.ENABLE_TELEMETRY = False で一切の通信・スレッド停止 +2. 機能開始時に track_core_feature() で 1 セッション 1 回のみ送信 +3. アプリ終了時に telemetryShutdown() で app_closed 送信 +4. config.ENABLE_TELEMETRY = False で一切の通信・スレッド停止 ### 設定・制御 - `config.ENABLE_TELEMETRY`: True/False で機能制御 @@ -137,6 +132,7 @@ Aptabase を用いた匿名な使用状況データ収集。デフォルト有 - API 通信失敗時: 例外握りつぶし、アプリ動作に影響なし - オフライン時: 機能停止のみ、再送・バッファリングなし - 無効化時: 一切の通信・スレッド・処理停止 +- Aptabase SDK ログ: CRITICAL レベルのみに制限(ノイズ削減) ## モデル重みダウンロード - `models.translation.translation_utils` と `models.transcription.transcription_whisper` にダウンロード/チェック関数があり、チェックサムやファイル存在を検証する。 diff --git a/src-python/model.py b/src-python/model.py index 0933eb43..904c9012 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -1331,9 +1331,4 @@ class Model: if hasattr(self, "telemetry") and self.telemetry: self.telemetry.track_core_feature(feature) - def telemetryTouchActivity(self): - """テレメトリアクティビティ更新 (Model ラッパー)""" - if hasattr(self, "telemetry") and self.telemetry: - self.telemetry.touch_activity() - model = Model() \ No newline at end of file diff --git a/src-python/models/telemetry/__init__.py b/src-python/models/telemetry/__init__.py index 78a1a31a..db36af48 100644 --- a/src-python/models/telemetry/__init__.py +++ b/src-python/models/telemetry/__init__.py @@ -10,7 +10,6 @@ from concurrent.futures import Future # Allow running as a script for quick verification. try: from .state import TelemetryState - from .heartbeat import HeartbeatManager from .core import TelemetryCore except ImportError: import os @@ -18,7 +17,6 @@ except ImportError: sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) from models.telemetry.state import TelemetryState - from models.telemetry.heartbeat import HeartbeatManager from models.telemetry.core import TelemetryCore @@ -36,9 +34,9 @@ class Telemetry: return self.state = TelemetryState() self.core = TelemetryCore(self.state) - self.heartbeat = HeartbeatManager(self.state, self.core, self._schedule_async_with_loop) self._loop = None self._loop_thread = None + self._init_called = False # init()が呼ばれたかを追跡(重複初期化防止) self._initialized = True def _start_event_loop(self): @@ -96,12 +94,20 @@ class Telemetry: except Exception: pass - def _schedule_async_with_loop(self, coro): - """ループ取得とスケジューリングのヘルパー(heartbeat用)""" - return self._schedule_async(coro) - def init(self, enabled: bool, app_version: str = "1.0.0"): - """テレメトリ初期化(同期インターフェース)""" + """テレメトリ初期化(同期インターフェース) + + 重要:このメソッドは冪等です。複数回呼ばれても安全です。 + 既に初期化済みの場合は、有効/無効の状態のみを更新します。 + これにより、設定変更時などに誤ってapp_startedイベントが重複送信される問題を防ぎます。 + """ + # 既に初期化済みの場合は、状態の更新のみ + if self._init_called: + self.state.set_enabled(enabled) + return + + # 初回初期化 + self._init_called = True self.state.set_enabled(enabled) if enabled: self._start_event_loop() @@ -111,23 +117,18 @@ class Telemetry: """非同期初期化処理""" await self.core.start(app_version=app_version) await self.core.send_event("app_started") - self.heartbeat.start() def shutdown(self): """テレメトリ終了(同期インターフェース) 重要:Tauri sidecar環境では、このメソッド実行後にプロセス終了が発生します。 app_closed イベントが確実に送信されるように、以下の手順を実行します: - 1. heartbeat スレッド停止 - 2. app_closed イベント送信を同期待機 - 3. Aptabase クライアント停止(フラッシュ含む) - 4. イベントループ完全停止を待機 + 1. app_closed イベント送信を同期待機 + 2. Aptabase クライアント停止(フラッシュ含む) + 3. イベントループ完全停止を待機 """ try: - # Step 1: Heartbeat 停止(5分待機中の送信を防ぐ) - self.heartbeat.stop() - - # Step 2-3: app_closed 送信とクライアント停止を同期待機 + # app_closed 送信とクライアント停止を同期待機 if self.state.is_enabled(): try: # _run_async で最大5秒間待機(Aptabase のフラッシュ含む) @@ -135,7 +136,7 @@ class Telemetry: except Exception: pass - # Step 4: イベントループを完全停止(フラッシュ確認を待つ) + # イベントループを完全停止(フラッシュ確認を待つ) # sidecar 終了前に確実に完了させるため、タイムアウトを長めに設定 self._stop_event_loop(timeout=5.0) except Exception: @@ -144,6 +145,7 @@ class Telemetry: finally: # 状態をリセット self.state.reset() + self._init_called = False # 初期化フラグもリセット async def _shutdown_async(self): """非同期終了処理""" @@ -160,7 +162,6 @@ class Telemetry: """コア機能イベント送信(同期インターフェース)""" if not self.state.is_enabled(): return - self.state.touch_activity() if self.core.is_duplicate_core_feature(feature): return self._schedule_async(self._track_core_feature_async(feature)) @@ -170,11 +171,6 @@ class Telemetry: await self.core.send_event("core_feature", {"feature": feature}) self.state.record_feature_sent(feature) - def touch_activity(self): - """アクティビティ時刻更新""" - if self.state.is_enabled(): - self.state.touch_activity() - def is_enabled(self) -> bool: """有効状態確認""" return self.state.is_enabled() @@ -190,7 +186,6 @@ if __name__ == "__main__": telemetry.init(enabled=True) telemetry.track("debug_test", {"message": "telemetry main demo"}) telemetry.track_core_feature("text_input") - telemetry.touch_activity() print("state:", telemetry.get_state()) # イベント送信完了を待つ diff --git a/src-python/models/telemetry/client.py b/src-python/models/telemetry/client.py index 32d25a89..9949cd5f 100644 --- a/src-python/models/telemetry/client.py +++ b/src-python/models/telemetry/client.py @@ -1,6 +1,7 @@ """ Aptabase SDK ラッパー(非同期版) """ +import logging from typing import Optional, Dict, Any # Aptabase SDK のインポート @@ -11,10 +12,12 @@ except ImportError: class AptabaseWrapper: - APP_KEY = "A-US-2082730845" + APP_KEY = "A-US-6044063021" def __init__(self): self.client = None + # Suppress noisy logs from the Aptabase SDK (only CRITICAL allowed) + logging.getLogger("aptabase").setLevel(logging.CRITICAL) async def start(self, app_version: str = "1.0.0"): """Aptabase クライアント開始""" diff --git a/src-python/models/telemetry/heartbeat.py b/src-python/models/telemetry/heartbeat.py deleted file mode 100644 index 0d417d93..00000000 --- a/src-python/models/telemetry/heartbeat.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Heartbeat スレッド管理 -- 5分間隔でアクティブ確認 -- 操作なしで5分経過したら送信停止 -""" -from datetime import datetime -from threading import Thread, Event -import time - - -class HeartbeatManager: - INTERVAL = 300 # 5 minutes - TIMEOUT = 300 # 5 minutes - - def __init__(self, state, core, schedule_async): - self.state = state - self.core = core - self.schedule_async = schedule_async - 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, name="telemetry_heartbeat") - 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(): - 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 送信(非同期スケジュール) - if self.core.client is not None: - self.schedule_async(self.core.send_event("session_heartbeat")) diff --git a/src-python/models/telemetry/state.py b/src-python/models/telemetry/state.py index 119972d3..02265b99 100644 --- a/src-python/models/telemetry/state.py +++ b/src-python/models/telemetry/state.py @@ -11,7 +11,6 @@ from threading import Lock class TelemetryState: def __init__(self): self._enabled = True # デフォルト有効 - self._last_activity = None self._session_features_sent = set() self._lock = Lock() @@ -27,16 +26,6 @@ class TelemetryState: with self._lock: return self._enabled - def touch_activity(self): - """最終操作時刻更新""" - with self._lock: - self._last_activity = datetime.now() - - def get_last_activity(self): - """最終操作時刻取得""" - with self._lock: - return self._last_activity - def record_feature_sent(self, feature: str): """送信済み機能を記録""" with self._lock: @@ -50,7 +39,6 @@ class TelemetryState: def reset(self): """状態をリセット""" with self._lock: - self._last_activity = None self._session_features_sent.clear() def get_debug_info(self) -> dict: @@ -58,6 +46,5 @@ class TelemetryState: 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), }