Skip to content
Closed
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
155 changes: 155 additions & 0 deletions website_sale_google_analytics_4/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
.. |company| replace:: ADHOC SA

.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png
:alt: ADHOC SA
:target: https://www.adhoc.com.ar

.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png

.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: https://www.gnu.org/licenses/agpl
:alt: License: AGPL-3

======================
GA4 Ecommerce Tracking
======================

Self-contained Google Analytics 4 ecommerce tracking module for Odoo 19 websites.
Extends the native Odoo tracking with additional funnel events and adds
server-side event delivery via the GA4 Measurement Protocol — **no Google Tag
Manager required**.

Features
========

Frontend events (via ``gtag.js``)
----------------------------------

The following events are fired client-side, complementing the events already
covered by Odoo's native ``website_sale`` tracking (``view_item``,
``add_to_cart``, ``purchase``):

* ``view_cart`` – fired when the shopping cart page is loaded.
* ``begin_checkout`` – fired when the user clicks "Proceed to Checkout".
* ``add_shipping_info`` – fired when the user selects a delivery method on
the checkout page.
* ``add_payment_info`` – fired when the user submits the payment form.
* ``remove_from_cart`` – fired when a cart line is deleted or its quantity is
decremented.
* ``sign_up`` – fired when the registration form is submitted.
* ``login`` – fired when the login form is submitted.

Backend events (via GA4 Measurement Protocol)
----------------------------------------------

Server-side events are sent directly from Odoo to Google Analytics as a
reliability safeguard (ad-blockers, async/offline payments, etc.):

* ``purchase`` – sent after ``sale.order._action_confirm()``.
* ``refund`` – sent after a credit note (``out_refund`` invoice) is posted.

A scheduled action retries failed Measurement Protocol calls every 15 minutes
(up to 5 attempts per event).

Consent Mode v2 compatibility
-------------------------------

This module relies on Odoo's native gtag.js injection (``website.google_analytics_key``),
which already configures Consent Mode v2 with denied defaults and grants consent
when the user accepts optional cookies. No additional consent configuration is
required.

Installation
============

#. Install this module. It depends on ``website_sale`` and ``payment``.
#. Ensure the native Google Analytics Measurement ID has been configured in
**Website → Configuration → Settings → Google Analytics** (field
``google_analytics_key``). This module shares that field.

Configuration
=============

#. Go to **Website → Configuration → Settings → Google Analytics**.
#. Set the **Measurement ID** (``G-XXXXXXXXXX``) in the existing
**Google Analytics Key** field — this is the same field used by Odoo
natively to inject the ``gtag.js`` snippet.
#. Set the **GA4 API Secret** in the new **GA4 Measurement Protocol API Secret**
field. This secret is required for server-side event delivery.

To generate an API secret: in the Google Analytics admin panel go to
**Admin → Data collection and modification → Data Streams → your stream →
Measurement Protocol API secrets → Create**.

.. warning::
The API secret must remain server-side only. Never expose it in the
browser or client-side code.

#. *(Optional)* The scheduled action **GA4: Retry Failed MP Events** is
created automatically and runs every 15 minutes. You can adjust the
frequency in **Technical → Scheduled Actions**.

Usage
=====

Once configured, GA4 events are fired automatically with no further action
required from the store operator:

* **Cart events** are fired on the ``/shop/cart`` page.
* **Checkout funnel events** are fired as the customer progresses through
address → delivery → payment steps.
* **Purchase events** are fired both client-side (via ``gtag.js``) and
server-side (via Measurement Protocol) on order confirmation. GA4
deduplicates these using the same ``transaction_id`` (the Odoo sale order
numeric ID).
* **Refund events** are fired server-side when a credit note linked to a
web order is confirmed.
* **Authentication events** are fired on the login/registration page.

GA session data (``_ga`` and ``_ga_<container>`` cookies) is captured from
the browser at the start of the checkout flow and stored on the sale order,
enabling proper session attribution for server-side events.

Known trade-offs
================

* **``value`` includes tax** – the event-level ``value`` parameter in all
events uses ``amount_total`` (inclusive of tax and shipping) to stay
consistent with Odoo's native tracking. The GA4 Measurement Protocol
documentation recommends excluding tax and shipping from ``value``, so
revenue figures may appear inflated if tax rates are significant. The
event-level ``tax`` and ``shipping`` fields are still sent separately so
the data can be reconciled.
* **GA client ID availability** – server-side events (``purchase``, ``refund``)
require the customer to have been on the checkout page with JavaScript
enabled so that the ``_ga`` cookie can be captured. Orders placed via the
backend or via API will not have a ``ga_client_id`` and the MP event will be
silently skipped.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues
<https://github.com/ingadhoc/website/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smash it by providing a detailed and welcomed feedback.

Credits
=======

Images
------

* |company| |icon|

Contributors
------------

Maintainer
----------

|company_logo|

This module is maintained by the |company|.

To contribute to this module, please visit https://www.adhoc.com.ar.
2 changes: 2 additions & 0 deletions website_sale_google_analytics_4/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import models
from . import controllers
22 changes: 22 additions & 0 deletions website_sale_google_analytics_4/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "Google Analytics 4 Ecommerce Tracking",
"summary": "Server-side and client-side GA4 ecommerce event tracking",
"version": "19.0.1.0.0",
"author": "ADHOC SA",
"category": "Website",
"license": "AGPL-3",
"depends": ["website_sale", "payment"],
"data": [
"data/ir_cron.xml",
"views/res_config_settings_view.xml",
"views/website_sale_templates.xml",
],
"assets": {
"web.assets_frontend": [
"website_sale_google_analytics_4/static/src/interactions/ga4_ecommerce.js",
"website_sale_google_analytics_4/static/src/interactions/ga4_user_tracking.js",
],
},
"installable": True,
"application": False,
}
1 change: 1 addition & 0 deletions website_sale_google_analytics_4/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
42 changes: 42 additions & 0 deletions website_sale_google_analytics_4/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import logging

from odoo import http
from odoo.addons.website_sale.controllers.main import WebsiteSale

_logger = logging.getLogger(__name__)


class GA4WebsiteSale(WebsiteSale):
@http.route()
def shop_checkout(self, try_skip_step=None, **query_params):
"""Override to capture GA4 browser cookies into the sale order."""
order = http.request.cart
if order and not order.ga_client_id:
try:
cookies = http.request.httprequest.cookies
website = http.request.website

# --- Parse _ga cookie → client_id ---
ga_cookie = cookies.get("_ga", "")
client_id = order._parse_ga_client_id(ga_cookie)

# --- Parse _ga_<container_id> cookie → session_id ---
measurement_id = website.google_analytics_key or ""
session_id = ""
if measurement_id:
# G-ABCDEF1234 → cookie name _ga_ABCDEF1234
container_suffix = measurement_id.replace("G-", "")
session_cookie = cookies.get(f"_ga_{container_suffix}", "")
session_id = order._parse_ga_session_id(session_cookie)

if client_id:
order.sudo().write(
{
"ga_client_id": client_id,
"ga_session_id": session_id or False,
}
)
except Exception as exc:
_logger.warning("GA4: failed to capture cookies: %s", exc)

return super().shop_checkout(try_skip_step=try_skip_step, **query_params)
13 changes: 13 additions & 0 deletions website_sale_google_analytics_4/data/ir_cron.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_retry_ga4_mp_events" model="ir.cron">
<field name="name">GA4: Retry Failed Measurement Protocol Events</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="model_id" ref="website.model_website"/>
<field name="state">code</field>
<field name="code">env['website'].sudo()._retry_failed_ga4_mp_events()</field>
</record>
</odoo>
5 changes: 5 additions & 0 deletions website_sale_google_analytics_4/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import website
from . import res_config_settings
from . import sale_order
from . import sale_order_line
from . import account_move
126 changes: 126 additions & 0 deletions website_sale_google_analytics_4/models/account_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import logging

import requests
from odoo import fields, models

_logger = logging.getLogger(__name__)

GA4_MP_ENDPOINT = "https://www.google-analytics.com/mp/collect"
GA4_MP_TIMEOUT = 3


class AccountMove(models.Model):
_inherit = "account.move"

ga4_mp_pending_payload = fields.Json(
string="GA4 MP Pending Payload",
help="Stores a failed refund Measurement Protocol payload for retry.",
copy=False,
)

# ------------------------------------------------------------------
# Post hook – server-side refund event
# ------------------------------------------------------------------

def _post(self, soft=True):
res = super()._post(soft=soft)
for move in res.filtered(lambda m: m.move_type == "out_refund"):
try:
move._send_ga4_mp_refund()
except Exception as exc:
_logger.warning(
"GA4 MP refund event skipped for move %s: %s",
move.name,
exc,
)
return res

def _send_ga4_mp_refund(self, _is_retry=False):
"""Send a ``refund`` Measurement Protocol event for this credit note.

Returns True on success, False on failure.
"""
self.ensure_one()
# Locate the originating sale order for GA session data.
# Prefer the direct relationship via invoice lines (most reliable), then
# fall back to invoice_origin string match filtered by company to avoid
# cross-company or multi-origin false matches.
sale_order = self.invoice_line_ids.sale_line_ids.order_id.filtered(lambda o: o.company_id == self.company_id)[
:1
]
if not sale_order:
sale_order = (
self.env["sale.order"]
.sudo()
.search(
[("name", "=", self.invoice_origin), ("company_id", "=", self.company_id.id)],
limit=1,
)
)
if not sale_order or not sale_order.ga_client_id:
return False

website = (sale_order.website_id or self.env["website"].get_current_website()).sudo()
measurement_id = website.google_analytics_key
api_secret = website.ga4_api_secret
if not measurement_id or not api_secret:
return False

items = []
for line in self.invoice_line_ids.filtered(lambda l: l.product_id and not l.display_type):
items.append(
{
"item_id": line.product_id.barcode or str(line.product_id.id),
"item_name": line.product_id.name or "-",
"item_category": line.product_id.categ_id.name or "-",
"price": abs(line.price_unit),
"quantity": abs(line.quantity),
}
)

params = {
"transaction_id": str(sale_order.id),
"value": abs(self.amount_total),
"currency": self.currency_id.name,
"items": items,
}
if sale_order.ga_session_id:
params["session_id"] = sale_order.ga_session_id
params["engagement_time_msec"] = 100

body = {
"client_id": sale_order.ga_client_id,
"events": [{"name": "refund", "params": params}],
}

try:
response = requests.post(
GA4_MP_ENDPOINT,
params={"measurement_id": measurement_id, "api_secret": api_secret},
json=body,
timeout=GA4_MP_TIMEOUT,
)
response.raise_for_status()
_logger.info(
"GA4 MP 'refund' event sent for move %s (origin order %s)",
self.name,
sale_order.name,
)
return True
except Exception as exc:
_logger.warning(
"GA4 MP refund event failed for move %s (origin order %s): %s",
self.name,
sale_order.name,
exc,
)
if not _is_retry:
attempts = (self.ga4_mp_pending_payload or {}).get("_attempts", 0)
self.ga4_mp_pending_payload = {
"_attempts": attempts + 1,
}
else:
payload = dict(self.ga4_mp_pending_payload or {})
payload["_attempts"] = payload.get("_attempts", 0) + 1
self.ga4_mp_pending_payload = payload
return False
12 changes: 12 additions & 0 deletions website_sale_google_analytics_4/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

ga4_api_secret = fields.Char(
string="GA4 API Secret",
related="website_id.ga4_api_secret",
readonly=False,
groups="base.group_system",
)
Loading
Loading