15 min read
Attribution Implementation Guide
Technical guide to implementing attribution tracking: GA4, GTM, server-side tracking, and custom attribution systems with production-ready code.
Implementation Architecture
A robust attribution system requires multiple layers working together to capture, process, and analyze touchpoint data.
code
┌─────────────────────────────────────────────────────────────────┐
│ ATTRIBUTION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ LAYER 1: DATA COLLECTION │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Client-Side │ │ Server-Side │ │ Platform │ │
│ │ (gtag.js) │ │ (CAPI) │ │ APIs │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ LAYER 2: IDENTITY RESOLUTION │
│ ┌─────────────────────────────────────────────────┐ │
│ │ User ID + Client ID + Device ID Matching │ │
│ └─────────────────────────┬───────────────────────┘ │
│ │ │
│ LAYER 3: TOUCHPOINT STORAGE │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Time-series database (PostgreSQL/BigQuery) │ │
│ └─────────────────────────┬───────────────────────┘ │
│ │ │
│ LAYER 4: ATTRIBUTION ENGINE │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Model Application + Credit Distribution │ │
│ └─────────────────────────┬───────────────────────┘ │
│ │ │
│ LAYER 5: REPORTING │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Dashboards + APIs + Exports │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘Step 1: UTM Parameter Standards
Consistent UTM parameters are the foundation of attribution.
UTM Taxonomy
code
// UTM Parameter Standard
interface UTMParameters {
utm_source: string; // Traffic source (google, facebook, newsletter)
utm_medium: string; // Marketing medium (cpc, email, social)
utm_campaign: string; // Campaign name (spring_sale_2026)
utm_content?: string; // Ad variation (blue_banner, video_v2)
utm_term?: string; // Paid keywords (running+shoes)
}
// Standardized UTM Values
const utmStandards = {
// Source naming (lowercase, underscore-separated)
sources: {
paid: ['google', 'meta', 'linkedin', 'tiktok', 'microsoft'],
organic: ['google', 'bing', 'yahoo', 'duckduckgo'],
social: ['facebook', 'instagram', 'twitter', 'linkedin', 'tiktok'],
email: ['newsletter', 'transactional', 'nurture', 'promo'],
referral: ['partner', 'affiliate', 'press']
},
// Medium naming
mediums: {
paid: ['cpc', 'ppc', 'display', 'paid_social', 'video', 'shopping'],
organic: ['organic', 'organic_social'],
owned: ['email', 'push', 'sms'],
earned: ['referral', 'pr', 'review']
},
// Campaign naming convention
campaignFormat: '{type}_{audience}_{offer}_{date}',
// Example: awareness_professionals_free-trial_2026q1
};URL Builder
code
// UTM URL Builder
class UTMBuilder {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.params = {};
}
source(value) {
this.params.utm_source = value.toLowerCase().replace(/\s+/g, '_');
return this;
}
medium(value) {
this.params.utm_medium = value.toLowerCase().replace(/\s+/g, '_');
return this;
}
campaign(value) {
this.params.utm_campaign = value.toLowerCase().replace(/\s+/g, '_');
return this;
}
content(value) {
this.params.utm_content = value.toLowerCase().replace(/\s+/g, '_');
return this;
}
term(value) {
this.params.utm_term = value.toLowerCase().replace(/\s+/g, '+');
return this;
}
build() {
const url = new URL(this.baseUrl);
Object.entries(this.params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
return url.toString();
}
}
// Usage
const url = new UTMBuilder('https://example.com/landing')
.source('google')
.medium('cpc')
.campaign('spring_sale_2026')
.content('blue_banner')
.build();
// Result: https://example.com/landing?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale_2026&utm_content=blue_bannerStep 2: Client-Side Tracking
GA4 Enhanced Configuration
code
<!-- Google Tag (gtag.js) with Attribution Enhancements -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
// Enhanced configuration
gtag('config', 'G-XXXXXXXXXX', {
// User identification
'user_id': getUserIdIfLoggedIn(),
// Session configuration
'session_engagement_time_msec': 10000,
// Attribution settings
'allow_google_signals': true,
'allow_ad_personalization_signals': true,
// Enhanced conversions
'enhanced_conversions': true,
// Custom dimensions for attribution
'custom_map': {
'dimension1': 'client_id',
'dimension2': 'session_id',
'dimension3': 'first_touch_channel'
},
// Link domains for cross-domain
'linker': {
'domains': ['example.com', 'shop.example.com', 'app.example.com']
}
});
// Set user data for enhanced matching
gtag('set', 'user_data', {
'email': hashSHA256(getUserEmail()),
'phone_number': hashSHA256(getUserPhone())
});
function getUserIdIfLoggedIn() {
// Return user ID if authenticated, undefined otherwise
return window.__USER_ID__ || undefined;
}
function hashSHA256(value) {
// Implement SHA256 hashing
if (!value) return undefined;
// Use Web Crypto API or library
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value.toLowerCase().trim()));
}
</script>Custom Touchpoint Tracking
code
// Comprehensive Touchpoint Tracker
class TouchpointTracker {
constructor(config) {
this.config = {
cookieName: '_pxl_touchpoints',
cookieDomain: config.cookieDomain || window.location.hostname,
maxTouchpoints: config.maxTouchpoints || 50,
lookbackDays: config.lookbackDays || 30,
serverEndpoint: config.serverEndpoint || '/api/touchpoints'
};
this.init();
}
init() {
// Capture current touchpoint
this.captureCurrentTouchpoint();
// Set up event listeners
this.setupEventListeners();
}
captureCurrentTouchpoint() {
const touchpoint = this.buildTouchpoint();
// Skip if same as last touchpoint (within session)
if (this.isDuplicateTouchpoint(touchpoint)) {
return;
}
// Store locally
this.storeTouchpoint(touchpoint);
// Send to server
this.sendToServer(touchpoint);
// Push to dataLayer for GTM
this.pushToDataLayer(touchpoint);
}
buildTouchpoint() {
const params = new URLSearchParams(window.location.search);
return {
id: this.generateId(),
timestamp: Date.now(),
session_id: this.getSessionId(),
client_id: this.getClientId(),
user_id: this.getUserId(),
// Source identification
channel: this.identifyChannel(params),
source: params.get('utm_source') || this.inferSource(),
medium: params.get('utm_medium') || this.inferMedium(),
campaign: params.get('utm_campaign'),
content: params.get('utm_content'),
term: params.get('utm_term'),
// Click IDs
gclid: params.get('gclid'),
gbraid: params.get('gbraid'),
wbraid: params.get('wbraid'),
fbclid: params.get('fbclid'),
msclkid: params.get('msclkid'),
ttclid: params.get('ttclid'),
li_fat_id: params.get('li_fat_id'),
// Page data
landing_page: window.location.pathname,
referrer: document.referrer,
page_title: document.title,
// Device data
device_type: this.getDeviceType(),
screen_resolution: `${screen.width}x${screen.height}`,
viewport: `${window.innerWidth}x${window.innerHeight}`,
user_agent: navigator.userAgent,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
}
identifyChannel(params) {
// Priority-based channel identification
// 1. Click IDs (highest priority - definitive paid traffic)
if (params.get('gclid') || params.get('gbraid') || params.get('wbraid')) {
return 'google_ads';
}
if (params.get('fbclid')) return 'meta_ads';
if (params.get('msclkid')) return 'microsoft_ads';
if (params.get('ttclid')) return 'tiktok_ads';
if (params.get('li_fat_id')) return 'linkedin_ads';
// 2. UTM medium (explicit tagging)
const medium = params.get('utm_medium')?.toLowerCase();
if (medium) {
if (['cpc', 'ppc', 'paid', 'paidsearch'].includes(medium)) return 'paid_search';
if (['display', 'banner', 'cpm'].includes(medium)) return 'display';
if (['social', 'paid_social', 'paidsocial'].includes(medium)) return 'paid_social';
if (medium === 'email') return 'email';
if (medium === 'affiliate') return 'affiliate';
if (medium === 'referral') return 'referral';
if (medium === 'organic') return 'organic_search';
}
// 3. Referrer-based identification
const referrer = document.referrer;
if (referrer) {
try {
const refHost = new URL(referrer).hostname.toLowerCase();
// Search engines
if (/google\.(com|[a-z]{2})$/.test(refHost)) return 'organic_search';
if (/bing\.com$/.test(refHost)) return 'organic_search';
if (/yahoo\.com$/.test(refHost)) return 'organic_search';
if (/duckduckgo\.com$/.test(refHost)) return 'organic_search';
if (/baidu\.com$/.test(refHost)) return 'organic_search';
// Social platforms
if (/facebook\.com|fb\.com$/.test(refHost)) return 'organic_social';
if (/instagram\.com$/.test(refHost)) return 'organic_social';
if (/twitter\.com|x\.com$/.test(refHost)) return 'organic_social';
if (/linkedin\.com$/.test(refHost)) return 'organic_social';
if (/tiktok\.com$/.test(refHost)) return 'organic_social';
if (/youtube\.com$/.test(refHost)) return 'organic_video';
if (/reddit\.com$/.test(refHost)) return 'organic_social';
// External referral
if (refHost !== window.location.hostname) {
return 'referral';
}
} catch (e) {
// Invalid referrer URL
}
}
// 4. Default to direct
return 'direct';
}
getSessionId() {
let sessionId = sessionStorage.getItem('_pxl_session');
if (!sessionId) {
sessionId = `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem('_pxl_session', sessionId);
}
return sessionId;
}
getClientId() {
let clientId = localStorage.getItem('_pxl_client');
if (!clientId) {
clientId = `cli_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('_pxl_client', clientId);
}
return clientId;
}
getUserId() {
// Get from your auth system
return window.__AUTH_USER_ID__ || null;
}
getDeviceType() {
const ua = navigator.userAgent;
if (/tablet|ipad|playbook|silk/i.test(ua)) return 'tablet';
if (/mobile|iphone|ipod|android|blackberry|opera mini|iemobile/i.test(ua)) return 'mobile';
return 'desktop';
}
generateId() {
return `tp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
isDuplicateTouchpoint(touchpoint) {
const lastTouchpoint = sessionStorage.getItem('_pxl_last_tp');
if (!lastTouchpoint) return false;
try {
const last = JSON.parse(lastTouchpoint);
// Consider duplicate if same channel within 30 seconds
return last.channel === touchpoint.channel &&
(touchpoint.timestamp - last.timestamp) < 30000;
} catch {
return false;
}
}
storeTouchpoint(touchpoint) {
// Store in cookie for cross-session persistence
let touchpoints = this.getTouchpoints();
// Add new touchpoint
touchpoints.push(touchpoint);
// Limit to max touchpoints
if (touchpoints.length > this.config.maxTouchpoints) {
touchpoints = touchpoints.slice(-this.config.maxTouchpoints);
}
// Remove old touchpoints
const cutoff = Date.now() - (this.config.lookbackDays * 24 * 60 * 60 * 1000);
touchpoints = touchpoints.filter(tp => tp.timestamp >= cutoff);
// Save
this.setCookie(this.config.cookieName, JSON.stringify(touchpoints));
// Store last touchpoint for deduplication
sessionStorage.setItem('_pxl_last_tp', JSON.stringify(touchpoint));
}
getTouchpoints() {
const cookie = this.getCookie(this.config.cookieName);
if (!cookie) return [];
try {
return JSON.parse(cookie);
} catch {
return [];
}
}
async sendToServer(touchpoint) {
try {
await fetch(this.config.serverEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(touchpoint),
keepalive: true // Ensure request completes even if page unloads
});
} catch (error) {
console.error('Failed to send touchpoint:', error);
// Queue for retry
this.queueForRetry(touchpoint);
}
}
pushToDataLayer(touchpoint) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'touchpoint_captured',
touchpoint_channel: touchpoint.channel,
touchpoint_source: touchpoint.source,
touchpoint_medium: touchpoint.medium,
touchpoint_campaign: touchpoint.campaign,
touchpoint_id: touchpoint.id
});
}
// Cookie helpers
setCookie(name, value) {
const expires = new Date(Date.now() + this.config.lookbackDays * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires.toUTCString()};path=/;domain=${this.config.cookieDomain};SameSite=Lax`;
}
getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
}
// Get journey for conversion
getJourney() {
return this.getTouchpoints().sort((a, b) => a.timestamp - b.timestamp);
}
setupEventListeners() {
// Track form submissions
document.addEventListener('submit', (e) => {
if (e.target.tagName === 'FORM') {
this.trackFormSubmission(e.target);
}
});
}
trackFormSubmission(form) {
const formData = {
form_id: form.id,
form_name: form.name || form.getAttribute('data-form-name'),
form_action: form.action
};
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_submission',
...formData,
journey: this.getJourney()
});
}
}
// Initialize
const touchpointTracker = new TouchpointTracker({
cookieDomain: '.example.com',
maxTouchpoints: 50,
lookbackDays: 30,
serverEndpoint: '/api/attribution/touchpoint'
});Step 3: Server-Side Processing
Touchpoint API Endpoint
code
// /api/attribution/touchpoint.ts (Next.js API Route)
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { headers } from 'next/headers';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
interface TouchpointPayload {
id: string;
timestamp: number;
session_id: string;
client_id: string;
user_id?: string;
channel: string;
source?: string;
medium?: string;
campaign?: string;
content?: string;
term?: string;
gclid?: string;
fbclid?: string;
landing_page: string;
referrer?: string;
device_type: string;
}
export async function POST(request: NextRequest) {
try {
const payload: TouchpointPayload = await request.json();
const headersList = headers();
// Enrich with server-side data
const enrichedTouchpoint = {
...payload,
ip_address: headersList.get('x-forwarded-for')?.split(',')[0] || 'unknown',
user_agent: headersList.get('user-agent'),
received_at: new Date().toISOString(),
// Geolocation (if using a geo service)
// geo_country: await getGeoCountry(ip_address),
// geo_region: await getGeoRegion(ip_address),
// geo_city: await getGeoCity(ip_address),
};
// Validate required fields
if (!payload.client_id || !payload.channel) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Store touchpoint
const { error } = await supabase
.from('touchpoints')
.insert(enrichedTouchpoint);
if (error) {
console.error('Failed to store touchpoint:', error);
return NextResponse.json(
{ error: 'Failed to store touchpoint' },
{ status: 500 }
);
}
// Trigger identity resolution (async)
await resolveIdentity(payload.client_id, payload.user_id);
return NextResponse.json({ success: true, id: payload.id });
} catch (error) {
console.error('Touchpoint API error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
async function resolveIdentity(clientId: string, userId?: string) {
// Link client_id to user_id if both present
if (userId) {
await supabase
.from('identity_links')
.upsert({
client_id: clientId,
user_id: userId,
linked_at: new Date().toISOString()
}, {
onConflict: 'client_id,user_id'
});
}
}Conversion Processing
code
// /api/attribution/conversion.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
interface ConversionPayload {
event_name: string;
client_id: string;
user_id?: string;
value: number;
currency: string;
transaction_id?: string;
items?: any[];
}
export async function POST(request: NextRequest) {
try {
const payload: ConversionPayload = await request.json();
// Create conversion record
const conversion = {
id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
event_name: payload.event_name,
client_id: payload.client_id,
user_id: payload.user_id,
value: payload.value,
currency: payload.currency,
transaction_id: payload.transaction_id,
timestamp: new Date().toISOString()
};
// Store conversion
const { error: convError } = await supabase
.from('conversions')
.insert(conversion);
if (convError) throw convError;
// Process attribution
const attribution = await processAttribution(conversion);
return NextResponse.json({
success: true,
conversion_id: conversion.id,
attribution
});
} catch (error) {
console.error('Conversion API error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
async function processAttribution(conversion: any) {
const lookbackMs = 30 * 24 * 60 * 60 * 1000; // 30 days
const lookbackStart = new Date(Date.now() - lookbackMs).toISOString();
// Get all client_ids linked to this user
let clientIds = [conversion.client_id];
if (conversion.user_id) {
const { data: links } = await supabase
.from('identity_links')
.select('client_id')
.eq('user_id', conversion.user_id);
if (links) {
clientIds = [...new Set([...clientIds, ...links.map(l => l.client_id)])];
}
}
// Get touchpoints within lookback window
const { data: touchpoints } = await supabase
.from('touchpoints')
.select('*')
.in('client_id', clientIds)
.gte('timestamp', lookbackStart)
.lte('timestamp', conversion.timestamp)
.order('timestamp', { ascending: true });
if (!touchpoints || touchpoints.length === 0) {
// No touchpoints - attribute to direct
const directAttribution = [{
conversion_id: conversion.id,
channel: 'direct',
source: 'direct',
medium: 'none',
campaign: null,
credit: 1.0,
value: conversion.value,
model: 'position_based'
}];
await supabase.from('attribution_results').insert(directAttribution);
return directAttribution;
}
// Apply position-based attribution
const attribution = calculatePositionBased(touchpoints, conversion);
// Store results
await supabase.from('attribution_results').insert(attribution);
return attribution;
}
function calculatePositionBased(touchpoints: any[], conversion: any) {
const results: any[] = [];
const n = touchpoints.length;
if (n === 1) {
results.push({
conversion_id: conversion.id,
touchpoint_id: touchpoints[0].id,
channel: touchpoints[0].channel,
source: touchpoints[0].source,
medium: touchpoints[0].medium,
campaign: touchpoints[0].campaign,
credit: 1.0,
value: conversion.value,
model: 'position_based'
});
return results;
}
// Calculate credits
const creditMap = new Map();
// First touch: 40%
const first = touchpoints[0];
creditMap.set(first.id, {
touchpoint: first,
credit: 0.4
});
// Last touch: 40%
const last = touchpoints[n - 1];
if (creditMap.has(last.id)) {
creditMap.get(last.id).credit += 0.4;
} else {
creditMap.set(last.id, {
touchpoint: last,
credit: 0.4
});
}
// Middle touches: 20% split
if (n > 2) {
const middleCredit = 0.2 / (n - 2);
for (let i = 1; i < n - 1; i++) {
const mid = touchpoints[i];
if (creditMap.has(mid.id)) {
creditMap.get(mid.id).credit += middleCredit;
} else {
creditMap.set(mid.id, {
touchpoint: mid,
credit: middleCredit
});
}
}
}
// Convert to results
creditMap.forEach(({ touchpoint, credit }) => {
results.push({
conversion_id: conversion.id,
touchpoint_id: touchpoint.id,
channel: touchpoint.channel,
source: touchpoint.source,
medium: touchpoint.medium,
campaign: touchpoint.campaign,
credit: credit,
value: conversion.value * credit,
model: 'position_based'
});
});
return results;
}Step 4: Database Schema
code
-- Supabase/PostgreSQL Schema for Attribution
-- Touchpoints table
CREATE TABLE touchpoints (
id TEXT PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
session_id TEXT NOT NULL,
client_id TEXT NOT NULL,
user_id TEXT,
-- Source identification
channel TEXT NOT NULL,
source TEXT,
medium TEXT,
campaign TEXT,
content TEXT,
term TEXT,
-- Click IDs
gclid TEXT,
gbraid TEXT,
wbraid TEXT,
fbclid TEXT,
msclkid TEXT,
ttclid TEXT,
li_fat_id TEXT,
-- Page data
landing_page TEXT,
referrer TEXT,
page_title TEXT,
-- Device data
device_type TEXT,
screen_resolution TEXT,
viewport TEXT,
user_agent TEXT,
ip_address TEXT,
language TEXT,
timezone TEXT,
-- Geo data
geo_country TEXT,
geo_region TEXT,
geo_city TEXT,
-- Metadata
received_at TIMESTAMPTZ DEFAULT NOW(),
-- Indexes
CONSTRAINT touchpoints_client_id_idx UNIQUE (client_id, timestamp)
);
CREATE INDEX idx_touchpoints_client ON touchpoints(client_id);
CREATE INDEX idx_touchpoints_user ON touchpoints(user_id);
CREATE INDEX idx_touchpoints_timestamp ON touchpoints(timestamp);
CREATE INDEX idx_touchpoints_channel ON touchpoints(channel);
CREATE INDEX idx_touchpoints_campaign ON touchpoints(campaign);
-- Identity links table
CREATE TABLE identity_links (
id SERIAL PRIMARY KEY,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
linked_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(client_id, user_id)
);
CREATE INDEX idx_identity_client ON identity_links(client_id);
CREATE INDEX idx_identity_user ON identity_links(user_id);
-- Conversions table
CREATE TABLE conversions (
id TEXT PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
client_id TEXT NOT NULL,
user_id TEXT,
event_name TEXT NOT NULL,
value DECIMAL(12, 2) NOT NULL DEFAULT 0,
currency TEXT DEFAULT 'USD',
transaction_id TEXT,
metadata JSONB
);
CREATE INDEX idx_conversions_client ON conversions(client_id);
CREATE INDEX idx_conversions_user ON conversions(user_id);
CREATE INDEX idx_conversions_timestamp ON conversions(timestamp);
CREATE INDEX idx_conversions_event ON conversions(event_name);
-- Attribution results table
CREATE TABLE attribution_results (
id SERIAL PRIMARY KEY,
conversion_id TEXT NOT NULL REFERENCES conversions(id),
touchpoint_id TEXT REFERENCES touchpoints(id),
channel TEXT NOT NULL,
source TEXT,
medium TEXT,
campaign TEXT,
credit DECIMAL(5, 4) NOT NULL,
value DECIMAL(12, 2) NOT NULL,
model TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_attribution_conversion ON attribution_results(conversion_id);
CREATE INDEX idx_attribution_channel ON attribution_results(channel);
CREATE INDEX idx_attribution_campaign ON attribution_results(campaign);
CREATE INDEX idx_attribution_model ON attribution_results(model);
-- Marketing spend table (for ROAS calculation)
CREATE TABLE marketing_spend (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
platform TEXT NOT NULL,
channel TEXT NOT NULL,
campaign_id TEXT,
campaign_name TEXT,
spend DECIMAL(12, 2) NOT NULL,
impressions INTEGER,
clicks INTEGER,
currency TEXT DEFAULT 'USD',
UNIQUE(date, platform, campaign_id)
);
CREATE INDEX idx_spend_date ON marketing_spend(date);
CREATE INDEX idx_spend_platform ON marketing_spend(platform);
CREATE INDEX idx_spend_campaign ON marketing_spend(campaign_id);
-- Views for reporting
CREATE OR REPLACE VIEW attribution_summary AS
SELECT
ar.channel,
ar.source,
ar.medium,
ar.campaign,
ar.model,
COUNT(DISTINCT ar.conversion_id) as conversions,
SUM(ar.credit) as attributed_conversions,
SUM(ar.value) as attributed_revenue,
DATE_TRUNC('day', c.timestamp) as date
FROM attribution_results ar
JOIN conversions c ON ar.conversion_id = c.id
GROUP BY ar.channel, ar.source, ar.medium, ar.campaign, ar.model, DATE_TRUNC('day', c.timestamp);Step 5: GTM Implementation
GTM Container Configuration
code
// GTM Data Layer Setup
window.dataLayer = window.dataLayer || [];
// Push page view with attribution data
dataLayer.push({
event: 'page_view',
page_path: window.location.pathname,
page_title: document.title,
// Attribution data
attribution: {
channel: touchpointTracker.identifyChannel(new URLSearchParams(window.location.search)),
source: new URLSearchParams(window.location.search).get('utm_source'),
medium: new URLSearchParams(window.location.search).get('utm_medium'),
campaign: new URLSearchParams(window.location.search).get('utm_campaign'),
gclid: new URLSearchParams(window.location.search).get('gclid'),
fbclid: new URLSearchParams(window.location.search).get('fbclid')
}
});GTM Tags for Attribution
code
{
"name": "GA4 - Attribution Event",
"type": "Google Analytics: GA4 Event",
"parameters": {
"eventName": "touchpoint_captured",
"eventParameters": [
{ "name": "touchpoint_channel", "value": "{{DLV - Attribution Channel}}" },
{ "name": "touchpoint_source", "value": "{{DLV - Attribution Source}}" },
{ "name": "touchpoint_medium", "value": "{{DLV - Attribution Medium}}" },
{ "name": "touchpoint_campaign", "value": "{{DLV - Attribution Campaign}}" }
]
},
"trigger": "All Pages"
}Testing & Validation
Attribution Test Suite
code
// attribution.test.ts
import { describe, it, expect } from 'vitest';
describe('Attribution Implementation', () => {
describe('Channel Identification', () => {
it('identifies Google Ads from gclid', () => {
const params = new URLSearchParams('?gclid=test123');
expect(identifyChannel(params)).toBe('google_ads');
});
it('identifies Meta Ads from fbclid', () => {
const params = new URLSearchParams('?fbclid=test123');
expect(identifyChannel(params)).toBe('meta_ads');
});
it('identifies organic search from referrer', () => {
const params = new URLSearchParams();
const referrer = 'https://www.google.com/search?q=test';
expect(identifyChannel(params, referrer)).toBe('organic_search');
});
it('defaults to direct when no identifiers', () => {
const params = new URLSearchParams();
expect(identifyChannel(params, '')).toBe('direct');
});
});
describe('Position-Based Attribution', () => {
it('gives 100% to single touchpoint', () => {
const touchpoints = [{ channel: 'organic', timestamp: Date.now() }];
const result = positionBasedAttribution(touchpoints, 100);
expect(result.organic.credit).toBe(1.0);
expect(result.organic.value).toBe(100);
});
it('splits 50/50 for two touchpoints', () => {
const touchpoints = [
{ channel: 'display', timestamp: Date.now() - 1000 },
{ channel: 'paid_search', timestamp: Date.now() }
];
const result = positionBasedAttribution(touchpoints, 100);
expect(result.display.credit).toBe(0.5);
expect(result.paid_search.credit).toBe(0.5);
});
it('applies 40/20/40 for multiple touchpoints', () => {
const touchpoints = [
{ channel: 'display', timestamp: Date.now() - 3000 },
{ channel: 'email', timestamp: Date.now() - 2000 },
{ channel: 'organic', timestamp: Date.now() - 1000 },
{ channel: 'paid_search', timestamp: Date.now() }
];
const result = positionBasedAttribution(touchpoints, 100);
expect(result.display.credit).toBeCloseTo(0.4);
expect(result.paid_search.credit).toBeCloseTo(0.4);
expect(result.email.credit).toBeCloseTo(0.1);
expect(result.organic.credit).toBeCloseTo(0.1);
});
});
});Implementation Checklist
code
## Data Collection
□ UTM parameter standards documented
□ Client-side touchpoint tracker deployed
□ All click IDs captured (gclid, fbclid, etc.)
□ Session and client ID generation working
□ Cross-domain tracking configured
## Server-Side
□ Touchpoint API endpoint deployed
□ Conversion API endpoint deployed
□ Database schema created
□ Identity resolution implemented
□ Attribution model applied
## Integration
□ GA4 configured with enhanced settings
□ GTM tags deployed
□ Data layer events firing
□ Server-side events sending
## Validation
□ Test suite passing
□ Real traffic verified
□ Attribution results validated
□ Cross-device journeys workingContinue: Set up Attribution Reporting to visualize your data, or explore Attribution Tools for platform recommendations.