Files
coder/coderd/agentapi/stats.go
T
Ehab Younes 9974294ca9 feat: report arbitrary workspace agent session types (#28338)
Report session counts as a dynamic app-name map from the agent, the SSH CLI, and the workspace usage API, so a new IDE flows end to end without a schema, proto, or server change.

- Report through `Stats.session_counts` on Agent API v2.11. Older agents keep reporting through the deprecated count fields.
- Accept arbitrary names from `coder ssh --usage-app` and the workspace usage API. A name the registry does not recognize keeps its identity and is attributed to the `unknown` family.
- Add reconnecting PTY counts to the map without overwriting the counts SSH reported.
- Normalize and merge names before applying the deterministic 64-name cap, ordered by known apps, then count, then name. Overflow sums into `unknown`, and non-positive counts cannot cancel valid activity.
- Strip control characters, treat whitespace-only HTTP app names as absent, and require the exact environment variable prefix when you extract an SSH session type.
- Count overflow entries in `coderd_agentstats_session_counts_overflow_total` and keep raw payloads out of logs.
- Change the usage API's `app_name` to a string, and update the SDK types and API reference.

closes #27410
2026-09-19 00:32:51 +02:00

91 lines
2.6 KiB
Go

package agentapi
import (
"context"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/durationpb"
"cdr.dev/slog/v3"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/codersdk"
)
type StatsAPI struct {
AgentID uuid.UUID
AgentName string
Workspace *CachedWorkspaceFields
Database database.Store
Log slog.Logger
StatsReporter *workspacestats.Reporter
AgentStatsRefreshInterval time.Duration
Experiments codersdk.Experiments
TimeNowFn func() time.Time // defaults to dbtime.Now()
}
func (a *StatsAPI) now() time.Time {
if a.TimeNowFn != nil {
return a.TimeNowFn()
}
return dbtime.Now()
}
func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
res := &agentproto.UpdateStatsResponse{
ReportInterval: durationpb.New(a.AgentStatsRefreshInterval),
}
// An empty stat means it's just looking for the report interval.
if req.Stats == nil {
return res, nil
}
// If cache is empty (prebuild or invalid), fall back to DB
var ws database.WorkspaceIdentity
var ok bool
if ws, ok = a.Workspace.AsWorkspaceIdentity(); !ok {
w, err := a.Database.GetWorkspaceByAgentID(ctx, a.AgentID)
if err != nil {
return nil, xerrors.Errorf("get workspace by agent ID %q: %w", a.AgentID, err)
}
ws = database.WorkspaceIdentityFromWorkspace(w)
}
// The payload is unnormalized and unbounded until the batcher caps it, so
// log scalars rather than its contents.
a.Log.Debug(ctx, "read stats report",
slog.F("interval", a.AgentStatsRefreshInterval),
slog.F("workspace_id", ws.ID),
slog.F("connection_count", req.Stats.GetConnectionCount()),
slog.F("session_count_keys", len(req.Stats.GetSessionCounts())),
slog.F("connections_by_proto_keys", len(req.Stats.GetConnectionsByProto())),
)
if a.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) {
// while the experiment is enabled we will not report
// session stats from the agent. This is because it is
// being handled by the CLI and the postWorkspaceUsage route.
workspacestats.ClearSessionCounts(req.Stats)
}
err := a.StatsReporter.ReportAgentStats(
ctx,
a.now(),
ws,
a.AgentID,
a.AgentName,
req.Stats,
false,
)
if err != nil {
return nil, xerrors.Errorf("report agent stats: %w", err)
}
return res, nil
}