Web vitals
Core Web Vitals reach Flare as attributes on a page's spans. They are spread over two spans because the browser does not know all of them at the same time.
The attributes
Every value is a number of milliseconds, except browser.web_vital.cls, which has no unit.
| Attribute | Type | Description |
|---|---|---|
browser.web_vital.lcp |
double | Largest Contentful Paint, in milliseconds |
browser.web_vital.cls |
double | Cumulative Layout Shift |
browser.web_vital.inp |
double | Interaction to Next Paint, in milliseconds |
browser.web_vital.fcp |
double | First Contentful Paint, in milliseconds |
browser.web_vital.ttfb |
double | Time to First Byte, in milliseconds |
If the browser never reported a vital, leave the attribute out. Do not send 0.
A Cumulative Layout Shift of 0 has meaning: it says the page did not shift at all, which is the best result a page can get. Only Chromium browsers measure it, so a client that sends 0 when it has nothing to report is telling Flare that every Firefox and Safari visit had a perfect layout. For that reason, leave out the value if it can not be measured instead of sending 0.
Why two spans
Some vitals are final the first time they report. Time to First Byte and First Contentful Paint happen once and never change.
The other three keep moving. Largest Contentful Paint can be replaced by a bigger element later. Cumulative Layout Shift adds up as the page shifts. Interaction to Next Paint needs the user to interact first, which may not have happened yet when the page finishes loading.
So a page reports its vitals twice:
- Whatever is already final when the
browser_pageloadspan closes gets added to that span. - Whatever is still moving gets sent later, on a
browser_web_vitalspan, in a separate request.
Flare looks for the vital attributes on any span in the aggregation. It does not care which of the two carried them, so you can put any vital on either span. Just send each vital once per pageload. Put the same value on both spans and it gets counted twice.
Send the second request on the first client-side navigation or when the page is hidden, whichever happens first. If you only wait for page hide, you lose the vitals of anyone who navigated away inside your single page app. A request started while the page is closing needs keepalive to survive, see Sending data.
The browser_web_vital span
It is a container span. That is what lets it point at a parent Flare received in an earlier request.
| Field | Value |
|---|---|
parentSpanId |
The spanId of the pageload span these vitals belong to |
startTimeUnixNano |
The startTimeUnixNano of that pageload span |
endTimeUnixNano |
The same value again, so the span has zero duration |
Both timestamps sit in the past
You send the browser_web_vital span long after the page loaded, but you do not timestamp it at that moment. Both timestamps of the vitals span have to match the start of the pageload span.
Flare files vitals into a time bucket based on the span's start time. If you timestamped the span at page hide, a tab someone left open for forty minutes would report its vitals forty minutes late, into a bucket with no matching pageload. Vitals that land in a bucket on their own could be dropped.
Repeat the page context
Keep a reference to the pageload root for as long as the document lives. Building the vitals span needs its traceId, its spanId, its start time and the attributes it was opened with. Reading traceId and spanId off a span that has already ended is fine, so the root closing costs you nothing.
Copy the page's attributes onto the vitals span from that retained copy. Flare groups the span on its own attributes and never looks at the parent, so anything you leave out is missing:
| Attribute | Required | Description |
|---|---|---|
http.route |
yes | The page's route, matching the pageload span |
flare.entry_point.handler.identifier |
no | The same route value again, matching the pageload span |
flare.route.source |
no | route or url, again matching the pageload span |
flare.entry_point.type, flare.entry_point.value, flare.entry_point.handler.type |
no | The rest of the entry point, copied from the pageload span |
url.full, user_agent.original, http.request.referrer, document.ready_state |
no | The page context, copied from the pageload span |
Only http.route decides whether the span can be grouped. Without it Flare accepts the request and the vitals go nowhere. The rest is worth sending anyway, so the vitals span describes the same page its numbers came from.
Capture those attributes when the page opens, not when you send the vitals. By page hide the browser may have moved on, and the vitals belong to the page that was loaded.
Keep the retained copy up to date
A router that resolves its route pattern late is the awkward case. The pageload span may already have closed by then, and renaming a closed span does nothing, but the vitals span has not been built yet.
So when a route settles, update your retained copy even when the pageload root itself is past changing. Skip that and the pageload span reports /products/{id} while the vitals span reports /products/9f2c, which puts the vitals in a group of their own, separate from the page they measured.
Effect on page metrics
A browser_web_vital span does not count towards the pageload count, the average, the duration percentiles or the error rate of its aggregation. It only contributes its vital values. Sending one will not inflate your pageload count, and its zero duration will not drag your page timings down.
Example
The second request for the page load in the events example. Time to First Byte and First Contentful Paint were already added to the page span, so only the remaining three are here:
{
"resourceSpans": [
{
"resource": {
"attributes": [
{ "key": "service.name", "value": { "stringValue": "Acme Storefront" } },
{ "key": "telemetry.sdk.language", "value": { "stringValue": "javascript" } },
{ "key": "telemetry.sdk.name", "value": { "stringValue": "@flareapp/js" } },
{ "key": "flare.framework.name", "value": { "stringValue": "react" } }
],
"droppedAttributesCount": 0
},
"scopeSpans": [
{
"scope": { "name": "@flareapp/js", "version": "2.6.0" },
"spans": [
{
"traceId": "8d4f1a2b3c5e607988d4f1a2b3c5e079",
"spanId": "5e6f708192a3b4c5",
"parentSpanId": "1a2b3c4d5e6f7081",
"name": "/products/{id}",
"startTimeUnixNano": 1710252000000000000,
"endTimeUnixNano": 1710252000000000000,
"status": { "code": 0 },
"attributes": [
{ "key": "flare.span_type", "value": { "stringValue": "browser_web_vital" } },
{ "key": "flare.entry_point.type", "value": { "stringValue": "web" } },
{ "key": "flare.entry_point.value", "value": { "stringValue": "https://acme.test/products/9f2c" } },
{ "key": "flare.entry_point.handler.type", "value": { "stringValue": "browser" } },
{ "key": "flare.entry_point.handler.identifier", "value": { "stringValue": "/products/{id}" } },
{ "key": "http.route", "value": { "stringValue": "/products/{id}" } },
{ "key": "flare.route.source", "value": { "stringValue": "route" } },
{ "key": "url.full", "value": { "stringValue": "https://acme.test/products/9f2c" } },
{ "key": "document.ready_state", "value": { "stringValue": "loading" } },
{ "key": "browser.web_vital.lcp", "value": { "doubleValue": 1104 } },
{ "key": "browser.web_vital.cls", "value": { "doubleValue": 0.04 } },
{ "key": "browser.web_vital.inp", "value": { "doubleValue": 72 } }
],
"droppedAttributesCount": 0,
"events": [],
"droppedEventsCount": 0
}
]
}
]
}
]
}
Both timestamps match the pageload span's start, and parentSpanId points at the pageload span from the earlier request. The entry point and page attributes are copies of what went out on that span, taken from the retained page context rather than read from the live page.
On this page