Why MySQL and not GA4 or BigQuery?

GA4 is excellent for analytics, but it has one fundamental drawback: it is slow. During high-volume periods like Black Friday, reporting lag can drive the wrong decisions. When ad spend needs to react in real time, an independent logging system stops being a nice-to-have and becomes essential.

The Web GTM → Server-Side GTM → MySQL pipeline:

  • captures every purchase the moment it happens
  • stores the data on your own server
  • operates independently of any analytics tool
  • is ideal for real-time dashboards and immediate budget decisions

Storing orders in MySQL gives you several advantages:

  • Real revenue in milliseconds from the moment the purchase happens
  • No dependency on GA4 latency or sampling
  • Your own dashboards in Power BI, Looker Studio, or Metabase
  • Easy integration with custom tools, scripts, or alerts
  • Reconciliation against ERP or site orders
  • Free — entire pipeline runs on open-source + your own infrastructure

The outcome: the marketing and performance team gets revenue as it actually happens, with no delays and no dependencies.

Architecture overview

The system is built on three layers, each with a clear role:

  1. Browser / dataLayer — emits the purchase event with all order details
  2. GTM Web Container — reads the dataLayer and forwards data to Server-Side GTM
  3. Server-Side GTM → MySQL API — processes, enriches, and stores purchases in the database

Data flow:

Browser (dataLayer.purchase)
   ↓
GTM Web (Tag: Send to Server)
   ↓
Server-Side GTM (Client + Tag)
   ↓
REST API Endpoint (Flask / Node / PHP)
   ↓
MySQL (orders table)

Every layer does exactly what it should — nothing more, nothing less. When the architecture is clean, management, debugging, and extension all become much simpler.

The dataLayer is the foundation

Before any GTM work (web or server-side), the e-shop must emit a well-structured purchase event into the dataLayer. Without a clean and complete event, the entire pipeline is built on half-truths — which ends in wrong revenue numbers and hard-to-debug errors.

Recommended purchase event structure (GA4 Enhanced E-commerce format):

dataLayer.push({
  event: "purchase",
  ecommerce: {
    transaction_id: "ORDER12345",
    value: 74.90,
    currency: "EUR",
    tax: 0,
    shipping: 3.90,
    discount: 5.00,
    coupon: "BF2025",
    items: [
      {
        item_id: "SKU-00123",
        item_name: "Oversized Hoodie",
        item_brand: "BrandX",
        item_category: "Apparel > Women > Hoodies",
        item_variant: "Black / Medium",
        price: 39.95,
        quantity: 1
      }
    ]
  }
});

Common dataLayer problems to fix at the e-shop level first:

  • missing order_id
  • missing coupon
  • no separation of subtotal, discount, and tax
  • missing item-level fields (brand, category, variant)
  • event fires at the wrong moment (refresh, back navigation, SPA transitions)

Fix these upstream. The dataLayer is the "data contract" that feeds every downstream layer.

Web GTM → Server-Side GTM

In Web GTM we deliberately avoid forwarding the entire dataLayer to Server-Side GTM — that inflates processing and transfer cost for no benefit. Instead, we create Data Layer Variables for the specific fields we need (transaction_id, value, currency, shipping, tax, discount, items) and send only those via a Stape Data Tag.

The tag triggers only on the purchase event, so every order is sent once to Server-Side GTM — no duplication, no wasted calls.

On the server side, the Data Client receives the incoming variables and converts them into an event inside the Server-Side GTM container. In Debug Mode you see one new event per purchase, containing exactly the fields we forwarded — transaction_id, value, currency, shipping, tax, discount, and the full items list. No transformations needed; values arrive as-is.

Server-Side GTM → Flask endpoint

Server-Side GTM forwards the purchase to our own backend via a JSON HTTP Request tag pointing at something like https://api.mydomain.gr/gtm/purchase-log. The payload carries only the fields we need — transaction_id, value, currency, shipping, tax, discount, items list, and basic context (timestamp, domain).

Minimal Flask app that receives and writes to MySQL:

from flask import Flask, request, jsonify
import mysql.connector
from datetime import datetime
import json

app = Flask(__name__)

db_config = {
  "host": "localhost",
  "user": "my_user",
  "password": "my_password",
  "database": "my_database",
  "charset": "utf8mb4"
}

@app.route("/gtm/purchase-log", methods=["POST"])
def purchase_log():
  data = request.get_json(silent=True) or {}
  order_id = data.get("transaction_id")
  value    = data.get("value")

  if not order_id or value is None:
    return jsonify({"status": "error", "message": "Missing order_id or value"}), 400

  conn = mysql.connector.connect(**db_config)
  cursor = conn.cursor()
  cursor.execute("""
    INSERT INTO orders_log
    (order_id, order_value, currency, shipping, tax, discount, items_json, created_at, source)
    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  """, (
    order_id, value,
    data.get("currency", "EUR"),
    data.get("shipping", 0), data.get("tax", 0), data.get("discount", 0),
    json.dumps(data.get("items", []), ensure_ascii=False),
    datetime.utcnow(),
    "gtm-ss"
  ))
  conn.commit()
  cursor.close()
  conn.close()
  return jsonify({"status": "ok"}), 200

MySQL table schema:

orders_log
----------
id             AUTO_INCREMENT PRIMARY KEY
order_id       VARCHAR
order_value    DECIMAL
currency       VARCHAR
shipping       DECIMAL
tax            DECIMAL
discount       DECIMAL
items_json     JSON or TEXT
created_at     DATETIME
source         VARCHAR  -- e.g. "gtm-ss"

For a first implementation, storing items as JSON is enough. A normalized order_items table can come later.

Apache reverse proxy + HTTPS

Server-Side GTM needs a public HTTPS URL to call, but Flask normally runs on 127.0.0.1:5000. An Apache reverse proxy terminates SSL on a public host and forwards the request internally to Flask.

Enable the needed modules and configure a Virtual Host on port 443 with Let's Encrypt certificates, ProxyPass to the Flask instance, and Header always set directives for CORS if the Server-Side GTM domain differs from the API. With that in place, Server-Side GTM can POST purchases to https://api.mysite.gr/gtm/purchase-log — and every order drops into MySQL within milliseconds of the customer clicking "confirm".