Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/config-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ If not defined in config all other Health Checkers would be disabled and endpoin
- `gdpr.special-features.sfN.vendor-exceptions[]` - bidder names that will be treated opposite to `sfN.enforce` value.
- `gdpr.purpose-one-treatment-interpretation` - option that allows to skip the Purpose one enforcement workflow.
- `gdpr.vendorlist.default-timeout-ms` - default operation timeout for obtaining new vendor list.
- `gdpr.vendorlist.live-gvl-url` - URL of the latest TCF GVL used to detect vendors with a past `deletedDate`. Default `https://vendor-list.consensu.org/v3/vendor-list.json`.
- `gdpr.vendorlist.live-gvl-refresh-period-ms` - how often to refresh the live GVL deleted-vendor set, in milliseconds. Default `86400000` (24 hours).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startup-cache-dir

- `gdpr.vendorlist.v2.http-endpoint-template` - template string for vendor list url version 2.
- `gdpr.vendorlist.v2.refresh-missing-list-period-ms` - time to wait between attempts to fetch vendor list version that previously was reported to be missing by origin. Default `3600000` (one hour).
- `gdpr.vendorlist.v2.fallback-vendor-list-path` - location on the file system of the fallback vendor list that will be used in place of missing vendor list versions. Optional.
Expand Down
1 change: 1 addition & 0 deletions docs/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Following metrics are collected and submitted if account is configured with `det
- `privacy.tcf.(v1,v2).in-geo` - number of requests received from TCF-concerned geo region with consent string of particular version
- `privacy.tcf.(v1,v2).out-geo` - number of requests received outside of TCF-concerned geo region with consent string of particular version
- `privacy.tcf.(v1,v2).vendorlist.(missing|ok|err|fallback)` - number of processed vendor lists of particular version
- `privacy.tcf.vendorlist.latest.(ok|err)` - number of successful or failed refreshes of the live GVL used for deleted-vendor detection

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latest -> live?

- `privacy.usp.specified` - number of requests with a valid US Privacy string (CCPA)
- `privacy.usp.opt-out` - number of requests that required privacy enforcement according to CCPA rules
- `privacy.lmt` - number of requests that required privacy enforcement according to LMT flag
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/org/prebid/server/metric/Metrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,14 @@ public void updatePrivacyTcfVendorListFallbackMetric(int version) {
updatePrivacyTcfVendorListMetric(version, MetricName.fallback);
}

public void updatePrivacyTcfVendorListLatestOkMetric() {
privacy().tcf().vendorListLatest().incCounter(MetricName.ok);
}

public void updatePrivacyTcfVendorListLatestErrorMetric() {
privacy().tcf().vendorListLatest().incCounter(MetricName.err);
}

private void updatePrivacyTcfVendorListMetric(int version, MetricName metricName) {
final TcfMetrics tcfMetrics = privacy().tcf();
tcfMetrics.fromVersion(version).vendorList().incCounter(metricName);
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/org/prebid/server/metric/TcfMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class TcfMetrics extends UpdatableMetrics {

private final TcfVersionMetrics tcfVersion1Metrics;
private final TcfVersionMetrics tcfVersion2Metrics;
private final VendorListLatestMetrics vendorListLatestMetrics;

TcfMetrics(MetricRegistry metricRegistry, CounterType counterType, String prefix) {
super(
Expand All @@ -25,6 +26,7 @@ class TcfMetrics extends UpdatableMetrics {

tcfVersion1Metrics = new TcfVersionMetrics(metricRegistry, counterType, createTcfPrefix(prefix), "v1");
tcfVersion2Metrics = new TcfVersionMetrics(metricRegistry, counterType, createTcfPrefix(prefix), "v2");
vendorListLatestMetrics = new VendorListLatestMetrics(metricRegistry, counterType, createTcfPrefix(prefix));
}

TcfVersionMetrics fromVersion(int version) {
Expand All @@ -35,6 +37,10 @@ TcfVersionMetrics fromVersion(int version) {
};
}

VendorListLatestMetrics vendorListLatest() {
return vendorListLatestMetrics;
}

private static String createTcfPrefix(String prefix) {
return prefix + ".tcf";
}
Expand Down Expand Up @@ -87,4 +93,22 @@ private static Function<MetricName, String> nameCreator(String prefix) {
return metricName -> "%s.%s".formatted(prefix, metricName);
}
}

static class VendorListLatestMetrics extends UpdatableMetrics {

VendorListLatestMetrics(MetricRegistry metricRegistry, CounterType counterType, String prefix) {
super(
metricRegistry,
counterType,
nameCreator(createLatestPrefix(prefix)));
}

private static String createLatestPrefix(String prefix) {
return prefix + ".vendorlist.latest";
}

private static Function<MetricName, String> nameCreator(String prefix) {
return metricName -> "%s.%s".formatted(prefix, metricName);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package org.prebid.server.privacy.gdpr.vendorlist;

import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import org.prebid.server.exception.PreBidException;
import org.prebid.server.json.JacksonMapper;
import org.prebid.server.log.Logger;
import org.prebid.server.log.LoggerFactory;
import org.prebid.server.metric.Metrics;
import org.prebid.server.privacy.gdpr.vendorlist.proto.Vendor;
import org.prebid.server.privacy.gdpr.vendorlist.proto.VendorList;
import org.prebid.server.util.HttpUtil;
import org.prebid.server.vertx.Initializable;
import org.prebid.server.vertx.httpclient.HttpClient;
import org.prebid.server.vertx.httpclient.model.HttpClientResponse;

import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

public class LiveVendorListService implements Initializable {

private static final Logger logger = LoggerFactory.getLogger(LiveVendorListService.class);

private final String cacheDir;
private final String liveGvlUrl;
private final long refreshPeriodMs;
private final int defaultTimeoutMs;
private final Vertx vertx;
private final HttpClient httpClient;
private final VendorListFileStore vendorListFileStore;
private final Metrics metrics;
private final JacksonMapper mapper;
private final Clock clock;

private volatile Set<Integer> deletedVendorIds = Set.of();

public LiveVendorListService(String cacheDir,
String liveGvlUrl,
long refreshPeriodMs,
int defaultTimeoutMs,
Vertx vertx,
HttpClient httpClient,
VendorListFileStore vendorListFileStore,
Metrics metrics,
JacksonMapper mapper,
Clock clock) {

this.cacheDir = Objects.requireNonNull(cacheDir);
this.liveGvlUrl = HttpUtil.validateUrl(Objects.requireNonNull(liveGvlUrl));
this.refreshPeriodMs = refreshPeriodMs;
this.defaultTimeoutMs = defaultTimeoutMs;
this.vertx = Objects.requireNonNull(vertx);
this.httpClient = Objects.requireNonNull(httpClient);
this.vendorListFileStore = Objects.requireNonNull(vendorListFileStore);
this.metrics = Objects.requireNonNull(metrics);
this.mapper = Objects.requireNonNull(mapper);
this.clock = Objects.requireNonNull(clock);
}

public boolean isDeleted(Integer id) {
final Set<Integer> ids = deletedVendorIds;
return !ids.isEmpty() && ids.contains(id);
}

@Override
public void initialize(Promise<Void> initializePromise) {
initializeWithLatestCachedVersion();
vertx.setPeriodic(0, refreshPeriodMs, ignored -> refresh());

initializePromise.tryComplete();
}

private void initializeWithLatestCachedVersion() {
vendorListFileStore.getLatestVendorListFromCache(cacheDir).ifPresent(vendorList -> {
saveDeletedVendorsFromVendorList(vendorList);
logger.info("Initialized live GVL from cache with version %d".formatted(vendorList.getVendorListVersion()));
});
}

void refresh() {
httpClient.get(liveGvlUrl, defaultTimeoutMs)
.map(this::processResponse)
.map(this::saveDeletedVendorsFromVendorList)
.otherwise(this::handleError);
}

private Void saveDeletedVendorsFromVendorList(VendorList vendorList) {
updateDeletedVendorIds(extractDeletedVendorIds(vendorList));
return null;
}

private VendorList processResponse(HttpClientResponse response) {
final int statusCode = response.getStatusCode();
if (statusCode != 200) {
throw new PreBidException("HTTP status code " + statusCode);
}

final String body = response.getBody();
final VendorList vendorList = VendorListUtil.parseVendorList(body, mapper);

if (!VendorListUtil.vendorListIsValid(vendorList)) {
throw new PreBidException("Fetched vendor list parsed but has invalid data: " + body);
}

return vendorList;
}

Set<Integer> extractDeletedVendorIds(VendorList vendorList) {
final Instant now = clock.instant();
return vendorList.getVendors().values().stream()
.filter(vendor -> VendorListUtil.vendorIsDeletedAt(vendor, now))
.map(Vendor::getId)
.filter(Objects::nonNull)
.collect(Collectors.toUnmodifiableSet());
}

private Void updateDeletedVendorIds(Set<Integer> ids) {
deletedVendorIds = ids;
metrics.updatePrivacyTcfVendorListLatestOkMetric();
return null;
}

private Void handleError(Throwable exception) {
logger.warn("Error occurred while fetching live GVL", exception);
metrics.updatePrivacyTcfVendorListLatestErrorMetric();
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package org.prebid.server.privacy.gdpr.vendorlist;

import com.github.benmanes.caffeine.cache.Caffeine;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.file.FileProps;
import io.vertx.core.file.FileSystem;
import io.vertx.core.file.FileSystemException;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.prebid.server.exception.PreBidException;
import org.prebid.server.json.JacksonMapper;
import org.prebid.server.log.ConditionalLogger;
import org.prebid.server.log.Logger;
import org.prebid.server.log.LoggerFactory;
import org.prebid.server.privacy.gdpr.vendorlist.proto.Vendor;
import org.prebid.server.privacy.gdpr.vendorlist.proto.VendorList;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

public class VendorListFileStore {

private static final Logger logger = LoggerFactory.getLogger(VendorListFileStore.class);
private static final ConditionalLogger conditionalLogger = new ConditionalLogger(logger);

private static final String JSON_SUFFIX = ".json";

private final double logSamplingRate;
private final FileSystem fileSystem;
private final JacksonMapper mapper;

public VendorListFileStore(double logSamplingRate,
FileSystem fileSystem,
JacksonMapper mapper) {

this.logSamplingRate = logSamplingRate;
this.fileSystem = Objects.requireNonNull(fileSystem);
this.mapper = Objects.requireNonNull(mapper);
}

Map<Integer, Map<Integer, Vendor>> createCacheFromDisk(String cacheDir) {
createAndCheckWritePermissionsForCacheDir(cacheDir);
final Map<Integer, String> versionToFileContent = readFileSystemCache(cacheDir);

final Map<Integer, Map<Integer, Vendor>> cache = Caffeine.newBuilder()
.<Integer, Map<Integer, Vendor>>build()
.asMap();

for (Map.Entry<Integer, String> versionAndFileContent : versionToFileContent.entrySet()) {
final VendorList vendorList = VendorListUtil.parseVendorList(versionAndFileContent.getValue(), mapper);

cache.put(versionAndFileContent.getKey(), vendorList.getVendors());
}
return cache;
}

private void createAndCheckWritePermissionsForCacheDir(String cacheDir) {
final FileProps props = fileSystem.existsBlocking(cacheDir) ? fileSystem.propsBlocking(cacheDir) : null;
if (props == null || !props.isDirectory()) {
try {
fileSystem.mkdirsBlocking(cacheDir);
} catch (FileSystemException e) {
throw new PreBidException("Cannot create directory: " + cacheDir, e);
}
} else if (!Files.isWritable(Paths.get(cacheDir))) {
throw new PreBidException("No write permissions for directory: " + cacheDir);
}
}

private Map<Integer, String> readFileSystemCache(String cacheDir) {
return fileSystem.readDirBlocking(cacheDir).stream()
.filter(filepath -> filepath.endsWith(JSON_SUFFIX))
.collect(Collectors.toMap(VendorListFileStore::parseCachedFileVersion,
filename -> fileSystem.readFileBlocking(filename).toString()));
Comment thread
And1sS marked this conversation as resolved.
}

Optional<VendorList> getLatestVendorListFromCache(String cacheDir) {
createAndCheckWritePermissionsForCacheDir(cacheDir);
return fileSystem.readDirBlocking(cacheDir).stream()
.filter(filepath -> filepath.endsWith(JSON_SUFFIX))
.max(Comparator.comparing(VendorListFileStore::parseCachedFileVersion))
.map(fileSystem::readFileBlocking)
.map(Buffer::toString)
.map(content -> VendorListUtil.parseVendorList(content, mapper));
}

private static Integer parseCachedFileVersion(String filepath) {
final String filename = new File(filepath).getName();
final String filenameWithoutExtension = StringUtils.removeEnd(filename, JSON_SUFFIX);
return Integer.valueOf(filenameWithoutExtension);
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
And1sS marked this conversation as resolved.
Dismissed
}

Future<VendorListResult> saveToFile(VendorListResult vendorListResult, String cacheDir, String generationVersion) {
final Promise<VendorListResult> promise = Promise.promise();
final int version = vendorListResult.getVersion();
final String filepath = new File(cacheDir, version + JSON_SUFFIX).getPath();

fileSystem.writeFile(filepath, Buffer.buffer(vendorListResult.getVendorListAsString()), result -> {
if (result.succeeded()) {
promise.complete(vendorListResult);
} else {
conditionalLogger.error(
"Could not create new vendor list for version %s.%s, file: %s, trace: %s".formatted(
generationVersion, version, filepath, ExceptionUtils.getStackTrace(result.cause())),
logSamplingRate);
promise.fail(result.cause());
}
});

return promise.future();
}

Map<Integer, Vendor> readFallbackVendorList(String fallbackVendorListPath) {
if (StringUtils.isBlank(fallbackVendorListPath)) {
return null;
}

final String vendorListContent = fileSystem.readFileBlocking(fallbackVendorListPath).toString();
final VendorList vendorList = VendorListUtil.parseVendorList(vendorListContent, mapper);
if (!VendorListUtil.vendorListIsValid(vendorList)) {
throw new PreBidException("Fallback vendor list parsed but has invalid data: " + vendorListContent);
}

return vendorList.getVendors();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.prebid.server.privacy.gdpr.vendorlist;

import lombok.Value;
import org.prebid.server.privacy.gdpr.vendorlist.proto.VendorList;

@Value(staticConstructor = "of")
class VendorListResult {

int version;

String vendorListAsString;

VendorList vendorList;
}
Loading
Loading