waveterm/wavesrv/pkg/telemetry/telemetry.go
2024-05-28 20:53:16 -07:00

321 lines
9.2 KiB
Go

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package telemetry
import (
"context"
"database/sql/driver"
"fmt"
"log"
"regexp"
"strconv"
"time"
"github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
)
const MaxTzNameLen = 50
// "terminal" should not be in this list
var allowedRenderers = map[string]bool{
"markdown": true,
"code": true,
"openai": true,
"csv": true,
"image": true,
"pdf": true,
"media": true,
"mustache": true,
}
type ActivityUpdate struct {
FgMinutes int
ActiveMinutes int
OpenMinutes int
NumCommands int
ClickShared int
HistoryView int
BookmarksView int
NumConns int
NumWorkspaces int
NumTabs int
NewTab int
ReinitBashErrors int
ReinitZshErrors int
Startup int
Shutdown int
FeAIChatOpen int
FeHistoryOpen int
FeAiCmdInfoOpen int
BuildTime string
Renderers map[string]int
}
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"`
DefaultShell string `json:"defaultshell"`
OSRelease string `json:"osrelease"`
}
type TelemetryData struct {
NumCommands int `json:"numcommands"`
ActiveMinutes int `json:"activeminutes"`
FgMinutes int `json:"fgminutes"`
OpenMinutes int `json:"openminutes"`
ClickShared int `json:"clickshared,omitempty"`
HistoryView int `json:"historyview,omitempty"`
BookmarksView int `json:"bookmarksview,omitempty"`
NumConns int `json:"numconns"`
NumWorkspaces int `json:"numworkspaces"`
NumTabs int `json:"numtabs"`
NewTab int `json:"newtab"`
NumStartup int `json:"numstartup,omitempty"`
NumShutdown int `json:"numshutdown,omitempty"`
NumAIChatOpen int `json:"numaichatopen,omitempty"`
NumHistoryOpen int `json:"numhistoryopen,omitempty"`
ReinitBashErrors int `json:"reinitbasherrors,omitempty"`
ReinitZshErrors int `json:"reinitzsherrors,omitempty"`
Renderers map[string]int `json:"renderers,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 IsAllowedRenderer(renderer string) bool {
return allowedRenderers[renderer]
}
// Wraps UpdateCurrentActivity, spawns goroutine, and logs errors
func GoUpdateActivityWrap(update ActivityUpdate, debugStr string) {
go func() {
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 GetCurDayStr() string {
now := time.Now()
dayStr := now.Format("2006-01-02")
return dayStr
}
func GetRelDayStr(relDays int) string {
now := time.Now()
dayStr := now.AddDate(0, 0, relDays).Format("2006-01-02")
return dayStr
}
// accepts a custom format string to return a daystr
// can be either a prefix, a delta, or a prefix w/ a delta
// if no prefix is given, "today" is assumed
// examples: today-2d, bow, bom+1m-1d (that's end of the month), 2024-04-01+1w
//
// prefixes:
//
// yyyy-mm-dd
// today
// yesterday
// bom (beginning of month)
// bow (beginning of week -- sunday)
//
// deltas:
//
// +[n]d, -[n]d (e.g. +1d, -5d)
// +[n]w, -[n]w (e.g. +2w)
// +[n]m, -[n]m (e.g. -1m)
// deltas can be combined e.g. +1w-2d
func GetCustomDayStr(format string) (string, error) {
m := customDayStrRe.FindStringSubmatch(format)
if m == nil {
return "", fmt.Errorf("invalid daystr format")
}
prefix, deltas := m[1], m[2]
if prefix == "" {
prefix = "today"
}
var rtnTime time.Time
now := time.Now()
switch prefix {
case "today":
rtnTime = now
case "yesterday":
rtnTime = now.AddDate(0, 0, -1)
case "bom":
rtnTime = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
case "bow":
weekday := now.Weekday()
if weekday == time.Sunday {
rtnTime = now
} else {
rtnTime = now.AddDate(0, 0, -int(weekday))
}
default:
m = daystrRe.FindStringSubmatch(prefix)
if m == nil {
return "", fmt.Errorf("invalid prefix format")
}
year, month, day := m[1], m[2], m[3]
yearInt, monthInt, dayInt := atoiNoErr(year), atoiNoErr(month), atoiNoErr(day)
if yearInt == 0 || monthInt == 0 || dayInt == 0 {
return "", fmt.Errorf("invalid prefix format")
}
rtnTime = time.Date(yearInt, time.Month(monthInt), dayInt, 0, 0, 0, 0, now.Location())
}
for _, delta := range regexp.MustCompile(`[+-]\d+[dwm]`).FindAllString(deltas, -1) {
deltaVal, err := strconv.Atoi(delta[1 : len(delta)-1])
if err != nil {
return "", fmt.Errorf("invalid delta format")
}
if delta[0] == '-' {
deltaVal = -deltaVal
}
switch delta[len(delta)-1] {
case 'd':
rtnTime = rtnTime.AddDate(0, 0, deltaVal)
case 'w':
rtnTime = rtnTime.AddDate(0, 0, deltaVal*7)
case 'm':
rtnTime = rtnTime.AddDate(0, deltaVal, 0)
}
}
return rtnTime.Format("2006-01-02"), nil
}
func atoiNoErr(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
func UpdateFeActivityWrap(feActivity *scpacket.FeActivityPacketType) {
update := ActivityUpdate{}
for key, val := range feActivity.Activity {
if key == "aichat-open" {
update.FeAIChatOpen = val
} else if key == "history-open" {
update.FeHistoryOpen = val
} else if key == "aicmdinfo-open" {
update.FeAiCmdInfoOpen = val
} else {
log.Printf("unknown feactivity key: %s\n", key)
}
}
GoUpdateActivityWrap(update, "feactivity")
}
var customDayStrRe = regexp.MustCompile(`^((?:\d{4}-\d{2}-\d{2})|today|yesterday|bom|bow)?((?:[+-]\d+[dwm])*)$`)
var daystrRe = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})$`)
func UpdateActivity(ctx context.Context, update ActivityUpdate) error {
now := time.Now()
dayStr := GetCurDayStr()
txErr := sstore.WithTx(ctx, func(tx *sstore.TxWrap) error {
var tdata TelemetryData
query := `SELECT tdata FROM activity WHERE day = ?`
found := tx.Get(&tdata, query, dayStr)
if !found {
query = `INSERT INTO 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, scbase.WaveVersion, scbase.ClientArch(), scbase.BuildTime, scbase.UnameKernelRelease())
}
tdata.NumCommands += update.NumCommands
tdata.FgMinutes += update.FgMinutes
tdata.ActiveMinutes += update.ActiveMinutes
tdata.OpenMinutes += update.OpenMinutes
tdata.ClickShared += update.ClickShared
tdata.HistoryView += update.HistoryView
tdata.BookmarksView += update.BookmarksView
tdata.ReinitBashErrors += update.ReinitBashErrors
tdata.ReinitZshErrors += update.ReinitZshErrors
tdata.NewTab += update.NewTab
tdata.NumStartup += update.Startup
tdata.NumShutdown += update.Shutdown
tdata.NumAIChatOpen += update.FeAIChatOpen
tdata.NumHistoryOpen += update.FeHistoryOpen
if update.NumConns > 0 {
tdata.NumConns = update.NumConns
}
if update.NumWorkspaces > 0 {
tdata.NumWorkspaces = update.NumWorkspaces
}
if update.NumTabs > 0 {
tdata.NumTabs = update.NumTabs
}
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
}
}
query = `UPDATE activity
SET tdata = ?,
clientversion = ?,
buildtime = ?
WHERE day = ?`
tx.Exec(query, tdata, scbase.WaveVersion, scbase.BuildTime, dayStr)
return nil
})
if txErr != nil {
return txErr
}
return nil
}
func GetNonUploadedActivity(ctx context.Context) ([]*ActivityType, error) {
var rtn []*ActivityType
txErr := sstore.WithTx(ctx, func(tx *sstore.TxWrap) error {
query := `SELECT * FROM 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
}
// note, will not mark the current day as uploaded
func MarkActivityAsUploaded(ctx context.Context, activityArr []*ActivityType) error {
dayStr := GetCurDayStr()
txErr := sstore.WithTx(ctx, func(tx *sstore.TxWrap) error {
query := `UPDATE activity SET uploaded = 1 WHERE day = ?`
for _, activity := range activityArr {
if activity.Day == dayStr {
continue
}
tx.Exec(query, activity.Day)
}
return nil
})
return txErr
}