feat: テレメトリ機能を追加し、アプリの起動・終了時にイベントを送信するように変更
This commit is contained in:
@@ -30,3 +30,4 @@ SudachiDict-full==20250825
|
|||||||
translators @ git+https://github.com/misyaguziya/translators@5.9.2.1
|
translators @ git+https://github.com/misyaguziya/translators@5.9.2.1
|
||||||
SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1
|
SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1
|
||||||
tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3
|
tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3
|
||||||
|
aptabase==0.1.0
|
||||||
@@ -31,3 +31,4 @@ SudachiDict-full==20250825
|
|||||||
translators @ git+https://github.com/misyaguziya/translators@5.9.2.1
|
translators @ git+https://github.com/misyaguziya/translators@5.9.2.1
|
||||||
SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1
|
SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1
|
||||||
tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3
|
tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3
|
||||||
|
aptabase==0.1.0
|
||||||
@@ -725,6 +725,9 @@ class Config:
|
|||||||
WEBSOCKET_HOST = ManagedProperty('WEBSOCKET_HOST', type_=str)
|
WEBSOCKET_HOST = ManagedProperty('WEBSOCKET_HOST', type_=str)
|
||||||
WEBSOCKET_PORT = ManagedProperty('WEBSOCKET_PORT', type_=int)
|
WEBSOCKET_PORT = ManagedProperty('WEBSOCKET_PORT', type_=int)
|
||||||
|
|
||||||
|
# --- Telemetry Settings ---
|
||||||
|
TELEMETRY_ENABLED = ManagedProperty('TELEMETRY_ENABLED', type_=bool)
|
||||||
|
|
||||||
# --- Selection properties with validation (ManagedProperty) ---
|
# --- Selection properties with validation (ManagedProperty) ---
|
||||||
SELECTED_TAB_NO = ManagedProperty('SELECTED_TAB_NO', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_TAB_NO_LIST)
|
SELECTED_TAB_NO = ManagedProperty('SELECTED_TAB_NO', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_TAB_NO_LIST)
|
||||||
SELECTED_TRANSCRIPTION_ENGINE = ManagedProperty('SELECTED_TRANSCRIPTION_ENGINE', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_TRANSCRIPTION_ENGINE_LIST)
|
SELECTED_TRANSCRIPTION_ENGINE = ManagedProperty('SELECTED_TRANSCRIPTION_ENGINE', type_=str, allowed=lambda v, inst: v in inst.SELECTABLE_TRANSCRIPTION_ENGINE_LIST)
|
||||||
@@ -1023,6 +1026,9 @@ class Config:
|
|||||||
self._WEBSOCKET_HOST = "127.0.0.1"
|
self._WEBSOCKET_HOST = "127.0.0.1"
|
||||||
self._WEBSOCKET_PORT = 2231
|
self._WEBSOCKET_PORT = 2231
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
self._TELEMETRY_ENABLED = True # デフォルト有効
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
if os_path.isfile(self.PATH_CONFIG) is not False:
|
if os_path.isfile(self.PATH_CONFIG) is not False:
|
||||||
with open(self.PATH_CONFIG, 'r', encoding="utf-8") as fp:
|
with open(self.PATH_CONFIG, 'r', encoding="utf-8") as fp:
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class Controller:
|
|||||||
def setRun(self, run:Callable[[int, str, Any], None]) -> None:
|
def setRun(self, run:Callable[[int, str, Any], None]) -> None:
|
||||||
self.run = run
|
self.run = run
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Shutdown controller and model (including telemetry)."""
|
||||||
|
try:
|
||||||
|
model.shutdown()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
# response functions
|
# response functions
|
||||||
def connectedNetwork(self) -> None:
|
def connectedNetwork(self) -> None:
|
||||||
self.run(
|
self.run(
|
||||||
@@ -271,6 +278,8 @@ class Controller:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(message, str) and len(message) > 0:
|
elif isinstance(message, str) and len(message) > 0:
|
||||||
|
model.telemetryTouchActivity()
|
||||||
|
model.telemetryTrackCoreFeature("mic_speech_to_text")
|
||||||
translation = []
|
translation = []
|
||||||
transliteration_message = []
|
transliteration_message = []
|
||||||
transliteration_translation = []
|
transliteration_translation = []
|
||||||
@@ -287,6 +296,7 @@ class Controller:
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
model.telemetryTrackCoreFeature("translation")
|
||||||
translation, success = model.getInputTranslate(message, source_language=language)
|
translation, success = model.getInputTranslate(message, source_language=language)
|
||||||
if all(success) is not True:
|
if all(success) is not True:
|
||||||
self.changeToCTranslate2Process()
|
self.changeToCTranslate2Process()
|
||||||
@@ -437,6 +447,8 @@ class Controller:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(message, str) and len(message) > 0:
|
elif isinstance(message, str) and len(message) > 0:
|
||||||
|
model.telemetryTouchActivity()
|
||||||
|
model.telemetryTrackCoreFeature("speaker_speech_to_text")
|
||||||
translation = []
|
translation = []
|
||||||
transliteration_message = []
|
transliteration_message = []
|
||||||
transliteration_translation = []
|
transliteration_translation = []
|
||||||
@@ -453,6 +465,7 @@ class Controller:
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
model.telemetryTrackCoreFeature("translation")
|
||||||
translation, success = model.getOutputTranslate(message, source_language=language)
|
translation, success = model.getOutputTranslate(message, source_language=language)
|
||||||
if all(success) is not True:
|
if all(success) is not True:
|
||||||
self.changeToCTranslate2Process()
|
self.changeToCTranslate2Process()
|
||||||
@@ -617,6 +630,8 @@ class Controller:
|
|||||||
id = data["id"]
|
id = data["id"]
|
||||||
message = data["message"]
|
message = data["message"]
|
||||||
if len(message) > 0:
|
if len(message) > 0:
|
||||||
|
model.telemetryTouchActivity()
|
||||||
|
model.telemetryTrackCoreFeature("text_input")
|
||||||
translation = []
|
translation = []
|
||||||
transliteration_message: List[Any] = []
|
transliteration_message: List[Any] = []
|
||||||
transliteration_translation = []
|
transliteration_translation = []
|
||||||
@@ -624,6 +639,7 @@ class Controller:
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
model.telemetryTrackCoreFeature("translation")
|
||||||
if config.USE_EXCLUDE_WORDS is True:
|
if config.USE_EXCLUDE_WORDS is True:
|
||||||
replacement_message, replacement_dict = self.replaceExclamationsWithRandom(message)
|
replacement_message, replacement_dict = self.replaceExclamationsWithRandom(message)
|
||||||
translation, success = model.getInputTranslate(replacement_message)
|
translation, success = model.getInputTranslate(replacement_message)
|
||||||
|
|||||||
@@ -370,47 +370,67 @@ def telemetry.get_state() -> dict:
|
|||||||
|
|
||||||
### パブリック API 使用例
|
### パブリック API 使用例
|
||||||
|
|
||||||
#### ケース1: アプリ起動・終了
|
#### ケース1: アプリ起動・終了(MVC 準拠)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# mainloop.py
|
# model.py
|
||||||
from models.telemetry import telemetry
|
from models.telemetry import telemetry
|
||||||
from config import config
|
from config import config
|
||||||
|
|
||||||
# 起動時
|
class Model:
|
||||||
def app_startup():
|
def init(self):
|
||||||
telemetry.init(enabled=config.TELEMETRY_ENABLED)
|
# ... 既存の初期化処理
|
||||||
# ... 他の初期化処理
|
|
||||||
|
|
||||||
# 終了時
|
# Telemetry 初期化
|
||||||
def app_shutdown():
|
try:
|
||||||
telemetry.shutdown()
|
telemetry.init(enabled=config.TELEMETRY_ENABLED)
|
||||||
# ... 他のクリーンアップ
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Model cleanup on application shutdown."""
|
||||||
|
try:
|
||||||
|
# Telemetry 終了(app_closed 送信)
|
||||||
|
telemetry.shutdown()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
# ... その他のクリーンアップ処理
|
||||||
|
|
||||||
|
# controller.py
|
||||||
|
class Controller:
|
||||||
|
def init(self):
|
||||||
|
# Model 初期化(telemetry も初期化される)
|
||||||
|
model.init()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
# Model シャットダウン(telemetry も終了)
|
||||||
|
model.shutdown()
|
||||||
```
|
```
|
||||||
|
|
||||||
#### ケース2: 翻訳機能
|
#### ケース2: 翻訳機能(MVC 準拠)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# controller.py
|
# controller.py
|
||||||
def micMessage(self, result: dict):
|
def micMessage(self, result: dict):
|
||||||
# テキスト入力フェーズ(テキスト入力イベント用)
|
# テキスト入力フェーズ
|
||||||
message = result["text"]
|
message = result["text"]
|
||||||
telemetry.touch_activity()
|
model.telemetryTouchActivity() # model 経由でアクティビティ更新
|
||||||
|
|
||||||
# 翻訳開始前(コア機能イベント送信)
|
# 翻訳開始前(コア機能イベント送信)
|
||||||
if config.ENABLE_TRANSLATION:
|
if config.ENABLE_TRANSLATION:
|
||||||
telemetry.track_core_feature("translation")
|
model.telemetryTrackCoreFeature("translation") # model 経由
|
||||||
translation, success = model.getInputTranslate(message)
|
translation, success = model.getInputTranslate(message)
|
||||||
# ... 処理続行
|
# ... 処理続行
|
||||||
```
|
```
|
||||||
|
|
||||||
#### ケース3: マイク文字起こし
|
#### ケース3: マイク文字起こし(MVC 準拠)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# model.py
|
# model.py
|
||||||
def startMicTranscript(self, fnc):
|
def startMicTranscript(self, fnc):
|
||||||
# マイク開始前
|
# マイク開始前(内部で telemetry 呼び出し)
|
||||||
telemetry.track_core_feature("mic_speech_to_text")
|
self.telemetryTrackCoreFeature("mic_speech_to_text")
|
||||||
|
|
||||||
mic_device = selected_mic_device[0]
|
mic_device = selected_mic_device[0]
|
||||||
self.mic_audio_recorder = SelectedMicEnergyAndAudioRecorder(...)
|
self.mic_audio_recorder = SelectedMicEnergyAndAudioRecorder(...)
|
||||||
@@ -418,24 +438,24 @@ def startMicTranscript(self, fnc):
|
|||||||
# ... 処理続行
|
# ... 処理続行
|
||||||
```
|
```
|
||||||
|
|
||||||
#### ケース4: エラー報告
|
#### ケース4: エラー報告(MVC 準拠)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# controller.py
|
# controller.py
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
is_vram_error, error_message = model.detectVRAMError(e)
|
is_vram_error, error_message = model.detectVRAMError(e)
|
||||||
if is_vram_error:
|
if is_vram_error:
|
||||||
telemetry.track("error", {"error_type": "VRAM_ERROR"})
|
model.telemetryTrack("error", {"error_type": "VRAM_ERROR"}) # model 経由
|
||||||
# ... エラー処理
|
# ... エラー処理
|
||||||
```
|
```
|
||||||
|
|
||||||
#### ケース5: 設定変更
|
#### ケース5: 設定変更(MVC 準拠)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# controller.py
|
# controller.py
|
||||||
def setUiLanguage(data):
|
def setUiLanguage(data):
|
||||||
config.UI_LANGUAGE = data
|
config.UI_LANGUAGE = data
|
||||||
telemetry.track("config_changed", {"section": "appearance"})
|
model.telemetryTrack("config_changed", {"section": "appearance"}) # model 経由
|
||||||
return {"status": 200, "result": config.UI_LANGUAGE}
|
return {"status": 200, "result": config.UI_LANGUAGE}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -783,12 +803,10 @@ class TelemetryCore:
|
|||||||
|
|
||||||
### 既存コード構造への組み込み
|
### 既存コード構造への組み込み
|
||||||
|
|
||||||
#### パターン1: mainloop.py の stop() メソッド
|
#### パターン1: mainloop.py の stop() メソッド(MVC 準拠)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# mainloop.py
|
# mainloop.py
|
||||||
from models.telemetry import telemetry
|
|
||||||
|
|
||||||
class Main:
|
class Main:
|
||||||
def stop(self, wait: float = 2.0) -> None:
|
def stop(self, wait: float = 2.0) -> None:
|
||||||
"""Signal threads to stop and wait for them to finish.
|
"""Signal threads to stop and wait for them to finish.
|
||||||
@@ -796,11 +814,11 @@ class Main:
|
|||||||
Args:
|
Args:
|
||||||
wait: maximum seconds to wait for threads to join.
|
wait: maximum seconds to wait for threads to join.
|
||||||
"""
|
"""
|
||||||
# ここで telemetry を終了(最後のイベント送信)
|
# Controller 経由で shutdown(telemetry も含む)
|
||||||
try:
|
try:
|
||||||
telemetry.shutdown() # app_closed 送信
|
self.controller.shutdown() # model.shutdown() を呼び出し
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 握りつぶし
|
errorLogging()
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
# give threads a chance to exit
|
# give threads a chance to exit
|
||||||
@@ -810,7 +828,7 @@ class Main:
|
|||||||
th.join(timeout=remaining)
|
th.join(timeout=remaining)
|
||||||
```
|
```
|
||||||
|
|
||||||
**重要**: `telemetry.shutdown()` は **スレッド停止前** に呼び出す必要があります。そうしないと heartbeat スレッドが動作していない状態で shutdown に失敗する可能性があります。
|
**重要**: `controller.shutdown()` は **スレッド停止前** に呼び出す必要があります。そうしないと heartbeat スレッドが動作していない状態で shutdown に失敗する可能性があります。
|
||||||
|
|
||||||
#### パターン2: Controller.setWatchdogCallback
|
#### パターン2: Controller.setWatchdogCallback
|
||||||
|
|
||||||
@@ -922,24 +940,18 @@ def shutdown(self) -> None:
|
|||||||
|
|
||||||
その後、mainloop.py で呼び出し:
|
その後、mainloop.py で呼び出し:
|
||||||
|
|
||||||
|
```python(MVC 準拠):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# mainloop.py
|
# mainloop.py
|
||||||
def stop(self, wait: float = 2.0) -> None:
|
def stop(self, wait: float = 2.0) -> None:
|
||||||
"""Signal threads to stop and wait for them to finish."""
|
"""Signal threads to stop and wait for them to finish."""
|
||||||
|
|
||||||
# テレメトリ終了(app_closed 送信)
|
# Controller 経由でシャットダウン(telemetry + model 含む)
|
||||||
try:
|
try:
|
||||||
telemetry.shutdown()
|
self.controller.shutdown() # model.shutdown() が呼ばれる
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
errorLogging()op_event.set()
|
||||||
|
|
||||||
# モデルクリーンアップ
|
|
||||||
try:
|
|
||||||
model.shutdown()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._stop_event.set()
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
for th in self._threads:
|
for th in self._threads:
|
||||||
remaining = max(0.0, wait - (time.time() - start))
|
remaining = max(0.0, wait - (time.time() - start))
|
||||||
|
|||||||
@@ -460,14 +460,20 @@ class Main:
|
|||||||
"""Read lines from stdin, parse JSON and enqueue requests.
|
"""Read lines from stdin, parse JSON and enqueue requests.
|
||||||
|
|
||||||
Uses blocking readline but honors stop via _stop_event checked between reads.
|
Uses blocking readline but honors stop via _stop_event checked between reads.
|
||||||
|
EOF on stdin indicates the frontend has closed; trigger app_closed event.
|
||||||
"""
|
"""
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
line = sys.stdin.readline()
|
line = sys.stdin.readline()
|
||||||
if not line:
|
if not line:
|
||||||
# EOF reached; sleep briefly and re-check stop event
|
# EOF reached - frontend has closed connection
|
||||||
time.sleep(0.1)
|
# Trigger telemetry shutdown to send app_closed event
|
||||||
continue
|
printLog("Frontend disconnected (stdin EOF)", {"event": "frontend_closed"})
|
||||||
|
try:
|
||||||
|
self.controller.shutdown()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
break
|
||||||
received_data = json.loads(line.strip())
|
received_data = json.loads(line.strip())
|
||||||
|
|
||||||
if received_data:
|
if received_data:
|
||||||
@@ -584,6 +590,12 @@ class Main:
|
|||||||
Args:
|
Args:
|
||||||
wait: maximum seconds to wait for threads to join.
|
wait: maximum seconds to wait for threads to join.
|
||||||
"""
|
"""
|
||||||
|
# Controller 経由でシャットダウン(model.shutdown() → telemetry.shutdown() が呼ばれる)
|
||||||
|
try:
|
||||||
|
self.controller.shutdown()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
# give threads a chance to exit
|
# give threads a chance to exit
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from models.overlay.overlay import Overlay
|
|||||||
from models.overlay.overlay_image import OverlayImage
|
from models.overlay.overlay_image import OverlayImage
|
||||||
from models.watchdog.watchdog import Watchdog
|
from models.watchdog.watchdog import Watchdog
|
||||||
from models.websocket.websocket_server import WebSocketServer
|
from models.websocket.websocket_server import WebSocketServer
|
||||||
|
from models.telemetry import Telemetry
|
||||||
from utils import errorLogging, setupLogger
|
from utils import errorLogging, setupLogger
|
||||||
|
|
||||||
class threadFnc(Thread):
|
class threadFnc(Thread):
|
||||||
@@ -140,6 +141,11 @@ class Model:
|
|||||||
# default no-op callbacks for energy check functions
|
# default no-op callbacks for energy check functions
|
||||||
self.check_mic_energy_fnc: Callable[[float], None] = lambda v: None
|
self.check_mic_energy_fnc: Callable[[float], None] = lambda v: None
|
||||||
self.check_speaker_energy_fnc: Callable[[float], None] = lambda v: None
|
self.check_speaker_energy_fnc: Callable[[float], None] = lambda v: None
|
||||||
|
|
||||||
|
# Telemetry 初期化(Model 内でインスタンスを保持)
|
||||||
|
self.telemetry = Telemetry()
|
||||||
|
self.telemetry.init(enabled=config.TELEMETRY_ENABLED, app_version=config.VERSION)
|
||||||
|
|
||||||
self._inited = True
|
self._inited = True
|
||||||
|
|
||||||
def ensure_initialized(self) -> None:
|
def ensure_initialized(self) -> None:
|
||||||
@@ -1291,4 +1297,76 @@ class Model:
|
|||||||
errorLogging()
|
errorLogging()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Telemetry Methods (MVC Pattern)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Model cleanup on application shutdown."""
|
||||||
|
# Telemetry 終了(app_closed 送信)
|
||||||
|
if hasattr(self, "telemetry") and self.telemetry:
|
||||||
|
self.telemetry.shutdown()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# オーバーレイ終了
|
||||||
|
if hasattr(self, 'overlay') and self.overlay:
|
||||||
|
self.shutdownOverlay()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Watchdog 停止
|
||||||
|
if hasattr(self, 'th_watchdog'):
|
||||||
|
self.stopWatchdog()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# WebSocket サーバー停止
|
||||||
|
if hasattr(self, 'websocket_server_alive') and self.websocket_server_alive:
|
||||||
|
self.stopWebSocketServer()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# OSC ハンドラー停止
|
||||||
|
if hasattr(self, 'osc_handler'):
|
||||||
|
self.stopReceiveOSC()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# オーディオ停止
|
||||||
|
self.stopMicTranscript()
|
||||||
|
self.stopSpeakerTranscript()
|
||||||
|
self.stopCheckMicEnergy()
|
||||||
|
self.stopCheckSpeakerEnergy()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ロガー停止
|
||||||
|
if hasattr(self, 'logger') and self.logger:
|
||||||
|
self.stopLogger()
|
||||||
|
except Exception:
|
||||||
|
errorLogging()
|
||||||
|
|
||||||
|
# メモリ解放
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
def telemetryTrack(self, event: str, payload: dict = None):
|
||||||
|
"""汎用テレメトリイベント送信 (Model ラッパー)"""
|
||||||
|
if hasattr(self, "telemetry") and self.telemetry:
|
||||||
|
self.telemetry.track(event, payload)
|
||||||
|
|
||||||
|
def telemetryTrackCoreFeature(self, feature: str):
|
||||||
|
"""コア機能テレメトリイベント送信 (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()
|
model = Model()
|
||||||
202
src-python/models/telemetry/__init__.py
Normal file
202
src-python/models/telemetry/__init__.py
Normal 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")
|
||||||
|
|
||||||
58
src-python/models/telemetry/client.py
Normal file
58
src-python/models/telemetry/client.py
Normal 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
|
||||||
57
src-python/models/telemetry/core.py
Normal file
57
src-python/models/telemetry/core.py
Normal 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)
|
||||||
53
src-python/models/telemetry/heartbeat.py
Normal file
53
src-python/models/telemetry/heartbeat.py
Normal 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"))
|
||||||
63
src-python/models/telemetry/state.py
Normal file
63
src-python/models/telemetry/state.py
Normal 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),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user