package telemetry import ( "bytes" "context" "encoding/json" "fmt" "math/rand" "net/http" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/chaitin/panda-wiki/config" "github.com/chaitin/panda-wiki/consts" "github.com/chaitin/panda-wiki/domain" "github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/repo/pg" "github.com/chaitin/panda-wiki/usecase" ) const ( machineIDFile = "/data/.machine_id" reportInterval = time.Hour ) // Client is the telemetry client type Client struct { baseURL string httpClient *http.Client machineID string firstReport bool stopChan chan struct{} logger *log.Logger repo *pg.KnowledgeBaseRepository modelUsecase *usecase.ModelUsecase userUsecase *usecase.UserUsecase nodeRepo *pg.NodeRepository conversationRepo *pg.ConversationRepository mcpRepo *pg.MCPRepository cfg *config.Config aesKey string } // NewClient creates a new telemetry client func NewClient(logger *log.Logger, repo *pg.KnowledgeBaseRepository, modelUsecase *usecase.ModelUsecase, userUsecase *usecase.UserUsecase, nodeRepo *pg.NodeRepository, conversationRepo *pg.ConversationRepository, mcpRepo *pg.MCPRepository, cfg *config.Config) (*Client, error) { baseURL := "https://baizhi.cloud/api/public/data/report" aesKey := "SZ3SDP38y9Gg2c6yHdLPgDeX" client := &Client{ baseURL: baseURL, httpClient: &http.Client{ Timeout: 10 * time.Second, }, firstReport: true, stopChan: make(chan struct{}), logger: logger.WithModule("telemetry"), repo: repo, modelUsecase: modelUsecase, userUsecase: userUsecase, nodeRepo: nodeRepo, conversationRepo: conversationRepo, mcpRepo: mcpRepo, cfg: cfg, aesKey: aesKey, } // get or create machine ID machineID, err := client.getOrCreateMachineID() if err != nil { logger.Error("failed to get or create machine ID", log.Error(err)) return nil, fmt.Errorf("failed to get or create machine ID: %w", err) } client.machineID = machineID // report immediately on startup if err := client.reportInstallation(); err != nil { logger.Error("initial report installation", log.Error(err)) } // start periodic report go client.startPeriodicReport() return client, nil } func (c *Client) GetMachineID() string { return c.machineID } func (c *Client) getOrCreateMachineID() (string, error) { // get machine id from file if id, err := os.ReadFile(machineIDFile); err == nil { c.firstReport = false return strings.TrimSpace(string(id)), nil } else if !os.IsNotExist(err) { return "", fmt.Errorf("failed to read machine ID file: %w", err) } // ensure dir is exists dir := filepath.Dir(machineIDFile) if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("failed to create machine ID directory: %w", err) } // create lock file to prevent concurrent access lockFile := machineIDFile + ".lock" lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { if os.IsExist(err) { // if lock file already exists, wait and try again c.logger.Info("lock file already exists, waiting and trying again") time.Sleep(100 * time.Millisecond) return c.getOrCreateMachineID() } return "", fmt.Errorf("failed to create lock file: %w", err) } defer func() { if err := lock.Close(); err != nil { c.logger.Error("failed to close lock file", log.Error(err)) } if err := os.Remove(lockFile); err != nil { c.logger.Error("failed to remove lock file", log.Error(err)) } }() if id, err := os.ReadFile(machineIDFile); err == nil { c.firstReport = false return strings.TrimSpace(string(id)), nil } // generate unique ID using UUID id := uuid.New().String() // write machine ID to file and ensure data is written to disk if err := os.WriteFile(machineIDFile, []byte(id), 0o644); err != nil { return "", fmt.Errorf("failed to write machine ID file: %w", err) } // sync file to ensure data is written to disk if file, err := os.OpenFile(machineIDFile, os.O_RDWR, 0o644); err == nil { if err := file.Sync(); err != nil { if err := file.Close(); err != nil { c.logger.Error("failed to close machine ID file after write", log.Error(err)) } return "", fmt.Errorf("failed to sync machine ID file: %w", err) } if err := file.Close(); err != nil { c.logger.Error("failed to close machine ID file after sync", log.Error(err)) } } return id, nil } // startPeriodicReport starts periodic report func (c *Client) startPeriodicReport() { ticker := time.NewTicker(reportInterval) defer ticker.Stop() dataTimer := time.NewTimer(c.nextReportDataDelay()) defer dataTimer.Stop() for { select { case <-ticker.C: if err := c.reportInstallation(); err != nil { c.logger.Error("periodic report installation", log.Error(err)) } case <-dataTimer.C: if err := c.reportData(); err != nil { c.logger.Error("periodic report data", log.Error(err)) } dataTimer.Reset(c.nextReportDataDelay()) case <-c.stopChan: return } } } // 计算下一次数据上报的延迟,使其在每天 23:30:00–23:58:00 窗口内随机触发。 // 若当前时间位于当日窗口内,返回窗口剩余时间内的随机秒数;否则返回到最近窗口的随机偏移。 func (c *Client) nextReportDataDelay() time.Duration { now := time.Now() loc := now.Location() start := time.Date(now.Year(), now.Month(), now.Day(), 23, 30, 0, 0, loc) end := time.Date(now.Year(), now.Month(), now.Day(), 23, 58, 0, 0, loc) window := end.Sub(start) // 如果当前时间在窗口之前,安排在今日窗口的随机时间 if now.Before(start) { sec := int(window / time.Second) // 防止 sec 为 0 if sec <= 0 { sec = 1 } offset := time.Duration(rand.Intn(sec)) * time.Second return time.Until(start.Add(offset)) } // 如果当前时间在窗口内,返回窗口剩余时间内的随机秒数 if !now.After(end) { remaining := end.Sub(now) sec := int(remaining / time.Second) if sec <= 0 { sec = 1 } offset := rand.Intn(sec) + 1 return time.Duration(offset) * time.Second } // 否则安排在次日窗口的随机时间 nextStart := start.Add(24 * time.Hour) sec := int(window / time.Second) if sec <= 0 { sec = 1 } offset := time.Duration(rand.Intn(sec)) * time.Second return time.Until(nextStart.Add(offset)) } // reportInstallation reports installation information func (c *Client) reportInstallation() error { event := InstallationEvent{ Version: Version, Timestamp: time.Now().Format(time.RFC3339), MachineID: c.machineID, Type: "installation", } if !c.firstReport { event.Type = "heartbeat" } if repoList, err := c.repo.GetKnowledgeBaseList(context.Background()); err != nil { c.logger.Error("get knowledge base list failed in telemetry", log.Error(err)) } else { event.KBCount = len(repoList) } eventRaw, err := json.Marshal(event) if err != nil { return fmt.Errorf("marshal installation event: %w", err) } eventEncrypted, err := Encrypt([]byte(c.aesKey), eventRaw) if err != nil { return fmt.Errorf("encrypt installation event: %w", err) } data := map[string]string{ "index": "panda-wiki-installation", "data": eventEncrypted, "id": uuid.New().String(), } eventEncryptedRaw, err := json.Marshal(data) if err != nil { return fmt.Errorf("marshal installation event: %w", err) } req, err := http.NewRequest("POST", c.baseURL, bytes.NewBuffer(eventEncryptedRaw)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } c.firstReport = false return nil } func (c *Client) reportData() error { event := DailyReportEvent{ InstallationEvent: InstallationEvent{ Version: Version, Timestamp: time.Now().Format(time.RFC3339), MachineID: c.machineID, Type: "data_report", }, } if repoList, err := c.repo.GetKnowledgeBaseList(context.Background()); err == nil { event.KBCount = len(repoList) } else { c.logger.Error("get knowledge base list failed in telemetry", log.Error(err)) } if modelModeSetting, err := c.modelUsecase.GetModelModeSetting(context.Background()); err == nil { event.ModelConfigMode = string(modelModeSetting.Mode) } else { c.logger.Error("get model config mode failed in telemetry", log.Error(err)) } if ok, err := c.isAdminLoggedInYesterday(); err == nil { event.AdminLoggedInToday = ok } else { c.logger.Error("get admin login today failed in telemetry", log.Error(err)) } if count, err := c.nodeRepo.GetNodeCount(context.Background()); err == nil { event.DocsCount = count } else { c.logger.Error("get docs count failed in telemetry", log.Error(err)) } // conversation counts by app type across all KBs if totals, err := c.conversationRepo.GetConversationCountByAppType(context.Background()); err == nil { event.WebConversationCount = int(totals[domain.AppTypeWeb]) event.WidgetConversationCount = int(totals[domain.AppTypeWidget]) event.DingTalkBotConversationCount = int(totals[domain.AppTypeDingTalkBot]) event.FeishuBotConversationCount = int(totals[domain.AppTypeFeishuBot]) event.WechatBotConversationCount = int(totals[domain.AppTypeWechatBot]) event.WeChatServerBotConversationCount = int(totals[domain.AppTypeWechatServiceBot]) event.DiscordBotConversationCount = int(totals[domain.AppTypeDisCordBot]) event.WechatOfficialAccountConversationCount = int(totals[domain.AppTypeWechatOfficialAccount]) event.OpenAIAPIConversationCount = int(totals[domain.AppTypeOpenAIAPI]) event.WecomAIBotConversationCount = int(totals[domain.AppTypeWecomAIBot]) event.LarkBotConversationCount = int(totals[domain.AppTypeLarkBot]) } else { c.logger.Error("get conversation count by app type failed", log.Error(err)) } if count, err := c.mcpRepo.GetMCPCallCount(context.Background()); err == nil { event.McpServerConversationCount = int(count) } else { c.logger.Error("get mcp call count failed", log.Error(err)) } eventRaw, err := json.Marshal(event) if err != nil { return fmt.Errorf("marshal installation event: %w", err) } c.logger.Info("report data event", log.String("event", string(eventRaw))) eventEncrypted, err := Encrypt([]byte(c.aesKey), eventRaw) if err != nil { return fmt.Errorf("encrypt installation event: %w", err) } data := map[string]string{ "index": "panda-wiki-installation", "data": eventEncrypted, "id": uuid.New().String(), } eventEncryptedRaw, err := json.Marshal(data) if err != nil { return fmt.Errorf("marshal installation event: %w", err) } req, err := http.NewRequest("POST", c.baseURL, bytes.NewBuffer(eventEncryptedRaw)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } return nil } // 判断“昨日是否有管理员访问”。 // 因为数据在每天 0–1 点上报,这里采用昨日 0:00 至今日 0:00 的时间窗口。 func (c *Client) isAdminLoggedInYesterday() (bool, error) { resp, err := c.userUsecase.ListUsers(context.Background()) if err != nil { return false, err } now := time.Now() loc := now.Location() todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) yesterdayMidnight := todayMidnight.Add(-24 * time.Hour) for _, u := range resp.Users { if u.Role == consts.UserRoleAdmin && u.LastAccess != nil && !u.LastAccess.Before(yesterdayMidnight) && u.LastAccess.Before(todayMidnight) { return true, nil } } return false, nil } // Stop stops periodic report func (c *Client) Stop() { close(c.stopChan) } // InstallationEvent represents installation event type InstallationEvent struct { Version string `json:"version"` MachineID string `json:"machine_id"` Timestamp string `json:"timestamp"` Type string `json:"type"` KBCount int `json:"kb_count"` } type DailyReportEvent struct { InstallationEvent ModelConfigMode string `json:"model_config_mode"` // 模型配置模式 AdminLoggedInToday bool `json:"admin_logged_in_today"` // 是否今日登录管理端 DocsCount int `json:"docs_count"` // 文件数量 WebConversationCount int `json:"web_conversation_count"` // 网页对话次数 WidgetConversationCount int `json:"widget_conversation_count"` // 插件对话次数 DingTalkBotConversationCount int `json:"dingtalk_bot_conversation_count"` // 钉钉机器人对话次数 FeishuBotConversationCount int `json:"feishu_bot_conversation_count"` // 飞书机器人对话次数 WechatBotConversationCount int `json:"wechat_bot_conversation_count"` // 企业微信机器人对话次数 WeChatServerBotConversationCount int `json:"wechat_server_bot_conversation_count"` // 企业微信客服对话次数 DiscordBotConversationCount int `json:"discord_bot_conversation_count"` // Discord 机器人对话次数 WechatOfficialAccountConversationCount int `json:"wechat_official_account_conversation_count"` // 微信公众号对话次数 OpenAIAPIConversationCount int `json:"openai_api_conversation_count"` // OpenAI API 调用次数 WecomAIBotConversationCount int `json:"wecom_ai_bot_conversation_count"` // 企业微信智能机器人对话次数 LarkBotConversationCount int `json:"lark_bot_conversation_count"` // 飞书机器人对话次数 McpServerConversationCount int `json:"mcp_server_conversation_count"` // MCP 对话次数 }