7 min readAug 10, 2026by jakub
Changelog
Current version: 1.0.0
The format follows Keep a Changelog; versioning follows SemVer.
[1.0.0] — unreleased
Initial release.
Added
- First-touch attribution capture. Ad click IDs (
gclid,gbraid,wbraid,fbclid,msclkid,ttclid) and campaign parameters (utm_*), plus the landing page and referrer, are captured in the browser and stored against the order at placement. First touch wins, so a shopper acquired by an ad who later returns organically stays credited to the ad. Capture is client-side by necessity: landing pages are full-page cached, so PHP never runs on the cache hit that serves a paid click. - Reliable
purchase, independent of the success page. The server-side event fires at order placement (checkout_submit_all_after), so a shopper who pays and closes the tab — or whose payment provider fails to redirect back — is still counted. The deterministicord-<increment_id>id means the success page's client-side pixel deduplicates against it rather than double-counting. - Item collections capped to GA4's 200-item maximum. The Measurement Protocol rejects an oversized request body outright, so an uncapped 300-line B2B order would lose its
purchaseevent entirely rather than merely being truncated. Truncation is logged with the event name and both counts. - DJson-templated data layer: every emitted event is defined by an admin-editable JSON template, with store-view scoping and one-enabled-template-per-scope resolution. Fourteen GA4 ecommerce defaults are installed on setup:
view_item,view_item_list,add_to_cart,remove_from_cart,view_cart,begin_checkout,add_shipping_info,add_payment_info,purchase,refund,add_to_wishlist,login,sign_up,search. - GA4 checkout funnel:
add_shipping_infoandadd_payment_info, collected by plugins on the checkout shipping-information and set-payment-information REST calls — customer and guest checkout — and delivered on the checkout page in GA4 order, beforepurchase. Funnel events carry a deterministic per-quote event ID, so the repeated collection Magento performs during place-order collapses to a single push while a genuine payment-method or shipping-tier change still re-fires. select_item, emitted client-side when a shopper clicks a product in a listing: it reuses the embeddedview_item_listpayload and needs no template or extra markup.- GA4 item parameters:
item_brand(from a configurable brand attribute), the category hierarchy (item_category/item_category2…5),item_variant(configurable option selection), and per-itemdiscountandindex. Optional fields are omitted rather than emitted as nulls. - Custom/dynamic variables: a
confignamespace exposing admin-mapped store-config values to every template ({{config.name}}), and per-item product attributes underitem.custom({{item.custom.color}}). - Admin UI: template grid + form with a CodeMirror JSON editor, DJson Validate and sample-data Preview, mass enable/disable/delete; read-only server-side Dispatch Log grid; system configuration under Qoliber → Data Layer; full ACL tree.
- Storefront delivery: CSP-safe GTM container + Consent Mode v2 default snippets; FPC-safe split between server-rendered page events (inert JSON script blocks) and session-queued visitor events delivered via the
qoliber-datalayercustomer-data section; client-side event-id deduplication. - Server-side transmission (queue-based, consent-gated): GA4 Measurement Protocol (incl. GTM Server-Side endpoint override), Meta Conversions API (shared
event_idpixel dedup, SHA-256-hashed identifiers), TikTok Events API; per-attempt dispatch log with configurable retention and a nightly purge cron. Therefundevent is server-side only, fired from credit-memo creation with a deterministicref-<increment_id>ID. - Consent layer: Google Consent Mode v2 defaults per store view,
qdl_consentcookie contract, replaceableConsentStateProviderInterface, consent snapshots captured at event time and enforced at dispatch time. - Service contracts:
TemplateRepositoryInterface(+ REST CRUD under/V1/qoliber-datalayer/templates),TemplateRendererInterface,EventDataProviderInterface,DestinationInterface,EventDispatchServiceInterface,EventProcessorInterface. - CLI:
qoliber:datalayer:template:reset,qoliber:datalayer:render,qoliber:datalayer:destination:testandqoliber:datalayer:ga4:validate, which renders each template as the dispatcher would and asks GA4's debug endpoint whether it understands the result — the live endpoint answers 204 to anything, so this is the only way to catch a template that is valid DJson but describes an event GA4 discards. - Storefront debug panel: an opt-in floating overlay listing every
window.dataLayerpush, for development and QA. Off by default. - Extension points: provider/destination/function DI pools, the
qoliber_datalayer_event_collectedpayload-mutation event,qoliber_datalayer_dispatch_before/qoliber_datalayer_dispatch_afteraround every server-side delivery attempt, and a replaceable consent provider preference. - Dedicated
qoliber_datalayercache type with automatic FPC invalidation on template save. - Test suites: PHPUnit unit (676 tests) + Magento integration (54 tests) + Playwright storefront/admin E2E (52 tests), and a GitHub Actions pipeline running syntax, PHPCS (Magento 2 + PSR-12), PHPMD, unit and integration on every push. The Playwright lane provisions a full Mage-OS instance; it runs automatically for release candidates and stays opt-in elsewhere.
Security
- Personal data is filtered where the payload is built, not where it is sent. The order payload is handed to an admin-editable template, so anything present in it is something a template can put on the page or into an outbound request.
order.customer_emailand the household-level billing fields needad_user_dataconsent and "Expose Raw Personal Data";order.customer_email_hashneeds the consent alone, because hashing governs how an identifier travels rather than whether a refused one may be sent;order.attributionneedsad_storage. - Custom variables are allowlisted, not blocklisted. Config paths must match a permitted prefix, extendable only through DI — so widening what the storefront can read is a code review rather than an admin text field. A blocklist could not catch credentials like
carriers/dhl/accountor a third-partyclient_id. - Ad click IDs are not stored before consent. Persisting
gclidand friends isad_storageunder Consent Mode v2, so capture waits for consent rather than writing a 90-day cookie on arrival. A capture made while consent is pending is held in memory and written only if the shopper grants it during that page view. - Raw order PII honours "Expose Raw Personal Data". Previously the setting was applied to the customer section but not to the order payload, so it silently did not cover the payload most likely to leave the site.
- Custom variables cannot read credentials. Config paths under
admin,payment,cryptandsmtp, and any path naming itself a password, token, key or secret, are refused before the value is read, and the refusal is logged.
Fixed
- Multishipping orders are reported. Magento dispatches
checkout_submit_all_afterwith anordersarray and noorderkey for multishipping, so observers reading the singular key ignored the entire checkout: with the success page rendering only the last order, every other order went unreported. Both observers now normalise either shape, so each order is dispatched as its ownpurchaseand all of them receive the checkout's attribution. - Refund value matches the refunded items. It was derived from credit-memo totals, whose aggregate discount includes any refunded shipping discount that no item price subtracts — so the value disagreed with the items in the same payload, and diverged again whenever items hit the 200-item cap. It is now summed from the emitted rows, as
purchasealready was. - Indivisible rows keep their cents. A unit price is a row divided by a quantity and does not always land on a cent: 10.00 across three units rounded to 3.33 and multiplied back to 9.99. Emitted unit prices keep twelve decimals so price × quantity reconstructs the row, with the event value still rounded to currency precision. Twelve because that is what the schema demands: quantity is
DECIMAL(12,4)against a row total ofDECIMAL(20,4), so the smallest unit price a valid row can produce is exactly1.0e-12. At eight decimals a 0.01 row across 2,000,000 units reconstructed to 0.02, and across 2,000,001 units to 0.00, losing the line's revenue entirely. - GA4 revenue and discounts.
valuecarried the grand total whiletaxandshippingwere also sent separately, double-counting both. Item money was wrong in two further ways:pricewas tax-inclusive, and it was the list price sent alongside adiscount— but GA4 derives item revenue asprice × quantityand never subtractsdiscount, so a discounted line overstated revenue by the whole discount.priceis now the net price actually paid per unit,discountis per unit and reported for the discount metric only,*_incl_taxfields carry the gross equivalents, andvalueis summed from the emitted prices. - Attribution survives Magento's own cookie notice. The native notice announces acceptance with a jQuery-triggered event that a native listener never receives, so a shopper who accepted through it lost the held click ID.
add_to_cartreports the chosen variant of a configurable, not the parent SKU at the parent's (cheapest-variant) price, and carriesitem_variantmatching whatpurchasereports for the same line.- A refund is reported once per credit memo. The bound event fires on every save, so adding a comment used to report a second refund.
- A rejected payload is no longer logged as delivered. TikTok answers 200 while discarding a payload it rejects; destinations now read the response body before an attempt counts as success.
- An unattributable visitor is skipped, not sent. TikTok requires at least one identifier to attribute an event and rejects a call carrying none. The destination now skips that case the way Meta already did, so the dispatch log records
no user identifiersinstead of a delivery failure that reads like a credential problem. - Uninstall removes data.
module:uninstall --remove-datanow drops all four tables and the module's config; previously there was noUninstallclass, so it dropped nothing. - GA4 validation is strict.
qoliber:datalayer:ga4:validatesendsvalidation_behavior=ENFORCE_RECOMMENDATIONS, so it cannot report "valid" under GA4's relaxed validation.
Compatibility
- PHP 8.1 through 8.5. The unit suite runs clean on 8.5 with no deprecation from module code. A Rector downgrade dry-run guards the 8.1 floor in CI, because forward compatibility only ever breaks from below: syntax or a stdlib call newer than 8.1 works on the version it was written on and fatals on a merchant's.
Known limitations
- PHPStan (level 8) runs as a local gate rather than in CI: a run without Magento installed cannot resolve inherited core methods, and installing PHPStan 2.x into the Magento project conflicts with the 1.x that Mage-OS requires.