insert` call. GA4 * identifies a Custom Dimension by `parameter_name` (the key sent on * the event payload) and `displayName` (the human label shown in * reports). The integration creates dimensions with `scope=EVENT` * only — `USER` and `ITEM` scopes are out of scope for a * device-detection plugin emitting per-pageview event parameters. * * Created dimensions get a "Fifty One Degrees " prefix on their * displayName so they remain identifiable in the admin's GA4 console * even when the underlying parameter_name overlaps with something the * admin defined for their own analytics. The prefix must start with a * letter — GA4 rejects display_name values that begin with a digit * with INVALID_ARGUMENT, which is why the spelt-out "Fifty One" form * is used instead of the brand's "51Degrees". * * GA4 enforces a hard per-property cap (50 in the free tier, 125 for * GA4 360). The plugin pre-checks against the free-tier limit before * starting any create loop so a property that is one dimension away * from the cap does not get a half-finished batch. * * Auth errors (401 / 403) are surfaced via the shared * FiftyOneDegreesGa4AuthError thrown by the property service so the * caller can route them to a single "reconnect Google Analytics" * notice rather than the misleading "no Web stream" / "create * failed" copy. */ class FiftyOneDegreesGa4DimensionService { /** * Only event-scope dimensions make sense for per-pageview device * properties. User-scope would persist across sessions; item-scope * is for e-commerce items. */ public const SCOPE = 'EVENT'; /** * Prefix on the GA4 displayName. Keeps plugin-created dimensions * visually distinct from any the admin defined themselves and * survives the case where a parameter_name collision is benign * (admin opted to map their own dim onto the same key). */ public const DISPLAY_NAME_PREFIX = 'Fifty One Degrees '; /** * GA4 free-tier per-property limit. The 360 tier raises this to * 125 but we conservatively gate against the free-tier value — * over-creating would 4xx mid-loop on the 51st create and leave * the admin with a half-applied mapping. */ public const MAX_DIMENSIONS_PER_PROPERTY = 50; /** * Page size requested on listPropertiesCustomDimensions. GA4's * documented maximum is 200; our working cap is 50 (free) / 125 * (360-tier) so 200 fits both in one page and gives headroom * for admin-created dimensions that share the property. The * service still cross-checks for an unexpected nextPageToken * and logs a warning if one ever surfaces — defensive against * a future API change that lowers the default page size. */ private const LIST_PAGE_SIZE = 200; /** * Returns the list of Custom Dimensions configured on the given * GA4 property as a flat array of associative rows. * * Shape per row: * - parameter_name (string) * - display_name (string) * - scope (string, "EVENT" / "USER" / etc.) * * Non-auth API errors return []. Auth errors (401 / 403 / token * refresh failures / scope-insufficient — see * FiftyOneDegreesGa4AuthError::matches) throw so the caller can * surface a reconnect notice. * * @param Google_Service_GoogleAnalyticsAdmin $admin * @param string $propertyId numeric (e.g. "123456789") * @return array> * @throws FiftyOneDegreesGa4AuthError on auth / scope failure */ public static function list_custom_dimensions($admin, $propertyId) { if ($propertyId === '' || $propertyId === null) { return []; } $parent = 'properties/' . $propertyId; try { $response = $admin->properties_customDimensions ->listPropertiesCustomDimensions( $parent, ['pageSize' => self::LIST_PAGE_SIZE] ); } catch (Throwable $e) { if (FiftyOneDegreesGa4AuthError::matches($e)) { error_log( '51Degrees GA4 customDimensions.list rejected as unauthorized for ' . $parent . ': ' . $e->getMessage() ); throw new FiftyOneDegreesGa4AuthError($e->getMessage(), 0, $e); } error_log( '51Degrees GA4 customDimensions.list failed for ' . $parent . ': ' . $e->getMessage() ); return []; } // Defensive: GA4 returning a nextPageToken at pageSize=200 // would mean the property has 200+ dimensions, which is // beyond every known tier cap. Log so a future API-shape // change doesn't silently mask a missing-row condition in // dimension_exists(). $token = (string) $response->getNextPageToken(); if ($token !== '') { error_log( '51Degrees GA4 customDimensions.list returned ' . 'a nextPageToken for ' . $parent . ' (page size ' . self::LIST_PAGE_SIZE . '); idempotency check ' . 'and pre-flight count may be incomplete' ); } $dimensions = $response->getCustomDimensions(); if (empty($dimensions)) { return []; } $rows = []; foreach ($dimensions as $dimension) { $rows[] = [ 'parameter_name' => (string) $dimension->getParameterName(), 'display_name' => (string) $dimension->getDisplayName(), 'scope' => (string) $dimension->getScope(), ]; } return $rows; } /** * Creates a single GA4 Custom Dimension on the given property. * Idempotent in one specific sense: a 409 ALREADY_EXISTS that * matches the parameter_name + EVENT-scope we are creating is * treated as success (we re-fetch the list to verify). A 409 * that does not match is treated as a real conflict (different * scope, or a sibling row with the same display name but a * different parameter_name) — logged and reported as failure. * * Known best-effort case: a concurrent delete by another admin * between this create's 409 and the recovery refetch will look * indistinguishable from a real non-matching conflict (the * dimension is no longer there). Re-running the create on the * next admin save resolves to a clean success in that case, so * the worst outcome is one transient false-failure notice. * * Input validation: all three string arguments are required to * be non-empty after trim; whitespace-only inputs would emit * either an invalid payload (e.g. displayName="Fifty One Degrees ") or * an opaque GA4 INVALID_ARGUMENT and are refused locally. * * @param Google_Service_GoogleAnalyticsAdmin $admin * @param string $propertyId numeric (e.g. "123456789") * @param string $parameterName GA4 event-parameter key, e.g. "device_type" * @param string $displayLabel human label that will be prefixed * with "Fifty One Degrees " in GA4, e.g. * "DeviceType" -> "Fifty One Degrees DeviceType" * @return bool true on success or idempotent re-create; false on * real conflict or non-auth API failure * @throws FiftyOneDegreesGa4AuthError on auth / scope failure */ public static function create_custom_dimension( $admin, $propertyId, $parameterName, $displayLabel ) { $propertyId = is_scalar($propertyId) ? trim((string) $propertyId) : ''; $parameterName = is_scalar($parameterName) ? trim((string) $parameterName) : ''; $displayLabel = is_scalar($displayLabel) ? trim((string) $displayLabel) : ''; if ($propertyId === '' || $parameterName === '' || $displayLabel === '') { return false; } $parent = 'properties/' . $propertyId; $payload = new Google_Service_GoogleAnalyticsAdmin_GoogleAnalyticsAdminV1betaCustomDimension(); $payload->setParameterName($parameterName); $payload->setDisplayName(self::DISPLAY_NAME_PREFIX . $displayLabel); $payload->setScope(self::SCOPE); try { $admin->properties_customDimensions->create($parent, $payload); return true; } catch (Throwable $e) { if (FiftyOneDegreesGa4AuthError::matches($e)) { error_log( '51Degrees GA4 customDimensions.create rejected as unauthorized for ' . $parent . ': ' . $e->getMessage() ); throw new FiftyOneDegreesGa4AuthError($e->getMessage(), 0, $e); } if ($e->getCode() === 409) { // 409 from the GA4 Admin API can mean either: // (a) the exact (parameter_name, EVENT) pair we // just asked for already exists (idempotent // re-create — admin re-saved the CD screen) // (b) a different conflict — e.g. a USER-scope // dimension with the same parameter_name, or // a displayName collision under our prefix. // (a) is the common case; we re-fetch the list and // promote it to success if our (param, EVENT) pair // is now present. (b) we log and surface as failure // so the admin can resolve at the GA4 console. if (self::dimension_exists($admin, $propertyId, $parameterName)) { return true; } error_log( '51Degrees GA4 customDimensions.create 409 for ' . $parent . ' parameter ' . $parameterName . ' did not resolve to an existing matching dimension: ' . $e->getMessage() ); return false; } error_log( '51Degrees GA4 customDimensions.create failed for ' . $parent . ' parameter ' . $parameterName . ': ' . $e->getMessage() ); return false; } } /** * Pre-flight capacity check: returns true when creating * $new_count additional dimensions on a property that already * has $existing_count would exceed the per-property cap. * * Exposed so a caller orchestrating a batch create can refuse * to start the loop and surface a single "would exceed 50 CDs" * notice rather than failing mid-flight on the 51st create. */ public static function would_exceed_limit($existing_count, $new_count) { return ($existing_count + $new_count) > self::MAX_DIMENSIONS_PER_PROPERTY; } /** * Verifies that a (parameter_name, EVENT-scope) dimension now * exists on the property — used to resolve a 409 from create() * as idempotent success rather than failure. * * Behaviour notes: * - Auth errors (401 / 403 / refresh-failure / scope-insufficient) * propagate as FiftyOneDegreesGa4AuthError because * list_custom_dimensions re-throws them. * - Non-auth list failures (transient 5xx, network timeout) * return [] from list_custom_dimensions, which means this * helper returns false and the calling create() reports a * real conflict. That conflates "verification unavailable" * with "definitely doesn't exist"; the false-failure is * surfaced with an extra error_log marker at the call site * so the admin can correlate a "create failed" notice with * the underlying list-failed log line. Re-running the * create on the next save resolves cleanly via the same * 409-idempotent path. */ private static function dimension_exists($admin, $propertyId, $parameterName) { $existing = self::list_custom_dimensions($admin, $propertyId); foreach ($existing as $row) { if ($row['parameter_name'] === $parameterName && $row['scope'] === self::SCOPE ) { return true; } } return false; } }