Back to Blog
Analytics

Consent-Aware Analytics: Advanced Attribution Models

Michael Chang, Analytics LeadOctober 17, 202514 min read
AttributionAnalyticsMarketingPrivacy

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.
M

Michael Chang, Analytics Lead

Contributing writer at GetCookies, specializing in privacy compliance, consent management, and digital marketing optimization.

Ready to Simplify Cookie Consent?

GetCookies makes GDPR, CCPA, and global privacy compliance effortless. Get started today.