Skip to content

Web Vitals

Hosted Agent Analytics stores cwv_* on page_view. Self-hosted core stores the same keys on a $web_vitals event. Hosted does not store $web_vitals.

POST /vitals and GET /vitals are hosted reads. The event is fixed to page_view.

data-track-vitals="true" turns collection on. data-track-vitals="false" turns it off. Default install snippets include data-track-vitals="true". A snippet that omits the attribute stays off. Existing opt-in installs keep the same flag.

On hosted, each flush is merged into the latest page_view for that session and path. A later flush overwrites those keys. The path sent with the vitals is the path captured at the start of that page.

data-track-performance still writes perf_ttfb on page_view as responseStart - requestStart. That is not cwv_ttfb.

The headline is the 75th percentile (p75) of page loads. The method is nearest-rank: sort the values ascending, break ties by event id, and take rank ceil(0.75 * n). Rank 1 is the smallest value. The result is an observed value.

Metric Property Unit Definition
LCP cwv_lcp ms, integer Largest Contentful Paint. Omitted when the page was hidden before the first paint.
INP cwv_inp ms, integer Interaction to Next Paint. Event entries are grouped by interactionId. Each interaction contributes its max duration. INP is the worst interaction after ignoring one highest interaction per 50 interactions.
CLS cwv_cls unitless, 3 decimal places Cumulative Layout Shift. Largest session window: gap under 1 second, window at most 5 seconds, shifts with hadRecentInput excluded.
FCP cwv_fcp ms, integer First Contentful Paint. Omitted when the page was hidden before the first paint.
TTFB cwv_ttfb ms, integer Time to First Byte. Navigation responseStart - max(activationStart, 0), measured from navigation start.

Prerendered pages are not tracked.

Core Web Vitals are LCP, INP, and CLS. FCP and TTFB are the other two metrics on this read. Definitions and thresholds follow Web Vitals.

A value is good when it is <= good_max, poor when it is > poor_min, and needs-improvement otherwise. The p75 rating uses that rule on the p75. share uses the same rule on each page load. Share fractions are from 0 to 1, with 3 decimal places. The share keys are good, needs_improvement, and poor. The rating string uses a hyphen (needs-improvement). The share key uses an underscore (needs_improvement).

Metric Unit Good Needs improvement Poor
LCP ms <= 2500 <= 4000 > 4000
INP ms <= 200 <= 500 > 500
CLS unitless <= 0.1 <= 0.25 > 0.25
FCP ms <= 1800 <= 3000 > 3000
TTFB ms <= 800 <= 1800 > 1800

cwv_pass uses only LCP, INP, and CLS. It is true when the LCP, INP, and CLS p75 values are all good. When INP n is 0, pass or fail uses LCP and CLS only. cwv_pass is null when LCP n is 0 or CLS n is 0.

CLS and INP are collected in Chromium only. On other browsers the key is absent. It is not stored as 0.

LCP, FCP, and TTFB are written when the browser reports the entry. A browser that does not report the entry omits the key. An omitted key is not 0.

n is the number of page_view rows in the group that included that metric. skipped is the number of page_view rows in the group that did not.

A missing vital is skipped, not a zero. Route-change views can lack LCP and FCP. Non-Chromium browsers lack CLS and INP. A page with no interaction lacks INP. There is no minimum n on this read.

When the vitals flag is on, the tracker can send three target properties:

Property Selector
cwv_lcp_target LCP element
cwv_inp_target INP interaction target
cwv_cls_target CLS largest shift target

Each value is a CSS selector, trimmed, at most 200 characters. The fields contain selectors only: no text content and no URLs.

top_poor_targets lists up to 5 selectors by count among loads rated poor for that metric. Each entry is { target, count }. FCP and TTFB have no target field, so top_poor_targets is [].

Pro. A free account receives 403 with error PRO_REQUIRED. That 403 is not metered. One successful response counts as one metered read. The body on 403 is the standard error object: error, message, and payment when the caller can see billing.

POST /vitals JSON body, or the same fields as query parameters on GET /vitals:

Field Required Values
project yes Project name
days no Integer day window
date_from no YYYY-MM-DD
date_to no YYYY-MM-DD
group_by no device or path
limit no Default 10, maximum 20. Used when group_by is path.

The event is page_view. /query and /compare are unchanged.

group_by omitted: one row, group is null. group_by device: group is mobile, tablet, or desktop. group_by path: the top limit paths by n, sorted by worst LCP p75. group is the pathname.

Percentiles are nearest-rank. The sample is capped at 200,000 events. When the cap is hit, the response is still 200, approximate is true, and caps is present. Otherwise approximate is false and caps is omitted.

{
"project": "my-site",
"date_from": "2026-11-06",
"date_to": "2026-11-12",
"event": "page_view",
"group_by": null,
"percentile": "p75",
"percentile_method": "nearest_rank",
"approximate": false,
"thresholds": {
"lcp": { "unit": "ms", "good_max": 2500, "poor_min": 4000 },
"inp": { "unit": "ms", "good_max": 200, "poor_min": 500 },
"cls": { "unit": "unitless", "good_max": 0.1, "poor_min": 0.25 },
"fcp": { "unit": "ms", "good_max": 1800, "poor_min": 3000 },
"ttfb": { "unit": "ms", "good_max": 800, "poor_min": 1800 }
},
"rows": [
{
"group": null,
"cwv_pass": true,
"metrics": {
"lcp": {
"p75": 2100,
"rating": "good",
"n": 400,
"skipped": 20,
"share": { "good": 0.8, "needs_improvement": 0.15, "poor": 0.05 },
"top_poor_targets": [{ "target": "img.hero", "count": 4 }]
},
"inp": {
"p75": 180,
"rating": "good",
"n": 360,
"skipped": 60,
"share": { "good": 0.75, "needs_improvement": 0.2, "poor": 0.05 },
"top_poor_targets": [{ "target": "button.buy", "count": 3 }]
},
"cls": {
"p75": 0.08,
"rating": "good",
"n": 390,
"skipped": 30,
"share": { "good": 0.82, "needs_improvement": 0.12, "poor": 0.06 },
"top_poor_targets": [{ "target": "#main > .banner", "count": 2 }]
},
"fcp": {
"p75": 1600,
"rating": "good",
"n": 400,
"skipped": 20,
"share": { "good": 0.84, "needs_improvement": 0.12, "poor": 0.04 },
"top_poor_targets": []
},
"ttfb": {
"p75": 700,
"rating": "good",
"n": 400,
"skipped": 20,
"share": { "good": 0.78, "needs_improvement": 0.16, "poor": 0.06 },
"top_poor_targets": []
}
}
}
]
}

p75 and rating are null when n is 0. rating is good, needs-improvement, poor, or null.

The hosted MCP tool is web_vitals. Arguments match /vitals: project, days, date_from, date_to, group_by, and limit. The tool description includes the thresholds. p75 is the headline. The text result prints, for each metric, the p75 with its unit, the rating, the good / needs-improvement / poor share, and n.

query and compare still accept cwv_* as numeric properties. On hosted, the event is page_view.

vitals is in @agent-analytics/cli@0.5.41. It calls the hosted /vitals read.

Terminal window
npx --yes @agent-analytics/cli@0.5.41 vitals <project> [--days N] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--by device|path] [--limit N] [--json]

--from and --to are date_from and date_to. --by is group_by. --limit defaults to 10 and the maximum is 20. --json prints the API body.

On 2026-11-13, CLS and INP changed in place (tracker 0.2.5).

Before that date, CLS was the sum of layout-shift values without hadRecentInput, including a stored 0 when another vital was present and layout-shift was unsupported. INP was a percentile of every event entry with an interactionId, so one interaction could contribute several durations.

From 2026-11-13, CLS is the largest session window, and the key is omitted when layout-shift is unsupported. INP is the worst interaction after grouping by interactionId, ignoring one highest interaction per 50. cwv_fcp, cwv_ttfb, and the three target keys start on that date.

A window that crosses 2026-11-13 mixes the two definitions. n can differ by metric across that date. See Release notes.