Connecting Web Vitals to Backend Spans
Record LCP, INP, and CLS as attributes on a long-lived page-view span that shares its trace with the page’s API calls — then a query grouping backend latency by LCP bucket answers “is the slow page caused by our services or by the client”.
Context and when it matters
Real-user monitoring and distributed tracing usually live in separate tools and disagree constantly. RUM says the 75th-percentile LCP is 4.1 s; the tracing backend says every API call is under 150 ms. Both are correct, and neither can explain the other, because there is no identifier joining a page view to the requests it made.
Putting the vitals on a span in the same trace as those requests makes the join trivial. It also changes what you can ask: not just “how slow is LCP” but “for the page views with the worst LCP, what did the backend do differently” — which is the question that distinguishes a slow database from a heavy JavaScript bundle.
The timing problem
Web Vitals are not available when the page loads. LCP finalises when the user interacts or the page is hidden. INP requires at least one interaction and updates over the session. CLS accumulates until the page is hidden. A span that ends at load cannot carry any of them.
Implementation
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals/attribution';
import { trace, context } from '@opentelemetry/api';
const tracer = trace.getTracer('web.vitals');
// One long-lived span per page view. It shares the trace with every fetch the
// page makes, which is the whole point.
const pageView = tracer.startSpan('page view', {
attributes: {
'page.route': routeTemplate(location.pathname), // TEMPLATE, not the URL
'page.referrer_type': document.referrer ? 'internal' : 'direct',
// Client conditions that explain a slow metric without being personal data.
'network.effective_type': navigator.connection?.effectiveType ?? 'unknown',
'device.hardware_concurrency': navigator.hardwareConcurrency ?? 0,
},
});
const pageCtx = trace.setSpan(context.active(), pageView);
function record(metric) {
// Value plus rating: the rating is bounded (good/needs-improvement/poor)
// and is what you group by; the value is what you compute percentiles from.
pageView.setAttribute(`web_vitals.${metric.name.toLowerCase()}`, metric.value);
pageView.setAttribute(`web_vitals.${metric.name.toLowerCase()}.rating`, metric.rating);
// Attribution turns "LCP is 4s" into "LCP is 4s because of this element,
// and 2.6s of it was resource load delay".
const a = metric.attribution ?? {};
if (metric.name === 'LCP') {
pageView.setAttribute('web_vitals.lcp.element', a.element ?? 'unknown');
pageView.setAttribute('web_vitals.lcp.ttfb', a.timeToFirstByte ?? 0);
pageView.setAttribute('web_vitals.lcp.resource_load_delay', a.resourceLoadDelay ?? 0);
pageView.setAttribute('web_vitals.lcp.render_delay', a.elementRenderDelay ?? 0);
}
if (metric.name === 'INP') {
pageView.setAttribute('web_vitals.inp.target', a.interactionTarget ?? 'unknown');
pageView.setAttribute('web_vitals.inp.input_delay', a.inputDelay ?? 0);
pageView.setAttribute('web_vitals.inp.processing_duration', a.processingDuration ?? 0);
}
if (metric.name === 'CLS') {
pageView.setAttribute('web_vitals.cls.largest_shift_target', a.largestShiftTarget ?? 'unknown');
}
}
[onTTFB, onFCP, onLCP, onINP, onCLS].forEach((fn) => fn(record));
// End the span when the visit ends, and flush before the tab goes away.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && pageView.isRecording()) {
pageView.end();
provider.forceFlush().catch(() => {});
}
}, { once: true });
Two details make this work in practice. page.route must be the route template rather than the URL, for the same cardinality reason as http.route on the server side. And the attribution import (web-vitals/attribution) is a larger bundle than the base one — worth it, because a metric without attribution tells you there is a problem and nothing about where it is.
Making the join useful
With the vitals on a span in the same trace as the API calls, the analysis that was previously impossible becomes one query:
# Backend latency for page views in the worst LCP bucket
{ resource.service.name = "dashboard-web" && span.web_vitals.lcp.rating = "poor" }
>> { span:kind = server && resource.service.name != "dashboard-web" }
| by(resource.service.name) | avg(span:duration)
# Compare against the same query for rating = "good".
# If backend latency is identical in both, the problem is client-side.
That comparison is the point of the whole exercise. Three outcomes, three different owners:
Verification
- Open one trace and confirm the page-view span carries all five metrics and shares its trace ID with the page’s API spans.
- Compare the LCP distribution against your RUM tool for the same window; a systematic difference usually means the browser sample rate differs from what you assumed.
- Check that spans arrive from real sessions, not just from the ones that stay open — if only long sessions report, the unload flush is not working.
- Confirm
page.routeis bounded: a distinct-count query should return the number of routes, not the number of visits.
What to keep, and how much of it
Browser telemetry volume scales with users rather than with your capacity, so a sampling policy is not optional. The useful asymmetry is that the interesting page views are rare: a session with a poor LCP is a few percent of traffic, and those are the ones worth every byte.
That suggests a two-tier policy. Sample page views uniformly at a low rate — five percent is generous for trend analysis — and additionally keep every page view whose LCP or INP rating is poor. Because the rating is known before the span ends, the decision can be made in the browser: set the sampled flag on the page-view span when a metric crosses the threshold, and let the uniform sampler handle the rest. The result is a dataset where the aggregate is statistically sound and the tail is complete.
There is a subtlety worth planning around. The page-view span’s sampling decision is made at span creation, before any vital is known, so a late upgrade to “keep this one” requires either a tail-based decision at the Collector or a deliberate design where the page-view span is always sampled and only the associated fetch spans are thinned. The second option is usually simpler and cheaper: one span per page view is a small volume even at full retention, and the fetch spans are where the bulk sits.
Retention deserves a separate decision from backend traces. A page-load profile stops being actionable once the bundle changes, which for an actively developed front end is a matter of days. Routing browser telemetry to a shorter-retention tenant keeps cost proportionate and stops a traffic spike in the browser tier from evicting backend traces that are still under investigation.
Finally, be deliberate about what a browser span may carry. Attribution data names DOM elements, which is fine; but URLs, query strings, and any user-entered value are as sensitive here as anywhere else, and a browser is the easiest place for one to slip in. Template the route, avoid recording the full URL, and treat the same redaction discipline as applying to front-end telemetry.
Common pitfalls
- Ending the page-view span at
load. Then LCP, INP, and CLS are all missing, and the exercise achieves nothing. - Creating one span per metric. Five sibling spans cannot be compared to each other and clutter the waterfall; the metrics belong on one span as attributes.
- Recording the URL as
page.route. Unbounded, and it makes every grouping useless. - Forgetting
visibilitychangeon mobile. iOS frequently skipsbeforeunload, so metrics from mobile sessions never arrive and the data skews desktop. - Sampling browser traces uniformly. The interesting page views are the slow ones; if your sample rate is low, consider always keeping sessions whose LCP rating is poor, which is a small fraction of traffic.
A closing note on interpretation: Web Vitals are percentile metrics defined over a population, so a single trace’s LCP is a data point rather than a verdict. Use individual traces to understand why a page view was slow, and the aggregate to decide whether it matters.
Related
- Browser-to-backend trace continuity — establishing the shared trace this page depends on
- Propagating trace context from browser fetch calls — how the page’s API calls join the trace
- Finding latency bottlenecks with critical path analysis — following the backend half of a slow page view
↑ Back to Browser-to-Backend Trace Continuity