mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
coderd_insights_applications_usage_seconds summed each family's per-app minutes, so a user with VS Code and Cursor open in the same minute was counted as two VS Code minutes. GetTemplateInsightsByTemplate now takes the registry's app-to-family mapping as jsonb, groups the raw agent stats by family, and counts each minute once per user and family. - Keep the metric's four built-in series (Visual Studio Code, JetBrains, Web Terminal, SSH) and their labels; only their values change. The HELP text now says built-in apps are grouped by family. - Add AppFamilyName.DisplayName() and remove the duplicate TemplateBuiltinAppDisplayName* constants from codersdk. The template insights handler returns the same display names. - Pin how the migration 000596 rollback approximates family minutes from per-app rows. Family values can drop wherever the old per-app sum double-counted overlapping apps. No migration or storage changes.
230 lines
8.7 KiB
Go
230 lines
8.7 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
utilstrings "github.com/coder/coder/v2/coderd/util/strings"
|
|
)
|
|
|
|
// maxAppNameLength caps an app name in runes.
|
|
const maxAppNameLength = 64
|
|
|
|
// AppFamilyName is the bounded set of groups an app name falls into, such as
|
|
// vscode or ssh. A name Coder does not recognize falls under unknown.
|
|
type AppFamilyName string
|
|
|
|
const (
|
|
AppFamilyVSCode AppFamilyName = "vscode"
|
|
AppFamilyJetBrains AppFamilyName = "jetbrains"
|
|
AppFamilySSH AppFamilyName = "ssh"
|
|
AppFamilyReconnectingPTY AppFamilyName = "reconnecting_pty"
|
|
AppFamilyPortForwarding AppFamilyName = "port_forwarding"
|
|
AppFamilySFTP AppFamilyName = "sftp"
|
|
AppFamilyUnknown AppFamilyName = "unknown"
|
|
)
|
|
|
|
// appFamilyDisplayNames names each family.
|
|
var appFamilyDisplayNames = map[AppFamilyName]string{
|
|
AppFamilyVSCode: "Visual Studio Code",
|
|
AppFamilyJetBrains: "JetBrains",
|
|
AppFamilySSH: "SSH",
|
|
AppFamilyReconnectingPTY: "Web Terminal",
|
|
AppFamilyPortForwarding: "Port Forwarding",
|
|
AppFamilySFTP: "SFTP",
|
|
AppFamilyUnknown: "Unknown",
|
|
}
|
|
|
|
// DisplayName returns the family's human-readable name.
|
|
func (f AppFamilyName) DisplayName() string {
|
|
return appFamilyDisplayNames[f]
|
|
}
|
|
|
|
// AppNameOverflow sums the app names past the per-report cap.
|
|
const AppNameOverflow = "overflow"
|
|
|
|
// SessionCountApp is one app's session count and how to present it.
|
|
type SessionCountApp struct {
|
|
Count int64 `json:"count"`
|
|
// DisplayName is the registry's name for a known app, otherwise the
|
|
// normalized identifier itself.
|
|
DisplayName string `json:"display_name"`
|
|
// Icon is a bundled path under /icon/, empty if the app has none.
|
|
Icon string `json:"icon,omitempty"`
|
|
// Family is the group this app totals under.
|
|
Family AppFamilyName `json:"family"`
|
|
}
|
|
|
|
type sessionApp struct {
|
|
family AppFamilyName
|
|
displayName string
|
|
icon string
|
|
}
|
|
|
|
// sessionApps keys attribution and presentation by normalized registry module
|
|
// ID. An unregistered ID shows as-is under AppFamilyUnknown.
|
|
var sessionApps = map[string]sessionApp{
|
|
// VS Code forks report their URI scheme, with hyphens folded.
|
|
"vscode": {AppFamilyVSCode, "VS Code", "/icon/code.svg"},
|
|
"vscode_insiders": {AppFamilyVSCode, "VS Code Insiders", "/icon/code-insiders.svg"},
|
|
"vscode_web": {AppFamilyVSCode, "VS Code Web", "/icon/code.svg"},
|
|
"code_server": {AppFamilyVSCode, "code-server", "/icon/code.svg"},
|
|
"code_oss": {AppFamilyVSCode, "Code - OSS", ""},
|
|
"cursor": {AppFamilyVSCode, "Cursor", "/icon/cursor.svg"},
|
|
// Reported by Windsurf builds from before the Devin Desktop rename.
|
|
"windsurf": {AppFamilyVSCode, "Windsurf", "/icon/windsurf.svg"},
|
|
"positron": {AppFamilyVSCode, "Positron", "/icon/positron.svg"},
|
|
"vscodium": {AppFamilyVSCode, "VSCodium", ""},
|
|
"vscodium_insiders": {AppFamilyVSCode, "VSCodium Insiders", ""},
|
|
"codium": {AppFamilyVSCode, "VSCodium", ""},
|
|
"antigravity": {AppFamilyVSCode, "Antigravity", "/icon/antigravity.svg"},
|
|
"trae": {AppFamilyVSCode, "Trae", ""},
|
|
"trae_cn": {AppFamilyVSCode, "Trae CN", ""},
|
|
"kiro": {AppFamilyVSCode, "Kiro", "/icon/kiro.svg"},
|
|
"devin": {AppFamilyVSCode, "Devin Desktop", "/icon/devin.svg"},
|
|
"devin_next": {AppFamilyVSCode, "Devin Desktop Next", "/icon/devin.svg"},
|
|
"jetbrains": {AppFamilyJetBrains, "JetBrains", "/icon/jetbrains.svg"},
|
|
// JetBrains plugins report one name per IDE, whatever its edition.
|
|
"intellij": {AppFamilyJetBrains, "IntelliJ IDEA", "/icon/intellij.svg"},
|
|
"pycharm": {AppFamilyJetBrains, "PyCharm", "/icon/pycharm.svg"},
|
|
"goland": {AppFamilyJetBrains, "GoLand", "/icon/goland.svg"},
|
|
"webstorm": {AppFamilyJetBrains, "WebStorm", "/icon/webstorm.svg"},
|
|
"phpstorm": {AppFamilyJetBrains, "PhpStorm", "/icon/phpstorm.svg"},
|
|
"rubymine": {AppFamilyJetBrains, "RubyMine", "/icon/rubymine.svg"},
|
|
"clion": {AppFamilyJetBrains, "CLion", "/icon/clion.svg"},
|
|
"rider": {AppFamilyJetBrains, "Rider", "/icon/rider.svg"},
|
|
"rustrover": {AppFamilyJetBrains, "RustRover", "/icon/rustrover.svg"},
|
|
"datagrip": {AppFamilyJetBrains, "DataGrip", "/icon/datagrip.svg"},
|
|
"dataspell": {AppFamilyJetBrains, "DataSpell", "/icon/dataspell.svg"},
|
|
"mps": {AppFamilyJetBrains, "MPS", ""},
|
|
"android_studio": {AppFamilyJetBrains, "Android Studio", "/icon/android-studio.svg"},
|
|
// Coder's own clients don't report sftp, but other clients can. The
|
|
// family also holds usage from the old sftp_mins column.
|
|
"sftp": {AppFamilySFTP, "SFTP", "/icon/terminal.svg"},
|
|
// Zed speaks SSH, so it counts toward the SSH total.
|
|
"zed": {AppFamilySSH, "Zed", "/icon/zed.svg"},
|
|
"ssh": {AppFamilySSH, "SSH", "/icon/terminal.svg"},
|
|
"reconnecting_pty": {AppFamilyReconnectingPTY, "Web Terminal", ""},
|
|
"port_forwarding": {AppFamilyPortForwarding, "Port Forwarding", ""},
|
|
}
|
|
|
|
// SessionCountApps pairs each count with its presentation, keyed by app name.
|
|
func SessionCountApps(counts map[string]int64) map[string]SessionCountApp {
|
|
apps := make(map[string]SessionCountApp, len(counts))
|
|
for appName, count := range counts {
|
|
app := sessionApps[NormalizeAppName(appName)]
|
|
apps[appName] = SessionCountApp{
|
|
Count: count,
|
|
DisplayName: cmp.Or(app.displayName, appName),
|
|
Icon: app.icon,
|
|
Family: cmp.Or(app.family, AppFamilyUnknown),
|
|
}
|
|
}
|
|
return apps
|
|
}
|
|
|
|
// SessionCountAppFamilies returns a copy of the registry's app-to-family mapping.
|
|
func SessionCountAppFamilies() map[string]AppFamilyName {
|
|
families := make(map[string]AppFamilyName, len(sessionApps))
|
|
for appName, app := range sessionApps {
|
|
families[appName] = app.family
|
|
}
|
|
return families
|
|
}
|
|
|
|
// SumByFamily totals a per-app map by family. An unregistered name totals
|
|
// under AppFamilyUnknown.
|
|
func SumByFamily(byApp map[string]int64) map[AppFamilyName]int64 {
|
|
byFamily := make(map[AppFamilyName]int64)
|
|
for appName, value := range byApp {
|
|
byFamily[AppNameFamily(appName)] += value
|
|
}
|
|
return byFamily
|
|
}
|
|
|
|
// UnionByFamily folds per-app template IDs into the distinct set each family
|
|
// was seen in, ordered by app name.
|
|
func UnionByFamily(byApp map[string][]uuid.UUID) map[AppFamilyName][]uuid.UUID {
|
|
byFamily := make(map[AppFamilyName][]uuid.UUID, len(byApp))
|
|
seen := make(map[AppFamilyName]map[uuid.UUID]struct{}, len(byApp))
|
|
for _, appName := range slices.Sorted(maps.Keys(byApp)) {
|
|
family := AppNameFamily(appName)
|
|
if seen[family] == nil {
|
|
seen[family] = map[uuid.UUID]struct{}{}
|
|
}
|
|
for _, id := range byApp[appName] {
|
|
if _, ok := seen[family][id]; ok {
|
|
continue
|
|
}
|
|
seen[family][id] = struct{}{}
|
|
byFamily[family] = append(byFamily[family], id)
|
|
}
|
|
}
|
|
return byFamily
|
|
}
|
|
|
|
// AppNameFamily normalizes an app name and returns its family, or
|
|
// AppFamilyUnknown.
|
|
func AppNameFamily(appName string) AppFamilyName {
|
|
if app, ok := sessionApps[NormalizeAppName(appName)]; ok {
|
|
return app.family
|
|
}
|
|
return AppFamilyUnknown
|
|
}
|
|
|
|
// AppDisplayName returns the registry name of appName, or the normalized name
|
|
// if unregistered.
|
|
func AppDisplayName(appName string) string {
|
|
appName = NormalizeAppName(appName)
|
|
return cmp.Or(sessionApps[appName].displayName, appName)
|
|
}
|
|
|
|
// NormalizeAppName prepares a client-supplied app name for storage and
|
|
// lookup: it strips control characters, then trims, truncates, lowercases,
|
|
// and folds hyphens to underscores. Empty becomes AppFamilyUnknown.
|
|
// Stripping control characters covers both the null bytes Postgres TEXT
|
|
// rejects and escape sequences that would otherwise reach logs and terminals.
|
|
func NormalizeAppName(appName string) string {
|
|
appName = strings.Map(func(r rune) rune {
|
|
if unicode.IsControl(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, appName)
|
|
// Trim before truncating so padding does not spend the budget, and after
|
|
// in case the cut lands in whitespace.
|
|
appName = strings.TrimSpace(appName)
|
|
appName = strings.TrimSpace(utilstrings.Truncate(appName, maxAppNameLength))
|
|
if appName == "" {
|
|
return string(AppFamilyUnknown)
|
|
}
|
|
return strings.ReplaceAll(strings.ToLower(appName), "-", "_")
|
|
}
|
|
|
|
// appMapValue is what an app-keyed JSONB payload holds.
|
|
type appMapValue interface {
|
|
int64 | []uuid.UUID
|
|
}
|
|
|
|
// DecodeAppMap decodes an app-keyed JSONB payload. An absent or SQL NULL
|
|
// payload decodes as empty; a malformed one errors rather than reading as zero.
|
|
func DecodeAppMap[V appMapValue](raw json.RawMessage) (map[string]V, error) {
|
|
var decoded map[string]V
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
return nil, xerrors.Errorf("unmarshal session app map: %w", err)
|
|
}
|
|
}
|
|
if decoded == nil {
|
|
decoded = map[string]V{}
|
|
}
|
|
return decoded, nil
|
|
}
|