Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion DataLayer/Event/Purchase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand All @@ -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(),
Expand Down
8 changes: 7 additions & 1 deletion DataLayer/Event/PurchaseWebhookEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,7 @@ class PurchaseWebhookEvent
private LoggerInterface $logger;
private Debugger $debugger;
private NewCustomerResolverInterface $newCustomerResolver;
private EventIdGenerator $eventIdGenerator;

public function __construct(
Json $json,
Expand All @@ -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;
Expand All @@ -43,6 +46,7 @@ public function __construct(
$this->logger = $logger;
$this->debugger = $debugger;
$this->newCustomerResolver = $newCustomerResolver;
$this->eventIdGenerator = $eventIdGenerator;
}

public function purchase(OrderInterface $order)
Expand Down Expand Up @@ -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(),
Expand Down
35 changes: 35 additions & 0 deletions DataLayer/Tag/Order/EventId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php declare(strict_types=1);

namespace Tagging\GTM\DataLayer\Tag\Order;

use Magento\Checkout\Model\Session as CheckoutSession;
use Tagging\GTM\Api\Data\TagInterface;
use Tagging\GTM\Util\EventIdGenerator;

class EventId implements TagInterface
{
private CheckoutSession $checkoutSession;
private EventIdGenerator $eventIdGenerator;

/**
* @param CheckoutSession $checkoutSession
* @param EventIdGenerator $eventIdGenerator
*/
public function __construct(
CheckoutSession $checkoutSession,
EventIdGenerator $eventIdGenerator
) {
$this->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());
}
}
10 changes: 10 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<increment_id>`) 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
Expand Down
25 changes: 25 additions & 0 deletions Util/EventIdGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php declare(strict_types=1);

namespace Tagging\GTM\Util;

use Magento\Sales\Api\Data\OrderInterface;

/**
* Builds the deterministic event identifier that ties a browser purchase event to
* the server-side order_created webhook.
*
* Meta deduplicates browser and Conversions API events on event_id, not on
* transaction_id, so both sides have to produce the exact same value. Keep this
* the only place where the format is defined.
*/
class EventIdGenerator
{
/**
* @param OrderInterface $order
* @return string
*/
public function forOrder(OrderInterface $order): string
{
return 'purchase.' . (string)$order->getIncrementId();
}
}
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions view/frontend/layout/checkout_onepage_success.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<argument name="data_layer_events" xsi:type="array">
<item name="purchase_event" xsi:type="array">
<item name="event" xsi:type="string">trytagging_purchase</item>
<item name="event_id" xsi:type="object">Tagging\GTM\DataLayer\Tag\Order\EventId</item>
<item name="ecommerce" xsi:type="array">
<item name="order" xsi:type="object">Tagging\GTM\DataLayer\Tag\Order\Order</item>
<item name="items" xsi:type="object">Tagging\GTM\DataLayer\Tag\Order\OrderItems</item>
Expand Down
82 changes: 80 additions & 2 deletions view/frontend/templates/hyva/script-pusher.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,73 @@ declare(strict_types=1);
*/
?>
<script>
// Assigned on window rather than declared, so a second render of this
// template cannot break the page on a duplicate declaration
window.Tagging_GTM_PURCHASE_EVENT = 'trytagging_purchase';
window.Tagging_GTM_PUSHED_TRANSACTIONS_KEY = 'tagging_gtm_pushed_transactions';
window.Tagging_GTM_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.
*/
function googleTagManager2StableStringify(value) {
if (Array.isArray(value)) {
return '[' + value.map(googleTagManager2StableStringify).join(',') + ']';
}

if (value && typeof value === 'object') {
return '{' + Object.keys(value).sort().map(function (key) {
return JSON.stringify(key) + ':' + googleTagManager2StableStringify(value[key]);
}).join(',') + '}';
}

const serialized = JSON.stringify(value);

return typeof serialized === 'undefined' ? 'null' : serialized;
}

function googleTagManager2GetPushedTransactions() {
try {
const stored = JSON.parse(window.localStorage.getItem(window.Tagging_GTM_PUSHED_TRANSACTIONS_KEY) || '[]');

return Array.isArray(stored) ? stored : [];
} catch (error) {
// No storage available, fall back to the in-memory guard only
return [];
}
}

function googleTagManager2RememberTransaction(transactionId) {
try {
const transactions = googleTagManager2GetPushedTransactions();
transactions.push(transactionId);

window.localStorage.setItem(
window.Tagging_GTM_PUSHED_TRANSACTIONS_KEY,
JSON.stringify(transactions.slice(-window.Tagging_GTM_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.
*/
function googleTagManager2GetTransactionId(eventData) {
if (eventData.event !== window.Tagging_GTM_PURCHASE_EVENT || !eventData.ecommerce) {
return null;
}

const transactionId = eventData.ecommerce.transaction_id;

return transactionId ? String(transactionId) : null;
}

function googleTagManager2Pusher(eventData, message) {
window.Tagging_GTM_PAST_EVENTS = window.Tagging_GTM_PAST_EVENTS || [];

Expand All @@ -21,7 +88,14 @@ declare(strict_types=1);
delete copyEventData.meta;
}

const eventHash = btoa(encodeURIComponent(JSON.stringify(copyEventData)));
// Prevent the same purchase from being triggered twice, across page loads
const transactionId = googleTagManager2GetTransactionId(copyEventData);
if (transactionId && googleTagManager2GetPushedTransactions().includes(transactionId)) {
googleTagManager2Logger('Warning: Purchase already triggered for transaction "' + transactionId + '"', eventData);
return;
}

const eventHash = btoa(encodeURIComponent(googleTagManager2StableStringify(copyEventData)));
if (window.Tagging_GTM_PAST_EVENTS.includes(eventHash)) {
googleTagManager2Logger('Warning: Event already triggered', eventData);
return;
Expand Down Expand Up @@ -70,5 +144,9 @@ declare(strict_types=1);

window.dataLayer.push(eventData);
window.Tagging_GTM_PAST_EVENTS.push(eventHash);

if (transactionId) {
googleTagManager2RememberTransaction(transactionId);
}
}
</script><?php if (isset($hyvaCsp) && $hyvaCsp): ?><?php $hyvaCsp->registerInlineScript() ?><?php endif; ?>
</script><?php if (isset($hyvaCsp) && $hyvaCsp): ?><?php $hyvaCsp->registerInlineScript() ?><?php endif; ?>
92 changes: 91 additions & 1 deletion view/frontend/web/js/push.js
Original file line number Diff line number Diff line change
@@ -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 || [];

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -80,5 +166,9 @@ define(["googleTagManagerLogger"], function (logger) {

window.dataLayer.push(cleanEventData);
window.Tagging_GTM_PAST_EVENTS.push(eventHash);

if (transactionId) {
rememberTransaction(transactionId);
}
};
});
Loading