Conversation
83aa8d3 to
cd9543f
Compare
cd9543f to
bdac30a
Compare
bdac30a to
d34d4b6
Compare
d34d4b6 to
32b28f7
Compare
a16831d to
d17e5b7
Compare
| -- 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 ( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
066a75f to
44025d6
Compare
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.
44025d6 to
cea8c2f
Compare
code-asher
left a comment
There was a problem hiding this comment.
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 😆
| -- 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 |
There was a problem hiding this comment.
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 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.'; |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| -- The same gate the union below applies to agent stats, so a | ||
| -- bucket that only has app stats records no session usage. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
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
cursororzed. 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 alterstemplate_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 migration000590is 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
work_mem=8MB.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.GetTemplateInsights429 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