// Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package telemetry import ( "context" "database/sql/driver" "encoding/json" "fmt" "log" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/daystr" "github.com/wavetermdev/waveterm/pkg/util/dbutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) const MaxTzNameLen = 50 const ActivityEventName = "app:activity" type ActivityType struct { Day string `json:"day"` Uploaded bool `json:"-"` TData TelemetryData `json:"tdata"` TzName string `json:"tzname"` TzOffset int `json:"tzoffset"` ClientVersion string `json:"clientversion"` ClientArch string `json:"clientarch"` BuildTime string `json:"buildtime"` OSRelease string `json:"osrelease"` } type TelemetryData struct { ActiveMinutes int `json:"activeminutes"` FgMinutes int `json:"fgminutes"` OpenMinutes int `json:"openminutes"` NumTabs int `json:"numtabs"` NumBlocks int `json:"numblocks,omitempty"` NumWindows int `json:"numwindows,omitempty"` NumWS int `json:"numws,omitempty"` NumWSNamed int `json:"numwsnamed,omitempty"` NumSSHConn int `json:"numsshconn,omitempty"` NumWSLConn int `json:"numwslconn,omitempty"` NumMagnify int `json:"nummagnify,omitempty"` NewTab int `json:"newtab"` NumStartup int `json:"numstartup,omitempty"` NumShutdown int `json:"numshutdown,omitempty"` NumPanics int `json:"numpanics,omitempty"` NumAIReqs int `json:"numaireqs,omitempty"` SetTabTheme int `json:"settabtheme,omitempty"` Displays []wshrpc.ActivityDisplayType `json:"displays,omitempty"` Renderers map[string]int `json:"renderers,omitempty"` Blocks map[string]int `json:"blocks,omitempty"` WshCmds map[string]int `json:"wshcmds,omitempty"` Conn map[string]int `json:"conn,omitempty"` } func (tdata TelemetryData) Value() (driver.Value, error) { return dbutil.QuickValueJson(tdata) } func (tdata *TelemetryData) Scan(val interface{}) error { return dbutil.QuickScanJson(tdata, val) } func IsTelemetryEnabled() bool { settings := wconfig.GetWatcher().GetFullConfig() return settings.Settings.TelemetryEnabled } func IsAutoUpdateEnabled() bool { settings := wconfig.GetWatcher().GetFullConfig() return settings.Settings.AutoUpdateEnabled } func AutoUpdateChannel() string { settings := wconfig.GetWatcher().GetFullConfig() return settings.Settings.AutoUpdateChannel } // Wraps UpdateCurrentActivity, spawns goroutine, and logs errors func GoUpdateActivityWrap(update wshrpc.ActivityUpdate, debugStr string) { go func() { defer func() { panichandler.PanicHandlerNoTelemetry("GoUpdateActivityWrap", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() err := UpdateActivity(ctx, update) if err != nil { // ignore error, just log, since this is not critical log.Printf("error updating current activity (%s): %v\n", debugStr, err) } }() } func insertTEvent(ctx context.Context, event *telemetrydata.TEvent) error { if event.Uuid == "" { return fmt.Errorf("cannot insert TEvent: uuid is empty") } if event.Ts == 0 { return fmt.Errorf("cannot insert TEvent: ts is 0") } if event.TsLocal == "" { return fmt.Errorf("cannot insert TEvent: tslocal is empty") } if event.Event == "" { return fmt.Errorf("cannot insert TEvent: event is empty") } return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `INSERT INTO db_tevent (uuid, ts, tslocal, event, props) VALUES (?, ?, ?, ?, ?)` tx.Exec(query, event.Uuid, event.Ts, event.TsLocal, event.Event, dbutil.QuickJson(event.Props)) return nil }) } // merges newActivity into curActivity, returns curActivity func mergeActivity(curActivity *telemetrydata.TEventProps, newActivity telemetrydata.TEventProps) { curActivity.ActiveMinutes += newActivity.ActiveMinutes curActivity.FgMinutes += newActivity.FgMinutes curActivity.OpenMinutes += newActivity.OpenMinutes } // ignores the timestamp in tevent, and uses the current time func updateActivityTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { eventTs := time.Now() // compute to hour boundary, and round up to next hour eventTs = eventTs.Truncate(time.Hour).Add(time.Hour) return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { // find event that matches this timestamp with event name "app:activity" var hasRow bool var curActivity telemetrydata.TEventProps uuidStr := tx.GetString(`SELECT uuid FROM db_tevent WHERE ts = ? AND event = ?`, eventTs.UnixMilli(), ActivityEventName) if uuidStr != "" { hasRow = true rawProps := tx.GetString(`SELECT props FROM db_tevent WHERE uuid = ?`, uuidStr) err := json.Unmarshal([]byte(rawProps), &curActivity) if err != nil { // ignore, curActivity will just be 0 log.Printf("error unmarshalling activity props: %v\n", err) } } mergeActivity(&curActivity, tevent.Props) if hasRow { query := `UPDATE db_tevent SET props = ? WHERE uuid = ?` tx.Exec(query, dbutil.QuickJson(curActivity), uuidStr) } else { query := `INSERT INTO db_tevent (uuid, ts, tslocal, event, props) VALUES (?, ?, ?, ?, ?)` tsLocal := utilfn.ConvertToWallClockPT(eventTs).Format(time.RFC3339) tx.Exec(query, uuid.New().String(), eventTs.UnixMilli(), tsLocal, ActivityEventName, dbutil.QuickJson(curActivity)) } return nil }) } func TruncateActivityTEventForShutdown(ctx context.Context) error { nowTs := time.Now() eventTs := nowTs.Truncate(time.Hour).Add(time.Hour) return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { // find event that matches this timestamp with event name "app:activity" uuidStr := tx.GetString(`SELECT uuid FROM db_tevent WHERE ts = ? AND event = ?`, eventTs.UnixMilli(), ActivityEventName) if uuidStr == "" { return nil } // we're going to update this app:activity event back to nowTs tsLocal := utilfn.ConvertToWallClockPT(nowTs).Format(time.RFC3339) query := `UPDATE db_tevent SET ts = ?, tslocal = ? WHERE uuid = ?` tx.Exec(query, nowTs.UnixMilli(), tsLocal, uuidStr) return nil }) } func GoRecordTEventWrap(tevent *telemetrydata.TEvent) { if tevent == nil || tevent.Event == "" { return } go func() { defer func() { panichandler.PanicHandlerNoTelemetry("GoRecordTEventWrap", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() err := RecordTEvent(ctx, tevent) if err != nil { // ignore error, just log, since this is not critical log.Printf("error recording %q telemetry event: %v\n", tevent.Event, err) } }() } func RecordTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { if tevent == nil { return nil } if tevent.Uuid == "" { tevent.Uuid = uuid.New().String() } err := tevent.Validate(true) if err != nil { return err } tevent.EnsureTimestamps() if tevent.Event == ActivityEventName { return updateActivityTEvent(ctx, tevent) } return insertTEvent(ctx, tevent) } func CleanOldTEvents(ctx context.Context) error { return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { // delete events older than 28 days query := `DELETE FROM db_tevent WHERE ts < ?` olderThan := time.Now().AddDate(0, 0, -28).UnixMilli() tx.Exec(query, olderThan) return nil }) } func GetNonUploadedTEvents(ctx context.Context, maxEvents int) ([]*telemetrydata.TEvent, error) { now := time.Now() return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) ([]*telemetrydata.TEvent, error) { var rtn []*telemetrydata.TEvent query := `SELECT uuid, ts, tslocal, event, props, uploaded FROM db_tevent WHERE uploaded = 0 AND ts <= ? ORDER BY ts LIMIT ?` tx.Select(&rtn, query, now.UnixMilli(), maxEvents) for _, event := range rtn { if err := event.ConvertRawJSON(); err != nil { return nil, fmt.Errorf("scan json for event %s: %w", event.Uuid, err) } } return rtn, nil }) } func MarkTEventsAsUploaded(ctx context.Context, events []*telemetrydata.TEvent) error { return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { ids := make([]string, 0, len(events)) for _, event := range events { ids = append(ids, event.Uuid) } query := `UPDATE db_tevent SET uploaded = 1 WHERE uuid IN (SELECT value FROM json_each(?))` tx.Exec(query, dbutil.QuickJson(ids)) return nil }) } func UpdateActivity(ctx context.Context, update wshrpc.ActivityUpdate) error { now := time.Now() dayStr := daystr.GetCurDayStr() txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { var tdata TelemetryData query := `SELECT tdata FROM db_activity WHERE day = ?` found := tx.Get(&tdata, query, dayStr) if !found { query = `INSERT INTO db_activity (day, uploaded, tdata, tzname, tzoffset, clientversion, clientarch, buildtime, osrelease) VALUES ( ?, 0, ?, ?, ?, ?, ?, ?, ?)` tzName, tzOffset := now.Zone() if len(tzName) > MaxTzNameLen { tzName = tzName[0:MaxTzNameLen] } tx.Exec(query, dayStr, tdata, tzName, tzOffset, wavebase.WaveVersion, wavebase.ClientArch(), wavebase.BuildTime, wavebase.UnameKernelRelease()) } tdata.FgMinutes += update.FgMinutes tdata.ActiveMinutes += update.ActiveMinutes tdata.OpenMinutes += update.OpenMinutes tdata.NewTab += update.NewTab tdata.NumStartup += update.Startup tdata.NumShutdown += update.Shutdown tdata.SetTabTheme += update.SetTabTheme tdata.NumMagnify += update.NumMagnify tdata.NumPanics += update.NumPanics tdata.NumAIReqs += update.NumAIReqs if update.NumTabs > 0 { tdata.NumTabs = update.NumTabs } if update.NumBlocks > 0 { tdata.NumBlocks = update.NumBlocks } if update.NumWindows > 0 { tdata.NumWindows = update.NumWindows } if update.NumWS > 0 { tdata.NumWS = update.NumWS } if update.NumWSNamed > 0 { tdata.NumWSNamed = update.NumWSNamed } if update.NumSSHConn > 0 && update.NumSSHConn > tdata.NumSSHConn { tdata.NumSSHConn = update.NumSSHConn } if update.NumWSLConn > 0 && update.NumWSLConn > tdata.NumWSLConn { tdata.NumWSLConn = update.NumWSLConn } if len(update.Renderers) > 0 { if tdata.Renderers == nil { tdata.Renderers = make(map[string]int) } for key, val := range update.Renderers { tdata.Renderers[key] += val } } if len(update.WshCmds) > 0 { if tdata.WshCmds == nil { tdata.WshCmds = make(map[string]int) } for key, val := range update.WshCmds { tdata.WshCmds[key] += val } } if len(update.Conn) > 0 { if tdata.Conn == nil { tdata.Conn = make(map[string]int) } for key, val := range update.Conn { tdata.Conn[key] += val } } if len(update.Displays) > 0 { tdata.Displays = update.Displays } if len(update.Blocks) > 0 { tdata.Blocks = update.Blocks } query = `UPDATE db_activity SET tdata = ?, clientversion = ?, buildtime = ? WHERE day = ?` tx.Exec(query, tdata, wavebase.WaveVersion, wavebase.BuildTime, dayStr) return nil }) if txErr != nil { return txErr } return nil } func GetNonUploadedActivity(ctx context.Context) ([]*ActivityType, error) { var rtn []*ActivityType txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `SELECT * FROM db_activity WHERE uploaded = 0 ORDER BY day DESC LIMIT 30` tx.Select(&rtn, query) return nil }) if txErr != nil { return nil, txErr } return rtn, nil } func MarkActivityAsUploaded(ctx context.Context, activityArr []*ActivityType) error { dayStr := daystr.GetCurDayStr() txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `UPDATE db_activity SET uploaded = 1 WHERE day = ?` for _, activity := range activityArr { if activity.Day == dayStr { continue } tx.Exec(query, activity.Day) } return nil }) return txErr }