A real-user monitoring pipeline that reports “third-party JavaScript cost 180 ms of blocking time this week” is almost useless. Which third party? The tag manager, the session recorder, the ad exchange, the chat widget, or the A/B testing SDK that a growth team wired in through the tag manager without telling you? Aggregate field data tells you that you have a problem; it does not tell you whose name to put on the ticket. The gap between “our third-party code is slow” and “Vendor X’s collect endpoint is responsible for 62% of our long-task time on mobile” is the difference between a standing complaint and a fix.

Closing that gap is an attribution problem, and it has three moving parts. First, every raw observer entry carries a URL in entry.name — you must parse the origin out of it. Second, a single vendor speaks from many hostnames (www.google-analytics.com, region1.google-analytics.com, analytics.google.com, www.googletagmanager.com), so a raw-origin breakdown scatters one vendor’s cost across five rows; you need a canonical registry that collapses those hostnames into one stable key. Third, field metrics are distributions, not numbers — a mean hides the tail where your slowest cohort actually lives, so you aggregate at the 75th percentile and reconcile the result against the Chrome UX Report so your first-party telemetry is anchored to the same statistic Google ranks you on.

This guide builds that attribution layer: an origin parser, a vendor registry, a per-page-view summary that keys metrics by vendor, and a server-side p75 aggregation you can trust. It assumes you are already collecting raw entries; if you are not, wire up PerformanceObserver instrumentation for third-party scripts first — this page consumes what that instrumentation produces.


RUM vendor-attribution pipeline A left-to-right flow: a raw PerformanceObserver entry with a name URL enters an origin-parse step that extracts a hostname; the hostname is resolved through a vendor registry that maps many hostnames to one canonical vendor key; resolved samples flow into a per-vendor store that computes a p75 per metric; the store is reconciled against CrUX field data shown above it. entry .name = URL .duration parse origin new URL() → hostname vendor registry N hosts → 1 key per-vendor p75 store keyed by vendor CrUX p75 reconcile / anchor google-analytics.com region1.analytics.google.com googletagmanager.com all → key "google"

Prerequisites: What You Need Before Attributing

Attribution is a processing layer on top of collection. Reach for this guide when all of the following hold:

  • You already emit raw performance entries from the client. This attribution layer consumes resource, longtask, event, and long-animation-frame entries observed via a PerformanceObserver. If you have not set that up, start with instrumenting third-party scripts with PerformanceObserver, which produces exactly the entry shapes this page parses.
  • You have a beacon endpoint that receives per-page-view payloads (a navigator.sendBeacon target, a serverless function, or a RUM SaaS ingestion API you control the schema of).
  • You need to name vendors, not just measure a lump sum. If a single aggregate third-party number is enough for your purposes, you do not need a registry — but you will not be able to file an actionable ticket either.

One decision gates everything downstream: attribute on the client or on the server? Parse the origin and resolve the vendor on the client so the beacon carries a compact, already-keyed summary rather than hundreds of raw entries. Compute percentiles on the server, where you have the full population of page views. Splitting the work this way keeps the beacon small and the statistics correct — a percentile computed per page view is meaningless.

The Attribution Contract: Origin, Registry, Percentile

Three precise mechanics make attribution correct rather than approximately right.

entry.name is a URL for the entries that matter. For a PerformanceResourceTiming entry, name is the resolved request URL. For a PerformanceLongAnimationFrame entry’s scripts[], each script’s sourceURL is a URL. Passing that string to new URL(name).hostname gives you the host. A longtask entry’s name is not a URL (it is a literal like "self" or "unknown"), so longtask must be attributed indirectly through its attribution[] container src, or — far more reliably — through the newer long-animation-frame API and its per-script attribution.

A vendor is a set of hostnames, not one hostname. Google Analytics 4 alone beacons from www.google-analytics.com, region1.google-analytics.com, and analytics.google.com, and its loader ships from www.googletagmanager.com. If you group by raw hostname, one vendor’s cost fragments across four rows and every row looks small. The registry is the function that collapses hostname → canonical vendor key, so all of Google’s endpoints roll up under one name you can actually reason about.

p75, not mean. Field performance is right-skewed: most page views are fast, a slow minority stretches far to the right, and the mean sits in the empty middle where no real user lives. Google ranks Core Web Vitals at the 75th percentile of the distribution, and the Chrome UX Report (CrUX) publishes p75 for its metrics. Aggregating your own vendor attribution at p75 means your internal number and the number Google judges you on are the same statistic — you can reconcile them instead of arguing about why they disagree.

Implementation

Step 1 — Build a Canonical Vendor Registry

The registry is a single source of truth mapping hostnames (and hostname suffixes) to a stable vendor key plus a human label. Keep it declarative so non-engineers can extend it during a vendor audit.

// vendor-registry.js
// Each entry: canonical key -> { label, match: [suffixes] }.
// `match` entries are matched as domain suffixes, so "google-analytics.com"
// also captures "region1.google-analytics.com".
export const VENDOR_REGISTRY = {
  google: {
    label: 'Google Analytics / GTM',
    match: ['google-analytics.com', 'googletagmanager.com', 'analytics.google.com'],
  },
  meta: {
    label: 'Meta Pixel',
    match: ['connect.facebook.net', 'facebook.com'],
  },
  hotjar: {
    label: 'Hotjar',
    match: ['hotjar.com', 'hotjar.io'],
  },
  intercom: {
    label: 'Intercom',
    match: ['intercom.io', 'intercomcdn.com', 'intercomassets.com'],
  },
  segment: {
    label: 'Segment',
    match: ['segment.com', 'segment.io', 'cdn.segment.com'],
  },
};

// Reverse index: longest-suffix-wins lookup structure built once at module load.
const SUFFIX_INDEX = Object.entries(VENDOR_REGISTRY)
  .flatMap(([key, { match }]) => match.map((suffix) => ({ suffix, key })))
  // Longer suffixes first so "cdn.segment.com" wins over a hypothetical "segment.com".
  .sort((a, b) => b.suffix.length - a.suffix.length);

/**
 * Resolve a hostname to a canonical vendor key, or null for first-party / unknown.
 */
export function resolveVendor(hostname) {
  if (!hostname) return null;
  const host = hostname.toLowerCase();
  for (const { suffix, key } of SUFFIX_INDEX) {
    if (host === suffix || host.endsWith(`.${suffix}`)) return key;
  }
  return null; // unrecognised origin — bucket as "other" upstream if desired
}

Suffix matching with an explicit .${suffix} boundary is deliberate: it prevents notgoogle-analytics.com from matching google-analytics.com, a class of attribution bug that silently inflates a vendor’s cost.

Step 2 — Write the Attribution Function

The attribution function takes a raw entry, extracts its origin, resolves the vendor, and returns a normalised sample. It is pure and synchronous, so it is cheap to run inside the observer callback.

// attribute.js
import { resolveVendor } from './vendor-registry.js';

const FIRST_PARTY = location.hostname;

/**
 * Extract the hostname from an entry's URL-bearing field.
 * Returns null if the entry carries no usable URL (e.g. name === "self").
 */
function originOf(entry) {
  const url = entry.name && entry.name.startsWith('http') ? entry.name : null;
  if (!url) return null;
  try {
    return new URL(url).hostname;
  } catch {
    return null; // malformed URL — never throw inside the observer
  }
}

/**
 * Map a raw PerformanceEntry to { vendor, metric, value } or null to skip.
 */
export function attribute(entry) {
  const hostname = originOf(entry);
  if (!hostname || hostname === FIRST_PARTY) return null; // first-party or unknown

  const vendor = resolveVendor(hostname);
  if (!vendor) return null; // unrecognised third party; extend the registry to capture it

  // Pick the metric that matters for this entry type.
  switch (entry.entryType) {
    case 'resource':
      // Blocking is best proxied by the time the resource occupied the network+parse.
      return { vendor, metric: 'resource_duration', value: entry.duration };
    case 'event':
      // INP-style interaction latency for handlers bound by this vendor.
      return { vendor, metric: 'event_duration', value: entry.duration };
    default:
      return null;
  }
}

Step 3 — Fold Entries Into a Per-Page-View Summary

Do not beacon raw entries. Fold them into a compact object keyed by vendor and metric, so one page view emits one small payload regardless of how many entries fired. This is the structure your RUM schema stores.

// collect.js
import { attribute } from './attribute.js';

// Shape: { [vendor]: { [metric]: number[] } } — raw samples for THIS page view.
const pageSummary = Object.create(null);

function record(sample) {
  if (!sample) return;
  const { vendor, metric, value } = sample;
  (pageSummary[vendor] ??= Object.create(null));
  (pageSummary[vendor][metric] ??= []).push(value);
}

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) record(attribute(entry));
});
observer.observe({ type: 'resource', buffered: true });
observer.observe({ type: 'event', buffered: true, durationThreshold: 40 });

/**
 * On page hide, reduce this page view's samples to per-vendor maxima
 * (worst interaction, longest resource) and beacon a compact payload.
 * Percentiles are computed server-side across page views — never here.
 */
function flush() {
  const payload = { url: location.pathname, vendors: {} };
  for (const vendor in pageSummary) {
    payload.vendors[vendor] = {};
    for (const metric in pageSummary[vendor]) {
      const samples = pageSummary[vendor][metric];
      // Per-page-view representative value: the worst sample the user actually felt.
      payload.vendors[vendor][metric] = Math.round(Math.max(...samples));
    }
  }
  navigator.sendBeacon('/rum/ingest', JSON.stringify(payload));
}

// pagehide is the reliable terminal event on mobile Safari and Chrome.
addEventListener('pagehide', flush, { once: true });

Emitting the per-page-view maximum for each vendor/metric captures the worst experience that page view actually delivered, which is the sample the server-side p75 should draw from. Emitting a per-page-view mean would pre-average away the tail before the server ever sees it.

Step 4 — Compute p75 Server-Side

The server receives one representative value per vendor/metric per page view. Aggregate a window of them and take the 75th percentile using nearest-rank, the same method CrUX uses.

// percentile.js — runs on your ingestion/aggregation server (Node, Worker, etc.)

/**
 * Nearest-rank p-th percentile. `sorted` must be ascending.
 * p = 0.75 returns the value at or below which 75% of samples fall.
 */
export function percentile(sorted, p = 0.75) {
  if (sorted.length === 0) return null;
  const rank = Math.ceil(p * sorted.length); // nearest-rank, 1-indexed
  return sorted[Math.min(rank, sorted.length) - 1];
}

/**
 * Given rows [{ vendor, metric, value }] for a time window,
 * produce { [vendor]: { [metric]: { p75, n } } }.
 */
export function aggregate(rows) {
  const byVendorMetric = new Map();
  for (const { vendor, metric, value } of rows) {
    const key = `${vendor}${metric}`;
    (byVendorMetric.get(key) ?? byVendorMetric.set(key, []).get(key)).push(value);
  }
  const out = {};
  for (const [key, values] of byVendorMetric) {
    const [vendor, metric] = key.split('�');
    values.sort((a, b) => a - b);
    (out[vendor] ??= {})[metric] = { p75: percentile(values, 0.75), n: values.length };
  }
  return out;
}

Guard every published p75 with its sample count n. A p75 over 20 page views is noise; hold a stable threshold (for example, n >= 1000 per vendor per window) before you treat a movement as real. This is the same reason CrUX only reports origins that clear a traffic floor.

Step 5 — Reconcile Against CrUX

Your first-party p75 measures what you instrument; CrUX measures the aggregate page-level Core Web Vitals for Chrome users who opted in. They will not be identical, but they should track. Pull CrUX for your origin and compare its page-level p75 (LCP, INP, CLS) against the sum of your attributed vendor costs to sanity-check that your instrumentation is not missing a large contributor.

# Query the CrUX API for your origin's p75 field metrics.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://example.com","metrics":["interaction_to_next_paint","largest_contentful_paint"]}'

If CrUX reports an INP p75 of 240 ms but your attributed vendor event_duration p75 values sum to only 40 ms, you are under-instrumenting — most likely missing long-animation-frame script attribution, which is the mechanism explored in attributing INP regressions to a single vendor script.

Verification Checklist

Interaction with Instrumentation and Budget Enforcement

Attribution sits in the middle of a three-stage measurement stack, and it depends on the stage before it and feeds the stage after it.

Upstream, the quality of your attribution is capped by the quality of your collection. The registry and percentile logic here are only as good as the entries fed in, which is why PerformanceObserver instrumentation of third-party scripts is a hard prerequisite — specifically the long-animation-frame script attribution that lets you tie main-thread blocking to a named source rather than the useless longtask "self" label.

Downstream, per-vendor p75 numbers are exactly what makes a performance budget enforced with Lighthouse CI defensible. A synthetic budget threshold picked from one lucky Lighthouse run either never fires or fires constantly; a threshold derived from a vendor’s real field p75 is grounded. Set the budget from the field, then let CI defend it pre-merge. And because both attribution and budgets key off which origin a request came from, the connection and scheduling choices you make while optimizing the network waterfall for external assets move the very p75 values this pipeline reports.

Troubleshooting

One vendor appears as several rows in the dashboard

Symptom: Google Analytics shows up as three separate entries (google-analytics.com, region1.google-analytics.com, analytics.google.com), each with a small cost.

Cause: You are grouping by raw hostname somewhere downstream of resolveVendor, or the vendor’s newer regional endpoint has a hostname your registry’s match list does not cover.

Fix: Confirm the beacon is keyed by the resolved vendor key, not entry.name or hostname. Add the missing regional suffix (google-analytics.com already captures region1. via the suffix match, so check you did not accidentally list the full host). Re-run the resolveVendor verification cases.

Attributed cost is far lower than the CrUX field p75

Symptom: Your summed vendor event_duration p75 is a fraction of the CrUX INP p75.

Cause: event entries only surface interactions above durationThreshold, and longtask/event attribution does not name the script. Blocking work done inside a vendor’s handler is being counted against "self" and dropped by attribute().

Fix: Add a long-animation-frame observer and attribute via scripts[].sourceURL, as covered in the INP regression attribution guide. Lower durationThreshold to 16 ms during investigation to catch shorter interactions.

The p75 swings wildly week to week

Symptom: A vendor’s reported p75 jumps 80 ms between windows with no code change.

Cause: Insufficient sample count. A percentile over a few hundred page views is dominated by outliers.

Fix: Enforce a minimum n before publishing (start at n >= 1000 per vendor per window). Widen the aggregation window for low-traffic vendors, and annotate any p75 below the floor as provisional rather than plotting it as fact.

new URL(entry.name) throws and kills the observer callback

Symptom: Attribution silently stops; the console shows TypeError: Failed to construct 'URL': Invalid URL.

Cause: Not every entry’s name is a URL — longtask uses "self", "same-origin", "unknown"; some paint entries use literals. An unguarded new URL() throws and, because it is inside the observer callback, aborts processing of the whole batch.

Fix: Guard with the startsWith('http') check and a try/catch returning null, exactly as in the originOf helper above. The observer callback must never throw.

Frequently Asked Questions

Why aggregate at p75 instead of the average or p95?

Because that is the statistic Google ranks Core Web Vitals on and the one CrUX publishes, so p75 lets you reconcile your first-party attribution against public field data directly. The average sits in the empty middle of a skewed distribution and hides the slow tail; p95 is noisier and needs far more samples to stabilise. p75 is the pragmatic balance — tail-aware enough to reflect real pain, stable enough to trend. You can compute p95 alongside it for investigation, but gate and report on p75.

Should the vendor registry live in code or in a database?

Start in code — a declarative module like the one above is diffable, reviewable in a pull request, and deploys atomically with the client that uses it. Move it to a database or remote config only when non-engineers need to edit it without a deploy, or when you have hundreds of vendors. If you do externalise it, cache it aggressively on the client and treat an unresolved origin as "other" rather than dropping it, so a stale registry never loses data.

Why compute percentiles on the server instead of in the browser?

A percentile is a property of a population, and a single page view is one sample — computing a percentile client-side is a category error. The browser should emit one representative value per vendor per metric (the worst the user felt), and the server should compute the p75 across the full window of page views. This also keeps the beacon tiny and puts the statistics where you can recompute them over any window without re-collecting.

What about an unrecognised third-party origin that is not in the registry?

Decide explicitly rather than silently dropping it. The simplest correct behaviour is to bucket every unresolved third-party origin under a synthetic "other" key so its cost stays visible in the total — a growing "other" bucket is your signal to run a vendor audit and extend the registry. Dropping unknowns entirely makes your attributed sum understate real third-party cost and breaks the CrUX reconciliation.


Up: Monitoring & Measuring Third-Party Performance