Skip to content

feat: normalize workspace agent session counts into a child table - #27179

Closed
EhabY wants to merge 1 commit into
mainfrom
feat/normalized-session-counts
Closed

feat: normalize workspace agent session counts into a child table#27179
EhabY wants to merge 1 commit into
mainfrom
feat/normalized-session-counts

Conversation

@EhabY

@EhabY EhabY commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Adding a new IDE session type (Cursor, Windsurf, ...) currently requires changes to 15+ files across migrations, proto, server, SDK, and UI. This PR replaces the fixed session_count_* columns on the ephemeral workspace_agent_stats table with a normalized workspace_agent_session_counts child table keyed by app name, and makes the agent report session counts as a dynamic proto map. New session types then flow end-to-end (agent counters → stats pipeline → template usage rollup) with zero schema, proto, or server changes.

Phase 1 of the Scalable Approach for Adding New IDE Session Types RFC. External surfaces are intentionally unchanged: all read queries keep their row shapes, so the deployment stats API, Prometheus gauge names, and telemetry snapshots are byte-identical. Later phases add the API map field and family-labeled metrics (2), migrate connection_logs off its type enum (3), merge template_usage_stats *_mins into app_usage_mins (4), and version-gate the IDE clients (5).

NOTE: The migration copies the ephemeral stats buffer (~1 day of rows, bounded by dbpurge) into the child table, drops the four session_count_* columns, and recreates the insights covering index without them. There is no reporting gap. The down migration restores the four canonical app names.

Key changes:

  • workspace_agent_session_counts (workspace_agent_stats_id, created_at, app_name, count) with ON DELETE CASCADE and no CHECK constraint. created_at is copied from the parent row and carries a BRIN index, so windowed reads prune the child table instead of scanning it. App names are normalized at ingestion by idemetadata.Normalize(): null bytes stripped, truncated to 64 runes, lowercased, and - folded to _ (the fold keeps the SDK's legacy reconnecting-pty spelling aggregating into the canonical reconnecting_pty). Everything else is preserved, so arbitrary names flow through.
  • Proto Stats.session_counts map (field 13); fields 8–11 are deprecated and converted server-side by workspacestats.SessionCountsFromProto, so old agents keep working through the upgrade window.
  • Reports are capped at 64 distinct app names; overflow counts are aggregated under unknown (well-known names always kept, remainder chosen deterministically). This bounds child-table fan-out from malicious or buggy agents.
  • The agent counts sessions per session type via sync.Map; JetBrains keeps its channel-watcher special case via family lookup; agent session metrics are labeled by family to keep cardinality bounded.
  • New coderd/idemetadata leaf package: canonical app names, Normalize(), and the family map (cursor/windsurf/... → vscode). Display names are added at the API/UI boundary in phase 2 from the same package.
  • The batcher passes session counts as a JSONB array (same pattern as connections_by_proto), exploded into the child table within the same insert statement.
  • The workspace usage API and coder ssh --usage-app accept arbitrary app names; codersdk.AllowedAppNames is removed.

Behavior changes live as of this phase: the workspace usage API accepts and stores (normalized) unknown app names instead of returning 400, coder ssh --usage-app passes them through instead of coercing to ssh, and unknown SSH session types are counted instead of silently dropped. Bundled IDE clients keep sending only the canonical names until the phase-5 gate flips.

Benchmarks

Measured on PostgreSQL 13 with 1.73M stats rows (300 agents × 24h @ 15s) and 1.3M child rows; results byte-identical to the old queries in every case. The rollup and insights queries originally used per-row lateral probes and regressed 7.6×/7.7×; rewriting them as direct hash joins (the inclusion filter becomes the join, DISTINCT/MAX aggregates tolerate the fan-out) recovered most of it:

Query (cadence) Old New Notes
Deployment stats, 15m window (~30s) 10.9 ms 14.4 ms lateral probes only rn=1 rows
Prometheus CTE, 5m window (~1m) 4.2 ms 4.1 ms parity
Rollup CTE, 1h window (5m) 41.6 ms 98.9 ms hash join; scales with child table size, bounded by ~1-day purge
Insights by template, 24h window (5–15m) 395 ms 659 ms hash join of two seq scans
Insert 1024-row batch 12.3 ms 19.5 ms +0.7 child rows/stat avg

A covering INCLUDE (count) index was evaluated and rejected: the hash-join plans never touch it, so it would be pure write overhead. Total storage at this scale dropped 697 MB → 666 MB, since the 40% of rows with no session activity now store nothing.

BRIN validation (follow-up run at the same scale, different machine, so absolute numbers are not comparable with the table above): toggling the created_at BRIN, the 1h rollup CTE reads only the window slice of the child table via a bitmap scan (~50k of 1.3M rows) instead of a full seq scan, 163 → 146 ms end-to-end; parent-side aggregation dominates at this scale, and the gap grows as the child table outgrows the window (e.g. the 180-day fresh-deployment buffer). The 24h insights window spans the entire ~1-day table, so its plan and cost are unchanged, and the deployment/Prometheus queries probe the child PK directly and never touch the BRIN. Prune selectivity degrades as vacuum recycles purged pages into new inserts; the floor is the pre-BRIN seq scan, bounded by the ~1-day purge.

Risks and mitigations

  • Write amplification: up to one child row per active session type per stats row (only counts > 0 are stored; idle rows store nothing, previously four zero bigints). Bounded by the ~1 day retention, the 64-name cap, and cascade on purge. Net storage decreased in the benchmark.
  • Query plans: benchmarked above; the two window-scan queries pay an inherent second-table join (2.4×/1.7×, ~100/~660 ms absolute at 1.7M rows) and everything else is at parity. The BRIN on created_at lets short-window reads prune the child table to their window (measured in the BRIN validation note above); if BRIN selectivity degrades with page churn, the floor is the full child scan, bounded by the ~1-day purge.
  • Positional contract: the batch insert zips the JSONB session counts array with the ID array by position; Add() appends to both buffers unconditionally, so the lengths cannot diverge.
  • Clean-cut rollback: the down migration restores only the four canonical names and silently discards any others. Non-canonical rows can exist as soon as this ships (any authenticated caller can write them through the usage API), so a rollback drops them; exposure is bounded by the ~1-day purge. Bundled clients send only canonical names until phase 5.
  • Open name space: bounded by auth (workspace owners can only spam their own stats, same as today), the 64-rune name cap, the 64-entry report cap with unknown aggregation, and the ~1-day purge. Prometheus is unaffected (family labels).
Implementation plan and decision log

Goal

Replace the four fixed session_count_* columns on the ephemeral workspace_agent_stats table with a normalized child table, make the agent report session counts dynamically via a proto map, and keep every external surface byte-identical (API JSON, Prometheus metric names, telemetry snapshot shape).

Non-goals (later phases)

  • New API map field, labeled Prometheus metric, UI changes (Phase 2)
  • connection_logs ENUM to TEXT migration, proto Connection.type_str (Phase 3)
  • template_usage_stats *_mins merge into JSONB + sftp_mins drop (Phase 4)
  • vscode-coder / jetbrains-coder client changes, FeatureSet gate (Phase 5)

Phase 1 invariant: bundled clients send only canonical app names (gated on the Phase 3 release). The usage API and CLI accept arbitrary names as of this phase, but nothing sends them by default, so rollout has no observable behavior change.

Design summary

  • Migration (clean cut): create table → copy ~1 day of rows (WHERE session_count_<name> > 0 per column, UNION ALL) → drop covering index → drop columns → recreate index with only the connection_median_latency_ms INCLUDE. Down migration re-adds columns, backfills the canonical four, restores the original index. Fixture seeds parent + child rows including a non-canonical name.
  • Proto: map<string, int64> session_counts = 13; fields 8–11 [deprecated = true], never reserved/removed. Documented as Agent API v2.11 in tailnet/proto/version.go. New agents populate the map only.
  • Agent: extractMagicSessionType() normalizes (null-strip, truncate, lowercase, hyphen fold) instead of classifying; empty stays ssh. Dynamic sync.Map counters; SessionCounts() returns map[string]int64; JetbrainsChannelWatcher takes the counter from the same store; ReportConnection and session metric labels map by idemetadata.Family().
  • Server: SessionCountsFromProto is the single old-agent conversion point (and applies normalization plus the 64-entry cap with unknown aggregation), used by the batcher, the reporter activity check, and (via ClearSessionCounts) the ExperimentWorkspaceUsage zeroing. postWorkspaceUsage normalizes and stores any app name, replacing the allowlist 400 and the per-app switch.
  • Batcher: second JSONB side-buffer mirroring connectionsByProto; parent + child inserted in one CTE statement (jsonb_array_elements zipped with unnest(@id), jsonb_each_text explodes each map, > 0 filter).
  • Queries: the SUM-based read queries use LEFT JOIN LATERAL per-stats-row pre-aggregation (probing only latest-row subsets); the rollup and insights-by-template CTEs use a direct child join because their aggregates are DISTINCT/MAX (fan-out tolerant) and their inclusion filter is the join itself. Row shapes and output aliases preserved (load-bearing for positional struct conversions in metricscache/prometheusmetrics). UpsertTemplateUsageStats still writes the dedicated *_mins columns until Phase 4. DeleteOldWorkspaceAgentStats unchanged (cascade).
  • Store layers: no new querier methods (child reads/writes embedded in existing statements) → no dbauthz changes. dbgen.WorkspaceAgentStat seeds counts via a variadic map.

Decisions (resolved during planning and implementation review)

  1. Metadata package location: internal leaf package coderd/idemetadata (importable from agent/, no heavy deps). Promote to codersdk only if external consumers ever need it.
  2. codersdk.AllowedAppNames: removed outright; it only fed the 400 path. UsageAppName constants stay.
  3. Proto: map-only from new agents; deprecated fields populated only by old agents and converted server-side.
  4. Normalization (revised during implementation review, supersedes the earlier "preserve casing" draft): names are lowercased with - folded to _ at ingestion. The hyphen fold exists to keep the SDK's legacy reconnecting-pty spelling aggregating into reconnecting_pty; lowercasing keeps Cursor/cursor as one row. No regex sanitizer and no CHECK constraints, per the RFC review thread.
  5. Overflow aggregation (revised): reports beyond 64 distinct names aggregate into unknown, keeping totals accurate while bounding row fan-out.

File-by-file

Area Files Change
Migration coderd/database/migrations/000545_*.{up,down}.sql + fixture New table, copy, drop columns, index swap
Queries queries/workspaceagentstats.sql, queries/insights.sql 8 rewrites
Generated queries.sql.go, models.go, querier.go, dbmock, dbmetrics, dump.sql, agent.pb.go make gen
Proto agent/proto/agent.proto, tailnet/proto/version.go map field 13, deprecate 8–11, v2.11
Agent agent/agentssh/{agentssh,metrics}.go, agent/agent.go dynamic counters, normalization, family labels, map reporting
Shared coderd/idemetadata (new) canonical names, Normalize(), family map
Server coderd/agentapi/stats.go, coderd/workspacestats/{batcher,reporter,sessioncounts}.go, coderd/workspaces.go fallback conversion + cap, map activity check, JSONB side-buffer, usage endpoint relaxation
SDK/CLI codersdk/workspaces.go, cli/ssh.go allowlist removal, usage-app passthrough
DB helpers coderd/database/dbgen/dbgen.go map-based seeding

🤖 This PR was generated by Coder Agents on behalf of @EhabY.

EhabY

This comment was marked as outdated.

@EhabY
EhabY force-pushed the feat/normalized-session-counts branch 4 times, most recently from 95ffdd9 to 45df97f Compare July 16, 2026 10:56
@EhabY

EhabY commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-22 11:59 UTC by @EhabY
Spend: $132.05 / $100.00

Review history
  • R1 (2026-07-16): 19 reviewers, 11 Nit, 7 Note, 1 P2, 4 P3, COMMENT. Review
  • R2 (2026-07-21): 16 reviewers, 14 Nit, 11 Note, 1 P2, 6 P3, COMMENT. Review
  • R3 (2026-07-22), 14 Nit, 11 Note, 1 P2, 6 P3, COMMENT. Review
  • R4 (2026-07-22): 12 reviewers, 15 Nit, 12 Note, 1 P2, 6 P3, APPROVE. Review

deep-review v0.9.0 | Round 4 | 6ec45f7..0a4509c

Last posted: Round 4, 34 findings (1 P2, 6 P3, 15 Nit, 12 Note), APPROVE. Review

Finding inventory

Finding inventory — PR #27179

Law analysis

  • Effective LOC: +1185 -617 (production ~635, generated 1116 cascade). Below 3000 mandatory threshold.
  • Head SHA: 45df97f
  • Verdict: Don't split. Enforcement: Advisory.
  • Rationale: coordinated atomic pipeline refactor (agent counters -> proto -> ingestion -> child table -> rollup/insights) held by a byte-identical external-surface invariant. Concerns 7 (usage endpoint) and 8 (syncmap fix) checked and both coupled.

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Author fixed (d0be576) agent/agentssh/agentssh.go:331 Agent per-session-type counter map grows unbounded; server 64-cap does not protect agent R1 Netero; Kurapika/Ryosuke/Mafuuu concur Yes
CRF-2 Note Author fixed (d0be576) coderd/idemetadata/idemetadata.go:69 Normalize does not trim/collapse whitespace; whitespace variants fragment rows and family grouping R1 Netero Yes
CRF-3 P3 Author fixed (d0be576) agent/agent.go:2160 reconnecting_pty count assigned with = not +=, clobbering the SSH-path count for the same key R1 Hisoka P3, Knov P3, Melody P3 Yes
CRF-4 P3 Author fixed (d0be576) coderd/workspacestats/sessioncounts.go:76 64-name overflow fold into unknown emits no log/metric; the misbehaving agent it defends against is undetectable R1 Chopper Yes
CRF-5 P3 Author fixed (d0be576) coderd/workspaces.go:1783 "external surfaces unchanged / exposure window empty" premise already false at phase 1: API 400->204 and CLI passthrough accept arbitrary names now R1 Pariston P3; Melody/Knov/Razor Note concur Yes
CRF-6 P2 Author fixed (d0be576) coderd/workspacestats/batcher_internal_test.go:77 Batcher session-count flush (the PR's #1 stated risk, positional zip) runs in tests but no session-count value is ever asserted R1 Bisky Yes
CRF-7 Nit Author fixed (d0be576) coderd/workspacestats/sessioncounts.go:46 capSessionCounts can emit 65 entries, contradicting its "at most maxSessionCountEntries" comment R1 Mafuuu/Knov/Melody Nit, Hisoka Note Yes
CRF-8 Nit Author fixed (d0be576) coderd/database/migrations/000544_workspace_agent_session_counts.up.sql:4 count bigint NOT NULL DEFAULT 0 is a dead default that mismodels the count>0 invariant; drop DEFAULT or add CHECK R1 Zoro Nit, Knuckle Note Yes
CRF-9 Nit Author fixed (d0be576) agent/proto/agent.proto:198 session_counts doc says keys are "raw app identifier" but a v2.11 agent sends normalized keys R1 Zoro Nit, Mafuuu Note Yes
CRF-10 Nit Author fixed (d0be576) codersdk/workspaces.go:387 SDK comment claims unrecognized names are "stored raw"; they are normalized at ingestion R1 Razor Yes
CRF-11 Nit Author fixed (d0be576) coderd/workspaces_test.go:5137 Test comment says "accepted and stored raw" but assertions only check NoError; value never read back R1 Bisky Note, Chopper Nit Yes
CRF-12 Nit Author fixed (d0be576) cli/ssh.go:1539 Comment narrates mechanism / restates the return instead of explaining why validation is absent R1 Gon (raised P2) Yes
CRF-13 Nit Author fixed (d0be576) agent/agentssh/agentssh.go:337 ConnStats() now returns a session-count map; name still says connection stats. Rename SessionCounts() R1 Gon Yes
CRF-14 Nit Author fixed (d0be576) coderd/workspacestats/sessioncounts.go:29 Loop variable l is meaningless and reads like a digit; rename R1 Gon Yes
CRF-15 Nit Author fixed (d0be576) coderd/idemetadata/idemetadata.go:25 families doc says it groups "canonical app names" but most keys are aliases, not canonical names R1 Leorio Yes
CRF-16 Nit Author fixed (d0be576) coderd/idemetadata/idemetadata.go:43 AppNameUnknown: AppNameUnknown families entry is redundant with Family's fallback R1 Zoro Yes
CRF-17 Nit Author fixed (d0be576) coderd/workspacestats/sessioncounts_test.go:60 AllZeroMapFallsBackToLegacyFields does not exercise the fallback it names; both paths yield empty R1 Bisky Yes
CRF-18 Note Author fixed (d0be576) coderd/database/queries/insights.sql:177 insights/rollup still hardcode the four canonical names; "zero server changes for new types" only partial. Phase 4 must precede phase 5 or minutes drop R1 Pariston/Knov/Melody Yes
CRF-19 Note Author fixed (d0be576) coderd/database/migrations/000544_workspace_agent_session_counts.up.sql:26 Backfill + column drops + index rebuild run in one transaction; ACCESS EXCLUSIVE lock scales with table size (180-day fallback when template_usage_stats empty) R1 Knuckle, Knov Yes
CRF-20 Note Author fixed (d0be576) coderd/database/queries/insights.sql:575 Rollup/insights child join is an O(fleet) full child-table scan every 5 min; grows with fleet, not the query window R1 Killua Yes
CRF-21 Note Author fixed (d0be576) coderd/database/queries/workspaceagentstats.sql:35 Child insert is safe only because SessionCountsFromProto always returns a non-nil map (nil -> null -> jsonb_each_text errors, failing the whole batch); undocumented at the append site R1 Ryosuke Yes
CRF-22 Note Author fixed (d0be576) coderd/database/queries/workspaceagentstats.sql:284 The LATERAL-vs-direct join split is load-bearing (prevents SUM double-count) but under-commented; a future SUM beside a direct join inflates silently R1 Ryosuke, Knuckle Yes
CRF-23 Note Author fixed (d0be576) agent/agentssh/agentssh.go:428 Unknown-session-type log downgraded Warn->Debug (correct) but drops the pre-normalization raw spelling R1 Chopper Yes
CRF-24 Note Author fixed (fe1ee77) coderd/workspacestats/batcher.go:143 Over-cap Warn (the CRF-4 fix) fires on raw proto map length, not the post-normalization/capped count; can log an overflow message when normalization collapsed names and nothing was aggregated R2 Netero Yes
CRF-25 P3 Author fixed (fe1ee77) coderd/workspacestats/sessioncounts_test.go:78 Server capSessionCounts well-known-preservation branch is untested (glass); the only well-known fixture name sorts lexicographically early, so the known-first branch never runs. Bisky proved neutering the sort still passes R2 Bisky Yes
CRF-26 P3 Author fixed (fe1ee77) agent/agentssh/agentssh.go:345 Agent cap folds ANY late type into unknown once 64 keys exist; unlike the server it does not protect well-known names. Lazily-created ssh/vscode can fold to unknown and drop from canonical reads. "mirroring the server-side cap" comment is false; also lacks the operator signal the server got (CRF-4) R2 Hisoka P3, Mafuuu P3; Meruem/Melody/Razor/Chopper Note Yes
CRF-27 Note Author addressed (fe1ee77): re-measured toggling the BRIN, PR body updated with churn caveat coderd/database/migrations/000545_workspace_agent_session_counts.up.sql:33 PR-body benchmark numbers (98.9ms rollup, 659ms insights) predate the BRIN and were never re-measured; steady-state prune benefit is unverified and may degrade with churn (page reuse after cascade-delete + vacuum). Floor stays the CRF-20 scan R2 Killua, Knuckle Yes
CRF-28 Note Author fixed (fe1ee77) coderd/database/queries/workspaceagentstats.sql:113 Windowed reads now depend on child.created_at == parent.created_at, an invariant nothing enforces (no CHECK/trigger/FK); a future writer that omits the parent timestamp silently drops rows and undercounts. CRF-6 batcher test gives partial coverage via the window read R2 Knuckle Yes
CRF-29 Nit Author fixed (fe1ee77) cli/ssh_test.go:1671 New test comment says arbitrary names are "stored raw" but they are normalized at ingestion; reintroduces the CRF-10 class the SDK fix closed R2 Chopper Nit, Bisky Nit Yes
CRF-30 Nit Author fixed (fe1ee77) agent/agentssh/agentssh.go:330 The 64 cap is defined twice (maxSessionTypes agent, maxSessionCountEntries server), tied only by a comment; can drift silently. Both import idemetadata; move to one constant there R2 Gon Nit, Zoro Nit Yes
CRF-31 Nit Author fixed (fe1ee77) agent/agentssh/agentssh.go:160 connCounts/getOrCreateConnCounter still say "conn" while the public method is SessionCounts(); and connCounts is a value syncmap.Map diverging from the *syncmap.Map via New() convention (sync.Map must not be copied). Rename to sessionCounts and use a pointer R2 Gon Nit, Zoro Nit Yes
CRF-32 Note Author fixed (0a4509c) agent/agentssh/metrics.go:50 Prometheus magic_type label now carries app family, not the raw session type, undocumented at the metric definition; magic_type="vscode" will aggregate Cursor/Windsurf/etc at phase 5 and silently change dashboard shape R2 Leorio Yes
CRF-33 Note Dropped by orchestrator (contract lives in code + OldAgentFallback test, not just the comment; deprecated fields will not be reordered) agent/proto/agent.proto:157 Deprecation doc asymmetry: field 8 carries the full conversion contract, fields 9-11 do not R2 Gon No
CRF-34 Note Open agent/agent_test.go:4310 Family-valued magic_type label (CRF-32) is only asserted end-to-end for the identity case (ssh->ssh); the divergent case (cursor->vscode) is unproven at the metric boundary. Low value: Family is unit-tested and magicTypeMetricLabel is a one-line delegator R4 Bisky Yes
CRF-35 Nit Open agent/agentssh/agentssh.go:369 The CRF-31 rename bled a sessionType/magicType split into agentssh.go for the same MagicSessionType concept; magicType is dominant and matches the type name. Rename the two touched returns/params back to magicType R4 Gon Yes

Author self-review (posted as a PR review comment) — panel disposition

  • B1 (INNER->LEFT JOIN in GetWorkspaceAgentUsageStatsAndLabels): does not reproduce. Base already used LEFT JOIN latest_agent_stats; new keeps it. Verified against base 35ade9e.
  • M2 (minute_buckets fan-out overcount): does not reproduce. Child PK (workspace_agent_stats_id, app_name) guarantees one row per app per stats row; SUM(count) FILTER cannot double-count (Knuckle verified).
  • M3 (has_connection excludes connection-only rows): does not reproduce. Base WHERE already filters to rows with a positive session count before MAX(connection_count) (verified); new INNER JOIN reproduces that inclusion set (Melody trace).
  • M4 (fallback path not capped): does not reproduce. SessionCountsFromProto returns capSessionCounts(counts) for both the map and the deprecated-field fallback path.
  • M1 / m1-m5 / n1-n5: minor doc/observability points; several overlap panel findings (CRF-2, CRF-18, CRF-23).

Round log

Round 1

Netero + Law (infrastructure) then 19-persona panel. Netero: 1 P3, 1 Note (mechanical floor clean). Law: Don't split (advisory). Panel: 1 P2, 4 P3, 11 Nit, 8 Note. Author self-review B1/M2/M3/M4 verified as non-reproducing. Reviewed against 35ade9e..45df97f.

Round 2

Churn guard: PROCEED. All 23 round-1 findings classified addressed (verified against the tree, not the author's claim). Author fixes claimed at head d0be576; panel verifies on encounter. Notable new code beyond fixes: child table gained a created_at column + BRIN index with windowed query pruning (CRF-20 response), and an agent-side 64-type cap fold (CRF-1 response). Law not re-run (effective additions +105 since R1, under the 500 threshold). Reviewed against 9862f10..d0be576.

Round 2 panel

Netero + 16-persona panel. All 23 R1 fixes verified addressed on encounter. New findings: 2 P3 (CRF-25 server cap test glass, CRF-26 agent cap well-known folding), 4 Note (CRF-24, 27, 28, 32), 3 Nit (CRF-29, 30, 31). 1 dropped (CRF-33). CRF-26 convergence: Hisoka/Mafuuu P3, Meruem/Melody/Razor/Chopper Note; orchestrator verified the agent code lacks the server's known-first protection. Event COMMENT (no P0/P1).

Round 3 update

BLOCKED by churn guard. Head fe1ee77 (base a9a1dcc). 7 addressed (CRF-24, 25, 26, 28, 29, 30, 31), 1 acknowledged (CRF-27: re-measured + PR body updated), 1 silent (CRF-32). No panel or Netero this round. CRF-32 (magic_type label documentation) got no author reply and no code change; it was folded into the round-2 review body (metrics.go:50 outside the diff), so it was easy to miss. Posted a BLOCKED review asking the author to fix CRF-32 or explain. The 8 addressed/acknowledged fixes are recorded as author-claimed and will be panel-verified on encounter in the next PROCEED round.

Round 4

Churn guard: PROCEED. Head 0a4509c (base 6ec45f7). CRF-32 addressed (comment added at both NewCounterVec definitions; author explained the family-valued label is intentional for phase-5 cardinality). Panel runs to verify CRF-32 plus the still-unverified round-3 fixes (CRF-24, 25, 26, 27, 28, 29, 30, 31) on encounter, since round 3 was BLOCKED with no panel. Law not re-run (effective additions +162 since R1). Reviewed against 6ec45f7..0a4509c.

Round 4 panel

Netero (no findings) + 12-persona panel. All round-3 fixes independently verified genuine and correct on encounter, including the CRF-26 P3 rewrite (known-family types bypass the cap; TestSessionCountsCapped proves it) and the CRF-25 test (now bites when the known-first sort is neutered). New findings: 1 Nit (CRF-35 sessionType/magicType split from the rename), 1 Note (CRF-34 family-label metric assertion only covers the identity case). No P0-P3. PR has converged; posted APPROVE (tool downgrades to COMMENT). No REQUEST_CHANGES to dismiss (all prior rounds were COMMENT).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 1 of the IDE-session-type normalization, and it is a disciplined one. The idemetadata leaf package gives the agent and server a single app-name vocabulary (pinned against drift by TestFamilyKeysAreCanonical and TestCodersdkUsageAppNamesAreCanonical), the read-query rewrites correctly split into fan-out-tolerant hash joins (DISTINCT/MAX) versus per-row LATERAL pre-aggregation (where connection_count is summed alongside), the batcher's JSONB side-buffer mirrors the established connections_by_proto pattern with a lock-protected positional zip, and the syncmap.LoadOrStore fix is a real correctness fix (the wrapper previously returned the zero value on store, which would have handed getOrCreateConnCounter a nil *atomic.Int64). The old-agent conversion path is the single, well-tested apex. As Killua put it: "The hot paths earn a shrug."

Severity count: 0 P0/P1, 1 P2, 4 P3, 11 Nit, 8 Note.

The P2 is a test gap on the exact code the PR calls its #1 risk: the batcher session-count flush runs green with no assertion on any session-count value. The P3s cluster around the newly-opened name space: an agent-side counter map with no bound (CRF-1), a reconnecting_pty key that the SSH path and the PTY server both write with last-writer-wins (CRF-3), a silent overflow fold with no operator signal (CRF-4), and a "surfaces unchanged" premise that is already false at the usage API and CLI in phase 1, not phase 5 (CRF-5). None are reachable by well-behaved first-party clients today, but this PR's whole point is that arbitrary names start flowing later, and there is no follow-up author to revisit them then.

One thing to flag directly: the author-agent's own review comment posted four findings above Nit (B1 Blocker, M2/M3/M4 Major) and none of them reproduce. B1 claims an INNER->LEFT JOIN change in GetWorkspaceAgentUsageStatsAndLabels, but the base query already used LEFT JOIN latest_agent_stats. M2's minute_buckets overcount cannot happen because the child PK (workspace_agent_stats_id, app_name) guarantees one row per app per stats row. M3's has_connection regression does not occur because the base query already filters to rows with a positive session count before MAX(connection_count), which the new INNER JOIN reproduces. M4's uncapped-fallback claim is wrong because SessionCountsFromProto returns capSessionCounts(counts) for both branches. The independent panel verified each against the base SHA. Worth calibrating the self-review's confidence before those land as blockers on a human.

Process note: the PR description points reviewers at migration 000543_* twice (the NOTE and the file-by-file table), but the shipped files are 000544_*; 000543 is an unrelated chat_status_remove_unused migration. A rebase bumped the number and the description was not updated. Please fix both references so a reviewer grepping by number lands on the right file.


coderd/workspacestats/batcher_internal_test.go:77

P2 [CRF-6] The batcher's session-count flush path executes under test but no session-count value is ever asserted. (Bisky)

DBBatcher is the only caller that runs SessionCountsFromProto, marshals the sessionCounts side-buffer, and depends on the positional zip in InsertWorkspaceAgentStats (the PR's stated #1 risk). randStats sets the deprecated fields, so the child insert does run, but every assertion in TestBatchStats is a row count. Nothing reads a session count back.

The other suites do not close this: querier_test.go seeds through dbgen (bypasses the batcher) and cli/ssh_test.go/vscodessh_test.go assert against the workspacestatstest.StatsBatcher fake that never inserts or normalizes. A marshal/zip regression (empty slice, off-by-one, wrong buffer, mis-position) on the highest-risk code in the PR ships green. Make a deterministic second flush carry a known session-count map and assert the stored value back.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread agent/agentssh/agentssh.go Outdated
Comment thread agent/agent.go Outdated
Comment thread coderd/workspacestats/sessioncounts.go
Comment thread coderd/workspaces.go
Comment thread coderd/workspacestats/sessioncounts.go Outdated
Comment thread coderd/database/queries/insights.sql
Comment thread coderd/database/queries/workspaceagentstats.sql
Comment thread coderd/database/queries/workspaceagentstats.sql
Comment thread agent/agentssh/agentssh.go
Comment thread coderd/idemetadata/idemetadata.go
@EhabY

EhabY commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

CRF-6: the batcher test now flushes known session counts for two agents and asserts the stored values round-trip, covering normalization, zero-dropping, and the positional zip.

@EhabY
EhabY force-pushed the feat/normalized-session-counts branch 3 times, most recently from daae7b5 to d0be576 Compare July 16, 2026 14:03
@EhabY

EhabY commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2. All 23 round-1 findings are addressed, and the panel verified each fix against the tree rather than trusting the resolution. The standouts: CRF-6's batcher test now flushes distinct per-agent maps and asserts they round-trip normalized with zeros dropped (the PR's #1 stated risk finally has a real assertion), and the CRF-20 O(fleet) scan is fixed by construction, not just by comment. The new created_at column is copied positionally from the parent row on every write path (batcher, backfill, dbgen), so the added sc.created_at BRIN filters are redundant-for-correctness and only prune blocks; the windowed results stay byte-identical. Nice work turning a scale note into a structural fix.

Severity count this round: 0 P0/P1, 2 P3, 4 Note, 3 Nit (1 finding dropped on cross-check). Nothing blocks; using COMMENT.

The two P3s are two sides of one guarantee, that well-known session types survive the 64-entry cap. CRF-26 is the live one: the agent-side cap added for CRF-1 does not mirror the server-side cap it cites. The server sorts well-known names first and keeps them; the agent folds by arrival, so once 64 distinct types have ever been seen, a lazily-created ssh or vscode counter can be routed into unknown and its minutes drop out of every canonical read. Two reviewers rated it P3 and four more converged as Note; I verified the agent code lacks the known-first branch. CRF-25 is the same guarantee on the server side, where the code is correct but the preserve-well-known branch is untested (Bisky proved neutering the sort keeps the test green). Fixing both is cheap: exempt the five known-family names from the agent fold, and add a server test with 64 names that sort before a canonical name.

The BRIN design is the right call, but two honesty notes: the PR-body benchmark rows (98.9 ms rollup, 659 ms insights) predate the BRIN by the author's own admission, so they describe code that no longer ships and should be re-measured; and the steady-state prune benefit is unverified, with a plausible correlation-decay path under cascade-delete + vacuum churn (CRF-27). As Hisoka signed off: "When code fights back this well, I don't manufacture a rematch."


agent/agentssh/metrics.go:50

Note [CRF-32] The magic_type Prometheus label now carries the app family, not the raw session type, and nothing at the metric definition says so. (Leorio)

magicTypeMetricLabel was rewritten [...] to return idemetadata.Family(...). Before this PR, a session type the agent didn't recognize landed under magic_type="unknown"; now every alias folds into its family, so agent_sessions_total{magic_type="vscode"} will start counting Cursor, Windsurf, Positron, VSCodium, Kiro, Devin, and the rest the moment an SSH client sends one of those values.

The metric name stays byte-identical and the canonical four are unchanged in phase 1, so this is latent until phase 5. But the two NewCounterVec definitions declaring []string{"magic_type", "pty"} say nothing about the semantic shift, and an operator's existing dashboard query silently changes shape when family folding goes live. A one-line comment at the metric definitions noting the label is family-valued would prevent the 2 AM surprise.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread agent/agentssh/agentssh.go Outdated
Comment thread coderd/workspacestats/sessioncounts_internal_test.go
Comment thread coderd/workspacestats/batcher.go
Comment thread coderd/database/queries/workspaceagentstats.sql Outdated
Comment thread cli/ssh_test.go Outdated
Comment thread agent/agentssh/agentssh.go Outdated
Comment thread agent/agentssh/agentssh.go Outdated
@EhabY

EhabY commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3: the fixes are strong. Eight of the nine round-2 findings are handled: the agent cap now protects known-family types and only folds genuinely-unknown overflow with a debug log (CRF-26), the server cap gained a real preservation test with 100 junk names sorting before ssh (CRF-25), the over-cap Warn moved to the normalized pre-cap count (CRF-24), the batcher round-trip test now asserts child.created_at == parent.created_at (CRF-28), the cap constant is unified in idemetadata (CRF-30), the field is renamed and pointer-typed (CRF-31), and CRF-27 was re-measured toggling the BRIN with the PR body updated to state the churn caveat honestly.

One finding is unaddressed with no response, so this round is blocked pending a reply rather than spending another full panel pass:

  • CRF-32 (Note, agent/agentssh/metrics.go:50): the magic_type Prometheus label now returns idemetadata.Family(...), so it carries the app family rather than the raw session type. agent_sessions_total{magic_type="vscode"} will start aggregating Cursor, Windsurf, Positron, and the rest once phase 5 clients send those values, and an operator's existing dashboard query silently changes shape. The two NewCounterVec definitions declaring []string{"magic_type", "pty"} say nothing about the shift. This was folded into the round-2 review body (the line sits outside the diff hunks), so it is easy to have missed, which is likely why it went silent.

No code or ticket is strictly required for a Note: a one-line comment at the metric definitions noting the label is family-valued would close it, or reply explaining why it should not be changed (for example, that phase 2 will re-document the metric). A bare non-response is what blocks; any of fix, ticket, or reasoned decline unblocks. Once you respond, the panel will re-engage and verify the eight round-3 fixes on encounter (they are recorded as author-claimed, not yet panel-verified this round).

No inline comments this round; review is blocked on the item above.

🤖 This review was automatically generated with Coder Agents.

@EhabY
EhabY force-pushed the feat/normalized-session-counts branch from fe1ee77 to 0a4509c Compare July 22, 2026 11:58
@EhabY

EhabY commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

CRF-32: the family-valued magic_type label is intentional. Once arbitrary client-supplied session types flow through (phase 5), labeling by raw type would make metric cardinality unbounded, so both session metrics label by family via magicTypeMetricLabel. Added a comment at both NewCounterVec definitions in agent/agentssh/metrics.go noting the label is family-valued.

@EhabY

EhabY commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 4, and the PR has converged. Round 3 was blocked on a single silent finding, so eight round-3 fixes had been recorded as author-claimed but never panel-verified. This round the panel verified every one of them against the code and the tests, and they are genuine, not paste. The load-bearing one, CRF-26, is correct: the agent cap now gates the fold behind idemetadata.Family(key) == AppNameUnknown, so lazily-created ssh/vscode counters always keep their own key and only genuinely-unknown types fold past the cap, with a debug log; TestSessionCountsCapped breaks if you drop the guard. CRF-25's server test finally bites (neuter the known-first sort and got["ssh"] drops to 0), CRF-24's over-cap Warn fires on the normalized pre-cap count with agent_id/reported/max, CRF-28's batcher round-trip asserts child.created_at == parent.created_at via a direct SQL join, the cap constant is unified in idemetadata.MaxSessionCountEntries, and CRF-32 (this round's change) documents the family-valued magic_type label at both metric definitions with the cardinality rationale. CRF-27's BRIN was re-measured toggling the index and the PR body now states the prune benefit plus the churn caveat honestly.

All 32 prior findings are resolved or acknowledged. Two minor, non-blocking items are new this round, neither requiring a change to ship:

As Bisky put it: "I went in expecting paste and found the real thing."

Approving. The two inline notes below are optional polish.


agent/agent_test.go:4310

Note [CRF-34] The family-valued magic_type label (this round's CRF-32 change) is only asserted end-to-end for the identity case where family equals the raw type. (Bisky)

magicTypeMetricLabel now returns idemetadata.Family(...), so a Cursor or Windsurf session should emit magic_type="vscode". The only end-to-end metric assertion feeds an ssh session and checks magic_type="ssh", which passes whether the label carries the family or the raw type; delete the family mapping and this test stays green.

Low value to add, since Family() is unit-tested in TestFamily and magicTypeMetricLabel is a one-line delegator, so the composed behavior is covered indirectly. Worth knowing, not worth blocking. If you touch this area for phase 2, a divergent-case assertion (cursor -> vscode) at the metric boundary would close it.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread agent/agentssh/agentssh.go Outdated
@EhabY

EhabY commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

CRF-34: added TestMagicTypeMetricLabel in agent/agentssh/sessiontype_internal_test.go (cb0b108) asserting the divergent case (cursor -> vscode) plus the ssh and unknown families at the metric boundary. Deleting the family mapping in magicTypeMetricLabel now fails a test, which closes the gap without a second end-to-end SSH session.

@EhabY

EhabY commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Planning note: template insights and non-canonical app names

As of this PR, the rollup (UpsertTemplateUsageStats) and GetTemplateInsightsByTemplate hard-filter the session counts child table to app_name IN ('ssh', 'reconnecting_pty', 'vscode', 'jetbrains'). Non-canonical names (e.g. cursor) are dropped at the rollup boundary — not even bucketed into their family — and because the minute buckets derive from the same filtered join, a user connecting only via an unrecognized IDE would also contribute zero agent-side usage_mins to template insights. This stays invisible until phase 4 (DEVEX-642 / #27413) makes the rollup dynamic.

Decision: rather than adding an interim app_family column to workspace_agent_session_counts (and dropping it again in phase 4), we close the window by sequencing: the VS Code extension's version gate (DEVEX-643 / coder/vscode-coder#1044) will target the release containing both the connection_logs ENUM→TEXT migration (phase 3, #27412) and the template_usage_stats JSONB merge (phase 4, #27413). Since bundled clients keep sending only canonical names until that gate flips, no real traffic hits the dropped-names path. DEVEX-642 has been bumped to Medium priority accordingly.

Residual gap (accepted): custom callers can already send arbitrary names through the workspace usage API / coder ssh --usage-app as of this PR, and those minutes are excluded from the rollup until phase 4. Nothing sends them by default.

Open question for reviewers: if we can't guarantee phases 3 and 4 land in the same release window, is an interim app_family column worth it after all? It's cheap here (amend the 000545 migration, family computed by idemetadata.Family() in the batcher, swap the two IN filters), retires naturally in the phase-4 migration, and phase 2's family-labeled Prometheus gauge could reuse it — but it's dead weight if the sequencing holds.


🤖 Comment by Coder Agents on behalf of @EhabY.

@EhabY
EhabY force-pushed the feat/normalized-session-counts branch 2 times, most recently from 33ae017 to 47be094 Compare July 29, 2026 13:40
@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 6, 2026
Replace the fixed session_count_* columns on workspace_agent_stats with a
workspace_agent_session_counts child table keyed by app name, so any IDE
can report session counts without schema changes. Agent API v2.11 adds a
session_counts map to Stats, deprecating the fixed fields.
@EhabY
EhabY force-pushed the feat/normalized-session-counts branch from 47be094 to 9707a7a Compare August 9, 2026 12:57
@EhabY EhabY closed this Aug 9, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 9, 2026
@EhabY

EhabY commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Closed in favor of #27952, the same branch squashed to a single commit and rebased on main, since this PR had become too crowded to review.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant