TLDR: 45% of EU users reject analytics cookies. Your attribution models are trained on the other 55%—who behave differently. That's why your ROAS looks great but revenue targets get missed.
Read full summary
Move beyond basic consent rates to understand consent quality, timing effects, and revenue attribution. Learn to build consent-aware attribution models that accurately measure marketing performance in a privacy-first world. This guide covers data-driven attribution, incrementality testing, media mix modeling, conversion modeling with Google Consent Mode v2, and privacy-preserving measurement techniques with complete TypeScript implementations.
*Summary by Claude AI*
## The €4.2 Million Attribution Mistake
A European online retailer's marketing team was confident in their attribution model. Google Ads showed a 380% ROAS. Meta reported 420% ROAS. The CMO approved a €1.2 million increase in digital ad spend.
Six months later, revenue had grown 8%—not the projected 35%. The CFO demanded an explanation. The marketing team discovered the problem: 47% of their German and French customers rejected analytics cookies. Their attribution models were trained entirely on the consenting 53%.
Here's what they missed: privacy-conscious users who reject cookies tend to be higher-value customers. They research more, compare prices less, and have higher lifetime value. But they were invisible to the attribution model. The model over-credited low-funnel campaigns that reached easy-to-track users while ignoring the brand awareness that influenced the invisible majority.
## How does consent affect marketing attribution?
When users deny consent for analytics cookies, traditional marketing attribution models (like last-click, first-click, or linear) become fundamentally broken. These models rely on tracking the complete user journey from first touchpoint to conversion—without cookies, you're flying blind.
The impact is significant: studies show that 30-50% of users in Europe reject analytics cookies. This means your marketing team might be making million-dollar decisions based on data that represents only half your actual customer base. Even worse, the users who accept tracking often have different behaviors than those who don't, creating systematic bias in your attribution data.
This comprehensive guide explores how to build consent-aware attribution systems that maintain measurement accuracy while respecting user privacy choices. We'll cover advanced attribution models, Google Consent Mode v2 integration, privacy-preserving measurement techniques, and practical TypeScript implementations you can deploy today.
## Why Traditional Attribution Fails in a Consent-First World
Before diving into solutions, let's understand exactly why traditional attribution breaks down when consent enters the picture.
### The Consent Gap Problem
Traditional attribution assumes you can track every touchpoint in the customer journey:
```typescript
// What traditional attribution expects
interface IdealUserJourney {
touchpoints: Array<{
channel: string;
timestamp: Date;
campaign: string;
userId: string; // Same user tracked across all touchpoints
}>;
conversion: {
value: number;
timestamp: Date;
userId: string; // Same user, linked to journey
};
}
// What you actually get with consent
interface RealityUserJourney {
// User visits via Google Ad - consented to tracking
touchpoint1?: { channel: 'paid_search'; userId: 'abc123' };
// Same user visits via email - different session, declined cookies
touchpoint2?: { channel: 'email'; userId: undefined }; // Lost!
// Same user converts - consented this time
conversion: { value: 500; userId: 'xyz789' }; // Different ID!
}
```
The result? Your attribution model might credit the Google Ad with 100% of the conversion (last identifiable touch), completely missing the email campaign that actually drove the decision.
### Consent Bias in Your Data
Users who accept cookies behave differently from those who decline:
```typescript
interface ConsentBiasAnalysis {
// Observed patterns from industry research
consentingUsers: {
averageSessionDuration: number; // Typically 20-30% longer
pagesPerSession: number; // More pages viewed
returnVisitRate: number; // Higher return rates
conversionRate: number; // Often higher conversion
demographicSkew: 'older' | 'mixed'; // Skews older in some markets
};
nonConsentingUsers: {
averageSessionDuration: number;
pagesPerSession: number;
returnVisitRate: number;
conversionRate: number;
demographicSkew: 'younger' | 'privacy-conscious';
};
}
// This means your "data-driven" decisions are based on
// a systematically biased sample of your actual customers
```
## Building a Consent-Aware Attribution System
Let's build a comprehensive attribution system that accounts for consent status and models missing data accurately.
### Core Attribution Engine
```typescript
import { EventEmitter } from 'events';
interface TouchpointData {
id: string;
sessionId: string;
channel: string;
source: string;
medium: string;
campaign?: string;
content?: string;
keyword?: string;
timestamp: Date;
consentStatus: ConsentStatus;
deviceFingerprint?: string; // Privacy-safe device hints
geoRegion: string;
}
interface ConsentStatus {
analytics: boolean;
marketing: boolean;
timestamp: Date;
method: 'explicit' | 'implicit' | 'modeled';
}
interface ConversionData {
id: string;
sessionId: string;
value: number;
currency: string;
product?: string;
category?: string;
timestamp: Date;
consentStatus: ConsentStatus;
}
interface AttributionResult {
conversionId: string;
totalValue: number;
attributedTouchpoints: Array<{
touchpointId: string;
channel: string;
credit: number;
creditPercentage: number;
confidence: number;
modelType: string;
}>;
modeledData: {
estimatedMissingTouchpoints: number;
consentAdjustmentFactor: number;
confidenceInterval: [number, number];
};
metadata: {
model: string;
computedAt: Date;
dataCompleteness: number;
};
}
class ConsentAwareAttributionEngine extends EventEmitter {
private touchpointStore: Map = new Map();
private conversionStore: Map = new Map();
private consentRatesByChannel: Map = new Map();
private channelPerformanceHistory: Map = new Map();
constructor(
private config: AttributionConfig
) {
super();
this.initializeHistoricalData();
}
private async initializeHistoricalData(): Promise {
// Load historical consent rates by channel
// These inform our modeling of missing touchpoints
const historicalData = await this.loadHistoricalConsentRates();
for (const [channel, rate] of Object.entries(historicalData)) {
this.consentRatesByChannel.set(channel, rate);
}
}
async recordTouchpoint(data: TouchpointData): Promise {
const journeyId = await this.resolveJourneyId(data);
const existing = this.touchpointStore.get(journeyId) || [];
existing.push(data);
this.touchpointStore.set(journeyId, existing);
// Update consent rate statistics for this channel
this.updateConsentRateStats(data.channel, data.consentStatus.analytics);
this.emit('touchpoint:recorded', { journeyId, data });
}
async recordConversion(data: ConversionData): Promise {
const journeyId = await this.resolveJourneyId(data);
this.conversionStore.set(data.id, data);
// Get known touchpoints for this journey
const knownTouchpoints = this.touchpointStore.get(journeyId) || [];
// Apply consent-aware attribution
const result = await this.computeAttribution(
journeyId,
knownTouchpoints,
data
);
this.emit('conversion:attributed', result);
return result;
}
private async computeAttribution(
journeyId: string,
touchpoints: TouchpointData[],
conversion: ConversionData
): Promise {
// Step 1: Estimate missing touchpoints based on consent patterns
const missingTouchpointEstimate = this.estimateMissingTouchpoints(
touchpoints,
conversion
);
// Step 2: Apply the appropriate attribution model
const rawAttribution = await this.applyAttributionModel(
touchpoints,
conversion,
this.config.model
);
// Step 3: Adjust for consent bias
const adjustedAttribution = this.adjustForConsentBias(
rawAttribution,
missingTouchpointEstimate
);
// Step 4: Calculate confidence intervals
const confidence = this.calculateConfidence(
touchpoints.length,
missingTouchpointEstimate.count,
conversion.consentStatus
);
return {
conversionId: conversion.id,
totalValue: conversion.value,
attributedTouchpoints: adjustedAttribution,
modeledData: {
estimatedMissingTouchpoints: missingTouchpointEstimate.count,
consentAdjustmentFactor: missingTouchpointEstimate.adjustmentFactor,
confidenceInterval: confidence.interval
},
metadata: {
model: this.config.model,
computedAt: new Date(),
dataCompleteness: this.calculateDataCompleteness(
touchpoints.length,
missingTouchpointEstimate.count
)
}
};
}
private estimateMissingTouchpoints(
known: TouchpointData[],
conversion: ConversionData
): { count: number; adjustmentFactor: number; byChannel: Map } {
// Calculate expected touchpoints based on:
// 1. Historical average touchpoints per conversion
// 2. Consent rates by channel
// 3. Time between first known touch and conversion
const avgTouchpointsPerConversion = this.config.historicalAvgTouchpoints || 4.5;
const knownCount = known.length;
// Calculate weighted consent rate based on known channels
const channelsInJourney = new Set(known.map(t => t.channel));
let weightedConsentRate = 0;
let totalWeight = 0;
for (const channel of channelsInJourney) {
const rate = this.consentRatesByChannel.get(channel) || 0.5;
const weight = this.channelPerformanceHistory.get(channel)?.touchpointFrequency || 1;
weightedConsentRate += rate * weight;
totalWeight += weight;
}
// Add estimate for channels not in journey (they might be missing due to no consent)
const potentialMissingChannels = this.getPotentialMissingChannels(channelsInJourney);
for (const channel of potentialMissingChannels) {
const rate = this.consentRatesByChannel.get(channel) || 0.5;
const weight = 0.5; // Lower weight for speculative channels
weightedConsentRate += rate * weight;
totalWeight += weight;
}
weightedConsentRate = weightedConsentRate / totalWeight;
// Estimate missing touchpoints
const expectedTotal = avgTouchpointsPerConversion / weightedConsentRate;
const estimatedMissing = Math.max(0, expectedTotal - knownCount);
// Calculate adjustment factor for attribution values
const adjustmentFactor = expectedTotal / Math.max(knownCount, 1);
// Estimate missing touchpoints by channel
const byChannel = new Map();
for (const channel of potentialMissingChannels) {
const channelRate = this.consentRatesByChannel.get(channel) || 0.5;
const channelFreq = this.channelPerformanceHistory.get(channel)?.touchpointFrequency || 0.1;
byChannel.set(channel, estimatedMissing * channelFreq * (1 - channelRate));
}
return { count: estimatedMissing, adjustmentFactor, byChannel };
}
private async applyAttributionModel(
touchpoints: TouchpointData[],
conversion: ConversionData,
model: string
): Promise> {
switch (model) {
case 'data-driven':
return this.dataDrivenAttribution(touchpoints, conversion);
case 'position-based':
return this.positionBasedAttribution(touchpoints, conversion);
case 'time-decay':
return this.timeDecayAttribution(touchpoints, conversion);
case 'linear':
return this.linearAttribution(touchpoints, conversion);
default:
return this.dataDrivenAttribution(touchpoints, conversion);
}
}
private async dataDrivenAttribution(
touchpoints: TouchpointData[],
conversion: ConversionData
): Promise> {
// Data-driven attribution uses machine learning to assign credit
// based on actual contribution to conversions
if (touchpoints.length === 0) {
return [];
}
// Get channel weights from our ML model
const channelWeights = await this.getDataDrivenWeights(
touchpoints.map(t => t.channel)
);
// Calculate position adjustments
const positionMultipliers = touchpoints.map((_, index) => {
// First and last touches get boosted
if (index === 0) return 1.3;
if (index === touchpoints.length - 1) return 1.2;
return 1.0;
});
// Calculate time-decay adjustments
const conversionTime = conversion.timestamp.getTime();
const timeMultipliers = touchpoints.map(t => {
const hoursSinceTouch = (conversionTime - t.timestamp.getTime()) / (1000 * 60 * 60);
// Half-life of 7 days
return Math.pow(0.5, hoursSinceTouch / 168);
});
// Combine all factors
const rawScores = touchpoints.map((t, i) => ({
touchpoint: t,
score: (channelWeights.get(t.channel) || 1) *
positionMultipliers[i] *
timeMultipliers[i]
}));
const totalScore = rawScores.reduce((sum, r) => sum + r.score, 0);
return rawScores.map(({ touchpoint, score }) => ({
touchpointId: touchpoint.id,
channel: touchpoint.channel,
credit: (score / totalScore) * conversion.value,
creditPercentage: (score / totalScore) * 100,
confidence: this.calculateTouchpointConfidence(touchpoint),
modelType: 'data-driven'
}));
}
private timeDecayAttribution(
touchpoints: TouchpointData[],
conversion: ConversionData
): Array<{ touchpointId: string; channel: string; credit: number; creditPercentage: number; confidence: number; modelType: string }> {
if (touchpoints.length === 0) return [];
const conversionTime = conversion.timestamp.getTime();
const halfLifeHours = this.config.timeDecayHalfLife || 168; // 7 days default
const weights = touchpoints.map(t => {
const hoursSinceTouch = (conversionTime - t.timestamp.getTime()) / (1000 * 60 * 60);
return Math.pow(0.5, hoursSinceTouch / halfLifeHours);
});
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
return touchpoints.map((t, i) => ({
touchpointId: t.id,
channel: t.channel,
credit: (weights[i] / totalWeight) * conversion.value,
creditPercentage: (weights[i] / totalWeight) * 100,
confidence: this.calculateTouchpointConfidence(t),
modelType: 'time-decay'
}));
}
private positionBasedAttribution(
touchpoints: TouchpointData[],
conversion: ConversionData
): Array<{ touchpointId: string; channel: string; credit: number; creditPercentage: number; confidence: number; modelType: string }> {
if (touchpoints.length === 0) return [];
const sorted = [...touchpoints].sort((a, b) =>
a.timestamp.getTime() - b.timestamp.getTime()
);
// 40% to first, 40% to last, 20% distributed among middle
const firstWeight = 0.4;
const lastWeight = 0.4;
const middleWeight = 0.2;
return sorted.map((t, i) => {
let weight: number;
if (i === 0) {
weight = firstWeight;
} else if (i === sorted.length - 1) {
weight = sorted.length === 1 ? 1 : lastWeight;
} else {
weight = middleWeight / (sorted.length - 2);
}
return {
touchpointId: t.id,
channel: t.channel,
credit: weight * conversion.value,
creditPercentage: weight * 100,
confidence: this.calculateTouchpointConfidence(t),
modelType: 'position-based'
};
});
}
private linearAttribution(
touchpoints: TouchpointData[],
conversion: ConversionData
): Array<{ touchpointId: string; channel: string; credit: number; creditPercentage: number; confidence: number; modelType: string }> {
if (touchpoints.length === 0) return [];
const equalWeight = 1 / touchpoints.length;
return touchpoints.map(t => ({
touchpointId: t.id,
channel: t.channel,
credit: equalWeight * conversion.value,
creditPercentage: equalWeight * 100,
confidence: this.calculateTouchpointConfidence(t),
modelType: 'linear'
}));
}
private adjustForConsentBias(
rawAttribution: Array<{ touchpointId: string; channel: string; credit: number; creditPercentage: number; confidence: number; modelType: string }>,
missingEstimate: { count: number; adjustmentFactor: number; byChannel: Map }
): Array<{ touchpointId: string; channel: string; credit: number; creditPercentage: number; confidence: number; modelType: string }> {
// Adjust credits based on estimated missing touchpoints
// Channels with lower consent rates get boosted
return rawAttribution.map(attr => {
const channelConsentRate = this.consentRatesByChannel.get(attr.channel) || 0.5;
// Channels with lower consent rates are likely underrepresented
// Boost their attribution proportionally
const boostFactor = 1 + ((1 - channelConsentRate) * 0.5);
// Cap the boost to prevent runaway adjustments
const cappedBoost = Math.min(boostFactor, 1.5);
return {
...attr,
credit: attr.credit * cappedBoost,
creditPercentage: attr.creditPercentage * cappedBoost,
confidence: attr.confidence * channelConsentRate // Lower confidence for low-consent channels
};
});
}
private calculateConfidence(
knownTouchpoints: number,
estimatedMissing: number,
conversionConsent: ConsentStatus
): { score: number; interval: [number, number] } {
// Base confidence on data completeness
const dataCompleteness = knownTouchpoints / (knownTouchpoints + estimatedMissing);
// Adjust for consent quality
const consentQuality = conversionConsent.method === 'explicit' ? 1.0 :
conversionConsent.method === 'implicit' ? 0.8 : 0.6;
const score = dataCompleteness * consentQuality;
// Calculate confidence interval (95%)
const standardError = Math.sqrt((1 - score) / Math.max(knownTouchpoints, 1));
const interval: [number, number] = [
Math.max(0, score - 1.96 * standardError),
Math.min(1, score + 1.96 * standardError)
];
return { score, interval };
}
private calculateTouchpointConfidence(touchpoint: TouchpointData): number {
let confidence = 1.0;
// Reduce confidence for modeled consent
if (touchpoint.consentStatus.method === 'modeled') {
confidence *= 0.7;
}
// Reduce confidence for implicit consent
if (touchpoint.consentStatus.method === 'implicit') {
confidence *= 0.85;
}
// Reduce confidence if analytics consent was denied
if (!touchpoint.consentStatus.analytics) {
confidence *= 0.5;
}
return confidence;
}
private async getDataDrivenWeights(channels: string[]): Promise> {
// In production, this would call your ML model
// Here we use historical performance data
const weights = new Map();
for (const channel of channels) {
const metrics = this.channelPerformanceHistory.get(channel);
if (metrics) {
// Weight based on conversion rate and average order value
weights.set(channel, metrics.conversionRate * metrics.avgOrderValue);
} else {
weights.set(channel, 1);
}
}
return weights;
}
private calculateDataCompleteness(known: number, estimated: number): number {
return known / (known + estimated);
}
private async resolveJourneyId(data: TouchpointData | ConversionData): Promise {
// Implement your identity resolution logic
// This might use first-party IDs, device fingerprinting, or probabilistic matching
return data.sessionId;
}
private updateConsentRateStats(channel: string, consented: boolean): void {
// Exponential moving average of consent rates
const currentRate = this.consentRatesByChannel.get(channel) || 0.5;
const alpha = 0.1; // Smoothing factor
const newRate = alpha * (consented ? 1 : 0) + (1 - alpha) * currentRate;
this.consentRatesByChannel.set(channel, newRate);
}
private getPotentialMissingChannels(known: Set): string[] {
const allChannels = ['organic_search', 'paid_search', 'social', 'email', 'display', 'referral', 'direct'];
return allChannels.filter(c => !known.has(c));
}
private async loadHistoricalConsentRates(): Promise> {
// Load from database in production
return {
organic_search: 0.65,
paid_search: 0.58,
social: 0.45,
email: 0.72,
display: 0.35,
referral: 0.60,
direct: 0.70
};
}
}
interface AttributionConfig {
model: 'data-driven' | 'position-based' | 'time-decay' | 'linear';
historicalAvgTouchpoints?: number;
timeDecayHalfLife?: number;
}
interface ChannelMetrics {
touchpointFrequency: number;
conversionRate: number;
avgOrderValue: number;
}
```
## Google Consent Mode v2 Integration
Google's Consent Mode v2 is essential for maintaining measurement in a consent-first world. It uses machine learning to model conversions for users who didn't consent to tracking.
### Implementing Consent Mode v2
```typescript
interface GoogleConsentState {
ad_storage: 'granted' | 'denied';
ad_user_data: 'granted' | 'denied';
ad_personalization: 'granted' | 'denied';
analytics_storage: 'granted' | 'denied';
functionality_storage?: 'granted' | 'denied';
personalization_storage?: 'granted' | 'denied';
security_storage?: 'granted' | 'denied';
}
interface ConsentModeConfig {
defaultState: GoogleConsentState;
waitForUpdate: number; // milliseconds
urlPassthrough: boolean;
adsDataRedaction: boolean;
regions?: Array<{
region: string[];
state: Partial;
}>;
}
class GoogleConsentModeManager {
private currentState: GoogleConsentState;
private initialized: boolean = false;
constructor(private config: ConsentModeConfig) {
this.currentState = config.defaultState;
}
initialize(): void {
// Initialize gtag with consent defaults
this.pushToDataLayer('consent', 'default', {
...this.config.defaultState,
wait_for_update: this.config.waitForUpdate
});
// Set URL passthrough for better attribution without cookies
if (this.config.urlPassthrough) {
this.pushToDataLayer('set', 'url_passthrough', true);
}
// Enable ads data redaction when consent denied
if (this.config.adsDataRedaction) {
this.pushToDataLayer('set', 'ads_data_redaction', true);
}
// Apply regional defaults
if (this.config.regions) {
for (const regional of this.config.regions) {
this.pushToDataLayer('consent', 'default', {
...regional.state,
region: regional.region
});
}
}
this.initialized = true;
}
updateConsent(newState: Partial): void {
if (!this.initialized) {
console.warn('Consent Mode not initialized');
return;
}
// Update internal state
this.currentState = { ...this.currentState, ...newState };
// Push update to Google
this.pushToDataLayer('consent', 'update', newState);
// Log for debugging and compliance
this.logConsentUpdate(newState);
}
mapCMPConsentToGoogleConsent(cmpConsent: CMPConsentState): GoogleConsentState {
// Map your CMP's consent categories to Google's consent types
return {
ad_storage: cmpConsent.advertising ? 'granted' : 'denied',
ad_user_data: cmpConsent.advertising ? 'granted' : 'denied',
ad_personalization: cmpConsent.advertising ? 'granted' : 'denied',
analytics_storage: cmpConsent.analytics ? 'granted' : 'denied',
functionality_storage: cmpConsent.functional ? 'granted' : 'denied',
personalization_storage: cmpConsent.personalization ? 'granted' : 'denied',
security_storage: 'granted' // Usually always granted for security
};
}
handleCMPConsentChange(cmpConsent: CMPConsentState): void {
const googleConsent = this.mapCMPConsentToGoogleConsent(cmpConsent);
this.updateConsent(googleConsent);
}
getConsentModeStatus(): ConsentModeStatus {
return {
initialized: this.initialized,
currentState: this.currentState,
isAdvancedMode: this.isAdvancedMode(),
conversionModelingEligible: this.isConversionModelingEligible()
};
}
private isAdvancedMode(): boolean {
// Advanced mode: sends cookieless pings even when consent denied
// This enables conversion modeling
return this.config.defaultState.analytics_storage === 'denied' ||
this.config.defaultState.ad_storage === 'denied';
}
private isConversionModelingEligible(): boolean {
// Google requires minimum thresholds for conversion modeling:
// - At least 1,000 daily users with ad_storage=granted
// - At least 7 days of data
// This is a placeholder - actual check requires Google's API
return true;
}
private pushToDataLayer(...args: any[]): void {
// @ts-ignore
window.dataLayer = window.dataLayer || [];
// @ts-ignore
window.dataLayer.push(arguments);
}
private logConsentUpdate(state: Partial): void {
console.log('[Consent Mode] Updated:', state, 'at', new Date().toISOString());
}
}
interface CMPConsentState {
necessary: boolean;
functional: boolean;
analytics: boolean;
advertising: boolean;
personalization: boolean;
}
interface ConsentModeStatus {
initialized: boolean;
currentState: GoogleConsentState;
isAdvancedMode: boolean;
conversionModelingEligible: boolean;
}
// Example implementation with your CMP
class ConsentModeIntegration {
private googleConsentMode: GoogleConsentModeManager;
constructor() {
// Initialize with privacy-first defaults
this.googleConsentMode = new GoogleConsentModeManager({
defaultState: {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'granted',
personalization_storage: 'denied',
security_storage: 'granted'
},
waitForUpdate: 500,
urlPassthrough: true,
adsDataRedaction: true,
regions: [
// California - stricter defaults
{
region: ['US-CA'],
state: { ad_storage: 'denied', analytics_storage: 'denied' }
},
// Rest of US - can be more permissive
{
region: ['US'],
state: { analytics_storage: 'granted' }
}
]
});
}
setup(): void {
// Initialize before any Google tags load
this.googleConsentMode.initialize();
// Listen for CMP consent changes
document.addEventListener('cmp:consent-update', (event: CustomEvent) => {
this.googleConsentMode.handleCMPConsentChange(event.detail);
});
}
}
```
## Media Mix Modeling for Privacy-First Attribution
Media Mix Modeling (MMM) provides aggregate-level attribution that doesn't rely on user-level tracking—perfect for a consent-constrained world.
### Building a Simple MMM
```typescript
import * as tf from '@tensorflow/tfjs';
interface MMMDataPoint {
date: Date;
sales: number;
channels: {
tvSpend: number;
radioSpend: number;
digitalSpend: number;
printSpend: number;
socialSpend: number;
searchSpend: number;
emailVolume: number;
};
externalFactors: {
seasonalityIndex: number;
competitorActivity: number;
economicIndex: number;
weatherIndex?: number;
};
}
interface MMMResult {
channelContributions: Map;
roi: Map;
saturationCurves: Map;
baselineRevenue: number;
modelFit: {
rSquared: number;
mape: number;
predictions: number[];
};
}
interface SaturationCurve {
halfSaturation: number; // Spend level at 50% effectiveness
maxEffect: number; // Maximum incremental effect
currentEfficiency: number; // Current marginal efficiency
}
class MediaMixModel {
private model: tf.LayersModel | null = null;
private channelNames: string[] = [];
private trainedCoefficients: Map = new Map();
async buildAndTrainModel(data: MMMDataPoint[]): Promise {
// Prepare features
const { features, labels, channelNames } = this.prepareData(data);
this.channelNames = channelNames;
// Apply adstock transformation (carryover effect)
const adstockedFeatures = this.applyAdstock(features, channelNames);
// Apply saturation transformation
const saturatedFeatures = this.applySaturation(adstockedFeatures, channelNames);
// Build regression model
this.model = this.buildModel(saturatedFeatures[0].length);
// Train
const featureTensor = tf.tensor2d(saturatedFeatures);
const labelTensor = tf.tensor2d(labels.map(l => [l]));
await this.model.fit(featureTensor, labelTensor, {
epochs: 500,
batchSize: 32,
validationSplit: 0.2,
callbacks: {
onEpochEnd: (epoch, logs) => {
if (epoch % 100 === 0) {
console.log(`Epoch ${epoch}: loss = ${logs?.loss?.toFixed(4)}`);
}
}
}
});
// Extract results
return this.extractResults(data, saturatedFeatures, labels);
}
private prepareData(data: MMMDataPoint[]): {
features: number[][];
labels: number[];
channelNames: string[];
} {
const channelNames = Object.keys(data[0].channels);
const externalNames = Object.keys(data[0].externalFactors);
const features = data.map(d => [
...Object.values(d.channels),
...Object.values(d.externalFactors)
]);
const labels = data.map(d => d.sales);
return { features, labels, channelNames: [...channelNames, ...externalNames] };
}
private applyAdstock(
features: number[][],
channelNames: string[]
): number[][] {
// Adstock models the carryover effect of advertising
// Ad effects don't disappear immediately - they decay over time
const decayRates: Record = {
tvSpend: 0.7, // TV has long carryover
radioSpend: 0.5,
digitalSpend: 0.3, // Digital decays faster
printSpend: 0.6,
socialSpend: 0.3,
searchSpend: 0.2, // Search intent is immediate
emailVolume: 0.4,
seasonalityIndex: 0,
competitorActivity: 0,
economicIndex: 0,
weatherIndex: 0
};
const adstocked = features.map((row, t) => {
return row.map((value, i) => {
const channelName = channelNames[i];
const decay = decayRates[channelName] || 0;
if (decay === 0 || t === 0) return value;
// Geometric adstock: current + decay * previous_adstock
let adstockValue = value;
for (let lag = 1; lag <= Math.min(t, 8); lag++) {
adstockValue += Math.pow(decay, lag) * features[t - lag][i];
}
return adstockValue;
});
});
return adstocked;
}
private applySaturation(
features: number[][],
channelNames: string[]
): number[][] {
// Saturation models diminishing returns
// More spend → less incremental effect
// Hill function parameters (would be learned in production)
const halfSaturation: Record = {
tvSpend: 500000,
radioSpend: 100000,
digitalSpend: 200000,
printSpend: 150000,
socialSpend: 100000,
searchSpend: 150000,
emailVolume: 50000
};
return features.map(row => {
return row.map((value, i) => {
const channelName = channelNames[i];
const k = halfSaturation[channelName];
if (!k) return value; // No saturation for non-channel features
// Hill function: x / (x + k)
return value / (value + k);
});
});
}
private buildModel(inputDim: number): tf.LayersModel {
const model = tf.sequential();
// Simple linear regression with regularization
model.add(tf.layers.dense({
units: 1,
inputShape: [inputDim],
kernelRegularizer: tf.regularizers.l2({ l2: 0.01 }),
useBias: true
}));
model.compile({
optimizer: tf.train.adam(0.01),
loss: 'meanSquaredError',
metrics: ['mae']
});
return model;
}
private async extractResults(
originalData: MMMDataPoint[],
features: number[][],
actuals: number[]
): Promise {
if (!this.model) throw new Error('Model not trained');
// Get model coefficients
const weights = this.model.getWeights()[0];
const bias = this.model.getWeights()[1];
const coefficients = await weights.array() as number[][];
const baselineValue = (await bias.array() as number[])[0];
// Calculate channel contributions
const contributions = new Map();
const roi = new Map();
for (let i = 0; i < this.channelNames.length; i++) {
const channelName = this.channelNames[i];
const coefficient = coefficients[i][0];
// Sum contribution across all time periods
const totalContribution = features.reduce((sum, row) => {
return sum + row[i] * coefficient;
}, 0);
contributions.set(channelName, totalContribution);
// Calculate ROI (for spend channels)
if (channelName.includes('Spend')) {
const totalSpend = originalData.reduce((sum, d) => {
return sum + (d.channels as any)[channelName];
}, 0);
roi.set(channelName, totalContribution / totalSpend);
}
}
// Calculate predictions and model fit
const predictions = await this.predict(features);
const rSquared = this.calculateRSquared(actuals, predictions);
const mape = this.calculateMAPE(actuals, predictions);
// Calculate saturation curves
const saturationCurves = this.calculateSaturationCurves(originalData);
return {
channelContributions: contributions,
roi,
saturationCurves,
baselineRevenue: baselineValue,
modelFit: {
rSquared,
mape,
predictions
}
};
}
private async predict(features: number[][]): Promise {
if (!this.model) throw new Error('Model not trained');
const featureTensor = tf.tensor2d(features);
const predictions = this.model.predict(featureTensor) as tf.Tensor;
const predArray = await predictions.array() as number[][];
return predArray.map(p => p[0]);
}
private calculateRSquared(actuals: number[], predictions: number[]): number {
const mean = actuals.reduce((a, b) => a + b, 0) / actuals.length;
const ssTotal = actuals.reduce((sum, y) => sum + Math.pow(y - mean, 2), 0);
const ssResidual = actuals.reduce((sum, y, i) =>
sum + Math.pow(y - predictions[i], 2), 0
);
return 1 - (ssResidual / ssTotal);
}
private calculateMAPE(actuals: number[], predictions: number[]): number {
const ape = actuals.map((actual, i) =>
Math.abs((actual - predictions[i]) / actual)
);
return (ape.reduce((a, b) => a + b, 0) / ape.length) * 100;
}
private calculateSaturationCurves(data: MMMDataPoint[]): Map {
const curves = new Map();
// Simplified saturation curve estimation
const channels = ['tvSpend', 'digitalSpend', 'socialSpend', 'searchSpend'];
for (const channel of channels) {
const spends = data.map(d => (d.channels as any)[channel]);
const maxSpend = Math.max(...spends);
const avgSpend = spends.reduce((a, b) => a + b, 0) / spends.length;
curves.set(channel, {
halfSaturation: maxSpend * 0.3, // Simplified
maxEffect: maxSpend * 2,
currentEfficiency: avgSpend < maxSpend * 0.3 ? 0.8 : 0.4
});
}
return curves;
}
generateOptimizationRecommendations(
result: MMMResult,
budget: number
): BudgetOptimization {
const recommendations: BudgetOptimization = {
currentAllocation: new Map(),
recommendedAllocation: new Map(),
expectedLift: 0,
insights: []
};
// Find underinvested and overinvested channels
for (const [channel, roiValue] of result.roi.entries()) {
const curve = result.saturationCurves.get(channel);
if (curve && curve.currentEfficiency > 0.6) {
recommendations.insights.push({
channel,
type: 'underinvested',
message: `${channel} shows high marginal efficiency (${(curve.currentEfficiency * 100).toFixed(0)}%). Consider increasing spend.`,
recommendedChange: '+20%'
});
} else if (curve && curve.currentEfficiency < 0.3) {
recommendations.insights.push({
channel,
type: 'overinvested',
message: `${channel} shows diminishing returns (${(curve.currentEfficiency * 100).toFixed(0)}% efficiency). Consider reallocating budget.`,
recommendedChange: '-15%'
});
}
}
return recommendations;
}
}
interface BudgetOptimization {
currentAllocation: Map;
recommendedAllocation: Map;
expectedLift: number;
insights: Array<{
channel: string;
type: 'underinvested' | 'overinvested' | 'optimal';
message: string;
recommendedChange: string;
}>;
}
```
## Incrementality Testing Framework
Incrementality testing measures the true causal impact of your marketing by comparing test and control groups—no cookies required.
```typescript
interface IncrementalityTestConfig {
testName: string;
channel: string;
testDuration: number; // days
controlGroupSize: number; // percentage
minimumDetectableEffect: number; // percentage lift
confidenceLevel: number; // e.g., 0.95
}
interface IncrementalityTestResult {
testName: string;
status: 'running' | 'complete' | 'inconclusive';
metrics: {
testGroupConversions: number;
controlGroupConversions: number;
testGroupSize: number;
controlGroupSize: number;
incrementalLift: number;
incrementalLiftPercentage: number;
statisticalSignificance: number;
confidenceInterval: [number, number];
};
recommendation: string;
}
class IncrementalityTestingEngine {
private activeTests: Map = new Map();
async createGeoTest(config: IncrementalityTestConfig): Promise {
// Geo-based incrementality test
// Split by geographic regions, not individual users
const test = new GeoIncrementalityTest({
...config,
testRegions: await this.selectTestRegions(config.controlGroupSize),
controlRegions: await this.selectControlRegions(config.controlGroupSize)
});
this.activeTests.set(config.testName, test);
return test;
}
async createTimeSplitTest(config: IncrementalityTestConfig): Promise {
// Time-based incrementality test
// Compare same region with/without marketing exposure
const test = new TimeSplitIncrementalityTest({
...config,
baselinePeriod: this.calculateBaselinePeriod(config.testDuration),
testPeriod: this.calculateTestPeriod(config.testDuration)
});
this.activeTests.set(config.testName, test);
return test;
}
async createConversionLiftTest(config: IncrementalityTestConfig): Promise {
// Platform-native conversion lift test
// Works with Meta, Google, TikTok conversion lift APIs
const test = new PlatformConversionLiftTest(config);
this.activeTests.set(config.testName, test);
return test;
}
private async selectTestRegions(targetPercentage: number): Promise {
// Select statistically similar regions for test group
// Based on historical conversion patterns, demographics, seasonality
return ['region_1', 'region_3', 'region_5']; // Placeholder
}
private async selectControlRegions(targetPercentage: number): Promise {
// Select matched control regions
return ['region_2', 'region_4', 'region_6']; // Placeholder
}
private calculateBaselinePeriod(testDays: number): { start: Date; end: Date } {
const end = new Date();
end.setDate(end.getDate() - testDays);
const start = new Date(end);
start.setDate(start.getDate() - testDays);
return { start, end };
}
private calculateTestPeriod(testDays: number): { start: Date; end: Date } {
const start = new Date();
const end = new Date();
end.setDate(end.getDate() + testDays);
return { start, end };
}
}
abstract class IncrementalityTest {
protected results: IncrementalityTestResult | null = null;
abstract start(): Promise;
abstract stop(): Promise;
abstract getIntermediateResults(): IncrementalityTestResult;
protected calculateStatisticalSignificance(
testConversions: number,
testSize: number,
controlConversions: number,
controlSize: number
): number {
// Two-proportion z-test
const p1 = testConversions / testSize;
const p2 = controlConversions / controlSize;
const pooledP = (testConversions + controlConversions) / (testSize + controlSize);
const standardError = Math.sqrt(
pooledP * (1 - pooledP) * (1/testSize + 1/controlSize)
);
const zScore = (p1 - p2) / standardError;
// Convert to p-value (two-tailed)
const pValue = 2 * (1 - this.normalCDF(Math.abs(zScore)));
return 1 - pValue; // Return significance level
}
protected calculateConfidenceInterval(
testConversions: number,
testSize: number,
controlConversions: number,
controlSize: number,
confidenceLevel: number
): [number, number] {
const p1 = testConversions / testSize;
const p2 = controlConversions / controlSize;
const diff = p1 - p2;
const standardError = Math.sqrt(
(p1 * (1 - p1) / testSize) + (p2 * (1 - p2) / controlSize)
);
const zValue = this.getZValue(confidenceLevel);
return [
diff - zValue * standardError,
diff + zValue * standardError
];
}
protected calculateMinimumSampleSize(
baselineConversionRate: number,
minimumDetectableEffect: number,
confidenceLevel: number,
power: number = 0.8
): number {
const alpha = 1 - confidenceLevel;
const beta = 1 - power;
const zAlpha = this.getZValue(1 - alpha/2);
const zBeta = this.getZValue(power);
const p1 = baselineConversionRate;
const p2 = baselineConversionRate * (1 + minimumDetectableEffect);
const pooledP = (p1 + p2) / 2;
const n = Math.pow(
zAlpha * Math.sqrt(2 * pooledP * (1 - pooledP)) +
zBeta * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)),
2
) / Math.pow(p2 - p1, 2);
return Math.ceil(n);
}
private normalCDF(x: number): number {
const a1 = 0.254829592;
const a2 = -0.284496736;
const a3 = 1.421413741;
const a4 = -1.453152027;
const a5 = 1.061405429;
const p = 0.3275911;
const sign = x < 0 ? -1 : 1;
x = Math.abs(x) / Math.sqrt(2);
const t = 1.0 / (1.0 + p * x);
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return 0.5 * (1.0 + sign * y);
}
private getZValue(confidenceLevel: number): number {
// Common z-values
const zTable: Record = {
0.90: 1.645,
0.95: 1.96,
0.99: 2.576
};
return zTable[confidenceLevel] || 1.96;
}
}
class GeoIncrementalityTest extends IncrementalityTest {
constructor(private config: IncrementalityTestConfig & {
testRegions: string[];
controlRegions: string[];
}) {
super();
}
async start(): Promise {
// Activate marketing in test regions only
console.log(`Starting geo test: ${this.config.testName}`);
console.log(`Test regions: ${this.config.testRegions.join(', ')}`);
console.log(`Control regions: ${this.config.controlRegions.join(', ')}`);
}
async stop(): Promise {
// Collect final results
return this.getIntermediateResults();
}
getIntermediateResults(): IncrementalityTestResult {
// In production, query your analytics system
const mockResults = {
testGroupConversions: 1250,
controlGroupConversions: 1050,
testGroupSize: 50000,
controlGroupSize: 50000
};
const incrementalLift = mockResults.testGroupConversions - mockResults.controlGroupConversions;
const incrementalLiftPercentage = (incrementalLift / mockResults.controlGroupConversions) * 100;
const significance = this.calculateStatisticalSignificance(
mockResults.testGroupConversions,
mockResults.testGroupSize,
mockResults.controlGroupConversions,
mockResults.controlGroupSize
);
const confidenceInterval = this.calculateConfidenceInterval(
mockResults.testGroupConversions,
mockResults.testGroupSize,
mockResults.controlGroupConversions,
mockResults.controlGroupSize,
this.config.confidenceLevel
);
return {
testName: this.config.testName,
status: significance >= this.config.confidenceLevel ? 'complete' : 'running',
metrics: {
...mockResults,
incrementalLift,
incrementalLiftPercentage,
statisticalSignificance: significance,
confidenceInterval
},
recommendation: significance >= this.config.confidenceLevel
? `${this.config.channel} shows ${incrementalLiftPercentage.toFixed(1)}% incremental lift with ${(significance * 100).toFixed(0)}% confidence.`
: 'Continue test - not yet statistically significant.'
};
}
}
class TimeSplitIncrementalityTest extends IncrementalityTest {
constructor(private config: IncrementalityTestConfig & {
baselinePeriod: { start: Date; end: Date };
testPeriod: { start: Date; end: Date };
}) {
super();
}
async start(): Promise {
console.log(`Starting time-split test: ${this.config.testName}`);
}
async stop(): Promise {
return this.getIntermediateResults();
}
getIntermediateResults(): IncrementalityTestResult {
// Compare baseline period (no marketing) to test period (with marketing)
// Control for seasonality and trends
return {
testName: this.config.testName,
status: 'running',
metrics: {
testGroupConversions: 0,
controlGroupConversions: 0,
testGroupSize: 0,
controlGroupSize: 0,
incrementalLift: 0,
incrementalLiftPercentage: 0,
statisticalSignificance: 0,
confidenceInterval: [0, 0]
},
recommendation: 'Test in progress'
};
}
}
class PlatformConversionLiftTest extends IncrementalityTest {
constructor(private config: IncrementalityTestConfig) {
super();
}
async start(): Promise {
// Integrate with platform APIs (Meta, Google, etc.)
console.log(`Starting platform conversion lift test: ${this.config.testName}`);
}
async stop(): Promise {
return this.getIntermediateResults();
}
getIntermediateResults(): IncrementalityTestResult {
return {
testName: this.config.testName,
status: 'running',
metrics: {
testGroupConversions: 0,
controlGroupConversions: 0,
testGroupSize: 0,
controlGroupSize: 0,
incrementalLift: 0,
incrementalLiftPercentage: 0,
statisticalSignificance: 0,
confidenceInterval: [0, 0]
},
recommendation: 'Test in progress'
};
}
}
```
## Privacy-Preserving Measurement Techniques
Beyond modeling and aggregation, several advanced techniques enable measurement while preserving individual privacy.
### Differential Privacy Implementation
```typescript
interface DifferentialPrivacyConfig {
epsilon: number; // Privacy budget (lower = more private)
delta: number; // Probability of privacy breach
sensitivity: number; // Maximum influence of single record
}
class DifferentialPrivacyEngine {
constructor(private config: DifferentialPrivacyConfig) {}
addLaplaceNoise(trueValue: number): number {
// Laplace mechanism for numeric queries
const scale = this.config.sensitivity / this.config.epsilon;
const noise = this.sampleLaplace(scale);
return trueValue + noise;
}
addGaussianNoise(trueValue: number): number {
// Gaussian mechanism (for (ε,δ)-differential privacy)
const sigma = this.config.sensitivity * Math.sqrt(2 * Math.log(1.25 / this.config.delta)) / this.config.epsilon;
const noise = this.sampleGaussian(0, sigma);
return trueValue + noise;
}
randomizedResponse(trueBit: boolean, probability: number = 0.75): boolean {
// Randomized response for binary data
// With probability p, return true answer; otherwise, flip a coin
if (Math.random() < probability) {
return trueBit;
}
return Math.random() < 0.5;
}
aggregateWithPrivacy>(
records: T[],
aggregations: Array
): Record {
const result: Record = {};
for (const field of aggregations) {
const trueSum = records.reduce((sum, r) => sum + (r[field] as number), 0);
result[field as string] = this.addLaplaceNoise(trueSum);
}
return result as Record;
}
private sampleLaplace(scale: number): number {
const u = Math.random() - 0.5;
return -scale * Math.sign(u) * Math.log(1 - 2 * Math.abs(u));
}
private sampleGaussian(mean: number, stdDev: number): number {
// Box-Muller transform
const u1 = Math.random();
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + stdDev * z;
}
}
// Example: Privacy-preserving conversion reporting
class PrivateConversionReporter {
private dpEngine: DifferentialPrivacyEngine;
constructor() {
this.dpEngine = new DifferentialPrivacyEngine({
epsilon: 1.0, // Moderate privacy
delta: 1e-5,
sensitivity: 1 // One user can only contribute one conversion
});
}
reportConversionsByChannel(
conversions: Array<{ channel: string; value: number }>
): Map {
// Group by channel
const byChannel = new Map();
for (const conv of conversions) {
const existing = byChannel.get(conv.channel) || { count: 0, totalValue: 0 };
byChannel.set(conv.channel, {
count: existing.count + 1,
totalValue: existing.totalValue + conv.value
});
}
// Add noise to each channel's metrics
const privateResults = new Map();
for (const [channel, metrics] of byChannel.entries()) {
privateResults.set(channel, {
count: Math.max(0, Math.round(this.dpEngine.addLaplaceNoise(metrics.count))),
value: Math.max(0, this.dpEngine.addLaplaceNoise(metrics.totalValue))
});
}
return privateResults;
}
}
```
### K-Anonymity for Attribution Reports
```typescript
interface AnonymityConfig {
kThreshold: number; // Minimum group size
suppressionValue: string; // Value for suppressed cells
}
class KAnonymityEngine {
constructor(private config: AnonymityConfig) {}
anonymizeAttributionReport(
data: AttributionReportRow[]
): AttributionReportRow[] {
// Group data and check k-anonymity
const groups = this.groupData(data);
return data.map(row => {
const groupKey = this.getGroupKey(row);
const groupSize = groups.get(groupKey) || 0;
if (groupSize < this.config.kThreshold) {
// Suppress small groups
return this.suppressRow(row);
}
return row;
});
}
private groupData(data: AttributionReportRow[]): Map {
const groups = new Map();
for (const row of data) {
const key = this.getGroupKey(row);
groups.set(key, (groups.get(key) || 0) + 1);
}
return groups;
}
private getGroupKey(row: AttributionReportRow): string {
// Define quasi-identifiers that could identify users
return `${row.channel}|${row.region}|${row.deviceCategory}`;
}
private suppressRow(row: AttributionReportRow): AttributionReportRow {
return {
...row,
conversions: this.config.suppressionValue as any,
revenue: this.config.suppressionValue as any
};
}
generalizeForPrivacy(
data: AttributionReportRow[],
hierarchies: GeneralizationHierarchies
): AttributionReportRow[] {
// Generalize quasi-identifiers until k-anonymity is achieved
let currentData = [...data];
let level = 0;
while (!this.meetsKAnonymity(currentData) && level < 5) {
currentData = this.applyGeneralization(currentData, hierarchies, level);
level++;
}
return currentData;
}
private meetsKAnonymity(data: AttributionReportRow[]): boolean {
const groups = this.groupData(data);
return Array.from(groups.values()).every(size => size >= this.config.kThreshold);
}
private applyGeneralization(
data: AttributionReportRow[],
hierarchies: GeneralizationHierarchies,
level: number
): AttributionReportRow[] {
return data.map(row => ({
...row,
region: hierarchies.region[level]?.(row.region) || row.region,
deviceCategory: hierarchies.device[level]?.(row.deviceCategory) || row.deviceCategory
}));
}
}
interface AttributionReportRow {
channel: string;
region: string;
deviceCategory: string;
conversions: number;
revenue: number;
}
interface GeneralizationHierarchies {
region: Array<(value: string) => string>;
device: Array<(value: string) => string>;
}
// Example hierarchies
const exampleHierarchies: GeneralizationHierarchies = {
region: [
// Level 0: City -> State
(city) => cityToStateMap[city] || city,
// Level 1: State -> Country
(state) => stateToCountryMap[state] || state,
// Level 2: Country -> Continent
(country) => countryToContinentMap[country] || country,
// Level 3: All -> *
() => '*'
],
device: [
// Level 0: Specific model -> Category
(model) => modelToCategoryMap[model] || model,
// Level 1: Category -> *
() => '*'
]
};
const cityToStateMap: Record = {
'San Francisco': 'California',
'Los Angeles': 'California',
'New York': 'New York',
'Chicago': 'Illinois'
};
const stateToCountryMap: Record = {
'California': 'United States',
'New York': 'United States',
'Illinois': 'United States'
};
const countryToContinentMap: Record = {
'United States': 'North America',
'Canada': 'North America',
'Germany': 'Europe'
};
const modelToCategoryMap: Record = {
'iPhone 15': 'Mobile',
'Pixel 8': 'Mobile',
'iPad Pro': 'Tablet',
'MacBook': 'Desktop'
};
```
## Building a Consent Analytics Dashboard
Bring all these concepts together in a unified dashboard that provides actionable insights while respecting privacy.
```typescript
interface ConsentAnalyticsDashboard {
overview: OverviewMetrics;
channelPerformance: ChannelPerformance[];
consentImpact: ConsentImpactAnalysis;
recommendations: Recommendation[];
}
interface OverviewMetrics {
totalConversions: number;
attributedConversions: number;
modeledConversions: number;
overallConfidence: number;
consentRate: number;
dataCompleteness: number;
}
interface ChannelPerformance {
channel: string;
attributedRevenue: number;
modeledRevenue: number;
confidence: number;
consentRate: number;
roi: number;
incrementalLift?: number;
}
interface ConsentImpactAnalysis {
revenueAtRisk: number; // Revenue from low-consent channels
measurementGap: number; // Percentage of unmeasured activity
biasRisk: 'low' | 'medium' | 'high';
recommendations: string[];
}
class ConsentAnalyticsDashboardBuilder {
constructor(
private attributionEngine: ConsentAwareAttributionEngine,
private mmmModel: MediaMixModel,
private incrementalityEngine: IncrementalityTestingEngine,
private privacyEngine: DifferentialPrivacyEngine
) {}
async buildDashboard(
dateRange: { start: Date; end: Date }
): Promise {
// Gather data from all sources
const attributionData = await this.getAttributionData(dateRange);
const mmmResults = await this.getMMMResults(dateRange);
const incrementalityResults = await this.getIncrementalityResults();
// Build overview
const overview = this.buildOverview(attributionData);
// Build channel performance (with privacy)
const channelPerformance = this.buildChannelPerformance(
attributionData,
mmmResults,
incrementalityResults
);
// Analyze consent impact
const consentImpact = this.analyzeConsentImpact(attributionData);
// Generate recommendations
const recommendations = this.generateRecommendations(
overview,
channelPerformance,
consentImpact
);
return {
overview,
channelPerformance,
consentImpact,
recommendations
};
}
private buildOverview(data: AttributionData): OverviewMetrics {
const totalConversions = data.conversions.length;
const attributedConversions = data.conversions.filter(c => c.hasAttribution).length;
const modeledConversions = totalConversions - attributedConversions;
const avgConfidence = data.conversions.reduce((sum, c) =>
sum + c.confidence, 0
) / totalConversions;
const consentedSessions = data.sessions.filter(s => s.consented).length;
const consentRate = consentedSessions / data.sessions.length;
const dataCompleteness = attributedConversions / totalConversions;
return {
totalConversions,
attributedConversions,
modeledConversions,
overallConfidence: avgConfidence,
consentRate,
dataCompleteness
};
}
private buildChannelPerformance(
attribution: AttributionData,
mmm: MMMResult,
incrementality: Map
): ChannelPerformance[] {
const channels = new Set();
// Collect all channels
for (const conv of attribution.conversions) {
for (const tp of conv.touchpoints) {
channels.add(tp.channel);
}
}
return Array.from(channels).map(channel => {
const channelConversions = attribution.conversions.filter(c =>
c.touchpoints.some(tp => tp.channel === channel)
);
const attributedRevenue = channelConversions.reduce((sum, c) => {
const channelCredit = c.attribution.find(a => a.channel === channel);
return sum + (channelCredit?.credit || 0);
}, 0);
const mmmContribution = mmm.channelContributions.get(channel) || 0;
const mmmROI = mmm.roi.get(channel) || 0;
const incrementalityResult = incrementality.get(channel);
const consentedTouchpoints = attribution.touchpoints.filter(
tp => tp.channel === channel && tp.consented
).length;
const totalTouchpoints = attribution.touchpoints.filter(
tp => tp.channel === channel
).length;
return {
channel,
attributedRevenue,
modeledRevenue: mmmContribution,
confidence: this.calculateChannelConfidence(channelConversions),
consentRate: totalTouchpoints > 0 ? consentedTouchpoints / totalTouchpoints : 0,
roi: mmmROI,
incrementalLift: incrementalityResult?.metrics.incrementalLiftPercentage
};
});
}
private analyzeConsentImpact(data: AttributionData): ConsentImpactAnalysis {
// Calculate revenue at risk from low-consent channels
const lowConsentChannels = this.identifyLowConsentChannels(data);
const revenueAtRisk = lowConsentChannels.reduce((sum, channel) => {
return sum + this.getChannelRevenue(data, channel);
}, 0);
// Calculate measurement gap
const unconsentedSessions = data.sessions.filter(s => !s.consented).length;
const measurementGap = unconsentedSessions / data.sessions.length;
// Assess bias risk
const biasRisk = this.assessBiasRisk(data);
// Generate recommendations
const recommendations: string[] = [];
if (measurementGap > 0.4) {
recommendations.push(
'Consider implementing server-side tracking for critical conversions'
);
}
if (biasRisk === 'high') {
recommendations.push(
'High consent bias detected. Recommend running incrementality tests to validate attribution'
);
}
if (lowConsentChannels.length > 0) {
recommendations.push(
`${lowConsentChannels.join(', ')} have low consent rates. Consider privacy-first alternatives`
);
}
return {
revenueAtRisk,
measurementGap: measurementGap * 100,
biasRisk,
recommendations
};
}
private generateRecommendations(
overview: OverviewMetrics,
channels: ChannelPerformance[],
impact: ConsentImpactAnalysis
): Recommendation[] {
const recommendations: Recommendation[] = [];
// Data completeness recommendations
if (overview.dataCompleteness < 0.6) {
recommendations.push({
priority: 'high',
category: 'measurement',
title: 'Improve data completeness',
description: 'Less than 60% of conversions have full attribution. Consider implementing Google Consent Mode v2 for conversion modeling.',
expectedImpact: '+20-30% visibility into conversion paths'
});
}
// Channel-specific recommendations
for (const channel of channels) {
if (channel.consentRate < 0.4 && channel.attributedRevenue > 10000) {
recommendations.push({
priority: 'medium',
category: 'channel',
title: `Validate ${channel.channel} attribution`,
description: `${channel.channel} has low consent rate (${(channel.consentRate * 100).toFixed(0)}%) but significant attributed revenue. Run incrementality test to validate.`,
expectedImpact: 'Accurate ROI measurement for budget allocation'
});
}
if (channel.incrementalLift !== undefined && channel.incrementalLift < 5) {
recommendations.push({
priority: 'high',
category: 'budget',
title: `Review ${channel.channel} spend`,
description: `Incrementality testing shows only ${channel.incrementalLift.toFixed(1)}% lift. Consider reallocating budget.`,
expectedImpact: 'Improved marketing efficiency'
});
}
}
// Privacy compliance recommendations
if (overview.consentRate < 0.5) {
recommendations.push({
priority: 'medium',
category: 'privacy',
title: 'Optimize consent experience',
description: 'Consent rate below 50%. Review consent UX and consider testing different messaging.',
expectedImpact: '+10-15% consent rate improvement'
});
}
return recommendations;
}
private calculateChannelConfidence(conversions: ConversionData[]): number {
if (conversions.length === 0) return 0;
return conversions.reduce((sum, c) => sum + c.confidence, 0) / conversions.length;
}
private identifyLowConsentChannels(data: AttributionData): string[] {
const channelConsent = new Map();
for (const tp of data.touchpoints) {
const existing = channelConsent.get(tp.channel) || { consented: 0, total: 0 };
channelConsent.set(tp.channel, {
consented: existing.consented + (tp.consented ? 1 : 0),
total: existing.total + 1
});
}
return Array.from(channelConsent.entries())
.filter(([_, stats]) => stats.consented / stats.total < 0.4)
.map(([channel]) => channel);
}
private getChannelRevenue(data: AttributionData, channel: string): number {
return data.conversions.reduce((sum, c) => {
const channelCredit = c.attribution.find(a => a.channel === channel);
return sum + (channelCredit?.credit || 0);
}, 0);
}
private assessBiasRisk(data: AttributionData): 'low' | 'medium' | 'high' {
// Compare behavior of consenting vs non-consenting users
const consentedSessions = data.sessions.filter(s => s.consented);
const unconsentedSessions = data.sessions.filter(s => !s.consented);
if (unconsentedSessions.length < 100) return 'low'; // Not enough data
const consentedConvRate = consentedSessions.filter(s => s.converted).length / consentedSessions.length;
const unconsentedConvRate = unconsentedSessions.filter(s => s.converted).length / unconsentedSessions.length;
const ratioDiff = Math.abs(consentedConvRate - unconsentedConvRate) / unconsentedConvRate;
if (ratioDiff > 0.3) return 'high';
if (ratioDiff > 0.15) return 'medium';
return 'low';
}
private async getAttributionData(dateRange: { start: Date; end: Date }): Promise {
// Fetch from your data warehouse
return {
conversions: [],
touchpoints: [],
sessions: []
};
}
private async getMMMResults(dateRange: { start: Date; end: Date }): Promise {
// Get cached MMM results or run new model
return {
channelContributions: new Map(),
roi: new Map(),
saturationCurves: new Map(),
baselineRevenue: 0,
modelFit: { rSquared: 0, mape: 0, predictions: [] }
};
}
private async getIncrementalityResults(): Promise> {
return new Map();
}
}
interface AttributionData {
conversions: Array;
}>;
touchpoints: Array;
sessions: Array<{ id: string; consented: boolean; converted: boolean }>;
}
interface Recommendation {
priority: 'high' | 'medium' | 'low';
category: 'measurement' | 'channel' | 'budget' | 'privacy';
title: string;
description: string;
expectedImpact: string;
}
```
## FAQ
### What's the difference between Consent Mode v1 and v2?
Consent Mode v2 adds two new consent signals: `ad_user_data` and `ad_personalization`. These are required for using customer match audiences and enhanced conversions in the EEA. V2 also has improved conversion modeling algorithms that can fill more data gaps when users decline consent.
### How accurate is conversion modeling?
Google reports that Consent Mode v2 conversion modeling recovers approximately 70% of conversions that would otherwise be lost to consent denial. However, this varies by industry, geography, and your specific consent rates. Always validate with incrementality testing.
### Can I use MMM and MTA together?
Yes! This is actually the recommended approach. MTA (Multi-Touch Attribution) provides granular, near-real-time insights for optimization, while MMM provides the "ground truth" validation and captures offline and non-digital channels. Use MTA for tactical decisions and MMM for strategic budget allocation.
### What privacy budget should I use for differential privacy?
Epsilon values between 0.1 and 1.0 are considered "strong" privacy protection. For marketing analytics where individual-level data isn't sensitive, epsilon of 1-3 is common. For sensitive data like healthcare, use epsilon < 1. Lower epsilon = more privacy but more noise in results.
### How do I handle Safari's ITP and Firefox's ETP?
Both browsers block third-party cookies and limit first-party cookie lifespans. Focus on: (1) server-side tracking for conversion measurement, (2) first-party data collection with explicit consent, (3) Google's Privacy Sandbox APIs when available, and (4) probabilistic attribution for Safari traffic.
## Your Privacy-First Measurement Roadmap
The death of third-party cookies doesn't mean the death of marketing measurement. By implementing consent-aware attribution, leveraging conversion modeling, running incrementality tests, and adopting privacy-preserving techniques, you can maintain accurate measurement while building user trust.
Key actions to take today:
1. **Implement Google Consent Mode v2** if you use Google Analytics or Google Ads
2. **Audit your consent rates** by channel and identify measurement gaps
3. **Run your first incrementality test** on your highest-spend channel
4. **Build a simple MMM** to validate your digital attribution
5. **Start collecting first-party data** with clear value exchanges
The future of marketing measurement is privacy-first, and the companies that adapt now will have a significant competitive advantage.