Skip to content

Commit 171a19b

Browse files
Multiple Uids Cookies Support (#3668)
1 parent 3e91c96 commit 171a19b

27 files changed

+894
-382
lines changed

src/main/java/org/prebid/server/cookie/PrioritizedCoopSyncProvider.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,4 @@ public boolean isPrioritizedFamily(String cookieFamilyName) {
7474
final String bidder = prioritizedCookieFamilyNameToBidderName.get(cookieFamilyName);
7575
return prioritizedBidders.contains(bidder);
7676
}
77-
78-
public boolean hasPrioritizedBidders() {
79-
return !prioritizedBidders.isEmpty();
80-
}
8177
}

src/main/java/org/prebid/server/cookie/UidsCookie.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public UidsCookie updateOptout(boolean optout) {
102102
/**
103103
* Converts {@link Uids} to JSON string.
104104
*/
105-
String toJson() {
105+
public String toJson() {
106106
return mapper.encodeToString(uids);
107107
}
108108

src/main/java/org/prebid/server/cookie/UidsCookieService.java

Lines changed: 111 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,23 @@
66
import io.vertx.ext.web.RoutingContext;
77
import org.apache.commons.lang3.StringUtils;
88
import org.prebid.server.cookie.model.UidWithExpiry;
9-
import org.prebid.server.cookie.model.UidsCookieUpdateResult;
109
import org.prebid.server.cookie.proto.Uids;
1110
import org.prebid.server.json.DecodeException;
1211
import org.prebid.server.json.JacksonMapper;
1312
import org.prebid.server.log.Logger;
1413
import org.prebid.server.log.LoggerFactory;
1514
import org.prebid.server.metric.Metrics;
1615
import org.prebid.server.model.HttpRequestContext;
16+
import org.prebid.server.model.UpdateResult;
1717
import org.prebid.server.util.HttpUtil;
1818

1919
import java.time.Duration;
20+
import java.util.ArrayList;
2021
import java.util.Base64;
2122
import java.util.Collections;
23+
import java.util.HashMap;
2224
import java.util.Iterator;
25+
import java.util.List;
2326
import java.util.Map;
2427
import java.util.Objects;
2528
import java.util.Optional;
@@ -34,15 +37,20 @@ public class UidsCookieService {
3437
private static final Logger logger = LoggerFactory.getLogger(UidsCookieService.class);
3538

3639
private static final String COOKIE_NAME = "uids";
40+
private static final String COOKIE_NAME_FORMAT = "uids%d";
3741
private static final int MIN_COOKIE_SIZE_BYTES = 500;
42+
private static final int MIN_NUMBER_OF_UID_COOKIES = 1;
43+
private static final int MAX_NUMBER_OF_UID_COOKIES = 30;
3844

3945
private final String optOutCookieName;
4046
private final String optOutCookieValue;
4147
private final String hostCookieFamily;
4248
private final String hostCookieName;
4349
private final String hostCookieDomain;
4450
private final long ttlSeconds;
51+
4552
private final int maxCookieSizeBytes;
53+
private final int numberOfUidCookies;
4654

4755
private final PrioritizedCoopSyncProvider prioritizedCoopSyncProvider;
4856
private final Metrics metrics;
@@ -55,6 +63,7 @@ public UidsCookieService(String optOutCookieName,
5563
String hostCookieDomain,
5664
int ttlDays,
5765
int maxCookieSizeBytes,
66+
int numberOfUidCookies,
5867
PrioritizedCoopSyncProvider prioritizedCoopSyncProvider,
5968
Metrics metrics,
6069
JacksonMapper mapper) {
@@ -64,13 +73,20 @@ public UidsCookieService(String optOutCookieName,
6473
"Configured cookie size is less than allowed minimum size of " + MIN_COOKIE_SIZE_BYTES);
6574
}
6675

76+
if (numberOfUidCookies < MIN_NUMBER_OF_UID_COOKIES || numberOfUidCookies > MAX_NUMBER_OF_UID_COOKIES) {
77+
throw new IllegalArgumentException(
78+
"Configured number of uid cookies should be in the range from %d to %d"
79+
.formatted(MIN_NUMBER_OF_UID_COOKIES, MAX_NUMBER_OF_UID_COOKIES));
80+
}
81+
6782
this.optOutCookieName = optOutCookieName;
6883
this.optOutCookieValue = optOutCookieValue;
6984
this.hostCookieFamily = hostCookieFamily;
7085
this.hostCookieName = hostCookieName;
7186
this.hostCookieDomain = StringUtils.isNotBlank(hostCookieDomain) ? hostCookieDomain : null;
7287
this.ttlSeconds = Duration.ofDays(ttlDays).getSeconds();
7388
this.maxCookieSizeBytes = maxCookieSizeBytes;
89+
this.numberOfUidCookies = numberOfUidCookies;
7490
this.prioritizedCoopSyncProvider = Objects.requireNonNull(prioritizedCoopSyncProvider);
7591
this.metrics = Objects.requireNonNull(metrics);
7692
this.mapper = Objects.requireNonNull(mapper);
@@ -105,57 +121,66 @@ public UidsCookie parseFromRequest(HttpRequestContext httpRequest) {
105121
*/
106122
UidsCookie parseFromCookies(Map<String, String> cookies) {
107123
final Uids parsedUids = parseUids(cookies);
124+
final boolean isOptedOut = isOptedOut(cookies);
108125

109-
final Boolean optout;
110-
final Map<String, UidWithExpiry> uidsMap;
111-
112-
if (isOptedOut(cookies)) {
113-
optout = true;
114-
uidsMap = Collections.emptyMap();
115-
} else {
116-
optout = parsedUids != null ? parsedUids.getOptout() : null;
117-
uidsMap = enrichAndSanitizeUids(parsedUids, cookies);
118-
}
119-
120-
final Uids uids = Uids.builder().uids(uidsMap).optout(optout).build();
126+
final Uids uids = Uids.builder()
127+
.uids(isOptedOut ? Collections.emptyMap() : enrichAndSanitizeUids(parsedUids, cookies))
128+
.optout(isOptedOut)
129+
.build();
121130

122131
return new UidsCookie(uids, mapper);
123132
}
124133

125134
/**
126135
* Parses cookies {@link Map} and composes {@link Uids} model.
127136
*/
128-
public Uids parseUids(Map<String, String> cookies) {
129-
if (cookies.containsKey(COOKIE_NAME)) {
130-
final String cookieValue = cookies.get(COOKIE_NAME);
137+
private Uids parseUids(Map<String, String> cookies) {
138+
final Map<String, UidWithExpiry> uids = new HashMap<>();
139+
140+
for (Map.Entry<String, String> cookie : cookies.entrySet()) {
141+
final String cookieKey = cookie.getKey();
142+
if (!cookieKey.startsWith(COOKIE_NAME)) {
143+
continue;
144+
}
145+
131146
try {
132-
return mapper.decodeValue(Buffer.buffer(Base64.getUrlDecoder().decode(cookieValue)), Uids.class);
147+
final Uids parsedUids = mapper.decodeValue(
148+
Buffer.buffer(Base64.getUrlDecoder().decode(cookie.getValue())), Uids.class);
149+
if (parsedUids != null && parsedUids.getUids() != null) {
150+
parsedUids.getUids().forEach((key, value) -> uids.merge(key, value, (newValue, oldValue) ->
151+
newValue.getExpires().compareTo(oldValue.getExpires()) > 0 ? newValue : oldValue));
152+
}
133153
} catch (IllegalArgumentException | DecodeException e) {
134-
logger.debug("Could not decode or parse {} cookie value {}", e, COOKIE_NAME, cookieValue);
154+
logger.debug("Could not decode or parse {} cookie value {}", e, COOKIE_NAME, cookie.getValue());
135155
}
136156
}
137-
return null;
157+
158+
return Uids.builder().uids(uids).build();
138159
}
139160

140161
/**
141162
* Creates a {@link Cookie} with 'uids' as a name and encoded JSON string representing supplied {@link UidsCookie}
142163
* as a value.
143164
*/
144-
public Cookie toCookie(UidsCookie uidsCookie) {
145-
return makeCookie(uidsCookie);
165+
public Cookie aliveCookie(String cookieName, UidsCookie uidsCookie) {
166+
final String value = Base64.getUrlEncoder().encodeToString(uidsCookie.toJson().getBytes());
167+
return makeCookie(cookieName, value, ttlSeconds);
168+
}
169+
170+
public Cookie aliveCookie(UidsCookie uidsCookie) {
171+
return aliveCookie(COOKIE_NAME, uidsCookie);
146172
}
147173

148-
private int cookieBytesLength(UidsCookie uidsCookie) {
149-
return makeCookie(uidsCookie).encode().getBytes().length;
174+
public Cookie expiredCookie(String cookieName) {
175+
return makeCookie(cookieName, StringUtils.EMPTY, 0);
150176
}
151177

152-
private Cookie makeCookie(UidsCookie uidsCookie) {
153-
return Cookie
154-
.cookie(COOKIE_NAME, Base64.getUrlEncoder().encodeToString(uidsCookie.toJson().getBytes()))
178+
private Cookie makeCookie(String cookieName, String value, long maxAge) {
179+
return Cookie.cookie(cookieName, value)
155180
.setPath("/")
156181
.setSameSite(CookieSameSite.NONE)
157182
.setSecure(true)
158-
.setMaxAge(ttlSeconds)
183+
.setMaxAge(maxAge)
159184
.setDomain(hostCookieDomain);
160185
}
161186

@@ -221,20 +246,18 @@ private static boolean facebookSentinelOrEmpty(Map.Entry<String, UidWithExpiry>
221246

222247
/***
223248
* Removes expired {@link Uids}, updates {@link UidsCookie} with new uid for family name according to priority
224-
* and trims it to the limit
225249
*/
226-
public UidsCookieUpdateResult updateUidsCookie(UidsCookie uidsCookie, String familyName, String uid) {
227-
final UidsCookie initialCookie = trimToLimit(removeExpiredUids(uidsCookie)); // if already exceeded limit
228-
229-
if (StringUtils.isBlank(uid)) {
230-
return UidsCookieUpdateResult.unaltered(initialCookie.deleteUid(familyName));
231-
} else if (UidsCookie.isFacebookSentinel(familyName, uid)) {
232-
// At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook.
233-
// They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID.
234-
return UidsCookieUpdateResult.unaltered(initialCookie);
250+
public UpdateResult<UidsCookie> updateUidsCookie(UidsCookie uidsCookie, String familyName, String uid) {
251+
final UidsCookie initialCookie = removeExpiredUids(uidsCookie);
252+
253+
// At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook.
254+
// They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID.
255+
if (StringUtils.isBlank(uid) || UidsCookie.isFacebookSentinel(familyName, uid)) {
256+
return UpdateResult.unaltered(initialCookie);
235257
}
236258

237-
return updateUidsCookieByPriority(initialCookie, familyName, uid);
259+
final UidsCookie updatedCookie = initialCookie.updateUid(familyName, uid);
260+
return UpdateResult.updated(updatedCookie);
238261
}
239262

240263
private static UidsCookie removeExpiredUids(UidsCookie uidsCookie) {
@@ -250,47 +273,58 @@ private static UidsCookie removeExpiredUids(UidsCookie uidsCookie) {
250273
return updatedCookie;
251274
}
252275

253-
private UidsCookieUpdateResult updateUidsCookieByPriority(UidsCookie uidsCookie, String familyName, String uid) {
254-
final UidsCookie updatedCookie = uidsCookie.updateUid(familyName, uid);
255-
if (!cookieExceededMaxLength(updatedCookie)) {
256-
return UidsCookieUpdateResult.updated(updatedCookie);
257-
}
276+
public List<Cookie> splitUidsIntoCookies(UidsCookie uidsCookie) {
277+
final Uids cookieUids = uidsCookie.getCookieUids();
278+
final Map<String, UidWithExpiry> uids = cookieUids.getUids();
279+
final boolean hasOptout = !uidsCookie.allowsSync();
258280

259-
if (!prioritizedCoopSyncProvider.hasPrioritizedBidders()
260-
|| prioritizedCoopSyncProvider.isPrioritizedFamily(familyName)) {
261-
return UidsCookieUpdateResult.updated(trimToLimit(updatedCookie));
262-
} else {
263-
metrics.updateUserSyncSizeBlockedMetric(familyName);
264-
return UidsCookieUpdateResult.unaltered(uidsCookie);
265-
}
266-
}
281+
final Iterator<String> cookieFamilies = cookieFamilyNamesByDescPriorityAndExpiration(uidsCookie);
282+
final List<Cookie> splitCookies = new ArrayList<>();
267283

268-
private boolean cookieExceededMaxLength(UidsCookie uidsCookie) {
269-
return maxCookieSizeBytes > 0 && cookieBytesLength(uidsCookie) > maxCookieSizeBytes;
270-
}
284+
final int cookieSchemaSize = UidsCookieSize.schemaSize(makeCookie(COOKIE_NAME, StringUtils.EMPTY, ttlSeconds));
285+
String nextCookieFamily = null;
286+
for (int i = 0; i < numberOfUidCookies; i++) {
287+
final int digits = i < 10 ? Integer.signum(i) : 2;
288+
final UidsCookieSize uidsCookieSize = new UidsCookieSize(cookieSchemaSize + digits, maxCookieSizeBytes);
271289

272-
private UidsCookie trimToLimit(UidsCookie uidsCookie) {
273-
if (!cookieExceededMaxLength(uidsCookie)) {
274-
return uidsCookie;
275-
}
290+
final Map<String, UidWithExpiry> tempUids = new HashMap<>();
291+
while (nextCookieFamily != null || cookieFamilies.hasNext()) {
292+
nextCookieFamily = nextCookieFamily == null ? cookieFamilies.next() : nextCookieFamily;
293+
final UidWithExpiry uidWithExpiry = uids.get(nextCookieFamily);
276294

277-
UidsCookie trimmedUids = uidsCookie;
278-
final Iterator<String> familyToRemoveIterator = cookieFamilyNamesByAscendingPriority(uidsCookie);
295+
uidsCookieSize.addUid(nextCookieFamily, uidWithExpiry.getUid());
296+
if (!uidsCookieSize.isValid()) {
297+
break;
298+
}
299+
300+
tempUids.put(nextCookieFamily, uidWithExpiry);
301+
nextCookieFamily = null;
302+
}
303+
304+
final String uidsName = i == 0 ? COOKIE_NAME : COOKIE_NAME_FORMAT.formatted(i + 1);
305+
306+
if (tempUids.isEmpty()) {
307+
splitCookies.add(expiredCookie(uidsName));
308+
} else {
309+
splitCookies.add(aliveCookie(
310+
uidsName,
311+
new UidsCookie(Uids.builder().uids(tempUids).optout(hasOptout).build(), mapper)));
312+
}
313+
}
279314

280-
while (familyToRemoveIterator.hasNext() && cookieExceededMaxLength(trimmedUids)) {
281-
final String familyToRemove = familyToRemoveIterator.next();
282-
metrics.updateUserSyncSizedOutMetric(familyToRemove);
283-
trimmedUids = trimmedUids.deleteUid(familyToRemove);
315+
if (nextCookieFamily != null) {
316+
updateSyncSizeMetrics(nextCookieFamily);
284317
}
285318

286-
return trimmedUids;
319+
cookieFamilies.forEachRemaining(this::updateSyncSizeMetrics);
320+
321+
return splitCookies;
287322
}
288323

289-
private Iterator<String> cookieFamilyNamesByAscendingPriority(UidsCookie uidsCookie) {
324+
private Iterator<String> cookieFamilyNamesByDescPriorityAndExpiration(UidsCookie uidsCookie) {
290325
return uidsCookie.getCookieUids().getUids().entrySet().stream()
291326
.sorted(this::compareCookieFamilyNames)
292327
.map(Map.Entry::getKey)
293-
.toList()
294328
.iterator();
295329
}
296330

@@ -303,9 +337,17 @@ private int compareCookieFamilyNames(Map.Entry<String, UidWithExpiry> left,
303337
if ((leftPrioritized && rightPrioritized) || (!leftPrioritized && !rightPrioritized)) {
304338
return left.getValue().getExpires().compareTo(right.getValue().getExpires());
305339
} else if (leftPrioritized) {
306-
return 1;
307-
} else { // right is prioritized
308340
return -1;
341+
} else { // right is prioritized
342+
return 1;
343+
}
344+
}
345+
346+
private void updateSyncSizeMetrics(String nextCookieFamily) {
347+
if (prioritizedCoopSyncProvider.isPrioritizedFamily(nextCookieFamily)) {
348+
metrics.updateUserSyncSizedOutMetric(nextCookieFamily);
349+
} else {
350+
metrics.updateUserSyncSizeBlockedMetric(nextCookieFamily);
309351
}
310352
}
311353

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package org.prebid.server.cookie;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import io.vertx.core.http.Cookie;
5+
import org.apache.commons.lang3.StringUtils;
6+
import org.prebid.server.json.ObjectMapperProvider;
7+
8+
import java.time.Instant;
9+
import java.time.ZoneId;
10+
import java.time.ZonedDateTime;
11+
12+
public class UidsCookieSize {
13+
14+
// {"tempUIDs":{},"optout":false}
15+
private static final int TEMP_UIDS_BASE64_BYTES = "eyJ0ZW1wVUlEcyI6e30sIm9wdG91dCI6ZmFsc2V9".length();
16+
private static final int UID_TEMPLATE_BYTES;
17+
18+
static {
19+
try {
20+
UID_TEMPLATE_BYTES = "\"\":{\"uid\":\"\",\"expires\":\"%s\"},"
21+
.formatted(ObjectMapperProvider.mapper().writeValueAsString(
22+
ZonedDateTime.ofInstant(Instant.ofEpochSecond(0, 1), ZoneId.of("UTC"))))
23+
.length();
24+
} catch (JsonProcessingException e) {
25+
throw new RuntimeException(e);
26+
}
27+
}
28+
29+
private final int cookieSchemaSize;
30+
private final int maxSize;
31+
private int encodedUidsSize;
32+
33+
public UidsCookieSize(int cookieSchemaSize, int maxSize) {
34+
this.cookieSchemaSize = cookieSchemaSize;
35+
this.maxSize = maxSize;
36+
37+
encodedUidsSize = 0;
38+
}
39+
40+
public static int schemaSize(Cookie cookieSchema) {
41+
return cookieSchema.setValue(StringUtils.EMPTY).encode().length();
42+
}
43+
44+
public boolean isValid() {
45+
return maxSize <= 0 || totalSize() <= maxSize;
46+
}
47+
48+
public int totalSize() {
49+
return cookieSchemaSize
50+
+ TEMP_UIDS_BASE64_BYTES
51+
+ Base64Size.base64Size(encodedUidsSize);
52+
}
53+
54+
public void addUid(String cookieFamily, String uid) {
55+
final int uidSize = UID_TEMPLATE_BYTES + cookieFamily.length() + uid.length();
56+
encodedUidsSize = Base64Size.encodeSize(Base64Size.decodeSize(encodedUidsSize) + uidSize);
57+
}
58+
59+
private static class Base64Size {
60+
61+
public static int encodeSize(int size) {
62+
return size / 3 * 4 + size % 3;
63+
}
64+
65+
public static int decodeSize(int encodedSize) {
66+
return encodedSize / 4 * 3 + encodedSize % 4;
67+
}
68+
69+
private static int base64Size(int encodedSize) {
70+
return (encodedSize & -4) + 4 * Integer.signum(encodedSize % 4);
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)