codersdk/appname.go
Rama main · réplica de lectura del repositorio original.
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. The VS Code family covers every
// fork, so its name sets it apart from the VS Code app.
var appFamilyDisplayNames = map[AppFamilyName]string{
AppFamilyVSCode: "VS Code Family",
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]
}
// AppFamilyDisplayNames returns a copy of every family's display name.
func AppFamilyDisplayNames() map[AppFamilyName]string {
return maps.Clone(appFamilyDisplayNames)
}
// AppNameOverflow sums the unregistered app names past the limit of 64.
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", "/icon/terminal.svg"},
"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 {
displayName, icon, family := SessionAppPresentation(appName)
apps[appName] = SessionCountApp{
Count: count,
DisplayName: displayName,
Icon: icon,
Family: family,
}
}
return apps
}
// SessionAppPresentation returns registry metadata or an unknown-family label.
func SessionAppPresentation(appName string) (displayName, icon string, family AppFamilyName) {
appName = NormalizeAppName(appName)
if app, ok := sessionApps[appName]; ok {
return app.displayName, app.icon, app.family
}
// Accounting labels are not registered apps and must not bypass app limits.
switch appName {
case string(AppFamilyUnknown):
return "Unknown", "", AppFamilyUnknown
case AppNameOverflow:
return "Other apps", "", AppFamilyUnknown
default:
return appName, "", AppFamilyUnknown
}
}
// 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
}
// 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
}
// SessionAppMethod derives a session app's method from its normalized name.
// Every reporter's app name implies its method, so none is stored.
func SessionAppMethod(appName string) ConnectionLogMethod {
switch AppFamilyName(appName) {
case AppFamilyReconnectingPTY:
return ConnectionLogMethodReconnectingPTY
case AppFamilyPortForwarding:
return ConnectionLogMethodPortForwarding
default:
return ConnectionLogMethodSSH
}
}
// FoldUnregisteredSessionApps applies the session app fold rule, which
// GetTemplateInsights mirrors in SQL. Registered apps, unknown, overflow, and
// the busiest TemplateInsightsMaxUnregisteredApps other apps keep their
// entries; the rest are added to overflow. Ties break by app name in byte
// order. It edits apps in place and returns the number of apps folded.
func FoldUnregisteredSessionApps(apps map[string]int64) int {
var unregistered []string
for app := range apps {
if _, ok := sessionApps[app]; !ok && app != string(AppFamilyUnknown) && app != AppNameOverflow {
unregistered = append(unregistered, app)
}
}
if len(unregistered) <= TemplateInsightsMaxUnregisteredApps {
return 0
}
slices.SortFunc(unregistered, func(a, b string) int {
return cmp.Or(cmp.Compare(apps[b], apps[a]), strings.Compare(a, b))
})
folded := unregistered[TemplateInsightsMaxUnregisteredApps:]
for _, app := range folded {
apps[AppNameOverflow] += apps[app]
delete(apps, app)
}
return len(folded)
}