From 34a60b7a9f4f902ea1de83bf3b66aff689a899d8 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 7 Feb 2025 17:06:12 +0000 Subject: [PATCH 1/3] feat(analytics): enhance GTM event tracking with GA4-compatible format --- .../src/common/analytics/types.ts | 2 +- libs/analytics/src/gtm/types.ts | 68 ++++++++++++++++++- libs/analytics/src/types.ts | 14 ++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/apps/cowswap-frontend/src/common/analytics/types.ts b/apps/cowswap-frontend/src/common/analytics/types.ts index 1418aa201d..561a558178 100644 --- a/apps/cowswap-frontend/src/common/analytics/types.ts +++ b/apps/cowswap-frontend/src/common/analytics/types.ts @@ -16,7 +16,7 @@ export enum CowSwapAnalyticsCategory { HOOKS = 'Hooks', CURRENCY_SELECT = 'Currency Select', RECIPIENT_ADDRESS = 'Recipient address', - ORDER_SLIPAGE_TOLERANCE = 'Order Slippage Tolerance', + ORDER_SLIPPAGE_TOLERANCE = 'Order Slippage Tolerance', ORDER_EXPIRATION_TIME = 'Order Expiration Time', WRAP_NATIVE_TOKEN = 'Wrapped Native Token', CLAIM_COW_FOR_LOCKED_GNO = 'Claim COW for Locked GNO', diff --git a/libs/analytics/src/gtm/types.ts b/libs/analytics/src/gtm/types.ts index f3c255ceae..bd7f437eab 100644 --- a/libs/analytics/src/gtm/types.ts +++ b/libs/analytics/src/gtm/types.ts @@ -3,7 +3,7 @@ */ import { AnalyticsContext } from '../CowAnalytics' -import { Category, GtmEvent } from '../types' +import { Category, GtmEvent, GA4Event } from '../types' // Re-export Category as GtmCategory for backward compatibility export type GtmCategory = Category @@ -34,7 +34,69 @@ export function isValidGtmClickEvent(data: unknown): data is GtmClickEvent { return true } -// Helper to create data-click-event attribute value with dynamic properties +/** + * Converts events to GA4-compatible format for Google Tag Manager + * + * GA4 uses a flat event model where: + * - The event name is a clear, descriptive string of the action + * - Additional context is provided via custom parameters + * + * Example: + * Input event: + * { + * category: 'Wallet', + * action: 'Connect wallet button click', + * label: 'MetaMask' + * } + * + * Becomes GA4 event: + * { + * event: 'Connect wallet button click', + * event_category: 'Wallet', + * event_label: 'MetaMask' + * } + * + * @param event The internal event format + * @returns JSON string of GA4-compatible event + */ export function toGtmEvent(event: Partial): string { - return JSON.stringify(event) + // Log original event + console.group('🔍 Analytics Event') + console.log('Original Event:', { + ...event, + timestamp: new Date().toISOString(), + location: window.location.href, + }) + + const ga4Event: GA4Event = { + // Use the action directly as the event name - no transformation needed + event: event.action || '', // This will keep the nice readable format like 'Toggle Hooks Enabled' + + // Keep the rest of the metadata + ...(event.category && { event_category: event.category }), + ...(event.label && { event_label: event.label }), + ...(event.value !== undefined && { event_value: event.value }), + ...(event.orderId && { order_id: event.orderId }), + ...(event.orderType && { order_type: event.orderType }), + ...(event.tokenSymbol && { token_symbol: event.tokenSymbol }), + ...(event.chainId && { chain_id: event.chainId }), + ...Object.entries(event) + .filter( + ([key]) => + !['category', 'action', 'label', 'value', 'orderId', 'orderType', 'tokenSymbol', 'chainId'].includes(key), + ) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), + } + + // Log transformed GA4 event + console.log('GA4 Event:', { + ...ga4Event, + stack: new Error().stack + ?.split('\n') + .slice(2) + .map((line) => line.trim()), + }) + console.groupEnd() + + return JSON.stringify(ga4Event) } diff --git a/libs/analytics/src/types.ts b/libs/analytics/src/types.ts index 51724bdc25..54402b9bab 100644 --- a/libs/analytics/src/types.ts +++ b/libs/analytics/src/types.ts @@ -16,6 +16,19 @@ export enum Category { // Re-export the legacy category as GtmCategory for backward compatibility export type GtmCategory = Category +/** + * GA4-compatible event format + * See: https://developers.google.com/analytics/devguides/collection/ga4/reference/events + */ +export interface GA4Event { + event: string // The event name + [key: string]: any // Additional parameters +} + +/** + * Base GTM event format - maintained for backward compatibility + * Will be transformed into GA4 format before sending to dataLayer + */ export interface BaseGtmEvent { category: T action: string @@ -25,6 +38,7 @@ export interface BaseGtmEvent { orderType?: string tokenSymbol?: string chainId?: number + [key: string]: any // Allow additional custom parameters } // Base type for creating application-specific category enums From da1e6735ed0c58b824a034d780daaa23ad762858 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 7 Feb 2025 17:41:09 +0000 Subject: [PATCH 2/3] refactor: optimize GTM analytics implementation with singleton and improved event handling --- libs/analytics/src/gtm/CowAnalyticsGtm.ts | 138 ++++++++++++---------- libs/analytics/src/gtm/types.ts | 23 +--- 2 files changed, 77 insertions(+), 84 deletions(-) diff --git a/libs/analytics/src/gtm/CowAnalyticsGtm.ts b/libs/analytics/src/gtm/CowAnalyticsGtm.ts index 032810488a..db00ee4fd8 100644 --- a/libs/analytics/src/gtm/CowAnalyticsGtm.ts +++ b/libs/analytics/src/gtm/CowAnalyticsGtm.ts @@ -1,7 +1,5 @@ import { debounce } from '@cowprotocol/common-utils' -import { isValidGtmClickEvent } from './types' - import { AnalyticsContext, CowAnalytics, EventOptions, OutboundLinkParams } from '../CowAnalytics' import { GtmEvent, Category } from '../types' @@ -14,53 +12,95 @@ type DataLayer = DataLayerEvent[] declare global { interface Window { dataLayer: unknown[] + cowAnalyticsInstance?: CowAnalyticsGtm // For singleton pattern } } /** * GTM Analytics Provider implementing the CowAnalytics interface - * Maintains compatibility with existing analytics while using GTM's dataLayer + * + * IMPORTANT: This implementation relies on Google Tag Manager (GTM) for click tracking. + * + * === GTM SETUP INSTRUCTIONS === + * + * 1. Create a new GTM Trigger: + * - Trigger Type: "All Elements" + * - This trigger fires on: "Click" + * - Fire on: "Some Clicks" + * - Condition: "Click Element" matches CSS selector "[data-click-event]" + * + * 2. Create GTM Variables to extract data-click-event contents: + * - Variable Type: "Custom JavaScript" + * - Function should parse the data-click-event attribute: + * ```javascript + * function() { + * var clickElement = {{Click Element}}; + * if (!clickElement) return; + * + * var eventData = clickElement.getAttribute('data-click-event'); + * if (!eventData) return; + * + * try { + * return JSON.parse(eventData); + * } catch(e) { + * console.error('Failed to parse click event data:', e); + * return null; + * } + * } + * ``` + * + * 3. Create GTM Tag: + * - Tag Type: "Google Analytics: GA4 Event" + * - Event Name: {{ClickEvent.action}} (using the variable created above) + * - Parameters to include: + * - event_category: {{ClickEvent.category}} + * - event_label: {{ClickEvent.label}} + * - event_value: {{ClickEvent.value}} + * - order_id: {{ClickEvent.orderId}} + * - order_type: {{ClickEvent.orderType}} + * - token_symbol: {{ClickEvent.tokenSymbol}} + * - chain_id: {{ClickEvent.chainId}} + * + * === USAGE IN CODE === + * + * Add data-click-event attribute to elements you want to track: + * ```tsx + * + * ``` + * + * The data-click-event attribute will be automatically picked up by GTM + * when a user clicks the element. No additional JavaScript handling is needed. */ export class CowAnalyticsGtm implements CowAnalytics { private dimensions: Record = {} - private debouncedPageView: (path?: string, params?: string[], title?: string) => void - private dataLayer: DataLayer + private debouncedPageView = debounce((path?: string, params?: string[], title?: string) => { + this._sendPageView(path, params, title) + }, 1000) + private dataLayer: DataLayer = [] constructor() { - // Initialize dataLayer if (typeof window !== 'undefined') { + // Implement singleton pattern + if (window.cowAnalyticsInstance) { + console.warn('CowAnalyticsGtm instance already exists. Reusing existing instance.') + return window.cowAnalyticsInstance + } + window.cowAnalyticsInstance = this window.dataLayer = window.dataLayer || [] this.dataLayer = window.dataLayer as DataLayer - // Add global click listener for data attributes - window.addEventListener('click', this.handleDataAttributeClick.bind(this), true) - } else { - this.dataLayer = [] - } - - // Initialize debounced page view - this.debouncedPageView = debounce((path?: string, params?: string[], title?: string) => { - setTimeout(() => this._sendPageView(path, params, title), 1000) - }) - } - - // Handle clicks on elements with data-click-event attribute - private handleDataAttributeClick(event: MouseEvent): void { - const target = event.target as HTMLElement - const clickEventElement = target.closest('[data-click-event]') - - if (!clickEventElement) return - - const eventData = clickEventElement.getAttribute('data-click-event') - if (!eventData) return - - try { - const parsedEvent = JSON.parse(eventData) - if (isValidGtmClickEvent(parsedEvent)) { - this.sendEvent(parsedEvent) - } - } catch (error) { - console.warn('Failed to parse GTM click event:', error) + // Clean up on page unload + window.addEventListener('unload', () => { + delete window.cowAnalyticsInstance + }) } } @@ -91,22 +131,13 @@ export class CowAnalyticsGtm implements CowAnalytics { typeof event === 'string' ? { event, ...(params as Record) } : { - // Create specific event name from category and action - event: `${event.category.toLowerCase()}_${event.action.toLowerCase().replace(/\s+/g, '_')}`, - - // Core parameters at root level + event: event.action, category: event.category, action: event.action, label: event.label, value: event.value, - - // Non-interaction flag non_interaction: event.nonInteraction, - - // Spread dimensions at root level ...this.getDimensions(), - - // Include additional dynamic properties if present ...((event as GtmEvent).orderId && { order_id: (event as GtmEvent).orderId }), ...((event as GtmEvent).orderType && { order_type: (event as GtmEvent).orderType }), ...((event as GtmEvent).tokenSymbol && { @@ -145,18 +176,11 @@ export class CowAnalyticsGtm implements CowAnalytics { ...this.getDimensions(), }) - // Execute callback after pushing to dataLayer if (hitCallback) { setTimeout(hitCallback, 0) } } - /** - * Sets an analytics dimension value. - * @param key - The dimension key to set - * @param value - The dimension value. Any value (including falsy values like '0' or '') will be stored, - * except undefined which will remove the dimension. - */ setContext(key: AnalyticsContext, value?: string): void { if (typeof value !== 'undefined') { this.dimensions[key] = value @@ -165,25 +189,15 @@ export class CowAnalyticsGtm implements CowAnalytics { } } - // Helper to push to dataLayer with proper typing private pushToDataLayer(data: DataLayerEvent): void { if (typeof window !== 'undefined') { - // TODO: TEMPORARY - Remove after debugging - console.log('🔍 Analytics Event:', { - ...data, - timestamp: new Date().toISOString(), - stack: new Error().stack?.split('\n').slice(2).join('\n'), // Capture call stack but skip the first 2 lines (Error and this function) - }) - this.dataLayer.push(data) } } - // Get current dimensions as GTM-compatible object private getDimensions(): Record { return Object.entries(this.dimensions).reduce( (acc, [key, value]) => { - // Include all values that are not undefined if (typeof value !== 'undefined') { acc[`dimension_${key}`] = value } diff --git a/libs/analytics/src/gtm/types.ts b/libs/analytics/src/gtm/types.ts index bd7f437eab..9b8a9952e4 100644 --- a/libs/analytics/src/gtm/types.ts +++ b/libs/analytics/src/gtm/types.ts @@ -60,19 +60,8 @@ export function isValidGtmClickEvent(data: unknown): data is GtmClickEvent { * @returns JSON string of GA4-compatible event */ export function toGtmEvent(event: Partial): string { - // Log original event - console.group('🔍 Analytics Event') - console.log('Original Event:', { - ...event, - timestamp: new Date().toISOString(), - location: window.location.href, - }) - const ga4Event: GA4Event = { - // Use the action directly as the event name - no transformation needed - event: event.action || '', // This will keep the nice readable format like 'Toggle Hooks Enabled' - - // Keep the rest of the metadata + event: event.action || '', ...(event.category && { event_category: event.category }), ...(event.label && { event_label: event.label }), ...(event.value !== undefined && { event_value: event.value }), @@ -88,15 +77,5 @@ export function toGtmEvent(event: Partial): string { .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), } - // Log transformed GA4 event - console.log('GA4 Event:', { - ...ga4Event, - stack: new Error().stack - ?.split('\n') - .slice(2) - .map((line) => line.trim()), - }) - console.groupEnd() - return JSON.stringify(ga4Event) } From b43b0fffe03dcfba3b6059010b39cb5bd1a6813f Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 7 Feb 2025 17:57:23 +0000 Subject: [PATCH 3/3] refactor: update SectionTitleIcon component with prop naming convention --- apps/cow-fi/app/(main)/cow-amm/page.tsx | 12 ++++++------ apps/cow-fi/app/(main)/cow-protocol/page.tsx | 14 +++++++------- apps/cow-fi/app/(main)/cow-swap/page.tsx | 10 +++++----- apps/cow-fi/app/(main)/page.tsx | 6 +++--- apps/cow-fi/app/(main)/widget/page.tsx | 10 +++++----- apps/cow-fi/app/(mev-blocker)/mev-blocker/page.tsx | 6 +++--- apps/cow-fi/components/CareersPageContent.tsx | 2 +- apps/cow-fi/components/DaosPageComponent.tsx | 6 +++--- apps/cow-fi/styles/styled.ts | 8 ++++---- 9 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/cow-fi/app/(main)/cow-amm/page.tsx b/apps/cow-fi/app/(main)/cow-amm/page.tsx index 1d9e24ddca..35cda1fea7 100644 --- a/apps/cow-fi/app/(main)/cow-amm/page.tsx +++ b/apps/cow-fi/app/(main)/cow-amm/page.tsx @@ -114,7 +114,7 @@ export default function Page() { - + AMMs don't want you to know about LVR @@ -160,7 +160,7 @@ export default function Page() { - + Finally, an AMM designed with LPs in mind @@ -205,7 +205,7 @@ export default function Page() { - + @@ -251,7 +251,7 @@ export default function Page() { - + CoW AMM benefits LPs of all types @@ -325,7 +325,7 @@ export default function Page() { - + Trust the experts @@ -356,7 +356,7 @@ export default function Page() { - + FAQs diff --git a/apps/cow-fi/app/(main)/cow-protocol/page.tsx b/apps/cow-fi/app/(main)/cow-protocol/page.tsx index be2e457cb0..6ff2fbe6ed 100644 --- a/apps/cow-fi/app/(main)/cow-protocol/page.tsx +++ b/apps/cow-fi/app/(main)/cow-protocol/page.tsx @@ -126,7 +126,7 @@ export default function Page() { - + The leading intents-based DEX aggregation protocol @@ -144,7 +144,7 @@ export default function Page() { - + How it works @@ -342,7 +342,7 @@ export default function Page() { - + Powering innovation across DeFi @@ -398,7 +398,7 @@ export default function Page() { - + @@ -488,7 +488,7 @@ export default function Page() { - + Build with CoW Protocol @@ -538,7 +538,7 @@ export default function Page() { - + Want to build a solver? @@ -576,7 +576,7 @@ export default function Page() { - + FAQs diff --git a/apps/cow-fi/app/(main)/cow-swap/page.tsx b/apps/cow-fi/app/(main)/cow-swap/page.tsx index 485de4a293..8374e4cad8 100644 --- a/apps/cow-fi/app/(main)/cow-swap/page.tsx +++ b/apps/cow-fi/app/(main)/cow-swap/page.tsx @@ -141,7 +141,7 @@ export default function Page() { - + @@ -180,7 +180,7 @@ export default function Page() { - + CoW Swap is the first user interface built on top of CoW Protocol @@ -209,7 +209,7 @@ export default function Page() { - + S-moooo-th trading @@ -285,7 +285,7 @@ export default function Page() { - + The DEX of choice for crypto whales and pros @@ -384,7 +384,7 @@ export default function Page() { - + FAQs diff --git a/apps/cow-fi/app/(main)/page.tsx b/apps/cow-fi/app/(main)/page.tsx index 064e7122ab..0168f369d8 100644 --- a/apps/cow-fi/app/(main)/page.tsx +++ b/apps/cow-fi/app/(main)/page.tsx @@ -61,7 +61,7 @@ export default function Page() { - + Innovation in action @@ -85,7 +85,7 @@ export default function Page() { - + Governance @@ -127,7 +127,7 @@ export default function Page() { - + Grants diff --git a/apps/cow-fi/app/(main)/widget/page.tsx b/apps/cow-fi/app/(main)/widget/page.tsx index ab2a4d78b7..650632ff57 100644 --- a/apps/cow-fi/app/(main)/widget/page.tsx +++ b/apps/cow-fi/app/(main)/widget/page.tsx @@ -2,7 +2,7 @@ import { Font, Color, ProductLogo, ProductVariant } from '@cowprotocol/ui' import { initGtm } from '@cowprotocol/analytics' -import { CowFiCategory, toCowFiGtmEvent } from 'src/common/analytics/types' +import { CowFiCategory } from 'src/common/analytics/types' import IMG_ICON_OWL from '@cowprotocol/assets/images/icon-owl.svg' import IMG_ICON_GHOST from '@cowprotocol/assets/images/icon-ghost.svg' @@ -117,7 +117,7 @@ export default function Page() { - + Integrate now @@ -169,7 +169,7 @@ export default function Page() { - + Every Bell, Whistle, and Moo @@ -225,7 +225,7 @@ export default function Page() { - + Everything You'd Want in a Widget @@ -258,7 +258,7 @@ export default function Page() { - + diff --git a/apps/cow-fi/app/(mev-blocker)/mev-blocker/page.tsx b/apps/cow-fi/app/(mev-blocker)/mev-blocker/page.tsx index 2a3c6602d2..dbf5cf0e54 100644 --- a/apps/cow-fi/app/(mev-blocker)/mev-blocker/page.tsx +++ b/apps/cow-fi/app/(mev-blocker)/mev-blocker/page.tsx @@ -161,7 +161,7 @@ export default function Page() { - + Broad spectrum MEV defense @@ -221,7 +221,7 @@ export default function Page() { - + Get Protected @@ -457,7 +457,7 @@ export default function Page() { - + Trusted by the best diff --git a/apps/cow-fi/components/CareersPageContent.tsx b/apps/cow-fi/components/CareersPageContent.tsx index 4be641cabc..1d09df377b 100644 --- a/apps/cow-fi/components/CareersPageContent.tsx +++ b/apps/cow-fi/components/CareersPageContent.tsx @@ -40,7 +40,7 @@ export function CareersPageContent({ - + Want to build the future of decentralized trading? diff --git a/apps/cow-fi/components/DaosPageComponent.tsx b/apps/cow-fi/components/DaosPageComponent.tsx index 0f9834515c..eedf67a095 100644 --- a/apps/cow-fi/components/DaosPageComponent.tsx +++ b/apps/cow-fi/components/DaosPageComponent.tsx @@ -80,7 +80,7 @@ export function DaosPageComponent() { - + Expert trading for expert DAOs @@ -134,7 +134,7 @@ export function DaosPageComponent() { - + Advanced order types @@ -274,7 +274,7 @@ export function DaosPageComponent() { - + diff --git a/apps/cow-fi/styles/styled.ts b/apps/cow-fi/styles/styled.ts index 0609a255b9..6cd3edf786 100644 --- a/apps/cow-fi/styles/styled.ts +++ b/apps/cow-fi/styles/styled.ts @@ -834,8 +834,8 @@ export const SectionTitleDescription = styled.p<{ } ` -export const SectionTitleIcon = styled.div<{ size?: number; multiple?: boolean }>` - --size: ${({ size }) => (size ? `${size}px` : '82px')}; +export const SectionTitleIcon = styled.div<{ $size?: number; $multiple?: boolean }>` + --size: ${({ $size }) => ($size ? `${$size}px` : '82px')}; width: 100%; height: var(--size); object-fit: contain; @@ -847,12 +847,12 @@ export const SectionTitleIcon = styled.div<{ size?: number; multiple?: boolean } > span { height: var(--size); - width: ${({ multiple }) => (multiple ? 'auto' : '100%')}; + width: ${({ $multiple = false }) => ($multiple ? 'auto' : '100%')}; color: inherit; } svg { - width: ${({ multiple }) => (multiple ? 'auto' : '100%')}; + width: ${({ $multiple = false }) => ($multiple ? 'auto' : '100%')}; height: 100%; max-height: var(--size); fill: currentColor;