Analytics integrations

Extend the event catalogue, connect consent, and implement compatible Analytics destinations.

Analytics Core owns capture, validation, consent signals, the event catalogue, this site's own reports and the admin interface. It carries no destination code. Google, Meta and Mixpanel are separately updated add-ons, and each brings its own browser tag, server route, event properties and test tool through the seams described under Implement a destination. Integration code must check the available Core contract before calling methods introduced by a newer version.

Capture an application event

Register the canonical event in PHP before Analytics builds its settings or browser configuration. Give it scalar properties and explicit provider mappings:

add_filter('xpac_analytics_event_catalog', static function (array $events): array {
    $events['content_open'] = [
        'label' => 'Content opened',
        'category' => 'Content',
        'description' => 'The visitor opened an article panel.',
        'trigger' => 'After the panel opens successfully',
        'properties' => ['content_id'],
        'providers' => [
            'google' => [
                'event' => 'content_open',
                'native' => false,
                'populate' => ['content_id' => 'content_id'],
            ],
            'meta' => [
                'event' => 'ContentOpen',
                'native' => false,
                'populate' => ['content_id' => 'content_id'],
            ],
            'mixpanel' => [
                'event' => 'content_open',
                'properties' => ['content_id'],
            ],
        ],
    ];
    return $events;
});

Then call the capture API when the action actually succeeds:

window.xpADL?.captureEvent('content_open', { content_id: 42 });

Make the caller depend on the xpac-analytics-front WordPress script handle, or attach to xpac-analytics-initialized before the runtime loads. The event is fired once per runtime initialization. Check for an existing window.xpADL when installing a listener after page load.

Registering a catalogue entry makes it acceptable to the server and discoverable in settings; it does not generate the interaction. Confirm the chosen provider mapping and enabled route. Each destination add-on reads its own key from the providers block: Mixpanel browser capture uses catalogue event names and declared scalar properties and its server route uses the mixpanel mapping; Google and Meta use theirs. Built-in maps retain their established names.

Core rejects canonical names that would overwrite a setting: id and enabled, plus every suffix an installed destination add-on reserves through xpac_analytics_reserved_event_names. Google adds secret and user_data; Meta adds access_token, id, test_event_code and user_data; Mixpanel adds enabled. This applies to PHP catalogue filters and configured declarations alike, and the list is computed once per request, so a reservation has to be registered before the first catalogue read. The Google add-on drops only its own mapping for Google-reserved names and invalid web event names; Meta, Mixpanel and the neutral capture remain available. The optional user_engagement definition already maps to the accepted Google name xpac_user_engagement.

Capture creates a shared ID and timestamp and adds page context. Each browser handler receives { eventID, eventName, eventTimestamp, eventData }. Use registerEventHandler(name, callback) for an additional browser destination; duplicate handler names are ignored. A throwing or rejecting handler is isolated so it cannot interrupt the interaction. Custom handlers must enforce their own consent rules and avoid mutating the shared event.

Core skips browser destination handlers for an excluded visitor, including when another integration has already loaded a destination SDK. The Google add-on's handler protects its debug_mode, user_id and send_to parameters the same way: custom mappings, data attributes and public capture calls cannot replace the values owned by its authenticated page configuration.

Capture with data attributes

Site owners can declare Custom click events under Settings → Engagement instead of adding a PHP filter. Each row accepts an event name, a label and comma-separated property names. A destination add-on may read extra columns of its own. The declaration contributes a scalar-property allowlist; each destination add-on adds its own mapping for the event.

<a href="/articles/42" data-xpac-event="content_open"
   data-xpac-event-properties='{"content_id":42,"surface":"featured"}'>Read</a>

Declare content_id,surface as properties for this example. Event names start with a lowercase letter and use lowercase letters, digits and underscores, up to 40 characters. Names reserved through xpac_analytics_reserved_event_names are rejected for every destination. The Google add-on keeps ga_, google_ and firebase_ prefixes off its own route and removes parameter names with its reserved prefixes. At most 30 custom definitions and 20 property names per definition are retained. The data attribute must be a JSON object no longer than 4,096 characters; nested values are discarded and strings are bounded to 200 characters. Consent fields, protected page context and any property a registered transport owns cannot be supplied through these attributes or through captureEvent().

Forms, popups and navigation

Each of these integrations is off by default in Engagement settings:

  • Forms attach to .xp-block-form and the existing xpacForm callbacks. First focus or submission starts the form; success and error callbacks report generic outcomes. xpacFormInitialize supports forms initialized later. Only the form ID is captured. Password/authentication controls and data-xpac-analytics-ignore suppress capture; UserAuth-marked success responses are excluded from generic form_submit.
  • Popups observe xpac-popup-post-open and xpac-popup-post-close, read the popup ID from detail.template.xpacPopup.getSettings(), and include a valid detail.rule.id when supplied. These hooks report opens/closes, not inline impressions or conversions.
  • History capture wraps pushState and replaceState and observes popstate. It records changes to origin/path/query once, ignores hash-only changes, and dispatches xpac-analytics-navigation to reset page-level capture state. Virtual page views clear the old post/template/content-group metadata instead of assigning it to the new URL. Search capture then reads the new URL rather than the initial server term.
  • Visible engagement tracks visible-document time; its optional user_engagement event flushes on visibilitychange to hidden, with pagehide as a fallback that does not duplicate the flushed interval. It handles pageshow restoration separately so a restored page can record a later visit interval. The Google add-on maps this custom event to xpac_user_engagement; Meta and Mixpanel keep the canonical name.

Social shares have no setting. Dispatch xpac-analytics-share on document when a share completes, with a detail of { url, method, contentType, linkClasses, linkText, linkUrl }. url is required; contentType defaults to article and the rest to empty strings. Core captures it as social_share.

Search capture reads the initial URL/configuration immediately, so a short visit does not require a later window load. Virtual navigation resets that context and does not let a delayed load notification replay the initial search. Audio/video capture shares one playback lifecycle: progress requires started, active playback; seeking before play or while paused emits no milestones. Completion and source replacement reset milestone state. Initializers return cleanup callbacks for integrations that explicitly tear down their capture listeners.

xpac_analytics_page_context($context) customizes public page metadata. Keep values scalar and avoid user-specific context in cached pages. The built-in grouping selects post type or the lowest category ID; it does not interpret an SEO plugin's primary category setting.

Choose Your own JavaScript as the consent source in the Privacy settings, then apply the visitor's current choices after the bridge exists:

function applyChoices(choices) {
    const state = {
        statistics: choices.statistics === true,
        marketing: choices.marketing === true,
    };
    if (window.xpADL) {
        window.xpADL.setConsent(state);
    } else if (window.xpacAnalyticsConsent) {
        window.xpacAnalyticsConsent.set(state);
    }
}

xpacAnalyticsConsent is initialized by the head privacy bundle. Arrange script dependencies or call again on xpac-analytics-initialized if neither bridge exists yet. Analytics does not persist your CMP's choices for it. Reapply them on each page and each choice change.

getConsent() returns statistics, marketing, and gpc. Partial updates preserve the other category. true, "allow" and "granted" grant a category and any other value denies it; a non-object argument changes nothing. GPC can restrict a requested grant according to the site's Privacy settings. These two calls are the whole consent API: Core calls no gtag, fbq or SDK method of its own.

Every accepted set() dispatches xpac-analytics-consent-changed on document. Its detail is { statistics, marketing, gpc, changed, active }: the full state after the update, changed listing the categories the call named, and active whether a consent source is switched on. Destination add-ons listen for it and apply the decision to their own tag. The Google add-on updates Consent Mode and forgets its fallback identifiers when statistics is revoked, the Meta add-on grants or revokes the Pixel, and the Mixpanel add-on opts its SDK in or out. A listener that throws does not stop the others.

Consent withdrawal invalidates already captured pending events for that category, including nonce retries, and an immediate regrant permits new events without reviving that queue. A throwing WordPress Consent API fails closed.

In WordPress Consent API mode the bridge reads wp_has_consent('statistics') and wp_has_consent('marketing'), and responds to wp_listen_for_consent_change. The runtime adds consent_statistics, consent_marketing, and gpc to eventData. These are browser intent signals, not authentication credentials. Core also honors the request's Sec-GPC: 1 signal.

Public intake normalizes consent strictly: only true, 1, "1", and "true" grant statistics or marketing. Other supplied values, including objects, arrays, and unrecognized strings, deny that category. Integrations should send JSON booleans. An absent signal is governed by the configured consent mode; omission is different from an explicit invalid or denied value. A destination's own consent flag, such as Mixpanel's mixpanel_consent, is validated by the sanitizer that add-on registers.

With consent enabled, an older cached payload missing consent fails closed. An old extension with no consent category can block server dispatch when either category is denied. Update the matching plugins and purge cached assets after a staged upgrade.

The public intake boundary

Use the runtime's window.xpacAnalytics.trackUrl instead of hard-coding the REST namespace. The route accepts one JSON event per request. Its current bounds are a 16 KiB body, an exact registered string event name, scalar allowed properties, and a string event ID of 1–128 ASCII letters, digits, underscores or hyphens. IDs are not truncated or sanitized into another identity; malformed names and IDs are rejected. Use the runtime's generated ID for browser/server deduplication; providers can impose tighter ID limits of their own. The page URL must use HTTP or HTTPS, contain no embedded username/password, and have an allowed host. Timestamps use seconds and must fall between three days old and five minutes ahead. Unknown properties are discarded; a property a destination added to the allowlist is kept only when it passes the validator that destination registered. Malformed envelopes return an error.

Accepted IDs are reserved atomically for deduplication. A persistent object cache uses an atomic cache claim. Without one, a non-autoloaded option row holds the claim's expiration, and concurrent retries compete for a single claim. Expiration pruning and uninstall remove these temporary rows; they contain no event payload.

Anonymous intake does not require a nonce. Signed-in requests use a REST nonce to retain authenticated identity; a failed nonce can fall back to anonymous intake for eligible visitors. The retry rechecks consent and route eligibility; excluded users cannot downgrade their request to bypass role exclusions. Do not put signed-in personalized markup into a shared page cache.

xpac_analytics_track_event($event, $request) filters the validated envelope and xpac_analytics_track($event) notifies server destinations. PHP callbacks at this boundary are trusted integration code; preserve consent and destination-owned identity fields when enriching an event.

Implement a destination

Extend XPACGroup\Plugin\Analytics\Extension and use a stable, unique ID. Register the installed add-on with Bootstrap::instance()->registerAddon($addon) so incomplete configuration appears in the admin. Register it as an active extension only when it has valid configuration and at least one usable route.

Extension::ADMIN_CONTRACT_VERSION is 5, and that is what the shipped add-ons compile against. Gate on it, never on method_exists(); a method can survive a release and return less. Below the contract the add-on must refuse to register and show its notice rather than half-run: Core installs first, so the only way to meet an older Core is a partially failed update. Set $serverSideTracking = true when the add-on delivers server-side. WordPress's Requires Plugins header declares the Core dependency but does not express a minimum Core version.

Versions 1 to 3 added the extension methods:

MethodContract
getServerEvents(): ?arrayCanonical events requested by the transport; [] means none, null retains legacy wildcard routing
consentCategory(): stringstatistics or marketing; an empty string means no declared consent support
sendTestEvent(array $options = [])Explicit synthetic test returning a result array or WP_Error
getAdminDefinition(): arrayConfiguration/status/route metadata with no credential values

Version 4 added the surface a destination needs to own its own settings page and browser tag: Bootstrap::SETTINGS_MODULE_ID, Bootstrap::settingsPageExists(), Bootstrap::pageContext(), Bootstrap::ANALYTICS_FRONT_HANDLE, Modules\Privacy::HANDLE, Bootstrap::isTrackableVisitor(), Bootstrap::isExcludedUser(), Modules\Privacy::configuration(), DataLayer::registerSanitizer(), Admin\DeliveryHealth::registerMessages(), and on window.xpADL registerEventHandler() returning false for a taken name, getEventConsent(event) and hasServerClaim(event).

Version 5 is where Core carries no destination code. Everything a destination needs beyond the neutral runtime, it brings itself through the seams below.

Browser transport

A destination registers one transport with Core's runtime, from the same place it registers its event handler:

const attach = () => {
    const runtime = window.xpADL;
    if (!runtime) return false;
    runtime.registerTransport({
        id: 'exampleServerRoute',
        ownedKeys: ['example_client_id', 'example_tag_blocked'],
        contribute: (eventName, data, consent) =>
            consent.statistics ? { example_client_id: readClientId() } : {},
        ready: (event) => (needsTag(event) ? waitForTag() : undefined),
        before: (event, data, consent) => {
            if (!consent.statistics) delete data.example_client_id;
            data.example_tag_blocked = !window.exampleTagReady;
        },
        claim: (event, data) => data.example_tag_blocked === true,
        route: (event) => !browserAlreadySent(event),
    });
    runtime.registerEventHandler('exampleTag', exampleTag);
    return true;
};
if (!attach()) {
    document.addEventListener('xpac-analytics-initialized', attach, { once: true });
}

Core dispatches xpac-analytics-initialized synchronously while it builds window.xpADL, so a bundle loaded after Core never sees the event and one loaded before it has nothing to register against yet; handle both orders as above. registerTransport() returns false when id is missing or already registered. Give it the extension's getId(): that is how Core pairs a route veto and a claim with the add-on's entry in window.xpacAnalytics.serverRoutes. Every other member is optional. Transports run in registration order.

MemberSemantics
ownedKeysEvent properties only this transport may set. Core strips them from captureEvent() input and from data-xpac-event-properties before any transport runs; window.xpADL.isOwnedKey(key) answers for every registered transport
contribute(eventName, data, consent)Runs synchronously at capture, after page context and the caller's properties are merged. Return an object to merge into eventData; anything else adds nothing. data is the event data so far, consent a copy of the current state
ready(event)Return a promise Core awaits before posting a non-navigation event; every transport's promise runs under Promise.all, so waits overlap instead of adding up. click, file_download, social_share, user_engagement and contact are never delayed. Consent and route eligibility are checked again after the wait
before(event, data, consent)Runs just before the POST on the mutable posted copy data, after Core has written the consent fields. Delete keys on it or return an object to merge; the frozen event.eventData the browser handlers saw is untouched
claim(event, data, consent)Return true to record a server claim under this transport's id. window.xpADL.hasServerClaim(event, id) reads it back, so a browser handler can skip an event its server route already took; without id it answers whether any transport claimed the event
route(event, consent)Called only for this transport's own server route when Core decides whether to post. Return false to veto that route for this event; other routes and the site's own record are unaffected

A member that throws contributes nothing, waits for nothing, claims nothing and vetoes nothing. Core never lets a destination interrupt the page. window.xpADL.getEventConsent(event) is the consent an event still has: a category revoked after capture stays revoked for that event even when it is granted again, so read it rather than getConsent() inside a handler or a readiness wait.

Event properties on the server

The public route keeps a property only when it is on the allowlist, and a destination that adds a property validates it:

use XPACGroup\Plugin\Analytics\DataLayer;

add_filter('xpac_analytics_event_data_keys', static fn (array $keys): array =>
    array_values(array_unique(array_merge($keys, ['example_client_id', 'example_tag_blocked']))));
add_filter('xpac_analytics_identity_keys', static fn (array $keys): array =>
    array_values(array_unique(array_merge($keys, ['example_client_id']))));
add_filter('xpac_analytics_reserved_event_names', static fn (array $names): array =>
    array_values(array_unique(array_merge($names, ['secret']))));
DataLayer::registerSanitizer('example_client_id', static fn ($value, string $key): ?string =>
    preg_match('/^\d{1,20}\.\d{1,20}$/', (string) $value) ? (string) $value : null);
DataLayer::registerSanitizer('example_tag_blocked', 'rest_sanitize_boolean');
  • xpac_analytics_event_data_keys (string[]) is the allowlist. Core's own list holds the neutral capture properties and the consent booleans; nothing vendor specific.
  • DataLayer::registerSanitizer($key, callable) takes ($value, $key) and returns a scalar to store or anything else to drop the property. First registration wins. A registered validator replaces the generic branch entirely, including its esc_url_raw() and 2048-byte truncation; Core's own branches for the consent booleans and numeric clamps run first and cannot be claimed. Register from plugins_loaded, behind the contract gate, so the request that carries the property sees it.
  • xpac_analytics_identity_keys (string[]) names the properties Modules\Privacy::applyToEventData() drops when statistics consent is denied, whether by an explicit false or by GPC set to deny statistics. Core lists none of its own.
  • xpac_analytics_reserved_event_names (string[], lower-case suffixes) keeps a destination's setting names out of the event namespace. Core supplies id and enabled; the result is computed once per request.

Wording results and the test tool

Admin\DeliveryHealth::summarize() knows http_request_failed, not_supported, any *_not_configured code and xpac_analytics_message on a successful array result. Everything a destination says beyond that comes through two seams:

  • Admin\DeliveryHealth::registerMessages([code => sentence]) supplies the sentence for an error code the add-on raises. Core's two entries win, then the first registration.
  • xpac_analytics_delivery_summary($summary, $result, $diagnostic) rewords a summary. $summary carries status, message and httpStatus, plus eventID and eventTimestamp for a diagnostic; $result is what sendTestEvent() or the processor returned; $diagnostic is true for an administrator's test. Return the summary with a better message, and for a diagnostic add notes (sentences) and links ({ label, url } pairs).

Core whitelists the outcome. status is accepted, queued or failed; message is a sanitized sentence; httpStatus is 0 to 599. For a diagnostic, eventID, eventTimestamp, up to 10 notes and up to 5 links (http or https only) survive as well. Nothing else does, so a raw response body cannot reach the screen.

The card from getAdminDefinition() accepts two more keys. testInput is ['name' => ..., 'label' => ..., 'help' => ..., 'placeholder' => ...], the one free-text input the test accepts; name is passed through sanitize_key() and label is required. links is [['label' => ..., 'url' => ...], ...], bounded like the summary links. The Tools screen renders testInput as a text control prefilled from testCode, the links as buttons, and after a test shows result.message, each note, the event ID and the result links. It has no destination-specific branches.

The screen posts { options: { [testInput.name]: value } } to POST /analytics/admin/tools/{id}/test, and the route forwards every scalar option to sendTestEvent(): keys through sanitize_key(), values cut at 200 characters, otherwise untouched. wp xpac analytics test-event <destination> --<field>=<value> forwards every association the same way, dashes changed to underscores. The destination validates what it receives and ignores what it does not know.

Changed in contract 5

  • window.xpADL.setGoogleConsent(), setMetaConsent() and setMixpanelConsent() are gone. Call setConsent({ statistics, marketing }); Mixpanel's own gate is window.xpacMixpanelBridge.setConsent() from its add-on.
  • xpacAnalyticsConsent.googleState() is gone. The Google add-on derives Consent Mode from xpacAnalyticsConsent.get().
  • Core calls no gtag, fbq or SDK method. xpac-analytics-consent-changed carries changed and active so each add-on can decide for itself.
  • client_id, session_id, mixpanel_consent, mixpanel_distinct_id and mixpanel_device_id are no longer Core event properties; the Google and Mixpanel add-ons register them. Core no longer reads or clears xpac_analytics_client_id and xpac_analytics_session in localStorage.
  • The envelope no longer carries ga4_blocked; the Google transport sets it inside eventData.
  • The inline data no longer carries mimeTypes.
  • Modules\Privacy::applyToEventData() drops only the xpac_analytics_identity_keys when statistics is denied, and reserved event names are id and enabled plus xpac_analytics_reserved_event_names.
  • DeliveryHealth::summarize() knows nothing vendor specific: validated, providerCode, providerSubcode, testMode, eventsReceived and fbtrace_id are not summary keys. Add-ons word their results through xpac_analytics_delivery_summary.
  • The test route and the CLI forward every scalar option, not only test_event_code.
  • Core's uninstall removes only its general_, privacy_ and engagement_ settings; each add-on removes its own prefix, and whoever leaves the row empty deletes it.

Delivery

Run delivery through Dispatcher::defer() from the accepted-event action. Deferred work runs at shutdown and has no durable spool. Recursive flush calls are guarded, and queued follow-up tasks are drained. Provider callbacks must still tolerate a process ending before delivery and must not assume that intake success means delivery.

The administrator-only operations are POST /analytics/admin/tools/{id}/test and DELETE /analytics/admin/tools/{id}/health under the plugin's REST namespace. They require manage_options and authenticated REST access. Use the normalized tool ID from the admin read model; these IDs are lowercased. The test body is { "options": { ... } } and every scalar option reaches the destination; there is no caller-supplied event payload or destination URL. The response summarizes status, message and httpStatus, plus eventID, eventTimestamp, notes and links when the destination supplies them. HTTP 200 from this admin route can still carry a failed status.

The admin app at XPAC → Analytics has four report screens beside the pipeline screens, each a sidebar item with its own hash: Overview (#/traffic, the default; the id predates the label), Live (#/live), Sessions (#/sessions) and Goals (#/goals). Everything they show comes from REST routes under the plugin's namespace, and every route requires manage_options.

A screen's state lives in the query string of its hash, one value per key:

$url = admin_url('admin.php?page=xpac-analytics') . '#/traffic?' . http_build_query(
    [
        'period' => '7d',
        'seg' => Filters::encode([['op' => 'is', 'dim' => 'channel', 'values' => ['Search']]]),
    ],
    '',
    '&',
    PHP_QUERY_RFC3986
);

Filters is XPACGroup\Plugin\Analytics\Core\Reports\Filters. Overview reads period (today, yesterday, 7d, 30d, 90d, mtd, last_month, ytd, 12m, or custom with from and to as Y-m-d site days), compare (off, previous, year, or custom with cfrom), match (dow or date), interval (hour, day, week, month), metric, seg, tabs, and the details modal's modal, q, sort, order, page and dim2. tabs is the tab each card shows, as card:tab pairs joined by ;: sources (channels, referrers, utm_source, utm_medium, utm_campaign, utm_term, utm_content), pages (pages, entry, exit, searches, notFound, page_type, and under a page filter next and previous), technology (devices, browsers, os) and behaviour (events, goals), so sources:referrers;pages:exit opens those two cards on Referrers and Exit. A card left out shows its first tab. Sessions reads the same range and seg, and opens one visit with action=view&id=<id>&started=<started_at>. Goals opens its editor with action=new or action=edit&id=<goal id>, and Events highlights the catalogue row named by event. A value a screen does not recognise falls back to its default rather than failing.

The seg filter

One parameter carries every filter, on every screen and every route, because the hash router keeps only the last value of a repeated key:

seg    = clause *( ";" clause )
clause = op "," dim "," value *( "," value )
op     = "is" / "is_not" / "contains" / "does_not_contain"
dim    = "page" / "entry" / "exit" / "page_type" / "channel" / "referrer" / "source"
       / "medium" / "campaign" / "term" / "content" / "device" / "browser" / "os"
       / "event" / "goal"
value  = the text, percent-encoded as rawurlencode() / encodeURIComponent() do

Clauses are ANDed; the values of one clause are ORed. contains and does_not_contain apply to the text dimensions only: page, entry, exit, referrer, source, medium, campaign, term and content. Values are the text a person reads, never an internal id:

DimensionValue
page, entry, exitThe URL path, such as /pricing/: any view of it, the visit's first view, or its last. Search results never match: a search keeps the home URL, so / would otherwise select every visit that searched. Select searches with page_type search
page_typecontent, search or 404
channelDirect, Search, Social, AI or Referral, untranslated
referrerThe referring host, such as www.google.com
source, medium, campaign, term, contentThe utm_* value
device, browser, osThe label the store keeps, such as mobile or Chrome; matched case-insensitively. A device row reads Mobile, translated, while its filter.value stays mobile
eventThe catalogue event name, such as file_download, not its caption
goalThe goal id from GET /goals; several ids in one clause mean any of them. is does not narrow: it marks the visits that converted and keeps every visit as the denominator, so totals carry conversions and cr beside the unchanged visits, and every row carries its own. /sessions under is lists only the converting visits, so its X-WP-Total equals totals.current.conversions. is_not narrows every route to the visits that reached none of the goals

A filter selects visits, not hits. Filters::parse() and Filters::encode() in PHP and parseSeg() and encodeSeg() in assets/packages/analytics/admin/lib/filters.js read and write the same bytes, including !'()* and non-ASCII text. A string that does not parse is a 400 naming seg. Every row a report returns carries filter: { dim, value }, the is clause that selects exactly that row, so build filters from it rather than from a row's label, which may be translated. A search page's filter is null: a search keeps the home URL, and no clause selects one search term.

Report routes

All paths are under /analytics/admin in the plugin's REST namespace. The report routes take the range, comparison, interval and seg parameters above as query arguments and open their answer with generatedAt, range, previousRange and coverage (detailFrom, rollupFrom, retentionDays, clipped, capDays and countersSince).

RouteAnswers
GET /dashboardhasData, filtered, totals { current, previous }, series, previousSeries, breakdowns, annotations, live.now and provenance (timezone, retentionDays, sessionIdleMinutes, lastHitAt, schemaPending). breakdowns holds the top 10 rows of the tab each card shows, as tabs names them, keyed by the tab: pages, entry, exit, page_type, searches, notFound, channels, referrers, utm_source, utm_medium, utm_campaign, utm_term, utm_content, devices, browsers, os, events, and next or previous under a page filter. A tab not named is absent, not empty, and so are utm_term and utm_content without a filter, which have no daily totals; read those from /breakdown. part=hours answers { hours, hoursFrom, coverage } instead: Active hours, 7 × 24, Monday first, in the site's timezone, over at most the last 92 days of the range, with hoursFrom the first day they cover when that is later than the range's (otherwise null), and hours: null when the range lies wholly before visit detail. format=csv&part=series&metric=<id> returns the chart as CSV
GET /breakdownOne list in full: dimension (a breakdown key above, or a filter id), dim2 (channel, source, device, browser, os, entry), q, sort, order, page (1–50), per_page (1–100), format=csv → { rows, total, page, perPage, coverage }. An events row carries its top value in values and, as on /dashboard, its event name as id. Overview fills a card tab /dashboard left out from here, ten rows with compare=off; its details modal sends the real comparison
GET /valuesSuggestions for one filter dimension, optionally narrowed by q; dimension=event&event=<name> lists that event's recorded values → { values: [{ value, label, count }] }, at most 100
GET /live{ now, at, minutes[30], pages, sources, events, devices, visits30, views30, events30 }; takes no parameters and ignores filters. pages is the page each visit open in the last five minutes is on, the same window as now; sources, events and devices cover the last 30, and the screen labels each card with its own window
GET /sessionsA JSON array of visits, newest first, with X-WP-Total (counted to 10,001) and X-WP-TotalPages. Takes the range (at most 92 days, default the last 7) and seg, plus views_min, duration_min, s, per_page (up to 100), before=<started_at>,<id> for the next page, or page (up to 200) to jump; format=csv exports up to 10,000. The body is a bare list, so the window it covers travels in headers: X-XPAC-Range-From and X-XPAC-Range-To (the site days listed, after any clipping), X-XPAC-Clipped (1 or 0), X-XPAC-Cap-Days (0 unfiltered, else 31 or 92) and X-XPAC-Detail-From (the first day of visit detail, empty when there is none)
GET /sessions/{id}?started=<started_at>{ session, timeline }: each view with its time on page and engaged seconds, each event under its page; started is required so the lookup prunes partitions
GET /goals, POST /goals, PUT /goals/{id}, DELETE /goals/{id}{ version, goals, funnels }. Every write sends the version it read and answers with the new list; a stale version is a 409
GET /goals/report{ goals: [{ id, name, kind, conversions, visits, cr, previous, filter }], coverage } for the range and filters; a row counted over fewer days than asked adds cappedDays (31 for a scroll_depth or click goal, 92 for the rest) or clippedFrom (the day visit detail starts). A page goal, like a page filter, never counts a search result
GET /annotations, POST /annotations, PUT /annotations/{id}, DELETE /annotations/{id}Chart notes { id, day, kind, text, author, editable }; POST takes { day, text } (up to 280 characters). The author or any administrator may edit

A series point carries t (a day, week or month point's first site day; an hour's UTC start), label, day, visitors, visits, views, bounceRate, duration and events, and partial: true while it is still counting; under a page chip also entries, exits and timeOnPage, under a goal chip conversions and cr. At interval=hour a point is one local hour, filtered or not: bounce rate and duration come from the visits that started in it, visitors is null because a visitor is counted per site day, and an hour not yet begun is null in every figure. Unfiltered, only the chart is hourly; the totals and breakdowns stay whole days from the rollups.

Rates with no denominator are null, never 0, and so are exit rates and per-source bounce and duration over any range that starts before the matching countersSince day. Those counters start on the first full site day after the 1.3.0 schema update: the next day when the store had already recorded that day, the same day on an empty store. GET /reports takes days or from/to and answers from the rollups.

Errors carry a code a caller can branch on:

CodeStatusMeaning
xpac_analytics_bad_request400A parameter is wrong; data.param names it
xpac_analytics_cap400The filter matches too many visits for its window; data.suggest_days is a window that fits
xpac_analytics_forbidden403The note belongs to someone else
xpac_analytics_stale409The goals changed since version was read
xpac_analytics_query_failed500A statement failed; the message carries the database error only under WP_DEBUG
xpac_analytics_budget503A statement ran past its 5-second budget; data.suggest_days is 7

Unfiltered day, week and month figures are sums over the daily rollups. Filtered figures, the hour interval, Active hours, next and previous pages, goals, Live and Sessions read the individual visits, views and events, so they are bounded: a filter whose clauses are all on device, browser, os, channel, page_type or goal covers at most 31 days, any other filter 92, and neither reaches before coverage.detailFrom; goals on scroll_depth or click cover at most 31 days. The window follows the dimension, not the value: a referrer filter on a host that sends a large share of the visits keeps its 92 days, so like an unselective filter it can take several seconds to answer, each statement still stopped at 5 seconds with a budget error. A filter matching more than 250,000 visits in its window is refused with xpac_analytics_cap before anything is copied (xpac_analytics_segment_cap moves the number). Every detail statement runs under the 5-second budget, max_statement_time on MariaDB and the MAX_EXECUTION_TIME hint on MySQL 5.7.8 and later. On MariaDB that includes the INSERT … SELECT statements that copy a filter's visits into temporary tables; MySQL applies its hint to a read-only SELECT only, so there those copies run unbudgeted and the day and visit caps are their only guard. Answers are cached per resolved question (60 seconds for a range ending in the last three days, 6 hours up to 30 days back, 24 hours beyond) under xpac_analytics_reports_version, which every goal or note write, retention prune, retention change and schema update increments. tabs is part of the question; part=hours is cached apart and ignores the comparison and the interval, which move no hour, so changing either finds the hour grid warm — on a site with a persistent object cache. Without one, only the 6-hour and 24-hour entries become transients: a 60-second entry lives in the request that wrote it, so the next request recomputes both answers. An xpac_analytics_cap refusal is kept for 60 seconds under its request's key, as a transient when there is no persistent object cache, so a retry does not count the visits again; no other error is kept. /live, /sessions and CSV exports are never cached.

WP-CLI operations

Core registers wp xpac analytics when running under WP-CLI:

wp xpac analytics status
wp xpac analytics prune
wp xpac analytics upgrade

status prints JSON with the Core contract version, the consent source, a store block (recording, tablesReady, schemaVersion, retentionDays, hasData, runwayDays) and one entry per installed destination: id, status, configured, enabled, active, server event count, consent category and last failure time. It does not expose saved credentials, event payloads, or raw responses. It does not send a test event. prune runs the same expiration cleanup used for public-intake deduplication and rate limits. upgrade builds the report indexes a schema update left to the xpac_analytics_upgrade cron event (it does so above 200,000 rows in any detail table), one table at a time with a five-second lock wait, and exits nonzero if a build fails; on a current schema it does nothing.

Send a synthetic event explicitly:

wp xpac analytics test-event <destination> [--<field>=<value>]

Destination ids come from status; each add-on documents its own. The command makes an external request through the add-on's sendTestEvent() method and accepts no event payload or destination URL. Every --<field>=<value> is passed to the destination as a test option with dashes changed to underscores, so --test-event-code=TEST123 reaches Meta as test_event_code. A destination with no such option ignores it, and an explicitly blank value overrides whatever the destination has saved. Output is the same redacted delivery summary as the admin test route; a failed or unsupported test exits nonzero.

WordPress Tools → Site Health runs six Analytics checks: recording works (the report tables exist), report storage is prepared ahead (fewer than 30 days left is flagged, and any wp-admin visit extends it), no destination reported a failure in the last 24 hours, temporary state is cleaned up, active add-ons declare consent support, and the public endpoint answers. The endpoint probe sends an invalid envelope and expects a validation error; it never creates an event anywhere. Site Health's debug information contains configuration summaries and counts rather than credentials or visitor data.

Operational hooks

Core's PHP fires these 34 hooks. Actions are marked; everything else is a filter.

Capture:

HookPurpose
xpac_analytics_init()Action. Fires on init once Analytics is ready to be extended; catalogue registration must still precede the first catalogue read
xpac_analytics_event_catalog($catalog)Register event definitions: label, category, description, trigger and scalar property names. Destination add-ons add their own mappings here
xpac_analytics_reserved_event_names($names)Lower-case setting suffixes refused as event names; Core supplies id and enabled, and the result is computed once per request
xpac_analytics_page_context($context)Refine public scalar page context before it is printed inline
xpac_analytics_inline_js_data($data)Contribute public runtime configuration; never credentials or per-visitor data, because the page may be cached
xpac_analytics_track_visitor($allowed)Whether the runtime is loaded for this request, after the page query resolves

Intake:

HookPurpose
xpac_analytics_allowed_events($names)Accepted public event names; a catalogue entry is still needed for settings and mappings
xpac_analytics_event_data_keys($keys)The server scalar-property allowlist; a destination adds its own keys here and registers a validator for each
xpac_analytics_event_data_sanitizers($sanitizers)Per-property validators keyed by property name; each takes ($value, $key) and returns a scalar to store, or anything else to drop it. Only consulted where Core has no validator of its own
xpac_analytics_identity_keys($keys)Properties dropped when statistics consent is denied; Core lists none of its own
xpac_analytics_allowed_source_hosts($hosts)First-party page hosts accepted by intake
xpac_analytics_rate_limit($limit)Requests per window, 300 by default; a non-positive value disables the limit
xpac_analytics_rate_window($seconds)The window, 60 seconds by default
xpac_analytics_client_ip($ip)The address the visitor identity and the rate limit are keyed on, after proxy resolution
xpac_analytics_trusted_proxies($ranges)CIDR ranges whose forwarded headers are believed; Cloudflare's ship by default
xpac_analytics_redact($value)A value on its way into first-party storage, after the built-in masks
xpac_analytics_track_event($event, $request)Enrich the validated envelope before destinations receive it
xpac_analytics_track($event)Action. Observe accepted events and schedule destination delivery

First-party store:

HookPurpose
xpac_analytics_first_party_enabled($enabled)Turn the site's own record off
xpac_analytics_first_party_requires_consent($required, $event)Gate the site's own record on statistics consent
xpac_analytics_first_party_write_failed($error, $event)Action. A page view or an event could not be stored
xpac_analytics_internal_hosts($hosts)Hostnames treated as this site when classifying referrers
xpac_analytics_reports_pruned($removed, $cutoff)Action. A retention pass finished. $removed counts partitions dropped and rows deleted from views, events, sessions, visitors, rollups, resources, referrers and labels (event values only); $cutoff is the oldest UTC timestamp kept
xpac_analytics_schema_installed($synced)Action. The first-party tables exist and match the declaration
xpac_analytics_tables_renamed($tables)Action. Tables left on the old xpac_analytics_ prefix were moved to xp_analytics_; receives the new names
xpac_analytics_index_build_threshold($threshold)Rows in any detail table above which a schema update builds its indexes from cron instead of inside the request; 200,000 by default
xpac_analytics_owned_options($options)Options removed with the tables
xpac_analytics_segment_cap($cap)Visits a filter may match in its window before a report refuses it with xpac_analytics_cap (400, with suggest_days); 250,000 by default, values below 1 read as 1. The estimate is counted in _sessions before anything is copied, so the cap bounds every filtered read's temporary tables

Admin and lifecycle:

HookPurpose
xpac_analytics_admin_integrations($integrations)Refine the normalized destination cards shown to administrators
xpac_analytics_event_route_prefixes($routes, $page)Per-channel settings-key prefixes for one settings page; an add-on answers for the page it owns
xpac_analytics_delivery_summary($summary, $result, $diagnostic)Word a destination's delivery result; for a test, add notes and links
xpac_analytics_privacy_policy_content($html)Tailor WordPress's suggested policy text
xpac_analytics_remove_data_on_uninstall($remove)Return false to keep Analytics settings and cached state through uninstall; the site's own traffic tables answer to xpac_analytics_remove_reports_on_uninstall
xpac_analytics_remove_reports_on_uninstall($remove)Return true to drop this site's own traffic tables and identity secret on uninstall (default: the General setting, off)

Delivery hooks such as xpac_analytics_provider_dispatched and xpac_analytics_delivery_blocking, and the per-destination request filters, are fired by each destination add-on and documented on its page. The Meta add-on fires xpac_analytics_client_ip with a second $provider argument; keep it optional in a callback, because Core's intake passes one. Use a server-validated proxy address; never trust an arbitrary client-supplied forwarding header.

See the generated Core, Google, Meta, and Mixpanel references for exact call-site signatures. The docs generators rebuild those references directly from PHP and JavaScript.

Browser capture regression matrix

tests/js/analytics-trigger-matrix.test.js boots the real Core and the Mixpanel bundle, then exercises 21 supported canonical events and one declared custom event from their DOM or integration triggers. The shared, independently authored tests/fixtures/analytics-provider-events.json supplies provider names and property mappings for both the browser and PHP transport checks.

The browser matrix intercepts outgoing requests and SDK calls. It checks shared event IDs and timestamps across channels, media milestones, visible engagement time, form success versus submit attempts, popup callbacks, declarative-property filtering, virtual-page context, consent withdrawal, channel toggles and excluded users.

Each add-on's transport has its own regression that boots the add-on bundle beside Core: the Google one covers its identifiers, the two-second tag wait, Consent Mode updates and revocation; the Meta one covers the one-second first-cookie wait and the fbq consent relay; the Mixpanel one covers the owned identity keys and the statistics-only consent relay. Separate Google control and routing regressions cover protected account/debug parameters, delayed tags, page exits and nonce retries.

npx vitest run tests/js/analytics-trigger-matrix.test.js tests/js/analytics-google-controls.test.js tests/js/analytics-google-routing.test.js tests/js/analytics-google-transport.test.js tests/js/analytics-meta-transport.test.js tests/js/analytics-mixpanel-transport.test.js

These checks verify capture and routing without sending real provider events. Use a small, explicitly requested diagnostic and the destination's own event view when checking the configured external project.

On this page