From 341e22793e0f354c3b49ca956a5f2105549769fb Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 13 Aug 2026 17:52:55 +0300 Subject: [PATCH 1/4] refactor: normalize workspace agent session metadata --- agent/agent.go | 24 ++-- agent/agentssh/agentssh.go | 106 ++++++++++-------- agent/agentssh/jetbrainstrack.go | 14 ++- agent/agentssh/metrics.go | 16 +-- agent/agentssh/sessiontype_internal_test.go | 90 +++++++++++++++ coderd/idemetadata/idemetadata.go | 81 +++++++++++++ .../idemetadata/idemetadata_internal_test.go | 16 +++ coderd/idemetadata/idemetadata_test.go | 83 ++++++++++++++ 8 files changed, 353 insertions(+), 77 deletions(-) create mode 100644 agent/agentssh/sessiontype_internal_test.go create mode 100644 coderd/idemetadata/idemetadata.go create mode 100644 coderd/idemetadata/idemetadata_internal_test.go create mode 100644 coderd/idemetadata/idemetadata_test.go diff --git a/agent/agent.go b/agent/agent.go index b3a9fa6f48f62..c157a58a4f71b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -58,6 +58,7 @@ import ( "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/gitauth" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/idemetadata" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -426,17 +427,16 @@ func (a *agent) init() { BlockLocalPortForwarding: a.blockLocalPortForwarding, ReportConnection: func(id uuid.UUID, magicType agentssh.MagicSessionType, ip string) func(code int, reason string) { var connectionType proto.Connection_Type - switch magicType { - case agentssh.MagicSessionTypeSSH: + // Connection_Type is a fixed enum, stored as a database enum in + // the connection log, so it can only hold a family. + switch idemetadata.Family(string(magicType)) { + case idemetadata.AppNameSSH: connectionType = proto.Connection_SSH - case agentssh.MagicSessionTypeVSCode: + case idemetadata.AppNameVSCode: connectionType = proto.Connection_VSCODE - case agentssh.MagicSessionTypeJetBrains: + case idemetadata.AppNameJetBrains: connectionType = proto.Connection_JETBRAINS - case agentssh.MagicSessionTypeUnknown: - connectionType = proto.Connection_TYPE_UNSPECIFIED default: - a.logger.Error(a.hardCtx, "unhandled magic session type when reporting connection", slog.F("magic_type", magicType)) connectionType = proto.Connection_TYPE_UNSPECIFIED } @@ -2162,11 +2162,11 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect stats.TxPackets += int64(counts.TxPackets) } - // The count of active sessions. - sshStats := a.sshServer.ConnStats() - stats.SessionCountSsh = sshStats.Sessions - stats.SessionCountVscode = sshStats.VSCode - stats.SessionCountJetbrains = sshStats.JetBrains + // The count of active sessions; types without a protocol field are dropped. + sessionCounts := a.sshServer.SessionCounts() + stats.SessionCountSsh = sessionCounts[idemetadata.AppNameSSH] + stats.SessionCountVscode = sessionCounts[idemetadata.AppNameVSCode] + stats.SessionCountJetbrains = sessionCounts[idemetadata.AppNameJetBrains] stats.SessionCountReconnectingPty = a.reconnectingPTYServer.ConnCount() diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index eb2e9ebb6bf0d..0f7be6d60a91d 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "net" "os" "os/exec" @@ -31,6 +32,7 @@ import ( "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentrsa" "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/coderd/idemetadata" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty" ) @@ -71,17 +73,16 @@ const ( ContainerUserEnvironmentVariable = "CODER_CONTAINER_USER" ) -// MagicSessionType enums. +// Well-known magic session types, defined as canonical app names so the agent +// and server vocabularies cannot drift. const ( - // MagicSessionTypeUnknown means the session type could not be determined. - MagicSessionTypeUnknown MagicSessionType = "unknown" // MagicSessionTypeSSH is the default session type. - MagicSessionTypeSSH MagicSessionType = "ssh" + MagicSessionTypeSSH MagicSessionType = idemetadata.AppNameSSH // MagicSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself. - MagicSessionTypeVSCode MagicSessionType = "vscode" + MagicSessionTypeVSCode MagicSessionType = idemetadata.AppNameVSCode // MagicSessionTypeJetBrains is set in the SSH config by the JetBrains // extension to identify itself. - MagicSessionTypeJetBrains MagicSessionType = "jetbrains" + MagicSessionTypeJetBrains MagicSessionType = idemetadata.AppNameJetBrains ) // BlockedFileTransferCommands contains a list of restricted file transfer commands. @@ -89,6 +90,10 @@ var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"} type reportConnectionFunc func(id uuid.UUID, sessionType MagicSessionType, ip string) (disconnected func(code int, reason string)) +// startSessionFunc counts a session until endSession is called, which must +// happen exactly once. +type startSessionFunc func(sessionType MagicSessionType) (endSession func()) + // Config sets configuration parameters for the agent SSH server. type Config struct { // MaxTimeout sets the absolute connection timeout, none if empty. If set to @@ -148,6 +153,10 @@ type Server struct { // a lock on mu but protected by closing. wg sync.WaitGroup + // Kept off mu, which Close holds while closing sessions. + sessionCountsMu sync.Mutex + sessionCounts map[string]int64 + Execer agentexec.Execer logger slog.Logger srv *ssh.Server @@ -155,10 +164,6 @@ type Server struct { config *Config - connCountVSCode atomic.Int64 - connCountJetBrains atomic.Int64 - connCountSSHSession atomic.Int64 - metrics *sshServerMetrics } @@ -200,13 +205,14 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom metrics := newSSHServerMetrics(prometheusRegistry) s := &Server{ - Execer: execer, - listeners: make(map[net.Listener]struct{}), - fs: fs, - conns: make(map[net.Conn]struct{}), - sessions: make(map[ssh.Session]struct{}), - processes: make(map[*os.Process]struct{}), - logger: logger, + Execer: execer, + listeners: make(map[net.Listener]struct{}), + fs: fs, + conns: make(map[net.Conn]struct{}), + sessions: make(map[ssh.Session]struct{}), + processes: make(map[*os.Process]struct{}), + sessionCounts: make(map[string]int64), + logger: logger, config: config, @@ -232,7 +238,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom ChannelHandlers: map[string]ssh.ChannelHandler{ "direct-tcpip": func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { // Wrapper is designed to find and track JetBrains Gateway connections. - wrapped := NewJetbrainsChannelWatcher(ctx, s.logger, s.config.ReportConnection, newChan, &s.connCountJetBrains) + wrapped := NewJetbrainsChannelWatcher(ctx, s.logger, s.config.ReportConnection, newChan, s.startSession) ssh.DirectTCPIPHandler(srv, conn, wrapped, ctx) }, "direct-streamlocal@openssh.com": func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { @@ -324,18 +330,28 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom return s, nil } -type ConnStats struct { - Sessions int64 - VSCode int64 - JetBrains int64 +// startSession counts a session until the returned function is called. +func (s *Server) startSession(magicType MagicSessionType) (endSession func()) { + key := idemetadata.Normalize(string(magicType)) + s.sessionCountsMu.Lock() + defer s.sessionCountsMu.Unlock() + s.sessionCounts[key]++ + return func() { + s.sessionCountsMu.Lock() + defer s.sessionCountsMu.Unlock() + s.sessionCounts[key]-- + if s.sessionCounts[key] <= 0 { + delete(s.sessionCounts, key) + } + } } -func (s *Server) ConnStats() ConnStats { - return ConnStats{ - Sessions: s.connCountSSHSession.Load(), - VSCode: s.connCountVSCode.Load(), - JetBrains: s.connCountJetBrains.Load(), - } +// SessionCounts returns active sessions per type, excluding idle ones. Never +// nil, so callers can merge in other session sources. +func (s *Server) SessionCounts() map[string]int64 { + s.sessionCountsMu.Lock() + defer s.sessionCountsMu.Unlock() + return maps.Clone(s.sessionCounts) } func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType string, filteredEnv []string) { @@ -348,16 +364,11 @@ func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType // Keep going, we'll use the last instance of the env. } - // Always force lowercase checking to be case-insensitive. - switch MagicSessionType(strings.ToLower(rawType)) { - case MagicSessionTypeVSCode: - magicType = MagicSessionTypeVSCode - case MagicSessionTypeJetBrains: - magicType = MagicSessionTypeJetBrains - case "", MagicSessionTypeSSH: + if rawType == "" { magicType = MagicSessionTypeSSH - default: - magicType = MagicSessionTypeUnknown + } else { + // Canonicalize, don't classify: unknown names flow through. + magicType = MagicSessionType(idemetadata.Normalize(rawType)) } return magicType, rawType, slices.DeleteFunc(env, func(kv string) bool { @@ -424,6 +435,13 @@ func (s *Server) sessionHandler(session ssh.Session) { env := session.Environ() magicType, magicTypeRaw, env := extractMagicSessionType(env) + magicTypeFamily := idemetadata.Family(string(magicType)) + if magicTypeFamily == idemetadata.AppNameUnknown { + logger.Debug(ctx, "unrecognized ssh session type", + slog.F("magic_type", magicType), + slog.F("raw_type", magicTypeRaw), + ) + } // It's not safe to assume RemoteAddr() returns a non-nil value. slog.F usage is fine because it correctly // handles nil. @@ -449,19 +467,13 @@ func (s *Server) sessionHandler(session ssh.Session) { reportSession := true - switch magicType { - case MagicSessionTypeVSCode: - s.connCountVSCode.Add(1) - defer s.connCountVSCode.Add(-1) - case MagicSessionTypeJetBrains: + if magicTypeFamily == idemetadata.AppNameJetBrains { // Do nothing here because JetBrains launches hundreds of ssh sessions. // We instead track JetBrains in the single persistent tcp forwarding channel. reportSession = false - case MagicSessionTypeSSH: - s.connCountSSHSession.Add(1) - defer s.connCountSSHSession.Add(-1) - case MagicSessionTypeUnknown: - logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("raw_type", magicTypeRaw)) + } else { + endSession := s.startSession(magicType) + defer endSession() } closeCause := func(_ string) {} diff --git a/agent/agentssh/jetbrainstrack.go b/agent/agentssh/jetbrainstrack.go index 2ea54b5430649..7dde5ccf37253 100644 --- a/agent/agentssh/jetbrainstrack.go +++ b/agent/agentssh/jetbrainstrack.go @@ -7,7 +7,6 @@ import ( "github.com/gliderlabs/ssh" "github.com/google/uuid" - "go.uber.org/atomic" gossh "golang.org/x/crypto/ssh" "cdr.dev/slog/v3" @@ -26,15 +25,18 @@ type localForwardChannelData struct { // JetbrainsChannelWatcher is used to track JetBrains port forwarded (Gateway) // channels. If the port forward is something other than JetBrains, this struct // is a noop. +// +// Sessions are counted here, not in sessionHandler: JetBrains opens hundreds +// of ssh sessions but only one persistent forwarded channel. type JetbrainsChannelWatcher struct { gossh.NewChannel - jetbrainsCounter *atomic.Int64 + startSession startSessionFunc logger slog.Logger originAddr string reportConnection reportConnectionFunc } -func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConnection reportConnectionFunc, newChannel gossh.NewChannel, counter *atomic.Int64) gossh.NewChannel { +func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConnection reportConnectionFunc, newChannel gossh.NewChannel, startSession startSessionFunc) gossh.NewChannel { d := localForwardChannelData{} if err := gossh.Unmarshal(newChannel.ExtraData(), &d); err != nil { // If the data fails to unmarshal, do nothing. @@ -63,7 +65,7 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConne return &JetbrainsChannelWatcher{ NewChannel: newChannel, - jetbrainsCounter: counter, + startSession: startSession, logger: logger.With(slog.F("destination_port", d.DestPort)), originAddr: d.OriginAddr, reportConnection: reportConnection, @@ -78,14 +80,14 @@ func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request disconnected(1, err.Error()) return c, r, err } - w.jetbrainsCounter.Add(1) + endSession := w.startSession(MagicSessionTypeJetBrains) // nolint: gocritic // JetBrains is a proper noun and should be capitalized w.logger.Debug(context.Background(), "JetBrains watcher accepted channel") return &ChannelOnClose{ Channel: c, done: func() { - w.jetbrainsCounter.Add(-1) + endSession() disconnected(0, "normal close") // nolint: gocritic // JetBrains is a proper noun and should be capitalized w.logger.Debug(context.Background(), "JetBrains channel closed", diff --git a/agent/agentssh/metrics.go b/agent/agentssh/metrics.go index 22bbf1fd80743..a6175ab354a6a 100644 --- a/agent/agentssh/metrics.go +++ b/agent/agentssh/metrics.go @@ -1,9 +1,9 @@ package agentssh import ( - "strings" - "github.com/prometheus/client_golang/prometheus" + + "github.com/coder/coder/v2/coderd/idemetadata" ) type sshServerMetrics struct { @@ -72,14 +72,6 @@ func newSSHServerMetrics(registerer prometheus.Registerer) *sshServerMetrics { } func magicTypeMetricLabel(magicType MagicSessionType) string { - switch magicType { - case MagicSessionTypeVSCode: - case MagicSessionTypeJetBrains: - case MagicSessionTypeSSH: - case MagicSessionTypeUnknown: - default: - magicType = MagicSessionTypeUnknown - } - // Always be case insensitive - return strings.ToLower(string(magicType)) + // Family, not raw type, so cardinality stays bounded. + return idemetadata.Family(string(magicType)) } diff --git a/agent/agentssh/sessiontype_internal_test.go b/agent/agentssh/sessiontype_internal_test.go new file mode 100644 index 0000000000000..222791105a7d7 --- /dev/null +++ b/agent/agentssh/sessiontype_internal_test.go @@ -0,0 +1,90 @@ +package agentssh + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/idemetadata" +) + +func TestExtractMagicSessionType(t *testing.T) { + t.Parallel() + + envWith := func(value string) []string { + return []string{ + "FOO=bar", + fmt.Sprintf("%s=%s", MagicSessionTypeEnvironmentVariable, value), + "BAZ=qux", + } + } + + for _, tc := range []struct { + name string + env []string + want MagicSessionType + }{ + {"NoEnvDefaultsToSSH", []string{"FOO=bar"}, MagicSessionTypeSSH}, + {"EmptyValueDefaultsToSSH", envWith(""), MagicSessionTypeSSH}, + {"VSCode", envWith("vscode"), MagicSessionTypeVSCode}, + {"JetBrainsLegacyCasing", envWith("JetBrains"), MagicSessionTypeJetBrains}, + {"UnknownTypeCanonicalized", envWith("Cursor-Nightly"), MagicSessionType("cursor_nightly")}, + {"LastInstanceWins", append(envWith("vscode"), MagicSessionTypeEnvironmentVariable+"=cursor"), MagicSessionType("cursor")}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + sessionType, _, filteredEnv := extractMagicSessionType(tc.env) + require.Equal(t, tc.want, sessionType) + for _, kv := range filteredEnv { + require.NotContains(t, kv, MagicSessionTypeEnvironmentVariable+"=") + } + }) + } +} + +func TestMagicTypeMetricLabel(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + magicType MagicSessionType + want string + }{ + {"SSH", MagicSessionTypeSSH, idemetadata.AppNameSSH}, + {"ForkUsesFamily", MagicSessionType("cursor"), idemetadata.AppNameVSCode}, + // No alias for the nightly variant, so it does not get a label. + {"UnlistedVariant", MagicSessionType("cursor_nightly"), idemetadata.AppNameUnknown}, + {"Unknown", MagicSessionType("some_new_ide"), idemetadata.AppNameUnknown}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, magicTypeMetricLabel(tc.magicType)) + }) + } +} + +func TestSessionCounts(t *testing.T) { + t.Parallel() + + s := &Server{sessionCounts: make(map[string]int64)} + + // Counters are created on demand and names are canonicalized. + _ = s.startSession(MagicSessionTypeSSH) + _ = s.startSession(MagicSessionTypeVSCode) + endCursor1 := s.startSession(MagicSessionType("Cursor")) + endCursor2 := s.startSession(MagicSessionType("cursor")) + require.Equal(t, map[string]int64{ + idemetadata.AppNameSSH: 1, + idemetadata.AppNameVSCode: 1, + "cursor": 2, + }, s.SessionCounts()) + + // Zero-count entries are dropped, not reported as idle apps. + endCursor1() + endCursor2() + require.Equal(t, map[string]int64{ + idemetadata.AppNameSSH: 1, + idemetadata.AppNameVSCode: 1, + }, s.SessionCounts()) +} diff --git a/coderd/idemetadata/idemetadata.go b/coderd/idemetadata/idemetadata.go new file mode 100644 index 0000000000000..e9298a49a2702 --- /dev/null +++ b/coderd/idemetadata/idemetadata.go @@ -0,0 +1,81 @@ +// Package idemetadata defines the app names and family grouping used in +// workspace usage reporting. It is a leaf package importable by both agent +// and coderd code. +package idemetadata + +import ( + "strings" + + utilstrings "github.com/coder/coder/v2/coderd/util/strings" +) + +// MaxAppNameLength is the maximum app name length in runes. +const MaxAppNameLength = 64 + +// MaxSessionCountEntries bounds distinct app names per stats report; +// overflow aggregates under AppNameUnknown. +const MaxSessionCountEntries = 64 + +// Canonical app names for Coder's built-in session types. +const ( + AppNameVSCode = "vscode" + AppNameJetBrains = "jetbrains" + AppNameZed = "zed" + AppNameSSH = "ssh" + AppNameReconnectingPTY = "reconnecting_pty" + AppNameUnknown = "unknown" +) + +// families groups app names for metric labels and the Connection_Type enum, +// never for storage, so a missing alias only costs an AppNameUnknown label. +// Names are the ids Coder's registry modules use. +var families = map[string]string{ + AppNameVSCode: AppNameVSCode, + "vscode_insiders": AppNameVSCode, + "vscode_web": AppNameVSCode, + "code_server": AppNameVSCode, + "cursor": AppNameVSCode, + "windsurf": AppNameVSCode, + "positron": AppNameVSCode, + "vscodium": AppNameVSCode, + "codium": AppNameVSCode, + "antigravity": AppNameVSCode, + "trae": AppNameVSCode, + "kiro": AppNameVSCode, + "devin": AppNameVSCode, + + AppNameJetBrains: AppNameJetBrains, + // Zed connects over plain SSH, so its sessions belong in the SSH bucket + // until a durable per-app rollup exists. + AppNameZed: AppNameSSH, + AppNameSSH: AppNameSSH, + AppNameReconnectingPTY: AppNameReconnectingPTY, +} + +// Family returns the family for an app name, or AppNameUnknown. Matching is +// case-insensitive and alias-aware. +func Family(appName string) string { + if family, ok := families[canonicalKey(appName)]; ok { + return family + } + return AppNameUnknown +} + +// Normalize prepares a client-supplied app name for storage, stripping null +// bytes that Postgres TEXT rejects. An empty name becomes AppNameUnknown; an +// unset session type instead means plain SSH, which callers resolve first. +func Normalize(appName string) string { + appName = strings.ReplaceAll(appName, "\x00", "") + // 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 AppNameUnknown + } + return canonicalKey(appName) +} + +func canonicalKey(appName string) string { + return strings.ReplaceAll(strings.ToLower(appName), "-", "_") +} diff --git a/coderd/idemetadata/idemetadata_internal_test.go b/coderd/idemetadata/idemetadata_internal_test.go new file mode 100644 index 0000000000000..ea391492c7779 --- /dev/null +++ b/coderd/idemetadata/idemetadata_internal_test.go @@ -0,0 +1,16 @@ +package idemetadata + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Lookups fold input through canonicalKey before probing, so map keys that +// are not already canonical can never match. +func TestFamilyKeysAreCanonical(t *testing.T) { + t.Parallel() + for name := range families { + require.Equal(t, canonicalKey(name), name) + } +} diff --git a/coderd/idemetadata/idemetadata_test.go b/coderd/idemetadata/idemetadata_test.go new file mode 100644 index 0000000000000..f95dbb42b6522 --- /dev/null +++ b/coderd/idemetadata/idemetadata_test.go @@ -0,0 +1,83 @@ +package idemetadata_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/idemetadata" + "github.com/coder/coder/v2/codersdk" +) + +func TestNormalize(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + want string + }{ + {"CanonicalPassthrough", "vscode", "vscode"}, + {"KnownNameCaseInsensitive", "JetBrains", "jetbrains"}, + {"LegacyAlias", "reconnecting-pty", "reconnecting_pty"}, + {"HyphenatedKnownName", "vscode-insiders", "vscode_insiders"}, + {"UnknownHyphensFolded", "my-future-ide", "my_future_ide"}, + {"UnknownLowercased", "Cursor Nightly", "cursor nightly"}, + {"UnknownPreservesUnicode", "エディタ", "エディタ"}, + {"StripsNullBytes", "cur\x00sor", "cursor"}, + {"TrimsWhitespace", " vscode\t", "vscode"}, + {"Empty", "", "unknown"}, + {"OnlyNullBytes", "\x00\x00", "unknown"}, + {"OnlyWhitespace", " ", "unknown"}, + {"TruncatesToMaxRunes", strings.Repeat("a", idemetadata.MaxAppNameLength+10), strings.Repeat("a", idemetadata.MaxAppNameLength)}, + {"TruncatesMultibyteSafely", strings.Repeat("あ", idemetadata.MaxAppNameLength+1), strings.Repeat("あ", idemetadata.MaxAppNameLength)}, + // Padding is trimmed before truncating, so it does not spend the budget. + {"TrimsPaddingBeforeTruncating", " " + strings.Repeat("a", idemetadata.MaxAppNameLength) + " ", strings.Repeat("a", idemetadata.MaxAppNameLength)}, + {"TrimsWhitespaceLeftByTruncation", strings.Repeat("a", 60) + " tail", strings.Repeat("a", 60)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, idemetadata.Normalize(tc.input)) + }) + } +} + +// The codersdk usage-app vocabulary is declared independently because +// codersdk cannot import coderd packages. This pins it to the canonical +// names so the two cannot drift. +func TestCodersdkUsageAppNamesAreCanonical(t *testing.T) { + t.Parallel() + + for sdkName, want := range map[codersdk.UsageAppName]string{ + codersdk.UsageAppNameVscode: idemetadata.AppNameVSCode, + codersdk.UsageAppNameJetbrains: idemetadata.AppNameJetBrains, + codersdk.UsageAppNameReconnectingPty: idemetadata.AppNameReconnectingPTY, + codersdk.UsageAppNameSSH: idemetadata.AppNameSSH, + } { + require.Equal(t, want, idemetadata.Normalize(string(sdkName))) + } +} + +func TestFamily(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + want string + }{ + {"VSCode", "vscode", idemetadata.AppNameVSCode}, + {"VSCodeFork", "cursor", idemetadata.AppNameVSCode}, + {"VSCodeInsidersHyphenated", "vscode-insiders", idemetadata.AppNameVSCode}, + {"CaseInsensitive", "JetBrains", idemetadata.AppNameJetBrains}, + {"SSHClientJoinsSSHFamily", "zed", idemetadata.AppNameSSH}, + {"Alias", "reconnecting-pty", idemetadata.AppNameReconnectingPTY}, + {"Unknown", "SomeFutureIDE", idemetadata.AppNameUnknown}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, idemetadata.Family(tc.input)) + }) + } +} From 211489d506e3bc172fc0a66dd1d4d1a0126fb09f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 20 Aug 2026 14:23:24 +0300 Subject: [PATCH 2/4] refactor: move session app name vocabulary into codersdk Address review on #28124: the app name vocabulary now lives next to the UsageAppName enum it must not drift from, so agent, coderd, and cli share one declaration. - Move coderd/idemetadata to codersdk/appname.go as NormalizeAppName and AppNameFamily. - Fold canonicalKey into NormalizeAppName so there is one notion of a normalized name, and normalize before family lookup. - Drop MagicSessionType and its constants; sessions carry plain app names. - Drop AppNameZed, which is a leaf name rather than a family. --- agent/agent.go | 17 ++-- agent/agent_test.go | 4 +- agent/agentssh/agentssh.go | 47 ++++------- agent/agentssh/jetbrainstrack.go | 4 +- agent/agentssh/metrics.go | 6 +- agent/agentssh/sessiontype_internal_test.go | 46 +++++----- .../idemetadata/idemetadata_internal_test.go | 16 ---- coderd/idemetadata/idemetadata_test.go | 83 ------------------- .../idemetadata.go => codersdk/appname.go | 52 ++++++------ codersdk/appname_internal_test.go | 16 ++++ codersdk/appname_test.go | 82 ++++++++++++++++++ 11 files changed, 176 insertions(+), 197 deletions(-) delete mode 100644 coderd/idemetadata/idemetadata_internal_test.go delete mode 100644 coderd/idemetadata/idemetadata_test.go rename coderd/idemetadata/idemetadata.go => codersdk/appname.go (51%) create mode 100644 codersdk/appname_internal_test.go create mode 100644 codersdk/appname_test.go diff --git a/agent/agent.go b/agent/agent.go index c157a58a4f71b..facee443883fa 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -58,7 +58,6 @@ import ( "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/gitauth" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/idemetadata" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -425,16 +424,16 @@ func (a *agent) init() { BlockFileTransfer: a.blockFileTransfer, BlockReversePortForwarding: a.blockReversePortForwarding, BlockLocalPortForwarding: a.blockLocalPortForwarding, - ReportConnection: func(id uuid.UUID, magicType agentssh.MagicSessionType, ip string) func(code int, reason string) { + ReportConnection: func(id uuid.UUID, magicType string, ip string) func(code int, reason string) { var connectionType proto.Connection_Type // Connection_Type is a fixed enum, stored as a database enum in // the connection log, so it can only hold a family. - switch idemetadata.Family(string(magicType)) { - case idemetadata.AppNameSSH: + switch codersdk.AppNameFamily(magicType) { + case codersdk.AppNameSSH: connectionType = proto.Connection_SSH - case idemetadata.AppNameVSCode: + case codersdk.AppNameVSCode: connectionType = proto.Connection_VSCODE - case idemetadata.AppNameJetBrains: + case codersdk.AppNameJetBrains: connectionType = proto.Connection_JETBRAINS default: connectionType = proto.Connection_TYPE_UNSPECIFIED @@ -2164,9 +2163,9 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect // The count of active sessions; types without a protocol field are dropped. sessionCounts := a.sshServer.SessionCounts() - stats.SessionCountSsh = sessionCounts[idemetadata.AppNameSSH] - stats.SessionCountVscode = sessionCounts[idemetadata.AppNameVSCode] - stats.SessionCountJetbrains = sessionCounts[idemetadata.AppNameJetBrains] + stats.SessionCountSsh = sessionCounts[codersdk.AppNameSSH] + stats.SessionCountVscode = sessionCounts[codersdk.AppNameVSCode] + stats.SessionCountJetbrains = sessionCounts[codersdk.AppNameJetBrains] stats.SessionCountReconnectingPty = a.reconnectingPTYServer.ConnCount() diff --git a/agent/agent_test.go b/agent/agent_test.go index 908ce0ed3d8c6..b27684d099f7c 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -310,7 +310,7 @@ func TestAgent_Stats_Magic(t *testing.T) { defer sshClient.Close() session, err := sshClient.NewSession() require.NoError(t, err) - session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(agentssh.MagicSessionTypeVSCode)) + session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, codersdk.AppNameVSCode) defer session.Close() command := "sh -c 'echo $" + agentssh.MagicSessionTypeEnvironmentVariable + "'" @@ -338,7 +338,7 @@ func TestAgent_Stats_Magic(t *testing.T) { defer sshClient.Close() session, err := sshClient.NewSession() require.NoError(t, err) - session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(agentssh.MagicSessionTypeVSCode)) + session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, codersdk.AppNameVSCode) defer session.Close() stdin, err := session.StdinPipe() require.NoError(t, err) diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 0f7be6d60a91d..255b02afb24e8 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -32,7 +32,6 @@ import ( "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentrsa" "github.com/coder/coder/v2/agent/usershell" - "github.com/coder/coder/v2/coderd/idemetadata" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty" ) @@ -55,10 +54,6 @@ const ( BlockedFileTransferErrorMessage = "File transfer has been disabled." ) -// MagicSessionType is a type that represents the type of session that is being -// established. -type MagicSessionType string - const ( // MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection. // This is stripped from any commands being executed, and is counted towards connection stats. @@ -73,26 +68,14 @@ const ( ContainerUserEnvironmentVariable = "CODER_CONTAINER_USER" ) -// Well-known magic session types, defined as canonical app names so the agent -// and server vocabularies cannot drift. -const ( - // MagicSessionTypeSSH is the default session type. - MagicSessionTypeSSH MagicSessionType = idemetadata.AppNameSSH - // MagicSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself. - MagicSessionTypeVSCode MagicSessionType = idemetadata.AppNameVSCode - // MagicSessionTypeJetBrains is set in the SSH config by the JetBrains - // extension to identify itself. - MagicSessionTypeJetBrains MagicSessionType = idemetadata.AppNameJetBrains -) - // BlockedFileTransferCommands contains a list of restricted file transfer commands. var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"} -type reportConnectionFunc func(id uuid.UUID, sessionType MagicSessionType, ip string) (disconnected func(code int, reason string)) +type reportConnectionFunc func(id uuid.UUID, sessionType string, ip string) (disconnected func(code int, reason string)) // startSessionFunc counts a session until endSession is called, which must // happen exactly once. -type startSessionFunc func(sessionType MagicSessionType) (endSession func()) +type startSessionFunc func(sessionType string) (endSession func()) // Config sets configuration parameters for the agent SSH server. type Config struct { @@ -197,7 +180,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom config.EnvInfo = &usershell.SystemEnvInfo{} } if config.ReportConnection == nil { - config.ReportConnection = func(uuid.UUID, MagicSessionType, string) func(int, string) { return func(int, string) {} } + config.ReportConnection = func(uuid.UUID, string, string) func(int, string) { return func(int, string) {} } } forwardHandler := &ssh.ForwardedTCPHandler{} @@ -331,8 +314,8 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom } // startSession counts a session until the returned function is called. -func (s *Server) startSession(magicType MagicSessionType) (endSession func()) { - key := idemetadata.Normalize(string(magicType)) +func (s *Server) startSession(magicType string) (endSession func()) { + key := codersdk.NormalizeAppName(magicType) s.sessionCountsMu.Lock() defer s.sessionCountsMu.Unlock() s.sessionCounts[key]++ @@ -346,15 +329,15 @@ func (s *Server) startSession(magicType MagicSessionType) (endSession func()) { } } -// SessionCounts returns active sessions per type, excluding idle ones. Never -// nil, so callers can merge in other session sources. +// SessionCounts returns active sessions per app name, omitting names at +// zero. Never nil, so callers can merge in other session sources. func (s *Server) SessionCounts() map[string]int64 { s.sessionCountsMu.Lock() defer s.sessionCountsMu.Unlock() return maps.Clone(s.sessionCounts) } -func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType string, filteredEnv []string) { +func extractMagicSessionType(env []string) (magicType, rawType string, filteredEnv []string) { for _, kv := range env { if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) { continue @@ -365,10 +348,10 @@ func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType } if rawType == "" { - magicType = MagicSessionTypeSSH + magicType = codersdk.AppNameSSH } else { - // Canonicalize, don't classify: unknown names flow through. - magicType = MagicSessionType(idemetadata.Normalize(rawType)) + // Normalize, don't classify: unknown names flow through. + magicType = codersdk.NormalizeAppName(rawType) } return magicType, rawType, slices.DeleteFunc(env, func(kv string) bool { @@ -435,8 +418,8 @@ func (s *Server) sessionHandler(session ssh.Session) { env := session.Environ() magicType, magicTypeRaw, env := extractMagicSessionType(env) - magicTypeFamily := idemetadata.Family(string(magicType)) - if magicTypeFamily == idemetadata.AppNameUnknown { + magicTypeFamily := codersdk.AppNameFamily(magicType) + if magicTypeFamily == codersdk.AppNameUnknown { logger.Debug(ctx, "unrecognized ssh session type", slog.F("magic_type", magicType), slog.F("raw_type", magicTypeRaw), @@ -467,7 +450,7 @@ func (s *Server) sessionHandler(session ssh.Session) { reportSession := true - if magicTypeFamily == idemetadata.AppNameJetBrains { + if magicTypeFamily == codersdk.AppNameJetBrains { // Do nothing here because JetBrains launches hundreds of ssh sessions. // We instead track JetBrains in the single persistent tcp forwarding channel. reportSession = false @@ -623,7 +606,7 @@ func (s *Server) fileTransferBlocked(session ssh.Session) bool { return false } -func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType, container, containerUser string) (retErr error) { +func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType, container, containerUser string) (retErr error) { ctx := session.Context() magicTypeLabel := magicTypeMetricLabel(magicType) diff --git a/agent/agentssh/jetbrainstrack.go b/agent/agentssh/jetbrainstrack.go index 7dde5ccf37253..224179d07880e 100644 --- a/agent/agentssh/jetbrainstrack.go +++ b/agent/agentssh/jetbrainstrack.go @@ -73,14 +73,14 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConne } func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request, error) { - disconnected := w.reportConnection(uuid.New(), MagicSessionTypeJetBrains, w.originAddr) + disconnected := w.reportConnection(uuid.New(), codersdk.AppNameJetBrains, w.originAddr) c, r, err := w.NewChannel.Accept() if err != nil { disconnected(1, err.Error()) return c, r, err } - endSession := w.startSession(MagicSessionTypeJetBrains) + endSession := w.startSession(codersdk.AppNameJetBrains) // nolint: gocritic // JetBrains is a proper noun and should be capitalized w.logger.Debug(context.Background(), "JetBrains watcher accepted channel") diff --git a/agent/agentssh/metrics.go b/agent/agentssh/metrics.go index a6175ab354a6a..474238ca9a83c 100644 --- a/agent/agentssh/metrics.go +++ b/agent/agentssh/metrics.go @@ -3,7 +3,7 @@ package agentssh import ( "github.com/prometheus/client_golang/prometheus" - "github.com/coder/coder/v2/coderd/idemetadata" + "github.com/coder/coder/v2/codersdk" ) type sshServerMetrics struct { @@ -71,7 +71,7 @@ func newSSHServerMetrics(registerer prometheus.Registerer) *sshServerMetrics { } } -func magicTypeMetricLabel(magicType MagicSessionType) string { +func magicTypeMetricLabel(magicType string) string { // Family, not raw type, so cardinality stays bounded. - return idemetadata.Family(string(magicType)) + return codersdk.AppNameFamily(magicType) } diff --git a/agent/agentssh/sessiontype_internal_test.go b/agent/agentssh/sessiontype_internal_test.go index 222791105a7d7..e5111a5c5bf78 100644 --- a/agent/agentssh/sessiontype_internal_test.go +++ b/agent/agentssh/sessiontype_internal_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/coderd/idemetadata" + "github.com/coder/coder/v2/codersdk" ) func TestExtractMagicSessionType(t *testing.T) { @@ -23,14 +23,14 @@ func TestExtractMagicSessionType(t *testing.T) { for _, tc := range []struct { name string env []string - want MagicSessionType + want string }{ - {"NoEnvDefaultsToSSH", []string{"FOO=bar"}, MagicSessionTypeSSH}, - {"EmptyValueDefaultsToSSH", envWith(""), MagicSessionTypeSSH}, - {"VSCode", envWith("vscode"), MagicSessionTypeVSCode}, - {"JetBrainsLegacyCasing", envWith("JetBrains"), MagicSessionTypeJetBrains}, - {"UnknownTypeCanonicalized", envWith("Cursor-Nightly"), MagicSessionType("cursor_nightly")}, - {"LastInstanceWins", append(envWith("vscode"), MagicSessionTypeEnvironmentVariable+"=cursor"), MagicSessionType("cursor")}, + {"NoEnvDefaultsToSSH", []string{"FOO=bar"}, codersdk.AppNameSSH}, + {"EmptyValueDefaultsToSSH", envWith(""), codersdk.AppNameSSH}, + {"VSCode", envWith("vscode"), codersdk.AppNameVSCode}, + {"JetBrainsLegacyCasing", envWith("JetBrains"), codersdk.AppNameJetBrains}, + {"UnknownTypeNormalized", envWith("Cursor-Nightly"), ("cursor_nightly")}, + {"LastInstanceWins", append(envWith("vscode"), MagicSessionTypeEnvironmentVariable+"=cursor"), ("cursor")}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -48,14 +48,14 @@ func TestMagicTypeMetricLabel(t *testing.T) { for _, tc := range []struct { name string - magicType MagicSessionType + magicType string want string }{ - {"SSH", MagicSessionTypeSSH, idemetadata.AppNameSSH}, - {"ForkUsesFamily", MagicSessionType("cursor"), idemetadata.AppNameVSCode}, + {"SSH", codersdk.AppNameSSH, codersdk.AppNameSSH}, + {"ForkUsesFamily", ("cursor"), codersdk.AppNameVSCode}, // No alias for the nightly variant, so it does not get a label. - {"UnlistedVariant", MagicSessionType("cursor_nightly"), idemetadata.AppNameUnknown}, - {"Unknown", MagicSessionType("some_new_ide"), idemetadata.AppNameUnknown}, + {"UnlistedVariant", ("cursor_nightly"), codersdk.AppNameUnknown}, + {"Unknown", ("some_new_ide"), codersdk.AppNameUnknown}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -69,22 +69,22 @@ func TestSessionCounts(t *testing.T) { s := &Server{sessionCounts: make(map[string]int64)} - // Counters are created on demand and names are canonicalized. - _ = s.startSession(MagicSessionTypeSSH) - _ = s.startSession(MagicSessionTypeVSCode) - endCursor1 := s.startSession(MagicSessionType("Cursor")) - endCursor2 := s.startSession(MagicSessionType("cursor")) + // Counters are created on demand and names are normalized. + _ = s.startSession(codersdk.AppNameSSH) + _ = s.startSession(codersdk.AppNameVSCode) + endCursor1 := s.startSession(("Cursor")) + endCursor2 := s.startSession(("cursor")) require.Equal(t, map[string]int64{ - idemetadata.AppNameSSH: 1, - idemetadata.AppNameVSCode: 1, - "cursor": 2, + codersdk.AppNameSSH: 1, + codersdk.AppNameVSCode: 1, + "cursor": 2, }, s.SessionCounts()) // Zero-count entries are dropped, not reported as idle apps. endCursor1() endCursor2() require.Equal(t, map[string]int64{ - idemetadata.AppNameSSH: 1, - idemetadata.AppNameVSCode: 1, + codersdk.AppNameSSH: 1, + codersdk.AppNameVSCode: 1, }, s.SessionCounts()) } diff --git a/coderd/idemetadata/idemetadata_internal_test.go b/coderd/idemetadata/idemetadata_internal_test.go deleted file mode 100644 index ea391492c7779..0000000000000 --- a/coderd/idemetadata/idemetadata_internal_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package idemetadata - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -// Lookups fold input through canonicalKey before probing, so map keys that -// are not already canonical can never match. -func TestFamilyKeysAreCanonical(t *testing.T) { - t.Parallel() - for name := range families { - require.Equal(t, canonicalKey(name), name) - } -} diff --git a/coderd/idemetadata/idemetadata_test.go b/coderd/idemetadata/idemetadata_test.go deleted file mode 100644 index f95dbb42b6522..0000000000000 --- a/coderd/idemetadata/idemetadata_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package idemetadata_test - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/idemetadata" - "github.com/coder/coder/v2/codersdk" -) - -func TestNormalize(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - name string - input string - want string - }{ - {"CanonicalPassthrough", "vscode", "vscode"}, - {"KnownNameCaseInsensitive", "JetBrains", "jetbrains"}, - {"LegacyAlias", "reconnecting-pty", "reconnecting_pty"}, - {"HyphenatedKnownName", "vscode-insiders", "vscode_insiders"}, - {"UnknownHyphensFolded", "my-future-ide", "my_future_ide"}, - {"UnknownLowercased", "Cursor Nightly", "cursor nightly"}, - {"UnknownPreservesUnicode", "エディタ", "エディタ"}, - {"StripsNullBytes", "cur\x00sor", "cursor"}, - {"TrimsWhitespace", " vscode\t", "vscode"}, - {"Empty", "", "unknown"}, - {"OnlyNullBytes", "\x00\x00", "unknown"}, - {"OnlyWhitespace", " ", "unknown"}, - {"TruncatesToMaxRunes", strings.Repeat("a", idemetadata.MaxAppNameLength+10), strings.Repeat("a", idemetadata.MaxAppNameLength)}, - {"TruncatesMultibyteSafely", strings.Repeat("あ", idemetadata.MaxAppNameLength+1), strings.Repeat("あ", idemetadata.MaxAppNameLength)}, - // Padding is trimmed before truncating, so it does not spend the budget. - {"TrimsPaddingBeforeTruncating", " " + strings.Repeat("a", idemetadata.MaxAppNameLength) + " ", strings.Repeat("a", idemetadata.MaxAppNameLength)}, - {"TrimsWhitespaceLeftByTruncation", strings.Repeat("a", 60) + " tail", strings.Repeat("a", 60)}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tc.want, idemetadata.Normalize(tc.input)) - }) - } -} - -// The codersdk usage-app vocabulary is declared independently because -// codersdk cannot import coderd packages. This pins it to the canonical -// names so the two cannot drift. -func TestCodersdkUsageAppNamesAreCanonical(t *testing.T) { - t.Parallel() - - for sdkName, want := range map[codersdk.UsageAppName]string{ - codersdk.UsageAppNameVscode: idemetadata.AppNameVSCode, - codersdk.UsageAppNameJetbrains: idemetadata.AppNameJetBrains, - codersdk.UsageAppNameReconnectingPty: idemetadata.AppNameReconnectingPTY, - codersdk.UsageAppNameSSH: idemetadata.AppNameSSH, - } { - require.Equal(t, want, idemetadata.Normalize(string(sdkName))) - } -} - -func TestFamily(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - name string - input string - want string - }{ - {"VSCode", "vscode", idemetadata.AppNameVSCode}, - {"VSCodeFork", "cursor", idemetadata.AppNameVSCode}, - {"VSCodeInsidersHyphenated", "vscode-insiders", idemetadata.AppNameVSCode}, - {"CaseInsensitive", "JetBrains", idemetadata.AppNameJetBrains}, - {"SSHClientJoinsSSHFamily", "zed", idemetadata.AppNameSSH}, - {"Alias", "reconnecting-pty", idemetadata.AppNameReconnectingPTY}, - {"Unknown", "SomeFutureIDE", idemetadata.AppNameUnknown}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tc.want, idemetadata.Family(tc.input)) - }) - } -} diff --git a/coderd/idemetadata/idemetadata.go b/codersdk/appname.go similarity index 51% rename from coderd/idemetadata/idemetadata.go rename to codersdk/appname.go index e9298a49a2702..ccf768a0990b6 100644 --- a/coderd/idemetadata/idemetadata.go +++ b/codersdk/appname.go @@ -1,7 +1,4 @@ -// Package idemetadata defines the app names and family grouping used in -// workspace usage reporting. It is a leaf package importable by both agent -// and coderd code. -package idemetadata +package codersdk import ( "strings" @@ -9,27 +6,30 @@ import ( utilstrings "github.com/coder/coder/v2/coderd/util/strings" ) +// Server-side vocabulary, not part of the HTTP API. +// @typescript-ignore MaxAppNameLength, MaxSessionCountEntries, AppNameVSCode, AppNameJetBrains, AppNameSSH, AppNameReconnectingPTY, AppNameUnknown + // MaxAppNameLength is the maximum app name length in runes. const MaxAppNameLength = 64 -// MaxSessionCountEntries bounds distinct app names per stats report; -// overflow aggregates under AppNameUnknown. +// MaxSessionCountEntries bounds distinct app names per stats report. +// Overflow aggregates under AppNameUnknown. const MaxSessionCountEntries = 64 -// Canonical app names for Coder's built-in session types. +// Normalized app names for Coder's built-in session types. const ( AppNameVSCode = "vscode" AppNameJetBrains = "jetbrains" - AppNameZed = "zed" AppNameSSH = "ssh" AppNameReconnectingPTY = "reconnecting_pty" AppNameUnknown = "unknown" ) -// families groups app names for metric labels and the Connection_Type enum, -// never for storage, so a missing alias only costs an AppNameUnknown label. -// Names are the ids Coder's registry modules use. -var families = map[string]string{ +// appNameFamilies groups app names for callers that need a bounded value, +// such as a metric label or the Connection_Type enum. It is never used for +// storage, so a missing alias only costs an AppNameUnknown label. Keys are +// the IDs Coder's registry modules use. +var appNameFamilies = map[string]string{ AppNameVSCode: AppNameVSCode, "vscode_insiders": AppNameVSCode, "vscode_web": AppNameVSCode, @@ -45,26 +45,28 @@ var families = map[string]string{ "devin": AppNameVSCode, AppNameJetBrains: AppNameJetBrains, - // Zed connects over plain SSH, so its sessions belong in the SSH bucket - // until a durable per-app rollup exists. - AppNameZed: AppNameSSH, + // Zed has no Connection_Type or session count field of its own, so it + // rolls up under SSH. The raw name still reaches storage. + "zed": AppNameSSH, AppNameSSH: AppNameSSH, AppNameReconnectingPTY: AppNameReconnectingPTY, } -// Family returns the family for an app name, or AppNameUnknown. Matching is -// case-insensitive and alias-aware. -func Family(appName string) string { - if family, ok := families[canonicalKey(appName)]; ok { +// AppNameFamily normalizes an app name and returns its family, or +// AppNameUnknown. +func AppNameFamily(appName string) string { + if family, ok := appNameFamilies[NormalizeAppName(appName)]; ok { return family } return AppNameUnknown } -// Normalize prepares a client-supplied app name for storage, stripping null -// bytes that Postgres TEXT rejects. An empty name becomes AppNameUnknown; an -// unset session type instead means plain SSH, which callers resolve first. -func Normalize(appName string) string { +// NormalizeAppName prepares a client-supplied app name for storage and +// lookup. It strips the null bytes Postgres TEXT rejects, trims, truncates to +// MaxAppNameLength runes, lowercases, and folds hyphens to underscores. An +// empty name becomes AppNameUnknown; callers resolve an unset session type to +// AppNameSSH before calling this. +func NormalizeAppName(appName string) string { appName = strings.ReplaceAll(appName, "\x00", "") // Trim before truncating so padding does not spend the budget, and after // in case the cut lands in whitespace. @@ -73,9 +75,5 @@ func Normalize(appName string) string { if appName == "" { return AppNameUnknown } - return canonicalKey(appName) -} - -func canonicalKey(appName string) string { return strings.ReplaceAll(strings.ToLower(appName), "-", "_") } diff --git a/codersdk/appname_internal_test.go b/codersdk/appname_internal_test.go new file mode 100644 index 0000000000000..8bba278efdb61 --- /dev/null +++ b/codersdk/appname_internal_test.go @@ -0,0 +1,16 @@ +package codersdk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Lookups fold input through NormalizeAppName before probing, so map keys +// that are not already normalized can never match. +func TestAppNameFamilyKeysAreNormalized(t *testing.T) { + t.Parallel() + for name := range appNameFamilies { + require.Equal(t, NormalizeAppName(name), name) + } +} diff --git a/codersdk/appname_test.go b/codersdk/appname_test.go new file mode 100644 index 0000000000000..cd5174165650c --- /dev/null +++ b/codersdk/appname_test.go @@ -0,0 +1,82 @@ +package codersdk_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestNormalizeAppName(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + want string + }{ + {"NormalizedPassthrough", "vscode", "vscode"}, + {"KnownNameCaseInsensitive", "JetBrains", "jetbrains"}, + {"LegacyAlias", "reconnecting-pty", "reconnecting_pty"}, + {"HyphenatedKnownName", "vscode-insiders", "vscode_insiders"}, + {"UnknownHyphensFolded", "my-future-ide", "my_future_ide"}, + {"UnknownLowercased", "Cursor Nightly", "cursor nightly"}, + {"UnknownPreservesUnicode", "エディタ", "エディタ"}, + {"StripsNullBytes", "cur\x00sor", "cursor"}, + {"TrimsWhitespace", " vscode\t", "vscode"}, + {"Empty", "", "unknown"}, + {"OnlyNullBytes", "\x00\x00", "unknown"}, + {"OnlyWhitespace", " ", "unknown"}, + {"TruncatesToMaxRunes", strings.Repeat("a", codersdk.MaxAppNameLength+10), strings.Repeat("a", codersdk.MaxAppNameLength)}, + {"TruncatesMultibyteSafely", strings.Repeat("あ", codersdk.MaxAppNameLength+1), strings.Repeat("あ", codersdk.MaxAppNameLength)}, + // Padding is trimmed before truncating, so it does not spend the budget. + {"TrimsPaddingBeforeTruncating", " " + strings.Repeat("a", codersdk.MaxAppNameLength) + " ", strings.Repeat("a", codersdk.MaxAppNameLength)}, + {"TrimsWhitespaceLeftByTruncation", strings.Repeat("a", 60) + " tail", strings.Repeat("a", 60)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, codersdk.NormalizeAppName(tc.input)) + }) + } +} + +// The usage-app vocabulary is declared independently of the normalized app +// names because its values are part of the HTTP API. This pins the two +// together so they cannot drift. +func TestUsageAppNamesAreNormalized(t *testing.T) { + t.Parallel() + + for sdkName, want := range map[codersdk.UsageAppName]string{ + codersdk.UsageAppNameVscode: codersdk.AppNameVSCode, + codersdk.UsageAppNameJetbrains: codersdk.AppNameJetBrains, + codersdk.UsageAppNameReconnectingPty: codersdk.AppNameReconnectingPTY, + codersdk.UsageAppNameSSH: codersdk.AppNameSSH, + } { + require.Equal(t, want, codersdk.NormalizeAppName(string(sdkName))) + } +} + +func TestAppNameFamily(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + want string + }{ + {"VSCode", "vscode", codersdk.AppNameVSCode}, + {"VSCodeFork", "cursor", codersdk.AppNameVSCode}, + {"VSCodeInsidersHyphenated", "vscode-insiders", codersdk.AppNameVSCode}, + {"CaseInsensitive", "JetBrains", codersdk.AppNameJetBrains}, + {"SSHClientJoinsSSHFamily", "zed", codersdk.AppNameSSH}, + {"Alias", "reconnecting-pty", codersdk.AppNameReconnectingPTY}, + {"Unknown", "SomeFutureIDE", codersdk.AppNameUnknown}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, codersdk.AppNameFamily(tc.input)) + }) + } +} From f4134b46a2cc899f677542a991dca1466a6275fe Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 21 Aug 2026 00:25:01 +0300 Subject: [PATCH 3/4] refactor: name session app names as app names, not magic types Address review on #28124. - Rename the enum-shaped AppName* constants to AppFamily* behind a new AppFamilyName type, so a family is distinct from a raw app name. - Rename magicType to appName across agentssh and agent, including extractAppName and appNameMetricLabel. - Unexport MaxAppNameLength and drop the unused MaxSessionCountEntries. The magic_type metric label and MagicSessionTypeEnvironmentVariable keep their names: renaming them breaks dashboards and the wire contract. --- agent/agent.go | 16 ++-- agent/agent_test.go | 4 +- agent/agentssh/agentssh.go | 92 ++++++++++----------- agent/agentssh/appname_internal_test.go | 88 ++++++++++++++++++++ agent/agentssh/jetbrainstrack.go | 4 +- agent/agentssh/metrics.go | 6 +- agent/agentssh/sessiontype_internal_test.go | 90 -------------------- codersdk/appname.go | 86 +++++++++---------- codersdk/appname_test.go | 33 ++++---- 9 files changed, 208 insertions(+), 211 deletions(-) create mode 100644 agent/agentssh/appname_internal_test.go delete mode 100644 agent/agentssh/sessiontype_internal_test.go diff --git a/agent/agent.go b/agent/agent.go index facee443883fa..ed50f41108036 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -424,16 +424,16 @@ func (a *agent) init() { BlockFileTransfer: a.blockFileTransfer, BlockReversePortForwarding: a.blockReversePortForwarding, BlockLocalPortForwarding: a.blockLocalPortForwarding, - ReportConnection: func(id uuid.UUID, magicType string, ip string) func(code int, reason string) { + ReportConnection: func(id uuid.UUID, appName string, ip string) func(code int, reason string) { var connectionType proto.Connection_Type // Connection_Type is a fixed enum, stored as a database enum in // the connection log, so it can only hold a family. - switch codersdk.AppNameFamily(magicType) { - case codersdk.AppNameSSH: + switch codersdk.AppNameFamily(appName) { + case codersdk.AppFamilySSH: connectionType = proto.Connection_SSH - case codersdk.AppNameVSCode: + case codersdk.AppFamilyVSCode: connectionType = proto.Connection_VSCODE - case codersdk.AppNameJetBrains: + case codersdk.AppFamilyJetBrains: connectionType = proto.Connection_JETBRAINS default: connectionType = proto.Connection_TYPE_UNSPECIFIED @@ -2163,9 +2163,9 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect // The count of active sessions; types without a protocol field are dropped. sessionCounts := a.sshServer.SessionCounts() - stats.SessionCountSsh = sessionCounts[codersdk.AppNameSSH] - stats.SessionCountVscode = sessionCounts[codersdk.AppNameVSCode] - stats.SessionCountJetbrains = sessionCounts[codersdk.AppNameJetBrains] + stats.SessionCountSsh = sessionCounts[string(codersdk.AppFamilySSH)] + stats.SessionCountVscode = sessionCounts[string(codersdk.AppFamilyVSCode)] + stats.SessionCountJetbrains = sessionCounts[string(codersdk.AppFamilyJetBrains)] stats.SessionCountReconnectingPty = a.reconnectingPTYServer.ConnCount() diff --git a/agent/agent_test.go b/agent/agent_test.go index b27684d099f7c..b098287259f8a 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -310,7 +310,7 @@ func TestAgent_Stats_Magic(t *testing.T) { defer sshClient.Close() session, err := sshClient.NewSession() require.NoError(t, err) - session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, codersdk.AppNameVSCode) + session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(codersdk.AppFamilyVSCode)) defer session.Close() command := "sh -c 'echo $" + agentssh.MagicSessionTypeEnvironmentVariable + "'" @@ -338,7 +338,7 @@ func TestAgent_Stats_Magic(t *testing.T) { defer sshClient.Close() session, err := sshClient.NewSession() require.NoError(t, err) - session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, codersdk.AppNameVSCode) + session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(codersdk.AppFamilyVSCode)) defer session.Close() stdin, err := session.StdinPipe() require.NoError(t, err) diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 255b02afb24e8..ea70d819a9b8c 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -314,8 +314,8 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom } // startSession counts a session until the returned function is called. -func (s *Server) startSession(magicType string) (endSession func()) { - key := codersdk.NormalizeAppName(magicType) +func (s *Server) startSession(appName string) (endSession func()) { + key := codersdk.NormalizeAppName(appName) s.sessionCountsMu.Lock() defer s.sessionCountsMu.Unlock() s.sessionCounts[key]++ @@ -329,32 +329,32 @@ func (s *Server) startSession(magicType string) (endSession func()) { } } -// SessionCounts returns active sessions per app name, omitting names at -// zero. Never nil, so callers can merge in other session sources. +// SessionCounts returns active sessions per app name, omitting zeroes. Never +// nil, so callers can merge in other sources. func (s *Server) SessionCounts() map[string]int64 { s.sessionCountsMu.Lock() defer s.sessionCountsMu.Unlock() return maps.Clone(s.sessionCounts) } -func extractMagicSessionType(env []string) (magicType, rawType string, filteredEnv []string) { +func extractAppName(env []string) (appName, rawAppName string, filteredEnv []string) { for _, kv := range env { if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) { continue } - rawType = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=") + rawAppName = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=") // Keep going, we'll use the last instance of the env. } - if rawType == "" { - magicType = codersdk.AppNameSSH + if rawAppName == "" { + appName = string(codersdk.AppFamilySSH) } else { // Normalize, don't classify: unknown names flow through. - magicType = codersdk.NormalizeAppName(rawType) + appName = codersdk.NormalizeAppName(rawAppName) } - return magicType, rawType, slices.DeleteFunc(env, func(kv string) bool { + return appName, rawAppName, slices.DeleteFunc(env, func(kv string) bool { return strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable+"=") }) } @@ -417,12 +417,12 @@ func (s *Server) sessionHandler(session ssh.Session) { logger.Info(ctx, "handling ssh session") env := session.Environ() - magicType, magicTypeRaw, env := extractMagicSessionType(env) - magicTypeFamily := codersdk.AppNameFamily(magicType) - if magicTypeFamily == codersdk.AppNameUnknown { + appName, rawAppName, env := extractAppName(env) + family := codersdk.AppNameFamily(appName) + if family == codersdk.AppFamilyUnknown { logger.Debug(ctx, "unrecognized ssh session type", - slog.F("magic_type", magicType), - slog.F("raw_type", magicTypeRaw), + slog.F("app_name", appName), + slog.F("raw_app_name", rawAppName), ) } @@ -438,7 +438,7 @@ func (s *Server) sessionHandler(session ssh.Session) { if !s.trackSession(session, true) { reason := "unable to accept new session, server is closing" // Report connection attempt even if we couldn't accept it. - disconnected := s.config.ReportConnection(id, magicType, remoteAddrString) + disconnected := s.config.ReportConnection(id, appName, remoteAddrString) defer disconnected(1, reason) logger.Info(ctx, reason) @@ -450,12 +450,12 @@ func (s *Server) sessionHandler(session ssh.Session) { reportSession := true - if magicTypeFamily == codersdk.AppNameJetBrains { + if family == codersdk.AppFamilyJetBrains { // Do nothing here because JetBrains launches hundreds of ssh sessions. // We instead track JetBrains in the single persistent tcp forwarding channel. reportSession = false } else { - endSession := s.startSession(magicType) + endSession := s.startSession(appName) defer endSession() } @@ -467,7 +467,7 @@ func (s *Server) sessionHandler(session ssh.Session) { scr := &sessionCloseTracker{Session: session} session = scr - disconnected := s.config.ReportConnection(id, magicType, remoteAddrString) + disconnected := s.config.ReportConnection(id, appName, remoteAddrString) defer func() { logger.Info(ctx, "ssh session closed", codersdk.ConnectionDirectionAgentToClient.SlogField(), @@ -532,7 +532,7 @@ func (s *Server) sessionHandler(session ssh.Session) { env = append(env, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber)) } - err := s.sessionStart(logger, session, env, magicType, container, containerUser) + err := s.sessionStart(logger, session, env, appName, container, containerUser) var exitError *exec.ExitError if xerrors.As(err, &exitError) { code := exitError.ExitCode() @@ -606,10 +606,10 @@ func (s *Server) fileTransferBlocked(session ssh.Session) bool { return false } -func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType, container, containerUser string) (retErr error) { +func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, appName, container, containerUser string) (retErr error) { ctx := session.Context() - magicTypeLabel := magicTypeMetricLabel(magicType) + appLabel := appNameMetricLabel(appName) sshPty, windowSize, isPty := session.Pty() ptyLabel := "no" if isPty { @@ -621,20 +621,20 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str if s.config.ExperimentalContainers && container != "" { ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser) if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, ptyLabel, "container_env_info").Add(1) return err } } cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, ei) if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "create_command").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, ptyLabel, "create_command").Add(1) return err } if ssh.AgentRequested(session) { l, err := ssh.NewAgentListener() if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "listener").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, ptyLabel, "listener").Add(1) return xerrors.Errorf("new agent listener: %w", err) } defer l.Close() @@ -643,13 +643,13 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str } if isPty { - return s.startPTYSession(logger, session, magicTypeLabel, cmd, sshPty, windowSize) + return s.startPTYSession(logger, session, appLabel, cmd, sshPty, windowSize) } - return s.startNonPTYSession(logger, session, magicTypeLabel, cmd.AsExec()) + return s.startNonPTYSession(logger, session, appLabel, cmd.AsExec()) } -func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, magicTypeLabel string, cmd *exec.Cmd) error { - s.metrics.sessionsTotal.WithLabelValues(magicTypeLabel, "no").Add(1) +func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, appLabel string, cmd *exec.Cmd) error { + s.metrics.sessionsTotal.WithLabelValues(appLabel, "no").Add(1) // Create a process group and send SIGHUP to child processes, // otherwise context cancellation will not propagate properly @@ -668,19 +668,19 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag // use StdinPipe. It's unknown what causes this. stdinPipe, err := cmd.StdinPipe() if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "stdin_pipe").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "no", "stdin_pipe").Add(1) return xerrors.Errorf("create stdin pipe: %w", err) } go func() { _, err := io.Copy(stdinPipe, session) if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "stdin_io_copy").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "no", "stdin_io_copy").Add(1) } _ = stdinPipe.Close() }() err = cmd.Start() if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "no", "start_command").Add(1) return xerrors.Errorf("start: %w", err) } @@ -701,7 +701,7 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag }() go func() { for sig := range sigs { - handleSignal(logger, sig, cmd.Process, s.metrics, magicTypeLabel) + handleSignal(logger, sig, cmd.Process, s.metrics, appLabel) } }() return cmd.Wait() @@ -717,8 +717,8 @@ type ptySession interface { Signals(chan<- ssh.Signal) } -func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTypeLabel string, cmd *pty.Cmd, sshPty ssh.Pty, windowSize <-chan ssh.Window) (retErr error) { - s.metrics.sessionsTotal.WithLabelValues(magicTypeLabel, "yes").Add(1) +func (s *Server) startPTYSession(logger slog.Logger, session ptySession, appLabel string, cmd *pty.Cmd, sshPty ssh.Pty, windowSize <-chan ssh.Window) (retErr error) { + s.metrics.sessionsTotal.WithLabelValues(appLabel, "yes").Add(1) ctx := session.Context() // Disable minimal PTY emulation set by gliderlabs/ssh (NL-to-CRNL). @@ -732,7 +732,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy err := showAnnouncementBanner(session, banner) if err != nil { logger.Error(ctx, "agent failed to show announcement banner", slog.Error(err)) - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "announcement_banner").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "announcement_banner").Add(1) break } } @@ -743,7 +743,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy err := showMOTD(s.fs, session, s.config.MOTDFile()) if err != nil { logger.Error(ctx, "agent failed to show MOTD", slog.Error(err)) - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "motd").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "motd").Add(1) } } @@ -755,14 +755,14 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy pty.WithLogger(slog.Stdlib(ctx, logger, slog.LevelInfo)), )) if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "start_command").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "start_command").Add(1) return xerrors.Errorf("start command: %w", err) } defer func() { closeErr := ptty.Close() if closeErr != nil { logger.Warn(ctx, "failed to close tty", slog.Error(closeErr)) - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "close").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "close").Add(1) if retErr == nil { retErr = closeErr } @@ -786,7 +786,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy sigs = nil continue } - handleSignal(logger, sig, process, s.metrics, magicTypeLabel) + handleSignal(logger, sig, process, s.metrics, appLabel) case win, ok := <-windowSize: if !ok { windowSize = nil @@ -797,7 +797,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy // If the pty is closed, then command has exited, no need to log. if resizeErr != nil && !errors.Is(resizeErr, pty.ErrClosed) { logger.Warn(ctx, "failed to resize tty", slog.Error(resizeErr)) - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "resize").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "resize").Add(1) } } } @@ -806,7 +806,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy go func() { _, err := io.Copy(ptty.InputWriter(), session) if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "input_io_copy").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "input_io_copy").Add(1) } }() @@ -821,7 +821,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy n, err := io.Copy(session, ptty.OutputReader()) logger.Debug(ctx, "copy output done", slog.F("bytes", n), slog.Error(err)) if err != nil { - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "output_io_copy").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "output_io_copy").Add(1) return xerrors.Errorf("copy error: %w", err) } // We've gotten all the output, but we need to wait for the process to @@ -833,7 +833,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy // and not something to be concerned about. But, if it's something else, we should log it. if err != nil && !xerrors.As(err, &exitErr) { logger.Warn(ctx, "process wait exited with error", slog.Error(err)) - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "wait").Add(1) + s.metrics.sessionErrors.WithLabelValues(appLabel, "yes", "wait").Add(1) } if err != nil { return xerrors.Errorf("process wait: %w", err) @@ -841,7 +841,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy return nil } -func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signal(os.Signal) error }, metrics *sshServerMetrics, magicTypeLabel string) { +func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signal(os.Signal) error }, metrics *sshServerMetrics, appLabel string) { ctx := context.Background() sig := osSignalFrom(ssig) logger = logger.With(slog.F("ssh_signal", ssig), slog.F("signal", sig.String())) @@ -849,7 +849,7 @@ func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signa err := signaler.Signal(sig) if err != nil { logger.Warn(ctx, "signaling the process failed", slog.Error(err)) - metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "signal").Add(1) + metrics.sessionErrors.WithLabelValues(appLabel, "yes", "signal").Add(1) } } diff --git a/agent/agentssh/appname_internal_test.go b/agent/agentssh/appname_internal_test.go new file mode 100644 index 0000000000000..f1b7ae080f44a --- /dev/null +++ b/agent/agentssh/appname_internal_test.go @@ -0,0 +1,88 @@ +package agentssh + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractAppName(t *testing.T) { + t.Parallel() + + envWith := func(value string) []string { + return []string{ + "FOO=bar", + fmt.Sprintf("%s=%s", MagicSessionTypeEnvironmentVariable, value), + "BAZ=qux", + } + } + + for _, tc := range []struct { + name string + env []string + want string + }{ + {"NoEnvDefaultsToSSH", []string{"FOO=bar"}, "ssh"}, + {"EmptyValueDefaultsToSSH", envWith(""), "ssh"}, + {"VSCode", envWith("vscode"), "vscode"}, + {"JetBrainsLegacyCasing", envWith("JetBrains"), "jetbrains"}, + {"UnknownTypeNormalized", envWith("Cursor-Nightly"), "cursor_nightly"}, + {"LastInstanceWins", append(envWith("vscode"), MagicSessionTypeEnvironmentVariable+"=cursor"), "cursor"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + appName, _, filteredEnv := extractAppName(tc.env) + require.Equal(t, tc.want, appName) + for _, kv := range filteredEnv { + require.NotContains(t, kv, MagicSessionTypeEnvironmentVariable+"=") + } + }) + } +} + +func TestAppNameMetricLabel(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + appName string + want string + }{ + {"SSH", "ssh", "ssh"}, + {"ForkUsesFamily", "cursor", "vscode"}, + // No alias for the nightly variant, so it does not get a label. + {"UnlistedVariant", "cursor_nightly", "unknown"}, + {"Unknown", "some_new_ide", "unknown"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, appNameMetricLabel(tc.appName)) + }) + } +} + +func TestSessionCounts(t *testing.T) { + t.Parallel() + + s := &Server{sessionCounts: make(map[string]int64)} + + // Counters are created on demand and names are normalized. + _ = s.startSession("ssh") + _ = s.startSession("vscode") + endCursor1 := s.startSession("Cursor") + endCursor2 := s.startSession("cursor") + require.Equal(t, map[string]int64{ + "ssh": 1, + "vscode": 1, + "cursor": 2, + }, s.SessionCounts()) + + // Zero-count entries are dropped, not reported as idle apps. + endCursor1() + endCursor2() + require.Equal(t, map[string]int64{ + "ssh": 1, + "vscode": 1, + }, s.SessionCounts()) +} diff --git a/agent/agentssh/jetbrainstrack.go b/agent/agentssh/jetbrainstrack.go index 224179d07880e..a636c08f17237 100644 --- a/agent/agentssh/jetbrainstrack.go +++ b/agent/agentssh/jetbrainstrack.go @@ -73,14 +73,14 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConne } func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request, error) { - disconnected := w.reportConnection(uuid.New(), codersdk.AppNameJetBrains, w.originAddr) + disconnected := w.reportConnection(uuid.New(), string(codersdk.AppFamilyJetBrains), w.originAddr) c, r, err := w.NewChannel.Accept() if err != nil { disconnected(1, err.Error()) return c, r, err } - endSession := w.startSession(codersdk.AppNameJetBrains) + endSession := w.startSession(string(codersdk.AppFamilyJetBrains)) // nolint: gocritic // JetBrains is a proper noun and should be capitalized w.logger.Debug(context.Background(), "JetBrains watcher accepted channel") diff --git a/agent/agentssh/metrics.go b/agent/agentssh/metrics.go index 474238ca9a83c..ff70df7df7517 100644 --- a/agent/agentssh/metrics.go +++ b/agent/agentssh/metrics.go @@ -71,7 +71,7 @@ func newSSHServerMetrics(registerer prometheus.Registerer) *sshServerMetrics { } } -func magicTypeMetricLabel(magicType string) string { - // Family, not raw type, so cardinality stays bounded. - return codersdk.AppNameFamily(magicType) +func appNameMetricLabel(appName string) string { + // Family, not raw name, so cardinality stays bounded. + return string(codersdk.AppNameFamily(appName)) } diff --git a/agent/agentssh/sessiontype_internal_test.go b/agent/agentssh/sessiontype_internal_test.go deleted file mode 100644 index e5111a5c5bf78..0000000000000 --- a/agent/agentssh/sessiontype_internal_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package agentssh - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/codersdk" -) - -func TestExtractMagicSessionType(t *testing.T) { - t.Parallel() - - envWith := func(value string) []string { - return []string{ - "FOO=bar", - fmt.Sprintf("%s=%s", MagicSessionTypeEnvironmentVariable, value), - "BAZ=qux", - } - } - - for _, tc := range []struct { - name string - env []string - want string - }{ - {"NoEnvDefaultsToSSH", []string{"FOO=bar"}, codersdk.AppNameSSH}, - {"EmptyValueDefaultsToSSH", envWith(""), codersdk.AppNameSSH}, - {"VSCode", envWith("vscode"), codersdk.AppNameVSCode}, - {"JetBrainsLegacyCasing", envWith("JetBrains"), codersdk.AppNameJetBrains}, - {"UnknownTypeNormalized", envWith("Cursor-Nightly"), ("cursor_nightly")}, - {"LastInstanceWins", append(envWith("vscode"), MagicSessionTypeEnvironmentVariable+"=cursor"), ("cursor")}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - sessionType, _, filteredEnv := extractMagicSessionType(tc.env) - require.Equal(t, tc.want, sessionType) - for _, kv := range filteredEnv { - require.NotContains(t, kv, MagicSessionTypeEnvironmentVariable+"=") - } - }) - } -} - -func TestMagicTypeMetricLabel(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - name string - magicType string - want string - }{ - {"SSH", codersdk.AppNameSSH, codersdk.AppNameSSH}, - {"ForkUsesFamily", ("cursor"), codersdk.AppNameVSCode}, - // No alias for the nightly variant, so it does not get a label. - {"UnlistedVariant", ("cursor_nightly"), codersdk.AppNameUnknown}, - {"Unknown", ("some_new_ide"), codersdk.AppNameUnknown}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tc.want, magicTypeMetricLabel(tc.magicType)) - }) - } -} - -func TestSessionCounts(t *testing.T) { - t.Parallel() - - s := &Server{sessionCounts: make(map[string]int64)} - - // Counters are created on demand and names are normalized. - _ = s.startSession(codersdk.AppNameSSH) - _ = s.startSession(codersdk.AppNameVSCode) - endCursor1 := s.startSession(("Cursor")) - endCursor2 := s.startSession(("cursor")) - require.Equal(t, map[string]int64{ - codersdk.AppNameSSH: 1, - codersdk.AppNameVSCode: 1, - "cursor": 2, - }, s.SessionCounts()) - - // Zero-count entries are dropped, not reported as idle apps. - endCursor1() - endCursor2() - require.Equal(t, map[string]int64{ - codersdk.AppNameSSH: 1, - codersdk.AppNameVSCode: 1, - }, s.SessionCounts()) -} diff --git a/codersdk/appname.go b/codersdk/appname.go index ccf768a0990b6..dc0249bdad980 100644 --- a/codersdk/appname.go +++ b/codersdk/appname.go @@ -6,74 +6,70 @@ import ( utilstrings "github.com/coder/coder/v2/coderd/util/strings" ) -// Server-side vocabulary, not part of the HTTP API. -// @typescript-ignore MaxAppNameLength, MaxSessionCountEntries, AppNameVSCode, AppNameJetBrains, AppNameSSH, AppNameReconnectingPTY, AppNameUnknown +// maxAppNameLength caps an app name in runes. +const maxAppNameLength = 64 -// MaxAppNameLength is the maximum app name length in runes. -const MaxAppNameLength = 64 +// AppFamilyName is the bounded set of names that arbitrary app names group +// into, for callers that need a fixed value such as a metric label or the +// Connection_Type enum. Server-side only, not part of the HTTP API. +// +// @typescript-ignore AppFamilyName +type AppFamilyName string -// MaxSessionCountEntries bounds distinct app names per stats report. -// Overflow aggregates under AppNameUnknown. -const MaxSessionCountEntries = 64 - -// Normalized app names for Coder's built-in session types. const ( - AppNameVSCode = "vscode" - AppNameJetBrains = "jetbrains" - AppNameSSH = "ssh" - AppNameReconnectingPTY = "reconnecting_pty" - AppNameUnknown = "unknown" + AppFamilyVSCode AppFamilyName = "vscode" + AppFamilyJetBrains AppFamilyName = "jetbrains" + AppFamilySSH AppFamilyName = "ssh" + AppFamilyReconnectingPTY AppFamilyName = "reconnecting_pty" + AppFamilyUnknown AppFamilyName = "unknown" ) -// appNameFamilies groups app names for callers that need a bounded value, -// such as a metric label or the Connection_Type enum. It is never used for -// storage, so a missing alias only costs an AppNameUnknown label. Keys are -// the IDs Coder's registry modules use. -var appNameFamilies = map[string]string{ - AppNameVSCode: AppNameVSCode, - "vscode_insiders": AppNameVSCode, - "vscode_web": AppNameVSCode, - "code_server": AppNameVSCode, - "cursor": AppNameVSCode, - "windsurf": AppNameVSCode, - "positron": AppNameVSCode, - "vscodium": AppNameVSCode, - "codium": AppNameVSCode, - "antigravity": AppNameVSCode, - "trae": AppNameVSCode, - "kiro": AppNameVSCode, - "devin": AppNameVSCode, +// appNameFamilies never gates storage, so a missing alias only costs an +// AppFamilyUnknown label. Keys are the IDs Coder's registry modules use. +var appNameFamilies = map[string]AppFamilyName{ + "vscode": AppFamilyVSCode, + "vscode_insiders": AppFamilyVSCode, + "vscode_web": AppFamilyVSCode, + "code_server": AppFamilyVSCode, + "cursor": AppFamilyVSCode, + "windsurf": AppFamilyVSCode, + "positron": AppFamilyVSCode, + "vscodium": AppFamilyVSCode, + "codium": AppFamilyVSCode, + "antigravity": AppFamilyVSCode, + "trae": AppFamilyVSCode, + "kiro": AppFamilyVSCode, + "devin": AppFamilyVSCode, - AppNameJetBrains: AppNameJetBrains, + "jetbrains": AppFamilyJetBrains, // Zed has no Connection_Type or session count field of its own, so it // rolls up under SSH. The raw name still reaches storage. - "zed": AppNameSSH, - AppNameSSH: AppNameSSH, - AppNameReconnectingPTY: AppNameReconnectingPTY, + "zed": AppFamilySSH, + "ssh": AppFamilySSH, + "reconnecting_pty": AppFamilyReconnectingPTY, } // AppNameFamily normalizes an app name and returns its family, or -// AppNameUnknown. -func AppNameFamily(appName string) string { +// AppFamilyUnknown. +func AppNameFamily(appName string) AppFamilyName { if family, ok := appNameFamilies[NormalizeAppName(appName)]; ok { return family } - return AppNameUnknown + return AppFamilyUnknown } // NormalizeAppName prepares a client-supplied app name for storage and -// lookup. It strips the null bytes Postgres TEXT rejects, trims, truncates to -// MaxAppNameLength runes, lowercases, and folds hyphens to underscores. An -// empty name becomes AppNameUnknown; callers resolve an unset session type to -// AppNameSSH before calling this. +// lookup: it strips the null bytes Postgres TEXT rejects, trims, truncates, +// lowercases, and folds hyphens to underscores. Empty becomes +// AppFamilyUnknown. func NormalizeAppName(appName string) string { appName = strings.ReplaceAll(appName, "\x00", "") // 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)) + appName = strings.TrimSpace(utilstrings.Truncate(appName, maxAppNameLength)) if appName == "" { - return AppNameUnknown + return string(AppFamilyUnknown) } return strings.ReplaceAll(strings.ToLower(appName), "-", "_") } diff --git a/codersdk/appname_test.go b/codersdk/appname_test.go index cd5174165650c..e7c5cbaf66f76 100644 --- a/codersdk/appname_test.go +++ b/codersdk/appname_test.go @@ -9,6 +9,9 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// Duplicated from the package so the limit stays unexported. +const maxAppNameLength = 64 + func TestNormalizeAppName(t *testing.T) { t.Parallel() @@ -29,10 +32,10 @@ func TestNormalizeAppName(t *testing.T) { {"Empty", "", "unknown"}, {"OnlyNullBytes", "\x00\x00", "unknown"}, {"OnlyWhitespace", " ", "unknown"}, - {"TruncatesToMaxRunes", strings.Repeat("a", codersdk.MaxAppNameLength+10), strings.Repeat("a", codersdk.MaxAppNameLength)}, - {"TruncatesMultibyteSafely", strings.Repeat("あ", codersdk.MaxAppNameLength+1), strings.Repeat("あ", codersdk.MaxAppNameLength)}, + {"TruncatesToMaxRunes", strings.Repeat("a", maxAppNameLength+10), strings.Repeat("a", maxAppNameLength)}, + {"TruncatesMultibyteSafely", strings.Repeat("あ", maxAppNameLength+1), strings.Repeat("あ", maxAppNameLength)}, // Padding is trimmed before truncating, so it does not spend the budget. - {"TrimsPaddingBeforeTruncating", " " + strings.Repeat("a", codersdk.MaxAppNameLength) + " ", strings.Repeat("a", codersdk.MaxAppNameLength)}, + {"TrimsPaddingBeforeTruncating", " " + strings.Repeat("a", maxAppNameLength) + " ", strings.Repeat("a", maxAppNameLength)}, {"TrimsWhitespaceLeftByTruncation", strings.Repeat("a", 60) + " tail", strings.Repeat("a", 60)}, } { t.Run(tc.name, func(t *testing.T) { @@ -49,10 +52,10 @@ func TestUsageAppNamesAreNormalized(t *testing.T) { t.Parallel() for sdkName, want := range map[codersdk.UsageAppName]string{ - codersdk.UsageAppNameVscode: codersdk.AppNameVSCode, - codersdk.UsageAppNameJetbrains: codersdk.AppNameJetBrains, - codersdk.UsageAppNameReconnectingPty: codersdk.AppNameReconnectingPTY, - codersdk.UsageAppNameSSH: codersdk.AppNameSSH, + codersdk.UsageAppNameVscode: "vscode", + codersdk.UsageAppNameJetbrains: "jetbrains", + codersdk.UsageAppNameReconnectingPty: "reconnecting_pty", + codersdk.UsageAppNameSSH: "ssh", } { require.Equal(t, want, codersdk.NormalizeAppName(string(sdkName))) } @@ -64,15 +67,15 @@ func TestAppNameFamily(t *testing.T) { for _, tc := range []struct { name string input string - want string + want codersdk.AppFamilyName }{ - {"VSCode", "vscode", codersdk.AppNameVSCode}, - {"VSCodeFork", "cursor", codersdk.AppNameVSCode}, - {"VSCodeInsidersHyphenated", "vscode-insiders", codersdk.AppNameVSCode}, - {"CaseInsensitive", "JetBrains", codersdk.AppNameJetBrains}, - {"SSHClientJoinsSSHFamily", "zed", codersdk.AppNameSSH}, - {"Alias", "reconnecting-pty", codersdk.AppNameReconnectingPTY}, - {"Unknown", "SomeFutureIDE", codersdk.AppNameUnknown}, + {"VSCode", "vscode", codersdk.AppFamilyVSCode}, + {"VSCodeFork", "cursor", codersdk.AppFamilyVSCode}, + {"VSCodeInsidersHyphenated", "vscode-insiders", codersdk.AppFamilyVSCode}, + {"CaseInsensitive", "JetBrains", codersdk.AppFamilyJetBrains}, + {"SSHClientJoinsSSHFamily", "zed", codersdk.AppFamilySSH}, + {"Alias", "reconnecting-pty", codersdk.AppFamilyReconnectingPTY}, + {"Unknown", "SomeFutureIDE", codersdk.AppFamilyUnknown}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() From 51db48a41a1cdb28b95e9daba05a0c1dd2dbadc1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 21 Aug 2026 00:31:12 +0300 Subject: [PATCH 4/4] refactor(agent): rename MagicSessionTypeEnvironmentVariable to AppNameEnvironmentVariable The Go identifier is package-local vocabulary. The env var value stays CODER_SSH_SESSION_TYPE, so no client changes. --- agent/agent_test.go | 8 ++++---- agent/agentssh/agentssh.go | 14 ++++++++------ agent/agentssh/appname_internal_test.go | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index b098287259f8a..f11cde7467bc7 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -310,13 +310,13 @@ func TestAgent_Stats_Magic(t *testing.T) { defer sshClient.Close() session, err := sshClient.NewSession() require.NoError(t, err) - session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(codersdk.AppFamilyVSCode)) + session.Setenv(agentssh.AppNameEnvironmentVariable, string(codersdk.AppFamilyVSCode)) defer session.Close() - command := "sh -c 'echo $" + agentssh.MagicSessionTypeEnvironmentVariable + "'" + command := "sh -c 'echo $" + agentssh.AppNameEnvironmentVariable + "'" expected := "" if runtime.GOOS == "windows" { - expected = "%" + agentssh.MagicSessionTypeEnvironmentVariable + "%" + expected = "%" + agentssh.AppNameEnvironmentVariable + "%" command = "cmd.exe /c echo " + expected } output, err := session.Output(command) @@ -338,7 +338,7 @@ func TestAgent_Stats_Magic(t *testing.T) { defer sshClient.Close() session, err := sshClient.NewSession() require.NoError(t, err) - session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(codersdk.AppFamilyVSCode)) + session.Setenv(agentssh.AppNameEnvironmentVariable, string(codersdk.AppFamilyVSCode)) defer session.Close() stdin, err := session.StdinPipe() require.NoError(t, err) diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index ea70d819a9b8c..f3b148fda62d1 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -55,9 +55,11 @@ const ( ) const ( - // MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection. - // This is stripped from any commands being executed, and is counted towards connection stats. - MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE" + // AppNameEnvironmentVariable carries the name of the app opening an SSH + // connection. This is stripped from any commands being executed, and is + // counted towards connection stats. The value keeps its original spelling + // so existing clients keep working. + AppNameEnvironmentVariable = "CODER_SSH_SESSION_TYPE" // ContainerEnvironmentVariable is used to specify the target container for an SSH connection. // This is stripped from any commands being executed. // Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true. @@ -339,11 +341,11 @@ func (s *Server) SessionCounts() map[string]int64 { func extractAppName(env []string) (appName, rawAppName string, filteredEnv []string) { for _, kv := range env { - if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) { + if !strings.HasPrefix(kv, AppNameEnvironmentVariable) { continue } - rawAppName = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=") + rawAppName = strings.TrimPrefix(kv, AppNameEnvironmentVariable+"=") // Keep going, we'll use the last instance of the env. } @@ -355,7 +357,7 @@ func extractAppName(env []string) (appName, rawAppName string, filteredEnv []str } return appName, rawAppName, slices.DeleteFunc(env, func(kv string) bool { - return strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable+"=") + return strings.HasPrefix(kv, AppNameEnvironmentVariable+"=") }) } diff --git a/agent/agentssh/appname_internal_test.go b/agent/agentssh/appname_internal_test.go index f1b7ae080f44a..2a52def92366a 100644 --- a/agent/agentssh/appname_internal_test.go +++ b/agent/agentssh/appname_internal_test.go @@ -13,7 +13,7 @@ func TestExtractAppName(t *testing.T) { envWith := func(value string) []string { return []string{ "FOO=bar", - fmt.Sprintf("%s=%s", MagicSessionTypeEnvironmentVariable, value), + fmt.Sprintf("%s=%s", AppNameEnvironmentVariable, value), "BAZ=qux", } } @@ -28,14 +28,14 @@ func TestExtractAppName(t *testing.T) { {"VSCode", envWith("vscode"), "vscode"}, {"JetBrainsLegacyCasing", envWith("JetBrains"), "jetbrains"}, {"UnknownTypeNormalized", envWith("Cursor-Nightly"), "cursor_nightly"}, - {"LastInstanceWins", append(envWith("vscode"), MagicSessionTypeEnvironmentVariable+"=cursor"), "cursor"}, + {"LastInstanceWins", append(envWith("vscode"), AppNameEnvironmentVariable+"=cursor"), "cursor"}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() appName, _, filteredEnv := extractAppName(tc.env) require.Equal(t, tc.want, appName) for _, kv := range filteredEnv { - require.NotContains(t, kv, MagicSessionTypeEnvironmentVariable+"=") + require.NotContains(t, kv, AppNameEnvironmentVariable+"=") } }) }