From 2506550cb2171b999175a4eeb85917dbc1579887 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 2 Mar 2023 02:28:49 +0000 Subject: [PATCH] feat: add agent stats for different connection types This allows us to track when our extensions are used, when the web terminal is used, and average connection latency to the agent. --- agent/agent.go | 184 +++++++++++++----- agent/agent_test.go | 44 ++++- coderd/apidoc/docs.go | 30 ++- coderd/apidoc/swagger.json | 30 ++- coderd/database/dbfake/databasefake.go | 29 +-- coderd/database/dump.sql | 7 +- ...00102_workspace_agent_stats_types.down.sql | 5 + .../000102_workspace_agent_stats_types.up.sql | 5 + coderd/database/models.go | 29 +-- coderd/database/queries.sql.go | 48 +++-- .../database/queries/workspaceagentstats.sql | 9 +- coderd/database/sqlc.yaml | 5 + coderd/workspaceagents.go | 29 +-- coderd/workspaceagents_test.go | 17 +- codersdk/agentsdk/agentsdk.go | 19 +- docs/api/agents.md | 9 +- docs/api/schemas.md | 32 +-- tailnet/conn.go | 12 ++ 18 files changed, 412 insertions(+), 131 deletions(-) create mode 100644 coderd/database/migrations/000102_workspace_agent_stats_types.down.sql create mode 100644 coderd/database/migrations/000102_workspace_agent_stats_types.up.sql diff --git a/agent/agent.go b/agent/agent.go index c509ddc5ff5ea..6b9adfcac83c0 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -18,6 +18,7 @@ import ( "os/user" "path/filepath" "runtime" + "sort" "strconv" "strings" "sync" @@ -56,6 +57,14 @@ const ( // command just returning a nonzero exit code, and is chosen as an arbitrary, high number // unlikely to shadow other exit codes, which are typically 1, 2, 3, etc. MagicSessionErrorCode = 229 + + // MagicSSHSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection. + // This is stripped from any commands being executed, and is counted towards connection stats. + MagicSSHSessionTypeEnvironmentVariable = "__CODER_SSH_SESSION_TYPE" + // MagicSSHSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself. + MagicSSHSessionTypeVSCode = "vscode" + // MagicSSHSessionTypeJetBrains is set in the SSH config by the JetBrains extension to identify itself. + MagicSSHSessionTypeJetBrains = "jetbrains" ) type Options struct { @@ -146,6 +155,15 @@ type agent struct { network *tailnet.Conn connStatsChan chan *agentsdk.Stats + + statRxPackets atomic.Int64 + statRxBytes atomic.Int64 + statTxPackets atomic.Int64 + statTxBytes atomic.Int64 + connCountVSCode atomic.Int64 + connCountJetBrains atomic.Int64 + connCountReconnectingPTY atomic.Int64 + connCountSSHSession atomic.Int64 } // runLoop attempts to start the agent in a retry loop. @@ -350,33 +368,7 @@ func (a *agent) run(ctx context.Context) error { return xerrors.New("agent is closed") } - setStatInterval := func(d time.Duration) { - network.SetConnStatsCallback(d, 2048, - func(_, _ time.Time, virtual, _ map[netlogtype.Connection]netlogtype.Counts) { - select { - case a.connStatsChan <- convertAgentStats(virtual): - default: - a.logger.Warn(ctx, "network stat dropped") - } - }, - ) - } - - // Report statistics from the created network. - cl, err := a.client.ReportStats(ctx, a.logger, a.connStatsChan, setStatInterval) - if err != nil { - a.logger.Error(ctx, "report stats", slog.Error(err)) - } else { - if err = a.trackConnGoroutine(func() { - // This is OK because the agent never re-creates the tailnet - // and the only shutdown indicator is agent.Close(). - <-a.closed - _ = cl.Close() - }); err != nil { - a.logger.Debug(ctx, "report stats goroutine", slog.Error(err)) - _ = cl.Close() - } - } + a.startReportingConnectionStats(ctx) } else { // Update the DERP map! network.SetDERPMap(metadata.DERPMap) @@ -765,23 +757,6 @@ func (a *agent) init(ctx context.Context) { go a.runLoop(ctx) } -func convertAgentStats(counts map[netlogtype.Connection]netlogtype.Counts) *agentsdk.Stats { - stats := &agentsdk.Stats{ - ConnectionsByProto: map[string]int64{}, - ConnectionCount: int64(len(counts)), - } - - for conn, count := range counts { - stats.ConnectionsByProto[conn.Proto.String()]++ - stats.RxPackets += int64(count.RxPackets) - stats.RxBytes += int64(count.RxBytes) - stats.TxPackets += int64(count.TxPackets) - stats.TxBytes += int64(count.TxBytes) - } - - return stats -} - // createCommand processes raw command input with OpenSSH-like behavior. // If the rawCommand provided is empty, it will default to the users shell. // This injects environment variables specified by the user at launch too. @@ -892,7 +867,27 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri func (a *agent) handleSSHSession(session ssh.Session) (retErr error) { ctx := session.Context() - cmd, err := a.createCommand(ctx, session.RawCommand(), session.Environ()) + env := session.Environ() + var magicType string + for index, kv := range env { + if !strings.HasPrefix(kv, MagicSSHSessionTypeEnvironmentVariable) { + continue + } + magicType = strings.TrimPrefix(kv, MagicSSHSessionTypeEnvironmentVariable+"=") + env = append(env[:index], env[index+1:]...) + } + switch magicType { + case MagicSSHSessionTypeVSCode: + a.connCountVSCode.Add(1) + case MagicSSHSessionTypeJetBrains: + a.connCountJetBrains.Add(1) + case "": + a.connCountSSHSession.Add(1) + default: + a.logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("type", magicType)) + } + + cmd, err := a.createCommand(ctx, session.RawCommand(), env) if err != nil { return err } @@ -990,6 +985,8 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) { func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.WorkspaceAgentReconnectingPTYInit, conn net.Conn) (retErr error) { defer conn.Close() + a.connCountReconnectingPTY.Add(1) + connectionID := uuid.NewString() logger = logger.With(slog.F("id", msg.ID), slog.F("connection_id", connectionID)) @@ -1180,6 +1177,103 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m } } +// startReportingConnectionStats runs the connection stats reporting goroutine. +func (a *agent) startReportingConnectionStats(ctx context.Context) { + reportStats := func(networkStats map[netlogtype.Connection]netlogtype.Counts) { + stats := &agentsdk.Stats{ + ConnectionCount: int64(len(networkStats)), + ConnectionsByProto: map[string]int64{}, + } + // Tailscale resets counts on every report! + // We'd rather have these compound, like Linux does! + for conn, counts := range networkStats { + stats.ConnectionsByProto[conn.Proto.String()]++ + stats.RxBytes = a.statRxBytes.Add(int64(counts.RxBytes)) + stats.RxPackets = a.statRxPackets.Add(int64(counts.RxPackets)) + stats.TxBytes = a.statTxBytes.Add(int64(counts.TxBytes)) + stats.TxPackets = a.statTxPackets.Add(int64(counts.TxPackets)) + } + + // Tailscale's connection stats are not cumulative, but it makes no sense to make + // ours temporary. + stats.SessionCountSSH = a.connCountSSHSession.Load() + stats.SessionCountVSCode = a.connCountVSCode.Load() + stats.SessionCountJetBrains = a.connCountJetBrains.Load() + stats.SessionCountReconnectingPTY = a.connCountReconnectingPTY.Load() + + // Compute the median connection latency! + var wg sync.WaitGroup + var mu sync.Mutex + status := a.network.Status() + durations := []float64{} + ctx, cancelFunc := context.WithTimeout(ctx, 5*time.Second) + defer cancelFunc() + for nodeID, peer := range status.Peer { + if !peer.Active { + continue + } + addresses, found := a.network.NodeAddresses(nodeID) + if !found { + continue + } + if len(addresses) == 0 { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + duration, _, _, err := a.network.Ping(ctx, addresses[0].Addr()) + if err != nil { + return + } + mu.Lock() + durations = append(durations, float64(duration.Microseconds())) + mu.Unlock() + }() + } + wg.Wait() + sort.Float64s(durations) + durationsLength := len(durations) + if durationsLength == 0 { + stats.ConnectionMedianLatencyMS = -1 + } else if durationsLength%2 == 0 { + stats.ConnectionMedianLatencyMS = (durations[durationsLength/2-1] + durations[durationsLength/2]) / 2 + } else { + stats.ConnectionMedianLatencyMS = durations[durationsLength/2] + } + // Convert from microseconds to milliseconds. + stats.ConnectionMedianLatencyMS /= 1000 + + select { + case a.connStatsChan <- stats: + default: + a.logger.Warn(ctx, "network stat dropped") + } + } + + // Report statistics from the created network. + cl, err := a.client.ReportStats(ctx, a.logger, a.connStatsChan, func(d time.Duration) { + a.network.SetConnStatsCallback(d, 2048, + func(_, _ time.Time, virtual, _ map[netlogtype.Connection]netlogtype.Counts) { + reportStats(virtual) + }, + ) + }) + if err != nil { + a.logger.Error(ctx, "report stats", slog.Error(err)) + } else { + if err = a.trackConnGoroutine(func() { + // This is OK because the agent never re-creates the tailnet + // and the only shutdown indicator is agent.Close(). + <-a.closed + _ = cl.Close() + }); err != nil { + a.logger.Debug(ctx, "report stats goroutine", slog.Error(err)) + _ = cl.Close() + } + } +} + // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { diff --git a/agent/agent_test.go b/agent/agent_test.go index 947f84d9543dc..0a72bbeaa10a1 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -73,7 +73,7 @@ func TestAgent_Stats_SSH(t *testing.T) { require.Eventuallyf(t, func() bool { var ok bool s, ok = <-stats - return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 + return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSSH == 1 }, testutil.WaitLong, testutil.IntervalFast, "never saw stats: %+v", s, ) @@ -102,7 +102,47 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) { require.Eventuallyf(t, func() bool { var ok bool s, ok = <-stats - return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 + return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountReconnectingPTY == 1 + }, testutil.WaitLong, testutil.IntervalFast, + "never saw stats: %+v", s, + ) +} + +func TestAgent_Stats_Magic(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + conn, _, stats, _ := setupAgent(t, agentsdk.Metadata{}, 0) + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshClient.Close() + session, err := sshClient.NewSession() + require.NoError(t, err) + session.Setenv(agent.MagicSSHSessionTypeEnvironmentVariable, agent.MagicSSHSessionTypeVSCode) + defer session.Close() + + command := "sh -c 'echo $" + agent.MagicSSHSessionTypeEnvironmentVariable + "'" + expected := "" + if runtime.GOOS == "windows" { + expected = "%" + agent.MagicSSHSessionTypeEnvironmentVariable + "%" + command = "cmd.exe /c echo " + expected + } + output, err := session.Output(command) + require.NoError(t, err) + require.Equal(t, expected, strings.TrimSpace(string(output))) + var s *agentsdk.Stats + require.Eventuallyf(t, func() bool { + var ok bool + s, ok = <-stats + return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && + // Ensure that the connection didn't count as a "normal" SSH session. + // This was a special one, so it should be labeled specially in the stats! + s.SessionCountVSCode == 1 && + // Ensure that connection latency is being counted! + // If it isn't, it's set to -1. + s.ConnectionMedianLatencyMS >= 0 }, testutil.WaitLong, testutil.IntervalFast, "never saw stats: %+v", s, ) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1feb9dcfea276..7e4b1c88f2815 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5201,17 +5201,21 @@ const docTemplate = `{ "agentsdk.Stats": { "type": "object", "properties": { - "conns_by_proto": { + "connection_count": { + "description": "ConnectionCount is the number of connections received by an agent.", + "type": "integer" + }, + "connection_median_latency_ms": { + "description": "ConnectionMedianLatencyMS is the median latency of all connections in milliseconds.", + "type": "number" + }, + "connections_by_proto": { "description": "ConnectionsByProto is a count of connections by protocol.", "type": "object", "additionalProperties": { "type": "integer" } }, - "num_comms": { - "description": "ConnectionCount is the number of connections received by an agent.", - "type": "integer" - }, "rx_bytes": { "description": "RxBytes is the number of received bytes.", "type": "integer" @@ -5220,6 +5224,22 @@ const docTemplate = `{ "description": "RxPackets is the number of received packets.", "type": "integer" }, + "session_count_jetbrains": { + "description": "SessionCountJetBrains is the number of connections received by an agent\nthat are from our JetBrains extension.", + "type": "integer" + }, + "session_count_reconnecting_pty": { + "description": "SessionCountReconnectingPTY is the number of connections received by an agent\nthat are from the reconnecting web terminal.", + "type": "integer" + }, + "session_count_ssh": { + "description": "SessionCountSSH is the number of connections received by an agent\nthat are normal, non-tagged SSH sessions.", + "type": "integer" + }, + "session_count_vscode": { + "description": "SessionCountVSCode is the number of connections received by an agent\nthat are from our VS Code extension.", + "type": "integer" + }, "tx_bytes": { "description": "TxBytes is the number of transmitted bytes.", "type": "integer" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d670f289bff79..06d51248e6a7c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4594,17 +4594,21 @@ "agentsdk.Stats": { "type": "object", "properties": { - "conns_by_proto": { + "connection_count": { + "description": "ConnectionCount is the number of connections received by an agent.", + "type": "integer" + }, + "connection_median_latency_ms": { + "description": "ConnectionMedianLatencyMS is the median latency of all connections in milliseconds.", + "type": "number" + }, + "connections_by_proto": { "description": "ConnectionsByProto is a count of connections by protocol.", "type": "object", "additionalProperties": { "type": "integer" } }, - "num_comms": { - "description": "ConnectionCount is the number of connections received by an agent.", - "type": "integer" - }, "rx_bytes": { "description": "RxBytes is the number of received bytes.", "type": "integer" @@ -4613,6 +4617,22 @@ "description": "RxPackets is the number of received packets.", "type": "integer" }, + "session_count_jetbrains": { + "description": "SessionCountJetBrains is the number of connections received by an agent\nthat are from our JetBrains extension.", + "type": "integer" + }, + "session_count_reconnecting_pty": { + "description": "SessionCountReconnectingPTY is the number of connections received by an agent\nthat are from the reconnecting web terminal.", + "type": "integer" + }, + "session_count_ssh": { + "description": "SessionCountSSH is the number of connections received by an agent\nthat are normal, non-tagged SSH sessions.", + "type": "integer" + }, + "session_count_vscode": { + "description": "SessionCountVSCode is the number of connections received by an agent\nthat are from our VS Code extension.", + "type": "integer" + }, "tx_bytes": { "description": "TxBytes is the number of transmitted bytes.", "type": "integer" diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index a4693643bd433..7968175fe0f51 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -272,18 +272,23 @@ func (q *fakeQuerier) InsertWorkspaceAgentStat(_ context.Context, p database.Ins defer q.mutex.Unlock() stat := database.WorkspaceAgentStat{ - ID: p.ID, - CreatedAt: p.CreatedAt, - WorkspaceID: p.WorkspaceID, - AgentID: p.AgentID, - UserID: p.UserID, - ConnectionsByProto: p.ConnectionsByProto, - ConnectionCount: p.ConnectionCount, - RxPackets: p.RxPackets, - RxBytes: p.RxBytes, - TxPackets: p.TxPackets, - TxBytes: p.TxBytes, - TemplateID: p.TemplateID, + ID: p.ID, + CreatedAt: p.CreatedAt, + WorkspaceID: p.WorkspaceID, + AgentID: p.AgentID, + UserID: p.UserID, + ConnectionsByProto: p.ConnectionsByProto, + ConnectionCount: p.ConnectionCount, + RxPackets: p.RxPackets, + RxBytes: p.RxBytes, + TxPackets: p.TxPackets, + TxBytes: p.TxBytes, + TemplateID: p.TemplateID, + SessionCountVSCode: p.SessionCountVSCode, + SessionCountJetBrains: p.SessionCountJetBrains, + SessionCountReconnectingPTY: p.SessionCountReconnectingPTY, + SessionCountSSH: p.SessionCountSSH, + ConnectionMedianLatencyMS: p.ConnectionMedianLatencyMS, } q.workspaceAgentStats = append(q.workspaceAgentStats, stat) return stat, nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e182ad382370e..bccf259df243a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -474,7 +474,12 @@ CREATE TABLE workspace_agent_stats ( rx_packets bigint DEFAULT 0 NOT NULL, rx_bytes bigint DEFAULT 0 NOT NULL, tx_packets bigint DEFAULT 0 NOT NULL, - tx_bytes bigint DEFAULT 0 NOT NULL + tx_bytes bigint DEFAULT 0 NOT NULL, + connection_median_latency_ms bigint DEFAULT '-1'::integer NOT NULL, + session_count_vscode bigint DEFAULT 0 NOT NULL, + session_count_jetbrains bigint DEFAULT 0 NOT NULL, + session_count_reconnecting_pty bigint DEFAULT 0 NOT NULL, + session_count_ssh bigint DEFAULT 0 NOT NULL ); CREATE TABLE workspace_agents ( diff --git a/coderd/database/migrations/000102_workspace_agent_stats_types.down.sql b/coderd/database/migrations/000102_workspace_agent_stats_types.down.sql new file mode 100644 index 0000000000000..989a171cdcf28 --- /dev/null +++ b/coderd/database/migrations/000102_workspace_agent_stats_types.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE workspace_agent_stats DROP COLUMN session_count_vscode, + DROP COLUMN session_count_jetbrains, + DROP COLUMN session_count_reconnecting_pty, + DROP COLUMN session_count_ssh, + DROP COLUMN connection_median_latency_ms; diff --git a/coderd/database/migrations/000102_workspace_agent_stats_types.up.sql b/coderd/database/migrations/000102_workspace_agent_stats_types.up.sql new file mode 100644 index 0000000000000..39effadfee42c --- /dev/null +++ b/coderd/database/migrations/000102_workspace_agent_stats_types.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE workspace_agent_stats ADD COLUMN connection_median_latency_ms bigint DEFAULT -1 NOT NULL; +ALTER TABLE workspace_agent_stats ADD COLUMN session_count_vscode bigint DEFAULT 0 NOT NULL; +ALTER TABLE workspace_agent_stats ADD COLUMN session_count_jetbrains bigint DEFAULT 0 NOT NULL; +ALTER TABLE workspace_agent_stats ADD COLUMN session_count_reconnecting_pty bigint DEFAULT 0 NOT NULL; +ALTER TABLE workspace_agent_stats ADD COLUMN session_count_ssh bigint DEFAULT 0 NOT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index 1467b95ef1e0e..9dfc6671716ba 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1549,18 +1549,23 @@ type WorkspaceAgent struct { } type WorkspaceAgentStat struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - ConnectionsByProto json.RawMessage `db:"connections_by_proto" json:"connections_by_proto"` - ConnectionCount int64 `db:"connection_count" json:"connection_count"` - RxPackets int64 `db:"rx_packets" json:"rx_packets"` - RxBytes int64 `db:"rx_bytes" json:"rx_bytes"` - TxPackets int64 `db:"tx_packets" json:"tx_packets"` - TxBytes int64 `db:"tx_bytes" json:"tx_bytes"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + ConnectionsByProto json.RawMessage `db:"connections_by_proto" json:"connections_by_proto"` + ConnectionCount int64 `db:"connection_count" json:"connection_count"` + RxPackets int64 `db:"rx_packets" json:"rx_packets"` + RxBytes int64 `db:"rx_bytes" json:"rx_bytes"` + TxPackets int64 `db:"tx_packets" json:"tx_packets"` + TxBytes int64 `db:"tx_bytes" json:"tx_bytes"` + ConnectionMedianLatencyMS int64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` + SessionCountVSCode int64 `db:"session_count_vscode" json:"session_count_vscode"` + SessionCountJetBrains int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` + SessionCountReconnectingPTY int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` + SessionCountSSH int64 `db:"session_count_ssh" json:"session_count_ssh"` } type WorkspaceApp struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c05b97727a368..60541499003a0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5416,25 +5416,35 @@ INSERT INTO rx_packets, rx_bytes, tx_packets, - tx_bytes + tx_bytes, + session_count_vscode, + session_count_jetbrains, + session_count_reconnecting_pty, + session_count_ssh, + connection_median_latency_ms ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, user_id, agent_id, workspace_id, template_id, connections_by_proto, connection_count, rx_packets, rx_bytes, tx_packets, tx_bytes + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, created_at, user_id, agent_id, workspace_id, template_id, connections_by_proto, connection_count, rx_packets, rx_bytes, tx_packets, tx_bytes, connection_median_latency_ms, session_count_vscode, session_count_jetbrains, session_count_reconnecting_pty, session_count_ssh ` type InsertWorkspaceAgentStatParams struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - ConnectionsByProto json.RawMessage `db:"connections_by_proto" json:"connections_by_proto"` - ConnectionCount int64 `db:"connection_count" json:"connection_count"` - RxPackets int64 `db:"rx_packets" json:"rx_packets"` - RxBytes int64 `db:"rx_bytes" json:"rx_bytes"` - TxPackets int64 `db:"tx_packets" json:"tx_packets"` - TxBytes int64 `db:"tx_bytes" json:"tx_bytes"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + ConnectionsByProto json.RawMessage `db:"connections_by_proto" json:"connections_by_proto"` + ConnectionCount int64 `db:"connection_count" json:"connection_count"` + RxPackets int64 `db:"rx_packets" json:"rx_packets"` + RxBytes int64 `db:"rx_bytes" json:"rx_bytes"` + TxPackets int64 `db:"tx_packets" json:"tx_packets"` + TxBytes int64 `db:"tx_bytes" json:"tx_bytes"` + SessionCountVSCode int64 `db:"session_count_vscode" json:"session_count_vscode"` + SessionCountJetBrains int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` + SessionCountReconnectingPTY int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` + SessionCountSSH int64 `db:"session_count_ssh" json:"session_count_ssh"` + ConnectionMedianLatencyMS int64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` } func (q *sqlQuerier) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) { @@ -5451,6 +5461,11 @@ func (q *sqlQuerier) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWor arg.RxBytes, arg.TxPackets, arg.TxBytes, + arg.SessionCountVSCode, + arg.SessionCountJetBrains, + arg.SessionCountReconnectingPTY, + arg.SessionCountSSH, + arg.ConnectionMedianLatencyMS, ) var i WorkspaceAgentStat err := row.Scan( @@ -5466,6 +5481,11 @@ func (q *sqlQuerier) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWor &i.RxBytes, &i.TxPackets, &i.TxBytes, + &i.ConnectionMedianLatencyMS, + &i.SessionCountVSCode, + &i.SessionCountJetBrains, + &i.SessionCountReconnectingPTY, + &i.SessionCountSSH, ) return i, err } diff --git a/coderd/database/queries/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 7c24df75698db..57225711bde7e 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -12,10 +12,15 @@ INSERT INTO rx_packets, rx_bytes, tx_packets, - tx_bytes + tx_bytes, + session_count_vscode, + session_count_jetbrains, + session_count_reconnecting_pty, + session_count_ssh, + connection_median_latency_ms ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *; -- name: GetTemplateDAUs :many SELECT diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index e708a6c4ca065..709ee213aca20 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -25,6 +25,11 @@ overrides: api_key_scope_all: APIKeyScopeAll api_key_scope_application_connect: APIKeyScopeApplicationConnect avatar_url: AvatarURL + session_count_vscode: SessionCountVSCode + session_count_jetbrains: SessionCountJetBrains + session_count_reconnecting_pty: SessionCountReconnectingPTY + session_count_ssh: SessionCountSSH + connection_median_latency_ms: ConnectionMedianLatencyMS login_type_oidc: LoginTypeOIDC oauth_access_token: OAuthAccessToken oauth_expiry: OAuthExpiry diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 38fb311a4441d..20202849aa47f 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -941,18 +941,23 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques now := database.Now() _, err = api.Database.InsertWorkspaceAgentStat(ctx, database.InsertWorkspaceAgentStatParams{ - ID: uuid.New(), - CreatedAt: now, - AgentID: workspaceAgent.ID, - WorkspaceID: workspace.ID, - UserID: workspace.OwnerID, - TemplateID: workspace.TemplateID, - ConnectionsByProto: payload, - ConnectionCount: req.ConnectionCount, - RxPackets: req.RxPackets, - RxBytes: req.RxBytes, - TxPackets: req.TxPackets, - TxBytes: req.TxBytes, + ID: uuid.New(), + CreatedAt: now, + AgentID: workspaceAgent.ID, + WorkspaceID: workspace.ID, + UserID: workspace.OwnerID, + TemplateID: workspace.TemplateID, + ConnectionsByProto: payload, + ConnectionCount: req.ConnectionCount, + RxPackets: req.RxPackets, + RxBytes: req.RxBytes, + TxPackets: req.TxPackets, + TxBytes: req.TxBytes, + SessionCountVSCode: req.SessionCountVSCode, + SessionCountJetBrains: req.SessionCountJetBrains, + SessionCountReconnectingPTY: req.SessionCountReconnectingPTY, + SessionCountSSH: req.SessionCountSSH, + ConnectionMedianLatencyMS: int64(req.ConnectionMedianLatencyMS), }) if err != nil { httpapi.InternalServerError(rw, err) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a0cfd57cc9b62..46dd945296b65 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1178,12 +1178,17 @@ func TestWorkspaceAgentReportStats(t *testing.T) { agentClient.SetSessionToken(authToken) _, err := agentClient.PostStats(context.Background(), &agentsdk.Stats{ - ConnectionsByProto: map[string]int64{"TCP": 1}, - ConnectionCount: 1, - RxPackets: 1, - RxBytes: 1, - TxPackets: 1, - TxBytes: 1, + ConnectionsByProto: map[string]int64{"TCP": 1}, + ConnectionCount: 1, + RxPackets: 1, + RxBytes: 1, + TxPackets: 1, + TxBytes: 1, + SessionCountVSCode: 1, + SessionCountJetBrains: 1, + SessionCountReconnectingPTY: 1, + SessionCountSSH: 1, + ConnectionMedianLatencyMS: 10, }) require.NoError(t, err) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 3d39593dcc6a7..8d43e0013a390 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -427,9 +427,11 @@ func (c *Client) ReportStats(ctx context.Context, log slog.Logger, statsChan <-c // user-facing metrics and debugging. type Stats struct { // ConnectionsByProto is a count of connections by protocol. - ConnectionsByProto map[string]int64 `json:"conns_by_proto"` + ConnectionsByProto map[string]int64 `json:"connections_by_proto"` // ConnectionCount is the number of connections received by an agent. - ConnectionCount int64 `json:"num_comms"` + ConnectionCount int64 `json:"connection_count"` + // ConnectionMedianLatencyMS is the median latency of all connections in milliseconds. + ConnectionMedianLatencyMS float64 `json:"connection_median_latency_ms"` // RxPackets is the number of received packets. RxPackets int64 `json:"rx_packets"` // RxBytes is the number of received bytes. @@ -438,6 +440,19 @@ type Stats struct { TxPackets int64 `json:"tx_packets"` // TxBytes is the number of transmitted bytes. TxBytes int64 `json:"tx_bytes"` + + // SessionCountVSCode is the number of connections received by an agent + // that are from our VS Code extension. + SessionCountVSCode int64 `json:"session_count_vscode"` + // SessionCountJetBrains is the number of connections received by an agent + // that are from our JetBrains extension. + SessionCountJetBrains int64 `json:"session_count_jetbrains"` + // SessionCountReconnectingPTY is the number of connections received by an agent + // that are from the reconnecting web terminal. + SessionCountReconnectingPTY int64 `json:"session_count_reconnecting_pty"` + // SessionCountSSH is the number of connections received by an agent + // that are normal, non-tagged SSH sessions. + SessionCountSSH int64 `json:"session_count_ssh"` } type StatsResponse struct { diff --git a/docs/api/agents.md b/docs/api/agents.md index ffea8e40065f3..1da3536e9c08c 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -401,13 +401,18 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/me/report-stats \ ```json { - "conns_by_proto": { + "connection_count": 0, + "connection_median_latency_ms": 0, + "connections_by_proto": { "property1": 0, "property2": 0 }, - "num_comms": 0, "rx_bytes": 0, "rx_packets": 0, + "session_count_jetbrains": 0, + "session_count_reconnecting_pty": 0, + "session_count_ssh": 0, + "session_count_vscode": 0, "tx_bytes": 0, "tx_packets": 0 } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index fe21b9f74c101..4952fd82a43a0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -248,13 +248,18 @@ ```json { - "conns_by_proto": { + "connection_count": 0, + "connection_median_latency_ms": 0, + "connections_by_proto": { "property1": 0, "property2": 0 }, - "num_comms": 0, "rx_bytes": 0, "rx_packets": 0, + "session_count_jetbrains": 0, + "session_count_reconnecting_pty": 0, + "session_count_ssh": 0, + "session_count_vscode": 0, "tx_bytes": 0, "tx_packets": 0 } @@ -262,15 +267,20 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------- | -------- | ------------ | ------------------------------------------------------------ | -| `conns_by_proto` | object | false | | Conns by proto is a count of connections by protocol. | -| » `[any property]` | integer | false | | | -| `num_comms` | integer | false | | Num comms is the number of connections received by an agent. | -| `rx_bytes` | integer | false | | Rx bytes is the number of received bytes. | -| `rx_packets` | integer | false | | Rx packets is the number of received packets. | -| `tx_bytes` | integer | false | | Tx bytes is the number of transmitted bytes. | -| `tx_packets` | integer | false | | Tx packets is the number of transmitted bytes. | +| Name | Type | Required | Restrictions | Description | +| -------------------------------- | ------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `connection_count` | integer | false | | Connection count is the number of connections received by an agent. | +| `connection_median_latency_ms` | number | false | | Connection median latency ms is the median latency of all connections in milliseconds. | +| `connections_by_proto` | object | false | | Connections by proto is a count of connections by protocol. | +| » `[any property]` | integer | false | | | +| `rx_bytes` | integer | false | | Rx bytes is the number of received bytes. | +| `rx_packets` | integer | false | | Rx packets is the number of received packets. | +| `session_count_jetbrains` | integer | false | | Session count jetbrains is the number of connections received by an agent that are from our JetBrains extension. | +| `session_count_reconnecting_pty` | integer | false | | Session count reconnecting pty is the number of connections received by an agent that are from the reconnecting web terminal. | +| `session_count_ssh` | integer | false | | Session count ssh is the number of connections received by an agent that are normal, non-tagged SSH sessions. | +| `session_count_vscode` | integer | false | | Session count vscode is the number of connections received by an agent that are from our VS Code extension. | +| `tx_bytes` | integer | false | | Tx bytes is the number of transmitted bytes. | +| `tx_packets` | integer | false | | Tx packets is the number of transmitted bytes. | ## agentsdk.StatsResponse diff --git a/tailnet/conn.go b/tailnet/conn.go index 6b19058691d0b..c9c74961d1ea3 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -447,6 +447,18 @@ func (c *Conn) UpdateNodes(nodes []*Node, replacePeers bool) error { return nil } +// NodeAddresses returns the addresses of a node from the NetworkMap. +func (c *Conn) NodeAddresses(publicKey key.NodePublic) ([]netip.Prefix, bool) { + c.mutex.Lock() + defer c.mutex.Unlock() + for _, node := range c.netMap.Peers { + if node.Key == publicKey { + return node.Addresses, true + } + } + return nil, false +} + // Status returns the current ipnstate of a connection. func (c *Conn) Status() *ipnstate.Status { sb := &ipnstate.StatusBuilder{WantPeers: true}