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-formand the existingxpacFormcallbacks. First focus or submission starts the form; success and error callbacks report generic outcomes.xpacFormInitializesupports forms initialized later. Only the form ID is captured. Password/authentication controls anddata-xpac-analytics-ignoresuppress capture; UserAuth-marked success responses are excluded from genericform_submit. - Popups observe
xpac-popup-post-openandxpac-popup-post-close, read the popup ID fromdetail.template.xpacPopup.getSettings(), and include a validdetail.rule.idwhen supplied. These hooks report opens/closes, not inline impressions or conversions. - History capture wraps
pushStateandreplaceStateand observespopstate. It records changes to origin/path/query once, ignores hash-only changes, and dispatchesxpac-analytics-navigationto 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_engagementevent flushes onvisibilitychangeto hidden, withpagehideas a fallback that does not duplicate the flushed interval. It handlespageshowrestoration separately so a restored page can record a later visit interval. The Google add-on maps this custom event toxpac_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.
Consent-manager integration
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:
| Method | Contract |
|---|---|
getServerEvents(): ?array | Canonical events requested by the transport; [] means none, null retains legacy wildcard routing |
consentCategory(): string | statistics or marketing; an empty string means no declared consent support |
sendTestEvent(array $options = []) | Explicit synthetic test returning a result array or WP_Error |
getAdminDefinition(): array | Configuration/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.
| Member | Semantics |
|---|---|
ownedKeys | Event 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 itsesc_url_raw()and 2048-byte truncation; Core's own branches for the consent booleans and numeric clamps run first and cannot be claimed. Register fromplugins_loaded, behind the contract gate, so the request that carries the property sees it.xpac_analytics_identity_keys(string[]) names the propertiesModules\Privacy::applyToEventData()drops when statistics consent is denied, whether by an explicitfalseor 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 suppliesidandenabled; 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.$summarycarriesstatus,messageandhttpStatus, pluseventIDandeventTimestampfor a diagnostic;$resultis whatsendTestEvent()or the processor returned;$diagnosticistruefor an administrator's test. Return the summary with a bettermessage, and for a diagnostic addnotes(sentences) andlinks({ 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()andsetMixpanelConsent()are gone. CallsetConsent({ statistics, marketing }); Mixpanel's own gate iswindow.xpacMixpanelBridge.setConsent()from its add-on.xpacAnalyticsConsent.googleState()is gone. The Google add-on derives Consent Mode fromxpacAnalyticsConsent.get().- Core calls no
gtag,fbqor SDK method.xpac-analytics-consent-changedcarrieschangedandactiveso each add-on can decide for itself. client_id,session_id,mixpanel_consent,mixpanel_distinct_idandmixpanel_device_idare no longer Core event properties; the Google and Mixpanel add-ons register them. Core no longer reads or clearsxpac_analytics_client_idandxpac_analytics_sessioninlocalStorage.- The envelope no longer carries
ga4_blocked; the Google transport sets it insideeventData. - The inline data no longer carries
mimeTypes. Modules\Privacy::applyToEventData()drops only thexpac_analytics_identity_keyswhen statistics is denied, and reserved event names areidandenabledplusxpac_analytics_reserved_event_names.DeliveryHealth::summarize()knows nothing vendor specific:validated,providerCode,providerSubcode,testMode,eventsReceivedandfbtrace_idare not summary keys. Add-ons word their results throughxpac_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_andengagement_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.
Link to and read this site's reports
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.
Deep links
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() doClauses 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:
| Dimension | Value |
|---|---|
page, entry, exit | The 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_type | content, search or 404 |
channel | Direct, Search, Social, AI or Referral, untranslated |
referrer | The referring host, such as www.google.com |
source, medium, campaign, term, content | The utm_* value |
device, browser, os | The label the store keeps, such as mobile or Chrome; matched case-insensitively. A device row reads Mobile, translated, while its filter.value stays mobile |
event | The catalogue event name, such as file_download, not its caption |
goal | The 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).
| Route | Answers |
|---|---|
GET /dashboard | hasData, 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 /breakdown | One 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 /values | Suggestions 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 /sessions | A 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:
| Code | Status | Meaning |
|---|---|---|
xpac_analytics_bad_request | 400 | A parameter is wrong; data.param names it |
xpac_analytics_cap | 400 | The filter matches too many visits for its window; data.suggest_days is a window that fits |
xpac_analytics_forbidden | 403 | The note belongs to someone else |
xpac_analytics_stale | 409 | The goals changed since version was read |
xpac_analytics_query_failed | 500 | A statement failed; the message carries the database error only under WP_DEBUG |
xpac_analytics_budget | 503 | A 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 upgradestatus 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:
| Hook | Purpose |
|---|---|
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:
| Hook | Purpose |
|---|---|
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:
| Hook | Purpose |
|---|---|
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:
| Hook | Purpose |
|---|---|
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.jsThese 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.