web_vitals (#41) stores the metric, the path, the value and the time. It carries no device column, so the vitals report answers "is my site slow" but not "is my site slow on mobile" — which is the question people actually have, because it is usually the answer.
The route comment says as much today:
Adding a device breakdown later is additive (a column plus a GROUP BY) and is the obvious next step [...] It is out of scope here because a first cut that answers "is my site slow" correctly beats one that answers more, later.
This is that step.
Scope
device_typeonweb_vitals, populated in the vitals branch of/collectfrom the sameparseUserAgentthe pageview path already uses, with the same lowercasedesktop/mobile/tabletvalues aspage_views.device_type.- A per-device p75 in
GET /api/sites/{siteId}/vitals, and an optional?device=scope on the report and on/vitals-trends. - A device row in the dashboard panel, showing all devices side by side rather than behind a selector — the comparison is the point.
Privacy
Device class is three buckets, already recorded per visitor on page_views for the same visit. It adds no linkage the database does not already hold, and it is not a device-persistent identifier — the thing #28 rules out. Nothing about the visitor hash, the rotation, or erasure changes.
The disclosure floor matters more here than in the site-wide report, not less: splitting by device divides the sample count, so more buckets fall under it. A p75 over one person's phone is that person's phone and network reported to the site owner as a device characteristic — exactly the case the floor exists for. Each bucket is suppressed independently, and shows "too few visits" rather than vanishing.
Existing rows
The column is nullable with no backfill, because we do not know the device for measurements taken before it existed and inventing one would be worse than admitting it. Those rows read as an unknown bucket, shown only when it actually has samples, and it ages out on its own.