config + event (fresh) * - output_gtag_code_tagged_property() -> set + event (coexist) * * Each method emits a self-contained IIFE that runtime-checks * window.dataLayer for an existing config call and self-gates so * exactly one branch fires per page render even though both blocks * are present in the document. The fresh branch additionally stamps * a window-scoped ownership sentinel after issuing its own config * so the tagged branch — which would otherwise observe the just- * pushed config and execute as well — bails on a fresh page. * * TODO: migrate to wp_add_inline_script attached to the fod.js * handle so this inline body benefits from WordPress's CSP-nonce * support and inherits dependency ordering with the fod.js * runtime. Out of scope for this commit (would require touching * the script-enqueue path in fiftyone-service). */ class Fiftyonedegrees_Tracking_Gtag { /** * Window-scoped ownership sentinel. Stamped by the fresh-branch * IIFE after its own `gtag('config', ...)` call so the tagged * branch — which sees the just-pushed config entry in * window.dataLayer — can distinguish "a sibling plugin tagged * us first" from "we just tagged ourselves" and bail in the * latter case. Externalized to a constant so the tests can * pin both the setter site (fresh branch) and the checker * site (tagged branch) by exact string. */ public const OWNERSHIP_SENTINEL = '__fod51d_owns_config'; public $name = 'gtag'; public $version = '2.0.0'; public function __construct() {} /** * @return array{parameters: array, delayed_evidence: bool} * * Walks Options::GA_CUSTOM_DIMENSIONS_MAP and yields one row per * property->parameter mapping for the GA4 event payload, plus a * delayed-evidence flag that controls whether the JS load * handler calls `fod.complete(update, "location")` (waits for * the location engine) or `fod.complete(update)` (fires * immediately). * * Shape of each returned parameter: * - key = the GA4 parameter_name (event parameter the admin * registered as a Custom Dimension on the property) * - value = a JS expression that pulls the value out of the * FOD library's `data` callback argument, e.g. * "data.device.devicetype" * * Hardening: * - is_scalar() guard on each option field so a corrupted DB * row (e.g. array stored where a string was expected) does * not trigger PHP warnings or emit "Array" as the literal * parameter key. * - regex whitelist on the datakey and property_name segments * that build the unquoted JS expression: only `[a-z0-9_]+` * accepted, anything else is skipped. The CD-map is admin- * controlled and Pipeline-sourced today, but a single bad * row would otherwise become executable JS in wp_head. * * The CD-map row must carry an explicit `parameter_name`; rows * without one are skipped. The v3 schema migration sweeps any * legacy v2-shape map on upgrade, and the CD admin UI writes * the new shape on every save, so a row missing parameter_name * is a corrupted DB write rather than a routine transitional * state. */ public function get_event_parameters() { $custom_dimensions = get_option(Options::GA_CUSTOM_DIMENSIONS_MAP); if (!is_array($custom_dimensions)) { return ['parameters' => [], 'delayed_evidence' => false]; } // Inclusion set (property_name => true) of ticked CD rows. An // absent option means "admin has never submitted the form" -> // emit everything (fresh install historical behaviour). A row // whose property_name is not in the set is silently dropped: // emitting its parameter to GA4 would auto-unarchive a matching // archived Custom Dimension and undo the admin's intent. $included_map = get_option(Options::GA_DIMENSIONS_INCLUDED); $has_included_map = is_array($included_map); $parameters = []; $delayed_evidence = false; foreach ($custom_dimensions as $dimension) { if (!is_array($dimension)) { continue; } $param = $this->extract_parameter_name($dimension); if ($param === '') { continue; } $datakey = $this->normalize_segment($dimension, 'custom_dimension_datakey'); $propName = $this->normalize_segment($dimension, 'property_name'); if ($datakey === '' || $propName === '') { continue; } if ($has_included_map && !isset($included_map[$propName])) { continue; } // Guard the namespace lookup with `(data. || {})` so a // missing engine slot (e.g. Resource Key without idprob / // robotstxt entitlements, or a cloud-side failure that // drops the namespace) does not throw `Cannot read // properties of undefined` from inside the gtag event // call and silently break the whole tracking emission. // Optional chaining (`data?.?.`) would read // cleaner but is unavailable in the older browsers a // WordPress plugin still has to support. $parameters[$param] = '(data.' . $datakey . ' || {}).' . $propName; if ($datakey === 'location') { // The location engine resolves asynchronously; the // FOD library has a per-key `complete(callback, key)` // overload that waits for it. $delayed_evidence = true; } } return [ 'parameters' => $parameters, 'delayed_evidence' => $delayed_evidence, ]; } /** * Resolves the GA4 parameter name for a single CD-map row. * Returns '' when the row does not carry a usable * `parameter_name` (missing, non-scalar, or empty string). */ private function extract_parameter_name(array $dimension) { if (isset($dimension['parameter_name']) && is_scalar($dimension['parameter_name']) && (string) $dimension['parameter_name'] !== '' ) { return (string) $dimension['parameter_name']; } return ''; } /** * Lowercases a CD-map field for use as a segment of the unquoted * `data..` JS expression and rejects anything * outside the `[a-z0-9_]+` whitelist. Belt-and-braces against a * corrupted or maliciously-edited option row escaping the JS * expression context. */ private function normalize_segment(array $dimension, $field) { if (!isset($dimension[$field]) || !is_scalar($dimension[$field])) { return ''; } $value = strtolower((string) $dimension[$field]); if ($value === '' || !preg_match('/^[a-z0-9_]+$/', $value)) { return ''; } return $value; } /** * Top-of-page entry: returns the full head fragment containing * the GA4 loader script + the two self-gating IIFEs. Returns the * empty string when GA_MEASUREMENT_ID is not set so the wp_head * action emits nothing and the page is not littered with a * useless empty ' . "\n"; echo "\t\n"; echo "\t\n\n"; return ob_get_clean(); } /** * GA4 emission for the case where the page does NOT already have * a `gtag('config', $measurement_id, ...)` from another plugin. * Emits the config (so page-view fires) and the fod event with * 51Degrees device properties attached as event parameters. * * Self-gated: at runtime the IIFE checks window.dataLayer and * bails when a sibling plugin has already configured the same * Measurement ID — the tagged-property method below handles * that case instead. After issuing its own config call, this * IIFE stamps an ownership sentinel on window so the tagged * branch (which runs immediately after, in the same