diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 076fedd451..a78c68b96f 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -145,6 +145,18 @@ func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.T props.CountSSHConn = conncontroller.GetNumSSHHasConnected() props.CountWSLConn = wslconn.GetNumWSLHasConnected() props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) + + fullConfig := wconfig.GetWatcher().GetFullConfig() + customWidgets := fullConfig.CountCustomWidgets() + customAIPresets := fullConfig.CountCustomAIPresets() + customSettings := wconfig.CountCustomSettings() + + props.UserSet = &telemetrydata.TEventUserProps{ + SettingsCustomWidgets: customWidgets, + SettingsCustomAIPresets: customAIPresets, + SettingsCustomSettings: customSettings, + } + if utilfn.CompareAsMarshaledJson(props, lastCounts) { return lastCounts } diff --git a/docs/docs/telemetry.mdx b/docs/docs/telemetry.mdx index 81f6789bba..9689f2791e 100644 --- a/docs/docs/telemetry.mdx +++ b/docs/docs/telemetry.mdx @@ -66,7 +66,7 @@ Below is a list of the event types collected in the new telemetry system. More e | `app:shutdown` | Logged on every shutdown | | `app:activity` | Logged once per hour of app activity | | `app:display` | Logged on startup and contains information about size of displays | -| `app:counts` | Logged once per hour when app is active, contains basic counts like number of windows, tabs, workspaces, blocks, etc. | +| `app:counts` | Logged once per hour when app is active, contains basic counts like number of windows, tabs, workspaces, blocks, number of settings customizations, etc. | | `action:magnify` | Logged each time a block is magnified | | `action:settabtheme` | Logged each time a tab theme is changed | | `action:runaicmd` | Logged each time an AI request is made (no prompt information or text is sent), only sends "ai:backendtype" to know what type of AI backend is being used (OpenAI, Claude, Gemini, etc.) | diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index b2c89fe3f7..8fa74f10ca 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -824,9 +824,13 @@ declare global { "autoupdate:enabled"?: boolean; "loc:countrycode"?: string; "loc:regioncode"?: string; + "settings:customwidgets"?: number; + "settings:customaipresets"?: number; + "settings:customsettings"?: number; "activity:activeminutes"?: number; "activity:fgminutes"?: number; "activity:openminutes"?: number; + "app:firstday"?: boolean; "action:initiator"?: "keyboard" | "mouse"; "debug:panictype"?: string; "block:view"?: string; @@ -862,6 +866,9 @@ declare global { "autoupdate:enabled"?: boolean; "loc:countrycode"?: string; "loc:regioncode"?: string; + "settings:customwidgets"?: number; + "settings:customaipresets"?: number; + "settings:customsettings"?: number; }; // waveobj.Tab diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 45fbf00292..9a314b35a5 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "log" + "sync/atomic" "time" "github.com/google/uuid" @@ -18,6 +19,7 @@ import ( "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/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" @@ -26,6 +28,25 @@ import ( const MaxTzNameLen = 50 const ActivityEventName = "app:activity" +var cachedTosAgreedTs atomic.Int64 + +func GetTosAgreedTs() int64 { + cached := cachedTosAgreedTs.Load() + if cached != 0 { + return cached + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil || client == nil || client.TosAgreed == 0 { + return 0 + } + + cachedTosAgreedTs.Store(client.TosAgreed) + return client.TosAgreed +} + type ActivityType struct { Day string `json:"day"` Uploaded bool `json:"-"` @@ -135,6 +156,7 @@ func updateActivityTEvent(ctx context.Context, tevent *telemetrydata.TEvent) err 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 @@ -150,6 +172,7 @@ func updateActivityTEvent(ctx context.Context, tevent *telemetrydata.TEvent) err } } mergeActivity(&curActivity, tevent.Props) + if hasRow { query := `UPDATE db_tevent SET props = ? WHERE uuid = ?` tx.Exec(query, dbutil.QuickJson(curActivity), uuidStr) @@ -209,6 +232,13 @@ func RecordTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { return err } tevent.EnsureTimestamps() + + // Set AppFirstDay if within first day of TOS agreement + tosAgreedTs := GetTosAgreedTs() + if tosAgreedTs == 0 || (tosAgreedTs != 0 && time.Now().UnixMilli()-tosAgreedTs <= int64(24*60*60*1000)) { + tevent.Props.AppFirstDay = true + } + if tevent.Event == ActivityEventName { return updateActivityTEvent(ctx, tevent) } diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 1370a00ba8..a390e2bc0a 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -51,10 +51,16 @@ type TEventUserProps struct { ClientBuildTime string `json:"client:buildtime,omitempty"` ClientOSRelease string `json:"client:osrelease,omitempty"` ClientIsDev bool `json:"client:isdev,omitempty"` - AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` - AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` - LocCountryCode string `json:"loc:countrycode,omitempty"` - LocRegionCode string `json:"loc:regioncode,omitempty"` + + AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` + AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` + + LocCountryCode string `json:"loc:countrycode,omitempty"` + LocRegionCode string `json:"loc:regioncode,omitempty"` + + SettingsCustomWidgets int `json:"settings:customwidgets,omitempty"` + SettingsCustomAIPresets int `json:"settings:customaipresets,omitempty"` + SettingsCustomSettings int `json:"settings:customsettings,omitempty"` } type TEventProps struct { @@ -64,6 +70,8 @@ type TEventProps struct { FgMinutes int `json:"activity:fgminutes,omitempty"` OpenMinutes int `json:"activity:openminutes,omitempty"` + AppFirstDay bool `json:"app:firstday,omitempty"` + ActionInitiator string `json:"action:initiator,omitempty" tstype:"\"keyboard\" | \"mouse\""` PanicType string `json:"debug:panictype,omitempty"` BlockView string `json:"block:view,omitempty"` diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 1a8fe33c6f..e7070c990f 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -127,7 +127,7 @@ type Client struct { Version int `json:"version"` WindowIds []string `json:"windowids"` Meta MetaMapType `json:"meta"` - TosAgreed int64 `json:"tosagreed,omitempty"` + TosAgreed int64 `json:"tosagreed,omitempty"` // unix milli HasOldHistory bool `json:"hasoldhistory,omitempty"` TempOID string `json:"tempoid,omitempty"` } diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index f2016e4db8..a6822a61da 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -741,3 +741,48 @@ type TermThemeType struct { Background string `json:"background"` Cursor string `json:"cursor"` } + +// CountCustomWidgets returns the number of custom widgets the user has defined. +// Custom widgets are identified as widgets whose ID doesn't start with "defwidget@". +func (fc *FullConfigType) CountCustomWidgets() int { + count := 0 + for widgetID := range fc.Widgets { + if !strings.HasPrefix(widgetID, "defwidget@") { + count++ + } + } + return count +} + +// CountCustomAIPresets returns the number of custom AI presets the user has defined. +// Custom AI presets are identified as presets that start with "ai@" but aren't "ai@global" or "ai@wave". +func (fc *FullConfigType) CountCustomAIPresets() int { + count := 0 + for presetID := range fc.Presets { + if strings.HasPrefix(presetID, "ai@") && presetID != "ai@global" && presetID != "ai@wave" { + count++ + } + } + return count +} + +// CountCustomSettings returns the number of settings in the user's settings file. +// This excludes telemetry:enabled which doesn't count as a customization. +func CountCustomSettings() int { + // Load user settings + userSettings, _ := ReadWaveHomeConfigFile("settings.json") + if userSettings == nil { + return 0 + } + + // Count all keys except telemetry:enabled + count := 0 + for key := range userSettings { + if key == "telemetry:enabled" { + continue + } + count++ + } + + return count +} diff --git a/pkg/wcore/block.go b/pkg/wcore/block.go index 8eb720ab8e..28c5955d99 100644 --- a/pkg/wcore/block.go +++ b/pkg/wcore/block.go @@ -57,6 +57,10 @@ func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *wave } func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (rtnBlock *waveobj.Block, rtnErr error) { + return CreateBlockWithTelemetry(ctx, tabId, blockDef, rtOpts, true) +} + +func CreateBlockWithTelemetry(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts, recordTelemetry bool) (rtnBlock *waveobj.Block, rtnErr error) { var blockCreated bool var newBlockOID string defer func() { @@ -94,29 +98,33 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, } } } - go func() { - defer func() { - panichandler.PanicHandler("CreateBlock:telemetry", recover()) - }() + if recordTelemetry { blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "") - if blockView == "" { - return - } - tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) - defer cancelFn() - telemetry.UpdateActivity(tctx, wshrpc.ActivityUpdate{ - Renderers: map[string]int{blockView: 1}, - }) - telemetry.RecordTEvent(tctx, &telemetrydata.TEvent{ - Event: "action:createblock", - Props: telemetrydata.TEventProps{ - BlockView: blockView, - }, - }) - }() + go recordBlockCreationTelemetry(blockView) + } return blockData, nil } +func recordBlockCreationTelemetry(blockView string) { + defer func() { + panichandler.PanicHandler("CreateBlock:telemetry", recover()) + }() + if blockView == "" { + return + } + tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + telemetry.UpdateActivity(tctx, wshrpc.ActivityUpdate{ + Renderers: map[string]int{blockView: 1}, + }) + telemetry.RecordTEvent(tctx, &telemetrydata.TEvent{ + Event: "action:createblock", + Props: telemetrydata.TEventProps{ + BlockView: blockView, + }, + }) +} + func createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) { tab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), tabId) diff --git a/pkg/wcore/layout.go b/pkg/wcore/layout.go index 0d9a659176..63a9f2ef57 100644 --- a/pkg/wcore/layout.go +++ b/pkg/wcore/layout.go @@ -126,14 +126,13 @@ func QueueLayoutActionForTab(ctx context.Context, tabId string, actions ...waveo return QueueLayoutAction(ctx, layoutStateId, actions...) } -func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayout) error { - log.Printf("ApplyPortableLayout, tabId: %s, layout: %v\n", tabId, layout) +func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayout, recordTelemetry bool) error { actions := make([]waveobj.LayoutActionData, len(layout)+1) actions[0] = waveobj.LayoutActionData{ActionType: LayoutActionDataType_ClearTree} for i := 0; i < len(layout); i++ { layoutAction := layout[i] - blockData, err := CreateBlock(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}) + blockData, err := CreateBlockWithTelemetry(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}, recordTelemetry) if err != nil { return fmt.Errorf("unable to create block to apply portable layout to tab %s: %w", tabId, err) } @@ -184,7 +183,7 @@ func BootstrapStarterLayout(ctx context.Context) error { starterLayout := GetStarterLayout() - err = ApplyPortableLayout(ctx, tabId, starterLayout) + err = ApplyPortableLayout(ctx, tabId, starterLayout, false) if err != nil { return fmt.Errorf("error applying starter layout: %w", err) } diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index d61b2ad7b7..14f63b9523 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -141,7 +141,7 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, return false, "", fmt.Errorf("error closing tab: %w", err) } } - windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) + windowId, _ := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) err = wstore.DBDelete(ctx, waveobj.OType_Workspace, workspaceId) if err != nil { return false, "", fmt.Errorf("error deleting workspace: %w", err) @@ -221,7 +221,7 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate // No need to apply an initial layout for the initial launch, since the starter layout will get applied after TOS modal dismissal if !isInitialLaunch { - err = ApplyPortableLayout(ctx, tab.OID, GetNewTabLayout()) + err = ApplyPortableLayout(ctx, tab.OID, GetNewTabLayout(), true) if err != nil { return tab.OID, fmt.Errorf("error applying new tab layout: %w", err) }