mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
feat(telemetry): payload v2 with feature usage counters and instance heartbeat
Per-query events now carry event_type='query' and a features JSON object: - tool_calls by source (native/plugin/mcp/skill) via ToolManager - tool_call_rounds, kb usage (count/engine plugins/retrieved entries) via local-agent - sandbox execs/errors via BoxService - activated_skills and bound mcp_servers snapshots New instance_heartbeat event (startup + daily) reports anonymous instance profile: deploy platform, database/vdb kind, box backend/availability, adapter type names, and resource counts. Respects space.disable_telemetry. All collection helpers are defensive and never break the pipeline. Verified: ruff, 37 telemetry unit tests (13 new), 504 box/provider/pipeline tests.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""Instance heartbeat telemetry.
|
||||
|
||||
Sends a periodic (startup + daily) anonymous snapshot of the instance's
|
||||
configuration profile so feature *adoption* can be measured separately from
|
||||
feature *usage* (which is covered by per-query telemetry).
|
||||
|
||||
The snapshot contains only configuration categories and object counts —
|
||||
never names of user resources (except adapter type names, which are LangBot
|
||||
adapter identifiers, not account info), never message content, never
|
||||
credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import typing
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ..utils import constants, platform as platform_utils
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core import app as core_app
|
||||
|
||||
|
||||
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
async def _count(ap: core_app.Application, table) -> int:
|
||||
"""Count rows in a persistence table; -1 when unavailable."""
|
||||
try:
|
||||
result = await ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count()).select_from(table))
|
||||
return int(result.scalar() or 0)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
|
||||
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||
"""Collect the anonymous instance profile snapshot."""
|
||||
from ..entity.persistence import bot as persistence_bot
|
||||
from ..entity.persistence import mcp as persistence_mcp
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
from ..entity.persistence import rag as persistence_rag
|
||||
|
||||
config = ap.instance_config.data if ap.instance_config else {}
|
||||
|
||||
features: dict = {
|
||||
'deploy_platform': platform_utils.get_platform(),
|
||||
'database': config.get('database', {}).get('use', 'sqlite'),
|
||||
'vdb': config.get('vdb', {}).get('use', 'chroma'),
|
||||
}
|
||||
|
||||
# Box / sandbox profile
|
||||
try:
|
||||
box_service = getattr(ap, 'box_service', None)
|
||||
if box_service is not None:
|
||||
box_info: dict = {
|
||||
'enabled': bool(box_service.enabled),
|
||||
'available': bool(box_service.available),
|
||||
}
|
||||
box_cfg = config.get('box', {})
|
||||
box_info['backend'] = box_cfg.get('backend', 'local')
|
||||
try:
|
||||
box_info['shares_fs'] = bool(box_service.shares_filesystem_with_box)
|
||||
except Exception:
|
||||
pass
|
||||
features['box'] = box_info
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bots / adapters (adapter type names only)
|
||||
try:
|
||||
platform_mgr = getattr(ap, 'platform_mgr', None)
|
||||
if platform_mgr is not None and getattr(platform_mgr, 'bots', None) is not None:
|
||||
enabled_bots = [bot for bot in platform_mgr.bots if getattr(bot, 'enable', False)]
|
||||
features['bot_count'] = len(platform_mgr.bots)
|
||||
adapters = sorted({bot.adapter.__class__.__name__ for bot in enabled_bots if getattr(bot, 'adapter', None)})
|
||||
features['adapters'] = adapters
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resource counts
|
||||
features['pipeline_count'] = await _count(ap, persistence_pipeline.LegacyPipeline)
|
||||
features['mcp_server_count'] = await _count(ap, persistence_mcp.MCPServer)
|
||||
features['knowledge_base_count'] = await _count(ap, persistence_rag.KnowledgeBase)
|
||||
if 'bot_count' not in features:
|
||||
features['bot_count'] = await _count(ap, persistence_bot.Bot)
|
||||
|
||||
# Plugin count (from plugin runtime)
|
||||
try:
|
||||
plugin_connector = getattr(ap, 'plugin_connector', None)
|
||||
if plugin_connector is not None:
|
||||
plugins = await plugin_connector.list_plugins()
|
||||
features['plugin_count'] = len(plugins)
|
||||
except Exception:
|
||||
features['plugin_count'] = -1
|
||||
|
||||
# Skill count (from Box runtime via skill manager)
|
||||
try:
|
||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||
if skill_mgr is not None and getattr(skill_mgr, 'skills', None) is not None:
|
||||
features['skill_count'] = len(skill_mgr.skills)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'event_type': 'instance_heartbeat',
|
||||
'query_id': '',
|
||||
'version': constants.semantic_version,
|
||||
'instance_id': constants.instance_id,
|
||||
'edition': constants.edition,
|
||||
'features': features,
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def heartbeat_loop(ap: core_app.Application) -> None:
|
||||
"""Send one heartbeat shortly after startup, then daily."""
|
||||
# Small delay so managers (platform, skills, plugins) finish loading first
|
||||
await asyncio.sleep(30)
|
||||
while True:
|
||||
try:
|
||||
payload = await build_heartbeat_payload(ap)
|
||||
await ap.telemetry.start_send_task(payload)
|
||||
except Exception as e:
|
||||
try:
|
||||
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
|
||||
Reference in New Issue
Block a user