@dxv-systems/measurement

中文文档
0.11.1发布于 2026/11/10MITTypeScriptCommonJS近 30 天下载 6486

One consent gate (Consent Mode v2), one fan-out to GA4, Meta, PostHog and TikTok, and a Metis relay leg that always reports

原始使用文档
文档目录16 节
  1. The rule
  2. Policy
  3. Next.js (App Router)
  4. Vite + Express
  5. Server reporting
  6. Better matching (0.3.0)
  7. Funnel steps server-side (0.4.0)
  8. Google tag: the customer's e-mail and phone (0.5.0)
  9. Express: the funnel route (0.5.0)
  10. Which platforms the banner names (0.6.0)
  11. Translated or custom banners (0.7.0)
  12. Ask only what the site needs (0.8.0)
  13. Customer id, name and address (0.9.0)
  14. paidTouch: was this visit from a paid ad?
  15. How long a consent answer lasts
  16. How the Metis consent drop-off monitor reads this

@dxv-systems/measurement

One consent gate, one fan-out, and a Metis leg that always reports.

  • Consent: Google Consent Mode v2 with four categories, a banner, and the cookie mirror the server reads.
  • Fan-out: one typed track() to GA4, Meta Pixel and PostHog, each gated by the shop's policy. The TikTok Pixel is loaded and sends page views under the same gate, but receives no track() events.
  • Metis: reportPurchase / reportLead send every conversion to the Metis relay, carrying the visitor's consent.

Import this package rather than @dxv-systems/metis-client directly: everything an app needs from metis-client is re-exported here, so no code path can send to Metis without passing the consent mapping.

npm i @dxv-systems/measurement
SubpathRuns inContents
@dxv-systems/measurementanywhere (browser-safe)consent state, read/write/parse, pushConsentUpdate, toMetisConsent, policy, inline script builders, browser click-id capture
@dxv-systems/measurement/reactbrowserConsentBootstrap, ConsentBanner, useConsent, useBannerCategories, ClickIdCapture, createTracker
@dxv-systems/measurement/nextNext proxy / middlewarepersistClickIds
@dxv-systems/measurement/serverNodereportPurchase, reportLead, readMeasurementCookies

The rule

Always report to Metis. Consent decides forwarding; Metis decides nothing about storage.

Every purchase and lead goes to Metis whatever the visitor answered. Consent decides two things only:

  1. What the event carries. Hashed email/phone and click ids are sent only when the visitor granted both ad_user_data and ad_personalization. Otherwise the event is bare: the order or lead, its value, and the consent flags.
  2. Whether Metis may forward it to Meta and Google. Metis forwards only when both flags are true and the visitor decided.

Metis stores every event it receives. That is what lets it count how many conversions the consent gate drops.

Policy

Each shop decides, per destination, whether it fires only with consent or always. The package encodes that decision; it never makes it.

import { definePolicy } from "@dxv-systems/measurement";

// Anything not named stays strict (gated on consent).
export const policy = definePolicy({ meta: "always" });
DestinationStrict (default)"always"
ga4events only with analytics_storageevents always; gtag.js still applies Consent Mode (cookieless pings when denied)
posthogloads and captures only with analytics_storagemeasures every visitor: with analytics_storage normally (cookies, identify, replay), without it cookieless (cookieless_mode: "on_reject", no cookies or browser storage, only a flag that remembers the refusal)
sessionReplayonly with analytics_storage (and only when PostHog runs)always
metafbq('consent','revoke') and no events without ad_storagefbq('consent','grant') whatever the answer
tiktokpixel loads only with ad_storageloads immediately

STRICT_POLICY is every destination gated. A common posture is definePolicy({ ga4: "always", posthog: "always" }): GA4 and PostHog measure every visitor, while Meta, TikTok and session replay follow the visitor's answer. Which posture a shop uses is the shop owner's decision.

posthog: "always" needs the PostHog project setting "Cookieless server hash mode" (project settings). Without it PostHog drops the cookieless events, so visitors who refuse or never answer stay invisible. With the policy, createTracker opts a visitor without analytics_storage out explicitly (on_reject treats "never answered" as opted out), opts in when they agree, starts replay only with analytics_storage, and identify (which sends traits such as an e-mail address) also needs analytics_storage.

The Meta and TikTok pixels are gated on ad_storage alone (hasPixelConsent): that is the cookie consent for a non-Google pixel. ad_user_data and ad_personalization are Google Consent Mode signals about sending data to Google, and gtag.js applies them itself. hasAdConsent (ad_storage + ad_user_data) is kept for Google-ads-specific checks and is not the pixel gate.

Banner copy must match this gate. The options release different things. ad_storage alone opens the browser pixels and cookies (Google Ads, Meta, TikTok), so its option must name those pixels and say what they receive. ad_user_data and ad_personalization together allow the server-side sharing: hashed e-mail address and phone number plus click ids, sent through Metis, which may forward the conversion to Google and Meta (and they are Google's own Consent Mode signals). Those two options must therefore name Google and Meta, and say the sending happens only when both are on; wording them as Google-only under-describes it. DUTCH_BANNER_COPY does this and a test pins it. It also says the e-mail address and phone number go to Google through the Google tag; a site that loads no Google Ads tag (no shareUserDataWithGoogle) passes copy={dutchBannerCopy({ googleTag: false })} (0.5.3) so the banner does not promise more than happens. Since 0.5.4 the text is built from what the site does: dutchBannerCopy({ pixels, forwardsTo, contactTags }) names the pixels ad_storage opens, every platform Metis forwards to (Google, Meta, Pinterest) and every browser tag that gets hashed contact details. Declare the lists once per site and keep them equal to what Metis has switched on: a platform that is not named is not consented to. Since 0.6.0 the banner can take the platform list from Metis instead (platformsEndpoint, see "Which platforms the banner names"): it then stores the named platforms with the answer and asks again when Metis adds one. Without platformsEndpoint the banner does not ask again by itself when the text changes: a stored answer still counts, so before switching a new platform on, ask again (a new consent storage key) or forward to it only for answers given after the text changed. A shop passing its own copy must keep the same promise; otherwise a visitor who declines "sharing data" still has pixel events or server-side conversions sent, which is not informed consent.

The Consent Mode update to gtag is sent on every answer regardless of policy: it is the signal itself, not a destination. Click ids are persisted only with ad_storage, regardless of policy.

Next.js (App Router)

lib/measurement.ts, shared by the layout and client components:

import { definePolicy } from "@dxv-systems/measurement";
import { createTracker } from "@dxv-systems/measurement/react";

export const policy = definePolicy({});
export const tracker = createTracker({
  policy,
  ga4MeasurementId: process.env.NEXT_PUBLIC_GA4_ID,
  posthog: process.env.NEXT_PUBLIC_POSTHOG_KEY
    ? {
        key: process.env.NEXT_PUBLIC_POSTHOG_KEY,
        load: () => import("posthog-js").then((m) => m.default),
        options: { api_host: "/ingest", ui_host: "https://eu.posthog.com" },
      }
    : undefined,
});

Root layout. The bootstrap must come before any tag:

import Script from "next/script";
import { ga4ScriptSrc, metaPixelScript, tiktokPixelScript } from "@dxv-systems/measurement";
import { ClickIdCapture, ConsentBanner, ConsentBootstrap } from "@dxv-systems/measurement/react";
import { policy } from "@/lib/measurement";

<html>
  <head>
    <ConsentBootstrap ga4MeasurementId={GA4_ID} />
  </head>
  <body>
    {children}
    <Script src={ga4ScriptSrc(GA4_ID)} strategy="afterInteractive" />
    <Script id="meta-pixel" strategy="afterInteractive">{metaPixelScript(META_ID, policy)}</Script>
    <Script id="tiktok-pixel" strategy="afterInteractive">{tiktokPixelScript(TIKTOK_ID, policy)}</Script>
    <ConsentBanner policy={policy} />
    <ClickIdCapture />
  </body>
</html>

ConsentBanner and ClickIdCapture are client components; render them only where they belong (not on staff pages). The banner's copy defaults to Dutch; pass copy (e.g. from next-intl) for other locales. Its default classes are Tailwind, so add the package to your sources (@source "../node_modules/@dxv-systems/measurement/dist";) or pass classNames. A footer link reopens it with openConsentSettings().

proxy.ts (or middleware.ts):

import { persistClickIds } from "@dxv-systems/measurement/next";

export default function proxy(req: NextRequest) {
  const res = NextResponse.next();
  persistClickIds(req, res); // writes dxv_click_ids only when the consent cookie grants ad_storage
  return res;
}

Late accepters are covered by ClickIdCapture, which holds the ids in memory and writes them once the visitor grants ad_storage.

Tracking, from client components:

tracker.track("add_to_cart", { item, cartValueCents });
tracker.trackPurchase({ orderNumber: order.number, items, valueCents, taxCents, shippingCents, discountCents });
tracker.trackLead({ leadId: lead.id, source: "offerte" });

trackPurchase and trackLead give Meta and PostHog a deterministic event id (purchaseEventUuid(orderNumber) / leadEventUuid(leadId)). The server report derives the same id from the same key, so the platforms dedupe the browser and server events. The orderNumber / leadId in the browser must equal the order.id / lead.id on the server.

Server, in the route or server action that creates the order or lead:

import { cookies } from "next/headers";
import { readMeasurementCookies, reportPurchase } from "@dxv-systems/measurement/server";

const { consent, clickIds } = readMeasurementCookies(await cookies());
// Store { consent, clickIds } with the order: a payment webhook arrives without the shopper's cookies.
await reportPurchase({
  order: { id: order.number, totalCents: order.totalCents, currency: "EUR" },
  customer: { email: order.email, phone: order.phone },
  consent,
  clickIds,
});

Vite + Express

In index.html, before any tag, inline the output of consentBootstrapScript({ ga4MeasurementId }) (a Vite transformIndexHtml hook, or render <ConsentBootstrap /> if the shell is server-rendered). Add the pixels the same way with metaPixelScript / tiktokPixelScript, or skip the ones the app does not use. In the React app, mount ConsentBanner and ClickIdCapture once, and use createTracker as above.

Express:

import { readMeasurementCookies, reportLead } from "@dxv-systems/measurement/server";

app.post("/api/lead", async (req, res) => {
  const lead = await saveLead(req.body);
  const { consent, clickIds } = readMeasurementCookies(req.headers.cookie);
  void reportLead({ lead: { id: String(lead.id) }, customer: { email: lead.email }, consent, clickIds });
  res.status(201).json({ id: lead.id });
});

On a serverless runtime, await the report instead of void: a detached promise can be frozen with the function.

A job application on a careers page is a lead with kind: "application" (reportLead({ lead: { id, kind: "application" }, ... }), metis-client 0.15.0): Metis counts it and never forwards it to an ad platform. Do not call tracker.trackLead for an application either, or the browser pixels report it to Meta as a lead after all.

TikTok events (ttq.track for purchases, leads and the like) are not sent: the TikTok Pixel is loaded and sends page views only. They are a possible later addition.

Server reporting

reportPurchase and reportLead:

  • read DXV_CUSTOMER_ID, DXV_CONVERSIONS_RELAY_SECRET and DXV_CONVERSIONS_RELAY_URL (default https://metis.dxvagentic.com), unless relay is passed;
  • with those missing, do nothing and log one warning per process;
  • never throw. They return { status: "sent" | "skipped" | "failed" } and log failures (pass logger to use your own);
  • retry 5xx through metis-client (1s, 2s, 4s), so budget up to ~7 seconds on a dead relay.

consent is required, so a caller cannot forget it:

You passMeansMetis sees
a ConsentStatethe visitor answered{ adUserData, adPersonalization } from the answer
nullno consent cookie: the visitor never answered{ adUserData: false, adPersonalization: false, decided: false }
NO_CONSENT_MECHANISMthe integration has no consent banner at allno consent object

Better matching (0.3.0)

Metis forwards a conversion to Meta and Google with whatever identifiers it carries, so send what the request knows. Everything here is dropped, like the hashes, unless the visitor granted both ad_user_data and ad_personalization:

import { readMeasurementCookies, reportPurchase, requestContext } from "@dxv-systems/measurement/server";

const { consent, clickIds, browserIds } = readMeasurementCookies(req.headers.cookie);
await reportPurchase({
  order, consent, clickIds,
  customer: { email, phone },
  phoneCountryCode: "31",                                        // "06 …" becomes +31 for Meta and Google
  request: { ...requestContext(req.headers), ...browserIds },   // IP, user agent, Meta _fbc/_fbp
  eventSourceUrl: pageUrl,                                       // without its query string, and only with consent
});

Store request with the order as you store consent and clickIds: the payment webhook has none of it.

Sales staff enter (phone, e-mail, chat) have no browser and no banner. Report them with reportOfflinePurchase({ order, customer, phoneCountryCode, actionSource: "phone_call" | "email" | "chat" | "physical_store" | "other" }): no consent object, the hashes included. Metis keeps them and forwards them only once an operator attests the app's offline sales. A website sale is refused there; it goes through reportPurchase with the visitor's consent.

Funnel steps server-side (0.4.0)

Metis also forwards three browsing steps to Meta: view_item (ViewContent), add_to_cart (AddToCart) and begin_checkout (InitiateCheckout). Give the tracker a route of your own and serve it with the handler:

// lib/measurement.ts
createTracker({ policy, funnelEndpoint: "/api/measurement/funnel" });

// app/api/measurement/funnel/route.ts
import { funnelRouteHandler } from "@dxv-systems/measurement/server";
export const POST = funnelRouteHandler();

With Meta consent, the tracker gives the pixel call a random event id and posts the same step (event id, value, items, page) to that route. The handler reads the consent cookie, Meta's _fbc/_fbp, the IP and the user agent, and reports it with reportFunnelEvent; without ad consent it sends nothing (Metis would drop it) and answers 204. Meta dedupes the browser and server events on the shared id. Funnel steps are never counted as conversions in Metis. The route takes the relay settings from the same environment variables as reportPurchase.

Bots are dropped (0.11.0). A step from a known bot, crawler, link previewer, headless browser or scripted client (isBotUserAgent from @dxv-systems/metis-client: Googlebot, AdsBot, bingbot, facebookexternalhit, HeadlessChrome, curl, wget, python-requests and more), or with no user agent at all, is answered 204 and sent nowhere; reportFunnelEvent answers it { status: "skipped", reason: "bot" }, before consent is read. A crawler that renders the page would otherwise send fake steps to Meta. Purchases and leads are never filtered: they come from paid orders and sent forms. The user agent is the only bot signal the handler uses, so put a per-IP rate limit in front of the route as well.

App hint (0.11.0). Set DXV_METIS_APP_ID (or relay.appId) to the app's id in Metis and every report and the consent-platforms read send it as X-DXV-App-Id, so Metis tries that app's secret first. Optional; without it nothing changes.

Google tag: the customer's e-mail and phone (0.5.0)

Google's enhanced conversions for leads matches the hashed e-mail and phone that DXV Metis uploads against what the Google tag saw on the site next to the ad click. Without it, Google Ads reports "No user-provided data matches" and only uploads that carry a gclid count. Call this when someone orders or sends a form, in the browser:

import { shareUserDataWithGoogle } from "@dxv-systems/measurement";

shareUserDataWithGoogle(
  { email: form.email, phone: form.phone, phoneCountryCode: "31" }, // "06 …" becomes +31…
  { adsId: process.env.NEXT_PUBLIC_GOOGLE_ADS_ID },                // "AW-…"
);

It sets user_data (lowercased e-mail, E.164 phone) and sends a form_submit to the Ads id. That event names no conversion label, so it counts nothing: never add a browser conversion next to Metis, or every sale counts twice. It does nothing without both ad consents (ad_user_data and ad_personalization), the same rule Metis forwards under. In Google Ads, turn on enhanced conversions for leads (method "Google Ads API") and the Google tag's "Form interactions". phoneE164(phone, countryCode?) is exported for anything else that needs the E.164 form.

Express: the funnel route (0.5.0)

funnelRouteHandler is a Next route; Express gets the same behaviour:

import { funnelExpressHandler } from "@dxv-systems/measurement/server";
app.post("/api/metis/funnel", express.json(), funnelExpressHandler());

It reads the parsed body (req.body), the consent cookie, Meta's _fbc/_fbp, the IP and the user agent, reports the step with reportFunnelEvent and answers 204 (400 for a body that is no beacon). It drops bot steps like the Next route. Put a rate limit in front of it.

Both handlers take relay, logger, fetchImpl and (0.5.1) sleepMs. A route with its own time limit passes sleepMs: async () => {} so the retries do not wait.

Which platforms the banner names (0.6.0)

A shop never hard-codes which ad platforms the banner names. The banner asks the shop's own server, which asks Metis which platforms it forwards this app's conversions to. The answer is stored with the consent, every report to Metis carries it, and Metis forwards only to platforms the visitor saw named.

1. Mount the route. Next (App Router), app/api/measurement/consent-platforms/route.ts:

import { consentPlatformsHandler } from "@dxv-systems/measurement/server";
export const GET = consentPlatformsHandler();

Next 15 and later run a GET route per request; on Next 14 also add export const dynamic = "force-dynamic"; so the list is not frozen at build time.

Express:

import { consentPlatformsExpressHandler } from "@dxv-systems/measurement/server";
app.get("/api/measurement/consent-platforms", consentPlatformsExpressHandler());

Both read the relay from DXV_CUSTOMER_ID, DXV_CONVERSIONS_RELAY_SECRET and DXV_CONVERSIONS_RELAY_URL (or take relay, logger and fetchImpl like the funnel handlers) and answer { platforms: ("google" | "meta" | "pinterest")[], version } with Cache-Control: public, max-age=300. The Metis answer is cached in-process for 5 minutes. When Metis cannot be asked (relay env missing, Metis down or refusing) they answer every supported platform, { platforms: ["google", "meta", "pinterest"], version: "fallback" } with max-age=60, so the banner over-discloses rather than under-discloses. No production gate: a preview shows the real list.

2. Pass the route to the banner.

<ConsentBanner
  policy={policy}
  platformsEndpoint="/api/measurement/consent-platforms"
  copyOptions={{ pixels: ["Google Ads", "Meta"], contactTags: ["Google"] }}
/>

The banner fetches the route (1 second timeout; on failure, a slow answer, or the route's fallback answer it names every supported platform but asks no one again, since that list is a guess, not Metis's word) and builds its text with dutchBannerCopy({ ...copyOptions, forwardsTo }), platform names written as Google, Meta and Pinterest. A site that also passes copy keeps its own strings and categories, except the two sharing options (ad_user_data, ad_personalization), which always come from the built list. Until the route answers, an unanswered banner names every supported platform.

What is stored. The answer in consent_v2 and dxv_consent_v2 gains platforms, the list the banner named (writeConsent(state, policy, { platforms }); read it back with readConsentRecord()). An answer without platforms (from before 0.6.0, or a banner without platformsEndpoint) stays valid and counts as Google and Meta. readConsent() still returns the four categories only.

Asking again. When the route names a platform the stored answer lacks (consentCoversPlatforms(stored, current)), the banner opens again, pre-filled with the visitor's earlier choices. A visitor who refused both sharing options is not asked again: no platform receives anything from them, a new one included. A visitor who never answered sees the banner as before.

What Metis gets. readMeasurementCookies returns the stored platforms with the consent, and toMetisConsent puts them on the relay's consent.platforms for reportPurchase, reportLead, reportFunnelEvent and the funnel routes. An answer without a list sends no platforms, and Metis applies its own rule for old answers.

The privacy page names the same platforms (0.6.1). Where the privacy statement says who gets the data, render <ConsentPlatformList endpoint="/api/measurement/consent-platforms" /> (from @dxv-systems/measurement/react; joiner="of" for "Google, Meta of Pinterest"). It renders every supported platform first, so the page reads correctly without JavaScript and over- rather than under-discloses, and swaps in Metis's list when the route answers with one.

Translated or custom banners (0.7.0)

The Dutch builder only helps a shop that accepts Dutch text. Two other shapes keep the same promise (the banner names the platforms Metis forwards to, stores them, asks again when one is added):

Your own words: copy as a function. It gets the platform names ("Google", "Meta", "Pinterest"; every supported one until the route answers) and its result wins everywhere, the two sharing options included. You own the text, translations too, and must name every platform you are given. formatPlatformList(names, { locale: "nl" | "en", joiner: "and" | "or" }) joins them ("Google, Meta and Pinterest").

// next-intl: messages hold "... sent to {platforms} ..."
const t = useTranslations("consent");
const locale = useLocale() === "nl" ? "nl" : "en";

<ConsentBanner
  policy={policy}
  platformsEndpoint="/api/measurement/consent-platforms"
  copy={(platforms) => bannerCopy(t, formatPlatformList(platforms, { locale }))}
/>

bannerCopy(t, platforms) is the shop's own mapper: it passes { platforms } to t(...) for ad_user_data and ad_personalization. When Metis forwards to no platform, the function receives [] and the banner hides the two sharing options, so it needs no empty-list case; ConsentPlatformList renders nothing then, so wrap the privacy sentence in an element you can hide (or render the sentence only when your own check says so). An object copy behaves as before: the built Dutch sharing options still replace yours. The privacy page takes <ConsentPlatformList endpoint="..." locale="en" /> (joiner="or" for "or"). formatPlatformList is exported from the package root and from /react.

Your own banner. Read the list with useConsentPlatforms(endpoint), decide whether to ask again with shouldReaskForPlatforms, and store the list with the answer:

import { readConsentRecord, shouldReaskForPlatforms, writeConsent } from "@dxv-systems/measurement";
import { useConsentPlatforms } from "@dxv-systems/measurement/react";

const listed = useConsentPlatforms("/api/measurement/consent-platforms"); // { platforms, settled }
const names = listed.platforms.map(toDisplayName); // name every one of them in your text

useEffect(() => {
  if (shouldReaskForPlatforms(readConsentRecord(), listed)) setOpen(true);
}, [listed]);

function save(state: ConsentState) {
  writeConsent(state, policy, { platforms: listed.platforms });
}

useConsentPlatforms returns every supported platform with settled: false until the route answers, when it fails, on the route's fallback and with no endpoint; the same over-disclosure the banner uses. When Metis answers that it forwards to nobody (a shop with no ad accounts connected), it returns { platforms: [], settled: true }: a custom banner then hides the ad_user_data and ad_personalization options and stores both as denied, with writeConsent(state, policy, { platforms: [] }). shouldReaskForPlatforms(record, listed) is the banner's own rule: true only for a visitor who shares (ad_user_data or ad_personalization granted) and whose stored answer lacks a platform Metis now names; never on an unsettled list, never for a refusal. An answer recorded with platforms: [] never showed the sharing options, so its denials are not a refusal: it asks again as soon as Metis names a platform. Pass listed.platforms to writeConsent only when your text named them; without a list the answer counts as Google and Meta.

Ask only what the site needs (0.8.0)

Consent is needed only for non-essential storage or access on the device (ePrivacy art. 5(3), Telecommunicatiewet 11.7a) and for sharing personal data with ad platforms. A site that loads no such tag and whose conversions Metis forwards nowhere needs no banner. Its privacy page stays: the duty to inform does not depend on a banner.

loads: declare the consent-gated tags the site loads in the browser. The keys are the policy destinations (ga4, posthog, sessionReplay, meta, tiktok) plus googleAds, the Google tag with a Google Ads id (gated on ad_storage by Consent Mode, so it has no policy entry). The banner then asks only what is needed, computed by bannerCategories({ loads, policy, platforms }) from the package root:

CategoryShown when
analytics_storageloads has ga4, posthog or sessionReplay whose policy is not "always"
ad_storageloads has meta or tiktok whose policy is not "always", or googleAds, or Metis forwards to a platform (the click-id cookie serves that forwarding)
ad_user_data, ad_personalizationMetis forwards to a platform (as in 0.7.0)

A destination on "always" does not count: it runs without the visitor's answer (GA4 and PostHog then run cookieless, see Policy). "Analytics only" shows only next to another category. "Accept all", "Reject" and "Save selection" cover what is shown; anything not shown is stored as denied.

No category, no banner. ConsentBanner then renders nothing, also on openConsentSettings(), and writes nothing to the consent cookie or localStorage. Undecided keeps every gate closed, so nothing fires. Hide the footer's "Cookie settings" link the same way with useBannerCategories({ policy, loads, platformsEndpoint }) from /react: null while the platform list is coming, then the categories ([] = no banner).

It waits for the list. With loads, the banner renders only once the platform route answered or gave up (1 second at most), so it does not flash. Metis down, a slow answer, the route's fallback or no platformsEndpoint all mean every supported platform, so the banner shows the ad categories: over-disclosure, as before. A site that wants no banner therefore needs the route, answering [].

Asking again. A visitor who got no banner has no answer, so the banner simply appears once something needs consent (Metis names a platform, or the site adds a tag to loads). With loads, an answer stores the categories it was asked about (categories in consent_v2; not in the cookie, the server does not need it). When loads later needs a category the answer was never asked about, the banner opens again, pre-filled (shouldReaskForCategories(readConsentRecord(), needed) for a custom banner). That holds for analytics too, even for a visitor who refused everything else: they were never asked about the new purpose, so their silence is not a refusal. A category they did see and refused is never asked again (until the answer expires). The platform list keeps its 0.6.0 rule (shouldReaskForPlatforms): a refusal of the sharing options is not asked again when Metis adds a platform.

Without loads the banner behaves exactly as 0.7.0: every category, the sharing options hidden when Metis really forwards to none.

Privacy link. privacyHref renders a real link after the banner body, text copy.privacyLink (Dutch default "Lees de privacyverklaring"; PRIVACY_LINK_TEXT.en is "Read the privacy statement"). The link gets classNames.privacyLink.

A site with no analytics, no ad tags and no Metis platforms (the route answers []): no banner, and no footer link.

const banner = { policy, loads: [], platformsEndpoint: "/api/measurement/consent-platforms" } as const;
<ConsentBanner {...banner} privacyHref="/privacy" />

const categories = useBannerCategories(banner); // in the footer
{categories?.length ? <button onClick={openConsentSettings}>Cookie-instellingen</button> : null}

A site with PostHog (strict policy) and no Metis platforms asks one question, analytics; once Metis names a platform it asks the ad categories too:

<ConsentBanner
  policy={policy}
  loads={["posthog"]}
  platformsEndpoint="/api/measurement/consent-platforms"
  privacyHref="/privacy"
/>

With loads, the Dutch body names only the purposes asked ("cookies voor analyse"); an object or function copy still wins.

Customer id, name and address (0.9.0)

Needs @dxv-systems/metis-client 0.14.0 or later (a dependency, nothing to install). customer on reportPurchase, reportLead, reportFunnelEvent and reportOfflinePurchase also takes externalId, firstName, lastName, city, postalCode and country (ISO alpha-2):

await reportPurchase({
  order, consent, clickIds,
  customer: {
    email, phone,
    externalId: order.customerId,                  // the shop's own customer (or lead) id
    firstName: order.billing.firstName,
    lastName: order.billing.lastName,
    city: order.billing.city,
    postalCode: order.billing.postalCode,
    country: order.billing.country,                // "NL"
  },
});

They are gated exactly like the e-mail and phone: sent only when the visitor granted both ad_user_data and ad_personalization, and Metis passes them only to the platforms the consent named. metis-client normalises and hashes them; the postal and country code also go plain, because Google takes those unhashed (see the metis-client README). Store them with the order as you store consent and clickIds if the report happens in a webhook.

The click-id capture (ClickIdCapture, persistClickIds) now also keeps ttclid, msclkid and epik, under the same ad_storage gate and first-touch rule, and counts them as paid for paidTouch. Nothing sends ttclid or msclkid to TikTok or Microsoft yet: Metis keeps them for a later forwarder. The cookie's description in the privacy statement should name them.

paidTouch: was this visit from a paid ad?

The Metis consent drop-off monitor splits dropped conversions (refused and undecided) into paid, organic and unknown, so a shop can see whether the visitors it loses to the banner are the ones its ads paid for. That needs one boolean per conversion, paidTouch.

  • ClickIdCapture records it once per page load from the landing URL (gclid, fbclid, gbraid, wbraid, epik, ttclid, msclkid, or a paid utm_medium such as cpc or paid_social), whatever the consent. Read it with getPaidTouch() from @dxv-systems/measurement: true, false, or undefined before capture.
  • Memory only. The value lives in a module variable. It is never written to a cookie, localStorage, sessionStorage or anything else, so no device storage is accessed (ePrivacy 5(3)) and nothing needs consent. The click ids themselves are not sent. Metis counts the flag and never forwards it to an ad platform. A test fails if it is ever written to storage.
  • Pass it to your server yourself, with the lead or order payload, then on to reportLead / reportPurchase. The server does not infer it from cookies (there is none):
// browser
import { getPaidTouch } from "@dxv-systems/measurement";
await fetch("/api/lead", { method: "POST", body: JSON.stringify({ ...form, paidTouch: getPaidTouch() }) });

// server
void reportLead({ lead, consent, clickIds, paidTouch: typeof body.paidTouch === "boolean" ? body.paidTouch : undefined });
  • false means "landed organically", never "this page had no params". A paid signal in the URL gives true. false is recorded only when the page load is provably a landing: a plain navigation (Navigation Timing type navigate) with an empty or cross-origin referrer. A reload, back/forward, prerender, a same-origin referrer (another page on the same site) or a browser without Navigation Timing leaves the value undefined (unknown), and the flag is then omitted from the event. Client-side navigation keeps the first value. Consequence: after a full reload or a return visit, or on an order placed after one, the flag is usually unknown, so paid is under-counted as unknown, never as organic. Paid params always win, even on a reload.
  • Mention it in the shop's privacy statement: an aggregate, in-memory landing-page signal used for measurement, based on legitimate interest, not stored on the device.

An answer is valid for 12 months (CONSENT_VALIDITY_DAYS, 365). Regulators commonly point to 6 to 13 months before a visitor should be asked again; change it with setConsentValidityDays(days) at startup (also on the server if it builds the inline scripts, since they embed the period).

The answer lives in two places, localStorage consent_v2 and the cookie dxv_consent_v2 that server code reads, and both carry a decidedAt timestamp. They expire together: a fresh decision sets the cookie's max-age to the full period. On every browser read (readConsent, useConsent, ConsentBanner, ClickIdCapture) the package re-checks them:

  • a missing or different cookie is rewritten from localStorage, with the decision's remaining validity as max-age;
  • an expired decision is removed from both, so the banner asks again and the gates fall back to denied;
  • an answer stored by 0.1.0 has no decidedAt; it is stamped as decided now, so nobody is re-prompted by the upgrade.

The inline scripts (consentBootstrapScript, the pixel snippets) skip an expired answer too. Old cookies without decidedAt still parse on the server.

Metis derives one of four states per event from its consent object:

StateWireWhat it says
consentedboth flags trueforwardable to the ad platforms
refusedobject present and decided, not both true (includes partial and analytics-only)the visitor said no
undecideddecided: falsethe visitor never answered the banner
not_recordedno objectthe sender does not report consent: an integration gap

The monitor's drop-off rate is refused plus undecided over all events, with not_recorded shown apart. A shop on this package never produces not_recorded unless it passes NO_CONSENT_MECHANISM. It cannot see pixel-side loss (GA4, Meta Pixel, PostHog in the browser), only relay events.

版本与发布记录

@dxv-systems/measurement 共发布 21 个版本,首次发布于 2026/09/29;过去 12 个月发布了 21 个版本。

版本发布日期距今
0.11.1latest2026/11/103 天前
0.11.02026/10/3113 天前
0.10.02026/10/3113 天前
0.9.02026/10/3113 天前
0.8.02026/10/3113 天前
0.7.02026/10/2816 天前

关于 @dxv-systems/measurement 的常见问题

@dxv-systems/measurement 是什么?

One consent gate (Consent Mode v2), one fan-out to GA4, Meta, PostHog and TikTok, and a Metis relay leg that always reports

如何安装 @dxv-systems/measurement?

在项目目录运行 npm install @dxv-systems/measurement,或使用 pnpm add / yarn add / bun add。

@dxv-systems/measurement 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dxv-systems/measurement 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@dxv-systems/measurement 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@dxv-systems/measurement 可以商用吗?

@dxv-systems/measurement 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dxv-systems/measurement 的下载量有多少?

根据 npm 官方下载统计,@dxv-systems/measurement 在 2026/10/13 – 2026/11/11 这 30 天内被下载 6,486 次,日均约 216 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dxv-systems/measurement 还在维护吗?

最新版本 0.11.1 发布于 2026/11/10(3 天前);至今共发布 21 个版本,其中过去 12 个月发布了 21 个;首次发布于 2026/09/29。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。