Skip to content

Commit 469039a

Browse files
nattb8claude
andcommitted
feat(audience-sdk): tighten attribution consent tiers — idfa/gaid/installReferrer to Full-only (SDK-331)
idfa, gaid, and installReferrer are cross-app / campaign-source identifiers in the same privacy class as userId. Previously they shipped at Anonymous+Full (CanTrack); this sweep re-tiers them to Full-only (CanIdentify), matching the existing userId gate. State-class properties (attStatus, gaidLimitAdTracking, skanRegistered) are non-identifying and remain at Anonymous+Full unchanged. The install_referrer_received sent-marker is intentionally not written when consent is Anonymous, so a later upgrade to Full can fire the event on the next launch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 396c324 commit 469039a

2 files changed

Lines changed: 134 additions & 9 deletions

File tree

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,10 @@ public static void Init(AudienceConfig config)
254254
// usually still empty when game_launch fires, so we ship a
255255
// dedicated event after Init when the value first becomes
256256
// observable. Idempotent across launches via an on-disk marker.
257-
if (!string.IsNullOrEmpty(installReferrer))
257+
// installReferrer encodes campaign attribution source — same privacy
258+
// class as userId. Only ship at Full; don't write the sent marker
259+
// at Anonymous so a later consent upgrade can fire the event.
260+
if (!string.IsNullOrEmpty(installReferrer) && consentAtInit.CanIdentify())
258261
FireInstallReferrerReceivedOnce(config, installReferrer!);
259262
}
260263

@@ -1130,10 +1133,18 @@ private static void FireGameLaunch(
11301133

11311134
// iOS ATT/IDFA snapshot — merged after Unity context so attribution
11321135
// keys are authoritative if both sources happen to set the same key.
1136+
// idfa and gaid are cross-app device identifiers, same privacy class
1137+
// as userId — gate them at Full-only. State-class keys (attStatus,
1138+
// gaidLimitAdTracking) are non-identifying and ship at Anon+Full.
11331139
if (attributionContext != null)
11341140
{
1141+
var canIdentify = consentAtInit.CanIdentify();
11351142
foreach (var kvp in attributionContext)
1143+
{
1144+
if ((kvp.Key == "idfa" || kvp.Key == "gaid") && !canIdentify)
1145+
continue;
11361146
properties[kvp.Key] = kvp.Value;
1147+
}
11371148
}
11381149

11391150
// No sessionId on game_launch per Event Reference. Pipeline correlates

src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,7 +1302,7 @@ public void Init_GameLaunch_IncludesAttStatusAndIdfa_WhenContextProviderReturns(
13021302
["attStatus"] = "authorized",
13031303
["idfa"] = "11111111-2222-3333-4444-555555555555",
13041304
};
1305-
var config = MakeConfig();
1305+
var config = MakeConfig(ConsentLevel.Full);
13061306
config.EnableMobileAttribution = true;
13071307
ImmutableAudience.Init(config);
13081308
ImmutableAudience.Shutdown();
@@ -1423,7 +1423,7 @@ public void Init_GameLaunch_IncludesGaidAndLimitFlag_WhenContextProviderReturns(
14231423
["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789",
14241424
["gaidLimitAdTracking"] = false,
14251425
};
1426-
var config = MakeConfig();
1426+
var config = MakeConfig(ConsentLevel.Full);
14271427
config.EnableMobileAttribution = true;
14281428
ImmutableAudience.Init(config);
14291429
ImmutableAudience.Shutdown();
@@ -1459,6 +1459,64 @@ public void Init_GameLaunch_OmitsGaid_WhenUserOptedOut()
14591459
"gaid must not appear when the user has opted out");
14601460
}
14611461

1462+
// -----------------------------------------------------------------
1463+
// Consent-tier tightening: idfa, gaid => Full-only
1464+
//
1465+
// idfa and gaid are cross-app device identifiers — same privacy class
1466+
// as userId. They ship only when consent is Full. State-class keys
1467+
// (attStatus, gaidLimitAdTracking) are non-identifying and ship at
1468+
// Anonymous+Full (CanTrack).
1469+
// -----------------------------------------------------------------
1470+
1471+
[Test]
1472+
public void Init_GameLaunch_StripsIdfa_WhenConsentAnonymous()
1473+
{
1474+
ImmutableAudience.MobileAttributionContextProvider = () =>
1475+
new Dictionary<string, object>
1476+
{
1477+
["attStatus"] = "authorized",
1478+
["idfa"] = "11111111-2222-3333-4444-555555555555",
1479+
};
1480+
var config = MakeConfig(ConsentLevel.Anonymous);
1481+
config.EnableMobileAttribution = true;
1482+
ImmutableAudience.Init(config);
1483+
ImmutableAudience.Shutdown();
1484+
1485+
var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
1486+
.Select(File.ReadAllText)
1487+
.First(c => c.Contains("\"game_launch\""));
1488+
StringAssert.Contains("\"attStatus\":\"authorized\"", launchFile,
1489+
"attStatus must ship at Anonymous — it is non-identifying state");
1490+
Assert.IsFalse(launchFile.Contains("\"idfa\""),
1491+
"idfa must not ship at Anonymous — it is a cross-app device identifier");
1492+
}
1493+
1494+
[Test]
1495+
public void Init_GameLaunch_StripsGaid_WhenConsentAnonymous()
1496+
{
1497+
// gaid is stripped at Anonymous; gaidLimitAdTracking is non-identifying
1498+
// state and must still ship so the pipeline can distinguish
1499+
// "fetched, opted out" from "not fetched yet".
1500+
ImmutableAudience.MobileAttributionContextProvider = () =>
1501+
new Dictionary<string, object>
1502+
{
1503+
["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789",
1504+
["gaidLimitAdTracking"] = false,
1505+
};
1506+
var config = MakeConfig(ConsentLevel.Anonymous);
1507+
config.EnableMobileAttribution = true;
1508+
ImmutableAudience.Init(config);
1509+
ImmutableAudience.Shutdown();
1510+
1511+
var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
1512+
.Select(File.ReadAllText)
1513+
.First(c => c.Contains("\"game_launch\""));
1514+
StringAssert.Contains("\"gaidLimitAdTracking\":false", launchFile,
1515+
"gaidLimitAdTracking must ship at Anonymous — it is non-identifying state");
1516+
Assert.IsFalse(launchFile.Contains("\"gaid\""),
1517+
"gaid must not ship at Anonymous — it is a cross-app device identifier");
1518+
}
1519+
14621520
// -----------------------------------------------------------------
14631521
// install_referrer_received
14641522
//
@@ -1473,7 +1531,7 @@ public void Init_FiresInstallReferrerReceived_WhenProviderReturnsReferrer()
14731531
{
14741532
ImmutableAudience.MobileInstallReferrerProvider = () =>
14751533
"utm_source=google-play&utm_medium=organic";
1476-
var config = MakeConfig();
1534+
var config = MakeConfig(ConsentLevel.Full);
14771535
config.EnableMobileAttribution = true;
14781536
ImmutableAudience.Init(config);
14791537
ImmutableAudience.Shutdown();
@@ -1492,7 +1550,7 @@ public void Init_GameLaunch_NeverIncludesInstallReferrer()
14921550
// installReferrer is exclusively on the dedicated event; ensure
14931551
// we don't regress and start leaking it onto game_launch.
14941552
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test";
1495-
var config = MakeConfig();
1553+
var config = MakeConfig(ConsentLevel.Full);
14961554
config.EnableMobileAttribution = true;
14971555
ImmutableAudience.Init(config);
14981556
ImmutableAudience.Shutdown();
@@ -1544,7 +1602,7 @@ public void Init_DoesNotFireInstallReferrerReceived_WhenAlreadyFired()
15441602
// Simulate the second launch: cache is populated, marker is set
15451603
// by the previous Init. Event must not refire.
15461604
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test";
1547-
var config = MakeConfig();
1605+
var config = MakeConfig(ConsentLevel.Full);
15481606
config.EnableMobileAttribution = true;
15491607

15501608
ImmutableAudience.Init(config);
@@ -1554,7 +1612,7 @@ public void Init_DoesNotFireInstallReferrerReceived_WhenAlreadyFired()
15541612
var queueDir = AudiencePaths.QueueDir(_testDir);
15551613
foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);
15561614

1557-
var config2 = MakeConfig();
1615+
var config2 = MakeConfig(ConsentLevel.Full);
15581616
config2.EnableMobileAttribution = true;
15591617
ImmutableAudience.Init(config2);
15601618
ImmutableAudience.Shutdown();
@@ -1598,7 +1656,7 @@ public void Init_FiresInstallReferrerReceived_OnSecondLaunch_WhenFirstMissedCach
15981656
ImmutableAudience.MobileInstallReferrerProvider = () =>
15991657
++callCount == 1 ? firstCallReturn : secondCallReturn;
16001658

1601-
var config = MakeConfig();
1659+
var config = MakeConfig(ConsentLevel.Full);
16021660
config.EnableMobileAttribution = true;
16031661
ImmutableAudience.Init(config);
16041662
ImmutableAudience.Shutdown();
@@ -1612,7 +1670,7 @@ public void Init_FiresInstallReferrerReceived_OnSecondLaunch_WhenFirstMissedCach
16121670

16131671
foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);
16141672

1615-
var config2 = MakeConfig();
1673+
var config2 = MakeConfig(ConsentLevel.Full);
16161674
config2.EnableMobileAttribution = true;
16171675
ImmutableAudience.Init(config2);
16181676
ImmutableAudience.Shutdown();
@@ -1643,6 +1701,62 @@ public void Init_InstallReferrerProviderThrows_DoesNotPreventGameLaunch()
16431701
Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\"")));
16441702
}
16451703

1704+
[Test]
1705+
public void Init_DoesNotFireInstallReferrerReceived_WhenConsentAnonymous()
1706+
{
1707+
// installReferrer encodes campaign attribution source — Full-only.
1708+
// The sent marker must NOT be written so a later upgrade to Full
1709+
// can fire the event.
1710+
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=google-play";
1711+
var config = MakeConfig(ConsentLevel.Anonymous);
1712+
config.EnableMobileAttribution = true;
1713+
ImmutableAudience.Init(config);
1714+
ImmutableAudience.Shutdown();
1715+
1716+
var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
1717+
.Select(File.ReadAllText).ToList();
1718+
Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\"")),
1719+
"install_referrer_received must not fire when consent is Anonymous");
1720+
Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)),
1721+
"sent marker must not be written at Anonymous so a Full upgrade can fire the event");
1722+
}
1723+
1724+
[Test]
1725+
public void Init_FiresInstallReferrerReceived_AfterConsentUpgradedToFull()
1726+
{
1727+
// First launch at Anonymous: referrer is available but event is
1728+
// gated — no event fires and no sent marker is written.
1729+
// Second launch at Full: event fires and marker is written.
1730+
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=upgrade_test";
1731+
1732+
var config = MakeConfig(ConsentLevel.Anonymous);
1733+
config.EnableMobileAttribution = true;
1734+
ImmutableAudience.Init(config);
1735+
ImmutableAudience.Shutdown();
1736+
1737+
var queueDir = AudiencePaths.QueueDir(_testDir);
1738+
var firstBlobs = Directory.GetFiles(queueDir, "*.json")
1739+
.Select(File.ReadAllText).ToList();
1740+
Assert.IsFalse(firstBlobs.Any(c => c.Contains("\"install_referrer_received\"")),
1741+
"event must not ship on first launch when consent is Anonymous");
1742+
Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)),
1743+
"sent marker must not exist after Anonymous launch");
1744+
1745+
foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);
1746+
1747+
var config2 = MakeConfig(ConsentLevel.Full);
1748+
config2.EnableMobileAttribution = true;
1749+
ImmutableAudience.Init(config2);
1750+
ImmutableAudience.Shutdown();
1751+
1752+
var secondBlobs = Directory.GetFiles(queueDir, "*.json")
1753+
.Select(File.ReadAllText).ToList();
1754+
Assert.IsTrue(secondBlobs.Any(c =>
1755+
c.Contains("\"install_referrer_received\"") &&
1756+
c.Contains("\"installReferrer\":\"utm_source=upgrade_test\"")),
1757+
"event must fire on the first Full-consent launch after an Anonymous launch");
1758+
}
1759+
16461760
// -----------------------------------------------------------------
16471761
// RequestTrackingAuthorizationAsync
16481762
// -----------------------------------------------------------------

0 commit comments

Comments
 (0)