From ea461441dea9f6b01767dad8b66c465f1601372a Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Feb 2026 14:56:18 +0100 Subject: [PATCH 1/2] codex update 1 --- agent.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 agent.md diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..7b1e7ad --- /dev/null +++ b/agent.md @@ -0,0 +1,85 @@ +# Project Summary + +## Objective +Implemented the `todo.md` GA4 backlog in this repository without executing runtime tracking flows. + +## What was implemented + +### 1) Event payload correctness and completeness +- Fixed event serialization so `session_id` and `engagement_time_msec` are now included in event `params`. +- Added event-level timestamp override support (`event.timestamp_micros`) via `setEventTimestampMicros(...)`. +- Added campaign parameter setters and merge support in event helpers: + - `campaign_source`, `campaign_medium`, `campaign_name`, `campaign_term`, `campaign_content`, `campaign_id`. + +### 2) Top-level Measurement Protocol request fields +- Added support for top-level request fields in both Analytics and Firebase payload models: + - `non_personalized_ads` + - `validation_behavior` + - `ip_override` + - `user_location` + - `device` +- Added public setters for the above in both models. + +### 3) User properties and user data +- Added user property timestamp support via `setTimestampMicros(...)`, serialized as `set_timestamp_micros`. +- Removed hard gate that only allowed `user_data` when `user_id` was present; payload now includes `user_data` when provided. +- Improved phone normalization API to accept `int|string` and sanitize formatted inputs to E.164-compatible hashing format. + +### 4) Cookie/session integration helpers +- Improved session cookie parsing (`parseSessionCookie`) for GS1/GS2 formats: + - fixed `timestamp` key typo + - normalized GS2 values by removing token prefixes + - added version/domain metadata consistently +- Added client ID extraction helpers: + - `parseClientIdFromGaCookie(...)` + - `parseClientIdFromCookies(...)` + - `parseGaCookies(...)` + +### 5) Required-parameter audit updates +- Tightened required params for selected events based on GA4 recommended semantics used in backlog: + - `EarnVirtualCurrency`: requires `virtual_currency_name`, `value` + - `JoinGroup`: requires `group_id` + - `LevelUp`: requires `level` + - `Search`: requires `search_term` + +### 6) Tests and backlog tracking +- Updated and extended tests for: + - event timestamp override + - campaign serialization + - top-level request fields + - user property timestamp + - cookie parsing normalization and client ID helpers + - formatted phone normalization +- Marked `todo.md` tasks complete. + +## Files added +- `agent.md` + +## Notable files updated +- `src/Helper/EventMainHelper.php` +- `src/Helper/EventHelper.php` +- `src/Helper/EventParamsHelper.php` +- `src/Analytics.php` +- `src/Firebase.php` +- `src/Helper/UserPropertyHelper.php` +- `src/UserProperty.php` +- `src/Helper/UserDataHelper.php` +- `src/Helper/ConvertHelper.php` +- `src/Event/EarnVirtualCurrency.php` +- `src/Event/JoinGroup.php` +- `src/Event/LevelUp.php` +- `src/Event/Search.php` +- `src/Facade/Type/AnalyticsType.php` +- `src/Facade/Type/FirebaseType.php` +- `src/Facade/Type/DefaultEventParamsType.php` +- `src/Facade/Type/UserPropertyType.php` +- `test/Unit/EventTest.php` +- `test/Unit/AnalyticsTest.php` +- `test/Unit/FirebaseTest.php` +- `test/Unit/HelperTest.php` +- `test/Unit/UserDataTest.php` +- `test/Unit/UserPropertyTest.php` +- `todo.md` + +## Validation status +- Runtime test/lint execution was not completed because `php` is unavailable in this environment (`php: command not found`). From 71ad2e5f30b2c4b7250601a9f73f3b36e3237071 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Feb 2026 14:56:32 +0100 Subject: [PATCH 2/2] codex update --- src/Analytics.php | 60 +++++++- src/Event/EarnVirtualCurrency.php | 5 +- src/Event/JoinGroup.php | 4 +- src/Event/LevelUp.php | 2 +- src/Event/Search.php | 4 +- src/Facade/Type/AnalyticsType.php | 40 ++++++ src/Facade/Type/DefaultEventParamsType.php | 56 ++++++++ src/Facade/Type/FirebaseType.php | 40 ++++++ src/Facade/Type/UserPropertyType.php | 8 ++ src/Firebase.php | 60 +++++++- src/Helper/ConvertHelper.php | 156 +++++++++++++++++---- src/Helper/EventHelper.php | 36 +++++ src/Helper/EventMainHelper.php | 67 +++++++++ src/Helper/EventParamsHelper.php | 67 ++++++++- src/Helper/UserDataHelper.php | 31 ++-- src/Helper/UserPropertyHelper.php | 22 +++ src/UserProperty.php | 2 +- test/Unit/AnalyticsTest.php | 45 ++++++ test/Unit/EventTest.php | 42 ++++++ test/Unit/FirebaseTest.php | 45 ++++++ test/Unit/HelperTest.php | 78 +++++++++-- test/Unit/UserDataTest.php | 13 ++ test/Unit/UserPropertyTest.php | 16 +++ 23 files changed, 836 insertions(+), 63 deletions(-) diff --git a/src/Analytics.php b/src/Analytics.php index 376fd13..f738e08 100644 --- a/src/Analytics.php +++ b/src/Analytics.php @@ -19,8 +19,12 @@ class Analytics extends Helper\IOHelper implements Facade\Type\AnalyticsType protected null|bool $non_personalized_ads = false; protected null|int $timestamp_micros; + protected null|string $validation_behavior; + protected null|string $ip_override; protected null|string $client_id; protected null|string $user_id; + protected array $user_location = []; + protected array $device = []; protected array $user_properties = []; protected array $events = []; @@ -40,8 +44,12 @@ public function getParams(): array return [ 'non_personalized_ads', 'timestamp_micros', + 'validation_behavior', + 'ip_override', 'client_id', 'user_id', + 'user_location', + 'device', 'user_properties', 'events', ]; @@ -68,6 +76,12 @@ public function setClientId(string $id) return $this; } + public function setNonPersonalizedAds(bool $enabled = true) + { + $this->non_personalized_ads = $enabled; + return $this; + } + public function setUserId(string $id) { $this->user_id = $id; @@ -89,6 +103,30 @@ public function setTimestampMicros(int|float $microOrUnix) return $this; } + public function setValidationBehavior(null|string $behavior) + { + $this->validation_behavior = $behavior; + return $this; + } + + public function setIpOverride(null|string $ip) + { + $this->ip_override = $ip; + return $this; + } + + public function setUserLocation(array $location) + { + $this->user_location = static::cleanAssoc($location); + return $this; + } + + public function setDevice(array $device) + { + $this->device = static::cleanAssoc($device); + return $this; + } + public function addUserProperty(Facade\Type\UserPropertyType ...$props) { foreach ($props as $prop) { @@ -130,9 +168,11 @@ public function post(): void $url = $this->debug ? Facade\Type\AnalyticsType::URL_DEBUG : Facade\Type\AnalyticsType::URL_LIVE; $url .= '?' . http_build_query(['measurement_id' => $this->measurement_id, 'api_secret' => $this->api_secret]); + $userData = $this->userdata->toArray(); + $body = array_replace_recursive( $this->toArray(), - ["user_data" => !empty($this->user_id) ? $this->userdata->toArray() : []], // Only accepted if user_id is passed too + ["user_data" => $userData], ["user_properties" => $this->user_properties], ["consent" => $this->consent->toArray()], ); @@ -197,4 +237,22 @@ public static function new(string $measurement_id, string $api_secret, bool $deb { return new static($measurement_id, $api_secret, $debug); } + + private static function cleanAssoc(array $input): array + { + $out = []; + foreach ($input as $key => $value) { + if (!is_string($key)) { + continue; + } + + if ($value === null || $value === '') { + continue; + } + + $out[$key] = $value; + } + + return $out; + } } diff --git a/src/Event/EarnVirtualCurrency.php b/src/Event/EarnVirtualCurrency.php index d9d44e5..e84e57d 100644 --- a/src/Event/EarnVirtualCurrency.php +++ b/src/Event/EarnVirtualCurrency.php @@ -25,7 +25,10 @@ public function getParams(): array public function getRequiredParams(): array { - return []; + return [ + 'virtual_currency_name', + 'value', + ]; } public function setVirtualCurrencyName(null|string $name) diff --git a/src/Event/JoinGroup.php b/src/Event/JoinGroup.php index 97615b7..9964ff8 100644 --- a/src/Event/JoinGroup.php +++ b/src/Event/JoinGroup.php @@ -23,7 +23,9 @@ public function getParams(): array public function getRequiredParams(): array { - return []; + return [ + 'group_id', + ]; } public function setGroupId(null|string $id) diff --git a/src/Event/LevelUp.php b/src/Event/LevelUp.php index 79516c5..6412272 100644 --- a/src/Event/LevelUp.php +++ b/src/Event/LevelUp.php @@ -25,7 +25,7 @@ public function getParams(): array public function getRequiredParams(): array { - return []; + return ['level']; } public function setLevel(null|int $lvl) diff --git a/src/Event/Search.php b/src/Event/Search.php index 33ecfc8..d04a233 100644 --- a/src/Event/Search.php +++ b/src/Event/Search.php @@ -23,7 +23,9 @@ public function getParams(): array public function getRequiredParams(): array { - return []; + return [ + 'search_term', + ]; } public function setSearchTerm(null|string $term) diff --git a/src/Facade/Type/AnalyticsType.php b/src/Facade/Type/AnalyticsType.php index c6a56ae..fae02e0 100644 --- a/src/Facade/Type/AnalyticsType.php +++ b/src/Facade/Type/AnalyticsType.php @@ -17,6 +17,14 @@ interface AnalyticsType extends IOType */ public function setClientId(string $id); + /** + * Mark payload as non-personalized ads. + * + * @var non_personalized_ads + * @param bool $enabled + */ + public function setNonPersonalizedAds(bool $enabled = true); + /** * A unique identifier for a user. See User-ID for cross-platform analysis for more information on this identifier. * @@ -34,6 +42,38 @@ public function setUserId(string $id); */ public function setTimestampMicros(int|float $microOrUnix); + /** + * Validation behavior for debug endpoint. + * + * @var validation_behavior + * @param string|null $behavior + */ + public function setValidationBehavior(null|string $behavior); + + /** + * Override source IP address. + * + * @var ip_override + * @param string|null $ip + */ + public function setIpOverride(null|string $ip); + + /** + * Override user location object. + * + * @var user_location + * @param array $location + */ + public function setUserLocation(array $location); + + /** + * Override device object. + * + * @var device + * @param array $device + */ + public function setDevice(array $device); + /** * The user properties for the measurement (Up to 25 custom per project, see link) * diff --git a/src/Facade/Type/DefaultEventParamsType.php b/src/Facade/Type/DefaultEventParamsType.php index 7d124ee..dcb1930 100644 --- a/src/Facade/Type/DefaultEventParamsType.php +++ b/src/Facade/Type/DefaultEventParamsType.php @@ -64,5 +64,61 @@ public function setSessionId(string $id); */ public function setEngagementTimeMSec(int $msec); + /** + * Optional event-level override for event timestamp. + * + * @var timestamp_micros + * @param int|float $microOrUnix microtime(true) or time() + */ + public function setEventTimestampMicros(int|float $microOrUnix); + + /** + * Campaign source. + * + * @var campaign_source + * @param string $source eg. newsletter + */ + public function setCampaignSource(string $source); + + /** + * Campaign medium. + * + * @var campaign_medium + * @param string $medium eg. email + */ + public function setCampaignMedium(string $medium); + + /** + * Campaign name. + * + * @var campaign_name + * @param string $name eg. spring_sale + */ + public function setCampaignName(string $name); + + /** + * Campaign term. + * + * @var campaign_term + * @param string $term + */ + public function setCampaignTerm(string $term); + + /** + * Campaign content. + * + * @var campaign_content + * @param string $content + */ + public function setCampaignContent(string $content); + + /** + * Campaign identifier. + * + * @var campaign_id + * @param string $id + */ + public function setCampaignId(string $id); + public function toArray(): array; } diff --git a/src/Facade/Type/FirebaseType.php b/src/Facade/Type/FirebaseType.php index 4df0946..4d1831c 100644 --- a/src/Facade/Type/FirebaseType.php +++ b/src/Facade/Type/FirebaseType.php @@ -17,6 +17,14 @@ interface FirebaseType extends IOType */ public function setAppInstanceId(string $id); + /** + * Mark payload as non-personalized ads. + * + * @var non_personalized_ads + * @param bool $enabled + */ + public function setNonPersonalizedAds(bool $enabled = true); + /** * A unique identifier for a user. See User-ID for cross-platform analysis for more information on this identifier. * @@ -34,6 +42,38 @@ public function setUserId(string $id); */ public function setTimestampMicros(int|float $microOrUnix); + /** + * Validation behavior for debug endpoint. + * + * @var validation_behavior + * @param string|null $behavior + */ + public function setValidationBehavior(null|string $behavior); + + /** + * Override source IP address. + * + * @var ip_override + * @param string|null $ip + */ + public function setIpOverride(null|string $ip); + + /** + * Override user location object. + * + * @var user_location + * @param array $location + */ + public function setUserLocation(array $location); + + /** + * Override device object. + * + * @var device + * @param array $device + */ + public function setDevice(array $device); + /** * The user properties for the measurement (Up to 25 custom per project, see link) * diff --git a/src/Facade/Type/UserPropertyType.php b/src/Facade/Type/UserPropertyType.php index 39c9e15..8c03577 100644 --- a/src/Facade/Type/UserPropertyType.php +++ b/src/Facade/Type/UserPropertyType.php @@ -30,4 +30,12 @@ public function setName(string $name): static; * @return static */ public function setValue(int|float|string $value): static; + + /** + * Optional timestamp override for when the user property was set. + * + * @param int|float $microOrUnix microtime(true) or time() + * @return static + */ + public function setTimestampMicros(int|float $microOrUnix): static; } diff --git a/src/Firebase.php b/src/Firebase.php index 43b11b9..00afd90 100644 --- a/src/Firebase.php +++ b/src/Firebase.php @@ -16,8 +16,12 @@ class Firebase extends Helper\IOHelper implements Facade\Type\FirebaseType protected null|bool $non_personalized_ads = false; protected null|int $timestamp_micros; + protected null|string $validation_behavior; + protected null|string $ip_override; protected null|string $app_instance_id; protected null|string $user_id; + protected array $user_location = []; + protected array $device = []; protected array $user_properties = []; protected array $events = []; @@ -37,8 +41,12 @@ public function getParams(): array return [ 'non_personalized_ads', 'timestamp_micros', + 'validation_behavior', + 'ip_override', 'app_instance_id', 'user_id', + 'user_location', + 'device', 'user_properties', 'events', ]; @@ -55,6 +63,12 @@ public function setAppInstanceId(string $id) return $this; } + public function setNonPersonalizedAds(bool $enabled = true) + { + $this->non_personalized_ads = $enabled; + return $this; + } + public function setUserId(string $id) { $this->user_id = $id; @@ -76,6 +90,30 @@ public function setTimestampMicros(int|float $microOrUnix) return $this; } + public function setValidationBehavior(null|string $behavior) + { + $this->validation_behavior = $behavior; + return $this; + } + + public function setIpOverride(null|string $ip) + { + $this->ip_override = $ip; + return $this; + } + + public function setUserLocation(array $location) + { + $this->user_location = static::cleanAssoc($location); + return $this; + } + + public function setDevice(array $device) + { + $this->device = static::cleanAssoc($device); + return $this; + } + public function addUserProperty(Facade\Type\UserPropertyType ...$props) { foreach ($props as $prop) { @@ -121,10 +159,12 @@ public function post(): void $url = $this->debug ? Facade\Type\AnalyticsType::URL_DEBUG : Facade\Type\AnalyticsType::URL_LIVE; $url .= '?' . http_build_query(['firebase_app_id' => $this->firebase_app_id, 'api_secret' => $this->api_secret]); + $userData = $this->userdata->toArray(); + $body = array_replace_recursive( $this->toArray(), ["app_instance_id" => $this->app_instance_id], - ["user_data" => !empty($this->user_id) ? $this->userdata->toArray() : []], // Only accepted if user_id is passed too + ["user_data" => $userData], ["user_properties" => $this->user_properties], ["consent" => $this->consent->toArray()], ); @@ -189,4 +229,22 @@ public static function new(string $firebase_app_id, string $api_secret, bool $de { return new static($firebase_app_id, $api_secret, $debug); } + + private static function cleanAssoc(array $input): array + { + $out = []; + foreach ($input as $key => $value) { + if (!is_string($key)) { + continue; + } + + if ($value === null || $value === '') { + continue; + } + + $out[$key] = $value; + } + + return $out; + } } diff --git a/src/Helper/ConvertHelper.php b/src/Helper/ConvertHelper.php index e00d329..bc94ea2 100644 --- a/src/Helper/ConvertHelper.php +++ b/src/Helper/ConvertHelper.php @@ -86,10 +86,10 @@ public static function parseEvents(array $list): array */ public static function parseSessionCookie(string $session): array { - $parts = explode('.', $session); - + $parts = explode('.', trim($session), 3); + $version = $parts[0] ?? null; - $k = $parts[1] ?? null; + $domainLevel = $parts[1] ?? null; $data = $parts[2] ?? null; // If the data part is empty, return an empty array @@ -98,16 +98,23 @@ public static function parseSessionCookie(string $session): array } if ($version === 'GS1') { - $data = explode('.', $session); - - return [ - 'version' => $data[0] ?? null, - 'domain_level' => $data[1] ?? null, - 'session_id' => $data[2] ?? null, - 'session_number' => $data[3] ?? null, - 'session_engagement' => $data[4] ?? null, - 'timestampt' => $data[5] ?? null - ]; + $model = explode('.', $data); + + return array_filter([ + 'version' => $version, + 'domain_level' => $domainLevel, + 'session_id' => $model[0] ?? null, + 'session_number' => $model[1] ?? null, + 'session_engagement' => $model[2] ?? null, + 'timestamp' => $model[3] ?? null, + 'join_timer' => $model[4] ?? null, + 'logged_in_state' => $model[5] ?? null, + 'join_id' => $model[6] ?? null, + ], fn ($val) => $val !== null && $val !== ''); + } + + if ($version !== 'GS2') { + return []; } $cookieParts = explode('$', $data); @@ -116,27 +123,116 @@ public static function parseSessionCookie(string $session): array return []; } - $data = array_map( - fn ($part) => match ($part[0]) { - 's' => ['session_id' => $part], - 't' => ['timestamp' => $part], - 'o' => ['session_number' => $part], - 'g' => ['session_engaged' => $part], - 'j' => ['join_timer' => $part], - 'l' => ['logged_in_state' => $part], - 'h' => ['user_id' => $part], - 'd' => ['join_id' => $part], - default => [$part[0] => $part] - }, - $cookieParts - ); + $result = []; + foreach ($cookieParts as $part) { + if ($part === '') { + continue; + } + + $key = $part[0]; + $value = substr($part, 1); + + if ($value === '') { + continue; + } + + $mappedKey = match ($key) { + 's' => 'session_id', + 't' => 'timestamp', + 'o' => 'session_number', + 'g' => 'session_engaged', + 'j' => 'join_timer', + 'l' => 'logged_in_state', + 'h' => 'user_id', + 'd' => 'join_id', + default => $key + }; + + $result[$mappedKey] = $value; + } + + if (count($result) < 1) { + return []; + } + + return array_replace([ + 'version' => $version, + 'domain_level' => $domainLevel, + ], $result); + } + + /** + * Parse _ga/_gid style cookie values into Measurement Protocol client_id format. + * + * @param string $cookie + * @return string|null + */ + public static function parseClientIdFromGaCookie(string $cookie): ?string + { + $cookie = trim($cookie); + if ($cookie === '') { + return null; + } + + $match = []; + if (!preg_match('/^GA\d+\.\d+\.(\d+)\.(\d+)$/', $cookie, $match)) { + return null; + } + + return "{$match[1]}.{$match[2]}"; + } + + /** + * Parse client id from cookie map, preferring _ga and then _gid. + * + * @param array $cookies + * @return string|null + */ + public static function parseClientIdFromCookies(array $cookies): ?string + { + foreach (['_ga', '_gid'] as $name) { + $cookie = $cookies[$name] ?? null; + if (!is_scalar($cookie)) { + continue; + } + + $clientId = static::parseClientIdFromGaCookie(strval($cookie)); + if ($clientId !== null) { + return $clientId; + } + } + return null; + } + + /** + * Parse common GA cookies into a structure that can be mapped directly to event/client fields. + * + * @param array $cookies + * @return array + */ + public static function parseGaCookies(array $cookies): array + { $result = []; - foreach ($data as $mapValue) { - foreach ($mapValue as $key => $value) { - $result[$key] = $value; + $clientId = static::parseClientIdFromCookies($cookies); + if ($clientId !== null) { + $result['client_id'] = $clientId; + } + + foreach ($cookies as $key => $value) { + if (!is_string($key) || !str_starts_with($key, '_ga_') || !is_scalar($value)) { + continue; } + + $session = static::parseSessionCookie(strval($value)); + if (count($session) < 1) { + continue; + } + + $result['session_cookie_name'] = $key; + $result['session'] = $session; + break; } return $result; diff --git a/src/Helper/EventHelper.php b/src/Helper/EventHelper.php index 107e68f..3b17f67 100644 --- a/src/Helper/EventHelper.php +++ b/src/Helper/EventHelper.php @@ -44,6 +44,42 @@ public function setScreenResolution(string $wxh) return $this; } + public function setCampaignSource(string $source) + { + $this->campaign['campaign_source'] = $source; + return $this; + } + + public function setCampaignMedium(string $medium) + { + $this->campaign['campaign_medium'] = $medium; + return $this; + } + + public function setCampaignName(string $name) + { + $this->campaign['campaign_name'] = $name; + return $this; + } + + public function setCampaignTerm(string $term) + { + $this->campaign['campaign_term'] = $term; + return $this; + } + + public function setCampaignContent(string $content) + { + $this->campaign['campaign_content'] = $content; + return $this; + } + + public function setCampaignId(string $id) + { + $this->campaign['campaign_id'] = $id; + return $this; + } + public function setEventPage(DefaultEventParamsType $page) { $args = $page->toArray(); diff --git a/src/Helper/EventMainHelper.php b/src/Helper/EventMainHelper.php index 8559778..94045ba 100644 --- a/src/Helper/EventMainHelper.php +++ b/src/Helper/EventMainHelper.php @@ -4,12 +4,14 @@ use AlexWestergaard\PhpGa4\Facade\Type\GtmEventType; use AlexWestergaard\PhpGa4\Facade\Type\EventType; +use AlexWestergaard\PhpGa4\Exception\Ga4Exception; use AlexWestergaard\PhpGa4\Exception\Ga4EventException; abstract class EventMainHelper extends IOHelper implements EventType { protected null|string $session_id; protected null|int $engagement_time_msec; + protected null|int $event_timestamp_micros; public function setSessionId(string $id) { @@ -23,6 +25,63 @@ public function setEngagementTimeMSec(int $msec) return $this; } + public function setEventTimestampMicros(int|float $microOrUnix) + { + $min = ConvertHelper::timeAsMicro(strtotime('-3 days') + 10); + $max = ConvertHelper::timeAsMicro(time() + 3); + + $time = ConvertHelper::timeAsMicro($microOrUnix); + + if ($time < $min || $time > $max) { + throw Ga4Exception::throwMicrotimeExpired(); + } + + $this->event_timestamp_micros = $time; + return $this; + } + + public function offsetExists(mixed $offset): bool + { + $offset = is_string($offset) ? ConvertHelper::snake($offset) : $offset; + return $offset === 'timestamp_micros' || parent::offsetExists($offset); + } + + public function offsetGet(mixed $offset): mixed + { + $offset = is_string($offset) ? ConvertHelper::snake($offset) : $offset; + if ($offset === 'timestamp_micros') { + return $this->event_timestamp_micros; + } + + return parent::offsetGet($offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $offset = is_string($offset) ? ConvertHelper::snake($offset) : $offset; + if ($offset === 'timestamp_micros') { + if ($value === null) { + $this->event_timestamp_micros = null; + } else { + $this->setEventTimestampMicros($value); + } + return; + } + + parent::offsetSet($offset, $value); + } + + public function offsetUnset(mixed $offset): void + { + $offset = is_string($offset) ? ConvertHelper::snake($offset) : $offset; + if ($offset === 'timestamp_micros') { + $this->event_timestamp_micros = null; + return; + } + + parent::offsetUnset($offset); + } + public function toArray(): array { $return = []; @@ -45,6 +104,10 @@ public function toArray(): array } } + if ($this->event_timestamp_micros !== null) { + $return['timestamp_micros'] = $this->event_timestamp_micros; + } + $return['params'] = parent::toArray(); return $return; @@ -53,6 +116,10 @@ public function toArray(): array public function getAllParams(): array { return array_unique(array_merge( + [ + 'session_id', + 'engagement_time_msec', + ], $this->getParams(), $this->getRequiredParams() )); diff --git a/src/Helper/EventParamsHelper.php b/src/Helper/EventParamsHelper.php index df468e1..4172841 100644 --- a/src/Helper/EventParamsHelper.php +++ b/src/Helper/EventParamsHelper.php @@ -11,7 +11,11 @@ public function __construct( protected null|string $page_location = null, protected null|string $page_referrer = null, protected null|string $page_title = null, - protected null|string $screen_resolution = null + protected null|string $screen_resolution = null, + protected null|string $session_id = null, + protected null|int $engagement_time_msec = null, + protected null|int $event_timestamp_micros = null, + protected array $campaign = [], ) {} public function setLanguage(string $lang) @@ -44,14 +48,71 @@ public function setScreenResolution(string $wxh) return $this; } + public function setSessionId(string $id) + { + $this->session_id = $id; + return $this; + } + + public function setEngagementTimeMSec(int $msec) + { + $this->engagement_time_msec = $msec; + return $this; + } + + public function setEventTimestampMicros(int|float $microOrUnix) + { + $this->event_timestamp_micros = ConvertHelper::timeAsMicro($microOrUnix); + return $this; + } + + public function setCampaignSource(string $source) + { + $this->campaign['campaign_source'] = $source; + return $this; + } + + public function setCampaignMedium(string $medium) + { + $this->campaign['campaign_medium'] = $medium; + return $this; + } + + public function setCampaignName(string $name) + { + $this->campaign['campaign_name'] = $name; + return $this; + } + + public function setCampaignTerm(string $term) + { + $this->campaign['campaign_term'] = $term; + return $this; + } + + public function setCampaignContent(string $content) + { + $this->campaign['campaign_content'] = $content; + return $this; + } + + public function setCampaignId(string $id) + { + $this->campaign['campaign_id'] = $id; + return $this; + } + public function toArray(): array { - return [ + return array_replace([ 'language' => $this->language, 'page_location' => $this->page_location, 'page_referrer' => $this->page_referrer, 'page_title' => $this->page_title, 'screen_resolution' => $this->screen_resolution, - ]; + 'session_id' => $this->session_id, + 'engagement_time_msec' => $this->engagement_time_msec, + 'timestamp_micros' => $this->event_timestamp_micros, + ], $this->campaign); } } diff --git a/src/Helper/UserDataHelper.php b/src/Helper/UserDataHelper.php index d3d656a..11882a8 100644 --- a/src/Helper/UserDataHelper.php +++ b/src/Helper/UserDataHelper.php @@ -58,20 +58,35 @@ public function setEmail(string $email): bool } /** - * @param int $number International number (without prefix "+" and dashes) eg. \ - * "+1-123-4567890" for USA or\ - * "+44-1234-5678900" for UK or\ - * "+45-12345678" for DK + * @param int|string $number International number in E.164-compatible style. * @return bool */ - public function setPhone(int $number): bool + public function setPhone(int|string $number): bool { - $sNumber = strval($number); - if (strlen($sNumber) < 3 || strlen($sNumber) > 15) { + $sNumber = trim(strval($number)); + if ($sNumber === '') { return false; } - $this->sha256_phone_number = hash("sha256", "+{$sNumber}"); + $sNumber = preg_replace('/[^\d+]/', '', $sNumber); + if ($sNumber === null || $sNumber === '') { + return false; + } + + if (str_starts_with($sNumber, '00')) { + $sNumber = '+' . substr($sNumber, 2); + } + + $digits = ltrim($sNumber, '+'); + if (!ctype_digit($digits)) { + return false; + } + + if (strlen($digits) < 3 || strlen($digits) > 15) { + return false; + } + + $this->sha256_phone_number = hash("sha256", "+{$digits}"); return true; } diff --git a/src/Helper/UserPropertyHelper.php b/src/Helper/UserPropertyHelper.php index 0950c2d..ee719de 100644 --- a/src/Helper/UserPropertyHelper.php +++ b/src/Helper/UserPropertyHelper.php @@ -3,10 +3,13 @@ namespace AlexWestergaard\PhpGa4\Helper; use AlexWestergaard\PhpGa4\Facade\Type\UserPropertyType; +use AlexWestergaard\PhpGa4\Exception\Ga4Exception; use AlexWestergaard\PhpGa4\Exception\Ga4UserPropertyException; abstract class UserPropertyHelper extends IOHelper implements UserPropertyType { + protected null|int $set_timestamp_micros; + public function setName(string $name): static { if ( @@ -24,6 +27,21 @@ public function setName(string $name): static return $this; } + public function setTimestampMicros(int|float $microOrUnix): static + { + $min = ConvertHelper::timeAsMicro(strtotime('-3 days') + 10); + $max = ConvertHelper::timeAsMicro(time() + 3); + + $time = ConvertHelper::timeAsMicro($microOrUnix); + + if ($time < $min || $time > $max) { + throw Ga4Exception::throwMicrotimeExpired(); + } + + $this->set_timestamp_micros = $time; + return $this; + } + public function toArray(): array { $return = []; @@ -37,6 +55,10 @@ public function toArray(): array $value = ['value' => $value]; } + if (isset($this->set_timestamp_micros)) { + $value['set_timestamp_micros'] = $this->set_timestamp_micros; + } + $return[$this->name] = $value; return $return; diff --git a/src/UserProperty.php b/src/UserProperty.php index 4929f2e..0dc2e4c 100644 --- a/src/UserProperty.php +++ b/src/UserProperty.php @@ -40,7 +40,7 @@ public function setValue(int|float|string $value): static public function getParams(): array { - return ['name', 'value']; + return ['name', 'value', 'set_timestamp_micros']; } public function getRequiredParams(): array diff --git a/test/Unit/AnalyticsTest.php b/test/Unit/AnalyticsTest.php index 9032283..3ddd191 100644 --- a/test/Unit/AnalyticsTest.php +++ b/test/Unit/AnalyticsTest.php @@ -100,6 +100,51 @@ public function test_can_configure_and_export() $this->assertEquals([$event->toArray()], $asArray['events']); } + public function test_can_set_extended_top_level_fields() + { + $analytics = Analytics::new( + $this->prefill['measurement_id'], + $this->prefill['api_secret'], + $debug = true + ) + ->setClientId($this->prefill['client_id']) + ->setNonPersonalizedAds(true) + ->setValidationBehavior('ENFORCE_RECOMMENDATIONS') + ->setIpOverride('203.0.113.42') + ->setUserLocation([ + 'city' => 'Copenhagen', + 'country_id' => 'DK', + ]) + ->setDevice([ + 'category' => 'desktop', + 'language' => 'en-US', + ]) + ->addEvent(Login::new()->setMethod('password')); + + $asArray = $analytics->toArray(); + $this->assertTrue($asArray['non_personalized_ads']); + $this->assertEquals('ENFORCE_RECOMMENDATIONS', $asArray['validation_behavior']); + $this->assertEquals('203.0.113.42', $asArray['ip_override']); + $this->assertEquals('DK', $asArray['user_location']['country_id']); + $this->assertEquals('desktop', $asArray['device']['category']); + } + + public function test_user_data_can_be_sent_without_user_id() + { + $this->expectNotToPerformAssertions(); + + $analytics = Analytics::new( + $this->prefill['measurement_id'], + $this->prefill['api_secret'], + $debug = true + ) + ->setClientId($this->prefill['client_id']); + + $analytics->userdata()->setEmail('test@gmail.com'); + $analytics->addEvent(Login::new()->setMethod('password')); + $analytics->post(); + } + public function test_can_post_only_client_id_to_google() { $this->expectNotToPerformAssertions(); diff --git a/test/Unit/EventTest.php b/test/Unit/EventTest.php index e865e95..1331822 100644 --- a/test/Unit/EventTest.php +++ b/test/Unit/EventTest.php @@ -15,6 +15,38 @@ final class EventTest extends MeasurementTestCase { + public function test_event_can_set_event_timestamp_override() + { + $event = Event\Login::new() + ->setMethod('password') + ->setEventTimestampMicros($time = time()); + + $asArray = $event->toArray(); + + $this->assertArrayHasKey('timestamp_micros', $asArray); + $this->assertEquals($time * 1_000_000, $asArray['timestamp_micros']); + } + + public function test_event_can_set_campaign_defaults() + { + $event = Event\Login::new() + ->setMethod('password') + ->setCampaignSource('newsletter') + ->setCampaignMedium('email') + ->setCampaignName('spring_sale') + ->setCampaignTerm('term') + ->setCampaignContent('content') + ->setCampaignId('cmp_1'); + + $asArray = $event->toArray(); + $this->assertEquals('newsletter', $asArray['params']['campaign_source']); + $this->assertEquals('email', $asArray['params']['campaign_medium']); + $this->assertEquals('spring_sale', $asArray['params']['campaign_name']); + $this->assertEquals('term', $asArray['params']['campaign_term']); + $this->assertEquals('content', $asArray['params']['campaign_content']); + $this->assertEquals('cmp_1', $asArray['params']['campaign_id']); + } + public function test_page_view() { $event = new Event\PageView; @@ -799,6 +831,9 @@ protected function assertImportableByConvertHelper(array $form, object $expected private function populateEventByFromArray(Type\EventType $event) { return $event::fromArray([ + 'session_id' => '1700000000', + 'engagement_time_msec' => 150, + 'timestamp_micros' => time(), 'language' => 'en-US', 'page_location' => 'https://www.example.com/', 'page_referrer' => 'https://example.com/', @@ -849,6 +884,9 @@ private function populateEventByArrayable(Type\EventType $event) $event['page_referrer'] = 'https://www.example.com/'; $event['page_title'] = 'Home - Example'; $event['screen_resolution'] = '1920x1080'; + $event['session_id'] = '1700000000'; + $event['engagement_time_msec'] = 150; + $event['timestamp_micros'] = time(); $event['description'] = 'This is a short description'; $event['fatal'] = true; @@ -893,6 +931,10 @@ private function populateEventByMethod( ) { $params = $event->getAllParams(); + $event->setSessionId('1700000000'); + $event->setEngagementTimeMSec(150); + $event->setEventTimestampMicros(time()); + if ($event instanceof EventHelper) { $event->setLanguage('en-US'); $event->setPageReferrer('https://example.com/'); diff --git a/test/Unit/FirebaseTest.php b/test/Unit/FirebaseTest.php index f486815..0087138 100644 --- a/test/Unit/FirebaseTest.php +++ b/test/Unit/FirebaseTest.php @@ -71,6 +71,51 @@ public function test_can_configure_and_export() $this->assertEquals([$event->toArray()], $asArray['events']); } + public function test_can_set_extended_top_level_fields() + { + $firebase = Firebase::new( + $this->prefill['firebase_app_id'], + $this->prefill['api_secret'], + $debug = true + ) + ->setAppInstanceId($this->prefill['app_instance_id']) + ->setNonPersonalizedAds(true) + ->setValidationBehavior('ENFORCE_RECOMMENDATIONS') + ->setIpOverride('203.0.113.42') + ->setUserLocation([ + 'city' => 'Copenhagen', + 'country_id' => 'DK', + ]) + ->setDevice([ + 'category' => 'mobile', + 'language' => 'en-US', + ]) + ->addEvent(Login::new()->setMethod('password')); + + $asArray = $firebase->toArray(); + $this->assertTrue($asArray['non_personalized_ads']); + $this->assertEquals('ENFORCE_RECOMMENDATIONS', $asArray['validation_behavior']); + $this->assertEquals('203.0.113.42', $asArray['ip_override']); + $this->assertEquals('DK', $asArray['user_location']['country_id']); + $this->assertEquals('mobile', $asArray['device']['category']); + } + + public function test_user_data_can_be_sent_without_user_id() + { + $this->expectNotToPerformAssertions(); + + $firebase = Firebase::new( + $this->prefill['firebase_app_id'], + $this->prefill['api_secret'], + $debug = true + ) + ->setAppInstanceId($this->prefill['app_instance_id']); + + $firebase->userdata()->setEmail('test@gmail.com'); + $firebase->addEvent(Login::new()->setMethod('password')); + $firebase->post(); + } + public function test_can_post_only_client_id_to_google() { $this->expectNotToPerformAssertions(); diff --git a/test/Unit/HelperTest.php b/test/Unit/HelperTest.php index 90384d2..b1e7e14 100644 --- a/test/Unit/HelperTest.php +++ b/test/Unit/HelperTest.php @@ -92,7 +92,10 @@ public function test_parse_session_cookie_gs1() 'session_id' => '1689053763', 'session_number' => '1', 'session_engagement' => '1', - 'timestampt' => '1689054101', + 'timestamp' => '1689054101', + 'join_timer' => '0', + 'logged_in_state' => '0', + 'join_id' => '0', ], $sessionData); } @@ -100,25 +103,30 @@ public function test_parse_session_cookie_gs2() { $sessionData = Helper\ConvertHelper::parseSessionCookie('GS2.1.s1764888982$o50$g1$t1764890260$j59$l0$h681028196'); $this->assertEquals([ - 'session_id' => 's1764888982', - 'session_number' => 'o50', - 'session_engaged' => 'g1', - 'timestamp' => 't1764890260', - 'join_timer' => 'j59', - 'logged_in_state' => 'l0', - 'user_id' => 'h681028196', + 'version' => 'GS2', + 'domain_level' => '1', + // Prefix removed from values. + 'session_id' => '1764888982', + 'session_number' => '50', + 'session_engaged' => '1', + 'timestamp' => '1764890260', + 'join_timer' => '59', + 'logged_in_state' => '0', + 'user_id' => '681028196', ], $sessionData); // same data, different order $sessionData = Helper\ConvertHelper::parseSessionCookie('GS2.1.t1764890260$o50$g1$s1764888982$j59$l0$h681028196'); $this->assertEquals([ - 'session_id' => 's1764888982', - 'session_number' => 'o50', - 'session_engaged' => 'g1', - 'timestamp' => 't1764890260', - 'join_timer' => 'j59', - 'logged_in_state' => 'l0', - 'user_id' => 'h681028196', + 'version' => 'GS2', + 'domain_level' => '1', + 'session_id' => '1764888982', + 'session_number' => '50', + 'session_engaged' => '1', + 'timestamp' => '1764890260', + 'join_timer' => '59', + 'logged_in_state' => '0', + 'user_id' => '681028196', ], $sessionData); } @@ -130,4 +138,44 @@ public function test_parse_session_cookie_invalid() $sessionData = Helper\ConvertHelper::parseSessionCookie(''); $this->assertEquals([], $sessionData); } + + public function test_parse_client_id_from_ga_cookie() + { + $this->assertEquals( + '1234567890.987654321', + Helper\ConvertHelper::parseClientIdFromGaCookie('GA1.1.1234567890.987654321') + ); + $this->assertNull(Helper\ConvertHelper::parseClientIdFromGaCookie('invalid')); + } + + public function test_parse_client_id_from_cookies() + { + $this->assertEquals( + '1234567890.987654321', + Helper\ConvertHelper::parseClientIdFromCookies([ + '_ga' => 'GA1.1.1234567890.987654321', + '_gid' => 'GA1.1.111.222', + ]) + ); + + $this->assertEquals( + '111.222', + Helper\ConvertHelper::parseClientIdFromCookies([ + '_gid' => 'GA1.1.111.222', + ]) + ); + } + + public function test_parse_ga_cookies_bundle() + { + $parsed = Helper\ConvertHelper::parseGaCookies([ + '_ga' => 'GA1.1.1234567890.987654321', + '_ga_123456789' => 'GS2.1.s1764888982$o50$g1$t1764890260', + ]); + + $this->assertArrayHasKey('client_id', $parsed); + $this->assertArrayHasKey('session', $parsed); + $this->assertEquals('1234567890.987654321', $parsed['client_id']); + $this->assertEquals('1764888982', $parsed['session']['session_id']); + } } diff --git a/test/Unit/UserDataTest.php b/test/Unit/UserDataTest.php index b142d41..8a58568 100644 --- a/test/Unit/UserDataTest.php +++ b/test/Unit/UserDataTest.php @@ -37,6 +37,19 @@ public function test_user_data_is_fillable() $this->assertEquals($setCountry, $export["address"]["country"], $setCountry); } + public function test_phone_number_accepts_string_formatting() + { + $uda = new UserDataHelper(); + $this->assertTrue($uda->setPhone('+45 00-00-00-00')); + + $export = $uda->toArray(); + $this->assertArrayHasKey('sha256_phone_number', $export); + $this->assertEquals( + hash('sha256', '+4500000000'), + $export['sha256_phone_number'] + ); + } + public function test_user_data_is_sendable() { $this->expectNotToPerformAssertions(); diff --git a/test/Unit/UserPropertyTest.php b/test/Unit/UserPropertyTest.php index f30ca10..ca714bd 100644 --- a/test/Unit/UserPropertyTest.php +++ b/test/Unit/UserPropertyTest.php @@ -22,6 +22,22 @@ public function test_can_configure_and_export() $this->assertEquals($value, $export[$name]['value']); } + public function test_can_set_property_timestamp() + { + $userProperty = new UserProperty(); + + $userProperty + ->setName('testname') + ->setValue('testvalue') + ->setTimestampMicros($time = time()); + + $export = $userProperty->toArray(); + + $this->assertArrayHasKey('testname', $export); + $this->assertArrayHasKey('set_timestamp_micros', $export['testname']); + $this->assertEquals($time * 1_000_000, $export['testname']['set_timestamp_micros']); + } + public function test_throws_on_reserved_name() { $userProperty = new UserProperty();