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.

  • Performance
  • $rum-monitoring
  • 167 lines
  • 2 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- rum-monitoring

Installs to ~/.agents/skills/rum-monitoring/ for every project on this machine.

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.

~/.agents/skills/rum-monitoring/SKILL.md

--- name: rum-monitoring description: 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… license: MIT ---

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

SituationDestination
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 effortSend as GA4 events, export to BigQuery for percentiles (GA4 UI averages are misleading)
Want control and low costOwn endpoint: beacon to /rum, store in a warehouse or ClickHouse/Postgres
Want product analytics alongsidePostHog 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 on unload (unreliable and blocks bfcache).
  • Metrics may be reported more than once per page (e.g. after bfcache restore); id is unique per page load, so de-duplicate by id keeping 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.

  • 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.yamlgenerated from this page

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.

esc