Skip to content

feat: store session usage per app and family in template usage stats - #29109

Open
EhabY wants to merge 2 commits into
mainfrom
feat/session-family-usage-rollup
Open

EhabY wants to merge 2 commits into
mainfrom
feat/session-family-usage-rollup

Conversation

@EhabY

@EhabY EhabY commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Store per-app session minutes in template_usage_stats_session_apps, replacing the fixed family-minute columns. A new app no longer requires a rollup schema change, and the API, Prometheus, and telemetry outputs are unchanged.

Reads group app names into families through the registry in codersdk, so the registry never reaches SQL and attribution applies to all history rather than only the buckets the rollup still revisits. GetTemplateInsightsByTemplate, which feeds Prometheus, groups by app for the same reason, so both surfaces report a family identically. A digest with length-prefixed names skips child-row writes for unchanged buckets without confusing names that contain delimiters.

Semantic change: a family total is the sum of its apps, not the distinct minutes any of them was active, so a minute two apps of one family share counts in both. Reported numbers are unchanged today, because every agent reports one canonical app name per family. The difference appears once #28338 lands the producers and clients send names such as cursor or zed. The apps report already overlaps across rows, and the dashboard builds its percentage denominator from the sum of the rows, so a family row stays consistent with the per-app breakdown it will sit above.

Migration 000596: backfill the family totals the fixed columns recorded, sftp included, under the family name as the app name, which the registry maps back to itself. Workspace web-app usage stays separate. The migration alters template_usage_stats; the exclusive locks the ALTERs acquire last until the migration transaction commits. Downgrading folds app names back into the five fixed columns and discards the per-app detail. Already-merged migration 000590 is unchanged.

The second commit is the response to review and reads on its own; the first is the rest of the work squashed.

Follows merged #28337 and #29134; #28338 enables the producers. Covers the storage portion of #27413. Dynamic external projections (#27411 and the remaining #27413 work) and the connection-log migration (#27412) remain separate.

Design decisions and performance context
  • A child table rather than a family table: the family grouping is a read-time concern, and the per-app rows are what the dashboard breakdown needs anyway. Folding in Go costs nothing on reads, because the query aggregates straight to its output rows instead of adding an intermediate per-bucket aggregate that spills at work_mem=8MB.
  • A child table rather than a jsonb column on template_usage_stats: jsonb is cheaper to write and store but roughly 2.3x the read, and it widens the main row by 22% for every query that scans it.
  • Recomputed buckets and old buckets are attributed the same way, because attribution happens at read time.
  • Synthetic fixtures on PostgreSQL 17, ~308k buckets and 539k app rows over a 30 day window, median of 11 runs. GetTemplateInsights 429 ms with the family table against 444 ms folding in Go, 777 ms folding in SQL, and 1034 ms from a jsonb column. Cold rollup over 422k agent stats 3386 ms against 2687 ms. Storage 221 MB against 151 MB. These are fixture measurements, not production guarantees.

Description updated by Coder Agents for @EhabY.

🤖 Generated with Claude Code

@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch from 83aa8d3 to cd9543f Compare September 9, 2026 14:33
@EhabY
EhabY removed this pull request from stack #29110 September 9, 2026 14:34
@EhabY
EhabY changed the base branch from feat/session-count-family-attribution to feat/session-count-generic-queries September 9, 2026 14:34
@EhabY
EhabY added this pull request to stack #29135 September 9, 2026 14:35
@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch from cd9543f to bdac30a Compare September 10, 2026 11:27
@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch from bdac30a to d34d4b6 Compare September 10, 2026 11:54
Base automatically changed from feat/session-count-generic-queries to main September 10, 2026 12:07
@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch from d34d4b6 to 32b28f7 Compare September 10, 2026 12:07
@EhabY
EhabY marked this pull request as ready for review September 10, 2026 12:12
@EhabY
EhabY requested a review from code-asher September 10, 2026 16:31
@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch from a16831d to d17e5b7 Compare September 14, 2026 18:10
-- caps a user's minutes per half hour across templates, can look up one user's
-- rows through that prefix. The upsert's conflict target names the same
-- columns in the parent's order, satisfied by the unique index.
CREATE TABLE template_usage_stats_session_families (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need this table? My instinct is that in the database we should only need to record per app rather than family. The family grouping seems more like a display-time thing to me that we do in code. We need the individual counts anyway to provide the breakdown in the dashboard, right? So eventually this table and associated queries would go unused.

I think the reduction in query complexity would be worth it, unless summing families is prohibitively expensive or something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped it. Folding in Go is within noise of the family table on reads, and the rollup gets ~20% faster with ~30% less storage. Folding in SQL is the slow path, that's the cost worth avoiding. Can share numbers.

The registry is out of SQL now (@app_families and its dbauthz validation are gone) and attribution applies to all history instead of freezing at rollup time. I also grouped GetTemplateInsightsByTemplate by app so Prometheus and the API agree.

Semantic change: a family total is the sum of its apps, not distinct minutes, so VS Code + Cursor in the same minute counts twice. No-op today since agents report one name per family, starts mattering with #28338. The report's rows already overlap and the UI divides by the row sum, so this keeps families consistent with the per-app breakdown.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Awesome, that makes sense. Honestly, I think it might make sense to eventually always report per-app everywhere and not even bother with families, except where space is very limited like the dashboard status bar.

@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch 2 times, most recently from 066a75f to 44025d6 Compare September 16, 2026 12:19
Replace the fixed family-minute columns with per-app and per-family
child tables, and skip the child writes for buckets whose session usage
digest is unchanged.
Drop template_usage_stats_session_families and fold app names into
families in Go, so the registry no longer reaches SQL and attribution
applies to every bucket rather than only the ones the rollup revisits.
@EhabY
EhabY force-pushed the feat/session-family-usage-rollup branch from 44025d6 to cea8c2f Compare September 16, 2026 12:42
@EhabY
EhabY requested a review from code-asher September 16, 2026 12:44

@code-asher code-asher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is mostly me trying to figure out the sql lol

maybe we wanna get one more pair of eyes with more sql experience than me 😆

Comment on lines +29 to +30
-- copies codersdk.appNameFamilies. An app registered after this migration has
-- no entry here, so its minutes are dropped, as are the per-app detail and any

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We could perhaps group the unknown ones into ssh, might be better than losing the data altogether, especially since this list will inevitably become out of date.

Comment on lines +17 to +21
COMMENT ON TABLE template_usage_stats_session_apps IS 'Session usage of each template_usage_stats bucket, split by app name. No row means the bucket recorded no session usage. Reads group app names into families through the codersdk registry.';

COMMENT ON COLUMN template_usage_stats_session_apps.app_name IS 'App name as the agent reported it, so a source label rather than a curated identity. Rows converted from the fixed session columns carry a family name here instead.';

COMMENT ON COLUMN template_usage_stats_session_apps.usage_mins IS 'Total minutes the user has been using the app. A minute counts once however many sessions were open. A family total sums its apps, so a minute two apps of one family share counts twice.';

@code-asher code-asher Sep 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A family total sums its apps, so a minute two apps of one family share counts twice.

Nit, but this is just an Go code implementation detail, no? One could also sum them up in a way that does not double-count. Either way though, this comment might not belong here, since the DB has no mention of families otherwise.

Edit: ah well I guess it could not be summed up in a way that does not double count because we return the data across a range, not across a single bucket unless the range is for one bucket, so no way for the consumer to actually tally them up that way.

base AS MATERIALIZED (
-- One pass computes each user's template count and capped minutes
-- per half hour, plus the templates in the window. GROUPING
-- distinguishes user rows from template rows.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Huh OK so the grouping sets gives us multiple kinds of rows in one query, sorta, and we can distinguish between the two types of rows using is_user_row (although looks like we could also do it based on user_id <> null since that will be null on the template rows?)

Just talking out loud to make sure I got it right lol

This is faster than two sub-queries I imagine.

-- minutes per half hour across templates, can scan one user's rows by prefix.
-- The upsert's conflict target names the same columns in the parent's order.
CREATE TABLE template_usage_stats_session_apps (
start_time timestamptz NOT NULL,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why no end time? I suppose our buckets are always 30 minutes so it would be OK but it does seem a departure from template_usage_stats.

If we change the interval for example then it seems like it would retroactively change the meaning of all these rows (well I guess we could still get the end time from joining on template_usage_stats).

AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
GROUP BY GROUPING SETS ((start_time, user_id), (template_id))
),
users AS (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I might be missing something but why do we need a separate multi user and single/multiple app views and filter here, etc?

If I understand correctly, we want a list of users, a sum of the total usage (up to 30 mins per bucket), and then also a sum of each app (each individually up to 30 mins per bucket), and then also get a list of all the templates, and a per-app list of templates.

e.g. could this query be a bit more simply (WHERE clauses omitted, and using the grouping sets assuming they are more performant than multiple sub-queries):

WITH
-- Get usage by bucket, unique template ids, and unique users.
total_usage AS (
  SELECT
    start_time,
	  user_id,
	  template_id,
	  COUNT(*) AS templates,
	  LEAST(SUM(usage_mins), 30) * 60 AS usage_seconds
  FROM template_usage_stats
  GROUP BY GROUPING SETS ((start_time), (template_id), (user_id))
),
-- Get app usage by bucket and unique template ids per app.
app_usage AS (
  SELECT
    template_id,
    app_name,
    LEAST(SUM(usage_mins), 30) * 60 as usage_seconds
  FROM template_usage_stats_session_apps
  GROUP BY GROUPING SETS ((app_name, start_time), (app_name, template_id))
),
-- Sum app usage (separate query since aggregates cannot be nested).
app_totals AS (
  SELECT app_name, SUM(usage_seconds) as usage_seconds
  FROM app_usage
  WHERE template_id IS NULL
  GROUP BY app_name
),
-- Aggregate template ids (separate query since aggregates cannot be nested).
app_templates AS (
  SELECT app_name, array_agg(template_id) as template_ids
  FROM app_usage
  WHERE template_id IS NOT NULL
  GROUP BY app_name
)
SELECT
  COALESCE((SELECT array_agg(template_id) FROM total_usage WHERE template_id IS NOT NULL), '{}')::uuid[] AS template_ids,
  COALESCE(COUNT(user_id), 0)::bigint AS active_users,
  COALESCE((SELECT SUM(usage_seconds) FROM total_usage WHERE start_time IS NOT NULL), 0)::bigint AS usage_total_seconds,
  COALESCE((SELECT jsonb_object_agg(app_name, usage_seconds) FROM app_totals), '{}'::jsonb)::jsonb AS session_app_usage_seconds,
  COALESCE((SELECT jsonb_object_agg(app_name, template_ids) FROM app_templates), '{}'::jsonb)::jsonb AS session_app_template_ids
FROM
  total_usage;

template_id,
user_id,
app_name,
bit_or(minute_bit) AS minute_mask

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Trying to understand the minute mask. Why do we not just select on the minute (the original date_part('minute', created_at) ? The bit/mask feels like an extra step to get to the same thing.

Comment on lines +755 to +756
-- The same gate the union below applies to agent stats, so a
-- bucket that only has app stats records no session usage.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh wait is this by design? We expect to have app stats without any connection count?

minutes.template_id,
minutes.user_id,
minutes.app_name,
length(replace(minutes.minute_mask::bit(30)::text, '0', ''))::smallint AS usage_mins

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

And then here I would think something like a count over the minute column to get the total minutes for each 30 min bucket.

tus.user_id
),
changed_buckets AS (
-- New or changed buckets only. A bucket whose main row, digest

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So we must be calling this over time frames that were already rolled up?

usage_mins = EXCLUDED.usage_mins
WHERE
(tus.*) IS DISTINCT FROM (EXCLUDED.*);
apps.usage_mins IS DISTINCT FROM EXCLUDED.usage_mins;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But wait does this not already handle the case where the apps already exist and are unchanged? So if no apps have changed, this ends up doing nothing. Do we even need to calculate a hash, then? Feels like we can just rely on the conflict semantics here to do the work for us.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants