Exporting Web Vitals as OpenTelemetry Spans
You already have browser tracing posting document-load and fetch spans to a Collector, and you want the Core Web Vitals numbers themselves to land in the same trace store — so a slow LCP sits next to the request waterfall that caused it, queryable by service.name. This page is the concrete recipe for that: take each web-vitals callback, mint a short OpenTelemetry span carrying the metric value, rating, and attribution fields, attach it to the page-load trace, and ship it over your existing OTLPTraceExporter. It sits under OpenTelemetry for Web RUM, and assumes the provider wiring from Configuring OpenTelemetry for Frontend Performance is already in place.
Prerequisites
Before the first vital span leaves the browser, have these in place:
- A working browser tracer from Configuring OpenTelemetry for Frontend Performance: a registered
WebTracerProvider, aBatchSpanProcessor, and anOTLPTraceExporterpointed at your Collector. This page reuses that pipeline rather than standing up a second one. - The
web-vitalspackage, installed and configured as covered in Web Vitals API Implementation. Use the attribution build (web-vitals/attribution) so each metric carries the diagnostic fields you will copy onto span attributes. - A decision on representation: a span per metric (this page’s default) or an OTel metric instrument (a histogram). Spans give you per-session attribution and trace linkage; histograms give you cheap pre-aggregated percentiles. Most teams start with spans for debuggability and add a histogram later.
- Awareness that vitals are late and singular:
onLCP/onINP/onCLSfire at most once per page (final value) and often only on tab hide, so the span you create has no meaningful duration — set its start and end to the same instant and let the attributes carry the data.
How to export Web Vitals as spans
Step 1 — Get a tracer from your existing provider
import { trace } from '@opentelemetry/api';
// Reuses the global WebTracerProvider registered in your OTel setup.
// The version string is just the instrumentation scope label.
export const vitalsTracer = trace.getTracer('web-vitals', '1.0.0');
Why: trace.getTracer() returns a tracer bound to the already-registered provider, so vital spans flow through the same BatchSpanProcessor and OTLPTraceExporter as your document-load spans. Naming the instrumentation scope web-vitals lets you filter these spans apart from auto-instrumentation spans in the store.
Step 2 — Capture the page-load root span as the parent
import { context, trace, ROOT_CONTEXT } from '@opentelemetry/api';
// Create one root span per page load that vital spans hang off of.
// Started at navigation, kept open until the page is hidden.
const pageSpan = vitalsTracer.startSpan('page-load', {
root: true,
attributes: { 'page.url': location.pathname }, // path only — low cardinality
}, ROOT_CONTEXT);
// A context carrying the page span, used as parent for every vital span.
export const pageCtx = trace.setSpan(context.active(), pageSpan);
export { pageSpan };
Why: vitals are reported independently and asynchronously, long after DocumentLoadInstrumentation has closed its own span. Holding an open page-load span gives every metric a stable parent so all three vitals for one session share a trace id. Storing location.pathname (not the full URL with query string) keeps the attribute low-cardinality. The span is closed in Step 5.
Step 3 — Convert each metric into a span with attributes
import { trace } from '@opentelemetry/api';
import { vitalsTracer, pageCtx } from './vitals-tracer.js';
// performance.timeOrigin + metric.entries[0].startTime would give a true
// wall-clock start; for a point-in-time metric, start == end is fine.
function recordVitalSpan(metric) {
const span = vitalsTracer.startSpan(
`web_vital.${metric.name}`,
{ attributes: {
'webvital.name': metric.name, // LCP | INP | CLS | FCP | TTFB
'webvital.value': metric.value, // ms, or unitless for CLS
'webvital.rating': metric.rating, // good | needs-improvement | poor
'webvital.delta': metric.delta,
'webvital.id': metric.id, // unique per metric instance
'webvital.navigation_type': metric.navigationType, // navigate | back-forward-cache | reload | prerender
} },
pageCtx, // parent = the open page-load span
);
// No work happens between start and end: this is a marker span.
span.end();
}
Why: metric.name, metric.value, metric.rating, and metric.navigationType are present on every web-vitals report. Prefixing attribute keys with webvital. namespaces them away from semantic-convention keys and makes them trivial to query. metric.rating is computed by the library against the current Google thresholds, so you do not recompute Good/Needs-Improvement/Poor yourself. webvital.id deduplicates: a back-forward-cache restore can re-report a metric, and the id distinguishes instances.
Step 4 — Add attribution fields so a slow span is actionable
// Copy the attribution build's diagnostic fields onto the span.
// Each metric has a different attribution shape, so map per name.
function attributionAttributes(metric) {
const a = metric.attribution ?? {};
switch (metric.name) {
case 'LCP':
return {
'webvital.lcp.element': a.element, // CSS selector of LCP element
'webvital.lcp.url': a.url, // resource URL, if image
'webvital.lcp.ttfb': a.timeToFirstByte,
'webvital.lcp.resource_load_delay': a.resourceLoadDelay,
'webvital.lcp.render_delay': a.elementRenderDelay,
};
case 'INP':
return {
'webvital.inp.event_type': a.interactionType, // pointer | keyboard
'webvital.inp.target': a.interactionTarget, // selector of slow element
'webvital.inp.input_delay': a.inputDelay,
'webvital.inp.processing_duration': a.processingDuration,
'webvital.inp.presentation_delay': a.presentationDelay,
};
case 'CLS':
return {
'webvital.cls.largest_shift_target': a.largestShiftTarget, // selector
'webvital.cls.largest_shift_value': a.largestShiftValue,
'webvital.cls.load_state': a.loadState,
};
default:
return {};
}
}
Why: a bare webvital.value of 4200 ms tells you INP or LCP is Poor but not why. The attribution build breaks each metric into sub-parts — for LCP, the resourceLoadDelay versus elementRenderDelay split tells you whether to fix the network or the render; for INP, inputDelay/processingDuration/presentationDelay localizes the bottleneck; for CLS, largestShiftTarget names the element that jumped. These are the fields that make a span queryable into a fix. Merge this object into the attributes from Step 3.
Step 5 — Wire the callbacks and close the page span
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals/attribution';
import { pageSpan } from './vitals-tracer.js';
import { trace, context } from '@opentelemetry/api';
function record(metric) {
// recordVitalSpan from Step 3, extended to merge attributionAttributes.
const span = vitalsTracer.startSpan(`web_vital.${metric.name}`, {
attributes: {
'webvital.name': metric.name,
'webvital.value': metric.value,
'webvital.rating': metric.rating,
'webvital.navigation_type': metric.navigationType,
...attributionAttributes(metric),
},
}, trace.setSpan(context.active(), pageSpan));
span.end();
}
// reportAllChanges:false → one final value per metric (the default).
onLCP(record);
onINP(record);
onCLS(record);
onFCP(record);
onTTFB(record);
// Close the parent once the page is going away, then let BatchSpanProcessor flush.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
pageSpan.end();
}
}, { capture: true, once: true });
Why: importing from web-vitals/attribution is what populates metric.attribution in Step 4 — the standard web-vitals entrypoint leaves it undefined. Keeping reportAllChanges at its default means each callback fires once with the finalized value, so you emit exactly one span per metric instead of a span per intermediate change. Ending pageSpan on the hidden transition closes the trace cleanly; the forceFlush you already wired on the same event (from the configuration page) drains the batch before the tab freezes.
Step 6 — Optionally mirror values into a metric instrument
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('web-vitals');
// One histogram per metric keeps cardinality bounded vs. per-session spans.
const lcpHistogram = meter.createHistogram('webvital.lcp', { unit: 'ms' });
function recordHistogram(metric) {
if (metric.name !== 'LCP') return;
lcpHistogram.record(metric.value, {
// Only low-cardinality dimensions belong on a metric.
'webvital.rating': metric.rating,
'webvital.navigation_type': metric.navigationType,
});
}
onLCP(recordHistogram);
Why: spans are perfect for per-session debugging but expensive to aggregate — computing p75 means scanning every span. A histogram instrument pre-aggregates in the Collector, so p75 is a cheap query, but it must stay low-cardinality: never put webvital.id, selectors, or URLs on a metric attribute. Run both — spans for “show me the worst sessions”, histograms for “what is p75 by navigationType”. This requires a metrics SDK (MeterProvider + metrics exporter) alongside the trace SDK.
Verifying it works
Confirm the vitals are arriving as spans:
- DevTools Network panel: filter to
v1/tracesand trigger a tab hide (switch tabs). The flushedPOSTbody should contain spans namedweb_vital.LCP,web_vital.CLS, etc. INP and CLS typically only appear on hide. - Console exporter in dev: temporarily attach a
SimpleSpanProcessor(new ConsoleSpanExporter())and watch forweb_vital.*span names with the attribute bag printed — proof the attribution merge worked before export is even involved. - Trace store: query
name =~ "web_vital.*"in Tempo/Jaeger. One trace per session should show thepage-loadroot with three-to-five vital child spans sharing its trace id, each carryingwebvital.ratingand the attribution attributes. - Threshold sanity check: confirm the library’s
ratingmatches the current Google bands before you alert on it.
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| FCP | ≤ 1.8 s | ≤ 3.0 s | > 3.0 s |
| TTFB | ≤ 800 ms | ≤ 1.8 s | > 1.8 s |
Edge cases & gotchas
| Symptom | Cause | Fix |
|---|---|---|
metric.attribution is undefined |
Imported from web-vitals, not web-vitals/attribution |
Switch the import to the attribution build |
| Vital spans have a different trace id each | No shared parent span | Reuse one open page-load span as parent via trace.setSpan |
| INP/CLS spans never arrive | Callbacks fire on hide and the batch never flushes | forceFlush() on visibilitychange → hidden |
| Trace store index bloats | High-cardinality attributes (full URL, webvital.id) on a metric instrument |
Keep only rating/navigationType on histograms; cardinality-heavy fields stay on spans |
| CLS span value looks tiny | CLS is unitless, not ms | Do not set a ms unit on the CLS span/attribute |
| Duplicate metric reports | back-forward-cache restore re-reports |
Deduplicate downstream on webvital.id |
Two timing hazards matter. First, the page-load span must be started in your entry module before the vitals callbacks can fire, or the first metric will find no active parent and start a detached trace. Second, on a bfcache restore the page is reused without a fresh navigation, so the same pageSpan may still be open from the previous activation — gate the span creation on the pageshow event when you support back/forward-cache sessions, so each activation gets its own root.
FAQ
Should I model a vital as a span or a metric instrument?
Both, for different jobs. A span carries per-session attribution (the LCP element selector, the slow interaction target) and links into the page-load trace, which is what you want when debugging a single bad session. A histogram instrument pre-aggregates so p75 by navigationType is a cheap query and storage stays bounded. Spans cost more to aggregate; metrics lose per-session detail. Emit spans for debuggability and add a low-cardinality histogram for dashboards.
Why does my vital span have zero duration?
Because Core Web Vitals are point-in-time final values, not operations with a span of work. onLCP/onINP/onCLS hand you a finished number, so the correct model is a marker span where start equals end and the value lives in an attribute. Do not try to stretch the span across the page lifetime — that distorts duration-based queries in the trace store.
How do I keep vital spans from exploding trace-store cardinality?
Keep high-cardinality fields (selectors, resource URLs, webvital.id) on spans, where they are stored as attributes and not indexed as time-series labels, and put only bounded dimensions like webvital.rating and webvital.navigation_type on any metric instrument. Sample at the trace level if volume is still high, and aggregate surviving values at p75 rather than averaging.
Related
- OpenTelemetry for Web RUM — parent overview of where browser tracing fits in a self-hosted RUM stack.
- Configuring OpenTelemetry for Frontend Performance — the provider, exporter, and Collector wiring this page reuses.
- Web Vitals API Implementation — capturing the vitals and the attribution build that feed these spans.