feat: テレメトリ機能を追加し、アプリの起動・終了時にイベントを送信するように変更

This commit is contained in:
misyaguziya
2026-01-08 09:16:18 +09:00
parent 4eb61fa4b7
commit d157dc8b7b
12 changed files with 603 additions and 44 deletions

View File

@@ -0,0 +1,202 @@
"""
テレメトリAptabase管理モジュール
パブリック API を提供し、内部実装を隠蔽する。
"""
import asyncio
import threading
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
import sys
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
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.core = TelemetryCore(self.state)
self.heartbeat = HeartbeatManager(self.state, self.core, self._schedule_async_with_loop)
self._loop = None
self._loop_thread = None
self._initialized = True
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 _schedule_async_with_loop(self, coro):
"""ループ取得とスケジューリングのヘルパーheartbeat用"""
return self._schedule_async(coro)
def init(self, enabled: bool, app_version: str = "1.0.0"):
"""テレメトリ初期化(同期インターフェース)"""
self.state.set_enabled(enabled)
if enabled:
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")
self.heartbeat.start()
def shutdown(self):
"""テレメトリ終了(同期インターフェース)
重要Tauri sidecar環境では、このメソッド実行後にプロセス終了が発生します。
app_closed イベントが確実に送信されるように、以下の手順を実行します:
1. heartbeat スレッド停止
2. app_closed イベント送信を同期待機
3. Aptabase クライアント停止(フラッシュ含む)
4. イベントループ完全停止を待機
"""
try:
# Step 1: Heartbeat 停止5分待機中の送信を防ぐ
self.heartbeat.stop()
# Step 2-3: app_closed 送信とクライアント停止を同期待機
if self.state.is_enabled():
try:
# _run_async で最大5秒間待機Aptabase のフラッシュ含む)
self._run_async(self._shutdown_async())
except Exception:
pass
# Step 4: イベントループを完全停止(フラッシュ確認を待つ)
# sidecar 終了前に確実に完了させるため、タイムアウトを長めに設定
self._stop_event_loop(timeout=5.0)
except Exception:
# どの段階で失敗してもプロセス終了処理は進行させる
pass
finally:
# 状態をリセット
self.state.reset()
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
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
self._schedule_async(self._track_core_feature_async(feature))
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 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()
if __name__ == "__main__":
# 同期インターフェースのデモ
telemetry = Telemetry()
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())
# イベント送信完了を待つ
import time
time.sleep(2)
telemetry.shutdown()
print("telemetry demo finished")

View File

@@ -0,0 +1,58 @@
"""
Aptabase SDK ラッパー(非同期版)
"""
from typing import Optional, Dict, Any
# Aptabase SDK のインポート
try:
from aptabase import Aptabase
except ImportError:
Aptabase = None
class AptabaseWrapper:
APP_KEY = "A-US-2082730845"
def __init__(self):
self.client = None
async def start(self, app_version: str = "1.0.0"):
"""Aptabase クライアント開始"""
if Aptabase is None:
raise ImportError("aptabase library not installed")
try:
self.client = Aptabase(
app_key=self.APP_KEY,
app_version=app_version,
is_debug=False,
max_batch_size=25,
flush_interval=10.0,
timeout=30.0
)
await self.client.start()
except Exception as e:
raise RuntimeError(f"Failed to initialize Aptabase: {e}")
async def track(self, event_name: str, properties: Optional[Dict[str, Any]] = None):
"""イベント送信(非同期)"""
if self.client is None:
return
# properties が None なら空辞書
if properties is None:
properties = {}
try:
await self.client.track(event_name, properties)
except Exception:
# テレメトリ送信失敗は黙殺(本体処理を止めない)
pass
async def stop(self):
"""クライアント停止(フラッシュ含む)"""
if self.client is not None:
try:
await self.client.stop()
except Exception:
pass
self.client = None

View File

@@ -0,0 +1,57 @@
"""
テレメトリコアロジック
- イベント構築・送信
- 重複検出
"""
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
async def start(self, app_version: str = "1.0.0"):
"""Aptabase クライアント開始"""
if self.client is None:
return
try:
await self.client.start(app_version=app_version)
except Exception:
self.client = None
async def stop(self):
"""Aptabase クライアント停止"""
if self.client is not None:
try:
await self.client.stop()
except Exception:
pass
async def send_event(self, event_name: str, payload: dict = None):
"""イベント送信(非同期)"""
if self.client is None:
return
# ペイロード準備
properties = payload or {}
# イベント送信
await self.client.track(event_name, properties)
def is_duplicate_core_feature(self, feature: str) -> bool:
"""セッション内の重複チェック"""
return self.state.has_feature_been_sent(feature)

View File

@@ -0,0 +1,53 @@
"""
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"))

View File

@@ -0,0 +1,63 @@
"""
テレメトリ状態管理
- 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):
"""最終操作時刻取得"""
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),
}