waveterm/pkg/telemetry/telemetry.go
2025-02-03 15:32:44 -08:00

373 lines
12 KiB
Go

// 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
}