Real User Monitoring skill for Codex
Field Web Vitals that point at the culprit. Codex instruments the site with the web-vitals attribution build, flushes final values on visibilitychange with sendBeacon, and tags each metric with route template, device, connection and release rather than raw URLs. It sets sampling and consent handling, then writes p75 queries per page group plus the elements behind poor INP and LCP, so field data says what to fix instead of just showing a score.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rum-monitoring Installs to ~/.agents/skills/rum-monitoring/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rum-monitoring The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rum-monitoring Installs to .agents/skills/rum-monitoring/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rum-monitoring.zipunzip -o rum-monitoring.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rum-monitoring PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rum-monitoring, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up real user monitoring of Core Web Vitals with the web-vitals library and its attribution build - LCP, INP, CLS, FCP and TTFB beaconed with sendBeacon to your own endpoint, GA4, or a RUM vendor - with route grouping, device and connection dimensions, sampling, consent handling, and p75 queries that tell you which page, element and interaction to fix. Use when a team has no field performance data, CrUX is too coarse or lagging, Lighthouse and real users disagree, or someone wants a Web Vitals dashboard or alerting from real traffic.
Use it when
- There is no field performance data beyond CrUX
- Lab and field results disagree and you need attribution from real sessions
- You want Web Vitals dashboards or alerts per page group and release
Use something else when
- You already have field data and need to fix a failing metric: use core-web-vitals
- You want scripted checks from fixed locations rather than real traffic: use synthetic-monitoring
How to check the result
One beacon per page lifecycle appears after hiding the tab with route templates in the payload, row counts match page views times the sample rate, and p75 values are in line with CrUX.
The mistake it prevents
Reporting on unload or only at page load, which loses final CLS and INP values and breaks the back/forward cache.
Done well means: every page view reports its vitals once (final values, not partial), each report carries enough attribution to point at an element or interaction, data is segmented by page group and device, results are read at p75, and collection respects consent and does not slow the page it measures.
Choose the destination
| Situation | Destination |
|---|---|
| Already on a RUM/APM vendor (Datadog, Sentry, New Relic, SpeedCurve, etc.) | Use the vendor’s SDK; check it reports INP with attribution |
| Using GA4 and want low effort | Send as GA4 events, export to BigQuery for percentiles (GA4 UI averages are misleading) |
| Want control and low cost | Own endpoint: beacon to /rum, store in a warehouse or ClickHouse/Postgres |
| Want product analytics alongside | PostHog or similar can ingest custom events ($posthog-analytics) |
CrUX (Search Console, PageSpeed Insights, CrUX API) stays useful as the external reference: it is what Google uses for search. RUM explains why and reacts in days.
Workflow
- [ ] 1. Read AGENTS.md: framework, router (SPA or MPA), consent manager, analytics already present
- [ ] 2. Add web-vitals (attribution build) and a reporter that batches to the destination
- [ ] 3. Define dimensions: page group (route template), device class, connection, country, release
- [ ] 4. Respect consent; sample if traffic is large
- [ ] 5. Verify payloads in DevTools and on the receiving side
- [ ] 6. Build p75 queries/dashboard per page group; compare with CrUX
- [ ] 7. Add alerting on sustained p75 regressions
Instrumentation
npm i web-vitals # needs network; use the repo's package manager
// rum.ts: load after the page is interactive; it is small
import { onCLS, onINP, onLCP, onFCP, onTTFB, type MetricWithAttribution } from 'web-vitals/attribution';
const queue: Record<string, unknown>[] = [];
const ctx = {
page: routeTemplate(location.pathname), // '/product/:id', never raw URLs with IDs or emails
release: import.meta.env.VITE_RELEASE ?? 'dev',
device: matchMedia('(pointer: coarse)').matches ? 'mobile' : 'desktop',
ect: (navigator as any).connection?.effectiveType, // Chromium only
};
function add(m: MetricWithAttribution) {
const a: any = m.attribution;
queue.push({
...ctx, name: m.name, value: Math.round(m.name === 'CLS' ? m.value * 1000 : m.value),
rating: m.rating, id: m.id, navType: m.navigationType,
// attribution field names vary by web-vitals major; check the installed version's types
target: a.target ?? a.interactionTarget ?? a.largestShiftTarget,
phases: m.name === 'INP' ? { input: a.inputDelay, processing: a.processingDuration, presentation: a.presentationDelay }
: m.name === 'LCP' ? { ttfb: a.timeToFirstByte, loadDelay: a.resourceLoadDelay, loadTime: a.resourceLoadDuration, renderDelay: a.elementRenderDelay }
: undefined,
});
}
function flush() {
if (!queue.length) return;
const body = JSON.stringify(queue.splice(0));
if (!navigator.sendBeacon?.('/rum', body)) fetch('/rum', { method: 'POST', body, keepalive: true });
}
[onCLS, onINP, onLCP, onFCP, onTTFB].forEach((fn) => fn(add));
addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); });
Key behaviours:
- CLS and INP are final only when the page is hidden; the library reports them on
visibilitychange. Flush there, not onunload(unreliable and blocks bfcache). - Metrics may be reported more than once per page (e.g. after bfcache restore);
idis unique per page load, so de-duplicate byidkeeping the latest value. - SPAs: standard vitals cover the initial page load only. Route changes need soft navigation support, which is still experimental in browsers; tag route-change interactions separately rather than pretending they are page loads.
- Browser support for INP and LCP arrived in Firefox and Safari much later than in Chromium, and older versions report nothing; segment by browser so missing data is not read as “good”, and remember CrUX is Chrome-only.
Server endpoint basics: accept text/plain or JSON bodies (sendBeacon sends
text/plain for strings), rate-limit, validate types and ranges, drop bot traffic, never
log IP addresses beyond what the privacy policy allows, and respond 204.
Privacy and consent
- Send route templates, not full URLs: query strings and paths can contain emails, tokens and search terms.
- Attribution selectors can contain user content in IDs or classes; truncate and sanitise.
- Whether RUM needs consent depends on jurisdiction, identifiers and what is stored; check with the privacy owner. Aggregate, cookie-less, first-party collection is the easiest to justify. Wire it to the consent manager when required.
Sampling
Send 100% on low-traffic sites. For high traffic, sample per page view (decide once at
load: Math.random() < 0.1) and record the sample rate so counts can be scaled. Never
sample per metric; it breaks per-page correlation.
Reading the data
- Use p75 per page group and device class, matching CrUX. Averages hide the tail.
- Require a minimum sample (for example 500 views per group per day) before trusting p75 swings.
-- p75 per page group for the last 7 days (Postgres; adapt percentile functions per warehouse)
SELECT page, device, name,
percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75,
count(*) AS n
FROM rum_vitals
WHERE received_at > now() - interval '7 days'
GROUP BY page, device, name
HAVING count(*) >= 500
ORDER BY name, p75 DESC;
-- Which elements cause poor INP on mobile
SELECT target, count(*) AS n, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75
FROM rum_vitals
WHERE name = 'INP' AND device = 'mobile' AND rating = 'poor' AND received_at > now() - interval '7 days'
GROUP BY target ORDER BY n DESC LIMIT 20;
GA4 path: send event('web_vitals', { metric_name, value, metric_id, page_group, ... })
and compute percentiles in the BigQuery export; GA4 reports themselves show sums and
averages. GA4 setup details are in $ga4-analytics.
Fixing what the data shows belongs to $core-web-vitals.
Alerting
Alert on p75 crossing the “good” threshold for a page group for 2–3 consecutive days with
enough samples, and on release-over-release regressions (release dimension). Do not page
on single-day noise. Route alerts to the owning team, not a shared channel nobody reads.
Verification
- DevTools Network: one beacon per page lifecycle with all metrics after switching tabs; payload contains route template, not raw URL.
- Receiving side: rows arrive with expected volumes (page views x sample rate).
- p75 values are in the same range as CrUX for the same pages (RUM often looks better or worse depending on audience; large gaps need explanation).
- No measurable cost: script is small, loaded after critical content, no long tasks.
Report template
## RUM setup: <site>
Library: web-vitals <ver> (attribution build), loaded after hydration
Destination: POST /rum -> rum_vitals table (ClickHouse), 20% sampling of page views
Dimensions: page group, device, ect, browser, country, release
Privacy: route templates only, selectors truncated to 100 chars, gated on analytics consent
Dashboard: p75 LCP/INP/CLS per page group and device; top INP targets; release comparison
Alerts: p75 above good threshold 3 days running with n >= 500 -> #web-perf
First read (7 days): mobile INP p75 280 ms on /search, driven by "button.filter-apply" What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Real User Monitoring"
short_description: "Field Web Vitals that point at the culprit"
default_prompt: "$rum-monitoring Add real user monitoring of Core Web Vitals to our Next.js app and send it to our API" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.