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 deterministic ord-<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 purchase event 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_info and add_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, before purchase. 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 embedded view_item_list payload 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-item discount and index. Optional fields are omitted rather than emitted as nulls.
  • Custom/dynamic variables: a config namespace exposing admin-mapped store-config values to every template ({{config.name}}), and per-item product attributes under item.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-datalayer customer-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_id pixel dedup, SHA-256-hashed identifiers), TikTok Events API; per-attempt dispatch log with configurable retention and a nightly purge cron. The refund event is server-side only, fired from credit-memo creation with a deterministic ref-<increment_id> ID.
  • Consent layer: Google Consent Mode v2 defaults per store view, qdl_consent cookie contract, replaceable ConsentStateProviderInterface, 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:test and qoliber: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.dataLayer push, for development and QA. Off by default.
  • Extension points: provider/destination/function DI pools, the qoliber_datalayer_event_collected payload-mutation event, qoliber_datalayer_dispatch_before / qoliber_datalayer_dispatch_after around every server-side delivery attempt, and a replaceable consent provider preference.
  • Dedicated qoliber_datalayer cache 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_email and the household-level billing fields need ad_user_data consent and "Expose Raw Personal Data"; order.customer_email_hash needs the consent alone, because hashing governs how an identifier travels rather than whether a refused one may be sent; order.attribution needs ad_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/account or a third-party client_id.
  • Ad click IDs are not stored before consent. Persisting gclid and friends is ad_storage under 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, crypt and smtp, 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_after with an orders array and no order key 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 own purchase and 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 purchase already 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 of DECIMAL(20,4), so the smallest unit price a valid row can produce is exactly 1.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. value carried the grand total while tax and shipping were also sent separately, double-counting both. Item money was wrong in two further ways: price was tax-inclusive, and it was the list price sent alongside a discount — but GA4 derives item revenue as price × quantity and never subtracts discount, so a discounted line overstated revenue by the whole discount. price is now the net price actually paid per unit, discount is per unit and reported for the discount metric only, *_incl_tax fields carry the gross equivalents, and value is 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_cart reports the chosen variant of a configurable, not the parent SKU at the parent's (cheapest-variant) price, and carries item_variant matching what purchase reports 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 identifiers instead of a delivery failure that reads like a credential problem.
  • Uninstall removes data. module:uninstall --remove-data now drops all four tables and the module's config; previously there was no Uninstall class, so it dropped nothing.
  • GA4 validation is strict. qoliber:datalayer:ga4:validate sends validation_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.
Changelog — Data Layer — Marketing — Extensions | qoliber Docs