Measurement and attribution
How to tell whether any of this worked. Query segmentation by page tier, distinguishing an indexation problem from a ranking problem, and what to look at weekly versus quarterly.
5 min read · updated 2026-07-26· last reviewed 2026-07-26
Sitewide numbers hide everything
The principle
A single impressions line for the whole site is the least useful chart in Search Console, because a site is not one thing. A programmatic tier can be collapsing while the total looks flat, because the blog grew by the same amount in the same period.
Segment by page tier and look at each separately. That one change turns Search Console from a vanity dashboard into a diagnostic tool.
The implementation
Design URL patterns so they are segmentable from the start. /glossary/, /schema/, /blog/, /playbook/ are each a filterable prefix. A flat URL structure where everything lives at the root is unsegmentable, and by the time you notice, changing it means redirecting the whole site.
In Search Console, filter Pages by URL containing the prefix, then read impressions and clicks per tier. Export monthly and keep the series, because Search Console's own retention is 16 months and you will want longer.
Indexation problem or ranking problem
The principle
These two look identical in a traffic chart and have opposite fixes. Diagnosing them apart is the single most useful measurement skill for a large site.
- Pages not indexed. A crawl or quality problem. Fix discovery, internal linking, or thinness.
- Pages indexed, near-zero impressions. A relevance or competition problem. Fix the content or accept the query is out of reach.
- Impressions with near-zero clicks. A presentation problem. Fix titles and descriptions.
Three different diagnoses, three different weeks of work, one identical-looking chart.
The implementation
Compare two Search Console reports for the same URL prefix:
- The Pages report: submitted versus indexed for that prefix.
- The Performance report: impressions for that prefix.
Full indexation with zero impressions is a ranking problem. Partial indexation is a crawl or quality problem, and the Pages report's exclusion reasons will usually name it: "Crawled, currently not indexed" means quality, "Discovered, currently not indexed" means crawl budget or discovery.
Revenue events fire server-side
The principle
A client success page is skipped on redirect failure, blocked by extensions, and replayed on refresh. Revenue reporting built on it is wrong in both directions at once: under-reporting real purchases and double-counting refreshed ones.
Fire the purchase event from the payment webhook, which is the only component that definitively knows a payment succeeded.
The implementation
// app/api/webhooks/[provider]/route.ts
await recordPurchase(order);
await analytics.captureServer(EVENTS.PURCHASE_COMPLETED, {
tier: order.tier,
amount_cents: order.amountCents,
currency: order.currency,
});Test it by completing a purchase and closing the tab before the success page loads. The event must still record exactly once.
Event names come from one place
The principle
A typo in an event name is a silently broken funnel that nobody notices for a month. Renaming an event after launch orphans every chart built on it.
One const object, imported everywhere, typed property shapes per event.
The implementation
export const EVENTS = {
CHECKLIST_SNIPPET_LOCKED_CLICKED: "checklist_snippet_locked_clicked",
CHECKOUT_STARTED: "checkout_started",
PURCHASE_COMPLETED: "purchase_completed",
} as const;
export type EventProperties = {
[EVENTS.CHECKLIST_SNIPPET_LOCKED_CLICKED]: { check_id: string; group: string };
[EVENTS.CHECKOUT_STARTED]: { tier: string; price_cents: number };
};The typed properties matter as much as the names. An event fired with checkId in one place and check_id in another produces two properties in the analytics tool and no error anywhere.
Web vitals with attribution
The principle
Knowing LCP moved from 1.4s to 2.6s is not actionable. Knowing it moved and now points at a specific image is.
Capture the element, not just the number.
The implementation
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const lcp = entry as PerformanceEntry & { element?: Element; url?: string };
capture("web_vital_lcp", {
value_ms: Math.round(entry.startTime),
element: lcp.element?.tagName?.toLowerCase(),
element_id: lcp.element?.id || undefined,
resource_url: lcp.url,
});
}
});
observer.observe({ type: "largest-contentful-paint", buffered: true });buffered: true is required, or you miss the entry that fired before the observer was registered, which is most of them.
Reverse-proxy the analytics
The principle
Ad blockers block the analytics vendor's domain, not yours. Without a proxy, an unknown and non-random share of your funnel is invisible, and the missing share correlates with exactly the technical audience most likely to be reading this.
The implementation
async rewrites() {
return [
{ source: "/ingest/static/:path*", destination: `${ASSET_HOST}/static/:path*` },
{ source: "/ingest/:path*", destination: `${API_HOST}/:path*` },
];
}Both routes are needed. Proxying the ingestion endpoint but not the static assets leaves the SDK loading from a blocked origin, which fails just as completely.
What to look at, and how often
Weekly. Impressions and clicks per tier. Indexed count per tier. Core Web Vitals field data. Anything that moved more than 20 percent.
Monthly. The citation check against the fixed seed query set. Conversion rate by entry page. Refund rate.
Quarterly. Full checklist re-audit. Strip-the-entity-name test on every programmatic tier. Review of the two fastest-ageing chapters.
The weekly list is deliberately short. A dashboard with 40 metrics is a dashboard nobody opens.
Checks this chapter covers
Each one has a command you can run against your own site.
- →A monthly citation check against a fixed set of seed queriesaeo-citation-monitoring
- →Search Console queries are segmented by page tier, not read in aggregatemeasure-gsc-by-tier
- →You can distinguish an indexation problem from a ranking problemmeasure-indexation-vs-ranking
- →Revenue events fire server-side from the webhook, never from a client success pagemeasure-server-side-revenue
- →Event names come from one const object, imported everywheremeasure-event-taxonomy
- →Web vitals are captured with element attribution, not just a numbermeasure-web-vitals-attribution
- →Analytics is reverse-proxied through your own originmeasure-analytics-proxied