package lib import ( "bytes" "database/sql" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "runtime" "strconv" "strings" "syscall" "time" ) // TelemetryConfig controls the optional telemetry reporter. // All fields zero/empty = telemetry disabled. type TelemetryConfig struct { FreqSeconds int // interval between POSTs (0 = disabled) Host string // e.g. https://hq.vault1984.com/telemetry Token string // Bearer token for auth DataDir string // vault data directory (to scan DBs) Mode string // "self-hosted" or "hosted" } // TelemetryPayload is the JSON body posted to the telemetry endpoint. type TelemetryPayload struct { Version string `json:"version"` Hostname string `json:"hostname"` UptimeSeconds int64 `json:"uptime_seconds"` Timestamp string `json:"timestamp"` System SystemMetrics `json:"system"` Vaults VaultMetrics `json:"vaults"` Mode string `json:"mode"` } type SystemMetrics struct { OS string `json:"os"` Arch string `json:"arch"` CPUs int `json:"cpus"` CPUPercent float64 `json:"cpu_percent"` MemTotalMB int64 `json:"memory_total_mb"` MemUsedMB int64 `json:"memory_used_mb"` DiskTotalMB int64 `json:"disk_total_mb"` DiskUsedMB int64 `json:"disk_used_mb"` Load1m float64 `json:"load_1m"` } type VaultMetrics struct { Count int `json:"count"` TotalSizeMB int64 `json:"total_size_mb"` TotalEntries int64 `json:"total_entries"` } // StartTelemetry launches a background goroutine that periodically // collects metrics and POSTs them to cfg.Host. Does nothing if // FreqSeconds <= 0 or Host is empty. func StartTelemetry(cfg TelemetryConfig) { if cfg.FreqSeconds <= 0 || cfg.Host == "" { return } startTime := time.Now() interval := time.Duration(cfg.FreqSeconds) * time.Second client := &http.Client{Timeout: 10 * time.Second} log.Printf("Telemetry enabled: posting every %ds to %s", cfg.FreqSeconds, cfg.Host) go func() { // Post immediately on startup, then on interval. for { payload := CollectPayload(cfg, startTime) postTelemetry(client, cfg.Host, cfg.Token, payload) time.Sleep(interval) } }() } // CollectPayload gathers system and vault metrics into a TelemetryPayload. func CollectPayload(cfg TelemetryConfig, startTime time.Time) TelemetryPayload { hostname, _ := os.Hostname() return TelemetryPayload{ Version: "0.1.0", Hostname: hostname, UptimeSeconds: int64(time.Since(startTime).Seconds()), Timestamp: time.Now().UTC().Format(time.RFC3339), System: collectSystemMetrics(cfg.DataDir), Vaults: collectVaultMetrics(cfg.DataDir), Mode: cfg.Mode, } } func postTelemetry(client *http.Client, host, token string, payload TelemetryPayload) { body, err := json.Marshal(payload) if err != nil { log.Printf("telemetry: marshal error: %v", err) return } req, err := http.NewRequest("POST", host, bytes.NewReader(body)) if err != nil { log.Printf("telemetry: request error: %v", err) return } req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := client.Do(req) if err != nil { log.Printf("telemetry: post error: %v", err) return } resp.Body.Close() if resp.StatusCode >= 300 { log.Printf("telemetry: unexpected status %d", resp.StatusCode) } } func collectSystemMetrics(dataDir string) SystemMetrics { m := SystemMetrics{ OS: runtime.GOOS, Arch: runtime.GOARCH, CPUs: runtime.NumCPU(), } m.CPUPercent = readCPUPercent() m.MemTotalMB, m.MemUsedMB = readMemInfo() m.DiskTotalMB, m.DiskUsedMB = readDiskUsage(dataDir) m.Load1m = readLoadAvg() return m } // readCPUPercent samples /proc/stat twice 500ms apart to compute real CPU usage. func readCPUPercent() float64 { s1 := readCPUStat() time.Sleep(500 * time.Millisecond) s2 := readCPUStat() total1, total2 := sumUint64(s1), sumUint64(s2) totalDiff := total2 - total1 if totalDiff == 0 { return 0 } // Field index 3 is idle time. idleDiff := s2[3] - s1[3] return float64(totalDiff-idleDiff) / float64(totalDiff) * 100 } func readCPUStat() []uint64 { data, err := os.ReadFile("/proc/stat") if err != nil { return make([]uint64, 10) } for _, line := range strings.Split(string(data), "\n") { if !strings.HasPrefix(line, "cpu ") { continue } fields := strings.Fields(line)[1:] // skip "cpu" vals := make([]uint64, len(fields)) for i, f := range fields { vals[i], _ = strconv.ParseUint(f, 10, 64) } return vals } return make([]uint64, 10) } func sumUint64(vals []uint64) uint64 { var t uint64 for _, v := range vals { t += v } return t } // readMemInfo parses /proc/meminfo for total and used memory. // Falls back to Go runtime stats on non-Linux. func readMemInfo() (totalMB, usedMB int64) { data, err := os.ReadFile("/proc/meminfo") if err != nil { // Fallback: Go runtime memory stats (process only, not system). var ms runtime.MemStats runtime.ReadMemStats(&ms) return int64(ms.Sys / 1024 / 1024), int64(ms.Alloc / 1024 / 1024) } var total, available int64 for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } val, _ := strconv.ParseInt(fields[1], 10, 64) switch fields[0] { case "MemTotal:": total = val // kB case "MemAvailable:": available = val // kB } } totalMB = total / 1024 usedMB = (total - available) / 1024 return } // readDiskUsage returns total and used disk space for the filesystem // containing dataDir. func readDiskUsage(path string) (totalMB, usedMB int64) { if path == "" { path = "." } var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { return 0, 0 } totalBytes := stat.Blocks * uint64(stat.Bsize) freeBytes := stat.Bavail * uint64(stat.Bsize) totalMB = int64(totalBytes / 1024 / 1024) usedMB = int64((totalBytes - freeBytes) / 1024 / 1024) return } // readLoadAvg parses /proc/loadavg for the 1-minute load average. func readLoadAvg() float64 { data, err := os.ReadFile("/proc/loadavg") if err != nil { return 0 } fields := strings.Fields(string(data)) if len(fields) < 1 { return 0 } load, _ := strconv.ParseFloat(fields[0], 64) return load } // collectVaultMetrics scans dataDir for .db files and counts entries. func collectVaultMetrics(dataDir string) VaultMetrics { if dataDir == "" { dataDir = "." } var m VaultMetrics matches, err := filepath.Glob(filepath.Join(dataDir, "*.db")) if err != nil { return m } for _, dbPath := range matches { base := filepath.Base(dbPath) // Skip non-vault databases. if base == "node.db" { continue } info, err := os.Stat(dbPath) if err != nil { continue } m.Count++ m.TotalSizeMB += info.Size() / 1024 / 1024 count := countEntries(dbPath) m.TotalEntries += count } // For self-hosted mode with a single DB, the size might round to 0. // Report in KB precision via MB (allow fractional). return m } func countEntries(dbPath string) int64 { db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL", dbPath)) if err != nil { return 0 } defer db.Close() var count int64 err = db.QueryRow("SELECT COUNT(*) FROM entries WHERE deleted_at IS NULL").Scan(&count) if err != nil { return 0 } return count }