From b167f4513c6ef93438bb2fa44757400beb9bdb45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:46:36 +0000 Subject: [PATCH] Deduplicate purchase events and add a shared event_id The success page pushes trytagging_purchase along two independent paths: the server-rendered layout event from checkout_onepage_success.xml and the session event that reaches the page through the gtm-checkout customerData section. Both carry the same values, but the layout path builds ecommerce with items first (Tag\Order\Order is a MergeTag that TagParser unsets and merges back), so the JSON.stringify hash that guards against duplicates differed and the second push went through. On Luma that means two purchase hits per order; Hyva only reads the cart and customer sections, so it saw one. - Hash events on a key-sorted serialization, so two events holding the same values dedup regardless of the order the keys were assembled in. - Dedup purchases on their transaction as well, kept in localStorage, since the in-memory guard is reset on every page load and does not survive a reload of the success page or a session event arriving on the next page view. Falls back to the in-memory guard when storage is unavailable. - Add a deterministic event_id (purchase.) to both browser events and to the order_created webhook. Meta deduplicates browser and Conversions API events on event_id, not on transaction_id, so both sides need the same value. EventIdGenerator is the single place that defines the format. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Et1MecPwQ7nDwHxAxUsEQ2 --- DataLayer/Event/Purchase.php | 7 +- DataLayer/Event/PurchaseWebhookEvent.php | 8 +- DataLayer/Tag/Order/EventId.php | 35 +++++++ USAGE.md | 10 ++ Util/EventIdGenerator.php | 25 +++++ composer.json | 2 +- .../layout/checkout_onepage_success.xml | 1 + .../templates/hyva/script-pusher.phtml | 82 ++++++++++++++++- view/frontend/web/js/push.js | 92 ++++++++++++++++++- 9 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 DataLayer/Tag/Order/EventId.php create mode 100644 Util/EventIdGenerator.php diff --git a/DataLayer/Event/Purchase.php b/DataLayer/Event/Purchase.php index a6427a69..3b936c11 100644 --- a/DataLayer/Event/Purchase.php +++ b/DataLayer/Event/Purchase.php @@ -9,6 +9,7 @@ use Tagging\GTM\Api\NewCustomerResolverInterface; use Tagging\GTM\Config\Config; use Tagging\GTM\DataLayer\Tag\Order\OrderItems; +use Tagging\GTM\Util\EventIdGenerator; use Tagging\GTM\Util\PriceFormatter; class Purchase implements EventInterface @@ -18,17 +19,20 @@ class Purchase implements EventInterface private Config $config; private PriceFormatter $priceFormatter; private NewCustomerResolverInterface $newCustomerResolver; + private EventIdGenerator $eventIdGenerator; public function __construct( OrderItems $orderItems, Config $config, PriceFormatter $priceFormatter, - NewCustomerResolverInterface $newCustomerResolver + NewCustomerResolverInterface $newCustomerResolver, + EventIdGenerator $eventIdGenerator ) { $this->orderItems = $orderItems; $this->config = $config; $this->priceFormatter = $priceFormatter; $this->newCustomerResolver = $newCustomerResolver; + $this->eventIdGenerator = $eventIdGenerator; } /** @@ -39,6 +43,7 @@ public function get(): array $order = $this->order; return [ 'event' => 'trytagging_purchase', + 'event_id' => $this->eventIdGenerator->forOrder($order), 'ecommerce' => [ 'transaction_id' => $order->getIncrementId(), 'affiliation' => $this->config->getStoreName(), diff --git a/DataLayer/Event/PurchaseWebhookEvent.php b/DataLayer/Event/PurchaseWebhookEvent.php index 42e8a25b..6142ccca 100644 --- a/DataLayer/Event/PurchaseWebhookEvent.php +++ b/DataLayer/Event/PurchaseWebhookEvent.php @@ -9,6 +9,7 @@ use Tagging\GTM\Api\NewCustomerResolverInterface; use Tagging\GTM\DataLayer\Tag\Order\OrderItems; use Magento\Sales\Api\Data\OrderInterface; +use Tagging\GTM\Util\EventIdGenerator; use Tagging\GTM\Util\PriceFormatter; use Tagging\GTM\Config\Config; use Psr\Log\LoggerInterface; @@ -24,6 +25,7 @@ class PurchaseWebhookEvent private LoggerInterface $logger; private Debugger $debugger; private NewCustomerResolverInterface $newCustomerResolver; + private EventIdGenerator $eventIdGenerator; public function __construct( Json $json, @@ -33,7 +35,8 @@ public function __construct( PriceFormatter $priceFormatter, LoggerInterface $logger, Debugger $debugger, - NewCustomerResolverInterface $newCustomerResolver + NewCustomerResolverInterface $newCustomerResolver, + EventIdGenerator $eventIdGenerator ) { $this->json = $json; $this->clientFactory = $clientFactory; @@ -43,6 +46,7 @@ public function __construct( $this->logger = $logger; $this->debugger = $debugger; $this->newCustomerResolver = $newCustomerResolver; + $this->eventIdGenerator = $eventIdGenerator; } public function purchase(OrderInterface $order) @@ -87,6 +91,8 @@ public function purchase(OrderInterface $order) $data = [ 'event' => 'trytagging_purchase', + // Shared with the browser event so Meta can deduplicate browser and CAPI + 'event_id' => $this->eventIdGenerator->forOrder($order), 'marketing' => $marketingData, 'store_domain' => $this->config->getStoreDomain(), 'plugin_version' => $this->config->getVersion(), diff --git a/DataLayer/Tag/Order/EventId.php b/DataLayer/Tag/Order/EventId.php new file mode 100644 index 00000000..6c3707f2 --- /dev/null +++ b/DataLayer/Tag/Order/EventId.php @@ -0,0 +1,35 @@ +checkoutSession = $checkoutSession; + $this->eventIdGenerator = $eventIdGenerator; + } + + /** + * @return string + */ + public function get(): string + { + // The increment ID is already on the session order, so there is no need + // to load the order again through the repository + return $this->eventIdGenerator->forOrder($this->checkoutSession->getLastRealOrder()); + } +} diff --git a/USAGE.md b/USAGE.md index 797b16b8..27d21a13 100644 --- a/USAGE.md +++ b/USAGE.md @@ -11,6 +11,16 @@ The extension has the following configuration options: - **Debug**: Enable this for additional debugging in a logfile and the browser console. - **Choose script placement**: Setting this option to YES will remove the tracking script from the page. Only use this option if you want to choose where the script is placed in the page. This option is not recommended for most users. +# Purchase deduplication +Every purchase carries an `event_id` (`purchase.`) in the dataLayer event *and* in the +`order_created` webhook, with the exact same value on both sides. Map that variable onto the Event ID field of +your Meta tag in GTM: Meta deduplicates browser and Conversions API events on `event_id`, not on +`transaction_id`, so without it the same order is counted twice. + +The extension itself pushes a purchase only once per transaction. The transactions that were already pushed are +kept in `localStorage` under `tagging_gtm_pushed_transactions`, so a reload of the success page does not +retrigger the event. + # Tip: Browser extension Use the [DataLayer Checker](https://chrome.google.com/webstore/detail/datalayer-checker/ffljdddodmkedhkcjhpmdajhjdbkogke) for Chrome to diff --git a/Util/EventIdGenerator.php b/Util/EventIdGenerator.php new file mode 100644 index 00000000..73f64981 --- /dev/null +++ b/Util/EventIdGenerator.php @@ -0,0 +1,25 @@ +getIncrementId(); + } +} diff --git a/composer.json b/composer.json index 21b4288c..c29da440 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "tagginggroup/gtm", - "version": "1.7.0", + "version": "1.8.0", "license": "OSL-3.0", "type": "magento2-module", "description": "AdPage tagging integration for Magento 2", diff --git a/view/frontend/layout/checkout_onepage_success.xml b/view/frontend/layout/checkout_onepage_success.xml index d12697fb..515c8da5 100644 --- a/view/frontend/layout/checkout_onepage_success.xml +++ b/view/frontend/layout/checkout_onepage_success.xml @@ -20,6 +20,7 @@ trytagging_purchase + Tagging\GTM\DataLayer\Tag\Order\EventId Tagging\GTM\DataLayer\Tag\Order\Order Tagging\GTM\DataLayer\Tag\Order\OrderItems diff --git a/view/frontend/templates/hyva/script-pusher.phtml b/view/frontend/templates/hyva/script-pusher.phtml index aceb3c4e..e040ed29 100644 --- a/view/frontend/templates/hyva/script-pusher.phtml +++ b/view/frontend/templates/hyva/script-pusher.phtml @@ -11,6 +11,73 @@ declare(strict_types=1); */ ?> registerInlineScript() ?> \ No newline at end of file +registerInlineScript() ?> diff --git a/view/frontend/web/js/push.js b/view/frontend/web/js/push.js index 9e461a3c..4891e5ef 100644 --- a/view/frontend/web/js/push.js +++ b/view/frontend/web/js/push.js @@ -1,4 +1,78 @@ define(["googleTagManagerLogger"], function (logger) { + const PURCHASE_EVENT = "trytagging_purchase"; + const PUSHED_TRANSACTIONS_KEY = "tagging_gtm_pushed_transactions"; + const PUSHED_TRANSACTIONS_MAX = 50; + + /** + * Serialize with the object keys sorted, so that two events holding the same + * values hash to the same string even when the keys were assembled in a + * different order server-side. + */ + const stableStringify = function (value) { + if (Array.isArray(value)) { + return "[" + value.map(stableStringify).join(",") + "]"; + } + + if (value && typeof value === "object") { + return ( + "{" + + Object.keys(value) + .sort() + .map(function (key) { + return JSON.stringify(key) + ":" + stableStringify(value[key]); + }) + .join(",") + + "}" + ); + } + + const serialized = JSON.stringify(value); + + return typeof serialized === "undefined" ? "null" : serialized; + }; + + const getPushedTransactions = function () { + try { + const stored = JSON.parse( + window.localStorage.getItem(PUSHED_TRANSACTIONS_KEY) || "[]" + ); + + return Array.isArray(stored) ? stored : []; + } catch (error) { + // No storage available, fall back to the in-memory guard only + return []; + } + }; + + const rememberTransaction = function (transactionId) { + try { + const transactions = getPushedTransactions(); + transactions.push(transactionId); + + window.localStorage.setItem( + PUSHED_TRANSACTIONS_KEY, + JSON.stringify(transactions.slice(-PUSHED_TRANSACTIONS_MAX)) + ); + } catch (error) { + // No storage available, fall back to the in-memory guard only + } + }; + + /** + * The transaction a purchase event belongs to, or null for any other event. + * The in-memory guard is reset on every page load, so purchases are also + * deduplicated on their transaction to survive a reload of the success page. + */ + const getTransactionId = function (eventData) { + if (eventData.event !== PURCHASE_EVENT || !eventData.ecommerce) { + return null; + } + + const transactionId = eventData.ecommerce.transaction_id; + + return transactionId ? String(transactionId) : null; + }; + return function (eventData, message) { window.Tagging_GTM_PAST_EVENTS = window.Tagging_GTM_PAST_EVENTS || []; @@ -30,8 +104,20 @@ define(["googleTagManagerLogger"], function (logger) { return; } + // Prevent the same purchase from being triggered twice, across page loads + const transactionId = getTransactionId(cleanEventData); + if (transactionId && getPushedTransactions().includes(transactionId)) { + logger( + 'Warning: Purchase already triggered for transaction "' + + transactionId + + '"', + eventData + ); + return; + } + // Prevent the same event from being triggered twice, when containing the same data - const eventHash = btoa(encodeURIComponent(JSON.stringify(cleanEventData))); + const eventHash = btoa(encodeURIComponent(stableStringify(cleanEventData))); if (window.Tagging_GTM_PAST_EVENTS.includes(eventHash)) { logger("Warning: Event already triggered", eventData); return; @@ -80,5 +166,9 @@ define(["googleTagManagerLogger"], function (logger) { window.dataLayer.push(cleanEventData); window.Tagging_GTM_PAST_EVENTS.push(eventHash); + + if (transactionId) { + rememberTransaction(transactionId); + } }; });