Scenario
Different channels attract different people — your organic search visitors behave differently than your paid social visitors. You pullsessionSource and sessionMedium crossed with userAgeBracket and userGender, then map each channel-demographic pair to a Mavera persona. The result is a persona library where each entry represents a specific channel-audience intersection, enabling channel-specific messaging.
Architecture
Code
import os, requests, time
from collections import defaultdict
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, Dimension, Metric, DateRange, OrderBy,
)
PROPERTY_ID = os.environ["GA4_PROPERTY_ID"]
MV = os.environ["MAVERA_API_KEY"]
MB = "https://app.mavera.io/api/v1"
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
client = BetaAnalyticsDataClient()
report = client.run_report(RunReportRequest(
property=f"properties/{PROPERTY_ID}",
dimensions=[
Dimension(name="sessionSource"),
Dimension(name="sessionMedium"),
Dimension(name="userAgeBracket"),
Dimension(name="userGender"),
],
metrics=[
Metric(name="totalUsers"),
Metric(name="conversions"),
Metric(name="engagementRate"),
Metric(name="averageSessionDuration"),
],
date_ranges=[DateRange(start_date="30daysAgo", end_date="today")],
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="totalUsers"), desc=True)],
limit=500,
))
channels = defaultdict(lambda: {
"users": 0, "conversions": 0, "engagement_sum": 0, "duration_sum": 0,
"age_dist": defaultdict(int), "gender_dist": defaultdict(int), "count": 0,
})
for row in report.rows:
source = row.dimension_values[0].value
medium = row.dimension_values[1].value
age = row.dimension_values[2].value
gender = row.dimension_values[3].value
users = int(row.metric_values[0].value)
conversions = int(row.metric_values[1].value)
engagement = float(row.metric_values[2].value)
duration = float(row.metric_values[3].value)
if age == "(not set)" or gender == "(not set)":
continue
key = f"{source}/{medium}"
channels[key]["users"] += users
channels[key]["conversions"] += conversions
channels[key]["engagement_sum"] += engagement * users
channels[key]["duration_sum"] += duration * users
channels[key]["age_dist"][age] += users
channels[key]["gender_dist"][gender] += users
channels[key]["count"] += 1
channel_profiles = []
for channel, data in channels.items():
if data["users"] < 50:
continue
top_age = max(data["age_dist"], key=data["age_dist"].get) if data["age_dist"] else "unknown"
top_gender = max(data["gender_dist"], key=data["gender_dist"].get) if data["gender_dist"] else "unknown"
channel_profiles.append({
"channel": channel,
"users": data["users"],
"conversions": data["conversions"],
"avg_engagement": data["engagement_sum"] / max(data["users"], 1),
"avg_duration": data["duration_sum"] / max(data["users"], 1),
"top_age": top_age,
"top_gender": top_gender,
"conv_rate": data["conversions"] / max(data["users"], 1),
"age_dist": dict(data["age_dist"]),
"gender_dist": dict(data["gender_dist"]),
})
channel_profiles.sort(key=lambda c: c["users"], reverse=True)
created = []
for cp in channel_profiles[:8]:
name = f"GA4 Channel: {cp['channel']} ({cp['top_gender']} {cp['top_age']})"
age_breakdown = ", ".join(f"{a}: {n}" for a, n in sorted(cp["age_dist"].items(), key=lambda x: -x[1])[:3])
gender_breakdown = ", ".join(f"{g}: {n}" for g, n in sorted(cp["gender_dist"].items(), key=lambda x: -x[1]))
persona = requests.post(f"{MB}/personas", headers=MH, json={
"name": name,
"description": (
f"Persona from GA4 channel {cp['channel']} (30d). "
f"Primary: {cp['top_gender']} {cp['top_age']}. "
f"Users: {cp['users']}, Conv: {cp['conversions']} ({cp['conv_rate']:.2%}). "
f"Engagement: {cp['avg_engagement']:.0%}. Session: {cp['avg_duration']:.0f}s. "
f"Age mix: {age_breakdown}. Gender: {gender_breakdown}."
),
"demographic": {
"age_range": cp["top_age"],
"gender": cp["top_gender"],
},
"psychographic": {
"acquisition_channel": cp["channel"],
"engagement_level": "high" if cp["avg_engagement"] > 0.6 else "medium",
},
}).json()
created.append({"channel": cp["channel"], "id": persona["id"], "users": cp["users"]})
print(f" {name} → {persona['id']}")
time.sleep(0.3)
print(f"\nMapped {len(created)} channel-persona pairs")
const MV = process.env.MAVERA_API_KEY;
const PROPERTY_ID = process.env.GA4_PROPERTY_ID;
const KEY_FILE = JSON.parse(require("fs").readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, "utf8"));
const MB = "https://app.mavera.io/api/v1";
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const { GoogleAuth } = require("google-auth-library");
const auth = new GoogleAuth({
credentials: KEY_FILE,
scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
});
const accessToken = await auth.getAccessToken();
const gaRes = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${PROPERTY_ID}:runReport`,
{
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
dimensions: [
{ name: "sessionSource" }, { name: "sessionMedium" },
{ name: "userAgeBracket" }, { name: "userGender" },
],
metrics: [
{ name: "totalUsers" }, { name: "conversions" },
{ name: "engagementRate" }, { name: "averageSessionDuration" },
],
dateRanges: [{ startDate: "30daysAgo", endDate: "today" }],
orderBys: [{ metric: { metricName: "totalUsers" }, desc: true }],
limit: 500,
}),
}
).then((r) => r.json());
const channels = {};
for (const row of gaRes.rows || []) {
const source = row.dimensionValues[0].value;
const medium = row.dimensionValues[1].value;
const age = row.dimensionValues[2].value;
const gender = row.dimensionValues[3].value;
const users = parseInt(row.metricValues[0].value);
const conv = parseInt(row.metricValues[1].value);
const eng = parseFloat(row.metricValues[2].value);
const dur = parseFloat(row.metricValues[3].value);
if (age === "(not set)" || gender === "(not set)") continue;
const key = `${source}/${medium}`;
channels[key] ??= { users: 0, conv: 0, engSum: 0, durSum: 0, ageDist: {}, genderDist: {} };
channels[key].users += users;
channels[key].conv += conv;
channels[key].engSum += eng * users;
channels[key].durSum += dur * users;
channels[key].ageDist[age] = (channels[key].ageDist[age] || 0) + users;
channels[key].genderDist[gender] = (channels[key].genderDist[gender] || 0) + users;
}
const profiles = Object.entries(channels)
.filter(([, d]) => d.users >= 50)
.map(([channel, d]) => {
const topAge = Object.entries(d.ageDist).sort(([, a], [, b]) => b - a)[0]?.[0] || "unknown";
const topGender = Object.entries(d.genderDist).sort(([, a], [, b]) => b - a)[0]?.[0] || "unknown";
return {
channel, users: d.users, conv: d.conv, topAge, topGender,
avgEng: d.engSum / (d.users || 1), avgDur: d.durSum / (d.users || 1),
convRate: d.conv / (d.users || 1), ageDist: d.ageDist, genderDist: d.genderDist,
};
})
.sort((a, b) => b.users - a.users);
const created = [];
for (const cp of profiles.slice(0, 8)) {
const name = `GA4 Channel: ${cp.channel} (${cp.topGender} ${cp.topAge})`;
const p = await fetch(`${MB}/personas`, {
method: "POST", headers: MH,
body: JSON.stringify({
name,
description: `Channel ${cp.channel} (30d). ${cp.topGender} ${cp.topAge}. Users: ${cp.users}, Conv: ${cp.conv} (${(cp.convRate * 100).toFixed(2)}%). Eng: ${(cp.avgEng * 100).toFixed(0)}%.`,
demographic: { age_range: cp.topAge, gender: cp.topGender },
psychographic: { acquisition_channel: cp.channel, engagement_level: cp.avgEng > 0.6 ? "high" : "medium" },
}),
}).then((r) => r.json());
created.push({ channel: cp.channel, id: p.id });
console.log(` ${name} → ${p.id}`);
await new Promise((r) => setTimeout(r, 300));
}
console.log(`\nMapped ${created.length} channel-persona pairs`);
Example Output
{
"mapped": 8,
"personas": [
{ "channel": "google/organic", "persona": "GA4 Channel: google/organic (male 25-34)", "id": "per_ch_01", "users": 4200 },
{ "channel": "twitter/social", "persona": "GA4 Channel: twitter/social (male 25-34)", "id": "per_ch_02", "users": 1800 },
{ "channel": "linkedin/social", "persona": "GA4 Channel: linkedin/social (female 35-44)", "id": "per_ch_03", "users": 1200 },
{ "channel": "google/cpc", "persona": "GA4 Channel: google/cpc (male 35-44)", "id": "per_ch_04", "users": 980 },
{ "channel": "(direct)/(none)", "persona": "GA4 Channel: (direct)/(none) (female 25-34)", "id": "per_ch_05", "users": 870 }
]
}
Error Handling
High cardinality warning
High cardinality warning
Crossing 4 dimensions can produce thousands of rows. The code caps at 500 rows and filters out (not set). For large properties, consider running separate reports per channel.
(direct)/(none) traffic
(direct)/(none) traffic
Direct traffic includes bookmarks, typed URLs, and unattributable sources. It often has the largest volume but least actionable demographic data. Consider separating it from other channels.
UTM tagging gaps
UTM tagging gaps
If sources show as
(not set), your campaigns may lack UTM parameters. Add utm_source, utm_medium, and utm_campaign to all marketing links.What’s Next
GA4 Integration
Back to GA4 integration overview
Real-Time Audience → Trending Response
Diagnose traffic spikes in real time
Device Behavior → Creative Format Recommendations
Optimize creative formats per device
Personas API
Full reference for POST /api/v1/personas